@mjasnikovs/pi-task 0.18.23 → 0.18.25

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.
@@ -33,6 +33,7 @@ export interface AutoDeps extends GateDeps {
33
33
  finalGate?: (cwd: string, planText?: string) => Promise<{
34
34
  ok: boolean;
35
35
  reason: string;
36
+ failures?: string[];
36
37
  debtNote?: string;
37
38
  openDebts?: AcceptDebt[];
38
39
  }>;
@@ -5,6 +5,7 @@
5
5
  * This module currently holds the planning half (AutoDeps + planAuto). The run
6
6
  * loop, command handlers, and defaultDeps are added by the next task.
7
7
  */
8
+ import { existsSync } from 'node:fs';
8
9
  import * as fsp from 'node:fs/promises';
9
10
  import * as path from 'node:path';
10
11
  import { gateRunTask, markResumable } from './orchestrator.js';
@@ -35,6 +36,7 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
35
36
  import { reconcileTitleSources } from './decompose-fidelity.js';
36
37
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
37
38
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
39
+ import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
38
40
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
39
41
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
40
42
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -493,6 +495,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
493
495
  catch {
494
496
  // best-effort channel
495
497
  }
498
+ // Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
499
+ // files the spec REFERENCES (server snippets, prose "serve the built
500
+ // index.html") that neither its file tree, its parsed build outputs, nor the
501
+ // existing scaffold produce. Sentence-grounded coverage credited the SERVING
502
+ // side and reported "0 unowned" while nothing ever CREATED the file — so
503
+ // these ride the coverage loop's `missing` list as unowned areas until some
504
+ // task title claims the artifact (grounded in titles, which the coverage-map
505
+ // model cannot fake — the run-12 lesson). Deterministic and best-effort.
506
+ let specDangling = [];
507
+ try {
508
+ specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
509
+ if (specDangling.length > 0) {
510
+ logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
511
+ + `spec: ${specDangling.map(d => d.path).join(', ')}`);
512
+ }
513
+ }
514
+ catch {
515
+ // best-effort channel
516
+ }
496
517
  // decompose
497
518
  const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
498
519
  // Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
@@ -589,6 +610,13 @@ export async function planAuto(ctx, cwd, feature, deps) {
589
610
  }
590
611
  }
591
612
  const missing = [...verdictMissing, ...(acc?.unmapped ?? []).map(e => `"${e.quote}"`)];
613
+ // Unclaimed dangling artifacts are unowned areas: they force a coverage
614
+ // round that assigns a producing task, and clear as soon as a title
615
+ // names the file.
616
+ for (const d of specDangling) {
617
+ if (!titlesCoverArtifact(titles, d))
618
+ missing.push(danglingMissingText(d));
619
+ }
592
620
  return {
593
621
  plan: { titles, covered, missing },
594
622
  accounting: acc,
@@ -707,12 +735,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
707
735
  const carriedCrossCutting = accounting?.crossCutting ?? [];
708
736
  const carriedUnmapped = accounting?.unmapped ?? [];
709
737
  const carriedJudge = best.judgeMissing;
710
- if (carriedCrossCutting.length > 0 || carriedUnmapped.length > 0 || carriedJudge.length > 0) {
711
- await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge);
738
+ // Dangling artifacts still unclaimed by any title of the SHIPPING plan are a
739
+ // fourth channel: the producing obligation travels verbatim into every task
740
+ // (whichever task builds the referencing side must also produce the file),
741
+ // and the final gate re-checks the shipped tree regardless.
742
+ const carriedDangling = specDangling
743
+ .filter(d => !titlesCoverArtifact(planTitles, d))
744
+ .map(danglingCarryText);
745
+ if (carriedCrossCutting.length > 0
746
+ || carriedUnmapped.length > 0
747
+ || carriedJudge.length > 0
748
+ || carriedDangling.length > 0) {
749
+ await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge, carriedDangling);
712
750
  const parts = [
713
751
  carriedCrossCutting.length > 0 ? `${carriedCrossCutting.length} cross-cutting` : '',
714
752
  carriedUnmapped.length > 0 ? `${carriedUnmapped.length} unowned` : '',
715
- carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : ''
753
+ carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : '',
754
+ carriedDangling.length > 0 ? `${carriedDangling.length} dangling-artifact` : ''
716
755
  ].filter(p => p.length > 0);
717
756
  ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
718
757
  + ' — see .pi-tasks/requirements.md.', 'info');
