@vincemakes/kiso-core 0.11.0 → 0.13.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.
@@ -8,10 +8,21 @@
8
8
  * the same events the loop yielded, so the verdict is replayable and the
9
9
  * model's narration never participates in its own grading.
10
10
  *
11
- * Producers are declared on tools (`delivers`, tools/tool.ts); the verdict
12
- * counts producer calls that completed (non-error results), against a
13
- * delivery claim in the text. The canonical lie "generated-document" with zero
14
- * producer calls and a clean completed terminal fails here.
11
+ * Producers are named by the CALLER, in `DeliveryConfig.producers` the
12
+ * hand-maintained set is the ONLY source of delivery truth. SC-1b removed
13
+ * `Tool.delivers`, the flag this note used to describe as the eventual
14
+ * replacement: it was declared on the contract and read by nothing, here
15
+ * or anywhere. Should a tool's own declaration ever supersede the caller's
16
+ * set, it enters as a new field with a wiring and a gate, never as a
17
+ * standing promise the code has not kept.
18
+ *
19
+ * The verdict counts producer calls that COMPLETED (non-error results).
20
+ * `claimedInText` is computed and REPORTED but does not enter `passed` —
21
+ * a caller that wants "claimed but not delivered" to fail combines the two
22
+ * itself (the evals fixture does). With `required: false`, text claiming
23
+ * delivery over zero producer calls passes here. The canonical lie —
24
+ * "generated-document" with zero producer calls and a clean completed
25
+ * terminal — is caught by that combination.
15
26
  *
16
27
  * In M3.5 the emission side (artifact URLs extracted from results) joins;
17
28
  * today a completed producer IS the emission.
@@ -8,10 +8,21 @@
8
8
  * the same events the loop yielded, so the verdict is replayable and the
9
9
  * model's narration never participates in its own grading.
10
10
  *
11
- * Producers are declared on tools (`delivers`, tools/tool.ts); the verdict
12
- * counts producer calls that completed (non-error results), against a
13
- * delivery claim in the text. The canonical lie "generated-document" with zero
14
- * producer calls and a clean completed terminal fails here.
11
+ * Producers are named by the CALLER, in `DeliveryConfig.producers` the
12
+ * hand-maintained set is the ONLY source of delivery truth. SC-1b removed
13
+ * `Tool.delivers`, the flag this note used to describe as the eventual
14
+ * replacement: it was declared on the contract and read by nothing, here
15
+ * or anywhere. Should a tool's own declaration ever supersede the caller's
16
+ * set, it enters as a new field with a wiring and a gate, never as a
17
+ * standing promise the code has not kept.
18
+ *
19
+ * The verdict counts producer calls that COMPLETED (non-error results).
20
+ * `claimedInText` is computed and REPORTED but does not enter `passed` —
21
+ * a caller that wants "claimed but not delivered" to fail combines the two
22
+ * itself (the evals fixture does). With `required: false`, text claiming
23
+ * delivery over zero producer calls passes here. The canonical lie —
24
+ * "generated-document" with zero producer calls and a clean completed
25
+ * terminal — is caught by that combination.
15
26
  *
16
27
  * In M3.5 the emission side (artifact URLs extracted from results) joins;
17
28
  * today a completed producer IS the emission.
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";
@@ -21,6 +21,8 @@ import type { Message } from "../protocol/messages.js";
21
21
  /**
22
22
  * Rough token estimate (chars/4 + structural overhead). Calibration-free on
23
23
  * purpose: context economy only needs a stable MONOTONE proxy, not an exact
24
- * count — the threshold absorbs the error (mauri ADR-0007).
24
+ * count — the threshold absorbs the error (mauri ADR-0007). The proxy's
25
+ * three-word contract, and the pins that hold it, are
26
+ * packages/core/tests/sc1b-estimator.test.ts.
25
27
  */
26
28
  export declare function estimateTokens(messages: readonly Message[]): number;
@@ -17,16 +17,43 @@
17
17
  * The model-generated half of context economy (the /compact summary layer)
18
18
  * lives in kernel/summarize.ts.
19
19
  */
