@mjasnikovs/pi-task 0.38.10 → 0.38.12

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 (78) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +93 -116
  8. package/dist/task/boot-probe.d.ts +298 -0
  9. package/dist/task/boot-probe.js +806 -0
  10. package/dist/task/child-runner.d.ts +56 -25
  11. package/dist/task/child-runner.js +65 -31
  12. package/dist/task/child-status.d.ts +95 -0
  13. package/dist/task/child-status.js +99 -0
  14. package/dist/task/command-run.d.ts +36 -0
  15. package/dist/task/command-run.js +48 -1
  16. package/dist/task/command-watchdog.js +1 -1
  17. package/dist/task/context-usage.d.ts +4 -3
  18. package/dist/task/context-usage.js +4 -3
  19. package/dist/task/contracts.js +18 -35
  20. package/dist/task/deep-render-check.d.ts +47 -0
  21. package/dist/task/deep-render-check.js +110 -65
  22. package/dist/task/env-notes.d.ts +3 -3
  23. package/dist/task/env-notes.js +24 -35
  24. package/dist/task/final-gate-fix.d.ts +1 -1
  25. package/dist/task/final-gate-fix.js +1 -1
  26. package/dist/task/final-gate.d.ts +38 -432
  27. package/dist/task/final-gate.js +105 -1213
  28. package/dist/task/gate-child.d.ts +8 -10
  29. package/dist/task/gate-child.js +15 -19
  30. package/dist/task/gate-deps.d.ts +29 -0
  31. package/dist/task/gate-deps.js +192 -206
  32. package/dist/task/gate-tally.d.ts +189 -0
  33. package/dist/task/gate-tally.js +249 -0
  34. package/dist/task/implementation-turn.d.ts +201 -0
  35. package/dist/task/implementation-turn.js +263 -0
  36. package/dist/task/launch-contract.js +27 -43
  37. package/dist/task/launch-manifest.d.ts +5 -0
  38. package/dist/task/launch-manifest.js +21 -0
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +70 -297
  45. package/dist/task/phases.d.ts +18 -0
  46. package/dist/task/phases.js +4 -3
  47. package/dist/task/plan-orchestrator.js +34 -33
  48. package/dist/task/requirements.d.ts +1 -1
  49. package/dist/task/requirements.js +50 -66
  50. package/dist/task/root-cause-repair.js +20 -32
  51. package/dist/task/run-bracket.d.ts +75 -0
  52. package/dist/task/run-bracket.js +41 -0
  53. package/dist/task/stall-detector.d.ts +110 -0
  54. package/dist/task/stall-detector.js +159 -0
  55. package/dist/task/task-gates.d.ts +69 -0
  56. package/dist/task/task-gates.js +114 -90
  57. package/dist/task/verify-work.d.ts +53 -67
  58. package/dist/task/verify-work.js +15 -11
  59. package/dist/workers/docs-core.d.ts +0 -4
  60. package/dist/workers/docs-core.js +10 -34
  61. package/dist/workers/docs-project.js +3 -3
  62. package/dist/workers/docs-resolve.d.ts +18 -0
  63. package/dist/workers/docs-resolve.js +39 -0
  64. package/dist/workers/docs-retrieve.d.ts +13 -0
  65. package/dist/workers/docs-retrieve.js +17 -2
  66. package/dist/workers/fetch-core.d.ts +0 -4
  67. package/dist/workers/fetch-core.js +2 -5
  68. package/dist/workers/phantom-imports.d.ts +3 -3
  69. package/dist/workers/phantom-imports.js +16 -29
  70. package/dist/workers/pi-worker-docs.d.ts +49 -0
  71. package/dist/workers/pi-worker-docs.js +33 -9
  72. package/dist/workers/pi-worker-fetch.d.ts +18 -0
  73. package/dist/workers/pi-worker-fetch.js +19 -4
  74. package/dist/workers/single-read-extension.d.ts +1 -1
  75. package/dist/workers/single-read-extension.js +5 -4
  76. package/dist/workers/single-read-guard.d.ts +32 -10
  77. package/dist/workers/single-read-guard.js +67 -16
  78. package/package.json +1 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The final gate's tally: everything its sections RECORD, and the one pure
