@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
@@ -16,11 +16,17 @@ import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
16
16
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
17
17
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
18
18
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
19
- import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
19
+ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
20
20
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
21
+ import { ENTRY_ATTEMPT_BUDGET } from './gate-resolution.js';
22
+ import { recordDebt } from './accept-debt.js';
21
23
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
22
- import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
23
- import { readTextFile } from '../shared/fs-text.js';
24
+ import { writeTaskFile, readTaskFile, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
25
+ // Re-exported as well as used: the @-mention helpers moved to their own module so
26
+ // the research phase can select a cited spec doc without importing this one, and
27
+ // the planning call sites still name them here.
28
+ import { expandFeatureMentions, readableMentions } from './mentions.js';
29
+ export { expandFeatureMentions, readableMentions };
24
30
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
25
31
  import { isFatalChildCause, prependHint, USER_CANCELLED } from './child-runner.js';
26
32
  import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
@@ -31,21 +37,25 @@ import { getParentContextWindow } from './context-usage.js';
31
37
  import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
32
38
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
33
39
  import { runGatesForTask } from './task-gates.js';
40
+ import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
41
+ import { HEALTH_BASELINE_SECTION, parseHealthBaseline } from './health-baseline.js';
34
42
  import { runFinalGateStage } from './run-final-gate.js';
35
43
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
36
44
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
37
45
  import { spawnCommand } from './command-run.js';
38
46
  import { getConfig } from '../config/config.js';
39
- import { debugLogLevel, shouldLogDebug } from './debug-log.js';
47
+ import { debugLogLevel, sanitizeDebugLine, shouldLogDebug } from './debug-log.js';
48
+ import { pruneRunLogs, runLogPath } from './state-dir.js';
40
49
  import { isYoloMode, yoloPickAnswer } from './yolo.js';
41
50
  import { QaTranscript, CLARIFY_QA_POLICY } from './qa-transcript.js';
42
51
  import { makeQuestionSource } from './question-source.js';
43
52
  import { CoverageLedger } from './plan-rounds.js';
44
53
  import { CLARIFY_QUALITY_RULES, PLAN_FORMAT_HINT } from './plan-session.js';
45
54
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
55
+ import { currentRunContext } from './run-context.js';
46
56
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
47
- import { reconcileTitleSources } from './decompose-fidelity.js';
48
- import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
57
+ import { reconcileTitleSources, renderFragments } from './decompose-fidelity.js';
58
+ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER, PLAN_SHAPE_TOPIC } from './decompose-granularity.js';
49
59
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
50
60
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
51
61
  import { groundedCoverage } from './coverage-loop.js';
@@ -126,23 +136,6 @@ function suspectPlanHint(count) {
126
136
  + 'list for the ENTIRE feature, covering every part of the spec end to end. '
127
137
  + 'Output every task, one "- [ ] " line each, nothing else.]');
128
138
  }
