@vincemakes/kiso-core 0.12.0 → 0.14.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.
package/dist/index.d.ts CHANGED
@@ -9,4 +9,3 @@ export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
11
  export * from "./kernel/project.js";
12
- export * from "./governance/delivery.js";
package/dist/index.js CHANGED
@@ -9,4 +9,3 @@ export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
11
  export * from "./kernel/project.js";
12
- export * from "./governance/delivery.js";
@@ -205,11 +205,33 @@ export async function* loop(config) {
205
205
  // rejection un-consumed — the no-op keeps it from surfacing as an
206
206
  // unhandled rejection while the race consumers still receive it.
207
207
  void violatedP.catch(() => { });
208
+ // ── EC-1 ① — the DURABLE TURN COMMIT gate ──────────────────────────────
209
+ // Invariant 3 (COMMIT GATING): a commit-required handler never starts
210
+ // before this turn's stop is DURABLE. The gate RESOLVES exactly once per
211
+ // turn — at the commit, or at the void — and the waiter then reads
212
+ // `committed`. It deliberately does not reuse `violatedP`: that one
213
+ // REJECTS, and a rejection awaited here would be caught by the launch's
214
+ // catch and recorded as a launchError, failing the whole run for what is
215
+ // an ordinary void. A gate that resolves keeps the void path quiet.
216
+ // Both are reset per turn (below): turn N+1's calls wait for turn N+1's
217
+ // commit. Safe because the settle drains every launch before the turn
218
+ // advances, so no launch ever waits on a stale gate.
219
+ let committed = false;
220
+ let settleTurn = () => { };
221
+ let turnSettled = new Promise((res) => {
222
+ settleTurn = res;
223
+ });
208
224
  // The ask gate: an ask's human resolution blocks the calls after it.
209
225
  // The DECIDE chain serializes the decisions in CALL order, so the gate
210
226
  // an ask installs is structurally in place before the successors decide.
227
+ // EC-1 ③: it must be installed THERE and not at the ask itself, even
228
+ // though the ask now happens later (post-commit): the gate is what a
229
+ // precommit-eligible sibling consults to know an ask was accepted ahead
230
+ // of it, and that has to be knowable BEFORE the commit. Each call
231
+ // captures the gate ahead of it and its own release (below) — one shared
232
+ // `askRelease` used to mean the first ask's resolution opened the SECOND
233
+ // ask's gate.
211
234
  let askGate = Promise.resolve();
212
- let askRelease = null;
213
235
  // The decision chain: each launch's decide is chained onto the previous
214
236
  // one's — the decides run in CALL order and the chain resolves to the
215
237
  // call's verdict.
@@ -219,16 +241,57 @@ export async function* loop(config) {
219
241
  // keys (the executionId comes from the drain, seq-stable).
220
242
  let idSeq = log.all.length + 1;
221
243
  const nextDecisionId = () => `d-${idSeq++}`;
244
+ // ── EC-1 ② / ③ — the FIFO EXCLUSIVE BARRIER ────────────────────────────
245
+ // Absence is the conservative truth: a tool that declares nothing is
246
+ // EXCLUSIVE, and the kernel serializes it. `concurrency: "shared"` is the
247
+ // only way to overlap, and it is a per-TOOL certificate the kernel
248
+ // enforces — never a per-call claim it could not police.
249
+ //
250
+ // The fence is installed at ACCEPTANCE (in `launch`, i.e. CALL order),
251
+ // not when a handler starts: that is what makes it FIFO. A later sibling
252
+ // — including a precommit-safe read — never overtakes an exclusive
253
+ // invocation accepted before it, so a read can never observe a
254
+ // half-written file. A read after a write therefore loses its latency
255
+ // win; safe overtaking and snapshot semantics stay future work.
256
+ let fence = Promise.resolve();
257
+ const sharedRunning = [];
258
+ /** Reserve this call's place in the FIFO: what it must await before
259
+ * running, and the release to call once its handler is done. */
260
+ const reserve = (tool) => {
261
+ const shared = tool?.effects?.concurrency === "shared";
262
+ let release;
263
+ const done = new Promise((res) => {
264
+ release = res;
265
+ });
266
+ // An exclusive invocation waits for the fence AND every shared call
267
+ // already accepted ahead of it — while it runs, it runs alone.
268
+ const wait = shared ? fence : Promise.all([fence, ...sharedRunning]).then(() => undefined);
269
+ if (shared)
270
+ sharedRunning.push(done);
271
+ else {
272
+ fence = done;
273
+ sharedRunning.length = 0;
274
+ }
275
+ return { wait, release };
276
+ };
222
277
  const launch = (call) => {
223
278
  execActive += 1;
279
+ const tool = registry.get(call.name);
280
+ const slot = reserve(tool);
281
+ // EC-1 ③ — this call's place in the ASK order, filled in by the
282
+ // decide chain (call order). `ahead` is the gate of an ask ACCEPTED
283
+ // BEFORE this call; `release` opens this call's own gate for its
284
+ // successors and stays a no-op unless this call asks. Per-call, so
285
+ // two asks in one turn queue honestly instead of sharing one release.
286
+ const askOrder = { ahead: Promise.resolve(), release: () => { } };
224
287
  launches.push((async () => {
225
288
  try {
226
- await acquireWindow();
227
289
  decideChain = decideChain.then(async () => {
228
290
  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);
291
+ askOrder.ahead = askGate;
229
292
  if (v.action === "ask") {
230
293
  askGate = new Promise((res) => {
231
- askRelease = res;
294
+ askOrder.release = res;
232
295
  });
233
296
  }
234
297
  return v;
@@ -240,33 +303,94 @@ export async function* loop(config) {
240
303
  pushExec(verdict.result);
241
304
  return;
242
305
  }
306
+ // the conservative order: the calls AFTER an ask wait for its
307
+ // human resolution. The context may have changed when the
308
+ // human approves — which is why even a precommit-safe read
309
+ // accepted after an ask waits here rather than racing ahead.
310
+ await askOrder.ahead;
311
+ if (violated)
312
+ return;
243
313
  if (verdict.action === "ask") {
244
- // The human pause — abortable by a user abort OR a turn
245
- // void (the violated promise). The gate opens when the
246
- // human decides, whatever the outcome (conservative
247
- // ordering: the successors then proceed with their own
248
- // verdicts).
249
- const decision = await Promise.race([
250
- humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec, verdict.speaker),
251
- violatedP,
252
- ]);
253
- askRelease?.();
254
- askRelease = null;
255
- if (violated)
256
- return;
257
- if (decision.action !== "allow") {
258
- // the reason rides the denial the human's words,
259
- // or the honest "no approval flow configured".
260
- pushExec(resultEvent(call, denialResult(decision.reason ?? "denied")));
261
- return;
314
+ try {
315
+ // EC-1 THE POST-COMMIT ASK. A human must never
316
+ // be asked to approve a call whose turn then proves
317
+ // invalid, so the pause waits for this turn's OWN
318
+ // commit exactly like a handler does. On an
319
+ // uncommitted turn the call bails here having asked
320
+ // NOTHING and started nothing. (Before EC-1 the
321
+ // question was put to a person mid-stream, and a
322
+ // provider that violated the protocol after its stop
323
+ // made that person's "yes" authorize a turn the
324
+ // kernel then voided.)
325
+ await turnSettled;
326
+ if (!committed)
327
+ return;
328
+ // The human pause abortable by a user abort OR a
329
+ // turn void (the violated promise). Post-commit a
330
+ // void can no longer reach this branch; the race
331
+ // stays as the cheap structural guarantee that it
332
+ // never could.
333
+ const decision = await Promise.race([
334
+ humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec, verdict.speaker),
335
+ violatedP,
336
+ ]);
337
+ if (violated)
338
+ return;
339
+ if (decision.action !== "allow") {
340
+ // the reason rides the denial — the human's words,
341
+ // or the honest "no approval flow configured".
342
+ pushExec(resultEvent(call, denialResult(decision.reason ?? "denied")));
343
+ return;
344
+ }
262
345
  }
346
+ finally {
347
+ // The gate opens the moment the human's part is over
348
+ // — whatever the outcome, and on every bail path:
349
+ // successors wait for the VERDICT, never for this
350
+ // call's handler, and a turn that voids before the
351
+ // ask must not strand them.
352
+ askOrder.release();
353
+ }
354
+ }
355
+ else if (tool?.effects?.precommitSafe !== true) {
356
+ // EC-1 ① (invariant 3, COMMIT GATING): the handler waits
357
+ // for this turn's OWN commit. An uncommitted turn never
358
+ // reaches a handler — the call bails here having emitted
359
+ // NO started event, so it is clean, never uncertain
360
+ // (abort semantics). This is the whole of "an invalid
361
+ // turn never starts a commit-required tool handler".
362
+ //
363
+ // EC-1 ③(b) — THE PRECOMMIT LAUNCH RULE is the `else`
364
+ // this branch is guarded by: a call skips the commit
365
+ // gate iff its tool declares `precommitSafe` AND its
366
+ // authorization is ALREADY satisfied — an `allow`
367
+ // verdict, no human in the loop. Both halves are
368
+ // necessary: the certificate says the EXECUTION is
369
+ // harmless (read-only, free, local, universally), never
370
+ // that the authorization is unnecessary. Such a call may
371
+ // run on a turn that later voids; invariant 7 owns that
372
+ // outcome — the receipt is an honest fact and the turn
373
+ // stays uncommitted.
374
+ await turnSettled;
375
+ if (!committed)
376
+ return;
377
+ }
378
+ // EC-1 ③ (invariants 5 + 6): wait behind the FIFO barrier,
379
+ // and only THEN take a window slot. Waiting — for the
380
+ // commit, for a human, or for the barrier — consumes no
381
+ // slot: the window is an EXECUTION window, not a
382
+ // pending-invocation window, so four held writes can never
383
+ // starve a runnable sibling.
384
+ await slot.wait;
385
+ if (violated)
386
+ return;
387
+ await acquireWindow();
388
+ try {
389
+ await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
390
+ }
391
+ finally {
392
+ releaseWindow();
263
393
  }
264
- // the conservative order: the calls AFTER an ask wait for its human
265
- // resolution (the askGate is the ask's pause promise —
266
- // resolved by default, released by the ask branch above).
267
- // The context may have changed when the human approves.
268
- await askGate;
269
- await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
270
394
  }
271
395
  catch (err) {
272
396
  // The abort sentinel (a user cancel during the decide or
@@ -277,7 +401,12 @@ export async function* loop(config) {
277
401
  launchError ??= err;
278
402
  }
279
403
  finally {
280
- releaseWindow();
404
+ // The barrier and the ask gate are released on EVERY exit —
405
+ // a denial, an uncommitted turn, an abort — or the siblings
406
+ // queued behind this call would wait forever. (Releasing an
407
+ // already-open gate is a no-op: a promise resolves once.)
408
+ askOrder.release();
409
+ slot.release();
281
410
  execActive -= 1;
282
411
  }
283
412
  })());
@@ -325,6 +454,25 @@ export async function* loop(config) {
325
454
  // user_input, …) from the stream is a FORGERY and must never reach
326
455
  // the log.
327
456
  let forgedEvent = false;
457
+ // EC-1 ①: a SECOND stop voids the turn — and unlike the pre-EC-1 path
458
+ // (which appended both, leaving two stops in the log forever), NEITHER
459
+ // stop is persisted now. A deliberate improvement of that class.
460
+ let duplicateStop = false;
461
+ // EC-1 ①: the turn's stop, HELD in memory until the stream proves
462
+ // clean. It is neither appended NOR yielded here — yield order must
463
+ // equal append order (persistence-ownership.test.ts), so the hold
464
+ // covers both, and the commit below performs both together.
465
+ let heldStop = null;
466
+ // EC-1 ⑤ (the live void): the last durable event BEFORE this turn's
467
+ // model output — the previous turn's stop, the user input, or a
468
+ // microcompact boundary. Everything after it is this turn's draft,
469
+ // which is exactly the range a void must abandon.
470
+ const turnStart = log.lastSeq;
471
+ // EC-1 ①: this turn's commit gate — reset BEFORE any call can launch.
472
+ committed = false;
473
+ turnSettled = new Promise((res) => {
474
+ settleTurn = res;
475
+ });
328
476
  while (true) {
329
477
  // Area 4: the backoff is abortable — a cancel landing during a
330
478
  // retry wait ends the run now, not after the backoff.
@@ -370,9 +518,24 @@ export async function* loop(config) {
370
518
  break;
371
519
  }
372
520
  if (ev.type === "stop") {
521
+ // EC-1 ① — DURABLE TURN COMMIT. The stop is HELD, not
522
+ // persisted: before EC-1 it was appended the instant it
523
+ // arrived, so a stop could be durable while the stream
524
+ // was still running — and the recovery keyed "this turn
525
+ // committed" on exactly that durable stop. The two
526
+ // truths are now one: a durable compatible stop means
527
+ // THIS producer observed clean stream exhaustion.
528
+ if (sawStop) {
529
+ duplicateStop = true;
530
+ violated = true;
531
+ violatedReject();
532
+ break;
533
+ }
373
534
  sawStop = true;
374
535
  lastStop = ev.reason;
375
536
  stopCount += 1;
537
+ heldStop = ev;
538
+ continue;
376
539
  }
377
540
  // R-E 0.1.43: the append precedes the launch — the call's
378
541
  // framework seq is assigned here; invocationSeq must never
@@ -430,78 +593,98 @@ export async function* loop(config) {
430
593
  error: { code: "invalid_request", retryable: false, message: "provider emitted events after its stop event" },
431
594
  };
432
595
  }
433
- else if (pending.length === 0) {
434
- // Area 6: protocol anomalies are STRUCTURED ERRORS, never a
435
- // default `completed` a stream with no stop, a duplicate stop,
436
- // or a tool_use that never produced a complete call.
437
- if (stopCount === 0) {
438
- voided = {
439
- kind: "error",
440
- error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
441
- };
442
- }
443
- else if (stopCount > 1) {
444
- voided = {
445
- kind: "error",
446
- error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
447
- };
448
- }
449
- else {
450
- yield await terminal(terminalForStop(lastStop));
451
- return;
596
+ else if (duplicateStop) {
597
+ // EC-1 ①: a second stop. The pre-EC-1 path appended BOTH and voided
598
+ // afterwards, so a duplicate-stop turn left two stops in the log
599
+ // forever; now neither is durable the turn simply never commits.
600
+ voided = {
601
+ kind: "error",
602
+ error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount + 1} stop events in one turn` },
603
+ };
604
+ }
605
+ else if (stopCount === 0) {
606
+ // Area 6: protocol anomalies are STRUCTURED ERRORS, never a default
607
+ // `completed`. EC-1 ①: ONE arm now, not two — the pre-EC-1 code
608
+ // duplicated this verdict across the pending/no-pending split, where
609
+ // it never differed.
610
+ voided = {
611
+ kind: "error",
612
+ error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
613
+ };
614
+ }
615
+ else if (pending.length > 0) {
616
+ // C group: STRUCTURAL COMPATIBILITY — a stop reason that cannot
617
+ // carry tool calls voids the turn. EC-1 ①: this is now the second
618
+ // half of the commit condition and runs BEFORE the commit, so an
619
+ // incompatible turn never persists its stop; combined with the
620
+ // commit gate its calls never became effects either (pre-EC-1 they
621
+ // had already launched, and the void could only let their receipts
622
+ // land after the fact).
623
+ switch (lastStop) {
624
+ case "tool_use":
625
+ case "function_call":
626
+ break; // compatible with complete calls — the executions proceed
627
+ case "max_tokens":
628
+ voided = { kind: "max_tokens" };
629
+ break;
630
+ case "abort":
631
+ voided = { kind: "aborted", by: "user" };
632
+ break;
633
+ case "error":
634
+ voided = {
635
+ kind: "error",
636
+ error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
637
+ };
638
+ break;
639
+ default:
640
+ voided = {
641
+ kind: "error",
642
+ error: {
643
+ code: "invalid_request",
644
+ retryable: false,
645
+ message: `provider stopped with '${String(lastStop)}' with ${pending.length} tool call(s) launched`,
646
+ },
647
+ };
648
+ break;
452
649
  }
453
650
  }
454
- // ── Abort check: an abort now abandons the launched executions
455
- // (started without receipt uncertain), exactly as before ──────
456
- if (voided === null && aborted()) {
651
+ // ── EC-1 TURN COMMIT ──────────────────────────────────────────
652
+ // The held stop is persisted HERE and only here: iterator done (the
653
+ // stream loop broke cleanly) AND structurally compatible (above). The
654
+ // append and the yield happen together, in the loop's ordinary order,
655
+ // so the durable log gains no event and loses none — only the MOMENT
656
+ // moves, and the projection is byte-identical. What it buys:
657
+ //
658
+ // a durable compatible stop ⇔ this producer observed clean stream
659
+ // exhaustion.
660
+ //
661
+ // The gate is released either way: a committed turn frees its
662
+ // handlers, a voided turn frees them to bail with no started event.
663
+ // An abort with calls in flight abandons the turn BEFORE it commits:
664
+ // the aborted turn leaves NO durable stop, so its calls stay an
665
+ // UNCOMMITTED DRAFT the resume voids (⑤) rather than a committed batch
666
+ // no one will ever execute — which would strand the model's tool_use
667
+ // blocks with no results (the EC1-F1 dangling-pair class). A turn with
668
+ // NO calls has nothing to abandon and still ends on its own stop
669
+ // reason, the pre-EC-1 order.
670
+ if (voided === null && pending.length > 0 && aborted()) {
671
+ settleTurn();
457
672
  yield await terminal({ kind: "aborted", by: "user" });
458
673
  return;
459
674
  }
460
- // ── C group: the turn is verified. 0.1.26 (streaming execution): the calls were
461
- // ALREADY launched at tool_call_end, so a non-compatible stop reason
462
- // VOIDS the turn instead of preventing the execution.
463
- if (voided === null && pending.length > 0) {
464
- if (stopCount === 0) {
465
- voided = {
466
- kind: "error",
467
- error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
468
- };
469
- }
470
- else if (stopCount > 1) {
471
- voided = {
472
- kind: "error",
473
- error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
474
- };
475
- }
476
- else {
477
- switch (lastStop) {
478
- case "tool_use":
479
- case "function_call":
480
- break; // compatible with complete calls — the executions proceed
481
- case "max_tokens":
482
- voided = { kind: "max_tokens" };
483
- break;
484
- case "abort":
485
- voided = { kind: "aborted", by: "user" };
486
- break;
487
- case "error":
488
- voided = {
489
- kind: "error",
490
- error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
491
- };
492
- break;
493
- default:
494
- voided = {
495
- kind: "error",
496
- error: {
497
- code: "invalid_request",
498
- retryable: false,
499
- message: `provider stopped with '${String(lastStop)}' with ${pending.length} tool call(s) launched`,
500
- },
501
- };
502
- break;
503
- }
504
- }
675
+ if (voided === null && heldStop !== null) {
676
+ const full = log.append(heldStop);
677
+ if (hooks.onEvent)
678
+ await hooks.onEvent(full, {}).catch(() => { });
679
+ committed = true;
680
+ yield full;
681
+ }
682
+ settleTurn();
683
+ // A turn with no tool calls ends on its OWN stop reason (Phase B,
684
+ // Area 6) — never a blanket `completed`. Reached only once committed.
685
+ if (voided === null && pending.length === 0) {
686
+ yield await terminal(terminalForStop(lastStop));
687
+ return;
505
688
  }
506
689
  // ── The turn settles: a void fires the violated signal — the
507
690
  // not-started executions bail (abort semantics — no started, no
@@ -526,6 +709,33 @@ export async function* loop(config) {
526
709
  if (launchError !== null)
527
710
  throw launchError;
528
711
  if (voided !== null) {
712
+ // EC-1 ⑤ — THE LIVE VOID. ① means a voided turn's commit-required
713
+ // call never ran, so nothing answers the `tool_use` its
714
+ // tool_call_end already persisted. The run ends here on its error
715
+ // terminal, and the recovery driver will never see it: its first
716
+ // rule is "the open run reached its terminal". So the NEXT turn of
717
+ // the same session would send the model an assistant tool_use with
718
+ // no result — the provider-400 class, live rather than after a
719
+ // crash. Pre-EC-1 the streaming launch had already answered the
720
+ // pair; closing the destructive hole opened this one.
721
+ //
722
+ // The fix is the instrument the resume already uses, produced here
723
+ // instead: the loop is a SECOND PRODUCER of `model_output_abandoned`
724
+ // (an existing variant — no new protocol surface, the frozen event
725
+ // contract holds). It voids the whole draft range, exactly as
726
+ // ABANDON_DRAFT does, and only when a call is still pure intent —
727
+ // a call with a durable started is a FACT, and the same rule as
728
+ // recovery-plan.ts's `unexecuted`. Idempotent by construction: the
729
+ // marker becomes the last boundary, so no later resume derives a
730
+ // second draft over the same range.
731
+ if (log.all.some((e) => e.type === "tool_call_end" &&
732
+ e.seq > turnStart &&
733
+ !log.all.some((x) => x.type === "tool_execution_started" && x.callId === e.callId))) {
734
+ const marker = log.append({ type: "model_output_abandoned", voidFromSeq: turnStart, reason: "the turn was voided before it committed" });
735
+ if (hooks.onEvent)
736
+ await hooks.onEvent(marker, {}).catch(() => { });
737
+ yield marker;
738
+ }
529
739
  yield await terminal(voided);
530
740
  return;
531
741
  }
@@ -826,7 +1036,7 @@ async function runLedgered(call, registry, hooks, ctx, signal, push) {
826
1036
  // observes ctx.signal, but the gate itself must not invoke it after
827
1037
  // a cancel.
828
1038
  result = signal?.aborted
829
- ? { content: "aborted before execution", isError: true, errorKind: "fatal" }
1039
+ ? { content: "aborted before execution", isError: true, errorKind: "precondition" }
830
1040
  : await tool.execute(call.input, ctx);
831
1041
  }
832
1042
  catch (err) {
@@ -844,8 +1054,10 @@ async function runLedgered(call, registry, hooks, ctx, signal, push) {
844
1054
  // receipt below, losslessly — a crash-window repair of the tool_result
845
1055
  // reproduces the normal path). Idempotent failures carry no note; the
846
1056
  // MCP bridge maps tools without declaring idempotency, so unknown
847
- // idempotency = the note applies (honest).
848
- if (result.isError && tool.idempotent !== true) {
1057
+ // idempotency = the note applies (honest). WR-1-F1: a PRECONDITION
1058
+ // refusal is work refused BEFORE it starts — by the kind's own
1059
+ // contract nothing ran, so the note would be a false statement there.
1060
+ if (result.isError && result.errorKind !== "precondition" && tool.idempotent !== true) {
849
1061
  result = {
850
1062
  ...result,
851
1063
  content: `${result.content}\n[non-idempotent tool failed — its side effects may have partially applied; verify before retrying]`,
@@ -518,9 +518,13 @@ export function projectMessages(events) {
518
518
  // its own range; the case documents the intent (and no flush:
519
519
  // it must never split a message).
520
520
  break;
521
- case "microcompacted":
522
- // handled above (the replacement pass)no open message.
523
- break;
521
+ // EC-1 (ruled in at the checkpoint): a SECOND `case
522
+ // "microcompacted"` sat here and was unreachable the earlier
523
+ // case at the top of this switch owns the label, and in a
524
+ // JavaScript switch the first matching case wins. It never ran,
525
+ // it emitted a build warning ("this case clause will never be
526
+ // evaluated"), and it cost two lines of the kernel's budget.
527
+ // Removed; the microcompact replacement pass above is unchanged.
524
528
  }
525
529
  }
526
530
  flushAssistant();
@@ -11,8 +11,9 @@
11
11
  * Their absence is the honest contract; the concurrency RACE they were
12
12
  * mistaken for a defense against is a real open question and moved to EC-1,
13
13
  * which owes a mechanism that does not depend on a per-tool opt-in.
14
- * Delivery truth is named by the CALLER (governance/delivery.ts's
15
- * `DeliveryConfig.producers`), which is where it always actually lived.
14
+ * Delivery truth is named by the CALLER (`DeliveryConfig.producers`), which
15
+ * is where it always actually lived — and since EC-1 that verdict lives in
16
+ * kiso-evals (governance/delivery.ts there), not in the kernel at all.
16
17
  *
17
18
  * WHY JSON Schema instead of a runtime library: the kernel has zero runtime
18
19
  * dependencies (ADR-0001). Zod / TypeBox / valibot live at the harness layer;
@@ -79,6 +80,38 @@ export interface Tool<I = unknown> {
79
80
  * applies. A retry is a NEW call and re-passes the approval chain.
80
81
  */
81
82
  readonly idempotent?: boolean;
83
+ /**
84
+ * EC-1 — an OPTIMIZATION CERTIFICATE, never a safety claim.
85
+ *
86
+ * Read the type carefully: there is no `"exclusive"`, and there is no
87
+ * `false`. ABSENCE is the conservative truth — an undeclared tool is
88
+ * commit-required and exclusive — so the type system cannot express a
89
+ * claim that something unsafe is safe. Correctness comes from absence;
90
+ * a declaration only buys performance back. That is the whole reason
91
+ * this field is shaped so oddly, and it is the lesson SC-1b paid for:
92
+ * `concurrencySafe` and `delivers` were declarations the kernel never
93
+ * read, so they were fiction. The kernel ENFORCES both of these, in the
94
+ * same round that introduces them.
95
+ *
96
+ * precommitSafe — "running this before the turn commits is harmless:
97
+ * read-only AND free AND local, for EVERY invocation." Only such a
98
+ * call may start before Turn Commit, and only when its authorization
99
+ * is already satisfied (auto-allowed). Its execution never makes an
100
+ * uncommitted turn valid (invariant 7).
101
+ *
102
+ * concurrency: "shared" — "EVERY invocation may overlap every
103
+ * sibling." Without it the kernel serializes the tool behind a FIFO
104
+ * exclusive barrier, which is what closes the same-path write race:
105
+ * two edit_file calls on one path can no longer interleave.
106
+ *
107
+ * Per-call conflict granularity (a resourceKey) stays future and
108
+ * evidence-gated: this field is per-TOOL on purpose, because a per-call
109
+ * claim is exactly the kind the type system could not police.
110
+ */
111
+ readonly effects?: {
112
+ readonly precommitSafe?: true;
113
+ readonly concurrency?: "shared";
114
+ };
82
115
  /** R-C: ONE line for the system prompt — the tool's role, never the
83
116
  * schema (the full description rides the JSON schema the provider
84
117
  * transmits anyway — never pay twice). */
@@ -11,8 +11,9 @@
11
11
  * Their absence is the honest contract; the concurrency RACE they were
12
12
  * mistaken for a defense against is a real open question and moved to EC-1,
13
13
  * which owes a mechanism that does not depend on a per-tool opt-in.
14
- * Delivery truth is named by the CALLER (governance/delivery.ts's
15
- * `DeliveryConfig.producers`), which is where it always actually lived.
14
+ * Delivery truth is named by the CALLER (`DeliveryConfig.producers`), which
15
+ * is where it always actually lived — and since EC-1 that verdict lives in
16
+ * kiso-evals (governance/delivery.ts there), not in the kernel at all.
16
17
  *
17
18
  * WHY JSON Schema instead of a runtime library: the kernel has zero runtime
18
19
  * dependencies (ADR-0001). Zod / TypeBox / valibot live at the harness layer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
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.12.0",
36
+ "@vincemakes/kiso-evals": "0.14.0",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"