@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
@@ -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, 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, 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';
@@ -36,16 +42,18 @@ import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
36
42
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
37
43
  import { spawnCommand } from './command-run.js';
38
44
  import { getConfig } from '../config/config.js';
39
- import { debugLogLevel, shouldLogDebug } from './debug-log.js';
45
+ import { debugLogLevel, sanitizeDebugLine, shouldLogDebug } from './debug-log.js';
46
+ import { pruneRunLogs, runLogPath } from './state-dir.js';
40
47
  import { isYoloMode, yoloPickAnswer } from './yolo.js';
41
48
  import { QaTranscript, CLARIFY_QA_POLICY } from './qa-transcript.js';
42
49
  import { makeQuestionSource } from './question-source.js';
43
50
  import { CoverageLedger } from './plan-rounds.js';
44
51
  import { CLARIFY_QUALITY_RULES, PLAN_FORMAT_HINT } from './plan-session.js';
45
52
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
53
+ import { currentRunContext } from './run-context.js';
46
54
  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';
55
+ import { reconcileTitleSources, renderFragments } from './decompose-fidelity.js';
56
+ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER, PLAN_SHAPE_TOPIC } from './decompose-granularity.js';
49
57
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
50
58
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
51
59
  import { groundedCoverage } from './coverage-loop.js';
@@ -126,23 +134,6 @@ function suspectPlanHint(count) {
126
134
  + 'list for the ENTIRE feature, covering every part of the spec end to end. '
127
135
  + 'Output every task, one "- [ ] " line each, nothing else.]');
128
136
  }
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
137
  /**
147
138
  * Every plan-debug write not yet on disk, chained.
148
139
  *
@@ -161,9 +152,9 @@ export function flushPlanDebug() {
161
152
  * Fire-and-forget debug line for the PLAN phase (clarify/decompose). It is the
162
153
  * only trail that phase has: planning runs before any task file exists, so there
163
154
  * 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.
155
+ * `plan-debug.log` in the run's state dir, beside every other log of the run, so
156
+ * one grep still finds them all. Never throws — the mkdir and the append are both
157
+ * best-effort.
167
158
  *
168
159
  * Every call site records a plan DECISION (how many titles a round produced,
169
160
  * whether a retry was adopted, which clarify answer was auto-resolved), so all of
@@ -173,11 +164,11 @@ export function flushPlanDebug() {
173
164
  function logPlanDebug(cwd, msg) {
174
165
  if (!shouldLogDebug('event', debugLogLevel()))
175
166
  return;
176
- const line = `${new Date().toISOString()} ${msg}\n`;
177
- const dir = tasksDir(cwd);
167
+ const line = `${new Date().toISOString()} ${sanitizeDebugLine(msg)}\n`;
168
+ const file = runLogPath(cwd, 'plan-debug.log');
178
169
  planDebugChain = planDebugChain
179
- .then(() => fsp.mkdir(dir, { recursive: true }))
180
- .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
170
+ .then(() => fsp.mkdir(path.dirname(file), { recursive: true }))
171
+ .then(() => fsp.appendFile(file, line))
181
172
  .catch(() => { });
182
173
  }
183
174
  /**
@@ -240,61 +231,6 @@ async function triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBl
240
231
  return null;
241
232
  }
242
233
  }
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
234
  /** A trailing "[decisions: …]" clause decompose may attach to a task line. */
299
235
  const DECISIONS_RE = /\s*\[decisions:\s*(.+?)\]\s*$/i;
300
236
  /**
@@ -342,29 +278,37 @@ export function attachSpecRefs(titles, refs) {
342
278
  *
343
279
  * The plan listing strips the threaded "| decisions … | spec …" tail from each
344
280
  * 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
281
+ * and it marks the current one `[this]` — both confirmed by building a fence
346
282
  * over threaded titles. The authoritative spec ref still rides on THIS step's own
347
283
  * title via attachSpecRefs.
284
+ *
285
+ * Every sibling used to read as unbuilt, which is a lie about the first half of
286
+ * any plan past step one: a step told its predecessors are "done in later runs"
287
+ * re-scaffolds what is already in the tree. The entries carry their own done
288
+ * state, so the listing states it.
348
289
  */
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}`;
290
+ export function buildScopeFence(entries, currentIndex) {
291
+ const n = entries.length;
292
+ const listing = entries
293
+ .map((e, i) => {
294
+ const head = e.title.split(' | ')[0].trim();
295
+ const marker = i === currentIndex ? 'this'
296
+ : e.done ? 'done'
297
+ : 'later';
298
+ return `[${i + 1}] [${marker}] ${head}`;
356
299
  })
357
300
  .join('\n');
358
301
  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`
