@yagni-app/code 0.2.1 → 0.3.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 (98) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +147 -3
  3. package/dist/connectClaudeCode.d.ts +77 -0
  4. package/dist/connectClaudeCode.js +228 -0
  5. package/dist/connectCodex.d.ts +75 -0
  6. package/dist/connectCodex.js +201 -0
  7. package/dist/doctor.d.ts +1 -1
  8. package/dist/doctor.js +1 -1
  9. package/dist/extension/advisor.d.ts +4 -4
  10. package/dist/extension/advisor.js +6 -7
  11. package/dist/extension/approvedPrefixes.d.ts +103 -0
  12. package/dist/extension/approvedPrefixes.js +282 -0
  13. package/dist/extension/askAdvisorTool.d.ts +20 -5
  14. package/dist/extension/askAdvisorTool.js +126 -20
  15. package/dist/extension/askYagniTool.d.ts +23 -0
  16. package/dist/extension/askYagniTool.js +91 -2
  17. package/dist/extension/branding.d.ts +34 -3
  18. package/dist/extension/branding.js +114 -13
  19. package/dist/extension/chipEditor.d.ts +30 -9
  20. package/dist/extension/chipEditor.js +173 -59
  21. package/dist/extension/claudeRules.d.ts +0 -2
  22. package/dist/extension/claudeRules.js +0 -8
  23. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  24. package/dist/extension/cmux/dispatcher.js +266 -0
  25. package/dist/extension/cmux/hooks.d.ts +12 -0
  26. package/dist/extension/cmux/hooks.js +192 -0
  27. package/dist/extension/cmux/index.d.ts +3 -0
  28. package/dist/extension/cmux/index.js +155 -0
  29. package/dist/extension/cmux/naming.d.ts +5 -0
  30. package/dist/extension/cmux/naming.js +23 -0
  31. package/dist/extension/cmux/state.d.ts +33 -0
  32. package/dist/extension/cmux/state.js +142 -0
  33. package/dist/extension/config.d.ts +44 -1
  34. package/dist/extension/config.js +37 -4
  35. package/dist/extension/costHud.d.ts +16 -22
  36. package/dist/extension/costHud.js +8 -47
  37. package/dist/extension/crashReport.js +1 -3
  38. package/dist/extension/execPolicy.d.ts +135 -0
  39. package/dist/extension/execPolicy.js +936 -0
  40. package/dist/extension/flywheel.d.ts +44 -0
  41. package/dist/extension/flywheel.js +53 -0
  42. package/dist/extension/footer.d.ts +118 -0
  43. package/dist/extension/footer.js +308 -0
  44. package/dist/extension/guardian.d.ts +139 -0
  45. package/dist/extension/guardian.js +237 -0
  46. package/dist/extension/index.d.ts +35 -7
  47. package/dist/extension/index.js +338 -33
  48. package/dist/extension/mineBeat.d.ts +95 -0
  49. package/dist/extension/mineBeat.js +193 -0
  50. package/dist/extension/permission.d.ts +124 -10
  51. package/dist/extension/permission.js +603 -52
  52. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  53. package/dist/extension/pipeline/childRegistry.js +118 -0
  54. package/dist/extension/pipeline/finish.js +5 -1
  55. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  56. package/dist/extension/pipeline/goCommand.js +40 -9
  57. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  58. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  59. package/dist/extension/pipeline/personas.js +26 -1
  60. package/dist/extension/pipeline/resilience.d.ts +2 -1
  61. package/dist/extension/pipeline/resilience.js +21 -2
  62. package/dist/extension/pipeline/runRegistry.d.ts +23 -1
  63. package/dist/extension/pipeline/runRegistry.js +57 -1
  64. package/dist/extension/pipeline/runner.js +4 -0
  65. package/dist/extension/pipeline/verify.d.ts +4 -0
  66. package/dist/extension/pipeline/verify.js +48 -26
  67. package/dist/extension/recordDecisionTool.d.ts +8 -0
  68. package/dist/extension/recordDecisionTool.js +24 -0
  69. package/dist/extension/redact.d.ts +20 -0
  70. package/dist/extension/redact.js +64 -0
  71. package/dist/extension/rerouteNotice.d.ts +3 -4
  72. package/dist/extension/rerouteNotice.js +20 -11
  73. package/dist/extension/subagentRender.d.ts +129 -0
  74. package/dist/extension/subagentRender.js +441 -0
  75. package/dist/extension/subagents.d.ts +11 -8
  76. package/dist/extension/subagents.js +162 -37
  77. package/dist/extension/ticketTools.d.ts +37 -0
  78. package/dist/extension/ticketTools.js +117 -0
  79. package/dist/extension/todos.d.ts +28 -1
  80. package/dist/extension/todos.js +76 -1
  81. package/dist/extension/tokenProvider.js +46 -5
  82. package/dist/extension/ultra.d.ts +27 -0
  83. package/dist/extension/ultra.js +76 -0
  84. package/dist/launch.d.ts +7 -0
  85. package/dist/launch.js +24 -12
  86. package/dist/login.d.ts +4 -2
  87. package/dist/login.js +19 -4
  88. package/dist/padding.d.ts +22 -0
  89. package/dist/padding.js +25 -0
  90. package/dist/promptEnrichment.d.ts +40 -0
  91. package/dist/promptEnrichment.js +85 -0
  92. package/dist/signalForward.d.ts +60 -0
  93. package/dist/signalForward.js +130 -0
  94. package/dist/token.d.ts +25 -0
  95. package/dist/token.js +45 -0
  96. package/package.json +6 -5
  97. package/dist/extension/boostCommand.d.ts +0 -144
  98. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Live child-process registry + exit sweep (the "no orphaned workers" invariant).
