@mjasnikovs/pi-task 0.40.50 → 0.41.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 (123) hide show
  1. package/README.md +7 -4
  2. package/dist/config/config.d.ts +61 -18
  3. package/dist/config/config.js +50 -6
  4. package/dist/config/register.js +18 -5
  5. package/dist/shared/content-tokens.d.ts +16 -0
  6. package/dist/shared/content-tokens.js +137 -0
  7. package/dist/task/accept-debt.d.ts +15 -1
  8. package/dist/task/accept-debt.js +4 -0
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +59 -7
  11. package/dist/task/auto-io.js +140 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +126 -112
  14. package/dist/task/child-runner.d.ts +18 -0
  15. package/dist/task/child-runner.js +7 -8
  16. package/dist/task/command-run.d.ts +4 -0
  17. package/dist/task/command-run.js +4 -2
  18. package/dist/task/constraint-policy.d.ts +52 -0
  19. package/dist/task/constraint-policy.js +66 -0
  20. package/dist/task/context-silence.d.ts +5 -4
  21. package/dist/task/context-silence.js +13 -5
  22. package/dist/task/coverage-loop.d.ts +7 -4
  23. package/dist/task/coverage-loop.js +16 -138
  24. package/dist/task/debug-log.d.ts +25 -2
  25. package/dist/task/debug-log.js +64 -10
  26. package/dist/task/decompose-fidelity.d.ts +17 -4
  27. package/dist/task/decompose-fidelity.js +95 -55
  28. package/dist/task/decompose-granularity.d.ts +7 -7
  29. package/dist/task/decompose-granularity.js +8 -8
  30. package/dist/task/env-notes.d.ts +65 -19
  31. package/dist/task/env-notes.js +152 -50
  32. package/dist/task/external-context.js +25 -5
  33. package/dist/task/fix-context.d.ts +39 -0
  34. package/dist/task/fix-context.js +58 -0
  35. package/dist/task/gate-deps.d.ts +49 -0
  36. package/dist/task/gate-deps.js +199 -38
  37. package/dist/task/gate-evidence.d.ts +51 -0
  38. package/dist/task/gate-evidence.js +90 -0
  39. package/dist/task/gate-resolution.d.ts +119 -0
  40. package/dist/task/gate-resolution.js +120 -0
  41. package/dist/task/git-state-guard.js +3 -26
  42. package/dist/task/handoff.d.ts +35 -0
  43. package/dist/task/handoff.js +100 -0
  44. package/dist/task/health-baseline.d.ts +87 -0
  45. package/dist/task/health-baseline.js +158 -0
  46. package/dist/task/ledger.d.ts +10 -0
  47. package/dist/task/ledger.js +22 -11
  48. package/dist/task/lint-fix.d.ts +11 -0
  49. package/dist/task/lint-fix.js +14 -8
  50. package/dist/task/loop-detector.d.ts +52 -3
  51. package/dist/task/loop-detector.js +81 -5
  52. package/dist/task/mentions.d.ts +17 -0
  53. package/dist/task/mentions.js +86 -0
  54. package/dist/task/orchestrator.d.ts +53 -9
  55. package/dist/task/orchestrator.js +104 -28
  56. package/dist/task/orientation.d.ts +135 -29
  57. package/dist/task/orientation.js +259 -67
  58. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  59. package/dist/task/owned-freeze-reassign.js +13 -5
  60. package/dist/task/parsers.d.ts +6 -1
  61. package/dist/task/parsers.js +21 -4
  62. package/dist/task/phases.d.ts +1 -1
  63. package/dist/task/phases.js +125 -153
  64. package/dist/task/plan-orchestrator.js +5 -6
  65. package/dist/task/plan-rounds.d.ts +1 -0
  66. package/dist/task/plan-rounds.js +8 -1
  67. package/dist/task/prohibition-probe.d.ts +6 -1
  68. package/dist/task/prohibition-probe.js +26 -6
  69. package/dist/task/prompts.js +13 -2
  70. package/dist/task/qa-transcript.d.ts +17 -0
  71. package/dist/task/qa-transcript.js +28 -0
  72. package/dist/task/question-source.d.ts +16 -2
  73. package/dist/task/question-source.js +21 -8
  74. package/dist/task/repo-health-check.d.ts +27 -0
  75. package/dist/task/repo-health-check.js +39 -5
  76. package/dist/task/requirements.d.ts +67 -13
  77. package/dist/task/requirements.js +129 -67
  78. package/dist/task/research-worker.d.ts +43 -2
  79. package/dist/task/research-worker.js +103 -8
  80. package/dist/task/run-bracket.d.ts +9 -4
  81. package/dist/task/run-bracket.js +9 -0
  82. package/dist/task/run-context.d.ts +152 -0
  83. package/dist/task/run-context.js +277 -0
  84. package/dist/task/spec-doc.d.ts +78 -0
  85. package/dist/task/spec-doc.js +205 -0
  86. package/dist/task/spec-model.d.ts +70 -0
  87. package/dist/task/spec-model.js +139 -0
  88. package/dist/task/stall-detector.d.ts +1 -1
  89. package/dist/task/stall-detector.js +9 -5
  90. package/dist/task/state-dir.d.ts +29 -0
  91. package/dist/task/state-dir.js +80 -0
  92. package/dist/task/suppression-probe.d.ts +67 -0
  93. package/dist/task/suppression-probe.js +78 -0
  94. package/dist/task/task-gates.d.ts +39 -57
  95. package/dist/task/task-gates.js +115 -195
  96. package/dist/task/task-io.d.ts +10 -0
  97. package/dist/task/task-io.js +69 -18
  98. package/dist/task/task-parsers.js +6 -6
  99. package/dist/task/task-types.d.ts +7 -0
  100. package/dist/task/timings.d.ts +11 -0
  101. package/dist/task/timings.js +21 -0
  102. package/dist/task/tree-hash.d.ts +8 -0
  103. package/dist/task/tree-hash.js +55 -0
  104. package/dist/task/verify-reconcile.d.ts +4 -3
  105. package/dist/task/verify-reconcile.js +7 -8
  106. package/dist/task/verify-resolution.d.ts +28 -5
  107. package/dist/task/verify-resolution.js +70 -13
  108. package/dist/task/verify-work.d.ts +98 -35
  109. package/dist/task/verify-work.js +227 -60
  110. package/dist/task/yolo.d.ts +0 -13
  111. package/dist/task/yolo.js +9 -21
  112. package/dist/workers/pi-worker-core.d.ts +20 -0
  113. package/dist/workers/pi-worker-core.js +16 -11
  114. package/dist/workers/pi-worker-docs.d.ts +6 -5
  115. package/dist/workers/pi-worker-docs.js +8 -7
  116. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  117. package/dist/workers/pi-worker-fetch.js +5 -4
  118. package/dist/workers/pi-worker-search.js +5 -4
  119. package/dist/workers/research-cache.d.ts +23 -1
  120. package/dist/workers/research-cache.js +63 -3
  121. package/dist/workers/shared.d.ts +18 -0
  122. package/dist/workers/shared.js +38 -22
  123. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  import { verifyFailClass } from './verify-work.js';
