@vincemakes/kiso-core 0.1.31 → 0.1.32

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.
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import { type Adapter, type AbortSignalLike } from "../protocol/adapter.js";
36
36
  import type { Event, StructuredError } from "../protocol/events.js";
37
- import type { ApprovalPolicy } from "../protocol/extension.js";
37
+ import type { ApprovalChain } from "../protocol/extension.js";
38
38
  import { EventLog } from "./event-log.js";
39
39
  import type { EventInput } from "./event-log.js";
40
40
  import type { AssistantBlock, AssistantMessage, Message, ToolResultMessage } from "../protocol/messages.js";
@@ -109,18 +109,17 @@ export interface LoopConfig {
109
109
  /** round 4 (adversarial): the uncertainty twin of `approvalVerdict`. */
110
110
  readonly uncertaintyVerdict?: (executionId: string) => "rerun" | "abandoned" | undefined;
111
111
  /**
112
- * E1: extension approval policies, tagged by their owning extension —
113
- * decided BEFORE the human flow. Any deny wins (the FIRST denial's
114
- * reason); else any ask falls into the existing flow; all allow
115
- * auto-approves. A policy that throws counts as ask. Allow/deny are
116
- * recorded durably with decidedBy = the extension's name, never pausing
117
- * for a human; a durable decision already recorded (resume) takes
118
- * effect and the chain never re-runs.
112
+ * E1: the COMPOSED approval chain the runtime composes the
113
+ * extensions' policies (deny > allow > ask, the R3 ruling) into ONE
114
+ * decide; the kernel's gate calls it BEFORE the human flow. Allow/deny
115
+ * are recorded durably with decidedBy = the deciding extension, never
116
+ * pausing for a human; an ask falls into the human flow (its speaker
117
+ * names the first non-abstain the panel's why-asked line). A
118
+ * throwing chain counts as ask. Absent, no chain runs. A durable
119
+ * decision already recorded (resume) takes effect and the chain never
120
+ * re-runs.
119
121
  */
120
- readonly approvalPolicies?: readonly {
121
- readonly extension: string;
122
- readonly policy: ApprovalPolicy;
123
- }[];
122
+ readonly approvalPolicy?: ApprovalChain;
124
123
  /** P3: the session's id — carried to tools via ToolContext.sessionId. */
125
124
  readonly sessionId?: string;
126
125
  }
