@sema-agent/core 7.9.2 → 7.10.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 (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/agents/child-model-seat.d.ts +31 -1
  3. package/dist/agents/child-model-seat.js +12 -0
  4. package/dist/agents/subagent.d.ts +2 -2
  5. package/dist/agents/subagent.js +6 -4
  6. package/dist/core/ask-unresolvable-notice.d.ts +52 -0
  7. package/dist/core/ask-unresolvable-notice.js +25 -0
  8. package/dist/core/auto-mode.d.ts +62 -3
  9. package/dist/core/auto-mode.js +31 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +2 -1
  12. package/dist/core/engine-notice.d.ts +28 -7
  13. package/dist/core/gate-lanes.js +15 -0
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +4 -0
  16. package/dist/core/hooks.d.ts +24 -1
  17. package/dist/core/hooks.js +2 -0
  18. package/dist/core/permission-rule-model.d.ts +51 -16
  19. package/dist/core/permission-rule-model.js +55 -21
  20. package/dist/core/permission-rules.d.ts +6 -4
  21. package/dist/core/permission-rules.js +14 -14
  22. package/dist/core/runner/contracts.d.ts +26 -2
  23. package/dist/core/runner/denial-limit-arms.d.ts +14 -3
  24. package/dist/core/runner/denial-limit-arms.js +15 -5
  25. package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
  26. package/dist/core/runner/permission-rule-lanes.js +9 -3
  27. package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
  28. package/dist/core/runner/prepare-caps-and-workflow.js +13 -3
  29. package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
  30. package/dist/core/runner/prepare-gate-stations.js +3 -0
  31. package/dist/core/runner/prepare-policy-chain.js +7 -6
  32. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
  33. package/dist/core/runner/prepare-wiring-manifest.js +8 -1
  34. package/dist/core/runner/runtask.d.ts +33 -32
  35. package/dist/core/runner/runtask.js +63 -33
  36. package/dist/core/runner-deps.d.ts +9 -2
  37. package/dist/core/swappable-deps.d.ts +90 -0
  38. package/dist/core/swappable-deps.js +55 -0
  39. package/dist/core/tool-policy.d.ts +26 -0
  40. package/dist/core/tool-policy.js +5 -1
  41. package/dist/core/wiring-manifest.d.ts +15 -1
  42. package/dist/core/wiring-manifest.js +10 -2
  43. package/dist/core/workflow-journal-store.d.ts +21 -2
  44. package/dist/core/workflow-journal-store.js +1 -1
  45. package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
  46. package/dist/engine/execution-env/node-execution-env.js +2 -1
  47. package/dist/engine/harness/types.d.ts +11 -0
  48. package/dist/index.d.ts +5 -3
  49. package/dist/index.js +5 -3
  50. package/dist/orchestration/run-workflow-tool.d.ts +17 -0
  51. package/dist/orchestration/run-workflow-tool.js +12 -0
  52. package/dist/orchestration/workflow-observe.d.ts +1 -1
  53. package/dist/orchestration/workflow-observe.js +2 -0
  54. package/dist/orchestration/workflow-types.d.ts +37 -2
  55. package/dist/orchestration/workflow-types.js +16 -0
  56. package/dist/orchestration/workflow.d.ts +41 -2
  57. package/dist/orchestration/workflow.js +295 -44
  58. package/dist/stores/file/workflow-journal-store.js +10 -3
  59. package/package.json +1 -1
  60. package/test/export-surface.snapshot.json +51 -5
@@ -5,7 +5,7 @@ import type { GateOutcome } from "./gate-outcome.js";
5
5
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { type AskClass } from "./ask-class.js";
8
- import type { AutoModeDenialTracker } from "./auto-mode.js";
8
+ import { type AutoModeDenialTracker } from "./auto-mode.js";
9
9
  export { formatHookFeedback } from "./reminder-mint.js";
10
10
  import type { WiringLegKind } from "./wiring-manifest.js";
11
11
  /**
@@ -1405,6 +1405,20 @@ export interface ToolGateInput {
1405
1405
  toolCallId: string;
1406
1406
  fallback: import("./auto-mode.js").DenialLimitFallback;
1407
1407
  }) => void;
1408
+ /**
1409
+ * #648 — the deny exit nobody judged: the ask's approver (the run's own, or an ancestor's frozen seat on
1410
+ * a delegated child) answered `unavailable` and the durable park re-route did not take the call, so the
1411
+ * fail-closed deny STANDS. Fired once per such call, after the park has had its one attempt, with the
1412
+ * facts the `delegation.ask_unresolvable` notice is composed from (`ask-unresolvable-notice.ts`);
1413
+ * `parkLaneExisted` = a park lane was armed for this run. Observe-only: a throwing seat never alters
1414
+ * the deny. Absent ⇒ the deny stands silently (a host driving the gate directly, no notice sink).
1415
+ */
1416
+ onAskUnresolvable?: (info: {
1417
+ toolName: string;
1418
+ toolCallId: string;
1419
+ settlementKind: "approver_unavailable";
1420
+ parkLaneExisted: boolean;
1421
+ }) => void;
1408
1422
  /**
1409
1423
  * design/153 §2/§7.4 (件4 复审, MED): true means this call's ask is MARKED — an inherited ancestor
1410
1424
  * constraint already determined "no synchronous layer may resolve this ask" (the ancestor's frozen
@@ -1643,6 +1657,12 @@ export interface AskCarry {
1643
1657
  /** The surviving ask's engine-stamped origin word ({@link import("./ask-origin.js").AskOrigin}), persisted
1644
1658
  * on the row so a durable card is rendered by the same word the synchronous card is. */
1645
1659
  readonly origin?: import("./ask-origin.js").AskOrigin;
1660
+ /** #616: the surviving ask's classifier-unavailable fact (the classifier was consulted on this ask and could
1661
+ * not run — `cause` is the verdict's word), persisted on the row so a durable card says why it is asking
1662
+ * exactly as the synchronous card does. Absent ⇒ the classifier answered, was not eligible, or was not wired. */
1663
+ readonly classifierUnavailable?: {
1664
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1665
+ };
1646
1666
  }
