@mjasnikovs/pi-task 0.18.9 → 0.18.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.
@@ -14,6 +14,9 @@ export interface ProcLike extends EventEmitter {
14
14
  stdout: EventEmitter | null;
15
15
  stderr: EventEmitter | null;
16
16
  killed: boolean;
17
+ /** OS pid; used to signal the child's whole process GROUP (orphan reaping). May
18
+ * be undefined for a mock spawn or a spawn that failed. */
19
+ pid?: number;
17
20
  kill(signal: string): boolean | void;
18
21
  }
19
22
  export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
@@ -23,6 +26,11 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
23
26
  /** Set only when the invocation needs env overrides (e.g. GIT_INDEX_FILE);
24
27
  * absent → the child inherits this process's environment as before. */
25
28
  env?: NodeJS.ProcessEnv;
29
+ /** true → give the child its own process group (POSIX `detached`), so any
30
+ * server it backgrounds (`bun run dev &`) can be reaped as a group when the
31
+ * child exits instead of leaking as an orphan holding a port (mx5 run 9
32
+ * item 3). Set only for model children (json-events); plumbing stays put. */
33
+ detached?: boolean;
26
34
  }) => ProcLike;
27
35
  export interface ChildResult {
28
36
  stdout: string;
@@ -1,4 +1,4 @@
1
- import { spawn as defaultSpawn } from 'node:child_process';
1
+ import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
2
  /** Grace period between SIGTERM and SIGKILL (ms). */
3
3
  export const KILL_GRACE_MS = 5000;
4
4
  /** Base flags shared by all child pi invocations. */
@@ -175,10 +175,17 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
175
175
  // prompt is present we open stdin as a pipe; otherwise keep it 'ignore'
176
176
  // (git and other arg-only spawns are unaffected). See GitHub issue #1.
177
177
  const usesStdin = invocation.stdin !== undefined;
178
+ // Model children (json-events) run arbitrary bash — they can `bun run dev &`
179
+ // a server that outlives the child and holds a port, wrecking the final gate
180
+ // with a self-inflicted EADDRINUSE (mx5 run 9 item 3). Spawn them in their
181
+ // OWN process group so every such grandchild can be reaped as a unit on exit.
182
+ // Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
183
+ const ownGroup = opts?.mode === 'json-events';
178
184
  const proc = spawn(invocation.command, invocation.args, {
179
185
  cwd,
180
186
  shell: false,
181
187
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
188
+ ...(ownGroup ? { detached: true } : {}),
182
189
  ...(invocation.env ? { env: invocation.env } : {})
183
190
  });
184
191
  if (usesStdin) {
@@ -186,14 +193,39 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
186
193
  proc.stdin?.write(invocation.stdin);
187
194
  proc.stdin?.end();
188
195
  }
189
- // One kill path, shared by user-abort and loop-kill: SIGTERM, then
190
- // SIGKILL after a grace period if the child ignored the term.
196
+ // Reap the child's whole process group — the child itself AND anything it
197
+ // backgrounded. No-op unless the child owns a group (ownGroup) and we have a
198
+ // pid; ESRCH (group already gone) is swallowed. POSIX: negative-pid signals
199
+ // the group; Windows has no groups, so taskkill /T tears down the tree.
200
+ const reapGroup = (sig) => {
201
+ if (!ownGroup || !proc.pid)
202
+ return;
203
+ try {
204
+ if (process.platform === 'win32') {
205
+ spawnSyncDefault('taskkill', ['/pid', String(proc.pid), '/T', '/F']);
206
+ }
207
+ else {
208
+ process.kill(-proc.pid, sig);
209
+ }
210
+ }
211
+ catch {
212
+ // group already gone
213
+ }
214
+ };
215
+ // One kill path, shared by user-abort and loop-kill: SIGTERM, then SIGKILL
216
+ // after a grace period if the child ignored the term. For a group-owning
217
+ // (model) child, ALSO sweep the group so anything it backgrounded dies with
218
+ // it — proc.kill hits only the leader, reapGroup the grandchildren.
191
219
  const killProc = () => {
192
220
  aborted = true;
193
221
  proc.kill('SIGTERM');
222
+ if (ownGroup)
223
+ reapGroup('SIGTERM');
194
224
  setTimeout(() => {
195
225
  if (!proc.killed)
196
226
  proc.kill('SIGKILL');
227
+ if (ownGroup)
228
+ reapGroup('SIGKILL');
197
229
  }, KILL_GRACE_MS);
198
230
  };
199
231
  const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
@@ -250,6 +282,14 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
250
282
  proc.on('close', (code) => {
251
283
  if (stallTimer)
252
284
  clearInterval(stallTimer);
285
+ // The child has exited, but anything it backgrounded (a dev server) may
286
+ // still hold its process group and a port — reap the group so the next
287
+ // gate's boot check does not collide with our own orphan. Best-effort:
288
+ // SIGTERM now, SIGKILL shortly after for anything that ignored it.
289
+ if (ownGroup) {
290
+ reapGroup('SIGTERM');
291
+ setTimeout(() => reapGroup('SIGKILL'), 1_000).unref();
292
+ }
253
293
  if (sink)
254
294
  sink.flush();
255
295
  const text = sink ? sink.text : undefined;
@@ -1,11 +1,3 @@
1
- /**
2
- * Per-task git commit for /task-auto.
3
- *
4
- * After each decomposed task passes, runAutoLoop snapshots the working tree into
5
- * a single commit so the run produces one commit per task. This is best-effort:
6
- * outside a git repo, with nothing staged, or on any git error we report the
7
- * reason and let the loop continue (the task already succeeded).
8
- */
9
1
  import { type SpawnFn } from '../shared/child-process.js';
10
2
  export interface CommitResult {
11
3
  committed: boolean;
@@ -58,11 +50,15 @@ export declare function gitCommitAll(cwd: string, message: string, signal?: Abor
58
50
  * `git reset --hard HEAD~1` throws the enforce commit away and brings back the
59
51
  * verified task commit underneath it.
60
52
  *
61
- * `reset --hard` is safe here precisely because it runs right after the enforce
62
- * commit: the working tree is clean (everything was just committed), so there is
63
- * no unrelated uncommitted work for it to destroy. HEAD~1 is the verified task
64
- * commit. The enforcement child runs `read,edit` with no `write`, so the dropped
65
- * commit contains only its in-place source edits — nothing else to preserve.
53
+ * `reset --hard` targets the enforce pass's in-place SOURCE edits. But it must NOT
54
+ * rewind the forensic gate trail: `.pi-tasks/` is frequently TRACKED (the per-task
55
+ * snapshots stage it via `git add -A`), so a bare reset restores TASK_00NN.md to the
56
+ * snapshot commit and ERASES every trail line written after it — the "commit: task
57
+ * snapshot committed", "enforce(edit): …", and resolution lines (mx5 run 9:
58
+ * TASK_0007/0008/0012 each lost their whole post-snapshot trail on this exact path,
59
+ * so a passing-then-reverted task looked like it had never been committed). So the
60
+ * trail is snapshotted before the reset and restored after — the revert undoes code,
61
+ * the audit log survives.
66
62
  *
67
63
  * Best-effort and never throws: a git failure is swallowed (the caller has
68
64
  * already decided to keep the verified work; a failed reset only leaves the
@@ -6,7 +6,12 @@
6
6
  * outside a git repo, with nothing staged, or on any git error we report the
7
7
  * reason and let the loop continue (the task already succeeded).
8
8
  */
9
+ import * as fsp from 'node:fs/promises';
10
+ import * as path from 'node:path';
9
11
  import { runChildDefault } from '../shared/child-process.js';
12
+ /** The gate machinery's own state/forensic dir — the trail, debug logs, and per-run
13
+ * ledgers. Preserved verbatim across a revert (see gitDropLastCommit). */
14
+ const TRAIL_DIR = '.pi-tasks';
10
15
  /**
11
16
  * Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
12
17
  * the headless docker container has no HOME gitconfig, so EVERY per-task commit
@@ -136,16 +141,65 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
136
141
  * `git reset --hard HEAD~1` throws the enforce commit away and brings back the
137
142
  * verified task commit underneath it.
138
143
  *
139
- * `reset --hard` is safe here precisely because it runs right after the enforce
140
- * commit: the working tree is clean (everything was just committed), so there is
141
- * no unrelated uncommitted work for it to destroy. HEAD~1 is the verified task
142
- * commit. The enforcement child runs `read,edit` with no `write`, so the dropped
143
- * commit contains only its in-place source edits — nothing else to preserve.
144
+ * `reset --hard` targets the enforce pass's in-place SOURCE edits. But it must NOT
145
+ * rewind the forensic gate trail: `.pi-tasks/` is frequently TRACKED (the per-task
146
+ * snapshots stage it via `git add -A`), so a bare reset restores TASK_00NN.md to the
147
+ * snapshot commit and ERASES every trail line written after it — the "commit: task
148
+ * snapshot committed", "enforce(edit): …", and resolution lines (mx5 run 9:
149
+ * TASK_0007/0008/0012 each lost their whole post-snapshot trail on this exact path,
150
+ * so a passing-then-reverted task looked like it had never been committed). So the
151
+ * trail is snapshotted before the reset and restored after — the revert undoes code,
152
+ * the audit log survives.
144
153
  *
145
154
  * Best-effort and never throws: a git failure is swallowed (the caller has
146
155
  * already decided to keep the verified work; a failed reset only leaves the
147
156
  * enforce commit in place, which is surfaced as a warning).
148
157
  */
149
158
  export async function gitDropLastCommit(cwd, signal, spawnFn) {
159
+ const trail = await snapshotTrail(cwd);
150
160
  await git(cwd, ['reset', '--hard', 'HEAD~1'], signal, spawnFn);
161
+ await restoreTrail(cwd, trail);
162
+ }
163
+ /** Read every file under `.pi-tasks/` into memory (relative path → bytes). Best-effort:
164
+ * a missing dir or unreadable file is skipped, so this never blocks the revert. */
165
+ async function snapshotTrail(cwd) {
166
+ const out = new Map();
167
+ const root = path.join(cwd, TRAIL_DIR);
168
+ const walk = async (dir) => {
169
+ let entries;
170
+ try {
171
+ entries = await fsp.readdir(dir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ for (const e of entries) {
177
+ const full = path.join(dir, e.name);
178
+ if (e.isDirectory())
179
+ await walk(full);
180
+ else if (e.isFile()) {
181
+ try {
182
+ out.set(path.relative(cwd, full), await fsp.readFile(full));
183
+ }
184
+ catch {
185
+ // unreadable — skip
186
+ }
187
+ }
188
+ }
189
+ };
190
+ await walk(root);
191
+ return out;
192
+ }
193
+ /** Re-materialise the snapshotted trail files, overwriting whatever the reset left. */
194
+ async function restoreTrail(cwd, trail) {
195
+ for (const [rel, buf] of trail) {
196
+ const full = path.join(cwd, rel);
197
+ try {
198
+ await fsp.mkdir(path.dirname(full), { recursive: true });
199
+ await fsp.writeFile(full, buf);
200
+ }
201
+ catch {
202
+ // best-effort restore
203
+ }
204
+ }
151
205
  }
@@ -36,7 +36,22 @@ type BootOutcome = {
36
36
  } | {
37
37
  outcome: 'fail';
38
38
  detail: string;
39
+ } | {
40
+ outcome: 'orphan-port';
41
+ detail: string;
42
+ port: number | null;
39
43
  };
44
+ /** Injectable environment probes for the boot check's orphan-port recovery, so the
45
+ * reap-and-retry path is deterministically testable without a real listener. */
46
+ export interface BootDeps {
47
+ /** The pid + command line holding `port` in LISTEN, or null if none/unknown. */
48
+ findPortHolder?: (port: number) => {
49
+ pid: number;
50
+ command: string;
51
+ } | null;
52
+ /** Terminate a pid we attribute to ourselves; returns whether it was signalled. */
53
+ reap?: (pid: number) => boolean;
54
+ }
40
55
  /**
41
56
  * Exercise the start command ONCE, with no port/URL/framework knowledge — the
42
57
  * command's own fate within the grace window decides:
@@ -63,5 +78,5 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
63
78
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
64
79
  * First real failure wins.
65
80
  */
66
- export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number): Promise<FinalGateOutcome>;
81
+ export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps): Promise<FinalGateOutcome>;
67
82
  export {};
@@ -167,6 +167,66 @@ export function discoverBootCommand(cwd) {
167
167
  }
168
168
  return null;
169
169
  }
170
+ /** Recognise an "address already in use" bind failure across runtimes (Node
171
+ * EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
172
+ function isAddressInUse(text) {
173
+ return /EADDRINUSE|address already in use|address in use|port \d+ (?:is |already )?in use/i.test(text);
174
+ }
175
+ /** Best-effort port number from a bind-failure message, for the diagnosis line. */
176
+ function extractPort(text) {
177
+ const m = /(?:port|:)\s*(\d{2,5})\b/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})\b/i.exec(text);
178
+ if (!m)
179
+ return null;
180
+ const n = Number(m[1]);
181
+ return n > 0 && n < 65536 ? n : null;
182
+ }
183
+ /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
184
+ * failure (the diagnosis then omits the pid — never blocks). */
185
+ function defaultFindPortHolder(port) {
186
+ try {
187
+ const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
188
+ encoding: 'utf8',
189
+ timeout: 4000
190
+ });
191
+ const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
192
+ if (!Number.isInteger(pid) || pid <= 0)
193
+ return null;
194
+ const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
195
+ encoding: 'utf8',
196
+ timeout: 4000
197
+ });
198
+ return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
204
+ function defaultReap(pid) {
205
+ try {
206
+ process.kill(pid, 'SIGTERM');
207
+ setTimeout(() => {
208
+ try {
209
+ process.kill(pid, 'SIGKILL');
210
+ }
211
+ catch {
212
+ // already gone
213
+ }
214
+ }, 1_000).unref();
215
+ return true;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
222
+ * the discovered boot command)? Only then do we reap it — never a foreign process
223
+ * the user happens to be running. */
224
+ function holderIsOurs(command, boot) {
225
+ const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
226
+ const c = command.toLowerCase();
227
+ return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
228
+ && (c.includes(` ${script}`) || c.endsWith(script)));
229
+ }
170
230
  /**
171
231
  * Exercise the start command ONCE, with no port/URL/framework knowledge — the
172
232
  * command's own fate within the grace window decides:
@@ -201,8 +261,18 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
201
261
  };
202
262
  const killGroup = (sig) => {
203
263
  try {
204
- if (child.pid)
264
+ if (!child.pid)
265
+ return;
266
+ if (process.platform === 'win32') {
267
+ // Windows has no process groups / negative-pid kill. taskkill
268
+ // /T tears down the whole tree (the detached child plus any
269
+ // grandchildren it spawned); /F forces it, so the SIGTERM→
270
+ // SIGKILL escalation collapses to one idempotent call.
271
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
272
+ }
273
+ else {
205
274
  process.kill(-child.pid, sig);
275
+ }
206
276
  }
207
277
  catch {
208
278
  // group already gone
@@ -222,6 +292,17 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
222
292
  }
223
293
  const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
224
294
  const tail = outputTail(out, err);
295
+ // A bind collision is an environment condition, not an app defect — hand
296
+ // it back distinctly so the gate can reap our own orphan and retry rather
297
+ // than reporting the app "crashed" (mx5 run 9 item 3).
298
+ if (isAddressInUse(`${out}\n${err}`)) {
299
+ settle({
300
+ outcome: 'orphan-port',
301
+ port: extractPort(`${out}\n${err}`),
302
+ detail: `${what}${tail ? ` — ${tail}` : ''}`
303
+ });
304
+ return;
305
+ }
225
306
  settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
226
307
  });
227
308
  });
@@ -273,13 +354,33 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
273
354
  }
274
355
  return { outcome: 'pass' };
275
356
  }
357
+ /**
358
+ * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
359
+ * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
360
+ * so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
361
+ * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
362
+ * to ourselves.
363
+ */
364
+ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
365
+ if (first.port === null)
366
+ return first;
367
+ const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
368
+ if (!holder || !holderIsOurs(holder.command, boot))
369
+ return first;
370
+ const reaped = (deps.reap ?? defaultReap)(holder.pid);
371
+ if (!reaped)
372
+ return first;
373
+ // Give the OS a moment to release the socket, then re-run the boot once.
374
+ await new Promise(r => setTimeout(r, 1_500));
375
+ return runBootCheck(cwd, boot, bootGraceMs);
376
+ }
276
377
  /**
277
378
  * Run the final gate: static analysis first, then the lockfile consistency
278
379
  * checks, then the discovered integration commands, then one boot exercise of
279
380
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
280
381
  * First real failure wins.
281
382
  */
