@mjasnikovs/pi-task 0.37.7 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/shared/child-output.d.ts +19 -3
  2. package/dist/shared/child-output.js +21 -5
  3. package/dist/shared/git-runner.d.ts +39 -0
  4. package/dist/shared/git-runner.js +38 -0
  5. package/dist/task/accept-debt.d.ts +27 -58
  6. package/dist/task/accept-debt.js +60 -130
  7. package/dist/task/auto-orchestrator.d.ts +7 -57
  8. package/dist/task/auto-orchestrator.js +25 -499
  9. package/dist/task/child-runner.d.ts +2 -0
  10. package/dist/task/child-runner.js +74 -70
  11. package/dist/task/enforce-guidelines.d.ts +1 -1
  12. package/dist/task/enforce-guidelines.js +2 -2
  13. package/dist/task/external-context.d.ts +85 -7
  14. package/dist/task/external-context.js +100 -63
  15. package/dist/task/file-inventory.js +22 -41
  16. package/dist/task/final-gate.d.ts +80 -0
  17. package/dist/task/final-gate.js +102 -49
  18. package/dist/task/gate-deps.js +6 -23
  19. package/dist/task/git-state-guard.d.ts +1 -1
  20. package/dist/task/git-state-guard.js +1 -7
  21. package/dist/task/phases.js +40 -83
  22. package/dist/task/run-final-gate.d.ts +127 -0
  23. package/dist/task/run-final-gate.js +492 -0
  24. package/dist/task/task-gates.d.ts +20 -57
  25. package/dist/task/task-gates.js +11 -11
  26. package/dist/task/verify-work.d.ts +40 -32
  27. package/dist/task/verify-work.js +301 -241
  28. package/dist/workers/docs-core.d.ts +14 -0
  29. package/dist/workers/docs-core.js +28 -16
  30. package/dist/workers/fetch-core.d.ts +6 -1
  31. package/dist/workers/fetch-core.js +26 -33
  32. package/dist/workers/focused-extractor.d.ts +73 -0
  33. package/dist/workers/focused-extractor.js +72 -0
  34. package/dist/workers/pi-worker-docs.d.ts +1 -1
  35. package/dist/workers/pi-worker-docs.js +48 -42
  36. package/dist/workers/pi-worker-fetch.js +6 -8
  37. package/dist/workers/typeonly-log.d.ts +13 -0
  38. package/package.json +1 -1
@@ -3,8 +3,22 @@ export declare function parseChildOutput(stdout: string): {
3
3
  excerpt?: string;
4
4
  };
5
5
  export declare function normaliseWhitespace(s: string): string;
6
- /** Check whether an excerpt appears verbatim in the source content
7
- * (whitespace-normalised). Returns false for empty excerpts. */
6
+ /**
7
+ * THE excerpt predicate: does this excerpt appear verbatim in the source content
8
+ * (whitespace-normalised)? {@link verifyExcerpt} delegates to it, so there is exactly one
9
+ * definition of "verified" in the codebase.
10
+ *
11
+ * False for an excerpt that is empty OR whitespace-only. The emptiness test is applied to
12
+ * the NORMALISED excerpt, not the raw one: `" "` is a non-empty string that normalises to
13
+ * `""`, and `content.includes('')` is true for every content — so the raw-string guard let a
14
+ * whitespace-only excerpt "verify" against anything, while `verifyExcerpt` (which tested the
15
+ * normalised length) called the same input unverified. A citation with no characters in it is
16
+ * not evidence, so both now answer false.
17
+ *
18
+ * Unreachable from the shipped extractors — `parseChildOutput` already maps a whitespace-only
19
+ * `<excerpt>` to `undefined` via `.trim() || undefined`, and every call site guards on that —
20
+ * so this is a latent-disagreement fix, not a behaviour change to any recorded verdict.
21
+ */
8
22
  export declare function isExcerptInContent(excerpt: string, content: string): boolean;
