@sema-agent/core 7.18.0 → 7.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +276 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +8 -3
  6. package/dist/brain/open-responses.js +12 -4
  7. package/dist/brain/openai.js +7 -2
  8. package/dist/brain/reasoning.d.ts +13 -13
  9. package/dist/brain/reasoning.js +2 -2
  10. package/dist/brain/stream-engine.d.ts +4 -0
  11. package/dist/brain/stream-engine.js +6 -1
  12. package/dist/brain/stream-shared.d.ts +3 -0
  13. package/dist/brain/stream-shared.js +1 -0
  14. package/dist/core/ask-origin.d.ts +16 -0
  15. package/dist/core/ask-origin.js +8 -0
  16. package/dist/core/auto-mode-arming.d.ts +6 -0
  17. package/dist/core/auto-mode-arming.js +5 -5
  18. package/dist/core/auto-mode-defaults.d.ts +43 -15
  19. package/dist/core/auto-mode-defaults.js +4 -1
  20. package/dist/core/auto-mode.d.ts +74 -26
  21. package/dist/core/auto-mode.js +80 -11
  22. package/dist/core/checkpoint-store.d.ts +7 -0
  23. package/dist/core/checkpoint-store.js +2 -2
  24. package/dist/core/child-clamp-seats.d.ts +79 -0
  25. package/dist/core/child-clamp-seats.js +29 -0
  26. package/dist/core/engine-notice.d.ts +89 -41
  27. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  28. package/dist/core/fs-write-gate-policy.js +51 -4
  29. package/dist/core/gate-bounded-await.d.ts +38 -0
  30. package/dist/core/gate-bounded-await.js +53 -0
  31. package/dist/core/gate-fold.js +20 -48
  32. package/dist/core/gate-lanes.js +17 -9
  33. package/dist/core/gate-outcome.d.ts +34 -6
  34. package/dist/core/gate-outcome.js +7 -0
  35. package/dist/core/governance-codes.d.ts +16 -1
  36. package/dist/core/governance-codes.js +18 -0
  37. package/dist/core/hooks.d.ts +8 -8
  38. package/dist/core/memory-engine/engine.d.ts +14 -9
  39. package/dist/core/memory-engine/engine.js +21 -8
  40. package/dist/core/memory-engine/layout.d.ts +31 -0
  41. package/dist/core/memory-engine/layout.js +68 -3
  42. package/dist/core/memory-engine/types.d.ts +24 -0
  43. package/dist/core/memory.d.ts +19 -0
  44. package/dist/core/memory.js +12 -0
  45. package/dist/core/read-root-candidate.d.ts +12 -0
  46. package/dist/core/read-root-candidate.js +24 -0
  47. package/dist/core/remote-env.d.ts +32 -0
  48. package/dist/core/remote-env.js +4 -1
  49. package/dist/core/roles.d.ts +9 -1
  50. package/dist/core/roles.js +4 -0
  51. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  52. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  53. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  54. package/dist/core/runner/announce-once-ledger.js +8 -0
  55. package/dist/core/runner/classifier-ladder.d.ts +154 -11
  56. package/dist/core/runner/classifier-ladder.js +170 -10
  57. package/dist/core/runner/contracts.d.ts +46 -1
  58. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  59. package/dist/core/runner/denial-limit-arms.js +7 -6
  60. package/dist/core/runner/env-capability-census.d.ts +30 -0
  61. package/dist/core/runner/env-capability-census.js +38 -0
  62. package/dist/core/runner/forward-sink.d.ts +32 -0
  63. package/dist/core/runner/forward-sink.js +41 -0
  64. package/dist/core/runner/gate-exit.d.ts +4 -0
  65. package/dist/core/runner/gate-exit.js +4 -1
  66. package/dist/core/runner/git-status-frame.d.ts +0 -9
  67. package/dist/core/runner/listing-fold.d.ts +57 -0
  68. package/dist/core/runner/listing-fold.js +75 -0
  69. package/dist/core/runner/prepare-ask-lane.js +1 -0
  70. package/dist/core/runner/prepare-caps-and-workflow.js +20 -31
  71. package/dist/core/runner/prepare-config-doors.d.ts +6 -0
  72. package/dist/core/runner/prepare-config-doors.js +8 -10
  73. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  74. package/dist/core/runner/prepare-execution-env.js +2 -0
  75. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  76. package/dist/core/runner/prepare-gate-stations.js +6 -2
  77. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  78. package/dist/core/runner/prepare-hands-readface.js +3 -5
  79. package/dist/core/runner/prepare-listings.d.ts +3 -0
  80. package/dist/core/runner/prepare-listings.js +4 -2
  81. package/dist/core/runner/prepare-memory.js +7 -1
  82. package/dist/core/runner/prepare-policy-chain.js +5 -5
  83. package/dist/core/runner/prepare-project-context.d.ts +5 -1
  84. package/dist/core/runner/prepare-project-context.js +3 -2
  85. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  86. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  87. package/dist/core/runner/prepare-run-refs.js +5 -4
  88. package/dist/core/runner/prepare-task.js +8 -6
  89. package/dist/core/runner/resume-apply.js +3 -3
  90. package/dist/core/runner/run-clock-and-content.js +3 -7
  91. package/dist/core/runner/run-harness-handlers.js +10 -10
  92. package/dist/core/runner/run-leg.js +31 -16
  93. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  94. package/dist/core/runner/run-turn-boundary.js +44 -25
  95. package/dist/core/runner/runtask.js +2 -1
  96. package/dist/core/runner/synthetic-tools.d.ts +41 -29
  97. package/dist/core/runner/synthetic-tools.js +25 -40
  98. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  99. package/dist/core/runner/task-root-canonical.js +18 -0
  100. package/dist/core/runner/turn-attachments.d.ts +160 -53
  101. package/dist/core/runner/turn-attachments.js +153 -51
  102. package/dist/core/safe-notify.d.ts +5 -0
  103. package/dist/core/safe-notify.js +24 -2
  104. package/dist/core/sensitive-path-policy.d.ts +84 -17
  105. package/dist/core/sensitive-path-policy.js +37 -19
  106. package/dist/core/skill-listing-budget.d.ts +106 -0
  107. package/dist/core/skill-listing-budget.js +57 -0
  108. package/dist/core/skills-directory.js +17 -10
  109. package/dist/core/task-registry.js +2 -1
  110. package/dist/core/task-spec.d.ts +29 -2
  111. package/dist/core/tool-policy.d.ts +31 -5
  112. package/dist/core/tool-policy.js +45 -32
  113. package/dist/core/tool-spec.d.ts +37 -4
  114. package/dist/core/types.d.ts +1 -1
  115. package/dist/core/write-protect.d.ts +105 -20
  116. package/dist/core/write-protect.js +89 -32
  117. package/dist/index.d.ts +8 -7
  118. package/dist/index.js +6 -5
  119. package/dist/orchestration/workflow-primitives.js +10 -20
  120. package/dist/stores/file/memory-store.d.ts +5 -2
  121. package/dist/stores/file/memory-store.js +8 -4
  122. package/dist/tools/fs/bash-readonly-classifier.d.ts +40 -2
  123. package/dist/tools/fs/bash-readonly-classifier.js +376 -41
  124. package/dist/tools/fs/fs-bash.d.ts +1 -1
  125. package/dist/tools/fs/fs-bash.js +19 -28
  126. package/dist/tools/fs/fs-shared.d.ts +22 -0
  127. package/dist/tools/fs/fs-shared.js +30 -0
  128. package/dist/tools/fs/index.d.ts +1 -1
  129. package/dist/tools/fs/index.js +1 -1
  130. package/dist/tools/fs/read-deny.d.ts +4 -3
  131. package/dist/tools/fs/safety.d.ts +15 -0
  132. package/dist/tools/fs/safety.js +7 -0
  133. package/package.json +2 -1
  134. package/test/export-surface.snapshot.json +31 -5
  135. package/wire-changes/core@7.18.0.json +1273 -0
  136. package/wire-changes/core@7.19.0.json +457 -0
  137. package/wire-changes/core@7.20.0.json +568 -0