129
- // Matches pi's @-file completion token (a path after @, until whitespace).
130
- const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
131
- // Trailing punctuation a user naturally types AFTER an @-mention when it sits in
132
- // prose — "Implement @design.md, reuse…" or "see @spec.md." — which the greedy
133
- // [^\s]+ above would otherwise swallow into the path. Left unstripped, the
134
- // resulting "design.md," resolves to no file, expansion is silently skipped, and
135
- // the planner reasons over a one-line "Implement @design.md" with NO spec inline
136
- // → it fabricates generic questions and tasks the spec never called for.
137
- //
138
- // Measured against a real file: the greedy token from "Implement @design.md,
139
- // reuse the parser" is `design.md,`, which does not exist; stripped, `design.md`
140
- // does. None of these chars are legitimate trailing characters of a doc path.
141
- const MENTION_TRAILING_PUNCT = /[.,;:!?)\]}>"']+$/;
142
- /** The cleaned path token of an @-mention: greedy match minus trailing prose punctuation. */
143
- function mentionPath(token) {
144
- return token.replace(MENTION_TRAILING_PUNCT, '');
145
- }
146
139
  /**
147
140
  * Every plan-debug write not yet on disk, chained.
148
141
  *
@@ -161,9 +154,9 @@ export function flushPlanDebug() {
161
154
  * Fire-and-forget debug line for the PLAN phase (clarify/decompose). It is the
162
155
  * only trail that phase has: planning runs before any task file exists, so there
163
156
  * is no per-task `TASK_NNNN-debug.log` to write into yet. This goes to
164
- * `.pi-tasks/plan-debug.log`, whose `*-debug.log` suffix matches the pattern
165
- * debug-log.ts documents, so one grep still finds every log. Never throws — the
166
- * mkdir and the append are both best-effort.
157
+ * `plan-debug.log` in the run's state dir, beside every other log of the run, so
158
+ * one grep still finds them all. Never throws — the mkdir and the append are both
159
+ * best-effort.
167
160
  *
168
161
  * Every call site records a plan DECISION (how many titles a round produced,
169
162
  * whether a retry was adopted, which clarify answer was auto-resolved), so all of
@@ -173,11 +166,11 @@ export function flushPlanDebug() {
173
166
  function logPlanDebug(cwd, msg) {
174
167
  if (!shouldLogDebug('event', debugLogLevel()))
175
168
  return;
176
- const line = `${new Date().toISOString()} ${msg}\n`;
177
- const dir = tasksDir(cwd);
169
+ const line = `${new Date().toISOString()} ${sanitizeDebugLine(msg)}\n`;
170
+ const file = runLogPath(cwd, 'plan-debug.log');
178
171
  planDebugChain = planDebugChain
179
- .then(() => fsp.mkdir(dir, { recursive: true }))
180
- .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
172
+ .then(() => fsp.mkdir(path.dirname(file), { recursive: true }))
173
+ .then(() => fsp.appendFile(file, line))
181
174
  .catch(() => { });
182
175
  }
183
176
  /**
@@ -240,61 +233,6 @@ async function triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBl
240
233
  return null;
241
234
  }
242
235
  }
243
- /**
244
- * Expand any @file references in the feature text by appending each referenced
245
- * file's contents, so the planning children (clarify, decompose) always see the
246
- * real spec inline instead of relying on the model to open the file itself.
247
- * Without this, clarify on a one-line "Implement @spec.md" tends to bail with
248
- * NONE because, to the model, the request looks small and unambiguous.
249
- * Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
250
- * is returned verbatim when nothing readable is referenced.
251
- */
252
- export async function expandFeatureMentions(cwd, feature) {
253
- const seen = new Set();
254
- const blocks = [];
255
- for (const m of feature.matchAll(MENTION_RE)) {
256
- const rel = mentionPath(m[1]);
257
- if (rel === '' || seen.has(rel))
258
- continue;
259
- seen.add(rel);
260
- try {
261
- // Normalize CRLF/CR so an @-mentioned design doc saved on Windows
262
- // inlines with LF endings the downstream phase parsers expect.
263
- const body = await readTextFile(path.resolve(cwd, rel));
264
- if (body.trim().length > 0) {
265
- blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
266
- }
267
- }
268
- catch {
269
- // not a readable file — leave the @token in place, skip expansion
270
- }
271
- }
272
- return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
273
- }
274
- /**
275
- * The @file references in the feature that point at a readable file on disk —
276
- * the bare path tokens, deduped, in first-seen order. Unreadable mentions
277
- * (typos, non-file @tokens) are dropped so we never advertise a missing file as
278
- * an authoritative spec.
279
- */
280
- export async function readableMentions(cwd, feature) {
281
- const out = [];
282
- const seen = new Set();
283
- for (const m of feature.matchAll(MENTION_RE)) {
284
- const rel = mentionPath(m[1]);
285
- if (rel === '' || seen.has(rel))
286
- continue;
287
- seen.add(rel);
288
- try {
289
- await fsp.access(path.resolve(cwd, rel));
290
- out.push(rel);
291
- }
292
- catch {
293
- // not a readable file — don't thread it into task titles
294
- }
295
- }
296
- return out;
297
- }
298
236
  /** A trailing "[decisions: …]" clause decompose may attach to a task line. */
299
237
  const DECISIONS_RE = /\s*\[decisions:\s*(.+?)\]\s*$/i;
