@mjasnikovs/pi-task 0.18.39 → 0.18.41

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.
@@ -29,8 +29,16 @@
29
29
  * `lsof` and the sandbox had neither) and no further attempt can move it. The
30
30
  * run is allowed to converge on the REMAINING checks, carrying this one here
31
31
  * so the next run's gate re-checks and re-surfaces it rather than losing it.
32
+ * - 'root-cause' — the task's verify FAILed because of a PRE-EXISTING defect in a
33
+ * file a DIFFERENT task created, which this task's own work never touched (mx5
34
+ * run 14: TASK_0007's `test/teardown.ts` TRUNCATE bug FAILed TASK_0013 and
35
+ * TASK_0019). The current task is not at fault, so its work — and, at the
36
+ * enforce site, the enforce pass's edits — are KEPT rather than reverted; the
37
+ * defect is recorded here and a scoped repair task is queued into the plan
38
+ * (root-cause-repair.ts). Before this class existed the ledger recorded the same
39
+ * root cause twice and nothing ever scheduled a fix, so it survived ~24h.
32
40
  */
33
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate';
41
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
34
42
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
35
43
  export interface AcceptDebt {
36
44
  taskId: string;
@@ -105,6 +113,15 @@ export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason
105
113
  * The taskId is the run's parent id — the demotion is a run-level decision.
106
114
  */
107
115
  export declare function recordFinalGateUnobservedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
116
+ /**
117
+ * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
118
+ * on a pre-existing defect in a file ANOTHER task created and this task never
119
+ * touched. The current task is not at fault — its work (and, at the enforce site,
120
+ * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
121
+ * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
122
+ * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
123
+ */
124
+ export declare function recordRootCauseDebt(cwd: string, taskId: string, reason: string): Promise<void>;
108
125
  /**
109
126
  * The deleted path a cross-task-deletion debt names (the fixed shape
110
127
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -78,7 +78,8 @@ export function parseAcceptDebts(raw) {
78
78
  || origin === 'frozen-blocked'
79
79
  || origin === 'cross-task-deletion'
80
80
  || origin === 'yolo-accepted'
81
- || origin === 'final-gate') ?
81
+ || origin === 'final-gate'
82
+ || origin === 'root-cause') ?
82
83
  { origin: origin }
83
84
  : {})
84
85
  });
@@ -203,6 +204,21 @@ export async function recordFinalGateUnobservedDebt(cwd, taskId, reason) {
203
204
  origin: 'final-gate'
204
205
  });
205
206
  }
207
+ /**
208
+ * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
209
+ * on a pre-existing defect in a file ANOTHER task created and this task never
210
+ * touched. The current task is not at fault — its work (and, at the enforce site,
211
+ * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
212
+ * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
213
+ * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
214
+ */
215
+ export async function recordRootCauseDebt(cwd, taskId, reason) {
216
+ await appendDebt(cwd, {
217
+ taskId: taskId.trim(),
218
+ reason: normaliseReason(reason),
219
+ origin: 'root-cause'
220
+ });
221
+ }
206
222
  /**
207
223
  * The deleted path a cross-task-deletion debt names (the fixed shape
208
224
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -357,6 +373,9 @@ export function describeDebt(d) {
357
373
  if (d.origin === 'yolo-accepted') {
358
374
  return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
359
375
  }
376
+ if (d.origin === 'root-cause') {
377
+ return "verify FAILed on a PRE-EXISTING defect in another task's file that this task never touched (this task's work was kept; a scoped repair task was queued for the root cause)";
378
+ }
360
379
  if (d.origin === 'final-gate') {
361
380
  return 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)';
362
381
  }
@@ -42,5 +42,21 @@ export declare function checkOffTask(cwd: string, id: string, index: number, pro
42
42
  * of starting a brand-new task — matching how /task-resume behaves.
43
43
  */
44
44
  export declare function stampTaskInProgress(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
45
+ /**
46
+ * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
47
+ * mid-run plan mutation the root-cause repair channel needs (mx5 run 14 item 5:
48
+ * a repair task must land BEFORE the next dependent task, not at the end of the
49
+ * plan, or the defect keeps failing everything in between).
50
+ *
51
+ * MONOTONIC by construction (the run-12 replacement lesson): this only ever
52
+ * SPLICES a line in. No existing entry is rewritten, reordered, or dropped, and
53
+ * an already-present title is a no-op — so a plan can grow mid-run but never
54
+ * shrink, and a retried insert cannot duplicate. Returns whether a line was added.
55
+ *
56
+ * Later entries shift down by one, which is safe because the /task-auto loop
57
+ * re-reads and re-parses the plan at the top of every iteration and locates its
58
+ * next step by "first unchecked" rather than by a cached index.
59
+ */
60
+ export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
45
61
  /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
46
62
  export declare function findResumableAuto(cwd: string): Promise<string | null>;
@@ -140,6 +140,60 @@ export async function checkOffTask(cwd, id, index, producedId, title) {
140
140
  export async function stampTaskInProgress(cwd, id, index, producedId, title) {
141
141
  await rewriteTaskLine(cwd, id, index, () => `- [ ] ${producedId} ${title}`, 'stampTaskInProgress');
142
142
  }
143
+ /** The bare title of a checkbox line (id stamp stripped), or null if not one. */
144
+ function entryTitle(line) {
145
+ const m = CHECKBOX_RE.exec(line.trim());
146
+ if (!m)
147
+ return null;
148
+ const rest = m[2].trim();
149
+ const idm = PRODUCED_ID_RE.exec(rest);
150
+ return idm ? idm[2].trim() : rest;
151
+ }
152
+ /**
153
+ * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
154
+ * mid-run plan mutation the root-cause repair channel needs (mx5 run 14 item 5:
155
+ * a repair task must land BEFORE the next dependent task, not at the end of the
156
+ * plan, or the defect keeps failing everything in between).
157
+ *
158
+ * MONOTONIC by construction (the run-12 replacement lesson): this only ever
159
+ * SPLICES a line in. No existing entry is rewritten, reordered, or dropped, and
160
+ * an already-present title is a no-op — so a plan can grow mid-run but never
161
+ * shrink, and a retried insert cannot duplicate. Returns whether a line was added.
162
+ *
163
+ * Later entries shift down by one, which is safe because the /task-auto loop
164
+ * re-reads and re-parses the plan at the top of every iteration and locates its
165
+ * next step by "first unchecked" rather than by a cached index.
166
+ */
167
+ export async function insertTaskAfter(cwd, id, afterIndex, title) {
168
+ const clean = title.trim();
169
+ if (clean.length === 0)
170
+ return false;
171
+ const { body } = await readTaskFile(cwd, id);
172
+ const section = extractSection(body, 'tasks') ?? '';
173
+ const lines = section.split('\n');
174
+ // Duplicate check scans the WHOLE list first: an existing entry with this exact
175
+ // title (checked or not) means the plan already carries this step, wherever it
176
+ // sits relative to afterIndex — never add a second one.
177
+ if (lines.some(l => entryTitle(l) === clean))
178
+ return false;
179
+ let seen = -1;
180
+ let insertAt = -1;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (!CHECKBOX_RE.test(lines[i].trim()))
183
+ continue;
184
+ seen++;
185
+ insertAt = i + 1;
186
+ if (seen === afterIndex)
187
+ break;
188
+ }
189
+ // An out-of-range index appends after the LAST checkbox rather than throwing:
190
+ // a plan that grew underneath the caller must still receive the entry.
191
+ if (insertAt === -1)
192
+ return false;
193
+ lines.splice(insertAt, 0, `- [ ] ${clean}`);
194
+ await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
195
+ return true;
196
+ }
143
197
  /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
144
198
  export async function findResumableAuto(cwd) {
145
199
  await ensureTasksDir(cwd);
@@ -14,7 +14,8 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
14
14
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
15
15
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
16
16
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
17
- import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
17
+ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAuto } from './auto-io.js';
18
+ import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
18
19
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
19
20
  import { readTextFile } from '../shared/fs-text.js';
20
21
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
@@ -38,6 +39,7 @@ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './y
38
39
  import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
39
40
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
40
41
  import { reconcileTitleSources } from './decompose-fidelity.js';
42
+ import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
41
43
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
42
44
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
43
45
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
@@ -320,6 +322,63 @@ export function buildScopeFence(titles, currentIndex) {
320
322
  + `components, or flows owned by a later step.\n\n`
321
323
  + `The full plan (these run separately — do NOT implement them here):\n${listing}`);
322
324
  }
325
+ /**
326
+ * The scope fence for step `currentIndex`, plus the REPAIR fence when that step is
327
+ * a queued root-cause repair. A repair title ("repair test/teardown.ts: …") reads
328
+ * to refine like any other feature step, and refine's job is to expand a title into
329
+ * a full spec — which is exactly how "repair the teardown" becomes "overhaul the
330
+ * test infrastructure" (the /task-auto drift lesson). The extra fence pins the one
331
+ * editable file and pins VERIFY to the command the defect was failing.
332
+ */
333
+ function buildStepFence(titles, currentIndex) {
334
+ const base = buildScopeFence(titles, currentIndex);
335
+ const repairFile = parseRepairTitleFile(titles[currentIndex] ?? '');
336
+ if (!repairFile)
337
+ return base;
338
+ return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(titles[currentIndex] ?? ''))}`;
339
+ }
340
+ /**
341
+ * Drain the gate's root-cause repair queue into the running plan: one scoped
342
+ * repair step per accused FILE, spliced in directly after the step that just
343
+ * finished.
344
+ *
345
+ * Three bounds, all mandatory (mx5 run 14 item 5 gray areas):
346
+ * - DEDUP by file — run 14's two `test/teardown.ts` debts must yield ONE repair
347
+ * step, not two. mergeRepairCandidates collapses the drained queue, and
348
+ * planHasRepairFor rejects a file the plan already carries a repair for.
349
+ * - CAP 1 per file per RUN — planHasRepairFor counts CHECKED-OFF entries too, so
350
+ * a repair step that itself failed is never re-spawned; it lands in the
351
+ * accept-debt ledger like any other task. That is what stops a repair loop.
352
+ * - MONOTONIC — insertTaskAfter only splices; no existing entry is rewritten,
353
+ * reordered or dropped (the run-12 replacement lesson).
354
+ *
355
+ * Best-effort throughout: a fault here must never fail the run that produced the
356
+ * finding — the debt is already durably recorded either way.
357
+ */
358
+ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
359
+ try {
360
+ const pending = await drainRepairQueue(cwd);
361
+ if (pending.length === 0)
362
+ return;
363
+ const { body } = await readTaskFile(cwd, id);
364
+ const titles = parseTaskList(body).map(e => e.title);
365
+ let at = afterIndex;
366
+ for (const repair of mergeRepairCandidates(pending)) {
367
+ if (planHasRepairFor(titles, repair.file))
368
+ continue;
369
+ const title = buildRepairTitle(repair);
370
+ if (!(await insertTaskAfter(cwd, id, at, title)))
371
+ continue;
372
+ titles.splice(at + 1, 0, title);
373
+ at += 1;
374
+ await deps.record?.(cwd, id, `plan: inserted scoped repair step after step ${afterIndex + 1} — ${title}`);
375
+ ctx.ui.notify(`${id}: queued a scoped repair for ${repair.file} (${repair.owner}'s file — root cause of ${repair.blamed.join(', ')}).`, 'warning');
376
+ }
377
+ }
378
+ catch {
379
+ // the plan is best-effort here; the underlying debt is already recorded
380
+ }
381
+ }
323
382
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
324
383
  export async function planAuto(ctx, cwd, feature, deps) {
325
384
  // clarify — sequential & adaptive: ask one question at a time, feeding every
@@ -532,8 +591,18 @@ export async function planAuto(ctx, cwd, feature, deps) {
532
591
  catch {
533
592
  // best-effort channel
534
593
  }
594
+ // Tests-in-the-same-change cadence (mx5 run 14, PROMPT item 6): when the
595
+ // decisions mandate it, a whole-project batch test task contradicts them —
596
+ // run 14 shipped one anyway (TASK_0037, 4.7h, yolo-accepted FAIL) because
597
+ // decompose mirrors the spec's milestone shape. The decisions channel
598
+ // OVERRIDES the spec doc, so this resolves toward the decision without asking.
599
+ const noBatchTests = mandatesTestsInSameChange(clarifications, featureForModel);
600
+ if (noBatchTests) {
601
+ logPlanDebug(cwd, 'decisions mandate tests-in-the-same-change — batch test tasks are banned '
602
+ + 'from this plan (prompt rule + host rewrite)');
603
+ }
535
604
  // decompose
536
- const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
605
+ const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries), noBatchTests);
537
606
  // Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
