@mjasnikovs/pi-task 0.17.23 → 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.
@@ -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');