@@ -913,13 +952,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
913
952
  // Hand the parent plan (the task list) to the gate so it can tell a
914
953
  // served app from a CLI: the boot check requires a listener only for
915
954
  // the former (mx5 run 10 — a CSS watcher satisfied "still alive").
955
+ // Trail EVERY aggregated failure entry (mx5 run 13): the gate now
956
+ // runs all sections and ranks the list; a single sliced reason
957
+ // line would re-hide everything past the first entry.
958
+ const trailGateFail = async (f) => {
959
+ const list = f.failures ?? [f.reason];
960
+ if (list.length <= 1) {
961
+ await recGate(`final-gate: FAIL — ${f.reason.slice(0, 300)}`);
962
+ return;
963
+ }
964
+ await recGate(`final-gate: FAIL — ${list.length} failures (ranked, most load-bearing first)`);
965
+ for (const [i, entry] of list.entries()) {
966
+ await recGate(`final-gate FAIL ${i + 1}/${list.length}: ${entry.slice(0, 300)}`);
967
+ }
968
+ };
916
969
  let fin = await deps.finalGate(cwd, body);
917
970
  // Record the outcome symmetrically (mx5 run 10 item 7): only FAIL was
918
971
  // ever trailed, so a PASSing gate was indistinguishable from a gate
919
972
  // that never ran. The PASS reason names the commands that were run.
