@yagni-app/code-staging 0.0.0 → 0.1.0-staging.1002.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.
- package/LICENSE.md +55 -0
- package/README.md +166 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/claudeCompat.d.ts +78 -0
- package/dist/claudeCompat.js +198 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +326 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +28 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +75 -0
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.js +45 -0
- package/dist/doctor.d.ts +94 -0
- package/dist/doctor.js +343 -0
- package/dist/extension/advisor.d.ts +84 -0
- package/dist/extension/advisor.js +101 -0
- package/dist/extension/askAdvisorTool.d.ts +83 -0
- package/dist/extension/askAdvisorTool.js +188 -0
- package/dist/extension/askYagniTool.d.ts +39 -0
- package/dist/extension/askYagniTool.js +63 -0
- package/dist/extension/bless.d.ts +45 -0
- package/dist/extension/bless.js +74 -0
- package/dist/extension/branding.d.ts +48 -0
- package/dist/extension/branding.js +119 -0
- package/dist/extension/chipEditor.d.ts +117 -0
- package/dist/extension/chipEditor.js +369 -0
- package/dist/extension/config.d.ts +112 -0
- package/dist/extension/config.js +108 -0
- package/dist/extension/costHud.d.ts +73 -0
- package/dist/extension/costHud.js +123 -0
- package/dist/extension/decisionCapture.d.ts +52 -0
- package/dist/extension/decisionCapture.js +66 -0
- package/dist/extension/decisions.d.ts +83 -0
- package/dist/extension/decisions.js +200 -0
- package/dist/extension/diagnostics.d.ts +41 -0
- package/dist/extension/diagnostics.js +110 -0
- package/dist/extension/index.d.ts +116 -0
- package/dist/extension/index.js +388 -0
- package/dist/extension/initDone.d.ts +28 -0
- package/dist/extension/initDone.js +66 -0
- package/dist/extension/initPass.d.ts +152 -0
- package/dist/extension/initPass.js +394 -0
- package/dist/extension/nextWorkTool.d.ts +51 -0
- package/dist/extension/nextWorkTool.js +80 -0
- package/dist/extension/permission.d.ts +91 -0
- package/dist/extension/permission.js +236 -0
- package/dist/extension/pipeline/activity.d.ts +37 -0
- package/dist/extension/pipeline/activity.js +151 -0
- package/dist/extension/pipeline/activityFeed.d.ts +59 -0
- package/dist/extension/pipeline/activityFeed.js +175 -0
- package/dist/extension/pipeline/budget.d.ts +48 -0
- package/dist/extension/pipeline/budget.js +68 -0
- package/dist/extension/pipeline/checkpoint.d.ts +31 -0
- package/dist/extension/pipeline/checkpoint.js +176 -0
- package/dist/extension/pipeline/eval.d.ts +168 -0
- package/dist/extension/pipeline/eval.js +182 -0
- package/dist/extension/pipeline/events.d.ts +56 -0
- package/dist/extension/pipeline/events.js +147 -0
- package/dist/extension/pipeline/findings.d.ts +42 -0
- package/dist/extension/pipeline/findings.js +144 -0
- package/dist/extension/pipeline/finish.d.ts +128 -0
- package/dist/extension/pipeline/finish.js +307 -0
- package/dist/extension/pipeline/goCommand.d.ts +128 -0
- package/dist/extension/pipeline/goCommand.js +972 -0
- package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
- package/dist/extension/pipeline/goCompareCommand.js +184 -0
- package/dist/extension/pipeline/goFlags.d.ts +40 -0
- package/dist/extension/pipeline/goFlags.js +46 -0
- package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
- package/dist/extension/pipeline/goStatusCommands.js +188 -0
- package/dist/extension/pipeline/invocation.d.ts +45 -0
- package/dist/extension/pipeline/invocation.js +64 -0
- package/dist/extension/pipeline/orchestrator.d.ts +131 -0
- package/dist/extension/pipeline/orchestrator.js +636 -0
- package/dist/extension/pipeline/personas.d.ts +44 -0
- package/dist/extension/pipeline/personas.js +248 -0
- package/dist/extension/pipeline/resilience.d.ts +85 -0
- package/dist/extension/pipeline/resilience.js +166 -0
- package/dist/extension/pipeline/resume.d.ts +18 -0
- package/dist/extension/pipeline/resume.js +106 -0
- package/dist/extension/pipeline/runRegistry.d.ts +112 -0
- package/dist/extension/pipeline/runRegistry.js +202 -0
- package/dist/extension/pipeline/runSession.d.ts +152 -0
- package/dist/extension/pipeline/runSession.js +167 -0
- package/dist/extension/pipeline/runState.d.ts +158 -0
- package/dist/extension/pipeline/runState.js +264 -0
- package/dist/extension/pipeline/runner.d.ts +79 -0
- package/dist/extension/pipeline/runner.js +298 -0
- package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
- package/dist/extension/pipeline/scrubSecrets.js +32 -0
- package/dist/extension/pipeline/stages.d.ts +51 -0
- package/dist/extension/pipeline/stages.js +113 -0
- package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
- package/dist/extension/pipeline/ticketResolution.js +75 -0
- package/dist/extension/pipeline/types.d.ts +436 -0
- package/dist/extension/pipeline/types.js +48 -0
- package/dist/extension/pipeline/verify.d.ts +277 -0
- package/dist/extension/pipeline/verify.js +758 -0
- package/dist/extension/pipeline/workspace.d.ts +32 -0
- package/dist/extension/pipeline/workspace.js +73 -0
- package/dist/extension/pipeline/worktree.d.ts +107 -0
- package/dist/extension/pipeline/worktree.js +200 -0
- package/dist/extension/provider.d.ts +11 -0
- package/dist/extension/provider.js +26 -0
- package/dist/extension/recall.d.ts +93 -0
- package/dist/extension/recall.js +187 -0
- package/dist/extension/recordContextTool.d.ts +38 -0
- package/dist/extension/recordContextTool.js +85 -0
- package/dist/extension/recordDecisionTool.d.ts +52 -0
- package/dist/extension/recordDecisionTool.js +102 -0
- package/dist/extension/repoDocs.d.ts +81 -0
- package/dist/extension/repoDocs.js +260 -0
- package/dist/extension/resilientFetch.d.ts +60 -0
- package/dist/extension/resilientFetch.js +133 -0
- package/dist/extension/reviewTool.d.ts +34 -0
- package/dist/extension/reviewTool.js +81 -0
- package/dist/extension/spool.d.ts +92 -0
- package/dist/extension/spool.js +266 -0
- package/dist/extension/stateHome.d.ts +2 -0
- package/dist/extension/stateHome.js +6 -0
- package/dist/extension/subagents.d.ts +135 -0
- package/dist/extension/subagents.js +281 -0
- package/dist/extension/surface.d.ts +10 -0
- package/dist/extension/surface.js +12 -0
- package/dist/extension/todos.d.ts +110 -0
- package/dist/extension/todos.js +217 -0
- package/dist/extension/tokenProvider.d.ts +93 -0
- package/dist/extension/tokenProvider.js +234 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.js +111 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +87 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +222 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/dist/upgrade.d.ts +97 -0
- package/dist/upgrade.js +284 -0
- 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
|