1647
1667
  /** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
1648
1668
  * the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
@@ -1651,6 +1671,9 @@ export interface AskCarry {
1651
1671
  export declare function askCarryRowMembers(carry: AskCarry | undefined): {
1652
1672
  denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1653
1673
  origin?: import("./ask-origin.js").AskOrigin;
1674
+ classifierUnavailable?: {
1675
+ readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
1676
+ };
1654
1677
  };
1655
1678
  /**
1656
1679
  * The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
@@ -6,6 +6,7 @@ export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandat
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
7
7
  import { brandPolicyAskClass } from "./ask-class.js";
8
8
  import { isAskOrigin } from "./ask-origin.js";
9
+ import { isAutoModeUnavailableCause } from "./auto-mode.js";
9
10
  import { inlineUntrusted } from "./untrusted-text.js";
10
11
  export { formatHookFeedback } from "./reminder-mint.js";
11
12
  import { createSafeNotifier } from "./safe-notify.js";
@@ -237,6 +238,7 @@ export function askCarryRowMembers(carry) {
237
238
  return {
238
239
  ...(carry.denialLimitFallback !== undefined ? { denialLimitFallback: { ...carry.denialLimitFallback } } : {}),
239
240
  ...(isAskOrigin(carry.origin) ? { origin: carry.origin } : {}),
241
+ ...(isAutoModeUnavailableCause(carry.classifierUnavailable?.cause) ? { classifierUnavailable: { cause: carry.classifierUnavailable.cause } } : {}),
240
242
  };
241
243
  }
242
244
  export async function runToolGate(input) {
@@ -497,28 +497,63 @@ export declare function parseRuleText(text: string, behavior: RuleBehavior): {
497
497
  * double-slash directory form; for `path` it is the pattern as spelled. */
498
498
  export declare function formatRuleText(command: string, match: PersistedRuleMatch, tool: PersistedRuleTool): string;
499
499
  /**
500
- * The bases a path pattern resolves against at match time — facts of the CALL, never of the rule:
501
- * `home` for `~/…` (absent ⇒ `os.homedir()`), `root` for `/…` (the task's project root), `cwd` for a
502
- * relative pattern (the live working directory; absent `root`). A pattern whose base is absent
503
- * resolves to nothing and reaches nothing.
500
+ * The closed set of BASES a path-form pattern resolves against — facts of the CALL (its execution
501
+ * environment and its task), never of the rule:
502
+ * - `cwd` — a relative pattern (`dist/**`, `./x`): the live tracked working directory; when no tracker
503
+ * moved (or the caller keeps none) the task root stands in for it — that stand-in is the WORD's
504
+ * own meaning (the same reading `TOOL_PATH_BASES.cwd` gives a tool's relative slot), not a
505
+ * fallback to a different base;
506
+ * - `root` — a root-relative pattern (`/dist/**`): the task's project root;
507
+ * - `home` — a home-relative pattern (`~/.ssh/**`): the home directory OF THE EXECUTION ENVIRONMENT the
508
+ * call runs in (`ExecutionEnv.homeDir`, declared by the adapter). NEVER the engine process's
509
+ * own home: on a remote or sandboxed leg the two differ, and a `~/` deny resolved against the
510
+ * engine host's home silently guarded the wrong directory (#644).
511
+ * A pattern whose base is ABSENT (or not an absolute path) cannot be judged: the reach is `unreadable`
512
+ * (fail-closed — a standing deny/ask the lane cannot read becomes an ask a person clears), never
513
+ * silence and never a guess. `//abs` patterns need no base. {@link ruleBasesNeeded} tells a host BEFORE
514
+ * prepare which base a rule will need, so it can declare it (or expect the ask).
504
515
  */
505
- export interface PathRuleBases {
506
- readonly root?: string;
507
- readonly cwd?: string;
508
- readonly home?: string;
509
- }
516
+ export declare const PATH_RULE_BASES: readonly ["cwd", "root", "home"];
517
+ export type PathRuleBase = (typeof PATH_RULE_BASES)[number];
518
+ /** The bases a call supplies, keyed by {@link PathRuleBase}; every member optional (absent = not supplied). */
519
+ export type PathRuleBases = {
520
+ readonly [K in PathRuleBase]?: string;
521
+ };
522
+ /** The noun a refusal or an unreadable reason names each base by — the disposition table over the set
523
+ * (a base without a row does not compile: {@link PathRuleBaseLabelCoversEveryBase}). */
524
+ export declare const PATH_RULE_BASE_LABEL: {
525
+ readonly cwd: "the working directory";
526
+ readonly root: "the project root";
527
+ readonly home: "the home directory of the execution environment";
528
+ };
529
+ /** The fence over the label table: `never` while every base has a row. */
530
+ export type PathRuleBaseLabelCoversEveryBase = AssertAllKeysHandled<Exclude<PathRuleBase, keyof typeof PATH_RULE_BASE_LABEL>>;
531
+ /**
532
+ * WHICH base a rule's pattern resolves against — the one classification `resolvePathPattern`, the rule
533
+ * compiler's compile-time check and a host's pre-prepare "can this environment judge this rule?" all read.
534
+ * Empty for a rule that needs none: a `//abs` pattern, a `subpath` (directory) rule, a command rule. The
535
+ * relative form names `cwd`, whose own meaning includes "the root stands in when no tracker moved" — a
536
+ * host that supplies `root` has therefore supplied `cwd`'s stand-in too.
537
+ */
538
+ export declare function ruleBasesNeeded(rule: Pick<PersistedRule, "match" | "command">): readonly PathRuleBase[];
510
539
  /** Is this base usable by {@link resolvePathPattern} — an absolute path with a lexical normal form? A rule
511
540
  * compiler asks this at compile time so a pattern is refused rather than compiled inert. */