282
- export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000) {
383
+ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}) {
283
384
  const stat = runRepoHealthCheck(cwd);
284
385
  // ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
285
386
  // the user accepted despite a verify-FAIL and re-check each against the current
@@ -329,10 +430,25 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
329
430
  }
330
431
  if (boot) {
331
432
  const label = `${boot[0]} ${boot[1].join(' ')}`;
332
- const b = await runBootCheck(cwd, boot, bootGraceMs);
433
+ let b = await runBootCheck(cwd, boot, bootGraceMs);
434
+ if (b.outcome === 'orphan-port') {
435
+ b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps);
436
+ }
333
437
  if (b.outcome === 'fail') {
334
438
  return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
335
439
  }
440
+ if (b.outcome === 'orphan-port') {
441
+ // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
442
+ // FAIL: name the port and (when known) the process squatting on it.
443
+ const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
444
+ const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
445
+ : b.port !== null ? ` — port ${b.port} is held by another process`
446
+ : '';
447
+ return withDebts({
448
+ ok: false,
449
+ reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
450
+ });
451
+ }
336
452
  if (b.outcome === 'pass')
337
453
  ran.push(label);
338
454
  }
@@ -242,8 +242,17 @@ export function buildGateDeps(params) {
242
242
  const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
243
243
  lastGuardReconcile = rec;
244
244
  if (rec.mutated) {
245
- log(`=== ${kind} GIT-STATE GUARD — child mutated repo state; restored: ${rec.actions.join('; ')} ===`);
246
- gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
245
+ // Distinguish the two outcomes in the trail: a tainting
246
+ // mutation (graded work altered → verdict will be
247
+ // discarded) vs benign cleanup (test-runner output the
248
+ // child left behind → verdict stands).
249
+ const label = rec.verdictTainted ?
250
+ 'child mutated graded state (verdict discarded)'
251
+ : 'cleaned child test-runner artifacts (verdict kept)';
252
+ log(`=== ${kind} GIT-STATE GUARD — ${label}; restored: ${rec.actions.join('; ')} ===`);
253
+ if (rec.verdictTainted) {
254
+ gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
255
+ }
247
256
  }
248
257
  }
