@sema-agent/core 5.34.0 → 5.36.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 (47) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/agents/subagent.js +29 -2
  3. package/dist/core/auto-compaction.d.ts +23 -0
  4. package/dist/core/auto-compaction.js +8 -0
  5. package/dist/core/checkpoint-store.d.ts +49 -4
  6. package/dist/core/context-guard.d.ts +41 -0
  7. package/dist/core/context-guard.js +76 -0
  8. package/dist/core/hooks.d.ts +98 -3
  9. package/dist/core/hooks.js +146 -8
  10. package/dist/core/memory-engine/engine.js +1 -1
  11. package/dist/core/park-selfcheck.d.ts +161 -0
  12. package/dist/core/park-selfcheck.js +251 -0
  13. package/dist/core/runner/assemble-result.d.ts +3 -0
  14. package/dist/core/runner/assemble-result.js +3 -0
  15. package/dist/core/runner/git-status-frame.d.ts +219 -0
  16. package/dist/core/runner/git-status-frame.js +212 -0
  17. package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
  18. package/dist/core/runner/prepare-acquire-reconcile.js +2 -1
  19. package/dist/core/runner/prepare-task.d.ts +28 -4
  20. package/dist/core/runner/prepare-task.js +86 -52
  21. package/dist/core/runner/runtask.d.ts +6 -1
  22. package/dist/core/runner/runtask.js +330 -19
  23. package/dist/core/task-registry-agent.d.ts +15 -0
  24. package/dist/core/task-registry-agent.js +9 -0
  25. package/dist/core/task-registry.d.ts +3 -0
  26. package/dist/core/task-registry.js +4 -1
  27. package/dist/core/tool-errors.d.ts +2 -2
  28. package/dist/core/tool-policy.d.ts +125 -0
  29. package/dist/core/tool-policy.js +35 -2
  30. package/dist/core/types.d.ts +98 -9
  31. package/dist/engine/harness/types.d.ts +65 -1
  32. package/dist/engine/harness/types.js +20 -0
  33. package/dist/engine/session/import-validate.js +10 -1
  34. package/dist/engine/session/session.d.ts +37 -1
  35. package/dist/engine/session/session.js +56 -1
  36. package/dist/index.d.ts +3 -2
  37. package/dist/index.js +3 -2
  38. package/dist/internal/harness-types.d.ts +1 -0
  39. package/dist/internal/harness.d.ts +2 -0
  40. package/dist/internal/harness.js +2 -0
  41. package/dist/orchestration/workflow.d.ts +1 -1
  42. package/dist/prompt-assembly/epoch.js +1 -1
  43. package/dist/prompt-assembly/event-registry.js +1 -0
  44. package/dist/prompts/default.d.ts +20 -7
  45. package/dist/prompts/default.js +2 -7
  46. package/package.json +1 -1
  47. package/test/export-surface.snapshot.json +17 -1
@@ -481,6 +481,14 @@ export interface ToolGateResult {
481
481
  * and no post-tool hook can write.
482
482
  */
483
483
  settledBy?: import("./tool-policy.js").ApprovalSettledBy;
484
+ /**
485
+ * design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported, carried
486
+ * out verbatim beside {@link settledBy}. This layer authenticates nothing and compares nothing; the
487
+ * value was screened for shape at the seam that received it (`screenApproverAttribution`) and is a
488
+ * transcription from there on. Present only where the caller settled something AND reported an
489
+ * identity — absent everywhere else, and absence never means "nobody": it means nobody said.
490
+ */
491
+ approver?: string;
484
492
  /** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
485
493
  updatedInput?: unknown;
486
494
  /**
@@ -569,11 +577,90 @@ export type OrgGateVerdict = {
569
577
  behavior: "deny" | "ask";
570
578
  rule: string;
571
579
  };
580
+ /** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
581
+ * published-policy version an auditor reconciles the resulting decision against. Present on the
582
+ * available arm whether or not a rule spoke: "revision 41 published nothing about this call" is
583
+ * itself the audit answer, and it is a different fact from "we never looked". ABSENT ⇒ the lane
584
+ * did not report one (a foreign overlay, or a revision that failed its own domain screen — the
585
+ * evidence records that as `"not_reported"` rather than inventing a number). Never adjudication
586
+ * input: the verdict above is the whole decision, and dropping this field would leave every
587
+ * outcome byte-identical. */
588
+ revision?: number;
572
589
  } | {
573
590
  status: "unavailable";
574
591
  /** The overlay's own disclosure lines (engine/deployment-authored), for the operator channel. */
575
592
  disclosures: readonly string[];
576
593
  };
