@yagni-app/code-staging 0.3.0-staging.1088.1 → 0.3.0-staging.1091.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");
@@ -30,7 +30,7 @@ import { type Component } from "@earendil-works/pi-tui";
30
30
  import { Type } from "typebox";
31
31
  import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
32
32
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
33
- import type { PipelineStage } from "./pipeline/types.js";
33
+ import { type PipelineStage } from "./pipeline/types.js";
34
34
  import { type RenderTheme, type SubagentTaskProgress } from "./subagentRender.js";
35
35
  /**
36
36
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
@@ -29,7 +29,9 @@ import { Text } from "@earendil-works/pi-tui";
29
29
  import { Type } from "typebox";
30
30
  import { ADVISOR_MODEL_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatConsultCost, } from "./advisor.js";
31
31
  import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
32
+ import { withResilience } from "./pipeline/resilience.js";
32
33
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
34
+ import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
33
35
  import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, receiptLine, renderSubagentResult, runningLines, } from "./subagentRender.js";
34
36
  /**
35
37
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
@@ -94,7 +96,13 @@ export function renderAdvisorCall(args, theme, _context) {
94
96
  }
95
97
  export function makeAskAdvisorTool(opts) {
96
98
  const limits = opts.limits ?? DEFAULT_ADVISOR_LIMITS;
97
- const runStage = opts.runStage ?? defaultRunStage;
99
+ // The default runner rides the /go pipeline's resilience wrapper, exactly
100
+ // like the subagent tool: idle + wall-clock ceilings and transient-only
101
+ // retry, so a stalled consult child aborts honestly instead of hanging the
102
+ // driver's tool call until the user presses Esc. The synthetic stage id is
103
+ // "plan" (read-only tools, no bash), so the wrapper's write-gate never
104
+ // blocks a retry — re-running a consult cannot double-apply anything.
105
+ const runStage = opts.runStage ?? withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY);
98
106
  return {
99
107
  name: "ask_advisor",
100
108
  label: "Ask the advisor",
@@ -47,7 +47,7 @@ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
47
47
  */
48
48
  export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified, `verification` for an adversarial pass that tries to break completed work before you rely on it. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
49
49
  /** The driver identity while /ultra is on: base identity + the diamond directive. */
50
- export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond.";
50
+ export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond. Subagents cannot touch your todo_write checklist, so keep it current yourself: update it when you split the job and again as each fanned-out piece lands, not only at the end.";
51
51
  export declare const PI_IDENTITY_RE: RegExp;
52
52
  /**
53
53
  * Env switch that bypasses the system-prompt rewrite entirely, so pi's
@@ -64,7 +64,9 @@ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has
64
64
  "agreement between checkers — not a single pass — as confirmation, and " +
65
65
  "surface what they could not verify. Delegate by default and reserve this " +
66
66
  "session for splitting, judging, and synthesis; only trivial work you can " +
67
- "finish in a couple of tool calls skips the diamond.";
67
+ "finish in a couple of tool calls skips the diamond. Subagents cannot touch " +
68
+ "your todo_write checklist, so keep it current yourself: update it when you " +
69
+ "split the job and again as each fanned-out piece lands, not only at the end.";
68
70
  /**
69
71
  * The identity used for the interactive DRIVER session ONLY: {@link
70
72
  * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
@@ -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
  }
@@ -143,7 +143,7 @@ export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js"
143
143
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
144
144
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
145
145
  export type { SubagentDef, SubagentSource } from "./subagents.js";
146
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
146
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
147
147
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
148
148
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
149
149
  export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
@@ -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, {
@@ -812,7 +814,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
812
814
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
813
815
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
814
816
  // The session todo checklist: todo_write tool, widget renderer, /todos.
815
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
817
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
816
818
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
817
819
  // the session bless-with-remember capture hook.
818
820
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
@@ -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.
@@ -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
@@ -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 {
@@ -17,6 +17,16 @@ import { Type } from "typebox";
17
17
  export declare const TODO_TOOL_NAME = "todo_write";
18
18
  export declare const MAX_TODOS = 50;
19
19
  export declare const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export declare const TODO_REMINDER_TURNS = 10;
20
30
  /**
21
31
  * The desktop's structured state record rides its own widget key, like the
22
32
  * `/go` run state: one JSON line the app parses and renders itself, never
@@ -61,6 +71,23 @@ export interface TodoTheme {
61
71
  export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme): string[];
62
72
  /** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
63
73
  export declare function todoStateLine(todos: TodoItem[]): string;
74
+ /**
75
+ * PURE: is a staleness reminder due? Only when the board has open work (an
76
+ * empty or fully-completed list never nags) and BOTH throttle counters have
77
+ * reached {@link TODO_REMINDER_TURNS}.
78
+ */
79
+ export declare function shouldRemindTodos(input: {
80
+ todos: TodoItem[];
81
+ turnsSinceWrite: number;
82
+ turnsSinceReminder: number;
83
+ }): boolean;
84
+ /**
85
+ * PURE: the hedged reminder block appended to a tool result when the board has
86
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
87
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
88
+ * glance rather than a spurious todo_write.
89
+ */
90
+ export declare function formatTodoReminder(todos: TodoItem[]): string;
64
91
  /** Replay the branch: the last todo_write result is the canonical list. */