9
23
  /**
10
24
  * The same verdict as {@link isExcerptInContent}, PLUS a retained record of what was
@@ -13,7 +27,9 @@ export declare function isExcerptInContent(excerpt: string, content: string): bo
13
27
  * DIAGNOSABLE after the fact, so it can be attributed to fabrication (the excerpt is nowhere
14
28
  * near the content) versus a normaliser gap (it is a markdown-escape or entity variant of
15
29
  * text that IS present) WITHOUT re-fetching. It deliberately does NOT loosen the verifier:
16
- * `.verified` is identical to `isExcerptInContent`. F-3(f) whether the normaliser needs
30
+ * `.verified` IS `isExcerptInContent` — delegated, not re-implemented, so the two cannot
31
+ * drift apart again (they had: a whitespace-only excerpt verified there and failed here).
32
+ * F-3(f) — whether the normaliser needs
17
33
  * markdown-escape handling — is left unproven on purpose; you decide that from the retained
18
34
  * evidence, not by weakening the one working hallucination detector first.
19
35
  */
@@ -20,18 +20,34 @@ export function parseChildOutput(stdout) {
20
20
  export function normaliseWhitespace(s) {
21
21
  return s.replace(/\s+/g, ' ').trim();
22
22
  }
23
- /** Check whether an excerpt appears verbatim in the source content
24
- * (whitespace-normalised). Returns false for empty excerpts. */
23
+ /**
24
+ * THE excerpt predicate: does this excerpt appear verbatim in the source content
25
+ * (whitespace-normalised)? {@link verifyExcerpt} delegates to it, so there is exactly one
26
+ * definition of "verified" in the codebase.
27
+ *
28
+ * False for an excerpt that is empty OR whitespace-only. The emptiness test is applied to
29
+ * the NORMALISED excerpt, not the raw one: `" "` is a non-empty string that normalises to
30
+ * `""`, and `content.includes('')` is true for every content — so the raw-string guard let a
31
+ * whitespace-only excerpt "verify" against anything, while `verifyExcerpt` (which tested the
32
+ * normalised length) called the same input unverified. A citation with no characters in it is
33
+ * not evidence, so both now answer false.
34
+ *
35
+ * Unreachable from the shipped extractors — `parseChildOutput` already maps a whitespace-only
36
+ * `<excerpt>` to `undefined` via `.trim() || undefined`, and every call site guards on that —
37
+ * so this is a latent-disagreement fix, not a behaviour change to any recorded verdict.
38
+ */
25
39
  export function isExcerptInContent(excerpt, content) {
26
- if (!excerpt)
40
+ const ne = normaliseWhitespace(excerpt);
41
+ if (ne.length === 0)
27
42
  return false;
28
- return normaliseWhitespace(content).includes(normaliseWhitespace(excerpt));
43
+ return normaliseWhitespace(content).includes(ne);
29
44
  }
30
45
  export function verifyExcerpt(excerpt, content) {
31
46
  const nc = normaliseWhitespace(content);
32
47
  const ne = normaliseWhitespace(excerpt);
33
48
  return {
34
- verified: ne.length > 0 && nc.includes(ne),
49
+ // Delegated on purpose: this struct adds EVIDENCE, never a second opinion.
50
+ verified: isExcerptInContent(excerpt, content),
35
51
  contentSha256: createHash('sha256').update(nc).digest('hex'),
36
52
  contentLength: nc.length,
37
53
  normalisedExcerpt: ne
@@ -0,0 +1,39 @@
1
+ /**
2
+ * git-runner — the ONE way this codebase runs `git`.
3
+ *
4
+ * WHY IT EXISTS. The right shape already existed, privately, inside
5
+ * `task/git-state-guard.ts`: an abort-aware async runner carrying an injectable
6
+ * `spawnFn` seam, returning `{stdout, exitCode}` and never throwing. Nothing
7
+ * outside that file could reach it, so ~27 other sites hand-rolled their own —
8
+ * with FOUR incompatible failure contracts (throws / returns null / returns '' /
9
+ * returns a result object) and THREE different maxBuffer limits. A caller reading
10
+ * one of them learns nothing about the next, and a git failure means something
11
+ * different at every site. Lifting the seam here does not add a capability; it
12
+ * removes the ambiguity about which contract you are holding.
13
+ *
14
+ * THE CONTRACT, deliberately narrow:
15
+ * - NEVER THROWS. A missing git, a non-repo cwd, a fatal subcommand — all of
16
+ * them arrive as a non-zero `exitCode`. Callers branch on the number, they do
17
+ * not wrap in try/catch. (`git rev-parse --verify HEAD` on an unborn HEAD is
18
+ * an ordinary answer, not an exception, and every guard built on this one
19
+ * treats "git could not tell me" as "no claim" rather than a crash.)
20
+ * - Only `stdout` and `exitCode` are exposed. stderr is deliberately absent:
21
+ * the callers that legitimately need it (auto-commit's identity-failure
22
+ * sniffing) also need `aborted`, and widening this type for them would push
23
+ * two fields nobody else reads onto every call site.
24
+ * - `signal` is honoured by the underlying runChild, including its listener
25
+ * detach discipline (GitHub issue #9) — a run-long orchestrator signal does
26
+ * not accumulate one listener per git invocation.
27
+ * - `env` entries are MERGED over `process.env` (the GIT_INDEX_FILE
28
+ * throwaway-index pattern), not substituted for it.
29
+ * - `spawnFn` is the test seam: pass a fake from `test-utils/fake-spawn.ts` and
30
+ * the runner never touches a real repo.
31
+ */
32
+ import { type SpawnFn } from './child-process.js';
33
+ export interface GitRunner {
34
+ (args: string[], env?: Record<string, string>): Promise<{
35
+ stdout: string;
36
+ exitCode: number;
37
+ }>;
38
+ }
39
+ export declare function makeGit(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): GitRunner;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * git-runner — the ONE way this codebase runs `git`.
3
+ *
4
+ * WHY IT EXISTS. The right shape already existed, privately, inside
5
+ * `task/git-state-guard.ts`: an abort-aware async runner carrying an injectable
6
+ * `spawnFn` seam, returning `{stdout, exitCode}` and never throwing. Nothing
7
+ * outside that file could reach it, so ~27 other sites hand-rolled their own —
8
+ * with FOUR incompatible failure contracts (throws / returns null / returns '' /
9
+ * returns a result object) and THREE different maxBuffer limits. A caller reading
10
+ * one of them learns nothing about the next, and a git failure means something
11
+ * different at every site. Lifting the seam here does not add a capability; it
12
+ * removes the ambiguity about which contract you are holding.
13
+ *
14
+ * THE CONTRACT, deliberately narrow:
15
+ * - NEVER THROWS. A missing git, a non-repo cwd, a fatal subcommand — all of
16
+ * them arrive as a non-zero `exitCode`. Callers branch on the number, they do
17
+ * not wrap in try/catch. (`git rev-parse --verify HEAD` on an unborn HEAD is
18
+ * an ordinary answer, not an exception, and every guard built on this one
19
+ * treats "git could not tell me" as "no claim" rather than a crash.)
20
+ * - Only `stdout` and `exitCode` are exposed. stderr is deliberately absent:
21
+ * the callers that legitimately need it (auto-commit's identity-failure
22
+ * sniffing) also need `aborted`, and widening this type for them would push
23
+ * two fields nobody else reads onto every call site.
24
+ * - `signal` is honoured by the underlying runChild, including its listener
25
+ * detach discipline (GitHub issue #9) — a run-long orchestrator signal does
26
+ * not accumulate one listener per git invocation.
27
+ * - `env` entries are MERGED over `process.env` (the GIT_INDEX_FILE
28
+ * throwaway-index pattern), not substituted for it.
29
+ * - `spawnFn` is the test seam: pass a fake from `test-utils/fake-spawn.ts` and
30
+ * the runner never touches a real repo.
31
+ */
32
+ import { runChildDefault } from './child-process.js';
33
+ export function makeGit(cwd, signal, spawnFn) {
34
+ return async (args, env) => {
35
+ const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
36
+ return { stdout: r.stdout, exitCode: r.exitCode };
37
+ };
38
+ }
@@ -92,71 +92,36 @@ export declare function readAcceptDebtsRaw(cwd: string): Promise<string>;
92
92
  export declare function parseAcceptDebts(raw: string): AcceptDebt[];
93
93
  /** Read + parse in one step. */
94
94
  export declare function readAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
95
- /** Record a user-ACCEPTED-despite-verify-FAIL debt. */
96
- export declare function recordAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
97
95
  /**
98
- * Record an ENFORCE-REVERT debt (mx5 run 10 item 3): an enforce re-verify FAILED and
99
- * the enforce edits were reverted, but the FAIL indicted the ORIGINAL work — so the
100
- * defect is still in the shipped tree. Durable so the final gate re-checks/surfaces it
101
- * rather than letting it die with the revert.
102
- */
103
- export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
104
- /**
105
- * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
106
- * on a check whose named files are DISJOINT from the enforce commit's own diff, so
107
- * the edits were kept discarding them could not have repaired a failure they cannot
108
- * reach. The defect is real and still in the shipped tree, so it is recorded with the
109
- * same durability as an enforce-revert; only the disposition of the edits differs.
110
- */
111
- export declare function recordEnforceKeptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
112
- /**
113
- * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
114
- * whose static findings can only be fixed by editing a path this task's spec froze —
115
- * a cross-task contradiction no unattended re-run may resolve. Recorded when the gate
116
- * loop routes to the picker (regardless of what the human then picks), so the final
117
- * gate re-checks it at run end. Static-class by reason prefix (`repo health: …`), so
118
- * it auto-closes iff the final gate's own static check passes.
96
+ * Record one durable defect against the run ledger, stamped with the DebtOrigin that
97
+ * says how it was reached (see DebtOrigin's doc for what each class asserts).
98
+ *
99
+ * This was eight exported wrappers that differed only in that one string literal, so
100
+ * a new class cost six edit sites. The origin is not cosmetic: the final gate
101
+ * re-checks and reports BY class, and an unattended auto-pick may never be recorded
102
+ * as the 'accepted' class — that one asserts a human weighed the failing artifact.
103
+ *
104
+ * Best-effort by construction (appendDebt swallows its own faults): the ledger is an
105
+ * auditing aid and must never break the gate sequence that calls it. `origin`
106
+ * defaults to 'accepted', which is the legacy 2-field on-disk shape.
119
107
  */
120
- export declare function recordFrozenBlockedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
108
+ export declare function recordDebt(cwd: string, taskId: string, reason: string, origin?: DebtOrigin): Promise<void>;
121
109
  /**
122
- * Record a CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's work deleted a
123
- * file a DIFFERENT task's commit introduced, verify FAILed, and the user ACCEPTed —
124
- * so the deletion survives into the next commit. The reason is a fixed machine-
125
- * parseable shape (`deleted \`<path>\` …`) so the final gate's re-check can extract
126
- * the path and prove the debt resolved iff the file is back in the tree.
110
+ * The reason text a CROSS-TASK DELETION debt stores (mx5 run 12 PROMPT 2): the task's
111
+ * work deleted a file a DIFFERENT task's commit introduced, verify FAILed, and the
112
+ * user ACCEPTed — so the deletion survives into the next commit. The shape is fixed
113
+ * and machine-parseable so the final gate's re-check can extract the path and prove
114
+ * the debt resolved iff the file is back in the tree; it lives here, next to
115
+ * extractDeletedDebtPath, because the writer and the reader of that shape have to
116
+ * move together.
127
117
  */
128
- export declare function recordCrossTaskDeletionDebt(cwd: string, taskId: string, deletion: {
118
+ export declare function crossTaskDeletionReason(deletion: {
129
119
  path: string;
130
120
  owner: string;
131
- }): Promise<void>;
132
- /**
133
- * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
134
- * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
135
- * therefore its own line in the final gate's surfaced report — so a later audit
136
- * reading only the artifacts can never read it as "a human decided this".
137
- */
138
- export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
139
- /**
140
- * Record a FINAL-GATE UNOBSERVED debt (mx5 run 14): the final gate demoted one of its
141
- * OWN checks after two tree-changing fix attempts returned an identical ranked-first
142
- * failure — unfalsifiable in this environment, so the gate stopped paying for it and
143
- * converged on the remaining checks. Durable so the NEXT run's gate re-checks it: it
144
- * is model-/environment-judged, so the re-check surfaces it (never auto-closes it).
145
- * The taskId is the run's parent id — the demotion is a run-level decision.
146
- */
147
- export declare function recordFinalGateUnobservedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
148
- /**
149
- * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
150
- * on a pre-existing defect in a file ANOTHER task created and this task never
151
- * touched. The current task is not at fault — its work (and, at the enforce site,
152
- * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
153
- * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
154
- * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
155
- */
156
- export declare function recordRootCauseDebt(cwd: string, taskId: string, reason: string): Promise<void>;
121
+ }): string;
157
122
  /**
158
123
  * The deleted path a cross-task-deletion debt names (the fixed shape
159
- * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
124
+ * crossTaskDeletionReason builds). Null on any other reason text — an
160
125
  * unextractable path means the re-check cannot prove anything, so the debt
161
126
  * stays open (surface, never re-hide).
162
127
  */
@@ -248,5 +213,9 @@ export declare function annotateDebtConflicts(debts: AcceptDebt[], introducedBy:
248
213
  * instructions — and a conflicting claim carries its contradiction inline.
249
214
  */
250
215
  export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
251
- /** One-line provenance label for a debt, for the surfaced report. */
216
+ /**
217
+ * One-line provenance label for a debt, for the surfaced report. Straight off
218
+ * DEBT_LABELS, so a new origin cannot ship without one; an absent or unregistered
219
+ * origin falls back to the 'accepted' class exactly as the branch chain did.
220
+ */
252
221
  export declare function describeDebt(d: AcceptDebt): string;
@@ -40,6 +40,33 @@ const MAX_REASON_LENGTH = 300;
40
40
  * cleanly and any stray tab in a reason is flattened to a space before storage.
41
41
  */
42
42
  const FIELD_SEP = '\t';
43
+ /**
44
+ * Origin → the one-line provenance label the surfaced report prints for it
45
+ * (describeDebt). This table IS the origin registry: `Record<DebtOrigin, string>`
46
+ * makes a new union member a compile error until it has a label, and both the
47
+ * describe side and the parse side read it, so adding an origin is one union member
48
+ * plus one line here — not the six edit sites the per-origin recorder functions used
49
+ * to cost. The label text is user-facing (it lands in the final gate's report and in
50
+ * the FAIL picker), so these strings are byte-frozen.
51
+ */
52
+ const DEBT_LABELS = {
53
+ accepted: 'accepted despite verify-FAIL',
54
+ 'enforce-revert': 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)',
55
+ 'enforce-kept': 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped',
56
+ 'frozen-blocked': 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)',
57
+ 'cross-task-deletion': "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)",
58
+ 'yolo-accepted': 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)',
59
+ 'final-gate': 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)',
60
+ 'root-cause': "verify FAILed on a PRE-EXISTING defect in another task's file that this task never touched (this task's work was kept; a scoped repair task was queued for the root cause)"
61
+ };
62
+ /**
63
+ * A stored origin field is honoured only when it is a REGISTERED origin — anything
64
+ * else (a hand-edited line, a field from a newer build) falls back to the 'accepted'
65
+ * class rather than being trusted or dropped.
66
+ */
67
+ function isKnownOrigin(origin) {
68
+ return origin !== undefined && Object.hasOwn(DEBT_LABELS, origin);
69
+ }
43
70
  export function acceptDebtFile(cwd) {
44
71
  return path.join(tasksDir(cwd), ACCEPT_DEBT_FILE);
45
72
  }
@@ -78,15 +105,10 @@ export function parseAcceptDebts(raw) {
78
105
  out.push({
79
106
  taskId: parts[0].trim(),
80
107
  reason: parts[1].trim(),
81
- ...((origin === 'enforce-revert'
82
- || origin === 'enforce-kept'
83
- || origin === 'frozen-blocked'
84
- || origin === 'cross-task-deletion'
85
- || origin === 'yolo-accepted'
86
- || origin === 'final-gate'
87
- || origin === 'root-cause') ?
88
- { origin: origin }
89
- : {}),
108
+ // 'accepted' is deliberately NOT carried: it is the legacy 2-field shape's
109
+ // implicit class, so an absent origin and a spelled-out 'accepted' must
110
+ // parse to the same record.
111
+ ...(isKnownOrigin(origin) && origin !== 'accepted' ? { origin } : {}),
90
112
  ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {})
91
113
  });
92
114
  }