249
258
  }
@@ -419,9 +428,13 @@ export function buildGateDeps(params) {
419
428
  return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
420
429
  },
421
430
  // Git-state guard result of the most recent child run: a verdict
422
- // computed on a tree the child itself mutated is discarded (the
423
- // guard already restored the state — see git-state-guard.ts).
424
- mutationCheck: () => lastGuardReconcile?.mutated ?
431
+ // computed on a tree the child itself mutated is discarded — but ONLY
432
+ // when the mutation touched graded state (verdictTainted). A child
433
+ // that merely left test-runner output behind (test-results/,
434
+ // playwright-report/ …) judged an equivalent tree; its verdict stands
435
+ // and the artifacts were still cleaned (mx5 run 9 lost 7 verdicts this
436
+ // way — see git-state-guard.ts).
437
+ mutationCheck: () => lastGuardReconcile?.verdictTainted ?
425
438
  { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
426
439
  : { mutated: false, detail: '' },
427
440
  // Per-run environment-facts cache under .pi-tasks/ (survives
@@ -11,8 +11,22 @@ export interface GitStateSnapshot {
11
11
  treeSha: string | null;
12
12
  }
13
13
  export interface ReconcileResult {
14
- /** true → the child moved repo state; every detected move was restored. */
14
+ /** true → the child moved repo state; every detected move was restored. This
15
+ * covers benign moves too (test-runner output), so it drives logging/notify —
16
+ * NOT the verdict decision. Use `verdictTainted` for that. */
15
17
  mutated: boolean;
18
+ /** true → the child changed *graded* state: a tracked-in-HEAD file was
19
+ * modified/deleted, HEAD/branch was moved, a stash was pushed, or an untracked
20
+ * non-artifact (source-shaped) file was modified/deleted. This is the real
21
+ * mutate-to-pass class; a verdict computed on such a tree is discarded.
22
+ *
23
+ * Deliberately false for child-CREATED files and for modified/deleted untracked
24
+ * *test-runner artifacts* (test-results/, playwright-report/, coverage output,
25
+ * *.tsbuildinfo, .last-run.json …): a gate child that merely ran the suite and
26
+ * left its report behind judged a tree whose only difference from pre-run is
27
+ * regenerable output — discarding a 49-min verify over that is the F-class this
28
+ * splits off (mx5 run 9: 7 of 9 guard firings were pure test-results churn). */
29
+ verdictTainted: boolean;
16
30
  /** Human-readable restore actions, for the debug log / notify / gate trail. */
17
31
  actions: string[];
18
32
  }
@@ -44,6 +44,24 @@ import * as path from 'node:path';
44
44
  import { runChildDefault } from '../shared/child-process.js';
45
45
  /** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
46
46
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
47
+ /**
48
+ * Untracked paths that are regenerable test/build OUTPUT, not graded source. A gate
49
+ * child creating or rewriting one of these has not mutated the work under judgement,
50
+ * so its verdict stands. Gitignored files never reach the snapshot (git add -A skips
51
+ * them); this list is for the ones a typical project leaves UNIGNORED — Playwright's
52
+ * `test-results/` and `playwright-report/` above all, the exact churn that discarded
53
+ * verify verdicts across mx5 run 9. Kept deliberately narrow: anything not matched
54
+ * here that a child modifies/deletes is treated as graded state (verdict-tainting).
55
+ */
56
+ const ARTIFACT_PATTERNS = [
57
+ /^(?:test-results|playwright-report|coverage|\.nyc_output|dist|build|\.next|\.turbo|\.svelte-kit)\//,
58
+ /(?:^|\/)\.last-run\.json$/,
59
+ /\.tsbuildinfo$/
60
+ ];
61
+ function isBenignArtifact(relPath) {
62
+ const p = relPath.replace(/\\/g, '/');
63
+ return ARTIFACT_PATTERNS.some(re => re.test(p));
64
+ }
47
65
  function makeGit(cwd, signal, spawnFn) {
48
66
  return async (args, env) => {
49
67
  const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
@@ -99,44 +117,103 @@ export async function captureGitState(cwd, signal, spawnFn) {
99
117
  treeSha: await captureWorktreeTree(git)
100
118
  };
101
119
  }
120
+ /** Paths tracked in the commit `headSha` points at — the "graded" codebase a gate
121
+ * child must not rewrite. Empty on any git error (the caller then treats every
122
+ * modified/deleted path as tracked, i.e. verdict-tainting — fail safe). */
123
+ async function trackedPathsAt(git, headSha) {
124
+ const r = await git(['ls-tree', '-r', '--name-only', headSha]);
125
+ if (r.exitCode !== 0)
126
+ return new Set();
127
+ return new Set(r.stdout
128
+ .split('\n')
129
+ .map(l => l.trim())
130
+ .filter(l => l.length > 0));
131
+ }
132
+ /** Cap on itemised path lines per class, so a suite that rewrites hundreds of report
133
+ * files cannot flood the gate trail. Beyond it, a single "…and N more" line. */
134
+ const ITEMIZE_CAP = 20;
135
+ function pushCapped(actions, verb, paths) {
136
+ const shown = paths.slice(0, ITEMIZE_CAP);
137
+ for (const p of shown)
138
+ actions.push(`${verb} ${p}`);
139
+ const extra = paths.length - shown.length;
140
+ if (extra > 0)
141
+ actions.push(`${verb} …and ${extra} more`);
142
+ }
102
143
  /**
103
144
  * Restore every file recorded in `beforeTree` (content + deletions) and remove
104
145
  * files that exist in `afterTree` but not in `beforeTree` (files the child
105
146
  * created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
106
147
  * -a -f` re-materialises the snapshot verbatim.
148
+ *
149
+ * Returns whether any restored change was *verdict-tainting* — a modified/deleted
150
+ * path that is tracked-in-HEAD or an untracked non-artifact (see isBenignArtifact).
151
+ * Creations and test-runner-artifact churn restore identically but do NOT taint.
152
+ * Each changed path is itemised (capped) so the gate trail says WHICH files moved.
107
153
  */
108
- async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
154
+ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions) {
109
155
  const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
110
156
  const env = { GIT_INDEX_FILE: tmpIndex };
157
+ let tainted = false;
111
158
  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();
159
+ // Classify every path the child changed BEFORE touching the tree, so the
160
+ // itemised trail and the taint decision are computed from one diff.
161
+ const created = [];
162
+ const artifactChanges = [];
163
+ const gradedModified = [];
164
+ const gradedDeleted = [];
165
+ const status = await git(['diff-tree', '-r', '--name-status', beforeTree, afterTree]);
166
+ if (status.exitCode === 0) {
167
+ for (const line of status.stdout.split('\n')) {
168
+ const trimmed = line.trim();
169
+ if (trimmed.length === 0)
170
+ continue;
171
+ // "M\tpath", "A\tpath", "D\tpath", "T\tpath" — no -M, so renames show
172
+ // as a D + an A pair; both get classified on their own merits.
173
+ const tab = trimmed.indexOf('\t');
174
+ if (tab < 0)
175
+ continue;
176
+ const code = trimmed[0];
177
+ const name = trimmed.slice(tab + 1).trim();
125
178
  if (name.length === 0)
126
179
  continue;
127
- await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
128
- actions.push(`removed child-created file ${name}`);
180
+ if (code === 'A') {
181
+ created.push(name);
182
+ }
183
+ else if (isBenignArtifact(name) && !tracked.has(name)) {
184
+ // Untracked, regenerable test/build output — not graded work.
185
+ artifactChanges.push(name);
186
+ }
187
+ else if (code === 'D') {
188
+ gradedDeleted.push(name);
189
+ tainted = true;
190
+ }
191
+ else {
192
+ // M, T, and anything else touching a graded (tracked or
193
+ // source-shaped untracked) path is the mutate-to-pass class.
194
+ gradedModified.push(name);
195
+ tainted = true;
196
+ }
129
197
  }
130
198
  }
199
+ // Files the child created — delete them BEFORE checkout so a restore failure
200
+ // cannot leave both halves stale.
201
+ for (const name of created) {
202
+ await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
203
+ }
204
+ pushCapped(actions, 'removed child-created file', created);
205
+ pushCapped(actions, 'restored modified file', gradedModified);
206
+ pushCapped(actions, 'restored deleted file', gradedDeleted);
207
+ pushCapped(actions, 'restored test-runner artifact', artifactChanges);
131
208
  const read = await git(['read-tree', beforeTree], env);
132
209
  if (read.exitCode !== 0) {
133
210
  actions.push('worktree restore FAILED (read-tree)');
134
- return;
211
+ return { tainted };
135
212
  }
136
213
  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)');
214
+ if (co.exitCode !== 0)
215
+ actions.push('worktree restore FAILED (checkout-index)');
216
+ return { tainted };
140
217
  }