65
92
  export declare function reconstructTodos(entries: unknown[]): TodoItem[];
66
93
  type TodoParams = {
@@ -104,7 +131,7 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
104
131
  isError?: undefined;
105
132
  }>;
106
133
  };
107
- /** Wire the tool, the branch-replay events, and the /todos command. */
134
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
108
135
  export declare function registerTodos(pi: ExtensionAPI): void;
109
136
  export {};
110
137
  //# sourceMappingURL=todos.d.ts.map
@@ -17,6 +17,16 @@ import { isDesktopSurface } from "./surface.js";
17
17
  export const TODO_TOOL_NAME = "todo_write";
18
18
  export const MAX_TODOS = 50;
19
19
  export const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export const TODO_REMINDER_TURNS = 10;
20
30
  const WIDGET_KEY = "yagni-todos";
21
31
  /**
22
32
  * The desktop's structured state record rides its own widget key, like the
@@ -101,6 +111,33 @@ export function renderTodoWidget(todos, theme) {
101
111
  export function todoStateLine(todos) {
102
112
  return JSON.stringify({ v: 1, todos });
103
113
  }
114
+ /**
115
+ * PURE: is a staleness reminder due? Only when the board has open work (an
116
+ * empty or fully-completed list never nags) and BOTH throttle counters have
117
+ * reached {@link TODO_REMINDER_TURNS}.
118
+ */
119
+ export function shouldRemindTodos(input) {
120
+ const { todos, turnsSinceWrite, turnsSinceReminder } = input;
121
+ if (todos.length === 0)
122
+ return false;
123
+ const { done, total } = todoSummary(todos);
124
+ if (done === total)
125
+ return false;
126
+ return turnsSinceWrite >= TODO_REMINDER_TURNS && turnsSinceReminder >= TODO_REMINDER_TURNS;
127
+ }
128
+ /**
129
+ * PURE: the hedged reminder block appended to a tool result when the board has
130
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
131
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
132
+ * glance rather than a spurious todo_write.
133
+ */
134
+ export function formatTodoReminder(todos) {
135
+ return ("⟦YAGNI todos⟧ The todo_write checklist has not been updated for a while. " +
136
+ "If the work has moved on, bring it current now: mark finished steps completed, " +
137
+ "set the step you are on to in_progress, and add newly discovered steps. " +
138
+ "If the list is already accurate, ignore this.\n" +
139
+ formatTodoList(todos));
140
+ }
104
141
  /** Replay the branch: the last todo_write result is the canonical list. */
105
142
  export function reconstructTodos(entries) {
106
143
  let todos = [];
@@ -186,9 +223,14 @@ export function makeTodoTool(get, set) {
186
223
  },
187
224
  };
188
225
  }