@@ -275,6 +275,25 @@ export declare class MemoryGateError extends Error {
275
275
  readonly code: string;
276
276
  constructor(code: string, detail?: string);
277
277
  }
278
+ /**
279
+ * The same SHAPE as {@link MemoryGateError} — a stable `code` a caller switches on — for the opposite
280
+ * PHASE of a memory write, and deliberately NOT that class: a gate error means the write was REFUSED
281
+ * and never reached the backend, so a host branching on it would take exactly the wrong action here.
282
+ *
283
+ * @contract memory.projection_failure_is_post_durable — a store throws this ONLY after the note's own
284
+ * event is durably appended and fsync'd, so `committed` is always true and the note survives a restart:
285
+ * a caller that retries the write on this error stores the note a second time. Only the derived
286
+ * projection is missing, and the next mutation of the same scope rewrites it whole.
287
+ */
288
+ export declare class MemoryProjectionError extends Error {
289
+ /** Stable, switchable — the phase this failure belongs to. */
290
+ readonly code = "memory.projection_failed";
291
+ /** Always true: this error only exists AFTER the durable append was fsync'd. Never retry the write. */
292
+ readonly committed = true;
293
+ /** The projection file that could not be rewritten. */
294
+ readonly path: string;
295
+ constructor(path: string, cause: unknown);
296
+ }
278
297
  /**
279
298
  * Scan `text` for a high-confidence credential (CC-parity P0-2). Returns the matched pattern's stable
280
299
  * `label` (NEVER the matched secret value — labels are safe to log/surface), or `null` when clean.
@@ -22,6 +22,18 @@ export class MemoryGateError extends Error {
22
22
  this.code = code;
23
23
  }
24
24
  }
25
+ export class MemoryProjectionError extends Error {
26
+ code = "memory.projection_failed";
27
+ committed = true;
28
+ path;
29
+ constructor(path, cause) {
30
+ const detail = cause instanceof Error ? cause.message : String(cause);
31
+ super(`memory.projection_failed: the note IS durably committed — only the derived projection ${path} could not be rewritten: ${detail}. ` +
32
+ `Do not retry the write (it would store the note twice); the projection is rebuilt by the next mutation of this scope.`, cause !== undefined ? { cause } : undefined);
33
+ this.name = "MemoryProjectionError";
34
+ this.path = path;
35
+ }
36
+ }
25
37
  function hasPromotableMarker(note) {
26
38
  return note.outcomeStats !== undefined;
27
39
  }
@@ -0,0 +1,12 @@
1
+ /** The display bound on a grant candidate's directory: generous next to a real `PATH_MAX`, tight enough
2
+ * that the seat cannot become an unbounded prose channel onto an approval card. */
3
+ export declare const READ_ROOT_CANDIDATE_DIR_MAX = 1024;
4
+ /**
5
+ * The validator for a probe's {@link import("./tool-spec.js").ReversibilityVerdict.readRootCandidate}: the
6
+ * errand `normalizeProbeCause` does for the cause, fail-closed on the disclosure alone — a malformed value
7
+ * yields `undefined`, the ask stands, the card carries no offer. It REFUSES where the cause NEUTRALIZES,
8
+ * per `shell.read_boundary.grant_candidate_is_the_grant`: the cause is prose, so folding a separator out of
9
+ * it keeps it true, while this string IS the grant, and a card showing one spelling while the deployment
10
+ * applies another is the failure the seat prevents.
11
+ */
12
+ export declare function normalizeReadRootCandidate(value: unknown): import("./tool-spec.js").ReadRootGrantCandidate | undefined;
@@ -0,0 +1,24 @@
1
+ import { isPlainRecord, safeDataValue } from "./checkpoint-store.js";
2
+ import { hasWin32AliasedComponent, isAbsolutePathForm, normalizeAbsPathLexically, pathFamilyOf, win32NamespaceScreen } from "../tools/fs/safety.js";
3
+ export const READ_ROOT_CANDIDATE_DIR_MAX = 1024;
4
+ export function normalizeReadRootCandidate(value) {
5
+ if (!isPlainRecord(value))
6
+ return undefined;
7
+ if (safeDataValue(value, "clearsThisAsk") !== true)
8
+ return undefined;
9
+ const dir = safeDataValue(value, "dir");
10
+ if (typeof dir !== "string" || dir.length === 0 || dir.length > READ_ROOT_CANDIDATE_DIR_MAX)
11
+ return undefined;
12
+ if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(dir))
13
+ return undefined;
14
+ if (!isAbsolutePathForm(dir))
15
+ return undefined;
16
+ const folded = normalizeAbsPathLexically(dir);
17
+ if (folded !== dir || normalizeAbsPathLexically(`${dir}/..`) === dir)
18
+ return undefined;
19
+ if (hasWin32AliasedComponent(dir))
20
+ return undefined;
21
+ if (!win32NamespaceScreen(dir, pathFamilyOf({ root: dir })).ok)
22
+ return undefined;
23
+ return { dir, clearsThisAsk: true };
24
+ }
@@ -316,6 +316,16 @@ export interface RemoteExecutionEnv extends ExecutionEnv {
316
316
  * without durable suspend instead (the human gate refuses it), or extend capabilities with an
317
317
  * explicit workspace-durability flag before building one. */
318
318
  suspendable: boolean;
319
+ /** True iff this adapter's {@link FileSystem.canonicalPath} is the AUTHORITY on where a path lands —
320
+ * it answers for the very filesystem the commands run on, and a failure to answer is a failure of
321
+ * the resolver rather than a namespace it cannot see into. Optional, and absent is not a
322
+ * declaration: an adapter that says nothing keeps the reading every adapter has had, in which a
323
+ * chain nothing could be established about leaves the lexical verdict standing. Declaring it says
324
+ * the opposite — that an unanswerable chain is a question, not an absence — and the read
325
+ * boundary's execute-time recheck then refuses rather than passing the written spelling through.
326
+ * Read STRICTLY through {@link canonicalPathIsAuthoritative}, like every other capability whose
327
+ * `true` changes what a containment leg does; a wrong-SHAPE value reads absent and is announced. */
328
+ canonicalPathAuthoritative?: boolean;
319
329
  };
