@mjasnikovs/pi-task 0.37.6 → 0.38.0

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 (39) hide show
  1. package/README.md +6 -3
  2. package/dist/shared/child-output.d.ts +19 -3
  3. package/dist/shared/child-output.js +21 -5
  4. package/dist/shared/git-runner.d.ts +39 -0
  5. package/dist/shared/git-runner.js +38 -0
  6. package/dist/task/accept-debt.d.ts +27 -58
  7. package/dist/task/accept-debt.js +60 -130
  8. package/dist/task/auto-orchestrator.d.ts +7 -57
  9. package/dist/task/auto-orchestrator.js +25 -499
  10. package/dist/task/child-runner.d.ts +2 -0
  11. package/dist/task/child-runner.js +74 -70
  12. package/dist/task/enforce-guidelines.d.ts +1 -1
  13. package/dist/task/enforce-guidelines.js +2 -2
  14. package/dist/task/external-context.d.ts +85 -7
  15. package/dist/task/external-context.js +100 -63
  16. package/dist/task/file-inventory.js +22 -41
  17. package/dist/task/final-gate.d.ts +80 -0
  18. package/dist/task/final-gate.js +102 -49
  19. package/dist/task/gate-deps.js +6 -23
  20. package/dist/task/git-state-guard.d.ts +1 -1
  21. package/dist/task/git-state-guard.js +1 -7
  22. package/dist/task/phases.js +40 -83
  23. package/dist/task/run-final-gate.d.ts +127 -0
  24. package/dist/task/run-final-gate.js +492 -0
  25. package/dist/task/task-gates.d.ts +20 -57
  26. package/dist/task/task-gates.js +11 -11
  27. package/dist/task/verify-work.d.ts +40 -32
  28. package/dist/task/verify-work.js +301 -241
  29. package/dist/workers/docs-core.d.ts +14 -0
  30. package/dist/workers/docs-core.js +28 -16
  31. package/dist/workers/fetch-core.d.ts +6 -1
  32. package/dist/workers/fetch-core.js +26 -33
  33. package/dist/workers/focused-extractor.d.ts +73 -0
  34. package/dist/workers/focused-extractor.js +72 -0
  35. package/dist/workers/pi-worker-docs.d.ts +1 -1
  36. package/dist/workers/pi-worker-docs.js +48 -42
  37. package/dist/workers/pi-worker-fetch.js +6 -8
  38. package/dist/workers/typeonly-log.d.ts +13 -0
  39. package/package.json +1 -1
@@ -538,6 +538,86 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
538
538
  * assumption rather than an observation.
539
539
  */
540
540
  export declare function rerunDebtVerifyCommand(cwd: string, command: string): VerifyRerunResult;
