@yagni-app/code-staging 0.3.0-staging.1085.1 → 0.3.0-staging.1090.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.
@@ -73,6 +73,17 @@ export declare function matchesGrant(command: string, grants: readonly ApprovedP
73
73
  export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
74
74
  /** Human label for the remember option: "git push …". */
75
75
  export declare function describePrefix(pattern: string[]): string;
76
+ /**
77
+ * Could {@link derivePrefix} ever have produced this pattern? The persisted
78
+ * file is plain JSON on disk, so a row that derivation could not have written
79
+ * (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
80
+ * tool, a path-prefixed word, or an over-long pattern) is treated as
81
+ * tampered/corrupt and dropped at load time rather than honored (PR #1698
82
+ * review). This is defense in depth, not the trust boundary itself — the
83
+ * boundary is that grants only enter the live gate at startup or through the
84
+ * gate's own ask flow.
85
+ */
86
+ export declare function isDerivablePattern(pattern: readonly string[]): boolean;
76
87
  export declare function rulesFilePath(homeOverride?: string | null): string;
77
88
  /**
78
89
  * Resolve the grant scope key for a session cwd: the git remote origin URL,
@@ -179,6 +179,35 @@ export function validateGrant(command, policy, repoKey) {
179
179
  export function describePrefix(pattern) {
180
180
  return `${pattern.join(" ")} …`;
181
181
  }
182
+ /**
183
+ * Could {@link derivePrefix} ever have produced this pattern? The persisted
184
+ * file is plain JSON on disk, so a row that derivation could not have written
185
+ * (a banned interpreter/destruction/egress prefix, a bare multi-subcommand
186
+ * tool, a path-prefixed word, or an over-long pattern) is treated as
187
+ * tampered/corrupt and dropped at load time rather than honored (PR #1698
188
+ * review). This is defense in depth, not the trust boundary itself — the
189
+ * boundary is that grants only enter the live gate at startup or through the
190
+ * gate's own ask flow.
191
+ */
192
+ export function isDerivablePattern(pattern) {
193
+ if (pattern.length < 1 || pattern.length > 2)
194
+ return false;
195
+ const first = pattern[0];
196
+ if (first.includes("/") || first.startsWith("\\"))
197
+ return false;
198
+ if (BANNED_PREFIXES.has(first))
199
+ return false;
200
+ if (pattern.length === 2) {
201
+ const second = pattern[1];
202
+ if (!MULTI_SUBCOMMAND_TOOLS.has(first))
203
+ return false;
204
+ if (second.startsWith("-") || !SAFE_SUBCOMMAND_RE.test(second))
205
+ return false;
206
+ return true;
207
+ }
208
+ // Derivation never emits a bare multi-subcommand tool ("git" alone).
209
+ return !MULTI_SUBCOMMAND_TOOLS.has(first);
210
+ }
182
211
  // --- I/O half ---
183
212
  export function rulesFilePath(homeOverride = null) {
184
213
  return join(codeStateHome(homeOverride), "rules.json");
@@ -220,6 +249,7 @@ export function loadGrants(homeOverride = null) {
220
249
  return parsed.grants.filter((g) => Array.isArray(g?.pattern) &&
221
250
  g.pattern.length > 0 &&
222
251
  g.pattern.every((t) => typeof t === "string") &&
252
+ isDerivablePattern(g.pattern) &&
223
253
  typeof g.repoKey === "string" &&
224
254
  typeof g.addedAt === "string" &&
225
255
  typeof g.cwd === "string");
@@ -26,10 +26,12 @@
26
26
  * this module is the I/O.
27
27
  */
28
28
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
29
+ import { type Component } from "@earendil-works/pi-tui";
29
30
  import { Type } from "typebox";
30
31
  import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
31
32
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
32
33
  import type { PipelineStage } from "./pipeline/types.js";
34
+ import { type RenderTheme, type SubagentTaskProgress } from "./subagentRender.js";
33
35
  /**
34
36
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
35
37
  * reason documented there: enough tools to navigate instead of flailing on a
@@ -66,10 +68,18 @@ export declare function buildConsultBrief(params: {
66
68
  * persona, and that is the advisor's own.
67
69
  */
68
70
  export declare function advisorStage(): PipelineStage;
69
- export declare function makeAskAdvisorTool(opts: MakeAskAdvisorToolOptions): ToolDefinition<typeof parameters, {
71
+ /** The ask_advisor result details: consult accounting plus the live progress
72
+ * record the subagent renderers paint (one task, the consult itself). */
73
+ export interface AdvisorToolDetails {
70
74
  consults: number;
71
75
  cost: number;
72
- }>;
76
+ tasks?: SubagentTaskProgress[];
77
+ }
78
+ /** Title painted the moment the ask_advisor call streams in. */
79
+ export declare function renderAdvisorCall(args: {
80
+ question?: string;
81
+ } | undefined, theme: RenderTheme, _context: unknown): Component;
82
+ export declare function makeAskAdvisorTool(opts: MakeAskAdvisorToolOptions): ToolDefinition<typeof parameters, AdvisorToolDetails>;
73
83
  /**
74
84
  * `/advise <question>` — the manual escalation lever.
75
85
  *
@@ -77,6 +87,11 @@ export declare function makeAskAdvisorTool(opts: MakeAskAdvisorToolOptions): Too
77
87
  * consult draws on the same cap rather than opening a side channel around it.
78
88
  * The advice is sent into the conversation (like /go-compare's report) so the
79
89
  * driver sees it in context and can act on it.
90
+ *
91
+ * A command has no tool row for pi to render, so the live view is an
92
+ * aboveEditor widget painted from the tool's partial updates: the same
93
+ * two-line `agent — task` / `↳ current tool` status the subagent tool shows,
94
+ * driven by a spinner ticker so it visibly moves between child events.
80
95
  */
81
96
  export declare function registerAdviseCommand(pi: ExtensionAPI, tool: ReturnType<typeof makeAskAdvisorTool>): void;
82
97
  export {};
@@ -25,9 +25,12 @@
25
25
  * The gate, the cap and the spend ceiling live in `advisor.ts` and are pure;
26
26
  * this module is the I/O.
27
27
  */
28
+ import { Text } from "@earendil-works/pi-tui";
28
29
  import { Type } from "typebox";
29
30
  import { ADVISOR_MODEL_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatConsultCost, } from "./advisor.js";
31
+ import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
30
32
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
33
+ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, receiptLine, renderSubagentResult, runningLines, } from "./subagentRender.js";
31
34
  /**
32
35
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
33
36
  * reason documented there: enough tools to navigate instead of flailing on a
@@ -71,6 +74,24 @@ export function advisorStage() {
71
74
  taskTemplate: "{ticket}",
72
75
  };
73
76
  }
77
+ /** Spinner cadence for the advisor's live views; matches subagentRender. */
78
+ const SPINNER_TICK_MS = 120;
79
+ /** Question preview width on the call title line. */
80
+ const QUESTION_PREVIEW_MAX = 128;
81
+ function clip(text, max) {
82
+ const collapsed = text.replace(/\s+/g, " ").trim();
83
+ if (collapsed.length <= max)
84
+ return collapsed;
85
+ return `${collapsed.slice(0, max - 1)}…`;
86
+ }
87
+ /** Title painted the moment the ask_advisor call streams in. */
88
+ export function renderAdvisorCall(args, theme, _context) {
89
+ const title = theme.fg("toolTitle", theme.bold("advisor"));
90
+ let text = `${title} ${theme.fg("accent", "peak-tier consult")}`;
91
+ if (args?.question)
92
+ text += `\n ${theme.fg("dim", clip(args.question, QUESTION_PREVIEW_MAX))}`;
93
+ return new Text(text, 0, 0);
94
+ }
74
95
  export function makeAskAdvisorTool(opts) {
75
96
  const limits = opts.limits ?? DEFAULT_ADVISOR_LIMITS;
76
97
  const runStage = opts.runStage ?? defaultRunStage;
@@ -95,6 +116,8 @@ export function makeAskAdvisorTool(opts) {
95
116
  "The advice comes back as plain text: act on it, and call record_decision when it settles a product-intent call so the next agent inherits it.",
96
117
  ],
97
118
  parameters,
119
+ renderCall: renderAdvisorCall,
120
+ renderResult: renderSubagentResult,
98
121
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
99
122
  // Read the LIVE session model: pi's picker can change it after this tool
100
123
  // was registered, in both directions.
@@ -109,19 +132,51 @@ export function makeAskAdvisorTool(opts) {
109
132
  details: { consults: opts.state.read().consults, cost: 0 },
110
133
  };
111
134
  }
112
- onUpdate?.({
113
- content: [{ type: "text", text: "Consulting the advisor…" }],
114
- details: { consults: opts.state.read().consults, cost: 0 },
115
- });
116
- const result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
117
- cwd: ctx?.cwd ?? process.cwd(),
118
- ...(signal ? { signal } : {}),
119
- // YAG-471: attribute the consult's completions to the advisor, not
120
- // the "plan" stage id advisorStage() borrows (see its docblock).
121
- callerLabel: "advisor",
122
- });
135
+ // Live progress, exactly the subagent tool's shape: the folded record
136
+ // rides every partial update's `details.tasks` (painted by
137
+ // renderSubagentResult), the plain-text summary rides `content` for
138
+ // headless consumers, and the harness "Working…" line mirrors it.
139
+ const progress = newTaskProgress("advisor", params.question, Date.now());
140
+ const ui = ctx?.hasUI ? ctx.ui : undefined;
141
+ let lastWorking;
142
+ const emit = () => {
143
+ const now = Date.now();
144
+ onUpdate?.({
145
+ content: [{ type: "text", text: progressSummaryText([progress], now) }],
146
+ details: {
147
+ consults: opts.state.read().consults,
148
+ cost: progress.usage.cost,
149
+ tasks: [{ ...progress, actions: [...progress.actions], usage: { ...progress.usage } }],
150
+ },
151
+ });
152
+ const working = formatWorkingMessage([progress], now);
153
+ if (ui && working !== lastWorking) {
154
+ lastWorking = working;
155
+ ui.setWorkingMessage?.(working);
156
+ }
157
+ };
158
+ emit();
159
+ let result;
160
+ try {
161
+ result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
162
+ cwd: ctx?.cwd ?? process.cwd(),
163
+ ...(signal ? { signal } : {}),
164
+ // YAG-471: attribute the consult's completions to the advisor, not
165
+ // the "plan" stage id advisorStage() borrows (see its docblock).
166
+ callerLabel: "advisor",
167
+ onEvent: (ev) => {
168
+ if (applyChildEvent(progress, ev))
169
+ emit();
170
+ },
171
+ });
172
+ }
173
+ finally {
174
+ // Restore the default "Working…" text whether we resolved or threw.
175
+ ui?.setWorkingMessage?.();
176
+ }
123
177
  const cost = result.usage?.cost ?? 0;
