@mjasnikovs/pi-task 0.18.13 → 0.18.14

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.
@@ -90,6 +90,17 @@ export interface RunChildJsonEventsOptions {
90
90
  onLine?: (line: string) => void;
91
91
  onContextUsage?: (snapshot: ContextSnapshot) => void;
92
92
  onToolCall?: (call: ToolCall) => LoopHit | null;
93
+ /**
94
+ * Fires when a tool call finishes, carrying its RESULT (mx5 run 10 item 6: the
95
+ * verify debug log recorded the `bash:` command but never its output, so "verify
96
+ * claimed curl PASS on a server that cannot serve" was undecidable from the log).
97
+ * Text is the tool's combined output; `isError` distinguishes a failed call.
98
+ */
99
+ onToolResult?: (result: {
100
+ name: string;
101
+ isError: boolean;
102
+ text: string;
103
+ }) => void;
93
104
  onFirstByte?: () => void;
94
105
  /**
95
106
  * Dead-backend stall guard (mx5 run 7: model server died mid-child, the
@@ -159,9 +159,27 @@ export class JsonEventSink {
159
159
  if (hit)
160
160
  this.onLoopKill();
161
161
  }
162
+ return;
163
+ }
164
+ if (t === 'tool_execution_end' && opts.onToolResult) {
165
+ const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
166
+ const res = evt.result;
167
+ const text = toolResultText(res?.content);
168
+ opts.onToolResult({ name: tn, isError: evt.isError === true, text });
162
169
  }
163
170
  }
164
171
  }
172
+ /** Flatten a tool result's `content` array (pi's `{type,text}[]`) into one string. */
173
+ function toolResultText(content) {
174
+ if (!Array.isArray(content))
175
+ return '';
176
+ const parts = [];
177
+ for (const c of content) {
178
+ if (c?.type === 'text' && typeof c.text === 'string')
179
+ parts.push(c.text);
180
+ }
181
+ return parts.join('');
182
+ }
165
183
  // ─── Unified runChild ────────────────────────────────────────────────────────
166
184
  export function runChild(spawn, invocation, cwd, signal, opts) {
167
185
  return new Promise(resolve => {
@@ -1,26 +1,43 @@
1
- /** One accepted-despite-FAIL record: the task and why its VERIFY failed. */
1
+ /**
2
+ * Provenance of a recorded defect:
3
+ * - 'accepted' — the user chose ACCEPT despite a verify-FAIL (the original class).
4
+ * - 'enforce-revert' — an enforce-pass re-verify FAILED and the enforce edits were
5
+ * reverted; the FAIL indicted the ORIGINAL work (mx5 run 10 TASK_0004: "Missing
6
+ * server entry point … the Hono server cannot be started"), so the terminal defect
7
+ * was FOUND and then erased by the very mechanism that found it. Persisted here so
8
+ * the final gate re-checks and surfaces it instead of letting it die with the revert.
9
+ */
10
+ export type DebtOrigin = 'accepted' | 'enforce-revert';
11
+ /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
2
12
  export interface AcceptDebt {
3
13
  taskId: string;
4
14
  reason: string;
15
+ /** Absent in legacy 2-field records → treated as 'accepted'. */
16
+ origin?: DebtOrigin;
5
17
  }
6
18
  export declare function acceptDebtFile(cwd: string): string;
7
19
  /** The raw stored ledger ('' when none recorded yet). Parse with parseAcceptDebts. */
8
20
  export declare function readAcceptDebtsRaw(cwd: string): Promise<string>;
9
21
  /**
10
- * Parse the stored ledger into records. A line without the separator (a reason but
11
- * no id, e.g. hand-edited) parses with an empty taskId rather than being dropped —
12
- * a recorded debt is never silently lost.
22
+ * Parse the stored ledger into records. Fields are tab-separated: `id`, `reason`, and
23
+ * an optional `origin` (legacy 2-field records have no origin → 'accepted'). Because a
24
+ * stored reason is tab-normalised (see normaliseReason), splitting on the separator is
25
+ * unambiguous. A line without any separator (a reason but no id, e.g. hand-edited)
26
+ * parses with an empty taskId rather than being dropped — a recorded debt is never
27
+ * silently lost.
13
28
  */
14
29
  export declare function parseAcceptDebts(raw: string): AcceptDebt[];
15
30
  /** Read + parse in one step. */
16
31
  export declare function readAcceptDebts(cwd: string): Promise<AcceptDebt[]>;
32
+ /** Record a user-ACCEPTED-despite-verify-FAIL debt. */
33
+ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
17
34
  /**
18
- * Append one accepted-despite-FAIL record, deduplicated against what is already
19
- * stored (case-insensitive on task id + reason), keeping the newest MAX_DEBTS.
20
- * Failures are swallowed — the ledger is an auditing aid, never a blocker of the
21
- * gate sequence that calls it.
35
+ * Record an ENFORCE-REVERT debt (mx5 run 10 item 3): an enforce re-verify FAILED and
36
+ * the enforce edits were reverted, but the FAIL indicted the ORIGINAL work — so the
37
+ * defect is still in the shipped tree. Durable so the final gate re-checks/surfaces it
38
+ * rather than letting it die with the revert.
22
39
  */
23
- export declare function recordAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
40
+ export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
24
41
  /** Overwrite the ledger with exactly these records (used to prune resolved debts). */
25
42
  export declare function writeAcceptDebts(cwd: string, debts: AcceptDebt[]): Promise<void>;
26
43
  /**
@@ -50,3 +67,5 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
50
67
  * picker). Empty when nothing is open.
51
68
  */
52
69
  export declare function buildAcceptDebtNote(open: AcceptDebt[]): string;
70
+ /** One-line provenance label for a debt, for the surfaced report. */
71
+ export declare function describeDebt(d: AcceptDebt): string;
Binary file
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import { type FinalGateFixFn } from './gate-deps.js';
3
3
  import { type GateDeps } from './task-gates.js';
4
- import type { AcceptDebt } from './accept-debt.js';
4
+ import { type AcceptDebt } from './accept-debt.js';
5
5
  /**
6
6
  * Injectable seams so the planner and loop are testable without spawning pi.
7
7
  * `runChild` is the planning-only seam used by planAuto; everything else (runTask,
@@ -30,7 +30,7 @@ export interface AutoDeps extends GateDeps {
30
30
  * own static checks plus its own test/build commands, unaided. Absent (tests /
31
31
  * gate off) → the run completes as before.
32
32
  */
33
- finalGate?: (cwd: string) => Promise<{
33
+ finalGate?: (cwd: string, planText?: string) => Promise<{
34
34
  ok: boolean;
35
35
  reason: string;
36
36
  openDebts?: AcceptDebt[];
@@ -27,10 +27,12 @@ import { buildGateDeps } from './gate-deps.js';
27
27
  import { runGatesForTask } from './task-gates.js';
28
28
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
29
29
  import { runFinalIntegrationGate } from './final-gate.js';
30
+ import { describeDebt } from './accept-debt.js';
30
31
  import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
31
32
  import { getConfig } from '../config/config.js';
32
33
  import { configureResearchRun } from '../workers/research-cache.js';
33
34
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
35
+ import { LAUNCH_EXTRACT_PROMPT, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
34
36
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
35
37
  // when the model emits NONE), but a model that never says NONE would otherwise
36
38
  // barrage the user — the real mx5 run asked 10, several of them redundant.
@@ -534,6 +536,21 @@ export async function planAuto(ctx, cwd, feature, deps) {
534
536
  catch {
535
537
  // best-effort registry
536
538
  }
539
+ // Launch contract (mx5 run 10 item 4): extract the package/build SCRIPTS the design
540
+ // declares the project must expose (`migrate`/`seed` fell through decompose and
541
+ // shipped missing, unchecked). Each emitted name is re-grounded against the design
542
+ // (keepGroundedScripts — kept only if the design backticks it), so the final gate's
543
+ // manifest diff can never false-flag a hallucinated script. Best-effort.
544
+ try {
545
+ const scriptRaw = await deps.runChild('launch-extract', '', LAUNCH_EXTRACT_PROMPT(featureForModel));
546
+ const grounded = keepGroundedScripts(parseScriptLines(scriptRaw), featureForModel);
547
+ logPlanDebug(cwd, `launch-contract extraction: ${grounded.length} grounded script(s) kept`
548
+ + ` from ${parseScriptLines(scriptRaw).length} emitted`);
549
+ await appendDeclaredScripts(cwd, grounded);
550
+ }
551
+ catch {
552
+ // best-effort artifact
553
+ }
537
554
  // Thread the feature's spec doc(s) into every title so each per-task
538
555
  // pipeline — which only ever sees its title — reads the real spec instead of
539
556
  // a lossy one-line paraphrase of it.
@@ -617,8 +634,8 @@ function defaultDeps(ctx, cwd, signal, title) {
617
634
  stashRef: cwd2 => gitStashRef(cwd2, signal),
618
635
  // The final integration gate follows the `verify work` switch: it is the
619
636
  // run-level half of the same verification story.
620
- finalGate: cwd2 => getConfig().verifyWork ?
621
- runFinalIntegrationGate(cwd2)
637
+ finalGate: (cwd2, planText) => getConfig().verifyWork ?
638
+ runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
622
639
  : Promise.resolve({ ok: true, reason: 'disabled' })
623
640
  };
624
641
  }
@@ -679,9 +696,16 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
679
696
  // recording must never break the gate
680
697
  }
681
698
  };
682
- let fin = await deps.finalGate(cwd);
683
- if (!fin.ok)
684
- await recGate(`final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
699
+ // Hand the parent plan (the task list) to the gate so it can tell a
700
+ // served app from a CLI: the boot check requires a listener only for
701
+ // the former (mx5 run 10 — a CSS watcher satisfied "still alive").
702
+ let fin = await deps.finalGate(cwd, body);
703
+ // Record the outcome symmetrically (mx5 run 10 item 7): only FAIL was
704
+ // ever trailed, so a PASSing gate was indistinguishable from a gate
705
+ // that never ran. The PASS reason names the commands that were run.
706
+ await recGate(fin.ok ?
707
+ `final-gate: PASS — ${fin.reason.slice(0, 300)}`
708
+ : `final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
685
709
  // ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012):
686
710
  // tasks the user accepted despite a verify-FAIL that the gate could
687
711
  // not prove resolved against the current tree. Surface them at the
@@ -690,9 +714,9 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
690
714
  // already a human decision, so this reports, it does not re-fail.
691
715
  if (fin.openDebts && fin.openDebts.length > 0) {
692
716
  for (const d of fin.openDebts) {
693
- await recGate(`accept-debt STILL OPEN — ${d.taskId || '(unknown task)'} was ACCEPTED despite verify-FAIL: ${d.reason.slice(0, 240)}`);
717
+ await recGate(`defect STILL OPEN — ${d.taskId || '(unknown task)'}: ${describeDebt(d)}: ${d.reason.slice(0, 240)}`);
694
718
  }
695
- active.ui.notify(`${id}: ${fin.openDebts.length} task(s) accepted despite verify-FAIL are STILL unresolved at run end — see the gate trail.`, 'warning');
719
+ active.ui.notify(`${id}: ${fin.openDebts.length} recorded verify-FAIL defect(s) are STILL unresolved at run end — see the gate trail.`, 'warning');
696
720
  }
697
721
  // Resolution loop: Leave-failed (recommended) / Autofix (bounded,
698
722
  // model-driven fix pass + gate re-run — run 7's gap: the picker
@@ -51,9 +51,25 @@ export interface BootDeps {
51
51
  } | null;
52
52
  /** Terminate a pid we attribute to ourselves; returns whether it was signalled. */
53
53
  reap?: (pid: number) => boolean;
54
+ /**
55
+ * Does process group `pgid` currently own a LISTENing TCP socket? Drives the
56
+ * served-app boot check (mx5 run 10): a watcher (`dev` = tailwind/bundler
57
+ * --watch) stays alive forever without ever listening, so "still alive after the
58
+ * grace window = PASS" blessed a project that cannot serve a single request.
59
+ * Injected so the listener requirement is deterministically testable without a
60
+ * real socket; the default probes ss/lsof + pgid.
61
+ */
62
+ groupHasListener?: (pgid: number) => boolean;
54
63
  }
55
64
  /**
56
- * Exercise the start command ONCE, with no port/URL/framework knowledge — the
65
+ * Does the finished run stand up a listening HTTP server? Deterministic, from the
66
+ * built manifest (a server-framework dependency is the plan's own artifact) OR, when
67
+ * available, the plan/spec text. Used to decide whether the boot check must observe a
68
+ * LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
69
+ */
70
+ export declare function detectsServedApp(cwd: string, planText?: string): boolean;
71
+ /**
72
+ * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
57
73
  * command's own fate within the grace window decides:
58
74
  *
59
75
  * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
@@ -61,9 +77,22 @@ export interface BootDeps {
61
77
  * - still alive when the window closes → PASS, then the whole process group is
62
78
  * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
63
79
  *
80
+ * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
81
+ * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
82
+ * forever without ever listening, and a type-only entrypoint exits 0 in <1s having
83
+ * served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
84
+ * PASSes only once a LISTENing socket owned by our process group is observed; if the
85
+ * command exits, or the grace window closes, with no listener ever seen → FAIL naming
86
+ * that a listening server was expected. (The listener requirement needs pgid probing,
87
+ * absent on win32, where `expectServer` collapses to the survival rule — best-effort,
88
+ * never a false FAIL on a platform we cannot probe.)
89
+ *
64
90
  * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
65
91
  */
66
- export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number): Promise<BootOutcome>;
92
+ export declare function runBootCheck(cwd: string, [bin, args]: HealthCommand, graceMs?: number, opts?: {
93
+ expectServer?: boolean;
94
+ deps?: BootDeps;
95
+ }): Promise<BootOutcome>;
67
96
  /**
68
97
  * Labels (`bin args…`) of every command the gate CAN currently discover — the
69
98
  * static half (repo-health) plus the integration half. Pure discovery, nothing
@@ -78,5 +107,5 @@ export declare function discoverGateCommandLabels(cwd: string): string[];
78
107
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
79
108
  * First real failure wins.
80
109
  */
81
- export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps): Promise<FinalGateOutcome>;
110
+ export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps, planText?: string): Promise<FinalGateOutcome>;
82
111
  export {};
@@ -43,6 +43,7 @@ import { existsSync, readFileSync } from 'node:fs';
43
43
  import * as path from 'node:path';
44
44
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
45
45
  import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote } from './accept-debt.js';
46
+ import { readDeclaredScripts, missingDeclaredScripts } from './launch-contract.js';
46
47
  function packageScripts(cwd) {
47
48
  try {
48
49
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -71,10 +72,18 @@ export function discoverIntegrationCommands(cwd) {
71
72
  if (existsSync(path.join(cwd, 'package.json'))) {
72
73
  const s = packageScripts(cwd);
73
74
  const cmds = [];
74
- for (const name of ['test', 'build']) {
75
- if (s[name])
76
- cmds.push(['bun', ['run', name]]);
77
- }
75
+ // Every test-shaped script, not just the one literally named `test` (mx5 run
76
+ // 10: `test:ct` — 89 Playwright component tests, the ONLY client-executing
77
+ // suite — never ran because the gate looked only for `test`/`build`). Plain
78
+ // `test` leads (richer, most common), then `test:*`/`test-*` in declaration
79
+ // order, then `build`. Env-gap SKIP still applies per command (a suite whose
80
+ // browser/runtime is absent skips, it does not fail — see runGateCommand).
81
+ const testNames = Object.keys(s).filter(n => n === 'test' || /^test[:_-]/.test(n));
82
+ testNames.sort((a, b) => (a === 'test' ? -1 : b === 'test' ? 1 : 0));
83
+ for (const name of testNames)
84
+ cmds.push(['bun', ['run', name]]);
85
+ if (s.build)
86
+ cmds.push(['bun', ['run', 'build']]);
78
87
  return { ecosystem: 'package.json', cmds };
79
88
  }
80
89
  if (existsSync(path.join(cwd, 'Makefile'))) {
@@ -183,6 +192,90 @@ function extractPort(text) {
183
192
  const n = Number(m[1]);
184
193
  return n > 0 && n < 65536 ? n : null;
185
194
  }
195
+ /** Package deps that mean "this project stands up an HTTP server" — the deterministic
196
+ * proxy for "the plan/spec promised a served app". Bare framework names plus the
197
+ * scoped families whose presence implies a listener at runtime. */
198
+ function isServerFrameworkDep(name) {
199
+ return (/^(?:hono|express|fastify|koa|polka|restify|next|nuxt|http-server|serve|ws|socket\.io)$/.test(name) || /^@(?:hono|fastify|koa|nestjs|sveltejs|remix-run)\//.test(name));
200
+ }
201
+ /** Spec/plan phrasings that promise a listening server, for the text signal. */
202
+ const SERVE_TEXT_RE = /\b(?:https?\s+server|web\s+server|serves?\b|listen(?:s|ing)?\b|Bun\.serve|app\.listen|createServer|serve\s+(?:static|the)|\/api\/|endpoints?\b)/i;
203
+ /**
204
+ * Does the finished run stand up a listening HTTP server? Deterministic, from the
205
+ * built manifest (a server-framework dependency is the plan's own artifact) OR, when
206
+ * available, the plan/spec text. Used to decide whether the boot check must observe a
207
+ * LISTENER (served app) or may pass on mere survival / quick exit (CLI project).
208
+ */
209
+ export function detectsServedApp(cwd, planText) {
210
+ try {
211
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
212
+ const all = { ...(j.dependencies ?? {}), ...(j.devDependencies ?? {}) };
213
+ if (Object.keys(all).some(isServerFrameworkDep))
214
+ return true;
215
+ }
216
+ catch {
217
+ // no/unreadable manifest → fall through to the text signal
218
+ }
219
+ return planText !== undefined && SERVE_TEXT_RE.test(planText);
220
+ }
221
+ /** Pids currently owning a LISTENing TCP socket (best-effort; ss first, then lsof).
222
+ * Empty on any failure — the caller then cannot attribute a listener to our group
223
+ * and the served-app check degrades to survival (never a false FAIL). */
224
+ function listeningSocketPids() {
225
+ const pids = new Set();
226
+ try {
227
+ const t = spawnSync('ss', ['-tlnpH'], { encoding: 'utf8', timeout: 4000 });
228
+ if (!t.error && t.stdout) {
229
+ for (const m of t.stdout.matchAll(/pid=(\d+)/g))
230
+ pids.add(Number(m[1]));
231
+ }
232
+ }
233
+ catch {
234
+ // ss missing — try lsof
235
+ }
236
+ if (pids.size === 0) {
237
+ try {
238
+ const t = spawnSync('lsof', ['-iTCP', '-sTCP:LISTEN', '-t', '-n', '-P'], {
239
+ encoding: 'utf8',
240
+ timeout: 4000
241
+ });
242
+ if (!t.error && t.stdout) {
243
+ for (const line of t.stdout.split('\n')) {
244
+ const n = Number(line.trim());
245
+ if (Number.isInteger(n) && n > 0)
246
+ pids.add(n);
247
+ }
248
+ }
249
+ }
250
+ catch {
251
+ // neither tool available
252
+ }
253
+ }
254
+ return [...pids];
255
+ }
256
+ /** Process-group id of `pid`, or null if it cannot be read. */
257
+ function pgidOf(pid) {
258
+ try {
259
+ const r = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], {
260
+ encoding: 'utf8',
261
+ timeout: 4000
262
+ });
263
+ const n = Number((r.stdout ?? '').trim());
264
+ return Number.isInteger(n) && n > 0 ? n : null;
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ }
270
+ /** Default listener probe: any LISTENing socket owned by a pid in process group
271
+ * `pgid` (the detached boot child IS its own group leader, so pgid === child.pid). */
272
+ function defaultGroupHasListener(pgid) {
273
+ for (const pid of listeningSocketPids()) {
274
+ if (pgidOf(pid) === pgid)
275
+ return true;
276
+ }
277
+ return false;
278
+ }
186
279
  /** Default port-holder lookup: `lsof` first, then `ss`/`fuser`. Returns null on any
187
280
  * failure (the diagnosis then omits the pid — never blocks). */
188
281
  function defaultFindPortHolder(port) {
@@ -231,7 +324,7 @@ function holderIsOurs(command, boot) {
231
324
  && (c.includes(` ${script}`) || c.endsWith(script)));
232
325
  }
233
326
  /**
234
- * Exercise the start command ONCE, with no port/URL/framework knowledge — the
327
+ * Exercise the start command ONCE. For a CLI project (`expectServer` false) the
235
328
  * command's own fate within the grace window decides:
236
329
  *
237
330
  * - non-zero exit (or signal death) before the window closes → FAIL, output tail;
@@ -239,9 +332,21 @@ function holderIsOurs(command, boot) {
239
332
  * - still alive when the window closes → PASS, then the whole process group is
240
333
  * killed (detached spawn = own group; SIGTERM, escalating to SIGKILL).
241
334
  *
335
+ * For a SERVED app (`expectServer` true — the spec/plan promised an HTTP server) mere
336
+ * survival is not enough: a watcher (`dev` = tailwind/bundler --watch) stays alive
337
+ * forever without ever listening, and a type-only entrypoint exits 0 in <1s having
338
+ * served nothing (mx5 run 10 — both were blessed by the survival rule). The boot then
339
+ * PASSes only once a LISTENing socket owned by our process group is observed; if the
340
+ * command exits, or the grace window closes, with no listener ever seen → FAIL naming
341
+ * that a listening server was expected. (The listener requirement needs pgid probing,
342
+ * absent on win32, where `expectServer` collapses to the survival rule — best-effort,
343
+ * never a false FAIL on a platform we cannot probe.)
344
+ *
242
345
  * Env-gap contract as everywhere: spawn error (ENOENT) or exit 127 → skip.
243
346
  */
244
- export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
347
+ export function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}) {
348
+ const expectServer = (opts.expectServer ?? false) && process.platform !== 'win32';
349
+ const groupHasListener = opts.deps?.groupHasListener ?? defaultGroupHasListener;
245
350
  return new Promise(resolve => {
246
351
  const child = spawn(bin, args, {
247
352
  cwd,
@@ -251,6 +356,7 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
251
356
  });
252
357
  let out = '';
253
358
  let err = '';
359
+ let listenerSeen = false;
254
360
  const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
255
361
  child.stdout?.on('data', (d) => (out = cap(out + String(d))));
256
362
  child.stderr?.on('data', (d) => (err = cap(err + String(d))));
@@ -260,6 +366,8 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
260
366
  return;
261
367
  settled = true;
262
368
  clearTimeout(timer);
369
+ if (poll)
370
+ clearInterval(poll);
263
371
  resolve(r);
264
372
  };
265
373
  const killGroup = (sig) => {
@@ -281,15 +389,47 @@ export function runBootCheck(cwd, [bin, args], graceMs = 10_000) {
281
389
  // group already gone
282
390
  }
283
391
  };
284
- const timer = setTimeout(() => {
392
+ const passAndKill = () => {
285
393
  settle({ outcome: 'pass' });
286
394
  killGroup('SIGTERM');
287
395
  setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
396
+ };
397
+ // Served apps only: poll for a listening socket owned by our process group.
398
+ // As soon as one appears the boot has demonstrably served → PASS early.
399
+ const poll = expectServer ?
400
+ setInterval(() => {
401
+ if (settled || !child.pid)
402
+ return;
403
+ if (groupHasListener(child.pid)) {
404
+ listenerSeen = true;
405
+ passAndKill();
406
+ }
407
+ }, 500)
408
+ : null;
409
+ const timer = setTimeout(() => {
410
+ if (expectServer && !listenerSeen) {
411
+ settle({
412
+ outcome: 'fail',
413
+ detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
414
+ });
415
+ killGroup('SIGTERM');
416
+ setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
417
+ return;
418
+ }
419
+ passAndKill();
288
420
  }, graceMs);
289
421
  child.on('error', () => settle({ outcome: 'skip' }));
290
422
  child.on('exit', (status, signal) => {
291
- if (status === 0)
423
+ if (status === 0) {
424
+ if (expectServer && !listenerSeen) {
425
+ return settle({
426
+ outcome: 'fail',
427
+ detail: 'exited 0 without ever opening a listening socket — the spec/dependencies '
428
+ + 'promise an HTTP server, so a boot that serves nothing is not a launch'
429
+ });
430
+ }
292
431
  return settle({ outcome: 'pass' });
432
+ }
293
433
  if (status === 127 || (status === null && signal === null)) {
294
434
  return settle({ outcome: 'skip' });
295
435
  }
@@ -335,11 +475,20 @@ function outputTail(stdout, stderr, limit = 400) {
335
475
  const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
336
476
  return combined.length > limit ? `…${tail}` : tail;
337
477
  }
478
+ /**
479
+ * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
480
+ * a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
481
+ * libraries were never installed here (mx5 run 10 item 2: `test:ct` must run in the
482
+ * gate, but on a box with no Playwright browsers it is an environment gap, not a FAIL).
483
+ * These exit non-zero (not 127), so they need output-shape recognition to skip.
484
+ */
485
+ const ENV_GAP_OUTPUT_RE = /Executable doesn't exist|playwright install|browserType\.\w+: Executable|(?:wasn't|weren't) installed|Host system is missing dependencies|No usable sandbox|Cypress verification|Cypress executable (?:not found|was not found)|browser(?:s)? (?:is|are)? ?not installed/i;
338
486
  /**
339
487
  * Run one gate command with the env-gap contract: tool missing, timeout, or
340
488
  * command-not-found inside the script chain (127) → environment gap, not a code
341
- * fault → skipped (same contract as repo-health). Only a command that actually
342
- * ran and exited non-zero fails.
489
+ * fault → skipped (same contract as repo-health). Also skips a non-zero exit whose
490
+ * output shows a missing browser/runtime (ENV_GAP_OUTPUT_RE). Only a command that
491
+ * actually ran and exited non-zero for a real reason fails.
343
492
  */
344
493
  function runGateCommand(cwd, [bin, args], timeoutMs) {
345
494
  // env passed explicitly: bun's spawnSync resolves the binary against a
@@ -353,6 +502,8 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
353
502
  if (r.error || r.status === null || r.status === 127)
354
503
  return { outcome: 'skip' };
355
504
  if (r.status !== 0) {
505
+ if (ENV_GAP_OUTPUT_RE.test(`${r.stdout ?? ''}\n${r.stderr ?? ''}`))
506
+ return { outcome: 'skip' };
356
507
  return { outcome: 'fail', status: r.status, tail: outputTail(r.stdout ?? '', r.stderr ?? '') };
357
508
  }
358
509
  return { outcome: 'pass' };
@@ -364,7 +515,7 @@ function runGateCommand(cwd, [bin, args], timeoutMs) {
364
515
  * the caller emit the harness diagnosis. Never reaps a process we cannot attribute
365
516
  * to ourselves.
366
517
  */
367
- async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
518
+ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, expectServer) {
368
519
  if (first.port === null)
369
520
  return first;
370
521
  const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
@@ -375,7 +526,7 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
375
526
  return first;
376
527
  // Give the OS a moment to release the socket, then re-run the boot once.
377
528
  await new Promise(r => setTimeout(r, 1_500));
378
- return runBootCheck(cwd, boot, bootGraceMs);
529
+ return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
379
530
  }
380
531
  /**
381
532
  * Run the final gate: static analysis first, then the lockfile consistency
@@ -383,7 +534,7 @@ async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps) {
383
534
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
384
535
  * First real failure wins.
385
536
  */
386
- export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}) {
537
+ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
387
538
  const stat = runRepoHealthCheck(cwd);
388
539
  // ACCEPT-debt re-check (mx5 run 4 B3 / run 8 TASK_0012): read the ledger of tasks
389
540
  // the user accepted despite a verify-FAIL and re-check each against the current
@@ -406,6 +557,20 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
406
557
  });
407
558
  if (!stat.ok)
408
559
  return withDebts({ ok: false, reason: `static checks: ${stat.reason}` });
560
+ // Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
561
+ // scripts that fell through decompose and shipped missing, unchecked. Diff the
562
+ // plan-time-extracted declared scripts against the manifest; a missing one is a
563
+ // launch-surface defect. FP-safe: empty declared list (nothing grounded) → no check.
564
+ const declared = await readDeclaredScripts(cwd);
565
+ if (declared.length > 0) {
566
+ const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
567
+ if (missing.length > 0) {
568
+ return withDebts({
569
+ ok: false,
570
+ reason: `launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`
571
+ });
572
+ }
573
+ }
409
574
  const lockCmds = discoverLockfileChecks(cwd);
410
575
  const { cmds } = discoverIntegrationCommands(cwd);
411
576
  const boot = discoverBootCommand(cwd);
@@ -433,9 +598,10 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
433
598
  }
434
599
  if (boot) {
435
600
  const label = `${boot[0]} ${boot[1].join(' ')}`;
436
- let b = await runBootCheck(cwd, boot, bootGraceMs);
601
+ const expectServer = detectsServedApp(cwd, planText);
602
+ let b = await runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps: bootDeps });
437
603
  if (b.outcome === 'orphan-port') {
438
- b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps);
604
+ b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDeps, expectServer);
439
605
  }
440
606
  if (b.outcome === 'fail') {
441
607
  return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
@@ -6,6 +6,12 @@ import { type ChangedFile } from './substitution-probe.js';
6
6
  /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
7
7
  * command so this module stays free of the orchestrators (avoids an import cycle). */
8
8
  export type RunTaskFn = GateDeps['runTask'];
9
+ /**
10
+ * One-line, tail-kept, whitespace-flattened summary of a tool's output for the gate
11
+ * debug log. The TAIL is kept (a bind failure / final status / assertion lands at the
12
+ * end of the output) with a leading ellipsis when truncated; empty output → "(no output)".
13
+ */
14
+ export declare function truncateToolResult(text: string, limit?: number): string;
9
15
  /** One bounded final-gate fix attempt (see final-gate-fix.ts): fix child →
10
16
  * shrink guard → gate re-run. Consumed by /task-auto's run-end gate branch. */
11
17
  export type FinalGateFixFn = (ctx: ExtensionCommandContext, cwd: string, failReason: string) => Promise<FinalFixResult>;
@@ -21,7 +21,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
22
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
23
23
  import { readContracts } from './contracts.js';
24
- import { recordAcceptDebt } from './accept-debt.js';
24
+ import { recordAcceptDebt, recordEnforceRevertDebt } from './accept-debt.js';
25
25
  import { runRepoHealthCheck } from './repo-health-check.js';
26
26
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
27
27
  import { runFinalGateAutofix } from './final-gate-fix.js';
@@ -38,6 +38,19 @@ import { formatLoopHint } from './child-runner.js';
38
38
  import { getConfig } from '../config/config.js';
39
39
  import { startAutoLoader } from './widget.js';
40
40
  import { resolveContextUsage } from './context-usage.js';
41
+ /** Max chars of a tool result kept in the gate debug log (mx5 run 10 item 6). */
42
+ const TOOL_RESULT_LOG_LIMIT = 300;
43
+ /**
44
+ * One-line, tail-kept, whitespace-flattened summary of a tool's output for the gate
45
+ * debug log. The TAIL is kept (a bind failure / final status / assertion lands at the
46
+ * end of the output) with a leading ellipsis when truncated; empty output → "(no output)".
47
+ */
48
+ export function truncateToolResult(text, limit = TOOL_RESULT_LOG_LIMIT) {
49
+ const flat = text.replace(/\s+/g, ' ').trim();
50
+ if (flat.length === 0)
51
+ return '(no output)';
52
+ return flat.length > limit ? `…${flat.slice(-limit)}` : flat;
53
+ }
41
54
  /** Keep the gate machinery's own artifacts out of every git pathspec below. */
42
55
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
43
56
  const splitLines = (s) => s
@@ -230,6 +243,11 @@ export function buildGateDeps(params) {
230
243
  lastLine = line;
231
244
  log(line);
232
245
  },
246
+ // Log tool OUTPUTS, not just the command (mx5 run 10 item 6):
247
+ // without the result "verify claimed curl PASS on a server that
248
+ // cannot serve" is undecidable from the log. Truncated, tail-kept
249
+ // (a bind failure / status usually lands at the end), error-flagged.
250
+ onToolResult: ({ name, isError, text }) => log(`↳ ${name} [${isError ? 'ERR' : 'ok'}]: ${truncateToolResult(text)}`),
233
251
  onContextUsage: snapshot => {
234
252
  contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
235
253
  }
@@ -278,6 +296,7 @@ export function buildGateDeps(params) {
278
296
  // Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
279
297
  // discardEdits): the final integration gate re-checks each debt at run end.
280
298
  recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
299
+ recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
281
300
  // Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
282
301
  // task's spec forbids modifying, so the gate sequence can UNDO any edit the
283
302
  // enforce EDIT pass makes to them before those edits are committed. Reads the
@@ -38,6 +38,7 @@
38
38
  * missing) disables the guard (capture returns ok:false and reconcile no-ops) —
39
39
  * the gate must keep working in non-git projects exactly as before.
40
40
  */
41
+ import { readFileSync } from 'node:fs';
41
42
  import * as fsp from 'node:fs/promises';
42
43
  import * as os from 'node:os';
43
44
  import * as path from 'node:path';
@@ -62,6 +63,55 @@ function isBenignArtifact(relPath) {
62
63
  const p = relPath.replace(/\\/g, '/');
63
64
  return ARTIFACT_PATTERNS.some(re => re.test(p));
64
65
  }
66
+ /**
67
+ * Regenerable machine state that is benign EVEN WHEN TRACKED — a project that
68
+ * mistakenly commits it (mx5 run 10 does exactly this) must not have a gate child's
69
+ * incidental rewrite of it discard the verdict. Two classes:
70
+ * - Playwright component-test build cache (`ctCacheDir` — run 10 committed 60+
71
+ * `.playwright-cache/assets/*.js` bundles; a `test:ct` run rewrites them every
72
+ * time), and
73
+ * - the test runner's `.last-run.json` run-state file.
74
+ * DELIBERATELY narrow: snapshot BASELINE images (`*-snapshots/*.png`) are NOT here —
75
+ * a child that rewrites a baseline to make a screenshot test pass is the real
76
+ * mutate-to-pass catch (run 10's other half), so those stay verdict-tainting.
77
+ */
78
+ const ALWAYS_REGENERABLE_PATTERNS = [/(?:^|\/)\.last-run\.json$/];
79
+ /** Playwright config files that may declare a custom `ctCacheDir`. */
80
+ const CT_CONFIG_FILES = [
81
+ 'playwright-ct.config.ts',
82
+ 'playwright-ct.config.js',
83
+ 'playwright.config.ts',
84
+ 'playwright.config.js'
85
+ ];
86
+ /** ctCacheDir defaults Playwright uses when a config does not override it. */
87
+ const DEFAULT_CT_CACHE_DIRS = ['.playwright-cache', 'playwright/.cache'];
88
+ /**
89
+ * The component-test cache dir(s) for this project: the `ctCacheDir` any Playwright
90
+ * config declares, plus the known defaults. Read once per reconcile (best-effort — a
91
+ * missing/odd config just leaves the defaults). Normalised to a repo-relative prefix.
92
+ */
93
+ function readCtCacheDirs(cwd) {
94
+ const dirs = new Set(DEFAULT_CT_CACHE_DIRS);
95
+ for (const f of CT_CONFIG_FILES) {
96
+ try {
97
+ const text = readFileSync(path.join(cwd, f), 'utf8');
98
+ const m = /ctCacheDir\s*:\s*['"`]([^'"`]+)['"`]/.exec(text);
99
+ if (m)
100
+ dirs.add(m[1].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
101
+ }
102
+ catch {
103
+ // no such config, or unreadable — defaults stand
104
+ }
105
+ }
106
+ return [...dirs].filter(d => d.length > 0);
107
+ }
108
+ /** Is this path regenerable machine state that is benign even when tracked-in-HEAD? */
109
+ function isAlwaysRegenerable(relPath, ctCacheDirs) {
110
+ const p = relPath.replace(/\\/g, '/');
111
+ if (ALWAYS_REGENERABLE_PATTERNS.some(re => re.test(p)))
112
+ return true;
113
+ return ctCacheDirs.some(d => p === d || p.startsWith(d + '/'));
114
+ }
65
115
  function makeGit(cwd, signal, spawnFn) {
66
116
  return async (args, env) => {
67
117
  const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
@@ -151,7 +201,7 @@ function pushCapped(actions, verb, paths) {
151
201
  * Creations and test-runner-artifact churn restore identically but do NOT taint.
152
202
  * Each changed path is itemised (capped) so the gate trail says WHICH files moved.
153
203
  */
154
- async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions) {
204
+ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCacheDirs, actions) {
155
205
  const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
156
206
  const env = { GIT_INDEX_FILE: tmpIndex };
157
207
  let tainted = false;
@@ -180,8 +230,11 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, actions
180
230
  if (code === 'A') {
181
231
  created.push(name);
182
232
  }
183
- else if (isBenignArtifact(name) && !tracked.has(name)) {
184
- // Untracked, regenerable test/build output — not graded work.
233
+ else if (isAlwaysRegenerable(name, ctCacheDirs)
234
+ || (isBenignArtifact(name) && !tracked.has(name))) {
235
+ // Regenerable test/build output — not graded work. Either an
236
+ // always-regenerable class (ct cache / run-state, benign even when
237
+ // tracked — mx5 run 10) or untracked test-runner output.
185
238
  artifactChanges.push(name);
186
239
  }
187
240
  else if (code === 'D') {
@@ -258,7 +311,8 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
258
311
  const afterTree = await captureWorktreeTree(git);
259
312
  if (afterTree && afterTree !== before.treeSha) {
260
313
  const tracked = await trackedPathsAt(git, before.headSha);
261
- const { tainted: worktreeTainted } = await restoreWorktree(cwd, git, before.treeSha, afterTree, tracked, actions);
314
+ const ctCacheDirs = readCtCacheDirs(cwd);
315
+ const { tainted: worktreeTainted } = await restoreWorktree(cwd, git, before.treeSha, afterTree, tracked, ctCacheDirs, actions);
262
316
  tainted = tainted || worktreeTainted;
263
317
  }
264
318
  }
@@ -0,0 +1,31 @@
1
+ export declare function launchContractFile(cwd: string): string;
2
+ /**
3
+ * Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
4
+ * whose token is not script-name-shaped is skipped (an accidental sentence, a path).
5
+ */
6
+ export declare function parseScriptLines(text: string): string[];
7
+ /**
8
+ * THE GROUNDING GUARD: keep only names the design declares as an inline-code token
9
+ * (`` `name` ``) — the form a design uses to name a script. A name the model
10
+ * paraphrased or invented has no such token, so it is dropped and the diff cannot
11
+ * false-flag on it. Deduplicated, case-insensitive.
12
+ */
13
+ export declare function keepGroundedScripts(names: string[], sourceDoc: string): string[];
14
+ /** The stored declared-script list ('' when none recorded). */
15
+ export declare function readLaunchContractRaw(cwd: string): Promise<string>;
16
+ /** The declared script names recorded for this run (deduped, order preserved). */
17
+ export declare function readDeclaredScripts(cwd: string): Promise<string[]>;
18
+ /** Append grounded script names, deduped against what is stored, keeping newest MAX. */
19
+ export declare function appendDeclaredScripts(cwd: string, names: string[]): Promise<void>;
20
+ /**
21
+ * Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
22
+ * declared script is present, or when nothing was declared (no check). This is the
23
+ * deterministic lever the final gate FAILs on.
24
+ */
25
+ export declare function missingDeclaredScripts(declared: string[], manifestScripts: string[]): string[];
26
+ /**
27
+ * The plan-time extraction prompt: the design in hand, emit the scripts it declares.
28
+ * Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
29
+ * (keepGroundedScripts), so a hallucinated script cannot reach the diff.
30
+ */
31
+ export declare const LAUNCH_EXTRACT_PROMPT: (feature: string) => string;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * launch-contract — a per-run record of the package/build SCRIPTS the SOURCE design
3
+ * declares the finished project must expose, extracted once at plan time and diffed
4
+ * by the final gate against the shipped manifest.
5
+ *
6
+ * The failure this closes (mx5 run 10 item 4): the design's §9 "Build & run" listed
7
+ * the required scripts verbatim — `dev`, `build`, `migrate`, `seed`, `test` — but the
8
+ * shipped package.json declared only `dev`, `build`, `lint`, `test`, `test:ct`. No
9
+ * task owned `migrate`/`seed` (they fell through decompose), and NOTHING re-checked
10
+ * the finished manifest against the design's own list, so the run completed missing
11
+ * two of its declared entrypoints. A per-slice gate cannot catch this — it is a
12
+ * whole-project launch-surface fact — and the final gate never had the design's list.
13
+ *
14
+ * Mechanism (mirrors contracts.ts): a plan-time child EMITs `SCRIPT:` lines naming the
15
+ * scripts the design declares; the host GROUNDS each against the design — a name is
16
+ * kept only if the design mentions it as an inline-code token (`` `migrate` ``), the
17
+ * form designs use to declare a script. A paraphrase or a script the model invented is
18
+ * not grounded and is dropped, so the diff can never false-flag on a hallucinated
19
+ * requirement. The grounded list is appended HOST-SIDE to `.pi-tasks/launch-contract.md`
20
+ * (children never write it), which survives discardEdits and the git-state guard.
21
+ *
22
+ * At run end the final gate reads the list, reads the manifest's `scripts`, and FAILs
23
+ * naming any declared script the manifest is missing. FP-safe by construction: an
24
+ * empty/ungrounded list (a design that never backticks a script name) yields no check.
25
+ */
26
+ import * as fsp from 'node:fs/promises';
27
+ import * as path from 'node:path';
28
+ import { tasksDir } from './task-io.js';
29
+ const LAUNCH_CONTRACT_FILE = 'launch-contract.md';
30
+ /** Cap kept entries so a noisy extraction cannot grow the artifact unboundedly. */
31
+ const MAX_SCRIPTS = 40;
32
+ /** npm/package script names are short kebab/colon tokens; reject anything unscript-like. */
33
+ const SCRIPT_NAME_RE = /^[a-z0-9][a-z0-9:_-]{0,39}$/i;
34
+ export function launchContractFile(cwd) {
35
+ return path.join(tasksDir(cwd), LAUNCH_CONTRACT_FILE);
36
+ }
37
+ /**
38
+ * Parse `SCRIPT: <name>` lines out of a child's answer into bare script names. A line
39
+ * whose token is not script-name-shaped is skipped (an accidental sentence, a path).
40
+ */
41
+ export function parseScriptLines(text) {
42
+ const out = [];
43
+ for (const m of text.matchAll(/^[ \t]*SCRIPT:[ \t]*(.+)$/gim)) {
44
+ // Take the first whitespace/comma-delimited token, stripping backticks/quotes.
45
+ const raw = m[1].trim().split(/[\s,]+/)[0]?.replace(/[`'"]/g, '') ?? '';
46
+ if (SCRIPT_NAME_RE.test(raw))
47
+ out.push(raw);
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * THE GROUNDING GUARD: keep only names the design declares as an inline-code token
53
+ * (`` `name` ``) — the form a design uses to name a script. A name the model
54
+ * paraphrased or invented has no such token, so it is dropped and the diff cannot
55
+ * false-flag on it. Deduplicated, case-insensitive.
56
+ */
57
+ export function keepGroundedScripts(names, sourceDoc) {
58
+ const haystack = sourceDoc.toLowerCase();
59
+ const seen = new Set();
60
+ const kept = [];
61
+ for (const n of names) {
62
+ const key = n.toLowerCase();
63
+ if (seen.has(key))
64
+ continue;
65
+ if (!haystack.includes('`' + key + '`'))
66
+ continue;
67
+ seen.add(key);
68
+ kept.push(n);
69
+ }
70
+ return kept;
71
+ }
72
+ /** The stored declared-script list ('' when none recorded). */
73
+ export async function readLaunchContractRaw(cwd) {
74
+ try {
75
+ return (await fsp.readFile(launchContractFile(cwd), 'utf8')).trim();
76
+ }
77
+ catch {
78
+ return '';
79
+ }
80
+ }
81
+ /** The declared script names recorded for this run (deduped, order preserved). */
82
+ export async function readDeclaredScripts(cwd) {
83
+ const raw = await readLaunchContractRaw(cwd);
84
+ const seen = new Set();
85
+ const out = [];
86
+ for (const line of raw.split('\n')) {
87
+ const n = line.trim();
88
+ if (n.length === 0 || !SCRIPT_NAME_RE.test(n))
89
+ continue;
90
+ const key = n.toLowerCase();
91
+ if (seen.has(key))
92
+ continue;
93
+ seen.add(key);
94
+ out.push(n);
95
+ }
96
+ return out;
97
+ }
98
+ /** Append grounded script names, deduped against what is stored, keeping newest MAX. */
99
+ export async function appendDeclaredScripts(cwd, names) {
100
+ if (names.length === 0)
101
+ return;
102
+ try {
103
+ const existing = await readDeclaredScripts(cwd);
104
+ const seen = new Set(existing.map(n => n.toLowerCase()));
105
+ const merged = [...existing];
106
+ for (const n of names) {
107
+ if (seen.has(n.toLowerCase()))
108
+ continue;
109
+ seen.add(n.toLowerCase());
110
+ merged.push(n);
111
+ }
112
+ const kept = merged.slice(-MAX_SCRIPTS);
113
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
114
+ await fsp.writeFile(launchContractFile(cwd), kept.join('\n') + '\n', 'utf8');
115
+ }
116
+ catch {
117
+ // best-effort artifact
118
+ }
119
+ }
120
+ /**
121
+ * Declared scripts the manifest does NOT expose (case-insensitive). Empty when every
122
+ * declared script is present, or when nothing was declared (no check). This is the
123
+ * deterministic lever the final gate FAILs on.
124
+ */
125
+ export function missingDeclaredScripts(declared, manifestScripts) {
126
+ const have = new Set(manifestScripts.map(s => s.toLowerCase()));
127
+ const seen = new Set();
128
+ const missing = [];
129
+ for (const d of declared) {
130
+ const key = d.toLowerCase();
131
+ if (have.has(key) || seen.has(key))
132
+ continue;
133
+ seen.add(key);
134
+ missing.push(d);
135
+ }
136
+ return missing;
137
+ }
138
+ /**
139
+ * The plan-time extraction prompt: the design in hand, emit the scripts it declares.
140
+ * Runs with --no-tools (pure extraction). Every emitted name is re-grounded HOST-SIDE
141
+ * (keepGroundedScripts), so a hallucinated script cannot reach the diff.
142
+ */
143
+ export const LAUNCH_EXTRACT_PROMPT = (feature) => [
144
+ 'You are recording the PACKAGE/BUILD SCRIPTS the design below says the finished',
145
+ 'project MUST expose (the `scripts` a package.json / Makefile / task runner must',
146
+ 'declare — e.g. build, test, a migration runner, a seed step, a start/serve command).',
147
+ 'These are launch-surface entrypoints the whole project shares; if one the design',
148
+ 'names is missing from the shipped manifest, the project cannot be run as specified.',
149
+ '',
150
+ 'DESIGN (the ONLY source — name only scripts the design itself declares):',
151
+ feature.trim(),
152
+ '',
153
+ 'For each script the design declares by name, emit exactly:',
154
+ ' SCRIPT: <name>',
155
+ 'one per line, the bare script name only (e.g. `SCRIPT: migrate`). RULES: (1) name',
156
+ 'ONLY scripts the design explicitly lists — do NOT invent conventional ones it does',
157
+ 'not mention. (2) Use the exact name the design uses. (3) A name that is not literally',
158
+ 'in the design is DISCARDED host-side, so guessing wastes effort. If the design',
159
+ 'declares no scripts, output nothing.',
160
+ '',
161
+ 'Output the SCRIPT: lines and nothing else.'
162
+ ].join('\n');
@@ -127,6 +127,14 @@ export interface GateDeps {
127
127
  * and surfaces it if still open. Best-effort; absent in tests → no ledger written.
128
128
  */
129
129
  recordAcceptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
130
+ /**
131
+ * Record a durable ENFORCE-REVERT debt (mx5 run 10 item 3): the enforce re-verify
132
+ * FAILED and the enforce edits were reverted, but the FAIL indicts the ORIGINAL
133
+ * work (run 10 TASK_0004: "Missing server entry point … the Hono server cannot be
134
+ * started"). Without this the diagnosis dies with the revert; recorded, the final
135
+ * gate re-checks and surfaces it like an accept-debt. Best-effort; absent in tests.
136
+ */
137
+ recordEnforceRevertDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
130
138
  /**
131
139
  * The concrete paths this task's spec forbids modifying (its `Do NOT modify`
132
140
  * CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
@@ -332,6 +332,13 @@ export async function runGatesForTask(ctxIn, deps, p) {
332
332
  if (deps.revert)
333
333
  await deps.revert(p.cwd);
334
334
  await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`);
335
+ // Persist the FAIL as a durable defect (mx5 run 10 item 3). The
336
+ // revert restores the tree the ORIGINAL verify already blessed, so
337
+ // this re-verify caught a defect that verify's earlier PASS missed —
338
+ // erasing it with the enforce edits buried the terminal fault 8.5h
339
+ // before run end. The final gate re-checks/surfaces it (static-class
340
+ // auto-closes if a later task fixed the statics; else stays open).
341
+ await deps.recordEnforceRevertDebt?.(p.cwd, p.taskId, after.reason ?? 'enforce re-verify failed');
335
342
  active.ui.notify(`${p.tag}: guideline fixes regressed verification on "${p.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
336
343
  }
337
344
  else {
@@ -11,6 +11,13 @@ export interface RunWorkerInput {
11
11
  extensions?: string[];
12
12
  /** Called for each tool execution start and text-writing event inside the worker. */
13
13
  onLine?: (line: string) => void;
14
+ /** Called when a tool call FINISHES, with its (truncatable) result — lets a caller
15
+ * log tool OUTPUTS, not just the command (mx5 run 10 item 6). */
16
+ onToolResult?: (result: {
17
+ name: string;
18
+ isError: boolean;
19
+ text: string;
20
+ }) => void;
14
21
  /**
15
22
  * Called for each context_usage snapshot the child emits (same `--mode json`
16
23
  * stream the phase children parse). Lets a caller's status widget show the
@@ -124,6 +124,7 @@ export async function runWorker(input) {
124
124
  return hit;
125
125
  },
126
126
  onLine: input.onLine,
127
+ onToolResult: input.onToolResult,
127
128
  onContextUsage: input.onContextUsage
128
129
  }, input.spawn);
129
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.13",
3
+ "version": "0.18.14",
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",