2
2
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
3
+ import { resolveDisposition } from './gate-resolution.js';
4
+ import { classifyHealthDelta } from './health-baseline.js';
3
5
  import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
4
- import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
6
+ import { isYoloMode, YOLO_STAMP } from './yolo.js';
5
7
  import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
6
8
  import { attributeEnforceFailure } from './enforce-attribution.js';
7
9
  // The debt ledger is reached through the injected `recordDebt` dep (so it stays
@@ -12,38 +14,6 @@ import { crossTaskDeletionReason } from './accept-debt.js';
12
14
  import { clampOutput } from './clamp-output.js';
13
15
  import { cancelCheckpoint } from './cancel-points.js';
14
16
  import { updateTaskFrontMatter } from './task-io.js';
15
- /**
16
- * How many times a verify FAIL may be auto-fixed UNATTENDED (the research
17
- * recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
18
- * loop falls back to the human picker. Each AUTOFIX is a full implementation
19
- * re-run, so a non-converging loop must not run forever with nobody able to break
20
- * it — after this many consecutive auto attempts that still FAIL, the picker is
21
- * shown so a person can decide. A recommendation to ACCEPT always shows the picker
22
- * regardless of this count (blessing an artifact as-is is a human's call).
23
- */
24
- export const MAX_AUTO_AUTOFIX = 3;
25
- /**
26
- * The reason an auto-ACCEPT is being written — NAMED, not assumed.
27
- *
28
- * Four disjoint branches reach the terminal auto-ACCEPT, and only ONE of them has
29
- * spent the autofix budget. An UNOBSERVED FAIL never consults the research; a
30
- * frozen-blocked one is a contradiction no re-run can resolve; an ACCEPT
31
- * recommendation can arrive with the budget untouched. A single "autofix budget
32
- * spent" line would be false on three of the four, and a durable trail that
33
- * misstates why a defect shipped reads as an exhausted fixer that never tried.
34
- */
35
- export function yoloAcceptReason(c) {
36
- if (c.isUnobserved)
37
- return 'verify UNOBSERVED — tooling absent, an unattended re-run cannot provision it';
38
- if (c.isFrozenBlocked)
39
- return 'repo-health blocked by a spec-frozen path — an impl re-run under the same freeze cannot converge';
40
- if (c.recommend === 'autofix') {
41
- return `autofix budget spent (${c.autoFixCount}/${MAX_AUTO_AUTOFIX})`;
42
- }
43
- return c.autoFixCount === 0 ?
44
- `judge recommended ACCEPT (autofix budget 0/${MAX_AUTO_AUTOFIX} unused)`
45
- : `judge recommended ACCEPT (autofix budget ${c.autoFixCount}/${MAX_AUTO_AUTOFIX} already spent)`;
46
- }
47
17
  // The trail-side output ceiling lives in clamp-output.ts, so the render probe's
48
18
  // evidence clamps identically — one ceiling, one implementation.