512
541
  export declare function isUsablePathBase(base: string | undefined): boolean;
513
542
  /**
514
- * The PATH family's tightening predicate: does this deny/ask rule reach the call's target path? The
515
- * target is the caller's ALREADY-RESOLVED lexical-normal absolute path (the same identity the read/write
516
- * fences judge with). A `subpath` rule reaches the directory and everything under it
517
- * ({@link directoryRuleAdmits}); a `path` rule resolves its spelling against the call's bases and
518
- * matches segment-wise (`*` within a segment, `**` across segments), reaching the path it names and
519
- * everything under a directory it names. A command-family rule reaches no path.
543
+ * The PATH family's tightening reach three-valued like the command family's ({@link programRunReachOf}):
544
+ * does this deny/ask rule reach the call's target path? The target is the caller's ALREADY-RESOLVED
545
+ * lexical-normal absolute path (the same identity the read/write fences judge with). A `subpath` rule
546
+ * reaches the directory and everything under it ({@link directoryRuleAdmits}); a `path` rule resolves its
547
+ * spelling against the call's bases and matches segment-wise (`*` within a segment, `**` across
548
+ * segments), reaching the path it names and everything under a directory it names. A command-family rule
549
+ * reaches no path. `unreadable` (#644): the pattern needs a base the call did not supply (or supplied as
550
+ * a non-absolute spelling) — the rule CANNOT be judged, and the answer says which base; a lane that must
551
+ * fail closed on it turns it into the ask a person clears (the persisted lane's unreadable answer, the
552
+ * content lane's unreadable ask), exactly as an unreadable command is treated. Silence here was the #644
553
+ * defect (a `~/` deny falling back to the engine host's home guarded the wrong directory; a `/…` deny with
554
+ * no root reached nothing at all).
520
555
  */
521
- export declare function pathRuleReaches(rule: Pick<PersistedRule, "match" | "command">, target: string, bases: PathRuleBases): boolean;
556
+ export declare function pathRuleReachOf(rule: Pick<PersistedRule, "match" | "command">, target: string, bases: PathRuleBases): ProgramRunReachOutcome;
522
557
  /**
523
558
  * Does this rule's command pattern admit `command`?
524
559
  *
@@ -331,20 +331,47 @@ function parsePathRuleContent(text, tool, content, behavior) {
331
331
  }
332
332
  return { rule: { behavior, rule: formatRuleText(content, "path", tool), tool, match: "path", command: content } };
333
333
  }
334
- function resolvePathPattern(pattern, bases) {
334
+ export const PATH_RULE_BASES = ["cwd", "root", "home"];
335
+ export const PATH_RULE_BASE_LABEL = {
336
+ cwd: "the working directory",
337
+ root: "the project root",
338
+ home: "the home directory of the execution environment",
339
+ };
340
+ export function ruleBasesNeeded(rule) {
341
+ if (rule.match !== "path")
342
+ return [];
343
+ const pattern = rule.command;
335
344
  if (pattern.startsWith("//"))
336
- return "/" + pattern.slice(2);
337
- const under = (base, rest) => {
338
- const normal = base === undefined ? undefined : lexicalNormalAbsolutePathOf(base);
339
- if (normal === undefined)
340
- return undefined;
341
- return (normal === "/" ? "" : normal) + "/" + rest;
342
- };
345
+ return [];
343
346
  if (pattern.startsWith("~/"))
344
- return under(bases.home ?? homedir(), pattern.slice(2));
347
+ return ["home"];
345
348
  if (pattern.startsWith("/"))
346
- return under(bases.root, pattern.slice(1));
347
- return under(bases.cwd ?? bases.root, pattern.startsWith("./") ? pattern.slice(2) : pattern);
349
+ return ["root"];
350
+ return ["cwd"];
351
+ }
352
+ function baseValueOf(base, bases) {
353
+ switch (base) {
354
+ case "home":
355
+ return bases.home;
356
+ case "root":
357
+ return bases.root;
358
+ case "cwd":
359
+ return bases.cwd ?? bases.root;
360
+ default: {
361
+ const _exhaustive = base;
362
+ return _exhaustive;
363
+ }
364
+ }
365
+ }
366
+ function resolvePathPattern(pattern, bases) {
367
+ if (pattern.startsWith("//"))
368
+ return { resolved: "/" + pattern.slice(2) };
369
+ const needed = ruleBasesNeeded({ match: "path", command: pattern })[0];
370
+ const normal = lexicalNormalAbsolutePathOf(baseValueOf(needed, bases) ?? "");
371
+ if (normal === undefined)
372
+ return { missingBase: needed };
373
+ const rest = pattern.startsWith("~/") ? pattern.slice(2) : pattern.startsWith("/") ? pattern.slice(1) : pattern.startsWith("./") ? pattern.slice(2) : pattern;
374
+ return { resolved: (normal === "/" ? "" : normal) + "/" + rest };
348
375
  }
349
376
  export function isUsablePathBase(base) {
350
377
  return base !== undefined && lexicalNormalAbsolutePathOf(base) !== undefined;
@@ -389,19 +416,20 @@ function globSegmentsReach(globs, target) {
389
416
  };
390
417
  return walk(0, 0);
391
418
  }
392
- export function pathRuleReaches(rule, target, bases) {
419
+ export function pathRuleReachOf(rule, target, bases) {
393
420
  if (rule.match === "subpath")
394
- return directoryRuleAdmits(rule, target);
421
+ return directoryRuleAdmits(rule, target) ? REACHED : NOT_REACHED;
395
422
  if (rule.match !== "path")
396
- return false;
423
+ return NOT_REACHED;
397
424
  if (!isLexicalNormalAbsoluteDir(target) && target !== "/")
398
- return false;
399
- const resolved = resolvePathPattern(rule.command, bases);
400
- if (resolved === undefined)
401
- return false;
402
- const globs = resolved.split("/").filter((sg) => sg !== "");
425
+ return NOT_REACHED;
426
+ const r = resolvePathPattern(rule.command, bases);
427
+ if ("missingBase" in r) {
428
+ return { reach: "unreadable", reason: `the rule is relative to ${PATH_RULE_BASE_LABEL[r.missingBase]} and this call supplies no absolute \`${r.missingBase}\` base to resolve it against` };
429
+ }
430
+ const globs = r.resolved.split("/").filter((sg) => sg !== "");
403
431
  const segments = target.split("/").filter((sg) => sg !== "");
404
- return globSegmentsReach(globs, segments);
432
+ return globSegmentsReach(globs, segments) ? REACHED : NOT_REACHED;
405
433
  }
406
434
  function hasUnescapedStar(s) {
407
435
  for (let i = 0; i < s.length; i++) {
@@ -778,12 +806,18 @@ export function adjudicatePersistedPathRules(rules, call, targets, bases) {
778
806
  }
779
807
  if (targets.tighten === undefined)
780
808
  continue;
809
+ let unreadable;
781
810
  for (const rule of rules) {
782
811
  if (!eligiblePersisted(rule, call, behavior))
783
812
  continue;
784
- if (pathRuleReaches(rule, targets.tighten, bases))
813
+ const outcome = pathRuleReachOf(rule, targets.tighten, bases);
814
+ if (outcome.reach === "reached")
785
815
  return { behavior, rules: [rule] };
816
+ if (outcome.reach === "unreadable")
817
+ unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason };
786
818
  }
819
+ if (unreadable !== undefined)
820
+ return unreadable;
787
821
  }
788
822
  return undefined;
789
823
  }
@@ -132,10 +132,12 @@ export interface PermissionRulePolicyOptions {
132
132
  */
