@mjasnikovs/pi-task 0.40.50 → 0.42.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 (125) 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 +31 -1
  8. package/dist/task/accept-debt.js +49 -3
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +66 -7
  11. package/dist/task/auto-io.js +152 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +184 -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 +201 -39
  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/health-repair.d.ts +68 -0
  47. package/dist/task/health-repair.js +124 -0
  48. package/dist/task/ledger.d.ts +10 -0
  49. package/dist/task/ledger.js +22 -11
  50. package/dist/task/lint-fix.d.ts +11 -0
  51. package/dist/task/lint-fix.js +14 -8
  52. package/dist/task/loop-detector.d.ts +52 -3
  53. package/dist/task/loop-detector.js +81 -5
  54. package/dist/task/mentions.d.ts +17 -0
  55. package/dist/task/mentions.js +86 -0
  56. package/dist/task/orchestrator.d.ts +53 -9
  57. package/dist/task/orchestrator.js +104 -28
  58. package/dist/task/orientation.d.ts +135 -29
  59. package/dist/task/orientation.js +259 -67
  60. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  61. package/dist/task/owned-freeze-reassign.js +13 -5
  62. package/dist/task/parsers.d.ts +6 -1
  63. package/dist/task/parsers.js +21 -4
  64. package/dist/task/phases.d.ts +1 -1
  65. package/dist/task/phases.js +125 -153
  66. package/dist/task/plan-orchestrator.js +5 -6
  67. package/dist/task/plan-rounds.d.ts +1 -0
  68. package/dist/task/plan-rounds.js +8 -1
  69. package/dist/task/prohibition-probe.d.ts +6 -1
  70. package/dist/task/prohibition-probe.js +26 -6
  71. package/dist/task/prompts.js +13 -2
  72. package/dist/task/qa-transcript.d.ts +17 -0
  73. package/dist/task/qa-transcript.js +28 -0
  74. package/dist/task/question-source.d.ts +16 -2
  75. package/dist/task/question-source.js +21 -8
  76. package/dist/task/repo-health-check.d.ts +27 -0
  77. package/dist/task/repo-health-check.js +39 -5
  78. package/dist/task/requirements.d.ts +67 -13
  79. package/dist/task/requirements.js +129 -67
  80. package/dist/task/research-worker.d.ts +43 -2
  81. package/dist/task/research-worker.js +103 -8
  82. package/dist/task/run-bracket.d.ts +9 -4
  83. package/dist/task/run-bracket.js +9 -0
  84. package/dist/task/run-context.d.ts +152 -0
  85. package/dist/task/run-context.js +277 -0
  86. package/dist/task/spec-doc.d.ts +78 -0
  87. package/dist/task/spec-doc.js +205 -0
  88. package/dist/task/spec-model.d.ts +70 -0
  89. package/dist/task/spec-model.js +139 -0
  90. package/dist/task/stall-detector.d.ts +1 -1
  91. package/dist/task/stall-detector.js +9 -5
  92. package/dist/task/state-dir.d.ts +29 -0
  93. package/dist/task/state-dir.js +80 -0
  94. package/dist/task/suppression-probe.d.ts +67 -0
  95. package/dist/task/suppression-probe.js +78 -0
  96. package/dist/task/task-gates.d.ts +52 -58
  97. package/dist/task/task-gates.js +161 -199
  98. package/dist/task/task-io.d.ts +10 -0
  99. package/dist/task/task-io.js +69 -18
  100. package/dist/task/task-parsers.js +6 -6
  101. package/dist/task/task-types.d.ts +7 -0
  102. package/dist/task/timings.d.ts +11 -0
  103. package/dist/task/timings.js +21 -0
  104. package/dist/task/tree-hash.d.ts +8 -0
  105. package/dist/task/tree-hash.js +55 -0
  106. package/dist/task/verify-reconcile.d.ts +4 -3
  107. package/dist/task/verify-reconcile.js +7 -8
  108. package/dist/task/verify-resolution.d.ts +32 -6
  109. package/dist/task/verify-resolution.js +78 -15
  110. package/dist/task/verify-work.d.ts +105 -35
  111. package/dist/task/verify-work.js +232 -60
  112. package/dist/task/yolo.d.ts +0 -13
  113. package/dist/task/yolo.js +9 -21
  114. package/dist/workers/pi-worker-core.d.ts +20 -0
  115. package/dist/workers/pi-worker-core.js +16 -11
  116. package/dist/workers/pi-worker-docs.d.ts +6 -5
  117. package/dist/workers/pi-worker-docs.js +8 -7
  118. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  119. package/dist/workers/pi-worker-fetch.js +5 -4
  120. package/dist/workers/pi-worker-search.js +5 -4
  121. package/dist/workers/research-cache.d.ts +23 -1
  122. package/dist/workers/research-cache.js +63 -3
  123. package/dist/workers/shared.d.ts +18 -0
  124. package/dist/workers/shared.js +38 -22
  125. package/package.json +1 -1
