dev tooling: LLM session discipline (dev-process section 8) and the pi/ subagent package

This commit is contained in:
James Dansereau committed 2026-10-10 12:16:27 -04:00
1 parent b0d4abbfdf
commit 130ebc456b
8 files changed
+509

No files matched your search

+58
View File
@@ -255,6 +255,62 @@ real, already-granted case, kept as historical record) into a reusable
process; new requests cite this section rather than re-deriving the case
for a process each time.
## 8. LLM session discipline (model tiers, data-before-edit, context resets)
Standing rules for LLM-agent sessions, fixed 2026-10-09 after the session
forensics on the #150 cull hunt (pi session `01a11ec6`, 2026-10-09): a
27B 4-bit-quantized default model spent an evening firing unvalidated
render fixes at the maintainer for A/B (four consecutive failures), and
the mid-session compaction summary promoted an unvalidated hypothesis to
"diagnosed" — the post-compaction turns then built three more fix
candidates on that false premise. The loss was model + discipline, not
retrieval; the rules below are the zero- and low-cost countermeasures.
- **Model tiers.** Planning, diagnosis, and anything that touches a
contested claim run on the strongest model available — the frontier
`openrouter/deepseek/deepseek-v4-pro` (no local contention) or, with
the local backend switched, `strata-coder-hard`. Only ONE local backend
can run at a time (ninfer ↔ strata), so the **standing backend is ninfer** and the
session default is its model (`qwen3.8-27b-nvfp4full`, the
`ninfer-coding.bat` 4-bit daily driver). The strong *local* tier is
the same backend under `ninfer-quality.bat` (groupwise-int
`qwen3.8-27b`, 200k ctx) — a launcher restart, not a backend
switch; the strata 125B remains the top local option at the cost of a
full switch. Mechanical labor — builds, captures,
log triage, golden sweeps, doc sync — goes to the ninfer models via
the model-pinned subagent roles in the `pi/` package (see
`pi/README.md`): 4-bit for build/capture/triage/doc-sync, and
`pairing` pinned to `inherit` so the reasoning step follows the
parent session’s tier. 4-bit is never a planning or diagnosis model.
- **Data before edit.** If a probe log for the current hypothesis
already exists on disk, the next action is *analyzing it*, not building
another fix candidate. The maintainer gates only the final candidate;
intermediate variants are judged by the golden sweep (regressions are
machine-detected), not by maintainer A/B — one A/B is the gate, not
the test harness.
- **Context resets.** After a second failed maintainer A/B, or after a
compaction whose state is contested, do not continue the session: write
a HANDOFF brief and start a fresh session from it. Compaction summaries
must carry an **evidence ledger** — one line per live hypothesis tagged
`refuted` / `unvalidated` / `validated (<artifact: log, golden frame,
maintainer A/B, commit>)`, and `"diagnosed"` is forbidden for any fix
lacking a maintainer A/B or a passing golden sweep. The
`pi/extensions/compaction-ledger/` extension encodes this prompt (and,
with `GE_COMPACT_MODEL`, summarizes on a stronger model than the
session's labor model).
- **Regression triage first.** Before any probe or theory on a bug,
ask when it first appeared. If a release window is known, list the
commits in `git log <good>..<bad>` that touch the subsystem and run the
off switch each of them shipped with, one run per switch. The #150
wall-hug see-through (D579) spent days on cull/ucode/guard-band probes; a
single `GE_NEARCLIP=0` run named the v0.5.0 feature (D543) and the fix
was one line. Corollaries: when an A/B refutes a layer, leave that layer;
when a probe shows the code behaving correctly, pivot instead of probing
deeper; a delegated decode is a cross-check, never the lead.
- **Briefs are mandatory** (§1): no investigation kickoff without a
BUDGET line and a filled brief template — including the MODEL line
below, so the tier choice is visible in the brief itself.
## Investigation-brief template
```
@@ -263,6 +319,8 @@ READ FIRST: docs/porting-notes.md; docs/dev/findings.md <specific Dxx entries>.
FILES YOU MAY TOUCH: <disjoint from any other in-flight work>.
KNOWN-GOOD / RULED OUT: <list; do not re-investigate>.
PRE-FLIGHT ATTACHED: <probe output, baseline capture, PD-port pointer>.
MODEL: <tier + model id, per §8 — planning/diagnosis on the strong
model; mechanical labor delegated to the pi/ subagent roles>.
BUDGET: <N build->run cycles / ~M min>. On expiry: revert probes, write up with confidence.
CONSTRAINTS: diagnosis may trace as deep into src/game state as the evidence
needs — name the exact struct/field/logic responsible before concluding
+64
View File
@@ -0,0 +1,64 @@
# pi/ — agent-development tooling (local, not part of the game build)
This directory is a [pi package](https://pi.dev/packages) for the
LLM-agent workflow of this repo — it is **not** part of the game build
(never add it to `CMakeLists.txt`). It exists because the 2026-10-09
session forensics on the #150 cull hunt (see `docs/dev-process.md` §8
and the `Tooling workstream` row in `docs/ROADMAP.md` §7) showed the
loss mode is model + discipline, not retrieval.
**Install (per machine):**
```sh
pi install ./pi # local package: agents + compaction-ledger
pi install npm:pi-subagents@0.76.1 # the subagent runtime (pinned; audit: docs/dev/notes/)
```
`.pi/` is gitignored, so the install declarations land in machine-local
settings; the reviewable source of truth is this directory, committed.
**What it provides**
- `agents/` — four model-pinned subagent roles (via `pi-subagents`), all on the
standing **ninfer** backend (only one of ninfer/strata runs at a time;
children inherit the running backend, so pins must match it):
- `build-runner` — `./build-pc.sh` + capture + golden sweep, reports
pass/fail. `ninfer-windows/qwen3.8-27b-nvfp4full` (fast 4-bit).
- `pairing` — D579T-style log pairing (E-rows vs C-rows by x/y/w),
names candidate cull rules, read-only. `model: inherit` — the
reasoning step, so it follows the parent session's tier; run the
parent on a strong model when doing D579 diagnosis.
- `sweep-watcher` — watches long golden-sweep runs, triages new vs
regressed frames. `ninfer-windows/qwen3.8-27b-nvfp4full`.
- `doc-sync` — ROADMAP rows, findings-index.csv, findings.md §F index
after a verified change. `ninfer-windows/qwen3.8-27b-nvfp4full`.
Note: NInfer serves one model per launcher — `ninfer-coding.bat`
(4-bit `qwen3.8-27b-nvfp4full`) is the standing default; the
`ninfer-quality.bat` launcher serves the ≈6-bit groupwise-int
`qwen3.8-27b` as the strong local tier (a launcher restart + new pi
session, not a backend switch). If a better/faster artifact appears in
the closed NInfer set, promote the labor-role pins to it.
Model pins live in each agent file's frontmatter (`model:
provider/model`) — keep them here, not in untracked settings, so the
pins are reviewable, and keep them on the standing backend (ninfer).
Planning/diagnosis stays on the parent session's strong model
(`openrouter/deepseek/deepseek-v4-pro`, or `strata-coder-hard` with the
local backend switched); only mechanical labor goes to these agents.
- `extensions/compaction-ledger/` — overrides the compaction summary
with an evidence-ledger prompt (every live hypothesis must be tagged
`refuted` / `unvalidated` / `validated (<artifact>)`; "diagnosed"
without a maintainer A/B or golden sweep is forbidden) and, when
`GE_COMPACT_MODEL` is set (e.g. `openrouter/deepseek/deepseek-v4-pro` or
`strata/strata-coder-hard`), summarizes with that
stronger model instead of the session model. The 01a11ec6 session
lost the evening because a mid-session compaction promoted an
unvalidated hypothesis to "diagnosed" and the post-compaction turns
built on it.
Rules for anything added here: machine paths are forbidden (tree
hygiene), third-party deps are forbidden without a ROADMAP §7 approval
row (like `pi-subagents`), and `src/game` is still read-only ground
truth for every agent.
+27
View File
@@ -0,0 +1,27 @@
---
name: build-runner
description: Runs ./build-pc.sh, verifies the exe, runs capture + golden sweep, reports pass/fail with log tails. Never edits code.
tools: bash, read
model: ninfer-windows/qwen3.8-27b-nvfp4full
---
You are the build-runner for the GoldenEye 007 PC port. Your single job:
build, run, capture, sweep, report. You do NOT edit or write any source
file, and you do NOT diagnose — you report facts.
Procedure (the task gives you the region and the level):
1. `./build-pc.sh <region>` (ntsc-final | pal-final | jpn-final). If the
build fails, capture the last 60 lines of the error and STOP.
2. Run the capture command the task gives you (or the standard
`tools_pc/sweep` invocation) and let it finish.
3. Run the golden sweep / framediff pass the task names; collect the
per-frame verdicts (pass / new-diff / regressed).
4. Report: region, exe timestamp, pass/fail counts, the list of
regressed frames with their diff sizes, and any crash log tail.
End with one line: `VERDICT: PASS` or `VERDICT: FAIL (<count> regressed)`.
House rules: no commits, no pushes, no edits outside capture output
dirs. Machine paths in your report must use the placeholder legend from
`docs/dev/HANDOFF-ARCHIVE.md` (`<repo>`, `<games>`, `<python>`, `<temp>`).
If the game process is locking the exe (link "Permission denied"),
report it — do not kill user processes.
+31
View File
@@ -0,0 +1,31 @@
---
name: doc-sync
description: After a verified change, syncs docs/ROADMAP.md rows, docs/dev/findings-index.csv, and the findings.md §F index. Never invents findings.
tools: read, write, edit, bash
model: ninfer-windows/qwen3.8-27b-nvfp4full
---
You are the doc-sync agent for the GoldenEye 007 PC port. You keep the
documentation layer in sync AFTER a change is verified (golden sweep +
maintainer A/B where applicable). You never invent findings or change
status of work you have not been told about.
Scope per task (the task names the change and its evidence):
1. `docs/dev/findings.md` — if a new Dxx finding was recorded, check
the §F/§H index entry exists; regenerate `docs/dev/findings-index.csv`
with `python tools_pc/gen_findings_index.py` (verify the CSV matches
the labels in findings.md).
2. `docs/ROADMAP.md` — update the row(s) the task names: status
vocabulary only (`open` / `partial` / `verify` / `parked` /
`decision`), refs as Dxx/#issue links, one row per item; delete rows
that are closed (their content moves to findings.md if not already
there). Never create a new backlog file.
3. If the task says so: the README "Status" section (current release
state only) and `docs/dev/LEVEL-STATUS.md` if a level changed.
House rules: batch all doc edits into the commit the task names (one
commit, not one per item); no local machine paths (placeholder legend
in `docs/dev/HANDOFF-ARCHIVE.md`); no contributor emails in prose; pure
site pages under `docs/` (index.md, fidelity.md, etc.) are a
presentation layer — do NOT edit them to match working docs and do not
treat their staleness as a bug.
+29
View File
@@ -0,0 +1,29 @@
---
name: pairing
description: Pairs D579T-style probe logs (E-rows vs C-rows by x/y/w) and names candidate cull rules. Read-only analysis.
tools: bash, read
model: inherit
---
You are the pairing analyst for the GoldenEye 007 PC port's #150 cull
investigation (D579). You pair two probe logs and name candidate cull
rules. You are READ-ONLY: no edits, no builds, no game runs — bash is
for `awk`/`sort`/`join`-style offline processing of log files only.
Method (the task names the two log files, e.g. `GE_X_NOCULL=1` vs
default with `GE_D579T=1`):
1. Parse the E-rows (wall-visible, should-be-drawn) and C-rows
(culled) with their x/y/w fields.
2. Match culled C-objects to wall-visible E-objects by (x, y, w) with a
small tolerance; report the match rate and the unmatched sets.
3. From the geometry of the matched/mismatched sets, state the
candidate cull rule that would explain them (e.g. "C set = E set
minus objects with w < 24 and y < horizon").
4. Output: a table of the paired rows, the match statistics, and the
candidate rule(s) ranked by how many rows they explain. Do not
declare a rule validated — validation is the golden sweep + a
maintainer A/B.
House rules: `src/game` is ground truth and read-only; cite the exact
log lines (line numbers) behind every claim; keep machine paths to the
placeholder convention.
+28
View File
@@ -0,0 +1,28 @@
---
name: sweep-watcher
description: Watches a long golden-sweep run in the background, triages new vs regressed frames against golden baselines, writes a triage table.
tools: bash, read, write
model: ninfer-windows/qwen3.8-27b-nvfp4full
---
You are the sweep-watcher for the GoldenEye 007 PC port. You monitor
long-running golden-sweep / capture jobs and triage their results. You
may write ONE triage file (the task names it); you do not edit source,
run builds, or start game sessions yourself.
Procedure:
1. Poll the sweep's output dir / log the task names until it finishes
(bounded: report and stop if it runs past the task's deadline).
2. Compare each frame against its golden baseline
(`tools_pc/golden/<level>/{win,linux,deck}/`); classify every
non-identical frame as NEW-DIFF (was passing) or REGRESSED (worse
than the previous sweep's triage file, if given).
3. Write the triage table: frame, level, platform, status, diff size,
one-line guess only if obvious from the diff stats (never
"diagnosed" — diagnosis needs a parent session with context).
4. Report: counts by status, the worst 5 frames, and the triage file
path. End with `VERDICT: CLEAN` / `VERDICT: N REGRESSIONS`.
House rules: capture output dirs are gitignored — write the triage file
where the task says (typically `docs/dev/notes/`), use placeholder
paths, and never commit or push anything.
+241
View File
@@ -0,0 +1,241 @@
/**
* Evidence-ledger compaction for the GoldenEye 007 PC port (pi/ package).
* Targets the installed pi 1.0.2 extension API (SessionBeforeCompactResult);
* the first-party examples/ in the npm package target a newer API — do not
* copy from them without re-checking dist/core/extensions/types.d.ts.
*
* What it does:
*
* 1. Overrides the compaction summary with an evidence-ledger prompt: every
* live hypothesis about the bug being worked gets one line tagged
* refuted / unvalidated / validated(<artifact>). "Diagnosed" is
* forbidden without a maintainer A/B or a passing golden sweep.
* Motivated by pi session 01a11ec6 (the #150 cull hunt): the 04:52Z
* compaction promoted an unvalidated hypothesis to "diagnosed" and the
* post-compaction turns built the next three fix candidates on that
* false premise.
*
* 2. When GE_COMPACT_MODEL is set (fully-qualified "provider/model", e.g.
* "strata/strata-coder-hard" or "openrouter/deepseek/deepseek-v4-pro"),
* the summarization runs on that stronger model instead of the session
* model. Unset -> the session model (labor on a fast local model,
* compaction on a strong one).
*
* Failure mode is safe by design: if the custom summarization throws (model
* down, no API key, shape drift after a pi upgrade), the handler returns
* nothing and pi's built-in compaction proceeds unchanged.
*
* GE_COMPACT_LEDGER=0 disables the extension's override entirely.
*/
import { completeSimple } from "@earendil-works/pi-ai/compat";
import type { Model } from "@earendil-works/pi-ai";
import type {
ExtensionAPI,
ExtensionContext,
SessionBeforeCompactEvent,
SessionBeforeCompactResult,
} from "@earendil-works/pi-coding-agent";
const EVIDENCE_LEDGER_PROMPT = [
"The summary MUST start with a section titled `Evidence ledger`. For every",
"live hypothesis or open claim about the bug/work item being pursued, write",
"exactly one line in this form:",
" - <claim> -- refuted | unvalidated | validated (<artifact>)",
"where <artifact> is a concrete check: a named log file, a golden-sweep",
"frame, a maintainer A/B, or a commit. Rules:",
' - A fix that has not passed a maintainer A/B or a golden sweep is',
'"unvalidated", never "diagnosed" or "root-caused".',
" - Carry forward the previous summary's ledger lines that are still live;",
" do not drop a refutation just because it was summarized before.",
"After the ledger, include: the uncommitted-tree state (files, which work",
"item each belongs to), the next concrete step, and -- if the session's",
"brief had a BUDGET line -- the remaining budget.",
].join("\n");
const SUMMARY_SYSTEM = [
"You are writing the context-compaction summary for a coding-agent session.",
"Produce a compact, dense handoff summary that a fresh session can resume",
"work from. Preserve: the active task, refuted hypotheses (so they are not",
"retried), the uncommitted tree state, standing environment rules, and the",
"next concrete step. No filler, no restating this instruction.",
].join("\n");
// ---------------------------------------------------------------- transcript
type Msg = { role?: string; content?: unknown; toolName?: string; [k: string]: unknown };
function contentText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((p) => {
if (p && typeof p === "object") {
const o = p as Record<string, unknown>;
if (o.type === "text") return (o.text as string) ?? "";
if (o.type === "toolCall" || o.type === "tool_use")
return `[tool call: ${(o.name as string) ?? "?"}]`;
if (o.type === "image" || o.type === "image_url") return "[image]";
}
return "";
})
.join(" ");
}
return "";
}
function truncate(s: string, n: number): string {
return s.length <= n ? s : s.slice(0, n) + ` [${s.length - n} chars elided]`;
}
function digestMessages(messages: Msg[], capChars: number): string {
const lines: string[] = [];
let budget = capChars;
for (const m of messages) {
const role = m?.role ?? "unknown";
let text = contentText(m?.content).replace(/\s+/g, " ").trim();
if (role === "toolResult" || m?.toolName) text = truncate(text, 400);
const line = `[${role}] ${text}`.trim();
if (budget - line.length - 1 < 0 && lines.length > 0) {
lines.unshift(`[... ${messages.length - lines.length} earlier messages elided ...]`);
break;
}
budget -= line.length + 1;
lines.push(line);
}
return lines.join("\n");
}
function buildLedgerPrompt(
prep: SessionBeforeCompactEvent["preparation"],
extraInstructions: string | undefined,
): string {
const sections: string[] = [];
if (prep.previousSummary) {
sections.push(
"## Previous compaction summary (update it; carry forward still-live ledger lines)\n" +
prep.previousSummary,
);
}
sections.push(
`## Transcript to summarize (${prep.messagesToSummarize.length} messages)\n` +
digestMessages(prep.messagesToSummarize as Msg[], 24000),
);
if (prep.turnPrefixMessages?.length) {
sections.push(
`## Split-turn prefix (${prep.turnPrefixMessages.length} messages)\n` +
digestMessages(prep.turnPrefixMessages as Msg[], 8000),
);
}
if (extraInstructions) {
sections.push(`## Extra custom instructions\n${extraInstructions}`);
}
sections.push(EVIDENCE_LEDGER_PROMPT);
return sections.join("\n\n");
}
// ------------------------------------------------------------------- models
function pickSummarizer(ctx: ExtensionContext): Model<any> | undefined {
const spec = process.env.GE_COMPACT_MODEL?.trim();
if (spec) {
const i = spec.indexOf("/");
if (i > 0 && i < spec.length - 1) {
const m = ctx.modelRegistry.find(spec.slice(0, i), spec.slice(i + 1));
if (m) return m;
try {
ctx.ui.notify?.(`compaction-ledger: GE_COMPACT_MODEL "${spec}" not found; using session model`, "warning");
} catch {
/* headless */
}
}
}
return ctx.model;
}
function extractSummaryText(resp: unknown): string {
const r = resp as { content?: unknown };
if (Array.isArray(r?.content)) {
return (r.content as Array<Record<string, unknown>>)
.filter((p) => p?.type === "text")
.map((p) => (p.text as string) ?? "")
.join("");
}
return typeof r?.content === "string" ? (r.content as string) : "";
}
// -------------------------------------------------------------------- entry
export default function (pi: ExtensionAPI): void {
pi.registerCommand("compact-ledger", {
description: "Compact now with the evidence-ledger summary prompt (usage: /compact-ledger [extra instruction])",
handler: async (args: string, ctx: ExtensionContext) => {
const instructions = [
EVIDENCE_LEDGER_PROMPT,
args ? `Additional user instruction: ${args}` : "",
]
.filter(Boolean)
.join("\n");
ctx.compact({
customInstructions: instructions,
onError: (err) => {
try {
ctx.ui.notify?.(`compaction failed: ${err.message}`, "error");
} catch {
/* headless */
}
},
});
try {
ctx.ui.notify?.("Compaction started (evidence ledger)", "info");
} catch {
/* headless */
}
},
});
pi.on("session_before_compact", async (event, ctx): Promise<SessionBeforeCompactResult | undefined> => {
if (process.env.GE_COMPACT_LEDGER === "0") return; // opt-out: built-in compaction
const prep = event.preparation;
const model = pickSummarizer(ctx);
if (!model) return; // no model resolvable -> built-in compaction
try {
const prompt = buildLedgerPrompt(prep, event.customInstructions);
const maxTokens = Math.min(8192, Math.max(2048, Math.floor(((model as { maxTokens?: number }).maxTokens ?? 4096) / 2)));
const resp = await completeSimple(
model as never,
{
systemPrompt: SUMMARY_SYSTEM,
messages: [{ role: "user", content: prompt }],
},
{ signal: event.signal, maxTokens, temperature: 0.2 },
);
const summary = extractSummaryText(resp);
if (!summary.trim()) throw new Error("summarizer returned an empty summary");
try {
ctx.ui.notify?.(
`Compaction complete (evidence ledger): ${prep.tokensBefore} -> ~${Math.ceil(summary.length / 4)} tokens`,
"info",
);
} catch {
/* headless */
}
return {
compaction: {
summary,
firstKeptEntryId: prep.firstKeptEntryId,
tokensBefore: prep.tokensBefore,
},
};
} catch (err) {
try {
ctx.ui.notify?.(
`compaction-ledger: custom summarization failed (${(err as Error).message}); using built-in compaction`,
"warning",
);
} catch {
/* headless */
}
return; // undefined -> pi's built-in compaction takes over
}
});
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "ge007-pi-tooling",
"version": "0.1.0",
"description": "GoldenEye 007 PC port - agent-development tooling: model-pinned subagent roles and evidence-ledger compaction (see pi/README.md)",
"license": "MIT",
"keywords": [
"pi-package"
],
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-ai": ">=0.86.1"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-ai": {
"optional": true
}
},
"pi": {
"extensions": [
"./extensions/compaction-ledger/index.ts"
],
"subagents": {
"agents": [
"./agents"
]
}
}
}