@mjasnikovs/pi-task 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,6 +89,12 @@ export interface AcceptDebt {
89
89
  * string is the VERIFY-block line itself (`inv-command-provenance`).
90
90
  */
91
91
  verifyCommand?: string;
92
+ /**
93
+ * The task whose verified work CLOSED this debt (a health repair whose check
94
+ * went green — see closeHealthDebts). A closed debt stays in the ledger as the
95
+ * record of what fixed it, and no re-check reads it again.
96
+ */
97
+ resolvedBy?: string;
92
98
  }
93
99
  export declare function acceptDebtFile(cwd: string): string;
94
100
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
@@ -146,6 +152,16 @@ export declare function crossTaskDeletionReason(deletion: {
146
152
  export declare function extractDeletedDebtPath(reason: string): string | null;
147
153
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
148
154
  export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
155
+ /** The debts nothing has closed yet — the only ones a re-check may read. */
156
+ export declare function readOpenAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
157
+ /**
158
+ * Close every open static-class debt that names `command`, stamping the task
159
+ * whose verified work made that check pass again. Returns the debts closed.
160
+ * A reason that quotes the command is the whole match: the health-check reason
161
+ * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
162
+ * and nothing else in the ledger quotes a health command. Best-effort.
163
+ */
164
+ export declare function closeHealthDebts(cwd: string, command: string, resolvedBy: string): Promise<AcceptDebt[]>;
149
165
  /**
150
166
  * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
151
167
  * health check (`repo health: …`, the prefix runWorkVerification's repoHealth branch
@@ -113,6 +113,7 @@ export function parseAcceptDebts(raw) {
113
113
  // 4th field: the verbatim VERIFY command the reason names.
114
114
  // Absent in every legacy record, and absent in most new ones.
115
115
  const verifyCommand = parts[3]?.trim();
116
+ const resolvedBy = parts[4]?.trim();
116
117
  out.push({
117
118
  taskId: parts[0].trim(),
118
119
  reason: parts[1].trim(),
@@ -120,7 +121,8 @@ export function parseAcceptDebts(raw) {
120
121
  // implicit class, so an absent origin and a spelled-out 'accepted' must
121
122
  // parse to the same record.
122
123
  ...(isKnownOrigin(origin) && origin !== 'accepted' ? { origin } : {}),
123
- ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {})
124
+ ...(verifyCommand !== undefined && verifyCommand.length > 0 ? { verifyCommand } : {}),
125
+ ...(resolvedBy !== undefined && resolvedBy.length > 0 ? { resolvedBy } : {})
124
126
  });
125
127
  }
126
128
  return out;
@@ -141,6 +143,16 @@ function serialize(d) {
141
143
  // only for the non-accepted classes, so old readers/files round-trip unchanged.
142
144
  // The 4th verify-command field forces the origin field to be written (positional
143
145
  // format) — 'accepted' spelled out there parses back to the same absent origin.
146
+ // The 5th field likewise forces an (empty) 4th.
147
+ if (d.resolvedBy !== undefined && d.resolvedBy.length > 0) {
148
+ return [
149
+ d.taskId,
150
+ d.reason,
151
+ d.origin ?? 'accepted',
152
+ d.verifyCommand ?? '',
153
+ d.resolvedBy
154
+ ].join(FIELD_SEP);
155
+ }
144
156
  if (d.verifyCommand !== undefined && d.verifyCommand.length > 0) {
145
157
  return [d.taskId, d.reason, d.origin ?? 'accepted', d.verifyCommand].join(FIELD_SEP);
146
158
  }
@@ -234,6 +246,33 @@ export function extractDeletedDebtPath(reason) {
234
246
  export async function writeAcceptDebts(cwd, debts) {
235
247
  await ledger.write(cwd, debts);
236
248
  }
249
+ /** The debts nothing has closed yet — the only ones a re-check may read. */
250
+ export async function readOpenAcceptDebts(cwd) {
251
+ return (await readAcceptDebts(cwd)).filter(d => d.resolvedBy === undefined);
252
+ }
253
+ /**
254
+ * Close every open static-class debt that names `command`, stamping the task
255
+ * whose verified work made that check pass again. Returns the debts closed.
256
+ * A reason that quotes the command is the whole match: the health-check reason
257
+ * (`repo health: \`bun run lint\` exited 1`) and its inherited form both do,
258
+ * and nothing else in the ledger quotes a health command. Best-effort.
259
+ */
260
+ export async function closeHealthDebts(cwd, command, resolvedBy) {
261
+ try {
262
+ const all = await readAcceptDebts(cwd);
263
+ const quoted = `\`${command}\``;
264
+ const closing = all.filter(d => d.resolvedBy === undefined
265
+ && isStaticClassDebt(d.reason)
266
+ && d.reason.includes(quoted));
267
+ if (closing.length === 0)
268
+ return [];
269
+ await ledger.write(cwd, all.map(d => (closing.includes(d) ? { ...d, resolvedBy } : d)));
270
+ return closing;
271
+ }
272
+ catch {
273
+ return [];
274
+ }
275
+ }
237
276
  /**
238
277
  * STATIC-CLASS debt: one whose accepted FAIL was the deterministic whole-repo static
239
278
  * health check (`repo health: …`, the prefix runWorkVerification's repoHealth branch
@@ -512,7 +551,10 @@ export function describeDebt(d) {
512
551
  export async function deriveOpenDebts(cwd, staticOk,
513
552
  /** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
514
553
  run = spawnCommand, signal) {
515
- const { open: openRaw, resolved, trail } = await recheckAcceptDebts(await readAcceptDebts(cwd), {
554
+ const all = await readAcceptDebts(cwd);
555
+ // Closed debts are kept as the record of what fixed them, and never re-checked.
556
+ const closed = all.filter(d => d.resolvedBy !== undefined);
557
+ const { open: openRaw, resolved, trail } = await recheckAcceptDebts(all.filter(d => d.resolvedBy === undefined), {
516
558
  staticOk,
517
559
  // Cross-task-deletion debts auto-close iff the deleted file is back in the
518
560
  // tree — a deterministic existence check, corroborating the per-file
@@ -524,7 +566,7 @@ run = spawnCommand, signal) {
524
566
  rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd, run, signal)
525
567
  });
526
568
  if (resolved.length > 0)
527
- await writeAcceptDebts(cwd, openRaw);
569
+ await writeAcceptDebts(cwd, [...closed, ...openRaw]);
528
570
  // Conflicting-claim annotation: an existence-as-failure debt whose
529
571
  // named file is another task's committed deliverable is a plan defect — surface
530
572
  // the contradiction with the debt so nobody (human or child) treats the claim as
@@ -127,6 +127,13 @@ export declare function recordTaskEnd(cwd: string, id: string, index: number, la
127
127
  * cached index.
128
128
  */
129
129
  export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
130
+ /**
131
+ * Insert a NEW unchecked entry directly BEFORE the `index`th checkbox — the
132
+ * position a health repair needs: the task about to run must wait until the red
133
+ * it would build on is fixed. Same monotonic, duplicate-refusing splice as
134
+ * {@link insertTaskAfter}.
135
+ */
136
+ export declare function insertTaskBefore(cwd: string, id: string, index: number, title: string): Promise<boolean>;
130
137
  /**
131
138
  * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
132
139
  * last-write time the resume banner reports (see resume-gap.ts). Null when there
@@ -294,6 +294,9 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
294
294
  if (!entry)
295
295
  continue;
296
296
  entries.push(entry);
297
+ // A negative afterIndex means "before the first entry".
298
+ if (entries.length === 1 && afterIndex < 0)
299
+ insertAt = i;
297
300
  if (entries.length - 1 <= afterIndex)
298
301
  insertAt = i + 1;
299
302
  }
@@ -310,6 +313,15 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
310
313
  await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
311
314
  return true;
312
315
  }
316
+ /**
317
+ * Insert a NEW unchecked entry directly BEFORE the `index`th checkbox — the
318
+ * position a health repair needs: the task about to run must wait until the red
319
+ * it would build on is fixed. Same monotonic, duplicate-refusing splice as
320
+ * {@link insertTaskAfter}.
321
+ */
322
+ export function insertTaskBefore(cwd, id, index, title) {
323
+ return insertTaskAfter(cwd, id, index - 1, title);
324
+ }
313
325
  /**
314
326
  * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
315
327
  * last-write time the resume banner reports (see resume-gap.ts). Null when there
@@ -16,12 +16,12 @@ 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, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
19
+ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, UNNAMED_COVERAGE_GAP, parseTaskList, planKeyAt, checkOffTask, stampTaskInProgress, beginTaskAttempt, recordTaskEnd, insertTaskAfter, insertTaskBefore, findResumableAutoDetailed } from './auto-io.js';
20
20
  import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
21
21
  import { ENTRY_ATTEMPT_BUDGET } from './gate-resolution.js';
22
22
  import { recordDebt } from './accept-debt.js';
23
23
  import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
24
- import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath } from './task-io.js';
24
+ import { writeTaskFile, readTaskFile, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
25
25
  // Re-exported as well as used: the @-mention helpers moved to their own module so
26
26
  // the research phase can select a cited spec doc without importing this one, and
27
27
  // the planning call sites still name them here.
@@ -37,6 +37,8 @@ import { getParentContextWindow } from './context-usage.js';
37
37
  import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
38
38
  import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
39
39
  import { runGatesForTask } from './task-gates.js';
40
+ import { buildHealthRepairFence, buildHealthRepairTitle, healthRedSubject, parseHealthRepairTitle, planCoversHealthRed } from './health-repair.js';
41
+ import { HEALTH_BASELINE_SECTION, parseHealthBaseline } from './health-baseline.js';
40
42
  import { runFinalGateStage } from './run-final-gate.js';
41
43
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
42
44
  import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
@@ -324,6 +326,9 @@ export function buildScopeFence(entries, currentIndex) {
324
326
  export function buildStepFence(entries, currentIndex) {
325
327
  const base = buildScopeFence(entries, currentIndex);
326
328
  const title = entries[currentIndex]?.title ?? '';
329
+ const healthRepair = parseHealthRepairTitle(title);
330
+ if (healthRepair)
331
+ return `${base}\n\n${buildHealthRepairFence(healthRepair)}`;
327
332
  const repairFile = parseRepairTitleFile(title);
328
333
  if (!repairFile)
329
334
  return base;
@@ -373,6 +378,53 @@ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
373
378
  // the plan is best-effort here; the underlying debt is already recorded
374
379
  }
375
380
  }
381
+ /**
382
+ * A red health check at the pre-task checkpoint becomes a repair entry spliced
383
+ * BEFORE the task about to run (health-repair.ts). Returns whether the plan
384
+ * changed — the caller then re-reads it and runs the repair first. No splice when
385
+ * the plan already covers this red (a repair that ran and failed included): the
386
+ * task then proceeds and inherits it, as the baseline differential intends.
387
+ */
388
+ async function spliceHealthRepair(cwd, id, next, entries, health, ctx, deps) {
389
+ try {
390
+ const red = healthRedSubject(health, cwd, (await deps.repoFiles?.(cwd)) ?? null);
391
+ if (!red)
392
+ return false;
393
+ if (planCoversHealthRed(entries.map(e => e.title), red))
394
+ return false;
395
+ const owners = [];
396
+ for (const f of red.files) {
397
+ const owner = await deps.introducedBy?.(cwd, f);
398
+ if (owner && !owners.includes(owner))
399
+ owners.push(owner);
400
+ }
401
+ const title = buildHealthRepairTitle({ ...red, owners });
402
+ if (!(await insertTaskBefore(cwd, id, next.index, title)))
403
+ return false;
404
+ await deps.record?.(cwd, id, `plan: \`${red.command}\` is red at the checkpoint before step ${next.index + 1} — inserted repair step first: ${title}`);
405
+ notifyRun(ctx, `${id}: \`${red.command}\` is red before "${next.title}" — queued a repair for `
406
+ + `${red.files.length > 0 ? red.files.join(', ') : 'it'} to run first.`, 'warning');
407
+ return true;
408
+ }
409
+ catch {
410
+ return false;
411
+ }
412
+ }
413
+ /**
414
+ * The health baseline a task starts from: the one its (resumed) task file already
415
+ * holds, else a fresh capture at the checkpoint. Null when neither is available.
416
+ */
417
+ async function baselineAtCheckpoint(ctx, cwd, resumeId, label, deps) {
418
+ if (resumeId) {
419
+ const stored = await readSection(cwd, resumeId, HEALTH_BASELINE_SECTION).catch(() => null);
420
+ const parsed = parseHealthBaseline(stored);
421
+ if (parsed)
422
+ return parsed;
423
+ }
424
+ if (!deps.captureHealthBaseline)
425
+ return null;
426
+ return deps.captureHealthBaseline(ctx, cwd, label);
427
+ }
376
428
  /**
377
429
  * ORIENT — read the feature, strike what must never reach a planning child, and
378
430
  * derive the requirement ledger. Depends on nothing but the feature and the tree,
@@ -1312,14 +1364,20 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1312
1364
  // the tree clean: what the project's own statics say now is what this task
1313
1365
  // INHERITED, and the verify gate attributes a red check against it instead
1314
1366
  // 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
1367
+ // inner task file does not exist yet, so the result is handed to the
1316
1368
  // 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
- : {};
1369
+ //
1370
+ // A RED baseline is also the one moment every way red enters the tree
1371
+ // is visible before anything builds on it — an accepted regression, a
1372
+ // leftover the checkpoint just committed, a repo red at run start — so
1373
+ // it is where the repair is scheduled (health-repair.ts).
1374
+ const baseline = await baselineAtCheckpoint(active, cwd, resumeId, next.title, deps);
1375
+ if (baseline
1376
+ && !baseline.outcome.ok
1377
+ && (await spliceHealthRepair(cwd, id, next, entries, baseline.outcome, active, deps))) {
1378
+ continue;
1379
+ }
1380
+ const healthBaseline = baseline ? { healthBaseline: () => Promise.resolve(baseline) } : {};
1323
1381
  // Stash ref before the task: compared after the gates so a stash pushed
1324
1382
  // during the task (impl model or any child) and left behind is called
1325
1383
  // out instead of silently waiting to detonate in a later task.
@@ -33,7 +33,7 @@ import { readEnvNotes, appendEnvNotes } from './env-notes.js';
33
33
  import { currentRunContext } from './run-context.js';
34
34
  import { runGateEvidence, evidenceVerifyFindings } from './gate-evidence.js';
35
35
  import { readContracts } from './contracts.js';
36
- import { recordDebt } from './accept-debt.js';
36
+ import { closeHealthDebts, recordDebt } from './accept-debt.js';
37
37
  import { recordRepairCandidate } from './root-cause-repair.js';
38
38
  import { runRepoHealthCheck } from './repo-health-check.js';
39
39
  import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
@@ -786,6 +786,7 @@ export function buildGateDeps(params) {
786
786
  // origin, and the final integration gate re-checks each one at run end.
787
787
  recordDebt,
788
788
  recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
789
+ closeHealthDebts,
789
790
  // file → introducing task, the provenance half of the discriminator.
790
791
  introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
791
792
  // Tracked paths, used only to resolve a bare file name a FAIL text names
@@ -0,0 +1,68 @@
1
+ /**
2
+ * health-repair — a red static check at a pre-task checkpoint becomes a repair
3
+ * entry in the running plan, BEFORE the next planned task builds on it.
4
+ *
5
+ * The failure this closes: red enters the tree by three doors — a task whose
6
+ * regression was accepted, a leftover from an abandoned run swept in by the
7
+ * checkpoint commit, a repo that was red when the run began — and none of them is
8
+ * a gate. The health baseline (health-baseline.ts) stops the NEXT task being
9
+ * blamed, but blaming nobody is not fixing it: every later task inherits the red,
10
+ * and only the run-end gate re-checks it. All three doors pass the checkpoint,
11
+ * which already measures health for the baseline, so that one measurement is
12
+ * where the repair is scheduled.
13
+ *
14
+ * The subject of a repair is what the health output NAMES: the tracked files the
15
+ * failing command reported, or the command itself when it named none. The plan is
16
+ * the dedup ledger — a title covering the same command, or any of the same files,
17
+ * means no second entry, checked-off ones included, which is what stops a repair
18
+ * that failed from being re-spawned.
19
+ */
20
+ import type { HealthSignal } from './health-baseline.js';
21
+ /** The failing check, and what its output named. */
22
+ export interface HealthRed {
23
+ command: string;
24
+ exitCode: number | null;
25
+ /** Repo-relative tracked paths the output named, in first-seen order. */
26
+ files: string[];
27
+ }
28
+ export interface HealthRedOwners {
29
+ /** Task ids whose commits introduced the named files; empty when nothing in
30
+ * the run owns them (a leftover, or a red the run started on). */
31
+ owners: string[];
32
+ }
33
+ /**
34
+ * What a red health result is about. Null when the result records no failing
35
+ * command (a legacy baseline, or a signal with no per-command detail) — there is
36
+ * nothing a repair could be pinned to.
37
+ */
38
+ export declare function healthRedSubject(health: HealthSignal & {
39
+ output?: string;
40
+ }, cwd: string, tracked: readonly string[] | null): HealthRed | null;
41
+ /**
42
+ * The plan title, in one of two fixed shapes the parser below recovers:
43
+ * `repair src/a.ts, src/b.ts: \`bun run lint\` exits 1 (introduced by TASK_0033)`
44
+ * `repair \`bun run lint\`: exits 1 (no task in this run owns it)`
45
+ * The subject sits right after the prefix so it is both the dedup key and what
46
+ * the scope fence pins.
47
+ */
48
+ export declare function buildHealthRepairTitle(red: HealthRed & HealthRedOwners): string;
49
+ export interface HealthRepairSubject {
50
+ command: string;
51
+ files: string[];
52
+ }
53
+ /** The command and files a health-repair title names, or null for any other title. */
54
+ export declare function parseHealthRepairTitle(title: string): HealthRepairSubject | null;
55
+ /**
56
+ * Does the plan already carry a repair for this red? Same command, or any of the
57
+ * same files — including a file-scoped root-cause repair (root-cause-repair.ts),
58
+ * which pins the same file. Checked-off entries count: a repair that ran and
59
+ * failed lands in the debt ledger, never in the plan a second time.
60
+ */
61
+ export declare function planCoversHealthRed(titles: readonly string[], red: HealthRed): boolean;
62
+ /**
63
+ * The extra scope fence a health-repair entry carries into refine. Without it
64
+ * "repair src/a.ts" refines into "overhaul the client", and a fix child left free
65
+ * to choose greens a linter fastest by suppressing it — which is how the red in
66
+ * the run this closes was painted over the first time.
67
+ */
68
+ export declare function buildHealthRepairFence(subject: HealthRepairSubject): string;
@@ -0,0 +1,124 @@
1
+ import { parseRepairTitleFile } from './root-cause-repair.js';
2
+ /** A path-like token: at least one directory separator, ending in a file name. */
3
+ const PATH_TOKEN_RE = /(?:[\w.@-]+[\\/])+[\w.@-]+\.\w+/g;
4
+ function normalisePath(p) {
5
+ return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
6
+ }
7
+ /**
8
+ * Resolve a token the output printed to the ONE tracked file it names. Linters
9
+ * print absolute paths, compilers print repo-relative ones, and a stack trace
10
+ * prints `node_modules/...` — the tracked list is what tells a deliverable from
11
+ * noise. An ambiguous suffix (two tracked `src/x.ts`) resolves to nothing rather
12
+ * than to a guess.
13
+ */
14
+ function resolveTracked(token, cwd, tracked) {
15
+ let t = normalisePath(token);
16
+ const root = normalisePath(cwd);
17
+ if (root.length > 0 && t.startsWith(`${root}/`))
18
+ t = t.slice(root.length + 1);
19
+ if (tracked.includes(t))
20
+ return t;
21
+ const bySuffix = tracked.filter(r => t.endsWith(`/${r}`));
22
+ return bySuffix.length === 1 ? bySuffix[0] : null;
23
+ }
24
+ /**
25
+ * What a red health result is about. Null when the result records no failing
26
+ * command (a legacy baseline, or a signal with no per-command detail) — there is
27
+ * nothing a repair could be pinned to.
28
+ */
29
+ export function healthRedSubject(health, cwd, tracked) {
30
+ const failing = (health.commands ?? []).find(c => c.outcome === 'fail');
31
+ if (!failing)
32
+ return null;
33
+ const files = [];
34
+ if (tracked) {
35
+ for (const m of (health.output ?? '').matchAll(PATH_TOKEN_RE)) {
36
+ const rel = resolveTracked(m[0], cwd, tracked);
37
+ if (rel !== null && !files.includes(rel))
38
+ files.push(rel);
39
+ }
40
+ }
41
+ return { command: failing.cmd, exitCode: failing.exitCode, files };
42
+ }
43
+ // ─── Plan entry ──────────────────────────────────────────────────────────────
44
+ /**
45
+ * The plan title, in one of two fixed shapes the parser below recovers:
46
+ * `repair src/a.ts, src/b.ts: \`bun run lint\` exits 1 (introduced by TASK_0033)`
47
+ * `repair \`bun run lint\`: exits 1 (no task in this run owns it)`
48
+ * The subject sits right after the prefix so it is both the dedup key and what
49
+ * the scope fence pins.
50
+ */
51
+ export function buildHealthRepairTitle(red) {
52
+ const exits = `exits ${red.exitCode ?? '?'}`;
53
+ const owner = red.owners.length > 0 ?
54
+ `introduced by ${red.owners.join(', ')}`
55
+ : 'no task in this run owns it';
56
+ return red.files.length > 0 ?
57
+ `repair ${red.files.join(', ')}: \`${red.command}\` ${exits} (${owner})`
58
+ : `repair \`${red.command}\`: ${exits} (${owner})`;
59
+ }
60
+ const PATH = String.raw `(?:[\w.@-]+\/)*[\w.@-]+\.\w+`;
61
+ const HEALTH_REPAIR_TITLE_RE = new RegExp(`^repair\\s+(?:\`([^\`]+)\`\\s*:\\s*exits|(${PATH}(?:,\\s*${PATH})*)\\s*:\\s*\`([^\`]+)\`\\s+exits)\\s`);
62
+ /** The command and files a health-repair title names, or null for any other title. */
63
+ export function parseHealthRepairTitle(title) {
64
+ const m = HEALTH_REPAIR_TITLE_RE.exec(title.trim());
65
+ if (!m)
66
+ return null;
67
+ if (m[1] !== undefined)
68
+ return { command: m[1].trim(), files: [] };
69
+ return { command: m[3].trim(), files: m[2].split(',').map(f => f.trim()) };
70
+ }
71
+ /**
72
+ * Does the plan already carry a repair for this red? Same command, or any of the
73
+ * same files — including a file-scoped root-cause repair (root-cause-repair.ts),
74
+ * which pins the same file. Checked-off entries count: a repair that ran and
75
+ * failed lands in the debt ledger, never in the plan a second time.
76
+ */
77
+ export function planCoversHealthRed(titles, red) {
78
+ const files = new Set(red.files.map(f => normalisePath(f).toLowerCase()));
79
+ return titles.some(t => {
80
+ const rootCause = parseRepairTitleFile(t);
81
+ if (rootCause !== null && files.has(normalisePath(rootCause).toLowerCase()))
82
+ return true;
83
+ const h = parseHealthRepairTitle(t);
84
+ if (h === null)
85
+ return false;
86
+ if (h.command === red.command)
87
+ return true;
88
+ return h.files.some(f => files.has(normalisePath(f).toLowerCase()));
89
+ });
90
+ }
91
+ /**
92
+ * The extra scope fence a health-repair entry carries into refine. Without it
93
+ * "repair src/a.ts" refines into "overhaul the client", and a fix child left free
94
+ * to choose greens a linter fastest by suppressing it — which is how the red in
95
+ * the run this closes was painted over the first time.
96
+ */
97
+ export function buildHealthRepairFence(subject) {
98
+ const scope = subject.files.length > 0 ?
99
+ [
100
+ ` - ${subject.files.map(f => `\`${f}\``).join(', ')} ${subject.files.length > 1 ? 'are' : 'is'} the ONLY`,
101
+ ' file(s) you may modify. Do not refactor, restructure, or "improve" anything else,',
102
+ ' and do not create new files.'
103
+ ]
104
+ : [
105
+ ' - Modify only the files the command reports. Do not refactor, restructure, or',
106
+ ' "improve" anything else, and do not create new files.'
107
+ ];
108
+ return [
109
+ `REPAIR TASK — this step exists ONLY to make \`${subject.command}\` pass again. It`,
110
+ 'was created because that check was red before this step, and every later step',
111
+ 'would otherwise build on the red; it is not a feature step and must not grow into one.',
112
+ '',
113
+ 'HARD CONSTRAINTS for this step (they override any broader reading of the title):',
114
+ ...scope,
115
+ ' - Fix the reported findings and nothing more. Do NOT redesign the build, the',
116
+ ' lint configuration, the schema, or any shared infrastructure — a wider change',
117
+ " here would silently overwrite sibling tasks' shipped work.",
118
+ ' - Do NOT suppress, disable, ignore or weaken the check to make it pass: no',
119
+ ' disable comments, no ignore entries, no relaxed rules, no deleted or skipped',
120
+ ' tests. A finding is fixed in the code it reports.',
121
+ ` - The VERIFY block MUST be exactly: \`${subject.command}\` — the check that was`,
122
+ ' red. It passing is the whole acceptance criterion.'
123
+ ].join('\n');
124
+ }
@@ -176,6 +176,16 @@ export interface GateDeps {
176
176
  * queues repairs exactly like /task-auto — only the plan mutation is the loop's.
177
177
  */
178
178
  recordRepairCandidate?: (cwd: string, candidate: RepairCandidate) => Promise<void>;
179
+ /**
180
+ * Close the open static debts naming a health command, stamped with the task
181
+ * whose verified work made that check pass — a health repair entry
182
+ * (health-repair.ts) going green. Returns the debts closed. Absent (tests) →
183
+ * nothing closes.
184
+ */
185
+ closeHealthDebts?: (cwd: string, command: string, resolvedBy: string) => Promise<{
186
+ taskId: string;
187
+ reason: string;
188
+ }[]>;
179
189
  /**
180
190
  * Paths the CURRENT task's own work touches — the AUTHORSHIP discriminator for
181
191
  * the root-cause channel: a FAIL blamed on a file this task edited may well be
@@ -282,7 +292,9 @@ export type GateResult = {
282
292
  * clarify/grill dialog: the same SessionUI.ask races the local boxed picker against
283
293
  * a remote answer, with the two actions also surfaced as remote buttons.
284
294
  */
285
- export declare function askVerifyResolution(ctx: ExtensionCommandContext, title: string, failReason: string, rec: ResolutionOutcome): Promise<ResolutionChoice>;
295
+ export declare function askVerifyResolution(ctx: ExtensionCommandContext, title: string, failReason: string, rec: ResolutionOutcome,
296
+ /** The repair ACCEPT would queue (a regressed health check), if any. */
297
+ acceptQueues?: string): Promise<ResolutionChoice>;
286
298
  /**
287
299
  * Run the verify + enforce gates against a task's just-finished implementation.
288
300
  *
@@ -6,6 +6,7 @@ import { SessionUI, notifyBoth, notifyRun } from '../remote/bridge.js';
6
6
  import { isYoloMode, YOLO_STAMP } from './yolo.js';
7
7
  import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
8
8
  import { attributeEnforceFailure } from './enforce-attribution.js';
9
+ import { healthRedSubject, parseHealthRepairTitle } from './health-repair.js';
9
10
  // The debt ledger is reached through the injected `recordDebt` dep (so it stays
10
11
  // absent-in-tests); only the origin TYPE and the cross-task-deletion reason SHAPE
11
12
  // come from accept-debt.ts directly — the latter because its writer and its
@@ -23,10 +24,13 @@ import { updateTaskFrontMatter } from './task-io.js';
23
24
  * clarify/grill dialog: the same SessionUI.ask races the local boxed picker against
24
25
  * a remote answer, with the two actions also surfaced as remote buttons.
25
26
  */
26
- export async function askVerifyResolution(ctx, title, failReason, rec) {
27
- const options = resolutionOptions(rec.recommend);
27
+ export async function askVerifyResolution(ctx, title, failReason, rec,
28
+ /** The repair ACCEPT would queue (a regressed health check), if any. */
29
+ acceptQueues) {
30
+ const options = resolutionOptions(rec.recommend, acceptQueues);
28
31
  const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
29
- + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
32
+ + `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`
33
+ + (acceptQueues ? `\n\nACCEPT queues ${acceptQueues} before the next task.` : '');
30
34
  const answer = await new SessionUI(ctx).ask({
31
35
  localTitle: 'Verification failed — how should pi proceed?',
32
36
  displayQuestion: question,
@@ -38,6 +42,12 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
38
42
  });
39
43
  return classifyResolutionAnswer(answer);
40
44
  }
45
+ /** The repair a red health check earns, as the picker and the trail name it. */
46
+ function describeHealthRepair(red) {
47
+ return red.files.length > 0 ?
48
+ `a repair for ${red.files.join(', ')} (\`${red.command}\`)`
49
+ : `a repair for \`${red.command}\``;
50
+ }
41
51
  /**
42
52
  * The VERIFY resolution loop: run the task's verification against the finished
43
53
  * work, and carry out what gate-resolution.ts's decision table says to do with a
@@ -72,6 +82,14 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
72
82
  // recording must never break the gate sequence
73
83
  }
74
84
  };
85
+ const healthRedOf = async (health) => {
86
+ try {
87
+ return healthRedSubject(health, p.cwd, (await deps.repoFiles?.(p.cwd)) ?? null);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ };
75
93
  // GATE: actually RUN the task's verification against the just-finished work
76
94
  // BEFORE it is checked off or committed. Whether this produced a GENUINE clean
77
95
  // pass (a real signal ran and the work met it) also decides how the enforce pass
@@ -142,6 +160,12 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
142
160
  await rec(`resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
143
161
  contradiction ??= recOutcome.contradiction ?? null;
144
162
  const unattended = isYoloMode();
163
+ // A regressed health check is THIS task's red. Accepting it ships the
164
+ // red, and the next checkpoint splices a repair before anything builds
165
+ // on it (health-repair.ts) — named here so a human choosing ACCEPT
166
+ // sees what the choice queues, and the trail says the same.
167
+ const regression = verified.health ? await healthRedOf(verified.health) : null;
168
+ const acceptQueues = regression ? describeHealthRepair(regression) : undefined;
145
169
  const disposition = resolveDisposition({
146
170
  failClass,
147
171
  recommend: recOutcome.recommend,
@@ -170,7 +194,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
170
194
  if (disposition.rule !== 'judge-accept') {
171
195
  await rec(`resolution: asking the human — ${disposition.reason}`);
172
196
  }
173
- choice = await askVerifyResolution(active, p.title, failReason, recOutcome);
197
+ choice = await askVerifyResolution(active, p.title, failReason, recOutcome, acceptQueues);
174
198
  }
175
199
  if (choice.action === 'cancel') {
176
200
  await rec('resolution: user dismissed the verify-FAIL picker — paused');
@@ -193,6 +217,9 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
193
217
  await settleDebt(origin, disposition.rule === 'spec-contradiction' ?
194
218
  `${failReason} — ${disposition.reason}`
195
219
  : failReason);
220
+ if (acceptQueues) {
221
+ await rec(`accept: repo health regressed by this task — ${acceptQueues} is spliced before the next task`);
222
+ }
196
223
  // ROOT CAUSE: an accepted FAIL that some OTHER task's file caused is
197
224
  // not fixed by accepting it — every later task keeps tripping over
198
225
  // the same bug. Queue the scoped repair so the plan closes it.
@@ -283,6 +310,21 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
283
310
  await rec(`accept-debt: inherited repo health — ${verified.inheritedHealth}`);
284
311
  await settleDebt('inherited-health', verified.inheritedHealth);
285
312
  }
313
+ // A health repair that verified CLEAN — the check it exists for ran green,
314
+ // nothing inherited — closes the debts that check opened, under its own id.
315
+ const repair = parseHealthRepairTitle(p.title);
316
+ if (repair && verified.ok && !verified.reason && !verified.inheritedHealth) {
317
+ try {
318
+ const closed = await deps.closeHealthDebts?.(p.cwd, repair.command, p.taskId);
319
+ if (closed && closed.length > 0) {
320
+ await rec(`accept-debt: closed ${closed.length} debt(s) on \`${repair.command}\` — `
321
+ + `${closed.map(d => d.taskId).join(', ')} — repaired by ${p.taskId}`);
322
+ }
323
+ }
324
+ catch {
325
+ // closing debts must never break the gate sequence
326
+ }
327
+ }
286
328
  // Loop exited because the work verified OR the user accepted the artifact. A
287
329
  // genuine clean pass is ok===true with NO reason; a no-op pass or an
288
330
  // accept-override (verified.ok still false at break) is NOT a guardable signal.
@@ -64,7 +64,10 @@ export interface ResolutionChoice {
64
64
  * `recommended: i === 0`). Values are the bare tokens; remote buttons carry the
65
65
  * labels — {@link classifyResolutionAnswer} accepts both.
66
66
  */
67
- export declare function resolutionOptions(recommend: ResolutionRecommendation): {
67
+ export declare function resolutionOptions(recommend: ResolutionRecommendation,
68
+ /** What ACCEPT queues besides keeping the artifact — the repair a red health
69
+ * check earns — so the picker shows the whole consequence of the choice. */
70
+ acceptQueues?: string): {
68
71
  label: string;
69
72
  value: string;
70
73
  recommended: boolean;
@@ -193,8 +193,14 @@ export const AUTOFIX_LABEL = 'Autofix — re-run the task to fix it, then verify
193
193
  * `recommended: i === 0`). Values are the bare tokens; remote buttons carry the
194
194
  * labels — {@link classifyResolutionAnswer} accepts both.
195
195
  */
196
- export function resolutionOptions(recommend) {
197
- const accept = { label: ACCEPT_LABEL, value: ACCEPT_VALUE };
196
+ export function resolutionOptions(recommend,
197
+ /** What ACCEPT queues besides keeping the artifact — the repair a red health
198
+ * check earns — so the picker shows the whole consequence of the choice. */
199
+ acceptQueues) {
200
+ const accept = {
201
+ label: acceptQueues ? `${ACCEPT_LABEL}; queues ${acceptQueues}` : ACCEPT_LABEL,
202
+ value: ACCEPT_VALUE
203
+ };
198
204
  const autofix = { label: AUTOFIX_LABEL, value: AUTOFIX_VALUE };
199
205
  return recommend === 'accept' ?
200
206
  [
@@ -35,6 +35,7 @@ export interface VerifyPass {
35
35
  unobserved?: undefined;
36
36
  crossTaskDeletions?: undefined;
37
37
  inheritedHealth?: string;
38
+ health?: undefined;
38
39
  probes?: ProbeFindings;
39
40
  }
40
41
  /**
@@ -63,6 +64,11 @@ export interface VerifyFail {
63
64
  * then ships in the next commit and the final gate must re-check it. */
64
65
  crossTaskDeletions?: CrossTaskDeletion[];
65
66
  inheritedHealth?: string;
67
+ /** The health result behind a `repo-health` FAIL: which command, and what its
68
+ * output named. What an ACCEPT of this FAIL hands to the repair channel. */
69
+ health?: HealthSignal & {
70
+ output?: string;
71
+ };
66
72
  /** What the deterministic probes found for THIS verdict, carried out so an
67
73
  * AUTOFIX re-run is told what the gate already knows (see fix-context.ts)
68
74
  * instead of re-deriving it from the one-line reason. */
@@ -254,6 +260,7 @@ export interface VerificationDeps {
254
260
  repoHealth?: () => Promise<{
255
261
  ok: boolean;
256
262
  reason: string;
263
+ output?: string;
257
264
  } & HealthSignal>;
258
265
  /**
259
266
  * What those same checks said BEFORE this task ran (see health-baseline.ts).
@@ -908,7 +908,12 @@ export async function runWorkVerification(deps) {
908
908
  if (!h.ok) {
909
909
  const baseline = deps.healthBaseline ? await deps.healthBaseline() : null;
910
910
  if (classifyHealthDelta(baseline?.outcome ?? null, h) === 'regressed') {
911
- return { ok: false, failClass: 'repo-health', reason: `repo health: ${h.reason}` };
911
+ return {
912
+ ok: false,
913
+ failClass: 'repo-health',
914
+ reason: `repo health: ${h.reason}`,
915
+ health: h
916
+ };
912
917
  }
913
918
  pre.repoHealth = inheritedHealthFindings(h);
914
919
  inheritedHealth = `repo health: ${h.reason} — already failing before this task`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",