@mjasnikovs/pi-task 0.18.15 → 0.18.17

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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/task/accept-debt.d.ts +28 -1
  3. package/dist/task/accept-debt.js +61 -3
  4. package/dist/task/auto-io.d.ts +4 -2
  5. package/dist/task/auto-io.js +6 -3
  6. package/dist/task/auto-orchestrator.d.ts +1 -0
  7. package/dist/task/auto-orchestrator.js +135 -19
  8. package/dist/task/auto-prompts.d.ts +5 -5
  9. package/dist/task/auto-prompts.js +9 -2
  10. package/dist/task/contracts.d.ts +8 -0
  11. package/dist/task/contracts.js +4 -2
  12. package/dist/task/decompose-fidelity.d.ts +47 -0
  13. package/dist/task/decompose-fidelity.js +132 -0
  14. package/dist/task/final-gate-fix.d.ts +22 -3
  15. package/dist/task/final-gate-fix.js +72 -7
  16. package/dist/task/final-gate.d.ts +48 -1
  17. package/dist/task/final-gate.js +182 -34
  18. package/dist/task/gate-deps.d.ts +7 -0
  19. package/dist/task/gate-deps.js +37 -1
  20. package/dist/task/launch-contract.d.ts +36 -1
  21. package/dist/task/launch-contract.js +83 -2
  22. package/dist/task/phases.d.ts +13 -1
  23. package/dist/task/phases.js +50 -11
  24. package/dist/task/prompts.js +2 -0
  25. package/dist/task/render-check.d.ts +42 -0
  26. package/dist/task/render-check.js +186 -0
  27. package/dist/task/requirements.d.ts +88 -0
  28. package/dist/task/requirements.js +334 -0
  29. package/dist/task/verify-reconcile.d.ts +36 -0
  30. package/dist/task/verify-reconcile.js +203 -0
  31. package/dist/task/write-guard.d.ts +52 -0
  32. package/dist/task/write-guard.js +112 -0
  33. package/package.json +1 -1
  34. package/dist/task/_ab.d.ts +0 -1
  35. package/dist/task/_ab.js +0 -68
  36. package/dist/task/task-file.d.ts +0 -14
  37. package/dist/task/task-file.js +0 -15
  38. package/dist/think-test/cli.d.ts +0 -1
  39. package/dist/think-test/cli.js +0 -98
  40. package/dist/think-test/client.d.ts +0 -26
  41. package/dist/think-test/client.js +0 -37
  42. package/dist/think-test/compressor.d.ts +0 -5
  43. package/dist/think-test/compressor.js +0 -25
  44. package/dist/think-test/judge.d.ts +0 -4
  45. package/dist/think-test/judge.js +0 -11
  46. package/dist/think-test/score.d.ts +0 -8
  47. package/dist/think-test/score.js +0 -22
  48. package/dist/think-test/serialize.d.ts +0 -19
  49. package/dist/think-test/serialize.js +0 -41
  50. package/dist/think-test/transcript.d.ts +0 -7
  51. package/dist/think-test/transcript.js +0 -41
  52. package/dist/think-test/transform.d.ts +0 -6
  53. package/dist/think-test/transform.js +0 -24
  54. package/dist/think-test/types.d.ts +0 -45
  55. package/dist/think-test/types.js +0 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-1504%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-1617%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -175,7 +175,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
175
175
 
176
176
  ```sh
177
177
  bun install
178
- bun test src/ # 1504 tests across 101 files
178
+ bun test src/ # 1617 tests across 106 files
179
179
  bun run lint # prettier + eslint + tsc --noEmit
180
180
  bun run build # tsc → dist/
181
181
  ```
@@ -14,6 +14,14 @@ export interface AcceptDebt {
14
14
  reason: string;
15
15
  /** Absent in legacy 2-field records → treated as 'accepted'. */
16
16
  origin?: DebtOrigin;
17
+ /**
18
+ * Set (never serialized) when the recorded reason CONFLICTS with the run itself:
19
+ * it asserts a file's existence is the failure, but that file is another task's
20
+ * committed deliverable (see annotateDebtConflicts). A conflicting debt is a
21
+ * PLAN defect to surface, never an instruction to act on — mx5 run 11's autofix
22
+ * child `rm`'d TASK_0008's verified admin page to satisfy exactly such a claim.
23
+ */
24
+ conflict?: string;
17
25
  }
18
26
  export declare function acceptDebtFile(cwd: string): string;
19
27
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
@@ -61,10 +69,29 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
61
69
  open: AcceptDebt[];
62
70
  resolved: AcceptDebt[];
63
71
  };