3
+ * function that turns the record into a `FinalGateOutcome`.
4
+ *
5
+ * `runFinalIntegrationGate` used to carry this as twelve mutable locals — the
6
+ * ranked failure list, four dynamic counters, three note lists, a warning list,
7
+ * the boot verdict — threaded through ~400 lines by closure, with three sections
8
+ * hand-incrementing the same counters and one branch doing `dynObserved -= 1`.
9
+ * The verdict assembly at the end was pure, but reachable only through a temp
10
+ * dir and real spawns. Here the sections call named methods (an attempt is
11
+ * `attempted(bin)`, the config-gap un-count is `unobserve()`, a probe that looked
12
+ * and saw something bad is `failObserved(...)`) and `verdict()` is the ONE place
13
+ * the PASS / FAIL / UNOBSERVED polarity, note ordering and debt attachment live,
14
+ * testable with no tree at all.
15
+ *
16
+ * The two verdict predicates the gate has always exported — `observabilityGapFailure`
17
+ * (run-16 full-blindness FAIL) and `unobservedVerdict` (the third, non-blocking
18
+ * verdict) — live here because they read exactly the counters the tally owns;
19
+ * `final-gate.ts` re-exports them so every existing importer is unchanged.
20
+ */
21
+ import type { AcceptDebt } from './accept-debt.js';
22
+ import type { FinalGateOutcome } from './final-gate.js';
23
+ /**
24
+ * Full-skip blindness guard (mx5 run 16). Per-command env-gap skips stay
25
+ * legitimate (a missing browser must not fail a suite); what may never happen
26
+ * again is ALL of them skipping while the gate still reports PASS — a gate that
27
+ * observed nothing dynamic has no basis to vouch for the assembled app. Pure so
28
+ * the semantics are unit-tested; the tally feeds it its own counters plus runner
29
+ * resolvability.
30
+ */
31
+ export declare function observabilityGapFailure(args: {
32
+ /** Dynamic commands the gate discovered and tried to run. */
33
+ attempted: number;
34
+ /** Of those, how many it actually OBSERVED (a real pass OR a real fail —
35
+ * either proves the command ran; only skips observe nothing). */
36
+ observed: number;
37
+ /** Of the skips, how many were SPAWN failures (runner never ran, ENOENT).
38
+ * Tool-level gaps (missing browser, 127 inside the chain, timeout) prove
39
+ * the runner itself works and keep the classic env-gap contract — the
40
+ * blindness class fires only when EVERY attempt failed to even spawn. */
41
+ spawnFailures: number;
42
+ /** Distinct runner bins across the attempted commands. */
43
+ runnerBins: string[];
44
+ /** Is this runner spawnable (bare or via a known install location)? */
45
+ runnerResolvable: (bin: string) => boolean;
46
+ }): string | null;
47
+ /**
48
+ * The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
49
+ * but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
50
+ * `attempted === 0`, and until now that silence fell straight through to
51
+ * `PASS — no integration command found (statics passed)`: the run-16 blindness class
52
+ * entering through a different door, where "we never checked" reads exactly like "we
53
+ * checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
54
+ * shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
55
+ * godot-engine (package.json whose only script is `verify`) reproduces it live today.
56
+ *
57
+ * So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
58
+ * existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
59
+ *
60
+ * WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
61
+ * the resolution):
62
+ * - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
63
+ * the runs with no dynamic evidence were exactly the runs already known to be
64
+ * carrying defects.
65
+ * - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
66
+ * gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
67
+ * integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
68
+ * missing signal was never the debt, it was the word PASS endorsing the run, and
69
+ * that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
70
+ * seed is `reason`; "no integration command is discoverable" is not fixable by
71
+ * editing code, so the highest-probability child response is to FABRICATE a
72
+ * runnable command to satisfy the gate — the same fabrication class that refuted
73
+ * the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
74
+ * run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
75
+ * means IAR1 and godot-engine can NEVER discover a command, so blocking would end
76
+ * every non-npm run in `failed` permanently, with no remedy — the task's own I3
77
+ * ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
78
+ * its stated consequence is to downgrade to a warning and say so. This is that.
79
+ * The teeth are elsewhere and are real: the verdict word changes, the gate trail says
80
+ * UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
81
+ * gate re-surfaces (it can never auto-close — it is not static-class).
82
+ */
83
+ export declare function unobservedVerdict(args: {
84
+ /** Dynamic commands the gate discovered and tried to run (0 ⇒ nothing existed). */
85
+ discovered: number;
86
+ /** Of those, how many actually RAN (a real pass or a real fail). */
87
+ observed: number;
88
+ }): string | null;
89
+ /** What `verdict()` attaches to every outcome, PASS or FAIL: the run's open
90
+ * ACCEPT debts, derived once before any section runs (deriveOpenDebts). */
91
+ export interface GateDebts {
92
+ openDebts: AcceptDebt[];
93
+ /** Human-facing note listing the open debts; absent/undefined when none. */
94
+ debtNote?: string;
95
+ }
96
+ export declare class GateTally {
97
+ private readonly failures;
98
+ /** Labels of the dynamic commands that ran AND passed — the PASS reason names them. */
99
+ private readonly passed;
100
+ private attempts;
101
+ private observations;
102
+ private spawnFailures;
103
+ private readonly bins;
104
+ private readonly warnings;
105
+ /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
106
+ * They ride in `unobserved`, not `warnings`, so the caller's existing
107
+ * `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
108
+ * never a PASS. */
109
+ private readonly configGapNotes;
110
+ /** The inert-launch-contract note (16A): "declared scripts, but no manifest to
111
+ * diff against" — a note, never a failure, never a silent pass. */
112
+ private readonly contractNotes;
113
+ /** The boot section's own UNOBSERVED verdict (bootSkipVerdict / rejected launch
114
+ * script). Lives outside the dynamic counters ON PURPOSE (mx5 run 18): the
115
+ * test/build commands that did run cannot cancel it. */
116
+ private bootNote;
117
+ /** A failure at `rank` (default 1). Rank 0 is boot/render, the most load-bearing. */
118
+ fail(text: string, rank?: number): void;
119
+ /**
120
+ * A failure a PROBE returned after observing (nexttask 19A — see
121
+ * FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
122
+ * section, whose `fail` outcome can only arise from a probe that looked. Every
123
+ * other `fail()` keeps its class, so nothing else changes.
124
+ */
125
+ failObserved(text: string, rank?: number): void;
126
+ /** A dynamic command that ran and passed; the PASS reason lists these. */
127
+ ran(label: string): void;
128
+ /** A dynamic spawn was attempted through runner `bin` (counted whether or not
129
+ * it then skips). */
130
+ attempted(bin: string): void;
131
+ /** The attempt was OBSERVED — a real pass or a real fail; only skips observe nothing. */
132
+ observed(): void;
133
+ /**
134
+ * Un-count one observation. The config-gap branch (mx5 run 20): a launch
135
+ * script failed, the four static conditions plus the placeholder re-run said
136
+ * the failure was a missing env variable the shipped template declares, and
137
+ * so NOTHING about that script was observed — the real run could not reach
138
+ * it and the probe run is a diagnostic, never an observation. It un-counts,
139
+ * exactly like a skip would have.
140
+ */
141
+ unobserve(): void;
142
+ /** The attempt through `bin` never even spawned (ENOENT-class), as opposed to a
143
+ * tool-level env gap inside the chain. */
144
+ spawnFailure(bin: string): void;
145
+ /** A WARNING appended to a PASS reason (excuse-note-covered skip, render note). */
146
+ warn(line: string): void;
147
+ configGap(note: string): void;
148
+ contractNote(note: string): void;
149
+ /** The boot section's UNOBSERVED verdict, or null when the boot was observed. */
150
+ bootUnobserved(note: string | null): void;
151
+ /** True while nothing dynamic has been attempted and nothing has failed —
152
+ * the state the zero-discovery return checks (see runFinalIntegrationGate). */
153
+ silent(): boolean;
154
+ /** The run-16 blindness guard over this tally's own counters (see
155
+ * observabilityGapFailure); the caller fails it at rank 0. */
156
+ blindness(runnerResolvable: (bin: string) => boolean): string | null;
157
+ /**
158
+ * The verdict. Pure over the tally's state; the same debts ride on every shape.
159
+ *
160
+ * FAIL when anything failed: stable-sorted so boot/render (rank 0) leads and
161
+ * everything else keeps execution order. One failure keeps the exact
162
+ * single-failure wording; several become a numbered list so the trail, the
163
+ * ACCEPT picker, and the autofix seed all carry the complete ranked picture.
164
+ * The observed subset rides along by exact text identity (19A) — the demote
165
+ * decision downstream reads THIS, instead of re-deriving observability from
166
+ * the failure string.
167
+ *
168
+ * Otherwise `ok: true`, with the UNOBSERVED note when the gate could not
169
+ * observe something it meant to. Two independent notes, either or both of which
170
+ * may apply: the boot never ran (run 18), and/or NOTHING dynamic ran at all
171
+ * (unobservedVerdict — commands WERE discovered, none spawn-failed so the run-16
172
+ * guard correctly stayed silent, and yet nothing ran: that used to be `statics
173
+ * passed (integration commands not runnable here)`, the identical "we never
174
+ * checked" silence wearing different words). The boot note leads because it
175
+ * names a concrete command and the trail line is sliced at 300 chars; then the
176
+ * config-gap notes, then the inert-contract note. Unchanged when anything at all
177
+ * was observed, so a project with runnable commands is byte-for-byte unaffected.
178
+ *
179
+ * ZERO ATTEMPTS IS UNOBSERVED, NEVER A PASS: when nothing dynamic was even
180
+ * attempted, the note IS the reason — there is no `statics passed (…)` suffix,
181
+ * because "we never checked" must not read like "we checked and it was fine".
182
+ * IAR1 shipped that verdict TWICE while carrying open verify-FAIL debt.
183
+ *
184
+ * The debt note rides in its OWN field: `reason` stays the mechanical failure
185
+ * because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
186
+ * run 11's fix child executed a recorded claim as an instruction).
187
+ */
188
+ verdict(debts: GateDebts): FinalGateOutcome;
189
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Full-skip blindness guard (mx5 run 16). Per-command env-gap skips stay
3
+ * legitimate (a missing browser must not fail a suite); what may never happen
4
+ * again is ALL of them skipping while the gate still reports PASS — a gate that
5
+ * observed nothing dynamic has no basis to vouch for the assembled app. Pure so
6
+ * the semantics are unit-tested; the tally feeds it its own counters plus runner
7
+ * resolvability.
8
+ */
9
+ export function observabilityGapFailure(args) {
10
+ if (args.attempted === 0 || args.observed > 0)
11
+ return null;
12
+ if (args.spawnFailures < args.attempted)
13
+ return null;
14
+ const unresolvable = args.runnerBins.filter(b => !args.runnerResolvable(b));
15
+ const runnerNote = unresolvable.length > 0 ?
16
+ ` — the project's own runner ${unresolvable
17
+ .map(b => `\`${b}\``)
18
+ .join(', ')} is not spawnable here (not on PATH nor any known install location)`
19
+ : '';
20
+ return (`observability gap: ${args.attempted} integration/boot command(s) exist but NONE `
21
+ + `could even spawn in this environment${runnerNote}; `
22
+ + `the gate observed nothing dynamic and cannot vouch for the assembled app`);
23
+ }
24
+ /**
25
+ * The THIRD verdict. observabilityGapFailure above covers "commands were DISCOVERED
26
+ * but every one failed to spawn" — a rank-0 FAIL. It deliberately returns null for
27
+ * `attempted === 0`, and until now that silence fell straight through to
28
+ * `PASS — no integration command found (statics passed)`: the run-16 blindness class
29
+ * entering through a different door, where "we never checked" reads exactly like "we
30
+ * checked and it was fine". Measured 2026-07-27: IAR1 (C++/CMake, no package.json)
31
+ * shipped that verdict TWICE while carrying 2 and 3 open verify-FAIL debts, and
32
+ * godot-engine (package.json whose only script is `verify`) reproduces it live today.
33
+ *
34
+ * So: observed anything dynamic ⇒ PASS; discovered-but-all-spawn-failed ⇒ the
35
+ * existing FAIL; observed NOTHING ⇒ this note, carried on an `ok: true` outcome.
36
+ *
37
+ * WHY NON-BLOCKING (decided, not deferred — the evidence cuts both ways and this is
38
+ * the resolution):
39
+ * - Blocking's case: both real occurrences also carried open verify-FAIL debt, so
40
+ * the runs with no dynamic evidence were exactly the runs already known to be
41
+ * carrying defects.
42
+ * - Against, and decisive: (1) that debt is ALREADY surfaced unconditionally at the
43
+ * gate moment, on PASS as on FAIL — the IAR1 records literally read "PASS — no
44
+ * integration command found … UNRESOLVED VERIFY-FAIL DEBT still open (2)". The
45
+ * missing signal was never the debt, it was the word PASS endorsing the run, and
46
+ * that is what this fixes. (2) `ok: false` routes into the autofix picker, whose
47
+ * seed is `reason`; "no integration command is discoverable" is not fixable by
48
+ * editing code, so the highest-probability child response is to FABRICATE a
49
+ * runnable command to satisfy the gate — the same fabrication class that refuted
50
+ * the `## verified tooling` harvest (see discoverIntegrationCommands) and that had
51
+ * run 11's fix child `rm` a sibling's deliverable. (3) That harvest being refuted
52
+ * means IAR1 and godot-engine can NEVER discover a command, so blocking would end
53
+ * every non-npm run in `failed` permanently, with no remedy — the task's own I3
54
+ * ("show blocking does not block IAR1/godot post-Task-1") is unsatisfiable, and
55
+ * its stated consequence is to downgrade to a warning and say so. This is that.
56
+ * The teeth are elsewhere and are real: the verdict word changes, the gate trail says
57
+ * UNOBSERVED, and the caller records a durable final-gate debt that the NEXT run's
58
+ * gate re-surfaces (it can never auto-close — it is not static-class).
59
+ */
60
+ export function unobservedVerdict(args) {
61
+ if (args.observed > 0)
62
+ return null;
63
+ // Kept short ON PURPOSE: the run-level trail line slices the reason at 300 chars,
64
+ // and the whole point of this verdict is that the durable record carries it.
65
+ const why = args.discovered === 0 ?
66
+ 'no integration, lockfile or boot command was discoverable here, so the gate ran '
67
+ + 'nothing at all'
68
+ : `all ${args.discovered} discovered command(s) skipped as environment gaps, so the `
69
+ + 'gate ran nothing observable';
70
+ return (`UNOBSERVED — NOT a pass: ${why}; statics passed, but this run produced NO evidence `
71
+ + 'that the assembled product builds, boots or works.');
72
+ }
73
+ export class GateTally {
74
+ // Aggregated failures across ALL sections (mx5 run 13 — see runFinalIntegrationGate's
75
+ // doc). rank 0 = boot/render ("does not serve/render" is the most load-bearing
76
+ // signal); rank 1 = everything else, kept in execution order by stable sort.
77
+ failures = [];
78
+ /** Labels of the dynamic commands that ran AND passed — the PASS reason names them. */
79
+ passed = [];
80
+ // Full-skip blindness counters (mx5 run 16): every dynamic spawn counts an
81
+ // attempt; a real pass OR a real fail counts an observation; skips observe
82
+ // nothing. If everything discovered ends up skipped, observabilityGapFailure
83
+ // turns the silence into a rank-0 failure instead of a static-only PASS.
84
+ attempts = 0;
85
+ observations = 0;
86
+ spawnFailures = 0;
87
+ bins = new Set();
88
+ warnings = [];
89
+ /** UNOBSERVED notes for launch scripts reclassified as CONFIG GAPS (run 20).
90
+ * They ride in `unobserved`, not `warnings`, so the caller's existing
91
+ * `recordDebt(cwd, id, fin.unobserved, 'final-gate')` writes the debt —
92
+ * never a PASS. */
93
+ configGapNotes = [];
94
+ /** The inert-launch-contract note (16A): "declared scripts, but no manifest to
95
+ * diff against" — a note, never a failure, never a silent pass. */
96
+ contractNotes = [];
97
+ /** The boot section's own UNOBSERVED verdict (bootSkipVerdict / rejected launch
98
+ * script). Lives outside the dynamic counters ON PURPOSE (mx5 run 18): the
99
+ * test/build commands that did run cannot cancel it. */
100
+ bootNote = null;
101
+ /** A failure at `rank` (default 1). Rank 0 is boot/render, the most load-bearing. */
102
+ fail(text, rank = 1) {
103
+ this.failures.push({ rank, text });
104
+ }
105
+ /**
106
+ * A failure a PROBE returned after observing (nexttask 19A — see
107
+ * FinalGateOutcome.observedFailures). Used by exactly one caller: the boot
108
+ * section, whose `fail` outcome can only arise from a probe that looked. Every
109
+ * other `fail()` keeps its class, so nothing else changes.
110
+ */
111
+ failObserved(text, rank = 1) {
112
+ this.failures.push({ rank, text, observed: true });
113
+ }
114
+ /** A dynamic command that ran and passed; the PASS reason lists these. */
115
+ ran(label) {
116
+ this.passed.push(label);
117
+ }
118
+ /** A dynamic spawn was attempted through runner `bin` (counted whether or not
119
+ * it then skips). */
120
+ attempted(bin) {
121
+ this.attempts += 1;
122
+ this.bins.add(bin);
123
+ }
124
+ /** The attempt was OBSERVED — a real pass or a real fail; only skips observe nothing. */
125
+ observed() {
126
+ this.observations += 1;
127
+ }
128
+ /**
129
+ * Un-count one observation. The config-gap branch (mx5 run 20): a launch
130
+ * script failed, the four static conditions plus the placeholder re-run said
131
+ * the failure was a missing env variable the shipped template declares, and
132
+ * so NOTHING about that script was observed — the real run could not reach
133
+ * it and the probe run is a diagnostic, never an observation. It un-counts,
134
+ * exactly like a skip would have.
135
+ */
136
+ unobserve() {
137
+ this.observations -= 1;
138
+ }
139
+ /** The attempt through `bin` never even spawned (ENOENT-class), as opposed to a
140
+ * tool-level env gap inside the chain. */
141
+ spawnFailure(bin) {
142
+ this.spawnFailures += 1;
143
+ this.bins.add(bin);
144
+ }
145
+ /** A WARNING appended to a PASS reason (excuse-note-covered skip, render note). */
146
+ warn(line) {
147
+ this.warnings.push(line);
148
+ }
149
+ configGap(note) {
150
+ this.configGapNotes.push(note);
151
+ }
152
+ contractNote(note) {
153
+ this.contractNotes.push(note);
154
+ }
155
+ /** The boot section's UNOBSERVED verdict, or null when the boot was observed. */
156
+ bootUnobserved(note) {
157
+ this.bootNote = note;
158
+ }
159
+ /** True while nothing dynamic has been attempted and nothing has failed —
160
+ * the state the zero-discovery return checks (see runFinalIntegrationGate). */
161
+ silent() {
162
+ return this.attempts === 0 && this.failures.length === 0;
163
+ }
164
+ /** The run-16 blindness guard over this tally's own counters (see
165
+ * observabilityGapFailure); the caller fails it at rank 0. */
166
+ blindness(runnerResolvable) {
167
+ return observabilityGapFailure({
168
+ attempted: this.attempts,
169
+ observed: this.observations,
170
+ spawnFailures: this.spawnFailures,
171
+ runnerBins: [...this.bins],
172
+ runnerResolvable
173
+ });
174
+ }
175
+ /**
176
+ * The verdict. Pure over the tally's state; the same debts ride on every shape.
177
+ *
178
+ * FAIL when anything failed: stable-sorted so boot/render (rank 0) leads and
179
+ * everything else keeps execution order. One failure keeps the exact
180
+ * single-failure wording; several become a numbered list so the trail, the
181
+ * ACCEPT picker, and the autofix seed all carry the complete ranked picture.
182
+ * The observed subset rides along by exact text identity (19A) — the demote
183
+ * decision downstream reads THIS, instead of re-deriving observability from
184
+ * the failure string.
185
+ *
186
+ * Otherwise `ok: true`, with the UNOBSERVED note when the gate could not
187
+ * observe something it meant to. Two independent notes, either or both of which
188
+ * may apply: the boot never ran (run 18), and/or NOTHING dynamic ran at all
189
+ * (unobservedVerdict — commands WERE discovered, none spawn-failed so the run-16
190
+ * guard correctly stayed silent, and yet nothing ran: that used to be `statics
191
+ * passed (integration commands not runnable here)`, the identical "we never
192
+ * checked" silence wearing different words). The boot note leads because it
193
+ * names a concrete command and the trail line is sliced at 300 chars; then the
194
+ * config-gap notes, then the inert-contract note. Unchanged when anything at all
195
+ * was observed, so a project with runnable commands is byte-for-byte unaffected.
196
+ *
197
+ * ZERO ATTEMPTS IS UNOBSERVED, NEVER A PASS: when nothing dynamic was even
198
+ * attempted, the note IS the reason — there is no `statics passed (…)` suffix,
199
+ * because "we never checked" must not read like "we checked and it was fine".
200
+ * IAR1 shipped that verdict TWICE while carrying open verify-FAIL debt.
201
+ *
202
+ * The debt note rides in its OWN field: `reason` stays the mechanical failure
203
+ * because it seeds the autofix child's prompt (see FinalGateOutcome.reason —
204
+ * run 11's fix child executed a recorded claim as an instruction).
205
+ */
206
+ verdict(debts) {
207
+ const withDebts = (o) => ({
208
+ ...o,
209
+ ...(debts.debtNote ? { debtNote: debts.debtNote } : {}),
210
+ openDebts: debts.openDebts
211
+ });
212
+ if (this.failures.length > 0) {
213
+ const ranked = [...this.failures].sort((a, b) => a.rank - b.rank);
214
+ const texts = ranked.map(f => f.text);
215
+ const observed = ranked.filter(f => f.observed === true).map(f => f.text);
216
+ return withDebts({
217
+ ok: false,
218
+ reason: texts.length === 1 ?
219
+ texts[0]
220
+ : `${texts.length} failures (ranked, most load-bearing first):\n${texts
221
+ .map((t, i) => `${i + 1}. ${t}`)
222
+ .join('\n')}`,
223
+ failures: texts,
224
+ ...(observed.length > 0 ? { observedFailures: observed } : {})
225
+ });
226
+ }
227
+ const unobserved = [
228
+ this.bootNote,
229
+ unobservedVerdict({ discovered: this.attempts, observed: this.observations }),
230
+ ...this.configGapNotes,
231
+ ...this.contractNotes
232
+ ]
233
+ .filter(n => n !== null)
234
+ .join(' ');
235
+ if (this.attempts === 0) {
236
+ return withDebts({ ok: true, unobserved, reason: unobserved });
237
+ }
238
+ const warningNote = this.warnings.length > 0 ? ` — WARNING: ${this.warnings.join('; WARNING: ')}` : '';
239
+ return withDebts({
240
+ ok: true,
241
+ ...(unobserved ? { unobserved } : {}),
242
+ reason: (unobserved ? `${unobserved} — ` : '')
243
+ + (this.passed.length > 0 ?
244
+ `statics + ${this.passed.map(c => `\`${c}\``).join(', ')} passed`
245
+ : 'statics passed (integration commands not runnable here)')
246
+ + warningNote
247
+ });
248
+ }
249
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Implementation-turn supervision — what happens between "the spec was delivered
3
+ * to the main session" and "we know how the implementation turn REALLY ended".
4
+ *
5
+ * A single `waitForIdle` resolves for four different reasons, and three of them
6
+ * are not "the model finished":
7
+ * • `aborted` — a user ESC (or the command watchdog) cut the turn short;
8
+ * • `compaction` — a threshold auto-compaction parked the turn at idle without
9
+ * auto-continuing (the runtime expects a manual continue);
10
+ * • `error` — the model/provider died mid-turn after pi's own retries;
11
+ * • `stop` — genuine completion.
12
+ * `classifyTurnEnd` reads the session entries and names ONE of those, in the
13
+ * precedence the supervision sequence needs; `superviseImplementation` then
14
+ * resumes across compactions, lets the user steer after an interrupt, and reports
15
+ * the terminal outcome. The orchestrator calls it once.
16
+ */
17
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
18
+ /** How the most recent implementation turn ended. See {@link classifyTurnEnd}. */
19
+ export type TurnEnd = 'stop' | 'aborted' | 'error' | 'compaction';
20
+ /**
21
+ * The slice of a session entry the classifier reads. Structural on purpose: the
22
+ * runtime's `SessionEntry` union is wider than we need, and the tests build
23
+ * entries from plain literals.
24
+ */
25
+ export type SessionEntryLike = {
26
+ type?: string;
27
+ message?: {
28
+ role?: string;
29
+ stopReason?: string;
30
+ errorMessage?: string;
31
+ content?: unknown;
32
+ };
33
+ };
34
+ /**
35
+ * Classify how the most recent turn ended, from the session entries alone.
36
+ *
37
+ * Precedence, when several signals are present at once (this is the order the
38
+ * supervision sequence has always applied, now stated in one place):
39
+ * 1. `aborted` — the last assistant message has stopReason "aborted". A user
40
+ * ESC (or watchdog abort) wins over everything: it is not a
41
+ * compaction pause, and the steer loop owns it.
42
+ * 2. `compaction` — a `compaction` entry sits AFTER the last assistant message.
43
+ * Position-based, not timestamp-based: the runtime appends the
44
+ * boundary to the tail of the branch after the message that
45
+ * triggered it (`appendCompaction` → `_appendEntry` push), so a
46
+ * trailing compaction means we are parked with no continuation.
47
+ * A finished turn ends on an assistant message; an *overflow*
48
+ * compaction self-retries and never leaves us idle here.
49
+ * 3. `error` — the last assistant message has stopReason "error": the
50
+ * model/provider died (context-overflow 400, disconnect, 5xx)
51
+ * after pi exhausted its own retries.
52
+ * 4. `stop` — anything else, including a session with no assistant turn.
53
+ */
54
+ export declare function classifyTurnEnd(entries: ReadonlyArray<SessionEntryLike>): TurnEnd;
55
+ /**
56
+ * The provider's error cause for an `error` turn end — the message the run's
57
+ * "stopped at …" line quotes so the real cause is not lost. Undefined unless the
58
+ * last assistant message ended with stopReason "error".
59
+ */
60
+ export declare function turnErrorMessage(entries: ReadonlyArray<SessionEntryLike>): string | undefined;
61
+ /**
62
+ * True when the watchdog's reminder follow-up has been DELIVERED into the session
63
+ * after the aborted assistant turn but its own turn has not finished yet — the
64
+ * artifact that confirms a pending watchdog recovery. Scoped after the LAST
65
+ * assistant entry so an earlier fire's reminder (already answered by its own
66
+ * turn) never matches.
67
+ */
68
+ export declare function watchdogReminderDelivered(entries: ReadonlyArray<SessionEntryLike>): boolean;
69
+ /**
70
+ * The slice of the replacement-session context the supervision needs.
71
+ * `sendUserMessage` lives on ReplacedSessionContext (not the base command ctx,
72
+ * and not re-exported from the package), so we narrow to just what we call.
73
+ */
74
+ export type SteerCtx = ExtensionCommandContext & {
75
+ sendUserMessage(content: string, options?: {
76
+ deliverAs?: 'steer' | 'followUp';
77
+ }): Promise<void>;
78
+ };
79
+ /**
80
+ * Timing knobs for the watchdog-abort guard in {@link steerUntilDone}, injectable
81
+ * so tests exercise the grace expiry without a 10-second wait. `graceMs` bounds
82
+ * how long the loop waits for the watchdog's follow-up to be DELIVERED (not to
83
+ * finish — its turn may legitimately run for minutes afterwards); delivery is
84
+ * normally near-instant, so the grace only expires on a stale flag.
85
+ */
86
+ export interface SteerWatchdogDeps {
87
+ consume: () => boolean;
88
+ graceMs: number;
89
+ pollMs: number;
90
+ }
91
+ /**
92
+ * What the resume/steer loops genuinely need from the session — no more. Built
93
+ * from a live ctx by {@link turnDepsFor}; a test builds one from plain fakes.
94
+ */
95
+ export interface ImplementationTurnDeps {
96
+ /** The live session entries — the only thing the classifier reads. */
97
+ entries: () => ReadonlyArray<SessionEntryLike>;
98
+ /** Queue a follow-up user turn on the (idle) session. */
99
+ send: (text: string) => Promise<void>;
100
+ /** Wait for the session to go idle again. */
101
+ waitForIdle: () => Promise<void>;
102
+ /** Solicit steering text after an interrupt; undefined/empty = pause the run. */
103
+ ask: () => Promise<string | undefined>;
104
+ /** The watchdog one-shot flag and the bounded wait for its follow-up. */
105
+ watchdog: SteerWatchdogDeps;
106
+ /** Optional trail for the decisions taken; absent → silent. */
107
+ log?: (msg: string) => void;
108
+ }
109
+ export interface SuperviseOptions {
110
+ /**
111
+ * Ask the user for a steering message after they interrupt (ESC) the
112
+ * implementation turn. Return text to continue the same task as another turn,
113
+ * or undefined/empty to pause the run. Defaults to a bridged SessionUI.ask
114
+ * (local TUI input raced against a remote browser card); injectable so the
115
+ * steer loop is testable without a real dialog.
116
+ */
117
+ promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
118
+ /** Watchdog-guard timing overrides (tests); absent → production defaults. */
119
+ watchdog?: Partial<SteerWatchdogDeps>;
120
+ log?: (msg: string) => void;
121
+ }
122
+ /** Bind the supervision deps to a live session context. */
123
+ export declare function turnDepsFor(ctx: SteerCtx, opts?: SuperviseOptions): ImplementationTurnDeps;
124
+ /**
125
+ * Nudge that resumes an implementation turn the runtime parked at a compaction
126
+ * boundary. It must let a turn that was genuinely finished (then tipped over the
127
+ * threshold by its own final message) confirm completion without inventing busywork
128
+ * — we cannot tell "paused mid-task by compaction" from "finished, then compacted"
129
+ * from the boundary alone, so the wording lets a done turn end in one line.
130
+ */
131
+ export declare const CONTINUE_AFTER_COMPACTION: string;
132
+ /**
133
+ * Safety cap on compaction-driven resumes for a single implementation turn. Each
134
+ * resume follows a real compaction (which only fires after the model produced a
135
+ * turn large enough to cross the threshold), so a legitimately large task may
136
+ * resume a handful of times; the cap exists only to stop a pathological loop from
137
+ * auto-sending forever with no user in the loop. Hitting it stops resuming and lets
138
+ * the verify gate / `/task-auto-resume` catch any leftover incompleteness.
139
+ */
140
+ export declare const MAX_COMPACTION_RESUMES = 20;
141
+ /**
142
+ * Resume an implementation turn that went idle at a threshold-compaction boundary.
143
+ * The runtime compacts and parks at idle without auto-continuing; we send a
144
+ * continue and wait again, repeating across successive compactions until the turn
145
+ * ends on a real assistant message (genuine completion). A user ESC takes priority
146
+ * (`classifyTurnEnd` ranks `aborted` above `compaction`, so the steer loop handles
147
+ * it), and the safety cap bounds a runaway. Returns the number of resumes
148
+ * performed (0 when the turn did not end on a compaction).
149
+ */
150
+ export declare function resumeAcrossCompactions(deps: ImplementationTurnDeps): Promise<number>;
151
+ /**
152
+ * After the implementation turn settles, honour a user ESC by letting them steer.
153
+ *
154
+ * `waitForIdle` resolves both on natural completion AND on an ESC (which aborts
155
+ * the turn → idle). When the last turn was aborted, the host's main input loop is
156
+ * blocked inside our command handler, so a message typed in the editor would only
157
+ * queue, never run (interactive-mode routes idle input through onInputCallback,
158
+ * which is unset while we hold the loop). We therefore solicit the steering text
159
+ * ourselves and feed it back as another turn via sendUserMessage — which runs to
160
+ * completion when the session is idle. Repeat until a turn finishes uninterrupted.
161
+ *
162
+ * A WATCHDOG abort also ends the turn with stopReason 'aborted' — indistinguishable
163
+ * from a human ESC by the session entries alone at that instant. The watchdog
164
+ * queues its own recovery follow-up, so prompting there would show a steering
165
+ * dialog to an empty room and wedge an unattended run on the race. The one-shot
166
+ * flag (set synchronously before the abort) routes that case to
167
+ * {@link awaitWatchdogFollowUp} instead; a stale flag degrades to a bounded wait
168
+ * followed by the ordinary prompt, never to a suppressed one.
169
+ *
170
+ * Returns true when the user declined to steer (empty/cancelled) and the run
171
+ * should pause; false when the implementation completed (steered or not).
172
+ */
173
+ export declare function steerUntilDone(deps: ImplementationTurnDeps): Promise<boolean>;
174
+ export interface ImplementationOutcome {
175
+ /**
176
+ * The user interrupted the implementation (ESC) and then declined to steer
177
+ * (empty steer prompt) — they want the run to pause rather than continue. A
178
+ * plain ESC followed by steering text does NOT set this: that case loops on the
179
+ * same task until a turn finishes uninterrupted.
180
+ */
181
+ interrupted: boolean;
182
+ /**
183
+ * The failure cause when the turn ended with stopReason "error" (the
184
+ * model/provider died mid-implementation after the task file was already
185
+ * marked `completed` at spec-handoff). Undefined when the turn ended cleanly,
186
+ * and never set alongside `interrupted`.
187
+ */
188
+ error?: string;
189
+ /** Compaction resumes performed before the turn reached its real end. */
190
+ resumes: number;
191
+ }
192
+ /**
193
+ * Supervise an implementation turn from the first idle after spec delivery until
194
+ * its REAL end: resume across any compaction boundaries first (so steering and
195
+ * error inspection see the turn's actual end, not a compaction pause), then let
196
+ * the user steer across interrupts, then read how the final turn ended. The
197
+ * caller has already awaited the first idle.
198
+ */
199
+ export declare function superviseImplementation(ctx: SteerCtx, opts?: SuperviseOptions): Promise<ImplementationOutcome>;
200
+ /** {@link superviseImplementation} over an already-bound deps object (tests). */
201
+ export declare function superviseWith(deps: ImplementationTurnDeps): Promise<ImplementationOutcome>;