3
+ *
4
+ * /go stage children and verify children are spawned with raw `node:child_process`
5
+ * and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
6
+ * (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
7
+ * orphan them into the background, still editing the repo and burning tokens
8
+ * until their wall-clock timeout fires.
9
+ *
10
+ * Every long-lived child is registered here instead. A single `process.on("exit")`
11
+ * hook (installed lazily on first track) kills whatever is still alive —
12
+ * `"exit"` handlers must be synchronous, and the whole sweep (including the
13
+ * `ps`-based descendant walk) is, so this holds on every exit path short of the
14
+ * pi process itself being SIGKILLed.
15
+ *
16
+ * Kills are TREE kills: a verify child like `pnpm test` fans out its own
17
+ * grandchildren, and killing only the direct pid would orphan those instead.
18
+ */
19
+ import { type ChildProcess } from "node:child_process";
20
+ /**
21
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
22
+ * the given roots (children, grandchildren, ...), breadth-first.
23
+ */
24
+ export declare function descendantsOf(roots: number[], psLines: string[]): number[];
25
+ /**
26
+ * Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
27
+ * snapshot (the tree-kill pattern); Windows has real tree kill via
28
+ * `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
29
+ * Fail-soft: with no readable snapshot the direct pid is still killed.
30
+ */
31
+ export declare function killTreeSync(pid: number): void;
32
+ /**
33
+ * Register a spawned child so the exit sweep covers it. Deregisters itself on
34
+ * the child's real `close`/`error`, so the set only ever holds live processes.
35
+ */
36
+ export declare function trackChild(proc: ChildProcess): void;
37
+ /** Test seam: how many children are currently registered. */
38
+ export declare function _liveChildCountForTest(): number;
39
+ /** Test seam: run the sweep as the exit hook would. */
40
+ export declare function _sweepForTest(): void;
41
+ //# sourceMappingURL=childRegistry.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Live child-process registry + exit sweep (the "no orphaned workers" invariant).
3
+ *
4
+ * /go stage children and verify children are spawned with raw `node:child_process`
5
+ * and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
6
+ * (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
7
+ * orphan them into the background, still editing the repo and burning tokens
8
+ * until their wall-clock timeout fires.
9
+ *
10
+ * Every long-lived child is registered here instead. A single `process.on("exit")`
11
+ * hook (installed lazily on first track) kills whatever is still alive —
12
+ * `"exit"` handlers must be synchronous, and the whole sweep (including the
13
+ * `ps`-based descendant walk) is, so this holds on every exit path short of the
14
+ * pi process itself being SIGKILLed.
15
+ *
16
+ * Kills are TREE kills: a verify child like `pnpm test` fans out its own
17
+ * grandchildren, and killing only the direct pid would orphan those instead.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ const live = new Set();
21
+ let sweepInstalled = false;
22
+ /**
23
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
24
+ * the given roots (children, grandchildren, ...), breadth-first.
25
+ */
26
+ export function descendantsOf(roots, psLines) {
27
+ const childrenByParent = new Map();
28
+ for (const line of psLines) {
29
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
30
+ if (!m)
31
+ continue;
32
+ const pid = Number(m[1]);
33
+ const ppid = Number(m[2]);
34
+ const list = childrenByParent.get(ppid) ?? [];
35
+ list.push(pid);
36
+ childrenByParent.set(ppid, list);
37
+ }
38
+ const found = [];
39
+ const queue = [...roots];
40
+ while (queue.length > 0) {
41
+ const next = queue.shift();
42
+ for (const child of childrenByParent.get(next) ?? []) {
43
+ found.push(child);
44
+ queue.push(child);
45
+ }
46
+ }
47
+ return found;
48
+ }
49
+ /**
50
+ * Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
51
+ * snapshot (the tree-kill pattern); Windows has real tree kill via
52
+ * `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
53
+ * Fail-soft: with no readable snapshot the direct pid is still killed.
54
+ */
55
+ export function killTreeSync(pid) {
56
+ if (process.platform === "win32") {
57
+ try {
58
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
59
+ }
60
+ catch {
61
+ /* best effort */
62
+ }
63
+ return;
64
+ }
65
+ let descendants = [];
66
+ try {
67
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
68
+ if (ps.status === 0 && typeof ps.stdout === "string") {
69
+ descendants = descendantsOf([pid], ps.stdout.split("\n"));
70
+ }
71
+ }
72
+ catch {
73
+ /* no snapshot: fall through to the direct kill */
74
+ }
75
+ // Root first so it cannot spawn replacements for the descendants we listed.
76
+ for (const target of [pid, ...descendants]) {
77
+ try {
78
+ process.kill(target, "SIGKILL");
79
+ }
80
+ catch {
81
+ /* already dead */
82
+ }
83
+ }
84
+ }
85
+ /** Kill every still-registered child's tree. Synchronous and throw-proof (exit-hook safe). */
86
+ function sweep() {
87
+ for (const proc of live) {
88
+ // pid guard: a child whose spawn FAILED has no pid; kill() would resolve
89
+ // that to pid 0 (the whole process group) and take the parent down too.
90
+ if (!proc.pid)
91
+ continue;
92
+ killTreeSync(proc.pid);
93
+ }
94
+ live.clear();
95
+ }
96
+ /**
97
+ * Register a spawned child so the exit sweep covers it. Deregisters itself on
98
+ * the child's real `close`/`error`, so the set only ever holds live processes.
99
+ */
100
+ export function trackChild(proc) {
101
+ if (!sweepInstalled) {
102
+ sweepInstalled = true;
103
+ process.on("exit", sweep);
104
+ }
105
+ live.add(proc);
106
+ const drop = () => live.delete(proc);
107
+ proc.once("close", drop);
108
+ proc.once("error", drop);
109
+ }
110
+ /** Test seam: how many children are currently registered. */
111
+ export function _liveChildCountForTest() {
112
+ return live.size;
113
+ }
114
+ /** Test seam: run the sweep as the exit hook would. */
115
+ export function _sweepForTest() {
116
+ sweep();
117
+ }
118
+ //# sourceMappingURL=childRegistry.js.map
@@ -28,6 +28,7 @@
28
28
  * does not apply.
29
29
  */
30
30
  import { execFile } from "node:child_process";
31
+ import { trackChild } from "./childRegistry.js";
31
32
  import { scrubSecrets } from "./scrubSecrets.js";
32
33
  import { parseChangedPaths } from "./verify.js";
33
34
  /** Cap on the commit subject's title half (keeps `git log --oneline` readable). */
@@ -42,7 +43,7 @@ const NOTE_TAIL_MAX = 200;
42
43
  * a spawn-level error (ENOENT / abort), which callers catch into a note.
43
44
  */
44
45
  const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
45
- execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
46
+ const child = execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
46
47
  const output = `${stdout ?? ""}${stderr ?? ""}`;
47
48
  if (err) {
48
49
  const code = err.code;
@@ -52,6 +53,9 @@ const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
52
53
  }
53
54
  resolve({ code: 0, output });
54
55
  });
