@mjasnikovs/pi-task 0.17.24 → 0.17.25

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.
@@ -20,6 +20,9 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
20
20
  cwd: string;
21
21
  shell: boolean;
22
22
  stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
23
+ /** Set only when the invocation needs env overrides (e.g. GIT_INDEX_FILE);
24
+ * absent → the child inherits this process's environment as before. */
25
+ env?: NodeJS.ProcessEnv;
23
26
  }) => ProcLike;
24
27
  export interface ChildResult {
25
28
  stdout: string;
@@ -113,9 +116,11 @@ export declare function runChild(spawn: SpawnFn, invocation: {
113
116
  command: string;
114
117
  args: ReadonlyArray<string>;
115
118
  stdin?: string;
119
+ env?: NodeJS.ProcessEnv;
116
120
  }, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions): Promise<ChildResult>;
117
121
  export declare function runChildDefault(invocation: {
118
122
  command: string;
119
123
  args: ReadonlyArray<string>;
124
+ env?: NodeJS.ProcessEnv;
120
125
  }, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions, spawnFn?: SpawnFn): Promise<ChildResult>;
121
126
  export declare function summarizeToolArgs(toolName: string, args: unknown): string;
@@ -178,7 +178,8 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
178
178
  const proc = spawn(invocation.command, invocation.args, {
179
179
  cwd,
180
180
  shell: false,
181
- stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
181
+ stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
182
+ ...(invocation.env ? { env: invocation.env } : {})
182
183
  });
183
184
  if (usesStdin) {
184
185
  // pi reads the prompt from stdin and waits for EOF, so write then end.
@@ -29,6 +29,21 @@ export declare function git(cwd: string, args: string[], signal: AbortSignal | u
29
29
  exitCode: number;
30
30
  aborted: boolean;
31
31
  }>;
32
+ /**
33
+ * Paths with unmerged index entries (an in-progress merge conflict), deduped.
34
+ * Empty outside a git repo or on any git error — this is a GUARD input, and a
35
+ * guard that cannot conclude must not block.
36
+ *
37
+ * Why it exists (mx5 run 6): a stale `git stash pop` mid-task left two paths UU;
38
+ * every later commit was doomed, three verify passes ran against a conflicted
39
+ * tree, and — worse — a blind `git add -A` on that index would have silently
40
+ * "resolved" the conflict with whatever happened to be on disk.
41
+ */
42
+ export declare function gitUnmergedPaths(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string[]>;
43
+ /** Sha of `refs/stash`, or null when there is no stash (or not a git repo). Used
44
+ * to detect a stash created (or consumed) during a task and left behind — the
45
+ * exact landmine that detonated mx5 run 6 two days after it was pushed. */
46
+ export declare function gitStashRef(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string | null>;
32
47
  /**
33
48
  * Stage everything (`git add -A`) and commit it with `message`. Honors
34
49
  * .gitignore via git itself. Never throws — failures surface as
@@ -31,6 +31,39 @@ export async function git(cwd, args, signal, spawnFn) {
31
31
  const r = await runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
32
32
  return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, aborted: r.aborted };
33
33
  }
34
+ /**
35
+ * Paths with unmerged index entries (an in-progress merge conflict), deduped.
36
+ * Empty outside a git repo or on any git error — this is a GUARD input, and a
37
+ * guard that cannot conclude must not block.
38
+ *
39
+ * Why it exists (mx5 run 6): a stale `git stash pop` mid-task left two paths UU;
40
+ * every later commit was doomed, three verify passes ran against a conflicted
41
+ * tree, and — worse — a blind `git add -A` on that index would have silently
42
+ * "resolved" the conflict with whatever happened to be on disk.
43
+ */
44
+ export async function gitUnmergedPaths(cwd, signal, spawnFn) {
45
+ const r = await git(cwd, ['ls-files', '-u'], signal, spawnFn);
46
+ if (r.exitCode !== 0)
47
+ return [];
48
+ const out = [];
49
+ for (const line of r.stdout.split('\n')) {
50
+ // `<mode> <sha> <stage>\t<path>`
51
+ const tab = line.indexOf('\t');
52
+ if (tab === -1)
53
+ continue;
54
+ const p = line.slice(tab + 1).trim();
55
+ if (p.length > 0 && !out.includes(p))
56
+ out.push(p);
57
+ }
58
+ return out;
59
+ }
60
+ /** Sha of `refs/stash`, or null when there is no stash (or not a git repo). Used
61
+ * to detect a stash created (or consumed) during a task and left behind — the
62
+ * exact landmine that detonated mx5 run 6 two days after it was pushed. */
63
+ export async function gitStashRef(cwd, signal, spawnFn) {
64
+ const r = await git(cwd, ['rev-parse', '-q', '--verify', 'refs/stash'], signal, spawnFn);
65
+ return r.exitCode === 0 ? r.stdout.trim() : null;
66
+ }
34
67
  /**
35
68
  * Stage everything (`git add -A`) and commit it with `message`. Honors
36
69
  * .gitignore via git itself. Never throws — failures surface as
@@ -44,21 +77,32 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
44
77
  if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
45
78
  return { committed: false, reason: 'not a git repository' };
46
79
  }
47
- // 2. Stage all working-tree changes (new, modified, deleted).
80
+ // 2. REFUSE on an unmerged index: `git add -A` would silently "resolve" the
81
+ // conflict by staging whatever is on disk, and the commit that followed
82
+ // would bless a half-merged tree. Surface it instead — this state needs a
83
+ // human (or the loop's own loud stop), not a snapshot.
84
+ const unmerged = await gitUnmergedPaths(cwd, signal, spawnFn);
85
+ if (unmerged.length > 0) {
86
+ return {
87
+ committed: false,
88
+ reason: `git commit blocked: unresolved merge conflict (${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''})`
89
+ };
90
+ }
91
+ // 3. Stage all working-tree changes (new, modified, deleted).
48
92
  const add = await git(cwd, ['add', '-A'], signal, spawnFn);
49
93
  if (add.aborted)
50
94
  return { committed: false, reason: 'cancelled' };
51
95
  if (add.exitCode !== 0) {
52
96
  return { committed: false, reason: `git add failed: ${firstLine(add.stderr)}` };
53
97
  }
54
- // 3. Anything staged? `git diff --cached --quiet` exits 0 when the index
98
+ // 4. Anything staged? `git diff --cached --quiet` exits 0 when the index
55
99
  // matches HEAD (nothing to commit), 1 when there are staged changes.
56
100
  const diff = await git(cwd, ['diff', '--cached', '--quiet'], signal, spawnFn);
57
101
  if (diff.aborted)
58
102
  return { committed: false, reason: 'cancelled' };
59
103
  if (diff.exitCode === 0)
60
104
  return { committed: false, reason: 'nothing to commit' };
61
- // 4. Commit. A failure here is usually missing user.name/user.email config —
105
+ // 5. Commit. A failure here is usually missing user.name/user.email config —
62
106
  // retry once with a self-supplied identity rather than losing the snapshot
63
107
  // (and with it enforce + every differential guard) for the whole run.
64
108
  const commit = await git(cwd, ['commit', '-m', message], signal, spawnFn);
@@ -8,6 +8,30 @@ import { type GateDeps } from './task-gates.js';
8
8
  */
9
9
  export interface AutoDeps extends GateDeps {
10
10
  runChild: (name: string, tools: string, prompt: string) => Promise<string>;
11
+ /**
12
+ * Paths with unmerged index entries (an in-progress merge conflict). The loop
13
+ * refuses to START a task on a conflicted tree — mx5 run 6 ran a full impl turn
14
+ * plus three verifies against one, with every commit doomed from the outset.
15
+ * Absent (tests) → treated as clean.
16
+ */
17
+ unmergedPaths?: (cwd: string) => Promise<string[]>;
18
+ /**
19
+ * Sha of refs/stash or null. Compared around each task so a stash pushed (or
20
+ * consumed) during the task and left behind is called out — an orphan stash is
21
+ * exactly the landmine that detonated as an unresolvable conflict in run 6.
22
+ * Absent (tests) → the check is skipped.
23
+ */
24
+ stashRef?: (cwd: string) => Promise<string | null>;
25
+ /**
26
+ * Whole-repo FINAL integration gate, run once when every task is checked off
27
+ * and BEFORE the run is declared complete (see final-gate.ts): the project's
28
+ * own static checks plus its own test/build commands, unaided. Absent (tests /
29
+ * gate off) → the run completes as before.
30
+ */
31
+ finalGate?: (cwd: string) => Promise<{
32
+ ok: boolean;
33
+ reason: string;
34
+ }>;
11
35
  }
12
36
  /**
13
37
  * Expand any @file references in the feature text by appending each referenced
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import * as fsp from 'node:fs/promises';
9
9
  import * as path from 'node:path';
10
- import { gateRunTask } from './orchestrator.js';
10
+ import { gateRunTask, markResumable } from './orchestrator.js';
11
11
  import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
12
12
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
13
13
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
@@ -25,6 +25,9 @@ import { startAutoLoader } from './widget.js';
25
25
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
26
26
  import { buildGateDeps } from './gate-deps.js';
27
27
  import { runGatesForTask } from './task-gates.js';
28
+ import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
29
+ import { runFinalIntegrationGate } from './final-gate.js';
30
+ import { getConfig } from '../config/config.js';
28
31
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
29
32
  // when the model emits NONE), but a model that never says NONE would otherwise
30
33
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -537,7 +540,15 @@ function defaultDeps(ctx, cwd, signal, title) {
537
540
  stopLoader();
538
541
  }
539
542
  },
540
- ...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask })
543
+ ...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask }),
544
+ // Loop-level repo integrity + run-end gate glue (see AutoDeps docs).
545
+ unmergedPaths: cwd2 => gitUnmergedPaths(cwd2, signal),
546
+ stashRef: cwd2 => gitStashRef(cwd2, signal),
547
+ // The final integration gate follows the `verify work` switch: it is the
548
+ // run-level half of the same verification story.
549
+ finalGate: cwd2 => getConfig().verifyWork ?
550
+ Promise.resolve(runFinalIntegrationGate(cwd2))
551
+ : Promise.resolve({ ok: true, reason: 'disabled' })
541
552
  };
542
553
  }
543
554
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -578,10 +589,58 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
578
589
  const entries = parseTaskList(body);
579
590
  const next = entries.find(e => !e.done);
580
591
  if (!next) {
592
+ // FINAL INTEGRATION GATE: every task passed its own per-slice gates,
593
+ // but per-slice green has shipped a dead app twice (mx5 runs 3 & 5:
594
+ // statics clean, every protected route 500ing). Run the project's
595
+ // OWN whole-repo commands once, unaided, before declaring the run
596
+ // complete. On a FAIL the user decides: accept (complete anyway) or
597
+ // leave the run failed — a resume re-enters this branch and re-runs
598
+ // the gate, so fixing and resuming converges.
599
+ if (deps.finalGate) {
600
+ active.ui.notify(`${id}: running final integration gate…`, 'info');
601
+ const fin = await deps.finalGate(cwd);
602
+ if (!fin.ok) {
603
+ const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}\n\n`
604
+ + 'All tasks are checked off — this is the whole-repo check '
605
+ + '(the project’s own test/build/static commands, run unaided).';
606
+ const answer = await new SessionUI(active).ask({
607
+ localTitle: 'Final integration gate failed — how should pi proceed?',
608
+ displayQuestion: question,
609
+ question,
610
+ recommended: 'Leave failed — I will fix and /task-auto-resume',
611
+ recommended2: 'Accept — complete the run anyway',
612
+ allowSkip: false,
613
+ options: [
614
+ {
615
+ label: 'Leave failed — I will fix and /task-auto-resume',
616
+ value: 'fail'
617
+ },
618
+ { label: 'Accept — complete the run anyway', value: 'accept' }
619
+ ]
620
+ });
621
+ if (answer === undefined || !/^accept\b/i.test(answer.trim())) {
622
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
623
+ announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
624
+ return;
625
+ }
626
+ active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
627
+ }
628
+ }
581
629
  await updateTaskFrontMatter(cwd, id, { state: 'completed' });
582
630
  announceDone(active, `${id} complete — all ${entries.length} tasks done.`, 'info');
583
631
  return;
584
632
  }
633
+ // REFUSE to start on a conflicted tree: an unmerged index dooms every
634
+ // commit ahead and a `git add -A` would silently mis-resolve it. mx5
635
+ // run 6 burned a full impl turn + three verify passes exactly here.
636
+ const unmerged = deps.unmergedPaths ? await deps.unmergedPaths(cwd) : [];
637
+ if (unmerged.length > 0) {
638
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
639
+ announceDone(active, `${id} stopped before "${next.title}" — the repository has unresolved merge conflicts `
640
+ + `(${unmerged.slice(0, 3).join(', ')}${unmerged.length > 3 ? `, +${unmerged.length - 3} more` : ''}). `
641
+ + 'Resolve them (git status), then /task-auto-resume.', 'error');
642
+ return;
643
+ }
585
644
  active.ui.notify(`${id}: task ${next.index + 1}/${entries.length} — ${next.title}`, 'info');
586
645
  // If this entry already has a stamped inner id, it was started in a
587
646
  // previous (interrupted) run — resume it from its saved phase rather
@@ -612,6 +671,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
612
671
  if (checkpoint.committed) {
613
672
  active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
614
673
  }
674
+ // Stash ref before the task: compared after the gates so a stash pushed
675
+ // during the task (impl model or any child) and left behind is called
676
+ // out instead of silently waiting to detonate in a later task.
677
+ const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
615
678
  const res = await deps.runTask(active, cwd, next.title, {
616
679
  resumeId,
617
680
  // Fence this step against re-expanding the whole referenced spec:
@@ -634,10 +697,15 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
634
697
  // this task's spec to finish it. (A plain ESC that the user
635
698
  // follows with steering text never reaches here — that loops on
636
699
  // the same task inside runSingleTask until a turn completes.)
700
+ await markResumable(cwd, res.taskId);
637
701
  announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
638
702
  return;
639
703
  }
640
704
  if (!res.ok) {
705
+ // Demote the INNER task file too: it reads `completed` from
706
+ // spec-handoff, and leaving it that way is how a failed run's task
707
+ // file claimed success in the run 6 audit.
708
+ await markResumable(cwd, res.taskId);
641
709
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
642
710
  // res.reason is set when the implementation turn itself died
643
711
  // (e.g. a context-overflow 400) — surface it so the real cause
@@ -668,6 +736,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
668
736
  });
669
737
  active = gate.ctx;
670
738
  if (gate.kind === 'paused') {
739
+ await markResumable(cwd, res.taskId);
671
740
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
672
741
  announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
673
742
  return;
@@ -677,16 +746,26 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
677
746
  return;
678
747
  }
679
748
  if (gate.kind === 'interrupted') {
749
+ await markResumable(cwd, res.taskId);
680
750
  announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
681
751
  return;
682
752
  }
683
753
  if (gate.kind === 'failed') {
754
+ await markResumable(cwd, res.taskId);
684
755
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
685
756
  const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
686
757
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
687
758
  return;
688
759
  }
689
- // gate.kind === 'done' → fall through to the next task.
760
+ // gate.kind === 'done' → fall through to the next task, after checking
761
+ // no landmine stash was left behind by anything that ran in between.
762
+ if (deps.stashRef && stashBefore !== undefined) {
763
+ const stashAfter = await deps.stashRef(cwd);
764
+ if (stashAfter !== stashBefore) {
765
+ active.ui.notify(`${id}: the git stash stack changed during "${next.title}" and was left that way — `
766
+ + 'inspect `git stash list`; an orphan stash later pops as an unresolvable conflict.', 'warning');
767
+ }
768
+ }
690
769
  }
691
770
  }
692
771
  catch (err) {
@@ -0,0 +1,23 @@
1
+ import { type HealthCommand } from './repo-health-check.js';
2
+ export interface FinalGateOutcome {
3
+ /** true → statics and every runnable integration command passed (or nothing to run). */
4
+ ok: boolean;
5
+ /** On a fail: the exact command, its exit code, and the tail of its output. */
6
+ reason: string;
7
+ }
8
+ /**
9
+ * The project's OWN whole-repo integration commands (test, then build — test
10
+ * first because it is the richer signal and the more common script). First
11
+ * manifest that exists wins, mirroring discoverHealthCommands. Empty means
12
+ * "nothing to run" — the static half may still gate.
13
+ */
14
+ export declare function discoverIntegrationCommands(cwd: string): {
15
+ ecosystem: string | null;
16
+ cmds: HealthCommand[];
17
+ };
18
+ /**
19
+ * Run the final gate: static analysis first, then the discovered integration
20
+ * commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
21
+ * the hood (callers wrap in Promise.resolve). First real failure wins.
22
+ */
23
+ export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number): FinalGateOutcome;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * final-gate — the run-level integration gate /task-auto runs ONCE, after every
3
+ * task is checked off and before the run is declared complete.
4
+ *
5
+ * The failure this closes (mx5 runs 3 and 5, validated): every existing gate is
6
+ * per-task/per-slice, so a run can finish with each slice individually blessed
7
+ * while the ASSEMBLED project is dead — statics green yet every protected route
8
+ * 500ing, 105/174 tests failing across slices, later tasks breaking files earlier
9
+ * tasks verified. Per-task repo-health closes the static half; nothing ever ran
10
+ * the project's own test/build commands against the finished whole.
11
+ *
12
+ * Like repo-health-check (whose discovery style this mirrors), it is deterministic
13
+ * — no model, no per-file narrowing, no "not my task" gray area. It discovers the
14
+ * project's OWN integration commands (the ones a fresh checkout / CI would run)
15
+ * and lets their REAL exit codes decide:
16
+ *
17
+ * - static analysis first (runRepoHealthCheck — cheap, precise), then
18
+ * - the project's own `test` and `build` commands, run verbatim and unaided.
19
+ *
20
+ * Environment-gap safety, same contract as repo-health-check: a command that
21
+ * CANNOT run (ENOENT, exit 127 = command-not-found inside the script chain, or a
22
+ * timeout) is an environment problem, not a code fault — it is SKIPPED, never
23
+ * failed. Only a command that actually ran and exited non-zero fails the gate,
24
+ * and the reason carries the tail of its real output so the user (and a resume
25
+ * fix) can act on it. A test suite that needs a database will fail here when the
26
+ * database is genuinely reachable-but-mis-wired — which is exactly the class the
27
+ * per-task gates kept excusing — and the caller puts a human on the decision
28
+ * (accept / leave failed), so a genuine external gap can still be overridden.
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import { runRepoHealthCheck } from './repo-health-check.js';
34
+ function packageScripts(cwd) {
35
+ try {
36
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
37
+ return j.scripts ?? {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ function makeHasTarget(cwd, target) {
44
+ try {
45
+ const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
46
+ return new RegExp(`^${target}:`, 'm').test(mk);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * The project's OWN whole-repo integration commands (test, then build — test
54
+ * first because it is the richer signal and the more common script). First
55
+ * manifest that exists wins, mirroring discoverHealthCommands. Empty means
56
+ * "nothing to run" — the static half may still gate.
57
+ */
58
+ export function discoverIntegrationCommands(cwd) {
59
+ if (existsSync(path.join(cwd, 'package.json'))) {
60
+ const s = packageScripts(cwd);
61
+ const cmds = [];
62
+ for (const name of ['test', 'build']) {
63
+ if (s[name])
64
+ cmds.push(['bun', ['run', name]]);
65
+ }
66
+ return { ecosystem: 'package.json', cmds };
67
+ }
68
+ if (existsSync(path.join(cwd, 'Makefile'))) {
69
+ const cmds = [];
70
+ for (const target of ['test', 'build']) {
71
+ if (makeHasTarget(cwd, target))
72
+ cmds.push(['make', [target]]);
73
+ }
74
+ return { ecosystem: 'Makefile', cmds };
75
+ }
76
+ if (existsSync(path.join(cwd, 'Cargo.toml'))) {
77
+ return {
78
+ ecosystem: 'Cargo.toml',
79
+ cmds: [
80
+ ['cargo', ['test', '--quiet']],
81
+ ['cargo', ['build', '--quiet']]
82
+ ]
83
+ };
84
+ }
85
+ if (existsSync(path.join(cwd, 'go.mod'))) {
86
+ return {
87
+ ecosystem: 'go.mod',
88
+ cmds: [
89
+ ['go', ['test', './...']],
90
+ ['go', ['build', './...']]
91
+ ]
92
+ };
93
+ }
94
+ if (existsSync(path.join(cwd, 'pyproject.toml'))) {
95
+ return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
96
+ }
97
+ return { ecosystem: null, cmds: [] };
98
+ }
99
+ /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
100
+ function outputTail(stdout, stderr, limit = 400) {
101
+ const combined = `${stdout}\n${stderr}`.trim();
102
+ if (combined.length === 0)
103
+ return '';
104
+ const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
105
+ return combined.length > limit ? `…${tail}` : tail;
106
+ }
107
+ /**
108
+ * Run the final gate: static analysis first, then the discovered integration
109
+ * commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
110
+ * the hood (callers wrap in Promise.resolve). First real failure wins.
111
+ */
112
+ export function runFinalIntegrationGate(cwd, timeoutMs = 900_000) {
113
+ const stat = runRepoHealthCheck(cwd);
114
+ if (!stat.ok)
115
+ return { ok: false, reason: `static checks: ${stat.reason}` };
116
+ const { ecosystem, cmds } = discoverIntegrationCommands(cwd);
117
+ if (!ecosystem || cmds.length === 0) {
118
+ return { ok: true, reason: 'no integration command found (statics passed)' };
119
+ }
120
+ const ran = [];
121
+ for (const [bin, args] of cmds) {
122
+ const label = `${bin} ${args.join(' ')}`;
123
+ const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
124
+ // Tool missing, timeout, or command-not-found inside the script chain →
125
+ // environment gap, not a code fault; skip (same contract as repo-health).
126
+ if (r.error || r.status === null || r.status === 127)
127
+ continue;
128
+ if (r.status !== 0) {
129
+ const tail = outputTail(r.stdout ?? '', r.stderr ?? '');
130
+ return {
131
+ ok: false,
132
+ reason: `\`${label}\` exited ${r.status}${tail ? ` — ${tail}` : ''}`
133
+ };
134
+ }
135
+ ran.push(label);
136
+ }
137
+ return {
138
+ ok: true,
139
+ reason: ran.length > 0 ?
140
+ `statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
141
+ : 'statics passed (integration commands not runnable here)'
142
+ };
143
+ }
@@ -23,6 +23,7 @@ import { runRepoHealthCheck } from './repo-health-check.js';
23
23
  import { researchResolution } from './verify-resolution.js';
24
24
  import { findSubstitutionSuspects } from './substitution-probe.js';
25
25
  import { runBoundedLintFix } from './lint-fix.js';
26
+ import { captureGitState, reconcileGitState } from './git-state-guard.js';
26
27
  import { runWorker } from '../workers/pi-worker-core.js';
27
28
  import { formatLoopHint } from './child-runner.js';
28
29
  import { getConfig } from '../config/config.js';
@@ -83,6 +84,10 @@ export function buildGateDeps(params) {
83
84
  // output line and context usage, exactly like the single-task phase widget.
84
85
  let lastLine;
85
86
  let contextUsage;
87
+ // What the git-state guard had to restore after the MOST RECENT verify/recommend
88
+ // child run (see git-state-guard.ts). runWorkVerification reads this through its
89
+ // mutationCheck dep to discard a verdict computed on a mutated tree.
90
+ let lastGuardReconcile = null;
86
91
  // Shared runner for the per-task GATE children (verify + post-FAIL recommend).
87
92
  // Both are read-only passes of the same local model that must run to completion:
88
93
  // unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
@@ -92,12 +97,20 @@ export function buildGateDeps(params) {
92
97
  const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
93
98
  lastLine = undefined;
94
99
  contextUsage = undefined;
100
+ lastGuardReconcile = null;
95
101
  const startedAt = Date.now();
96
102
  const logPath = path.join(tasksDir(cwd2), logFile);
97
103
  const log = (msg) => {
98
104
  void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
99
105
  };
100
106
  log(`=== ${kind} start: ${taskTitle} ===`);
107
+ // GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
108
+ // contract is prompt-level and the live model breaks it (mx5 run 6: the
109
+ // verify child `git stash`ed the task's uncommitted work and never popped
110
+ // — the impl was destroyed and the orphan stash detonated 2 days later).
111
+ // Snapshot before, deterministically restore after; lint-fix is excluded
112
+ // because editing is its job (it carries its own revert guard).
113
+ const guardSnapshot = kind === 'verify' || kind === 'recommend' ? await captureGitState(cwd2, sig) : null;
101
114
  const stopLoader = startAutoLoader(gateCtx, () => ({
102
115
  title: taskTitle,
103
116
  kind,
@@ -109,21 +122,36 @@ export function buildGateDeps(params) {
109
122
  contextUsage
110
123
  }));
111
124
  try {
112
- const r = await runWorker({
113
- prompt,
114
- cwd: cwd2,
115
- signal: sig,
116
- tools,
117
- timeoutMs: 0,
118
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
119
- onLine: line => {
120
- lastLine = line;
121
- log(line);
122
- },
123
- onContextUsage: snapshot => {
124
- contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
125
+ let r;
126
+ try {
127
+ r = await runWorker({
128
+ prompt,
129
+ cwd: cwd2,
130
+ signal: sig,
131
+ tools,
132
+ timeoutMs: 0,
133
+ loop: { pathThreshold: Number.POSITIVE_INFINITY },
134
+ onLine: line => {
135
+ lastLine = line;
136
+ log(line);
137
+ },
138
+ onContextUsage: snapshot => {
139
+ contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
140
+ }
141
+ });
142
+ }
143
+ finally {
144
+ // Restore whatever the child moved BEFORE any verdict/failure is
145
+ // acted on — a crashed child must not skip the restore either.
146
+ if (guardSnapshot) {
147
+ const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
148
+ lastGuardReconcile = rec;
149
+ if (rec.mutated) {
150
+ log(`=== ${kind} GIT-STATE GUARD — child mutated repo state; restored: ${rec.actions.join('; ')} ===`);
151
+ gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
152
+ }
125
153
  }
126
- });
154
+ }
127
155
  if (r.loopHit) {
128
156
  log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
129
157
  gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
@@ -251,7 +279,13 @@ export function buildGateDeps(params) {
251
279
  // Deterministic self-verification probe: test files the task itself
252
280
  // authored/changed become prompt-level findings mandating the child
253
281
  // to drive the real artifact before trusting their green result.
254
- probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects)
282
+ probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
283
+ // Git-state guard result of the most recent child run: a verdict
284
+ // computed on a tree the child itself mutated is discarded (the
285
+ // guard already restored the state — see git-state-guard.ts).
286
+ mutationCheck: () => lastGuardReconcile?.mutated ?
287
+ { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
288
+ : { mutated: false, detail: '' }
255
289
  });
256
290
  },
257
291
  lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
@@ -0,0 +1,44 @@
1
+ import { type SpawnFn } from '../shared/child-process.js';
2
+ export interface GitStateSnapshot {
3
+ /** false → not a usable git worktree; the guard is disabled for this run. */
4
+ ok: boolean;
5
+ headSha: string;
6
+ /** Symbolic ref ("refs/heads/main") when HEAD is on a branch, else null (detached). */
7
+ branchRef: string | null;
8
+ /** Sha of refs/stash, or null when there is no stash. */
9
+ stashSha: string | null;
10
+ /** Tree object capturing the worktree content (tracked + untracked, minus .pi-tasks). */
11
+ treeSha: string | null;
12
+ }
13
+ export interface ReconcileResult {
14
+ /** true → the child moved repo state; every detected move was restored. */
15
+ mutated: boolean;
16
+ /** Human-readable restore actions, for the debug log / notify / gate trail. */
17
+ actions: string[];
18
+ }
19
+ /** Capture the repo state a gate child must leave untouched. */
20
+ export declare function captureGitState(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<GitStateSnapshot>;
21
+ /**
22
+ * Compare the repo state against a pre-run snapshot and deterministically undo
23
+ * whatever a gate child moved. Ordering matters:
24
+ *
25
+ * 1. HEAD first — a child parked on another commit must be back on the original
26
+ * ref before the worktree comparison/restore makes sense.
27
+ * 2. Worktree content from the snapshot TREE (not from any stash the child may
28
+ * have pushed — the snapshot is the authoritative "as the child found it").
29
+ * 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
30
+ * is already restored — this is exactly the orphan that detonated mx5 run 6.
31
+ *
32
+ * Never throws; failures degrade to actions[] lines so the caller can log them.
33
+ */
34
+ export declare function reconcileGitState(cwd: string, before: GitStateSnapshot, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<ReconcileResult>;
35
+ /**
36
+ * Convenience wrapper for gate call-sites: run `fn` between a capture and a
37
+ * reconcile, and hand back both the result and what (if anything) had to be
38
+ * restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
39
+ * not skip the restore.
40
+ */
41
+ export declare function withGitStateGuard<T>(cwd: string, fn: () => Promise<T>, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<{
42
+ result: T;
43
+ reconcile: ReconcileResult;
44
+ }>;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * git-state-guard — deterministic repo-state snapshot/reconcile around the
3
+ * read-only gate children (verify, recommend).
4
+ *
5
+ * The failure this closes (proven twice on mx5): those children hold a `read,bash`
6
+ * contract whose "never modify the tree" clause is prompt-level only, and the live
7
+ * local model breaks it. Run 6's verify child ran `git stash; git checkout HEAD~1;
8
+ * tsc; git checkout HEAD` with NO pop — the task's whole uncommitted implementation
9
+ * vanished into a stash, the verify judged an empty tree (a full re-implementation
10
+ * was burned), and the orphaned stash detonated two days later when a later impl
11
+ * turn popped it onto a 14-commits-newer HEAD (unresolvable UU conflict, the
12
+ * /task-auto checklist reverted to a stale state). The same child has been observed
13
+ * running `eslint --fix .` mid-verification and ad-hoc DDL against the test DB.
14
+ *
15
+ * Prompt rules are evidence-insufficient for this class (FROZEN-CONTRACT framing
16
+ * A/B'd 0–1/5 compliance), so this is a capability-shaped fix: snapshot the repo
17
+ * state BEFORE the child runs, and afterwards deterministically restore anything
18
+ * it moved — no model in the loop.
19
+ *
20
+ * What is captured / reconciled:
21
+ * - HEAD (sha + symbolic branch ref): a child that checked out another commit
22
+ * and never came back is checked back out.
23
+ * - The WORKTREE CONTENT as a git tree object, built through a temporary index
24
+ * (`read-tree --empty` + `add -A` + `write-tree`, excluding .pi-tasks — the
25
+ * gate's own debug logs land there DURING the run). This snapshots tracked
26
+ * *and* untracked (non-ignored) files without touching the real index or the
27
+ * stash. Restoration re-materialises every changed/deleted file from the
28
+ * snapshot tree and deletes files the child created.
29
+ * - The STASH ref: entries the child pushed are dropped AFTER the worktree is
30
+ * restored from the snapshot (the snapshot, not the stash, is the source of
31
+ * truth), so no landmine stash survives the reconcile.
32
+ *
33
+ * The real index's staging state is deliberately NOT restored: pre-commit gate
34
+ * children run against a tree whose work is unstaged, and the auto-commit that
35
+ * follows re-stages everything with `add -A` anyway.
36
+ *
37
+ * Everything is best-effort: a repo where git itself fails (not a work tree, git
38
+ * missing) disables the guard (capture returns ok:false and reconcile no-ops) —
39
+ * the gate must keep working in non-git projects exactly as before.
40
+ */
41
+ import * as fsp from 'node:fs/promises';
42
+ import * as os from 'node:os';
43
+ import * as path from 'node:path';
44
+ import { runChildDefault } from '../shared/child-process.js';
45
+ /** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
46
+ const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
47
+ function makeGit(cwd, signal, spawnFn) {
48
+ return async (args, env) => {
49
+ const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
50
+ return { stdout: r.stdout, exitCode: r.exitCode };
51
+ };
52
+ }
53
+ /**
54
+ * Snapshot the worktree content into a tree object via a THROWAWAY index file, so
55
+ * neither the real index nor the stash is touched. Returns null when git cannot
56
+ * build the tree (odd states — the guard then skips tree reconciliation).
57
+ */
58
+ async function captureWorktreeTree(git) {
59
+ const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
60
+ const env = { GIT_INDEX_FILE: tmpIndex };
61
+ try {
62
+ const empty = await git(['read-tree', '--empty'], env);
63
+ if (empty.exitCode !== 0)
64
+ return null;
65
+ const add = await git(['add', '-A', '--', '.', EXCLUDE_TASKS_DIR], env);
66
+ if (add.exitCode !== 0)
67
+ return null;
68
+ const tree = await git(['write-tree'], env);
69
+ return tree.exitCode === 0 ? tree.stdout.trim() : null;
70
+ }
71
+ finally {
72
+ await fsp.rm(tmpIndex, { force: true }).catch(() => { });
73
+ }
74
+ }
75
+ /** Capture the repo state a gate child must leave untouched. */
76
+ export async function captureGitState(cwd, signal, spawnFn) {
77
+ const git = makeGit(cwd, signal, spawnFn);
78
+ const disabled = {
79
+ ok: false,
80
+ headSha: '',
81
+ branchRef: null,
82
+ stashSha: null,
83
+ treeSha: null
84
+ };
85
+ const inside = await git(['rev-parse', '--is-inside-work-tree']);
86
+ if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true')
87
+ return disabled;
88
+ const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
89
+ // An unborn HEAD (fresh init, no commits) has nothing to restore to — disable.
90
+ if (head.exitCode !== 0)
91
+ return disabled;
92
+ const branch = await git(['symbolic-ref', '-q', 'HEAD']);
93
+ const stash = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
94
+ return {
95
+ ok: true,
96
+ headSha: head.stdout.trim(),
97
+ branchRef: branch.exitCode === 0 ? branch.stdout.trim() : null,
98
+ stashSha: stash.exitCode === 0 ? stash.stdout.trim() : null,
99
+ treeSha: await captureWorktreeTree(git)
100
+ };
101
+ }
102
+ /**
103
+ * Restore every file recorded in `beforeTree` (content + deletions) and remove
104
+ * files that exist in `afterTree` but not in `beforeTree` (files the child
105
+ * created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
106
+ * -a -f` re-materialises the snapshot verbatim.
107
+ */
108
+ async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
109
+ const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
110
+ const env = { GIT_INDEX_FILE: tmpIndex };
111
+ try {
112
+ // Files present only in afterTree were created by the child — delete them
113
+ // BEFORE checkout so a restore failure cannot leave both halves stale.
114
+ const added = await git([
115
+ 'diff-tree',
116
+ '-r',
117
+ '--diff-filter=A',
118
+ '--name-only',
119
+ beforeTree,
120
+ afterTree
121
+ ]);
122
+ if (added.exitCode === 0) {
123
+ for (const rel of added.stdout.split('\n')) {
124
+ const name = rel.trim();
125
+ if (name.length === 0)
126
+ continue;
127
+ await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
128
+ actions.push(`removed child-created file ${name}`);
129
+ }
130
+ }
131
+ const read = await git(['read-tree', beforeTree], env);
132
+ if (read.exitCode !== 0) {
133
+ actions.push('worktree restore FAILED (read-tree)');
134
+ return;
135
+ }
136
+ const co = await git(['checkout-index', '-a', '-f'], env);
137
+ actions.push(co.exitCode === 0 ?
138
+ 'restored worktree files from pre-run snapshot'
139
+ : 'worktree restore FAILED (checkout-index)');
140
+ }
141
+ finally {
142
+ await fsp.rm(tmpIndex, { force: true }).catch(() => { });
143
+ }
144
+ }
145
+ /**
146
+ * Compare the repo state against a pre-run snapshot and deterministically undo
147
+ * whatever a gate child moved. Ordering matters:
148
+ *
149
+ * 1. HEAD first — a child parked on another commit must be back on the original
150
+ * ref before the worktree comparison/restore makes sense.
151
+ * 2. Worktree content from the snapshot TREE (not from any stash the child may
152
+ * have pushed — the snapshot is the authoritative "as the child found it").
153
+ * 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
154
+ * is already restored — this is exactly the orphan that detonated mx5 run 6.
155
+ *
156
+ * Never throws; failures degrade to actions[] lines so the caller can log them.
157
+ */
158
+ export async function reconcileGitState(cwd, before, signal, spawnFn) {
159
+ if (!before.ok)
160
+ return { mutated: false, actions: [] };
161
+ const git = makeGit(cwd, signal, spawnFn);
162
+ const actions = [];
163
+ // 1. HEAD / branch.
164
+ const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
165
+ const branch = await git(['symbolic-ref', '-q', 'HEAD']);
166
+ const headSha = head.exitCode === 0 ? head.stdout.trim() : '';
167
+ const branchRef = branch.exitCode === 0 ? branch.stdout.trim() : null;
168
+ if (headSha !== before.headSha || branchRef !== before.branchRef) {
169
+ const target = before.branchRef ? before.branchRef.replace(/^refs\/heads\//, '') : before.headSha;
170
+ const co = await git(['checkout', '-f', target]);
171
+ actions.push(co.exitCode === 0 ?
172
+ `checked HEAD back out to ${target}`
173
+ : `HEAD restore FAILED (checkout ${target})`);
174
+ }
175
+ // 2. Worktree content.
176
+ if (before.treeSha) {
177
+ const afterTree = await captureWorktreeTree(git);
178
+ if (afterTree && afterTree !== before.treeSha) {
179
+ await restoreWorktree(cwd, git, before.treeSha, afterTree, actions);
180
+ }
181
+ }
182
+ // 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
183
+ // snapshot again (bounded — a child pushes at most a handful; 10 is beyond
184
+ // anything observed). A stash the child POPPED (ref gone/behind) cannot be
185
+ // reconstructed — report it instead of guessing.
186
+ const stashNow = async () => {
187
+ const s = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
188
+ return s.exitCode === 0 ? s.stdout.trim() : null;
189
+ };
190
+ let stash = await stashNow();
191
+ if (stash !== before.stashSha) {
192
+ if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
193
+ let dropped = 0;
194
+ while (stash !== before.stashSha && stash !== null && dropped < 10) {
195
+ const drop = await git(['stash', 'drop', 'stash@{0}']);
196
+ if (drop.exitCode !== 0)
197
+ break;
198
+ dropped++;
199
+ stash = await stashNow();
200
+ }
201
+ actions.push(stash === before.stashSha ?
202
+ `dropped ${dropped} stash entr${dropped === 1 ? 'y' : 'ies'} the child pushed`
203
+ : 'stash restore INCOMPLETE (drop failed)');
204
+ }
205
+ else {
206
+ actions.push('stash ref changed in a way that cannot be undone (entry popped/dropped)');
207
+ }
208
+ }
209
+ return { mutated: actions.length > 0, actions };
210
+ }
211
+ /** Is `ancestorStash` still reachable in the stash reflog chain at `tipSha`? Used to
212
+ * tell "child pushed on top" (droppable) from "child popped/dropped ours" (not). */
213
+ async function stashContains(git, tipSha, wantedSha) {
214
+ if (tipSha === null)
215
+ return false;
216
+ const log = await git(['rev-list', '-g', 'refs/stash']);
217
+ if (log.exitCode !== 0)
218
+ return false;
219
+ return log.stdout
220
+ .split('\n')
221
+ .map(l => l.trim())
222
+ .includes(wantedSha);
223
+ }
224
+ /**
225
+ * Convenience wrapper for gate call-sites: run `fn` between a capture and a
226
+ * reconcile, and hand back both the result and what (if anything) had to be
227
+ * restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
228
+ * not skip the restore.
229
+ */
230
+ export async function withGitStateGuard(cwd, fn, signal, spawnFn) {
231
+ const before = await captureGitState(cwd, signal, spawnFn);
232
+ try {
233
+ const result = await fn();
234
+ return { result, reconcile: await reconcileGitState(cwd, before, signal, spawnFn) };
235
+ }
236
+ catch (err) {
237
+ await reconcileGitState(cwd, before, signal, spawnFn).catch(() => { });
238
+ throw err;
239
+ }
240
+ }
@@ -226,6 +226,14 @@ export declare function runSingleTask(ctx: ExtensionCommandContext, cwd: string,
226
226
  * orchestrators, to keep the dependency graph acyclic).
227
227
  */
228
228
  export declare const gateRunTask: RunTaskFn;
229
+ /**
230
+ * Demote a task file to a resumable state after a gate (or its implementation)
231
+ * stopped short. The file is marked `completed` at spec-handoff — before the work
232
+ * is even verified — so a gate FAIL would otherwise leave it un-resumable
233
+ * (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
234
+ * no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
235
+ */
236
+ export declare function markResumable(cwd: string, taskId: string): Promise<void>;
229
237
  /**
230
238
  * Run a single /task through implementation AND the shared verify + enforce gates,
231
239
  * blocking until both finish. Used instead of the fire-and-forget handoff whenever
@@ -581,7 +581,7 @@ export const gateRunTask = (c, cwd, t, opts) => runSingleTask(c, cwd, t, {
581
581
  * (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
582
582
  * no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
583
583
  */
584
- async function markResumable(cwd, taskId) {
584
+ export async function markResumable(cwd, taskId) {
585
585
  if (!taskId)
586
586
  return;
587
587
  try {
@@ -55,6 +55,11 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
55
55
  export interface PhaseResearchDeps extends ExternalContextDeps {
56
56
  getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
57
57
  }
58
+ /** Is live web search configured for this process? Mirrors search-core's env lookup. */
59
+ export declare function searchConfigured(getEnv?: (k: string) => string | undefined): boolean;
60
+ /** Extra prompt block for the APIS worker when search is available — trigger-framed
61
+ * (the validated shape for getting a local model to actually reach for search). */
62
+ export declare const RESEARCH_SEARCH_HINT: string;
58
63
  /**
59
64
  * The TOOLING worker only needs to know which verification commands the task
60
65
  * cares about — never the per-file edit list. Big refined prompts embed a long
@@ -145,6 +145,24 @@ export async function phaseVerifyTooling(deps, research) {
145
145
  return replaceToolingWithVerified(research, parsed.verified);
146
146
  }
147
147
  const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
148
+ /** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
149
+ * when a Brave key is configured (the tool without a key just errors, and a weak
150
+ * model burns calls on it). Search being absent from the research toolset was
151
+ * STRUCTURAL: three consecutive audited runs made 0 search calls because the
152
+ * child literally did not have the tool. */
153
+ const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
154
+ /** Is live web search configured for this process? Mirrors search-core's env lookup. */
155
+ export function searchConfigured(getEnv = k => process.env[k]) {
156
+ return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
157
+ }
158
+ /** Extra prompt block for the APIS worker when search is available — trigger-framed
159
+ * (the validated shape for getting a local model to actually reach for search). */
160
+ export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for external facts your training data may have stale: '
161
+ + 'the CURRENT version of a framework/runtime the task pins, a breaking API change you are '
162
+ + 'not sure shipped, an error message you cannot explain from the code. Call '
163
+ + '`pi-worker-search(query)` first, then `pi-worker-fetch(url)` on the result you want to '
164
+ + 'read. Do NOT answer version or release questions from memory. Skip search entirely for '
165
+ + "anything the project's own files or pi-worker-docs already answer.";
148
166
  /**
149
167
  * In-process guards loaded into the TOOLING worker only: block a re-read of any
150
168
  * file already read, and block any byte-identical grep/find/ls repeat, feeding
@@ -338,10 +356,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
338
356
  {
339
357
  section: 'APIS',
340
358
  label: 'worker:apis',
341
- // Read-heavy: gets the orientation core (see note above).
342
- prompt: appendNoThink(orientation.block + promptHeader + RESEARCH_APIS_PROMPT(refined)),
343
- tools: 'read,grep,find,ls,pi-worker-docs',
344
- extensions: [DOCS_EXTENSION_PATH]
359
+ // Read-heavy: gets the orientation core (see note above). Search/fetch
360
+ // ride along only when a Brave key exists — see SEARCH_EXTENSION_PATH.
361
+ prompt: appendNoThink(orientation.block
362
+ + promptHeader
363
+ + RESEARCH_APIS_PROMPT(refined)
364
+ + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')),
365
+ tools: 'read,grep,find,ls,pi-worker-docs'
366
+ + (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
367
+ extensions: [
368
+ DOCS_EXTENSION_PATH,
369
+ ...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
370
+ ]
345
371
  },
346
372
  {
347
373
  section: 'CONTEXT',
@@ -127,10 +127,20 @@ export async function runGatesForTask(ctxIn, deps, p) {
127
127
  break;
128
128
  }
129
129
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
130
- // guidance) prepended as a RE-ATTEMPT banner, then re-verify.
130
+ // guidance) prepended as a RE-ATTEMPT banner, then re-verify. The
131
+ // recommendation child already LOCATED the defect while deciding (mx5
132
+ // run 6: it pinned the exact SQL alias bug and the afterAll DB-drop) —
133
+ // hand that diagnosis to the re-run so it fixes the located cause
134
+ // instead of re-deriving it from the bare FAIL line. Skipped when there
135
+ // is no researched rationale beyond the failure text itself.
131
136
  await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
132
137
  active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
133
- const fixInstruction = choice.guidance ? `${failReason}\n\nUser guidance: ${choice.guidance}` : failReason;
138
+ const diagnosis = (recOutcome.recommend === 'autofix'
139
+ && recOutcome.rationale.length > 0
140
+ && recOutcome.rationale !== failReason) ?
141
+ `\n\nDIAGNOSIS (a read-only investigation of this failure found):\n${recOutcome.rationale}`
142
+ : '';
143
+ const fixInstruction = `${failReason}${diagnosis}${choice.guidance ? `\n\nUser guidance: ${choice.guidance}` : ''}`;
134
144
  const fixRes = await deps.runTask(active, p.cwd, p.title, {
135
145
  resumeId: p.taskId,
136
146
  planContext: p.planContext,
@@ -167,8 +177,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
167
177
  // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
168
178
  // git failure is louder: it silently disables enforce AND every commit-based
169
179
  // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
170
- // per-task warnings to show for it.
171
- const gitFailure = /^git (commit|add) failed/.test(commit.reason ?? '');
180
+ // per-task warnings to show for it. "blocked" is the unmerged-index refusal
181
+ // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
182
+ const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
172
183
  active.ui.notify(gitFailure ?
173
184
  `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
174
185
  : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
@@ -82,6 +82,18 @@ export interface VerificationDeps {
82
82
  * into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
83
83
  * the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
84
84
  probe?: () => Promise<string[]>;
85
+ /**
86
+ * Result of the git-state guard for the MOST RECENT runChild call (see
87
+ * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
88
+ * rewrites), which the guard then restored? A verdict computed on a mutated
89
+ * tree is untrustworthy in BOTH directions — the mx5 run 6 child stashed the
90
+ * work away and judged an empty tree — so it is discarded: the first mutated
91
+ * run is retried once on the restored tree; a second mutation is a FAIL that
92
+ * names the behavior. ABSENT → no guard (tests / non-git repos), unchanged. */
93
+ mutationCheck?: () => {
94
+ mutated: boolean;
95
+ detail: string;
96
+ };
85
97
  }
86
98
  /**
87
99
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run
@@ -320,6 +320,20 @@ export async function runWorkVerification(deps) {
320
320
  const msg = err instanceof Error ? err.message : String(err);
321
321
  return { ok: false, reason: `verification pass could not run: ${msg}` };
322
322
  }
323
+ // A child that mutated the repo (git-state guard fired) judged a tree it had
324
+ // itself changed — its verdict is meaningless in both directions, so discard
325
+ // it BEFORE parsing. The guard already restored the state, so one retry runs
326
+ // on the pristine tree; a child that mutates again is reported as the fault.
327
+ const mutation = deps.mutationCheck?.();
328
+ if (mutation?.mutated) {
329
+ if (attempt === 1)
330
+ continue;
331
+ return {
332
+ ok: false,
333
+ reason: 'verify child mutated repo state and its verdict was discarded '
334
+ + `(state restored: ${mutation.detail.slice(0, 200)})`
335
+ };
336
+ }
323
337
  const verdict = parseVerifyVerdict(text);
324
338
  if (verdict.pass)
325
339
  return { ok: true };
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ export default function (pi: ExtensionAPI): void;
@@ -0,0 +1,6 @@
1
+ import { registerPiWorkerSearch } from './pi-worker-search.js';
2
+ import { registerPiWorkerFetch } from './pi-worker-fetch.js';
3
+ export default function (pi) {
4
+ registerPiWorkerSearch(pi);
5
+ registerPiWorkerFetch(pi);
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.24",
3
+ "version": "0.17.25",
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",