72
+ /**
73
+ * Extract the paths whose EXISTENCE the reason asserts as the failure — a path
74
+ * token immediately followed by "exists" / "still exists", or by "must/should not
75
+ * exist". Only this narrow shape qualifies: a reason that merely MENTIONS a path
76
+ * (a prohibition violation, a broken import) is an ordinary defect claim, not an
77
+ * existence assertion, and must never be flagged (run 11's T1/T7 debts name paths
78
+ * this way and are genuine).
79
+ */
80
+ export declare function extractExistenceClaims(reason: string): string[];
81
+ /**
82
+ * Annotate each debt whose existence-as-failure claim names a file INTRODUCED by a
83
+ * different task's commit (per `introducedBy`, typically git history) with a
84
+ * human-readable conflict statement. Everything degrades to no annotation: no
85
+ * existence claim, an unknown introducer, or the debt's own task introducing the
86
+ * file (then the claim is at least self-consistent) all pass through unchanged.
87
+ */
88
+ export declare function annotateDebtConflicts(debts: AcceptDebt[], introducedBy: (path: string) => string | null): AcceptDebt[];
64
89
  /**
65
90
  * A one-line-per-debt suffix appended to the final gate's report reason so the still
66
91
  * -open accepted defects surface in the gate outcome the user sees (and in the fail
67
- * picker). Empty when nothing is open.
92
+ * picker). Empty when nothing is open. The header states the records' status
93
+ * explicitly: they are claims for the HUMAN, re-stated by the gate, never
94
+ * instructions — and a conflicting claim carries its contradiction inline.
68
95
  */
69
96
  export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
70
97
  /** One-line provenance label for a debt, for the surfaced report. */
@@ -182,17 +182,75 @@ export function recheckAcceptDebts(debts, opts) {
182
182
  }
183
183
  return { open, resolved };
184
184
  }