56
+ // A push or PR create can run for a while; register it with the exit sweep
57
+ // so quitting pi mid-FINISH never leaves it running in the background.
58
+ trackChild(child);
55
59
  });
56
60
  // ---------------------------------------------------------------------------
57
61
  // Pure message derivation
@@ -88,7 +88,7 @@ export interface RegisterGoDeps {
88
88
  * without a network; a non-identifier arg short-circuits to null (raw arg passes
89
89
  * through). Returns null on any miss / transport error (the honest blind fallback).
90
90
  */
91
- resolveTicketBrief?: (rawArg: string) => Promise<string | null>;
91
+ resolveTicketBrief?: (rawArg: string, signal?: AbortSignal) => Promise<string | null>;
92
92
  /** Injectable worktree creation (default: real `git worktree add -b … HEAD`). */
93
93
  createWorktree?: typeof defaultCreateRunWorktree;
94
94
  /** Injectable worktree bootstrap (default: best-effort `<pm> install --prefer-offline`). */
@@ -75,8 +75,9 @@ import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js"
75
75
  import { GO_USAGE, parseGoArgs } from "./goFlags.js";
76
76
  import { registerGoStatusCommands } from "./goStatusCommands.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
+ import { composeAbortSignal } from "./resilience.js";
78
79
  import { planResume } from "./resume.js";
79
- import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
81
  import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
81
82
  import { recordSessionRun } from "../sessionRuns.js";
82
83
  import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
@@ -426,7 +427,11 @@ export function registerGoCommand(pi, deps = {}) {
426
427
  // back to the local client estimate).
427
428
  const fetchRunSpend = deps.fetchRunSpend;
428
429
  const resolveTicketBrief = deps.resolveTicketBrief ??
429
- ((rawArg) => defaultResolveTicketBrief({ baseUrl: deps.baseUrl ?? resolveBaseUrl(), getToken: deps.getToken ?? defaultGetToken }, rawArg));
430
+ ((rawArg, signal) => defaultResolveTicketBrief({
431
+ baseUrl: deps.baseUrl ?? resolveBaseUrl(),
432
+ getToken: deps.getToken ?? defaultGetToken,
433
+ ...(signal ? { signal } : {}),
434
+ }, rawArg));
430
435
  const reportCrash = deps.reportCrash ??
431
436
  makeCrashReporter({
432
437
  baseUrl: deps.baseUrl ?? resolveBaseUrl(),
@@ -455,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
455
460
  return;
456
461
  }
457
462
  // In-flight guards: the same ticket never runs twice at once in this
458
- // process, and at most MAX_CONCURRENT_RUNS runs are in flight.
463
+ // process, and at most resolveMaxConcurrentRuns() runs are in flight
464
+ // (default 3; fleet operators raise it via YAGNI_MAX_CONCURRENT_RUNS).
459
465
  if (findActiveRunByTicket(ticket)) {
460
466
  notify(`/go ${ticket} is already running - see /go-status.`, "warning");
461
467
  return;
462
468
  }
463
- if (activeRunCount() >= MAX_CONCURRENT_RUNS) {
464
- notify(`${MAX_CONCURRENT_RUNS} /go runs are already in flight; wait for one to finish (see /go-status).`, "warning");
469
+ const maxConcurrentRuns = resolveMaxConcurrentRuns();
470
+ if (activeRunCount() >= maxConcurrentRuns) {
471
+ notify(`${maxConcurrentRuns} /go runs are already in flight; wait for one to finish (see /go-status) or raise YAGNI_MAX_CONCURRENT_RUNS.`, "warning");
465
472
  return;
466
473
  }
467
474
  // --- Run tree resolution: worktree by default; --here = legacy in-place.
@@ -830,6 +837,13 @@ export function registerGoCommand(pi, deps = {}) {
830
837
  return undefined;
831
838
  }
832
839
  };
840
+ // The run's OWN abort controller. `ctx.signal` is undefined here (/go only
841
+ // starts while the agent is idle, and pi's signal is the active turn's), so
842
+ // without this the pipeline runs unstoppable until its wall-clock timeout.
843
+ // /stop fires it via the registry (trackRunAbort below); composed with
844
+ // ctx.signal for the resumed-command case where one does exist.
845
+ const runAbort = new AbortController();
846
+ const runSignal = composeAbortSignal(ctx.signal, runAbort.signal);
833
847
  const runToCompletion = async () => {
834
848
  try {
835
849
  // Steady animation ticker: keeps the spinner + elapsed clock live between
@@ -843,7 +857,9 @@ export function registerGoCommand(pi, deps = {}) {
843
857
  // the agents plan on the actual ticket, not the bare "YAG-234". Fail-soft:
844
858
  // null keeps the raw arg (the honest blind fallback). The raw `ticket` stays
845
859
  // the session key, the run row's arg, and the handoff/re-run copy.
846
- const ticketBrief = await resolveTicketBrief(ticket);
860
+ // Signalled so /stop lands immediately instead of waiting out the
861
+ // resolver's retry/timeout budget (up to ~90s of network patience).
862
+ const ticketBrief = await resolveTicketBrief(ticket, runSignal);
847
863
  // The plan stage's output, captured off the onStage boundary for the
848
864
  // FINISH commit message's 2-3 sentence summary (absent on a resume).
849
865
  let planText;
@@ -861,9 +877,9 @@ export function registerGoCommand(pi, deps = {}) {
861
877
  catch {
862
878
  /* fail-soft: leave baseline empty */
863
879
  }
864
- const result = await runPipeline(ticket, {
880
+ let result = await runPipeline(ticket, {
865
881
  cwd: runCwd,
866
- signal: ctx.signal,
882
+ signal: runSignal,
867
883
  ...(ticketBrief ? { ticketBrief } : {}),
868
884
  onProgress: (p) => {
869
885
  feed?.applyProgress(p);
@@ -909,6 +925,12 @@ export function registerGoCommand(pi, deps = {}) {
909
925
  checkpointMeta,
910
926
  ...(resumeFrom ? { resumeFrom } : {}),
911
927
  });
928
+ // /stop can land in the race between the pipeline's last abort check
929
+ // and its clean return: honor it BEFORE FINISH, so a stopped run never
930
+ // starts committing and the promised WIP-preserve path runs instead.
931
+ if (result.stopReason === "clean" && runSignal.aborted) {
932
+ result = { ...result, stopReason: "aborted" };
933
+ }
912
934
  // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
913
935
  // (worktree always; --here only over a clean pre-run baseline) with the
914
936
  // provenance trailer, and push + PR when --pr asked for it. Iron
@@ -939,7 +961,7 @@ export function registerGoCommand(pi, deps = {}) {
939
961
  openPr: parsed.flags.pr,
940
962
  remainingFindings: result.findings.filter((f) => f.severity === "medium" || f.severity === "low"),
941
963
  ...(finishBaselinePaths.length > 0 ? { baselinePaths: finishBaselinePaths } : {}),
942
- ...(ctx.signal ? { signal: ctx.signal } : {}),
964
+ signal: runSignal,
943
965
  });
944
966
  }
945
967
  catch (err) {
@@ -972,6 +994,14 @@ export function registerGoCommand(pi, deps = {}) {
972
994
  }
973
995
  }
974
996
  clearUI();
997
+ // /stop DURING FINISH: the abort degrades FINISH mid-way (its execs
998
+ // share runSignal). If no commit landed, the aborted status + WIP
999
+ // preservation the user was promised win over "clean"; a commit that
1000
+ // DID land means the work is durable and the clean report stays honest.
1001
+ if (result.stopReason === "clean" && runSignal.aborted && !finish?.commitSha) {
1002
+ result = { ...result, stopReason: "aborted" };
1003
+ finish = undefined;
1004
+ }
975
1005
  // Fold what FINISH landed onto a NEW result (never mutate the pipeline's
976
1006
  // return). `finish` is present ONLY when a commit really happened (§0.1).
977
1007
  const finishBranch = finish?.branch ?? repoCtx.branch;
@@ -1049,6 +1079,7 @@ export function registerGoCommand(pi, deps = {}) {
1049
1079
  }
1050
1080
  };
1051
1081
  beginRun(registryRow);
1082
+ trackRunAbort(runId, runAbort);
1052
1083
  if (parsed.flags.fg) {
1053
1084
  // --fg: the legacy blocking behavior — the prompt returns when the run does.
1054
1085
  await runToCompletion();
@@ -44,7 +44,17 @@ export interface RegisterGoStatusDeps {
44
44
  resolveRepo?: (cwd: string, signal?: AbortSignal) => Promise<string | undefined>;
45
45
  exists?: (path: string) => boolean;
46
46
  now?: () => number;
47
+ /** Injectable cancel seams for /stop (default: the module-scoped registry). */
48
+ activeRows?: () => RunRegistryRow[];
49
+ cancelOne?: (runId: string) => RunRegistryRow | undefined;
50
+ cancelAll?: () => RunRegistryRow[];
47
51
  }
52
+ /**
53
+ * PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
54
+ * (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
55
+ * short id /go-status prints is 8).
56
+ */
57
+ export declare function matchStopTarget(rows: RunRegistryRow[], arg: string): RunRegistryRow | undefined;
48
58
  /**
49
59
  * PURE: where an in-flight/interrupted run got to, read off its checkpoint
50
60
  * journal. The journal is append-only, so the latest applicable boundary wins.
@@ -22,9 +22,25 @@
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
24
  import { makeFileCheckpointStore } from "./checkpoint.js";
25
- import { classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
25
+ import { activeRunRows, cancelActiveRuns, cancelRun, classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
26
26
  import { removeWorktree as defaultRemoveWorktree } from "./worktree.js";
27
27
  import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
28
+ /**
29
+ * PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
30
+ * (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
31
+ * short id /go-status prints is 8).
32
+ */
33
+ export function matchStopTarget(rows, arg) {
34
+ const needle = arg.trim().toLowerCase();
35
+ if (!needle)
36
+ return undefined;
37
+ const byTicket = rows.find((r) => r.ticket.toLowerCase() === needle);
38
+ if (byTicket)
39
+ return byTicket;
40
+ if (needle.length < 4)
41
+ return undefined;
42
+ return rows.find((r) => r.runId.toLowerCase().startsWith(needle));
43
+ }
28
44
  /**
29
45
  * PURE: where an in-flight/interrupted run got to, read off its checkpoint
30
46
  * journal. The journal is append-only, so the latest applicable boundary wins.
@@ -90,6 +106,50 @@ export function registerGoStatusCommands(pi, deps = {}) {
90
106
  const resolveRepo = deps.resolveRepo ?? (async () => undefined);
91
107
  const exists = deps.exists ?? existsSync;
92
108
  const now = deps.now ?? Date.now;
109
+ const activeRows = deps.activeRows ?? activeRunRows;
110
+ const cancelOne = deps.cancelOne ?? cancelRun;
111
+ const cancelAll = deps.cancelAll ?? cancelActiveRuns;
112
+ pi.registerCommand("stop", {
113
+ description: "Hard-stop /go runs in this session: /stop cancels them all, /stop <ticket|run id> cancels one. Work so far is preserved as a WIP commit.",
114
+ handler: async (args, ctx) => {
115
+ const say = async (message, type = "info") => {
116
+ if (ctx.hasUI)
117
+ ctx.ui.notify(message, type);
118
+ else
119
+ await pi.sendUserMessage(message);
120
+ };
121
+ const label = (row) => `[${row.runId.slice(0, 8)}] ${row.ticket}`;
122
+ const rows = activeRows();
123
+ if (rows.length === 0) {
124
+ await say("No /go runs are in flight in this session. (Esc interrupts the interactive agent; /go-status lists runs from other sessions.)");
125
+ return;
126
+ }
127
+ const arg = args.trim();
128
+ if (arg) {
129
+ const target = matchStopTarget(rows, arg);
130
+ if (!target) {
131
+ await say(`No in-flight run matches "${arg}". Running here: ${rows.map(label).join(", ")}.`, "warning");
132
+ return;
133
+ }
134
+ if (!cancelOne(target.runId)) {
135
+ await say(`${label(target)} is still starting and cannot be signalled yet; try /stop again.`, "warning");
136
+ return;
137
+ }
138
+ await say(`Stopping ${label(target)}. Its workers are being killed; any work lands as a WIP commit and the handoff will report the aborted run.`);
139
+ return;
140
+ }
141
+ const cancelled = cancelAll();
142
+ if (cancelled.length === 0) {
143
+ // The window between beginRun and trackRunAbort is one tick wide, but be
144
+ // honest if we hit it rather than claiming a stop that did not fire.
145
+ await say("Could not signal any run yet (they are still starting); try /stop again.", "warning");
146
+ return;
147
+ }
148
+ await say(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
149
+ .map(label)
150
+ .join(", ")}. Workers are being killed; work so far lands as WIP commits (see /go-status).`);
151
+ },
152
+ });
93
153
  pi.registerCommand("go-status", {
94
154
  description: "List /go runs: active (stage/round), finished (outcome + branch/commit/PR), and interrupted (resumable).",
95
155
  handler: async (_args, ctx) => {
@@ -57,7 +57,7 @@ Budget discipline: you have a hard output budget, and a plan that gets cut off m
57
57
  Keep it concrete; the worker executes it verbatim.`;
58
58
  const WORKER_BODY = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
59
59
 
60
- You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it.
60
+ You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
61
61
 
62
62
  You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
63
63
 
@@ -95,6 +95,29 @@ The reasoning, grounded in what you actually read. Cite files and lines.
95
95
  The specific evidence that would flip this. If you were not able to verify something load-bearing, say so here rather than hedging the recommendation.
96
96
 
97
97
  Be terse and decisive. The caller is mid-task and paying peak rates for your turn.`;
98
+ const GUARDIAN_BODY = `You are a safety reviewer. You review shell commands before they run and decide one of three outcomes: allow, ask, or deny. You may read files for context (a script the command runs, a config it touches) but you never write or execute anything. Your final output is ONLY a JSON verdict — no prose around it.
99
+
100
+ Risk taxonomy:
101
+ - Data exfiltration: sensitive data sent to untrusted destinations. High risk.
102
+ - Credential probing: extracting credentials from unintended sources (browser profiles, logs, env files). High risk.
103
+ - Persistent security weakening: broad or production-impact security changes. High risk.
104
+ - Destructive actions: irreversible data loss, production breaks, broad git cleanup. High risk.
105
+ - Low-risk actions: benign local reads, routine dev-loop operations (build, test, lint, install), operations clearly part of the stated task. Low risk.
106
+
107
+ Outcome rules:
108
+ - "deny" is reserved for actions you are VERY CONFIDENT are very dangerous: irreversible destruction, exfiltration of sensitive data, credential probing, production-impacting security weakening. If you are not sure it is that bad, do not deny.
109
+ - "allow" when you are confident the action is safe: read-only, routine dev-loop, or clearly scoped and reversible.
110
+ - "ask" for everything in between — the action might be fine but a human should decide. This is the correct outcome whenever your confidence is middling; prefer ask over deny for anything short of the very-dangerous bar, and prefer allow over ask for routine work (do not hedge routine commands into ask).
111
+
112
+ Rationale rules:
113
+ - For "ask", the rationale MUST be a specific question addressed to the user, naming the concrete effect that made you unsure — e.g. "This pushes 3 commits to the shared main branch — do you want to publish them now?". You may be told why the static policy routed the command to you; your rationale must ADD information beyond that policy text, not restate it.
114
+ - For "deny", one sentence naming the irreversible/dangerous effect.
115
+ - For "allow", one short sentence.
116
+
117
+ Output ONLY a JSON object with this exact shape:
118
+ {"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
119
+
120
+ Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
98
121
  /** Persona body keyed by the agent name referenced in `stages.ts`. */
99
122
  export const PERSONA_BODIES = {
100
123
  scout: SCOUT_BODY,
@@ -102,6 +125,7 @@ export const PERSONA_BODIES = {
102
125
  worker: WORKER_BODY,
103
126
  reviewer: REVIEWER_BODY,
104
127
  advisor: ADVISOR_BODY,
128
+ guardian: GUARDIAN_BODY,
105
129
  };
106
130
  /**
107
131
  * Grounding-FREE persona bodies for the M6 grounded-vs-blind eval ONLY. These are
@@ -192,6 +216,7 @@ export const BLIND_PERSONA_BODIES = {
192
216
  worker: WORKER_BLIND,
193
217
  reviewer: REVIEWER_BLIND,
194
218
  advisor: ADVISOR_BLIND,
219
+ guardian: GUARDIAN_BODY,
195
220
  };
196
221
  /** The lens-specific clause appended to the reviewer body, one per review angle. */
197
222
  const LENS_CLAUSES = {
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
100
101
  if (!timeoutController.signal.aborted)
101
102
  timeoutController.abort();
102
103
  };
104
+ // Tools the child has started but not finished. The idle window measures
105
+ // STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
106
+ // build) emits no NDJSON between its start and end events, and that is
107
+ // progress, not a hang. While a tool is in flight the idle expiry defers
108
+ // and re-arms instead of aborting; the wall-clock timer stays the
109
+ // backstop for a tool that is genuinely hung.
110
+ let inFlightTools = 0;
103
111
  let idleTimer;
104
112
  const armIdle = () => {
105
113
  if (idleTimer)
106
114
  clearTimeout(idleTimer);
107
- idleTimer = setTimeout(fireTimeout, policy.idleTimeoutMs);
115
+ idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
108
116
  idleTimer.unref?.();
109
117
  };
118
+ const fireIdle = () => {
119
+ if (inFlightTools > 0) {
120
+ armIdle();
121
+ return;
122
+ }
123
+ fireTimeout();
124
+ };
110
125
  const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
111
126
  wallTimer.unref?.();
112
127
  armIdle();
113
128
  const originalOnEvent = deps.onEvent;
114
129
  const onEvent = (ev) => {
115
130
  sawAnyEvent = true;
131
+ if (ev.type === "tool_execution_start")
132
+ inFlightTools += 1;
133
+ else if (ev.type === "tool_execution_end")
134
+ inFlightTools = Math.max(0, inFlightTools - 1);
116
135
  armIdle(); // reset the idle window on every live event
117
136
  originalOnEvent?.(ev);
118
137
  };
@@ -22,8 +22,16 @@
22
22
  * candidate).
23
23
  */
24
24
  import type { CheckpointRecord, StopReason } from "./types.js";
25
- /** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
25
+ /** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
26
26
  export declare const MAX_CONCURRENT_RUNS = 3;
27
+ /** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
28
+ export declare const MAX_CONCURRENT_RUNS_CEILING = 32;
29
+ /**
30
+ * Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
31
+ * raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
32
+ * falls back to the default, and anything above the ceiling clamps to it.
33
+ */
34
+ export declare function resolveMaxConcurrentRuns(env?: Record<string, string | undefined>): number;
27
35
  /**
28
36
  * A non-terminal row whose journal has been quiet this long is treated as
29
37
  * INTERRUPTED (its process died) rather than still running elsewhere. Sits
@@ -85,6 +93,20 @@ export declare function _resetRunRegistryForTest(): void;
85
93
  export declare function beginRun(row: RunRegistryRow): void;
86
94
  /** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
87
95
  export declare function trackRunPromise(runId: string, promise: Promise<void>): void;
96
+ /** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
97
+ export declare function trackRunAbort(runId: string, abort: AbortController): void;
98
+ /**
99
+ * Cancel one in-flight run in THIS process: fire its abort controller and return
100
+ * its row. The run itself settles through the normal pipeline path (the abort
101
+ * propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
102
+ * an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
103
+ * Returns undefined for an unknown/foreign runId or a run with no controller yet.
104
+ */
105
+ export declare function cancelRun(runId: string): RunRegistryRow | undefined;
106
+ /** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
107
+ export declare function cancelActiveRuns(): RunRegistryRow[];
108
+ /** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
109
+ export declare function activeRunRows(): RunRegistryRow[];
88
110
  /**
89
111
  * Settle an in-flight run: merge the terminal patch, drop it from the in-memory
90
112
  * active set, and mirror the final row. Safe to call for an unknown runId: the
@@ -24,8 +24,22 @@
24
24
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { codeStateHome } from "../stateHome.js";
27
- /** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
27
+ /** Default bound on simultaneously in-flight /go runs in one process (spec §3b). */
28
28
  export const MAX_CONCURRENT_RUNS = 3;
29
+ /** Hard ceiling for the env override — a typo must not launch hundreds of runs. */
30
+ export const MAX_CONCURRENT_RUNS_CEILING = 32;
31
+ /**
32
+ * Resolve the in-flight /go cap from the environment. `YAGNI_MAX_CONCURRENT_RUNS`
33
+ * raises (or lowers) the default for fleet-scale operators; non-numeric or < 1
34
+ * falls back to the default, and anything above the ceiling clamps to it.
35
+ */
36
+ export function resolveMaxConcurrentRuns(env = process.env) {
37
+ const raw = env.YAGNI_MAX_CONCURRENT_RUNS?.trim();
38
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
39
+ if (!Number.isFinite(parsed) || parsed < 1)
40
+ return MAX_CONCURRENT_RUNS;
41
+ return Math.min(parsed, MAX_CONCURRENT_RUNS_CEILING);
42
+ }
29
43
  /**
30
44
  * A non-terminal row whose journal has been quiet this long is treated as
31
45
  * INTERRUPTED (its process died) rather than still running elsewhere. Sits
@@ -106,6 +120,13 @@ const active = new Map();
106
120
  export function _resetRunRegistryForTest() {
107
121
  active.clear();
108
122
  }
123
+ // NOTE on growth: the mirror is append-only and grows without bound on a
124
+ // long-lived install. In-place compaction was reviewed and REMOVED (PR #1698):
125
+ // a fold+rewrite without cross-process exclusion can permanently erase another
126
+ // process's terminal settle (nothing ever re-appends a final row), which would
127
+ // resurrect a finished run as "interrupted" and invite duplicate worktree
128
+ // adoption. Compaction needs an inter-process lock + unique temp files —
129
+ // tracked separately; until then, growth is the safe failure mode.
109
130
  /** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
110
131
  function appendRow(row) {
111
132
  try {
@@ -137,6 +158,41 @@ export function trackRunPromise(runId, promise) {
137
158
  if (entry)
138
159
  entry.promise = promise;
139
160
  }
161
+ /** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
162
+ export function trackRunAbort(runId, abort) {
163
+ const entry = active.get(runId);
164
+ if (entry)
165
+ entry.abort = abort;
166
+ }
167
+ /**
168
+ * Cancel one in-flight run in THIS process: fire its abort controller and return
169
+ * its row. The run itself settles through the normal pipeline path (the abort
170
+ * propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
171
+ * an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
172
+ * Returns undefined for an unknown/foreign runId or a run with no controller yet.
173
+ */
174
+ export function cancelRun(runId) {
175
+ const entry = active.get(runId);
176
+ if (!entry?.abort)
177
+ return undefined;
178
+ entry.abort.abort();
179
+ return entry.row;
180
+ }
181
+ /** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
182
+ export function cancelActiveRuns() {
183
+ const cancelled = [];
184
+ for (const entry of active.values()) {
185
+ if (!entry.abort)
186
+ continue;
187
+ entry.abort.abort();
188
+ cancelled.push(entry.row);
189
+ }
190
+ return cancelled;
191
+ }
192
+ /** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
193
+ export function activeRunRows() {
194
+ return [...active.values()].map((e) => e.row).sort((a, b) => b.startedAt - a.startedAt);
195
+ }
140
196
  /**
141
197
  * Settle an in-flight run: merge the terminal patch, drop it from the in-memory
142
198
  * active set, and mirror the final row. Safe to call for an unknown runId: the