@@ -147,112 +169,37 @@ async function appendDebt(cwd, entry) {
147
169
  // best-effort ledger
148
170
  }
149
171
  }
150
- /** Record a user-ACCEPTED-despite-verify-FAIL debt. */
151
- export async function recordAcceptDebt(cwd, taskId, reason) {
152
- await appendDebt(cwd, { taskId: taskId.trim(), reason: normaliseReason(reason) });
153
- }
154
- /**
155
- * Record an ENFORCE-REVERT debt (mx5 run 10 item 3): an enforce re-verify FAILED and
156
- * the enforce edits were reverted, but the FAIL indicted the ORIGINAL work — so the
157
- * defect is still in the shipped tree. Durable so the final gate re-checks/surfaces it
158
- * rather than letting it die with the revert.
159
- */
160
- export async function recordEnforceRevertDebt(cwd, taskId, reason) {
161
- await appendDebt(cwd, {
162
- taskId: taskId.trim(),
163
- reason: normaliseReason(reason),
164
- origin: 'enforce-revert'
165
- });
166
- }
167
172
  /**
168
- * Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
169
- * on a check whose named files are DISJOINT from the enforce commit's own diff, so
170
- * the edits were kept — discarding them could not have repaired a failure they cannot
171
- * reach. The defect is real and still in the shipped tree, so it is recorded with the
172
- * same durability as an enforce-revert; only the disposition of the edits differs.
173
- */
174
- export async function recordEnforceKeptDebt(cwd, taskId, reason) {
175
- await appendDebt(cwd, {
176
- taskId: taskId.trim(),
177
- reason: normaliseReason(reason),
178
- origin: 'enforce-kept'
179
- });
180
- }
181
- /**
182
- * Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
183
- * whose static findings can only be fixed by editing a path this task's spec froze —
184
- * a cross-task contradiction no unattended re-run may resolve. Recorded when the gate
185
- * loop routes to the picker (regardless of what the human then picks), so the final
186
- * gate re-checks it at run end. Static-class by reason prefix (`repo health: …`), so
187
- * it auto-closes iff the final gate's own static check passes.
188
- */
189
- export async function recordFrozenBlockedDebt(cwd, taskId, reason) {
190
- await appendDebt(cwd, {
191
- taskId: taskId.trim(),
192
- reason: normaliseReason(reason),
193
- origin: 'frozen-blocked'
194
- });
195
- }
196
- /**
197
- * Record a CROSS-TASK DELETION debt (mx5 run 12 PROMPT 2): the task's work deleted a
198
- * file a DIFFERENT task's commit introduced, verify FAILed, and the user ACCEPTed —
199
- * so the deletion survives into the next commit. The reason is a fixed machine-
200
- * parseable shape (`deleted \`<path>\` …`) so the final gate's re-check can extract
201
- * the path and prove the debt resolved iff the file is back in the tree.
202
- */
203
- export async function recordCrossTaskDeletionDebt(cwd, taskId, deletion) {
204
- await appendDebt(cwd, {
205
- taskId: taskId.trim(),
206
- reason: normaliseReason(`deleted \`${deletion.path}\` — ${deletion.owner}'s committed deliverable, removed by this task's work`),
207
- origin: 'cross-task-deletion'
208
- });
209
- }
210
- /**
211
- * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
212
- * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
213
- * therefore its own line in the final gate's surfaced report — so a later audit
214
- * reading only the artifacts can never read it as "a human decided this".
215
- */
216
- export async function recordYoloAcceptDebt(cwd, taskId, reason) {
217
- await appendDebt(cwd, {
218
- taskId: taskId.trim(),
219
- reason: normaliseReason(reason),
220
- origin: 'yolo-accepted'
221
- });
222
- }
223
- /**
224
- * Record a FINAL-GATE UNOBSERVED debt (mx5 run 14): the final gate demoted one of its
225
- * OWN checks after two tree-changing fix attempts returned an identical ranked-first
226
- * failure — unfalsifiable in this environment, so the gate stopped paying for it and
227
- * converged on the remaining checks. Durable so the NEXT run's gate re-checks it: it
228
- * is model-/environment-judged, so the re-check surfaces it (never auto-closes it).
229
- * The taskId is the run's parent id — the demotion is a run-level decision.
173
+ * Record one durable defect against the run ledger, stamped with the DebtOrigin that
174
+ * says how it was reached (see DebtOrigin's doc for what each class asserts).
175
+ *
176
+ * This was eight exported wrappers that differed only in that one string literal, so
177
+ * a new class cost six edit sites. The origin is not cosmetic: the final gate
178
+ * re-checks and reports BY class, and an unattended auto-pick may never be recorded
179
+ * as the 'accepted' class — that one asserts a human weighed the failing artifact.
180
+ *
181
+ * Best-effort by construction (appendDebt swallows its own faults): the ledger is an
182
+ * auditing aid and must never break the gate sequence that calls it. `origin`
183
+ * defaults to 'accepted', which is the legacy 2-field on-disk shape.
230
184
  */