538
607
  // [source: "…"] citation against the doc, strip the clause, and re-attach any
539
608
  // `+`-joined constraint fragment the paraphrased title dropped (the silently
@@ -548,7 +617,18 @@ export async function planAuto(ctx, cwd, feature, deps) {
548
617
  .map(r => ` [task ${r.index + 1}: ${r.fragments.join(', ')}]`)
549
618
  .join(''));
550
619
  }
551
- return plan.titles;
620
+ // Batch-test ban (item 6): drop or scope a whole-project "write all the
621
+ // tests" task. Identity unless the cadence decision is present, and the
622
+ // sweep replacement re-grounds every requirement the drop would cost — so
623
+ // planned coverage cannot fall (run 12's lesson).
624
+ const debatched = rewriteBatchTestPlan(plan.titles, clarifications, featureForModel, reqEntries.map(e => e.quote), isCrossCuttingRequirement);
625
+ for (const a of debatched.actions) {
626
+ logPlanDebug(cwd, `batch test task ${a.kind} (tests-in-same-change decision): "${a.title}"`
627
+ + (a.kind === 'scoped' ?
628
+ ` → scoped sweep over ${a.orphaned.length} orphaned requirement(s)`
629
+ : ' — every requirement it touched is owned by another task'));
630
+ }
631
+ return debatched.titles;
552
632
  };