@@ -1,9 +1,12 @@
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';
9
+ import { healthRedSubject, parseHealthRepairTitle } from './health-repair.js';
7
10
  // The debt ledger is reached through the injected `recordDebt` dep (so it stays
8
11
  // absent-in-tests); only the origin TYPE and the cross-task-deletion reason SHAPE
9
12
  // come from accept-debt.ts directly — the latter because its writer and its
@@ -12,38 +15,6 @@ import { crossTaskDeletionReason } from './accept-debt.js';
12
15
  import { clampOutput } from './clamp-output.js';
13
16
  import { cancelCheckpoint } from './cancel-points.js';
14
17
  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
18
  // The trail-side output ceiling lives in clamp-output.ts, so the render probe's
48
19
  // evidence clamps identically — one ceiling, one implementation.
49
20
  /**
@@ -53,10 +24,13 @@ export function yoloAcceptReason(c) {
53
24
  * clarify/grill dialog: the same SessionUI.ask races the local boxed picker against
54
25
  * a remote answer, with the two actions also surfaced as remote buttons.
55
26
  */
56
- export async function askVerifyResolution(ctx, title, failReason, rec) {
57
- const options = resolutionOptions(rec.recommend);
27
+ export async function askVerifyResolution(ctx, title, failReason, rec,
28
+ /** The repair ACCEPT would queue (a regressed health check), if any. */
29
+ acceptQueues) {
30
+ const options = resolutionOptions(rec.recommend, acceptQueues);
58
31
  const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
59
- + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
32
+ + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`
33
+ + (acceptQueues ? `\n\nACCEPT queues ${acceptQueues} before the next task.` : '');
60
34
  const answer = await new SessionUI(ctx).ask({
61
35
  localTitle: 'Verification failed — how should pi proceed?',
62
36
  displayQuestion: question,
@@ -68,11 +42,17 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
68
42
  });
69
43
  return classifyResolutionAnswer(answer);
70
44
  }
45
+ /** The repair a red health check earns, as the picker and the trail name it. */
46
+ function describeHealthRepair(red) {
47
+ return red.files.length > 0 ?
48
+ `a repair for ${red.files.join(', ')} (\`${red.command}\`)`
49
+ : `a repair for \`${red.command}\``;
50
+ }
71
51
  /**
72
52
  * 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.
53
+ * work, and carry out what gate-resolution.ts's decision table says to do with a
54
+ * FAIL (bounded lint fix first, then the table's autofix / accept / ask) until it
55
+ * verifies, is accepted, or terminates.
76
56
  *
77
57
  * Split from `runGatesForTask` at the single boolean that crosses to the ENFORCE
78
58
  * half (`cleanPass`). This loop has four terminal exits and carries the whole
@@ -85,7 +65,31 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
85
65
  v.reason ?
86
66
  `verify: PASS (${v.reason})`
87
67
  : 'verify: PASS'
88
- : `verify: FAIL — ${v.reason ?? 'did not verify'}`;
68
+ : `verify: FAIL — ${v.reason}`;
69
+ /**
70
+ * The loop's ONLY debt-bearing exit. Every way out of a FAIL that leaves the
71
+ * task FINISHED — accepted by a human, accepted unattended, blocked by a spec
72
+ * contradiction, or dismissed at the picker — goes through here, so a defect
73
+ * cannot leave this gate with no ledger entry (five did in AUTO_0002). The
74
+ * exits that leave the task UNFINISHED and resumable (a cancel, a re-run that
75
+ * could not start) are deliberately not debts: that work is coming back.
76
+ */
77
+ const settleDebt = async (origin, reason) => {
78
+ try {
79
+ await deps.recordDebt?.(p.cwd, p.taskId, reason, origin);
80
+ }
81
+ catch {
82
+ // recording must never break the gate sequence
83
+ }
84
+ };
85
+ const healthRedOf = async (health) => {
86
+ try {
87
+ return healthRedSubject(health, p.cwd, (await deps.repoFiles?.(p.cwd)) ?? null);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
89
93
  // GATE: actually RUN the task's verification against the just-finished work
90
94
  // BEFORE it is checked off or committed. Whether this produced a GENUINE clean
91
95
  // pass (a real signal ran and the work met it) also decides how the enforce pass
@@ -97,24 +101,12 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
97
101
  notifyBoth(active, `${p.tag}: verifying "${p.title}"…`, 'info');
98
102
  let verified = await deps.verify(active, p.cwd, p.title, p.taskId);
99
103
  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
104
  let lintFixAttempted = false;
106
105
  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;
106
+ // A contradiction outlives the round that proved it: it is a fact about the
107
+ // SPEC, not about one verdict. Re-deriving it per round is how a loop spends
108
+ // its whole budget on a freeze that was already named in round one (0053).
109
+ let contradiction = null;
118
110
  while (!verified.ok) {
119
111
  // SAFE CHECKPOINT (before a resolution round): a round is a bounded
120
112
  // lint fix, a research child and possibly a whole implementation
@@ -134,12 +126,12 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
134
126
  await updateTaskFrontMatter(p.cwd, p.taskId, { state: 'cancelled' }).catch(() => { });
135
127
  return { stop: { kind: 'cancelled', ctx: active } };
136
128
  }
137
- const failReason = verified.reason ?? 'did not verify';
129
+ const failReason = verified.reason;
138
130
  // GRADUATED resolution: a repo-health FAIL (pure static findings) gets ONE
139
131
  // bounded fix attempt before the picker — smallest tool first. Applied →
140
132
  // 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);
133
+ // trip, no convergence) → fall through to the decision table unchanged.
134
+ const failClass = verifyFailClass(verified) ?? 'model-verdict';
143
135
  if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
144
136
  lintFixAttempted = true;
145
137
  notifyRun(active, `${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
@@ -152,143 +144,81 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
152
144
  await rec(verdictLine(verified));
153
145
  continue;
154
146
  }
155
- if ((fix.reason ?? '').startsWith('frozen-path:')) {
156
- frozenContradiction = fix.reason ?? null;
157
- }
158
- }
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
- }
147
+ if (fix.contradiction)
148
+ contradiction = fix.contradiction;
206
149
  }
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());
150
+ // Two FAILs make the recommendation research MOOT before it runs. An
151
+ // UNOBSERVED one (rule 5c) is a check that could not RUN for want of
152
+ // tooling, and a contradiction has already been PROVEN deterministically
153
+ // asking a model to re-derive either costs a child and decides nothing.
154
+ const unobserved = verified.unobserved === true;
155
+ const judge = contradiction === null && !unobserved ? deps.recommend : undefined;
156
+ const recOutcome = judge ?
157
+ await judge(active, p.cwd, p.title, p.taskId, failReason)
158
+ : { recommend: 'autofix', rationale: failReason };
159
+ if (judge)
160
+ await rec(`resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
161
+ contradiction ??= recOutcome.contradiction ?? null;
162
+ const unattended = isYoloMode();
163
+ // A regressed health check is THIS task's red. Accepting it ships the
164
+ // red, and the next checkpoint splices a repair before anything builds
165
+ // on it (health-repair.ts) named here so a human choosing ACCEPT
166
+ // sees what the choice queues, and the trail says the same.
167
+ const regression = verified.health ? await healthRedOf(verified.health) : null;
168
+ const acceptQueues = regression ? describeHealthRepair(regression) : undefined;
169
+ const disposition = resolveDisposition({
170
+ failClass,
171
+ recommend: recOutcome.recommend,
172
+ unobserved,
173
+ contradiction,
174
+ attempts: autoFixCount,
175
+ unattended
176
+ });
241
177
  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) {
178
+ if (disposition.action === 'autofix') {
255
179
  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');
180
+ await rec(`resolution: auto-AUTOFIX (${disposition.reason})`);
181
+ notifyRun(active, `${p.tag}: verify FAIL on "${p.title}" — auto-fixing (${disposition.reason})…`, 'info');
263
182
  choice = { action: 'autofix' };
264
183
  }
184
+ else if (disposition.action === 'accept') {
185
+ // NAME the rule that decided. This line is the durable record of why
186
+ // a defect shipped, and the rules that reach it have spent wildly
187
+ // different amounts of the budget — some none at all.
188
+ await rec(`resolution: auto-ACCEPTED despite verify FAIL — ${disposition.reason}${unattended ? `, nobody to ask ${YOLO_STAMP}` : ''}`);
189
+ choice = { action: 'accept' };
190
+ }
265
191
  else {
266
- choice = await askVerifyResolution(active, p.title, failReason, recOutcome);
192
+ // Why the picker is up, when that is not already the line above:
193
+ // the judge-accept row's reason only restates the recommendation.
194
+ if (disposition.rule !== 'judge-accept') {
195
+ await rec(`resolution: asking the human — ${disposition.reason}`);
196
+ }
197
+ choice = await askVerifyResolution(active, p.title, failReason, recOutcome, acceptQueues);
267
198
  }
268
199
  if (choice.action === 'cancel') {
269
200
  await rec('resolution: user dismissed the verify-FAIL picker — paused');
201
+ await settleDebt('dismissed', failReason);
270
202
  return { stop: { kind: 'paused', ctx: active, reason: failReason } };
271
203
  }
272
204
  if (choice.action === 'accept') {
273
- const byYolo = yoloChoice !== null;
274
- if (!byYolo)
205
+ // Provenance splits here, mandatorily. The table's own accept carries
206
+ // the origin that names the rule which decided; only a PICKER answer
207
+ // earns the plain 'accepted' class, which asserts a human weighed
208
+ // this failing artifact.
209
+ const origin = disposition.action === 'accept' ? disposition.debtOrigin : 'accepted';
210
+ if (origin === 'accepted') {
275
211
  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
- }
212
+ }
213
+ // Durable debt: the defect ships and nothing else in this task
214
+ // revisits it. The final integration gate re-checks it at run end and
215
+ // surfaces it if still open. The reason LEADS with the fail text so
216
+ // its minted class prefix still routes the run-end re-check.
217
+ await settleDebt(origin, disposition.rule === 'spec-contradiction' ?
218
+ `${failReason} ${disposition.reason}`
219
+ : failReason);
220
+ if (acceptQueues) {
221
+ await rec(`accept: repo health regressed by this task — ${acceptQueues} is spliced before the next task`);
292
222
  }
293
223
  // ROOT CAUSE: an accepted FAIL that some OTHER task's file caused is
294
224
  // not fixed by accepting it — every later task keeps tripping over
@@ -306,7 +236,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
306
236
  // recording must never break the gate sequence
307
237
  }
308
238
  }
309
- notifyRun(active, `${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${byYolo ? ` ${YOLO_STAMP}` : ''}`, 'warning');
239
+ notifyRun(active, `${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.${unattended ? ` ${YOLO_STAMP}` : ''}`, 'warning');
310
240
  break;
311
241
  }
312
242
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
@@ -315,24 +245,31 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
315
245
  // hand its diagnosis to the re-run rather than making it re-derive the
316
246
  // cause from the bare FAIL line. Skipped when there is no researched
317
247
  // 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) {
248
+ // Only the picker branch may claim a person chose this: the unattended
249
+ // branch already recorded itself one line above, and a trail that says
250
+ // "user chose" when nobody was asked is the same lie the accept line
251
+ // used to tell.
252
+ if (disposition.action === 'ask') {
323
253
  await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
324
254
  }
325
255
  notifyBoth(active, `${p.tag}: autofixing "${p.title}"…`, 'info');
326
256
  const diagnosis = (recOutcome.recommend === 'autofix'
327
257
  && recOutcome.rationale.length > 0
328
258
  && recOutcome.rationale !== failReason) ?
329
- `\n\nDIAGNOSIS (a read-only investigation of this failure found):\n${recOutcome.rationale}`
259
+ recOutcome.rationale
330
260
  : '';
331
- const fixInstruction = `${failReason}${diagnosis}${choice.guidance ? `\n\nUser guidance: ${choice.guidance}` : ''}`;
332
261
  const fixRes = await deps.runTask(active, p.cwd, p.title, {
333
262
  resumeId: p.taskId,
334
263
  planContext: p.planContext,
335
- fixInstruction
264
+ fixContext: {
265
+ outcome: verified,
266
+ disposition,
267
+ probes: verified.probes ?? {},
268
+ attempt: autoFixCount,
269
+ contradiction,
270
+ ...(diagnosis === '' ? {} : { diagnosis }),
271
+ ...(choice.guidance === undefined ? {} : { guidance: choice.guidance })
272
+ }
336
273
  });
337
274
  active = fixRes.ctx ?? active;
338
275
  // The re-run's ending, mapped to this loop's own terminal kinds. A
@@ -364,6 +301,30 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
364
301
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
365
302
  await rec(verdictLine(verified));
366
303
  }
304
+ // Static checks the repo was ALREADY failing when this task started (see
305
+ // health-baseline.ts). They did not fail this gate — that is the point — but
306
+ // dropping them here is how a sibling's defect vanishes until the run-end
307
+ // gate rediscovers it with nobody's name on it. Recorded once, after the
308
+ // loop, whatever the verdict was: a task can verify perfectly in a red repo.
309
+ if (verified.inheritedHealth) {
310
+ await rec(`accept-debt: inherited repo health — ${verified.inheritedHealth}`);
311
+ await settleDebt('inherited-health', verified.inheritedHealth);
312
+ }
313
+ // A health repair that verified CLEAN — the check it exists for ran green,
314
+ // nothing inherited — closes the debts that check opened, under its own id.
315
+ const repair = parseHealthRepairTitle(p.title);
316
+ if (repair && verified.ok && !verified.reason && !verified.inheritedHealth) {
317
+ try {
318
+ const closed = await deps.closeHealthDebts?.(p.cwd, repair.command, p.taskId);
319
+ if (closed && closed.length > 0) {
320
+ await rec(`accept-debt: closed ${closed.length} debt(s) on \`${repair.command}\` — `
321
+ + `${closed.map(d => d.taskId).join(', ')} — repaired by ${p.taskId}`);
322
+ }
323
+ }
324
+ catch {
325
+ // closing debts must never break the gate sequence
326
+ }
327
+ }
367
328
  // Loop exited because the work verified OR the user accepted the artifact. A
368
329
  // genuine clean pass is ok===true with NO reason; a no-op pass or an
369
330
  // accept-override (verified.ok still false at break) is NOT a guardable signal.
@@ -448,12 +409,13 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
448
409
  let enforceEditsBlocked = false;
449
410
  if (mode === 'edit' && deps.repoHealth && editsMade !== false) {
450
411
  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) {
412
+ // The SAME differential the verify gate runs (health-baseline.ts), per
413
+ // command rather than per overall verdict two runs can both be red
414
+ // while a different check failed in each. A missing baseline is no
415
+ // longer read as a clean one: that default silently turned this
416
+ // differential back into the absolute check it exists to replace.
417
+ const delta = classifyHealthDelta(healthBefore ?? null, after);
418
+ if (delta === 'regressed') {
457
419
  enforceEditsBlocked = true;
458
420
  const outputTail = after.output ? ` — output:\n${clampOutput(after.output)}` : '';
459
421
  if (deps.discardEdits) {
@@ -465,7 +427,7 @@ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args)
465
427
  }
466
428
  notifyRun(active, `${p.tag}: guideline edits on "${p.title}" regressed repo health (${after.reason.slice(0, 120)}) — discarded before commit.`, 'warning');
467
429
  }
468
- else if (!after.ok) {
430
+ else if (delta === 'pre-existing') {
469
431
  // Failing both before and after → not enforce's fault. Keep the edits;
470
432
  // record that the repo entered the gate already unhealthy so the trail
471
433
  // 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
  }