231
- export async function recordFinalGateUnobservedDebt(cwd, taskId, reason) {
232
- await appendDebt(cwd, {
233
- taskId: taskId.trim(),
234
- reason: normaliseReason(reason),
235
- origin: 'final-gate'
236
- });
185
+ export async function recordDebt(cwd, taskId, reason, origin = 'accepted') {
186
+ await appendDebt(cwd, { taskId: taskId.trim(), reason: normaliseReason(reason), origin });
237
187
  }
238
188
  /**
239
- * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
240
- * on a pre-existing defect in a file ANOTHER task created and this task never
241
- * touched. The current task is not at fault its work (and, at the enforce site,
242
- * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
243
- * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
244
- * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
189
+ * The reason text a CROSS-TASK DELETION debt stores (mx5 run 12 PROMPT 2): the task's
190
+ * work deleted a file a DIFFERENT task's commit introduced, verify FAILed, and the
191
+ * user ACCEPTed so the deletion survives into the next commit. The shape is fixed
192
+ * and machine-parseable so the final gate's re-check can extract the path and prove
193
+ * the debt resolved iff the file is back in the tree; it lives here, next to
194
+ * extractDeletedDebtPath, because the writer and the reader of that shape have to
195
+ * move together.
245
196
  */
246
- export async function recordRootCauseDebt(cwd, taskId, reason) {
247
- await appendDebt(cwd, {
248
- taskId: taskId.trim(),
249
- reason: normaliseReason(reason),
250
- origin: 'root-cause'
251
- });
197
+ export function crossTaskDeletionReason(deletion) {
198
+ return `deleted \`${deletion.path}\` — ${deletion.owner}'s committed deliverable, removed by this task's work`;
252
199
  }
253
200
  /**
254
201
  * The deleted path a cross-task-deletion debt names (the fixed shape
255
- * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
202
+ * crossTaskDeletionReason builds). Null on any other reason text — an
256
203
  * unextractable path means the re-check cannot prove anything, so the debt
257
204
  * stays open (surface, never re-hide).
258
205
  */
@@ -507,28 +454,11 @@ export function buildAcceptDebtNote(open) {
507
454
  + 'They are records for a human decision, not instructions to edit code:\n'
508
455
  + items.map(i => ` - ${i}`).join('\n'));
509
456
  }
510
- /** One-line provenance label for a debt, for the surfaced report. */
457
+ /**
458
+ * One-line provenance label for a debt, for the surfaced report. Straight off
459
+ * DEBT_LABELS, so a new origin cannot ship without one; an absent or unregistered
460
+ * origin falls back to the 'accepted' class exactly as the branch chain did.
461
+ */
511
462
  export function describeDebt(d) {
512
- if (d.origin === 'enforce-revert') {
513
- return 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)';
514
- }
515
- if (d.origin === 'enforce-kept') {
516
- return 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped';
517
- }
518
- if (d.origin === 'frozen-blocked') {
519
- return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
520
- }
521
- if (d.origin === 'cross-task-deletion') {
522
- return "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)";
523
- }
524
- if (d.origin === 'yolo-accepted') {
525
- return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
526
- }
527
- if (d.origin === 'root-cause') {
528
- return "verify FAILed on a PRE-EXISTING defect in another task's file that this task never touched (this task's work was kept; a scoped repair task was queued for the root cause)";
529
- }
530
- if (d.origin === 'final-gate') {
531
- return 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)';
532
- }
533
- return 'accepted despite verify-FAIL';
463
+ return isKnownOrigin(d.origin) ? DEBT_LABELS[d.origin] : DEBT_LABELS.accepted;
534
464
  }