133
133
  ruleFaces?: (toolName: string) => RuleFaceView | undefined;
134
134
  /**
135
- * The bases a path-form deny/ask pattern resolves against at match time: `root` for `/…` (the task's
136
- * project root), `cwd` for a relative pattern (absent ⇒ `root`), `home` for `~/…` (absent the process
137
- * owner's home). Facts of the DEPLOYMENT and the task, never of the rule — a pattern is stored as
138
- * spelled. A rule whose base is absent here is refused at compile time (`unsupported.path_base`).
135
+ * The bases a path-form deny/ask pattern resolves against at match time ({@link PathRuleBases}): `root`
136
+ * for `/…` (the task's project root), `cwd` for a relative pattern (the root stands in when absent the
137
+ * word's own meaning), `home` for `~/…` (the EXECUTION ENVIRONMENT's home never the engine process's
138
+ * own; #644). Facts of the DEPLOYMENT and the task, never of the rule a pattern is stored as spelled.
139
+ * A rule whose base is absent (or not an absolute path) here is refused at compile time
140
+ * (`unsupported.path_base`, naming the base — {@link ruleBasesNeeded} is the same classification).
139
141
  */
140
142
  pathBases?: PathRuleBases;
141
143
  }
@@ -1,7 +1,7 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
3
  import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
- import { isUsablePathBase, parseRuleText, pathRuleReaches, programRunReachOf } from "./permission-rule-model.js";
4
+ import { PATH_RULE_BASE_LABEL, isUsablePathBase, parseRuleText, pathRuleReachOf, programRunReachOf, ruleBasesNeeded } from "./permission-rule-model.js";
5
5
  import { effectivePathTargetOf } from "./effective-path-target.js";
6
6
  import { protectivePathTargetOf } from "./tool-registry.js";
7
7
  export { parsePermissionRule };
@@ -186,19 +186,14 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
186
186
  bad(text, "invalid.command_rule", `${parsedPath.reject.code}: ${parsedPath.reject.message}`);
187
187
  continue;
188
188
  }
