@mjasnikovs/pi-task 0.38.15 → 0.38.16

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 (62) hide show
  1. package/dist/shared/child-process.js +9 -16
  2. package/dist/task/accept-debt.d.ts +7 -5
  3. package/dist/task/accept-debt.js +16 -13
  4. package/dist/task/auto-orchestrator.js +38 -36
  5. package/dist/task/autofix-ledger.d.ts +113 -0
  6. package/dist/task/autofix-ledger.js +152 -0
  7. package/dist/task/boot-probe.d.ts +63 -1
  8. package/dist/task/boot-probe.js +98 -2
  9. package/dist/task/child-runner.d.ts +50 -6
  10. package/dist/task/child-runner.js +48 -69
  11. package/dist/task/command-run.d.ts +49 -6
  12. package/dist/task/command-run.js +154 -18
  13. package/dist/task/external-context.d.ts +9 -12
  14. package/dist/task/external-context.js +5 -5
  15. package/dist/task/failure-classifier.d.ts +9 -1
  16. package/dist/task/failure-classifier.js +9 -0
  17. package/dist/task/final-gate-fix.d.ts +22 -26
  18. package/dist/task/final-gate-fix.js +2 -7
  19. package/dist/task/final-gate.d.ts +10 -2
  20. package/dist/task/final-gate.js +49 -88
  21. package/dist/task/gate-deps.js +20 -13
  22. package/dist/task/orchestrator.d.ts +33 -24
  23. package/dist/task/orchestrator.js +66 -44
  24. package/dist/task/phases.d.ts +58 -34
  25. package/dist/task/phases.js +140 -113
  26. package/dist/task/plan-orchestrator.js +2 -2
  27. package/dist/task/repo-health-check.d.ts +21 -21
  28. package/dist/task/repo-health-check.js +43 -112
  29. package/dist/task/run-end.d.ts +77 -0
  30. package/dist/task/run-end.js +37 -0
  31. package/dist/task/run-final-gate.js +71 -79
  32. package/dist/task/task-gates.d.ts +8 -0
  33. package/dist/task/task-gates.js +23 -4
  34. package/dist/task/terminal-outcome.d.ts +1 -1
  35. package/dist/task/terminal-outcome.js +12 -0
  36. package/dist/workers/brave-search.d.ts +7 -0
  37. package/dist/workers/brave-search.js +36 -55
  38. package/dist/workers/ddg-search.d.ts +1 -1
  39. package/dist/workers/ddg-search.js +27 -47
  40. package/dist/workers/exa-search.d.ts +2 -2
  41. package/dist/workers/exa-search.js +53 -68
  42. package/dist/workers/html-clean.js +67 -88
  43. package/dist/workers/http-request.d.ts +74 -0
  44. package/dist/workers/http-request.js +103 -0
  45. package/dist/workers/npm-version.js +37 -42
  46. package/dist/workers/pi-worker-core.d.ts +13 -2
  47. package/dist/workers/pi-worker-core.js +12 -17
  48. package/dist/workers/pi-worker-docs.d.ts +1 -1
  49. package/dist/workers/pi-worker-docs.js +49 -68
  50. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  51. package/dist/workers/pi-worker-fetch.js +20 -21
  52. package/dist/workers/pi-worker-search.js +6 -4
  53. package/dist/workers/pi-worker.js +5 -4
  54. package/dist/workers/search-core.d.ts +1 -1
  55. package/dist/workers/search-core.js +36 -42
  56. package/dist/workers/search-types.d.ts +13 -0
  57. package/dist/workers/search-types.js +27 -0
  58. package/dist/workers/shared.d.ts +51 -11
  59. package/dist/workers/shared.js +0 -0
  60. package/dist/workers/worker-channels.d.ts +60 -0
  61. package/dist/workers/worker-channels.js +98 -0
  62. package/package.json +1 -1
@@ -3,12 +3,7 @@
3
3
  * critique) plus the config table that drives the orchestrator loop.
4
4
  */
5
5
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
6
- import { docsFocused } from '../workers/docs-core.js';
7
- import { fetchFocused } from '../workers/fetch-core.js';
8
- import { type RunWorkerInput, type RunWorkerResult } from '../workers/pi-worker-core.js';
9
- import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
10
- import type { SearchProvider } from '../workers/search-types.js';
11
- import { type ExternalContextDeps } from './external-context.js';
6
+ import { type SearchProvider } from '../workers/search-types.js';
12
7
  import { MAX_GRILL_QUESTIONS } from './prompts.js';