302
+ + `Each step below is implemented by its OWN separate run: a [done] step is ALREADY BUILT and `
303
+ + `in the tree (build on it never rebuild, duplicate or re-scaffold it), a [later] step is `
304
+ + `not built yet and is NOT your job. Implement ONLY the slice named in "Task" below.\n\n`
361
305
  + `The design/spec document the task references describes the WHOLE system across all ${n} `
362
306
  + `steps. Read it to get exact names, types, and signatures for YOUR step and to understand `
363
307
  + `how your step fits — but DO NOT design, scaffold, schema, route, page, query, component, or `
364
308
  + `test anything that belongs to another step listed below. Your GOAL / CONSTRAINTS / `
365
309
  + `KNOWN-UNKNOWNS must cover only THIS step's slice. Do not pull in tables, endpoints, pages, `
366
310
  + `components, or flows owned by a later step.\n\n`
367
- + `The full plan (these run separately — do NOT implement them here):\n${listing}`);
311
+ + `The full plan (these run separately — do NOT implement the [later] ones here):\n${listing}`);
368
312
  }
369
313
  /**
370
314
  * The scope fence for step `currentIndex`, plus the REPAIR fence when that step is
@@ -377,12 +321,13 @@ export function buildScopeFence(titles, currentIndex) {
377
321
  * `bun run test` yields it while a plain-prose defect yields undefined and only the
378
322
  * file pin applies.
379
323
  */
380
- function buildStepFence(titles, currentIndex) {
381
- const base = buildScopeFence(titles, currentIndex);
382
- const repairFile = parseRepairTitleFile(titles[currentIndex] ?? '');
324
+ export function buildStepFence(entries, currentIndex) {
325
+ const base = buildScopeFence(entries, currentIndex);
326
+ const title = entries[currentIndex]?.title ?? '';
327
+ const repairFile = parseRepairTitleFile(title);
383
328
  if (!repairFile)
384
329
  return base;
385
- return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(titles[currentIndex] ?? ''))}`;
330
+ return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(title))}`;
386
331
  }
