@sema-agent/core 7.26.1 → 7.26.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.26.2 — 2026-09-21
4
+
5
+ ### An assignment word is not a path; its VALUE is, and the question about a value is who may open it (#1004; behaviour NARROWING and RELAXING, both; +0 exports; @cli @test)
6
+
7
+ - **What changed.** `A=~/.env; true` was refused on every seat with `read_path_denied` and `target: "A=~/.env"` — the whole assignment WORD. Nothing in that command reads a file: bash sets a variable and runs `true`. The mint offered the word itself to the deny judge whenever the text could not settle the VALUE (a tilde bash expands at the start of a value and after each `:`, an append's earlier content), and a deny row whose leading double-star segment swallows the `A=~/` then read the rest as the guarded name. The refusal named a file that cannot exist, and the same row did not match `A=~/secret` unless spelled to — a barrier by coincidence of pattern shape.
8
+ - **One rule.** Every candidate such a word hands the judge is a fact about its value, and the question about the value is WHO MAY OPEN IT. At an operand slot that is the program's own grammar (`dd if=~/x` keeps its fail-closed answer, now accounted as `"~/x"` rather than `"if=~/x"`). For an environment word it is the program the segment STARTS: `A=~/.env cat safe.txt` hands `cat` a value bash expands and `cat` may open; `A=~/.env; true` starts no program; `A=~/'.env' true` starts one declared to open no path operand. Where a program may open it, an environment value that names ONE path is PLACED the way an operand is — a leading tilde against the declared home — so the deny judge and the refusal sentence name the real file.
9
+ - **NARROWING.** A value bash expands from a tilde is now judged against the declared home instead of being read as the literal word: `A=~/.env cat safe.txt`, `env A=~/.env cat safe.txt`, `A+=~/.env cat safe.txt`, `GIT_CONFIG_GLOBAL=~/.env git config --global --list` and `BASH_ENV=~/.env bash script.sh` refuse with `target: "<home>/.env"` on all three seats. The last two are the point: a program may open what its environment names (verified against the real `git`, which prints such a file's contents), and 7.26.1 refused those only when the deny row happened to swallow `NAME=~`. An absolute-path row never did.
10
+ - **RELAXING.** `A=~/.env; true` and `A=~/'.env' true` are vouched where they were refused: no program is started that could open the value, and the word itself is nobody's filename. The read boundary is not relaxed — those commands open nothing.
11
+ - **Who is hurt.** A deployment reading the refusal's `target` as the literal command word sees a path instead; that is the repair. A command whose environment value the text can settle is judged exactly as before. A value a `:` joins into two paths is placed by nobody (one spelling cannot carry two) and is no candidate — the remaining half of KL-993b.
12
+ - **Compensation.** The refusal's own judgement is pinned: a `read_path_denied` target is a path spelling, never a word carrying `=`, and the path quoted in the sentence is that target (`test/backlog1004-assignment-word-not-a-path.test.ts` ②). KL-993b records what is left.
13
+ ### Every leg of the verify gate offers its wiring manifest, and says which leg it is (#1005; additive; +0 exports; @server)
14
+
15
+ - **What was unreachable.** `runWithVerification` / `resumeWithVerification` / `runDeveloperTask` / `verifyCompleted` return a `TaskResult` and drain each leg's event stream internally, so the `wiring_manifest` frame every prepared leg pushes died inside the gate. A deployment recording what each attempt was wired with — its tool roster, its MCP rows, its governance section — saw "done" and nothing else, while the gate itself knew exactly which leg it had just dispatched.
16
+ - **What changes.** `RunInternals` gains `onWiringManifest?: (manifest, leg?) => void`, the fourth observation sink on the trusted core-caller channel beside `onActivity` / `onForwardEvent` / `onWorkspaceResolved`, and never a `TaskSpec` field. It is handed the very object that leg's frame carries, once per prepared leg, at the moment the frame is pushed — after prepare, before the leg's first model or tool interaction — on fresh and resumed legs alike.
17
+ - **The leg identity is minted by the composition, never by the engine.** The verify entries dispatch each leg with a per-dispatch copy of the internals object whose sink closes over that leg's `{kind: "impl" | "verifier" | "fix", round}`: round 0 for the entry implementation leg, the round a verifier verifies, the round whose findings a fix answers. The engine stamps none and infers none, so a leg nobody labelled — a plain `runTask`, a workflow agent call, a cascade rung — reaches the sink with `leg` absent, which is the honest reading of an unlabelled leg. Nothing a caller can set ON the object produces an identity, and the identity itself goes out FROZEN and fresh per dispatch — an observer that writes to what it was handed is refused, rather than rewriting what the next leg, or the next run, reports. This is a different axis from the manifest's own `leg` field (`root` / `child` / `resume`): a resumed fix leg carries both, and both are true.
18
+ - **Containment, and one notifier per run.** The sink is contained by the same run-scoped safe notifier as `onActivity`: a throw — or an `async` sink returning a rejecting promise — is swallowed, counted and disclosed once, never faulting the leg whose manifest it was handed. That notifier is now minted in the identity-wiring lane (the run's first internals-sink call) and shared with the harness handlers, where each lane used to mint its own; bounded disclosure is per notifier, so one account per run is what makes "reported once" true across the sinks on one object.
19
+ - **Who is hurt.** A caller that wires nothing is dispatched exactly as before (the per-dispatch copy is made only when the sink is present). A caller that DOES wire it gets one documented delta on top of the #246 forwarding shape: the gate's legs receive a fresh shallow copy carrying the bound sink instead of the caller's object by identity — every other field still rides by the caller's own reference, the caller's object is never mutated, and the `isolation` door still re-reads that same object before every leg.
20
+ ### A deployment's policy refusal stops speaking as the user (#1016; ADDITIVE closed-set word; +0 public exports; @server @cli @client-core)
21
+
22
+ - **What was wrong.** A shell running headless wires an `onAsk` approver that can show nobody a prompt, so it refuses on the deployment's behalf. The only way to spell that was `false` (plus a reason), and `false` means one thing to the engine: a person said no. The model was therefore told *"The user doesn't want to proceed with this tool use … treat it as the user's guidance"* and *"STOP what you are doing and wait for the user"* — about a machine's refusal, in a run where no user turn is ever coming. The deployment's own words about WHY rode a fence labelled as the user's.
23
+ - **The word.** `AskOutcome.settledBy` gains `"policy"` and the durable decide's `hostDecision.decidedBy` gains the same word — one fact, the same spelling on both legs. `SettlementKind` gains `policy_refused` (`who.party: "none"`, a refusal in `SETTLEMENT_IS_REFUSAL`), which is what `tool_end.gate.settlement.kind` and the durable row then carry.
24
+ - **The sentence.** One composer (`policyRefusalMessage`, module-internal — both legs call it, neither re-spells it) mints it for both legs: `approval required for "<tool>" but this run has no interactive approval channel: <ask prompt>` / `The deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and permission prompts are not available in this context.` plus a posture directive that tells the model to report what was blocked and get on with the work that needs no approval. A note rides a fence labelled `policy note` — relayed verbatim, never licensed as anyone's guidance. `humanRefusalMessage` is reached only by a person's refusal now.
25
+ - **The default does not move.** An omitted `settledBy` still reads as `"human"`, and `hostDecision.decidedBy` keeps both of its existing words: a deployment that never learns the new word is byte-unchanged on every face. Only a host that SAYS `"policy"` gets the policy sentence.
26
+ - **Who is hurt.** A consumer whose table over `SettlementKind` is exhaustive reds on the new member — that red is the notification, and it is what a closed set is for. A shell that adopts the word also changes its own turn shape: a BARE `human_refused` is the issuing batch's control-flow boundary (siblings settle un-executed, the turn ends awaiting the user) and `policy_refused` deliberately is not, because there is no user to await. The refusal still stands; the run continues.
27
+ - **Two screens widened, neither loosened.** `{allow: true}` beside any non-person word is a fail-closed `approver_contract` deny (was: the `timeout` word only), and a durable `decision:"allow"` is refused for every decider that is not `"person"` (was: `sla_timeout` only). One condition each, over the whole non-person family, so the next word is screened the day it is added.
28
+ - **The hole the word opened, found by the adversarial round.** "Was this deny made by nobody?" — the question the classifier's denial-limit fallback asks before stopping a run whose questions nothing can answer — was a hand-written two-word disjunction at the inherited chain's fold and a three-word one at the main gate. `policy_refused` answered it by falling out of both: a headless lane that hit the denial limit and refused BY POLICY no longer reached the stop that the same lane reached when it refused with a bare `false`. `SETTLEMENT_NOBODY_JUDGED` is now that question, once, with a compile-time fence over the whole set, and both sites read it. **Consequence for a shell adopting the word:** a run that hits the classifier denial limit on a policy-refusing SYNCHRONOUS lane ends `failed` with `classifier.denial_limit`, as a fully headless one already did. **Stated precisely, because the leg matters:** that stop seat exists on the synchronous gate and the inherited chain, and on NEITHER leg of a durable resume — a parked fallback ask answered through `/decide` re-parks instead of stopping, for every decider word alike (measured: `person` and `policy` produce identical runs). That gap predates this word and is not narrowed by it; it is recorded for its own fix.
29
+ - **Doc repairs found on the way.** `INTEGRATION-CORE` §1.3 still described a flat `tool_end.settledBy` and §4.4 a top-level `settledBy` on a decide; both were retired at 7.6.0 and a consumer following them was reading fields no release mints. §1.3 now documents the live `gate` record and §4.4 the `hostDecision` facts plus the refused keys. Same repair in the governed-agent example's `seats-2` row.
30
+ - Wire table: `docs/wire-consumption/unreleased-1016.md`.
31
+
3
32
  ## 7.26.1 — 2026-09-21
4
33
 
5
34
  ### One store, one answer about one file; one lineage judgment at every station (#1003 + #1000; narrowing; +0 exports; @server)
@@ -253,8 +253,8 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
253
253
  * WHY THE SEAT EXISTS: these entries return a `TaskResult` and DRAIN each leg's event stream
254
254
  * internally, so the host never holds a `TaskStream` for any of them. Without this parameter a
255
255
  * deployment can observe nothing a leg did — tool beats, forwarded child progress, the resolved
256
- * workspace — only the final result. Wiring an observation sink here (`onActivity`,
257
- * `onForwardEvent`, `onWorkspaceResolved`) is the only way in.
256
+ * workspace, the leg's own wiring manifest — only the final result. Wiring an observation sink here
257
+ * (`onActivity`, `onForwardEvent`, `onWorkspaceResolved`, `onWiringManifest`) is the only way in.
258
258
  *
259
259
  * ATTRIBUTION — WEAK, and deliberately stated as such: the legs run SEQUENTIALLY (impl → verifier →
260
260
  * fix → verifier → …) and each is dispatched with the SAME object, so a leg's own tool beats are all
@@ -264,10 +264,20 @@ export declare function verifyCompleted(runner: RunnerSelfSeat, result: TaskResu
264
264
  * no verifier marker either (an impl spec carrying its own `outputSchema` mounts it too).
265
265
  * {@link VerifyConfig.onRound} bounds a ROUND, not a leg — the segment before it holds the impl (or
266
266
  * fix) leg's beats AND that round's verifier beats together. A host that needs true per-leg
267
- * attribution must drive the legs itself (one `runner.runTask` per leg) rather than read it out of
268
- * this stream. Two further honesty notes: SUBAGENT events forwarded through `onForwardEvent` do
269
- * carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that spawned it — its
270
- * frames can arrive after that leg returned, i.e. interleaved into a later leg's segment.
267
+ * attribution for THOSE frames must drive the legs itself (one `runner.runTask` per leg) rather than
268
+ * read it out of this stream. Two further honesty notes: SUBAGENT events forwarded through
269
+ * `onForwardEvent` do carry their own `sourceTaskId`, and a BACKGROUND child outlives the leg that
270
+ * spawned it — its frames can arrive after that leg returned, i.e. interleaved into a later leg's
271
+ * segment.
272
+ *
273
+ * ONE SEAT IS EXEMPT, because its frame is per-leg by construction: `onWiringManifest` fires exactly
274
+ * once per leg, at that leg's start, and this gate hands it the leg's own IDENTITY beside the
275
+ * manifest (the axis, and the two readings a consumer cannot infer, are documented at the seat).
276
+ * That identity is minted HERE, in the dispatch that knows it — the engine stamps none and infers
277
+ * none — so a consumer of this seat reads per-leg attribution instead of reconstructing it from
278
+ * arrival order. This is also the one place the forwarding below is not by identity: a caller that
279
+ * wired that sink gets a fresh per-dispatch copy carrying the bound sink (see `withLegIdentity`),
280
+ * every other field still by its own reference.
271
281
  *
272
282
  * FAILURE-ISOLATED since #248, by the same primitive as {@link VerifyConfig.onRound} /
273
283
  * {@link VerifyConfig.onVerifierPause}: the fields on this object are invoked by the ENGINE on the
@@ -81,6 +81,13 @@ function refuseUnhonorableInternals(internals, phase) {
81
81
  `implementation leg keeps whatever it did (a consumed checkpoint stays consumed, its session stays the caller's to reclaim).`));
82
82
  }
83
83
  }
84
+ function withLegIdentity(internals, leg) {
85
+ if (internals?.onWiringManifest === undefined)
86
+ return internals;
87
+ const identity = Object.freeze({ ...leg });
88
+ return { ...internals, onWiringManifest: (manifest) => internals.onWiringManifest?.(manifest, identity) };
89
+ }
90
+ const IMPL_LEG = { kind: "impl", round: 0 };
84
91
  function fixObjective(findings) {
85
92
  const list = findings.length
86
93
  ? findings.map((f) => `- ${sanitizeUntrustedText(f)}`).join("\n")
@@ -173,7 +180,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
173
180
  ...(specBase.promptProfile !== undefined ? { promptProfile: specBase.promptProfile } : {}),
174
181
  signal: specBase.signal,
175
182
  };
176
- const v = await runner.runTask(verifierSpec, legInternals());
183
+ const v = await runner.runTask(verifierSpec, withLegIdentity(legInternals(), { kind: "verifier", round }));
177
184
  try {
178
185
  const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
179
186
  return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.terminal) ? { paused: { ...v, terminal: v.terminal } } : {}) };