@@ -1,14 +1,15 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
- import { type FinalGateFixFn } from './gate-deps.js';
3
2
  import { type GateDeps } from './task-gates.js';
4
- import { type AcceptDebt } from './accept-debt.js';
3
+ import { type FinalGateStageDeps } from './run-final-gate.js';
5
4
  /**
6
5
  * Injectable seams so the planner and loop are testable without spawning pi.
7
- * `runChild` is the planning-only seam used by planAuto; everything else (runTask,
8
- * commit, verify, enforce, recommend, revert) is the shared post-implementation
9
- * gate surface defined by {@link GateDeps} and built by {@link buildGateDeps}.
6
+ * `runChild` is the planning-only seam used by planAuto; everything else is one of
7
+ * two shared gate surfaces the per-task one ({@link GateDeps}, driven by
8
+ * runGatesForTask) and the run-end one ({@link FinalGateStageDeps}, driven by
9
+ * runFinalGateStage) — both built by {@link buildGateDeps} / defaultDeps. Only the
10
+ * loop's own repo-integrity probes are declared here.
10
11
  */
11
- export interface AutoDeps extends GateDeps {
12
+ export interface AutoDeps extends GateDeps, FinalGateStageDeps {
12
13
  runChild: (name: string, tools: string, prompt: string) => Promise<string>;
13
14
  /**
14
15
  * Paths with unmerged index entries (an in-progress merge conflict). The loop
@@ -24,57 +25,6 @@ export interface AutoDeps extends GateDeps {
24
25
  * Absent (tests) → the check is skipped.
25
26
  */
26
27
  stashRef?: (cwd: string) => Promise<string | null>;
27
- /**
28
- * Whole-repo FINAL integration gate, run once when every task is checked off
29
- * and BEFORE the run is declared complete (see final-gate.ts): the project's
30
- * own static checks plus its own test/build commands, unaided. Absent (tests /
31
- * gate off) → the run completes as before.
32
- */
33
- finalGate?: (cwd: string, planText?: string) => Promise<{
34
- ok: boolean;
35
- reason: string;
36
- failures?: string[];
37
- debtNote?: string;
38
- openDebts?: AcceptDebt[];
39
- /** Set ⇒ the gate observed nothing dynamic: UNOBSERVED, not PASS. */
40
- unobserved?: string;
41
- }>;
42
- /**
43
- * Bounded model-driven fix pass for a final-gate FAIL (see final-gate-fix.ts),
44
- * offered as the picker's third option. Runs the fix child, applies the
45
- * command-shrink guard, and re-runs the gate; the result's `ok` means the gate
46
- * now passes. Absent (tests / no fix wiring) → the picker keeps only
47
- * Leave-failed / Accept, exactly the pre-autofix behavior.
48
- */
49
- finalGateFix?: FinalGateFixFn;
50
- /**
51
- * Paths currently uncommitted in the working tree (`git status` shape), used to
52
- * detect SUB-FIXES a non-converging final-gate autofix left behind (mx5 run 13
53
- * PROMPT 4 item 3). Every task is committed by the time the final gate runs, so
54
- * anything dirty here is the fix pass's own work. Absent → the stranded-fix
55
- * handling is skipped entirely (prior behavior).
56
- */
57
- pendingChanges?: (cwd: string) => Promise<string[]>;
58
- /**
59
- * Re-derive the still-open ACCEPT-debt ledger against the tree AS IT IS NOW
60
- * (final-gate.ts `deriveOpenDebts`). Needed because the run's "N recorded
61
- * verify-FAIL defect(s) are STILL unresolved" report used to be built from the
62
- * FIRST gate result and the converged-autofix path then rebuilt the gate
63
- * outcome as a bare `{ok, reason}` — so `openDebts` was not merely un-actioned,
64
- * it was GONE from the value, and no code path could ever clear, re-check or
65
- * act on it (mx5 run 18: four defects reported STILL OPEN at 14:58, one of them
66
- * fixed by the autofix that converged at 15:03, and the report never moved).
67
- *
68
- * `staticOk` is the caller's PROOF about the current statics, never a guess:
69
- * pass true only where the gate itself just passed them. Absent (tests) → the
70
- * post-autofix re-check is skipped and the pre-autofix report stands, exactly
71
- * the prior behavior.
72
- */
73
- recheckOpenDebts?: (cwd: string, staticOk: boolean) => Promise<{
74
- openDebts: AcceptDebt[];
75
- debtNote?: string;
76
- trail?: string[];
77
- }>;
78
28
  }
79
29
  /**
80
30
  * Expand any @file references in the feature text by appending each referenced