@mjasnikovs/pi-task 0.38.15 → 0.38.17

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.
Files changed (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -108,10 +108,10 @@ export async function buildExternalContext(source, deps, lookups, policy = {}) {
108
108
  *
109
109
  * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''`.
110
110
  */
111
- export async function gatherExternalContext(refined, deps, researchDeps = {}) {
112
- const docsRawFn = researchDeps.docsRaw ?? docsRaw;
113
- const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
114
- const npmVersionFn = researchDeps.npmVersionLookup ?? npmVersionLookup;
111
+ export async function gatherExternalContext(refined, deps) {
112
+ const docsRawFn = deps.docsRaw ?? docsRaw;
113
+ const fetchRawFn = deps.fetchRaw ?? fetchRaw;
114
+ const npmVersionFn = deps.npmVersionLookup ?? npmVersionLookup;
115
115
  const docsQuery = refined.split('\n').find(l => l.trim()) ?? refined;
116
116
  return buildExternalContext(refined, deps, {
117
117
  docs: async (pkg) => {
@@ -135,7 +135,7 @@ export async function gatherExternalContext(refined, deps, researchDeps = {}) {
135
135
  const r = await fetchRawFn({ url, signal: deps.signal });
136
136
  return { body: r.markdown.slice(0, RAW_BODY_LIMIT) };
137
137
  },
138
- search: researchDeps.searchFn
138
+ search: deps.searchFn
139
139
  }, {
140
140
  versionLookup: pkg => npmVersionFn(pkg, { signal: deps.signal }),
141
141
  subStepLabel: 'enrichment',
@@ -12,4 +12,12 @@ export interface FailureClass {
12
12
  level: NotifyLevel;
13
13
  }
14
14
  export declare function classifyFailure(err: unknown, aborted: boolean): FailureClass;
15
- export declare function handleFailure(err: unknown, ctx: ExtensionCommandContext, cwd: string, id: string, aborted: boolean): Promise<void>;
15
+ /**
16
+ * Persist, flash and announce a failure — and RETURN the classification.
17
+ *
18
+ * It used to return `void`, so the name it had just computed died here and the
19
+ * caller learned how the run ended by re-reading the task file's front matter and
20
+ * narrowing it to a boolean. Handing the value back is what lets `TaskRunner.run`
21
+ * say `RunEnd` instead.
22
+ */
23
+ export declare function handleFailure(err: unknown, ctx: ExtensionCommandContext, cwd: string, id: string, aborted: boolean): Promise<FailureClass>;
@@ -74,6 +74,14 @@ export function classifyFailure(err, aborted) {
74
74
  level: 'error'
75
75
  };
76
76
  }
77
+ /**
78
+ * Persist, flash and announce a failure — and RETURN the classification.
79
+ *
80
+ * It used to return `void`, so the name it had just computed died here and the
81
+ * caller learned how the run ended by re-reading the task file's front matter and
82
+ * narrowing it to a boolean. Handing the value back is what lets `TaskRunner.run`
83
+ * say `RunEnd` instead.
84
+ */
77
85
  export async function handleFailure(err, ctx, cwd, id, aborted) {
78
86
  const c = classifyFailure(err, aborted);
79
87
  await updateTaskFrontMatter(cwd, id, { state: c.state, reason: c.reason });
@@ -82,4 +90,5 @@ export async function handleFailure(err, ctx, cwd, id, aborted) {
82
90
  // Mirror to remote viewers — ctx.ui.notify is terminal-only, so without this
83
91
  // the remote view shows nothing when a task fails.
84
92
  publishLifecycleNotice(`${id} ${c.notify}`, c.level);
93
+ return c;
85
94
  }
@@ -1,3 +1,4 @@
1
+ import type { FinalGateOutcome } from './final-gate.js';
1
2
  import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
2
3
  /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
3
4
  * failing command (and the project's own tooling), not to mutate git state. */
@@ -97,22 +98,24 @@ export interface FinalFixResult {
97
98
  ok: boolean;
98
99
  /** Human-readable outcome (converged gate reason, or why the attempt failed). */
99
100
  reason: string;
100
- /** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
101
- * picker (and next fix attempt) works from the current state, not the stale one. */
102
- gateReason?: string;
103
- /** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
104
- * so the caller can trail each entry — never just the first. */
105
- gateFailures?: string[];
106
- /** …and which of them a PROBE returned after OBSERVING (see
107
- * FinalGateOutcome.observedFailures). Carried so the caller's non-progress
108
- * classifier can ask the probe's own verdict instead of guessing from the
109
- * failure text a check that was observed to FAIL is never demote-eligible
110
- * (nexttask 19A). */
111
- gateObservedFailures?: string[];
112
- /** On a converged outcome: the re-run gate's UNOBSERVED note, if it observed
113
- * nothing dynamic (see FinalGateOutcome.unobserved). Carried so the caller
114
- * labels a converge-on-statics-alone the same way it labels a first-pass one —
115
- * "converged" must never quietly mean "we stopped being able to check". */
101
+ /**
102
+ * The re-run gate's OUTCOME, whole present whenever the gate actually ran
103
+ * (absent only when the fix child self-declared blocked and the re-run was
104
+ * skipped).
105
+ *
106
+ * This used to be four flattened `gate*` mirrors, and the flattening lost
107
+ * things. `openDebts` never crossed at all, so `runFinalGateStage` rebuilt
108
+ * `fin` as a literal three times and each literal dropped it — the recorded
109
+ * mx5 run-18 defect, fixed by RE-DERIVING the field (`reconcileDebts`) rather
110
+ * than by keeping the value. Then 19A had to push `observedFailures` across the
111
+ * same wall as a third parallel field and re-pair it downstream by
112
+ * `gateObservedFailures?.includes(detail)` — a membership test that exists only
113
+ * because the pairing was broken in transit.
114
+ */
115
+ gate?: FinalGateOutcome;
116
+ /** On a converged outcome: the UNOBSERVED note the CALLER should show. It is
117
+ * the gate's own note plus any downgrade this fix pass added (see the
118
+ * ignored-dependency probe below), so it is not simply `gate.unobserved`. */
116
119
  unobserved?: string;
117
120
  /** Gitignored path(s) this fix pass wrote, exempt classes already removed (see
118
121
  * write-guard.ts). Present whether or not the gate converged — the caller
@@ -141,16 +144,9 @@ export interface FinalFixDeps {
141
144
  /** Re-run the final integration gate — the only arbiter of convergence.
142
145
  * Converges only when the gate's FULL aggregated failure list is empty
143
146
  * (ok=true); `failures` rides through so the caller sees every entry. */
144
- gate: (cwd: string) => Promise<{
145
- ok: boolean;
146
- reason: string;
147
- failures?: string[];
148
- /** Which of them a probe returned after OBSERVING (nexttask 19A) — carried
149
- * through so the caller's demote decision can ask the probe's own verdict
150
- * instead of re-deriving observability from the failure string. */
151
- observedFailures?: string[];
152
- unobserved?: string;
153
- }>;
147
+ /** Returns the gate's own outcome type, not a structural copy of five of its
148
+ * fields — a re-declaration is how `openDebts` came to be silently absent. */
149
+ gate: (cwd: string) => Promise<FinalGateOutcome>;
154
150
  /** Labels of every currently-discoverable gate command (static + integration),
155
151
  * for the shrink guard. Pure discovery — nothing is executed. */
156
152
  discoverLabels: (cwd: string) => string[];
@@ -43,7 +43,7 @@
43
43
  * legitimate whole-repo fix that run needed (migrate.ts — frozen by its own
44
44
  * producing task). It activates only when a run-GLOBAL freeze source exists.
45
45
  */
46
- import { USER_CANCELLED } from './child-runner.js';
46
+ import { parseFixMarker, runFixChild } from './fix-child.js';
47
47
  import { findForbiddenDeletions, diffIgnoredSnapshots, ignoredWriteTrailLine, ignoredWriteUnobservedNote } from './write-guard.js';
48
48
  import { findNarrowedCommands, narrowingRejectionText } from './command-shrink.js';
49
49
  /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
@@ -151,16 +151,7 @@ export function buildFinalFixPrompt(failReason) {
151
151
  * early-out on a self-declared BLOCKED.
152
152
  */
153
153
  export function parseFinalFixMarker(text) {
154
- const re = /FINAL-GATE-FIX:\s*(DONE|BLOCKED)\b[ \t]*(.*)/gi;
155
- let last = null;
156
- for (let m = re.exec(text); m !== null; m = re.exec(text))
157
- last = m;
158
- if (!last)
159
- return { blocked: false };
160
- if (last[1].toUpperCase() === 'BLOCKED') {
161
- return { blocked: true, note: last[2].trim() || 'no reason given' };
162
- }
163
- return { blocked: false, note: last[2].trim() || undefined };
154
+ return parseFixMarker('FINAL-GATE-FIX', text);
164
155
  }
165
156
  /**
166
157
  * STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
@@ -219,16 +210,16 @@ export async function runFinalGateAutofix(deps) {
219
210
  // ignored files are untracked, so git alone cannot tell a file this pass wrote
220
211
  // from one that was already sitting in the worktree.
221
212
  const ignoredBefore = deps.ignoredSnapshot ? await deps.ignoredSnapshot() : null;
222
- let text;
223
- try {
224
- text = await deps.runChild(FINAL_FIX_TOOLS, buildFinalFixPrompt(deps.failReason), deps.signal);
225
- }
226
- catch (err) {
227
- const msg = err instanceof Error ? err.message : String(err);
228
- if (msg === USER_CANCELLED)
229
- throw err;
230
- return { ok: false, reason: `fix child failed: ${msg}` };
231
- }
213
+ // The four-rung ladder is task/fix-child.ts, shared with the per-task pass.
214
+ const end = await runFixChild({
215
+ runChild: deps.runChild,
216
+ tools: FINAL_FIX_TOOLS,
217
+ prompt: buildFinalFixPrompt(deps.failReason),
218
+ signal: deps.signal,
219
+ marker: 'FINAL-GATE-FIX'
220
+ });
221
+ if (end.kind === 'error')
222
+ return { ok: false, reason: `fix child failed: ${end.msg}` };
232
223
  // What the child wrote to gitignored paths. Recorded on the trail IMMEDIATELY —
233
224
  // before any guard can reject the attempt — because `discard` reverts tracked
234
225
  // edits only: an ignored file the pass wrote survives a rejection, and the trail
@@ -327,20 +318,13 @@ export async function runFinalGateAutofix(deps) {
327
318
  return r;
328
319
  }
329
320
  }
330
- const marker = parseFinalFixMarker(text);
331
- if (marker.blocked) {
321
+ if (end.kind === 'blocked') {
332
322
  // Self-declared blocked: skip the (expensive) gate re-run; nothing converged.
333
- return withIgnored({ ok: false, reason: `fix child blocked: ${marker.note}` });
323
+ return withIgnored({ ok: false, reason: `fix child blocked: ${end.note}` });
334
324
  }
335
325
  const fin = await deps.gate(deps.cwd);
336
326
  if (!fin.ok) {
337
- return withIgnored({
338
- ok: false,
339
- reason: `did not converge: ${fin.reason}`,
340
- gateReason: fin.reason,
341
- gateFailures: fin.failures,
342
- ...(fin.observedFailures ? { gateObservedFailures: fin.observedFailures } : {})
343
- });
327
+ return withIgnored({ ok: false, reason: `did not converge: ${fin.reason}`, gate: fin });
344
328
  }
345
329
  // IGNORED-DEPENDENCY DOWNGRADE (mx5 run 19). The gate says PASS; the question
346
330
  // this answers is whether that PASS belongs to the REPOSITORY or only to this
@@ -368,6 +352,7 @@ export async function runFinalGateAutofix(deps) {
368
352
  return withIgnored({
369
353
  ok: true,
370
354
  reason: fin.reason,
355
+ gate: fin,
371
356
  ...(notes.length > 0 ? { unobserved: notes.join(' ') } : {}),
372
357
  ...(ignoredDependent !== undefined ? { ignoredDependent } : {})
373
358
  });
@@ -1,6 +1,6 @@
1
1
  import { type HealthCommand } from './repo-health-check.js';
2
2
  import { deriveOpenDebts, rerunDebtVerifyCommand, type AcceptDebt } from './accept-debt.js';
3
- import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, type BootDeps } from './boot-probe.js';
3
+ import { discoverBootCommand, detectsServedApp, runBootCheck, runBootSection, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, type BootDeps } from './boot-probe.js';
4
4
  import { type CommandRunner } from './command-run.js';
5
5
  import { taskThatIntroduced } from './task-provenance.js';
6
6
  import { type EnvClosure } from './env-template-closure.js';
@@ -133,8 +133,9 @@ export declare function discoverGateCommandBodies(cwd: string): Record<string, s
133
133
  export { runVerifyCommandLine, type VerifyRerunOutcome } from './command-run.js';
134
134
  export { observabilityGapFailure, unobservedVerdict };
135
135
  export { taskThatIntroduced };
136
- export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
136
+ export { discoverBootCommand, detectsServedApp, runBootCheck, runBootSection, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
137
137
  export type { BootDeps };
138
+ export type { BootSectionVerdict } from './boot-probe.js';
138
139
  export { deriveOpenDebts, rerunDebtVerifyCommand };
139
140
  /**
140
141
  * Where in the gate a closure scan runs. The two stages are NOT interchangeable
@@ -259,5 +260,12 @@ export interface FinalGateOptions {
259
260
  envClosure?: (cwd: string) => EnvClosure;
260
261
  /** The repo's tracked file list, or null when it cannot be determined. */
261
262
  trackedFiles?: (cwd: string) => string[] | null;
263
+ /**
264
+ * The run's cancel. Reaches every command the gate spawns — repo-health, the
265
+ * lockfile/integration/launch sections and the ACCEPT-debt re-runs. Nothing
266
+ * could be cancelled while `CommandRunner` was synchronous: the event loop
267
+ * never got a turn in which to notice.
268
+ */
269
+ signal?: AbortSignal;
262
270
  }
263
271
  export declare function runFinalIntegrationGate(cwd: string, opts?: FinalGateOptions): Promise<FinalGateOutcome>;
@@ -51,10 +51,8 @@ import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.
51
51
  import { deriveOpenDebts, rerunDebtVerifyCommand } from './accept-debt.js';
52
52
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
53
53
  import { readLaunchManifest, inertLaunchContractNote, packageScripts, makeHasTarget } from './launch-manifest.js';
54
- import { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners, recoverOrphanPort, defaultFindPortHolder } from './boot-probe.js';
54
+ import { discoverBootCommand, detectsServedApp, runBootCheck, runBootSection, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners } from './boot-probe.js';
55
55
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
56
- import { runRenderCheck } from './render-check.js';
57
- import { runDeepRenderCheck } from './deep-render-check.js';
58
56
  import { resolveRunner, runnerEnv } from './runner-resolve.js';
59
57
  import { classifyCommandRun, spawnCommand, INFRA_GAP_OUTPUT_RE } from './command-run.js';
60
58
  import { findLaunchConfigGap, probeEnv, configGapUnobservedNote } from './launch-config-gap.js';
@@ -64,6 +62,7 @@ import { findMissingEnvDeclarations, envGateFailureText, scanEnvTemplateClosure,
64
62
  import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
65
63
  import { makefileRecipe } from './command-shrink.js';
66
64
  import { GateTally, observabilityGapFailure, unobservedVerdict } from './gate-tally.js';
65
+ import { VERIFY_FAIL_PREFIX } from './verify-work.js';
67
66
  /**
68
67
  * The project's OWN whole-repo integration commands (test, then build — test
69
68
  * first because it is the richer signal and the more common script). First
@@ -255,19 +254,26 @@ function resolveCommandBody(bin, args, scripts, makefile) {
255
254
  * passes `extraGapRe` (launch scripts), missing external infrastructure. Only a
256
255
  * command that actually ran and exited non-zero for a real reason fails.
257
256
  */
258
- function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe,
257
+ async function runGateCommand(cwd, [bin, args], timeoutMs, extraGapRe,
259
258
  /** Replaces the child's environment wholesale (config-gap probe re-run only —
260
259
  * see launch-config-gap.ts). Absent ⇒ `runnerEnv(runner)`, i.e. unchanged. */
261
260
  envOverride,
262
261
  /** The spawner. Injected so the gate's own tests can script a verdict. */
263
- run = spawnCommand) {
262
+ run = spawnCommand, signal) {
264
263
  // Runner resolution (mx5 run 16): a login-shell-stripped PATH left `bun`
265
264
  // unspawnable, so every dynamic check skipped and the gate went blind. The
266
265
  // resolved binary is spawned, and its directory rides on the child's PATH so
267
266
  // the SCRIPT CHAIN can re-invoke the runner (`bun run test` runs `bun test`
268
267
  // inside — a bare 127 there is the same blindness one level down).
269
268
  const runner = resolveRunner(bin);
270
- const verdict = classifyCommandRun(run({ cwd, bin: runner.bin, args, timeoutMs, env: envOverride ?? runnerEnv(runner) }), extraGapRe ? [extraGapRe] : []);
269
+ const verdict = classifyCommandRun(await run({
270
+ cwd,
271
+ bin: runner.bin,
272
+ args,
273
+ timeoutMs,
274
+ env: envOverride ?? runnerEnv(runner),
275
+ ...(signal === undefined ? {} : { signal })
276
+ }), extraGapRe ? [extraGapRe] : []);
271
277
  if (verdict.outcome === 'gap') {
272
278
  return { outcome: 'skip', spawnFailed: verdict.gap === 'spawn-failed' };
273
279
  }
@@ -288,7 +294,7 @@ export { taskThatIntroduced };
288
294
  // src/). Re-exported so the seven validation harnesses under scripts/ — which have
289
295
  // always imported exactly this surface and nothing else from the gate — keep
290
296
  // working unchanged. Same pattern as taskThatIntroduced above.
291
- export { discoverBootCommand, detectsServedApp, runBootCheck, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
297
+ export { discoverBootCommand, detectsServedApp, runBootCheck, runBootSection, bootSkipVerdict, nonLaunchScriptReason, rejectedLaunchScript, parseSsListeners, parseNetstatListeners, parseLsofListeners, pickFreePort, preferredDeclaredPort, canEnumerateListeners };
292
298
  // The ACCEPT-debt re-check (`deriveOpenDebts`, `rerunDebtVerifyCommand`) lives in
293
299
  // accept-debt.ts with the ledger it reads and writes; re-exported so the
294
300
  // orchestrator and the harnesses under scripts/ keep working unchanged.
@@ -394,18 +400,28 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
394
400
  return inertClosure();
395
401
  }
396
402
  }, trackedFiles: trackedFilesFn = trackedFiles } = opts;
397
- const stat = runRepoHealthCheck(cwd);
403
+ // ASYNC: the run-end gate no longer blocks the event loop for the project's own
404
+ // lint (measured 15s mx5 / 69s aiz-client with zero timer ticks), so a loader can
405
+ // paint and a cancel can reach the child.
406
+ const stat = await runRepoHealthCheck(cwd, {
407
+ run: runCmd,
408
+ ...(opts.signal === undefined ? {} : { signal: opts.signal })
409
+ });
398
410
  // Debts are derived once, before any section runs, and ride on every verdict
399
411
  // shape (GateTally.verdict): `reason` stays the mechanical failure because it
400
412
  // seeds the autofix child's prompt — run 11's fix child executed a recorded
401
413
  // claim as an instruction.
402
- const debts = await deriveOpenDebts(cwd, stat.ok);
414
+ const debts = await deriveOpenDebts(cwd, stat.ok, runCmd, opts.signal);
403
415
  // Every section below RECORDS into the tally (failures ranked, the four
404
416
  // dynamic counters, the notes) and the verdict is assembled ONCE at the end —
405
417
  // see gate-tally.ts for what each method means.
406
418
  const tally = new GateTally();
419
+ // The prefix comes from VERIFY_FAIL_PREFIX so this run-level mint and the
420
+ // task-level `repo health:` one stay linked: both are the deterministic
421
+ // whole-repo static check, and `isStaticClassDebt` must recognise a debt that
422
+ // entered the ledger through EITHER altitude.
407
423
  if (!stat.ok)
408
- tally.fail(`static checks: ${stat.reason}`);
424
+ tally.fail(`${VERIFY_FAIL_PREFIX['static-checks']} ${stat.reason}`);
409
425
  // Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
410
426
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
411
427
  // plan-time-extracted declared scripts against the manifest; a missing one is a
@@ -446,7 +462,7 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
446
462
  for (const cmd of list) {
447
463
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
448
464
  tally.attempted(cmd[0]);
449
- const r = runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd);
465
+ const r = await runGateCommand(cwd, cmd, timeoutMs, undefined, undefined, runCmd, opts.signal);
450
466
  if (r.outcome === 'skip') {
451
467
  if (r.spawnFailed)
452
468
  tally.spawnFailure(cmd[0]);
@@ -496,7 +512,7 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
496
512
  const cmd = ['bun', ['run', name]];
497
513
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
498
514
  tally.attempted(cmd[0]);
499
- const r = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd);
515
+ const r = await runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, undefined, runCmd, opts.signal);
500
516
  if (r.outcome === 'skip') {
501
517
  if (r.spawnFailed)
502
518
  tally.spawnFailure(cmd[0]);
@@ -525,7 +541,7 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
525
541
  env: process.env
526
542
  });
527
543
  if (gap) {
528
- const probe = runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap), runCmd);
544
+ const probe = await runGateCommand(cwd, cmd, launchTimeout, INFRA_GAP_OUTPUT_RE, probeEnv(runnerEnv(resolveRunner(cmd[0])), gap), runCmd, opts.signal);
529
545
  if (probe.outcome === 'pass') {
530
546
  // Nothing about this script was OBSERVED: the real run could
531
547
  // not reach it and the probe run is a diagnostic, never an
@@ -585,83 +601,33 @@ export async function runFinalIntegrationGate(cwd, opts = {}) {
585
601
  // statement about when they are meaningful.
586
602
  if (!boot && tally.silent())
587
603
  return tally.verdict(debts);
588
- if (boot) {
589
- const label = `${boot[0]} ${boot[1].join(' ')}`;
590
- tally.attempted(boot[0]);
591
- const expectServer = detectsServedApp(cwd, planText);
592
- // Render check (mx5 runs 8/11): for a served app, load the live page in a
593
- // headless browser and judge the RENDERED DOM — curl can't run JS, so a
594
- // blank-mount app passed every prior "renders" check. Default to the real
595
- // probe; tests inject their own. runRenderCheck env-gap-SKIPs when no
596
- // browser exists, so a box without one never gets a false FAIL.
597
- // Authenticated deep-render check (mx5 run 17): the page above renders, so
598
- // now sign in with the account the project's own dotenv declares (the same
599
- // ADMIN_PHONE/ADMIN_PASSWORD the launch contract's seed step consumes) and
600
- // require the session to actually work. WEB-ONLY by construction — it hangs
601
- // off the served-app branch and never runs for C++, Godot, CLI or library
602
- // projects. It may only FAIL when the SERVER authenticated us; no browser,
603
- // no credentials, an undrivable form or rejected credentials all skip as
604
- // env gaps (judgeDeepSession).
605
- const bootDepsWithRender = {
606
- ...bootDeps,
607
- renderProbe: bootDeps.renderProbe ?? runRenderCheck,
608
- deepRenderProbe: bootDeps.deepRenderProbe ?? (url => runDeepRenderCheck(url, cwd)),
609
- preferredPort: bootDeps.preferredPort ?? (() => preferredDeclaredPort(cwd))
610
- };
611
- let b = await runBootCheck(cwd, boot, bootGraceMs, {
612
- expectServer,
613
- deps: bootDepsWithRender
614
- });
615
- if (b.outcome === 'orphan-port') {
616
- b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
617
- }
618
- if (b.outcome !== 'skip')
619
- tally.observed();
620
- else if (b.spawnFailed)
621
- tally.spawnFailure(boot[0]);
622
- tally.bootUnobserved(bootSkipVerdict({
623
- label,
624
- skipped: b.outcome === 'skip',
625
- expectServer
626
- }));
627
- if (b.outcome === 'fail') {
628
- // OBSERVED (nexttask 19A). Every path that produces `fail` here is a
629
- // probe that looked: the render judge saw an empty body, the deep
630
- // session saw the authenticated half dead, the enumerator saw no
631
- // listener, or the launch command itself exited non-zero. The one
632
- // condition that means "we could not look" — no ss/netstat/lsof, mx5
633
- // run 14 — returns PASS stamped UNOBSERVED and never reaches here
634
- // (`b0f90a7`, final-gate.ts `if (!canEnumerate) return passAndKill(…)`).
635
- tally.failObserved(`boot check: \`${label}\` ${b.detail}`, 0);
636
- }
637
- else if (b.outcome === 'orphan-port') {
638
- // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
639
- // FAIL: name the port and (when known) the process squatting on it.
640
- const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
641
- const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
642
- : b.port !== null ? ` — port ${b.port} is held by another process`
643
- : '';
644
- tally.fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
645
- }
646
- else if (b.outcome === 'pass') {
647
- tally.ran(label);
648
- // A listener that served, but whose page could not be OBSERVED to render
649
- // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
650
- if (b.renderNote)
651
- tally.warn(b.renderNote);
652
- }
653
- }
654
- else {
655
- // Nothing to boot — but if the reason is that the project's only launch
656
- // script was REJECTED as not-a-launch (2A), that is not the same thing as a
657
- // project with no launch surface, and it must not degrade into silence.
658
- const rejected = rejectedLaunchScript(cwd);
659
- if (rejected && detectsServedApp(cwd, planText)) {
660
- tally.bootUnobserved(`boot check: this project's only launch script (\`${rejected.name}\`) is not a `
661
- + `launch — ${rejected.reason} — so nothing was started and the app was never `
662
- + 'observed to run.');
663
- }
604
+ // The boot CONCEPT lives in boot-probe.ts (runBootSection): discovery,
605
+ // served-app detection, the probe defaults, the boot check, orphan-port
606
+ // recovery, the port-holder diagnosis, the skip verdict and the
607
+ // rejected-launch-script branch. This is the record, and nothing else.
608
+ const bootSection = await runBootSection(cwd, {
609
+ ...(planText === undefined ? {} : { planText }),
610
+ ...(bootGraceMs === undefined ? {} : { graceMs: bootGraceMs }),
611
+ deps: bootDeps
612
+ });
613
+ if (bootSection.attempted)
614
+ tally.attempted(bootSection.attempted);
615
+ if (bootSection.observed)
616
+ tally.observed();
617
+ else if (bootSection.spawnFailedBin)
618
+ tally.spawnFailure(bootSection.spawnFailedBin);
619
+ tally.bootUnobserved(bootSection.unobservedNote ?? null);
620
+ if (bootSection.failure) {
621
+ const { detail, rank, observed } = bootSection.failure;
622
+ if (observed)
623
+ tally.failObserved(detail, rank);
624
+ else
625
+ tally.fail(detail, rank);
664
626
  }
627
+ if (bootSection.ranLabel)
628
+ tally.ran(bootSection.ranLabel);
629
+ for (const w of bootSection.warnings)
630
+ tally.warn(w);
665
631
  // Full-skip blindness guard (mx5 run 16): commands were discovered but every
666
632
  // one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
667
633
  // is checked through resolveRunner so the failure text can name the missing
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Running ONE bounded fix child and deciding what its ending MEANS.
3
+ *
4
+ * Two graduated-resolution passes drive a fix child through the same four rungs —
5
+ * cancel propagates, a thrown child is an error, a self-declared BLOCKED ends the
6
+ * attempt, anything else is DONE — and `final-gate-fix.ts:12` says so out loud:
7
+ * it "mirrors the per-task graduated-resolution shape (lint-fix.ts)". This is that
8
+ * ladder, once.
9
+ *
10
+ * NOT the rejected sharing. CONTEXT.md's "the two resolution loops stay two" is
11
+ * about `runGatesForTask` vs `runFinalGateStage` — the LOOPS, at two altitudes.
12
+ * This is one altitude down: the single child invocation inside each, where the
13
+ * two had actually drifted.
14
+ *
15
+ * - **The lint-fix marker was a DEAD protocol.** `buildLintFixPrompt` instructs
16
+ * the child to end with `LINT-FIX: DONE` or `LINT-FIX: BLOCKED <why>`, and the
17
+ * call site was `await deps.runChild(...)` with the return value DISCARDED —
18
+ * nothing in `src/` or `scripts/` parsed it. The twin parsed its own marker and
19
+ * used it to skip the expensive gate re-run. So a lint-fix child that reported
20
+ * BLOCKED still paid the full guard stack plus a whole repo-health run (15–69s
21
+ * measured), and the user was told `did not converge: <health.reason>` instead
22
+ * of the child's own stated reason. The suite fed `'LINT-FIX: DONE'` as fake
23
+ * output, so it stayed green whether the marker was parsed or deleted.
24
+ * - **The cancel rung re-typed its constant.** `lint-fix.ts` compared against a
25
+ * literal `'__user_cancelled__'`; it was the only production site in `src/` not
26
+ * importing `USER_CANCELLED`.
27
+ *
28
+ * What stays per-site: the arbiter (a gate re-run vs a repo-health re-run), the
29
+ * result shape, and the guard sets. Only the child call is shared.
30
+ */
31
+ /** How one fix child ended. A CANCEL is not a member: it throws, so the caller's
32
+ * own `USER_CANCELLED` path runs unchanged. */
33
+ export type FixChildEnd = {
34
+ kind: 'done';
35
+ text: string;
36
+ note?: string;
37
+ } | {
38
+ kind: 'blocked';
39
+ text: string;
40
+ note: string;
41
+ } | {
42
+ kind: 'error';
43
+ msg: string;
44
+ };
45
+ export interface FixChildInput {
46
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
47
+ tools: string;
48
+ prompt: string;
49
+ signal?: AbortSignal;
50
+ /** The marker word this pass's prompt instructs — `LINT-FIX`, `FINAL-GATE-FIX`. */
51
+ marker: string;
52
+ }
53
+ /**
54
+ * Parse a fix child's final marker. Last match wins: the model reasons before
55
+ * concluding, and bash output can echo the words. No marker → DONE, because the
56
+ * pass's own arbiter re-runs either way; a missing marker only forfeits the
57
+ * early-out on a self-declared BLOCKED.
58
+ */
59
+ export declare function parseFixMarker(marker: string, text: string): {
60
+ blocked: boolean;
61
+ note?: string;
62
+ };
63
+ /** Run one fix child through the four-rung ladder. Cancel THROWS; nothing else does. */
64
+ export declare function runFixChild(input: FixChildInput): Promise<FixChildEnd>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Running ONE bounded fix child and deciding what its ending MEANS.
3
+ *
4
+ * Two graduated-resolution passes drive a fix child through the same four rungs —
5
+ * cancel propagates, a thrown child is an error, a self-declared BLOCKED ends the
6
+ * attempt, anything else is DONE — and `final-gate-fix.ts:12` says so out loud:
7
+ * it "mirrors the per-task graduated-resolution shape (lint-fix.ts)". This is that
8
+ * ladder, once.
9
+ *
10
+ * NOT the rejected sharing. CONTEXT.md's "the two resolution loops stay two" is
11
+ * about `runGatesForTask` vs `runFinalGateStage` — the LOOPS, at two altitudes.
12
+ * This is one altitude down: the single child invocation inside each, where the
13
+ * two had actually drifted.
14
+ *
15
+ * - **The lint-fix marker was a DEAD protocol.** `buildLintFixPrompt` instructs
16
+ * the child to end with `LINT-FIX: DONE` or `LINT-FIX: BLOCKED <why>`, and the
17
+ * call site was `await deps.runChild(...)` with the return value DISCARDED —
18
+ * nothing in `src/` or `scripts/` parsed it. The twin parsed its own marker and
19
+ * used it to skip the expensive gate re-run. So a lint-fix child that reported
20
+ * BLOCKED still paid the full guard stack plus a whole repo-health run (15–69s
21
+ * measured), and the user was told `did not converge: <health.reason>` instead
22
+ * of the child's own stated reason. The suite fed `'LINT-FIX: DONE'` as fake
23
+ * output, so it stayed green whether the marker was parsed or deleted.
24
+ * - **The cancel rung re-typed its constant.** `lint-fix.ts` compared against a
25
+ * literal `'__user_cancelled__'`; it was the only production site in `src/` not
26
+ * importing `USER_CANCELLED`.
27
+ *
28
+ * What stays per-site: the arbiter (a gate re-run vs a repo-health re-run), the
29
+ * result shape, and the guard sets. Only the child call is shared.
30
+ */
31
+ import { USER_CANCELLED } from './child-runner.js';
32
+ /**
33
+ * Parse a fix child's final marker. Last match wins: the model reasons before
34
+ * concluding, and bash output can echo the words. No marker → DONE, because the
35
+ * pass's own arbiter re-runs either way; a missing marker only forfeits the
36
+ * early-out on a self-declared BLOCKED.
37
+ */
38
+ export function parseFixMarker(marker, text) {
39
+ const re = new RegExp(`${marker}:\\s*(DONE|BLOCKED)\\b[ \\t]*(.*)`, 'gi');
40
+ let last = null;
41
+ for (let m = re.exec(text); m !== null; m = re.exec(text))
42
+ last = m;
43
+ if (!last)
44
+ return { blocked: false };
45
+ if (last[1].toUpperCase() === 'BLOCKED') {
46
+ return { blocked: true, note: last[2].trim() || 'no reason given' };
47
+ }
48
+ return { blocked: false, note: last[2].trim() || undefined };
49
+ }
50
+ /** Run one fix child through the four-rung ladder. Cancel THROWS; nothing else does. */
51
+ export async function runFixChild(input) {
52
+ let text;
53
+ try {
54
+ text = await input.runChild(input.tools, input.prompt, input.signal);
55
+ }
56
+ catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err);
58
+ if (msg === USER_CANCELLED)
59
+ throw err;
60
+ return { kind: 'error', msg };
61
+ }
62
+ const parsed = parseFixMarker(input.marker, text);
63
+ if (parsed.blocked)
64
+ return { kind: 'blocked', text, note: parsed.note ?? 'no reason given' };
65
+ return parsed.note ? { kind: 'done', text, note: parsed.note } : { kind: 'done', text };
66
+ }