13
8
  import { type PhaseName } from './task-types.js';
14
9
  import { type WidgetState } from './widget.js';
@@ -101,29 +96,9 @@ export declare function resolveOwnedFreezeForThisTask(deps: PhaseDeps, spec: str
101
96
  export declare function claimOwnedFreezeForThisTask(deps: PhaseDeps, refined: string): Promise<void>;
102
97
  export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
103
98
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
104
- export interface PhaseResearchDeps extends ExternalContextDeps {
105
- getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
106
- /**
107
- * Run ONE research worker. Absent (production) → the real `runWorker`.
108
- *
109
- * The seam exists for the same reason `getFileInventory` does: every decision
110
- * `runSpec` makes — the three Research retry gates, the fatal/runaway/empty
111
- * classification, the marker choice, `postProcess` — is a pure function of the
112
- * returned `RunWorkerResult`, but reaching any of them used to require driving
113
- * a fake process that emits JSON events. Substituting the result lets a gate be
114
- * tested by the fields it actually reads.
115
- *
116
- * `label` is the worker's name — the same one `recordWorker` trails. It is
117
- * passed because a substitute must answer differently per worker, and the only
118
- * alternative is matching a marker sentence inside the prompt, which makes
119
- * prompt copy load-bearing test infrastructure. Same reason
120
- * `PhaseDeps.runChild` takes a name.
121
- */
122
- runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
123
- }
124
99
  /**
125
100
  * Is live web search configured for this process? The keyless providers (exa,
126
- * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
101
+ * ddg) always are; only brave needs its API key.
127
102
  */
128
103
  export declare function searchConfigured(getEnv?: (k: string) => string | undefined, provider?: SearchProvider): boolean;
129
104
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
@@ -177,13 +152,8 @@ export declare function degradedSectionBody(name: string, reason: string, partia
177
152
  */
178
153
  export declare function emptySectionBody(name: string): string;
179
154
  export declare function isBareNoneAnswer(text: string): boolean;
180
- export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
181
- export interface PhaseAutoAnswerDeps {
182
- docsFocused?: typeof docsFocused;
183
- fetchFocused?: typeof fetchFocused;
184
- searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
185
- }
186
- export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string, autoDeps?: PhaseAutoAnswerDeps): Promise<AutoAnswer>;
155
+ export declare function phaseResearch(deps: PhaseDeps, refined: string): Promise<string>;
156
+ export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string): Promise<AutoAnswer>;
187
157
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;
188
158
  /**
189
159
  * A refutation is a DELETION. Where the run's own research explicitly says a
@@ -220,5 +190,59 @@ export declare function phaseCritique(deps: PhaseDeps, spec: string, refined: st
220
190
  */
221
191
  extraDefects?: string | null): Promise<string>;
222
192
  export declare function critiqueWithFallback(d: PhaseDeps, p: PhaseContext): Promise<string>;