387
332
  /**
388
333
  * Drain the gate's root-cause repair queue into the running plan: one scoped
@@ -566,6 +511,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
566
511
  generate: hint => deps.runChild('auto-clarify', 'read', prependHint(hint, AUTO_CLARIFY_PROMPT(featureForModel, transcript.forGenerator()))),
567
512
  formatHint: PLAN_FORMAT_HINT,
568
513
  rules: CLARIFY_QUALITY_RULES,
514
+ topics: [{ id: PLAN_SHAPE_TOPIC, match: isPlanShapeQuestion }],
569
515
  cap: MAX_CLARIFY_QUESTIONS,
570
516
  log: msg => logPlanDebug(cwd, `clarify: ${msg}`)
571
517
  });
@@ -589,6 +535,10 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
589
535
  if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
590
536
  logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
591
537
  transcript.add('host-set', plainQ, PLAN_SHAPE_ANSWER);
538
+ // The transcript entry alone never stopped the re-draw: the generator
539
+ // is stateless and reworded the same fork (often into the plural),
540
+ // which then reached the user or the triage as if it were open.
541
+ source.settle(PLAN_SHAPE_TOPIC);
592
542
  continue;
593
543
  }
594
544
  // Answer-side triage (grill parity): if the inlined spec already settles
@@ -658,7 +608,7 @@ export async function decomposePlan(cwd, deps, oriented, clarifications) {
658
608
  logPlanDebug(cwd, `decompose fidelity: ${plan.sourced}/${plan.titles.length} titles cited a grounded source; `
659
609
  + `${plan.restored.length} restoration(s)`
660
610
  + plan.restored
661
- .map(r => ` [task ${r.index + 1}: ${r.fragments.join(', ')}]`)
611
+ .map(r => ` [task ${r.index + 1}: ${renderFragments(r.fragments)}]`)
662
612
  .join(''));
663
613
  }
664
614
  // Batch-test ban (item 6): drop or scope a whole-project "write all the
@@ -777,7 +727,12 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
777
727
  rethrowIfCancelled(err);
778
728
  verdict = null;
779
729
  }
780
- const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
730
+ // An `unparseable` verdict IS a verdict of INCOMPLETE — it just names no
731
+ // area. Reading it as "nothing missing" shipped a plan the judge had ruled
732
+ // incomplete; the host names the gap instead so the round still happens.
733
+ const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing
734
+ : verdict?.kind === 'unparseable' ? [UNNAMED_COVERAGE_GAP]
735
+ : [];
781
736
  let acc = null;
782
737
  // The monotonic guard's owned-set is grounded DETERMINISTICALLY in
783
738
  // requirement↔title token overlap — NOT the coverage-map model's TASK
@@ -880,6 +835,10 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
880
835
  .slice(0, 200)}]`
881
836
  : ''));
882
837
  }
838
+ if (outcome.decision.terminal === true) {
839
+ logPlanDebug(cwd, 'decompose-coverage: TERMINAL — another round asks the same question');
840
+ break;
841
+ }
883
842
  }
884
843
  const best = rounds.best();
885
844
  const round = rounds.round();
@@ -1026,16 +985,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
1026
985
  ground: emitted => keepGroundedScripts(emitted, featureForModel),
1027
986
  append: appendDeclaredScripts
1028
987
  });
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.
988
+ // Persist the TASK-MAPPED requirements under the plan key of the entry each
989
+ // was mapped to the same key `buildAutoBody` is about to write onto the
990
+ // checkbox line, so the two sides of the join are minted from one index. With
991
+ // only cross-cutting entries travelling, the mapped ones shape the title list
992
+ // and then vanish, and a task can narrow a requirement out of its own spec
993
+ // with nothing to stop it.
1033
994
  // Inert until the owned-requirements injection is wired into the phase
1034
995
  // prompts; recorded regardless so the plan's mapping is auditable per run.
1035
996
  if (best.accounting && best.accounting.mapped.length > 0) {
1036
997
  await writeOwnedRequirements(cwd, best.accounting.mapped
1037
998
  .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] })));
999
+ .map(m => ({
1000
+ quote: m.req.quote,
1001
+ anchor: m.req.anchor,
1002
+ key: planKeyAt(m.task - 1),
1003
+ title: titles[m.task - 1]
1004
+ })));
1039
1005
  }
1040
1006
  // persist
1041
1007
  const id = await allocateAutoId(cwd);
@@ -1276,9 +1242,28 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1276
1242
  return;
1277
1243
  }
1278
1244
  await updateTaskFrontMatter(cwd, id, { state: 'completed' });
1245
+ // Retention runs HERE and nowhere else: a completed run is the one
1246
+ // moment the state dir is quiet and this process still knows which
1247
+ // repo it was serving. A failed or cancelled run keeps its logs —
1248
+ // those are the ones somebody is about to read.
1249
+ await pruneRunLogs(cwd);
1279
1250
  announceDone(active, stage.message, stage.level);
1280
1251
  return;
1281
1252
  }
1253
+ // ABANDON an entry that has spent its attempt budget. A task that
1254
+ // crashes before it can be checked off is re-entered by every resume,
1255
+ // for as long as anyone keeps resuming — the plan never advances and
1256
+ // the entry never converges. Give up on THIS entry, write down what was
1257
+ // left unfinished, and let the rest of the plan run.
1258
+ if ((next.attempts ?? 0) >= ENTRY_ATTEMPT_BUDGET) {
1259
+ await recordDebt(cwd, next.producedId ?? id, `"${next.title}" was abandoned after ${next.attempts} attempts`
1260
+ + `${next.lastEnd ? ` (last ended: ${next.lastEnd})` : ''}`
1261
+ + ' — no verified artifact; the plan moved on without it', 'abandoned');
1262
+ await checkOffTask(cwd, id, next.index, next.producedId ?? '', next.title);
1263
+ notifyBoth(active, `${id}: abandoned "${next.title}" after ${next.attempts} attempts — `
1264
+ + 'recorded as debt; continuing with the rest of the plan.', 'warning');
1265
+ continue;
1266
+ }
1282
1267
  // REFUSE to start on a conflicted tree: an unmerged index dooms every
1283
1268
  // commit ahead and a `git add -A` would silently mis-resolve it.
1284
1269
  const unmerged = deps.unmergedPaths ? await deps.unmergedPaths(cwd) : [];
@@ -1323,6 +1308,18 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1323
1308
  if (checkpoint.committed) {
1324
1309
  notifyRun(active, `${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
1325
1310
  }
1311
+ // REPO-HEALTH BASELINE, taken here because the checkpoint above just made
1312
+ // the tree clean: what the project's own statics say now is what this task
1313
+ // INHERITED, and the verify gate attributes a red check against it instead
1314
+ // of failing the task for a sibling's defect (health-baseline.ts). The
1315
+ // inner task file does not exist yet, so the capture is handed to the
1316
+ // runner, which writes the section once its id is allocated.
1317
+ const capture = deps.captureHealthBaseline;
1318
+ const healthBaseline = capture ?
1319
+ {
1320
+ healthBaseline: (taskCtx) => capture(taskCtx, cwd, next.title)
1321
+ }
1322
+ : {};
1326
1323
  // Stash ref before the task: compared after the gates so a stash pushed
1327
1324
  // during the task (impl model or any child) and left behind is called
1328
1325
  // out instead of silently waiting to detonate in a later task.
@@ -1336,14 +1333,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1336
1333
  announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
1337
1334
  return;
1338
1335
  }
1336
+ // Counted BEFORE the run, not after it: a run that takes pi down
1337
+ // with it would never reach an after-the-fact increment, and an
1338
+ // attempt nobody counted is exactly the one the budget exists to
1339
+ // bound.
1340
+ await beginTaskAttempt(cwd, id, next.index);
1339
1341
  const res = await deps.runTask(active, cwd, next.title, {
1340
1342
  resumeId,
1343
+ ...healthBaseline,
1341
1344
  // Fence this step against re-expanding the whole referenced spec:
1342
1345
  // name the sibling steps so refine bounds this step's slice. Only
1343
1346
  // matters when refine runs fresh (a resumed task past refine ignores
1344
1347
  // it), but always supplied so a resume that restarts at refine is
1345
1348
  // fenced too.
1346
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1349
+ planContext: buildStepFence(entries, next.index),
1350
+ ...(next.key !== undefined && { planKey: next.key }),
1347
1351
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1348
1352
  });
1349
1353
  active = res.ctx ?? active;
@@ -1356,6 +1360,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1356
1360
  // only the wording is this command's.
1357
1361
  if (!runSucceeded(res.end)) {
1358
1362
  const policy = RUN_END_POLICY[res.end.kind];
1363
+ // The ending goes on the ENTRY, where the next resume reads it:
1364
+ // the inner task file's own state cannot distinguish the entry
1365
+ // that keeps faulting from the one a user stopped once.
1366
+ await recordTaskEnd(cwd, id, next.index, res.end.kind);
1359
1367
  // Demote the INNER task file: it reads `completed` from
1360
1368
  // spec-handoff, and leaving it that way is how a failed run's task
1361
1369
  // file claims success after the run failed.
@@ -1396,7 +1404,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1396
1404
  title: next.title,
1397
1405
  tag: id,
1398
1406
  // Fence an AUTOFIX re-run against re-expanding the whole spec.
1399
- planContext: buildStepFence(entries.map(e => e.title), next.index),
1407
+ planContext: buildStepFence(entries, next.index),
1400
1408
  // res.ok === true means runner.run() completed, so res.taskId is the
1401
1409
  // allocated TASK_NNNN id (never empty here). The parent task-list
1402
1410
  // check-off runs after verify passes/accepts and before the commit,
@@ -1481,10 +1489,12 @@ async function handleTaskAuto(args, ctx) {
1481
1489
  // and the ordinary command path cannot reach us.
1482
1490
  try {
1483
1491
  await withRun(ctx, { onCancel: terminalCancel }, async () => {
1484
- // Stamp a fresh per-run research-cache id BEFORE planning so enrichment and
1492
+ // Stamp the per-run research-cache id BEFORE planning so enrichment and
1485
1493
  // 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);
1494
+ // prior run left, so nothing is cached. It is the RUN's own id (the bracket
1495
+ // opened the run context one statement ago), so the cache file and the
1496
+ // env-notes ledger name this run the same way.
1497
+ configureResearchRun(getConfig().researchCache, currentRunContext(cwd).runId);
1488
1498
  const abort = new AbortController();
1489
1499
  const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
1490
1500
  let id;
@@ -1557,6 +1567,10 @@ async function handleTaskAutoResume(args, ctx) {
1557
1567
  // working cache, and a whole-file freshness gate can never hold on a
1558
1568
  // greenfield run that installs packages as it goes — so invalidation is
1559
1569
  // per entry. See resumeResearchRun.
1570
+ //
1571
+ // The RUN context keeps its own fresh id, deliberately: an external doc
1572
+ // digest does not go stale over a pause, and an environment fact measured
1573
+ // against the tree before it does.
1560
1574
  const research = await resumeResearchRun(cwd, getConfig().researchCache);
1561
1575
  if (research.reused) {
1562
1576
  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;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * constraint-policy — how much a constraint's PROVENANCE is worth, as one table
3
+ * both gate children render from.
4
+ *
5
+ * The verify child was told a spec constraint is unwaivable, full stop. The
6
+ * resolution judge was told to judge by function and treat over-literal wording
7
+ * as a false alarm. Neither was wrong on its own; together they made the gate's
8
+ * answer depend on which child happened to speak. 0043 is what that costs: a
9
+ * constraint a grill AUTO-ANSWER invented ("do NOT touch `test/ct/template/
10
+ * index.html`") was held as unwaivable as one the user typed, and the task failed
11
+ * on it.
12
+ *
13
+ * A constraint a human stated, or one the refined task carried in, BINDS. A
14
+ * constraint some automation produced — an auto-answer, a YOLO pick, an untagged
15
+ * line the composer wrote itself — is ADVISORY: it sharpens the work, and it
16
+ * fails a gate only when an ACCEPTANCE bullet also fails. Acceptance is the bar;
17
+ * an advisory constraint is a hint about how to clear it.
18
+ *
19
+ * One table, one renderer, two call sites (verify rule 4b, the judge's rules).
20
+ */
21
+ import type { Constraint, ConstraintProvenance } from './spec-model.js';
22
+ export type ConstraintWeight = 'binding' | 'advisory';
23
+ /**
24
+ * What each provenance is worth. A new `ConstraintProvenance` member is a compile
25
+ * error until it declares its weight — which is the point, since the cheapest way
26
+ * back to 0043 is a new automated kind quietly defaulting to `binding`.
27
+ *
28
+ * `accepted` binds because a human read the recommendation and took it; `typed`
29
+ * and `host-set` for the same reason at either end (a person's own words, and a
30
+ * decision the host makes deterministically rather than leaving to a model).
31
+ * Everything else was produced by automation reasoning about the task, and
32
+ * automation may not mint an unwaivable rule.
33
+ */
34
+ export declare const CONSTRAINT_WEIGHTS: Record<ConstraintProvenance, ConstraintWeight>;
35
+ export declare function constraintWeight(provenance: ConstraintProvenance): ConstraintWeight;
36
+ /** The weight marker a child sees on a constraint line, and probes match on. */
37
+ export declare function weightTag(provenance: ConstraintProvenance): string;
38
+ /** Does this collection of findings name at least one BINDING constraint? */
39
+ export declare function anyBinding(findings: readonly string[]): boolean;
40
+ /**
41
+ * The spec's CONSTRAINTS, annotated with each line's weight, for a child that
42
+ * must judge them. Returns null when the spec states none — the caller then emits
43
+ * no block rather than an empty heading.
44
+ */
45
+ export declare function annotateConstraints(constraints: readonly Constraint[]): string | null;
46
+ /**
47
+ * The policy, as prompt lines. ONE renderer: the verify pass and the resolution
48
+ * judge cite the same paragraph, so the two children cannot hold opposite
49
+ * policies again. `heading` differs because the two prompts number their rules
50
+ * differently — that is the only thing a call site may vary.
51
+ */
52
+ export declare function renderConstraintPolicy(heading: string): string[];