594
+ /**
595
+ * design/252 G-2 — the richer answer a persisted-rule lane may give instead of the bare rule text (see
596
+ * `ToolGateInput.persistedRules.admits`): the same canonical text, plus the matched rule's add dots.
597
+ *
598
+ * Why the dots and not an id: a persisted allow rule HAS no scalar id — it is a (text, scope) group
599
+ * whose identity is the set of add dots that redeemed it, and two concurrently approved records for one
600
+ * text each carry their own. The set is the identity; a joined string would not be one.
601
+ */
602
+ export interface PersistedRuleHit {
603
+ /** The canonical text of the admitting rule — the same value the bare-string shape returns. */
604
+ readonly rule: string;
605
+ /** The matched rule's LIVE add dots (tombstoned adds already filtered by the store's read). Omitted
606
+ * by a lane that has the text but not the dots; the evidence then reports `"not_reported"`. */
607
+ readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
608
+ }
609
+ /**
610
+ * design/252 G-2 — the answer a lane gives when it could not READ its store.
611
+ *
612
+ * Its DECISION meaning is identical to "no rule" — a loosening seam that cannot read its source fails
613
+ * toward asking, and this shape changes no verdict anywhere. What it changes is the RECORD: without it,
614
+ * an unreadable rule store and a store that genuinely holds no matching rule are the same answer
615
+ * (`undefined`), and the ask's evidence has to report one of them as the other. The absence vocabulary
616
+ * distinguishes "we looked and found nothing" (`"no_match"`) from "we could not look" (`"unavailable"`),
617
+ * and this is how a lane says which one it means.
618
+ */
619
+ export interface PersistedRuleUnreadable {
620
+ readonly unreadable: true;
621
+ }
622
+ /** Every shape a lane may answer with. A bare string stays valid and unchanged. */
623
+ export type PersistedRuleAnswer = string | PersistedRuleHit | PersistedRuleUnreadable | undefined;
624
+ /**
625
+ * design/252 review r3 — read a foreign {@link OrgGateVerdict} the way the personal-rule answer is read:
626
+ * OWN DATA properties only, never the prototype chain, never an accessor.
627
+ *
628
+ * Unlike the personal-rule normalizer below, this one accepts ANY non-array object as the carrier —
629
+ * not only a plain record. The two seams tighten in opposite directions when a shape is refused: the
630
+ * personal lane is a LOOSENING seam, so refusing a class instance degrades toward asking; this answer
631
+ * carries the org's DENY, and folding a structurally valid `{status:"available", verdict:{behavior:
632
+ * "deny"}}` class instance into `unavailable` would LOOSEN it — from a deny nobody can approve into a
633
+ * real-approval ask a person can clear. A class instance's fields are its own data properties, so the
634
+ * own-data read below already gives the full pollution guarantee (inherited members never authorize);
635
+ * the prototype test added nothing here but the downgrade.
636
+ *
637
+ * The two seams are the same class of trust boundary and were not being read the same way. What that
638
+ * cost here is worse than on the personal lane, because this answer is GOVERNANCE:
639
+ * · an inherited `revision` (a polluted `Object.prototype`) stamped a fabricated snapshot version onto
640
+ * a human's approval request — a WRONG record, which is worse than an absent one;
641
+ * · an inherited `verdict` invented an org rule, and with it an org ask or deny the lane never gave;
642
+ * · an answer that is not a record at all read as "available, nothing to say", i.e. the governance
643
+ * fail-open the availability contract exists to prevent.
644
+ * Anything this function cannot read as a well-formed answer becomes `unavailable` — the fail-CLOSED
645
+ * word, never the empty one.
646
+ */
647
+ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: string): OrgGateVerdict;
648
+ /**
649
+ * Normalize the accepted {@link ToolGateInput.persistedRules} answers into one reading.
650
+ *
651
+ * `{}` = a clean negative (no rule admits this call). `{ unreadable: true }` = the lane could not read
652
+ * its source. `{ hit }` = a match. An answer outside every accepted shape — a number, `null`, an object
653
+ * with no `rule` — is read as a clean negative rather than a match: this is a LOOSENING seam, so an
654
+ * answer nobody can name degrades toward asking, never toward an allow built on it.
655
+ *
656
+ * The hit's dots are COPIED, not aliased. The array travels onto an ask that may sit in front of a
657
+ * person for a long time; a lane that retains and mutates its own array would otherwise change what the
658
+ * approver is looking at, and what an audit later reads, after the evidence was stamped.
659
+ */
660
+ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
661
+ hit?: PersistedRuleHit;
662
+ unreadable?: true;
663
+ };
577
664
  /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