541
+ /**
542
+ * Where in the gate a closure scan runs. The two stages are NOT interchangeable
543
+ * and neither is a scheduling preference:
544
+ *
545
+ * - `pre-discovery` runs before the zero-discovery early return, so a project
546
+ * with no runnable command at all still FAILS the scan instead of returning
547
+ * UNOBSERVED. A static check needs no runner; that is the whole point of
548
+ * deciding it in exactly the environment where every dynamic probe went blind.
549
+ * - `post-boot` runs after the dynamic sections, which is where these scans'
550
+ * failures have always landed relative to command/launch/boot failures.
551
+ * Execution order is the aggregate's tiebreak within a rank, so moving a row
552
+ * between stages MOVES it in the user-visible failure list.
553
+ */
554
+ type ClosureScanStage = 'pre-discovery' | 'post-boot';
555
+ /** Everything a closure scan may look at. Static and synchronous by construction:
556
+ * a check that had to spawn something would belong in the dynamic sections
557
+ * above, not here — these run on trees where nothing is runnable. */
558
+ interface ClosureScanInput {
559
+ cwd: string;
560
+ planText?: string;
561
+ }
562
+ /**
563
+ * One run-level closure scan: "the shipped tree references/requires something it
564
+ * does not contain". Each row owns its scan, its formatting, its rank and its
565
+ * position; the driver owns the fault isolation.
566
+ */
567
+ interface ClosureScan {
568
+ /** Stable identity — how the driver and its tests address a row. */
569
+ id: string;
570
+ stage: ClosureScanStage;
571
+ /**
572
+ * Rank in the aggregated failure list. All three rows are 0 because all three
573
+ * say some form of "the app cannot be started / cannot serve what it
574
+ * references / cannot be configured at all", which is the same load-bearing
575
+ * class as boot/render. It is a per-row FIELD rather than a shared constant
576
+ * so that a future scan whose finding is NOT of that class can say so in the
577
+ * table instead of by getting a hand-typed argument right at a call site.
578
+ */
579
+ rank: number;
580
+ /**
581
+ * Scan and format in one pass, yielding one ready-to-emit failure text per
582
+ * finding.
583
+ *
584
+ * A GENERATOR rather than a function returning an array, for two reasons.
585
+ * First, the driver's try/catch wraps the ITERATION, so a scan that produces
586
+ * two findings and then faults still emits those two — precisely what the
587
+ * three hand-written `for (… ) fail(…)` loops inside try blocks did. Second,
588
+ * the three scans do not share a result shape (one nullable finding; a list;
589
+ * a list plus the template set its formatter also needs), and folding scan
590
+ * and format together lets each row keep its own arity instead of forcing a
591
+ * lowest-common-denominator result type on all of them.
592
+ */
593
+ run: (input: ClosureScanInput) => Iterable<string>;
594
+ }
595
+ /**
596
+ * The run-level closure scans, in emission order within their stage.
597
+ *
598
+ * ONLY checks of this shape belong here. Four other checks in this gate are
599
+ * deliberately NOT rows: repo-health returns {ok, reason} and formats inline;
600
+ * the launch-contract diff branches on manifest kind and can emit a NOTE instead
601
+ * of a failure; the launch config-gap produces neither a failure nor a note but
602
+ * UN-COUNTS a dynamic observation; and the boot check is an async, stateful,
603
+ * port-binding exercise. Squeezing any of those in would mean a row type with
604
+ * more escape hatches than content.
605
+ */
606
+ declare const CLOSURE_SCANS: ClosureScan[];
607
+ /**
608
+ * Run every closure scan belonging to `stage`, in table order, feeding each
609
+ * finding to `fail` at the row's own rank.
610
+ *
611
+ * FAULT ISOLATION IS PER ROW, not per stage: each row gets its own try/catch, so
612
+ * a scanner that throws contributes nothing and every LATER row still runs. One
613
+ * shared try would silently turn a fault in the first scan into blanket silence
614
+ * from the rest — the gate would keep working and keep missing defects it can
615
+ * decide. `scans` is injectable so that property can be tested with a row built
616
+ * to throw, without mocking the real scanners.
617
+ */
618
+ declare function runClosureScans(stage: ClosureScanStage, input: ClosureScanInput, fail: (text: string, rank: number) => void, scans?: readonly ClosureScan[]): void;
619
+ export { CLOSURE_SCANS, runClosureScans };
620
+ export type { ClosureScan, ClosureScanInput, ClosureScanStage };
541
621
  /**
542
622
  * Run the final gate: static analysis first, then the lockfile consistency
543
623
  * checks, then the discovered integration commands, then one boot exercise of
@@ -1339,6 +1339,98 @@ export function rerunDebtVerifyCommand(cwd, command) {
1339
1339
  }
1340
1340
  return { outcome: 'pass' };
1341
1341
  }
1342
+ /**
1343
+ * The run-level closure scans, in emission order within their stage.
1344
+ *
1345
+ * ONLY checks of this shape belong here. Four other checks in this gate are
1346
+ * deliberately NOT rows: repo-health returns {ok, reason} and formats inline;
1347
+ * the launch-contract diff branches on manifest kind and can emit a NOTE instead
1348
+ * of a failure; the launch config-gap produces neither a failure nor a note but
1349
+ * UN-COUNTS a dynamic observation; and the boot check is an async, stateful,
1350
+ * port-binding exercise. Squeezing any of those in would mean a row type with
1351
+ * more escape hatches than content.
1352
+ */
1353
+ const CLOSURE_SCANS = [
1354
+ {
1355
+ // Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server
1356
+ // app, expects to serve (SPA fallback / static read / a design clause), and
1357
+ // NOTHING anywhere starts a listener — `src/server/index.ts` ended at
1358
+ // `export {app}`, so the product could not be started at all while every
1359
+ // dynamic probe went blind on a docker-less box. Static, deterministic,
1360
+ // milliseconds, and — unlike the boot check — decidable in exactly the
1361
+ // environment where the boot skipped. Hence `pre-discovery`: a project with
1362
+ // no runnable command at all must still fail this, not report UNOBSERVED.
1363
+ id: 'serve-entry',
1364
+ stage: 'pre-discovery',
1365
+ rank: 0,
1366
+ *run({ cwd, planText }) {
1367
+ const found = findMissingServeEntry(cwd, planText);
1368
+ if (found)
1369
+ yield serveEntryGateFailureText(found);
1370
+ }
1371
+ },
1372
+ {
1373
+ // Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
1374
+ // reference with NO producer anywhere ships silently — the server read
1375
+ // `Bun.file('dist/index.html')` while the build emitted only app.css +
1376
+ // main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
1377
+ // Deterministic scan of the shipped tree (literal refs only, positive
1378
+ // producer evidence required — see artifact-closure.ts); each dangle names
1379
+ // referencer + missing path.
1380
+ id: 'dangling-artifact',
1381
+ stage: 'post-boot',
1382
+ rank: 0,
1383
+ *run({ cwd }) {
1384
+ for (const d of findDanglingArtifacts(cwd))
1385
+ yield danglingGateFailureText(d);
1386
+ }
1387
+ },
1388
+ {
1389
+ // Env-template closure (mx5 run 19, nexttask 10): a shipped source file
1390
+ // requires an env var the shipped template never mentions. `seed.ts` read
1391
+ // `process.env.ADMIN_PHONE`/`ADMIN_PASSWORD`, `.env.example` declared
1392
+ // neither, `bun run seed` exited 1, and the autofix "fixed" it by writing
1393
+ // the GITIGNORED `.env` — so the committed tree still cannot seed and
1394
+ // nothing at run end said why. Same shape and rank as the dangling-artifact
1395
+ // scan above: naming the ARTIFACT that is wrong, statically, instead of only
1396
+ // the command that failed. The formatter needs the template set as well as
1397
+ // the finding, which is why scan and format are folded into one row.
1398
+ // Inert on any tree with no tracked template (ENOENT = pass).
1399
+ id: 'env-template',
1400
+ stage: 'post-boot',
1401
+ rank: 0,
1402
+ *run({ cwd }) {
1403
+ const env = findMissingEnvDeclarations(cwd);
1404
+ for (const m of env.missing)
1405
+ yield envGateFailureText(m, env.templates);
1406
+ }
1407
+ }
1408
+ ];
1409
+ /**
1410
+ * Run every closure scan belonging to `stage`, in table order, feeding each
1411
+ * finding to `fail` at the row's own rank.
1412
+ *
1413
+ * FAULT ISOLATION IS PER ROW, not per stage: each row gets its own try/catch, so
1414
+ * a scanner that throws contributes nothing and every LATER row still runs. One
1415
+ * shared try would silently turn a fault in the first scan into blanket silence
1416
+ * from the rest — the gate would keep working and keep missing defects it can
1417
+ * decide. `scans` is injectable so that property can be tested with a row built
1418
+ * to throw, without mocking the real scanners.
1419
+ */
1420
+ function runClosureScans(stage, input, fail, scans = CLOSURE_SCANS) {
1421
+ for (const scan of scans) {
1422
+ if (scan.stage !== stage)
1423
+ continue;
1424
+ try {
1425
+ for (const text of scan.run(input))
1426
+ fail(text, scan.rank);
1427
+ }
1428
+ catch {
1429
+ // best-effort scan — a scanner fault must never break the gate
1430
+ }
1431
+ }
1432
+ }
1433
+ export { CLOSURE_SCANS, runClosureScans };
1342
1434
  /**
1343
1435
  * Run the final gate: static analysis first, then the lockfile consistency
1344
1436
  * checks, then the discovered integration commands, then one boot exercise of
@@ -1403,23 +1495,9 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1403
1495
  }
1404
1496
  }
1405
1497
  }
1406
- // Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
1407
- // expects to serve (SPA fallback / static read / a design clause), and NOTHING
1408
- // anywhere starts a listener — `src/server/index.ts` ended at `export {app}`, so
1409
- // the product could not be started at all while every dynamic probe went blind on
1410
- // a docker-less box. Static, deterministic, milliseconds, and — unlike the boot
1411
- // check — decidable in exactly the environment where the boot skipped. Rank 0:
1412
- // "the app cannot be started" is the same load-bearing class as boot/render.
1413
- // Placed BEFORE the zero-discovery early return on purpose: a project with no
1414
- // runnable command at all must still fail this, not report UNOBSERVED.
1415
- try {
1416
- const noServeEntry = findMissingServeEntry(cwd, planText);
1417
- if (noServeEntry)
1418
- fail(serveEntryGateFailureText(noServeEntry), 0);
1419
- }
1420
- catch {
1421
- // best-effort scan — a scanner fault must never break the gate
1422
- }
1498
+ // Run-level closure scans that must be decided BEFORE the zero-discovery early
1499
+ // return below a static check needs no runner (CLOSURE_SCANS: 'pre-discovery').
1500
+ runClosureScans('pre-discovery', { cwd, planText }, fail);
1423
1501
  const lockCmds = discoverLockfileChecks(cwd);
1424
1502
  const { cmds } = discoverIntegrationCommands(cwd);
1425
1503
  const boot = discoverBootCommand(cwd);
@@ -1486,7 +1564,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1486
1564
  const warnings = [];
1487
1565
  /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
1488
1566
  * They ride in `unobserved`, not `warnings`, so the caller's existing
1489
- * recordFinalGateUnobservedDebt writes the debt — never a PASS. */
1567
+ * `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
1568
+ * never a PASS. */
1490
1569
  const configGapNotes = [];
1491
1570
  if (declared.length > 0) {
1492
1571
  const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
@@ -1672,37 +1751,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1672
1751
  });
1673
1752
  if (gap)
1674
1753
  fail(gap, 0);
1675
- // Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
1676
- // reference with NO producer anywhere ships silently the server read
1677
- // `Bun.file('dist/index.html')` while the build emitted only app.css +
1678
- // main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
1679
- // Deterministic scan of the shipped tree (literal refs only, positive
1680
- // producer evidence required — see artifact-closure.ts); each dangle is a
1681
- // ranked failure naming referencer + missing path. Rank 0: "the app cannot
1682
- // serve what it references" is the same load-bearing class as boot/render.
1683
- try {
1684
- for (const d of findDanglingArtifacts(cwd))
1685
- fail(danglingGateFailureText(d), 0);
1686
- }
1687
- catch {
1688
- // best-effort scan — a scanner fault must never break the gate
1689
- }
1690
- // Env-template closure (mx5 run 19, nexttask 10): a shipped source file
1691
- // requires an env var the shipped template never mentions. `seed.ts` read
1692
- // `process.env.ADMIN_PHONE`/`ADMIN_PASSWORD`, `.env.example` declared neither,
1693
- // `bun run seed` exited 1, and the autofix "fixed" it by writing the GITIGNORED
1694
- // `.env` — so the committed tree still cannot seed and nothing at run end said
1695
- // why. Same shape and rank as the dangling-artifact scan one layer up: naming
1696
- // the ARTIFACT that is wrong, statically, instead of only the command that
1697
- // failed. Inert on any tree with no tracked template (ENOENT = pass).
1698
- try {
1699
- const env = findMissingEnvDeclarations(cwd);
1700
- for (const m of env.missing)
1701
- fail(envGateFailureText(m, env.templates), 0);
1702
- }
1703
- catch {
1704
- // best-effort scan — a scanner fault must never break the gate
1705
- }
1754
+ // The remaining run-level closure scans "the shipped tree references or
1755
+ // requires something it does not contain"after every dynamic section, so
1756
+ // their failures keep their historical place in the aggregate (CLOSURE_SCANS:
1757
+ // 'post-boot').
1758
+ runClosureScans('post-boot', { cwd, planText }, fail);
1706
1759
  if (failures.length > 0) {
1707
1760
  // Stable sort: boot/render (rank 0) leads, everything else keeps execution
1708
1761
  // order. One failure keeps the exact single-failure wording; several become
@@ -22,7 +22,7 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
22
22
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
23
23
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
25
- import { recordAcceptDebt, recordEnforceKeptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
25
+ import { recordDebt } from './accept-debt.js';
26
26
  import { recordRepairCandidate } from './root-cause-repair.js';
27
27
  import { runRepoHealthCheck, runRepoHealthCheckAsync } from './repo-health-check.js';
28
28
  import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
@@ -610,28 +610,11 @@ export function buildGateDeps(params) {
610
610
  // Durable per-task gate trail: every verdict/decision lands in the task
611
611
  // file's `## gates` section so gate behavior is auditable from artifacts.