124
178
  const state = opts.state.record(cost);
179
+ finalizeTask(progress, result, Date.now());
125
180
  if (result.exitCode !== 0 && !result.finalOutput.trim()) {
126
181
  // Fail honestly rather than returning an empty recommendation. The
127
182
  // consult still counts: it spawned, and it may well have spent.
@@ -134,7 +189,7 @@ export function makeAskAdvisorTool(opts) {
134
189
  `Decide this one yourself.`,
135
190
  },
136
191
  ],
137
- details: { consults: state.consults, cost },
192
+ details: { consults: state.consults, cost, tasks: [progress] },
138
193
  };
139
194
  }
140
195
  return {
@@ -144,11 +199,13 @@ export function makeAskAdvisorTool(opts) {
144
199
  text: `${result.finalOutput.trim()}\n\n${formatConsultCost({ cost, consults: state.consults, limits })}`,
145
200
  },
146
201
  ],
147
- details: { consults: state.consults, cost },
202
+ details: { consults: state.consults, cost, tasks: [progress] },
148
203
  };
149
204
  },
150
205
  };
151
206
  }
207
+ /** The widget key the /advise live panel paints under. */
208
+ const ADVISE_WIDGET_KEY = "yagni-advise";
152
209
  /**
153
210
  * `/advise <question>` — the manual escalation lever.
154
211
  *
@@ -156,6 +213,11 @@ export function makeAskAdvisorTool(opts) {
156
213
  * consult draws on the same cap rather than opening a side channel around it.
157
214
  * The advice is sent into the conversation (like /go-compare's report) so the
158
215
  * driver sees it in context and can act on it.
216
+ *
217
+ * A command has no tool row for pi to render, so the live view is an
218
+ * aboveEditor widget painted from the tool's partial updates: the same
219
+ * two-line `agent — task` / `↳ current tool` status the subagent tool shows,
220
+ * driven by a spinner ticker so it visibly moves between child events.
159
221
  */