189
- /** Wire the tool, the branch-replay events, and the /todos command. */
226
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
190
227
  export function registerTodos(pi) {
191
228
  let todos = [];
229
+ // Staleness-reminder counters (see TODO_REMINDER_TURNS). Session-local like
230
+ // the list cache itself; branch replay resets them so a resume/fork never
231
+ // opens with an instantly-due reminder.
232
+ let turnsSinceWrite = 0;
233
+ let turnsSinceReminder = 0;
192
234
  const reconstruct = (ctx) => {
193
235
  try {
194
236
  todos = reconstructTodos(ctx.sessionManager.getBranch());
@@ -196,12 +238,45 @@ export function registerTodos(pi) {
196
238
  catch {
197
239
  todos = [];
198
240
  }
241
+ turnsSinceWrite = 0;
242
+ turnsSinceReminder = 0;
199
243
  paintWidget(ctx, todos);
200
244
  };
201
245
  pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
202
246
  pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
247
+ // Turn counting: one tick per finalized assistant message, the same "turn"
248
+ // the model experiences between opportunities to call todo_write.
249
+ pi.on("message_end", async (event) => {
250
+ if (event.message?.role === "assistant") {
251
+ turnsSinceWrite += 1;
252
+ turnsSinceReminder += 1;
253
+ }
254
+ });
255
+ // The reminder rides an existing tool result (the same result-modification
256
+ // seam ambient recall uses), so it reaches the model mid-run without
257
+ // spending a turn. Never appended to todo_write's own result, and fail-soft:
258
+ // a reminder must never break a tool call.
259
+ pi.on("tool_result", async (event) => {
260
+ try {
261
+ if (event.toolName === TODO_TOOL_NAME)
262
+ return;
263
+ if (!shouldRemindTodos({ todos, turnsSinceWrite, turnsSinceReminder }))
264
+ return;
265
+ turnsSinceReminder = 0;
266
+ return {
267
+ content: [
268
+ ...event.content,
269
+ { type: "text", text: `\n\n${formatTodoReminder(todos)}` },
270
+ ],
271
+ };
272
+ }
273
+ catch {
274
+ return;
275
+ }
276
+ });
203
277
  pi.registerTool(makeTodoTool(() => todos, (next) => {
204
278
  todos = next;
279
+ turnsSinceWrite = 0;
205
280
  }));
206
281
  pi.registerCommand("todos", {
207
282
  description: "Show the agent's current task list for this session.",
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
6
  * the driver's delegation paragraph for the diamond directive (branding.ts's
7
- * YAGNI_IDENTITY_ULTRA, read live per turn in index.ts) and widens the
8
- * subagent tool's per-call fan-out ceiling (subagents.ts). Ultra is a prompt +
9
- * ceiling change only: it never touches the permission mode, the model tier,
10
- * or the /go pipeline.
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
11
15
  */
12
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
17
  export interface UltraHolder {
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
6
  * the driver's delegation paragraph for the diamond directive (branding.ts's
7
- * YAGNI_IDENTITY_ULTRA, read live per turn in index.ts) and widens the
8
- * subagent tool's per-call fan-out ceiling (subagents.ts). Ultra is a prompt +
9
- * ceiling change only: it never touches the permission mode, the model tier,
10
- * or the /go pipeline.
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
11
15
  */
12
16
  export function createUltraHolder(initial = false) {
13
17
  let on = initial;
@@ -24,6 +28,13 @@ const ULTRA_ON_COPY = "Ultra mode ON: meaningful work fans out to parallel subag
24
28
  "verification agents try to break the result, then the agent synthesizes. " +
25
29
  "Expect more subagent spend per task.";
26
30
  const ULTRA_OFF_COPY = "Ultra mode OFF: back to delegate-when-useful.";
31
+ /**
32
+ * Appended when the toggle lands mid-run: the identity swap only applies when
33
+ * the next prompt is submitted (see the module docblock), so without this note
34
+ * the chip flips while the running task visibly keeps its old behavior — which
35
+ * reads as ultra mode being broken.
36
+ */
37
+ const MID_RUN_NOTE = " The task currently running keeps its existing instructions; the change takes full effect on your next message.";
27
38
  /**
28
39
  * Wire the /ultra command onto a shared holder. No argument toggles; `on` /
29
40
  * `off` set explicitly; `status` reports without changing anything.
@@ -54,7 +65,11 @@ export function registerUltraCommand(pi, holder) {
54
65
  catch {
55
66
  // The chip is chrome; never let it break /ultra.
56
67
  }
57
- notify(next ? ULTRA_ON_COPY : ULTRA_OFF_COPY, "info");
68
+ // Guarded probe: test fakes (and any minimal harness ctx) may not carry
69
+ // isIdle, and its absence must read as idle, never as busy.
70
+ const midRun = typeof ctx.isIdle === "function" && !ctx.isIdle();
71
+ const copy = next ? ULTRA_ON_COPY : ULTRA_OFF_COPY;
72
+ notify(midRun ? copy + MID_RUN_NOTE : copy, "info");
58
73
  },
59
74
  });
60
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1088.1",
3
+ "version": "0.3.0-staging.1091.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": "eebceee1a0d1673dd4b2755942aa027a89f18611"
41
+ "yagniSourceSha": "50ccbb69838d6ee20a0b0d7a7b7259105158f215"
42
42
  }