185
+ // ─── Conflicting-claim classification (mx5 run 11) ──────────────────────────
186
+ //
187
+ // A recorded debt is a CLAIM about the tree, not an instruction to change it. The
188
+ // one class a deterministic check can prove SELF-CONTRADICTORY is an
189
+ // existence-as-failure claim ("<path> exists" fails the verify) whose named path is
190
+ // a DIFFERENT task's committed deliverable: the plan shipped the file on purpose
191
+ // and a sibling's verify indicts it — a plan defect (sibling scope-fence leaked
192
+ // into a verify assertion), not a fixable fault. Run 11's TASK_0009 debt read
193
+ // "src/client/pages/admin.tsx exists (introduced by prior TASK_0008…)" and the
194
+ // final-gate autofix child, seeded with it, ran `rm` on the verified page.
195
+ /** A path-like token: at least one directory separator, ending in a file name. */
196
+ const PATH_TOKEN_RE = /(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g;
197
+ /**
198
+ * Extract the paths whose EXISTENCE the reason asserts as the failure — a path
199
+ * token immediately followed by "exists" / "still exists", or by "must/should not
200
+ * exist". Only this narrow shape qualifies: a reason that merely MENTIONS a path
201
+ * (a prohibition violation, a broken import) is an ordinary defect claim, not an
202
+ * existence assertion, and must never be flagged (run 11's T1/T7 debts name paths
203
+ * this way and are genuine).
204
+ */
205
+ export function extractExistenceClaims(reason) {
206
+ const out = [];
207
+ for (const m of reason.matchAll(PATH_TOKEN_RE)) {
208
+ const after = reason.slice(m.index + m[0].length);
209
+ if (/^\s+(?:still\s+)?exists\b/.test(after)
210
+ || /^\s+(?:must|should)\s+not\s+exist\b/.test(after)) {
211
+ out.push(m[0]);
212
+ }
213
+ }
214
+ return [...new Set(out)];
215
+ }
216
+ /**
217
+ * Annotate each debt whose existence-as-failure claim names a file INTRODUCED by a
218
+ * different task's commit (per `introducedBy`, typically git history) with a
219
+ * human-readable conflict statement. Everything degrades to no annotation: no
220
+ * existence claim, an unknown introducer, or the debt's own task introducing the
221
+ * file (then the claim is at least self-consistent) all pass through unchanged.
222
+ */
223
+ export function annotateDebtConflicts(debts, introducedBy) {
224
+ return debts.map(d => {
225
+ for (const p of extractExistenceClaims(d.reason)) {
226
+ const producer = introducedBy(p);
227
+ if (producer && producer !== d.taskId) {
228
+ return {
229
+ ...d,
230
+ conflict: `\`${p}\` is ${producer}'s committed deliverable — this assertion `
231
+ + `contradicts a sibling task's shipped work (a plan defect, not a fix `
232
+ + `instruction); do NOT delete or rewrite that deliverable to satisfy it`
233
+ };
234
+ }
235
+ }
236
+ return d;
237
+ });
238
+ }
185
239
  /**
186
240
  * A one-line-per-debt suffix appended to the final gate's report reason so the still
187
241
  * -open accepted defects surface in the gate outcome the user sees (and in the fail
188
- * picker). Empty when nothing is open.
242
+ * picker). Empty when nothing is open. The header states the records' status
243
+ * explicitly: they are claims for the HUMAN, re-stated by the gate, never
244
+ * instructions — and a conflicting claim carries its contradiction inline.
189
245
  */
190
246
  export function buildAcceptDebtNote(open) {
191
247
  if (open.length === 0)
192
248
  return '';
193
- const items = open.map(d => `${d.taskId || '(unknown task)'} — ${describeDebt(d)}: ${d.reason}`);
249
+ const items = open.map(d => `${d.taskId || '(unknown task)'} — ${describeDebt(d)}: ${d.reason}`
250
+ + (d.conflict ? `\n ⚠ CONFLICTING CLAIM — ${d.conflict}` : ''));
194
251
  return (`\n\nUNRESOLVED VERIFY-FAIL DEBT still open (${open.length}) — `
195
- + 'these defects were recorded during the run and are NOT re-verified by this gate:\n'
252
+ + 'these defects were recorded during the run and are NOT re-verified by this gate. '
253
+ + 'They are records for a human decision, not instructions to edit code:\n'
196
254
  + items.map(i => ` - ${i}`).join('\n'));
197
255
  }
198
256
  /** One-line provenance label for a debt, for the surfaced report. */
@@ -20,8 +20,10 @@ export interface CoverageVerdict {
20
20
  export declare function parseCoverageVerdict(raw: string): CoverageVerdict | null;
21
21
  /** Parse the "## tasks" checkbox list. */
22
22
  export declare function parseTaskList(body: string): TaskEntry[];
23
- /** Build the initial AUTO-file body. */
24
- export declare function buildAutoBody(feature: string, clarifications: string, titles: string[]): string;
23
+ /** Build the initial AUTO-file body. `coverage` is the requirement-level
24
+ * accounting summary (goal A(c) — a durable, user-visible record of what was
25
+ * carried cross-cutting and what stayed unowned); '' omits the section. */
26
+ export declare function buildAutoBody(feature: string, clarifications: string, titles: string[], coverage?: string): string;
25
27
  /** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
26
28
  export declare function checkOffTask(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
27
29
  /**
@@ -91,12 +91,15 @@ export function parseTaskList(body) {
91
91
  }
92
92
  return entries;
93
93
  }
94
- /** Build the initial AUTO-file body. */
95
- export function buildAutoBody(feature, clarifications, titles) {
94
+ /** Build the initial AUTO-file body. `coverage` is the requirement-level
95
+ * accounting summary (goal A(c) — a durable, user-visible record of what was
96
+ * carried cross-cutting and what stayed unowned); '' omits the section. */
97
+ export function buildAutoBody(feature, clarifications, titles, coverage = '') {
96
98
  const tasks = titles.map(t => `- [ ] ${t}`).join('\n');
97
99
  return (`\n## feature prompt\n\n${feature.trim() || '(none)'}\n\n`
98
100
  + `## clarifications\n\n${clarifications.trim() || '(none)'}\n\n`
99
- + `## tasks\n\n${tasks}\n`);
101
+ + `## tasks\n\n${tasks}\n`
102
+ + (coverage.trim().length > 0 ? `\n## coverage\n\n${coverage.trim()}\n` : ''));
100
103
  }
101
104
  /** Rewrite the Nth checkbox line of the "## tasks" section in place. */
102
105
  async function rewriteTaskLine(cwd, id, index, render, label) {
@@ -33,6 +33,7 @@ export interface AutoDeps extends GateDeps {
33
33
  finalGate?: (cwd: string, planText?: string) => Promise<{
34
34
  ok: boolean;
35
35
  reason: string;
36
+ debtNote?: string;
36
37
  openDebts?: AcceptDebt[];
37
38
  }>;
38
39
  /**
@@ -32,7 +32,9 @@ import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FIN
32
32
  import { getConfig } from '../config/config.js';
33
33
  import { configureResearchRun } from '../workers/research-cache.js';
34
34
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
35
- import { LAUNCH_EXTRACT_PROMPT, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
35
+ import { reconcileTitleSources } from './decompose-fidelity.js';
36
+ import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
37
+ import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
36
38
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
37
39
  // when the model emits NONE), but a model that never says NONE would otherwise
38
40
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -436,10 +438,57 @@ export async function planAuto(ctx, cwd, feature, deps) {
436
438
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
437
439
  }
438
440
  const clarifications = answers.join('\n');
441
+ // Requirement extraction (mx5 run 11, goal A): grounded requirement units,
442
+ // extracted from whatever structure the spec has, BEFORE decompose — they ride
443
+ // into the decompose prompt as a ledger (structure-mirroring can't discharge
444
+ // them) and drive the per-requirement coverage accounting below. Best-effort:
445
+ // a fault leaves reqEntries empty and the whole channel degrades to the old
446
+ // behavior (one-liners / doc-less features naturally yield few or none).
447
+ let reqEntries = [];
448
+ try {
449
+ // Recall floor: the obligation-marked passages ride into the prompt as a
450
+ // checklist, and a marked passage that produced NO quote is hard evidence
451
+ // for one forced re-extraction (measured live: 1/5 extractions missed the
452
+ // entire marked testing section without this).
453
+ const passages = enumerateObligationPassages(featureForModel);
454
+ const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
455
+ reqEntries = await extractOnce(null);
456
+ const uncovered = uncoveredPassages(passages, reqEntries);
457
+ if (uncovered.length > 0) {
458
+ logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
459
+ + 'uncovered — forcing one re-extraction');
460
+ const retry = await extractOnce(extractionRetryHint(uncovered));
461
+ // Union of both grounded passes (keepGrounded dedupes).
462
+ reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
463
+ }
464
+ // Bound with marked-passage priority — a plain first-N cap truncates the
465
+ // doc's tail sections (measured live: an eager model fills 40 top-down).
466
+ reqEntries = capRequirements(reqEntries, passages);
467
+ logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
468
+ }
469
+ catch {
470
+ // best-effort channel
471
+ }
439
472
  // decompose
440
- const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications);
473
+ const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
474
+ // Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
475
+ // [source: "…"] citation against the doc, strip the clause, and re-attach any
476
+ // `+`-joined constraint fragment the paraphrased title dropped (the silently
477
+ // stripped "+ tests" class). Applied to EVERY decompose output — initial,
478
+ // suspect-retry, coverage-retry — so no path ships an unreconciled list.
479
+ const parsePlan = (raw) => {
480
+ const plan = reconcileTitleSources(parseDecomposeList(raw), featureForModel);
481
+ if (plan.sourced > 0 || plan.restored.length > 0) {
482
+ logPlanDebug(cwd, `decompose fidelity: ${plan.sourced}/${plan.titles.length} titles cited a grounded source; `
483
+ + `${plan.restored.length} restoration(s)`
484
+ + plan.restored
485
+ .map(r => ` [task ${r.index + 1}: ${r.fragments.join(', ')}]`)
486
+ .join(''));
487
+ }
488
+ return plan.titles;
489
+ };
441
490
  const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
442
- let planTitles = parseDecomposeList(listRaw);
491
+ let planTitles = parsePlan(listRaw);
443
492
  logPlanDebug(cwd, `decompose produced ${planTitles.length} title(s)`);
444
493
  // Distrust floor (see isSuspectPlan): a ≤2-title plan for a multi-KB spec is
445
494
  // regenerated once BEFORE the judge runs — the judge cannot be trusted to
@@ -450,7 +499,7 @@ export async function planAuto(ctx, cwd, feature, deps) {
450
499
  logPlanDebug(cwd, `decompose suspect (${planTitles.length} title(s) for a ${featureForModel.length}-char spec)`
451
500
  + ` — raw output: ${listRaw.trim().slice(0, 300)}`);
452
501
  const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(suspectPlanHint(planTitles.length), decomposePrompt));
453
- const retryTitles = parseDecomposeList(retryRaw);
502
+ const retryTitles = parsePlan(retryRaw);
454
503
  logPlanDebug(cwd, `decompose suspect-retry produced ${retryTitles.length} title(s)`);
455
504
  if (retryTitles.length > planTitles.length)
456
505
  planTitles = retryTitles;
@@ -472,20 +521,48 @@ export async function planAuto(ctx, cwd, feature, deps) {
472
521
  // re-judged the same unchanged list, and the known-incomplete plan shipped
473
522
  // with no warning.
474
523
  let unresolvedMissing = null;
524
+ // The last per-requirement accounting (goal A): completeness is computed
525
+ // HOST-SIDE from it — a holistic "COMPLETE" alone can no longer pass a plan
526
+ // while grounded requirements sit unowned (run 11: milestone-parity satisfied
527
+ // the judge while §10 Testing had zero tasks). Null when no requirements were
528
+ // extracted or every mapping call faulted (⇒ old judge-only behavior).
529
+ let accounting = null;
475
530
  for (let round = 0; round < MAX_COVERAGE_ROUNDS && planTitles.length > 0; round++) {
531
+ // Signal 1 — the holistic judge (kept as the belt; catches feature areas
532
+ // the requirement extraction itself missed). A fault yields no signal.
476
533
  let verdict;
477
534
  try {
478
535
  verdict = parseCoverageVerdict(await deps.runChild('decompose-coverage', '', DECOMPOSE_COVERAGE_PROMPT(featureForModel, clarifications, planTitles)));
479
536
  }
480
537
  catch {
481
- // Judge fault: unknown coverage, not known-missing — stay silent.
482
- unresolvedMissing = null;
483
- break;
538
+ verdict = null;
484
539
  }
485
- if (verdict === null || verdict.kind === 'complete') {
540
+ const verdictMissing = verdict?.kind === 'incomplete' ? verdict.missing : [];
541
+ // Signal 2 — the per-requirement map (the lever): every grounded
542
+ // requirement gets a falsifiable verdict (TASK n / CROSS-CUTTING / NONE);
543
+ // the host, not the model, decides what is uncovered. A fault keeps the
544
+ // previous round's accounting.
545
+ if (reqEntries.length > 0) {
546
+ try {
547
+ const mapRaw = await deps.runChild('coverage-map', '', COVERAGE_MAP_PROMPT(reqEntries, planTitles));
548
+ accounting = accountCoverage(reqEntries, parseCoverageMap(mapRaw, reqEntries.length, planTitles.length));
549
+ logPlanDebug(cwd, `coverage-map round ${round + 1}: ${accounting.mapped.length} task-mapped, `
550
+ + `${accounting.crossCutting.length} cross-cutting, `
551
+ + `${accounting.unmapped.length} unmapped`);
552
+ }
553
+ catch {
554
+ // mapping fault — keep whatever accounting an earlier round produced
555
+ }
556
+ }
557
+ const unmappedQuotes = (accounting?.unmapped ?? []).map(e => `"${e.quote}"`);
558
+ const missing = [...verdictMissing, ...unmappedQuotes];
559
+ if (missing.length === 0) {
486
560
  unresolvedMissing = null;
487
561
  logPlanDebug(cwd, `decompose-coverage round ${round + 1}: `
488
- + (verdict === null ? 'no verdict — accepting list' : 'COMPLETE'));
562
+ + (verdict === null ? 'no judge verdict' : 'judge COMPLETE')
563
+ + (reqEntries.length > 0 ?
564
+ ' and every grounded requirement is task-mapped or cross-cutting'
565
+ : ' — accepting list'));
489
566
  // A COMPLETE on a still-suspect plan is the judge's known live
490
567
  // false-pass mode (bare verdict, indistinguishable from a real one).
491
568
  // The plan still ships — the floor never rejects on count — but
@@ -496,11 +573,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
496
573
  }
497
574
  break;
498
575
  }
499
- unresolvedMissing = verdict.missing;
576
+ unresolvedMissing = missing;
500
577
  logPlanDebug(cwd, `decompose-coverage round ${round + 1}: INCOMPLETE — missing: `
501
- + verdict.missing.join('; ').slice(0, 300));
502
- const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(verdict.missing), decomposePrompt));
503
- const retryTitles = parseDecomposeList(retryRaw);
578
+ + missing.join('; ').slice(0, 300));
579
+ const retryRaw = await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(missing), decomposePrompt));
580
+ const retryTitles = parsePlan(retryRaw);
504
581
  logPlanDebug(cwd, `decompose retry produced ${retryTitles.length} title(s)`);
505
582
  if (retryTitles.length > 0 && retryTitles.length * 2 >= planTitles.length) {
506
583
  planTitles = retryTitles;
@@ -517,6 +594,22 @@ export async function planAuto(ctx, cwd, feature, deps) {
517
594
  + unresolvedMissing.join('; ').slice(0, 300));
518
595
  ctx.ui.notify(`/task-auto: plan may be missing coverage — ${unresolvedMissing.join('; ').slice(0, 200)} — review the plan before running.`, 'warning');
519
596
  }
597
+ // Carry what no single task owns (goal A(b)/(c)): cross-cutting requirements
598
+ // become `.pi-tasks/requirements.md`, injected VERBATIM into every task's
599
+ // refine/compose (run 11: §10's test-first cadence had no carrier — the "spec
600
+ // is authoritative" pointer recovered it in 1 of ~6 tasks; content travels,
601
+ // pointers don't). Requirements still unmapped after the rounds are carried
602
+ // too — marked — and recorded user-visibly in the plan file, never dropped.
603
+ if (accounting !== null) {
604
+ await appendCarriedRequirements(cwd, accounting.crossCutting, accounting.unmapped);
605
+ if (accounting.crossCutting.length > 0 || accounting.unmapped.length > 0) {
606
+ ctx.ui.notify(`/task-auto: carrying ${accounting.crossCutting.length} cross-cutting`
607
+ + (accounting.unmapped.length > 0 ?
608
+ ` and ${accounting.unmapped.length} unowned`
609
+ : '')
610
+ + ' requirement(s) into every task — see .pi-tasks/requirements.md.', 'info');
611
+ }
612
+ }
520
613
  // Cross-slice contract registry (mx5 run 8, F3): now that the plan is settled,
521
614
  // extract the interface facts MORE THAN ONE slice must agree on — endpoint paths,
522
615
  // exported signatures, file layouts, env var names the DESIGN pins — into a
@@ -540,9 +633,14 @@ export async function planAuto(ctx, cwd, feature, deps) {
540
633
  // declares the project must expose (`migrate`/`seed` fell through decompose and
541
634
  // shipped missing, unchecked). Each emitted name is re-grounded against the design
542
635
  // (keepGroundedScripts — kept only if the design backticks it), so the final gate's
543
- // manifest diff can never false-flag a hallucinated script. Best-effort.
636
+ // manifest diff can never false-flag a hallucinated script. Recall is mechanical
637
+ // (mx5 run 11): enumerateScriptCandidates hands the child every backticked
638
+ // script-shaped token near the word "script" as a checklist, so a script declared
639
+ // far from the design's summary list (`test:ct` in §2 vs §9's five) can't be
640
+ // missed by a weak model's recall — the child classifies, it no longer recalls.
641
+ // Best-effort.
544
642
  try {
545
- const scriptRaw = await deps.runChild('launch-extract', '', LAUNCH_EXTRACT_PROMPT(featureForModel));
643
+ const scriptRaw = await deps.runChild('launch-extract', '', LAUNCH_EXTRACT_PROMPT(featureForModel, enumerateScriptCandidates(featureForModel)));
546
644
  const grounded = keepGroundedScripts(parseScriptLines(scriptRaw), featureForModel);
547
645
  logPlanDebug(cwd, `launch-contract extraction: ${grounded.length} grounded script(s) kept`
548
646
  + ` from ${parseScriptLines(scriptRaw).length} emitted`);
@@ -571,7 +669,16 @@ export async function planAuto(ctx, cwd, feature, deps) {
571
669
  updated_at: now,
572
670
  title: deriveTitle(feature)
573
671
  };
574
- await writeTaskFile(cwd, fm, buildAutoBody(feature, clarifications, titles));
672
+ // Durable, user-visible coverage record (goal A(c)): what was carried and what
673
+ // stayed unowned lives in the plan file itself, not only in a transient toast.
674
+ const coverageNote = accounting === null ? '' : ([
675
+ `${reqEntries.length} grounded requirement(s): ${accounting.mapped.length} task-mapped, `
676
+ + `${accounting.crossCutting.length} cross-cutting (carried into every task via `
677
+ + `.pi-tasks/requirements.md), ${accounting.unmapped.length} unowned`,
678
+ ...accounting.crossCutting.map(e => `- carried: "${e.quote}"`),
679
+ ...accounting.unmapped.map(e => `- UNOWNED (no task covers this): "${e.quote}"`)
680
+ ].join('\n'));
681
+ await writeTaskFile(cwd, fm, buildAutoBody(feature, clarifications, titles, coverageNote));
575
682
  return id;
576
683
  }
577
684
  /** The two feature-level planning children, shown as steps in the loader. */
@@ -714,7 +821,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
714
821
  // already a human decision, so this reports, it does not re-fail.
715
822
  if (fin.openDebts && fin.openDebts.length > 0) {
716
823
  for (const d of fin.openDebts) {
717
- await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}`);
824
+ await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}${d.conflict ? ` [CONFLICTING CLAIM — ${d.conflict}]` : ''}`);
718
825
  }
719
826
  active.ui.notify(`${id}: ${fin.openDebts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
720
827
  }
@@ -726,7 +833,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
726
833
  let fixAttempts = 0;
727
834
  while (!fin.ok) {
728
835
  const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
729
- const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}\n\n`
836
+ // The picker question shows the debts (the HUMAN weighs them);
837
+ // the autofix seed below deliberately does not — mx5 run 11's
838
+ // fix child executed a debt claim as an `rm` instruction.
839
+ const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}${fin.debtNote ?? ''}\n\n`
730
840
  + 'All tasks are checked off — this is the whole-repo check '
731
841
  + '(the project’s own test/build/static commands, run unaided).'
732
842
  + (fixAttempts > 0 ?
@@ -772,7 +882,13 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
772
882
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
773
883
  // Work from the FRESH gate failure when the fix pass got
774
884
  // as far as re-running the gate; otherwise keep the last.
775
- fin = { ok: false, reason: fix.gateReason ?? fin.reason };
885
+ // The debt note is carried so the next picker still shows
886
+ // the open claims (the seed never includes it).
887
+ fin = {
888
+ ok: false,
889
+ reason: fix.gateReason ?? fin.reason,
890
+ debtNote: fin.debtNote
891
+ };
776
892
  continue;
777
893
  }
778
894
  // Leave failed — the dismissal default, unchanged from the
@@ -1,7 +1,3 @@
1
- /**
2
- * Prompts for /task-auto's two feature-level child calls. These produce a task
3
- * LIST only; all research/spec depth is /task's job, run per-title later.
4
- */
5
1
  /**
6
2
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
3
  * single numbered question followed by a "SUGGESTED: <default>" line, an optional
@@ -12,8 +8,12 @@
12
8
  export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) => string;
13
9
  /**
14
10
  * Decompose: output a markdown checkbox list of task titles (one line each).
11
+ * `requirementsLedger` is buildRequirementsLedger's grounded quote list (goal E's
12
+ * belt): the spec's obligations ride into decompose explicitly, so mirroring the
13
+ * spec's own milestone/section structure cannot silently discharge them. '' ⇒
14
+ * the prompt is unchanged.
15
15
  */
16
- export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string) => string;
16
+ export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string) => string;
17
17
  /**
18
18
  * Coverage triage: judge whether a decomposed task list covers the whole
19
19
  * feature. Guards the plan — the highest-leverage artifact in /task-auto —
@@ -2,6 +2,7 @@
2
2
  * Prompts for /task-auto's two feature-level child calls. These produce a task
3
3
  * LIST only; all research/spec depth is /task's job, run per-title later.
4
4
  */
5
+ import { DECOMPOSE_SOURCE_RULE } from './decompose-fidelity.js';
5
6
  /**
6
7
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
7
8
  * single numbered question followed by a "SUGGESTED: <default>" line, an optional
@@ -34,6 +35,7 @@ fork the breakdown). Account for the answers so far:
34
35
  real-time vs polling transport, search, deployment).
35
36
  - Skip anything /task will naturally resolve per-task during its own research.
36
37
  - Stay grounded in the referenced spec. If a design/spec doc is included above, do NOT propose a new subsystem, dependency, or requirement it does not call for, and do NOT re-ask a choice the spec already settles. Ask only about genuine forks the spec leaves open.
38
+ - If the question or your SUGGESTED default locks the task breakdown to ONE part or structure of the spec (e.g. "follow the milestone list", "one task per section"), the SUGGESTED line must ALSO name the spec's other REQUIRED content that structure does not represent (a testing/security/quality section, cross-cutting rules) and state how each is carried — folded into every applicable task, or as its own task. Never present a structure-lock as settling the whole spec.
37
39
 
38
40
  YOU MUST propose a default answer for the question — every question you emit
39
41
  carries exactly one SUGGESTED line. Never omit it, never leave it blank, never
@@ -65,8 +67,12 @@ No question remains:
65
67
  NONE`;
66
68
  /**
67
69
  * Decompose: output a markdown checkbox list of task titles (one line each).
70
+ * `requirementsLedger` is buildRequirementsLedger's grounded quote list (goal E's
71
+ * belt): the spec's obligations ride into decompose explicitly, so mirroring the
72
+ * spec's own milestone/section structure cannot silently discharge them. '' ⇒
73
+ * the prompt is unchanged.
68
74
  */
69
- export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications) => `Split this feature into an ordered list of implementation tasks. Each task
75
+ export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '') => `Split this feature into an ordered list of implementation tasks. Each task
70
76
  will be handed, by its title, to a separate pipeline that does its own research
71
77
  and writes its own spec — so here you produce TITLES ONLY, not specs.
72
78
 
@@ -75,7 +81,7 @@ ${feature.trim()}
75
81
 
76
82
  CLARIFICATIONS:
77
83
  ${clarifications.trim() || '(none)'}
78
-
84
+ ${requirementsLedger.trim().length > 0 ? `\n${requirementsLedger.trim()}\n` : ''}
79
85
  RULES:
80
86
  - One task per line, as a markdown checkbox: "- [ ] <title>".
81
87
  - Each title is a short imperative phrase; optionally add " — <one key detail>".
@@ -88,6 +94,7 @@ RULES:
88
94
  none. These are explicit user choices that may contradict the referenced spec
89
95
  doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
90
96
  not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
97
+ ${DECOMPOSE_SOURCE_RULE}
91
98
  - Output the checkbox list and NOTHING else (no preamble, no numbering).`;
92
99
  /**
93
100
  * Coverage triage: judge whether a decomposed task list covers the whole
@@ -7,6 +7,14 @@ export interface ContractEntry {
7
7
  export declare function contractsFile(cwd: string): string;
8
8
  /** The stored registry text ('' when none recorded yet). */
9
9
  export declare function readContracts(cwd: string): Promise<string>;
10
+ /**
11
+ * Normalise for substring matching: collapse all whitespace runs to one space and
12
+ * lowercase. Quoting across a line wrap or with reflowed spacing still matches the
13
+ * source; casing differences do not defeat the anti-synthesis guard. Exported as
14
+ * the ONE grounding normaliser every "verbatim quote from the source doc" guard
15
+ * shares (contracts, decompose source anchors), so the rule can't drift.
16
+ */
17
+ export declare function normalise(s: string): string;
10
18
  /**
11
19
  * Parse `CONTRACT:` lines out of a child's answer text into entries. The line shape
12
20
  * the extraction prompt asks for is:
@@ -52,9 +52,11 @@ export async function readContracts(cwd) {
52
52
  /**
53
53
  * Normalise for substring matching: collapse all whitespace runs to one space and
54
54
  * lowercase. Quoting across a line wrap or with reflowed spacing still matches the
55
- * source; casing differences do not defeat the anti-synthesis guard.
55
+ * source; casing differences do not defeat the anti-synthesis guard. Exported as
56
+ * the ONE grounding normaliser every "verbatim quote from the source doc" guard
57
+ * shares (contracts, decompose source anchors), so the rule can't drift.
56
58
  */
57
- function normalise(s) {
59
+ export function normalise(s) {
58
60
  return s.replace(/\s+/g, ' ').trim().toLowerCase();
59
61
  }
60
62
  /**
@@ -0,0 +1,47 @@
1
+ export interface SourcedTitle {
2
+ /** The title with any source clause stripped. */
3
+ base: string;
4
+ /** The cited spec line, when present and grounded in the source doc. */
5
+ source?: string;
6
+ }
7
+ /** Split a decompose title into its base and its GROUNDED source citation.
8
+ * An absent clause yields no source; a fabricated (ungrounded) one is stripped
9
+ * and dropped — exactly like keepGroundedContracts rejects a paraphrased quote. */
10
+ export declare function extractTitleSource(title: string, sourceDoc: string): SourcedTitle;
11
+ /**
12
+ * The `+`-joined trailing constraint fragments of `sourceLine` whose words are
13
+ * absent from `title`. "2. **Auth** — sessions, login/logout/me, guards + tests."
14
+ * yields the fragment "tests"; a title that never mentions tests gets it back.
15
+ * Fragments before the first `+` are the task's body — a title paraphrases those
16
+ * freely and they are never judged here. A `+`-part is further split on commas
17
+ * ("+ Tailwind v4 tokens, nav, router" is three constraints), so a title missing
18
+ * one of them gets ONLY that one restored, not the whole phrase (measured live:
19
+ * whole-phrase restoration re-attached text the title already carried).
20
+ */
21
+ export declare function findDroppedPlusFragments(sourceLine: string, title: string): string[];
22
+ export interface TitleRestoration {
23
+ /** Index into the reconciled titles array. */
24
+ index: number;
25
+ /** The verbatim fragments re-attached to the title. */
26
+ fragments: string[];
27
+ /** The grounded source line they came from. */
28
+ source: string;
29
+ }
30
+ export interface ReconciledPlan {
31
+ titles: string[];
32
+ restored: TitleRestoration[];
33
+ /** How many titles carried a GROUNDED source citation (adoption signal). */
34
+ sourced: number;
35
+ }
36
+ /**
37
+ * Reconcile decompose output against the source doc: ground each citation, strip
38
+ * the clause (its job ends here), and re-attach any dropped `+`-fragments to the
39
+ * title verbatim so downstream refine/compose — which see ONLY the title — get
40
+ * the constraint back. Titles without a citation pass through unchanged, so a
41
+ * model that never cites degrades to exactly the old behavior.
42
+ */
43
+ export declare function reconcileTitleSources(titles: string[], sourceDoc: string): ReconciledPlan;
44
+ /** The decompose-prompt rule that makes titles citable (the belt half; the host
45
+ * grounding + restoration above is the lever). Kept here so prompt and parser
46
+ * can't drift apart. */
47
+ export declare const DECOMPOSE_SOURCE_RULE: string;