553
633
  const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
554
634
  let planTitles = parsePlan(listRaw);
@@ -1341,7 +1421,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1341
1421
  // matters when refine runs fresh (a resumed task past refine ignores
1342
1422
  // it), but always supplied so a resume that restarts at refine is
1343
1423
  // fenced too.
1344
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
1424
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1345
1425
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1346
1426
  });
1347
1427
  active = res.ctx ?? active;
@@ -1396,7 +1476,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1396
1476
  title: next.title,
1397
1477
  tag: id,
1398
1478
  // Fence an AUTOFIX re-run against re-expanding the whole spec.
1399
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
1479
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1400
1480
  // res.ok === true means runner.run() completed, so res.taskId is the
1401
1481
  // allocated TASK_NNNN id (never empty here). The parent task-list
1402
1482
  // check-off runs after verify passes/accepts and before the commit,
@@ -1426,6 +1506,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1426
1506
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
1427
1507
  return;
1428
1508
  }
1509
+ // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
1510
+ // FAIL to a pre-existing defect in a file some OTHER task created. It can
1511
+ // only QUEUE that finding — mutating the plan is this loop's job. Drain
1512
+ // the queue and splice a scoped repair step in right after the step that
1513
+ // just finished, so the defect is fixed BEFORE the next dependent task
1514
+ // trips over it too (run 14 recorded the same `test/teardown.ts` cause
1515
+ // twice, scheduled nothing, and the bug outlived ~24h of the run).
1516
+ await schedulePendingRepairs(cwd, id, next.index, active, deps);
1429
1517
  // gate.kind === 'done' → fall through to the next task, after checking