49
19
  /**
@@ -70,9 +40,9 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
70
40
  }
71
41
  /**
72
42
  * The VERIFY resolution loop: run the task's verification against the finished
73
- * work, and negotiate a FAIL through the graduated ladder (bounded lint fix
74
- * recommendation unattended autofix picker) until it verifies, is accepted,
75
- * or terminates.
43
+ * work, and carry out what gate-resolution.ts's decision table says to do with a
44
+ * FAIL (bounded lint fix first, then the table's autofix / accept / ask) until it
45
+ * verifies, is accepted, or terminates.
76
46
  *
77
47
  * Split from `runGatesForTask` at the single boolean that crosses to the ENFORCE
78
48
  * half (`cleanPass`). This loop has four terminal exits and carries the whole
@@ -85,7 +55,23 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
85
55
  v.reason ?
86
56
  `verify: PASS (${v.reason})`
87
57
  : 'verify: PASS'
88
- : `verify: FAIL — ${v.reason ?? 'did not verify'}`;
58
+ : `verify: FAIL — ${v.reason}`;
59
+ /**
60
+ * The loop's ONLY debt-bearing exit. Every way out of a FAIL that leaves the
61
+ * task FINISHED — accepted by a human, accepted unattended, blocked by a spec
62
+ * contradiction, or dismissed at the picker — goes through here, so a defect
63
+ * cannot leave this gate with no ledger entry (five did in AUTO_0002). The
64
+ * exits that leave the task UNFINISHED and resumable (a cancel, a re-run that
65
+ * could not start) are deliberately not debts: that work is coming back.
66
+ */
67
+ const settleDebt = async (origin, reason) => {
68
+ try {
69
+ await deps.recordDebt?.(p.cwd, p.taskId, reason, origin);
70
+ }
71
+ catch {
72
+ // recording must never break the gate sequence
73
+ }
74
+ };
89
75
  // GATE: actually RUN the task's verification against the just-finished work
90
76
  // BEFORE it is checked off or committed. Whether this produced a GENUINE clean
91
77
  // pass (a real signal ran and the work met it) also decides how the enforce pass
@@ -97,24 +83,12 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
97
83
  notifyBoth(active, `${p.tag}: verifying "${p.title}"…`, 'info');
98
84
  let verified = await deps.verify(active, p.cwd, p.title, p.taskId);
99
85
  await rec(verdictLine(verified));
100
- // A FAIL no longer dead-stops. When the research recommends AUTOFIX, pi
101
- // re-runs the impl turn UNATTENDED (no picker) — the human is consulted only
102
- // when the recommendation is ACCEPT (blessing the artifact as-is). The
103
- // unattended fix is BOUNDED by MAX_AUTO_AUTOFIX: once that many consecutive
104
- // auto attempts still FAIL, the picker returns so a person can break the loop.
105
86
  let lintFixAttempted = false;
106
87
  let autoFixCount = 0;
107
- // Set when the bounded lint-fix reports the `frozen-path:` rejection: the
108
- // repo-health FAIL can only be fixed by editing a path THIS task's spec
109
- // froze. An impl re-run happens under the same freeze rule 4b fails the
110
- // task if it complies with the linter — so unattended AUTOFIX rounds
111
- // CANNOT converge and are skipped;
112
- // the picker is forced with the cross-task contradiction named, and the
113
- // defect is recorded as a durable debt for the final gate.
114
- let frozenContradiction = null;
115
- let frozenDebtRecorded = false;
116
- // YOLO only: has the one-attempt rescue below already been spent on this task?
117
- let yoloRescueUsed = false;
88
+ // A contradiction outlives the round that proved it: it is a fact about the
89
+ // SPEC, not about one verdict. Re-deriving it per round is how a loop spends
90
+ // its whole budget on a freeze that was already named in round one (0053).
91
+ let contradiction = null;
118
92
  while (!verified.ok) {
119
93
  // SAFE CHECKPOINT (before a resolution round): a round is a bounded
120
94
  // lint fix, a research child and possibly a whole implementation
@@ -134,12 +108,12 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
134
108
  await updateTaskFrontMatter(p.cwd, p.taskId, { state: 'cancelled' }).catch(() => { });
135
109
  return { stop: { kind: 'cancelled', ctx: active } };
136
110
  }
137
- const failReason = verified.reason ?? 'did not verify';
111
+ const failReason = verified.reason;
138
112
  // GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
139
113
  // bounded fix attempt before the picker — smallest tool first. Applied →
140
114
  // re-verify and re-enter the loop on the fresh verdict; not applied (guard
141
- // trip, no convergence) → fall through to the ordinary picker unchanged.
142
- const failClass = verifyFailClass(verified);
115
+ // trip, no convergence) → fall through to the decision table unchanged.
116
+ const failClass = verifyFailClass(verified) ?? 'model-verdict';
143
117
  if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
144
118
  lintFixAttempted = true;