20
+ /**
21
+ * What a non-text block (an image) contributes. There is no character count
22
+ * to proxy, and this module refuses to guess a provider's image
23
+ * tokenization; the figure exists so a block is never worth ZERO, which is
24
+ * what MONOTONE requires. Deliberately small: under-stating an image is a
25
+ * threshold that fires slightly late, over-stating it is one that fires on
26
+ * a conversation that was never large.
27
+ */
28
+ const NON_TEXT_BLOCK_TOKENS = 8;
29
+ /**
30
+ * chars/4 over a `string | ContentBlock[]` content field.
31
+ *
32
+ * SC-1b ①: the array arm must be summed BLOCK BY BLOCK. Reading `.length`
33
+ * off it yields the block COUNT — a five-block 50 KB message scored ~2
34
+ * tokens, and the live microcompact threshold believed it.
35
+ */
36
+ function contentTokens(content) {
37
+ if (typeof content === "string")
38
+ return Math.ceil(content.length / 4);
39
+ let tokens = 0;
40
+ for (const block of content) {
41
+ tokens += block.type === "text" ? Math.ceil(block.text.length / 4) : NON_TEXT_BLOCK_TOKENS;
42
+ }
43
+ return tokens;
44
+ }
20
45
  /**
21
46
  * Rough token estimate (chars/4 + structural overhead). Calibration-free on
22
47
  * purpose: context economy only needs a stable MONOTONE proxy, not an exact
23
- * count — the threshold absorbs the error (mauri ADR-0007).
48
+ * count — the threshold absorbs the error (mauri ADR-0007). The proxy's
49
+ * three-word contract, and the pins that hold it, are
50
+ * packages/core/tests/sc1b-estimator.test.ts.
24
51
  */