160
222
  export function registerAdviseCommand(pi, tool) {
161
223
  pi.registerCommand("advise", {
@@ -175,8 +237,41 @@ export function registerAdviseCommand(pi, tool) {
175
237
  return;
176
238
  }
177
239
  notify("Consulting the peak-tier advisor. This reads the repo, so it takes a moment.", "info");
240
+ // Live panel state: the newest folded progress record from the tool's
241
+ // partial updates, repainted on a spinner tick until the consult ends.
242
+ let progress;
243
+ let paintTimer;
244
+ const theme = ctx.hasUI ? ctx.ui.theme : undefined;
245
+ const paint = () => {
246
+ if (!ctx.hasUI || !theme || !progress)
247
+ return;
248
+ const now = Date.now();
249
+ const lines = progress.status === "running"
250
+ ? runningLines(progress, theme, now, SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length])
251
+ : [receiptLine(progress, theme)];
252
+ ctx.ui.setWidget?.(ADVISE_WIDGET_KEY, lines, { placement: "aboveEditor" });
253
+ };
254
+ const onUpdate = (update) => {
255
+ const task = update.details?.tasks?.[0];
256
+ if (!task)
257
+ return;
258
+ progress = task;
259
+ if (ctx.hasUI && theme && !paintTimer) {
260
+ paintTimer = setInterval(paint, SPINNER_TICK_MS);
261
+ paintTimer.unref?.();
262
+ }
263
+ paint();
264
+ };
265
+ const clearPanel = () => {
266
+ if (paintTimer) {
267
+ clearInterval(paintTimer);
268
+ paintTimer = undefined;
269
+ }
270
+ if (ctx.hasUI)
271
+ ctx.ui.setWidget?.(ADVISE_WIDGET_KEY, undefined);
272
+ };
178
273
  try {
179
- const out = await tool.execute("advise", { question }, ctx.signal, undefined, ctx);
274
+ const out = await tool.execute("advise", { question }, ctx.signal, onUpdate, ctx);
180
275
  const text = out.content
181
276
  .map((c) => c.text ?? "")
182
277
  .join("\n")
@@ -188,6 +283,9 @@ export function registerAdviseCommand(pi, tool) {
188
283
  notify(`/advise failed: ${message}`, "error");
189
284
  await pi.sendUserMessage(`/advise failed: ${message}`);
190
285
  }
286
+ finally {
287
+ clearPanel();
288
+ }
191
289
  },
192
290
  });
