faberun 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * `next`: one line per active campaign naming the single most urgent action
3
+ * and, when every argument is derivable from state, the exact command to run.
4
+ *
5
+ * It is the read side of the operator loop, and deliberately narrower than
6
+ * `status`: it enumerates campaigns with `discoverCampaigns`, reads each linked
7
+ * run's controller lock and node snapshots the way `status` does, and applies
8
+ * the six ranked predicates from the operator-loop spec — specific before
9
+ * generic, so exactly one matches — then orders the lines by rank and campaign
10
+ * id. It takes no lock and writes nothing; the only writes it could be tempted
11
+ * into (resume, findings, close) are rendered as commands for the operator.
12
+ *
13
+ * Node snapshots are parsed here with a tolerant `JSON.parse` in a try/catch,
14
+ * never through `loadRun`/`validateNodeSnapshot`: a torn snapshot is exactly
15
+ * the case this command must survive and report, not crash on.
16
+ */
17
+ import { join } from "node:path";
18
+ import { discoverCampaigns } from "../campaign/index.mjs";
19
+ import { readJournalForDedupe } from "../campaign/journal.mjs";
20
+ import { lockStale, readLock } from "../run/lock.mjs";
21
+ import { listNodeSnapshots, nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
22
+ import { errorMessage } from "../util.mjs";
23
+
24
+ /** @typedef {Record<string, unknown>} JsonObject */
25
+ /** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
26
+ /** @typedef {{campaign: string, rank: number, reason: string, command: string, runnable: boolean}} NextItem */
27
+
28
+ /** Node states that are still in flight, versus every settled terminal state. */
29
+ const IN_PROGRESS = new Set(["pending", "running"]);
30
+ const SETTLED_DONE = new Set(["done", "no-op"]);
31
+
32
+ /**
33
+ * The action list every renderer shares. A campaign contributes exactly one
34
+ * item — the lowest-ranked predicate its runs and nodes match — and corrupt
35
+ * campaigns contribute one item each, so nothing is skipped silently.
36
+ *
37
+ * @param {string} runsDir
38
+ * @param {string} cwd absolute working directory, for `campaign close`/`note`
39
+ * @returns {NextItem[]}
40
+ */
41
+ export function computeNextItems(runsDir, cwd) {
42
+ const { campaigns, corrupt } = discoverCampaigns(runsDir);
43
+ /** @type {NextItem[]} */
44
+ const items = [];
45
+ for (const entry of corrupt) {
46
+ items.push({
47
+ campaign: entry.id,
48
+ rank: 4,
49
+ reason: `campaign ${entry.id} unreadable: ${errorMessage(entry.error)} (${entry.path})`,
50
+ command: "",
51
+ runnable: false,
52
+ });
53
+ }
54
+ for (const entry of campaigns) {
55
+ if (entry.campaign.status !== "active") continue;
56
+ items.push(campaignItem(entry, runsDir, cwd));
57
+ }
58
+ items.sort((left, right) => left.rank - right.rank
59
+ || (left.campaign < right.campaign ? -1 : left.campaign > right.campaign ? 1 : 0));
60
+ return items;
61
+ }
62
+
63
+ /**
64
+ * @param {string} runsDir
65
+ * @param {string} cwd
66
+ * @returns {string}
67
+ */
68
+ export function renderNext(runsDir, cwd) {
69
+ const items = computeNextItems(runsDir, cwd);
70
+ if (!items.length) return "nothing needs anyone\n";
71
+ return `${items.map(renderLine).join("\n")}\n`;
72
+ }
73
+
74
+ /**
75
+ * @param {string} runsDir
76
+ * @param {string} cwd
77
+ * @returns {string}
78
+ */
79
+ export function renderNextJson(runsDir, cwd) {
80
+ const items = computeNextItems(runsDir, cwd);
81
+ const payload = {
82
+ schemaVersion: 1,
83
+ items: items.map((item) => ({
84
+ campaign: item.campaign,
85
+ rank: item.rank,
86
+ reason: item.reason,
87
+ command: item.command,
88
+ runnable: item.runnable,
89
+ })),
90
+ };
91
+ return `${JSON.stringify(payload, null, 2)}\n`;
92
+ }
93
+
94
+ /**
95
+ * @param {{path: string, campaign: Campaign}} entry
96
+ * @param {string} runsDir
97
+ * @param {string} cwd
98
+ * @returns {NextItem}
99
+ */
100
+ function campaignItem(entry, runsDir, cwd) {
101
+ const campaign = entry.campaign;
102
+ /** @type {NextItem[]} */
103
+ const candidates = [];
104
+ let inProgress = false;
105
+ /** @type {string|null} */
106
+ let liveRunId = null;
107
+
108
+ for (const runId of campaign.linkedRunIds) {
109
+ const runDir = join(runsDir, runId);
110
+ const active = controllerLive(runDir);
111
+ const { nodes, errors } = readRunNodes(runDir);
112
+ if (!nodes.length && !errors.length) {
113
+ // A linked run that never wrote a single snapshot is unknown, not
114
+ // terminal: counting it as terminal here is exactly the vacuous rank-5
115
+ // bug this item exists to prevent.
116
+ candidates.push(emptyRunItem(campaign.id, runId, runDir));
117
+ }
118
+ for (const { nodeId, snapshot } of nodes) {
119
+ const status = statusOf(snapshot);
120
+ if (status !== null && IN_PROGRESS.has(status)) {
121
+ inProgress = true;
122
+ if (active && liveRunId === null) liveRunId = runId;
123
+ if (!active) candidates.push(resumeItem(campaign.id, runId, runDir));
124
+ } else if (status !== null && SETTLED_DONE.has(status)) {
125
+ // Done, no-op: nothing needs the operator.
126
+ } else if (status === "blocked" && isBlockedContext(snapshot)) {
127
+ candidates.push(blockedContextItem(campaign.id, runId, runDir, nodeId, snapshot));
128
+ } else if (status === "exhausted" && hasGateFindings(snapshot)) {
129
+ candidates.push(findingsItem(campaign.id, runId, runDir, nodeId));
130
+ } else if (status === "blocked" || status === "failed" || status === "exhausted"
131
+ || status === "stalled" || status === "canceled") {
132
+ candidates.push(statusItem(campaign.id, runId, runDir, nodeId, status, snapshot));
133
+ } else {
134
+ candidates.push(unrecognizedItem(campaign.id, runId, runDir, nodeId));
135
+ }
136
+ }
137
+ for (const { nodeId, path, error } of errors) {
138
+ candidates.push(tornItem(campaign.id, runId, runDir, nodeId, path, error));
139
+ }
140
+ }
141
+
142
+ const best = candidates.reduce(
143
+ (found, candidate) => (!found || candidate.rank < found.rank ? candidate : found),
144
+ /** @type {NextItem|null} */ (null),
145
+ );
146
+ if (best) return best;
147
+ if (!inProgress) return closureItem(campaign, entry.path, cwd);
148
+ return {
149
+ campaign: campaign.id,
150
+ rank: 6,
151
+ reason: `run ${liveRunId ?? campaign.linkedRunIds[0] ?? ""} live; nothing to do`,
152
+ command: "",
153
+ runnable: false,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * A live controller is the exact inverse of the stale test: a lock that is
159
+ * absent, `{invalid: true}` (unparsable), stale, or unreadable is not live.
160
+ *
161
+ * @param {string} runDir
162
+ * @returns {boolean}
163
+ */
164
+ function controllerLive(runDir) {
165
+ let lock;
166
+ try {
167
+ lock = readLock(runDir);
168
+ } catch {
169
+ return false;
170
+ }
171
+ if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) return false;
172
+ return !lockStale(/** @type {import("../run/lock.mjs").LockRecord} */ (lock));
173
+ }
174
+
175
+ /**
176
+ * @param {string} runDir
177
+ * @returns {{nodes: {nodeId: string, snapshot: JsonObject}[], errors: {nodeId: string, path: string, error: unknown}[]}}
178
+ */
179
+ function readRunNodes(runDir) {
180
+ const names = listNodeSnapshots(runDir).sort();
181
+ /** @type {{nodeId: string, snapshot: JsonObject}[]} */
182
+ const nodes = [];
183
+ /** @type {{nodeId: string, path: string, error: unknown}[]} */
184
+ const errors = [];
185
+ for (const name of names) {
186
+ const nodeId = name.replace(/\.json$/u, "");
187
+ const path = nodeSnapshotPath(runDir, nodeId);
188
+ try {
189
+ nodes.push({ nodeId, snapshot: readNodeSnapshot(runDir, nodeId) });
190
+ } catch (error) {
191
+ errors.push({ nodeId, path, error });
192
+ }
193
+ }
194
+ return { nodes, errors };
195
+ }
196
+
197
+ /** @param {JsonObject} snapshot @returns {string|null} */
198
+ function statusOf(snapshot) {
199
+ return typeof snapshot.status === "string" ? snapshot.status : null;
200
+ }
201
+
202
+ /** @param {JsonObject} snapshot @returns {{status?: unknown, summary?: unknown, missingContext?: unknown}|null} */
203
+ function resultOf(snapshot) {
204
+ const result = snapshot.result;
205
+ if (!result || typeof result !== "object" || Array.isArray(result)) return null;
206
+ return /** @type {{status?: unknown, summary?: unknown, missingContext?: unknown}} */ (result);
207
+ }
208
+
209
+ /** @param {JsonObject} snapshot @returns {{findings?: unknown}|null} */
210
+ function gateOf(snapshot) {
211
+ const gate = snapshot.gate;
212
+ if (!gate || typeof gate !== "object" || Array.isArray(gate)) return null;
213
+ return /** @type {{findings?: unknown}} */ (gate);
214
+ }
215
+
216
+ /** @param {JsonObject} snapshot @returns {{code?: unknown, message?: unknown}|null} */
217
+ function errorOf(snapshot) {
218
+ const error = snapshot.error;
219
+ if (!error || typeof error !== "object" || Array.isArray(error)) return null;
220
+ return /** @type {{code?: unknown, message?: unknown}} */ (error);
221
+ }
222
+
223
+ /** @param {JsonObject} snapshot @returns {boolean} */
224
+ function isBlockedContext(snapshot) {
225
+ return resultOf(snapshot)?.status === "blocked_context";
226
+ }
227
+
228
+ /** @param {JsonObject} snapshot @returns {boolean} */
229
+ function hasGateFindings(snapshot) {
230
+ const findings = gateOf(snapshot)?.findings;
231
+ return Array.isArray(findings) && findings.length > 0;
232
+ }
233
+
234
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @returns {NextItem} */
235
+ function resumeItem(campaignId, runId, runDir) {
236
+ return {
237
+ campaign: campaignId,
238
+ rank: 1,
239
+ reason: `run ${runId} has a non-terminal node and no live controller`,
240
+ command: `resume ${quoteArg(runDir)}`,
241
+ runnable: true,
242
+ };
243
+ }
244
+
245
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @param {string} nodeId @param {JsonObject} snapshot @returns {NextItem} */
246
+ function blockedContextItem(campaignId, runId, runDir, nodeId, snapshot) {
247
+ const result = resultOf(snapshot);
248
+ const summary = typeof result?.summary === "string" && result.summary.trim()
249
+ ? result.summary.trim()
250
+ : "the worker stopped on missing context";
251
+ const missing = Array.isArray(result?.missingContext)
252
+ ? result.missingContext.filter((entry) => typeof entry === "string")
253
+ : [];
254
+ const asked = missing.length ? `; needs ${missing.join(", ")}` : "";
255
+ return {
256
+ campaign: campaignId,
257
+ rank: 2,
258
+ reason: `node ${nodeId} blocked on context: ${summary}${asked}`,
259
+ command: `resume ${quoteArg(runDir)} --answer ${nodeId}=<answer-file>`,
260
+ runnable: false,
261
+ };
262
+ }
263
+
264
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @param {string} nodeId @returns {NextItem} */
265
+ function findingsItem(campaignId, runId, runDir, nodeId) {
266
+ return {
267
+ campaign: campaignId,
268
+ rank: 3,
269
+ reason: `node ${nodeId} exhausted with gate findings`,
270
+ command: `findings ${quoteArg(runDir)}`,
271
+ runnable: true,
272
+ };
273
+ }
274
+
275
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @param {string} nodeId @param {string} status @param {JsonObject} snapshot @returns {NextItem} */
276
+ function statusItem(campaignId, runId, runDir, nodeId, status, snapshot) {
277
+ const error = errorOf(snapshot);
278
+ const code = typeof error?.code === "string" && error.code ? error.code : null;
279
+ const message = typeof error?.message === "string" && error.message ? error.message : null;
280
+ const blockedBy = Array.isArray(snapshot.blockedBy) ? snapshot.blockedBy.filter((id) => typeof id === "string") : [];
281
+ let detail = "";
282
+ if (status === "blocked" && blockedBy.length) detail = ` on ${blockedBy.join(", ")}`;
283
+ else if (code) detail = ` [${code}]`;
284
+ else if (message) detail = `: ${message}`;
285
+ return {
286
+ campaign: campaignId,
287
+ rank: 4,
288
+ reason: `node ${nodeId} ${status}${detail}`,
289
+ command: `status ${quoteArg(runDir)}`,
290
+ runnable: true,
291
+ };
292
+ }
293
+
294
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @param {string} nodeId @returns {NextItem} */
295
+ function unrecognizedItem(campaignId, runId, runDir, nodeId) {
296
+ return {
297
+ campaign: campaignId,
298
+ rank: 4,
299
+ reason: `node ${nodeId} snapshot has no recognized status`,
300
+ command: `status ${quoteArg(runDir)}`,
301
+ runnable: true,
302
+ };
303
+ }
304
+
305
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @returns {NextItem} */
306
+ function emptyRunItem(campaignId, runId, runDir) {
307
+ return {
308
+ campaign: campaignId,
309
+ rank: 4,
310
+ reason: `run ${runId} has no recorded nodes yet`,
311
+ command: `status ${quoteArg(runDir)}`,
312
+ runnable: true,
313
+ };
314
+ }
315
+
316
+ /** @param {string} campaignId @param {string} runId @param {string} runDir @param {string} nodeId @param {string} path @param {unknown} error @returns {NextItem} */
317
+ function tornItem(campaignId, runId, runDir, nodeId, path, error) {
318
+ return {
319
+ campaign: campaignId,
320
+ rank: 4,
321
+ reason: `node ${nodeId} snapshot unreadable: ${errorMessage(error)} (${path})`,
322
+ command: `status ${quoteArg(runDir)}`,
323
+ runnable: true,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Rank 5 checks the exact predicate `closeCampaign` enforces — a
329
+ * `retrospective` journal entry — so what `next` reports and what `close`
330
+ * refuses can never disagree. An unreadable journal reads as ineligible, never
331
+ * as a crash.
332
+ *
333
+ * @param {Campaign} campaign
334
+ * @param {string} campaignPath
335
+ * @param {string} cwd
336
+ * @returns {NextItem}
337
+ */
338
+ function closureItem(campaign, campaignPath, cwd) {
339
+ let eligible = false;
340
+ try {
341
+ eligible = readJournalForDedupe(campaignPath).some((entry) => entry.type === "retrospective");
342
+ } catch {
343
+ eligible = false;
344
+ }
345
+ const state = campaign.linkedRunIds.length === 0
346
+ ? "no linked runs"
347
+ : `all ${campaign.linkedRunIds.length} linked runs terminal`;
348
+ if (eligible) {
349
+ return {
350
+ campaign: campaign.id,
351
+ rank: 5,
352
+ reason: `${state}; retrospective recorded`,
353
+ command: `campaign close ${campaign.id} --cwd ${quoteArg(cwd)}`,
354
+ runnable: true,
355
+ };
356
+ }
357
+ return {
358
+ campaign: campaign.id,
359
+ rank: 5,
360
+ reason: `${state}; no retrospective note (close would refuse)`,
361
+ command: `campaign note ${campaign.id} --session-id <session-id> --kind retrospective --text <text>`,
362
+ runnable: false,
363
+ };
364
+ }
365
+
366
+ /** @param {NextItem} item @returns {string} */
367
+ function renderLine(item) {
368
+ const prefix = `${item.campaign}: ${item.reason}`;
369
+ if (!item.command) return prefix;
370
+ return `${prefix} · ${item.command}${item.runnable ? "" : " [template]"}`;
371
+ }
372
+
373
+ /**
374
+ * Quote a shell argument only when it needs it, so a normal path stays bare and
375
+ * a path with a space (or any other shell metacharacter) is single-quoted.
376
+ *
377
+ * @param {string} value
378
+ * @returns {string}
379
+ */
380
+ function quoteArg(value) {
381
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/u.test(value)) return value;
382
+ return `'${value.replaceAll("'", `'\\''`)}'`;
383
+ }