193
+ /**
194
+ * REFINE — restate the raw prompt as a bounded 4-section spec, then subtractively
195
+ * strike any phantom runtime specifier (`bun:sql`) it carried up verbatim from the
196
+ * spec doc, BEFORE it flows to research/grill/compose. An appended correction alone
197
+ * loses: the affirmative survives into the composed GOAL and on to the implementer
198
+ * (proven: compose re-leaks it 4/4). Rewriting the source so compose has nothing to
199
+ * contradict is the fix. Silent + no-op when nothing is wrong or the runtime's types
200
+ * aren't installed.
201
+ */
202
+ export declare function refinePhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
203
+ /**
204
+ * RESEARCH — the four workers, then the TOOLING verification pass, then a
205
+ * deterministic check of every runtime builtin specifier the refined task names
206
+ * (`bun:sql`, `node:…`) against the installed types. A doc can confidently name a
207
+ * module that does not exist; left unchecked it rides through every phase and the
208
+ * implementer fabricates a `declare module` shim to compile it. The corrections are
209
+ * APPENDED so compose folds them into CONSTRAINTS. No LLM cost, silent when clean.
210
+ */
211
+ export declare function researchPhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
212
+ /** GRILL — the adaptive question loop, and the only phase that talks to the user. */
213
+ export declare function grillPhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
214
+ /**
215
+ * COMPOSE — drop constraints research REFUTED before composing, then compose.
216
+ *
217
+ * The drop mutates `p.refined` in place on purpose: the refuted constraint must be
218
+ * gone from every later reader of the refined spec, not just from this call's
219
+ * argument. Critique re-reads it.
220
+ */
221
+ export declare function composePhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
222
+ /**
223
+ * CRITIQUE — the last spec-producing step, and the two host-side corrections that
224
+ * must run after it in THIS ORDER.
225
+ *
226
+ * BRACES (mx5 run 16): append any owned design obligation the spec still omits as a
227
+ * CONSTRAINTS bullet. The belt block upstream is obeyed ~25% (measured); a host-side
228
+ * append is obeyed by construction. Idempotent — quotes the spec already carries
229
+ * (belt-obeying reps) are skipped.
230
+ *
231
+ * Then DETACH: an owned obligation whose only file this spec also FREEZES is
232
+ * unsatisfiable here, so it moves to the pending task that writes that file rather
233
+ * than shipping a requirement no one can meet. It MUST run after the append, because
234
+ * the append is what writes the stamp the detach reads — a critique-time probe
235
+ * measured 0/40 because the stamp did not exist yet.
236
+ */
237
+ export declare function critiquePhase(d: PhaseDeps, p: PhaseContext): Promise<string>;
238
+ /**
239
+ * The pipeline, as a table with no bodies.
240
+ *
241
+ * Every row's `run` is a named exported function, so the COMPOSITION inside a
242
+ * step — which is where this codebase's recorded phase defects have lived, not in
243
+ * the parts — is drivable directly instead of only through a whole TaskRunner run.
244
+ * The parts stay exported and separately covered; what changed is that the ORDER
245
+ * they run in is now asserted by driving the row rather than retyped in a test.
246
+ */
223
247
  export declare const PHASES: PhaseConfig[];
224
248
  export declare function postCommitPhase(phase: PhaseConfig, deps: PhaseDeps, pc: PhaseContext, out: string): Promise<void>;
@@ -8,6 +8,8 @@ import { fetchFocused } from '../workers/fetch-core.js';
8
8
  import { runWorker } from '../workers/pi-worker-core.js';
9
9
  import { classifyWorkerFailure } from '../workers/worker-failure.js';
10
10
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
11
+ import { searchProviderKey } from '../workers/search-types.js';
12
+ import { channelSet } from '../workers/worker-channels.js';
11
13
  import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
12
14
  import { isIntegrationUnknown } from './unknown-routing.js';
13
15
  import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
@@ -36,7 +38,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
36
38
  import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
37
39
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
38
40
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
39
- import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
41
+ import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
40
42
  import { SessionUI } from '../remote/bridge.js';
41
43
  import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
42
44
  // ─── Re-export constants from their home modules ────────────────────────────
@@ -262,7 +264,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
262
264
  // re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
263
265
  const directives = extractUserDirectives(raw);
264
266
  const directivesBlock = preserveDirectivesBlock(directives);
265
- const refined = await runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock))),
267
+ const refined = await runPhaseChild(deps, 'refine', 'read', appendNoThink(REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock)),
266
268
  // refine's deliverable is a 4-section text rewrite that never strictly
267
269
  // needs a successful read — on a test-writing task against a large
268
270
  // existing codebase the model over-explores (re-reads source hunting for
@@ -270,7 +272,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
270
272
  // attempt instead of hard-failing the whole run. See TASK_0016 (mx5):
271
273
  // refine looped 3×/resume forever; the deliverable was always producible
272
274
  // from the title + design doc alone.
273
- { degradeOnExhaustion: true });
275
+ { degradeOnExhaustion: true, verb: 'restart' });
274
276
  // Deterministic backstop: if the refined spec still dropped a directive, append
275
277
  // it verbatim rather than trusting the paraphrase. No model in this path.
276
278
  const { text, appended } = enforceDirectives(refined, directives);
@@ -300,21 +302,34 @@ export async function phaseVerifyTooling(deps, research) {
300
302
  await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
301
303
  return replaceToolingWithVerified(research, parsed.verified);
302
304
  }
