@yagni-app/code 0.1.0 → 0.2.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 (155) hide show
  1. package/README.md +65 -11
  2. package/dist/claudeCompat.d.ts +109 -0
  3. package/dist/claudeCompat.js +260 -0
  4. package/dist/claudePlugins.d.ts +109 -0
  5. package/dist/claudePlugins.js +336 -0
  6. package/dist/cli.d.ts +1 -5
  7. package/dist/cli.js +79 -20
  8. package/dist/config.d.ts +1 -1
  9. package/dist/config.js +4 -2
  10. package/dist/crashReport.d.ts +135 -0
  11. package/dist/crashReport.js +291 -0
  12. package/dist/credentials.js +2 -1
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +27 -0
  16. package/dist/doctor.js +81 -3
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +194 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +87 -0
  25. package/dist/extension/boostCommand.d.ts +144 -0
  26. package/dist/extension/boostCommand.js +263 -0
  27. package/dist/extension/branding.d.ts +79 -0
  28. package/dist/extension/branding.js +156 -0
  29. package/dist/extension/chipEditor.d.ts +117 -0
  30. package/dist/extension/chipEditor.js +373 -0
  31. package/dist/extension/claudeRules.d.ts +54 -0
  32. package/dist/extension/claudeRules.js +180 -0
  33. package/dist/extension/config.d.ts +173 -0
  34. package/dist/extension/config.js +194 -0
  35. package/dist/extension/costHud.d.ts +186 -0
  36. package/dist/extension/costHud.js +293 -0
  37. package/dist/extension/crashReport.d.ts +89 -0
  38. package/dist/extension/crashReport.js +241 -0
  39. package/dist/extension/decisionCapture.d.ts +52 -0
  40. package/dist/extension/decisionCapture.js +66 -0
  41. package/dist/extension/decisions.d.ts +83 -0
  42. package/dist/extension/decisions.js +200 -0
  43. package/dist/extension/diagnostics.d.ts +41 -0
  44. package/dist/extension/diagnostics.js +110 -0
  45. package/dist/extension/index.d.ts +155 -0
  46. package/dist/extension/index.js +597 -0
  47. package/dist/extension/initDone.d.ts +28 -0
  48. package/dist/extension/initDone.js +66 -0
  49. package/dist/extension/initPass.d.ts +170 -0
  50. package/dist/extension/initPass.js +394 -0
  51. package/dist/extension/mcpTools.d.ts +57 -0
  52. package/dist/extension/mcpTools.js +132 -0
  53. package/dist/extension/nextWorkTool.d.ts +51 -0
  54. package/dist/extension/nextWorkTool.js +80 -0
  55. package/dist/extension/permission.d.ts +91 -0
  56. package/dist/extension/permission.js +236 -0
  57. package/dist/extension/pipeline/activity.d.ts +37 -0
  58. package/dist/extension/pipeline/activity.js +151 -0
  59. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  60. package/dist/extension/pipeline/activityFeed.js +175 -0
  61. package/dist/extension/pipeline/budget.d.ts +48 -0
  62. package/dist/extension/pipeline/budget.js +68 -0
  63. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  64. package/dist/extension/pipeline/checkpoint.js +176 -0
  65. package/dist/extension/pipeline/eval.d.ts +205 -0
  66. package/dist/extension/pipeline/eval.js +226 -0
  67. package/dist/extension/pipeline/events.d.ts +56 -0
  68. package/dist/extension/pipeline/events.js +147 -0
  69. package/dist/extension/pipeline/findings.d.ts +42 -0
  70. package/dist/extension/pipeline/findings.js +144 -0
  71. package/dist/extension/pipeline/finish.d.ts +128 -0
  72. package/dist/extension/pipeline/finish.js +307 -0
  73. package/dist/extension/pipeline/goCommand.d.ts +146 -0
  74. package/dist/extension/pipeline/goCommand.js +1085 -0
  75. package/dist/extension/pipeline/goCompareCommand.d.ts +58 -0
  76. package/dist/extension/pipeline/goCompareCommand.js +203 -0
  77. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  78. package/dist/extension/pipeline/goFlags.js +46 -0
  79. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  80. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  81. package/dist/extension/pipeline/invocation.d.ts +45 -0
  82. package/dist/extension/pipeline/invocation.js +64 -0
  83. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  84. package/dist/extension/pipeline/orchestrator.js +645 -0
  85. package/dist/extension/pipeline/personas.d.ts +44 -0
  86. package/dist/extension/pipeline/personas.js +248 -0
  87. package/dist/extension/pipeline/resilience.d.ts +85 -0
  88. package/dist/extension/pipeline/resilience.js +166 -0
  89. package/dist/extension/pipeline/resume.d.ts +18 -0
  90. package/dist/extension/pipeline/resume.js +106 -0
  91. package/dist/extension/pipeline/runCostTable.d.ts +37 -0
  92. package/dist/extension/pipeline/runCostTable.js +165 -0
  93. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  94. package/dist/extension/pipeline/runRegistry.js +202 -0
  95. package/dist/extension/pipeline/runSession.d.ts +152 -0
  96. package/dist/extension/pipeline/runSession.js +167 -0
  97. package/dist/extension/pipeline/runState.d.ts +177 -0
  98. package/dist/extension/pipeline/runState.js +275 -0
  99. package/dist/extension/pipeline/runner.d.ts +98 -0
  100. package/dist/extension/pipeline/runner.js +310 -0
  101. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  102. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  103. package/dist/extension/pipeline/stages.d.ts +53 -0
  104. package/dist/extension/pipeline/stages.js +115 -0
  105. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  106. package/dist/extension/pipeline/ticketResolution.js +75 -0
  107. package/dist/extension/pipeline/types.d.ts +439 -0
  108. package/dist/extension/pipeline/types.js +48 -0
  109. package/dist/extension/pipeline/verify.d.ts +277 -0
  110. package/dist/extension/pipeline/verify.js +763 -0
  111. package/dist/extension/pipeline/workspace.d.ts +32 -0
  112. package/dist/extension/pipeline/workspace.js +73 -0
  113. package/dist/extension/pipeline/worktree.d.ts +107 -0
  114. package/dist/extension/pipeline/worktree.js +202 -0
  115. package/dist/extension/provider.d.ts +17 -0
  116. package/dist/extension/provider.js +33 -0
  117. package/dist/extension/recall.d.ts +93 -0
  118. package/dist/extension/recall.js +190 -0
  119. package/dist/extension/recordContextTool.d.ts +38 -0
  120. package/dist/extension/recordContextTool.js +85 -0
  121. package/dist/extension/recordDecisionTool.d.ts +52 -0
  122. package/dist/extension/recordDecisionTool.js +102 -0
  123. package/dist/extension/repoDocs.d.ts +81 -0
  124. package/dist/extension/repoDocs.js +260 -0
  125. package/dist/extension/rerouteNotice.d.ts +42 -0
  126. package/dist/extension/rerouteNotice.js +67 -0
  127. package/dist/extension/resilientFetch.d.ts +60 -0
  128. package/dist/extension/resilientFetch.js +133 -0
  129. package/dist/extension/reviewTool.d.ts +34 -0
  130. package/dist/extension/reviewTool.js +81 -0
  131. package/dist/extension/sessionRuns.d.ts +45 -0
  132. package/dist/extension/sessionRuns.js +77 -0
  133. package/dist/extension/spool.d.ts +92 -0
  134. package/dist/extension/spool.js +266 -0
  135. package/dist/extension/stateHome.d.ts +2 -0
  136. package/dist/extension/stateHome.js +6 -0
  137. package/dist/extension/subagents.d.ts +145 -0
  138. package/dist/extension/subagents.js +326 -0
  139. package/dist/extension/surface.d.ts +10 -0
  140. package/dist/extension/surface.js +12 -0
  141. package/dist/extension/todos.d.ts +110 -0
  142. package/dist/extension/todos.js +217 -0
  143. package/dist/extension/tokenProvider.d.ts +93 -0
  144. package/dist/extension/tokenProvider.js +234 -0
  145. package/dist/launch.d.ts +25 -3
  146. package/dist/launch.js +27 -9
  147. package/dist/login.d.ts +7 -0
  148. package/dist/login.js +3 -1
  149. package/dist/paths.d.ts +13 -4
  150. package/dist/paths.js +17 -5
  151. package/dist/profiles.d.ts +1 -1
  152. package/dist/profiles.js +5 -2
  153. package/dist/upgrade.d.ts +97 -0
  154. package/dist/upgrade.js +284 -0
  155. package/package.json +11 -15
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The headless model behind every `/go` progress display.
3
+ *
4
+ * `RunState` folds the structured `PipelineProgress` signals, curated
5
+ * `ActivityLine`s and raw assistant `JsonEvent` usage into one small model that
6
+ * both surfaces render from: the terminal's boxed ANSI panel (via
7
+ * `ActivityFeed`, which is now a pure renderer over this) and the desktop's
8
+ * Agents tab (via `toJSON()`, pushed as one line over `setWidget`).
9
+ *
10
+ * It owns no I/O and no clock — the caller injects `at` — so it is fully
11
+ * deterministic in tests, exactly like the feed it replaces.
12
+ *
13
+ * Two properties are load-bearing:
14
+ *
15
+ * 1. **Lens events never touch their parent stage.** A `stage_start` /
16
+ * `stage_done` carrying a `lens` mutates only that lens's agent. Without
17
+ * this the per-lens signals would mark the terminal's `review` row done
18
+ * while two lenses were still running.
19
+ *
20
+ * 2. **Per-agent action lists only.** The terminal's flat, arrival-ordered ring
21
+ * buffer stays in `ActivityFeed`: it exists to fit pi's widget cap, which is
22
+ * a property of that renderer rather than of the run. Keeping it there means
23
+ * the panel's cross-lens interleaving is preserved by code that never moved.
24
+ */
25
+ /** The fixed stage order shown by both surfaces (v1 stages + the post-loop FINISH row). */
26
+ export const STAGE_ORDER = ["map", "plan", "implement", "review", "fix", "finish"];
27
+ /**
28
+ * Per-agent action cap. Generous compared to the terminal's `MAX_VISIBLE_ACTIONS`
29
+ * because the desktop pane scrolls: the TUI's 4-row ceiling is a widget-height
30
+ * constraint, not a truth about how much happened.
31
+ */
32
+ export const AGENT_ACTION_WINDOW = 40;
33
+ function emptyUsage() {
34
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
35
+ }
36
+ /**
37
+ * Stable agent id: a lens child is keyed by its lens AND round, and fix — the
38
+ * only lens-less stage that re-executes per round — by its round, so each
39
+ * round's fix keeps its own clock and usage instead of accumulating tokens on a
40
+ * timer that resets (the "181k tok in 59s" artifact). Review stage-level events
41
+ * stay on the bare `review` node: they mark the round boundary, and the lens
42
+ * children carry the round's real work.
43
+ */
44
+ export function agentId(stageId, lens, round) {
45
+ if (lens)
46
+ return `${stageId}:${lens}:r${round ?? 1}`;
47
+ if (stageId === "fix" && round !== undefined)
48
+ return `fix:r${round}`;
49
+ return stageId;
50
+ }
51
+ export class RunState {
52
+ runId;
53
+ ticket;
54
+ startedAt;
55
+ endedAt;
56
+ stopReason;
57
+ serverRunId;
58
+ round = 0;
59
+ findingsTotal = 0;
60
+ findingsBlocking = 0;
61
+ rounds = [];
62
+ agents;
63
+ constructor(init) {
64
+ this.runId = init.runId;
65
+ this.ticket = init.ticket;
66
+ this.startedAt = init.startedAt;
67
+ this.agents = STAGE_ORDER.map((id) => ({
68
+ id,
69
+ stageId: id,
70
+ status: "pending",
71
+ summary: "",
72
+ actions: [],
73
+ usage: emptyUsage(),
74
+ }));
75
+ }
76
+ /** The lens-less stage agent, or undefined for an unknown stage. */
77
+ stageAgent(stageId) {
78
+ return this.agents.find((a) => a.id === stageId);
79
+ }
80
+ /**
81
+ * The agent for a tag, created on demand. Lens children are inserted directly
82
+ * after their parent stage and any existing siblings, so the array order still
83
+ * reads top-to-bottom for the desktop and the lens-less filter still yields
84
+ * STAGE_ORDER for the terminal.
85
+ */
86
+ ensureAgent(stageId, lens, round, at) {
87
+ const id = agentId(stageId, lens, round);
88
+ const found = this.agents.find((a) => a.id === id);
89
+ if (found)
90
+ return found;
91
+ const node = {
92
+ id,
93
+ stageId,
94
+ ...(lens ? { lens } : {}),
95
+ ...(round !== undefined ? { round } : {}),
96
+ status: "active",
97
+ summary: "",
98
+ startedAt: at,
99
+ actions: [],
100
+ usage: emptyUsage(),
101
+ };
102
+ const parentIdx = this.agents.findIndex((a) => a.id === stageId);
103
+ if (parentIdx < 0) {
104
+ this.agents.push(node);
105
+ return node;
106
+ }
107
+ let insertAt = parentIdx + 1;
108
+ // Skip existing children (lens or per-round nodes: any id that is not its own
109
+ // stage row) so siblings append in arrival order after their parent.
110
+ while (insertAt < this.agents.length && this.agents[insertAt]?.id !== this.agents[insertAt]?.stageId)
111
+ insertAt += 1;
112
+ this.agents.splice(insertAt, 0, node);
113
+ return node;
114
+ }
115
+ /** Fold a structured progress signal. `at` is only read by the desktop surface. */
116
+ applyProgress(p, at = 0) {
117
+ switch (p.kind) {
118
+ case "stage_start": {
119
+ if (typeof p.round === "number")
120
+ this.round = p.round;
121
+ // `ensureAgent` routes by id: a lens or per-round fix signal creates its
122
+ // own child; a bare stage signal (including review's round boundary,
123
+ // which must return to active even though a prior round left it done)
124
+ // lands on the pre-seeded stage node.
125
+ const target = this.ensureAgent(p.stageId, p.lens, p.round, at);
126
+ target.status = "active";
127
+ target.startedAt = at;
128
+ // `delete`, not `= undefined`: an explicitly-undefined property still
129
+ // shows up in Object.keys, and the wire shape is asserted by key set.
130
+ delete target.endedAt;
131
+ break;
132
+ }
133
+ case "stage_done": {
134
+ if (typeof p.round === "number")
135
+ this.round = p.round;
136
+ const target = this.ensureAgent(p.stageId, p.lens, p.round, at);
137
+ if (p.degraded === "failed") {
138
+ target.status = "failed";
139
+ }
140
+ else if (target.status !== "failed") {
141
+ target.status = "done";
142
+ }
143
+ target.endedAt = at;
144
+ if (typeof p.findings === "number")
145
+ target.findings = p.findings;
146
+ if (typeof p.blocking === "number")
147
+ target.blocking = p.blocking;
148
+ if (p.degraded)
149
+ target.degraded = p.degraded;
150
+ break;
151
+ }
152
+ case "findings": {
153
+ this.round = p.round;
154
+ this.findingsTotal = p.total;
155
+ this.findingsBlocking = p.blocking;
156
+ // Keep the per-round history too: the header tally is the latest round,
157
+ // but the trajectory across rounds is what shows the loop converging.
158
+ const tally = { round: p.round, findings: p.total, blocking: p.blocking };
159
+ const existing = this.rounds.findIndex((r) => r.round === p.round);
160
+ if (existing >= 0)
161
+ this.rounds[existing] = tally;
162
+ else
163
+ this.rounds.push(tally);
164
+ break;
165
+ }
166
+ case "done": {
167
+ this.stopReason = p.stopReason;
168
+ const failed = p.stopReason === "failed" || p.stopReason === "aborted";
169
+ for (const a of this.agents) {
170
+ if (a.status === "active") {
171
+ a.status = failed ? "failed" : "done";
172
+ a.endedAt = at;
173
+ }
174
+ }
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ /**
180
+ * Fold one curated line. Narration becomes the emitting agent's summary;
181
+ * actions ride BOTH the agent's own list and the terminal's flat ring buffer.
182
+ */
183
+ applyLine(line, tag, at = 0) {
184
+ const target = this.ensureAgent(tag.stageId, tag.lens, tag.round, at);
185
+ if (line.kind === "narration") {
186
+ target.summary = line.text;
187
+ return;
188
+ }
189
+ if (line.state === "running") {
190
+ target.actions.push({
191
+ ...(line.toolCallId !== undefined ? { toolCallId: line.toolCallId } : {}),
192
+ text: line.text,
193
+ state: "running",
194
+ });
195
+ if (target.actions.length > AGENT_ACTION_WINDOW)
196
+ target.actions.shift();
197
+ return;
198
+ }
199
+ // A resolving end: update the matching running entry in place.
200
+ if (line.toolCallId === undefined)
201
+ return;
202
+ const mine = target.actions.find((a) => a.toolCallId === line.toolCallId);
203
+ if (mine)
204
+ mine.state = line.state;
205
+ }
206
+ /** Fold an assistant turn's usage into the emitting agent. Ignores everything else. */
207
+ applyUsage(ev, tag, at = 0) {
208
+ if (ev.type !== "message_end" || ev.message?.role !== "assistant")
209
+ return;
210
+ const target = this.ensureAgent(tag.stageId, tag.lens, tag.round, at);
211
+ target.usage.turns += 1;
212
+ const u = ev.message.usage;
213
+ if (!u)
214
+ return;
215
+ target.usage.input += u.input ?? 0;
216
+ target.usage.output += u.output ?? 0;
217
+ target.usage.cacheRead += u.cacheRead ?? 0;
218
+ target.usage.cacheWrite += u.cacheWrite ?? 0;
219
+ target.usage.cost += u.cost?.total ?? 0;
220
+ }
221
+ /** Stamp the run's end. Separate from `done` because FINISH runs after the pipeline returns. */
222
+ markEnded(at) {
223
+ this.endedAt = at;
224
+ }
225
+ /**
226
+ * Record the backend's run UUID once `openFreshRun` resolves (goCommand.ts,
227
+ * a beat after the run starts — never known at construction). The caller is
228
+ * responsible for repainting immediately after this so the desktop learns
229
+ * it as soon as possible rather than on the next debounced paint.
230
+ */
231
+ setServerRunId(id) {
232
+ this.serverRunId = id;
233
+ }
234
+ // ---- Terminal renderer accessors (never serialized) ----------------------
235
+ /**
236
+ * Stage rows for the panel: one row per stage, in order. Lens children are the
237
+ * desktop's business alone, and a looped stage that re-executes per round
238
+ * (fix) paints its LATEST round's node so the row shows the live execution,
239
+ * not a stale bare placeholder.
240
+ */
241
+ stageAgents() {
242
+ return STAGE_ORDER.flatMap((id) => {
243
+ const perRound = this.agents.filter((a) => a.stageId === id && a.lens === undefined && a.id !== id);
244
+ const row = perRound[perRound.length - 1] ?? this.stageAgent(id);
245
+ return row ? [row] : [];
246
+ });
247
+ }
248
+ header() {
249
+ return {
250
+ ticket: this.ticket,
251
+ round: this.round,
252
+ findingsTotal: this.findingsTotal,
253
+ findingsBlocking: this.findingsBlocking,
254
+ };
255
+ }
256
+ // ---- Wire record ---------------------------------------------------------
257
+ /** Named `toJSON` so `JSON.stringify(run)` yields the wire shape directly. */
258
+ toJSON() {
259
+ return {
260
+ v: 1,
261
+ runId: this.runId,
262
+ ticket: this.ticket,
263
+ round: this.round,
264
+ findingsTotal: this.findingsTotal,
265
+ findingsBlocking: this.findingsBlocking,
266
+ rounds: this.rounds,
267
+ startedAt: this.startedAt,
268
+ ...(this.endedAt !== undefined ? { endedAt: this.endedAt } : {}),
269
+ ...(this.stopReason ? { stopReason: this.stopReason } : {}),
270
+ agents: this.agents,
271
+ ...(this.serverRunId !== undefined ? { serverRunId: this.serverRunId } : {}),
272
+ };
273
+ }
274
+ }
275
+ //# sourceMappingURL=runState.js.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * THIN, IMPURE per-stage spawn-runner — lifted almost verbatim from the pi
3
+ * subagent example's `runSingleAgent` (index.ts L267-429).
4
+ *
5
+ * It writes the grounded persona body to a 0600 temp file, builds the grounded
6
+ * child argv, spawns ONE pi child, line-buffers the stdout NDJSON, delegates all
7
+ * parsing to `events.ts`, applies `PER_TASK_OUTPUT_CAP`, wires abort
8
+ * (SIGTERM then SIGKILL after 5s if the child has not really exited), and cleans
9
+ * up the temp file.
10
+ *
11
+ * v1 spawns `pi` directly (NOT the `yagni-code` launcher) and re-injects
12
+ * `-e <self> --provider yagni`, inheriting the parent's env (token / base URL /
13
+ * agent dir). This avoids a yagni-code-cli ↔ pi-extension-yagni circular
14
+ * dependency — see the blueprint risks.
15
+ *
16
+ * ALL impurity sits behind injectable seams (`spawn`, `writePrompt`,
17
+ * `resolveChild`) so the runner is unit-tested with a fake spawn emitting canned
18
+ * NDJSON — no real process, no model, no network. The live end-to-end run is
19
+ * Jack dogfood, not CI.
20
+ */
21
+ import { spawn as nodeSpawn } from "node:child_process";
22
+ import { type JsonEvent, type PipelineStage, type ReviewLens, type StageResult } from "./types.js";
23
+ /** Injectable seams so the runner is unit-testable without fs / process / net. */
24
+ export interface RunStageDeps {
25
+ cwd: string;
26
+ signal?: AbortSignal;
27
+ spawn?: typeof nodeSpawn;
28
+ writePrompt?: (body: string) => Promise<{
29
+ filePath: string;
30
+ cleanup: () => void;
31
+ }>;
32
+ resolveChild?: () => {
33
+ piCli: string;
34
+ extensionPath: string;
35
+ };
36
+ /** Optional environment for spawned child stages. Defaults to Node's inherited env. */
37
+ env?: NodeJS.ProcessEnv;
38
+ /**
39
+ * YAG-471 attribution: the tracked run's server-assigned id (a UUID), sent to
40
+ * the child as `YAGNI_RUN_ID` so the model proxy's `x-yagni-run-id` header
41
+ * wins over the session id for `llm_usage.session_id`. Absent when the run is
42
+ * untracked (recording disabled/failed) — the child still gets a caller
43
+ * label, just not a run id; attribution degrades gracefully rather than
44
+ * blocking anything.
45
+ */
46
+ attribution?: {
47
+ runId?: string;
48
+ };
49
+ /**
50
+ * YAG-471 attribution: the child's caller label, sent as `YAGNI_CALLER` (the
51
+ * model proxy's `x-yagni-caller` header). Defaults to `go:<stage.id>` (or
52
+ * `go:<stage.id>:<lens>` for a fanned-out review lens) when absent, so the
53
+ * /go pipeline's own stage loop never has to set this explicitly. The
54
+ * subagent tool and the advisor pass their own label through this seam.
55
+ */
56
+ callerLabel?: string;
57
+ /**
58
+ * Additive live tap: invoked once per parsed NDJSON event, in stream order,
59
+ * right after it is buffered for the reducers. PURE side-channel for the
60
+ * activity feed — it changes nothing about buffering, capping, abort, or the
61
+ * StageResult reduction, and a headless run simply omits it.
62
+ */
63
+ onEvent?: (ev: JsonEvent) => void;
64
+ /**
65
+ * Max bytes for a single stdout (NDJSON) line before it is discarded. Guards
66
+ * against a pathological runaway line growing `buffer` without bound and
67
+ * OOM-crashing the harness. Defaults to {@link DEFAULT_MAX_STDOUT_LINE_BYTES};
68
+ * tests inject a small value.
69
+ */
70
+ maxLineBytes?: number;
71
+ /**
72
+ * System-prompt body builder override. The general subagent tool injects
73
+ * custom agent bodies (from .claude/agents files) through this seam; /go's
74
+ * stages keep the default grounded personas.
75
+ */
76
+ personaBody?: (stage: PipelineStage, opts: {
77
+ grounded?: boolean;
78
+ }) => string;
79
+ }
80
+ /**
81
+ * Default per-line stdout ceiling (16 MiB). A legitimate NDJSON event — even one
82
+ * carrying a large tool payload — is far below this; a line beyond it is a
83
+ * runaway/garbage line, dropped rather than accumulated. Keeps the stdout reader
84
+ * O(n) and memory-bounded (a single ~GB line previously OOM-crashed `runStage`).
85
+ */
86
+ export declare const DEFAULT_MAX_STDOUT_LINE_BYTES: number;
87
+ /**
88
+ * Run one grounded child stage and reduce its NDJSON stream into a StageResult.
89
+ * `ctx.lens` (when set, for a fanned-out review) selects the lens clause in the
90
+ * persona body and is threaded through the invocation builder.
91
+ */
92
+ export declare function runStage(stage: PipelineStage, ctx: {
93
+ ticket: string;
94
+ previous?: string;
95
+ lens?: ReviewLens;
96
+ grounded?: boolean;
97
+ }, deps: RunStageDeps): Promise<StageResult>;
98
+ //# sourceMappingURL=runner.d.ts.map
@@ -0,0 +1,310 @@
1
+ /**
2
+ * THIN, IMPURE per-stage spawn-runner — lifted almost verbatim from the pi
3
+ * subagent example's `runSingleAgent` (index.ts L267-429).
4
+ *
5
+ * It writes the grounded persona body to a 0600 temp file, builds the grounded
6
+ * child argv, spawns ONE pi child, line-buffers the stdout NDJSON, delegates all
7
+ * parsing to `events.ts`, applies `PER_TASK_OUTPUT_CAP`, wires abort
8
+ * (SIGTERM then SIGKILL after 5s if the child has not really exited), and cleans
9
+ * up the temp file.
10
+ *
11
+ * v1 spawns `pi` directly (NOT the `yagni-code` launcher) and re-injects
12
+ * `-e <self> --provider yagni`, inheriting the parent's env (token / base URL /
13
+ * agent dir). This avoids a yagni-code-cli ↔ pi-extension-yagni circular
14
+ * dependency — see the blueprint risks.
15
+ *
16
+ * ALL impurity sits behind injectable seams (`spawn`, `writePrompt`,
17
+ * `resolveChild`) so the runner is unit-tested with a fake spawn emitting canned
18
+ * NDJSON — no real process, no model, no network. The live end-to-end run is
19
+ * Jack dogfood, not CI.
20
+ */
21
+ import { spawn as nodeSpawn } from "node:child_process";
22
+ import * as fs from "node:fs";
23
+ import * as os from "node:os";
24
+ import * as path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
27
+ import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
28
+ import { personaBody } from "./personas.js";
29
+ import { PER_TASK_OUTPUT_CAP, } from "./types.js";
30
+ /**
31
+ * Default per-line stdout ceiling (16 MiB). A legitimate NDJSON event — even one
32
+ * carrying a large tool payload — is far below this; a line beyond it is a
33
+ * runaway/garbage line, dropped rather than accumulated. Keeps the stdout reader
34
+ * O(n) and memory-bounded (a single ~GB line previously OOM-crashed `runStage`).
35
+ */
36
+ export const DEFAULT_MAX_STDOUT_LINE_BYTES = 16 * 1024 * 1024;
37
+ /** Default: write the persona body to a private (0600) temp file. */
38
+ async function defaultWritePrompt(body) {
39
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "yagni-pipeline-"));
40
+ const filePath = path.join(dir, "persona.md");
41
+ await fs.promises.writeFile(filePath, body, { encoding: "utf-8", mode: 0o600 });
42
+ const cleanup = () => {
43
+ try {
44
+ fs.unlinkSync(filePath);
45
+ }
46
+ catch {
47
+ /* ignore */
48
+ }
49
+ try {
50
+ fs.rmdirSync(dir);
51
+ }
52
+ catch {
53
+ /* ignore */
54
+ }
55
+ };
56
+ return { filePath, cleanup };
57
+ }
58
+ /**
59
+ * Default child resolution: the parent process IS pi (so `process.argv[1]` is
60
+ * pi's cli), and our compiled extension entry sits one dir up from this module.
61
+ */
62
+ function defaultResolveChild() {
63
+ const piCli = process.argv[1] ?? "pi";
64
+ const extensionPath = fileURLToPath(new URL("../index.js", import.meta.url));
65
+ return { piCli, extensionPath };
66
+ }
67
+ const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
68
+ /** Truncate to PER_TASK_OUTPUT_CAP bytes, never exceeding the cap. */
69
+ function capOutput(output) {
70
+ if (Buffer.byteLength(output, "utf8") <= PER_TASK_OUTPUT_CAP)
71
+ return output;
72
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
73
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
74
+ truncated = truncated.slice(0, -1);
75
+ }
76
+ return truncated;
77
+ }
78
+ /**
79
+ * Run one grounded child stage and reduce its NDJSON stream into a StageResult.
80
+ * `ctx.lens` (when set, for a fanned-out review) selects the lens clause in the
81
+ * persona body and is threaded through the invocation builder.
82
+ */
83
+ export async function runStage(stage, ctx, deps) {
84
+ const spawnFn = deps.spawn ?? nodeSpawn;
85
+ const writePrompt = deps.writePrompt ?? defaultWritePrompt;
86
+ const resolveChild = deps.resolveChild ?? defaultResolveChild;
87
+ const personaStage = ctx.lens ? { ...stage, lens: ctx.lens } : stage;
88
+ // grounded defaults true; only the M6 blind eval lane passes grounded: false.
89
+ const body = (deps.personaBody ?? personaBody)(personaStage, { grounded: ctx.grounded !== false });
90
+ const { filePath, cleanup } = await writePrompt(body);
91
+ const result = {
92
+ stageId: stage.id,
93
+ agent: stage.agent,
94
+ exitCode: 0,
95
+ finalOutput: "",
96
+ usage: { ...EMPTY_USAGE },
97
+ stderr: "",
98
+ toolCalls: 0,
99
+ toolErrors: 0,
100
+ };
101
+ try {
102
+ const passthrough = buildStageInvocation(stage, {
103
+ ticket: ctx.ticket,
104
+ previous: ctx.previous,
105
+ promptFilePath: filePath,
106
+ lens: ctx.lens,
107
+ });
108
+ const { piCli, extensionPath } = resolveChild();
109
+ const argv = groundedChildArgv(passthrough, { piCli, extensionPath });
110
+ // Fold events into a fixed-size accumulator as they stream — never retain
111
+ // the full array (that OOMs on a verbose stage; YAG-317 follow-up).
112
+ const acc = newEventAccumulator();
113
+ let eventCount = 0;
114
+ let stderr = "";
115
+ let wasAborted = false;
116
+ const maxLineBytes = deps.maxLineBytes ?? DEFAULT_MAX_STDOUT_LINE_BYTES;
117
+ let overlongLinesDropped = 0;
118
+ // Env-gated diagnostic (no-op unless YAGNI_PIPELINE_DEBUG_DIR set). Captures
119
+ // per-stage stream metrics — total bytes, max single NDJSON line, event count,
120
+ // interim heap — plus the full final output. Used to characterise the plan
121
+ // `length` truncation and the implement-stage OOM. Safe to leave in.
122
+ const DEBUG_DIR = process.env.YAGNI_PIPELINE_DEBUG_DIR;
123
+ const dbgLabel = ctx.lens ? `${stage.id}-${ctx.lens}` : stage.id;
124
+ let dbgTotalBytes = 0, dbgMaxLineBytes = 0, dbgLineCount = 0, dbgLastLogBytes = 0;
125
+ const dbgLog = (note) => {
126
+ if (!DEBUG_DIR)
127
+ return;
128
+ try {
129
+ fs.mkdirSync(DEBUG_DIR, { recursive: true });
130
+ const m = process.memoryUsage();
131
+ fs.appendFileSync(path.join(DEBUG_DIR, `${dbgLabel}.stream.log`), `${new Date().toISOString()} ${note} bytes=${dbgTotalBytes} maxLineBytes=${dbgMaxLineBytes} lines=${dbgLineCount} events=${eventCount} heapUsedMB=${Math.round(m.heapUsed / 1048576)} rssMB=${Math.round(m.rss / 1048576)}\n`);
132
+ }
133
+ catch { /* ignore */ }
134
+ };
135
+ // YAG-471 attribution: every child builds its OWN provider config at init
136
+ // (reading its own env), so a per-child env var becomes a per-child header.
137
+ // The caller label defaults from the stage/lens being run; a run id is
138
+ // included only when the caller (the orchestrator, once its run is tracked)
139
+ // supplied one — its absence just means this child's completions attribute
140
+ // to the session id alone.
141
+ const callerLabel = deps.callerLabel ?? (ctx.lens ? `go:${stage.id}:${ctx.lens}` : `go:${stage.id}`);
142
+ const childEnv = {
143
+ ...(deps.env ?? process.env),
144
+ YAGNI_CALLER: callerLabel,
145
+ ...(deps.attribution?.runId ? { YAGNI_RUN_ID: deps.attribution.runId } : {}),
146
+ };
147
+ const exitCode = await new Promise((resolve) => {
148
+ const proc = spawnFn(process.execPath, argv, {
149
+ cwd: deps.cwd,
150
+ env: childEnv,
151
+ shell: false,
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ });
154
+ let buffer = "";
155
+ // `overlong` = we are mid-way through a line that already blew the cap; we
156
+ // discard incoming bytes until its terminating newline, then drop the line.
157
+ let overlong = false;
158
+ const processLine = (line) => {
159
+ if (DEBUG_DIR) {
160
+ const b = Buffer.byteLength(line, "utf8");
161
+ if (b > dbgMaxLineBytes)
162
+ dbgMaxLineBytes = b;
163
+ dbgLineCount++;
164
+ }
165
+ if (!line.trim())
166
+ return;
167
+ let event;
168
+ try {
169
+ event = JSON.parse(line);
170
+ }
171
+ catch {
172
+ return;
173
+ }
174
+ foldEvent(acc, event);
175
+ eventCount += 1;
176
+ deps.onEvent?.(event);
177
+ };
178
+ // Track real process exit explicitly. `ChildProcess.killed` is set the
179
+ // instant a signal is *sent*, not when the process exits — so it cannot
180
+ // gate the SIGKILL escalation (it is already true after SIGTERM). This
181
+ // flag flips only on the real `close`/`error`, so a child that ignores
182
+ // SIGTERM still gets force-killed after the grace window.
183
+ const signal = deps.signal;
184
+ let closed = false;
185
+ let killTimer;
186
+ const onAbort = () => {
187
+ wasAborted = true;
188
+ // pid guard: kill() on a child whose spawn FAILED resolves to pid 0 —
189
+ // the whole process group — and would take the parent down with it.
190
+ if (proc.pid)
191
+ proc.kill("SIGTERM");
192
+ killTimer = setTimeout(() => {
193
+ if (!closed && proc.pid)
194
+ proc.kill("SIGKILL");
195
+ }, 5000);
196
+ killTimer.unref?.();
197
+ };
198
+ const finish = (code) => {
199
+ closed = true;
200
+ if (killTimer)
201
+ clearTimeout(killTimer);
202
+ // Detach the abort listener on normal completion so it does not
203
+ // accumulate on the shared, long-lived pipeline signal across stages.
204
+ if (signal)
205
+ signal.removeEventListener("abort", onAbort);
206
+ resolve(code);
207
+ };
208
+ proc.stdout?.on("data", (data) => {
209
+ const s = data.toString();
210
+ if (DEBUG_DIR) {
211
+ dbgTotalBytes += Buffer.byteLength(s, "utf8");
212
+ if (dbgTotalBytes - dbgLastLogBytes > 20_000_000) {
213
+ dbgLastLogBytes = dbgTotalBytes;
214
+ dbgLog("stream");
215
+ }
216
+ }
217
+ // Incremental newline scan — never re-split a growing buffer (that was
218
+ // O(n²) and OOM-crashed on a runaway single line). `buffer` only ever
219
+ // holds the current *partial* line, and is capped at maxLineBytes: an
220
+ // over-cap line is dropped, not accumulated.
221
+ let start = 0;
222
+ for (;;) {
223
+ const nl = s.indexOf("\n", start);
224
+ if (nl === -1) {
225
+ if (!overlong) {
226
+ buffer += s.slice(start);
227
+ if (buffer.length > maxLineBytes) {
228
+ overlong = true;
229
+ buffer = "";
230
+ }
231
+ }
232
+ break;
233
+ }
234
+ const seg = s.slice(start, nl);
235
+ start = nl + 1;
236
+ if (overlong) {
237
+ overlong = false;
238
+ overlongLinesDropped += 1;
239
+ continue;
240
+ }
241
+ const line = buffer + seg;
242
+ buffer = "";
243
+ if (line.length > maxLineBytes) {
244
+ overlongLinesDropped += 1;
245
+ continue;
246
+ }
247
+ processLine(line);
248
+ }
249
+ });
250
+ proc.stderr?.on("data", (data) => {
251
+ stderr += data.toString();
252
+ });
253
+ proc.on("close", (code) => {
254
+ if (overlong) {
255
+ overlongLinesDropped += 1;
256
+ overlong = false;
257
+ buffer = "";
258
+ }
259
+ else if (buffer.trim())
260
+ processLine(buffer);
261
+ finish(code ?? 0);
262
+ });
263
+ proc.on("error", () => finish(1));
264
+ if (signal) {
265
+ if (signal.aborted)
266
+ onAbort();
267
+ else
268
+ signal.addEventListener("abort", onAbort);
269
+ }
270
+ });
271
+ result.exitCode = exitCode;
272
+ result.stderr = stderr;
273
+ result.finalOutput = capOutput(finalOutputFrom(acc));
274
+ result.usage = acc.usage;
275
+ // R3-a: tap tool-result counts into stage health. The orchestrator turns
276
+ // this ratio into a write-stage failure when most edits errored, so a
277
+ // clean-looking summary can't mask a silent partial implement.
278
+ result.toolCalls = acc.toolCalls;
279
+ result.toolErrors = acc.toolErrors;
280
+ if (acc.stopReason)
281
+ result.stopReason = acc.stopReason;
282
+ if (acc.errorMessage)
283
+ result.errorMessage = acc.errorMessage;
284
+ if (wasAborted)
285
+ result.stopReason = "aborted";
286
+ if (overlongLinesDropped > 0)
287
+ result.overlongLinesDropped = overlongLinesDropped;
288
+ if (DEBUG_DIR) {
289
+ try {
290
+ fs.mkdirSync(DEBUG_DIR, { recursive: true });
291
+ const finalOut = finalOutputFrom(acc);
292
+ fs.writeFileSync(path.join(DEBUG_DIR, `${dbgLabel}.summary.json`), JSON.stringify({
293
+ stageId: stage.id, lens: ctx.lens, exitCode,
294
+ stopReason: result.stopReason, usage: result.usage,
295
+ events_count: eventCount, total_stream_bytes: dbgTotalBytes,
296
+ max_line_bytes: dbgMaxLineBytes, line_count: dbgLineCount,
297
+ final_output_bytes: Buffer.byteLength(finalOut, "utf8"),
298
+ final_output: finalOut,
299
+ }, null, 2));
300
+ dbgLog("stage_end");
301
+ }
302
+ catch { /* ignore */ }
303
+ }
304
+ return result;
305
+ }
306
+ finally {
307
+ cleanup();
308
+ }
309
+ }
310
+ //# sourceMappingURL=runner.js.map