300
238
  /**
@@ -342,29 +280,37 @@ export function attachSpecRefs(titles, refs) {
342
280
  *
343
281
  * The plan listing strips the threaded "| decisions … | spec …" tail from each
344
282
  * title and keeps the human-readable head, so the model reads clean step names,
345
- * and it marks the current one "(THIS STEP)" — both confirmed by building a fence
283
+ * and it marks the current one `[this]` — both confirmed by building a fence
346
284
  * over threaded titles. The authoritative spec ref still rides on THIS step's own
347
285
  * title via attachSpecRefs.
286
+ *
287
+ * Every sibling used to read as unbuilt, which is a lie about the first half of
288
+ * any plan past step one: a step told its predecessors are "done in later runs"
289
+ * re-scaffolds what is already in the tree. The entries carry their own done
290
+ * state, so the listing states it.
348
291
  */
349
- export function buildScopeFence(titles, currentIndex) {
350
- const n = titles.length;
351
- const listing = titles
352
- .map((t, i) => {
353
- const head = t.split(' | ')[0].trim();
354
- const tag = i === currentIndex ? ' (THIS STEP)' : '';
355
- return `[${i + 1}]${tag} ${head}`;
292
+ export function buildScopeFence(entries, currentIndex) {
293
+ const n = entries.length;
294
+ const listing = entries
295
+ .map((e, i) => {
296
+ const head = e.title.split(' | ')[0].trim();
297
+ const marker = i === currentIndex ? 'this'
298
+ : e.done ? 'done'
299
+ : 'later';
300
+ return `[${i + 1}] [${marker}] ${head}`;
356
301
  })
357
302
  .join('\n');
358
303
  return (`PLAN CONTEXT — this task is STEP ${currentIndex + 1} of ${n} in an already-decomposed plan. `
359
- + `Each step below is implemented by its OWN separate run; the others are NOT your job and `
360
- + `are done in later runs. Implement ONLY the slice named in "Task" below.\n\n`
304
+ + `Each step below is implemented by its OWN separate run: a [done] step is ALREADY BUILT and `
305
+ + `in the tree (build on it never rebuild, duplicate or re-scaffold it), a [later] step is `
306
+ + `not built yet and is NOT your job. Implement ONLY the slice named in "Task" below.\n\n`
361
307
  + `The design/spec document the task references describes the WHOLE system across all ${n} `
362
308
  + `steps. Read it to get exact names, types, and signatures for YOUR step and to understand `
363
309
  + `how your step fits — but DO NOT design, scaffold, schema, route, page, query, component, or `
364
310
  + `test anything that belongs to another step listed below. Your GOAL / CONSTRAINTS / `
365
311
  + `KNOWN-UNKNOWNS must cover only THIS step's slice. Do not pull in tables, endpoints, pages, `
366
312
  + `components, or flows owned by a later step.\n\n`
367
- + `The full plan (these run separately — do NOT implement them here):\n${listing}`);
313
+ + `The full plan (these run separately — do NOT implement the [later] ones here):\n${listing}`);
368
314
  }
369
315
  /**
370
316
  * The scope fence for step `currentIndex`, plus the REPAIR fence when that step is
@@ -377,12 +323,16 @@ export function buildScopeFence(titles, currentIndex) {
377
323
  * `bun run test` yields it while a plain-prose defect yields undefined and only the
378
324
  * file pin applies.
379
325
  */
380
- function buildStepFence(titles, currentIndex) {
381
- const base = buildScopeFence(titles, currentIndex);
382
- const repairFile = parseRepairTitleFile(titles[currentIndex] ?? '');
326
+ export function buildStepFence(entries, currentIndex) {
327
+ const base = buildScopeFence(entries, currentIndex);
328
+ const title = entries[currentIndex]?.title ?? '';
329
+ const healthRepair = parseHealthRepairTitle(title);
330
+ if (healthRepair)
331
+ return `${base}\n\n${buildHealthRepairFence(healthRepair)}`;
332
+ const repairFile = parseRepairTitleFile(title);
383
333
  if (!repairFile)
384
334
  return base;
385
- return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(titles[currentIndex] ?? ''))}`;
335
+ return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(title))}`;
386
336
  }
387
337
  /**
388
338
  * Drain the gate's root-cause repair queue into the running plan: one scoped
@@ -428,6 +378,53 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
428
378
  // the plan is best-effort here; the underlying debt is already recorded
429
379
  }
430
380
  }
381
+ /**
382
+ * A red health check at the pre-task checkpoint becomes a repair entry spliced
383
+ * BEFORE the task about to run (health-repair.ts). Returns whether the plan
384
+ * changed — the caller then re-reads it and runs the repair first. No splice when
385
+ * the plan already covers this red (a repair that ran and failed included): the
386
+ * task then proceeds and inherits it, as the baseline differential intends.
387
+ */
388
+ async function spliceHealthRepair(cwd, id, next, entries, health, ctx, deps) {
389
+ try {
390
+ const red = healthRedSubject(health, cwd, (await deps.repoFiles?.(cwd)) ?? null);
391
+ if (!red)
392
+ return false;
393
+ if (planCoversHealthRed(entries.map(e => e.title), red))
394
+ return false;
395
+ const owners = [];
396
+ for (const f of red.files) {
397
+ const owner = await deps.introducedBy?.(cwd, f);
398
+ if (owner && !owners.includes(owner))
399
+ owners.push(owner);
400
+ }
401
+ const title = buildHealthRepairTitle({ ...red, owners });
402
+ if (!(await insertTaskBefore(cwd, id, next.index, title)))
403
+ return false;
404
+ await deps.record?.(cwd, id, `plan: \`${red.command}\` is red at the checkpoint before step ${next.index + 1} — inserted repair step first: ${title}`);
405
+ notifyRun(ctx, `${id}: \`${red.command}\` is red before "${next.title}" — queued a repair for `
406
+ + `${red.files.length > 0 ? red.files.join(', ') : 'it'} to run first.`, 'warning');
407
+ return true;
408
+ }
409
+ catch {
410
+ return false;
411
+ }
412
+ }
413
+ /**
414
+ * The health baseline a task starts from: the one its (resumed) task file already
415
+ * holds, else a fresh capture at the checkpoint. Null when neither is available.
416
+ */
417
+ async function baselineAtCheckpoint(ctx, cwd, resumeId, label, deps) {
418
+ if (resumeId) {
419
+ const stored = await readSection(cwd, resumeId, HEALTH_BASELINE_SECTION).catch(() => null);
420
+ const parsed = parseHealthBaseline(stored);
421
+ if (parsed)
422
+ return parsed;
423
+ }
424
+ if (!deps.captureHealthBaseline)
425
+ return null;
426
+ return deps.captureHealthBaseline(ctx, cwd, label);
427
+ }
431
428
  /**
432
429
  * ORIENT — read the feature, strike what must never reach a planning child, and
433
430
  * derive the requirement ledger. Depends on nothing but the feature and the tree,
@@ -566,6 +563,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
566
563
  generate: hint => deps.runChild('auto-clarify', 'read', prependHint(hint, AUTO_CLARIFY_PROMPT(featureForModel, transcript.forGenerator()))),
567
564
  formatHint: PLAN_FORMAT_HINT,
568
565
  rules: CLARIFY_QUALITY_RULES,
566
+ topics: [{ id: PLAN_SHAPE_TOPIC, match: isPlanShapeQuestion }],
569
567
  cap: MAX_CLARIFY_QUESTIONS,
570
568
  log: msg => logPlanDebug(cwd, `clarify: ${msg}`)
571
569
  });
@@ -589,6 +587,10 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
589
587
  if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
590
588
  logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
591
589
  transcript.add('host-set', plainQ, PLAN_SHAPE_ANSWER);
590
+ // The transcript entry alone never stopped the re-draw: the generator
591
+ // is stateless and reworded the same fork (often into the plural),
592
+ // which then reached the user or the triage as if it were open.
593
+ source.settle(PLAN_SHAPE_TOPIC);
592
594
  continue;
593
595
  }
594
596
  // Answer-side triage (grill parity): if the inlined spec already settles
@@ -658,7 +660,7 @@ export async function decomposePlan(cwd, deps, oriented, clarifications) {
658
660
  logPlanDebug(cwd, `decompose fidelity: ${plan.sourced}/${plan.titles.length} titles cited a grounded source; `
659
661
  + `${plan.restored.length} restoration(s)`
660
662
  + plan.restored
661
- .map(r => ` [task ${r.index + 1}: ${r.fragments.join(', ')}]`)
663
+ .map(r => ` [task ${r.index + 1}: ${renderFragments(r.fragments)}]`)
662
664
  .join(''));
663
665
  }
664
666
  // Batch-test ban (item 6): drop or scope a whole-project "write all the
@@ -777,7 +779,12 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
777
779
  rethrowIfCancelled(err);
778
780
  verdict = null;
779
781
  }
780
- const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
782
+ // An `unparseable` verdict IS a verdict of INCOMPLETE — it just names no
783
+ // area. Reading it as "nothing missing" shipped a plan the judge had ruled
784
+ // incomplete; the host names the gap instead so the round still happens.
785
+ const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing
786
+ : verdict?.kind === 'unparseable' ? [UNNAMED_COVERAGE_GAP]
787
+ : [];
781
788
  let acc = null;
782
789
  // The monotonic guard's owned-set is grounded DETERMINISTICALLY in
783
790
  // requirement↔title token overlap — NOT the coverage-map model's TASK
@@ -880,6 +887,10 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
880
887
  .slice(0, 200)}]`
881
888
  : ''));
882
889
  }
890
+ if (outcome.decision.terminal === true) {
891
+ logPlanDebug(cwd, 'decompose-coverage: TERMINAL — another round asks the same question');
892
+ break;
893
+ }
883
894
  }
884
895
  const best = rounds.best();
885
896
  const round = rounds.round();
@@ -1026,16 +1037,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
1026
1037
  ground: emitted => keepGroundedScripts(emitted, featureForModel),
1027
1038
  append: appendDeclaredScripts
1028
1039
  });
1029
- // Persist the TASK-MAPPED requirements keyed by the (spec-ref-attached) title
1030
- // each task will carry. With only cross-cutting entries travelling, the
1031
- // mapped ones shape the title list and then vanish, and a task can narrow a
1032
- // requirement out of its own spec with nothing to stop it.
1040
+ // Persist the TASK-MAPPED requirements under the plan key of the entry each
1041
+ // was mapped to the same key `buildAutoBody` is about to write onto the
1042
+ // checkbox line, so the two sides of the join are minted from one index. With
1043
+ // only cross-cutting entries travelling, the mapped ones shape the title list
1044
+ // and then vanish, and a task can narrow a requirement out of its own spec
1045
+ // with nothing to stop it.
1033
1046
  // Inert until the owned-requirements injection is wired into the phase
1034
1047
  // prompts; recorded regardless so the plan's mapping is auditable per run.
1035
1048
  if (best.accounting && best.accounting.mapped.length > 0) {
1036
1049
  await writeOwnedRequirements(cwd, best.accounting.mapped
1037
1050
  .filter(m => m.task >= 1 && m.task <= titles.length)
1038
- .map(m => ({ quote: m.req.quote, anchor: m.req.anchor, title: titles[m.task - 1] })));
1051
+ .map(m => ({
1052
+ quote: m.req.quote,
1053
+ anchor: m.req.anchor,
1054
+ key: planKeyAt(m.task - 1),
1055
+ title: titles[m.task - 1]
1056
+ })));
1039
1057
  }
1040
1058
  // persist
1041
1059
  const id = await allocateAutoId(cwd);
@@ -1276,9 +1294,28 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1276
1294
  return;
1277
1295
  }
1278
1296
  await updateTaskFrontMatter(cwd, id, { state: 'completed' });
1297
+ // Retention runs HERE and nowhere else: a completed run is the one
1298
+ // moment the state dir is quiet and this process still knows which
1299
+ // repo it was serving. A failed or cancelled run keeps its logs —
1300
+ // those are the ones somebody is about to read.
1301
+ await pruneRunLogs(cwd);
1279
1302
  announceDone(active, stage.message, stage.level);
1280
1303
  return;
1281
1304
  }
1305
+ // ABANDON an entry that has spent its attempt budget. A task that
1306
+ // crashes before it can be checked off is re-entered by every resume,
1307
+ // for as long as anyone keeps resuming — the plan never advances and
1308
+ // the entry never converges. Give up on THIS entry, write down what was
1309
+ // left unfinished, and let the rest of the plan run.
1310
+ if ((next.attempts ?? 0) >= ENTRY_ATTEMPT_BUDGET) {
1311
+ await recordDebt(cwd, next.producedId ?? id, `"${next.title}" was abandoned after ${next.attempts} attempts`
1312
+ + `${next.lastEnd ? ` (last ended: ${next.lastEnd})` : ''}`
1313
+ + ' — no verified artifact; the plan moved on without it', 'abandoned');
1314
+ await checkOffTask(cwd, id, next.index, next.producedId ?? '', next.title);
1315
+ notifyBoth(active, `${id}: abandoned "${next.title}" after ${next.attempts} attempts — `
1316
+ + 'recorded as debt; continuing with the rest of the plan.', 'warning');
1317
+ continue;
1318
+ }
1282
1319
  // REFUSE to start on a conflicted tree: an unmerged index dooms every
1283
1320
  // commit ahead and a `git add -A` would silently mis-resolve it.
1284
1321
  const unmerged = deps.unmergedPaths ? await deps.unmergedPaths(cwd) : [];
@@ -1323,6 +1360,24 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1323
1360
  if (checkpoint.committed) {
1324
1361
  notifyRun(active, `${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
1325
1362
  }
1363
+ // REPO-HEALTH BASELINE, taken here because the checkpoint above just made
1364
+ // the tree clean: what the project's own statics say now is what this task
1365
+ // INHERITED, and the verify gate attributes a red check against it instead
1366
+ // of failing the task for a sibling's defect (health-baseline.ts). The
1367
+ // inner task file does not exist yet, so the result is handed to the
1368
+ // runner, which writes the section once its id is allocated.
1369
+ //
1370
+ // A RED baseline is also the one moment every way red enters the tree
1371
+ // is visible before anything builds on it — an accepted regression, a
1372
+ // leftover the checkpoint just committed, a repo red at run start — so
1373
+ // it is where the repair is scheduled (health-repair.ts).
1374
+ const baseline = await baselineAtCheckpoint(active, cwd, resumeId, next.title, deps);
1375
+ if (baseline
1376
+ && !baseline.outcome.ok
1377
+ && (await spliceHealthRepair(cwd, id, next, entries, baseline.outcome, active, deps))) {
1378
+ continue;
1379
+ }
1380
+ const healthBaseline = baseline ? { healthBaseline: () => Promise.resolve(baseline) } : {};
1326
1381
  // Stash ref before the task: compared after the gates so a stash pushed
1327
1382
  // during the task (impl model or any child) and left behind is called
1328
1383
  // out instead of silently waiting to detonate in a later task.
@@ -1336,14 +1391,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1336
1391
  announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
1337
1392
  return;
1338
1393
  }
1394
+ // Counted BEFORE the run, not after it: a run that takes pi down
1395
+ // with it would never reach an after-the-fact increment, and an
1396
+ // attempt nobody counted is exactly the one the budget exists to
1397
+ // bound.
1398
+ await beginTaskAttempt(cwd, id, next.index);
1339
1399
  const res = await deps.runTask(active, cwd, next.title, {
1340
1400
  resumeId,
1401
+ ...healthBaseline,
1341
1402
  // Fence this step against re-expanding the whole referenced spec:
1342
1403
  // name the sibling steps so refine bounds this step's slice. Only
1343
1404
  // matters when refine runs fresh (a resumed task past refine ignores
1344
1405
  // it), but always supplied so a resume that restarts at refine is
1345
1406
  // fenced too.
1346
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1407
+ planContext: buildStepFence(entries, next.index),
1408
+ ...(next.key !== undefined && { planKey: next.key }),
1347
1409
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1348
1410
  });
1349
1411
  active = res.ctx ?? active;
@@ -1356,6 +1418,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1356
1418
  // only the wording is this command's.
1357
1419
  if (!runSucceeded(res.end)) {
1358
1420
  const policy = RUN_END_POLICY[res.end.kind];
1421
+ // The ending goes on the ENTRY, where the next resume reads it:
1422
+ // the inner task file's own state cannot distinguish the entry
1423
+ // that keeps faulting from the one a user stopped once.
1424
+ await recordTaskEnd(cwd, id, next.index, res.end.kind);
1359
1425
  // Demote the INNER task file: it reads `completed` from
1360
1426
  // spec-handoff, and leaving it that way is how a failed run's task
1361
1427
  // file claims success after the run failed.
@@ -1396,7 +1462,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1396
1462
  title: next.title,
1397
1463
  tag: id,
1398
1464
  // Fence an AUTOFIX re-run against re-expanding the whole spec.
1399
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1465
+ planContext: buildStepFence(entries, next.index),
1400
1466
  // res.ok === true means runner.run() completed, so res.taskId is the
1401
1467
  // allocated TASK_NNNN id (never empty here). The parent task-list
1402
1468
  // check-off runs after verify passes/accepts and before the commit,
@@ -1481,10 +1547,12 @@ async function handleTaskAuto(args, ctx) {
1481
1547
  // and the ordinary command path cannot reach us.
1482
1548
  try {
1483
1549
  await withRun(ctx, { onCancel: terminalCancel }, async () => {
1484
- // Stamp a fresh per-run research-cache id BEFORE planning so enrichment and
1550
+ // Stamp the per-run research-cache id BEFORE planning so enrichment and
1485
1551
  // every task's research phase share one run's cache; disabled ⇒ clears any token a
1486
- // prior run left, so nothing is cached.
1487
- configureResearchRun(getConfig().researchCache);
1552
+ // prior run left, so nothing is cached. It is the RUN's own id (the bracket
1553
+ // opened the run context one statement ago), so the cache file and the
1554
+ // env-notes ledger name this run the same way.
1555
+ configureResearchRun(getConfig().researchCache, currentRunContext(cwd).runId);
1488
1556
  const abort = new AbortController();
1489
1557
  const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
1490
1558
  let id;
@@ -1557,6 +1625,10 @@ async function handleTaskAutoResume(args, ctx) {
1557
1625
  // working cache, and a whole-file freshness gate can never hold on a
1558
1626
  // greenfield run that installs packages as it goes — so invalidation is
1559
1627
  // per entry. See resumeResearchRun.
1628
+ //
1629
+ // The RUN context keeps its own fresh id, deliberately: an external doc
1630
+ // digest does not go stale over a pause, and an environment fact measured
1631
+ // against the tree before it does.
1560
1632
  const research = await resumeResearchRun(cwd, getConfig().researchCache);
1561
1633
  if (research.reused) {
1562
1634
  logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
@@ -16,6 +16,7 @@ import type { docsRaw, docsFocused } from '../workers/docs-core.js';
16
16
  import type { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
17
17
  import type { npmVersionLookup } from '../workers/npm-version.js';
18
18
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
19
+ import type { RunContext } from './run-context.js';
19
20
  export declare const USER_CANCELLED = "__user_cancelled__";
20
21
  /**
21
22
  * Why a phase child did not answer.
@@ -65,6 +66,13 @@ export interface PhaseDeps {
65
66
  cwd: string;
66
67
  taskId: string;
67
68
  signal: AbortSignal;
69
+ /**
70
+ * What is true of the RUN rather than of this task: the inventory, the
71
+ * orientation core, the manifest deps, the ecosystems, the verified tooling.
72
+ * Absent → the phase builds a throwaway context for itself, which is the old
73
+ * per-task behaviour (see run-context.ts).
74
+ */
75
+ runContext?: RunContext;
68
76
  onChildOutput?: (line: string) => void;
69
77
  onContextUsage?: (snapshot: ContextSnapshot) => void;
70
78
  /**
@@ -215,6 +223,16 @@ export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: stri
215
223
  */
216
224
  export declare function formatDegradeHint(hit: LoopHit): string;
217
225
  export declare function prependHint(hint: string | null, prompt: string): string;
226
+ /**
227
+ * Append one line per loop kill to the task file's `loop events` section.
228
+ *
229
+ * Best-effort by contract: it runs for every phase child AND every research
230
+ * worker, and not every caller
231
+ * owns a task file on disk (a scripted harness, a bare unit deps bag). A trail
232
+ * that cannot be written must cost the phase nothing — the loop kill itself is
233
+ * already reported through the debug log and the thrown error.
234
+ */
235
+ export declare function appendLoopEvents(cwd: string, taskId: string, phase: string, r: RunWorkerResult): Promise<void>;
218
236
  /**
219
237
  * Run a child up to twice; the second attempt gets `emphasized=true` to escalate
220
238
  * the prompt. On success, return the validator's value; on two failures, throw
@@ -11,9 +11,9 @@
11
11
  import { runWorker } from '../workers/pi-worker-core.js';
12
12
  import { classifyWorkerFailure } from '../workers/worker-failure.js';
13
13
  import { isFatalKill } from '../workers/worker-kill.js';
14
- import { MAX_LOOP_RESTARTS } from './loop-detector.js';
14
+ import { describeLoopHit, MAX_LOOP_RESTARTS } from './loop-detector.js';
15
15
  import { MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
16
- import { readSection, setTaskSection } from './task-io.js';
16
+ import { mergeTaskSection } from './task-io.js';
17
17
  import { streamStallCause } from '../shared/stream-watchdog.js';
18
18
  import { getConfig } from '../config/config.js';
19
19
  import { groupChildArgs, groupWindow } from '../config/group-args.js';
@@ -238,15 +238,15 @@ export function prependHint(hint, prompt) {
238
238
  /**
239
239
  * Append one line per loop kill to the task file's `loop events` section.
240
240
  *
241
- * Best-effort by contract: it runs for EVERY phase child, and not every caller
241
+ * Best-effort by contract: it runs for every phase child AND every research
242
+ * worker, and not every caller
242
243
  * owns a task file on disk (a scripted harness, a bare unit deps bag). A trail
243
244
  * that cannot be written must cost the phase nothing — the loop kill itself is
244
245
  * already reported through the debug log and the thrown error.
245
246
  */
246
- async function appendLoopEvents(cwd, taskId, phase, r) {
247
+ export async function appendLoopEvents(cwd, taskId, phase, r) {
247
248
  const line = (hit, strike, outcome) => `- ${new Date().toISOString()} ${phase} strike ${strike}/${MAX_LOOP_RESTARTS + 1} `
248
- + `${hit.call.name}(${JSON.stringify(hit.call.args)}) ×${hit.count} in last `
249
- + `${hit.windowSize} calls → ${outcome}`;
249
+ + `${describeLoopHit(hit)} ${outcome}`;
250
250
  const lines = r.restarts.flatMap(x => x.loopHit ?
251
251
  [
252
252
  line(x.loopHit, x.attempt, x.rescue ? 'degraded — no-tools final attempt' : 'restarted with hint')
@@ -257,8 +257,7 @@ async function appendLoopEvents(cwd, taskId, phase, r) {
257
257
  if (lines.length === 0)
258
258
  return;
259
259
  try {
260
- const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
261
- await setTaskSection(cwd, taskId, 'loop events', [existing, ...lines].filter(Boolean).join('\n'));
260
+ await mergeTaskSection(cwd, taskId, 'loop events', existing => [existing ?? '', ...lines].filter(Boolean).join('\n'));
262
261
  }
263
262
  catch {
264
263
  /* best-effort: a trail is never worth failing a phase for */
@@ -172,6 +172,10 @@ export type VerifyRerunOutcome = {
172
172
  outcome: 'gap';
173
173
  detail: string;
174
174
  };
175
+ /** The command word of a shell line, past any leading `VAR=value` assignments.
176
+ * Exported for gate-evidence, which spawns the same shape of line and must
177
+ * resolve the same runner against the same stripped PATH. */
178
+ export declare function leadingBin(line: string): string | null;
175
179
  /**
176
180
  * Re-run one VERIFY-block command line under the gate's existing
177
181
  * env-gap contract, so a debt whose reason NAMES that command can be closed by the
@@ -308,8 +308,10 @@ export function classifyCommandRun(run, gapPatterns = [], opts = {}) {
308
308
  tail: outputTail(run.stdout, run.stderr)
309
309
  };
310
310
  }
311
- /** The command word of a shell line, past any leading `VAR=value` assignments. */
312
- function leadingBin(line) {
311
+ /** The command word of a shell line, past any leading `VAR=value` assignments.
312
+ * Exported for gate-evidence, which spawns the same shape of line and must
313
+ * resolve the same runner against the same stripped PATH. */
314
+ export function leadingBin(line) {
313
315
  for (const tok of line.trim().split(/\s+/)) {
314
316
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
315
317
  continue;