141
218
  finally {
142
219
  await fsp.rm(tmpIndex, { force: true }).catch(() => { });
@@ -157,9 +234,12 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
157
234
  */
158
235
  export async function reconcileGitState(cwd, before, signal, spawnFn) {
159
236
  if (!before.ok)
160
- return { mutated: false, actions: [] };
237
+ return { mutated: false, verdictTainted: false, actions: [] };
161
238
  const git = makeGit(cwd, signal, spawnFn);
162
239
  const actions = [];
240
+ // A child that moved HEAD/branch or pushed a stash swallowed graded work — that
241
+ // is unambiguously verdict-tainting. Worktree classification adds to this.
242
+ let tainted = false;
163
243
  // 1. HEAD / branch.
164
244
  const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
165
245
  const branch = await git(['symbolic-ref', '-q', 'HEAD']);
@@ -171,12 +251,15 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
171
251
  actions.push(co.exitCode === 0 ?
172
252
  `checked HEAD back out to ${target}`
173
253
  : `HEAD restore FAILED (checkout ${target})`);
254
+ tainted = true;
174
255
  }
175
256
  // 2. Worktree content.
176
257
  if (before.treeSha) {
177
258
  const afterTree = await captureWorktreeTree(git);
178
259
  if (afterTree && afterTree !== before.treeSha) {
179
- await restoreWorktree(cwd, git, before.treeSha, afterTree, actions);
260
+ const tracked = await trackedPathsAt(git, before.headSha);
261
+ const { tainted: worktreeTainted } = await restoreWorktree(cwd, git, before.treeSha, afterTree, tracked, actions);
262
+ tainted = tainted || worktreeTainted;
180
263
  }
181
264
  }
182
265
  // 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
@@ -189,6 +272,9 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
189
272
  };