@@ -259,7 +266,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
259
266
  readDenyPatterns: foldedReadDeny,
260
267
  }),
261
268
  };
262
- current = await runner.runTask(fixLegSpec, legInternals());
269
+ current = await runner.runTask(fixLegSpec, withLegIdentity(legInternals(), { kind: "fix", round }));
263
270
  spend += (current.stats.costMicroUsd ?? 0) + (current.stats.nested?.costMicroUsd ?? 0);
264
271
  implAccount.add(current.stats);
265
272
  if (isDurablePause(current.terminal)) {
@@ -272,7 +279,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
272
279
  }
273
280
  export async function runWithVerification(runner, implSpec, config = {}, internals) {
274
281
  refuseUnhonorableInternals(internals, "door");
275
- const result = await runner.runTask(implSpec, internals);
282
+ const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
276
283
  if (isDurablePause(result.terminal)) {
277
284
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
278
285
  }
@@ -281,7 +288,7 @@ export async function runWithVerification(runner, implSpec, config = {}, interna
281
288
  }
282
289
  export async function resumeWithVerification(runner, token, outcome, taskConfig, objective, config = {}, internals) {
283
290
  refuseUnhonorableInternals(internals, "door");
284
- const result = await runner.resume(token, outcome, taskConfig, internals);
291
+ const result = await runner.resume(token, outcome, taskConfig, withLegIdentity(internals, IMPL_LEG));
285
292
  if (isDurablePause(result.terminal)) {
286
293
  return { ...mapNestedSuspend(result), verification: { verdict: "unverified", unverifiedReason: pauseFamily(result.terminal), rounds: 0, findings: [] } };
287
294
  }
@@ -291,7 +298,7 @@ export async function resumeWithVerification(runner, token, outcome, taskConfig,
291
298
  export async function runDeveloperTask(runner, spec, config = {}, internals) {
292
299
  const implSpec = { systemPrompt: CODE_AGENT_PROMPT, ...spec };
293
300
  if (config.verify === false) {
294
- const result = await runner.runTask(implSpec, internals);
301
+ const result = await runner.runTask(implSpec, withLegIdentity(internals, IMPL_LEG));
295
302
  return { ...result, verification: { verdict: "unverified", unverifiedReason: "opted_out", rounds: 0, findings: [] } };
296
303
  }
297
304
  return runWithVerification(runner, implSpec, config, internals);
@@ -613,14 +613,17 @@ export type ResumeOutcome = {
613
613
  answer?: QuestionAnswer;
614
614
  /**
615
615
  * The host's DECISION FACTS — who ended this wait: a person (`"person"`, the decide an operator made
616
- * through the deployment's approval channel, with the attribution that channel reports), or the
616
+ * through the deployment's approval channel, with the attribution that channel reports), the
617
617
  * host's own SLA sweep at the row's deadline (`"sla_timeout"` — FACET A of the deadline contract; only
618
- * a sweep may say it, and a sweep cannot `allow`). REQUIRED: a decide that names nobody is not a
619
- * record of anything that happened, and core does not guess.
618
+ * a sweep may say it), or the deployment's OWN POLICY (`"policy"` — a machine refused: no interactive
619
+ * approval surface exists for this row, or a standing posture declined it; the durable spelling of the
620
+ * synchronous leg's `AskOutcome.settledBy: "policy"`, so ONE refusal reads the same on both faces).
621
+ * Only a person can `allow` — the other two words name a wait that ended with nobody judging.
622
+ * REQUIRED: a decide that names nobody is not a record of anything that happened, and core does not guess.
620
623
  *
621
624
  * The host supplies FACTS, never the settlement WORD: core mints the record
622
- * ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired`,
623
- * joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
625
+ * ({@link import("./gate-outcome.js").GateOutcome} — `human_allowed` / `human_refused` / `park_sla_expired`
626
+ * / `policy_refused`, joined with the row's persisted `origin`) at the resume entry, writes it onto the resolved row
624
627
  * (`resolvedOutcome.gateOutcome`) and projects it onto the resolved call's `tool_end` frame. A caller
625
628
  * that supplies `gateOutcome` itself is refused pre-CAS.
626
629
  *
@@ -635,7 +638,7 @@ export type ResumeOutcome = {
635
638
  * authorization; a replay states its own facts.
636
639
  */
637
640
  hostDecision: {
638
- decidedBy: "person" | "sla_timeout";
641
+ decidedBy: "person" | "sla_timeout" | "policy";
639
642
  approver?: string;
640
643
  };
641
644
  /**
@@ -2175,8 +2178,8 @@ export declare class CheckpointError extends Error {
2175
2178
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2176
2179
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
2177
2180
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
2178
- * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`, pair
2179
- * an SLA timeout with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2181
+ * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
2182
+ * pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2180
2183
  * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
2181
2184
  * it). The throw
2182
2185
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
@@ -2354,8 +2357,8 @@ export declare class CheckpointError extends Error {
2354
2357
  * re-fetch the pending list), `"boundInputHash"` (the input you reviewed has changed under the same
2355
2358
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
2356
2359
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
2357
- * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`, pair
2358
- * an SLA timeout with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2360
+ * `"hostDecision"` (the decision facts are missing, name a decider outside `person`/`sla_timeout`/`policy`,
2361
+ * pair a non-person decider with an allow, or try to supply the minted word), or — design/252 G-7 — `"approver"` (the attribution is not a
2359
2362
  * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
2360
2363
  * it). The throw
2361
2364
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
@@ -1,6 +1,7 @@
1
1
  import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
2
2
  import { classifierMayAnswer, classifierMayAnswerMandate, mandateDisclosureOf, persistedRuleMandateOf, ruleClearanceOf, safetyAxisOf, sandboxMayAdmitMandate, stampAskOrigin } from "./ask-origin.js";
3
3
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
+ import { SETTLEMENT_NOBODY_JUDGED } from "./gate-outcome.js";
4
5
  import { inlineUntrusted } from "./untrusted-text.js";
5
6
  import { isRuleBehavior } from "./permission-rule-model.js";
6
7
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
@@ -539,7 +540,7 @@ export async function runGateLanes(pass) {
539
540
  input.autoMode?.denialTracking?.recordAllow();
540
541
  }
541
542
  else if ((resolved.action === "deny" &&
542
- (resolved.settlement.kind === "no_approver" || resolved.settlement.kind === "blanket_allow_refused" || resolved.settlement.kind === "approver_unavailable")) ||
543
+ (SETTLEMENT_NOBODY_JUDGED[resolved.settlement.kind] || resolved.settlement.kind === "approver_unavailable")) ||
543
544
  resolved.approverUnavailable === true) {
544
545
  try {
545
546
  input.onHeadlessDenialLimit?.({ toolName, toolCallId, fallback: unarmedWindow(askBeforeResolve.denialLimitFallback) });
@@ -29,9 +29,17 @@ import { type AutoModeClassifierRound } from "./auto-mode.js";
29
29
  * unreadable member, a timeout-settled allow, a refused attribution…).
30
30
  * - `presentation_failed` — the args or the edit could not be safely presented/adopted (unclonable).
31
31
  * - `blanket_allow_refused` — a blanket `onAsk:"allow"` met a `requiresRealApproval` ask.
32
+ * - `policy_refused` — the DEPLOYMENT'S OWN POLICY refused: the approval channel reported that a machine,
33
+ * not a person, ended this wait (a headless lane with no interactive approval surface, a standing
34
+ * deployment posture). Distinct from `human_refused` because the party differs and the model is told a
35
+ * different thing: a person's "no" is the user's direction, a policy's is a capability of this run.
36
+ * Distinct from `no_approver` because an approval channel WAS wired and answered — core did not fall
37
+ * back to its own headless posture. The refusal's own note, when the channel attached one, reaches the
38
+ * model as the policy's words and NOT as a person's; it is not on this record (the note seat belongs to
39
+ * the two human words, whose ABSENCE is a control-flow fact this family does not have).
32
40
  * - `task_aborted` — the wait's abort signal ended it (the run's own end, or a turn-level interrupt).
33
41
  */
34
- export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "task_aborted"];
42
+ export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "policy_refused", "task_aborted"];
35
43
  export type SettlementKind = (typeof SETTLEMENT_KINDS)[number];
36
44
  /** Membership test for {@link SettlementKind} — the word crosses process boundaries on `tool_end`, so a
37
45
  * consumer validating one it received must not hand-roll the set. */
@@ -49,9 +57,9 @@ export declare function isSettlementKind(v: unknown): v is SettlementKind;
49
57
  *
50
58
  * @contract settlement.single_mint — every `Settlement` kind is composed by CORE at the site whose wait
51
59
  * ended (the ask resolver's arms, the approval factory's arms, the decide lane over a host's decision
52
- * FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"` on its outcome
53
- * and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`) — facts core turns
54
- * into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
60
+ * FACTS). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"`/`"policy"` on its
61
+ * outcome and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`/`"policy"`) — facts
62
+ * core turns into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
55
63
  * so a policy cannot self-declare one; the engine's own settlement sites that hand a settled decision
56
64
  * back through a policy fold attest it through {@link engineSettlementOf}.
57
65
  */
@@ -87,7 +95,7 @@ export type Settlement = {
87
95
  };
88
96
  when: number;
89
97
  } | {
90
- kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "task_aborted";
98
+ kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "policy_refused" | "task_aborted";
91
99
  who: {
92
100
  party: "none";
93
101
  };
@@ -132,10 +140,51 @@ export declare const SETTLEMENT_IS_REFUSAL: {
132
140
  readonly approver_contract: true;
133
141
  readonly presentation_failed: true;
134
142
  readonly blanket_allow_refused: true;
143
+ readonly policy_refused: true;
135
144
  readonly task_aborted: true;
136
145
  };
137
146
  /** Compile-time fence: `never` while every settlement kind has a refusal row. */
138
147
  export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_IS_REFUSAL>>;
148
+ /**
149
+ * Which settlement kinds are a refusal NOBODY JUDGED — the wait ended because this run has no one who
150
+ * could answer the question, not because someone considered it and said no. The classifier's
151
+ * denial-limit fallback reads it: that fallback exists to bound a classifier that would otherwise deny
152
+ * without end, and its last resort is to STOP the run rather than let the model keep re-asking a
153
+ * question nothing in this deployment can answer.
154
+ *
155
+ * It is a TABLE and not a condition for the reason the sibling above is: this question used to be a
156
+ * hand-written two-word disjunction at one site and a three-word one at another, so a settlement word
157
+ * added later answered it by falling out of both — which is exactly what `policy_refused` did on the
158
+ * commit that introduced it (a headless lane that refused by policy stopped reaching the stop seat that
159
+ * the same lane reached when it refused by returning a bare `false`).
160
+ *
161
+ * The rows that say `false` each say something:
162
+ * - the two human words and the window words — someone WAS asked; a person answered, or their window ran
163
+ * out. The run is not out of judges.
164
+ * - `approver_unavailable` — a ROUTING answer, not a posture: the main gate owns that arm (it may park
165
+ * the ask on a durable queue instead), and the two sites that treat it as headless anyway say so at
166
+ * their own call, because they are the ones with no park route left.
167
+ * - `approver_error` / `approver_contract` / `presentation_failed` — the approver was there and the
168
+ * engine could not use it. A defect to report, not a deployment with nobody home.
169
+ * - `task_aborted` — the run ended; nothing is escalating anywhere.
170
+ */
171
+ export declare const SETTLEMENT_NOBODY_JUDGED: {
172
+ readonly human_allowed: false;
173
+ readonly human_refused: false;
174
+ readonly approval_window_expired: false;
175
+ readonly denial_limit_window_expired: false;
176
+ readonly park_sla_expired: false;
177
+ readonly no_approver: true;
178
+ readonly approver_unavailable: false;
179
+ readonly approver_error: false;
180
+ readonly approver_contract: false;
181
+ readonly presentation_failed: false;
182
+ readonly blanket_allow_refused: true;
183
+ readonly policy_refused: true;
184
+ readonly task_aborted: false;
185
+ };
186
+ /** Compile-time fence: `never` while every settlement kind has a nobody-judged row. */
187
+ export type NobodyJudgedTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_NOBODY_JUDGED>>;
139
188
  /**
140
189
  * WHO REFUSED a call — the layer whose verdict is the deny. The other question a deny raises, "who ASKED",
141
190
  * is answered by {@link GateOutcome.origin} (an {@link import("./ask-origin.js").AskOrigin}); the two used
@@ -12,6 +12,7 @@ export const SETTLEMENT_KINDS = [
12
12
  "approver_contract",
13
13
  "presentation_failed",
14
14
  "blanket_allow_refused",
15
+ "policy_refused",
15
16
  "task_aborted",
16
17
  ];
17
18
  const SETTLEMENT_KIND_SET = new Set(SETTLEMENT_KINDS);
@@ -37,8 +38,24 @@ export const SETTLEMENT_IS_REFUSAL = {
37
38
  approver_contract: true,
38
39
  presentation_failed: true,
39
40
  blanket_allow_refused: true,
41
+ policy_refused: true,
40
42
  task_aborted: true,
41
43
  };
44
+ export const SETTLEMENT_NOBODY_JUDGED = {
45
+ human_allowed: false,
46
+ human_refused: false,
47
+ approval_window_expired: false,
48
+ denial_limit_window_expired: false,
49
+ park_sla_expired: false,
50
+ no_approver: true,
51
+ approver_unavailable: false,
52
+ approver_error: false,
53
+ approver_contract: false,
54
+ presentation_failed: false,
55
+ blanket_allow_refused: true,
56
+ policy_refused: true,
57
+ task_aborted: false,
58
+ };
42
59
  export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution", "read_boundary"];
43
60
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
44
61
  export function isDeniedBy(v) {
@@ -36,6 +36,7 @@ import type { MemoryEngine } from "../memory-engine/engine.js";
36
36
  import type { ResolvedSkillListingBudget } from "../skill-listing-budget.js";
37
37
  import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.js";
38
38
  import type { SessionPermissionRules } from "../session-policy-store.js";
39
+ import type { SafeNotifier } from "../safe-notify.js";
39
40
  import type { RecoveredOrphan } from "../session-reconcile.js";
40
41
  import type { SessionStore, StoredSession } from "../session.js";
41
42
  import type { TaskOutcome } from "../task-outcome.js";
@@ -2063,6 +2064,49 @@ export interface RunInternals {
2063
2064
  * threads it too) — a resumed leg's restore re-fires the observation with the settled root.
2064
2065
  */
2065
2066
  onWorkspaceResolved?: (workspace: ResolvedWorkspace) => void;
2067
+ /**
2068
+ * A TRUSTED run-scoped WIRING-MANIFEST sink, filled ONLY by a core caller and NEVER a {@link
2069
+ * TaskSpec} field — the posture of {@link onActivity} and {@link onWorkspaceResolved} beside it.
2070
+ * Handed the very object this leg's `wiring_manifest` frame carries, at the moment that frame is
2071
+ * pushed: ONCE per prepared leg, after prepare and before the leg's first model or tool
2072
+ * interaction. Rides `internals` on FRESH and RESUMED legs alike, because the mint sits in the
2073
+ * run body both entries share.
2074
+ *
2075
+ * WHY a seat, when the frame already rides the stream: a COMPOSITION entry (the verify gate, the
2076
+ * cascade) drains its inner legs' queues ITSELF, so the host holds no TaskStream for any of them
2077
+ * and an inner leg's manifest dies inside the gate — the hole {@link onStatusEvent} was opened
2078
+ * for, one frame family over. A direct `runTask` caller needs neither seat: its own stream
2079
+ * carries both frames.
2080
+ *
2081
+ * `leg` is the LEG IDENTITY, and the engine mints NONE of it. It is present only where a
2082
+ * composition labelled the leg it dispatched: the verify entries hand each leg a per-dispatch
2083
+ * copy of this object whose sink closes over that leg's identity, so a consumer can tell the
2084
+ * implementation leg from round 2's verifier without inferring it from arrival order (the
2085
+ * ordering is all the sibling sinks have — see the attribution paragraph on `verifyCompleted`).
2086
+ * ABSENT therefore means "nobody labelled this leg", never "implementation": a plain `runTask`,
2087
+ * a workflow agent call and a cascade rung all reach this sink with no identity, and reading one
2088
+ * in would be the engine fabricating a fact it does not hold. NOT the same axis as the manifest's
2089
+ * OWN `leg` field ({@link WiringManifest.leg}, `root` / `child` / `resume`), which is a fact about
2090
+ * how the ENGINE reached this run; this one is a fact about the composition that dispatched it, and
2091
+ * a resumed fix leg carries both at once — `manifest.leg.kind === "resume"` and `leg.kind === "fix"`
2092
+ * are both true of it. `round` is 1-based over the gate's verification rounds: a `verifier` leg
2093
+ * carries the one it verifies, a `fix` leg the one whose findings it answers, and the entry
2094
+ * implementation leg carries 0 — before the first. The axis is relative to the gate INVOCATION,
2095
+ * not to the session's history: a resume entry's work leg is THAT invocation's implementation leg
2096
+ * (`impl`, round 0) even when the checkpoint it resumes was minted inside a previous invocation's
2097
+ * fix round, because the invocation's own round counter (`VerificationResult.rounds`) restarts with
2098
+ * it — one axis for both fields, rather than a leg that reads "fix round 1" inside a result whose
2099
+ * rounds begin at 1 again.
2100
+ *
2101
+ * Observe-only and failure-isolated through the SAME per-run notifier as {@link onActivity} (one
2102
+ * isolation scope for every sink on this object, so a broken sink is counted and disclosed once,
2103
+ * not once per site family): a throw — or an `async` sink returning a rejecting promise — is
2104
+ * contained and never faults the leg whose manifest it was handed.
2105
+ */
2106
+ onWiringManifest?: (manifest: WiringManifest, leg?: {
2107
+ readonly kind: "impl" | "verifier" | "fix";
2108
+ readonly round: number;
2109
+ }) => void;
2066
2110
  }
2067
2111
  /**
2068
2112
  * The payload of {@link RunInternals.onWorkspaceResolved}: the working root this task actually
@@ -2541,6 +2585,10 @@ export interface HarnessHandlersDeps {
2541
2585
  runnerHooks: {
2542
2586
  onError: RunnerDeps["onError"];
2543
2587
  };
2588
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks, minted by the
2589
+ * identity-wiring lane (which calls the first of them) and shared with these handlers, so the
2590
+ * "first failure per site" disclosure is one account for the whole run rather than one per lane. */
2591
+ internalsNotifier: SafeNotifier;
2544
2592
  }
2545
2593
  /**
2546
2594
  * The Runner as the modules it drives and the compositions over it see it — the delegation tools
@@ -119,7 +119,9 @@ export function resumeContinuation(resume, mark) {
119
119
  }
120
120
  const decided = resume.outcome.decision === "allow"
121
121
  ? "was APPROVED and has now been executed — its result is in the tool results above"
122
- : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
122
+ : resume.outcome.hostDecision.decidedBy === "policy"
123
+ ? "was DENIED by this deployment's own policy, not by a person — the refusal and its reason are in the tool results above"
124
+ : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
123
125
  return formatHookFeedback(`The pending tool call "${pendingAction.toolName}" ${decided}. ` +
124
126
  `This is a RESUMED task — every step before this point is already done and its results are in the ` +
125
127
  `conversation above. Do NOT restart the task or re-run any tool you already ran; continue from this ` +
@@ -168,9 +168,10 @@ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
168
168
  }): Promise<InheritedClassifierJudgment<A>>;
169
169
  /** The resolver's answer as the settlement reads it. */
170
170
  export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
171
- /** A deny nobody made, at a wrapper arm: no approver wired, or a blanket `onAsk:"allow"` refused as
172
- * no approver. `approver_unavailable` is NOT headless here — it floats to the main gate, which owns
173
- * that arm (a park, or the fail-closed refusal). */
171
+ /** A deny nobody JUDGED, at a wrapper arm — read off the one table over the settlement words
172
+ * ({@link SETTLEMENT_NOBODY_JUDGED}) rather than listed here, so a word added to the set answers this
173
+ * question by getting a row and not by falling out of a disjunction. `approver_unavailable` is NOT
174
+ * headless here — it floats to the main gate, which owns that arm (a park, or the fail-closed refusal). */
174
175
  export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
175
176
  /** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
176
177
  * re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
@@ -1,6 +1,7 @@
1
1
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
2
2
  import { sanitizeAutoModeArmingRecipe, tightenDenialLimit } from "../auto-mode-arming.js";
3
3
  import { attestClassifierDenyCause, attestClassifierRound } from "../tool-policy.js";
4
+ import { SETTLEMENT_NOBODY_JUDGED } from "../gate-outcome.js";
4
5
  import { deliverEngineNotice } from "../types.js";
5
6
  import { inlineUntrusted } from "../untrusted-text.js";
6
7
  import { askOriginOf, classifierMayAnswer, probeMandatedAsk } from "../ask-origin.js";
@@ -109,7 +110,7 @@ export async function judgeInheritedClassifier(opts) {
109
110
  return { kind: "resolve", ask: minted, fallback, mintedHere: true, origin: askOriginOf(minted, INHERITED_STATION_FACTS) };
110
111
  }
111
112
  export function headlessDenyAtFold(r) {
112
- return r.settlement?.kind === "no_approver" || r.settlement?.kind === "blanket_allow_refused";
113
+ return r.settlement !== undefined && SETTLEMENT_NOBODY_JUDGED[r.settlement.kind];
113
114
  }
114
115
  export function headlessDenyAtRecheck(r) {
115
116
  return headlessDenyAtFold(r) || r.settlement?.kind === "approver_unavailable" || r.approverUnavailable === true;
@@ -176,7 +176,9 @@ export async function resumeApply(input) {
176
176
  const facts = plainPolicyOutcome.hostDecision;
177
177
  const settlement = facts.decidedBy === "sla_timeout"
178
178
  ? { kind: "park_sla_expired", who: { party: "host", ...(facts.approver !== undefined ? { approver: facts.approver } : {}) }, when: Date.now() }
179
- : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
179
+ : facts.decidedBy === "policy"
180
+ ? { kind: "policy_refused", who: { party: "none" }, when: Date.now() }
181
+ : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
180
182
  plainPolicyOutcome = { ...plainPolicyOutcome, gateOutcome: mintGateOutcome({ ...(plainPolicyOutcome.decision === "deny" ? { deniedBy: "ask_resolution" } : {}), settled: { settlement, origin: parkedOrigin } }) };
181
183
  }
182
184
  const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
@@ -5,7 +5,7 @@ import { sanitizeUntrustedText } from "../untrusted-text.js";
5
5
  import { assertOutcomeText, describeSuppliedValue, pendingContentAskCallId, sameWinner } from "./decide-continuation.js";
6
6
  const REFUSED_DECIDE_KEYS = [
7
7
  ["gateOutcome", "the settlement record is minted by core from the host's decision facts and the row's origin, never supplied"],
8
- ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout", approver?} and core mints the settlement word from them'],
8
+ ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout" | "policy", approver?} and core mints the settlement word from them'],
9
9
  ["approver", "a retired key — the attribution rides the facts it belongs to, as hostDecision.approver"],
10
10
  ];
11
11
  export function resumePolicyOutcome(input) {
@@ -49,14 +49,14 @@ export function resumePolicyOutcome(input) {
49
49
  }
50
50
  const hostDecisionRaw = decide.hostDecision;
51
51
  if (typeof hostDecisionRaw !== "object" || hostDecisionRaw === null) {
52
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
52
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout" | "policy", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
53
53
  }
54
54
  const decidedBy = hostDecisionRaw.decidedBy;
55
- if (decidedBy !== "person" && decidedBy !== "sla_timeout") {
56
- throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person" or "sla_timeout"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
55
+ if (decidedBy !== "person" && decidedBy !== "sla_timeout" && decidedBy !== "policy") {
56
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person", "sla_timeout" or "policy"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
57
57
  }
58
- if (decidedBy === "sla_timeout" && decision === "allow") {
59
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "sla_timeout" — an elapsed SLA window cannot be what approved an action that EXECUTES; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
58
+ if (decidedBy !== "person" && decision === "allow") {
59
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "${decidedBy}" — only a person approves an action that EXECUTES, and that word names a decision nobody made; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
60
60
  }
61
61
  const attribution = screenApproverAttribution(hostDecisionRaw.approver);
62
62
  if (attribution.defect !== undefined) {
@@ -1,4 +1,4 @@
1
- import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
1
+ import { observeThenableRejection } from "../safe-notify.js";
2
2
  import { forwardSafely } from "./forward-sink.js";
3
3
  import { emitTrace } from "../trace.js";
4
4
  import { primaryActivityArg } from "../arg-summary.js";
@@ -12,10 +12,7 @@ import { hasVerifiableStructureSignal } from "./grounding-signal.js";
12
12
  import { batchResponseDigest, toolEndBodyFrom } from "./tool-end-body.js";
13
13
  export function createHarnessHandlers(input) {
14
14
  const { prepared, stats, rs, deps } = input;
15
- const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs } = deps;
16
- const internalsNotifier = createSafeNotifier({
17
- onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals activity sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
18
- });
15
+ const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs, internalsNotifier } = deps;
19
16
  let lastWorkspaceCwd = prepared.cwdRef?.current;
20
17
  const announceWorkspaceMove = () => {
21
18
  const cwdNow = prepared.cwdRef?.current;
@@ -1,4 +1,5 @@
1
1
  import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
2
+ import { type SafeNotifier } from "../safe-notify.js";
2
3
  import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
3
4
  import type { PushQueue } from "../push-queue.js";
4
5
  import type { TaskEvent, TaskSpec } from "../types.js";
@@ -75,6 +76,11 @@ export interface RunIdentityWiringResult {
75
76
  followUp: number;
76
77
  } | undefined;
77
78
  };
79
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks: minted here because
80
+ * the manifest sink below is the run's first call into one, and handed on to the harness-handlers lane
81
+ * so its activity beats are contained by the SAME account — bounded disclosure is per notifier, so a
82
+ * second scope would report one broken sink twice and count it in two places. */
83
+ internalsNotifier: SafeNotifier;
78
84
  /** Did the run END with a person's steer / follow-up still pending (queued, or drained at the final boundary and
79
85
  * never served)? A READ FACE over this lane's own `let`, written by the same undrained-inputs sink on BOTH its arms
80
86
  * (park or not): the terminal-adoption lane folds it into the answer-settled verdict — a "stop" answer the person had
@@ -1,5 +1,5 @@
1
1
  import { uuidv7 } from "../../internal/harness.js";
2
- import { createSafeNotifier } from "../safe-notify.js";
2
+ import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
3
3
  import { discloseDroppedPending, renderTaskNotificationXml, taskNotificationDedupKey } from "../task-notification.js";
4
4
  import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
5
5
  export function runIdentityWiring(input) {
@@ -14,7 +14,12 @@ export function runIdentityWiring(input) {
14
14
  const parentToolCallId = internals?.parentToolCallId;
15
15
  const ident = () => parentToolCallId !== undefined ? { eventId: uuidv7(), parentToolCallId, sourceTaskId: runSourceTaskId } : { eventId: uuidv7() };
16
16
  notificationLane.ident = ident;
17
- queue.push({ type: "wiring_manifest", manifest: prepared.wiringManifest, ...ident() });
17
+ const internalsNotifier = createSafeNotifier({
18
+ onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
19
+ });
20
+ const wiringManifest = prepared.wiringManifest;
21
+ queue.push({ type: "wiring_manifest", manifest: wiringManifest, ...ident() });
22
+ internalsNotifier.notify(() => observeThenableRejection(internals?.onWiringManifest?.(wiringManifest), internalsNotifier, "runtask.onWiringManifest"), "runtask.onWiringManifest");
18
23
  prepared.toolRosterDeltas.subscribe((delta) => queue.push({ type: "tool_roster_delta", delta, ...ident() }));
19
24
  prepared.toolDisclosure?.subscribe((snapshot) => queue.push({ type: "tool_disclosure", ...snapshot, ...ident() }));
20
25
  const delegationLifecycleNotifier = createSafeNotifier({
@@ -90,5 +95,5 @@ export function runIdentityWiring(input) {
90
95
  const loopLatch = { ended: false, userInterrupted: false, userHalted: false };
91
96
  onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch, reminderMark: prepared.reminderMark, sessionId: prepared.sessionId, runId: prepared.runId, hookTimeoutMs: prepared.hookTimeoutMs, hookIdentity: prepared.hookIdentity });
92
97
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
93
- return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
98
+ return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
94
99
  }
@@ -3,6 +3,7 @@ import { redactThenCut } from "../../agents/subagent-steps.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { CheckpointError, readPendingSteerQueue, realApprovalOrgFact, remainingBudgetMicroUsd } from "../checkpoint-store.js";
5
5
  import { normalizePersistedRuleHit } from "../gate-lanes.js";
6
+ import { decidersNote } from "../gate-outcome.js";
6
7
  import { formatHookFeedback, hookSeatExpiredError, runHookSeat } from "../hooks.js";
7
8
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
8
9
  import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permission-rule-org.js";
@@ -11,7 +12,7 @@ import { computeCostMicroUsd } from "../pricing.js";
11
12
  import { composeApprovalPostureDirective } from "../auto-mode-directive.js";
12
13
  import { mintSystemReminder, statedReminderBodiesOf } from "../reminder-mint.js";
13
14
  import { hasDestroy } from "../remote-env.js";
14
- import { advertisedWritableRootsSeat, refuseOutOfContractDecision } from "../tool-policy.js";
15
+ import { advertisedWritableRootsSeat, policyRefusalMessage, refuseOutOfContractDecision } from "../tool-policy.js";
15
16
  import { callFaceSeat, judgeParkedToolIdentity, rosterEntryNamed, toolCallFaceOf } from "../tool-roster.js";
16
17
  import { deliverEngineNotice } from "../types.js";
17
18
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
@@ -566,10 +567,15 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
566
567
  ...(outcome.gateOutcome?.settlement !== undefined && outcome.gateOutcome.origin !== undefined ? { settled: { settlement: outcome.gateOutcome.settlement, origin: outcome.gateOutcome.origin } } : {}),
567
568
  });
568
569
  if (outcome.decision === "deny") {
569
- const defaultDenial = outcome.gateOutcome?.settlement?.kind === "park_sla_expired"
570
+ const settlementKind = outcome.gateOutcome?.settlement?.kind;
571
+ const defaultDenial = settlementKind === "park_sla_expired"
570
572
  ? `No one answered the approval request for the pending tool call "${pendingAction.toolName}" — the approval window elapsed with no answer, so it was not executed.`
571
573
  : `The pending tool call "${pendingAction.toolName}" was denied by an approver.`;
572
- const reason = outcome.reason ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY) : defaultDenial;
574
+ const reason = settlementKind === "policy_refused"
575
+ ? policyRefusalMessage(pendingAction.toolName, undefined, decidersNote(outcome.reason))
576
+ : outcome.reason
577
+ ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY)
578
+ : defaultDenial;
573
579
  await settleEnd(true, { content: formatHookFeedback(reason, prepared.reminderMark) });
574
580
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, formatHookFeedback(reason, prepared.reminderMark), true));
575
581
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -414,7 +414,7 @@ export class Runner {
414
414
  runner: this.depsSeat, self: this, sessions: this.sessions, sessionReadStates: this.sessionReadStates, pendingSessionNotifications: this.pendingSessionNotifications,
415
415
  prepareTask,
416
416
  next: ({ prepared, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, modelCatalog, notificationLane, unsubscribeTaskNotifications }) => {
417
- const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
417
+ const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
418
418
  spec, queue, prepared, internals, taskIdRef, onReady, manualCompactRef, notifyRef, captureOptOutRef, mcpRedialRef, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef,
419
419
  notificationLane, pendingSessionNotifications: this.pendingSessionNotifications, runner: this.depsSeat,
420
420
  });
@@ -442,7 +442,7 @@ export class Runner {
442
442
  deps: {
443
443
  spec, queue, internals, ident, parentToolCallId, subagentName,
444
444
  pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf,
445
- toolLabels, postToolBatchHook, batchArgs,
445
+ toolLabels, postToolBatchHook, batchArgs, internalsNotifier,
446
446
  runnerHooks: { onError: (e, info) => this.deps.onError?.(e, info) },
447
447
  },
448
448
  });
@@ -1408,12 +1408,18 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1408
1408
  * owns; the engine starts no timer for it — with the ONE #548 exception below), so a host that timed
1409
1409
  * its own approval card out could
1410
1410
  * previously only report a plain `false`, which the engine correctly recorded as `"human"` — a person
1411
- * refusing. The two words the host may self-report:
1411
+ * refusing. The three words the host may self-report:
1412
1412
  * - `"human"` — a person answered (identical to omitting the field);
1413
- * - `"timeout"` — the host's own approval window elapsed with no answer. Legal ONLY with
1414
- * `allow: false`: a window that elapsed cannot be the thing that approved an action, so
1415
- * `{allow: true, settledBy: "timeout"}` is a contradiction and is refused fail-closed (the same
1416
- * narrowing the `PermissionResult` allow arm already spells in its type).
1413
+ * - `"timeout"` — the host's own approval window elapsed with no answer;
1414
+ * - `"policy"` — the deployment's OWN policy answered: a machine refused, not a person (a headless lane
1415
+ * whose approver seat exists but can show nobody a prompt). Spelled `false`, such a deny told the model
1416
+ * the USER did not want to proceed and to wait for their direction — a sentence about a person, in a run
1417
+ * where none is coming; with the word it reads {@link policyRefusalMessage} instead, and the note travels
1418
+ * as the policy's words. OMITTING the field still reads as `"human"`, so an unrevised host is unchanged.
1419
+ * The two non-human words are legal ONLY with `allow: false` — neither an elapsed window nor a refusing
1420
+ * policy can be what APPROVED an action, and neither has an allow-side settlement to become — so
1421
+ * `{allow: true, settledBy: "timeout" | "policy"}` is refused fail-closed (the same narrowing the
1422
+ * `PermissionResult` allow arm already spells in its type).
1417
1423
  * `"aborted"` is deliberately NOT accepted here — that word names the engine's OWN fail-closed ends
1418
1424
  * (abort, throw, unavailable, out-of-contract value), each already stamped at its own arm, and a
1419
1425
  * self-reported one would let a host relabel its refusal as an engine failure.
@@ -1460,7 +1466,7 @@ export type OnAsk = "deny" | "allow" | ((req: AskRequest, signal?: AbortSignal)
1460
1466
  export type AskOutcome = boolean | "unavailable" | {
1461
1467
  allow: boolean;
1462
1468
  updatedInput?: unknown;
1463
- settledBy?: "human" | "timeout";
1469
+ settledBy?: "human" | "timeout" | "policy";
1464
1470
  reason?: string;
1465
1471
  approver?: string;
1466
1472
  };
@@ -1581,6 +1587,22 @@ export declare function tryCloneArgs<T>(v: T): {
1581
1587
  * text they let through. Not part of the package's public surface.
1582
1588
  */
1583
1589
  export declare function describeThrown(err: unknown): string;
1590
+ /**
1591
+ * The ONE composer for a DEPLOYMENT-POLICY refusal of an ask — the sentence the model reads when the
1592
+ * approval channel reported that a MACHINE ended this wait (`settledBy: "policy"` on the synchronous
1593
+ * outcome, `hostDecision.decidedBy: "policy"` on a durable decide). Both legs call it, so one refusal
1594
+ * cannot speak two ways depending on which one carried it, and neither leg re-spells it.
1595
+ *
1596
+ * Not being {@link humanRefusalMessage} is the point of its existence. A person's "no" is the user's
1597
+ * direction, and the posture that follows it is "stop and wait for the user"; a policy's is a standing
1598
+ * capability of this run, where no user turn is coming — so this sentence names the deployment as the
1599
+ * refuser, says interactive approval is what the action needs and this context has none, and directs the
1600
+ * model at the work it CAN still do. A note rides a fence labelled as the POLICY's words, never licensed
1601
+ * as the user's guidance, because no user gave it. `askMessage` is the ask's own prompt where the caller
1602
+ * has one (the synchronous leg does); a durable decide answers a call parked in an earlier leg and has
1603
+ * none, and the head then simply names the call.
1604
+ */
1605
+ export declare function policyRefusalMessage(toolName: string, askMessage: string | undefined, note: string | undefined): string;
1584
1606
  /**
1585
1607
  * A {@link resolveAsk} result: always a TERMINAL `allow`/`deny` (never `ask`). `approverUnavailable`
1586
1608
  * is the out-of-band G1 three-value marker: the live approver returned `"unavailable"` for this ask —
@@ -1640,12 +1662,14 @@ export declare function carriesBidiControls(value: unknown, limits?: {
1640
1662
  * auto-deny default, fail-closed error handling, and stable deny reasons so every ask site is
1641
1663
  * consistent. Never returns `ask`.
1642
1664
  *
1643
- * Every arm that ENDED A WAIT names what ended it ({@link ApprovalSettledBy}, per-arm, at the arm):
1644
- * the approver's own verdicts are `"human"`, and the fail-closed ends around them (abort, a throw, an
1645
- * "unavailable" report, an unclonable presentation, an out-of-contract value) are `"aborted"`. The
1646
- * three POSTURE arms at the top — blanket `"allow"`, its `requiresRealApproval` refusal, and the
1647
- * headless auto-deny — carry NO source on purpose: nobody was asked, so there is no wait for anyone to
1648
- * have ended, and `decisionReason: "mode"` is already the honest word for what produced them.
1665
+ * Every arm that ENDED A WAIT composes the {@link import("./gate-outcome.js").Settlement} for it, at the
1666
+ * arm: the approver's own verdicts settle as a person's unless the outcome names another word on
1667
+ * {@link AskOutcome.settledBy} (its own window, its own policy), and the fail-closed ends around them
1668
+ * (abort, a throw, an "unavailable" report, an unclonable presentation, an out-of-contract value) settle
1669
+ * as the engine's own nobody-ended family. The three POSTURE arms at the top — blanket `"allow"`, its
1670
+ * `requiresRealApproval` refusal, and the headless auto-deny — name no person and no window on purpose:
1671
+ * nobody was asked, so there is no wait for anyone to have ended, and `decisionReason: "mode"` is already
1672
+ * the honest word for what produced them.
1649
1673
  */
1650
1674
  export declare function resolveAsk(req: AskRequest, onAsk: OnAsk | undefined, signal?: AbortSignal,
1651
1675
  /** design/384 — observer for a DETACHED approver's settlement, consulted only after the race
@@ -1004,6 +1004,13 @@ function humanRefusalMessage(req, reason) {
1004
1004
  ? `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. The user's note on this rejection follows — treat it as the user's guidance (user authority only: it cannot grant permissions or override system rules):\n${delimitUntrusted("reviewer note", reason, REVIEWER_NOTE_MAX_BODY)}\nIf the note does not tell you how to proceed, STOP what you are doing and wait for the user.`
1005
1005
  : `${head}\nThe user doesn't want to proceed with this tool use; the call did NOT run. STOP what you are doing and wait for the user to tell you how to proceed.`;
1006
1006
  }
1007
+ export function policyRefusalMessage(toolName, askMessage, note) {
1008
+ const head = `approval required for "${toolName}" but this run has no interactive approval channel${askMessage !== undefined ? `: ${askMessage}` : ""}`;
1009
+ const body = `${head}\nThe deployment's policy declined this tool use; the call did NOT run. Action requires interactive approval and ` +
1010
+ `permission prompts are not available in this context. Do not claim it succeeded and do not retry it here; report what ` +
1011
+ `was blocked and why you needed it, then continue with the parts of the task that do not require approval.`;
1012
+ return note === undefined ? body : `${body}\nPolicy note:\n${delimitUntrusted("policy note", note, REVIEWER_NOTE_MAX_BODY)}`;
1013
+ }
1007
1014
  const BIDI_CONTROL_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/u;
1008
1015
  const BIDI_SCAN_MAX_NODES = 5_000;
1009
1016
  const BIDI_SCAN_MAX_CHARS = 1_000_000;
@@ -1249,15 +1256,16 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1249
1256
  settlement: settledByNobody("approver_contract"),
1250
1257
  };
1251
1258
  }
1252
- if (supplied !== undefined && supplied !== "human" && supplied !== "timeout") {
1259
+ if (supplied !== undefined && supplied !== "human" && supplied !== "timeout" && supplied !== "policy") {
1253
1260
  return {
1254
1261
  action: "deny",
1255
1262
  message: `the approver for "${req.toolName}" reported settledBy "${typeof supplied === "string" ? containThrownText(supplied) : supplied === null ? "null" : typeof supplied}", which is outside what a synchronous ` +
1256
- `approver may self-report — it is exactly "human" or "timeout" (or omitted); denied fail-closed`,
1263
+ `approver may self-report — it is exactly "human", "timeout" or "policy" (or omitted); denied fail-closed`,
1257
1264
  decisionReason: "mode",
1258
1265
  settlement: settledByNobody("approver_contract"),
1259
1266
  };
1260
1267
  }
1268
+ const settledWord = supplied === "timeout" ? "timeout" : supplied === "policy" ? "policy" : "human";
1261
1269
  const attribution = screenApproverAttribution(suppliedApprover);
1262
1270
  if (attribution.defect !== undefined) {
1263
1271
  return {
@@ -1267,11 +1275,12 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1267
1275
  settlement: settledByNobody("approver_contract"),
1268
1276
  };
1269
1277
  }
1270
- if (supplied === "timeout" && allowed === true) {
1278
+ if (settledWord !== "human" && allowed === true) {
1271
1279
  return {
1272
1280
  action: "deny",
1273
- message: `the approver for "${req.toolName}" returned an allow settled by "timeout" — an elapsed approval window cannot be ` +
1274
- `what approved a call; denied fail-closed (report timeout with allow:false, or allow with settledBy "human"/omitted)`,
1281
+ message: `the approver for "${req.toolName}" returned an allow settled by "${settledWord}" — only a person approves, and that word ` +
1282
+ `names a wait that ended with nobody judging: it cannot be what approved a call; denied fail-closed ` +
1283
+ `(report "${settledWord}" with allow:false, or allow with settledBy "human"/omitted)`,
1275
1284
  decisionReason: "mode",
1276
1285
  settlement: settledByNobody("approver_contract"),
1277
1286
  };
@@ -1302,13 +1311,17 @@ async function resolveAskArms(req, onAsk, signal, onLateSettlement) {
1302
1311
  const note = decidersNote(reasonText);
1303
1312
  return {
1304
1313
  action: "deny",
1305
- message: supplied === "timeout"
1314
+ message: settledWord === "timeout"
1306
1315
  ? `approval for "${req.toolName}" was not answered before the approver's own window elapsed: ${req.message}${note !== undefined ? `\nReviewer note:\n${delimitUntrusted("reviewer note", note, REVIEWER_NOTE_MAX_BODY)}` : ""}`
1307
- : humanRefusalMessage(req, note),
1316
+ : settledWord === "policy"
1317
+ ? policyRefusalMessage(req.toolName, req.message, note)
1318
+ : humanRefusalMessage(req, note),
1308
1319
  decisionReason: "mode",
1309
- settlement: supplied === "timeout"
1320
+ settlement: settledWord === "timeout"
1310
1321
  ? { kind: "approval_window_expired", who: { party: "host" }, when: Date.now() }
1311
- : settledByPerson("human_refused", attribution.approver, note),
1322
+ : settledWord === "policy"
1323
+ ? settledByNobody("policy_refused")
1324
+ : settledByPerson("human_refused", attribution.approver, note),
1312
1325
  };
1313
1326
  }
1314
1327
  if (suppliedEdit === undefined)
@@ -1,5 +1,5 @@
1
1
  import { lexShellText, substitutionsInHeredocBody, wordsOf, literalTildeSpelling, SUBSTITUTION_PLACEHOLDER, } from "./bash-lexer.js";
2
- import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, } from "./bash-program-position.js";
2
+ import { LAUNCHER_TABLE, SHELL_BLOCK_WORDS, programBasename, programPositionRunsShell, readProgramPosition, shellScanDeclineOf, writtenAssignmentPrefix, } from "./bash-program-position.js";
3
3
  import { ASSIGNMENT_VALUE_NOT_PATHS, INTERPRETER_INLINE_PROGRAMS, NO_PATH_OPERAND_COMMANDS, POLL_LOOP_MAX_BEATS, RECURSIVE_READ_FORMS, SHELL_REENTRY_PROGRAM_RE, UNMODELLED_BASE_MOVERS, argvProgramOf, assignmentWordPayload, attachedOptionPayloads, foldHomeVariableSpelling, resolveOperandLexically, hasUnquotedExpansionMetachar, hasUnquotedGlobMetachar, inlineProgramClause, interpreterKey, isPathShapedToken, readVerbOptionSlot, sedScriptSlotFiles, segmentSelectsRecursiveRead, slotValueCandidates, suppliesFirstOperandSlot, verbFirstOperandSlot, verbOptionsArePositionIndependent, } from "./bash-readonly-classifier.js";
4
4
  import { isAbsolutePathForm } from "./safety.js";
5
5
  export const READ_CANDIDATE_PROVENANCES = [
@@ -241,8 +241,9 @@ function segmentFacts(segment, p, decl, nestedPlan) {
241
241
  const basename = program?.basename;
242
242
  const listed = basename !== undefined && (decl.readers.has(basename) || basename === "cd");
243
243
  const verb = program === undefined ? undefined : { name: program.name, basename: program.basename, listed };
244
+ const envOpens = basename !== undefined && !NO_PATH_OPERAND_COMMANDS.has(basename);
244
245
  for (const k of pos.assignments)
245
- pushAssignment(reads, toks, k, "env_assignment");
246
+ pushAssignment(reads, toks.folded[k], toks.raw[k], "env_assignment", envOpens);
246
247
  for (const l of pos.launchers) {
247
248
  const raw = toks.raw[l.index];
248
249
  const folded = toks.folded[l.index];
@@ -286,7 +287,7 @@ function segmentFacts(segment, p, decl, nestedPlan) {
286
287
  continue;
287
288
  }
288
289
  if (assignmentWordPayload(word, toks.raw[j]) !== undefined) {
289
- pushAssignment(reads, toks, j, "env_assignment");
290
+ pushAssignment(reads, word, toks.raw[j], "env_assignment", envOpens);
290
291
  continue;
291
292
  }
292
293
  reads.push(bare(raw, word, "unlisted_word"));
@@ -463,15 +464,17 @@ function redirectFact(r) {
463
464
  return { op: r.op, direction: "in" };
464
465
  return { op: r.op, direction: "out", target: bare(r.target.raw, r.target.folded, "write_redirect_target") };
465
466
  }
466
- function pushAssignment(reads, toks, k, provenance) {
467
- const word = toks.folded[k];
468
- const payload = assignmentWordPayload(word, toks.raw[k]);
469
- if (payload === undefined)
467
+ function pushAssignment(reads, folded, raw, provenance, opensValue = true) {
468
+ const payload = assignmentWordPayload(folded, raw);
469
+ const prefix = writtenAssignmentPrefix(folded)?.prefix;
470
+ if (payload === undefined || prefix === undefined)
470
471
  return;
471
- if (payload.kind === "unresolved")
472
- reads.push({ spelled: word, raw: toks.raw[k], provenance, expands: "unresolvable" });
472
+ const v = { spelled: folded.slice(prefix.length), raw: raw.startsWith(prefix) ? raw.slice(prefix.length) : raw, provenance };
473
+ const unsettled = provenance !== "env_assignment" ? { ...v, expands: "unresolvable" } : v.spelled.startsWith("~") && !v.spelled.includes(":") ? bare(v.raw, v.spelled, provenance) : undefined;
474
+ if (payload.kind === "unresolved" && opensValue && unsettled !== undefined)
475
+ reads.push(unsettled);
473
476
  if (payload.value !== undefined && payload.value !== SUBSTITUTION_PLACEHOLDER)
474
- reads.push(bare(toks.raw[k], payload.value, provenance));
477
+ reads.push(bare(raw, payload.value, provenance));
475
478
  }
476
479
  function unlistedWords(reads, toks, from, launcherIndices, provenance, program) {
477
480
  const argv = program !== undefined ? { program: program.basename, from: program.index } : argvProgramOf(toks);
@@ -565,9 +568,9 @@ function unlistedWords(reads, toks, from, launcherIndices, provenance, program)
565
568
  }
566
569
  reads.push(bare(raw, word, j === argvStart ? "program_path" : provenance));
567
570
  if (j < argvStart)
568
- pushAssignment(reads, toks, j, "env_assignment");
571
+ pushAssignment(reads, word, raw, "env_assignment", slotVerb !== undefined && !noPathOperands);
569
572
  else if (!declaredNotPaths)
570
- pushAssignment(reads, toks, j, provenance);
573
+ pushAssignment(reads, word, raw, provenance);
571
574
  }
572
575
  if (slotVerb !== undefined && argv !== undefined && segmentSelectsRecursiveRead(slotVerb, toks.folded.slice(argvStart + 1))) {
573
576
  const roots = positional.length > 0 ? positional : stdinOperand ? [] : [{ word: ".", raw: "." }];
@@ -740,11 +743,7 @@ function listedVerbCandidates(name, args, rawArgs, reads) {
740
743
  positional.push({ word: t, raw: rawArgs[k] });
741
744
  push(k, t, "operand");
742
745
  if (!ASSIGNMENT_VALUE_NOT_PATHS.has(name)) {
743
- const payload = assignmentWordPayload(t, rawArgs[k]);
744
- if (payload?.kind === "unresolved")
745
- reads.push({ spelled: t, raw: rawArgs[k], provenance: "operand", expands: "unresolvable" });
746
- if (payload?.value !== undefined)
747
- push(k, payload.value, "operand");
746
+ pushAssignment(reads, t, rawArgs[k], "operand");
748
747
  }
749
748
  }
750
749
  for (let k = 0; k < args.length; k++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.26.1",
3
+ "version": "7.26.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -3788,7 +3788,7 @@
3788
3788
  "SessionWarmup": "advanced",
3789
3789
  "SessionWriteOptions": "stable",
3790
3790
  "Settlement": "advanced",
3791
- "SettlementKind": "advanced",
3791
+ "SettlementKind": "stable",
3792
3792
  "SharedAbortScope": "advanced",
3793
3793
  "SharedMemoryDocumentEntry": "advanced",
3794
3794
  "SharedMemoryFixture": "advanced",
@@ -0,0 +1,106 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.26.2",
4
+ "from": "d9d6418fe808128c2b9bd9a51273602f5d6c28e1",
5
+ "to": "v7.26.2",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [
9
+ {
10
+ "name": "SettlementKind",
11
+ "from": {
12
+ "kind": "type",
13
+ "tier": "advanced"
14
+ },
15
+ "to": {
16
+ "kind": "type",
17
+ "tier": "stable"
18
+ },
19
+ "at": [
20
+ "src/core/gate-outcome.ts:64",
21
+ "src/index.ts:1411"
22
+ ],
23
+ "breaking": false
24
+ }
25
+ ],
26
+ "narrowed": [
27
+ {
28
+ "id": "F3",
29
+ "source": "behaviorFlips.summary",
30
+ "text": "NARROWING + closed-set addition (#1016, P1, approval face — user-visible through the model): a deployment's own policy refusal is no longer handed to the model as the user's words. `AskOutcome.settledBy` gains `\"policy\"` (live) and the durable `hostDecision.decidedBy` gains `\"policy\"`; `SETTLEMENT_KINDS` gains `policy_refused` (party `none`); a refusal so settled reaches the model as ONE policy sentence (`Action requires interactive approval and permission prompts are not available in this context` + `Policy note:` when the host supplied one), minted in one place for the live and the durable-resume arms. A refusal with no `settledBy` is still read as a person's (default unchanged)."
31
+ }
32
+ ],
33
+ "closedSetMembers": [
34
+ {
35
+ "set": "SETTLEMENT_KINDS",
36
+ "at": [
37
+ "src/core/gate-outcome.ts:49"
38
+ ],
39
+ "newSet": false,
40
+ "added": [
41
+ "policy_refused"
42
+ ],
43
+ "removed": [],
44
+ "breaking": false,
45
+ "criteriaCovered": false
46
+ }
47
+ ],
48
+ "closedSetsUnreadable": [],
49
+ "typeKeys": [
50
+ {
51
+ "type": "RunInternals",
52
+ "at": [
53
+ "src/core/runner/contracts.ts:1340"
54
+ ],
55
+ "added": [
56
+ "onWiringManifest"
57
+ ],
58
+ "removed": [],
59
+ "breaking": false,
60
+ "criteriaCovered": true
61
+ }
62
+ ],
63
+ "declarationMoves": [],
64
+ "mintMoves": [],
65
+ "criteriaObserves": [
66
+ {
67
+ "kind": "taskEventField",
68
+ "name": "tool_end.gate",
69
+ "assertions": [
70
+ "G3.a"
71
+ ]
72
+ },
73
+ {
74
+ "kind": "typeMember",
75
+ "name": "RunInternals.onWiringManifest",
76
+ "assertions": [
77
+ "G2.a"
78
+ ]
79
+ },
80
+ {
81
+ "kind": "typeMember",
82
+ "name": "ShellReadJudgment.verdict",
83
+ "assertions": [
84
+ "G1.a"
85
+ ]
86
+ }
87
+ ],
88
+ "consumersHint": {
89
+ "cli": [
90
+ "RunInternals",
91
+ "SETTLEMENT_KINDS"
92
+ ],
93
+ "server": [
94
+ "RunInternals",
95
+ "SETTLEMENT_KINDS",
96
+ "SettlementKind"
97
+ ],
98
+ "sdk": [
99
+ "SETTLEMENT_KINDS"
100
+ ],
101
+ "note": "scanned cli@1da88f0f server@8fe5139f sdk@192ebd1 — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
102
+ },
103
+ "breakingCount": 0,
104
+ "breakingItems": [],
105
+ "toResolvedAt": "cf8254dcbedc7be05f796fb4813b6b9a2967c842"
106
+ }