145
119
  notifyRun(active, `${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
@@ -152,144 +126,73 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
152
126
  await rec(verdictLine(verified));
153
127
  continue;
154
128
  }
155
- if ((fix.reason ?? '').startsWith('frozen-path:')) {
156
- frozenContradiction = fix.reason ?? null;
157
- }
129
+ if (fix.contradiction)
130
+ contradiction = fix.contradiction;
158
131
  }
159
- // UNOBSERVED (rule 5c): a spec-required behavioral check could not run because
160
- // its observation tooling is absent. An unattended AUTOFIX re-run cannot install
161
- // a missing tool, so it would only burn MAX_AUTO_AUTOFIX turns and re-FAIL — the
162
- // decision (provision the tool, or accept the unproven behavior) is the human's.
163
- // Skip the (moot) recommendation research and force the picker.
164
- const isUnobserved = verified.unobserved === true;
165
- // FROZEN-BLOCKED (cross-task contradiction): skip the recommendation
166
- // research too it would only re-derive what the deterministic lint-fix
167
- // rejection already proved. The picker shows the contradiction; ACCEPT
168
- // records the (already-recorded) defect as the human's call. Applies
169
- // only while the FAIL is still the repo-health one the contradiction
170
- // explains a later, different FAIL gets the ordinary resolution path.
171
- const isFrozenBlocked = frozenContradiction !== null && failClass === 'repo-health';
172
- const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
173
- : isFrozenBlocked ?
174
- {
175
- recommend: 'accept',
176
- rationale: `${frozenContradiction} — the failing static check can only be fixed by `
177
- + `editing a path this task's spec freezes (a cross-task contradiction: `
178
- + `the spec forbids the very edit the repo needs; the "owning" earlier `
179
- + `step already completed). An implementation re-run under the same `
180
- + `freeze cannot converge. ACCEPT records it as durable debt the final `
181
- + `gate re-checks; fixing it needs a plan-level change, not a re-run.`
182
- }
183
- : deps.recommend ?
184
- await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
185
- : { recommend: 'autofix', rationale: failReason };
186
- await rec(isUnobserved ?
187
- 'resolution: verify UNOBSERVED — spec-required check could not run (tooling absent); '
188
- + 'forcing the human picker, an unattended re-run cannot provision it'
189
- : isFrozenBlocked ?
190
- 'resolution: repo-health FAIL is blocked by spec-frozen path(s) — cross-task '
191
- + 'contradiction; unattended AUTOFIX skipped (an impl re-run under the same '
192
- + 'freeze cannot converge), forcing the human picker'
193
- : `resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
194
- if (isFrozenBlocked && !frozenDebtRecorded) {
195
- frozenDebtRecorded = true;
196
- // Durable regardless of what the human picks next: the contradiction
197
- // is real, cross-task, and outside this task's power to fix — the
198
- // final gate must surface it at run end (static-class: it auto-closes
199
- // iff the run-end static check passes).
200
- try {
201
- await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ${frozenContradiction}`, 'frozen-blocked');
202
- }
203
- catch {
204
- // recording must never break the gate sequence
205
- }
206
- }
207
- // AUTO-RESOLVE the AUTOFIX path: when the research says the work is
208
- // genuinely wrong, re-run the fix WITHOUT prompting the user. The picker is
209
- // reserved for the ACCEPT recommendation (the human decides whether to bless
210
- // an artifact the gate FAILed) and for the bounded fallback: after
211
- // MAX_AUTO_AUTOFIX consecutive unattended attempts that still FAIL, hand
212
- // control back so a person can break a non-converging loop.
213
- const autoFixNow = !isUnobserved
214
- && !isFrozenBlocked
215
- && recOutcome.recommend === 'autofix'
216
- && autoFixCount < MAX_AUTO_AUTOFIX
217
- // A rescue attempt that still FAILed is terminal under YOLO: the
218
- // recommendation that got us here was ACCEPT, so a later flip to
219
- // AUTOFIX must not bootstrap the full budget from it.
220
- && !yoloRescueUsed;
221
- // YOLO, THE RESCUE BRANCH: the recommendation is ACCEPT, nobody can be
222
- // asked, and the unattended budget is UNTOUCHED. Accepting here would
223
- // ship a defect having attempted nothing, on a judgement the human who
224
- // would normally weigh it never saw. So spend ONE attempt first.
225
- // Bounded by construction: one, not MAX_AUTO_AUTOFIX, so an ACCEPT
226
- // recommendation can never restart a full loop; if it still FAILs the
227
- // next turn falls through to the same auto-ACCEPT and the same debt.
228
- const yoloRescueNow = isYoloMode()
229
- && !yoloRescueUsed
230
- && !isUnobserved
231
- && !isFrozenBlocked
232
- && recOutcome.recommend === 'accept'
233
- && autoFixCount === 0;
234
- // YOLO: the picker is unreachable with nobody watching, and every
235
- // unattended attempt this task may make has been made — so the only
236
- // option left that terminates is ACCEPT, recorded as its own
237
- // 'yolo-accepted' debt. Deliberately NOT a re-entry into autofix:
238
- // MAX_AUTO_AUTOFIX exists to break a non-converging loop, and an
239
- // auto-pick here would restart the budget from the site that proves it ran out.
240
- const yoloChoice = autoFixNow || yoloRescueNow ? null : yoloVerifyResolution(isYoloMode());
132
+ // Two FAILs make the recommendation research MOOT before it runs. An
133
+ // UNOBSERVED one (rule 5c) is a check that could not RUN for want of
134
+ // tooling, and a contradiction has already been PROVEN deterministically
135
+ // asking a model to re-derive either costs a child and decides nothing.
136
+ const unobserved = verified.unobserved === true;
137
+ const judge = contradiction === null && !unobserved ? deps.recommend : undefined;
138
+ const recOutcome = judge ?
139
+ await judge(active, p.cwd, p.title, p.taskId, failReason)
140
+ : { recommend: 'autofix', rationale: failReason };
141
+ if (judge)
142
+ await rec(`resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
143
+ contradiction ??= recOutcome.contradiction ?? null;
144
+ const unattended = isYoloMode();
145
+ const disposition = resolveDisposition({
146
+ failClass,
147
+ recommend: recOutcome.recommend,
148
+ unobserved,
149
+ contradiction,
150
+ attempts: autoFixCount,
151
+ unattended
152
+ });
241
153
  let choice;
242
- if (yoloChoice !== null) {
243
- choice = yoloChoice;
244
- // NAME the branch. This line is the durable record of why a defect
245
- // shipped, and three of the four branches that reach it have spent
246
- // no budget at all.
247
- await rec(`resolution: auto-ACCEPTED despite verify FAIL — ${yoloAcceptReason({
248
- isUnobserved,
249
- isFrozenBlocked,
250
- recommend: recOutcome.recommend,
251
- autoFixCount
252
- })}, nobody to ask ${YOLO_STAMP}`);
253
- }
254
- else if (autoFixNow || yoloRescueNow) {
154
+ if (disposition.action === 'autofix') {
255
155
  autoFixCount += 1;
256
- if (yoloRescueNow)
257
- yoloRescueUsed = true;
258
- await rec(yoloRescueNow ?
259
- `resolution: auto-AUTOFIX (${YOLO_STAMP} rescue — judge recommended ACCEPT with the unattended `
260
- + `budget unspent; one attempt, ${autoFixCount}/${MAX_AUTO_AUTOFIX})`
261
- : `resolution: auto-AUTOFIX (recommended, unattended ${autoFixCount}/${MAX_AUTO_AUTOFIX})`);
262
- notifyRun(active, `${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${yoloRescueNow ? `${YOLO_STAMP} one attempt before accepting` : 'recommended'}, ${autoFixCount}/${MAX_AUTO_AUTOFIX})…`, 'info');
156
+ await rec(`resolution: auto-AUTOFIX (${disposition.reason})`);
157
+ notifyRun(active, `${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${disposition.reason})…`, 'info');
263
158
  choice = { action: 'autofix' };
264
159
  }
160
+ else if (disposition.action === 'accept') {
161
+ // NAME the rule that decided. This line is the durable record of why
162
+ // a defect shipped, and the rules that reach it have spent wildly
163
+ // different amounts of the budget — some none at all.
164
+ await rec(`resolution: auto-ACCEPTED despite verify FAIL — ${disposition.reason}${unattended ? `, nobody to ask ${YOLO_STAMP}` : ''}`);
165
+ choice = { action: 'accept' };
166
+ }
265
167
  else {
168
+ // Why the picker is up, when that is not already the line above:
169
+ // the judge-accept row's reason only restates the recommendation.
170
+ if (disposition.rule !== 'judge-accept') {
171
+ await rec(`resolution: asking the human — ${disposition.reason}`);
172
+ }
266
173
  choice = await askVerifyResolution(active, p.title, failReason, recOutcome);
267
174
  }
268
175
  if (choice.action === 'cancel') {
269
176
  await rec('resolution: user dismissed the verify-FAIL picker — paused');
177
+ await settleDebt('dismissed', failReason);
270
178
  return { stop: { kind: 'paused', ctx: active, reason: failReason } };
271
179
  }
272
180
  if (choice.action === 'accept') {
273
- const byYolo = yoloChoice !== null;
274
- if (!byYolo)
181
+ // Provenance splits here, mandatorily. The table's own accept carries
182
+ // the origin that names the rule which decided; only a PICKER answer
183
+ // earns the plain 'accepted' class, which asserts a human weighed
184
+ // this failing artifact.
185
+ const origin = disposition.action === 'accept' ? disposition.debtOrigin : 'accepted';
186
+ if (origin === 'accepted') {
275
187
  await rec('resolution: user ACCEPTED the work despite verify FAIL');
276
- // Durable debt: the human blessed a FAILing artifact as-is, so the
277
- // defect ships and nothing else in this task revisits it. Record it
278
- // to the run ledger; the final integration gate re-checks it at run
279
- // end and surfaces it if still open. Best-effort.
280
- // The frozen-blocked routing above already recorded this defect (with
281
- // the contradiction named) — don't double-enter it in the ledger.
282
- if (!frozenDebtRecorded) {
283
- try {
284
- // Provenance splits here, mandatorily: an auto-pick writes the
285
- // 'yolo-accepted' origin, never the plain 'accepted' one that
286
- // asserts a human weighed the failing artifact.
287
- await deps.recordDebt?.(p.cwd, p.taskId, failReason, byYolo ? 'yolo-accepted' : 'accepted');
288
- }
289
- catch {
290
- // recording must never break the gate sequence
291
- }
292
188
  }
189
+ // Durable debt: the defect ships and nothing else in this task
190
+ // revisits it. The final integration gate re-checks it at run end and
191
+ // surfaces it if still open. The reason LEADS with the fail text so
192
+ // its minted class prefix still routes the run-end re-check.
193
+ await settleDebt(origin, disposition.rule === 'spec-contradiction' ?
194
+ `${failReason} — ${disposition.reason}`
195
+ : failReason);
293
196
  // ROOT CAUSE: an accepted FAIL that some OTHER task's file caused is
294
197
  // not fixed by accepting it — every later task keeps tripping over
295
198
  // the same bug. Queue the scoped repair so the plan closes it.
@@ -306,7 +209,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
306
209
  // recording must never break the gate sequence
307
210
  }
308
211
  }
309
- notifyRun(active, `${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${byYolo ? ` ${YOLO_STAMP}` : ''}`, 'warning');
212
+ notifyRun(active, `${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${unattended ? ` ${YOLO_STAMP}` : ''}`, 'warning');
310
213
  break;
311
214
  }
312
215
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
@@ -315,24 +218,31 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
315
218
  // hand its diagnosis to the re-run rather than making it re-derive the
316
219
  // cause from the bare FAIL line. Skipped when there is no researched
317
220
  // rationale beyond the failure text itself.
318
- // Only the picker branch may claim a person chose this: the two
319
- // unattended branches already recorded themselves one line above, and a
320
- // trail that says "user chose" when nobody was asked is the same lie the
321
- // accept line used to tell.
322
- if (!autoFixNow && !yoloRescueNow) {
221
+ // Only the picker branch may claim a person chose this: the unattended
222
+ // branch already recorded itself one line above, and a trail that says
223
+ // "user chose" when nobody was asked is the same lie the accept line
224
+ // used to tell.
225
+ if (disposition.action === 'ask') {
323
226
  await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
324
227
  }
325
228
  notifyBoth(active, `${p.tag}: autofixing "${p.title}"…`, 'info');
326
229
  const diagnosis = (recOutcome.recommend === 'autofix'
327
230
  && recOutcome.rationale.length > 0
328
231
  && recOutcome.rationale !== failReason) ?
329
- `\n\nDIAGNOSIS (a read-only investigation of this failure found):\n${recOutcome.rationale}`
232
+ recOutcome.rationale
330
233
  : '';
331
- const fixInstruction = `${failReason}${diagnosis}${choice.guidance ? `\n\nUser guidance: ${choice.guidance}` : ''}`;
332
234
  const fixRes = await deps.runTask(active, p.cwd, p.title, {
333
235
  resumeId: p.taskId,
334
236
  planContext: p.planContext,
335
- fixInstruction
237
+ fixContext: {
238
+ outcome: verified,
239
+ disposition,
240
+ probes: verified.probes ?? {},
241
+ attempt: autoFixCount,
242
+ contradiction,
243
+ ...(diagnosis === '' ? {} : { diagnosis }),
244
+ ...(choice.guidance === undefined ? {} : { guidance: choice.guidance })
245
+ }
336
246
  });
337
247
  active = fixRes.ctx ?? active;
338
248
  // The re-run's ending, mapped to this loop's own terminal kinds. A
@@ -364,6 +274,15 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
364
274
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
365
275
  await rec(verdictLine(verified));
366
276
  }
277
+ // Static checks the repo was ALREADY failing when this task started (see
278
+ // health-baseline.ts). They did not fail this gate — that is the point — but
279
+ // dropping them here is how a sibling's defect vanishes until the run-end
280
+ // gate rediscovers it with nobody's name on it. Recorded once, after the
281
+ // loop, whatever the verdict was: a task can verify perfectly in a red repo.
282
+ if (verified.inheritedHealth) {
283
+ await rec(`accept-debt: inherited repo health — ${verified.inheritedHealth}`);
284
+ await settleDebt('inherited-health', verified.inheritedHealth);
285
+ }
367
286
  // Loop exited because the work verified OR the user accepted the artifact. A
368
287
  // genuine clean pass is ok===true with NO reason; a no-op pass or an
369
288
  // accept-override (verified.ok still false at break) is NOT a guardable signal.
@@ -448,12 +367,13 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
448
367
  let enforceEditsBlocked = false;
449
368
  if (mode === 'edit' && deps.repoHealth && editsMade !== false) {
450
369
  const after = await deps.repoHealth(active, p.cwd, p.title);
451
- // A regression needs a clean (or unknown) baseline turning to a fail. If
452
- // healthBefore is undefined (repoHealth was absent at baseline time) treat
453
- // the baseline as clean the conservative absolute behavior.
454
- const wasHealthyBefore = healthBefore?.ok ?? true;
455
- const regressed = !after.ok && wasHealthyBefore;
456
- if (regressed) {
370
+ // The SAME differential the verify gate runs (health-baseline.ts), per
371
+ // command rather than per overall verdict two runs can both be red
372
+ // while a different check failed in each. A missing baseline is no
373
+ // longer read as a clean one: that default silently turned this
374
+ // differential back into the absolute check it exists to replace.
375
+ const delta = classifyHealthDelta(healthBefore ?? null, after);
376
+ if (delta === 'regressed') {
457
377
  enforceEditsBlocked = true;
458
378
  const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
459
379
  if (deps.discardEdits) {
@@ -465,7 +385,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
465
385
  }
466
386
  notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
467
387
  }
468
- else if (!after.ok) {
388
+ else if (delta === 'pre-existing') {
469
389
  // Failing both before and after → not enforce's fault. Keep the edits;
470
390
  // record that the repo entered the gate already unhealthy so the trail
471
391
  // explains why a still-failing repo did NOT trigger a discard here.
@@ -21,6 +21,16 @@ export declare function writeTaskFile(cwd: string, fm: TaskFrontMatter, body: st
21
21
  export declare function updateTaskFrontMatter(cwd: string, id: string, patch: Partial<TaskFrontMatter>): Promise<void>;
22
22
  export declare function readSection(cwd: string, id: string, heading: string): Promise<string | null>;
23
23
  export declare function setTaskSection(cwd: string, id: string, heading: string, content: string): Promise<void>;
24
+ /**
25
+ * Rewrite a section from its own current contents.
26
+ *
27
+ * The read-then-write pair a caller would otherwise inline, kept whole here
28
+ * because the RE-ENTRY case is where it goes wrong: a resumed run that reaches a
29
+ * section it did not fully regenerate (an autofix re-entry runs zero phases)
30
+ * calls `setTaskSection` with what THIS pass produced and erases what the first
31
+ * pass proved. `merge` receives null when the section is absent.
32
+ */
33
+ export declare function mergeTaskSection(cwd: string, id: string, heading: string, merge: (old: string | null) => string): Promise<void>;
24
34
  /**
25
35
  * Append one timestamped line to the task's `## gates` section — the durable
26
36
  * per-task trail of gate outcomes (verify verdicts, enforce mode/verdict, commit
@@ -69,19 +69,51 @@ export async function readTaskFile(cwd, id) {
69
69
  const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
70
70
  return { frontMatter: fm, body };
71
71
  }
72
+ /**
73
+ * One writer per task file at a time. Research workers in graph mode, the
74
+ * loop-events trail and the gate recorder all rewrite the same file from
75
+ * overlapping continuations; without this, two read-modify-write pairs interleave
76
+ * and the second one erases the first, or a reader catches a half-written file
77
+ * and reports it as malformed front matter.
78
+ */
79
+ const fileChains = new Map();
80
+ async function withTaskFile(file, fn) {
81
+ const prev = fileChains.get(file) ?? Promise.resolve();
82
+ const next = prev.then(fn, fn);
83
+ fileChains.set(file, next.catch(() => { }));
84
+ try {
85
+ return await next;
86
+ }
87
+ finally {
88
+ if (fileChains.get(file) === next)
89
+ fileChains.delete(file);
90
+ }
91
+ }
72
92
  export async function writeTaskFile(cwd, fm, body) {
73
93
  await ensureTasksDir(cwd);
94
+ const file = taskFilePath(cwd, fm.id);
74
95
  const content = `${emitFrontMatter(fm)}\n${body}`;
75
- await fsp.writeFile(taskFilePath(cwd, fm.id), content, 'utf8');
96
+ // Written beside and renamed over: a reader never sees a truncated file.
97
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
98
+ await fsp.writeFile(tmp, content, 'utf8');
99
+ try {
100
+ await fsp.rename(tmp, file);
101
+ }
102
+ catch (err) {
103
+ await fsp.rm(tmp, { force: true }).catch(() => { });
104
+ throw err;
105
+ }
76
106
  }
77
107
  export async function updateTaskFrontMatter(cwd, id, patch) {
78
- const { frontMatter, body } = await readTaskFile(cwd, id);
79
- const next = {
80
- ...frontMatter,
81
- ...patch,
82
- updated_at: new Date().toISOString()
83
- };
84
- await writeTaskFile(cwd, next, body);
108
+ await withTaskFile(taskFilePath(cwd, id), async () => {
109
+ const { frontMatter, body } = await readTaskFile(cwd, id);
110
+ const next = {
111
+ ...frontMatter,
112
+ ...patch,
113
+ updated_at: new Date().toISOString()
114
+ };
115
+ await writeTaskFile(cwd, next, body);
116
+ });
85
117
  }
86
118
  // ─── Section read/write (append if absent, rewrite if present) ───────────────
87
119
  export async function readSection(cwd, id, heading) {
@@ -89,9 +121,15 @@ export async function readSection(cwd, id, heading) {
89
121
  const m = sectionRegex(heading).exec(body);
90
122
  return m ? m[2].trim() : null;
91
123
  }
92
- export async function setTaskSection(cwd, id, heading, content) {
124
+ export function setTaskSection(cwd, id, heading, content) {
125
+ return withTaskFile(taskFilePath(cwd, id), () => rewriteSection(cwd, id, heading, () => content));
126
+ }
127
+ /** The read-modify-write itself; callers hold the file's chain. */
128
+ async function rewriteSection(cwd, id, heading, render) {
93
129
  const { frontMatter, body } = await readTaskFile(cwd, id);
94
130
  const re = sectionRegex(heading);
131
+ const m = re.exec(body);
132
+ const content = render(m ? m[2].trim() : null);
95
133
  let next;
96
134
  if (re.test(body)) {
97
135
  // Use a replacer FUNCTION, not a replacement string: `content` is
@@ -112,6 +150,18 @@ export async function setTaskSection(cwd, id, heading, content) {
112
150
  }
113
151
  await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
114
152
  }
153
+ /**
154
+ * Rewrite a section from its own current contents.
155
+ *
156
+ * The read-then-write pair a caller would otherwise inline, kept whole here
157
+ * because the RE-ENTRY case is where it goes wrong: a resumed run that reaches a
158
+ * section it did not fully regenerate (an autofix re-entry runs zero phases)
159
+ * calls `setTaskSection` with what THIS pass produced and erases what the first
160
+ * pass proved. `merge` receives null when the section is absent.
161
+ */
162
+ export function mergeTaskSection(cwd, id, heading, merge) {
163
+ return withTaskFile(taskFilePath(cwd, id), () => rewriteSection(cwd, id, heading, merge));
164
+ }
115
165
  /**
116
166
  * Append one timestamped line to the task's `## gates` section — the durable
117
167
  * per-task trail of gate outcomes (verify verdicts, enforce mode/verdict, commit
@@ -124,19 +174,20 @@ export async function appendGateRecord(cwd, id, line) {
124
174
  try {
125
175
  const stamp = new Date().toISOString();
126
176
  const entry = `- ${stamp} ${line.replace(/\s*\n\s*/g, ' ').trim()}`;
127
- const existing = await readSection(cwd, id, 'gates');
128
- await setTaskSection(cwd, id, 'gates', existing ? `${existing}\n${entry}` : entry);
177
+ await mergeTaskSection(cwd, id, 'gates', existing => existing ? `${existing}\n${entry}` : entry);
129
178
  }
130
179
  catch {
131
180
  // Recording is observability, not control flow — never propagate.
132
181
  }
133
182
  }
134
183
  /** Remove a section (heading + body) if present; a no-op when it's absent. */
135
- export async function removeTaskSection(cwd, id, heading) {
136
- const { frontMatter, body } = await readTaskFile(cwd, id);
137
- const re = sectionRegex(heading);
138
- if (!re.test(body))
139
- return;
140
- const next = body.replace(re, '');
141
- await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
184
+ export function removeTaskSection(cwd, id, heading) {
185
+ return withTaskFile(taskFilePath(cwd, id), async () => {
186
+ const { frontMatter, body } = await readTaskFile(cwd, id);
187
+ const re = sectionRegex(heading);
188
+ if (!re.test(body))
189
+ return;
190
+ const next = body.replace(re, '');
191
+ await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
192
+ });
142
193
  }
@@ -13,19 +13,18 @@ const FRONT_MATTER_KEYS = [
13
13
  'updated_at',
14
14
  'title',
15
15
  'label',
16
+ 'plan_key',
16
17
  'reason'
17
18
  ];
19
+ /** Front-matter keys omitted entirely when empty rather than emitted blank. */
20
+ const OPTIONAL_KEYS = new Set(['label', 'plan_key', 'reason']);
18
21
  // ─── Front matter ────────────────────────────────────────────────────────────
19
22
  export function emitFrontMatter(fm) {
20
23
  const lines = ['---'];
21
24
  for (const k of FRONT_MATTER_KEYS) {
22
25
  const v = fm[k];
23
- if (v === undefined || v === '') {
24
- // Optional fields are omitted entirely when empty rather than emitted
25
- // as a blank `key:` line.
26
- if (k === 'reason' || k === 'label')
27
- continue;
28
- }
26
+ if ((v === undefined || v === '') && OPTIONAL_KEYS.has(k))
27
+ continue;
29
28
  lines.push(`${k}: ${typeof v === 'string' ? v : String(v)}`);
30
29
  }
31
30
  lines.push('---');
@@ -59,6 +58,7 @@ export function parseFrontMatter(content) {
59
58
  updated_at: obj.updated_at ?? obj.created_at,
60
59
  title: obj.title ?? '',
61
60
  label: obj.label || undefined,
61
+ plan_key: obj.plan_key || undefined,
62
62
  reason: obj.reason || undefined
63
63
  };
64
64
  }
@@ -21,6 +21,13 @@ export interface TaskFrontMatter {
21
21
  * of `title` (`titleForDisplay`, parsers.ts). See title-label.ts.
22
22
  */
23
23
  label?: string;
24
+ /**
25
+ * The /task-auto plan entry this task implements (`TaskEntry.key`). It is
26
+ * what the owned-requirements ledger joins on, and it lives here rather than
27
+ * in memory because a resumed run reconstructs the task from this file alone.
28
+ * Absent on a bare /task and on any task planned before keys existed.
29
+ */
30
+ plan_key?: string;
24
31
  reason?: string;
25
32
  }
26
33
  export declare const PHASE_ORDER: PhaseName[];
@@ -18,3 +18,14 @@ export interface TimingEntry {
18
18
  }
19
19
  export declare function formatMs(ms: number): string;
20
20
  export declare function formatTimings(entries: ReadonlyArray<TimingEntry>): string;
21
+ /**
22
+ * The `## phase timings` body once this attempt's phases are added to it.
23
+ *
24
+ * A run re-entered for an autofix resumes at `phase: done` and therefore runs no
25
+ * phases at all, so rendering only what IT timed wrote an empty block over the
26
+ * five phases the first attempt measured — the timings vanished for exactly the
27
+ * tasks that took longest. Each attempt gets its own labelled block instead, and
28
+ * an unlabelled body written before this grammar is adopted as attempt 1 rather
29
+ * than discarded.
30
+ */
31
+ export declare function mergeTimings(prev: string | null, next: ReadonlyArray<TimingEntry>): string;