1430
1518
  // no landmine stash was left behind by anything that ran in between.
1431
1519
  if (deps.stashRef && stashBefore !== undefined) {
@@ -12,8 +12,12 @@ export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) =>
12
12
  * belt): the spec's obligations ride into decompose explicitly, so mirroring the
13
13
  * spec's own milestone/section structure cannot silently discharge them. '' ⇒
14
14
  * the prompt is unchanged.
15
+ *
16
+ * `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
17
+ * when the decisions mandate tests-in-the-same-change, so every other run sees the
18
+ * prompt it always saw. It is the belt; the host-side rewrite is the lever.
15
19
  */
16
- export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string) => string;
20
+ export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string, noBatchTests?: boolean) => string;
17
21
  /**
18
22
  * Coverage triage: judge whether a decomposed task list covers the whole
19
23
  * feature. Guards the plan — the highest-leverage artifact in /task-auto —
@@ -3,6 +3,7 @@
3
3
  * LIST only; all research/spec depth is /task's job, run per-title later.
4
4
  */
5
5
  import { DECOMPOSE_SOURCE_RULE } from './decompose-fidelity.js';
6
+ import { DECOMPOSE_NO_BATCH_TESTS_RULE } from './batch-test-task.js';
6
7
  /**
7
8
  * Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
8
9
  * single numbered question followed by a "SUGGESTED: <default>" line, an optional
@@ -71,8 +72,12 @@ NONE`;
71
72
  * belt): the spec's obligations ride into decompose explicitly, so mirroring the
72
73
  * spec's own milestone/section structure cannot silently discharge them. '' ⇒
73
74
  * the prompt is unchanged.
75
+ *
76
+ * `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
77
+ * when the decisions mandate tests-in-the-same-change, so every other run sees the
78
+ * prompt it always saw. It is the belt; the host-side rewrite is the lever.
74
79
  */
75
- export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '') => `Split this feature into an ordered list of implementation tasks. Each task
80
+ export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '', noBatchTests = false) => `Split this feature into an ordered list of implementation tasks. Each task
76
81
  will be handed, by its title, to a separate pipeline that does its own research
77
82
  and writes its own spec — so here you produce TITLES ONLY, not specs.
78
83
 
@@ -94,7 +99,7 @@ RULES:
94
99
  none. These are explicit user choices that may contradict the referenced spec
95
100
  doc; phrase them as imperative directives (e.g. "use Bun's built-in bundler, do
96
101
  not add vite"). Do NOT invent decisions — only restate ones from CLARIFICATIONS.
97
- ${DECOMPOSE_SOURCE_RULE}
102
+ ${DECOMPOSE_SOURCE_RULE}${noBatchTests ? `\n${DECOMPOSE_NO_BATCH_TESTS_RULE}` : ''}
98
103
  - Output the checkbox list and NOTHING else (no preamble, no numbering).`;