@@ -225,7 +225,7 @@ export async function* loop(config) {
225
225
  try {
226
226
  await acquireWindow();
227
227
  decideChain = decideChain.then(async () => {
228
- const v = await decideCall(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicies, nextDecisionId, pushExec);
228
+ const v = await decideCall(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicy, nextDecisionId, pushExec);
229
229
  if (v.action === "ask") {
230
230
  askGate = new Promise((res) => {
231
231
  askRelease = res;
@@ -247,7 +247,7 @@ export async function* loop(config) {
247
247
  // ordering: the successors then proceed with their own
248
248
  // verdicts).
249
249
  const decision = await Promise.race([
250
- humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec),
250
+ humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec, verdict.speaker),
251
251
  violatedP,
252
252
  ]);
253
253
  askRelease?.();
@@ -604,7 +604,7 @@ function resultEvent(call, result, executionId) {
604
604
  * caller runs it (conservative ordering: the calls after an ask wait for
605
605
  * its resolution).
606
606
  */
607
- async function decideCall(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal, approvalPolicies, nextDecisionId, push) {
607
+ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal, approvalPolicy, nextDecisionId, push) {
608
608
  const payload = {
609
609
  callId: call.callId,
610
610
  name: call.name,
@@ -632,59 +632,30 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
632
632
  // all. Checked again here, after any permission path.
633
633
  if (signal?.aborted)
634
634
  throw ABORTED;
635
- // ── E1: the extension policy chain, decided BEFORE the human flow ─────
635
+ // ── E1: the composed approval chain, decided BEFORE the human flow ────
636
636
  // A durable POLICY decision for THIS call takes effect on resume — the
637
637
  // chain never re-runs when its verdict is already in the log (isomorphic
638
638
  // alreadyReplaced: the persisted fact speaks for the call). The match is
639
639
  // the same logical call: same callId, decidedBy set (a policy verdict,
640
640
  // never a human's), and input identical to the original tool_call_end —
641
641
  // a re-issued call with different arguments is a NEW call and re-decided.
642
- // Composition: deny > ask > allow any deny wins (the FIRST denial's
643
- // reason), else any ask falls into the existing human flow below, and
644
- // only an ALL-allow chain auto-approves: recorded durably with decidedBy
645
- // = the extension's name, never a human-visible pause. A policy that
646
- // throws counts as ask.
642
+ // The chain itself is COMPOSED by the runtime (composeApprovalChain
643
+ // the R3 ruling: deny > allow > ask, any deny wins, a LATER allow beats
644
+ // an EARLIER ask, an all-abstain asks per ADR-0042); the kernel consumes
645
+ // its verdict. A throwing chain counts as ask. Allow/deny are recorded
646
+ // durably with decidedBy = the deciding extension, never pausing for a
647
+ // human.
647
648
  const originalCall = [...log.all].reverse().find((e) => e.type === "tool_call_end" && e.callId === call.callId);
648
649
  const durable = originalCall !== undefined && JSON.stringify(originalCall.input) === JSON.stringify(call.input)
649
650
  ? log.all.find((e) => e.type === "permission_decided" && e.decidedBy !== undefined && e.callId === call.callId)
650
651
  : undefined;
651
652
  let chainVerdict;
652
- let deniedReason;
653
- let deniedBy;
654
- let firstSpeaker; // the first non-abstain verdict's extension
655
- let anySpoke = false;
656
- if (durable === undefined && approvalPolicies !== undefined && approvalPolicies.length > 0) {
657
- for (const { extension, policy } of approvalPolicies) {
658
- let v;
659
- try {
660
- v = await raceAbort(Promise.resolve(policy.decide(payload, ctx)), signal);
661
- }
662
- catch {
663
- v = { action: "ask" }; // a throwing policy counts as ask — it speaks, never silently
664
- }
665
- if (v.action === "abstain")
666
- continue; // no opinion — not a verdict
667
- anySpoke = true;
668
- firstSpeaker ??= extension;
669
- if (v.action === "deny") {
670
- deniedBy ??= extension;
671
- deniedReason ??= v.reason; // the FIRST denial's reason
672
- }
673
- else if (v.action === "ask") {
674
- chainVerdict = { action: "ask" };
675
- }
676
- }
677
- if (deniedBy !== undefined) {
678
- chainVerdict = { action: "deny", reason: deniedReason ?? "denied" };
679
- }
680
- else if (chainVerdict === undefined && anySpoke) {
681
- chainVerdict = { action: "allow" }; // every speaker allows
653
+ if (durable === undefined && approvalPolicy !== undefined) {
654
+ try {
655
+ chainVerdict = await raceAbort(Promise.resolve(approvalPolicy.decide(payload, ctx)), signal);
682
656
  }
683
- else if (chainVerdict === undefined) {
684
- // an all-abstain (ADR-0042): NO policy speaksthe call falls to
685
- // the ask flow below, never to a silent auto-approve. The human
686
- // decides; absent a channel, humanPause's honest denial.
687
- chainVerdict = { action: "ask" };
657
+ catch {
658
+ chainVerdict = { action: "ask" }; // a throwing chain counts as ask it speaks, never silently
688
659
  }
689
660
  if (chainVerdict.action !== "ask") {
690
661
  // allow/deny are PERSISTED FACTS (decidedBy = a SPEAKING
@@ -695,13 +666,15 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
695
666
  decisionId: nextDecisionId(),
696
667
  callId: call.callId,
697
668
  decision: chainVerdict.action === "allow" ? "approved" : "denied",
698
- ...(chainVerdict.action === "deny" ? { reason: chainVerdict.reason } : {}),
699
- decidedBy: deniedBy ?? firstSpeaker,
669
+ ...(chainVerdict.action === "deny" && chainVerdict.reason !== undefined
670
+ ? { reason: chainVerdict.reason }
671
+ : {}),
672
+ decidedBy: chainVerdict.decidedBy,
700
673
  });
701
674
  }
702
675
  }
703
676
  if (chainVerdict?.action === "deny") {
704
- return { action: "deny", result: resultEvent(call, denialResult(chainVerdict.reason)) };
677
+ return { action: "deny", result: resultEvent(call, denialResult(chainVerdict.reason ?? "denied")) };
705
678
  }
706
679
  if (durable !== undefined && durable.decision === "denied") {
707
680
  return { action: "deny", result: resultEvent(call, denialResult(durable.reason ?? "denied")) };
@@ -716,7 +689,13 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
716
689
  if (resolveApproval === undefined) {
717
690
  return { action: "deny", result: resultEvent(call, denialResult("a policy asked for a human decision, but no approval flow is configured")) };
718
691
  }
719
- return { action: "ask", decisionId: nextDecisionId() };
692
+ // W21: the speaker rides the ask verdict the panel's why-asked
693
+ // line names the extension that asked (the composed chain's first
694
+ // non-abstain; exactOptionalPropertyTypes: omitted, never
695
+ // `undefined`).
696
+ return chainVerdict.speaker === undefined
697
+ ? { action: "ask", decisionId: nextDecisionId() }
698
+ : { action: "ask", decisionId: nextDecisionId(), speaker: chainVerdict.speaker };
720
699
  }
721
700
  // Permission negotiation — defer is a REAL pause (Phase D). C group: the
722
701
  // hook itself is cancelable (a slow policy query must not outlive an
@@ -746,7 +725,7 @@ async function decideCall(call, registry, hooks, ctx, log, resolveApproval, reso
746
725
  * verdict given in the same instant is still recorded exactly once) —
747
726
  * then persist the decision. Returns "approved" | "denied".
748
727
  */
749
- async function humanPause(call, decisionId, hooks, log, resolveApproval, resolveApprovalVerdict, signal, push) {
728
+ async function humanPause(call, decisionId, hooks, log, resolveApproval, resolveApprovalVerdict, signal, push, speaker) {
750
729
  const pendingDecision = resolveApproval !== undefined
751
730
  ? resolveApproval(decisionId)
752
731
  : Promise.resolve({ action: "deny", reason: "no approval channel configured" });
@@ -756,6 +735,9 @@ async function humanPause(call, decisionId, hooks, log, resolveApproval, resolve
756
735
  callId: call.callId,
757
736
  name: call.name,
758
737
  input: call.input ?? {},
738
+ // W21: the ask verdict's speaker — the panel's why-asked line (a
739
+ // static-hook ask has none).
740
+ ...(speaker !== undefined ? { speaker } : {}),
759
741
  });
760
742
  if (hooks.onPause)
761
743
  await hooks.onPause("awaiting approval", {}).catch(() => { });
@@ -16,8 +16,8 @@
16
16
  * assigned by the kernel's EventLog at append time. Consumers (surfaces,
17
17
  * persistence, eval) sync by `seq`; a trajectory is the complete replay of
18
18
  * `seq` 0..N. Without `seq`, "what happened" can only be reconstructed by
19
- * array-shape heuristics — the exact failure Claude Code's transcript sync
20
- * lives in. See ADR-0002.
19
+ * array-shape heuristics — the exact failure the reference implementation's
20
+ * transcript sync lives in. See ADR-0002.
21
21
  *
22
22
  * This module is almost types-only: the only runtime value it emits is
23
23
  * `isKisoEvent`, the type guard the session store validates records with.
@@ -258,6 +258,9 @@ export interface PermissionRequested {
258
258
  readonly callId: string;
259
259
  readonly name: string;
260
260
  readonly input: Readonly<Record<string, unknown>>;
261
+ /** W21: the first non-abstain extension's name — the panel's why-asked
262
+ * line. Absent on static-hook asks and old logs. */
263
+ readonly speaker?: string;
261
264
  }
262
265
  /** The durable answer to a PermissionRequested. */
263
266
  export interface PermissionDecided {
@@ -400,7 +403,7 @@ export interface StructuredError {
400
403
  *
401
404
  * Every consumer switches on `kind`; with `exactOptionalPropertyTypes` and
402
405
  * `strictNullChecks` on, a terminal that nobody handles is a compile error,
403
- * not a production mystery. CC's query() returns 11 different reasons that
406
+ * not a production mystery. The reference implementation's query() returns 11 different reasons that
404
407
  * every consumer discards — here the terminal is an event like any other,
405
408
  * so it cannot be lost. See ADR-0004.
406
409
  *
@@ -16,8 +16,8 @@
16
16
  * assigned by the kernel's EventLog at append time. Consumers (surfaces,
17
17
  * persistence, eval) sync by `seq`; a trajectory is the complete replay of
18
18
  * `seq` 0..N. Without `seq`, "what happened" can only be reconstructed by
19
- * array-shape heuristics — the exact failure Claude Code's transcript sync
20
- * lives in. See ADR-0002.
19
+ * array-shape heuristics — the exact failure the reference implementation's
20
+ * transcript sync lives in. See ADR-0002.
21
21
  *
22
22
  * This module is almost types-only: the only runtime value it emits is
23
23
  * `isKisoEvent`, the type guard the session store validates records with.
@@ -205,7 +205,8 @@ const EVENT_VALIDATORS = {
205
205
  permission_requested: (v) => typeof v.decisionId === "string" &&
206
206
  typeof v.callId === "string" &&
207
207
  typeof v.name === "string" &&
208
- isPlainObject(v.input),
208
+ isPlainObject(v.input) &&
209
+ (v.speaker === undefined || typeof v.speaker === "string"),
209
210
  permission_decided: (v) => typeof v.decisionId === "string" &&
210
211
  (v.decision === "approved" || v.decision === "denied") &&
211
212
  (v.callId === undefined || typeof v.callId === "string") &&
@@ -38,6 +38,29 @@ export type PolicyVerdict = {
38
38
  export interface ApprovalPolicy {
39
39
  readonly decide: (call: PolicyCall, ctx: ToolContext) => PolicyVerdict | Promise<PolicyVerdict>;
40
40
  }
41
+ /**
42
+ * The COMPOSED chain's verdict — what the kernel's gate consumes. The
43
+ * runtime composes the extensions' policies (deny > allow > ask, the R3
44
+ * ruling); allow/deny always carry decidedBy — the deciding extension —
45
+ * and an ask may carry the speaker (the first non-abstain — the panel's
46
+ * why-asked line). The attribution is durable audit (rides
47
+ * permission_decided, never a human pause).
48
+ */
49
+ export type ChainVerdict = {
50
+ readonly action: "deny";
51
+ readonly reason?: string;
52
+ readonly decidedBy: string;
53
+ } | {
54
+ readonly action: "ask";
55
+ readonly speaker?: string;
56
+ } | {
57
+ readonly action: "allow";
58
+ readonly decidedBy: string;
59
+ };
60
+ /** The approval chain as the kernel sees it — ONE composed policy. */
61
+ export interface ApprovalChain {
62
+ readonly decide: (call: PolicyCall, ctx: ToolContext) => ChainVerdict | Promise<ChainVerdict>;
63
+ }
41
64
  /**
42
65
  * A loaded extension. `name` is unique per installation (the loader rejects
43
66
  * duplicates loudly); hooks/tools/approvals are all optional.
@@ -45,8 +45,8 @@ export type ContentBlock = TextContentBlock | ImageContentBlock;
45
45
  *
46
46
  * The model treats this as evidence about intent; a "user" line that the UI
47
47
  * recycled from a suggestion chip is NOT user intent, and a model that cannot
48
- * tell the difference drifts on its own recycled wording (Claude Code
49
- * #60087). The kernel preserves the label; product code decides how to render
48
+ * tell the difference drifts on its own recycled wording (the reference
49
+ * implementation's #60087). The kernel preserves the label; product code decides how to render
50
50
  * it. Defaults (when absent): role `user` → "user", role `assistant` →
51
51
  * "model", role `tool` → "tool_result".
52
52
  */
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * One registry per agent. It is the ONLY place the kernel learns which tools
5
5
  * exist: nothing is assembled from a list maintained elsewhere, because a
6
- * second list is a second truth (the failure class behind Claude Code's five
7
- * hand-maintained agent-tool sets and pi's six copies of the default tool
8
- * list — see ADR-0001).
6
+ * second list is a second truth (the failure class behind the reference
7
+ * implementation's hand-maintained agent-tool sets and its six copies of the
8
+ * default tool list — see ADR-0001).
9
9
  *
10
10
  * `subset()` is the structural tool filter: a mode or a subagent gets a
11
11
  * registry whose tool table PHYSICALLY lacks the tools it must not see. The
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * One registry per agent. It is the ONLY place the kernel learns which tools
5
5
  * exist: nothing is assembled from a list maintained elsewhere, because a
6
- * second list is a second truth (the failure class behind Claude Code's five
7
- * hand-maintained agent-tool sets and pi's six copies of the default tool
8
- * list — see ADR-0001).
6
+ * second list is a second truth (the failure class behind the reference
7
+ * implementation's hand-maintained agent-tool sets and its six copies of the
8
+ * default tool list — see ADR-0001).
9
9
  *
10
10
  * `subset()` is the structural tool filter: a mode or a subagent gets a
11
11
  * registry whose tool table PHYSICALLY lacks the tools it must not see. The
@@ -49,8 +49,8 @@ export interface Tool<I = unknown> {
49
49
  /** JSON Schema (draft-07 subset). Validated before execute. */
50
50
  readonly parameters: Readonly<Record<string, unknown>>;
51
51
  /**
52
- * Per-call concurrency predicate — the CC-invented shape that pi's static
53
- * executionMode cannot express: the same tool may be parallel-safe for one
52
+ * Per-call concurrency predicate — a shape the reference implementation's
53
+ * static executionMode cannot express: the same tool may be parallel-safe for one
54
54
  * input and must be serial for another (generate_image with
55
55
  * `chain_to_previous`). Absent = safe when true-ish; see ADR-0015.
56
56
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "kiso (foundation) core — protocol, event log, loop, hooks, modes, permissions, compaction, delivery truth. The 2,000-line kernel at the bottom of the kiso framework.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "openai"
34
34
  ],
35
35
  "devDependencies": {
36
- "@vincemakes/kiso-evals": "0.1.32",
36
+ "@vincemakes/kiso-evals": "0.1.33",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"