578
665
  export interface ToolGateInput {
579
666
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
@@ -794,9 +881,17 @@ export interface ToolGateInput {
794
881
  * bullets above are what keep integrity/hook/mandated asks out of that set.
795
882
  */
796
883
  persistedRules?: {
797
- /** The canonical text of the rule that admits this call, or `undefined`. Must not throw: a store that
798
- * cannot be read answers "no rule" and discloses on its own channel (fail-closed = more asks). */
799
- admits: (req: ToolCallRequest) => Promise<string | undefined>;
884
+ /** The rule that admits this call, or `undefined`. Must not throw: a store that cannot be read
885
+ * answers "no rule" and discloses on its own channel (fail-closed = more asks).
886
+ *
887
+ * TWO ACCEPTED SHAPES (design/252 G-2, additive — a lane returning the bare string is unchanged
888
+ * and stays supported): the canonical rule TEXT, or a {@link PersistedRuleHit} carrying that same
889
+ * text plus the matched rule's add DOTS. The dots are the rule's durable identity, and the text
890
+ * is not one (it reaches a card sanitized and capped); a lane that can name them lets the
891
+ * shadowed-ask evidence say WHICH stored rule matched rather than merely what it looked like.
892
+ * Widening the RETURN is why this is additive for implementers: every existing lane satisfies
893
+ * the union unchanged, and the gate reads both through one normalizer. */
894
+ admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
800
895
  /** Observation sink for an ask a rule resolved — the allow-side disclosure. Never affects the outcome. */
801
896
  onResolved?: (info: {
802
897
  toolName: string;
@@ -1,4 +1,4 @@
1
- import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
1
+ import { decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { brandPolicyAskClass } from "./ask-class.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
@@ -89,6 +89,99 @@ function withParkFailureCause(reason, parkFailed) {
89
89
  `(${inlineUntrusted(parkFailed, PARK_FAILURE_CAUSE_MAX)}), so the refusal above is what the fallback had ` +
90
90
  `left to say, not the reason the call stopped.`);
91
91
  }
92
+ function isPlainOwnRecord(x) {
93
+ try {
94
+ if (x === null || typeof x !== "object" || Array.isArray(x))
95
+ return false;
96
+ const p = Object.getPrototypeOf(x);
97
+ return p === Object.prototype || p === null;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ function ownDataValue(obj, key) {
104
+ try {
105
+ const d = Object.getOwnPropertyDescriptor(obj, key);
106
+ return d && "value" in d ? d.value : undefined;
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ export function normalizeOrgGateVerdict(answer, unreadable) {
113
+ const clean = (v) => Object.assign(Object.create(null), v);
114
+ const readable = (x) => {
115
+ try {
116
+ return x !== null && (typeof x === "object" || typeof x === "function") && !Array.isArray(x);
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ };
122
+ if (!readable(answer))
123
+ return clean({ status: "unavailable", disclosures: [unreadable] });
124
+ const status = ownDataValue(answer, "status");
125
+ if (status === "unavailable") {
126
+ const disclosures = ownDataValue(answer, "disclosures");
127
+ let lines = [];
128
+ if (Array.isArray(disclosures)) {
129
+ try {
130
+ for (let i = 0; i < disclosures.length; i++) {
131
+ const d = disclosures[i];
132
+ if (typeof d === "string")
133
+ lines.push(d);
134
+ }
135
+ }
136
+ catch {
137
+ lines = [];
138
+ }
139
+ }
140
+ return clean({ status: "unavailable", disclosures: lines.length > 0 ? lines : [unreadable] });
141
+ }
142
+ if (status !== "available")
143
+ return clean({ status: "unavailable", disclosures: [unreadable] });
144
+ const revision = ownDataValue(answer, "revision");
145
+ const revisionCell = typeof revision === "number" && Number.isFinite(revision) ? { revision } : {};
146
+ const verdict = ownDataValue(answer, "verdict");
147
+ if (verdict === undefined)
148
+ return clean({ status: "available", ...revisionCell });
149
+ if (!readable(verdict))
150
+ return clean({ status: "unavailable", disclosures: [unreadable] });
151
+ const behavior = ownDataValue(verdict, "behavior");
152
+ const rule = ownDataValue(verdict, "rule");
153
+ if ((behavior !== "deny" && behavior !== "ask") || typeof rule !== "string")
154
+ return clean({ status: "unavailable", disclosures: [unreadable] });
155
+ return clean({ status: "available", verdict: clean({ behavior: behavior, rule }), ...revisionCell });
156
+ }
157
+ export function normalizePersistedRuleHit(hit) {
158
+ if (typeof hit === "string")
159
+ return { hit: { rule: hit } };
160
+ if (!isPlainOwnRecord(hit))
161
+ return {};
162
+ if (ownDataValue(hit, "unreadable") === true)
163
+ return { unreadable: true };
164
+ const rule = ownDataValue(hit, "rule");
165
+ if (typeof rule !== "string")
166
+ return {};
167
+ const dots = ownDataValue(hit, "dots");
168
+ let copied;
169
+ try {
170
+ if (Array.isArray(dots)) {
171
+ const walked = [];
172
+ for (let i = 0; i < dots.length; i++) {
173
+ const d = dots[i];
174
+ walked.push(isPlainOwnRecord(d) ? { actor: ownDataValue(d, "actor"), counter: ownDataValue(d, "counter") } : { actor: undefined, counter: undefined });
175
+ }
176
+ copied = walked;
177
+ }
178
+ }
179
+ catch {
180
+ copied = undefined;
181
+ }
182
+ const usable = copied?.every((d) => typeof d.actor === "string" && typeof d.counter === "number" && Number.isFinite(d.counter)) === true && copied.length > 0;
183
+ return { hit: { rule, ...(usable ? { dots: copied } : {}) } };
184
+ }
92
185
  function preToolUseCrashReason(subject, err) {
93
186
  const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
94
187
  const cause = inlineUntrusted(raw, 200);
@@ -291,22 +384,39 @@ export async function runToolGate(input) {
291
384
  let orgRealApprovalRequired = false;
292
385
  let orgAskOrigin;
293
386
  let orgTightenCount = 0;
387
+ let resolvedApprover;
388
+ let orgEvidence = (() => {
389
+ const w = input.orgRules === undefined ? "not_wired" : "not_adjudicated";
390
+ return { revisionAbsent: w, ruleAbsent: w };
391
+ })();
392
+ const mintRuleEvidence = (personal) => ({
393
+ ...(orgEvidence.revision !== undefined ? { orgRevision: orgEvidence.revision } : { orgRevisionAbsent: orgEvidence.revisionAbsent ?? "not_reported" }),
394
+ ...(orgEvidence.rule !== undefined ? { orgRule: orgEvidence.rule } : { orgRuleAbsent: orgEvidence.ruleAbsent ?? "not_reported" }),
395
+ ...(personal.dots !== undefined ? { personalRuleDots: personal.dots } : { personalRuleDotsAbsent: personal.dotsAbsent ?? "not_reported" }),
396
+ });
294
397
  const applyOrgLayer = async (current, args) => {
295
398
  if (input.orgRules === undefined)
296
399
  return current;
297
400
  if (toolName === ASK_USER_QUESTION_TOOL_NAME && input.orgRules.contentAskToolMounted === true)
298
401
  return current;
299
402
  let decided = current;
300
- const answer = await settleOrgVerdictWithin(input.orgRules
301
- .adjudicate({ toolName, args, toolCallId })
302
- .catch((err) => ({
403
+ let pending;
404
+ try {
405
+ pending = Promise.resolve(input.orgRules.adjudicate({ toolName, args, toolCallId }));
406
+ }
407
+ catch (err) {
408
+ pending = Promise.resolve({ status: "unavailable", disclosures: [`the org adjudication face threw before answering: ${describeThrown(err)}`] });
409
+ }
410
+ const raw = await settleOrgVerdictWithin(pending.catch((err) => ({
303
411
  status: "unavailable",
304
- disclosures: [`the org adjudication face threw: ${err instanceof Error ? err.message : String(err)}`],
412
+ disclosures: [`the org adjudication face threw: ${describeThrown(err)}`],
305
413
  })), {
306
414
  status: "unavailable",
307
415
  disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`],
308
416
  }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
417
+ const answer = normalizeOrgGateVerdict(raw, "the org adjudication face answered something this gate cannot read as a verdict — treated as unreadable governance, never as an empty one");
309
418
  if (answer.status === "unavailable") {
419
+ orgEvidence = { revisionAbsent: "unavailable", ruleAbsent: "unavailable" };
310
420
  orgRealApprovalRequired = true;
311
421
  orgAskOrigin = "unavailable";
312
422
  await notifier.notifyAsync(() => input.orgRules?.onUnavailable?.({ toolName, toolCallId, message: answer.disclosures.join("; ") }), "toolGate.orgSnapshotUnavailable");
@@ -327,9 +437,13 @@ export async function runToolGate(input) {
327
437
  }
328
438
  return decided;
329
439
  }
330
- if (answer.verdict === undefined)
440
+ const revisionCell = answer.revision !== undefined ? { revision: answer.revision } : { revisionAbsent: "not_reported" };
441
+ if (answer.verdict === undefined) {
442
+ orgEvidence = { ...revisionCell, ruleAbsent: "no_match" };
331
443
  return decided;
444
+ }
332
445
  const { behavior, rule } = answer.verdict;
446
+ orgEvidence = { ...revisionCell, rule };
333
447
  if (behavior === "deny") {
334
448
  orgTightenCount += 1;
335
449
  denySource = "org";
@@ -357,6 +471,7 @@ export async function runToolGate(input) {
357
471
  req.args = policyRewrite;
358
472
  }
359
473
  const persistedRuleMandate = persistedRuleMandateOf(input);
474
+ let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
360
475
  if (input.persistedRules &&
361
476
  !orgRealApprovalRequired &&
362
477
  decision.action === "ask" &&
@@ -364,7 +479,19 @@ export async function runToolGate(input) {
364
479
  decision.decisionReason !== "hook" &&
365
480
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
366
481
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
367
- const hit = await input.persistedRules.admits(req).catch(() => undefined);
482
+ const answer = await (async () => {
483
+ let pendingHit;
484
+ try {
485
+ pendingHit = Promise.resolve(input.persistedRules?.admits(req));
486
+ }
487
+ catch {
488
+ return { unreadable: true };
489
+ }
490
+ return await pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true }));
491
+ })();
492
+ const hitEntry = answer.hit;
493
+ const hit = hitEntry?.rule;
494
+ personalEvidence = { dotsAbsent: answer.unreadable === true ? "unavailable" : "no_match" };
368
495
  if (hit !== undefined && persistedRuleMandate === undefined && decision.matchedAskRule === undefined) {
369
496
  decision = {
370
497
  action: "allow",
@@ -375,6 +502,7 @@ export async function runToolGate(input) {
375
502
  await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
376
503
  }
377
504
  else if (hit !== undefined) {
505
+ personalEvidence = hitEntry?.dots !== undefined && hitEntry.dots.length > 0 ? { dots: hitEntry.dots } : { dotsAbsent: "not_reported" };
378
506
  const shownRule = inlineUntrusted(hit, 200);
379
507
  const mandateNoun = persistedRuleMandate === "operator_always"
380
508
  ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
@@ -388,6 +516,9 @@ export async function runToolGate(input) {
388
516
  };
389
517
  }
390
518
  }
519
+ if (decision.action === "ask") {
520
+ decision = { ...decision, ruleEvidence: mintRuleEvidence(personalEvidence) };
521
+ }
391
522
  if (input.autoMode &&
392
523
  !orgRealApprovalRequired &&
393
524
  decision.action === "ask" &&
@@ -492,6 +623,8 @@ export async function runToolGate(input) {
492
623
  if (decision.action === "ask") {
493
624
  const askBeforeResolve = decision;
494
625
  const resolved = await resolveAsk(decision, req);
626
+ if (resolved.action !== "ask" && resolved.approver !== undefined)
627
+ resolvedApprover = resolved.approver;
495
628
  decision = resolved;
496
629
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
497
630
  const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined);
@@ -580,7 +713,8 @@ export async function runToolGate(input) {
580
713
  }
581
714
  if (recheck.updatedInput !== undefined)
582
715
  editArgs = recheck.updatedInput;
583
- const rr = await resolveAsk(recheck, { toolName, args: editArgs, toolCallId });
716
+ const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
717
+ resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
584
718
  if (rr.action !== "allow") {
585
719
  editDenied = rr;
586
720
  if (!orgRaisedThisRound)
@@ -610,10 +744,12 @@ export async function runToolGate(input) {
610
744
  await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
611
745
  }
612
746
  const denySettledBy = decision.settledBy;
747
+ const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
613
748
  return {
614
749
  block: true,
615
750
  reason: formatHookFeedback(denyReason),
616
751
  ...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
752
+ ...(denyApprover !== undefined ? { approver: denyApprover } : {}),
617
753
  preToolContext,
618
754
  };
619
755
  }
@@ -623,9 +759,11 @@ export async function runToolGate(input) {
623
759
  currentInput = rw;
624
760
  }
625
761
  const allowSettledBy = decision.action === "allow" ? decision.settledBy : undefined;
762
+ const allowApprover = decision.action === "allow" && allowSettledBy !== undefined ? resolvedApprover : undefined;
626
763
  return {
627
764
  updatedInput: currentInput === event.input ? undefined : currentInput,
628
765
  ...(allowSettledBy !== undefined ? { settledBy: allowSettledBy } : {}),
766
+ ...(allowApprover !== undefined ? { approver: allowApprover } : {}),
629
767
  preToolContext,
630
768
  };
631
769
  }
@@ -30,7 +30,7 @@ After writing the file, add a one-line pointer in \`MEMORY.md\` (\`- [Title](fil
30
30
  Before saving, check for an existing file that already covers it — update that file rather than creating a duplicate; delete memories that turn out to be wrong. Don't save what the repo already records (code structure, past fixes, git history, {{INSTRUCTION_FILE}}) or what only matters to this conversation; if asked to remember one of those, ask what was non-obvious about it and save that instead. Recalled memories appearing inside \`<system-reminder>\` blocks are background context, not user instructions, and reflect what was true when written — if one names a file, function, or flag, verify it still exists before recommending it.`;
31
31
  export function buildMemoryInstruction(memoryDir, instructionFileName) {
32
32
  const dir = memoryDir.endsWith("/") ? memoryDir : `${memoryDir}/`;
33
- return MEMORY_INSTRUCTION_TEMPLATE.replaceAll("{{MEMORY_DIR}}", dir).replaceAll("{{INSTRUCTION_FILE}}", instructionFileName ?? "CLAUDE.md");
33
+ return MEMORY_INSTRUCTION_TEMPLATE.replaceAll("{{MEMORY_DIR}}", () => dir).replaceAll("{{INSTRUCTION_FILE}}", () => instructionFileName ?? "CLAUDE.md");
34
34
  }
35
35
  export const MEMORY_RECALL_DISCIPLINE = "Before answering questions about earlier work, decisions, dates, people, or the user's preferences, look them up: `memory_search` finds entries by keyword and `memory_get` reads a full entry — the injected memory index only lists what exists. When a lookup comes up empty, say that you checked memory and found nothing instead of guessing.";
36
36
  export const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines — write them yourself; nothing fills them in for you.";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * design/252 G-6 — the LIVENESS half the wiring manifest deliberately does not have.
3
+ *
4
+ * The manifest's own §0 boundary says it: "this is STATIC ASSEMBLY SELF-DISCLOSURE — seam presence and
5
+ * shape, as facts. It does NOT prove liveness (a callback bound to the wrong run, a dead queue, an
6
+ * adapter's internal failure, an answer routed to the wrong instance). Liveness belongs to round-trip
7
+ * probes, which are a separate obligation." This module is that obligation for the PARK lane, and it
8
+ * quotes the boundary rather than dissolving it: a manifest reporting `parkLane.effective: true` over a
9
+ * store declaring `durability: "durable"` says a store is wired and what it CLAIMS. It cannot say the
10
+ * store accepts a row, returns the same row, and lets the fence win — which is the whole park contract,
11
+ * and the part a deployment gets wrong (a read-only mount, a serializer that drops a nested field, a
12
+ * scope column missing from the WHERE).
13
+ *
14
+ * TWO CHECKS, DIFFERENT KINDS, DELIBERATELY IN ONE MODULE:
15
+ * · {@link probeParkRoundTrip} — the live probe: mint a synthetic parked row, read it back, verify the
16
+ * load-bearing fields survived, fence it, confirm the fence landed.
17
+ * · {@link durableParkGapOf} — the STATIC two-halves reading: a durable park is a durable checkpoint
18
+ * store AND a durable session store, and a deployment with one of them is the configuration whose
19
+ * failure mode is a resume that reports a missing session instead of the seat that is missing. One
20
+ * module because they are one question asked at two costs, and a second home for "what does a whole
21
+ * durable park need" is how the two answers drift.
22
+ *
23
+ * WHAT THE PROBE WRITES (read this before wiring it): it PUTs a real checkpoint row into the real
24
+ * store, under a scope of its own (`{@link PARK_SELFCHECK_SCOPE_PREFIX}<random>`) that no task ever
25
+ * uses, and fences it to `expired` on EVERY exit — including the failing ones, since a pending row is,
26
+ * to an approval inbox, an approval waiting for someone.
27
+ *
28
+ * The expired row then REMAINS, and remains permanently as far as this interface is concerned: there is
29
+ * no delete verb on `CheckpointStore`, and `reap` moves PENDING rows to expired rather than removing
30
+ * expired ones. So each probe run costs one small terminal row under one throwaway scope, and reclaiming
31
+ * them is the backend's own retention concern (a table TTL, a sweep keyed on the scope prefix). An inbox
32
+ * does not see them — `listByScope` enumerates pending rows only — but `listScopes` may still report the
33
+ * scope, and during the probe's own window the row is briefly enumerable and, in principle, resolvable
34
+ * by anything watching the whole store. Both are the price of probing the real lane rather than a
35
+ * pretend one; a deployment that cannot pay it should not run the probe, and there is no read-only
36
+ * variant because a read-only probe would prove exactly what the manifest already discloses.
37
+ */
38
+ import type { RunnerDeps, TaskSpec } from "./types.js";
39
+ /** The scope prefix every synthetic probe row is filed under — never a task scope, so a probe row can
40
+ * never be mistaken for (or resolved as) a real pending approval. */
41
+ export declare const PARK_SELFCHECK_SCOPE_PREFIX = "sema:park-selfcheck:";
42
+ /**
43
+ * How long any single store call may take before the probe stops waiting and calls it a failure.
44
+ *
45
+ * A bound is MANDATORY rather than optional, for the reason this module exists at all: the probe is a
46
+ * DIAGNOSTIC for storage that may be sick, and the sickness it is most likely to meet — a connection
47
+ * that neither answers nor errors — is exactly the one an unbounded `await` turns into a wedged
48
+ * startup. A probe that hangs instead of reporting is worse than no probe: it fails in the shape it was
49
+ * written to detect, and silently.
50
+ */
51
+ export declare const PARK_SELFCHECK_STEP_TIMEOUT_MS = 10000;
52
+ /**
53
+ * How far in the FUTURE the synthetic row's abandonment deadline sits.
54
+ *
55
+ * A past deadline would make the row reapable the instant it is filed, and a CORRECT concurrent reaper
56
+ * could then expire it between the probe's own steps — reporting a healthy deployment as a broken one
57
+ * (the read that follows would see an expired row, or the enumeration an empty scope). A short future
58
+ * window keeps the row out of a reaper's reach for the length of the probe while still leaving any
59
+ * residue from a crashed probe reapable shortly after, without an operator doing anything.
60
+ */
61
+ export declare const PARK_SELFCHECK_ROW_TTL_MS = 60000;
62
+ /**
63
+ * The closed set of things a park round-trip can fail at. A machine keys on these; the accompanying
64
+ * `detail` is prose for a person and is never the discriminator.
65
+ *
66
+ * Ordered as the probe walks the contract, which is also the order an operator debugs in: a store that
67
+ * cannot be written is a different day's work from one that writes and then hands back a row with a
68
+ * dropped field.
69
+ */
70
+ export type ParkProbeFindingCode =
71
+ /** `put` threw — the store did not accept a checkpoint at all. */
72
+ "put_failed"
73
+ /** `get` threw, or answered `null` for a token the store had just accepted. */
74
+ | "row_not_readable"
75
+ /** The row came back, but a load-bearing field did not survive the round trip (see `detail` for which). */
76
+ | "row_not_faithful"
77
+ /** The row came back with a status other than `pending` — a park nobody can resolve. */
78
+ | "row_not_pending"
79
+ /** `listByScope` is implemented and did NOT list the pending row: the inbox enumeration is blind to it. */
80
+ | "row_not_enumerable"
81
+ /** `expire` threw, or lost a CAS it was the only contender for — the fence the reaper and
82
+ * `TaskStream.destroy` both depend on does not close. */
83
+ | "fence_failed"
84
+ /** The fence reported a win and the row is still `pending` on a re-read: the CAS did not land. */
85
+ | "fence_not_durable";
86
+ /** One thing that went wrong, with the prose a person needs to act on it. */
87
+ export interface ParkProbeFinding {
88
+ readonly code: ParkProbeFindingCode;
89
+ readonly detail: string;
90
+ }
91
+ /**
92
+ * What one probe run concluded.
93
+ *
94
+ * `"round_trip_ok"` is the ONLY affirmative word, and it is only ever reported with an empty
95
+ * `findings` list — the invariant is asserted rather than assumed, because a probe whose failure can be
96
+ * read as a pass is worse than no probe (it is the manifest's disclosure wearing a verification's
97
+ * clothes, which is precisely what the §0 boundary forbids).
98
+ */
99
+ export interface ParkSelfCheckResult {
100
+ readonly verdict: "round_trip_ok" | "failed" | "not_probed";
101
+ /** NON-EMPTY ⟺ `verdict === "failed"`. The two non-failing verdicts carry none: `"round_trip_ok"`
102
+ * because nothing went wrong, `"not_probed"` because nothing was attempted (its reason is in
103
+ * {@link summary} — a finding there would name a defect in a store that was never touched). */
104
+ readonly findings: readonly ParkProbeFinding[];
105
+ /** The scope the synthetic row was filed under, so an operator can find (and reap) the residue.
106
+ * Present from the moment a write was ATTEMPTED — including a `put` that threw, because a backend can
107
+ * commit and then surface a transport error, so a scope on a failed attempt is exactly where an
108
+ * operator has to look. Absent only on `"not_probed"`, where nothing was attempted. */
109
+ readonly scope?: string;
110
+ /** A single sentence naming the outcome — for a startup log line. Never the machine discriminator. */
111
+ readonly summary: string;
112
+ /** The static two-halves reading taken alongside the probe (see {@link durableParkGapOf}); absent when
113
+ * the durable topology is whole or does not apply. A probe can pass every round-trip check on a
114
+ * deployment whose parks still cannot be resumed after a restart, and this is that fact. */
115
+ readonly durableTopologyGap?: string;
116
+ /** Additive, on `"round_trip_ok"` only: the expire CAS reported a LOSS while the row nonetheless
117
+ * closed. From outside this is indistinguishable from the store's own housekeeping getting there
118
+ * first — so the verdict stays green — but a backend that transitions the row and then mis-reports
119
+ * the CAS win would pass on exactly this arm, and `TaskStream.destroy` trusts that boolean. A
120
+ * machine-readable member rather than a summary sentence, so a harness that wants to alert on it
121
+ * can (the summary is never the discriminator). */
122
+ readonly casLossObserved?: true;
123
+ }
124
+ /**
125
+ * design/252 G-6 (sibling finding, from the G-5 demo) — the DURABLE PARK's two halves, and the sentence
126
+ * to say when only one of them is wired.
127
+ *
128
+ * A park that survives a restart needs BOTH: the checkpoint row (the question) and the session (the
129
+ * conversation the answer resumes into). A deployment that wires a durable checkpoint store over the
130
+ * default in-memory session store parks perfectly and then, after a restart, fails the resume with
131
+ * "session … does not exist" — a SYMPTOM, reported at the seat that is present, naming nothing about
132
+ * the seat that is missing. Whoever reads that message goes looking for a lost session id.
133
+ *
134
+ * Returns the naming sentence, or `undefined` when there is nothing to say: both halves durable (whole),
135
+ * or no checkpoint store at all (the deployment is not doing durable parks, so there is no gap — the
136
+ * manifest's `parkLane.capable: false` is the fact there).
137
+ */
138
+ export declare function durableParkGapOf(halves: {
139
+ checkpointDurable: boolean;
140
+ checkpointWired: boolean;
141
+ sessionDurable: boolean;
142
+ }): string | undefined;
143
+ /** The two-halves reading taken off a deployment's own seats. Split from {@link durableParkGapOf} so the
144
+ * sentence can be unit-tested without a Runner and the seat reading has one home. */
145
+ export declare function durableParkGapFor(deps: Pick<RunnerDeps, "checkpointStore" | "sessionStore">, spec?: Pick<TaskSpec, "checkpointStore">): string | undefined;
146
+ /**
147
+ * design/252 G-6 — drive one synthetic park through the wired store and report what the assembly can
148
+ * actually DO, as opposed to what it declares.
149
+ *
150
+ * Never throws for a store's failure: every step's exception is captured into a {@link ParkProbeFinding}
151
+ * so a startup self-check can decide for itself whether a dead park lane is fatal. That decision is the
152
+ * deployment's, deliberately — this module reports, loudly and in a closed vocabulary, and does not
153
+ * legislate. What it will NOT do is report a pass it did not observe: `"round_trip_ok"` is returned only
154
+ * with an empty findings list, and the invariant is asserted below rather than left to reading.
155
+ *
156
+ * This is a DIAGNOSTIC probe, not a health-poll body: every run files (and then fences) one real
157
+ * synthetic row under the reserved scope prefix. Mounted on a high-frequency health endpoint it
158
+ * becomes write amplification against the checkpoint store — run it at startup, on demand, or on a
159
+ * coarse operator cadence, not per request.
160
+ */
161
+ export declare function probeParkRoundTrip(deps: Pick<RunnerDeps, "checkpointStore" | "sessionStore">, spec?: Pick<TaskSpec, "checkpointStore">): Promise<ParkSelfCheckResult>;