99
104
  /**
100
105
  * Coverage triage: judge whether a decomposed task list covers the whole
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Does the decisions/spec text mandate tests-in-the-same-change?
3
+ *
4
+ * Scans sentence by sentence and requires BOTH signals in the SAME sentence, so
5
+ * a testing section that happens to sit near an unrelated "don't defer" line
6
+ * cannot trigger the ban. `decisions` is checked first and alone is sufficient;
7
+ * the spec is scanned too because run 14's cadence rule is stated in §10 of the
8
+ * doc and only echoed into the decisions channel.
9
+ */
10
+ export declare function mandatesTestsInSameChange(decisions: string, spec?: string): boolean;
11
+ /**
12
+ * Indices of titles that are whole-project BATCH test tasks.
13
+ *
14
+ * Three conditions must all hold — the title's deliverable is tests, its scope is
15
+ * the whole project, and it is not test INFRASTRUCTURE. A per-feature task that
16
+ * carries "+ tests" never matches (its head names the feature), which is the
17
+ * point: those are the cadence the decision asks for.
18
+ */
19
+ export declare function findBatchTestTitles(titles: string[]): number[];
20
+ /**
21
+ * The spec's own coverage source — the command whose report scopes the sweep.
22
+ *
23
+ * Only backticked spans are considered, so the result is a command the spec
24
+ * actually writes down rather than a phrase inferred from prose; a span carrying
25
+ * a coverage flag wins over a plain test run. Falls back to a generic phrase when
26
+ * the spec names no command, which keeps the sweep title well-formed either way.
27
+ */
28
+ export declare function coverageSourceFromSpec(spec: string): string;
29
+ /**
30
+ * The scoped replacement: a gap-filling sweep, bounded by what the coverage
31
+ * source reports and by the requirements that lost their only owner. It states
32
+ * the ban explicitly, because the child that receives this title sees nothing
33
+ * else.
34
+ */
35
+ export declare function buildSweepTitle(orphaned: string[], coverageSource: string): string;
36
+ export interface BatchTestAction {
37
+ /** Index of the offending title in the INPUT list. */
38
+ index: number;
39
+ /** The offending title, verbatim. */
40
+ title: string;
41
+ kind: 'dropped' | 'scoped';
42
+ /** The sweep title that replaced it (`kind: 'scoped'` only). */
43
+ replacement?: string;
44
+ /** Requirement quotes that no OTHER title grounds — what the sweep must cover. */
45
+ orphaned: string[];
46
+ }
47
+ export interface BatchTestRewrite {
48
+ titles: string[];
49
+ actions: BatchTestAction[];
50
+ }
51
+ /**
52
+ * Rewrite a decomposed plan so it carries no whole-project batch test task when
53
+ * the decisions mandate tests-in-the-same-change.
54
+ *
55
+ * No mandate, or no batch title ⇒ the plan is returned untouched (identity), so
56
+ * every non-cadence run behaves exactly as before.
57
+ *
58
+ * With a mandate, ALL batch titles are removed at once before coverage is
59
+ * re-measured — otherwise two batch tasks would each mask the other's orphans and
60
+ * both would look droppable. The orphaned set is then whatever grounded coverage
61
+ * the removal costs; it is non-empty only when no other task's title claims that
62
+ * requirement, and it becomes the sweep's scope. At most ONE sweep is emitted (in
63
+ * the first batch title's position, so plan order is preserved).
64
+ */
65
+ export declare function rewriteBatchTestPlan(titles: string[], decisions: string, spec: string, requirementQuotes: string[], isCrossCutting: (quote: string) => boolean): BatchTestRewrite;
66
+ /**
67
+ * The decompose-prompt rule (the belt; the host rewrite above is the lever).
68
+ * Emitted ONLY when the decisions mandate the cadence, so ordinary runs see the
69
+ * prompt they always saw. Kept next to the detector so the two cannot drift.
70
+ */
71
+ export declare const DECOMPOSE_NO_BATCH_TESTS_RULE: string;