@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.1

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 (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  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 +188 -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 +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,972 @@
1
+ /**
2
+ * Registers the `/go <ticket>` entry point (spec decision §5 — NOT `/run` or
3
+ * `/work`, which collide with the Run and Work concepts), plus its companions
4
+ * `/go-status` and `/go-clean` (registered from this same pipeline registration
5
+ * path — src/index.ts only ever calls registerGoCommand).
6
+ *
7
+ * A `registerCommand` (not a static chain prompt) is used because the review→fix
8
+ * LOOP needs a runtime condition a markdown chain cannot express. The handler
9
+ * parses the remainder through the flag parser (`--here` in-place, `--fg`
10
+ * blocking, `--pr` push + PR at FINISH; unknown flags are refused with
11
+ * a usage warning), guards on `ctx.isIdle()` plus the run registry (same-ticket
12
+ * in-flight refusal, MAX_CONCURRENT_RUNS), and runs the pipeline. Everything
13
+ * downstream — ticket resolution, sessionKey, registry, run-row arg, the
14
+ * ActivityFeed header, and the handoff copy — receives the FLAG-FREE ticket.
15
+ *
16
+ * FINISH STAGE (spec §3c): when the review loop stops `clean`, the handler runs
17
+ * the deterministic FINISH stage (finish.ts): commit the run's work (worktree
18
+ * mode always; --here only when the pre-run baseline was verifiably clean) with
19
+ * a conventional subject + plan summary + the provenance trailer, and — with
20
+ * `--pr` — push the run branch and open a PR under the user's own gh auth. A
21
+ * FINISH failure NEVER flips a clean run to failed and never fakes a commit
22
+ * (§0.1): every skip/failure is an honest note on the handoff. The commit/PR
23
+ * land on `PipelineResult.finish`, `session.finish`, the registry row, a
24
+ * `finish_done` checkpoint record, and a `finish` row in the activity feed.
25
+ *
26
+ * WORKTREE BY DEFAULT (spec §3b): the run gets its own worktree + branch FIRST
27
+ * (`git worktree add -b yagni/<slug>-<shortId> ~/.yagni-code/worktrees/<shortId>
28
+ * HEAD`), and repoCtx, sessionKey, the checkpoint journal, and every resume
29
+ * snapshot are resolved against the WORKTREE path — fresh runs and
30
+ * registry-driven resumes agree on the same tree, so the same run always maps to
31
+ * the same sha1(sessionKey) journal file. A best-effort bootstrap installs
32
+ * dependencies (fail-open with an honest note; verify's infra-failure
33
+ * classification covers the rest). `--here` opts back into the legacy in-place
34
+ * behavior: dirty-tree guard + no-op guard unchanged, sessionKey from ctx.cwd,
35
+ * and never a WIP commit. If worktree creation itself fails, default /go stops
36
+ * before mutating the user's tree; `--here` is the explicit in-place escape
37
+ * hatch.
38
+ *
39
+ * DETACHED BY DEFAULT: /go returns the prompt as soon as the run session opens;
40
+ * the pipeline promise is tracked in the module-scoped run registry (mirrored to
41
+ * ~/.yagni-code/registry.ndjson) and completion delivers the existing handoff
42
+ * via `pi.sendUserMessage(handoff, { deliverAs: "followUp" })` (try/catch with a
43
+ * notify fallback — pi requires deliverAs when the agent is streaming). `--fg`
44
+ * restores blocking. On a NON-clean terminal outcome in worktree mode the run's
45
+ * work is preserved as a clearly-marked `wip(yagni): <ticket> - <stopReason>`
46
+ * commit on the run branch, so nothing is lost and /go-clean can remove the tree.
47
+ *
48
+ * When the session has a UI, it also paints a live activity feed in an
49
+ * `aboveEditor` widget (spec §1) under a PER-RUN key (`yagni-go:<runShortId>`) so
50
+ * two concurrent runs never flicker-fight one widget and one run's clearUI only
51
+ * removes its own panel. Every UI call is guarded behind `ctx.hasUI` and
52
+ * optional-chained so headless runs degrade gracefully. `runPipeline` is
53
+ * injected so the handler is unit-testable without spawning.
54
+ *
55
+ * Run tracking: the handler opens a tracked run session (a `runs` row visible on
56
+ * the Work page) around the pipeline — `start` before, `stage` for each pipeline
57
+ * boundary, and `finish` with an honest outcome afterwards. Every session call
58
+ * is fail-soft, so recording can never break `/go`.
59
+ */
60
+ import { execFile } from "node:child_process";
61
+ import { randomUUID } from "node:crypto";
62
+ import { existsSync } from "node:fs";
63
+ import { join } from "node:path";
64
+ import { eventToLine } from "./activity.js";
65
+ import { ActivityFeed, SPINNER_FRAMES } from "./activityFeed.js";
66
+ import { RunState } from "./runState.js";
67
+ import { aggregateRunUsage } from "./budget.js";
68
+ import { makeCombinedCheckpointStore, makeFileCheckpointStore, makePiJournalCheckpointStore, } from "./checkpoint.js";
69
+ import { getToken as defaultGetToken, resolveBaseUrl } from "../config.js";
70
+ import { isDesktopSurface } from "../surface.js";
71
+ import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js";
72
+ import { GO_USAGE, parseGoArgs } from "./goFlags.js";
73
+ import { registerGoStatusCommands } from "./goStatusCommands.js";
74
+ import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
75
+ import { planResume } from "./resume.js";
76
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunPromise, worktreesDir, } from "./runRegistry.js";
77
+ import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
78
+ import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
79
+ import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
80
+ import { parseChangedPaths } from "./verify.js";
81
+ import { bootstrapWorktree as defaultBootstrapWorktree, commitWip as defaultCommitWip, createRunWorktree as defaultCreateRunWorktree, runBranchName, wipCommitMessage, } from "./worktree.js";
82
+ /** The resume run key: the same ticket in the same worktree/branch resumes itself. */
83
+ function makeSessionKey(ticket, cwd, repo, branch) {
84
+ return [ticket, cwd ?? "", repo ?? "", branch ?? ""].join("::");
85
+ }
86
+ /**
87
+ * The default durable journal: a session-independent local NDJSON file (primary)
88
+ * plus, when the pi build exposes them, a mirror into pi's native append-only
89
+ * session journal (`pi.appendEntry` / `ctx.sessionManager.getEntries`). The
90
+ * mirror is skipped gracefully on a pi build (or a test mock) lacking those.
91
+ */
92
+ function defaultMakeCheckpointStore(sessionKey, pi, ctx) {
93
+ const stores = [makeFileCheckpointStore(sessionKey)];
94
+ const piAny = pi;
95
+ const ctxAny = ctx;
96
+ const appendEntry = piAny.appendEntry;
97
+ const sm = ctxAny.sessionManager;
98
+ if (typeof appendEntry === "function" && typeof sm?.getEntries === "function") {
99
+ stores.push(makePiJournalCheckpointStore(sessionKey, {
100
+ appendEntry: (t, d) => appendEntry.call(pi, t, d),
101
+ getEntries: () => sm.getEntries.call(sm),
102
+ }));
103
+ }
104
+ return makeCombinedCheckpointStore(stores);
105
+ }
106
+ /** Warning copy when a journal exists but the run is NOT safe to auto-resume. */
107
+ const WARN_PARTIAL_COPY = {
108
+ build_interrupted: "An earlier /go here was interrupted during the build stage; the working tree may hold partial changes. Starting fresh — check `git status` first.",
109
+ tree_diverged: "An earlier /go here was interrupted, and the working tree has changed since. Starting fresh rather than resuming onto a diverged tree — check `git status` first.",
110
+ };
111
+ /**
112
+ * Honest note for any review lens skipped because it failed (R2 partial-failure
113
+ * tolerance). Without this, a degraded round (the review ran on the survivors)
114
+ * would look identical to a full review, so the user never learns a lens was
115
+ * skipped. Empty when every round ran every lens. Uniqued across rounds.
116
+ */
117
+ function degradedLensNote(result) {
118
+ const skipped = new Set();
119
+ for (const r of result.rounds)
120
+ for (const lens of r.degradedLenses ?? [])
121
+ skipped.add(lens);
122
+ if (skipped.size === 0)
123
+ return "";
124
+ const list = [...skipped].join(", ");
125
+ const lensClause = skipped.size === 1 ? `a review lens (${list}) was` : `${skipped.size} review lenses (${list}) were`;
126
+ return `\n\nNote: ${lensClause} skipped after failing; the review proceeded on the surviving lenses.`;
127
+ }
128
+ /**
129
+ * Honest note when the P4 verify gate could not give a verdict (no build/test
130
+ * command detected, unrunnable, or timed out), so the user knows "clean" rested
131
+ * on the LLM review alone this run rather than an enforced build. Empty when the
132
+ * gate produced a verdict. No em-dashes (house copy rule).
133
+ */
134
+ function verifyNote(result) {
135
+ if (!result.verifyNote)
136
+ return "";
137
+ // The gate RAN (verifyCommand present) but part of it could not — e.g. the
138
+ // §3d test half's "tests could not run" / "tests skipped" notes. The note is
139
+ // already self-explanatory prose; surface it verbatim rather than wrapping it
140
+ // in the contradictory "did not run" copy below.
141
+ if (result.verifyCommand) {
142
+ return `\n\nNote: ${result.verifyNote}`;
143
+ }
144
+ return `\n\nNote: the build/test verify gate did not run (${result.verifyNote}), so "clean" reflects the review only. Add a verify command (package.json script or .yagni-code/verify) to enforce it.`;
145
+ }
146
+ /** Where the run's work lives, appended to the handoff for a worktree run. */
147
+ function worktreeNote(worktreePath, branch) {
148
+ if (!worktreePath)
149
+ return "";
150
+ return `\n\nThe run's work is on branch ${branch ?? "(unknown)"} in its worktree: ${worktreePath}`;
151
+ }
152
+ /**
153
+ * The FINISH stage's contribution to the handoff: the commit (+ PR) that landed,
154
+ * and every honest skip/failure note verbatim (§0.1 — a FINISH problem is a note
155
+ * on a still-successful run, never a laundered success or a hidden failure).
156
+ */
157
+ function finishNote(result, finish) {
158
+ const parts = [];
159
+ if (result.finish?.commitSha) {
160
+ const sha = result.finish.commitSha.slice(0, 7);
161
+ const branch = result.finish.branch ? ` on ${result.finish.branch}` : "";
162
+ const pr = result.finish.prUrl ? ` PR: ${result.finish.prUrl}` : "";
163
+ parts.push(`Committed ${sha}${branch}.${pr}`);
164
+ }
165
+ for (const note of finish?.notes ?? [])
166
+ parts.push(note);
167
+ if (parts.length === 0)
168
+ return "";
169
+ return `\n\n${parts.join("\n")}`;
170
+ }
171
+ /** Blocking-finding detail appended to the DURABLE handoff (spec: surface findings, not counts). */
172
+ function formatHandoff(result, ticket) {
173
+ const summary = formatSummary(result);
174
+ const degraded = `${degradedLensNote(result)}${verifyNote(result)}`;
175
+ const blocking = result.findings.filter((f) => f.severity === "critical" || f.severity === "high");
176
+ if (blocking.length === 0)
177
+ return `${summary}${degraded}`;
178
+ const lines = blocking.map((f) => {
179
+ const loc = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ""}` : "(no location)";
180
+ return ` • ${f.severity} | ${loc} | ${f.message} (${f.lens})`;
181
+ });
182
+ const next = result.stopReason === "round_cap" || result.stopReason === "failed"
183
+ ? `\n\nNext: address the finding${blocking.length === 1 ? "" : "s"} above (edit + commit), or re-run /go ${ticket} for another pass.`
184
+ : "";
185
+ return `${summary}\n\nUnresolved blocking finding${blocking.length === 1 ? "" : "s"}:\n${lines.join("\n")}${next}${degraded}`;
186
+ }
187
+ /**
188
+ * Per-run UI key prefix: the widget/status keys are `yagni-go:<runShortId>` so
189
+ * two concurrent runs each own their panel and clearUI removes only its own.
190
+ */
191
+ export const STATUS_KEY_PREFIX = "yagni-go:";
192
+ /**
193
+ * Per-run key for the desktop's structured state record. Distinct from
194
+ * STATUS_KEY_PREFIX so the app routes by key rather than sniffing content.
195
+ */
196
+ export const STATE_KEY_PREFIX = "yagni-go:state:";
197
+ /** Trailing-edge debounce window: coalesce bursty event paints to one per ~80ms. */
198
+ const PAINT_DEBOUNCE_MS = 80;
199
+ /**
200
+ * Steady animation cadence. The pipeline only emits events at stage/tool
201
+ * boundaries, so between them nothing would repaint and the spinner (and elapsed
202
+ * clock) freeze. A steady interval re-paints on its own, and the spinner frame is
203
+ * derived from wall-clock time so it advances smoothly no matter what triggered
204
+ * the paint. `setInterval` (not a self-rescheduling `setTimeout`) keeps the
205
+ * existing setTimeout-only mock-timer tests green: a real, immediately-cleared
206
+ * interval never fires inside those synchronous runs.
207
+ */
208
+ export const ANIMATION_INTERVAL_MS = 120;
209
+ const SPINNER_TICK_MS = 120;
210
+ /**
211
+ * Wall-clock liveness interval. /go pings POST /runs/:id/heartbeat on this
212
+ * cadence for the whole run so the server-side reaper (which fails a run idle
213
+ * past STALE_RUN_MS = 30 min) never reaps a legitimately-long run mid-flight —
214
+ * even a single quiet stage that emits no NDJSON for many minutes. Comfortably
215
+ * under the reaper window; fail-soft and unref'd so it never holds the process
216
+ * (or a test worker) open.
217
+ */
218
+ const HEARTBEAT_INTERVAL_MS = 5 * 60_000;
219
+ const STOP_REASON_COPY = {
220
+ clean: "clean, no blocking findings remain",
221
+ round_cap: "round cap reached, blocking findings remain",
222
+ aborted: "aborted before the review completed",
223
+ failed: "a review or fix stage failed before completing",
224
+ no_changes: "no changes were produced. The implement stage finished without writing any code, often because the plan it received was incomplete. Nothing was committed, re-run /go to retry",
225
+ budget_exceeded: "the run hit its budget ceiling (turns, cost, or tokens) and stopped before finishing. Review the partial change, then re-run /go to continue",
226
+ partial: "a build or fix stage stopped before finishing (a timeout or transient error once it had already begun editing). The partial changes are in your working tree, uncommitted. Review them, or re-run /go to retry",
227
+ };
228
+ /** Map the pipeline's stop reason onto the run-session outcome enum. */
229
+ function outcomeFor(stopReason) {
230
+ // `partial` is a preserved-but-incomplete run (work is in the tree), so it is a
231
+ // `stopped`, never a `failed` (which would imply nothing usable was produced).
232
+ if (stopReason === "aborted" || stopReason === "budget_exceeded" || stopReason === "partial")
233
+ return "stopped";
234
+ if (stopReason === "failed")
235
+ return "failed";
236
+ return "completed"; // clean | round_cap are both real completions
237
+ }
238
+ /**
239
+ * The bare "what was done" recap — stop reason + the review tally — with NO
240
+ * "YAGNI Code /go finished:" prefix. This is what rides `finish` as the
241
+ * `run_outcome` activity body. The Work-page timeline already labels that beat
242
+ * "Run outcome", so prefixing here would double up into "Run outcome: YAGNI
243
+ * Code /go finished: ...". `formatSummary` re-adds the prefix for the standalone
244
+ * toast + scrollback line, where the context is useful on its own.
245
+ */
246
+ function recapBody(result) {
247
+ const rounds = result.rounds.length;
248
+ const total = result.findings.length;
249
+ const blocking = result.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
250
+ const reason = STOP_REASON_COPY[result.stopReason];
251
+ return (`${reason}. ` +
252
+ `${rounds} review round${rounds === 1 ? "" : "s"}, ` +
253
+ `${total} finding${total === 1 ? "" : "s"} (${blocking} blocking).` +
254
+ runCostNote(result));
255
+ }
256
+ /**
257
+ * What the run actually cost.
258
+ *
259
+ * The number was already being computed every round to enforce
260
+ * `DEFAULT_RUN_BUDGET` and then discarded, so reporting it is nearly free. It
261
+ * matters more now that plan and review run on `peak`: without this line a /go
262
+ * run's entire spend is invisible, because `/cost` accumulates off the parent
263
+ * session's `turn_end` and every stage runs in a child process. YAG-383 is the
264
+ * server-authoritative fix; this is the honest number we already hold.
265
+ */
266
+ function runCostNote(result) {
267
+ const usage = aggregateRunUsage(result.stages, result.rounds);
268
+ if (usage.cost <= 0 && usage.input === 0 && usage.output === 0)
269
+ return "";
270
+ const tokens = (usage.input + usage.output).toLocaleString("en-US");
271
+ return ` Run cost: $${usage.cost.toFixed(2)} over ${tokens} tokens.`;
272
+ }
273
+ /**
274
+ * One-line, human-readable outcome for the session + a UI toast. This is the
275
+ * DURABLE record (sent via `sendUserMessage`, fired even on headless runs where
276
+ * no `ActivityFeed` is constructed), so it is intentionally kept separate from
277
+ * `ActivityFeed.finalLine` (the feed's compact, UI-only outcome glyph-line)
278
+ * rather than the two sharing one outcome-copy source.
279
+ */
280
+ function formatSummary(result) {
281
+ return `YAGNI Code /go finished: ${recapBody(result)}`;
282
+ }
283
+ /**
284
+ * User-facing notice when a /go run is NOT being recorded on the Work page.
285
+ * Without this, a flag-off / unauthorized / unreachable backend fails silently
286
+ * (the run-session swallows it) and the run vanishes with no explanation.
287
+ */
288
+ const UNTRACKED_NOTICE = {
289
+ disabled: "Run tracking is off for this workspace, so this /go run won't appear on the Work page.",
290
+ unauthorized: "Run tracking is unauthorized. Run `yagni login` to refresh your token so runs appear on the Work page.",
291
+ error: "Couldn't reach YAGNI to record this run, so it won't appear on the Work page. The pipeline ran anyway.",
292
+ };
293
+ /** Toast severity for an outcome: clean is good, round_cap warns, abort/fail are errors. */
294
+ function summaryTone(stopReason) {
295
+ if (stopReason === "clean")
296
+ return "info";
297
+ if (stopReason === "round_cap" ||
298
+ stopReason === "no_changes" ||
299
+ stopReason === "budget_exceeded" ||
300
+ stopReason === "partial")
301
+ return "warning";
302
+ return "error";
303
+ }
304
+ /** A stage transition / completion should paint immediately; findings can ride the debounce. */
305
+ function isTransition(p) {
306
+ return p.kind !== "findings";
307
+ }
308
+ /** The cheap status-line fallback for terminals where the widget is unavailable. */
309
+ function shortLabel(p) {
310
+ switch (p.kind) {
311
+ case "stage_start":
312
+ return p.round ? `${p.stageId} (round ${p.round})…` : `${p.stageId}…`;
313
+ case "findings":
314
+ return `round ${p.round}: ${p.total} finding${p.total === 1 ? "" : "s"}`;
315
+ default:
316
+ return undefined;
317
+ }
318
+ }
319
+ /** Best-effort `git` in `cwd`; resolves to undefined on any failure. */
320
+ function git(args, cwd, signal) {
321
+ return new Promise((resolve) => {
322
+ execFile("git", args, { cwd, signal, maxBuffer: 1024 * 1024 }, (err, stdout) => resolve(err ? undefined : stdout.trim() || undefined));
323
+ });
324
+ }
325
+ /** Parse `owner/name` from an origin remote URL (ssh or https). */
326
+ function parseRepo(remote) {
327
+ if (!remote)
328
+ return undefined;
329
+ const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
330
+ return m ? m[1] : undefined;
331
+ }
332
+ /** Default cwd-derived git context (branch, commit, owner/name); all best-effort. */
333
+ async function defaultResolveRepoContext(cwd, signal) {
334
+ const [branch, commitSha, remote] = await Promise.all([
335
+ git(["rev-parse", "--abbrev-ref", "HEAD"], cwd, signal),
336
+ git(["rev-parse", "HEAD"], cwd, signal),
337
+ git(["config", "--get", "remote.origin.url"], cwd, signal),
338
+ ]);
339
+ return { repo: parseRepo(remote), branch, commitSha };
340
+ }
341
+ export function registerGoCommand(pi, deps = {}) {
342
+ const runPipeline = deps.runPipeline ?? defaultRunPipeline;
343
+ const makeRunSession = deps.makeRunSession ?? defaultMakeRunSession;
344
+ const resolveRepoContext = deps.resolveRepoContext ?? defaultResolveRepoContext;
345
+ const makeCheckpointStore = deps.makeCheckpointStore ?? defaultMakeCheckpointStore;
346
+ const resolveSnapshot = deps.resolveSnapshot ?? defaultSnapshotWorkspace;
347
+ const createWorktree = deps.createWorktree ?? defaultCreateRunWorktree;
348
+ const bootstrap = deps.bootstrap ?? defaultBootstrapWorktree;
349
+ const commitWipFn = deps.commitWip ?? defaultCommitWip;
350
+ const runFinishFn = deps.runFinish ?? defaultRunFinish;
351
+ const loadJournal = deps.loadJournal ?? ((sessionKey) => makeFileCheckpointStore(sessionKey).load());
352
+ const exists = deps.exists ?? existsSync;
353
+ const now = deps.now ?? Date.now;
354
+ const resolveTicketBrief = deps.resolveTicketBrief ??
355
+ ((rawArg) => defaultResolveTicketBrief({ baseUrl: deps.baseUrl ?? resolveBaseUrl(), getToken: deps.getToken ?? defaultGetToken }, rawArg));
356
+ pi.registerCommand("go", {
357
+ description: "Run the grounded multi-agent pipeline (map → plan → implement → review → fix) on a ticket and produce a reviewed change.",
358
+ handler: async (args, ctx) => {
359
+ const notify = (message, type) => {
360
+ if (ctx.hasUI)
361
+ ctx.ui.notify(message, type);
362
+ };
363
+ // Flags first (spec §3a): everything downstream gets the FLAG-FREE ticket.
364
+ const parsed = parseGoArgs(args);
365
+ if (parsed.unknownFlags.length > 0) {
366
+ notify(`Unknown flag${parsed.unknownFlags.length === 1 ? "" : "s"}: ${parsed.unknownFlags.join(", ")}. ${GO_USAGE}`, "warning");
367
+ return;
368
+ }
369
+ const ticket = parsed.ticket;
370
+ if (!ticket) {
371
+ notify(GO_USAGE, "warning");
372
+ return;
373
+ }
374
+ if (!ctx.isIdle()) {
375
+ notify("YAGNI Code is busy; wait for the current turn to finish before running /go.", "warning");
376
+ return;
377
+ }
378
+ // In-flight guards: the same ticket never runs twice at once in this
379
+ // process, and at most MAX_CONCURRENT_RUNS runs are in flight.
380
+ if (findActiveRunByTicket(ticket)) {
381
+ notify(`/go ${ticket} is already running - see /go-status.`, "warning");
382
+ return;
383
+ }
384
+ if (activeRunCount() >= MAX_CONCURRENT_RUNS) {
385
+ notify(`${MAX_CONCURRENT_RUNS} /go runs are already in flight; wait for one to finish (see /go-status).`, "warning");
386
+ return;
387
+ }
388
+ // --- Run tree resolution: worktree by default; --here = legacy in-place.
389
+ // The worktree + run branch are created FIRST so repoCtx, sessionKey, the
390
+ // journal, and every snapshot resolve against the WORKTREE path.
391
+ let runId = randomUUID();
392
+ let runCwd = ctx.cwd;
393
+ let worktreeMode = false;
394
+ let worktreePath;
395
+ // True when this run reuses an INTERRUPTED prior run's worktree. On a
396
+ // resume_loop that tree is intentionally kept (it holds the completed
397
+ // build); on a warn_partial we run fresh and must first clear the abandoned
398
+ // partial edits so review + FINISH see only THIS run's work (see below).
399
+ let adoptedWorktree = false;
400
+ if (!parsed.flags.here) {
401
+ // Registry-driven resume: a prior run of this ticket that is non-terminal
402
+ // AND not in flight is a resume candidate; a journal quiet for > 30 min
403
+ // means its process died (interrupted), while recent activity means it is
404
+ // probably still running in ANOTHER session — refuse rather than fork it.
405
+ let adopted;
406
+ const candidates = loadRegistryRows().filter((r) => r.ticket === ticket && !isTerminalStatus(r.status) && !!r.worktreePath);
407
+ if (candidates.length > 0) {
408
+ const baseRepo = (await resolveRepoContext(ctx.cwd, ctx.signal)).repo;
409
+ for (const row of candidates.slice().sort((a, b) => b.startedAt - a.startedAt)) {
410
+ if (baseRepo && row.repo && row.repo !== baseRepo)
411
+ continue;
412
+ let journal = [];
413
+ try {
414
+ journal = loadJournal(row.sessionKey) ?? [];
415
+ }
416
+ catch {
417
+ journal = [];
418
+ }
419
+ const liveness = classifyRunLiveness(row, {
420
+ inFlight: isRunInFlight(row.runId),
421
+ lastJournalTs: lastJournalTs(journal) || undefined,
422
+ now: now(),
423
+ });
424
+ if (liveness === "in_flight" || liveness === "active_elsewhere") {
425
+ notify(`/go ${ticket} appears to be running already (its journal is still active) - see /go-status.`, "warning");
426
+ return;
427
+ }
428
+ if (liveness === "interrupted" && row.worktreePath && exists(row.worktreePath)) {
429
+ adopted = row;
430
+ break;
431
+ }
432
+ }
433
+ }
434
+ if (adopted?.worktreePath) {
435
+ // Resume INTO the interrupted run's worktree: same tree => same
436
+ // sessionKey => same journal file, so planResume sees the checkpoints.
437
+ runId = adopted.runId;
438
+ worktreePath = adopted.worktreePath;
439
+ worktreeMode = true;
440
+ runCwd = adopted.worktreePath;
441
+ adoptedWorktree = true;
442
+ notify(`Found an interrupted /go for "${ticket}"; picking it up in its worktree (${worktreePath}).`, "info");
443
+ }
444
+ else {
445
+ const shortId = runId.slice(0, 8);
446
+ const path = join(worktreesDir(), shortId);
447
+ const branch = runBranchName(ticket, shortId);
448
+ try {
449
+ await createWorktree({ repoCwd: ctx.cwd, worktreePath: path, branch, signal: ctx.signal });
450
+ worktreeMode = true;
451
+ worktreePath = path;
452
+ runCwd = path;
453
+ // Best-effort bootstrap (fail-open): without node_modules every
454
+ // verify/typecheck would exit `command not found`.
455
+ const boot = await bootstrap(path, { signal: ctx.signal });
456
+ if (boot.note)
457
+ notify(boot.note, "warning");
458
+ }
459
+ catch (err) {
460
+ const msg = err instanceof Error ? err.message : String(err);
461
+ notify(`Couldn't create a run worktree (${msg}). Stopping before mutating your working tree; re-run /go ${ticket} --here to run in place intentionally.`, "error");
462
+ return;
463
+ }
464
+ }
465
+ }
466
+ const runShortId = runId.slice(0, 8);
467
+ const uiKey = `${STATUS_KEY_PREFIX}${runShortId}`;
468
+ // Live feed state. Only constructed (and painted) when there is a UI, and
469
+ // exactly one of the two exists: the surfaces are mutually exclusive.
470
+ // `feed` renders the terminal's ANSI panel; `run` is the structured record
471
+ // the desktop renders itself from.
472
+ const desktop = isDesktopSurface();
473
+ const feed = ctx.hasUI && !desktop ? new ActivityFeed(ticket) : undefined;
474
+ const startedAt = Date.now();
475
+ const run = ctx.hasUI && desktop ? new RunState({ runId: runShortId, ticket, startedAt }) : undefined;
476
+ const stateKey = `${STATE_KEY_PREFIX}${runShortId}`;
477
+ let paintTimer;
478
+ let animationTimer;
479
+ let heartbeatTimer;
480
+ const elapsed = () => `${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s`;
481
+ const doPaint = () => {
482
+ if (!ctx.hasUI)
483
+ return;
484
+ if (run) {
485
+ // One line, no ANSI, no line caps: RPC mode forwards widgetLines
486
+ // verbatim, so the interactive 10-line cap does not apply here.
487
+ ctx.ui.setWidget?.(stateKey, [JSON.stringify(run)], { placement: "aboveEditor" });
488
+ return;
489
+ }
490
+ if (!feed)
491
+ return;
492
+ const theme = ctx.ui.theme;
493
+ if (!theme)
494
+ return; // no theme -> rely on the status-line fallback only
495
+ // Time-based frame: advances on the wall clock, so the steady ticker keeps
496
+ // the spinner moving between pipeline events instead of freezing on one glyph.
497
+ const frame = SPINNER_FRAMES[Math.floor((Date.now() - startedAt) / SPINNER_TICK_MS) % SPINNER_FRAMES.length];
498
+ ctx.ui.setWidget?.(uiKey, feed.render(theme, elapsed(), frame), { placement: "aboveEditor" });
499
+ };
500
+ const paint = (immediate = false) => {
501
+ if (!ctx.hasUI || (!feed && !run))
502
+ return;
503
+ if (immediate) {
504
+ if (paintTimer) {
505
+ clearTimeout(paintTimer);
506
+ paintTimer = undefined;
507
+ }
508
+ doPaint();
509
+ return;
510
+ }
511
+ // Trailing-edge: one pending paint per window; it renders the latest state.
512
+ if (paintTimer)
513
+ return;
514
+ paintTimer = setTimeout(() => {
515
+ paintTimer = undefined;
516
+ doPaint();
517
+ }, PAINT_DEBOUNCE_MS);
518
+ paintTimer.unref?.();
519
+ };
520
+ // Per-run clearUI: only ever touches THIS run's widget/status keys, so a
521
+ // concurrent run's panel is never cleared by this run finishing.
522
+ const clearUI = () => {
523
+ // Stamp and paint the terminal state BEFORE the timers go, so the
524
+ // desktop's last record carries the outcome rather than being cut off
525
+ // by the pending-paint cancellation below. Both exit paths (success and
526
+ // catch) converge here, which is why the stamp lives inside clearUI.
527
+ if (run) {
528
+ run.markEnded(Date.now());
529
+ doPaint();
530
+ }
531
+ if (animationTimer) {
532
+ clearInterval(animationTimer);
533
+ animationTimer = undefined;
534
+ }
535
+ if (heartbeatTimer) {
536
+ clearInterval(heartbeatTimer);
537
+ heartbeatTimer = undefined;
538
+ }
539
+ if (paintTimer) {
540
+ clearTimeout(paintTimer);
541
+ paintTimer = undefined;
542
+ }
543
+ if (ctx.hasUI && !desktop) {
544
+ // The desktop keeps its finished run visible: it is a monitor you can
545
+ // look back at, and the last record already carries the stop reason.
546
+ // Only the terminal's transient panel and status chip are torn down.
547
+ ctx.ui.setWidget?.(uiKey, undefined);
548
+ ctx.ui.setStatus?.(uiKey, undefined);
549
+ }
550
+ };
551
+ // Open the tracked run session (fail-soft — never blocks /go).
552
+ const session = makeRunSession({
553
+ baseUrl: deps.baseUrl ?? resolveBaseUrl(),
554
+ getToken: deps.getToken ?? defaultGetToken,
555
+ });
556
+ const repoCtx = await resolveRepoContext(runCwd, ctx.signal);
557
+ // Durable resilience journal, keyed by the RUN tree (the worktree path for
558
+ // a default run; ctx.cwd for --here). Detect an interrupted prior /go for
559
+ // this ticket+tree and either resume the (expensive) review loop or warn.
560
+ // The whole block is fail-soft: any error degrades to a normal fresh run.
561
+ const sessionKey = makeSessionKey(ticket, runCwd, repoCtx.repo, repoCtx.branch);
562
+ const store = makeCheckpointStore(sessionKey, pi, ctx);
563
+ let plan = { mode: "fresh" };
564
+ let snapshot;
565
+ // Whether this exact ticket+tree already has a /go journal. A dirty tree that
566
+ // belongs to this ticket's own prior pass (a round_cap / partial re-run) is NOT
567
+ // foreign, so the dirty-tree notice below is suppressed for it.
568
+ let priorRunExists = false;
569
+ try {
570
+ snapshot = await resolveSnapshot(runCwd, ctx.signal);
571
+ const records = store.load();
572
+ priorRunExists = records.length > 0;
573
+ plan = planResume(records, snapshot);
574
+ }
575
+ catch {
576
+ plan = { mode: "fresh" };
577
+ }
578
+ // The --here FINISH commit gate (spec §3c): an agent commit may only fold
579
+ // in the run's own work, so in-place commits require a verifiably-CLEAN
580
+ // pre-run baseline. Unknowable (untracked / snapshot failed) is treated as
581
+ // not-clean — FINISH then skips with an honest note rather than guessing.
582
+ // Worktree mode ignores this (a fresh worktree starts clean by construction).
583
+ const hereBaseline = !snapshot?.tracked
584
+ ? "unknown"
585
+ : snapshot.status.trim().length === 0
586
+ ? "clean"
587
+ : "dirty";
588
+ // Pre-flight dirty-tree notice (journal-aware, warn-and-proceed) — IN-PLACE
589
+ // runs only (a fresh worktree starts clean, so it is moot there). The review
590
+ // half is diff-driven (each reviewer runs `git diff`), so a FRESH /go over a
591
+ // tree that already holds uncommitted changes folds them into what it reviews.
592
+ // We WARN loudly + durably and PROCEED rather than block: a hard stop (and a
593
+ // headless abort) would break the documented "re-run /go to continue" after a
594
+ // round_cap / partial stop, where the tree is legitimately dirty with THIS
595
+ // ticket's own prior pass. So we stay silent when a prior /go on this same ticket
596
+ // explains the dirt (planResume reports a completed prior run as `fresh`), and
597
+ // only flag genuinely-foreign changes. A resume / warn_partial run is expected to
598
+ // be dirty and is never `fresh`. Fails open when no snapshot was resolved.
599
+ if (!worktreeMode &&
600
+ plan.mode === "fresh" &&
601
+ !priorRunExists &&
602
+ snapshot?.tracked &&
603
+ snapshot.status.trim().length > 0) {
604
+ const notice = `Heads up: your working tree already has uncommitted changes. /go reviews the working-tree diff, ` +
605
+ `so it will include them in what it reviews for ${ticket}. If they are unrelated, stop and commit ` +
606
+ `or stash them, then re-run /go ${ticket}.`;
607
+ notify(notice, "warning");
608
+ await pi.sendUserMessage(notice);
609
+ }
610
+ // Open or re-adopt the backend run row. A fresh run calls start(); a resume
611
+ // re-binds the original run (no duplicate row) when it was tracked.
612
+ const openFreshRun = async () => {
613
+ const started = await session.start({ arg: ticket, repo: repoCtx.repo, branch: repoCtx.branch });
614
+ if (started.tracked) {
615
+ if (started.gated) {
616
+ notify("This ticket needs approval in the YAGNI app before it shows as executing.", "warning");
617
+ }
618
+ return { runId: started.runId, workItemId: started.workItemId };
619
+ }
620
+ // Recording skipped (flag off) or failed (unauthorized / unreachable):
621
+ // tell the user the run won't show on the Work page rather than swallowing it.
622
+ notify(UNTRACKED_NOTICE[started.reason], "warning");
623
+ return {};
624
+ };
625
+ let resumeFrom;
626
+ let handle = {};
627
+ if (plan.mode === "resume_loop") {
628
+ notify(`Resuming /go for "${ticket}" from review round ${plan.round} — picking up the interrupted run instead of starting over.`, "info");
629
+ await pi.sendUserMessage(`Resuming an interrupted /go for "${ticket}" at review round ${plan.round}. The build is already in ${worktreeMode ? "the run worktree" : "your working tree"}; continuing the review loop.`);
630
+ resumeFrom = plan;
631
+ // Open a FRESH run attempt rather than re-binding the original handle.
632
+ // An interrupted run that slept past the reaper window is already a
633
+ // terminal 'failed' server-side, and binding it would strand THIS
634
+ // completed resume as a permanent phantom failure (failed is terminal —
635
+ // finish can't promote it). A fresh attempt records the resumed pass
636
+ // honestly; the original crashed attempt settles on its own (reaper).
637
+ handle = await openFreshRun();
638
+ }
639
+ else {
640
+ if (plan.mode === "warn_partial") {
641
+ notify(WARN_PARTIAL_COPY[plan.reason], "warning");
642
+ // We are DISCARDING the interrupted run's context (fresh journal, no
643
+ // resumeFrom). If we adopted its worktree, its abandoned partial edits
644
+ // are still in the tree — the diff-driven review would fold them in and
645
+ // FINISH's worktree `git add -A` would commit them under a clean
646
+ // provenance trailer (the "clean by construction" assumption is false
647
+ // here). Preserve that residue as a WIP commit on the run branch so the
648
+ // tree returns to its committed baseline before the fresh run: review
649
+ // and FINISH then see only THIS run's work, and nothing is lost.
650
+ if (adoptedWorktree && worktreeMode) {
651
+ try {
652
+ await commitWipFn(runCwd, wipCommitMessage(ticket, "interrupted-residue"));
653
+ }
654
+ catch {
655
+ /* fail-soft: an uncommittable residue just stays; the warn stands */
656
+ }
657
+ }
658
+ }
659
+ // Fresh run: drop any stale/partial journal so the new run writes clean.
660
+ store.clear();
661
+ handle = await openFreshRun();
662
+ }
663
+ const checkpointMeta = {
664
+ sessionKey,
665
+ ...(handle.runId ? { runId: handle.runId } : {}),
666
+ ...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
667
+ ...(repoCtx.repo ? { repo: repoCtx.repo } : {}),
668
+ ...(repoCtx.branch ? { branch: repoCtx.branch } : {}),
669
+ };
670
+ // Mark the journal terminal (fail-soft). A run_finish makes the key read as
671
+ // a COMPLETED run (so the next /go starts fresh, not a bogus resume); its
672
+ // absence is exactly what marks a crashed run resumable.
673
+ const recordRunFinish = (stopReason) => {
674
+ try {
675
+ store.record({
676
+ v: 1,
677
+ kind: "run_finish",
678
+ sessionKey,
679
+ ts: Date.now(),
680
+ ticket,
681
+ ...(handle.runId ? { runId: handle.runId } : {}),
682
+ ...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
683
+ stopReason: stopReason,
684
+ });
685
+ }
686
+ catch {
687
+ /* fail-soft */
688
+ }
689
+ };
690
+ // The run registry row (the LOCAL run identity — the backend handle lives
691
+ // on the checkpoint meta). Registered before the run detaches so the
692
+ // in-flight guards and /go-status see it immediately.
693
+ const registryRow = {
694
+ runId,
695
+ ticket,
696
+ sessionKey,
697
+ ...(repoCtx.repo ? { repo: repoCtx.repo } : {}),
698
+ ...(worktreePath ? { worktreePath } : {}),
699
+ ...(repoCtx.branch ? { branch: repoCtx.branch } : {}),
700
+ status: "running",
701
+ startedAt: now(),
702
+ };
703
+ // Wall-clock liveness heartbeat for the whole run; cleared in clearUI. Each
704
+ // tick is fail-soft and no-ops until the run is bound. Unref'd so it never
705
+ // keeps the process (or a test worker) alive.
706
+ heartbeatTimer = setInterval(() => void session.heartbeat(), HEARTBEAT_INTERVAL_MS);
707
+ heartbeatTimer.unref?.();
708
+ // Deliver the durable end-of-run handoff. A detached run completes while
709
+ // the agent may be mid-turn, so pi requires deliverAs; a delivery failure
710
+ // falls back to a notify (and /go-status remains the pull path).
711
+ const deliverHandoff = async (text, tone) => {
712
+ try {
713
+ await pi.sendUserMessage(text, { deliverAs: "followUp" });
714
+ }
715
+ catch {
716
+ try {
717
+ notify(text, tone);
718
+ }
719
+ catch {
720
+ /* the pull path (/go-status) still has the outcome */
721
+ }
722
+ }
723
+ };
724
+ // Preserve a non-clean terminal outcome's work as a clearly-marked WIP
725
+ // commit on the run branch (worktree mode only — NEVER in --here, where the
726
+ // tree is the user's own). Fail-soft: an uncommittable tree just stays
727
+ // dirty and /go-clean reports it honestly.
728
+ const preserveWip = async (stopReason) => {
729
+ if (!worktreeMode)
730
+ return undefined;
731
+ try {
732
+ return await commitWipFn(runCwd, wipCommitMessage(ticket, stopReason));
733
+ }
734
+ catch {
735
+ return undefined;
736
+ }
737
+ };
738
+ const runToCompletion = async () => {
739
+ try {
740
+ // Steady animation ticker: keeps the spinner + elapsed clock live between
741
+ // pipeline events. UI-only, self-unref'd so it never holds the loop open,
742
+ // and cleared in clearUI on every exit path.
743
+ if (ctx.hasUI && feed) {
744
+ animationTimer = setInterval(doPaint, ANIMATION_INTERVAL_MS);
745
+ animationTimer.unref?.();
746
+ }
747
+ // Resolve the ticket reference to its REAL brief (verbatim title + body) so
748
+ // the agents plan on the actual ticket, not the bare "YAG-234". Fail-soft:
749
+ // null keeps the raw arg (the honest blind fallback). The raw `ticket` stays
750
+ // the session key, the run row's arg, and the handoff/re-run copy.
751
+ const ticketBrief = await resolveTicketBrief(ticket);
752
+ // The plan stage's output, captured off the onStage boundary for the
753
+ // FINISH commit message's 2-3 sentence summary (absent on a resume).
754
+ let planText;
755
+ // Baseline dirt at pipeline start (post-bootstrap, post-adopt-cleanup):
756
+ // anything already changed here is NOT the run's own work (a lockfile a
757
+ // fresh-worktree install rewrote, or — in --here — the user's tree). FINISH
758
+ // excludes these paths so the provenance commit holds only the run's diff.
759
+ // Fail-soft: an unreadable snapshot ⇒ no exclusions (git add -A as before).
760
+ let finishBaselinePaths = [];
761
+ try {
762
+ const baseSnap = await resolveSnapshot(runCwd, ctx.signal);
763
+ if (baseSnap.tracked)
764
+ finishBaselinePaths = parseChangedPaths(baseSnap.status);
765
+ }
766
+ catch {
767
+ /* fail-soft: leave baseline empty */
768
+ }
769
+ const result = await runPipeline(ticket, {
770
+ cwd: runCwd,
771
+ signal: ctx.signal,
772
+ ...(ticketBrief ? { ticketBrief } : {}),
773
+ onProgress: (p) => {
774
+ feed?.applyProgress(p);
775
+ run?.applyProgress(p, Date.now());
776
+ // The ribbon replaces the status chip on the desktop, so the chip is
777
+ // only set for the terminal's status-line fallback.
778
+ if (ctx.hasUI && !desktop) {
779
+ const label = shortLabel(p);
780
+ if (label !== undefined)
781
+ ctx.ui.setStatus?.(uiKey, label);
782
+ }
783
+ paint(isTransition(p));
784
+ },
785
+ onEvent: (ev, tag) => {
786
+ if (!feed && !run)
787
+ return;
788
+ const at = Date.now();
789
+ // Usage folds per agent regardless of whether this event also
790
+ // produces a curated line, so per-lens token counts stay honest.
791
+ run?.applyUsage(ev, tag, at);
792
+ const line = eventToLine(ev, tag);
793
+ if (line) {
794
+ feed?.applyEvent(line, tag);
795
+ run?.applyLine(line, tag, at);
796
+ }
797
+ else if (!run) {
798
+ return;
799
+ }
800
+ paint(false);
801
+ },
802
+ onStage: (s) => {
803
+ // The plan text rides ONLY the plan-finish boundary; strip it from the
804
+ // stage beat (which never carries full text) and record the plan onto
805
+ // the work item instead. Both calls are fail-soft inside the session.
806
+ const { output, ...stageArgs } = s;
807
+ void session.stage(stageArgs);
808
+ if (s.stage === "plan" && s.phase === "finish" && output) {
809
+ planText = output;
810
+ void session.recordPlan({ stage: "plan", body: output });
811
+ }
812
+ },
813
+ checkpoint: store,
814
+ checkpointMeta,
815
+ ...(resumeFrom ? { resumeFrom } : {}),
816
+ });
817
+ // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
818
+ // (worktree always; --here only over a clean pre-run baseline) with the
819
+ // provenance trailer, and push + PR when --pr asked for it. Iron
820
+ // invariant (§0.1): a FINISH failure never flips the clean run to
821
+ // failed and never fakes a commit — runFinish never throws, and the
822
+ // belt-and-braces catch below degrades to an honest note anyway.
823
+ let finish;
824
+ if (result.stopReason === "clean") {
825
+ feed?.applyProgress({ kind: "stage_start", stageId: "finish" });
826
+ run?.applyProgress({ kind: "stage_start", stageId: "finish" }, Date.now());
827
+ if (ctx.hasUI && !desktop)
828
+ ctx.ui.setStatus?.(uiKey, "finish…");
829
+ paint(true);
830
+ void session.stage({ stage: "finish", phase: "start" });
831
+ try {
832
+ finish = await runFinishFn({
833
+ cwd: runCwd,
834
+ worktreeMode,
835
+ hereBaseline,
836
+ ...(repoCtx.branch ? { branch: repoCtx.branch } : {}),
837
+ ticket,
838
+ ...(ticketBrief ? { ticketBrief } : {}),
839
+ ...(planText ? { planText } : {}),
840
+ runId,
841
+ verify: verifyTrailerValue(result),
842
+ decisionsRecorded: result.decisionsRecorded ?? 0,
843
+ rounds: result.rounds.length,
844
+ openPr: parsed.flags.pr,
845
+ remainingFindings: result.findings.filter((f) => f.severity === "medium" || f.severity === "low"),
846
+ ...(finishBaselinePaths.length > 0 ? { baselinePaths: finishBaselinePaths } : {}),
847
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
848
+ });
849
+ }
850
+ catch (err) {
851
+ const msg = err instanceof Error ? err.message : String(err);
852
+ finish = {
853
+ committed: false,
854
+ notes: [`FINISH failed: ${msg}. The run itself is still clean; the changes are left in ${runCwd}.`],
855
+ };
856
+ }
857
+ void session.stage({ stage: "finish", phase: "finish" });
858
+ feed?.applyProgress({ kind: "stage_done", stageId: "finish" });
859
+ run?.applyProgress({ kind: "stage_done", stageId: "finish" }, Date.now());
860
+ paint(true);
861
+ // Durable finish_done boundary (fail-soft, like every journal write).
862
+ try {
863
+ store.record({
864
+ v: 1,
865
+ kind: "finish_done",
866
+ sessionKey,
867
+ ts: Date.now(),
868
+ ticket,
869
+ ...(handle.runId ? { runId: handle.runId } : {}),
870
+ ...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
871
+ ...(finish.commitSha ? { commitSha: finish.commitSha } : {}),
872
+ ...(finish.prUrl ? { prUrl: finish.prUrl } : {}),
873
+ });
874
+ }
875
+ catch {
876
+ /* fail-soft */
877
+ }
878
+ }
879
+ clearUI();
880
+ // Fold what FINISH landed onto a NEW result (never mutate the pipeline's
881
+ // return). `finish` is present ONLY when a commit really happened (§0.1).
882
+ const finishBranch = finish?.branch ?? repoCtx.branch;
883
+ const finishInfo = finish?.commitSha
884
+ ? {
885
+ commitSha: finish.commitSha,
886
+ ...(finishBranch ? { branch: finishBranch } : {}),
887
+ ...(finish.prUrl ? { prUrl: finish.prUrl } : {}),
888
+ ...(finish.prNumber != null ? { prNumber: finish.prNumber } : {}),
889
+ }
890
+ : undefined;
891
+ const finalResult = { ...result, ...(finishInfo ? { finish: finishInfo } : {}) };
892
+ const outcome = outcomeFor(finalResult.stopReason);
893
+ // The end-of-run recap: "what was done" (rounds, findings, stop reason).
894
+ // The bare `recapBody` rides `finish` as the run_outcome activity (the
895
+ // timeline labels that beat "Run outcome"); the prefixed `formatSummary`
896
+ // is the standalone toast + scrollback line shown to the user below.
897
+ // Non-clean terminal outcome in worktree mode: preserve the work as a
898
+ // WIP commit so it is never lost and the worktree becomes removable.
899
+ // (A clean outcome's commit is the FINISH stage's, captured above.)
900
+ // Computed BEFORE session.finish so the run reports the sha that actually
901
+ // holds its work — not the pre-run base HEAD (a base-HEAD fallback would
902
+ // both misattribute the commit and spuriously advance a work-less run).
903
+ const wipSha = finalResult.stopReason === "clean" ? undefined : await preserveWip(finalResult.stopReason);
904
+ const runCommitSha = finishInfo?.commitSha ?? wipSha;
905
+ const summary = formatSummary(finalResult);
906
+ await session.finish({
907
+ outcome,
908
+ stopReason: outcome === "completed" ? undefined : STOP_REASON_COPY[finalResult.stopReason],
909
+ branch: finishInfo?.branch ?? repoCtx.branch,
910
+ ...(runCommitSha ? { commitSha: runCommitSha } : {}),
911
+ ...(finishInfo?.prUrl ? { prUrl: finishInfo.prUrl } : {}),
912
+ ...(finishInfo?.prNumber != null ? { prNumber: finishInfo.prNumber } : {}),
913
+ rounds: finalResult.rounds.length,
914
+ outcomeSummary: recapBody(finalResult),
915
+ });
916
+ recordRunFinish(finalResult.stopReason);
917
+ settleRun(runId, {
918
+ status: finalResult.stopReason,
919
+ ...(runCommitSha ? { commitSha: runCommitSha } : {}),
920
+ ...(finishInfo?.prUrl ? { prUrl: finishInfo.prUrl } : {}),
921
+ });
922
+ notify(summary, summaryTone(finalResult.stopReason));
923
+ // The DURABLE record surfaces the unresolved blocking findings IN FULL
924
+ // (file:line + message), so a later turn never has to rediscover them.
925
+ await deliverHandoff(`${formatHandoff(finalResult, ticket)}${finishNote(finalResult, finish)}${worktreeNote(worktreePath, repoCtx.branch)}`, summaryTone(finalResult.stopReason));
926
+ }
927
+ catch (err) {
928
+ clearUI();
929
+ const message = err instanceof Error ? err.message : String(err);
930
+ await session.finish({ outcome: "failed", stopReason: message });
931
+ recordRunFinish("failed");
932
+ const wipSha = await preserveWip("failed");
933
+ settleRun(runId, { status: "failed", ...(wipSha ? { commitSha: wipSha } : {}) });
934
+ notify(`/go failed: ${message}`, "error");
935
+ await deliverHandoff(`/go failed: ${message}`, "error");
936
+ }
937
+ };
938
+ beginRun(registryRow);
939
+ if (parsed.flags.fg) {
940
+ // --fg: the legacy blocking behavior — the prompt returns when the run does.
941
+ await runToCompletion();
942
+ return;
943
+ }
944
+ // Detached by default: the prompt returns now; the tracked promise settles
945
+ // the registry row and delivers the handoff as a followUp when it lands.
946
+ // The backstop catch keeps a detached rejection from ever going unhandled
947
+ // (runToCompletion's own catch already reports honestly).
948
+ const done = runToCompletion().catch(() => {
949
+ try {
950
+ settleRun(runId, { status: "failed" });
951
+ }
952
+ catch {
953
+ /* fail-soft */
954
+ }
955
+ });
956
+ trackRunPromise(runId, done);
957
+ notify(`/go ${ticket} started in the background [${runShortId}]${worktreeMode ? ` on ${repoCtx.branch ?? "its run branch"} (worktree ${worktreePath})` : " (in place)"}. The handoff will arrive here when it finishes; /go-status to check on it.`, "info");
958
+ },
959
+ });
960
+ // The pull-path companions ride the same registration path (index.ts never
961
+ // changes): /go-status lists registry rows, /go-clean removes clean worktrees.
962
+ registerGoStatusCommands(pi, {
963
+ ...(deps.loadJournal ? { loadJournal: deps.loadJournal } : {}),
964
+ ...(deps.exists ? { exists: deps.exists } : {}),
965
+ ...(deps.now ? { now: deps.now } : {}),
966
+ ...(deps.resolveSnapshot ? { snapshot: deps.resolveSnapshot } : {}),
967
+ // /go-clean is per-repo: reuse this command's own git resolver so it only
968
+ // removes worktrees created from the current repo (fail-soft to no filter).
969
+ resolveRepo: (cwd, signal) => resolveRepoContext(cwd, signal).then((c) => c.repo).catch(() => undefined),
970
+ });
971
+ }
972
+ //# sourceMappingURL=goCommand.js.map