190
273
  let stash = await stashNow();
191
274
  if (stash !== before.stashSha) {
275
+ // A child that pushed/popped a stash moved graded work in or out of the tree
276
+ // (mx5 run 6's stash-and-abandon) — always verdict-tainting.
277
+ tainted = true;
192
278
  if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
193
279
  let dropped = 0;
194
280
  while (stash !== before.stashSha && stash !== null && dropped < 10) {
@@ -206,7 +292,7 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
206
292
  actions.push('stash ref changed in a way that cannot be undone (entry popped/dropped)');
207
293
  }
208
294
  }
209
- return { mutated: actions.length > 0, actions };
295
+ return { mutated: actions.length > 0, verdictTainted: tainted, actions };
210
296
  }
211
297
  /** Is `ancestorStash` still reachable in the stash reflog chain at `tipSha`? Used to
212
298
  * tell "child pushed on top" (droppable) from "child popped/dropped ours" (not). */
@@ -2,6 +2,7 @@
2
2
  * Phase pipeline — the five phase functions (refine, research, grill, compose,
3
3
  * critique) plus the config table that drives the orchestrator loop.
4
4
  */
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { docsFocused } from '../workers/docs-core.js';
6
7
  import { fetchFocused } from '../workers/fetch-core.js';
7
8
  import { formatNpmVersionSection } from '../workers/npm-version.js';