920
- await recGate(fin.ok ?
921
- `final-gate: PASS — ${fin.reason.slice(0, 300)}`
922
- : `final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
973
+ if (fin.ok) {
974
+ await recGate(`final-gate: PASS — ${fin.reason.slice(0, 300)}`);
975
+ }
976
+ else {
977
+ await trailGateFail(fin);
978
+ }
923
979
  // ACCEPT-debt re-check surfacing (mx5 run 4 B3 / run 8 TASK_0012):
924
980
  // tasks the user accepted despite a verify-FAIL that the gate could
925
981
  // not prove resolved against the current tree. Surface them at the
@@ -989,13 +1045,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
989
1045
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
990
1046
  // Work from the FRESH gate failure when the fix pass got
991
1047
  // as far as re-running the gate; otherwise keep the last.
992
- // The debt note is carried so the next picker still shows
993
- // the open claims (the seed never includes it).
1048
+ // The full ranked list rides along (and is re-trailed
1049
+ // when fresh) so the next picker and the next fix seed
1050
+ // still carry every entry, not just the first. The debt
1051
+ // note is carried so the next picker still shows the
1052
+ // open claims (the seed never includes it).
994
1053
  fin = {
995
1054
  ok: false,
996
1055
  reason: fix.gateReason ?? fin.reason,
1056
+ failures: fix.gateReason !== undefined ? fix.gateFailures : fin.failures,
997
1057
  debtNote: fin.debtNote
998
1058
  };
1059
+ if (fix.gateReason !== undefined
1060
+ && (fix.gateFailures?.length ?? 0) > 1) {
1061
+ await trailGateFail(fin);
1062
+ }
999
1063
  continue;
1000
1064
  }
1001
1065
  // Leave failed — the dismissal default, unchanged from the
@@ -42,7 +42,10 @@ export declare function extractFailingCommand(reason: string): string | null;
42
42
  /**
43
43
  * Build the fix child's prompt. Generic by construction: the only project facts
44
44
  * in it are the gate's own failure text — the command comes from the project's
45
- * discovered manifest, never from a hardcoded ecosystem.
45
+ * discovered manifest, never from a hardcoded ecosystem. The seed may carry
46
+ * SEVERAL failures (the gate aggregates every section since mx5 run 13, ranked
47
+ * most load-bearing first); convergence means the WHOLE list is empty, so the
48
+ * child is told to fix all of them.
46
49
  */
47
50
  export declare function buildFinalFixPrompt(failReason: string): string;
48
51
  /**
@@ -63,6 +66,9 @@ export interface FinalFixResult {
63
66
  /** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
64
67
  * picker (and next fix attempt) works from the current state, not the stale one. */
65
68
  gateReason?: string;
69
+ /** The fresh gate's individual ranked failures (see FinalGateOutcome.failures),
70
+ * so the caller can trail each entry — never just the first. */
71
+ gateFailures?: string[];
66
72
  }
67
73
  export interface FinalFixDeps {
68
74
  cwd: string;
@@ -72,10 +78,13 @@ export interface FinalFixDeps {
72
78
  failReason: string;
73
79
  /** Run the fix child; same closure shape the other gate children use. */
74
80
  runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
75
- /** Re-run the final integration gate — the only arbiter of convergence. */
81
+ /** Re-run the final integration gate — the only arbiter of convergence.
82
+ * Converges only when the gate's FULL aggregated failure list is empty
83
+ * (ok=true); `failures` rides through so the caller sees every entry. */
76
84
  gate: (cwd: string) => Promise<{
77
85
  ok: boolean;
78
86
  reason: string;
87
+ failures?: string[];
79
88
  }>;
80
89
  /** Labels of every currently-discoverable gate command (static + integration),
81
90
  * for the shrink guard. Pure discovery — nothing is executed. */
@@ -96,19 +96,24 @@ export function extractFailingCommand(reason) {
96
96
  /**
97
97
  * Build the fix child's prompt. Generic by construction: the only project facts
98
98
  * in it are the gate's own failure text — the command comes from the project's
99
- * discovered manifest, never from a hardcoded ecosystem.
99
+ * discovered manifest, never from a hardcoded ecosystem. The seed may carry
100
+ * SEVERAL failures (the gate aggregates every section since mx5 run 13, ranked
101
+ * most load-bearing first); convergence means the WHOLE list is empty, so the
102
+ * child is told to fix all of them.
100
103
  */
101
104
  export function buildFinalFixPrompt(failReason) {
102
105
  return [
103
106
  'You are a bounded fix pass for a FAILED whole-repo integration gate.',
104
107
  'Every task in this run is complete and committed; then the project’s own',
105
- 'integration command was run against the assembled repository and failed:',
108
+ 'integration commands were run against the assembled repository and failed:',
106
109
  '',
107
110
  failReason.trim(),
108
111
  '',
109
- 'Your ONLY job is to make that command pass by fixing the DEFECT it reveals.',
112
+ 'Your ONLY job is to fix the DEFECT(s) those failures reveal. When several',
113
+ 'failures are listed they are ranked most load-bearing first — fix ALL of',
114
+ 'them; the gate only converges when every one passes.',
110
115
  '',
111
- '1. Re-run the exact failing command first and read its full output.',
116
+ '1. Re-run each exact failing command first and read its full output.',
112
117
  '2. Diagnose the root cause, then fix it with the smallest correct change.',
113
118
  ' The project’s own manifests, configs and conventions define what',
114
119
  ' correct means — follow them, do not invent new structure.',
@@ -129,9 +134,9 @@ export function buildFinalFixPrompt(failReason) {
129
134
  ' revert, stash, clean). The work in this repository is finished and',
130
135
  ' committed — reverting it is destroying the run, not fixing it.',
131
136
  '',
132
- '4. Re-run the failing command after your fix and confirm it exits 0. The',
133
- ' gate is re-run mechanically after you finish — your claim is not the',
134
- ' verdict, the real exit code is.',
137
+ '4. Re-run the failing command(s) after your fix and confirm they exit 0.',
138
+ ' The gate is re-run mechanically after you finish — your claim is not',
139
+ ' the verdict, the real exit codes are.',
135
140
  '',
136
141
  'End with exactly one line:',
137
142
  ' FINAL-GATE-FIX: DONE',
@@ -240,7 +245,8 @@ export async function runFinalGateAutofix(deps) {
240
245
  return {
241
246
  ok: false,
242
247
  reason: `did not converge: ${fin.reason}`,
243
- gateReason: fin.reason
248
+ gateReason: fin.reason,
249
+ gateFailures: fin.failures
244
250
  };
245
251
  }
246
252
  return { ok: true, reason: fin.reason };
@@ -6,14 +6,25 @@ export interface FinalGateOutcome {
6
6
  /** true → statics and every runnable integration command passed (or nothing to run). */
7
7
  ok: boolean;
8
8
  /**
9
- * On a fail: the exact command, its exit code, and the tail of its output — the
10
- * MECHANICAL failure only. The accept-debt note is deliberately NOT folded in
9
+ * On a fail: the exact command(s), exit code(s), and output tail(s) — the
10
+ * MECHANICAL failures only. The accept-debt note is deliberately NOT folded in
11
11
  * here (mx5 run 11): this string seeds the final-gate AUTOFIX child's prompt,
12
12
  * and a debt included there is read as an instruction — the run-11 fix child
13
13
  * `rm`'d a sibling task's verified deliverable to satisfy a recorded claim. The
14
14
  * child cannot act on text it never receives; debts travel in `debtNote`.
15
+ * With multiple failures this is the numbered, ranked list (see `failures`).
15
16
  */
16
17
  reason: string;
18
+ /**
19
+ * On a fail: EVERY section failure individually, ranked most load-bearing
20
+ * first — boot/render ("the app does not serve/render") outranks any single
21
+ * test failure. The gate runs every section and aggregates rather than
22
+ * early-returning (mx5 run 13: a bun-test glob failure shadowed the boot +
23
+ * render probe, so the user accepted the FAIL having only ever seen 1 failing
24
+ * CT test while the shipped app 404'd on every non-API GET). Callers trail
25
+ * each entry and show the full list wherever an ACCEPT decision is made.
26
+ */
27
+ failures?: string[];
17
28
  /**
18
29
  * Human-facing suffix listing the still-open accepted-defect claims (see
19
30
  * buildAcceptDebtNote) — for the picker question and the trail, NEVER for the
@@ -146,6 +157,16 @@ export { taskThatIntroduced };
146
157
  * Run the final gate: static analysis first, then the lockfile consistency
147
158
  * checks, then the discovered integration commands, then one boot exercise of
148
159
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
149
- * First real failure wins.
160
+ *
161
+ * EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
162
+ * early-return on the first failing section, and the boot + render probe — built
163
+ * after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
164
+ * earlier failure shadowed the most load-bearing signal. Run 13's user accepted
165
+ * the FAIL having seen only 1 failing CT test while the app 404'd on every
166
+ * non-API GET; boot/render never executed in any attempt. Now the outcome
167
+ * carries the full ranked failure list (boot/render first — "the app does not
168
+ * serve/render" outranks any single test), the ACCEPT decision is made on all of
169
+ * it, and autofix converges only when the whole list is empty. Per-section
170
+ * env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
150
171
  */
151
172
  export declare function runFinalIntegrationGate(cwd: string, timeoutMs?: number, bootGraceMs?: number, bootDeps?: BootDeps, planText?: string): Promise<FinalGateOutcome>;
@@ -47,6 +47,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
47
47
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
48
48
  import { runRenderCheck } from './render-check.js';
49
49
  import { taskThatIntroduced } from './task-provenance.js';
50
+ import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
50
51
  function packageScripts(cwd) {
51
52
  try {
52
53
  const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -595,7 +596,17 @@ export { taskThatIntroduced };
595
596
  * Run the final gate: static analysis first, then the lockfile consistency
596
597
  * checks, then the discovered integration commands, then one boot exercise of
597
598
  * the start command — whole-repo, verbatim, unaided. Deterministic (no model).
598
- * First real failure wins.
599
+ *
600
+ * EVERY section runs and failures AGGREGATE (mx5 run 13): the gate used to
601
+ * early-return on the first failing section, and the boot + render probe — built
602
+ * after run 11 exactly for "app serves blank/nothing" — was ordered last, so any
603
+ * earlier failure shadowed the most load-bearing signal. Run 13's user accepted
604
+ * the FAIL having seen only 1 failing CT test while the app 404'd on every
605
+ * non-API GET; boot/render never executed in any attempt. Now the outcome
606
+ * carries the full ranked failure list (boot/render first — "the app does not
607
+ * serve/render" outranks any single test), the ACCEPT decision is made on all of
608
+ * it, and autofix converges only when the whole list is empty. Per-section
609
+ * env-gap/INFRA_GAP skip semantics and orphan-port recovery are unchanged.
599
610
  */
600
611
  export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGraceMs = 10_000, bootDeps = {}, planText) {
601
612
  const stat = runRepoHealthCheck(cwd);
@@ -630,8 +641,15 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
630
641
  ...(debtNote ? { debtNote } : {}),
631
642
  openDebts
632
643
  });
644
+ // Aggregated failures across ALL sections (mx5 run 13 — see the function doc).
645
+ // rank 0 = boot/render ("does not serve/render" is the most load-bearing
646
+ // signal); rank 1 = everything else, kept in execution order by stable sort.
647
+ const failures = [];
648
+ const fail = (text, rank = 1) => {
649
+ failures.push({ rank, text });
650
+ };
633
651
  if (!stat.ok)
634
- return withDebts({ ok: false, reason: `static checks: ${stat.reason}` });
652
+ fail(`static checks: ${stat.reason}`);
635
653
  // Launch-contract diff (mx5 run 10 item 4): the design declared `migrate`/`seed`
636
654
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
637
655
  // plan-time-extracted declared scripts against the manifest; a missing one is a
@@ -640,16 +658,13 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
640
658
  if (declared.length > 0) {
641
659
  const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
642
660
  if (missing.length > 0) {
643
- return withDebts({
644
- ok: false,
645
- reason: `launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`
646
- });
661
+ fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
647
662
  }
648
663
  }
649
664
  const lockCmds = discoverLockfileChecks(cwd);
650
665
  const { cmds } = discoverIntegrationCommands(cwd);
651
666
  const boot = discoverBootCommand(cwd);
652
- if (lockCmds.length === 0 && cmds.length === 0 && !boot) {
667
+ if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
653
668
  return withDebts({ ok: true, reason: 'no integration command found (statics passed)' });
654
669
  }
655
670
  const ran = [];
@@ -663,10 +678,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
663
678
  if (r.outcome === 'skip')
664
679
  continue;
665
680
  if (r.outcome === 'fail') {
666
- return withDebts({
667
- ok: false,
668
- reason: `${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
669
- });
681
+ fail(`${prefix}\`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
682
+ continue;
670
683
  }
671
684
  ran.push(label);
672
685
  }
@@ -685,7 +698,13 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
685
698
  if (declared.length > 0) {
686
699
  const covered = cmds.flatMap(([bin, args]) => (bin === 'bun' || bin === 'npm') && args[0] === 'run' && args[1] ? [args[1]] : []);
687
700
  const skippedLaunch = [];
701
+ // A declared script the manifest doesn't expose is already a launch-contract
702
+ // failure above; executing it too would double-report (pre-aggregation the
703
+ // contract diff early-returned, so this loop could assume presence).
704
+ const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
688
705
  for (const name of runnableDeclaredScripts(declared, covered)) {
706
+ if (!present.has(name.toLowerCase()))
707
+ continue;
689
708
  const cmd = ['bun', ['run', name]];
690
709
  const label = `${cmd[0]} ${cmd[1].join(' ')}`;
691
710
  const r = runGateCommand(cwd, cmd, Math.min(timeoutMs, 180_000), INFRA_GAP_OUTPUT_RE);
@@ -694,10 +713,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
694
713
  continue;
695
714
  }
696
715
  if (r.outcome === 'fail') {
697
- return withDebts({
698
- ok: false,
699
- reason: `launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`
700
- });
716
+ fail(`launch script: \`${label}\` exited ${r.status}${r.tail ? ` — ${r.tail}` : ''}`);
717
+ continue;
701
718
  }