303
- const DOCS_EXTENSION_PATH = fileURLToPath(new URL('../workers/docs-extension.js', import.meta.url));
304
- /** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
305
- * when a Brave key is configured (the tool without a key just errors, and a weak
306
- * model burns calls on it). Search being absent from the research toolset was
307
- * STRUCTURAL: three consecutive audited runs made 0 search calls because the
308
- * child literally did not have the tool. */
309
- const SEARCH_EXTENSION_PATH = fileURLToPath(new URL('../workers/search-extension.js', import.meta.url));
305
+ /**
306
+ * The worker channels the APIS research worker is given.
307
+ *
308
+ * `pi-worker-search` + `pi-worker-fetch` ride along only when the configured
309
+ * engine is usable (a keyless engine always is; brave needs its key). A tool
310
+ * without a key just errors, and a weak model burns calls on it — while search
311
+ * being ABSENT was structural in the other direction: three consecutive audited
312
+ * runs made 0 search calls because the child literally did not have the tool.
313
+ *
314
+ * Both halves of "given a channel" — the tools string and the `-e` path — come
315
+ * from the same rows, so they cannot disagree.
316
+ */
317
+ function apisWorkerChannels() {
318
+ return channelSet([
319
+ 'pi-worker-docs',
320
+ ...(searchConfigured() ? ['pi-worker-search', 'pi-worker-fetch'] : [])
321
+ ]);
322
+ }
310
323
  /**
311
324
  * Is live web search configured for this process? The keyless providers (exa,
312
- * ddg) always are; only brave needs its API key — mirrors search-core's lookup.
325
+ * ddg) always are; only brave needs its API key.
313
326
  */
314
327
  export function searchConfigured(getEnv = k => process.env[k], provider = getConfig().searchProvider) {
315
- if (provider !== 'brave')
316
- return true;
317
- return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
328
+ // Asks the SAME row `search()` asks. This used to re-state brave's env pair
329
+ // under a comment saying it "mirrors search-core's lookup" — two statements of
330
+ // one fact, and the one that decides whether the APIS worker is even handed the
331
+ // search tool.
332
+ return searchProviderKey(provider, getEnv) !== null;
318
333
  }
319
334
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
320
335
  * (the validated shape for getting a local model to actually reach for search). */
@@ -604,10 +619,10 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
604
619
  + 'nothing else. Keep the same rules as before: state an external library/API behaviour as fact '
605
620
  + 'ONLY when quoting an EXTERNAL CONTEXT block; otherwise write it as an "unverified:" open '
606
621
  + 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
607
- export async function phaseResearch(deps, refined, researchDeps = {}) {
608
- const fileInventoryFn = researchDeps.getFileInventory ?? getFileInventory;
609
- const runWorkerFn = researchDeps.runWorker ?? ((_label, input) => runWorker(input));
610
- const externalContext = await gatherExternalContext(refined, deps, researchDeps);
622
+ export async function phaseResearch(deps, refined) {
623
+ const fileInventoryFn = deps.getFileInventory ?? getFileInventory;
624
+ const runWorkerFn = deps.runWorker ?? ((_label, input) => runWorker(input));
625
+ const externalContext = await gatherExternalContext(refined, deps);
611
626
  // Pre-compute the project file inventory once and hand it to every worker.
612
627
  // Workers can then jump straight to targeted read/grep on known paths
613
628
  // instead of each spawning its own discovery loop (find/ls). A '' result
@@ -721,6 +736,9 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
721
736
  // The worker still calls as many tools as it wants; it just stops narrating
722
737
  // between them. See appendNoThink. Result order (files, apis, context,
723
738
  // tooling) is preserved for assembly.
739
+ // Resolved once: `searchConfigured()` reads the environment, and the tools
740
+ // string and the `-e` paths must be derived from the SAME answer.
741
+ const apisChannels = apisWorkerChannels();
724
742
  const workerSpecs = [
725
743
  {
726
744
  section: 'FILES',
@@ -746,13 +764,12 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
746
764
  // budget enforced without being announced would just read
747
765
  // to the worker as a broken tool.
748
766
  + (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))),
749
- tools: 'read,grep,find,ls,pi-worker-docs'
750
- + (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
767
+ // The tools string and the `-e` paths are ONE fact — which worker
768
+ // channels this research worker is given — and used to be two literals
769
+ // kept in step by eye. `channelSet` derives both from the same rows.
770
+ tools: `read,grep,find,ls,${apisChannels.tools}`,
751
771
  fanoutBounded: true,
752
- extensions: [
753
- DOCS_EXTENSION_PATH,
754
- ...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
755
- ],
772
+ extensions: apisChannels.extensions,
756
773
  // ZERO-RETRIEVAL GATE (mx5 run-15 F-1, distinct from the STAGE 1-3 stopping-point
757
774
  // thread). In a MINORITY of reps worker:apis emits a complete, plausible APIS section
758
775
  // having made ZERO retrieval tool calls — the whole thing recalled from memory.
@@ -1069,9 +1086,9 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
1069
1086
  }
1070
1087
  return sections.map(({ name, text }) => `${name}\n${text}`).join('\n\n');
1071
1088
  }
1072
- export async function phaseAutoAnswer(deps, refined, research, question, autoDeps = {}) {
1073
- const docsFocusedFn = autoDeps.docsFocused ?? docsFocused;
1074
- const fetchFocusedFn = autoDeps.fetchFocused ?? fetchFocused;
1089
+ export async function phaseAutoAnswer(deps, refined, research, question) {
1090
+ const docsFocusedFn = deps.docsFocused ?? docsFocused;
1091
+ const fetchFocusedFn = deps.fetchFocused ?? fetchFocused;
1075
1092
  try {
1076
1093
  // Same assembly as the research phase (see external-context.ts); what
1077
1094
  // differs is POLICY and the worker variant, and both are arguments now.
@@ -1109,7 +1126,7 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
1109
1126
  });
1110
1127
  return { body: countBody(r.answer || undefined) };
1111
1128
  },
1112
- search: autoDeps.searchFn
1129
+ search: deps.searchFn
1113
1130
  }, { targetCap: 2, serviceCap: 2 });