193
291
  }
@@ -37,7 +37,7 @@ export interface GuardianVerdict {
37
37
  rationale: string;
38
38
  }
39
39
  export interface GuardianLimits {
40
- /** Session cap on total Guardian reviews. */
40
+ /** Cap on Guardian reviews within the sliding window ({@link GUARDIAN_REVIEW_WINDOW_MS}). */
41
41
  maxReviews: number;
42
42
  /** Consecutive denials per turn before the circuit breaker trips. */
43
43
  maxConsecutiveDenials: number;
@@ -47,15 +47,25 @@ export interface GuardianLimits {
47
47
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
48
  /**
49
49
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
50
- * overrides the session review cap; anything non-numeric or < 1 falls back to
51
- * the default (a bad value must never zero out the cap and lock the session).
50
+ * overrides the sliding-window review cap; anything non-numeric or < 1 falls
51
+ * back to the default (a bad value must never zero out the cap and lock the
52
+ * session).
52
53
  */
53
54
  export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
54
55
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
55
56
  export declare const GUARDIAN_MODEL_TIER = "efficient";
56
57
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
57
58
  export declare const GUARDIAN_TOOLS: string[];
59
+ /**
60
+ * The review-cap window. `reviews` counts consults inside a SLIDING window
61
+ * rather than for the session's lifetime: a 24/7 session (a fleet operator's
62
+ * always-on terminal) must regain review capacity as old consults age out,
63
+ * not hard-block forever after the first N. The cap is a cost/runaway bound,
64
+ * not a safety bound — safety is the verdicts themselves.
65
+ */
66
+ export declare const GUARDIAN_REVIEW_WINDOW_MS: number;
58
67
  export interface GuardianState {
68
+ /** Guardian consults within the last {@link GUARDIAN_REVIEW_WINDOW_MS}. */
59
69
  reviews: number;
60
70
  consecutiveDenials: number;
61
71
  }
@@ -64,7 +74,7 @@ export interface GuardianStateHandle {
64
74
  recordReview(outcome: GuardianOutcome): GuardianState;
65
75
  resetTurn(): void;
66
76
  }
67
- export declare function makeGuardianState(): GuardianStateHandle;
77
+ export declare function makeGuardianState(now?: () => number): GuardianStateHandle;
68
78
  export interface CircuitBreakerResult {
69
79
  tripped: boolean;
70
80
  reason?: string;
@@ -35,8 +35,9 @@ export const DEFAULT_GUARDIAN_LIMITS = {
35
35
  };
36
36
  /**
37
37
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
38
- * overrides the session review cap; anything non-numeric or < 1 falls back to
39
- * the default (a bad value must never zero out the cap and lock the session).
38
+ * overrides the sliding-window review cap; anything non-numeric or < 1 falls
39
+ * back to the default (a bad value must never zero out the cap and lock the
40
+ * session).
40
41
  */
41
42
  export function resolveGuardianLimits(env = process.env) {
42
43
  const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
@@ -48,25 +49,48 @@ export function resolveGuardianLimits(env = process.env) {
48
49
  export const GUARDIAN_MODEL_TIER = "efficient";
49
50
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
50
51
  export const GUARDIAN_TOOLS = ["read"];
51
- export function makeGuardianState() {
52
- const state = { reviews: 0, consecutiveDenials: 0 };
52
+ // --- State ---
53
+ /**
54
+ * The review-cap window. `reviews` counts consults inside a SLIDING window
55
+ * rather than for the session's lifetime: a 24/7 session (a fleet operator's
56
+ * always-on terminal) must regain review capacity as old consults age out,
57
+ * not hard-block forever after the first N. The cap is a cost/runaway bound,
58
+ * not a safety bound — safety is the verdicts themselves.
59
+ */
60
+ export const GUARDIAN_REVIEW_WINDOW_MS = 60 * 60_000;
61
+ export function makeGuardianState(now = Date.now) {
62
+ const reviewTimes = [];
63
+ let consecutiveDenials = 0;
64
+ const prune = () => {
65
+ const cutoff = now() - GUARDIAN_REVIEW_WINDOW_MS;
66
+ while (reviewTimes.length > 0 && reviewTimes[0] <= cutoff)
67
+ reviewTimes.shift();
68
+ };
69
+ const snapshot = () => ({
70
+ reviews: reviewTimes.length,
71
+ consecutiveDenials,
72
+ });
53
73
  return {
54
- read: () => ({ ...state }),
74
+ read: () => {
75
+ prune();
76
+ return snapshot();
77
+ },
55
78
  recordReview(outcome) {
56
- state.reviews += 1;
79
+ prune();
80
+ reviewTimes.push(now());
57
81
  if (outcome === "deny") {
58
- state.consecutiveDenials += 1;
82
+ consecutiveDenials += 1;
59
83
  }
60
84
  else if (outcome === "allow") {
61
- state.consecutiveDenials = 0;
85
+ consecutiveDenials = 0;
62
86
  }
63
87
  // "ask" leaves the denial streak UNCHANGED: it is neither a denial nor
64
88
  // an exoneration. If it reset the streak, deny/ask/deny/ask would never
65
89
  // trip the breaker (round-2 review blocker).
66
- return { ...state };
90
+ return snapshot();
67
91
  },
68
92
  resetTurn() {
69
- state.consecutiveDenials = 0;
93
+ consecutiveDenials = 0;
70
94
  },
71
95
  };
72
96
  }
@@ -268,7 +268,9 @@ export async function registerYagni(pi, deps = {}) {
268
268
  catch { /* logging must never break the session */ }
269
269
  };
270
270
  // YAG-510: persisted "don't ask again" grants, per-repo keyed. Loaded once
271
- // at startup (grants added by other concurrent sessions appear next launch).
271
+ // at startup (grants added by other concurrent sessions appear next launch
272
+ // the startup load is the trust boundary; live reload was reviewed and
273
+ // rejected as a same-session self-authorization path, PR #1698).
272
274
  const sessionGrants = evalMode ? [] : loadGrants();
273
275
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
274
276
  registerPermissionGate(pi, {
@@ -279,6 +279,11 @@ export function registerPermissionGate(pi, deps = {}) {
279
279
  const guardianTier = deps.guardianTier;
280
280
  // --- YAG-510 gate state ---
281
281
  // Grants: in-memory list seeded from deps, appended on "don't ask again".
282
+ // Deliberately NOT live-reloaded from disk: auto mode can write files, so a
283
+ // mid-session re-read of rules.json would let the agent (or a prompt
284
+ // injection) author its own grants and self-authorize within the same
285
+ // session. New grants from concurrent sessions apply at next launch — the
286
+ // startup load is the trust boundary (PR #1698 review).
282
287
  const grants = [...(deps.grants ?? [])];
283
288
  // Keyed by cwd: a session can change working directory (cd, /go worktrees),
284
289
  // and a repoKey memoized from the first cwd would let repo-A grants match
@@ -410,12 +415,13 @@ export function registerPermissionGate(pi, deps = {}) {
410
415
  const guardianAvailable = Boolean(guardianState && !guardianDisabled && guardianReview);
411
416
  const limits = guardianLimits ?? DEFAULT_GUARDIAN_LIMITS;
412
417
  if (guardianAvailable && guardianState.read().reviews >= limits.maxReviews) {
413
- // Session consult cap. Review mode falls through to its ordinary
414
- // confirm (no LLM cost); auto blocks.
418
+ // Sliding-window consult cap (capacity recovers as old reviews age
419
+ // out a long-lived session is never bricked). Review mode falls
420
+ // through to its ordinary confirm (no LLM cost); auto blocks.
415
421
  if (modeAtEntry === "auto") {
416
422
  if (ctx?.hasUI)
417
- ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} this session).`, "warning");
418
- return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} this session). Switch to /mode review to approve manually.` };
423
+ ctx.ui.notify(`Guardian review cap reached (${limits.maxReviews} in the last hour).`, "warning");
424
+ return { block: true, reason: `Guardian review cap reached (${limits.maxReviews} in the last hour). Capacity recovers as older reviews age out; switch to /mode review to approve manually, or retry this step later.` };
419
425
  }
420
426
  // fall through to decision.confirm below
421
427
  }
@@ -77,7 +77,7 @@ import { registerGoStatusCommands } from "./goStatusCommands.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
78
  import { composeAbortSignal } from "./resilience.js";
79
79
  import { planResume } from "./resume.js";
80
- import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
81
81
  import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
82
82
  import { recordSessionRun } from "../sessionRuns.js";
83
83
  import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
@@ -460,13 +460,15 @@ export function registerGoCommand(pi, deps = {}) {
460
460
  return;
461
461
  }
462
462
  // In-flight guards: the same ticket never runs twice at once in this
463
- // 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).
464
465
  if (findActiveRunByTicket(ticket)) {
465
466
  notify(`/go ${ticket} is already running - see /go-status.`, "warning");
466
467
  return;
467
468
  }
468
- if (activeRunCount() >= MAX_CONCURRENT_RUNS) {
469
- 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");
470
472
  return;
471
473
  }
472
474
  // --- Run tree resolution: worktree by default; --here = legacy in-place.
@@ -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
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1085.1",
3
+ "version": "0.3.0-staging.1090.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "2af1762b42b06fc1e55b7f85fbbed71ce3bd3272"
41
+ "yagniSourceSha": "79e1523affbd694ad96a236da6aefe33000497d8"
42
42
  }