@mjasnikovs/pi-task 0.18.10 → 0.18.12

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,69 @@ 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. The
176
+ * digit run ends on any non-digit (a `(?!\d)` lookahead, NOT `\b`): runtimes often
177
+ * print ":3000" flush against the next token with no separating space/newline
178
+ * ("…:3000error: script exited"), where a trailing `\b` would never match. */
179
+ function extractPort(text) {
180
+ const m = /(?:port|:)\s*(\d{2,5})(?!\d)/i.exec(text) ?? /\baddress[^0-9]*(\d{2,5})(?!\d)/i.exec(text);
181
+ if (!m)
182
+ return null;
183
+ const n = Number(m[1]);
184
+ return n > 0 && n < 65536 ? n : null;
185
+ }
186
+ /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
187
+ * failure (the diagnosis then omits the pid — never blocks). */
188
+ function defaultFindPortHolder(port) {
189
+ try {
190
+ const t = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t', '-P', '-n'], {
191
+ encoding: 'utf8',
192
+ timeout: 4000
193
+ });
194
+ const pid = Number((t.stdout ?? '').split('\n')[0]?.trim());
195
+ if (!Number.isInteger(pid) || pid <= 0)
196
+ return null;
197
+ const ps = spawnSync('ps', ['-o', 'args=', '-p', String(pid)], {
198
+ encoding: 'utf8',
199
+ timeout: 4000
200
+ });
201
+ return { pid, command: (ps.stdout ?? '').trim() || `pid ${pid}` };
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function defaultReap(pid) {
208
+ try {
209
+ process.kill(pid, 'SIGTERM');
210
+ setTimeout(() => {
211
+ try {
212
+ process.kill(pid, 'SIGKILL');
213
+ }
214
+ catch {
215
+ // already gone
216
+ }
217
+ }, 1_000).unref();
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ /** Does the port holder look like one of OUR gate children (a `dev`/`start` run of
225
+ * the discovered boot command)? Only then do we reap it — never a foreign process
226
+ * the user happens to be running. */
227
+ function holderIsOurs(command, boot) {
228
+ const script = boot[1][boot[1].length - 1] ?? ''; // 'start' | 'dev' | 'run'
229
+ const c = command.toLowerCase();
230
+ return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
231
+ && (c.includes(` ${script}`) || c.endsWith(script)));
232
+ }
170
233
  /**
171
234
  * Exercise the start command ONCE, with no port/URL/framework knowledge — the
172
235
  * command's own fate within the grace window decides:
@@ -232,6 +295,17 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
232
295
  }
233
296
  const what = status !== null ? `exited ${status}` : `was killed by ${signal}`;
234
297
  const tail = outputTail(out, err);
298
+ // A bind collision is an environment condition, not an app defect — hand
299
+ // it back distinctly so the gate can reap our own orphan and retry rather
300
+ // than reporting the app "crashed" (mx5 run 9 item 3).
301
+ if (isAddressInUse(`${out}\n${err}`)) {
302
+ settle({
303
+ outcome: 'orphan-port',
304
+ port: extractPort(`${out}\n${err}`),
305
+ detail: `${what}${tail ? ` — ${tail}` : ''}`
306
+ });
307
+ return;
308
+ }
235
309
  settle({ outcome: 'fail', detail: `${what}${tail ? ` — ${tail}` : ''}` });
236
310
  });
237
311
  });
@@ -283,13 +357,33 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
283
357
  }
284
358
  return { outcome: 'pass' };
285
359
  }
360
+ /**
361
+ * Boot check hit an address-in-use bind failure. If the port is held by one of OUR
362
+ * own orphaned gate children (a `dev`/`start` run), reap it and retry the boot once
363
+ * so the app gets a fair launch; otherwise leave the (foreign) holder alone and let
364
+ * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
365
+ * to ourselves.
366
+ */
367
+ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
368
+ if (first.port === null)
369
+ return first;
370
+ const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
371
+ if (!holder || !holderIsOurs(holder.command, boot))
372
+ return first;
373
+ const reaped = (deps.reap ?? defaultReap)(holder.pid);
374
+ if (!reaped)
375
+ return first;
376
+ // Give the OS a moment to release the socket, then re-run the boot once.
377
+ await new Promise(r => setTimeout(r, 1_500));
378
+ return runBootCheck(cwd, boot, bootGraceMs);
379
+ }
286
380
  /**
287
381
  * Run the final gate: static analysis first, then the lockfile consistency
288
382
  * checks, then the discovered integration commands, then one boot exercise of
289
383
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
290
384
  * First real failure wins.
291
385
  */
292
- export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000) {
386
+ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}) {
293
387
  const stat = runRepoHealthCheck(cwd);
294
388
  // ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
295
389
  // the user accepted despite a verify-FAIL and re-check each against the current
@@ -339,10 +433,25 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
339
433
  }
340
434
  if (boot) {
341
435
  const label = `${boot[0]} ${boot[1].join(' ')}`;
342
- const b = await runBootCheck(cwd, boot, bootGraceMs);
436
+ let b = await runBootCheck(cwd, boot, bootGraceMs);
437
+ if (b.outcome === 'orphan-port') {
438
+ b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps);
439
+ }
343
440
  if (b.outcome === 'fail') {
344
441
  return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
345
442
  }
443
+ if (b.outcome === 'orphan-port') {
444
+ // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
445
+ // FAIL: name the port and (when known) the process squatting on it.
446
+ const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
447
+ const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
448
+ : b.port !== null ? ` — port ${b.port} is held by another process`
449
+ : '';
450
+ return withDebts({
451
+ ok: false,
452
+ reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
453
+ });
454
+ }
346
455
  if (b.outcome === 'pass')
347
456
  ran.push(label);
348
457
  }
@@ -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). */
@@ -11,6 +11,7 @@ import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } fr
11
11
  import { search as defaultSearch } from '../workers/search-core.js';
12
12
  import { extractEnrichTargets } from './enrichment.js';
13
13
  import { isIntegrationUnknown } from './unknown-routing.js';
14
+ import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
14
15
  import { getFileInventory } from './file-inventory.js';
15
16
  import { buildOrientation, orientationTier } from './orientation.js';
16
17
  import { getConfig } from '../config/config.js';
@@ -130,7 +131,14 @@ export async function phaseContractsBlock(deps) {
130
131
  export const phaseRefine = async (deps, raw, planContext) => {
131
132
  const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
132
133
  const contracts = await phaseContractsBlock(deps);
133
- 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))),
134
142
  // refine's deliverable is a 4-section text rewrite that never strictly
135
143
  // needs a successful read — on a test-writing task against a large
136
144
  // existing codebase the model over-explores (re-reads source hunting for
@@ -139,6 +147,14 @@ export const phaseRefine = async (deps, raw, planContext) => {
139
147
  // refine looped 3×/resume forever; the deliverable was always producible
140
148
  // from the title + design doc alone.
141
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;
142
158
  };
143
159
  export async function phaseVerifyTooling(deps, research) {
144
160
  const commands = extractToolingCommands(research);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.10",
3
+ "version": "0.18.12",
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",