25
52
  export function estimateTokens(messages) {
26
53
  let total = 0;
27
54
  for (const msg of messages) {
28
55
  if (msg.role === "user") {
29
- total += Math.ceil(msg.content.length / 4);
56
+ total += contentTokens(msg.content);
30
57
  }
31
58
  else if (msg.role === "assistant") {
32
59
  for (const block of msg.blocks) {
@@ -37,7 +64,7 @@ export function estimateTokens(messages) {
37
64
  }
38
65
  }
39
66
  else {
40
- total += Math.ceil(msg.content.length / 4) + 10;
67
+ total += contentTokens(msg.content) + 10;
41
68
  }
42
69
  }
43
70
  return total;
@@ -37,7 +37,12 @@ export declare class EventLog {
37
37
  */
38
38
  append(ev: EventInput): Event;
39
39
  get all(): readonly Event[];
40
- /** Incremental view for consumers that already saw `seq` and before. */
40
+ /**
41
+ * Incremental view for consumers that already saw `seq` and before —
42
+ * STRICTLY after, so a poll loop delivers each event exactly once. A
43
+ * consumer that has seen nothing passes -1. (SC-1b ④: this filtered
44
+ * `>= seq` and re-served the seam event on every poll.)
45
+ */
41
46
  since(seq: number): readonly Event[];
42
47
  get lastSeq(): number;
43
48
  }
@@ -41,9 +41,14 @@ export class EventLog {
41
41
  get all() {
42
42
  return this.#events;
43
43
  }
44
- /** Incremental view for consumers that already saw `seq` and before. */
44
+ /**
45
+ * Incremental view for consumers that already saw `seq` and before —
46
+ * STRICTLY after, so a poll loop delivers each event exactly once. A
47
+ * consumer that has seen nothing passes -1. (SC-1b ④: this filtered
48
+ * `>= seq` and re-served the seam event on every poll.)
49
+ */
45
50
  since(seq) {
46
- return this.#events.filter((e) => e.seq >= seq);
51
+ return this.#events.filter((e) => e.seq > seq);
47
52
  }
48
53
  get lastSeq() {
49
54
  return this.#next - 1;
@@ -2,8 +2,8 @@
2
2
  * L2 — the ReAct loop. The kernel's only loop; everything else is harness.
3
3
  *
4
4
  * An async generator that yields every event as it happens (never buffers a
5
- * turn into a list — the agno failure), and converges on exactly one
6
- * `terminal` event per run (ADR-0004).
5
+ * turn into a list — the reference implementation's failure), and converges
6
+ * on exactly one `terminal` event per run (ADR-0004).
7
7
  *
8
8
  * SINGLE TRUTH (Phase B): the loop holds ONE EventLog. Messages are never
9
9
  * stored alongside it — every adapter call derives them via
@@ -100,13 +100,23 @@ export interface LoopConfig {
100
100
  */
101
101
  readonly approvalVerdict?: (decisionId: string) => boolean | undefined;
102
102
  /**
103
- * C group: the channel that resolves a failed NON-idempotent execution.
104
- * The loop persists `uncertain_pending`, yields it, and AWAITS the
105
- * human verdict no next model turn, no sibling tool, no auto-retry.
106
- * Absent, the failure is recorded `abandoned` (never retried).
103
+ * DEAD accepted by the type, never read by this loop.
104
+ *
105
+ * It was the C group channel for the failed-receipt pause: the loop
106
+ * persisted `uncertain_pending` and awaited a human verdict. ADR-0038
107
+ * removed that pause (a complete receipt IS the outcome), and with it
108
+ * every read of this field — a failure is now simply recorded failed and
109
+ * the siblings run on. The runtime still passes both callbacks
110
+ * (runtime/run.ts), so supplying them is harmless and changes nothing.
111
+ *
112
+ * The crash window — started, no receipt — is the ONLY uncertainty left,
113
+ * and the runtime's recovery driver owns it (RESOLVE_UNCERTAIN in
114
+ * runtime/recovery-plan.ts), not the loop. Both fields are kept because
115
+ * the surface is frozen (ADR-0051); do not read them as live wiring.
107
116
  */
108
117
  readonly resolveUncertainty?: (executionId: string) => Promise<"rerun" | "abandoned">;
109
- /** round 4 (adversarial): the uncertainty twin of `approvalVerdict`. */
118
+ /** DEAD, as above the uncertainty twin of `approvalVerdict`
119
+ * (round 4, adversarial). Never read by the loop since ADR-0038. */
110
120
  readonly uncertaintyVerdict?: (executionId: string) => "rerun" | "abandoned" | undefined;
111
121
  /**
112
122
  * E1: the COMPOSED approval chain — the runtime composes the
@@ -2,8 +2,8 @@
2
2
  * L2 — the ReAct loop. The kernel's only loop; everything else is harness.
3
3
  *
4
4
  * An async generator that yields every event as it happens (never buffers a
5
- * turn into a list — the agno failure), and converges on exactly one
6
- * `terminal` event per run (ADR-0004).
5
+ * turn into a list — the reference implementation's failure), and converges
6
+ * on exactly one `terminal` event per run (ADR-0004).
7
7
  *
8
8
  * SINGLE TRUTH (Phase B): the loop holds ONE EventLog. Messages are never
9
9
  * stored alongside it — every adapter call derives them via
@@ -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
  }
@@ -6,21 +6,19 @@
6
6
  * - `visibleToolNames` — physical removal: the registry is subset() to these
7
7
  * tools BEFORE the adapter is called. The model cannot call what it cannot
8
8
  * see; no system-prompt overlay can make that guarantee.
9
- * - `systemOverlay` — appended to the system prompt by the harness (the
10
- * kernel does not compose prompts); kept here because it is part of the
11
- * mode's contract.
12
- * - `permissionDefault`the decision onPreTool falls back to when no hook
13
- * or store decides.
14
- * - `compactionKeepExtra` / `stopPredicate` reserved (ADR-0011); read by
15
- * the harness when the semantics land.
9
+ *
10
+ * That is the whole profile. SC-1b: `systemOverlay`, `permissionDefault`,
11
+ * `compactionKeepExtra`, and `stopPredicate` were REMOVED at 0.12.0 by the
12
+ * SC-1 memo's adjudication all four were declared here and read by
13
+ * nothing, in the kernel or above it. Two of them ("reserved, read by the
14
+ * harness when the semantics land") had been waiting since mauri ADR-0011
15
+ * for semantics that never landed. A field that describes an intention
16
+ * rather than a behavior is a promise the type system makes on the
17
+ * kernel's behalf and the kernel does not keep; the honest profile is the
18
+ * one member that is actually applied.
16
19
  */
17
- import type { PermissionDecision } from "./permission.js";
18
20
  export interface ModeProfile {
19
21
  readonly name: string;
20
- readonly systemOverlay?: string;
21
22
  readonly visibleToolNames?: readonly string[];
22
- readonly permissionDefault?: PermissionDecision;
23
- readonly compactionKeepExtra?: readonly string[];
24
- readonly stopPredicate?: string;
25
23
  }
26
24
  export declare function resolveModeProfile(modes: readonly ModeProfile[] | undefined, name: string | undefined): ModeProfile | undefined;
@@ -6,13 +6,16 @@
6
6
  * - `visibleToolNames` — physical removal: the registry is subset() to these
7
7
  * tools BEFORE the adapter is called. The model cannot call what it cannot
8
8
  * see; no system-prompt overlay can make that guarantee.
9
- * - `systemOverlay` — appended to the system prompt by the harness (the
10
- * kernel does not compose prompts); kept here because it is part of the
11
- * mode's contract.
12
- * - `permissionDefault`the decision onPreTool falls back to when no hook
13
- * or store decides.
14
- * - `compactionKeepExtra` / `stopPredicate` reserved (ADR-0011); read by
15
- * the harness when the semantics land.
9
+ *
10
+ * That is the whole profile. SC-1b: `systemOverlay`, `permissionDefault`,
11
+ * `compactionKeepExtra`, and `stopPredicate` were REMOVED at 0.12.0 by the
12
+ * SC-1 memo's adjudication all four were declared here and read by
13
+ * nothing, in the kernel or above it. Two of them ("reserved, read by the
14
+ * harness when the semantics land") had been waiting since mauri ADR-0011
15
+ * for semantics that never landed. A field that describes an intention
16
+ * rather than a behavior is a promise the type system makes on the
17
+ * kernel's behalf and the kernel does not keep; the honest profile is the
18
+ * one member that is actually applied.
16
19
  */
17
20
  export function resolveModeProfile(modes, name) {
18
21
  if (!name || !modes)
@@ -5,9 +5,18 @@
5
5
  * allow one call, deny with a reason fed back to the model, or defer to a
6
6
  * human. The kernel's contract is only the decision shape; where decisions
7
7
  * are stored (accept-for-session) is harness territory (PermissionStore,
8
- * M2). M1 treats `defer` as a deny with reason "awaiting user" — the model
9
- * sees the refusal and can adjust; the human-in-the-loop wiring arrives
10
- * with the harness.
8
+ * M2).
9
+ *
10
+ * `defer` IS A REAL PAUSE. The M1 note that once lived here — "defer is a
11
+ * deny with reason 'awaiting user'; the human-in-the-loop wiring arrives
12
+ * with the harness" — described the behavior ADR-0024 decision #3 replaced,
13
+ * and that ADR's own Context names this note as the thing it fixed (a
14
+ * denial "fakes the pause": the model sees a refusal and adjusts while no
15
+ * human ever decided). Shipped now: the loop persists `permission_requested`
16
+ * and AWAITS the approval channel in the same run frame, the human's
17
+ * verdict is persisted as `permission_decided`, and the pause survives a
18
+ * crash (kernel/loop.ts). With no approval channel configured the deferral
19
+ * degrades to an HONEST denial that says so.
11
20
  */
12
21
  export type PermissionDecision = {
13
22
  readonly action: "allow";
@@ -5,9 +5,18 @@
5
5
  * allow one call, deny with a reason fed back to the model, or defer to a
6
6
  * human. The kernel's contract is only the decision shape; where decisions
7
7
  * are stored (accept-for-session) is harness territory (PermissionStore,
8
- * M2). M1 treats `defer` as a deny with reason "awaiting user" — the model
9
- * sees the refusal and can adjust; the human-in-the-loop wiring arrives
10
- * with the harness.
8
+ * M2).
9
+ *
10
+ * `defer` IS A REAL PAUSE. The M1 note that once lived here — "defer is a
11
+ * deny with reason 'awaiting user'; the human-in-the-loop wiring arrives
12
+ * with the harness" — described the behavior ADR-0024 decision #3 replaced,
13
+ * and that ADR's own Context names this note as the thing it fixed (a
14
+ * denial "fakes the pause": the model sees a refusal and adjusts while no
15
+ * human ever decided). Shipped now: the loop persists `permission_requested`
16
+ * and AWAITS the approval channel in the same run frame, the human's
17
+ * verdict is persisted as `permission_decided`, and the pause survives a
18
+ * crash (kernel/loop.ts). With no approval channel configured the deferral
19
+ * degrades to an HONEST denial that says so.
11
20
  */
12
21
  /** The denial a tool result carries when a call was refused pre-flight. */
13
22
  export function denialResult(reason) {
@@ -19,8 +19,10 @@
19
19
  * re-runs the compaction algorithm (a future version could differ, A group/D
20
20
  * group); `microcompacted` boundaries re-derive the cleared view from the
21
21
  * stream itself (deterministic and idempotent); `summarized` (ADR-0044)
22
- * replaces its covered range with one assistant summary message. All
23
- * three are persisted factsthe replay equals the live run. See ADR-0002.
22
+ * replaces its covered range with one USER message carrying the summary
23
+ * behind SUMMARY_FRAMING (E6's boundary honesty see that constant
24
+ * below; it is deliberately NOT an assistant message). All three are
25
+ * persisted facts — the replay equals the live run. See ADR-0002.
24
26
  */
25
27
  import type { Event } from "../protocol/events.js";
26
28
  import type { EventInput } from "./event-log.js";
@@ -19,8 +19,10 @@
19
19
  * re-runs the compaction algorithm (a future version could differ, A group/D
20
20
  * group); `microcompacted` boundaries re-derive the cleared view from the
21
21
  * stream itself (deterministic and idempotent); `summarized` (ADR-0044)
22
- * replaces its covered range with one assistant summary message. All
23
- * three are persisted factsthe replay equals the live run. See ADR-0002.
22
+ * replaces its covered range with one USER message carrying the summary
23
+ * behind SUMMARY_FRAMING (E6's boundary honesty see that constant
24
+ * below; it is deliberately NOT an assistant message). All three are
25
+ * persisted facts — the replay equals the live run. See ADR-0002.
24
26
  */
25
27
  /**
26
28
  * C area: tools whose output is eligible for microcompact clearing — reads,
@@ -516,9 +518,13 @@ export function projectMessages(events) {
516
518
  // its own range; the case documents the intent (and no flush:
517
519
  // it must never split a message).
518
520
  break;
519
- case "microcompacted":
520
- // handled above (the replacement pass)no open message.
521
- 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.
522
528
  }
523
529
  }
524
530
  flushAssistant();
@@ -8,9 +8,9 @@
8
8
  * events carry `seq`). The three properties that let a ReAct loop stream
9
9
  * without buffering and without losing its place.
10
10
  *
11
- * WHY not a `Promise<Event[]>`: a buffered adapter is what made agno's
12
- * streaming a second-class feature bolted onto a batch loop. The contract
13
- * shape IS the architecture — see ADR-0001.
11
+ * WHY not a `Promise<Event[]>`: a buffered adapter is what made the
12
+ * reference implementation's streaming a second-class feature bolted onto
13
+ * a batch loop. The contract shape IS the architecture — see ADR-0001.
14
14
  *
15
15
  * Adapters translate provider wire events INTO `Event`. They never see tool
16
16
  * handlers (only `ToolSpec` projections) — the kernel is the only thing that
@@ -8,9 +8,9 @@
8
8
  * events carry `seq`). The three properties that let a ReAct loop stream
9
9
  * without buffering and without losing its place.
10
10
  *
11
- * WHY not a `Promise<Event[]>`: a buffered adapter is what made agno's
12
- * streaming a second-class feature bolted onto a batch loop. The contract
13
- * shape IS the architecture — see ADR-0001.
11
+ * WHY not a `Promise<Event[]>`: a buffered adapter is what made the
12
+ * reference implementation's streaming a second-class feature bolted onto
13
+ * a batch loop. The contract shape IS the architecture — see ADR-0001.
14
14
  *
15
15
  * Adapters translate provider wire events INTO `Event`. They never see tool
16
16
  * handlers (only `ToolSpec` projections) — the kernel is the only thing that
@@ -220,10 +220,17 @@ export interface ToolExecutionSucceeded {
220
220
  readonly tags?: readonly string[];
221
221
  }
222
222
  /**
223
- * The side effect ran and FAILED. `safeToRetry` is the tool's own proof
224
- * (declared idempotent): only then is a failure a clean "failed"; a
225
- * non-idempotent failure may have produced a side effect and is UNCERTAIN
226
- * until a human decides (Area 3).
223
+ * The side effect ran and FAILED. A complete receipt IS the outcome, so this
224
+ * execution is "failed" NEVER "uncertain", whatever `safeToRetry` says
225
+ * (ADR-0038 superseding ADR-0025 decision #3: uncertainty belongs to the
226
+ * crash window alone started, no receipt). The run does not pause here;
227
+ * siblings continue and the model retries with the error in hand, and that
228
+ * retry is a NEW call that re-passes the approval chain.
229
+ *
230
+ * `safeToRetry` is the tool's own `idempotent: true` declaration, carried
231
+ * for HISTORY: since ADR-0038 it no longer feeds the ledger's status
232
+ * derivation (runtime/ledger.ts). Its one live consequence is the honest
233
+ * note appended to a non-idempotent failure's result (ADR-0038 Amendment 1).
227
234
  */
228
235
  export interface ToolExecutionFailed {
229
236
  readonly seq: number;
@@ -294,11 +301,20 @@ export interface PermissionExpired {
294
301
  readonly reason: string;
295
302
  }
296
303
  /**
297
- * A non-idempotent execution FAILED and the run PAUSES until a human
298
- * decides (C group): no next model turn, no sibling tool, no auto-retry. The
299
- * verdict is recorded by the session (resolveUncertain) and the ledger
300
- * transitions uncertain rerun/abandoned; the event itself is the durable
301
- * pause marker.
304
+ * HISTORICAL the current kernel NEVER emits this event.
305
+ *
306
+ * It marked the C group's failed-receipt pause: a non-idempotent execution
307
+ * that FAILED paused the run until a human ruled. ADR-0038 removed that
308
+ * pause (a complete receipt IS the outcome), so nothing appends this any
309
+ * more — pinned by `packages/core/tests/execution-gate.test.ts` and
310
+ * `packages/runtime/tests/execution.test.ts`, which assert its ABSENCE.
311
+ *
312
+ * The variant stays because the durable contract is frozen (ADR-0051) and
313
+ * old logs replay theirs verbatim (ADR-0038, Consequences): the projection
314
+ * renders nothing for it, and the ledger reports those receipted executions
315
+ * as "failed". The crash window — started, no receipt — is now the only
316
+ * source of uncertainty, and it is resolved through
317
+ * `uncertainExecutions()` / `resolveUncertain()`, not through this event.
302
318
  */
303
319
  export interface UncertainPending {
304
320
  readonly seq: number;
@@ -342,9 +358,11 @@ export interface MicroCompactEvent {
342
358
  * range. `coversToSeq` is the seq of the LAST covered event; the covered
343
359
  * range runs from just past the previous `summarized` event's coversToSeq
344
360
  * (or the trajectory's start for the first) up to coversToSeq. The
345
- * projection replaces exactly those events with ONE assistant summary
346
- * message (`summary`); every `summarized` event always renders its own
347
- * message. Byte-stable: a summarized event is a persisted fact, so the
361
+ * projection replaces exactly those events with ONE USER message carrying
362
+ * the summary behind a fixed framing line (E6's boundary honesty: the
363
+ * model reads compressed history as CONTEXT, never as a reply it produced
364
+ * — see SUMMARY_FRAMING in kernel/project.ts). It is not an assistant
365
+ * message; every `summarized` event always renders its own message. Byte-stable: a summarized event is a persisted fact, so the
348
366
  * same events derive the same messages on every replay.
349
367
  *
350
368
  * The summary is generated OFF-LOOP through the session's own adapter —
@@ -83,7 +83,7 @@ export interface KisoExtension {
83
83
  * E2: EXTEND the system prompt — append-only, never replace (a replace
84
84
  * is a footgun; appends guarantee "adding an extension never removes
85
85
  * existing guidance" — the monotonicity family of the approval chain's
86
- * deny>ask>allow and the veto short-circuit). The session's own
86
+ * deny > allow > ask and the veto short-circuit). The session's own
87
87
  * systemPrompt comes first, then each extension's append in load order,
88
88
  * \n\n-joined.
89
89
  */
@@ -10,7 +10,12 @@
10
10
  * carries images. Forcing every caller into the array shape to serve the
11
11
  * 5% that need vision is a tax paid on every line of product code.
12
12
  *
13
- * See ADR-0003 (sum type) and ADR-0008 (tool contract).
13
+ * See ADR-0003 (sum type). The tool contract's record is mauri ADR-0008
14
+ * the PREDECESSOR series, cited the same way in kernel/hooks.ts and
15
+ * kernel/mode.ts: kiso's own numbers 0006-0019 were NEVER assigned
16
+ * (docs/adrs/README.md), so an unprefixed "ADR-0008" sends a reader to a
17
+ * record that does not exist in this repo. The live tool contract is
18
+ * tools/tool.ts itself.
14
19
  *
15
20
  * This module is types-only: it compiles to nothing.
16
21
  */
@@ -107,8 +112,12 @@ export type Message = UserMessage | AssistantMessage | ToolResultMessage;
107
112
  /**
108
113
  * What the adapter advertises to the model.
109
114
  *
110
- * Deliberately minimal: the full tool (handler, repair, concurrency, dedup)
111
- * is L3-internal and an adapter must never see it. An adapter that can reach
115
+ * Deliberately minimal: the full tool (the handler above all, plus the
116
+ * declarations the kernel keeps to itself) is L3-internal and an adapter
117
+ * must never see it. NB the historical list here named a `repair` member
118
+ * and a `dedup` member — neither exists on `Tool`: the (name, input) dedup
119
+ * guard was removed by ADR-0025, and repair means RECEIPT repair, which is
120
+ * the runtime's, not a tool's. An adapter that can reach
112
121
  * the handler will eventually call it, and then the kernel is no longer the
113
122
  * only thing that runs tools.
114
123
  */
@@ -10,7 +10,12 @@
10
10
  * carries images. Forcing every caller into the array shape to serve the
11
11
  * 5% that need vision is a tax paid on every line of product code.
12
12
  *
13
- * See ADR-0003 (sum type) and ADR-0008 (tool contract).
13
+ * See ADR-0003 (sum type). The tool contract's record is mauri ADR-0008
14
+ * the PREDECESSOR series, cited the same way in kernel/hooks.ts and
15
+ * kernel/mode.ts: kiso's own numbers 0006-0019 were NEVER assigned
16
+ * (docs/adrs/README.md), so an unprefixed "ADR-0008" sends a reader to a
17
+ * record that does not exist in this repo. The live tool contract is
18
+ * tools/tool.ts itself.
14
19
  *
15
20
  * This module is types-only: it compiles to nothing.
16
21
  */
@@ -4,9 +4,16 @@
4
4
  * A tool is a pure declaration + handler pair. The kernel never branches on
5
5
  * a tool's internals: `parameters` is a JSON Schema the kernel validates and
6
6
  * projects to the adapter as `ToolSpec` (adapter never sees the handler —
7
- * see ADR-0001), `concurrencySafe` decides batch scheduling, `delivers`
8
- * marks a tool as an artifact producer (consumed by harness-side delivery
9
- * tracking; the kernel only carries the flag).
7
+ * see ADR-0001).
8
+ *
9
+ * SC-1b: `concurrencySafe` and `delivers` were REMOVED at 0.12.0 by the
10
+ * SC-1 memo's adjudication — both were declared and consulted by nothing.
11
+ * Their absence is the honest contract; the concurrency RACE they were
12
+ * mistaken for a defense against is a real open question and moved to EC-1,
13
+ * which owes a mechanism that does not depend on a per-tool opt-in.
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.
10
17
  *
11
18
  * WHY JSON Schema instead of a runtime library: the kernel has zero runtime
12
19
  * dependencies (ADR-0001). Zod / TypeBox / valibot live at the harness layer;
@@ -49,24 +56,62 @@ export interface Tool<I = unknown> {
49
56
  /** JSON Schema (draft-07 subset). Validated before execute. */
50
57
  readonly parameters: Readonly<Record<string, unknown>>;
51
58
  /**
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
- * input and must be serial for another (generate_image with
55
- * `chain_to_previous`). Absent = safe when true-ish; see ADR-0015.
59
+ * The tool's own declaration that REPEATING its side effect is safe
60
+ * (reads, searches, pure computations).
61
+ *
62
+ * It is NOT a dedup guard. The kernel runs every logical call, including
63
+ * one whose (name, input) is identical to an earlier one — ADR-0024
64
+ * decision #2's (name, input) guard was REMOVED by ADR-0025 decision #1
65
+ * ("NO (name, input) dedup", kernel/loop.ts). Exactly-once is enforced
66
+ * by execution IDENTITY instead: a confirmed success is never
67
+ * re-executed (a lost model-facing result is repaired FROM the durable
68
+ * receipt), and an execution that STARTED without reporting — the crash
69
+ * window — waits for a human verdict. A FAILED execution is failed, not
70
+ * uncertain: a complete receipt IS the outcome (ADR-0038).
71
+ *
72
+ * What this flag actually decides, and nothing more:
73
+ * - a failure from a tool that did NOT declare it carries the honest
74
+ * "side effects may have partially applied; verify before retrying"
75
+ * note on the result (ADR-0038 Amendment 1);
76
+ * - it rides the durable receipt as `tool_execution_failed.safeToRetry`
77
+ * — history only, since ADR-0038 stopped it feeding ledger status.
78
+ *
79
+ * Undeclared is the safe side: unknown idempotency means the note
80
+ * applies. A retry is a NEW call and re-passes the approval chain.
56
81
  */
57
- readonly concurrencySafe?: (input: I) => boolean;
58
- /** Marks this tool as an artifact producer (harness-side delivery truth). */
59
- readonly delivers?: {
60
- readonly kind: string;
61
- };
82
+ readonly idempotent?: boolean;
62
83
  /**
63
- * Exactly-once guard escape hatch (Phase D): a tool whose side effects
64
- * are safe to repeat (reads, searches, pure computations) declares
65
- * `idempotent: true`. Without it, the kernel refuses to run the same
66
- * tool+input twice in a sessiona confirmed success is replayed, an
67
- * interrupted attempt blocks. The default is the safe side.
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.
68
110
  */
69
- readonly idempotent?: boolean;
111
+ readonly effects?: {
112
+ readonly precommitSafe?: true;
113
+ readonly concurrency?: "shared";
114
+ };
70
115
  /** R-C: ONE line for the system prompt — the tool's role, never the
71
116
  * schema (the full description rides the JSON schema the provider
72
117
  * transmits anyway — never pay twice). */
@@ -4,9 +4,16 @@
4
4
  * A tool is a pure declaration + handler pair. The kernel never branches on
5
5
  * a tool's internals: `parameters` is a JSON Schema the kernel validates and
6
6
  * projects to the adapter as `ToolSpec` (adapter never sees the handler —
7
- * see ADR-0001), `concurrencySafe` decides batch scheduling, `delivers`
8
- * marks a tool as an artifact producer (consumed by harness-side delivery
9
- * tracking; the kernel only carries the flag).
7
+ * see ADR-0001).
8
+ *
9
+ * SC-1b: `concurrencySafe` and `delivers` were REMOVED at 0.12.0 by the
10
+ * SC-1 memo's adjudication — both were declared and consulted by nothing.
11
+ * Their absence is the honest contract; the concurrency RACE they were
12
+ * mistaken for a defense against is a real open question and moved to EC-1,
13
+ * which owes a mechanism that does not depend on a per-tool opt-in.
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.
10
17
  *
11
18
  * WHY JSON Schema instead of a runtime library: the kernel has zero runtime
12
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.11.0",
3
+ "version": "0.13.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.11.0",
36
+ "@vincemakes/kiso-evals": "0.13.0",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"