@mjasnikovs/pi-task 0.38.9 → 0.38.11

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.
@@ -35,3 +35,8 @@ export declare function readLaunchManifest(cwd: string): LaunchManifest;
35
35
  * silently did nothing must not read later as a check that passed.
36
36
  */
37
37
  export declare function inertLaunchContractNote(declared: string[], manifest: LaunchManifest): string;
38
+ /** The `scripts` map of this tree's package.json, or `{}` when there isn't one.
39
+ * Shared by the gate and the boot probe — both ask the same manifest question. */
40
+ export declare function packageScripts(cwd: string): Record<string, string>;
41
+ /** Does this tree's Makefile declare `target`? */
42
+ export declare function makeHasTarget(cwd: string, target: string): boolean;
@@ -149,3 +149,24 @@ export function inertLaunchContractNote(declared, manifest) {
149
149
  + `but this project could not be diffed against a manifest — ${manifest.why ?? 'no manifest'}. `
150
150
  + 'The contract was NOT checked here.');
151
151
  }
152
+ /** The `scripts` map of this tree's package.json, or `{}` when there isn't one.
153
+ * Shared by the gate and the boot probe — both ask the same manifest question. */
154
+ export function packageScripts(cwd) {
155
+ try {
156
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
157
+ return j.scripts ?? {};
158
+ }
159
+ catch {
160
+ return {};
161
+ }
162
+ }
163
+ /** Does this tree's Makefile declare `target`? */
164
+ export function makeHasTarget(cwd, target) {
165
+ try {
166
+ const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
167
+ return new RegExp(`^${target}:`, 'm').test(mk);
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
@@ -303,7 +303,7 @@ export class TaskRunner {
303
303
  this._stopWidget = null;
304
304
  }
305
305
  async _deliverSpec(_ctx) {
306
- const spec = this._specForDelivery();
306
+ const spec = await this._specForDelivery();
307
307
  // Keep the rich status block alive across the implementation turn (the phase
308
308
  // widget was disposed at handoff). Awaited (/task-auto) stays armed across all
309
309
  // sub-turns and is disarmed here; fire-and-forget (/task) arms one-shot and its
@@ -343,8 +343,8 @@ export class TaskRunner {
343
343
  * deterministic check proves does not exist. No-op (returns the spec unchanged) when
344
344
  * nothing is flagged or the runtime's types aren't installed.
345
345
  */
346
- _specForDelivery() {
347
- const phantoms = findDeliveryPhantoms(this._pc.spec, this._cwd);
346
+ async _specForDelivery() {
347
+ const phantoms = await findDeliveryPhantoms(this._pc.spec, this._cwd);
348
348
  const apiBanner = formatApiOverrideBanner(phantoms);
349
349
  if (apiBanner) {
350
350
  this._deps.logDebug?.(`impl-handoff API override banner prepended for: ${phantoms.map(p => p.spec).join(', ')}`);
@@ -5,6 +5,7 @@
5
5
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
7
  import { fetchFocused } from '../workers/fetch-core.js';
8
+ import { type RunWorkerInput, type RunWorkerResult } from '../workers/pi-worker-core.js';
8
9
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
9
10
  import type { SearchProvider } from '../workers/search-types.js';
10
11
  import { type ExternalContextDeps } from './external-context.js';
@@ -102,6 +103,23 @@ export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: s
102
103
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
103
104
  export interface PhaseResearchDeps extends ExternalContextDeps {
104
105
  getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
106
+ /**
107
+ * Run ONE research worker. Absent (production) → the real `runWorker`.
108
+ *
109
+ * The seam exists for the same reason `getFileInventory` does: every decision
110
+ * `runSpec` makes — the three Research retry gates, the fatal/runaway/empty
111
+ * classification, the marker choice, `postProcess` — is a pure function of the
112
+ * returned `RunWorkerResult`, but reaching any of them used to require driving
113
+ * a fake process that emits JSON events. Substituting the result lets a gate be
114
+ * tested by the fields it actually reads.
115
+ *
116
+ * `label` is the worker's name — the same one `recordWorker` trails. It is
117
+ * passed because a substitute must answer differently per worker, and the only
118
+ * alternative is matching a marker sentence inside the prompt, which makes
119
+ * prompt copy load-bearing test infrastructure. Same reason
120
+ * `PhaseDeps.runChild` takes a name.
121
+ */
122
+ runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
105
123
  }
106
124
  /**
107
125
  * Is live web search configured for this process? The keyless providers (exa,
@@ -606,6 +606,7 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
606
606
  + 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
607
607
  export async function phaseResearch(deps, refined, researchDeps = {}) {
608
608
  const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
609
+ const runWorkerFn = researchDeps.runWorker ?? ((_label, input) => runWorker(input));
609
610
  const externalContext = await gatherExternalContext(refined, deps, researchDeps);
610
611
  // Pre-compute the project file inventory once and hand it to every worker.
611
612
  // Workers can then jump straight to targeted read/grep on known paths
@@ -859,7 +860,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
859
860
  }
860
861
  deps.logDebug?.(`${spec.label}: start`);
861
862
  const basePrompt = typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt;
862
- const runOnce = (extraPreamble) => recordWorker(spec.label, runWorker({
863
+ const runOnce = (extraPreamble) => recordWorker(spec.label, runWorkerFn(spec.label, {
863
864
  prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
864
865
  cwd: deps.cwd,
865
866
  signal: deps.signal,
@@ -1510,7 +1511,7 @@ export const PHASES = [
1510
1511
  // (proven: compose re-leaks it 4/4). Rewriting the source so compose has
1511
1512
  // nothing to contradict is the fix. Silent + no-op when nothing is wrong
1512
1513
  // or the runtime's types aren't installed.
1513
- const phantoms = findPhantomImports(refined, d.cwd);
1514
+ const phantoms = await findPhantomImports(refined, d.cwd);
1514
1515
  if (phantoms.length === 0)
1515
1516
  return refined;
1516
1517
  d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
@@ -1534,7 +1535,7 @@ export const PHASES = [
1534
1535
  // through every phase and the implementer fabricates a `declare module`
1535
1536
  // shim to compile it. Append the corrections so compose folds them into
1536
1537
  // CONSTRAINTS. No LLM cost and silent when nothing is wrong.
1537
- const corrections = formatApiCorrections(findPhantomImports(p.refined, d.cwd));
1538
+ const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
1538
1539
  if (corrections) {
1539
1540
  d.logDebug?.(`phantom imports flagged:\n${corrections}`);
1540
1541
  return `${out}\n\n${corrections}`;
@@ -63,6 +63,18 @@ export interface GateDeps {
63
63
  * tests or when `verify work` is off → the sequence treats it as a pass.
64
64
  */
65
65
  verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
66
+ /**
67
+ * The DIFFERENTIAL re-verify the enforce pass runs against the enforced tree,
68
+ * to decide whether its own commit survives. Absent → `verify`, which is what
69
+ * it has always been, so production wiring is untouched.
70
+ *
71
+ * Its own field because it answers a different question from the gate above.
72
+ * While the two shared one field the only way to answer them differently was to
73
+ * count invocations — a `verifyCalls` state machine whose FIRST return existed
74
+ * solely to unlock `mode === 'edit'`, re-invented in the suite and again in
75
+ * scripts/enforce-revert-attribution-replay-ab.ts.
76
+ */
77
+ reVerify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
66
78
  /**
67
79
  * Hold the committed work to AGENTS.md / CLAUDE.md. `edit` (fix in place) only
68
80
  * with a clean verify signal to guard against; otherwise `flag` (report only).
@@ -293,4 +305,61 @@ export declare function askVerifyResolution(ctx: ExtensionCommandContext, title:
293
305
  * only a user cancel inside a gate child propagates (handled by the caller's
294
306
  * USER_CANCELLED path).
295
307
  */
308
+ /** The durable per-task gate trail: every outcome is appended to the task file so
309
+ * the sequence is auditable from artifacts alone. Best-effort — recording must
310
+ * never break the gate sequence. */
311
+ type Recorder = (line: string) => Promise<void>;
312
+ /**
313
+ * The root-cause channel: was this FAIL caused by a pre-existing defect in a file
314
+ * some OTHER task created and this task never touched?
315
+ */
316
+ type RootCauseRouter = (failReason: string, rationale: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<RepairCandidate | null>;
317
+ /**
318
+ * What the VERIFY half settled. Either the sequence is over (`stop` carries the
319
+ * terminal GateResult — dismissed picker, cancelled session, interrupted or failed
320
+ * autofix) or the task proceeds to commit + enforce.
321
+ *
322
+ * `cleanPass` is the ONE fact that crosses to the enforce half: a GENUINE clean
323
+ * pass (a real signal ran and the work met it) is the only thing that gives
324
+ * enforce a signal to revert against, so only then may it edit in place. A no-op
325
+ * pass, a disabled gate or an accept-override leaves it false → flag-only.
326
+ */
327
+ type VerifyGateStep = {
328
+ stop: GateResult;
329
+ } | {
330
+ proceed: {
331
+ ctx: ExtensionCommandContext;
332
+ cleanPass: boolean;
333
+ };
334
+ };
335
+ /**
336
+ * The VERIFY resolution loop: run the task's verification against the finished
337
+ * work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
338
+ * recommendation → unattended autofix → picker) until it verifies, is accepted,
339
+ * or terminates.
340
+ *
341
+ * Split out of `runGatesForTask` at the single boolean that crosses to the
342
+ * ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
343
+ * exits; enforce carries one local and always falls through. Joining them meant a
344
+ * test of the enforce differential had to traverse this whole loop first, which is
345
+ * why `deps.verify` was driven by an invocation counter whose first return existed
346
+ * only to unlock `mode === 'edit'`.
347
+ */
348
+ export declare function resolveVerifyGate(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter): Promise<VerifyGateStep>;
349
+ /**
350
+ * The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
351
+ * then decide whether the pass\'s own commit survives.
352
+ *
353
+ * `reVerify` is deliberately NOT `deps.verify`. They answer two different
354
+ * questions — the gate above, and this differential — and while they shared one
355
+ * field the only way to answer them differently was to count invocations.
356
+ *
357
+ * Reads `active` and never reassigns it: nothing here can replace the live
358
+ * session, unlike the autofix in the verify half.
359
+ */
360
+ export declare function runEnforcePass(active: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter, args: {
361
+ cleanPass: boolean;
362
+ commit: CommitResult;
363
+ }): Promise<void>;
296
364
  export declare function runGatesForTask(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams): Promise<GateResult>;
365
+ export {};
@@ -67,60 +67,20 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
67
67
  return classifyResolutionAnswer(answer);
68
68
  }
69
69
  /**
70
- * Run the verify + enforce gates against a task's just-finished implementation.
70
+ * The VERIFY resolution loop: run the task's verification against the finished
71
+ * work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
72
+ * recommendation → unattended autofix → picker) until it verifies, is accepted,
73
+ * or terminates.
71
74
  *
72
- * Lifted verbatim from /task-auto's per-task loop so the two commands gate
73
- * identically. Returns a GateResult; `done` means the caller should proceed (the
74
- * work is verified-or-accepted, checked off, committed, and enforced), every other
75
- * kind is a terminal stop the caller announces. Never throws for a gate outcome —
76
- * only a user cancel inside a gate child propagates (handled by the caller's
77
- * USER_CANCELLED path).
75
+ * Split out of `runGatesForTask` at the single boolean that crosses to the
76
+ * ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
77
+ * exits; enforce carries one local and always falls through. Joining them meant a
78
+ * test of the enforce differential had to traverse this whole loop first, which is
79
+ * why `deps.verify` was driven by an invocation counter whose first return existed
80
+ * only to unlock `mode === 'edit'`.
78
81
  */
79
- export async function runGatesForTask(ctxIn, deps, p) {
82
+ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
80
83
  let active = ctxIn;
81
- // Durable per-task gate trail — every outcome below is also appended to the
82
- // task file so the sequence is auditable from artifacts alone. Best-effort.
83
- const rec = async (line) => {
84
- try {
85
- await deps.record?.(p.cwd, p.taskId, line);
86
- }
87
- catch {
88
- // recording must never break the gate sequence
89
- }
90
- };
91
- /**
92
- * ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
93
- * pre-existing defect in a file some OTHER task created and this task never
94
- * touched. On a hit: record the durable debt (so the final gate surfaces it)
95
- * and queue a scoped repair task (so something finally FIXES it — run 14
96
- * recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
97
- * the bug survived ~24h). Returns the candidate so the caller can also decide
98
- * NOT to punish the current task for it. Never throws: any fault degrades to
99
- * null, i.e. exactly the pre-existing behavior.
100
- */
101
- const routeRootCause = async (failReason, rationale, scope) => {
102
- if (!deps.touchedFiles || !deps.introducedBy)
103
- return null;
104
- try {
105
- const candidate = await findRepairCandidate({
106
- failReason,
107
- rationale,
108
- currentTaskId: p.taskId,
109
- touched: await deps.touchedFiles(p.cwd, scope),
110
- introducedBy: rel => deps.introducedBy(p.cwd, rel)
111
- });
112
- if (!candidate)
113
- return null;
114
- await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
115
- await deps.recordRepairCandidate?.(p.cwd, candidate);
116
- await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
117
- + 'recorded as durable debt and a scoped repair task queued');
118
- return candidate;
119
- }
120
- catch {
121
- return null;
122
- }
123
- };
124
84
  const verdictLine = (v) => v.ok ?
125
85
  v.reason ?
126
86
  `verify: PASS (${v.reason})`
@@ -289,7 +249,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
289
249
  }
290
250
  if (choice.action === 'cancel') {
291
251
  await rec('resolution: user dismissed the verify-FAIL picker — paused');
292
- return { kind: 'paused', ctx: active, reason: failReason };
252
+ return { stop: { kind: 'paused', ctx: active, reason: failReason } };
293
253
  }
294
254
  if (choice.action === 'accept') {
295
255
  const byYolo = yoloChoice !== null;
@@ -360,11 +320,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
360
320
  });
361
321
  active = fixRes.ctx ?? active;
362
322
  if (fixRes.sessionCancelled)
363
- return { kind: 'session-cancelled', ctx: active };
323
+ return { stop: { kind: 'session-cancelled', ctx: active } };
364
324
  if (fixRes.interrupted)
365
- return { kind: 'interrupted', ctx: active };
325
+ return { stop: { kind: 'interrupted', ctx: active } };
366
326
  if (!fixRes.ok)
367
- return { kind: 'failed', ctx: active, reason: fixRes.reason };
327
+ return { stop: { kind: 'failed', ctx: active, reason: fixRes.reason } };
368
328
  // Resume reuses the same inner task id, so p.taskId is stable.
369
329
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
370
330
  await rec(verdictLine(verified));
@@ -374,39 +334,21 @@ export async function runGatesForTask(ctxIn, deps, p) {
374
334
  // accept-override (verified.ok still false at break) is NOT a guardable signal.
375
335
  verifyCleanPass = verified.ok && !verified.reason;
376
336
  }
377
- // Mark the work verified (parent task-list check-off for /task-auto; no-op for
378
- // /task) BEFORE committing, so the commit captures the check-off too.
379
- await p.onVerified?.();
380
- // Commit the task's work as one snapshot FIRST before guideline enforcement —
381
- // so a passing task is durably recorded no matter what enforcement later finds.
382
- const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
383
- if (commit.committed) {
384
- await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
385
- // SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
386
- // output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
387
- // failure screenshots and two later fix attempts were rejected for deleting
388
- // them). A SILENT exclusion is the same failure class as the silent
389
- // ignored-path write nexttask 4 closed, so it gets its own trail line.
390
- if (commit.excluded && commit.excluded.length > 0) {
391
- await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
392
- + `snapshot — regenerable output, not deliverables: `
393
- + `${commit.excluded.slice(0, 8).join(', ')}`
394
- + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
395
- }
396
- active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
397
- }
398
- else {
399
- await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
400
- // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
401
- // git failure is louder: it silently disables enforce AND every commit-based
402
- // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
403
- // per-task warnings to show for it. "blocked" is the unmerged-index refusal
404
- // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
405
- const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
406
- active.ui.notify(gitFailure ?
407
- `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
408
- : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
409
- }
337
+ return { proceed: { ctx: active, cleanPass: verifyCleanPass } };
338
+ }
339
+ /**
340
+ * The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
341
+ * then decide whether the pass\'s own commit survives.
342
+ *
343
+ * `reVerify` is deliberately NOT `deps.verify`. They answer two different
344
+ * questions the gate above, and this differential — and while they shared one
345
+ * field the only way to answer them differently was to count invocations.
346
+ *
347
+ * Reads `active` and never reassigns it: nothing here can replace the live
348
+ * session, unlike the autofix in the verify half.
349
+ */
350
+ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args) {
351
+ const { cleanPass: verifyCleanPass, commit } = args;
410
352
  // With the task committed, hold its work to AGENTS.md / CLAUDE.md — but as a step
411
353
  // INSIDE the validation gate, gated by the verify signal (see GateDeps.enforce).
412
354
  // Skipped when nothing was committed this round, when enforce is off, or in tests
@@ -516,8 +458,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
516
458
  if (enforceCommit.committed) {
517
459
  // Differential guard: re-run the verify signal against the enforced
518
460
  // tree. A regression ⇒ drop the enforce commit, keep the verified work.
519
- const after = deps.verify ?
520
- await deps.verify(active, p.cwd, p.title, p.taskId)
461
+ const differential = deps.reVerify ?? deps.verify;
462
+ const after = differential ?
463
+ await differential(active, p.cwd, p.title, p.taskId)
521
464
  : { ok: true };
522
465
  const afterReason = after.reason ?? 'enforce re-verify failed';
523
466
  // PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
@@ -632,5 +575,86 @@ export async function runGatesForTask(ctxIn, deps, p) {
632
575
  // so a missing enforce run is explainable from the trail (mx5 audit gap).
633
576
  await rec('enforce: skipped (nothing committed this round)');
634
577
  }
578
+ }
579
+ export async function runGatesForTask(ctxIn, deps, p) {
580
+ const rec = async (line) => {
581
+ try {
582
+ await deps.record?.(p.cwd, p.taskId, line);
583
+ }
584
+ catch {
585
+ // recording must never break the gate sequence
586
+ }
587
+ };
588
+ /**
589
+ * ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
590
+ * pre-existing defect in a file some OTHER task created and this task never
591
+ * touched. On a hit: record the durable debt (so the final gate surfaces it)
592
+ * and queue a scoped repair task (so something finally FIXES it — run 14
593
+ * recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
594
+ * the bug survived ~24h). Returns the candidate so the caller can also decide
595
+ * NOT to punish the current task for it. Never throws: any fault degrades to
596
+ * null, i.e. exactly the pre-existing behavior.
597
+ */
598
+ const routeRootCause = async (failReason, rationale, scope) => {
599
+ if (!deps.touchedFiles || !deps.introducedBy)
600
+ return null;
601
+ try {
602
+ const candidate = await findRepairCandidate({
603
+ failReason,
604
+ rationale,
605
+ currentTaskId: p.taskId,
606
+ touched: await deps.touchedFiles(p.cwd, scope),
607
+ introducedBy: rel => deps.introducedBy(p.cwd, rel)
608
+ });
609
+ if (!candidate)
610
+ return null;
611
+ await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
612
+ await deps.recordRepairCandidate?.(p.cwd, candidate);
613
+ await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
614
+ + 'recorded as durable debt and a scoped repair task queued');
615
+ return candidate;
616
+ }
617
+ catch {
618
+ return null;
619
+ }
620
+ };
621
+ const step = await resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause);
622
+ if ('stop' in step)
623
+ return step.stop;
624
+ const { ctx: active, cleanPass } = step.proceed;
625
+ // Mark the work verified (parent task-list check-off for /task-auto; no-op for
626
+ // /task) BEFORE committing, so the commit captures the check-off too.
627
+ await p.onVerified?.();
628
+ // Commit the task's work as one snapshot FIRST — before guideline enforcement —
629
+ // so a passing task is durably recorded no matter what enforcement later finds.
630
+ const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
631
+ if (commit.committed) {
632
+ await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
633
+ // SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
634
+ // output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
635
+ // failure screenshots and two later fix attempts were rejected for deleting
636
+ // them). A SILENT exclusion is the same failure class as the silent
637
+ // ignored-path write nexttask 4 closed, so it gets its own trail line.
638
+ if (commit.excluded && commit.excluded.length > 0) {
639
+ await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
640
+ + `snapshot — regenerable output, not deliverables: `
641
+ + `${commit.excluded.slice(0, 8).join(', ')}`
642
+ + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
643
+ }
644
+ active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
645
+ }
646
+ else {
647
+ await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
648
+ // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
649
+ // git failure is louder: it silently disables enforce AND every commit-based
650
+ // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
651
+ // per-task warnings to show for it. "blocked" is the unmerged-index refusal
652
+ // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
653
+ const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
654
+ active.ui.notify(gitFailure ?
655
+ `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
656
+ : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
657
+ }
658
+ await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
635
659
  return { kind: 'done', ctx: active };
636
660
  }
@@ -183,9 +183,5 @@ export declare function buildPrompt(pkg: ResolvedPackage, query: string, content
183
183
  * (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
184
184
  * this call compile, with three of the five fields existing only for that.
185
185
  */
186
- export declare function formatResultText(header: string, parsed: {
187
- answer: string;
188
- excerpt?: string;
189
- }, verified: boolean | undefined): string;
190
186
  /** The header for an npm package answer. */
191
187
  export declare function packageHeader(pkg: ResolvedPackage): string;
@@ -4,15 +4,14 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { openCache as defaultOpenCache } from './docs-cache.js';
6
6
  import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
7
- import { resolvePackage as defaultResolvePackage, ResolveError, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile, splitRuntimeNamespace } from './docs-resolve.js';
8
- import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
7
+ import { resolvePackage as defaultResolvePackage, ResolveError, isDtsFile, resolveTypeSource, typesPackageName, splitRuntimeNamespace } from './docs-resolve.js';
8
+ import { retrieveChunks as defaultRetrieveChunks, PACKAGE_RETRIEVE_LIMIT, RETRIEVE_CONTENT_BUDGET } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { runChild } from '../shared/child-process.js';
11
11
  import { runFocusedExtraction } from './focused-extractor.js';
12
12
  import { buildExtractionPrompt } from './abstention.js';
13
- import { formatResultText as formatResultTextShared } from '../shared/child-output.js';
14
- const DEFAULT_LIMIT = 8;
15
- const DEFAULT_BUDGET = 24_000;
13
+ const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
14
+ const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
16
15
  const NO_CACHE_HEAD = 25_000;
17
16
  const NO_CACHE_TAIL = 5_000;
18
17
  const NO_CACHE_TOTAL = NO_CACHE_HEAD + NO_CACHE_TAIL;
@@ -224,31 +223,11 @@ async function tryResolveOrInstall(name, cwd, spawn, resolvePackage, signal) {
224
223
  }
225
224
  }
226
225
  }
227
- /** Follow the @types/<name> + triple-slash `<reference types>` redirect chain
228
- * from a package that ships no usable types of its own to the one that actually
229
- * holds the declarations (e.g. bun -> @types/bun -> bun-types). Bounded to a few
230
- * hops; returns the original package if no better source is found. */
231
- async function resolveTypeSource(pkg, requested, cwd, spawn, resolvePackage, signal) {
232
- const visited = new Set([pkg.name, extractParentPackage(requested)]);
233
- let cur = pkg;
234
- for (let depth = 0; depth < 3; depth++) {
235
- let next = detectTypesRedirect(cur);
236
- if (next && visited.has(next))
237
- next = null;
238
- if (!next && !hasTypeFiles(cur.root)) {
239
- const types = typesPackageName(cur.name);
240
- if (types && !visited.has(types))
241
- next = types;
242
- }
243
- if (!next)
244
- break;
245
- visited.add(next);
246
- const resolved = await tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal);
247
- if (!resolved)
248
- break;
249
- cur = resolved;
250
- }
251
- return cur;
226
+ /** The docs pipeline's adapter over the shared redirect walk (docs-resolve.ts):
227
+ * hops resolve through the auto-installing lookup, so a declaration package that is
228
+ * declared but not yet on disk is fetched rather than abandoned. */
229
+ function resolveTypeSourceForDocs(pkg, requested, cwd, spawn, resolvePackage, signal) {
230
+ return resolveTypeSource(pkg, extractParentPackage(requested), next => tryResolveOrInstall(next, cwd, spawn, resolvePackage, signal));
252
231
  }
253
232
  export async function docsRaw(input) {
254
233
  const resolvePackage = input.resolvePackage ?? defaultResolvePackage;
@@ -344,7 +323,7 @@ export async function docsRaw(input) {
344
323
  // @types/<name> + triple-slash `<reference types>` chain to the package that
345
324
  // actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
346
325
  // Best-effort: any failure leaves the original resolution untouched.
347
- pkg = await resolveTypeSource(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
326
+ pkg = await resolveTypeSourceForDocs(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
348
327
  // Step 2: open cache
349
328
  let cache = null;
350
329
  let cacheError;
@@ -569,9 +548,6 @@ export function buildPrompt(pkg, query, content) {
569
548
  * (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
570
549
  * this call compile, with three of the five fields existing only for that.
571
550
  */
572
- export function formatResultText(header, parsed, verified) {
573
- return formatResultTextShared(header, parsed, verified);
574
- }
575
551
  /** The header for an npm package answer. */
576
552
  export function packageHeader(pkg) {
577
553
  return `Per ${pkg.name}@${pkg.version}:`;
@@ -2,11 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
- import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
5
+ import { retrieveChunks as defaultRetrieveChunks, PROJECT_RETRIEVE_LIMIT, RETRIEVE_CONTENT_BUDGET } from './docs-retrieve.js';
6
6
  import { buildExtractionPrompt } from './abstention.js';
7
7
  import { chunkDeclarations } from './docs-chunk.js';
8
- const DEFAULT_LIMIT = 50;
9
- const DEFAULT_BUDGET = 24_000;
8
+ const DEFAULT_LIMIT = PROJECT_RETRIEVE_LIMIT;
9
+ const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
10
10
  export function getProjectName(cwd) {
11
11
  try {
12
12
  const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -54,3 +54,21 @@ export declare function countEntryDeclarations(content: string): number;
54
54
  * local `/// <reference path=... />` aggregator entry, or an entry file that
55
55
  * declares anything of its own). */
56
56
  export declare function detectTypesRedirect(pkg: ResolvedPackage): string | null;
57
+ /**
58
+ * Follow the `@types/<name>` + triple-slash `<reference types>` redirect chain from
59
+ * a package that ships no usable types of its own to the one that actually holds
60
+ * the declarations — `bun` → `@types/bun` → `bun-types`. Bounded to three hops;
61
+ * returns the package it started from when no better source is found.
62
+ *
63
+ * This is what the four predicates above EXIST for. They were exported and heavily
64
+ * tested (35 references between them) while the loop that calls them lived in two
65
+ * byte-identical copies — `docs-core.ts` and `phantom-imports.ts` — and NEITHER was
66
+ * covered: both of their tests pin only the zero-hop case, so the multi-hop
67
+ * behaviour cited by name in five doc comments was asserted nowhere.
68
+ *
69
+ * `resolveHop` is the one thing the two call sites genuinely disagree about: the
70
+ * docs pipeline resolves the next hop through an auto-installing async lookup, the
71
+ * phantom-import checker through a bare sync resolve that must never install. It
72
+ * returns null to stop the walk.
73
+ */
74
+ export declare function resolveTypeSource(start: ResolvedPackage, seed: string, resolveHop: (name: string) => Promise<ResolvedPackage | null>): Promise<ResolvedPackage>;
@@ -304,3 +304,42 @@ export function detectTypesRedirect(pkg) {
304
304
  return null;
305
305
  return target;
306
306
  }
307
+ /**
308
+ * Follow the `@types/<name>` + triple-slash `<reference types>` redirect chain from
309
+ * a package that ships no usable types of its own to the one that actually holds
310
+ * the declarations — `bun` → `@types/bun` → `bun-types`. Bounded to three hops;
311
+ * returns the package it started from when no better source is found.
312
+ *
313
+ * This is what the four predicates above EXIST for. They were exported and heavily
314
+ * tested (35 references between them) while the loop that calls them lived in two
315
+ * byte-identical copies — `docs-core.ts` and `phantom-imports.ts` — and NEITHER was
316
+ * covered: both of their tests pin only the zero-hop case, so the multi-hop
317
+ * behaviour cited by name in five doc comments was asserted nowhere.
318
+ *
319
+ * `resolveHop` is the one thing the two call sites genuinely disagree about: the
320
+ * docs pipeline resolves the next hop through an auto-installing async lookup, the
321
+ * phantom-import checker through a bare sync resolve that must never install. It
322
+ * returns null to stop the walk.
323
+ */
324
+ export async function resolveTypeSource(start, seed, resolveHop) {
325
+ const visited = new Set([start.name, seed]);
326
+ let cur = start;
327
+ for (let hop = 0; hop < 3; hop++) {
328
+ let next = detectTypesRedirect(cur);
329
+ if (next && visited.has(next))
330
+ next = null;
331
+ if (!next && !hasTypeFiles(cur.root)) {
332
+ const types = typesPackageName(cur.name);
333
+ if (types && !visited.has(types))
334
+ next = types;
335
+ }
336
+ if (!next)
337
+ break;
338
+ visited.add(next);
339
+ const resolved = await resolveHop(next);
340
+ if (!resolved)
341
+ break;
342
+ cur = resolved;
343
+ }
344
+ return cur;
345
+ }