1114
1131
  const basePrompt = externalContext + GRILL_AUTO_ANSWER_PROMPT(refined, research, question);
1115
1132
  let text = await runPhaseChild(deps, 'grill-auto', 'read', basePrompt);
@@ -1209,7 +1226,7 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1209
1226
  for (let n = 0; n < MAX_GRILL_QUESTIONS; n++) {
1210
1227
  const tGenStart = Date.now();
1211
1228
  const genHint = dupHint;
1212
- const raw = await runPhaseWithLoopGuard(deps, 'grill-gen', 'read', hint => prependHint(hint, prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n')))));
1229
+ const raw = await runPhaseChild(deps, 'grill-gen', 'read', prependHint(genHint, GRILL_GEN_PROMPT(refined, research, qa.join('\n'))), { verb: 'restart' });
1213
1230
  deps.recordSubStep?.('gen', Date.now() - tGenStart);
1214
1231
  const questions = parseGrillQuestions(raw);
1215
1232
  if (questions.length === 0)
@@ -1497,91 +1514,101 @@ export async function critiqueWithFallback(d, p) {
1497
1514
  }
1498
1515
  }
1499
1516
  // ─── Phase config table ──────────────────────────────────────────────────────
1500
- export const PHASES = [
1501
- {
1502
- name: 'refine',
1503
- section: 'refined prompt',
1504
- field: 'refined',
1505
- run: async (d, p) => {
1506
- const refined = await phaseRefine(d, p.rawPrompt, p.planContext);
1507
- // Subtractively strike any phantom runtime specifier (`bun:sql`) the
1508
- // refine carried up verbatim from the spec doc, BEFORE it flows to
1509
- // research/grill/compose. An appended correction alone loses: the
1510
- // affirmative survives into the composed GOAL and on to the implementer
1511
- // (proven: compose re-leaks it 4/4). Rewriting the source so compose has
1512
- // nothing to contradict is the fix. Silent + no-op when nothing is wrong
1513
- // or the runtime's types aren't installed.
1514
- const phantoms = await findPhantomImports(refined, d.cwd);
1515
- if (phantoms.length === 0)
1516
- return refined;
1517
- d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
1518
- return rewritePhantomSpecifiers(refined, phantoms);
1519
- }
1520
- },
1521
- {
1522
- name: 'research',
1523
- section: 'research',
1524
- field: 'research',
1525
- run: async (d, p) => {
1526
- const tResearch = Date.now();
1527
- const rawResearch = await phaseResearch(d, p.refined);
1528
- d.recordSubStep?.('workers', Date.now() - tResearch);
1529
- const tVerify = Date.now();
1530
- const out = await phaseVerifyTooling(d, rawResearch);
1531
- d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
1532
- // Deterministically verify every runtime builtin specifier the refined
1533
- // task names (`bun:sql`, `node:…`) against the installed types. A doc can
1534
- // confidently name a module that does not exist; left unchecked it rides
1535
- // through every phase and the implementer fabricates a `declare module`
1536
- // shim to compile it. Append the corrections so compose folds them into
1537
- // CONSTRAINTS. No LLM cost and silent when nothing is wrong.
1538
- const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
1539
- if (corrections) {
1540
- d.logDebug?.(`phantom imports flagged:\n${corrections}`);
1541
- return `${out}\n\n${corrections}`;
1542
- }
1543
- return out;
1544
- }
1545
- },
1546
- {
1547
- name: 'grill',
1548
- section: 'grill Q&A',
1549
- field: 'qa',
1550
- run: (d, p) => phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research)
1551
- },
1552
- {
1553
- name: 'compose',
1554
- section: 'spec',
1555
- field: 'spec',
1556
- run: async (d, p) => {
1557
- p.refined = await dropRefutedConstraints(d, p.refined, p.research);
1558
- return await phaseCompose(d, p.refined, p.research, p.qa);
1559
- }
1560
- },
1561
- {
1562
- name: 'critique',
1563
- section: 'spec',
1564
- field: 'spec',
1565
- run: async (d, p) => {
1566
- const spec = await critiqueWithFallback(d, p);
1567
- // BRACES (mx5 run 16): after the LAST spec-producing step, append any
1568
- // owned design obligation the spec still omits as a CONSTRAINTS
1569
- // bullet. The belt block upstream is obeyed ~25% (measured); a
1570
- // host-side append is obeyed by construction. Idempotent: quotes the
1571
- // spec already carries (belt-obeying reps) are skipped.
1572
- const owned = await ownedForThisTask(d);
1573
- if (owned.length === 0)
1574
- return spec;
1575
- const out = appendOwnedConstraints(spec, owned);
1576
- if (out !== spec) {
1577
- d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
1578
- }
1579
- // An owned obligation whose only file this spec also FREEZES is
1580
- // unsatisfiable here; move it to the pending task that writes that
1581
- // file rather than shipping a requirement no one can meet.
1582
- return await resolveOwnedFreezeForThisTask(d, out);
1583
- }
1517
+ /**
1518
+ * REFINE — restate the raw prompt as a bounded 4-section spec, then subtractively
1519
+ * strike any phantom runtime specifier (`bun:sql`) it carried up verbatim from the
1520
+ * spec doc, BEFORE it flows to research/grill/compose. An appended correction alone
1521
+ * loses: the affirmative survives into the composed GOAL and on to the implementer
1522
+ * (proven: compose re-leaks it 4/4). Rewriting the source so compose has nothing to
1523
+ * contradict is the fix. Silent + no-op when nothing is wrong or the runtime's types
1524
+ * aren't installed.
1525
+ */
1526
+ export async function refinePhase(d, p) {
1527
+ const refined = await phaseRefine(d, p.rawPrompt, p.planContext);
1528
+ const phantoms = await findPhantomImports(refined, d.cwd);
1529
+ if (phantoms.length === 0)
1530
+ return refined;
1531
+ d.logDebug?.(`phantom specifiers rewritten in refined: ${phantoms.map(x => x.spec).join(', ')}`);
1532
+ return rewritePhantomSpecifiers(refined, phantoms);
1533
+ }
1534
+ /**
1535
+ * RESEARCH — the four workers, then the TOOLING verification pass, then a
1536
+ * deterministic check of every runtime builtin specifier the refined task names
1537
+ * (`bun:sql`, `node:…`) against the installed types. A doc can confidently name a
1538
+ * module that does not exist; left unchecked it rides through every phase and the
1539
+ * implementer fabricates a `declare module` shim to compile it. The corrections are
1540
+ * APPENDED so compose folds them into CONSTRAINTS. No LLM cost, silent when clean.
1541
+ */
1542
+ export async function researchPhase(d, p) {
1543
+ const tResearch = Date.now();
1544
+ const rawResearch = await phaseResearch(d, p.refined);
1545
+ d.recordSubStep?.('workers', Date.now() - tResearch);
1546
+ const tVerify = Date.now();
1547
+ const out = await phaseVerifyTooling(d, rawResearch);
1548
+ d.recordSubStep?.('verify-tooling', Date.now() - tVerify);
1549
+ const corrections = formatApiCorrections(await findPhantomImports(p.refined, d.cwd));
1550
+ if (corrections) {
1551
+ d.logDebug?.(`phantom imports flagged:\n${corrections}`);
1552
+ return `${out}\n\n${corrections}`;
1584
1553
  }
1554
+ return out;
1555
+ }
1556
+ /** GRILL — the adaptive question loop, and the only phase that talks to the user. */
1557
+ export function grillPhase(d, p) {
1558
+ return phaseGrill(d, p.ctx, p.widgetState, p.refined, p.research);
1559
+ }
1560
+ /**
1561
+ * COMPOSE — drop constraints research REFUTED before composing, then compose.
1562
+ *
1563
+ * The drop mutates `p.refined` in place on purpose: the refuted constraint must be
1564
+ * gone from every later reader of the refined spec, not just from this call's
1565
+ * argument. Critique re-reads it.
1566
+ */
1567
+ export async function composePhase(d, p) {
1568
+ p.refined = await dropRefutedConstraints(d, p.refined, p.research);
1569
+ return await phaseCompose(d, p.refined, p.research, p.qa);
1570
+ }
1571
+ /**
1572
+ * CRITIQUE — the last spec-producing step, and the two host-side corrections that
1573
+ * must run after it in THIS ORDER.
1574
+ *
1575
+ * BRACES (mx5 run 16): append any owned design obligation the spec still omits as a
1576
+ * CONSTRAINTS bullet. The belt block upstream is obeyed ~25% (measured); a host-side
1577
+ * append is obeyed by construction. Idempotent — quotes the spec already carries
1578
+ * (belt-obeying reps) are skipped.
1579
+ *
1580
+ * Then DETACH: an owned obligation whose only file this spec also FREEZES is
1581
+ * unsatisfiable here, so it moves to the pending task that writes that file rather
1582
+ * than shipping a requirement no one can meet. It MUST run after the append, because
1583
+ * the append is what writes the stamp the detach reads — a critique-time probe
1584
+ * measured 0/40 because the stamp did not exist yet.
1585
+ */
1586
+ export async function critiquePhase(d, p) {
1587
+ const spec = await critiqueWithFallback(d, p);
1588
+ const owned = await ownedForThisTask(d);
1589
+ if (owned.length === 0)
1590
+ return spec;
1591
+ const out = appendOwnedConstraints(spec, owned);
1592
+ if (out !== spec) {
1593
+ d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
1594
+ }
1595
+ return await resolveOwnedFreezeForThisTask(d, out);
1596
+ }
1597
+ /**
1598
+ * The pipeline, as a table with no bodies.
1599
+ *
1600
+ * Every row's `run` is a named exported function, so the COMPOSITION inside a
1601
+ * step — which is where this codebase's recorded phase defects have lived, not in
1602
+ * the parts — is drivable directly instead of only through a whole TaskRunner run.
1603
+ * The parts stay exported and separately covered; what changed is that the ORDER
1604
+ * they run in is now asserted by driving the row rather than retyped in a test.
1605
+ */
1606
+ export const PHASES = [
1607
+ { name: 'refine', section: 'refined prompt', field: 'refined', run: refinePhase },
1608
+ { name: 'research', section: 'research', field: 'research', run: researchPhase },
1609
+ { name: 'grill', section: 'grill Q&A', field: 'qa', run: grillPhase },
1610
+ { name: 'compose', section: 'spec', field: 'spec', run: composePhase },
1611
+ { name: 'critique', section: 'spec', field: 'spec', run: critiquePhase }
1585
1612
  ];
1586
1613
  // INTEGRATION-DEPTH APPEND (2026-07-27): the lever proposed for this exact site —
1587
1614
  // deterministically append a known-runnable integration command to the VERIFY block
@@ -187,8 +187,8 @@ async function defaultHandoff(ctx, cwd, prompt) {
187
187
  await runGatedTask(ctx, cwd, prompt);
188
188
  return undefined;
189
189
  }
190
- const { taskId, sessionCancelled } = await runSingleTask(ctx, cwd, prompt, { notifyFinish: true });
191
- if (sessionCancelled) {
190
+ const { taskId, end } = await runSingleTask(ctx, cwd, prompt, { notifyFinish: true });
191
+ if (end.kind === 'no-session') {
192
192
  ctx.ui.notify('Could not start a fresh session for /task-plan.', 'warning');
193
193
  return undefined;
194
194
  }
@@ -1,3 +1,4 @@
1
+ import { type CommandRunner } from './command-run.js';
1
2
  export interface HealthOutcome {
2
3
  /** true → every discovered static check passed, or there was nothing to run.
3
4
  * false → a discovered command actually ran and exited non-zero. */
@@ -29,38 +30,37 @@ export declare function discoverHealthCommands(cwd: string): {
29
30
  ecosystem: string | null;
30
31
  cmds: HealthCommand[];
31
32
  };
33
+ /** Progress hook: called with each command's label as it STARTS, so a caller can
34
+ * keep a live status line naming what is currently running. */
35
+ export type HealthProgress = (command: string) => void;
32
36
  /**
33
37
  * Run the discovered static checks whole-repo and let the real exit codes decide.
34
- * Deterministic and synchronous under the hood (a wrapper keeps the caller async).
35
38
  *
36
39
  * - No manifest / no static command → ok (nothing can regress).
37
- * - A command that CANNOT run (ENOENT / null exit tool not installed) → skipped,
40
+ * - A command that CANNOT run (ENOENT / null exit / 127 inside the chain) → skipped,
38
41
  * treated as an environment gap, not a fault.
39
42
  * - A command that ran and exited non-zero → the first such failure is returned.
40
43
  *
41
- * A generous per-command timeout guards against a wedged tool; a timeout is treated
42
- * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
44
+ * This module owns DISCOVERY and its own output policy; running a command and
45
+ * deciding what its ending MEANS is `command-run.ts`'s. It used to own those too
46
+ * `HealthRun`, `classifyHealthRun` and `spawnHealthCommand` were a second statement
47
+ * of the gap ladder, with no injectable runner, so every classification case in the
48
+ * suite spawned a real shell. `command-run.ts`'s own header notes that this module
49
+ * "had solved exactly this shape years earlier" and the gate never adopted it; this
50
+ * is the adoption, in the other direction.
43
51
  *
44
- * SYNCHRONOUS it blocks the event loop for as long as the project's own lint takes
45
- * (MEASURED: 15s on mx5, 69s on aiz-client), so nothing can render or animate while
46
- * it runs. Gate callers must use {@link runRepoHealthCheckAsync} instead; this stays
47
- * for callers that genuinely have no async seam.
48
- */
49
- export declare function runRepoHealthCheck(cwd: string, timeoutMs?: number): HealthOutcome;
50
- /** Progress hook: called with each command's label as it STARTS, so a caller can
51
- * keep a live status line naming what is currently running. */
52
- export type HealthProgress = (command: string) => void;
53
- /**
54
- * Same check, same verdicts, without blocking the event loop.
52
+ * `captureHealthOutput` stays this module's own: 40 lines of a linter's report is a
53
+ * real difference from `outputTail`'s 400 characters, and that is a parameter, not a
54
+ * thing to unify.
55
55
  *
56
- * The gate runs this immediately after the implementation turn ends, when the impl
57
- * widget has just been cleared the sync version froze the whole TUI there for the
58
- * duration of the project's lint (MEASURED: 0 of 686 expected 100ms timer ticks
59
- * fired during a 69s aiz-client run), so no spinner, clock or queued notify could
60
- * paint. `onCommand` lets the caller name the running command in a live status line.
56
+ * `onCommand` lets the caller name the running command in a live status line — the
57
+ * gate runs this immediately after the implementation turn ends, when the impl
58
+ * widget has just been cleared.
61
59
  */
62
- export declare function runRepoHealthCheckAsync(cwd: string, opts?: {
60
+ export declare function runRepoHealthCheck(cwd: string, opts?: {
63
61
  timeoutMs?: number;
64
62
  signal?: AbortSignal;
65
63
  onCommand?: HealthProgress;
64
+ /** The spawner. Injected so a verdict is testable without a real shell. */
65
+ run?: CommandRunner;
66
66
  }): Promise<HealthOutcome>;