189
- const pattern = parsedPath.rule.command;
190
- if (parsedPath.rule.match === "path" && !pattern.startsWith("//")) {
191
- const needsRoot = pattern.startsWith("/");
192
- const base = pattern.startsWith("~/") ? (pathBases.home ?? "/") : needsRoot ? pathBases.root : (pathBases.cwd ?? pathBases.root);
193
- if (!isUsablePathBase(base)) {
194
- bad(text, "unsupported.path_base", pattern.startsWith("~/")
195
- ? `"${text}" is relative to the home directory, and this policy's \`pathBases.home\` is not an absolute path — the rule would reach nothing`
196
- : needsRoot
197
- ? `"${text}" is relative to the project root, and this policy was given no absolute \`pathBases.root\` to resolve it against — the rule would reach nothing`
198
- : `"${text}" is relative to the working directory, and this policy was given no absolute \`pathBases.cwd\` (or \`root\`) to resolve it against — the rule would reach nothing`);
199
- continue;
189
+ for (const base of ruleBasesNeeded(parsedPath.rule)) {
190
+ const value = base === "cwd" ? (pathBases.cwd ?? pathBases.root) : pathBases[base];
191
+ if (!isUsablePathBase(value)) {
192
+ bad(text, "unsupported.path_base", `"${text}" is relative to ${PATH_RULE_BASE_LABEL[base]}, and this policy was given no absolute \`pathBases.${base}\`${base === "cwd" ? " (or `root`)" : ""} to resolve it against the rule could not be judged on any call`);
200
193
  }
201
194
  }
195
+ if (issues.length > 0 && issues[issues.length - 1].rule === text && issues[issues.length - 1].code === "unsupported.path_base")
196
+ continue;
202
197
  entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
203
198
  byTool.set(toolName, entry);
204
199
  continue;
@@ -326,8 +321,13 @@ export function createPermissionRulePolicy(rules, opts) {
326
321
  let unreadable;
327
322
  for (const r of lane) {
328
323
  if (r.parsed.match === "subpath" || r.parsed.match === "path") {
329
- if (target !== undefined && pathRuleReaches(r.parsed, target, pathBases))
324
+ if (target === undefined)
325
+ continue;
326
+ const outcome = pathRuleReachOf(r.parsed, target, pathBases);
327
+ if (outcome.reach === "reached")
330
328
  return { rule: r };
329
+ if (outcome.reach === "unreadable")
330
+ unreadable ??= { rule: r, unreadable: outcome.reason };
331
331
  continue;
332
332
  }
333
333
  if (typeof command !== "string")
@@ -342,7 +342,7 @@ export function createPermissionRulePolicy(rules, opts) {
342
342
  };
343
343
  const unreadableAsk = (hit, req) => ({
344
344
  action: "ask",
345
- message: `tool "${req.toolName}" needs approval: the command could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
345
+ message: `tool "${req.toolName}" needs approval: the call could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
346
346
  matchedAskRule: hit.rule.ruleText,
347
347
  requiresRealApproval: true,
348
348
  });
@@ -1486,6 +1486,22 @@ export interface RunInternals {
1486
1486
  * see from `spec`. Consumed by the `run_workflow` tool wiring (S8c), not by `prepareTask` itself.
1487
1487
  */
1488
1488
  workflowDepth?: number;
1489
+ /**
1490
+ * #642 — the host's DECISIONS for parked workflow-agent rows (`wa*` rows with `status:"parked"`), a TRUSTED
1491
+ * run channel like `workflowDepth` (never a `TaskSpec` field, never a tool argument — a model cannot decide
1492
+ * an approval). A host that decided a workflow child's parked checkpoint launches the run that re-invokes
1493
+ * `Workflow({resumeFromRunId})` with the decision here; the Workflow tool applies the entries naming the
1494
+ * resumed run (`RunWorkflowOptions.parkedResume`) and the resume drives the parked child on instead of
1495
+ * re-parking. `inheritedGate` is the cross-process re-supply of the checkpoint's opaque parent constraints
1496
+ * (the same seat the background revival's `reviveClaim.parkedResume` takes). Consumed by the `run_workflow`
1497
+ * tool wiring, not by `prepareTask` itself.
1498
+ */
1499
+ workflowParkedResume?: ReadonlyArray<{
1500
+ runId: string;
1501
+ token: CheckpointToken;
1502
+ outcome: ResumeOutcome;
1503
+ inheritedGate?: InheritedGate;
1504
+ }>;
1489
1505
  /**
1490
1506
  * design/110 — set ONLY by the Agent tool's fork route (`Agent(subagent_type:"fork")`, a core caller) on the
1491
1507
  * child it spawns: this run IS a forked child. `prepareTask` threads it to tool ctx as `insideFork` so the
@@ -1762,6 +1778,14 @@ export interface RunInternals {
1762
1778
  * channel, same posture as every other field here.
1763
1779
  */
1764
1780
  sessionReadStates?: SessionReadFileStates;
1781
+ /**
1782
+ * #616 — the Runner's per-session breaker ledger ({@link import("../auto-mode.js").AutoModeBreakerLedger}):
1783
+ * the arming site's `onBreakerOpen` wrap records a trip here, the wiring-manifest phase of a LATER leg of
1784
+ * the same session reads the most recent one onto `autoMode.breaker`. Always set by the Runner's own
1785
+ * prepare call (overriding any caller value, like `sessionReadStates` beside it); absent on a standalone
1786
+ * prepareTask, where no trip is recorded and none is reported. Trusted internals channel.
1787
+ */
1788
+ autoModeBreakerLedger?: import("../auto-mode.js").AutoModeBreakerLedger;
1765
1789
  onTaskNotification?: (notification: TaskNotificationPayload,
1766
1790
  /** Injection tier (design/373 — the ladder is LIVE): "next" = the running turn's next boundary
1767
1791
  * (arrival order, consecutive frames batch); "later" = the run's would-otherwise-stop seat
@@ -2357,7 +2381,7 @@ export interface HarnessHandlersDeps {
2357
2381
  * THE MEMBER SET IS MEASURED, NOT DESIGNED: it is exactly the union of what those consumers read off a
2358
2382
  * Runner today (a TS-Program census over `src/`, pinned in test/runner-self-seat.test.ts so a member
2359
2383
  * nobody reads reds as dead weight and a consumer reaching for a member outside it reds as a widening).
2360
- * `swapModels`, `sideQuery` and the constructor are NOT here — no consumer reaches them; a host that
2384
+ * `swapDeps`, `sideQuery` and the constructor are NOT here — no consumer reaches them; a host that
2361
2385
  * needs them holds the class. The class implements this interface, so the two cannot drift apart.
2362
2386
  *
2363
2387
  * Signatures are spelled out (not `Pick<Runner, …>`), because naming the class from here would be the
@@ -2365,7 +2389,7 @@ export interface HarnessHandlersDeps {
2365
2389
  */
2366
2390
  /**
2367
2391
  * The Runner's deployment deps as a LIVE read for the lanes that read them after an await (the resume ladder's
2368
- * rungs). `Runner.deps` is not a constant: `swapModels` replaces the object, so a lane handed the object at its
2392
+ * rungs). `Runner.deps` is not a constant: `swapDeps` replaces the object, so a lane handed the object at its
2369
2393
  * call would read a stale catalog — and a stale anything — where the one-function ladder read `this.deps.x` at the
2370
2394
  * instant of the read. The seat is the Runner's own view (a getter over the private field), so `runner.deps.x`
2371
2395
  * in a lane is exactly `this.deps.x` in the method it came from.
@@ -1,4 +1,4 @@
1
- import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
1
+ import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type AutoModeUnavailableCause, type DenialLimitFallback, type DenialLimitFallbackFace, type UnarmedDenialLimitFallback } from "../auto-mode.js";
2
2
  import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
3
  import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "../tool-policy.js";
4
4
  import { type EngineNotice } from "../types.js";
@@ -84,10 +84,14 @@ export type InheritedAsk = Extract<PermissionResult, {
84
84
  }>;
85
85
  /** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
86
86
  * four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
87
- * they spell themselves: the ask's origin word and its denial-limit fallback. */
87
+ * they spell themselves: the ask's origin word, its denial-limit fallback and the classifier-unavailable
88
+ * fact (#616). */
88
89
  export interface AskRequestCarry {
89
90
  origin?: AskOrigin;
90
91
  denialLimitFallback?: DenialLimitFallback;
92
+ classifierUnavailable?: {
93
+ readonly cause: AutoModeUnavailableCause;
94
+ };
91
95
  }
92
96
  /** The GATE's own mint station: both members are read off the surviving decision, which is the gate's
93
97
  * OWN snapshot (its stamp point spreads the policy's object and stamps the word onto the copy), so the
@@ -99,7 +103,9 @@ export declare function gateAskCarry(decision: InheritedAsk, liveApprover: boole
99
103
  * fallback member it captured — each read ONCE there and threaded here, never re-read off the
100
104
  * caller-owned object (a stateful accessor could otherwise answer the card with one value and the
101
105
  * settlement with another; a self-declared `origin` on the ancestor's decision is never read). */
102
- export declare function inheritedAskCarry(origin: AskOrigin, fallback: UnarmedDenialLimitFallback | undefined, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
106
+ export declare function inheritedAskCarry(judged: Pick<Extract<InheritedClassifierJudgment<InheritedAsk>, {
107
+ kind: "resolve";
108
+ }>, "origin" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
103
109
  /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
104
110
  export type InheritedClassifierJudgment<A extends InheritedAsk> =
105
111
  /** The frozen classifier allowed — the arm returns its own allow shape (with or without the ask's rewrite). */
@@ -118,6 +124,11 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
118
124
  * unchanged-ask branches it is the SAME read the exclusion judged by (a caller-owned decision with a
119
125
  * stateful accessor cannot answer the exclusion with one word and the card with another); on the
120
126
  * bound branch it is derived from the re-spoken ask this station minted (its own object). */
127
+ /** `ask` is the incoming ask unchanged — EXCEPT on an UNAVAILABLE round, where it is an engine-owned copy
128
+ * carrying `classifierUnavailable: { cause }` (#616): the fact rides the ASK because the ask is what the
129
+ * wrapper arms hand back to the gate when the frozen approver answers unavailable (the inherited-unavailable
130
+ * float), and the gate's own park carry reads the fact off the decision it parks — a side member on this
131
+ * judgment would have been lost on that route (codex r1). The request mint reads the same object. */
121
132
  | {
122
133
  kind: "resolve";
123
134
  ask: A;
@@ -60,10 +60,18 @@ function fallbackCarry(fallback, liveApprover, tracker) {
60
60
  return { denialLimitFallback: liveApprover && tracker !== undefined ? tracker.armTimedWindow(fallback) : unarmedWindow(fallback) };
61
61
  }
62
62
  export function gateAskCarry(decision, liveApprover, tracker) {
63
- return { ...(decision.origin !== undefined ? { origin: decision.origin } : {}), ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker) };
63
+ return {
64
+ ...(decision.origin !== undefined ? { origin: decision.origin } : {}),
65
+ ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker),
66
+ ...(decision.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: decision.classifierUnavailable.cause } } : {}),
67
+ };
64
68
  }
65
- export function inheritedAskCarry(origin, fallback, liveApprover, tracker) {
66
- return { origin, ...fallbackCarry(fallback, liveApprover, tracker) };
69
+ export function inheritedAskCarry(judged, liveApprover, tracker) {
70
+ return {
71
+ origin: judged.origin,
72
+ ...fallbackCarry(judged.fallback, liveApprover, tracker),
73
+ ...(judged.ask.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: judged.ask.classifierUnavailable.cause } } : {}),
74
+ };
67
75
  }
68
76
  export async function judgeInheritedClassifier(opts) {
69
77
  const { autoMode, ask, req } = opts;
@@ -77,8 +85,10 @@ export async function judgeInheritedClassifier(opts) {
77
85
  autoMode.denialTracking?.recordAllow();
78
86
  return { kind: "allow" };
79
87
  }
80
- if (verdict.kind !== "block")
81
- return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
88
+ if (verdict.kind !== "block") {
89
+ const resolved = verdict.kind === "unavailable" ? { ...ask, classifierUnavailable: { cause: verdict.cause } } : ask;
90
+ return { kind: "resolve", ask: resolved, fallback: ask.denialLimitFallback, mintedHere: false, origin };
91
+ }
82
92
  const reason = verdict.reason ? inlineUntrusted(verdict.reason) : "";
83
93
  const category = verdict.category ? inlineUntrusted(verdict.category) : "";
84
94
  const tracked = autoMode.denialTracking?.recordBlock();
@@ -107,7 +107,8 @@ export declare function pathRuleLaneAnswer(table: readonly PersistedRule[], req:
107
107
  root: string | undefined;
108
108
  sessionId: string | undefined;
109
109
  liveCwd: string | undefined;
110
- }): PersistedRuleHit | undefined;
110
+ home: string | undefined;
111
+ }): PersistedRuleHit | PersistedRuleUnreadable | undefined;
111
112
  /** The two lanes prepare hands the gate. Each is `undefined` when its partition is not configured, so
112
113
  * a deployment without one keeps a decision path byte-identical to a build without the feature. */
113
114
  export interface PermissionRuleLanes {
@@ -137,6 +138,11 @@ export declare function createPermissionRuleLanes(cfg: {
137
138
  /** The LIVE tracked working directory (adversarial-review P1): the base a relative cd resolves against
138
139
  * after an earlier observable `cd`; read per call. */
139
140
  liveCwd: () => string | undefined;
141
+ /** #644 — the EXECUTION ENVIRONMENT's home directory (`ExecutionEnv.homeDir`, adapter-declared), the base a
142
+ * `~/` path rule resolves against. `undefined` = the adapter declares none: a `~/` deny/ask row then reads
143
+ * UNREADABLE for every call (a fail-closed ask), never the engine process's home. A declared value that is
144
+ * not an absolute path is refused HERE, loudly (a bad declaration is a wiring defect, not an absence). */
145
+ home: string | undefined;
140
146
  /** True iff the reserved question-tool NAME resolves to the ENGINE's own content-ask tool on this leg. */
141
147
  questionToolMounted: boolean;
142
148
  questionToolName: string;
@@ -1,5 +1,6 @@
1
1
  import { persistedRuleMandateOf } from "../hooks.js";
2
2
  import { adjudicatePersistedPathRules, adjudicatePersistedRules, ruleToolGrammarOf, segmentCoverageOf, suggestRulesForCommand } from "../permission-rule-model.js";
3
+ import { isAbsolutePathForm } from "../../tools/fs/safety.js";
3
4
  import { effectivePathTargetOf } from "../effective-path-target.js";
4
5
  import { declaredPathTargetOf, protectivePathTargetOf } from "../tool-registry.js";
5
6
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
@@ -31,15 +32,20 @@ export function persistedRuleAnswerOf(verdict) {
31
32
  return persistedRuleHitOf(verdict);
32
33
  }
33
34
  export function pathRuleLaneAnswer(table, req, ctx) {
34
- const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}) };
35
+ const bases = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), ...(ctx.liveCwd !== undefined ? { cwd: ctx.liveCwd } : {}), ...(ctx.home !== undefined ? { home: ctx.home } : {}) };
35
36
  const tighten = effectivePathTargetOf(req, protectivePathTargetOf(req), bases);
36
37
  const allow = effectivePathTargetOf(req, declaredPathTargetOf(req), bases);
37
38
  if (tighten === undefined && allow === undefined)
38
39
  return undefined;
39
- return persistedRuleHitOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
40
+ return persistedRuleAnswerOf(adjudicatePersistedPathRules(table, { tool: req.toolName, cwd: ctx.root, sessionId: ctx.sessionId }, { ...(tighten !== undefined ? { tighten } : {}), ...(allow !== undefined ? { allow } : {}) }, bases));
40
41
  }
41
42
  export function createPermissionRuleLanes(cfg) {
42
43
  const provider = cfg.provider;
44
+ if (cfg.home !== undefined && !isAbsolutePathForm(cfg.home)) {
45
+ const e = new Error(`the execution environment declares homeDir ${JSON.stringify(cfg.home)}, which is not an absolute path — a \`~/\` permission rule could not be resolved against it; declare an absolute home directory or none`);
46
+ e.code = "config.execution_env_home_dir_invalid";
47
+ throw e;
48
+ }
43
49
  if (cfg.localOwnerDeclared) {
44
50
  if (provider === undefined || !provider.partitions.durable) {
45
51
  throw new Error("RunnerDeps.localOwnerRules is declared but no durable permission-rule partition is wired — there is no bucket for the local owner to hold rules in; refusing rather than running as if the declaration were absent");
@@ -127,7 +133,7 @@ export function createPermissionRuleLanes(cfg) {
127
133
  const table = view.rules;
128
134
  const liveCwd = cfg.liveCwd();
129
135
  if (grammar === "path") {
130
- return pathRuleLaneAnswer(table, req, { root: cfg.root, sessionId: cfg.sessionId, liveCwd });
136
+ return pathRuleLaneAnswer(table, req, { root: cfg.root, sessionId: cfg.sessionId, liveCwd, home: cfg.home });
131
137
  }
132
138
  const command = req.args?.command;
133
139
  if (typeof command !== "string")
@@ -49,7 +49,7 @@ export interface PrepareCapsAndWorkflowInput {
49
49
  deps: RunnerDeps;
50
50
  /** borrowed-readonly — the trusted spawn-side channel; the Workflow mount forwards `rootSessionId`, `placementRoot`,
51
51
  * `onTaskNotification`, `workflowDepth`, and the capture-floor getter reads `memoryCaptureAncestors`. */
52
- internals: Pick<RunInternals, "rootSessionId" | "placementRoot" | "onTaskNotification" | "workflowDepth" | "memoryCaptureAncestors"> | undefined;
52
+ internals: Pick<RunInternals, "rootSessionId" | "placementRoot" | "onTaskNotification" | "workflowDepth" | "memoryCaptureAncestors" | "workflowParkedResume" | "autoModeBreakerLedger"> | undefined;
53
53
  /** borrowed-readonly — the acquired session; the classifier leg rebuilds its transcript window from `buildContext`. */
54
54
  session: Pick<StoredSession, "buildContext">;
55
55
  /** borrowed-readonly — the acquired session id (operator-line context, the refusal codes' phase record). */
@@ -1,6 +1,6 @@
1
1
  import { isSyntheticApiErrorMessage } from "../../internal/harness.js";
2
2
  import { createHash } from "node:crypto";
3
- import { deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
3
+ import { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel, forkGovernanceDenial } from "../../agents/subagent.js";
4
4
  import { CROSS_SESSION_CLASSIFIER_RULE } from "../../agents/cross-session-envelope.js";
5
5
  import { createRunWorkflowTool } from "../../orchestration/run-workflow-tool.js";
6
6
  import { isSelfOrchestrationActive } from "../../orchestration/workflow-script-runner.js";
@@ -170,7 +170,10 @@ export async function prepareCapsAndWorkflow(input) {
170
170
  autoModeDecider = createAutoModeDecider({
171
171
  ...(am.timeoutMs !== undefined ? { timeoutMs: am.timeoutMs } : {}),
172
172
  ...(am.failureThreshold !== undefined ? { failureThreshold: am.failureThreshold } : {}),
173
- ...(am.onBreakerOpen !== undefined ? { onBreakerOpen: am.onBreakerOpen } : {}),
173
+ onBreakerOpen: (info) => {
174
+ internals?.autoModeBreakerLedger?.record(sessionId, { openedAtMs: Date.now(), lastCause: info.lastCause, failures: info.consecutiveFailures, runId });
175
+ am.onBreakerOpen?.(info);
176
+ },
174
177
  onClassified: (info) => emitTrace(deps.tracer, () => ({
175
178
  kind: "auto_mode.classified",
176
179
  version: 1,
@@ -246,6 +249,13 @@ export async function prepareCapsAndWorkflow(input) {
246
249
  parentModel: () => deploymentSubagentTierModel(runnerSelf.agentCatalog) ?? harnessRef.current?.getModel(),
247
250
  ...(spec.getApiKeyAndHeaders !== undefined ? { parentGetApiKeyAndHeaders: spec.getApiKeyAndHeaders } : {}),
248
251
  ...(frozenOnAsk !== undefined ? { parentOnAsk: frozenOnAsk } : {}),
252
+ ...(spec.durableApproval !== undefined ? { parentDurableApproval: { ...spec.durableApproval } } : {}),
253
+ ...(internals?.workflowParkedResume !== undefined
254
+ ? { parkedResume: (resumeFromRunId) => {
255
+ const mine = internals.workflowParkedResume.filter((d) => d.runId === resumeFromRunId).map(({ runId: _r, ...rest }) => rest);
256
+ return mine.length > 0 ? mine : undefined;
257
+ } }
258
+ : {}),
249
259
  principal: spec.principal,
250
260
  oneShot: spec.oneShot,
251
261
  ...(resolvedInteractionPosture !== undefined ? { parentInteractionPosture: resolvedInteractionPosture } : {}),
@@ -258,7 +268,7 @@ export async function prepareCapsAndWorkflow(input) {
258
268
  autoModeReview: () => (autoModeDecider !== undefined ? { decider: autoModeDecider } : undefined),
259
269
  workflowDepth: internals?.workflowDepth,
260
270
  parentCwd: taskRootFinal,
261
- parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
271
+ parentThinking: () => childThinkingSeat(undefined, deploymentSubagentThinking(runnerSelf.agentCatalog?.roles), harnessRef.current?.getThinkingLevel() ?? thinking).thinking,
262
272
  parentReadFace: () => carrierReadFace(),
263
273
  parentReadDenyPatterns: () => {
264
274
  const readDenyAdditionsNormalized = input.readDenyAdditionsNormalized();
@@ -49,8 +49,9 @@ export interface PrepareGateStationsInput {
49
49
  * first-party tool's declared effect (never overrides a scanned one); the plan-mode deny reads it per call. */
50
50
  toolEffects: Map<string, ToolEffect>;
51
51
  /** borrowed-readonly — the deployment seats these stations read, as a Pick over the SAME `deps` object (receiver
52
- * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face. */
53
- deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError">;
52
+ * preserved for `deps.onError?.()`): the write-protection table and its data root, the tracer, the error face, and
53
+ * the notice sink (#648: the gate's unresolvable-ask exit delivers `delegation.ask_unresolvable` through it). */
54
+ deps: Pick<RunnerDeps, "writeProtectedPaths" | "memoryEngineDir" | "tracer" | "onError" | "onNotice">;
54
55
  /** borrowed-mutable — the protocol-tools phase's arming belt. Writer here: `armed` ← the registration predicate, the
55
56
  * one write; the refresh seam reads it. */
56
57
  toolCallGateArmedRef: {