612
612
  record: (cwd2, taskId, line) => appendGateRecord(cwd2, taskId, line),
613
- // Durable ACCEPT-despite-verify-FAIL ledger under .pi-tasks/ (survives
614
- // discardEdits): the final integration gate re-checks each debt at run end.
615
- recordAcceptDebt: (cwd2, taskId, reason) => recordAcceptDebt(cwd2, taskId, reason),
616
- recordYoloAcceptDebt: (cwd2, taskId, reason) => recordYoloAcceptDebt(cwd2, taskId, reason),
617
- recordEnforceRevertDebt: (cwd2, taskId, reason) => recordEnforceRevertDebt(cwd2, taskId, reason),
618
- // Same ledger, the KEPT disposition (mx5 run 18 / nexttask 4): the enforce
619
- // re-verify FAILed on a check the enforce diff cannot reach, so the edits
620
- // stayed and only the defect was recorded.
621
- recordEnforceKeptDebt: (cwd2, taskId, reason) => recordEnforceKeptDebt(cwd2, taskId, reason),
622
- // Durable cross-task-contradiction ledger (PROMPT 1 layer B): a repo-health
623
- // FAIL whose only fix is an edit to a path this task's spec froze — recorded
624
- // when the gate loop routes it to the picker, re-checked by the final gate.
625
- recordFrozenBlockedDebt: (cwd2, taskId, reason) => recordFrozenBlockedDebt(cwd2, taskId, reason),
626
- // Durable cross-task-deletion ledger (PROMPT 2): a sibling's committed
627
- // deliverable this task's diff deletes, ACCEPTed into a commit anyway —
628
- // the final gate re-checks it (resolved iff the file is back in the tree).
629
- recordCrossTaskDeletionDebt: (cwd2, taskId, deletion) => recordCrossTaskDeletionDebt(cwd2, taskId, deletion),
630
- // ROOT-CAUSE channel (mx5 run 14 item 5): a FAIL another task's untouched
631
- // file caused is recorded as its own debt class and queued as a scoped
632
- // repair task, instead of being blamed on — and reverted out of — the task
633
- // that merely tripped over it.
634
- recordRootCauseDebt: (cwd2, taskId, reason) => recordRootCauseDebt(cwd2, taskId, reason),
613
+ // Durable defect ledger under .pi-tasks/ (survives discardEdits): every
614
+ // recorded class accepted, yolo-accepted, enforce-revert, enforce-kept,
615
+ // frozen-blocked, cross-task-deletion, root-cause lands here with its
616
+ // origin, and the final integration gate re-checks each one at run end.
617
+ recordDebt,
635
618
  recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