702
719
  ran.push(label);
703
720
  }
@@ -714,6 +731,9 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
714
731
  }
715
732
  }
716
733
  }
734
+ // Boot + render ALWAYS runs (mx5 run 13): it is independent of test results by
735
+ // construction, and it carries the run's most load-bearing signal — earlier
736
+ // failures no longer shadow it. Its failures rank FIRST in the aggregate.
717
737
  if (boot) {
718
738
  const label = `${boot[0]} ${boot[1].join(' ')}`;
719
739
  const expectServer = detectsServedApp(cwd, planText);
@@ -734,21 +754,18 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
734
754
  b = await recoverOrphanPort(cwd, boot, b, bootGraceMs, bootDepsWithRender, expectServer);
735
755
  }
736
756
  if (b.outcome === 'fail') {
737
- return withDebts({ ok: false, reason: `boot check: \`${label}\` ${b.detail}` });
757
+ fail(`boot check: \`${label}\` ${b.detail}`, 0);
738
758
  }
739
- if (b.outcome === 'orphan-port') {
759
+ else if (b.outcome === 'orphan-port') {
740
760
  // Could not clear the port. Distinct HARNESS diagnosis, never a bare app
741
761
  // FAIL: name the port and (when known) the process squatting on it.
742
762
  const holder = b.port !== null ? (bootDeps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
743
763
  const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
744
764
  : b.port !== null ? ` — port ${b.port} is held by another process`
745
765
  : '';
746
- return withDebts({
747
- ok: false,
748
- reason: `boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`
749
- });
766
+ fail(`boot check: \`${label}\` could not bind: orphaned process / port already in use${who} (harness condition, not an app fault)`, 0);
750
767
  }
751
- if (b.outcome === 'pass') {
768
+ else if (b.outcome === 'pass') {
752
769
  ran.push(label);
753
770
  // A listener that served, but whose page could not be OBSERVED to render
754
771
  // (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
@@ -756,6 +773,37 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
756
773
  warnings.push(b.renderNote);
757
774
  }
758
775
  }
776
+ // Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
777
+ // reference with NO producer anywhere ships silently — the server read
778
+ // `Bun.file('dist/index.html')` while the build emitted only app.css +
779
+ // main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
780
+ // Deterministic scan of the shipped tree (literal refs only, positive
781
+ // producer evidence required — see artifact-closure.ts); each dangle is a
782
+ // ranked failure naming referencer + missing path. Rank 0: "the app cannot
783
+ // serve what it references" is the same load-bearing class as boot/render.
784
+ try {
785
+ for (const d of findDanglingArtifacts(cwd))
786
+ fail(danglingGateFailureText(d), 0);
787
+ }
788
+ catch {
789
+ // best-effort scan — a scanner fault must never break the gate
790
+ }
791
+ if (failures.length > 0) {
792
+ // Stable sort: boot/render (rank 0) leads, everything else keeps execution
793
+ // order. One failure keeps the exact single-failure wording; several become
794
+ // a numbered list so the trail, the ACCEPT picker, and the autofix seed all
795
+ // carry the complete ranked picture.
796
+ const texts = [...failures].sort((a, b) => a.rank - b.rank).map(f => f.text);
797
+ return withDebts({
798
+ ok: false,
799
+ reason: texts.length === 1 ?
800
+ texts[0]
801
+ : `${texts.length} failures (ranked, most load-bearing first):\n${texts
802
+ .map((t, i) => `${i + 1}. ${t}`)
803
+ .join('\n')}`,
804
+ failures: texts
805
+ });
806
+ }
759
807
  const warningNote = warnings.length > 0 ? ` — WARNING: ${warnings.join('; WARNING: ')}` : '';
760
808
  return withDebts({
761
809
  ok: true,
@@ -88,8 +88,13 @@ export declare function readRequirements(cwd: string): Promise<string>;
88
88
  * was warned-about then dropped). These are plain strings, not quotes of the
89
89
  * source; marked distinctly so a task can tell an inferred area from a verbatim
90
90
  * obligation.
91
+ * • `danglingArtifacts` — runtime files the spec references but nothing
92
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
93
+ * build output ever created), still unclaimed by any title at coverage
94
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
95
+ * judge areas they are host-authored strings, not source quotes.
91
96
  */
92
- export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[]): Promise<void>;
97
+ export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[], danglingArtifacts?: string[]): Promise<void>;
93
98
  /**
94
99
  * The read-only block refine/compose receive when carried requirements exist.
95
100
  * Verbatim content travels with every task (the directive pattern that works),
@@ -320,10 +320,19 @@ function formatEntry(e, marker) {
320
320
  * was warned-about then dropped). These are plain strings, not quotes of the
321
321
  * source; marked distinctly so a task can tell an inferred area from a verbatim
322
322
  * obligation.
323
+ * • `danglingArtifacts` — runtime files the spec references but nothing
324
+ * produces (mx5 run 13: the served `index.html` no task, tree entry, or
325
+ * build output ever created), still unclaimed by any title at coverage
326
+ * exhaustion. Deterministically extracted (artifact-closure.ts), so like
327
+ * judge areas they are host-authored strings, not source quotes.
323
328
  */
324
- export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = []) {
325
- if (crossCutting.length === 0 && unresolved.length === 0 && judgeFlagged.length === 0)
329
+ export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = [], danglingArtifacts = []) {
330
+ if (crossCutting.length === 0
331
+ && unresolved.length === 0
332
+ && judgeFlagged.length === 0
333
+ && danglingArtifacts.length === 0) {
326
334
  return;
335
+ }
327
336
  try {
328
337
  const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
329
338
  const seen = new Set(existing.map(l => {
@@ -337,6 +346,10 @@ export async function appendCarriedRequirements(cwd, crossCutting, unresolved =
337
346
  [
338
347
  judgeFlagged.map(q => ({ quote: q, anchor: '' })),
339
348
  'judge-flagged uncovered area, no task owns this — surfaced at plan time'
349
+ ],
350
+ [
351
+ danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
352
+ 'dangling runtime artifact, nothing produces it — surfaced at plan time'
340
353
  ]
341
354
  ]) {
342
355
  for (const e of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.23",
3
+ "version": "0.18.25",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",