@@ -10,6 +11,7 @@ import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } fr
10
11
  import { search as defaultSearch } from '../workers/search-core.js';
11
12
  import { extractEnrichTargets } from './enrichment.js';
12
13
  import { isIntegrationUnknown } from './unknown-routing.js';
14
+ import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
13
15
  import { getFileInventory } from './file-inventory.js';
14
16
  import { buildOrientation, orientationTier } from './orientation.js';
15
17
  import { getConfig } from '../config/config.js';
@@ -129,7 +131,14 @@ export async function phaseContractsBlock(deps) {
129
131
  export const phaseRefine = async (deps, raw, planContext) => {
130
132
  const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
131
133
  const contracts = await phaseContractsBlock(deps);
132
- return runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts))),
134
+ // Imperative tool directives the user wrote into the RAW prompt ("via web
135
+ // search", "fetch <url>"). Refine paraphrases the task and a weak model drops
136
+ // these some of the time (mx5 run 9: "via web search" vanished, the whole run
137
+ // made 0 search calls). Hand them to refine as a MUST-PRESERVE block (belt) and
138
+ // re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
139
+ const directives = extractUserDirectives(raw);
140
+ const directivesBlock = preserveDirectivesBlock(directives);
141
+ const refined = await runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock))),
133
142
  // refine's deliverable is a 4-section text rewrite that never strictly
134
143
  // needs a successful read — on a test-writing task against a large
135
144
  // existing codebase the model over-explores (re-reads source hunting for
@@ -138,6 +147,14 @@ export const phaseRefine = async (deps, raw, planContext) => {
138
147
  // refine looped 3×/resume forever; the deliverable was always producible
139
148
  // from the title + design doc alone.
140
149
  { degradeOnExhaustion: true });
150
+ // Deterministic backstop: if the refined spec still dropped a directive, append
151
+ // it verbatim rather than trusting the paraphrase. No model in this path.
152
+ const { text, appended } = enforceDirectives(refined, directives);
153
+ if (appended.length > 0) {
154
+ deps.logDebug?.(`refine: re-attached ${appended.length} dropped user directive(s): `
155
+ + appended.map(d => d.kind).join(', '));
156
+ }
157
+ return text;
141
158
  };
142
159
  export async function phaseVerifyTooling(deps, research) {
143
160
  const commands = extractToolingCommands(research);
@@ -159,13 +176,13 @@ export async function phaseVerifyTooling(deps, research) {
159
176
  await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
160
177
  return replaceToolingWithVerified(research, parsed.verified);
161
178
  }
162
- const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
179
+ const DOCS_EXTENSION_PATH = fileURLToPath(new URL('../workers/docs-extension.js', import.meta.url));
163
180
  /** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
164
181
  * when a Brave key is configured (the tool without a key just errors, and a weak
165
182
  * model burns calls on it). Search being absent from the research toolset was
166
183
  * STRUCTURAL: three consecutive audited runs made 0 search calls because the
167
184
  * child literally did not have the tool. */
168
- const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
185
+ const SEARCH_EXTENSION_PATH = fileURLToPath(new URL('../workers/search-extension.js', import.meta.url));
169
186
  /**
170
187
  * Is live web search configured for this process? The keyless providers (exa,
171
188
  * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
@@ -191,8 +208,7 @@ export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for e
191
208
  * healthy recorded run, so neither rule has a legitimate false positive here.
192
209
  * See single-read-guard.ts.
193
210
  */
194
- const SINGLE_READ_EXTENSION_PATH = new URL('../workers/single-read-extension.js', import.meta.url)
195
- .pathname;
211
+ const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
196
212
  /**
197
213
  * Task-file heading under which a research worker's validated output is cached.
198
214
  * A resumed research phase reads these to skip workers that already succeeded,
@@ -47,7 +47,7 @@ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) =>
47
47
  * "Scaffold …" title re-expands the entire design into one task (validated: a real
48
48
  * /task-auto run implemented all 24 steps under step 1).
49
49
  */
50
- declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string, contracts?: string) => string;
50
+ declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string, contracts?: string, directives?: string) => string;
51
51
  declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
52
52
  declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
53
53
  declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap?: string) => string;
@@ -61,7 +61,7 @@ ${title}`;
61
61
  * "Scaffold …" title re-expands the entire design into one task (validated: a real
62
62
  * /task-auto run implemented all 24 steps under step 1).
63
63
  */
64
- const REFINE_PROMPT = (raw, planContext, existingFiles, contracts) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
64
+ const REFINE_PROMPT = (raw, planContext, existingFiles, contracts, directives) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
65
65
 
66
66
  Output structure (four sections, exact headings, in this order):
67
67
 
@@ -88,7 +88,7 @@ Rules:
88
88
  - If the task references a design/spec document (an @-path or a named spec file), READ it and treat it as authoritative. Carry its concrete schema verbatim into GOAL/CONSTRAINTS — table and column names, types, endpoint methods and paths, enum values. The task title is only a pointer into that spec: where the title and the spec disagree, follow the spec, and never introduce a table, column, endpoint, or dependency the spec does not define.
89
89
  - CITE interface WIRING, do NOT synthesize it. A wiring specific — how modules/endpoints/files connect (a mount prefix, a route/mount table, a module→path mapping, an exported function/type signature, a file or module layout) — must be citable from the design or the CROSS-SLICE CONTRACTS. The design often pins the interface FACTS (the exact endpoint paths, exported names, layouts) WITHOUT stating the wiring that produces them; when it does, any wiring you write MUST reproduce those pinned facts EXACTLY. Do NOT infer a "uniform" or "tidy" pattern from them — e.g. do not assume one module maps to one mount prefix when the design's pinned facts for that module do not all sit under a single prefix (that exact inference is a seam bug: the consumers follow the pinned facts, the assembly follows your invented pattern, and the seam ships broken). If the design pins neither the fact nor the wiring, leave the detail unspecified rather than inventing a specific.
90
90
  - Do not output any preamble, commentary, or markdown headings beyond the four sections above.
91
- ${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
91
+ ${directives && directives.trim() ? `\n${directives.trim()}\n` : ''}${contracts && contracts.trim() ? `\n${contracts.trim()}\n` : ''}${existingFiles && existingFiles.trim() ? `\n${existingFiles.trim()}\n` : ''}
92
92
  Task: ${raw}`;