320
330
  /** Identity of the connected workspace (design/48 §5 gap 1). Synchronous: it is data the env already holds. */
321
331
  workspaceHandle(): WorkspaceHandle;
@@ -507,6 +517,9 @@ export declare function missingRestoreSurface(env: ExecutionEnv): readonly ("res
507
517
  *
508
518
  * A composite type guard (structural AND capability) so a `suspendVM` call site narrowed by THIS predicate
509
519
  * gets the {@link RemoteExecutionEnv} type without a cast — `true` always implies the structural check too.
520
+ * The capability is read STRICTLY, like the containment ones: a non-boolean reads as NOT suspendable and
521
+ * the census announces it. This took anything truthy, so `suspendable: "false"` was snapshotted — the one
522
+ * key of the three whose malformed reading fell on the unsafe side.
510
523
  *
511
524
  * It also requires the RESTORE surface ({@link missingRestoreSurface}). A suspend is only durable
512
525
  * if something can undo it; an adapter that offers `suspendVM` but no `resumeVM` used to pass this guard,
@@ -549,3 +562,22 @@ export declare function isIsolated(env: ExecutionEnv): boolean;
549
562
  * answers exactly, and reading the shape as containment made that deployment's boundary lexical-only.
550
563
  */
551
564
  export declare function boundaryIsTheSandbox(env: ExecutionEnv): boolean;
565
+ /**
566
+ * Does this env DECLARE that its `canonicalPath` is the authority on where a path lands?
567
+ *
568
+ * The question the execute-time read boundary asks about its own resolver's SILENCE. Resolving a
569
+ * spelling walks it from the deepest answerable ancestor; when no ancestor answers at all, two
570
+ * different worlds produce the same `undefined`: a resolver that is authoritative and is FAILING (an
571
+ * RPC into the machine the command will run on, a transport that dropped), and an env that simply does
572
+ * not answer path questions (an in-memory double, an adapter with no resolution surface). The first is
573
+ * a question and the second is an absence, and reading both as the absence is what leaves a containment
574
+ * leg silently open on an RPC-backed lane.
575
+ *
576
+ * So the adapter says which it is, and says it the way {@link isIsolated} is said — strictly, because a
577
+ * `true` here makes a containment leg REFUSE where it used to pass. ABSENT is the reading every adapter
578
+ * has had, so no existing env moves; a PRESENT non-boolean reads absent HERE too but is announced at the
579
+ * env's admission point, once per key — this reader does not validate. A non-remote env keeps the same
580
+ * reading, which is why the local lane is unchanged: its resolver answers `/`, so the arm this gates
581
+ * has no case to fire on.
582
+ */
583
+ export declare function canonicalPathIsAuthoritative(env: ExecutionEnv): boolean;
@@ -37,7 +37,7 @@ export function missingRestoreSurface(env) {
37
37
  return missing;
38
38
  }
39
39
  export function isSuspendable(env) {
40
- return isRemoteExecutionEnv(env) && env.capabilities.suspendable && missingRestoreSurface(env).length === 0;
40
+ return isRemoteExecutionEnv(env) && env.capabilities.suspendable === true && missingRestoreSurface(env).length === 0;
41
41
  }
42
42
  export function isIsolated(env) {
43
43
  return isRemoteExecutionEnv(env) ? env.capabilities.isolation === true : false;
@@ -45,3 +45,6 @@ export function isIsolated(env) {
45
45
  export function boundaryIsTheSandbox(env) {
46
46
  return isIsolated(env);
47
47
  }
48
+ export function canonicalPathIsAuthoritative(env) {
49
+ return isRemoteExecutionEnv(env) ? env.capabilities.canonicalPathAuthoritative === true : false;
50
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "../internal/harness-types.js";
2
2
  import type { Model } from "../internal/llm.js";
3
- import type { ModelRef, ModelRole, ModelRoles } from "./types.js";
3
+ import type { ModelRef, ModelRole, ModelRoles, RoleSpec } from "./types.js";
4
4
  export type { ModelRole, RoleSpec, ModelRoles } from "./types.js";
5
5
  /** Resolve a string ModelRef against the catalog (a Model object passes through). */
6
6
  export declare function resolveModel(ref: ModelRef, models?: Record<string, Model>): Model;
@@ -87,6 +87,14 @@ export declare function parseModelMention(text: string, allowedNames: Iterable<s
87
87
  * (即使 catalog 恰有裸模型名 `pro`/`flash` 也不入档位路由——INERT 靠配置成立,不靠键名运气)。
88
88
  */
89
89
  export declare const ROLE_TIER_DEFAULTS: Readonly<Partial<Record<ModelRole, string>>>;
90
+ /**
91
+ * Whether a RoleSpec NAMES a model — a string ref, a Model object, or a config object carrying `model` or
92
+ * `select` — as opposed to a preset-only object (`{ thinking }`, `{ systemPrompt }`) that shapes the role's
93
+ * request but leaves its model to the defaults. The classifier seat's own default (the tier below the run's
94
+ * main model, `classifier-ladder.ts`) applies exactly when this is false: a `{select}` keeps its selection, a
95
+ * preset-only role does not disable the relative pick. One reader, the same parts {@link specParts} reads.
96
+ */
97
+ export declare function roleSpecNamesModel(spec: RoleSpec | undefined): boolean;
90
98
  export interface ResolvedRole {
91
99
  model: Model;
92
100
  /** The effective thinking level: explicit `spec.thinking` wins, else the role's default. */
@@ -130,6 +130,10 @@ function specParts(s) {
130
130
  const cfg = s;
131
131
  return { model: cfg.model, select: cfg.select, thinking: cfg.thinking, systemPrompt: cfg.systemPrompt };
132
132
  }
133
+ export function roleSpecNamesModel(spec) {
134
+ const parts = specParts(spec);
135
+ return parts.model !== undefined || parts.select !== undefined;
136
+ }
133
137
  function toConfig(s) {
134
138
  if (s == null) {
135
139
  return undefined;
@@ -20,6 +20,11 @@
20
20
  * planted path is never written through); an advertiser whose directory is created elsewhere passes
21
21
  * no marker and an absent directory narrows.
22
22
  *
23
+ * A directory the fence already holds is never admitted as a HOLE in it: a candidate that resolves onto
24
+ * a root — or above one — would turn that whole root into the advertised, write-gate-exempt domain, so
25
+ * an ordinary source write inside the workspace would stop asking. One predicate
26
+ * ({@link swallowedFenceRoot}) states that, and the write gate's configured domains apply the same one.
27
+ *
23
28
  * Reverse check (the seat's own invariant): nothing enters `roots` except through `admit`, and every
24
29
  * caller of `admit` is an advertiser deciding its advertisement off the returned verdict — so no
25
30
  * directory reaches the ROOTS FENCE through this seat without the prompt advertising it, and none
@@ -55,12 +60,65 @@ export interface AdvertisedWritableDir {
55
60
  }
56
61
  /** The skip announcement — the one loud outlet for a directory that was advertised but could not be admitted. */
57
62
  export type AdvertisedWritableDirSkipped = (dir: AdvertisedWritableDir, reason: string) => void;
63
+ /**
64
+ * ONE rule for both halves of the auto-approved write surface: a directory offered as a WRITABLE DOMAIN
65
+ * (an advertised writable directory here; a configured `exemptDirs`/`acceptDirs` entry at the write
66
+ * gate) must not SWALLOW a root the fence already holds. Returns the first root the candidate equals or
67
+ * is an ancestor of — the one to name in the refusal — else `undefined`.
68
+ *
69
+ * Why a domain that contains a root is not a domain: the point of the domain is to exempt a small area
70
+ * the prompt (or the deployment) named. A candidate resolving onto the task root — a symlinked
71
+ * scratchpad or memory home is all it takes, and the resolution is what makes it invisible to the
72
+ * spelling — exempts the ENTIRE workspace instead, turning every ordinary source write from `ask` into
73
+ * `allow`. Refusing the admission is the fix; a special case inside the gate ("ignore this domain when
74
+ * it contains the root") would be the same rule written twice, in the place where it is easiest to
75
+ * forget. Both arguments must already be CANONICAL — this compares spellings, it does not resolve them.
76
+ */
77
+ export declare function swallowedFenceRoot(domainCanonical: string, fenceRootsCanonical: readonly string[]): string | undefined;
58
78
  export declare class AdvertisedWritableDirs {
59
79
  private readonly env;
60
80
  private readonly onSkipped;
81
+ /** The extra fence roots the SPEC declares (`additionalDirectories` / `additionalReadDirectories`), as
82
+ * spelled. Resolved by this seat rather than awaited from the hands phase — the memory advertiser is
83
+ * admitted BEFORE that phase, so a list that only grew there made the rule depend on phase order. An
84
+ * entry that does not resolve is not a fence root at all (the hands phase reads the SAME memoized
85
+ * answer and skips it too, announcing it there), so there is nothing here for it to guard. */
86
+ private readonly declaredRootSpellings;
61
87
  private readonly admitted;
62
88
  private readonly verdicts;
63
- constructor(env: ExecutionEnv, onSkipped: AdvertisedWritableDirSkipped);
89
+ /** The CANONICAL roots the fence holds: the task root at construction, plus every root the spec
90
+ * DECLARES, resolved by this seat before it judges anything (see {@link resolveDeclaredRoots}) —
91
+ * so which advertiser runs first cannot decide what the rule sees. */
92
+ private readonly fenceRoots;
93
+ /** Latched after the declared spellings have been resolved once (the resolution is per seat, not per call). */
94
+ private declaredRootsResolved;
95
+ /** ONE answer per declared spelling, for this seat AND for the hands phase that folds the same entries
96
+ * into the allowlist ({@link canonicalizeDeclaredRoot}): two independent lookups of one path can
97
+ * DISAGREE, and a root outside this rule's list while inside the fence is exactly the gap. */
98
+ private readonly declaredRootAnswers;
99
+ constructor(env: ExecutionEnv, onSkipped: AdvertisedWritableDirSkipped,
100
+ /** The run's task root, CANONICAL (the driver's one resolution — see `task-root-canonical.ts`). */
101
+ taskRootCanonical: string,
102
+ /** The extra fence roots the SPEC declares (`additionalDirectories` / `additionalReadDirectories`), as
103
+ * spelled. Resolved by this seat rather than awaited from the hands phase — the memory advertiser is
104
+ * admitted BEFORE that phase, so a list that only grew there made the rule depend on phase order. An
105
+ * entry that does not resolve is not a fence root at all (the hands phase reads the SAME memoized
106
+ * answer and skips it too, announcing it there), so there is nothing here for it to guard. */
107
+ declaredRootSpellings?: readonly string[]);
108
+ /** Declare one more CANONICAL root the fence holds (an `additionalDirectories` /
109
+ * `additionalReadDirectories` entry, once the hands phase has resolved it). Declaring is
110
+ * idempotent and order-free; a root declared after an advertiser was judged does not re-open it. */
111
+ declareFenceRoot(canonical: string): void;
112
+ /**
113
+ * The canonical form of a DECLARED fence-root spelling — resolved at most once per spelling and
114
+ * served from the memo thereafter. The hands phase calls this instead of canonicalizing the same
115
+ * entries again, so the list this rule judges against and the allowlist the fence ends up holding
116
+ * are built from ONE answer per path: a lookup that fails for this seat fails for that phase too,
117
+ * and a root cannot be outside the rule's view while being inside the fence.
118
+ */
119
+ canonicalizeDeclaredRoot(spelled: string): Promise<Awaited<ReturnType<ExecutionEnv["canonicalPath"]>>>;
120
+ /** Resolve the spec-declared root spellings into {@link fenceRoots}, exactly once per seat. */
121
+ private resolveDeclaredRoots;
64
122
  /** The admitted roots, CANONICAL, in admission order, deduplicated. The hands phase folds this list into the
65
123
  * write-capable containment allowlist before the toolkit compiles; it is not a live view after that. */
66
124
  get roots(): readonly string[];
@@ -1,11 +1,52 @@
1
+ import { isWithin } from "./session-rule-policy.js";
2
+ export function swallowedFenceRoot(domainCanonical, fenceRootsCanonical) {
3
+ for (const root of fenceRootsCanonical) {
4
+ if (root && isWithin(domainCanonical, root))
5
+ return root;
6
+ }
7
+ return undefined;
8
+ }
1
9
  export class AdvertisedWritableDirs {
2
10
  env;
3
11
  onSkipped;
12
+ declaredRootSpellings;
4
13
  admitted = [];
5
14
  verdicts = new Map();
6
- constructor(env, onSkipped) {
15
+ fenceRoots;
16
+ declaredRootsResolved;
17
+ declaredRootAnswers = new Map();
18
+ constructor(env, onSkipped, taskRootCanonical, declaredRootSpellings = []) {
7
19
  this.env = env;
8
20
  this.onSkipped = onSkipped;
21
+ this.declaredRootSpellings = declaredRootSpellings;
22
+ this.fenceRoots = [taskRootCanonical];
23
+ }
24
+ declareFenceRoot(canonical) {
25
+ if (canonical && !this.fenceRoots.includes(canonical))
26
+ this.fenceRoots.push(canonical);
27
+ }
28
+ async canonicalizeDeclaredRoot(spelled) {
29
+ const memo = this.declaredRootAnswers.get(spelled);
30
+ if (memo !== undefined)
31
+ return memo;
32
+ const c = await this.env.canonicalPath(spelled);
33
+ const first = this.declaredRootAnswers.get(spelled);
34
+ if (first !== undefined)
35
+ return first;
36
+ this.declaredRootAnswers.set(spelled, c);
37
+ if (c.ok)
38
+ this.declareFenceRoot(c.value);
39
+ return c;
40
+ }
41
+ resolveDeclaredRoots() {
42
+ this.declaredRootsResolved ??= (async () => {
43
+ for (const spelled of this.declaredRootSpellings) {
44
+ if (!spelled || !spelled.trim())
45
+ continue;
46
+ await this.canonicalizeDeclaredRoot(spelled);
47
+ }
48
+ })();
49
+ return this.declaredRootsResolved;
9
50
  }
10
51
  get roots() {
11
52
  return this.admitted;
@@ -41,8 +82,15 @@ export class AdvertisedWritableDirs {
41
82
  this.onSkipped(dir, `the execution environment resolves it to ${c.value} while the advertiser's gates key on ${dir.expectCanonical} — a fence hole those gates cannot see is not opened`);
42
83
  return false;
43
84
  }
44
- if (!this.admitted.includes(c.value))
45
- this.admitted.push(c.value);
85
+ if (this.admitted.includes(c.value))
86
+ return true;
87
+ await this.resolveDeclaredRoots();
88
+ const swallowed = swallowedFenceRoot(c.value, [...this.fenceRoots, ...this.admitted]);
89
+ if (swallowed !== undefined) {
90
+ this.onSkipped(dir, `it resolves to ${c.value}, which covers ${swallowed === this.fenceRoots[0] ? "task root" : "root"} ${swallowed} — a directory that holds a root is not a hole in the fence but the whole of it, and admitting it would auto-approve every write under that root`);
91
+ return false;
92
+ }
93
+ this.admitted.push(c.value);
46
94
  return true;
47
95
  }
48
96
  }
@@ -27,6 +27,21 @@ export interface AnnounceOnceSession {
27
27
  getAnnouncedListing(): Promise<AnnouncedListingSnapshot | undefined>;
28
28
  appendAnnouncedListing(snapshot: AnnouncedListingSnapshotInput): Promise<string>;
29
29
  }
30
+ /**
31
+ * Flush the PARKED announced-listing snapshot write (`FrameReceiptLedger.listingSnapshotOwed`, set by a
32
+ * frame receipt's commit inside the `message_end` walk) at a serialization point — the boundary's end,
33
+ * the prompt's settle. Best-effort: a lost append is a duplicate announcement next run. Never while
34
+ * prepare's branch read failed: a whole-snapshot write would replace the branch's record with this
35
+ * leg's partial view (the flag is cleared either way — nothing is owed on such a leg).
36
+ */
37
+ export declare function flushOwedAnnouncedSnapshot(prepared: {
38
+ frameReceipts: {
39
+ listingSnapshotOwed: boolean;
40
+ };
41
+ announcedSnapshotRecovered: boolean;
42
+ announcedListingsRef: AnnouncedListingSnapshotInput;
43
+ session: Pick<AnnounceOnceSession, "appendAnnouncedListing">;
44
+ }): Promise<void>;
30
45
  /** The run-local ledger — see the module header for the two commit disciplines it serves. */
31
46
  export interface AnnounceOnceLedger {
32
47
  /** The nearest announced snapshot on the branch as read at prepare (undefined = pre-entry
@@ -29,6 +29,14 @@ export function withAnnounceDigests(existing, add) {
29
29
  function sameFailure(a, b) {
30
30
  return a.name === b.name && a.error === b.error;
31
31
  }
32
+ export async function flushOwedAnnouncedSnapshot(prepared) {
33
+ if (!prepared.frameReceipts.listingSnapshotOwed)
34
+ return;
35
+ prepared.frameReceipts.listingSnapshotOwed = false;
36
+ if (!prepared.announcedSnapshotRecovered)
37
+ return;
38
+ await prepared.session.appendAnnouncedListing(prepared.announcedListingsRef).catch(() => undefined);
39
+ }
32
40
  export async function loadAnnounceOnceLedger(session, checkpointMirror, onError) {
33
41
  let recovered = true;
34
42
  const entry = await session.getAnnouncedListing().catch(() => {
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The auto-mode classifier's CANDIDATE LADDER, as one module: which models a classification may be asked on,
3
- * its per-rung reasoning seat, and the once-per-run disclosure its first move mints.
2
+ * The auto-mode classifier's CANDIDATE LADDER, as one module: which models a classification may be asked on
3
+ * (and which model stands in when no `classifier` role names one), its per-rung reasoning seat and the
4
+ * per-attempt user-message tail, how a rung's reply is read for a re-ask or a failure, and the once-per-run
5
+ * disclosures its first move and its broken contract mint.
4
6
  *
5
7
  * Beside `derived-route-fallback.ts` and in the same family — both are seats the caps phase's classifier
6
8
  * arming reaches DOWN for, and both answer "this leg's model did not work out" one layer apart. Which is why
@@ -30,7 +32,7 @@
30
32
  * `modelRequested` reading above; the unification (the pre-flight as rung 0 under its own cause word, one
31
33
  * record face for both) changes the notice set and is a ticket, not a repair.
32
34
  */
33
- import type { Model } from "../../internal/llm.js";
35
+ import type { AssistantMessage, Model } from "../../internal/llm.js";
34
36
  import { type AutoModeClassified } from "../auto-mode.js";
35
37
  import type { RunnerDeps } from "../types.js";
36
38
  /**
@@ -51,14 +53,74 @@ import type { RunnerDeps } from "../types.js";
51
53
  * loud refusal at the brain's request gate.
52
54
  */
53
55
  export declare function classifierLadderRungs(knob: unknown, classifier: Model, main: Model): readonly Model[];
56
+ /**
57
+ * WHY the classifier seat is the model it is when no `classifier` role names one — the closed set behind the
58
+ * `classifier.role_defaulted` notice's `reason`. `tier_below_main` = the nearest directly-bound tier below the
59
+ * run's main model; `tier_table` = the static `flash` row (a main bound to no tier); `role_fallback` = the
60
+ * `summarize → default` chain (no tiers at all); `main_only` = the main model is the only model there is (a
61
+ * single-model catalog, or nothing bound below the main's tier); `role_unresolvable` = a `classifier` role
62
+ * NAMED a model the catalog cannot resolve (a stale or misspelled reference), so the main model stands in —
63
+ * the pre-#805 leg fell back the same way, silently.
64
+ */
65
+ export declare const CLASSIFIER_ROLE_DEFAULT_REASONS: readonly ["tier_below_main", "tier_table", "role_fallback", "main_only", "role_unresolvable"];
66
+ export type ClassifierRoleDefaultReason = (typeof CLASSIFIER_ROLE_DEFAULT_REASONS)[number];
67
+ /** What {@link classifierSeatModel} returns: the model, and — when no role NAMED it — why it is that one. */
68
+ export type ClassifierSeat = {
69
+ readonly model: Model;
70
+ readonly defaulted?: ClassifierRoleDefaultReason;
71
+ };
72
+ /**
73
+ * The model the classifier SEAT resolves to for this run — ONE resolver for "which model classifies":
74
+ * · a `classifier` role that NAMES a model (`roleSpecNamesModel`: a string ref, a Model object, `{model}`,
75
+ * `{select}`) ⇒ the role resolver's answer, exactly as before (a preset-only role — `{thinking}` alone —
76
+ * does not name one and takes the default below);
77
+ * · else, with tier bindings declared (`deps.tiers`) and a main model on a tier ⇒ the nearest tier strictly
78
+ * BELOW the main's whose route differs from the main's — `tier_below_main`; the main's tier is the LOWEST
79
+ * tier its route sits on (a route bound as both `max` and `flash` is a `flash` main: deterministic, and
80
+ * the cheaper reading); nothing below ⇒ the main itself, `main_only`. Read off the expanded catalog's
81
+ * tier keys, the Runner's own resolution of a tier word: a key synthesised for an unbound tier falls DOWN
82
+ * to the next bound tier, which is the model this walk reaches anyway, and a catalog key spelled like a
83
+ * tier shadows the binding here as it does everywhere. No thinking preference: whether a model's
84
+ * thinking can be turned off is the SEAT's business ({@link classifierRungSeat}), not routing's;
85
+ * · else the role resolver's own fallback (the static tier row `classifier → flash` when tiers are active,
86
+ * `tier_table`; the `summarize → default` chain otherwise, `role_fallback`); a catalog with no roles at
87
+ * all ⇒ the main model, `main_only`.
88
+ * The relative pick follows the run's MAIN model on purpose: a side channel that resolved without it is the
89
+ * misfit the ticket names (a `flash` classifier above a `lite` main; two tiers below a `max` main).
90
+ *
91
+ * @contract classifier.seat.one_resolver — this function is the ONE answer to "which model judges this run's
92
+ * gated calls". The engine's arming calls it and a host that DISPLAYS the seat (a capabilities face, an
93
+ * operator pane) calls the same exported function rather than re-deriving the pick from catalog, roles and
94
+ * tiers: a second derivation is a second answer, and the two drift the moment either side's rules move.
95
+ */
96
+ export declare function classifierSeatModel(args: {
97
+ deps: Pick<RunnerDeps, "models" | "roles" | "tiers">;
98
+ main: Model;
99
+ }): ClassifierSeat;
100
+ /**
101
+ * The `classifier.role_defaulted` line — minted ONCE per run by the arming, AFTER the route pre-flight, so it
102
+ * names the seat actually asked (a pre-flight swap to the main model is its own line,
103
+ * `route.fallback_to_primary`, same run). Silent when a role named the model: nothing was defaulted.
104
+ */
105
+ export declare function announceClassifierRoleDefaulted(args: {
106
+ onNotice: RunnerDeps["onNotice"];
107
+ seat: ClassifierSeat;
108
+ sessionId?: string;
109
+ runId?: string;
110
+ }): void;
54
111
  /**
55
112
  * The classifier's REASONING SEAT, read off the model a classification is actually asked on — ONE predicate:
56
- * can the wire say off? Yes ⇒ `off` + the verdict-sized cap (the request pays for the answer alone); no ⇒
57
- * `low` + the verdict cap + the low-reasoning budget (a model that thinks regardless is told to think the
58
- * LEAST its wire can spell, in exactly the room that tier needs). PER RUNG, not once per arming: the second
59
- * rung is a different model with its own wire, and asking it under the first rung's seat is how a correct
60
- * fallback comes back with zero text. The `low` seat is also the re-ask seat: an unknown model that answers
61
- * NOTHING at the `off` cap has just told us it thinks anyway, and is re-asked as one that does.
113
+ * does the wire SPELL off ({@link thinkingOffSpelled})? Yes ⇒ `off` + the verdict-sized cap (the request pays
114
+ * for the answer alone); no ⇒ `low` + the verdict cap + the low allowance (a model whose thinking the wire
115
+ * cannot turn off is asked for the least it can grade, in the room that costs — CC 2.1.259 `MU`: spell off,
116
+ * or leave thinking to the provider and pay 2048). A model that declares no reasoning at all is on the `low`
117
+ * seat too: its wire carries nothing either way, so the request is the gateway's default, and a default-on
118
+ * gateway thinks — the arm that used to read it as "never thinks" is the one the field report was about.
119
+ * PER RUNG, not once per arming: the second rung is a different model with its own wire.
120
+ *
121
+ * The THINK seat is the RE-ASK seat ({@link classifierReplyAsksAgain}): a first attempt that produced no
122
+ * verdict is asked once more with thinking allowed the room of CC's stage-2 classifier (8192) — an unknown
123
+ * model that answered NOTHING at its cap has just told us it thinks past it, and is re-asked as one that does.
62
124
  */
63
125
  export declare const CLASSIFIER_OFF_SEAT: {
64
126
  reasoning: "off";
@@ -68,11 +130,92 @@ export declare const CLASSIFIER_LOW_SEAT: {
68
130
  reasoning: "low";
69
131
  maxTokens: number;
70
132
  };
133
+ export declare const CLASSIFIER_THINK_SEAT: {
134
+ reasoning: "low";
135
+ maxTokens: number;
136
+ };
137
+ export type ClassifierSeatRequest = typeof CLASSIFIER_OFF_SEAT | typeof CLASSIFIER_LOW_SEAT | typeof CLASSIFIER_THINK_SEAT;
138
+ /**
139
+ * The seat for one rung's FIRST attempt — see the seat table above.
140
+ *
141
+ * @contract classifier.budget_is_room — every classifier output cap is ROOM the request asks for, never a
142
+ * bound the engine can vouch for: a gateway may exclude reasoning tokens from `max_tokens` (a vLLM-fronted
143
+ * Qwen gateway answered a cap of 128 with 938 output tokens and `stop`), so an over-long think is cut by the
144
+ * round-trip timeout (`unavailable` / `timeout`), not by the cap (`parse_error` on an empty reply), and the
145
+ * two are attested as the two different facts they are.
146
+ */
71
147
  export declare function classifierRungSeat(model: Model): typeof CLASSIFIER_OFF_SEAT | typeof CLASSIFIER_LOW_SEAT;
148
+ /**
149
+ * The user-message TAIL of each attempt — CC 2.1.259's classifier suffixes, verbatim: the FIRST attempt
150
+ * carries the fast-mode suffix (`$ns`: the answer must begin with `<block>`, no preamble), the RE-ASK carries
151
+ * the thinking-stage suffix (`Uns`: think in `<thinking>` first, then answer). The base prompt (2.1.250 `KRg`)
152
+ * already says "no preamble" once; upstream repeats it at the END of the user message, and it is the repeat
153
+ * that lands — live 2026-09-15, a Qwen 3.8 with thinking off went from 70% tagged replies (1.2–3.9k chars of
154
+ * "Looking at this action…" prose) to 90% at 8 output tokens with the first suffix, and the re-ask suffix
155
+ * had it write its reasoning inside `<thinking>` (stripped by the reader) before the tag.
156
+ */
157
+ export declare const CLASSIFIER_ATTEMPT_SUFFIX: {
158
+ readonly first: "\nErr on the side of blocking. Your ENTIRE response MUST begin with <block>. Do NOT output any analysis, reasoning, or commentary before <block>. No \"Looking at...\" or similar preamble.";
159
+ readonly reask: "\nReview the classification process and follow it carefully, making sure you deny actions that should be blocked. As a reminder, explicit (not suggestive or implicit) user confirmation is required to override blocks. Use <thinking> before responding with <block>. Think longer on ambiguous or borderline actions; keep reasoning brief for clear-cut ones.";
160
+ };
161
+ /** The classifier reply's answer text — every text block joined; thinking blocks are not the verdict. */
162
+ export declare function classifierReplyText(reply: AssistantMessage): string;
163
+ /**
164
+ * CC 2.1.259 `$Tt`: an EMPTY reply that stopped at the output cap is "unparseable" — a reply to ask for again,
165
+ * not a refusal and not a verdict. The engine's brains report that cut two ways: `stopReason:"length"` (an
166
+ * engine-shrunk cap) or `stopReason:"error"` with `errorKind:"length_empty"` (a static cap — the classifier's
167
+ * own). An empty reply with any OTHER stop is CC's `policy_refusal` arm: not re-asked.
168
+ */
169
+ export declare function classifierReplyEmptyAtCap(reply: AssistantMessage): boolean;
170
+ /**
171
+ * A reply that may answer in the TAG form only: its text is not a complete answer of its own. Output cut by the cap
172
+ * (`stopReason: "length"`), a dropped connection or a stalled stream (`partialFinalized`, with a synthesised
173
+ * `stop`), and a reply that CALLED A TOOL (`stopReason: "toolUse"`, or a tool-call block in the content — the
174
+ * classifier is given no tools, and the openai brain reports a length cut with a surviving call as `toolUse`):
175
+ * whatever text arrived is a prefix or a fragment — a `No` on its way to `No, this must be blocked` — so it may
176
+ * carry a verdict only through a `<block>` tag, which reads a `<block>no` prefix as CC does; the bare-answer arm
177
+ * never sees it.
178
+ */
179
+ export declare function classifierReplyTagOnly(reply: AssistantMessage): boolean;
180
+ /**
181
+ * The text the DECIDER reads for its verdict: the reply's answer text, except that a tag-only reply
182
+ * ({@link classifierReplyTagOnly}) whose text carries NO tagged answer reads as EMPTY — a cut prefix must not
183
+ * reach the bare-answer arm as if it were the whole reply (adversarial passes 2–3: a connection cut after `No`,
184
+ * and a scratch `<block>` mention before a cut `No`, executed the gated tool). Judged by the reader itself with
185
+ * its bare arm closed, so "no tagged verdict" and "no verdict" cannot drift. Only the MISSING form is emptied:
186
+ * a tag-only reply that carries a CONFLICT keeps its text, so the decider records the conflict and the leg
187
+ * does not re-ask it (adversarial pass 4: emptying every parse failure had turned a cut conflict into a
188
+ * missing reply, and the re-ask into an allow). The decider then records `parse_error` / `missing` for the
189
+ * emptied text, the same word an empty reply gets.
190
+ */
191
+ export declare function classifierReplyVerdictText(reply: AssistantMessage): string;
192
+ /**
193
+ * Whether a first attempt is asked AGAIN on the THINK seat — ONE rule: the reply is MISSING a verdict, either
194
+ * empty at the cap ({@link classifierReplyEmptyAtCap}), cut or fragmentary without a tagged verdict
195
+ * ({@link classifierReplyTagOnly}), or text in which the reader finds no answer at all (`parse_error` of form
196
+ * `missing`: prose, a thinking-only reply). Not a CONFLICT (two `<block>` answers — it carries a `yes`, and a second attempt that answered `no`
197
+ * would erase it: the fail-closed `parse_error` it is stands) and not an empty reply with a non-cap stop
198
+ * (CC's refusal arm). CC 2.1.259 `Vrr` re-asks an unparseable reply up to `maxRetries` times; sema re-asks
199
+ * ONCE, on the stage-2 room, so a rung costs at most two calls. A failed reply ({@link classifierReplyFailure})
200
+ * is judged before this and never reaches it.
201
+ */
202
+ export declare function classifierReplyAsksAgain(reply: AssistantMessage): boolean;
203
+ /**
204
+ * A reply that is a LEG FAILURE rather than an answer — the brains RETURN a provider error as a message
205
+ * (`stopReason: "error"` with the provider's text on `errorMessage`: an HTTP 4xx/5xx, a quota refusal, an
206
+ * overloaded gateway) and a cancelled request as `"aborted"`; neither is a reply the reader may judge. Read as
207
+ * text they were `parse_error` — a card saying "the classifier's reply carried no verdict" about a model that
208
+ * never answered, a ladder record blaming `parse_error` instead of `error`. The leg THROWS what this returns
209
+ * and the decider maps the throw to `unavailable` (`error`), as it maps every leg failure; the sibling
210
+ * side-channel legs (compaction, consolidation) already read an error stop this way. The cap-cut form
211
+ * (`errorKind: "length_empty"`) is not a failure: it is the empty-at-cap reply the re-ask owns.
212
+ */
213
+ export declare function classifierReplyFailure(reply: AssistantMessage): Error | undefined;
72
214
  /**
73
215
  * The per-decision READ of a classification, as one seat: the `auto_mode.classified` trace frame (wall time,
74
- * verdict, cause) and the ladder's throttled `classifier.fallback` disclosure. Both are observers — a
75
- * throwing sink never reaches the gate (the decider swallows it), and neither changes a verdict.
216
+ * verdict, cause), the ladder's throttled `classifier.fallback` disclosure and the per-model
217
+ * `classifier.contract_broken` streak. All are observers — a throwing sink never reaches the gate (the decider
218
+ * swallows it), and none changes a verdict.
76
219
  *
77
220
  * The frame's `model` is the model the classification RAN ON, which is what the field has always meant (its
78
221
  * declaration reads "the resolved classifier role, or the main model it fell back to"): the route