@yagni-app/code-staging 1.2.3-staging.1661.1 → 1.2.3-staging.1700.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.
@@ -918,7 +918,7 @@ export async function registerYagni(pi, deps = {}) {
918
918
  });
919
919
  if (sandboxHandle)
920
920
  sandboxHandle.setRules(loadedRules.rules);
921
- registerPermissionGate(pi, {
921
+ const permissionGate = registerPermissionGate(pi, {
922
922
  ...(sandboxHandle
923
923
  ? {
924
924
  sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
@@ -1955,6 +1955,13 @@ export async function registerYagni(pi, deps = {}) {
1955
1955
  if (GIT_MUTATING_PATTERN.test(command)) {
1956
1956
  footerInvalidateHandle.invalidateGit();
1957
1957
  }
1958
+ // The gate's repeat-failure streaks: every bash outcome, sandboxed or
1959
+ // not (an allowed command failing the same way ten times is denied).
1960
+ if (command) {
1961
+ permissionGate.recordBashOutcome(command, event.isError === true, (event.content ?? [])
1962
+ .map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : ""))
1963
+ .join("\n"));
1964
+ }
1958
1965
  // The auto-sandbox rung's prior-failure evidence: a SANDBOXED bash
1959
1966
  // result carrying a sandbox-denial signature marks this command — its
1960
1967
  // later escape attempts re-arm the consent dialog instead of
@@ -428,6 +428,22 @@ export declare const filterStalePlanContext: typeof filterStaleModeContext;
428
428
  * pre-widening read-only shape without a release.
429
429
  */
430
430
  export declare function escapeClassifyGateKnob(env?: NodeJS.ProcessEnv): boolean;
431
+ /**
432
+ * The repeat-failure denial (spec 2026-10-08 build environments, §6): an
433
+ * allowed bash command that has failed identically this many times in a row
434
+ * is denied on the next attempt. In a build sandbox a missing tool otherwise
435
+ * turns into dozens of identical retries until the Guardian's cap stops the
436
+ * run with nothing to show for it.
437
+ */
438
+ export declare const REPEAT_FAILURE_LIMIT = 10;
439
+ export declare const REPEAT_FAILURE_REASON = "This command has failed the same way ten times; the sandbox cannot do what it asks.";
440
+ interface RepeatFailureRing {
441
+ /** Feed one bash outcome (the tool_result's error flag and text). */
442
+ record(command: string, isError: boolean, text: string): void;
443
+ /** Has this command failed identically REPEAT_FAILURE_LIMIT times in a row? */
444
+ exhausted(command: string): boolean;
445
+ }
446
+ export declare function createRepeatFailureRing(): RepeatFailureRing;
431
447
  /** The decision source of ONE tool_call (telemetry), carried per invocation
432
448
  * because tool calls run concurrently: refined by the user dialog, hooks,
433
449
  * and Guardian outcomes as the call moves through the gate. */
@@ -445,6 +461,15 @@ export declare function wireToolCallGate(pi: Pick<ExtensionAPI, "on">, run: (eve
445
461
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
446
462
  * auto, so absent any /mode this is a no-op over today's behavior.
447
463
  */
448
- export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): void;
464
+ /** What the wiring layer feeds back into the gate from its tool_result listener. */
465
+ export interface PermissionGateHandle {
466
+ /**
467
+ * One bash outcome, from index.ts's existing tool_result listener (the
468
+ * gate keeps no observer of its own): the command, the result's error flag
469
+ * and its text. Feeds the repeat-failure streaks. Fail-soft; never throws.
470
+ */
471
+ recordBashOutcome(command: string, isError: boolean, text: string): void;
472
+ }
473
+ export declare function registerPermissionGate(pi: ExtensionAPI, deps?: RegisterPermissionDeps): PermissionGateHandle;
449
474
  export {};
450
475
  //# sourceMappingURL=gate.d.ts.map
@@ -29,6 +29,7 @@
29
29
  import { derivePrefix, describePrefix, describeSeedPrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
+ import { splitExitStatusError } from "../bashSemantics.js";
32
33
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
33
34
  import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
34
35
  import { evaluateRules } from "../permissionRules/engine.js";
@@ -374,6 +375,64 @@ function guardianErrorMessage(error) {
374
375
  default: return "unknown error";
375
376
  }
376
377
  }
378
+ /**
379
+ * The repeat-failure denial (spec 2026-10-08 build environments, §6): an
380
+ * allowed bash command that has failed identically this many times in a row
381
+ * is denied on the next attempt. In a build sandbox a missing tool otherwise
382
+ * turns into dozens of identical retries until the Guardian's cap stops the
383
+ * run with nothing to show for it.
384
+ */
385
+ export const REPEAT_FAILURE_LIMIT = 10;
386
+ export const REPEAT_FAILURE_REASON = "This command has failed the same way ten times; the sandbox cannot do what it asks.";
387
+ /** Commands tracked per session; the least recently failed drops out first. */
388
+ const REPEAT_FAILURE_RING_MAX = 50;
389
+ /** Whitespace-insensitive, on top of the grant canonicalization. */
390
+ function repeatFailureKey(command) {
391
+ return sandboxFailureKey(command).replace(/\s+/g, " ");
392
+ }
393
+ /**
394
+ * A failure's identity: exit code + the first non-empty line of its output.
395
+ * pi's bash tool merges stderr into the output and throws non-zero exits as
396
+ * `<output>\n\nCommand exited with code N`, so the first output line is the
397
+ * error's first line. Anything else (abort, timeout, a gate block) has no
398
+ * exit code and returns null: it neither extends nor breaks a streak.
399
+ */
400
+ function failureIdentity(text) {
401
+ const parsed = splitExitStatusError(text);
402
+ if (!parsed)
403
+ return null;
404
+ const firstLine = parsed.output.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
405
+ return `${parsed.exitCode}\u0000${firstLine}`;
406
+ }
407
+ export function createRepeatFailureRing() {
408
+ const streaks = new Map();
409
+ return {
410
+ record(command, isError, text) {
411
+ const key = repeatFailureKey(command);
412
+ if (!key)
413
+ return;
414
+ if (!isError) {
415
+ streaks.delete(key);
416
+ return;
417
+ }
418
+ const identity = failureIdentity(text);
419
+ if (identity === null)
420
+ return;
421
+ const prior = streaks.get(key);
422
+ const count = prior && prior.identity === identity ? prior.count + 1 : 1;
423
+ streaks.delete(key);
424
+ streaks.set(key, { identity, count });
425
+ if (streaks.size > REPEAT_FAILURE_RING_MAX) {
426
+ const oldest = streaks.keys().next().value;
427
+ if (oldest !== undefined)
428
+ streaks.delete(oldest);
429
+ }
430
+ },
431
+ exhausted(command) {
432
+ return (streaks.get(repeatFailureKey(command))?.count ?? 0) >= REPEAT_FAILURE_LIMIT;
433
+ },
434
+ };
435
+ }
377
436
  /**
378
437
  * The pi tool_call handler wiring (crash-honest attribution). Extracted so
379
438
  * the telemetry contract is testable against the production path: a thrown
@@ -424,10 +483,6 @@ export function wireToolCallGate(pi, run, onToolDecision) {
424
483
  }
425
484
  });
426
485
  }
427
- /**
428
- * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
429
- * auto, so absent any /mode this is a no-op over today's behavior.
430
- */
431
486
  export function registerPermissionGate(pi, deps = {}) {
432
487
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
433
488
  let mode = deps.mode ?? "auto";
@@ -589,6 +644,10 @@ export function registerPermissionGate(pi, deps = {}) {
589
644
  deps.modeHolder?.onSet(() => {
590
645
  sandboxedFailures.clear();
591
646
  });
647
+ // Repeat-failure streaks (spec 2026-10-08 §6): per normalised command, the
648
+ // identity of its last failure and how many times in a row it failed that
649
+ // way. Session state, bounded LRU; a success of the command clears it.
650
+ const repeatFailures = createRepeatFailureRing();
592
651
  /** Shared consult-call builder: routes through the injectable seam with
593
652
  * the sink line + the onPrefixConsult hook — ONE place so the escape and
594
653
  * Guardian-ask fan-outs cannot drift. */
@@ -2196,7 +2255,28 @@ export function registerPermissionGate(pi, deps = {}) {
2196
2255
  reason: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
2197
2256
  };
2198
2257
  };
2199
- wireToolCallGate(pi, gateToolCall, deps.onToolDecision);
2258
+ // The repeat-failure denial rides the result side of an ALLOWED call: the
2259
+ // gate (Guardian, rules, grants, hooks) decides first exactly as before,
2260
+ // and only a call it would let through is checked against the streak. One
2261
+ // missing tool must not turn into eighty identical pulls.
2262
+ const gateWithRepeatFailure = async (event, ctx, slot) => {
2263
+ const result = await gateToolCall(event, ctx, slot);
2264
+ if (result?.block || event.toolName !== "bash")
2265
+ return result;
2266
+ const command = event.input?.command;
2267
+ if (typeof command !== "string" || !repeatFailures.exhausted(command))
2268
+ return result;
2269
+ slot.source = "config";
2270
+ logEvent({
2271
+ source: "permission-rules",
2272
+ level: "info",
2273
+ event: "repeat_failure_denied",
2274
+ // No command content on the always-on tier; the limit is the evidence.
2275
+ fields: { limit: REPEAT_FAILURE_LIMIT },
2276
+ });
2277
+ return { block: true, reason: REPEAT_FAILURE_REASON };
2278
+ };
2279
+ wireToolCallGate(pi, gateWithRepeatFailure, deps.onToolDecision);
2200
2280
  // Model awareness: every agent turn carries a hidden mode-context message so
2201
2281
  // the model knows what it can do (auto: verify proactively, review: wait for
2202
2282
  // approval, plan: hold writes). The context hook strips stale mode context
@@ -2254,5 +2334,23 @@ export function registerPermissionGate(pi, deps = {}) {
2254
2334
  }
2255
2335
  },
2256
2336
  });
2337
+ return {
2338
+ recordBashOutcome: (command, isError, text) => {
2339
+ try {
2340
+ repeatFailures.record(command, isError, text);
2341
+ }
2342
+ catch (err) {
2343
+ try {
2344
+ logEvent({
2345
+ source: "permission-rules",
2346
+ level: "warn",
2347
+ event: "repeat_failure_record_failed",
2348
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err },
2349
+ });
2350
+ }
2351
+ catch { /* telemetry must never affect the gate */ }
2352
+ }
2353
+ },
2354
+ };
2257
2355
  }
2258
2356
  //# sourceMappingURL=gate.js.map
@@ -94,12 +94,17 @@ export declare function stageWontFit(budget: RunBudget, kind: TimedStageKind, ex
94
94
  * than the time actually left, since the host stops the run at the deadline.
95
95
  */
96
96
  export declare const STAGE_WALL_FLOOR_MS: number;
97
- /** Time the verify gate is allowed after the last write, on top of the stage estimates. */
97
+ /**
98
+ * Time the verify gate is allowed after the last write, on top of the stage
99
+ * estimates: the verify run's own ceiling (`VERIFY_TIMEOUT_MS`, twenty
100
+ * minutes). Five until 2026-10-09, when verify could run ten, so a long stage
101
+ * could leave the gate less time than it may take.
102
+ */
98
103
  export declare const VERIFY_ALLOWANCE_MS: number;
99
104
  /**
100
105
  * What must still run after a stage stops: a review round, one fix, and the
101
- * verify gate. Held back from the stage's wall clock so a long stage still
102
- * leaves the run time to review what it wrote.
106
+ * verify gate (4 + 6 + 20 = 30 minutes). Held back from the stage's wall clock
107
+ * so a long stage still leaves the run time to review and verify what it wrote.
103
108
  */
104
109
  export declare const STAGE_WALL_RESERVE_MS: number;
105
110
  /**
@@ -126,12 +126,17 @@ export function stageWontFit(budget, kind, expectedMs, nowMs = Date.now()) {
126
126
  * than the time actually left, since the host stops the run at the deadline.
127
127
  */
128
128
  export const STAGE_WALL_FLOOR_MS = 20 * 60_000;
129
- /** Time the verify gate is allowed after the last write, on top of the stage estimates. */
130
- export const VERIFY_ALLOWANCE_MS = 5 * 60_000;
129
+ /**
130
+ * Time the verify gate is allowed after the last write, on top of the stage
131
+ * estimates: the verify run's own ceiling (`VERIFY_TIMEOUT_MS`, twenty
132
+ * minutes). Five until 2026-10-09, when verify could run ten, so a long stage
133
+ * could leave the gate less time than it may take.
134
+ */
135
+ export const VERIFY_ALLOWANCE_MS = 20 * 60_000;
131
136
  /**
132
137
  * What must still run after a stage stops: a review round, one fix, and the
133
- * verify gate. Held back from the stage's wall clock so a long stage still
134
- * leaves the run time to review what it wrote.
138
+ * verify gate (4 + 6 + 20 = 30 minutes). Held back from the stage's wall clock
139
+ * so a long stage still leaves the run time to review and verify what it wrote.
135
140
  */
136
141
  export const STAGE_WALL_RESERVE_MS = DEFAULT_STAGE_ESTIMATE_MS.review + DEFAULT_STAGE_ESTIMATE_MS.fix + VERIFY_ALLOWANCE_MS;
137
142
  /**
@@ -544,9 +544,10 @@ export interface ResiliencePolicy {
544
544
  }
545
545
  /**
546
546
  * Default resilience policy. Generous timeouts so a legitimately long but live
547
- * stage is never killed: the idle window (well under the 5-min run heartbeat) is
548
- * the check for a stuck model, and the wall window only bounds a runaway that
549
- * keeps emitting events. The wall is 60 minutes for an interactive /go, where a
547
+ * stage is never killed: the idle window is the check for a stuck model (four
548
+ * minutes until 2026-10-09; fifteen now, since a long test run or a slow
549
+ * model turn emits nothing for minutes and is not stuck), and the wall window
550
+ * only bounds a runaway that keeps emitting events. The wall is 60 minutes for an interactive /go, where a
550
551
  * person can abort; a run with a deadline replaces it per stage with the time
551
552
  * left (budget.ts#stageWall). Three attempts with short, jittered
552
553
  * backoff so a single transient blip across the up-to-8 child spawns no longer
@@ -21,16 +21,17 @@ export const MAX_REVIEW_ROUNDS = 2;
21
21
  export const MAX_FIX_TURNS = 3;
22
22
  /**
23
23
  * Default resilience policy. Generous timeouts so a legitimately long but live
24
- * stage is never killed: the idle window (well under the 5-min run heartbeat) is
25
- * the check for a stuck model, and the wall window only bounds a runaway that
26
- * keeps emitting events. The wall is 60 minutes for an interactive /go, where a
24
+ * stage is never killed: the idle window is the check for a stuck model (four
25
+ * minutes until 2026-10-09; fifteen now, since a long test run or a slow
26
+ * model turn emits nothing for minutes and is not stuck), and the wall window
27
+ * only bounds a runaway that keeps emitting events. The wall is 60 minutes for an interactive /go, where a
27
28
  * person can abort; a run with a deadline replaces it per stage with the time
28
29
  * left (budget.ts#stageWall). Three attempts with short, jittered
29
30
  * backoff so a single transient blip across the up-to-8 child spawns no longer
30
31
  * terminates the whole run.
31
32
  */
32
33
  export const DEFAULT_RESILIENCE_POLICY = {
33
- idleTimeoutMs: 4 * 60_000,
34
+ idleTimeoutMs: 15 * 60_000,
34
35
  wallTimeoutMs: 60 * 60_000,
35
36
  maxAttempts: 3,
36
37
  backoffBaseMs: 1_000,
@@ -47,9 +47,13 @@
47
47
  * findings, handoffs, or run records (§0.9).
48
48
  */
49
49
  import type { Finding } from "./types.js";
50
- /** Total wall-clock budget for a single verify run before it is abandoned (fail-open). */
50
+ /**
51
+ * Total wall-clock budget for a single verify run before it is abandoned
52
+ * (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
53
+ * budget's `VERIFY_ALLOWANCE_MS`.
54
+ */
51
55
  export declare const VERIFY_TIMEOUT_MS: number;
52
- /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. */
56
+ /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
53
57
  export declare const VERIFY_TEST_TIMEOUT_MS: number;
54
58
  /** A resolved verify command plus how it was found. */
55
59
  export interface VerifyCommand {
@@ -59,10 +59,14 @@ import { claimCovers } from "./fanout.js";
59
59
  import { composeAbortSignal } from "./resilience.js";
60
60
  import { scrubSecrets } from "./scrubSecrets.js";
61
61
  import { snapshotWorkspace } from "./workspace.js";
62
- /** Total wall-clock budget for a single verify run before it is abandoned (fail-open). */
63
- export const VERIFY_TIMEOUT_MS = 10 * 60_000;
64
- /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. */
65
- export const VERIFY_TEST_TIMEOUT_MS = 10 * 60_000;
62
+ /**
63
+ * Total wall-clock budget for a single verify run before it is abandoned
64
+ * (fail-open). Ten minutes until 2026-10-09; twenty now, matching the run
65
+ * budget's `VERIFY_ALLOWANCE_MS`.
66
+ */
67
+ export const VERIFY_TIMEOUT_MS = 20 * 60_000;
68
+ /** Per-PACKAGE wall-clock budget for the test half (spec §3d); overrun fails open. Ten minutes until 2026-10-09. */
69
+ export const VERIFY_TEST_TIMEOUT_MS = 20 * 60_000;
66
70
  /** Cap on located findings parsed from one failing run, and on the opaque-tail message. */
67
71
  const MAX_LOCATED_FINDINGS = 25;
68
72
  const MAX_TAIL_CHARS = 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.3-staging.1661.1",
3
+ "version": "1.2.3-staging.1700.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)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "78db828d4bed8121dd01039b1ee8ef846f2136a7"
61
+ "yagniSourceSha": "ca2b75c6072f31b7a6c2ee58fa166ce63b0cefca"
62
62
  }