93
93
  // ─── Research fan-out prompts ─────────────────────────────────────────────────
94
94
  const RESEARCH_READ_ONLY_CONSTRAINT = `IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.`;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * user-directives — deterministic extraction of the imperative tool/method
3
+ * directives a user writes into a raw task prompt, so a downstream rewrite (refine)
4
+ * cannot silently drop them.
5
+ *
6
+ * The failure this closes (mx5 run 9, validated): the raw prompt was
7
+ * "Research Playwright best practices VIA WEB SEARCH — focus on E2E testing…"
8
+ * Refine rewrote the task well in every other respect (it correctly killed a wrong
9
+ * "Next.js" framing) but the "via web search" instruction vanished from the refined
10
+ * spec entirely — and the whole 2-run session then made ZERO pi-worker-search and
11
+ * ZERO pi-worker-fetch calls. A user who explicitly asks for web search must get
12
+ * web search; the model's paraphrase is not allowed to quietly demote it to
13
+ * "research from local files".
14
+ *
15
+ * The mechanism is the lever, the prompt is the belt: refine is HANDED the
16
+ * directives as a MUST-PRESERVE block (prompt), and — because a weak model still
17
+ * drops them some fraction of the time — the refined text is deterministically
18
+ * re-checked afterwards and the directive is APPENDED verbatim if it went missing
19
+ * (mechanism). Extraction is conservative: only concrete tool/method directives
20
+ * (web search, fetch <url>) are recognised, and a negated mention ("do NOT use web
21
+ * search") is ignored, so a prompt that never asked for a tool gets nothing added.
22
+ */
23
+ export type UserDirectiveKind = 'web-search' | 'fetch-url';
24
+ export interface UserDirective {
25
+ kind: UserDirectiveKind;
26
+ /** The canonical MUST line threaded into refine and appended as a backstop. */
27
+ must: string;
28
+ /** Regexes that, if ANY matches the refined text, prove the directive survived. */
29
+ survives: RegExp[];
30
+ }
31
+ /**
32
+ * The imperative tool/method directives present in a raw user prompt. Deterministic
33
+ * and side-effect free. Deduped by kind (a prompt that says "web search" twice yields
34
+ * one directive). Empty when the prompt names no concrete tool directive.
35
+ */
36
+ export declare function extractUserDirectives(raw: string): UserDirective[];
37
+ /** Does the refined text still carry `directive`? True when ANY survives-regex hits. */
38
+ export declare function directiveSurvives(refined: string, directive: UserDirective): boolean;
39
+ /**
40
+ * The MUST-PRESERVE block threaded into the refine prompt (the belt). Empty string
41
+ * when there are no directives, so refine is unchanged on an ordinary prompt.
42
+ */
43
+ export declare function preserveDirectivesBlock(directives: UserDirective[]): string;
44
+ /**
45
+ * Backstop (the lever): if the refined spec dropped a directive, append it under a
46
+ * CONSTRAINTS-adjacent header so downstream phases still see it. Returns the possibly
47
+ * amended text and the list of directives that had to be force-appended (for logging).
48
+ */
49
+ export declare function enforceDirectives(refined: string, directives: UserDirective[]): {
50
+ text: string;
51
+ appended: UserDirective[];
52
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * user-directives — deterministic extraction of the imperative tool/method
3
+ * directives a user writes into a raw task prompt, so a downstream rewrite (refine)
4
+ * cannot silently drop them.
5
+ *
6
+ * The failure this closes (mx5 run 9, validated): the raw prompt was
7
+ * "Research Playwright best practices VIA WEB SEARCH — focus on E2E testing…"
8
+ * Refine rewrote the task well in every other respect (it correctly killed a wrong
9
+ * "Next.js" framing) but the "via web search" instruction vanished from the refined
10
+ * spec entirely — and the whole 2-run session then made ZERO pi-worker-search and
11
+ * ZERO pi-worker-fetch calls. A user who explicitly asks for web search must get
12
+ * web search; the model's paraphrase is not allowed to quietly demote it to
13
+ * "research from local files".
14
+ *
15
+ * The mechanism is the lever, the prompt is the belt: refine is HANDED the
16
+ * directives as a MUST-PRESERVE block (prompt), and — because a weak model still
17
+ * drops them some fraction of the time — the refined text is deterministically
18
+ * re-checked afterwards and the directive is APPENDED verbatim if it went missing
19
+ * (mechanism). Extraction is conservative: only concrete tool/method directives
20
+ * (web search, fetch <url>) are recognised, and a negated mention ("do NOT use web
21
+ * search") is ignored, so a prompt that never asked for a tool gets nothing added.
22
+ */
23
+ /** ~24 chars of lead-in before a match, scanned for a negation that flips intent. */
24
+ const NEGATION = /\b(?:no|not|never|without|don'?t|do not|avoid|skip)\b[^.?!]{0,24}$/i;
25
+ function isNegated(text, matchIndex) {
26
+ return NEGATION.test(text.slice(Math.max(0, matchIndex - 40), matchIndex));
27
+ }
28
+ // "web search", "search the web/internet", "search online" — the concrete
29
+ // external-research directive. Kept tight so ordinary prose ("search the codebase",
30
+ // "search for the function") never trips it: the object must be web/internet/online.
31
+ const WEB_SEARCH_RE = /\bweb[-\s]*search(?:es|ing)?\b|\bsearch(?:es|ing)?\s+(?:the\s+|on\s+the\s+|across\s+the\s+)?(?:web|internet|online)\b|\bsearch\s+online\b/i;
32
+ // "fetch https://…" / "fetch the page at https://…" — an explicit fetch directive
33
+ // naming a URL. The URL is captured so it can be preserved verbatim.
34
+ const FETCH_URL_RE = /\bfetch(?:es|ing)?\b[^.\n]{0,40}?(https?:\/\/[^\s)>"']+)/i;
35
+ const WEB_SEARCH_SURVIVES = [
36
+ /\bweb[-\s]*search/i,
37
+ /\bsearch(?:es|ing)?\s+(?:the\s+|on\s+the\s+|across\s+the\s+)?(?:web|internet|online)\b/i,
38
+ /\bsearch\s+online\b/i,
39
+ /pi-worker-search/i
40
+ ];
41
+ /**
42
+ * The imperative tool/method directives present in a raw user prompt. Deterministic
43
+ * and side-effect free. Deduped by kind (a prompt that says "web search" twice yields
44
+ * one directive). Empty when the prompt names no concrete tool directive.
45
+ */
46
+ export function extractUserDirectives(raw) {
47
+ const out = [];
48
+ const ws = WEB_SEARCH_RE.exec(raw);
49
+ if (ws && !isNegated(raw, ws.index)) {
50
+ out.push({
51
+ kind: 'web-search',
52
+ must: 'MUST use live web search (via pi-worker-search) — the user explicitly asked to search the web. Do NOT restrict this task to local files or pi-worker-docs only; external web research is a required part of the deliverable.',
53
+ survives: WEB_SEARCH_SURVIVES
54
+ });
55
+ }
56
+ const fu = FETCH_URL_RE.exec(raw);
57
+ if (fu && !isNegated(raw, fu.index)) {
58
+ const url = fu[1];
59
+ out.push({
60
+ kind: 'fetch-url',
61
+ must: `MUST fetch ${url} (via pi-worker-fetch) — the user explicitly asked to fetch this URL; keep it named in the spec.`,
62
+ survives: [/pi-worker-fetch/i, new RegExp(escapeRegExp(url), 'i'), /\bfetch\b/i]
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+ function escapeRegExp(s) {
68
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+ }
70
+ /** Does the refined text still carry `directive`? True when ANY survives-regex hits. */
71
+ export function directiveSurvives(refined, directive) {
72
+ return directive.survives.some(re => re.test(refined));
73
+ }
74
+ /**
75
+ * The MUST-PRESERVE block threaded into the refine prompt (the belt). Empty string
76
+ * when there are no directives, so refine is unchanged on an ordinary prompt.
77
+ */
78
+ export function preserveDirectivesBlock(directives) {
79
+ if (directives.length === 0)
80
+ return '';
81
+ const lines = directives.map(d => `- ${d.must}`).join('\n');
82
+ return ('USER TOOL DIRECTIVES — MUST PRESERVE (the raw task explicitly names how to do the '
83
+ + 'work; carry each into GOAL/CONSTRAINTS verbatim in intent — never drop or soften '
84
+ + `it to a local-only paraphrase):\n${lines}`);
85
+ }
86
+ /**
87
+ * Backstop (the lever): if the refined spec dropped a directive, append it under a
88
+ * CONSTRAINTS-adjacent header so downstream phases still see it. Returns the possibly
89
+ * amended text and the list of directives that had to be force-appended (for logging).
90
+ */
91
+ export function enforceDirectives(refined, directives) {
92
+ const appended = directives.filter(d => !directiveSurvives(refined, d));
93
+ if (appended.length === 0)
94
+ return { text: refined, appended };
95
+ const block = appended.map(d => `- ${d.must}`).join('\n');
96
+ const text = `${refined.trimEnd()}\n\nUSER TOOL DIRECTIVES (preserved from the raw prompt):\n${block}\n`;
97
+ return { text, appended };
98
+ }
@@ -145,7 +145,10 @@ function ingestBody(cache, pkg, contentHash) {
145
145
  let filesIngested = 0;
146
146
  const insertChunk = cache.db.prepare('INSERT INTO chunks (name, version, file_path, kind, content) VALUES (?, ?, ?, ?, ?)');
147
147
  for (const abs of files.dts) {
148
- const rel = path.relative(pkg.root, abs);
148
+ // Store the file identifier POSIX-style so indexed docs are identical
149
+ // across platforms (this value is a model-facing label, never re-joined
150
+ // to the filesystem — node reads forward slashes fine on Windows too).
151
+ const rel = path.relative(pkg.root, abs).replace(/\\/g, '/');
149
152
  let raw;
150
153
  try {
151
154
  raw = fs.readFileSync(abs, 'utf8');
@@ -163,7 +166,7 @@ function ingestBody(cache, pkg, contentHash) {
163
166
  }
164
167
  }
165
168
  if (files.readme) {
166
- const rel = path.relative(pkg.root, files.readme);
169
+ const rel = path.relative(pkg.root, files.readme).replace(/\\/g, '/');
167
170
  const raw = fs.readFileSync(files.readme, 'utf8');
168
171
  const chunks = chunkReadme(raw);
169
172
  if (chunks.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.9",
3
+ "version": "0.18.11",
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",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "build": "tsc -p tsconfig.build.json",
16
16
  "lint": "prettier --log-level warn --write 'src/**/*.ts' && eslint --fix . && tsc --noEmit",
17
- "test": "AGENT=1 bun test src/",
17
+ "test": "cross-env AGENT=1 bun test --isolate src/",
18
18
  "prepublishOnly": "bun run build"
19
19
  },
20
20
  "peerDependencies": {
@@ -36,12 +36,12 @@
36
36
  "@earendil-works/pi-coding-agent": "0.80.2",
37
37
  "@earendil-works/pi-tui": "0.80.2",
38
38
  "@eslint/js": "10.0.1",
39
- "@sinclair/typebox": "0.34.49",
40
39
  "@types/bun": "1.3.12",
41
40
  "@types/qrcode": "1.5.6",
42
41
  "@types/turndown": "5.0.6",
43
42
  "@types/web-push": "3.6.4",
44
43
  "@types/ws": "8.18.1",
44
+ "cross-env": "^10.1.0",
45
45
  "eslint": "10.2.1",
46
46
  "globals": "17.5.0",
47
47
  "prettier": "3.8.3",