636
619
  // file → introducing task, the provenance half of the discriminator.
637
620
  introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
@@ -1,4 +1,4 @@
1
- import { type SpawnFn } from '../shared/child-process.js';
1
+ import type { SpawnFn } from '../shared/child-process.js';
2
2
  export interface GitStateSnapshot {
3
3
  /** false → not a usable git worktree; the guard is disabled for this run. */
4
4
  ok: boolean;
@@ -42,7 +42,7 @@ import { readFileSync } from 'node:fs';
42
42
  import * as fsp from 'node:fs/promises';
43
43
  import * as os from 'node:os';
44
44
  import * as path from 'node:path';
45
- import { runChildDefault } from '../shared/child-process.js';
45
+ import { makeGit } from '../shared/git-runner.js';
46
46
  import { isRegenerableArtifact } from './regenerable-artifacts.js';
47
47
  /** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
48
48
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
@@ -109,12 +109,6 @@ function isAlwaysRegenerable(relPath, ctCacheDirs) {
109
109
  return true;
110
110
  return ctCacheDirs.some(d => p === d || p.startsWith(d + '/'));
111
111
  }
112
- function makeGit(cwd, signal, spawnFn) {
113
- return async (args, env) => {
114
- const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
115
- return { stdout: r.stdout, exitCode: r.exitCode };
116
- };
117
- }
118
112
  /**
119
113
  * Snapshot the worktree content into a tree object via a THROWAWAY index file, so
120
114
  * neither the real index nor the stash is touched. Returns null when git cannot
@@ -5,11 +5,8 @@
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
7
  import { fetchFocused } from '../workers/fetch-core.js';
8
- import { formatNpmVersionSection } from '../workers/npm-version.js';
9
8
  import { runWorker } from '../workers/pi-worker-core.js';
10
9
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
11
- import { search as defaultSearch } from '../workers/search-core.js';
12
- import { extractEnrichTargets } from './enrichment.js';
13
10
  import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
14
11
  import { isIntegrationUnknown } from './unknown-routing.js';
15
12
  import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
@@ -20,8 +17,7 @@ import { buildOrientation, orientationTier } from './orientation.js';
20
17
  import { getConfig } from '../config/config.js';
21
18
  import { readFile } from 'node:fs/promises';
22
19
  import { resolve } from 'node:path';
23
- import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
24
- import { gatherExternalContext } from './external-context.js';
20
+ import { buildExternalContext, gatherExternalContext } from './external-context.js';
25
21
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
26
22
  import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
27
23
  import { applyRefutations } from './refuted-constraint.js';
@@ -1054,83 +1050,44 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
1054
1050
  const docsFocusedFn = autoDeps.docsFocused ?? docsFocused;
1055
1051
  const fetchFocusedFn = autoDeps.fetchFocused ?? fetchFocused;
1056
1052
  try {
1057
- const enrichTargets = extractEnrichTargets(question);
1058
- const allTargets = [
1059
- ...enrichTargets.packages.slice(0, 2).map(pkg => ({ kind: 'pkg', pkg })),
1060
- ...enrichTargets.urls
1061
- .slice(0, 2 - Math.min(enrichTargets.packages.length, 2))
1062
- .map(url => ({ kind: 'url', url }))
1063
- ];
1064
- const cappedTargets = allTargets.slice(0, 2);
1065
- const npmSections = [];
1066
- const docSections = [];
1067
- const searchFn = autoDeps.searchFn ?? defaultSearch;
1068
- const cappedServices = enrichTargets.services.slice(0, 2);
1069
- // Fan out doc/url focused workers and service searches in parallel —
1070
- // otherwise the user waits for max(docs, fetch) + search instead of
1071
- // max(docs, fetch, search) on every grill auto-answer with at least
1072
- // one service plus a package or url. Mirrors phaseResearch's pattern.
1073
- const [, serviceResults] = await Promise.all([
1074
- Promise.all(cappedTargets.map(async (t, idx) => {
1075
- if (t.kind === 'pkg') {
1076
- const r = await docsFocusedFn({
1077
- pkg: t.pkg,
1078
- query: question,
1079
- cwd: deps.cwd,
1080
- signal: deps.signal
1081
- }).catch(() => null);
1082
- if (r?.npmVersion) {
1083
- npmSections[idx] = formatNpmVersionSection(r.npmVersion);
1084
- }
1085
- if (r?.answer) {
1086
- docSections[idx] = `### docs: ${t.pkg}\n${r.answer}`;
1087
- }
1088
- }
1089
- else {
1090
- const r = await fetchFocusedFn({
1091
- url: t.url,
1092
- query: question,
1093
- cwd: deps.cwd,
1094
- signal: deps.signal
1095
- }).catch(() => null);
1096
- if (r?.answer) {
1097
- docSections[idx] = `### url: ${t.url}\n${r.answer}`;
1098
- }
1099
- }
1100
- })),
1101
- Promise.all(cappedServices.map(s => searchFn({
1102
- query: `${s.name} ${s.query}`,
1103
- count: 3,
1104
- signal: deps.signal
1105
- }).catch(() => null)))
1106
- ]);
1107
- const serviceSections = [];
1108
- const skipped = [];
1109
- for (let i = 0; i < cappedServices.length; i++) {
1110
- const s = cappedServices[i];
1111
- const r = serviceResults[i];
1112
- if (r === null)
1113
- continue;
1114
- if (r.kind === 'no_key') {
1115
- skipped.push(s.name);
1116
- continue;
1117
- }
1118
- if (r.kind === 'error')
1119
- continue;
1120
- serviceSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
1121
- }
1122
- if (skipped.length > 0) {
1123
- serviceSections.push(formatFreshnessSkippedBlock(skipped));
1124
- }
1125
- // npm blocks lead so the model anchors on live version data first.
1126
- const contextSections = [
1127
- ...npmSections.filter(Boolean),
1128
- ...docSections.filter(Boolean),
1129
- ...serviceSections
1130
- ];
1131
- const externalContext = contextSections.length > 0 ?
1132
- `EXTERNAL CONTEXT\n${contextSections.join('\n\n')}\n\n`
1133
- : '';
1053
+ // Same assembly as the research phase (see external-context.ts); what
1054
+ // differs is POLICY and the worker variant, and both are arguments now.
1055
+ // The caps exist because this runs per grill question in front of a
1056
+ // waiting user; there is deliberately no version-lookup fan-out, no body
1057
+ // truncation (the focused child already answers in a paragraph) and no
1058
+ // timing sub-step here.
1059
+ //
1060
+ // `groundingBodies` counts the doc/url bodies that made it into the block:
1061
+ // the surviving-unknown routing below asks "did any doc/fetch worker
1062
+ // produce a grounding section?", which is exactly this and nothing about
1063
+ // npm-version or service blocks.
1064
+ let groundingBodies = 0;
1065
+ const countBody = (body) => {
1066
+ if (body !== undefined)
1067
+ groundingBodies += 1;
1068
+ return body;
1069
+ };
1070
+ const externalContext = await buildExternalContext(question, deps, {
1071
+ docs: async (pkg) => {
1072
+ const r = await docsFocusedFn({
1073
+ pkg,
1074
+ query: question,
1075
+ cwd: deps.cwd,
1076
+ signal: deps.signal
1077
+ });
1078
+ return { npmVersion: r.npmVersion, body: countBody(r.answer || undefined) };
1079
+ },
1080
+ url: async (url) => {
1081
+ const r = await fetchFocusedFn({
1082
+ url,
1083
+ query: question,
1084
+ cwd: deps.cwd,
1085
+ signal: deps.signal
1086
+ });
1087
+ return { body: countBody(r.answer || undefined) };
1088
+ },
1089
+ search: autoDeps.searchFn
1090
+ }, { targetCap: 2, serviceCap: 2 });
1134
1091
  const basePrompt = externalContext + GRILL_AUTO_ANSWER_PROMPT(refined, research, question);
1135
1092
  let text = await runPhaseChild(deps, 'grill-auto', 'read', basePrompt);
1136
1093
  if (!autoAnswerHasTag(text)) {
@@ -1192,7 +1149,7 @@ export async function phaseAutoAnswer(deps, refined, research, question, autoDep
1192
1149
  // refuse the guess and surface it to the user — carrying the model's
1193
1150
  // best-effort answer as the pre-filled recommendation so the user accepts
1194
1151
  // it with one keystroke or overrides it. Benign unknowns are untouched.
1195
- const docResolved = docSections.filter(Boolean).length > 0;
1152
+ const docResolved = groundingBodies > 0;
1196
1153
  if (parsed.kind === 'answered' && !docResolved && isIntegrationUnknown(question)) {
1197
1154
  deps.logDebug?.(`grill-auto: integration unknown unresolved by fetch — surfacing to user `
1198
1155
  + `instead of auto-answering: ${question.replace(/\s+/g, ' ').slice(0, 120)}`);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * run-final-gate — the RUN-LEVEL final integration gate, the twin of task-gates.ts.
3
+ *
4
+ * task-gates.ts gates ONE task against its own spec. This gates the WHOLE REPO once,
5
+ * when every task in a /task-auto run is checked off and BEFORE the run is declared
6
+ * complete: every task passed its own per-slice gates, but per-slice green has shipped
7
+ * a dead app twice (mx5 runs 3 & 5 — statics clean, every protected route 500ing). So
8
+ * the project's OWN whole-repo commands run once here, unaided.
9
+ *
10
+ * The stage owns four things the per-task gate has no equivalent of:
11
+ *
12
+ * 1. THREE verdicts, not two — PASS / FAIL / UNOBSERVED. A gate that observed
13
+ * nothing dynamic is never announced as a pass (final-gate.ts unobservedVerdict).
14
+ * 2. The run-end ACCEPT-debt report — every defect a task was allowed to ship with,
15
+ * surfaced at the last moment anyone will look, and RE-DERIVED against the final
16
+ * tree after a converged autofix so the last word is about the tree that shipped.
17
+ * 3. The resolution loop — Leave-failed (recommended) / Autofix (a bounded,
18
+ * model-driven fix pass + gate re-run) / Accept, with the autofix card withdrawn
19
+ * after MAX_FINAL_GATE_AUTOFIX so a non-converging fix pass cannot loop forever.
20
+ * 4. The stranded sub-fixes a non-converging fix pass leaves in the working tree —
21
+ * committed on EVERY terminal outcome, because both of them end the run and the
22
+ * next `git checkout` would destroy real repairs (mx5 run 14: 13 of them).
23
+ *
24
+ * Every terminal outcome is RETURNED, never announced here — same contract as
25
+ * {@link runGatesForTask}'s GateResult: the caller owns the parent task file's state
26
+ * and its own resume wording. Nothing in this module touches per-task loop state, so
27
+ * a resume simply re-enters it and re-runs the gate.
28
+ */
29
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
30
+ import type { CommitResult } from './auto-commit.js';
31
+ import type { FinalGateOutcome } from './final-gate.js';
32
+ import type { FinalGateFixFn } from './gate-deps.js';
33
+ import { type AcceptDebt } from './accept-debt.js';
34
+ /**
35
+ * The seams this stage drives. A strict subset of what /task-auto builds, and
36
+ * deliberately narrow: every optional dep absent degrades to a documented earlier
37
+ * behaviour, so a test supplies only the ones its scenario is about.
38
+ */
39
+ export interface FinalGateStageDeps {
40
+ /**
41
+ * Whole-repo FINAL integration gate: the project's own static checks plus its own
42
+ * test/build commands, unaided (see final-gate.ts). Absent (tests / gate off) →
43
+ * the run completes without one, as it did before the gate existed.
44
+ */
45
+ finalGate?: (cwd: string, planText?: string) => Promise<FinalGateOutcome>;
46
+ /**
47
+ * Bounded model-driven fix pass for a FAIL (see final-gate-fix.ts), offered as the
48
+ * picker's third option. Runs the fix child, applies the command-shrink guard, and
49
+ * re-runs the gate; the result's `ok` means the gate now passes. Absent (tests / no
50
+ * fix wiring) → the picker keeps only Leave-failed / Accept, exactly the
51
+ * pre-autofix behavior.
52
+ */
53
+ finalGateFix?: FinalGateFixFn;
54
+ /**
55
+ * Paths currently uncommitted in the working tree (`git status` shape), used to
56
+ * detect SUB-FIXES a non-converging fix pass left behind (mx5 run 13 PROMPT 4 item
57
+ * 3). Every task is committed by the time this stage runs, so anything dirty here
58
+ * is the fix pass's own work. Absent → the stranded-fix handling is skipped
59
+ * entirely (prior behavior).
60
+ */
61
+ pendingChanges?: (cwd: string) => Promise<string[]>;
62
+ /**
63
+ * Re-derive the still-open ACCEPT-debt ledger against the tree AS IT IS NOW
64
+ * (final-gate.ts `deriveOpenDebts`). Needed because the run's "N recorded
65
+ * verify-FAIL defect(s) are STILL unresolved" report used to be built from the
66
+ * FIRST gate result and the converged-autofix path then rebuilt the gate outcome
67
+ * as a bare `{ok, reason}` — so `openDebts` was not merely un-actioned, it was GONE
68
+ * from the value, and no code path could ever clear, re-check or act on it (mx5 run
69
+ * 18: four defects reported STILL OPEN at 14:58, one of them fixed by the autofix
70
+ * that converged at 15:03, and the report never moved).
71
+ *
72
+ * `staticOk` is the caller's PROOF about the current statics, never a guess: it is
73
+ * passed true only where the gate itself just passed them. Absent (tests) → the
74
+ * post-autofix re-check is skipped and the pre-autofix report stands, exactly the
75
+ * prior behavior.
76
+ */
77
+ recheckOpenDebts?: (cwd: string, staticOk: boolean) => Promise<{
78
+ openDebts: AcceptDebt[];
79
+ debtNote?: string;
80
+ trail?: string[];
81
+ }>;
82
+ /** Snapshot the working tree into one commit — used for a converged autofix and
83
+ * for the stranded sub-fixes either terminal outcome would otherwise abandon. */
84
+ commit: (cwd: string, message: string) => Promise<CommitResult>;
85
+ /** Append one line to the RUN's durable gate trail (`## gates` on the parent task
86
+ * file) — the same auditability contract the per-task records carry. Best-effort:
87
+ * absent in tests → skipped; a failure never breaks the gate. */
88
+ record?: (cwd: string, taskId: string, line: string) => Promise<void>;
89
+ }
90
+ /** Inputs that vary per caller. */
91
+ export interface FinalGateStageParams {
92
+ cwd: string;
93
+ /** The parent /task-auto id: trail target, notify prefix, commit-message tag. */
94
+ runId: string;
95
+ /** The parent plan (the task list), handed to the gate so it can tell a served app
96
+ * from a CLI — the boot check requires a listener only for the former (mx5 run 10:
97
+ * a CSS watcher satisfied "still alive"). */
98
+ planText: string;
99
+ /** How many tasks the run completed — for the completion announcement only. */
100
+ taskCount: number;
101
+ }
102
+ /**
103
+ * How the run ended. The caller announces each one with its own resume wording and
104
+ * owns the parent task file's state — `cancelled` changes no state (a resume
105
+ * re-enters this stage and runs the gate then), `failed` marks the run failed,
106
+ * `completed` marks it complete.
107
+ */
108
+ export type FinalGateStageResult = {
109
+ kind: 'cancelled';
110
+ message: string;
111
+ } | {
112
+ kind: 'failed';
113
+ message: string;
114
+ } | {
115
+ kind: 'completed';
116
+ message: string;
117
+ level: 'info' | 'warning';
118
+ };
119
+ /**
120
+ * Run the whole-repo gate and resolve its verdict with the user.
121
+ *
122
+ * Lifted verbatim out of /task-auto's run loop, which it never shared state with:
123
+ * the stage reads no per-task variable and writes none. Never throws for a gate
124
+ * outcome — only a user cancel inside a gate child propagates (the caller's
125
+ * USER_CANCELLED path handles it).
126
+ */
127
+ export declare function runFinalGateStage(active: ExtensionCommandContext, deps: FinalGateStageDeps, p: FinalGateStageParams): Promise<FinalGateStageResult>;