babavoss 0.12.4 → 0.12.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babavoss",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
4
4
  "license": "Apache-2.0",
5
5
  "repository": "github:alanremarc/babavoss",
6
6
  "homepage": "https://babavoss.org",
@@ -35,9 +35,9 @@
35
35
  "scripts": {
36
36
  "voss": "bun bin/voss.ts",
37
37
  "test": "bun test",
38
- "check": "bunx tsc --noEmit -p ../..",
39
- "prepack": "bun ../../tools/release/pack.ts prepack",
40
- "postpack": "bun ../../tools/release/pack.ts postpack"
38
+ "test:kernel": "VOSS_KERNEL=1 bun test ./test/kernel.test.ts",
39
+ "test:all": "VOSS_KERNEL=1 bun test",
40
+ "check": "bunx tsc --noEmit -p ../.."
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react-dom": "19"
@@ -155,12 +155,16 @@ export class Runtime {
155
155
  * Runs an action now, as its system, over the state as it is: the round's and
156
156
  * the testbed's. `args` are already checked; an `s.entity(component)` field
157
157
  * is resolved from a key and checked to carry its component first. All or
158
- * nothing: a throw or a bad result undoes every write it made.
158
+ * nothing: a throw or a bad result undoes every write it made. With an
159
+ * idempotency `key` the state remembers, the answer is the one remembered
160
+ * and the action does not run; a new key remembers the answer it gets.
159
161
  */
160
- act(name: string, args: unknown): unknown {
162
+ act(name: string, args: unknown, key?: string): unknown {
161
163
  const d = this.baba.actions[name];
162
164
  if (!d) throw new ContractError("noaction", `no action ${name}; actions: ${Object.keys(this.baba.actions).join(", ")}`);
163
165
  const w = this.w;
166
+ const kept = key === undefined ? undefined : w.recall(key);
167
+ if (kept) return kept.value;
164
168
  args = this.entities(d.args.json, args);
165
169
  return w.transaction(() => {
166
170
  const was = w.writer;
@@ -171,7 +175,10 @@ export class Runtime {
171
175
  throw new ContractError("failed", `${name}: ${e instanceof Error ? e.message : String(e)}`);
172
176
  } finally { w.writer = was; }
173
177
  if (r === undefined) r = null;
174
- try { return d.result.check(r); } catch (e) { throw new ContractError("result", `${name} returned a bad result: ${e instanceof SchemaError ? e.message : String(e)}`); }
178
+ let v: unknown;
179
+ try { v = d.result.check(r); } catch (e) { throw new ContractError("result", `${name} returned a bad result: ${e instanceof SchemaError ? e.message : String(e)}`); }
180
+ if (key !== undefined) w.remember(key, v);
181
+ return v;
175
182
  });
176
183
  }
177
184
 
@@ -369,13 +376,7 @@ export class Runtime {
369
376
  // The inbox: actions run as their system, raw writes happen, answers land.
370
377
  for (const i of this.inbox.splice(0)) {
371
378
  if (i.kind === "fire") {
372
- const kept = i.key === undefined ? undefined : w.recall(i.key);
373
- if (kept) { i.resolve(kept.value); continue; }
374
- try {
375
- const v = this.act(i.name, i.args);
376
- if (i.key !== undefined) w.remember(i.key, v);
377
- i.resolve(v);
378
- } catch (err) { i.reject(err instanceof Error ? err : new Error(String(err))); }
379
+ try { i.resolve(this.act(i.name, i.args, i.key)); } catch (err) { i.reject(err instanceof Error ? err : new Error(String(err))); }
379
380
  } else if (i.kind === "raw") {
380
381
  try { i.resolve(w.transaction(() => this.apply(i.raw))); } catch (err) { i.reject(err instanceof Error ? err : new Error(String(err))); }
381
382
  } else if (i.kind === "batch") {
@@ -502,7 +503,13 @@ export class Runtime {
502
503
  }
503
504
  }
504
505
 
505
- /** Runs a job against its adapter: a request's reply lands after its delay; a source's listing at once, its events at their moments, listed again every `relist`. */
506
+ /**
507
+ * Runs a job against its adapter: a request's reply lands after its delay; a
508
+ * source's listing at once, its events at their moments. As the driver has
509
+ * it: a poll (a list and no watch) is done with its listing, and `every`
510
+ * runs it again on the schedule; a watched source with a list lists again
511
+ * every `relist` of the adapter, or every `every` of the effect.
512
+ */
506
513
  private adapt(job: Job, adapter: Adapter, ctl: AbortController): void {
507
514
  const { effect, entity, args, instance } = job;
508
515
  const signal = ctl.signal;
@@ -518,9 +525,11 @@ export class Runtime {
518
525
  }
519
526
  if (effect.shape !== "source") { this.answer(effect, entity, instance, false, `${effect.name} is a request; its adapter is a source`); return; }
520
527
  job.phase = "list";
521
- const list = () => { try { this.deliver(effect, entity, instance, { listed: adapter.listing(args, { ...ctx, now: this.sched.now() }) }); } catch (e) { this.answer(effect, entity, instance, false, e instanceof Error ? e.message : String(e)); } };
528
+ const list = (): boolean => { try { this.deliver(effect, entity, instance, { listed: adapter.listing(args, { ...ctx, now: this.sched.now() }) }); return true; } catch (e) { this.answer(effect, entity, instance, false, e instanceof Error ? e.message : String(e)); return false; } };
529
+ if (effect.list && !effect.watch) { at(0, () => { if (list()) this.answer(effect, entity, instance, true, undefined); }); return; }
522
530
  if (effect.list) { at(0, list); this.relisters.set(instance, () => at(0, list)); }
523
- if (adapter.relist !== null && effect.list) { const again = () => at(adapter.relist!, () => { list(); again(); }); again(); }
531
+ const every = adapter.relist ?? effect.every;
532
+ if (every !== null && effect.list) { const again = () => at(every, () => { list(); again(); }); again(); }
524
533
  if (effect.watch) { job.phase = "watch"; for (const ev of adapter.events(args, ctx)) at(ev.at, () => { this.lastEvent.set(jobKey(effect, entity), ev.event); this.deliver(effect, entity, instance, { events: [ev.event] }); }); }
525
534
  } catch (e) { this.answer(effect, entity, instance, false, e instanceof Error ? e.message : String(e)); }
526
535
  }
@@ -17,11 +17,14 @@ type ActOf<V> = IsAny<V> extends true ? Record<string, { args: any; result: any
17
17
  type ReadOf<V> = IsAny<V> extends true ? Record<string, { args: any; result: any }> : ContractReads<V>;
18
18
  type ArgsOf<D> = D extends { args: infer A } ? ({} extends A ? [args?: A] : [args: A]) : [];
19
19
  type ResultOf<D> = D extends { result: infer R } ? R : never;
20
+ /** How a fire is made: with an idempotency `key`, the answer remembered under it is answered again and the action does not run. */
21
+ export interface Fired { key?: string }
22
+ type FireOf<D> = D extends { args: infer A } ? ({} extends A ? [args?: A, o?: Fired] : [args: A, o?: Fired]) : [args?: Record<string, never>, o?: Fired];
20
23
 
21
24
  export interface Testbed<V> {
22
25
  readonly w: State<V>;
23
- /** Runs the action now, as its system, over the state as it is; what it returned. */
24
- fire<K extends keyof ActOf<V> & string>(name: K, ...args: ArgsOf<ActOf<V>[K]>): ResultOf<ActOf<V>[K]>;
26
+ /** Runs the action now, as its system, over the state as it is; what it returned. With `{ key }`, as `--key` at the shell: the answer remembered under it, without running again. */
27
+ fire<K extends keyof ActOf<V> & string>(name: K, ...args: FireOf<ActOf<V>[K]>): ResultOf<ActOf<V>[K]>;
25
28
  /** The clock to `now` (the last time plus one by default), every round due on the way, then one round at `now`. */
26
29
  tick(at?: { now: number }): void;
27
30
  /** The clock to `to`: every round due on the way, and nothing more. What a live driver calls as real time passes. */
@@ -58,10 +61,10 @@ export function testbed<V extends Baba | System>(v: V, at: { now?: number; seed?
58
61
  });
59
62
  return {
60
63
  w: rt.w as State<V>,
61
- fire: ((name: string, args?: unknown) => {
64
+ fire: ((name: string, args?: unknown, o?: Fired) => {
62
65
  const d = rt.baba.actions[name];
63
66
  if (!d) throw new Error(`no action ${name}`);
64
- return rt.act(name, d.args.check(args ?? {}));
67
+ return rt.act(name, d.args.check(args ?? {}), o?.key);
65
68
  }) as never,
66
69
  // The clock to `to`, the round, then whatever the round's jobs answered at once: an adapter's reply with no delay lands before the hand looks.
67
70
  tick(t) { const to = t?.now ?? clock.now() + 1; clock.advance(to); rt.step(to); clock.flush(); },
@@ -1,4 +1,4 @@
1
1
  // The commit and the moment a package was built from, stamped by the pack
2
2
  // of a release (tools/release/pack.ts) and restored after it. From a
3
3
  // checkout both are empty: the tree is the build.
4
- export const build = {"sha":"f1afec7","at":"2026-10-08T21:00:04.416Z"};
4
+ export const build = { sha: "", at: "" };
@@ -48,6 +48,7 @@ An adapter plays an effect, by the effect itself: `[pond.weather, after(300, ok(
48
48
 
49
49
  - Requests: `ok(value)`, `fail(error)`, `wait()`, `after(ms, a)`, `sequence([…])` (successive jobs in turn, the last for every job after), `match(where, a, otherwise)`, `model(initial, (state, args) => [state, answer])` for an outside with memory, `custom((args, ctx) => answer)`, `recorded(answer)`.
50
50
  - Sources: `listing(items)` (or a function of the args and the adapter's `ctx.state`) with optional `events` and `relist`, and `events([{ at, event }])` for a stream.
51
+ - A played poll (a `list` with `every` and no `watch`) is done the round its listing lands and runs again on its `every`, as the real driver has it; a watched source with a `list` lists again on its `every` too. A listing a scenario changes by hand belongs to a quiet source: list it in the setup, then again when the scenario says.
51
52
  - An adapter's state is the spec's: seeded with it, reset with it, shown in the Maker. Keep state in `model` or `ctx.state`, never in a closure.
52
53
  - An effect the spec plays no adapter for is **quiet**: its job is never started, so it waits, pending, like an outside that has not answered yet. A script answers it by hand: `done(effect, result, where?)`, `failed(effect, error, where?)`, `list(source, items)`, `watch(source, events)`; `where` picks the jobs by their args, or `{ entity }`; every job of the effect without it. A quiet effect is nothing faked: a quiet mirror keeps what was listed by hand. Waiting jobs are in `t.jobs` (`t.pending` stays empty in a spec). A verdict names the quiet effects a scenario's jobs asked of: what an adapter would have to play.
53
54
 
@@ -56,10 +57,11 @@ An adapter plays an effect, by the effect itself: `[pond.weather, after(300, ok(
56
57
  A scenario is `{ name, intent, params?, script }`, run from the spec's setup. `script(words, params)` answers the lines, in the testbed's words:
57
58
 
58
59
  - `given(label, parts | (t) => …)`: a precondition the contract cannot express; parts by component name spawn one entity, `given("one fish", { fish: { x: 100, y: 100, hunger: 0 } })`.
59
- - `fire(action, args)`: an action, now; `answer(0, "entity")` is an earlier fire's answer in a later fire's args.
60
+ - `fire(action, args, { key }?)`: an action, now; `answer(0, "entity")` is an earlier fire's answer in a later fire's args; `key` is the idempotency key, as `--key` at the shell.
60
61
  - `tick({ after | at })`, `ticks(count, every)`: rounds on the spec's clock.
61
62
  - `done`, `failed`, `list`, `watch`: the outside answering, landing at the next tick.
62
63
  - `check(label, (t) => read, want?)`: deep-equal to `want`, or true. A failed check records and the run goes on; a step that throws stops it.
64
+ - `want` may be a function of the testbed, `(t) => value`: read when the check runs and recorded as the value it gave, so a check can want what a `given` captured. A fire that must be refused is a check whose read catches the throw: `check("refused", (t) => { try { t.fire(…); return false; } catch { return true; } })`.
63
65
 
64
66
  Everything runs on the spec's own clock: an adapter's delay, a retry, a schedule and the baba's tick are due moments on it. Headless the clock jumps; live it follows real time at the speed chosen. The state lands the same either way; `w.random()` is seeded, so a run repeats.
65
67
 
@@ -79,11 +79,12 @@ export default [
79
79
  - Read the state in promptware: it is static, rendered from the manifest once per generation.
80
80
  - Explain why in a step; put reasons in the context if they are needed at all.
81
81
  - Write `simply`, `just`, `easily`, `should`, or a synonym for a term that has one name: system, app, spec, step, state, round, action, query, effect, source, mirror, context, skill, hook, baba, voss.
82
- - Edit `CLAUDE.md`, `AGENTS.md` or a `SKILL.md`: they are outputs.
82
+ - Edit `CLAUDE.md`, `AGENTS.md`, a `SKILL.md` or `.claude/settings.local.json`: they are outputs. Git keeps none of them: voss writes its lines of the project's `.gitignore`, and a checkout makes its own with `voss baba promptware sync`.
83
+ - Commit an output. A hand-written `.claude/settings.json` beside the made `settings.local.json` is the place for what the repository itself tells Claude Code, a `SessionStart` hook that runs the sync, say.
83
84
 
84
85
  ## Hooks
85
86
 
86
- A hook hands a harness moment to an action: `hook({ on, match?, action, strict? })` with `on` one of PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, PreCompact, Notification, and `match` a tool-name regex for the two tool events. The action takes `hookArgs(EVENT)` and answers `hookResult()`: `{ decision: "block", reason }` stops the tool call and the agent reads the reason; `{ context }` is told to the agent; `{}` lets it through. Voss compiles it into `.claude/settings.json` as `voss baba ACTION @- --hook EVENT`; a baba that is down lets through with a note, `strict: true` blocks. Prove a hook with a scenario: `fire` the action with an event, `check` the decision.
87
+ A hook hands a harness moment to an action: `hook({ on, match?, action, strict? })` with `on` one of PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, PreCompact, Notification, and `match` a tool-name regex for the two tool events. The action takes `hookArgs(EVENT)` and answers `hookResult()`: `{ decision: "block", reason }` stops the tool call and the agent reads the reason; `{ context }` is told to the agent; `{}` lets it through. Voss compiles it into `.claude/settings.local.json` as `voss baba ACTION @- --hook EVENT`; a baba that is down lets through with a note, `strict: true` blocks. Prove a hook with a scenario: `fire` the action with an event, `check` the decision.
87
88
 
88
89
  ## Voice
89
90
 
@@ -2,7 +2,7 @@
2
2
  // context and skills rendered and its harness declaration, becomes what
3
3
  // each output path, relative to the project, should hold.
4
4
  //
5
- // Claude Code CLAUDE.md .claude/skills/NAME/SKILL.md .claude/settings.json
5
+ // Claude Code CLAUDE.md .claude/skills/NAME/SKILL.md .claude/settings.local.json
6
6
  // Codex AGENTS.md .agents/skills/NAME/SKILL.md
7
7
  //
8
8
  // Promptware is know-how; the contract teaches itself at the doors, the
@@ -48,7 +48,21 @@ export type Merge =
48
48
  | { kind: "json-key"; at: string[]; value: unknown }
49
49
  | { kind: "json-list"; at: string[]; values: unknown[] }
50
50
  | { kind: "json-lists"; lists: { at: string[]; values: unknown[] }[] }
51
- | { kind: "toml-table"; table: string; body: string };
51
+ | { kind: "toml-table"; table: string; body: string }
52
+ | { kind: "lines"; lines: string[] };
53
+
54
+ /** Voss's shared settings of Claude Code: the local file, which Claude Code keeps out of git, since what voss writes is made, not kept. */
55
+ export const CLAUDE_SETTINGS = ".claude/settings.local.json";
56
+
57
+ /**
58
+ * The outputs are made from the promptware: a checkout makes its own with `voss baba promptware sync` (a landing does),
59
+ * and git keeps none of them. Voss's lines of the project's .gitignore say so.
60
+ */
61
+ export const ignoreLines = (): string[] => [
62
+ "# voss makes these from .baba/promptware: `voss baba promptware sync` writes them, git keeps none",
63
+ ...harnesses.flatMap((h) => [`/${files[h].instructions}`, `/${files[h].skills}/`]),
64
+ `/${CLAUDE_SETTINGS}`,
65
+ ];
52
66
 
53
67
  /** Output path to text, sorted by path. */
54
68
  export type Outputs = Record<string, string>;
@@ -101,10 +115,11 @@ export function compile(input: CompileInput): Compiled {
101
115
  byEvent.get(hk.on)!.push(entry);
102
116
  }
103
117
  for (const [on, values] of byEvent) lists.push({ at: ["hooks", on], values });
104
- if (lists.length === 1 && lists[0]!.at[0] === "permissions") merges[".claude/settings.json"] = { kind: "json-list", at: lists[0]!.at, values: lists[0]!.values };
105
- else if (lists.length) merges[".claude/settings.json"] = { kind: "json-lists", lists };
118
+ if (lists.length === 1 && lists[0]!.at[0] === "permissions") merges[CLAUDE_SETTINGS] = { kind: "json-list", at: lists[0]!.at, values: lists[0]!.values };
119
+ else if (lists.length) merges[CLAUDE_SETTINGS] = { kind: "json-lists", lists };
106
120
  }
107
121
  }
122
+ merges[".gitignore"] = { kind: "lines", lines: ignoreLines() };
108
123
  return { files: sorted(out), merges: sorted(merges) };
109
124
  }
110
125
 
@@ -9,9 +9,10 @@
9
9
  // held owned but edited by hand, or not a regular file: never touched
10
10
  // foreign present and never written by the compiler: never touched
11
11
  //
12
- // A shared file, .mcp.json or .claude/settings.json or .codex/config.toml,
13
- // is voss's only for its part: merged in, taken out when no longer wanted,
14
- // and the whole file held when the part was edited by hand.
12
+ // A shared file, .mcp.json or .claude/settings.local.json or
13
+ // .codex/config.toml, or the project's .gitignore, is voss's only for its
14
+ // part: merged in, taken out when no longer wanted, and the whole file held
15
+ // when the part was edited by hand.
15
16
  //
16
17
  // The files are reached through `Files`: Bun's on the shell's offline path
17
18
  // (disk.ts), the effect's ctx.fs inside a baba. plan() writes nothing;
@@ -183,6 +184,7 @@ export function partOf(m: Merge, text: string): string | null {
183
184
  const b = tomlBlock(lines, m.table);
184
185
  return b ? [`[${m.table}]`, ...lines.slice(b.start + 1, b.end)].join("\n").trim() : null;
185
186
  }
187
+ if (m.kind === "lines") { const have = new Set(text.split("\n").map((l) => l.trim())); const present = m.lines.filter((l) => have.has(l)); return present.length ? stable(present) : null; }
186
188
  const o = parseJson(text);
187
189
  if (m.kind === "json-key") { const v = getAt(o, m.at); return v === undefined ? null : stable(v); }
188
190
  const present = listsOf(m).map((l) => { const v = getAt(o, l.at); return l.values.filter((x) => Array.isArray(v) && v.some((y) => stable(y) === stable(x))); });
@@ -192,7 +194,7 @@ export function partOf(m: Merge, text: string): string | null {
192
194
 
193
195
  /** Voss's part whole, as partOf reads it once merged. */
194
196
  export function wholePart(m: Merge): string {
195
- return m.kind === "toml-table" ? tomlText(m) : stable(m.kind === "json-key" ? m.value : m.kind === "json-list" ? m.values : listsOf(m).map((l) => l.values));
197
+ return m.kind === "toml-table" ? tomlText(m) : m.kind === "lines" ? stable(m.lines) : stable(m.kind === "json-key" ? m.value : m.kind === "json-list" ? m.values : listsOf(m).map((l) => l.values));
196
198
  }
197
199
 
198
200
  /** The lists a list merge holds: one for the older single-list kind. */
@@ -220,6 +222,22 @@ export function mergeText(text: string | null, want: Merge | undefined, was: Mer
220
222
  const out = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
221
223
  return out ? out + "\n" : null;
222
224
  }
225
+ if (kind === "lines") {
226
+ // Lines: each of voss's, by content, taken out where no longer wanted and put in where missing, as a block at the end; others' lines stay.
227
+ const w = want as Extract<Merge, { kind: "lines" }> | undefined;
228
+ const old = was as Extract<Merge, { kind: "lines" }> | undefined;
229
+ const keep = new Set(w?.lines ?? []);
230
+ const gone = new Set((old?.lines ?? []).filter((l) => !keep.has(l)));
231
+ let lines = (text ?? "").replace(/\r\n/g, "\n").split("\n").filter((l) => !gone.has(l.trim()));
232
+ const have = new Set(lines.map((l) => l.trim()));
233
+ const missing = (w?.lines ?? []).filter((l) => !have.has(l));
234
+ if (missing.length) {
235
+ while (lines.length && !lines.at(-1)!.trim()) lines.pop();
236
+ lines = [...lines, ...(lines.length ? [""] : []), ...missing];
237
+ }
238
+ const out = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
239
+ return out ? out + "\n" : null;
240
+ }
223
241
  const o = parseJson(text);
224
242
  if (kind === "json-key") {
225
243
  const w = want as Extract<Merge, { kind: "json-key" }> | undefined;
@@ -260,7 +278,7 @@ export function mergeText(text: string | null, want: Merge | undefined, was: Mer
260
278
  /** Whether two texts of a shared file say the same: equal JSON for JSON, equal text for TOML. */
261
279
  function same(m: Merge, a: string, b: string): boolean {
262
280
  if (a === b) return true;
263
- if (m.kind === "toml-table") return a.trim() === b.trim();
281
+ if (m.kind === "toml-table" || m.kind === "lines") return a.trim() === b.trim();
264
282
  try { return stable(JSON.parse(a)) === stable(JSON.parse(b)); } catch { return false; }
265
283
  }
266
284
 
@@ -299,7 +317,7 @@ export async function plan(io: Files, wanted: Compiled, root = ""): Promise<Plan
299
317
  keep();
300
318
  continue;
301
319
  }
302
- if (!owned && wm && wm.kind !== "json-list" && wm.kind !== "json-lists") {
320
+ if (!owned && wm && wm.kind !== "json-list" && wm.kind !== "json-lists" && wm.kind !== "lines") {
303
321
  const part = partOf(wm, text);
304
322
  if ((part !== null && part !== wholePart(wm)) || (wm.kind === "toml-table" && tomlElsewhere(text, wm.table))) {
305
323
  entries.push({ path, status: "held", action: null, before: now, reason: "an entry voss did not write" });
@@ -36,7 +36,7 @@ const entries = async (fs: Fs, path: string) => ((await fs.stat(path).catch(() =
36
36
 
37
37
  /** Every place an output may be: the instruction files, the shared files (with those an older voss merged into, to take its part out), and the skill directories as they are now. */
38
38
  async function outputs(fs: Fs): Promise<{ path: string; hash: string }[]> {
39
- const paths = ["AGENTS.md", "CLAUDE.md", ".mcp.json", ".claude/settings.json", ".codex/config.toml"];
39
+ const paths = ["AGENTS.md", "CLAUDE.md", ".mcp.json", ".claude/settings.local.json", ".claude/settings.json", ".codex/config.toml", ".gitignore"];
40
40
  for (const dir of [".claude/skills", ".agents/skills", ".codex/skills"]) for (const e of await entries(fs, dir)) if (e.dir) paths.push(`${dir}/${e.name}/SKILL.md`);
41
41
  const out: { path: string; hash: string }[] = [];
42
42
  for (const path of paths) {
package/src/spec/run.ts CHANGED
@@ -46,9 +46,9 @@ export function* execute<T extends Testbed<any>>(steps: Line<T>[], t: T, o: { fr
46
46
  break;
47
47
  }
48
48
  case "fire": {
49
- const rec: StepRecord = { kind: "fire", label: step.name, args: step.args };
49
+ const rec: StepRecord = { kind: "fire", label: step.name, args: step.args, ...(step.key !== undefined ? { key: step.key } : {}) };
50
50
  const at = answers.push(undefined) - 1;
51
- yield attempt(i, rec, () => { rec.args = resolveAnswers(step.args, answers.slice(0, at)); rec.result = (t.fire as unknown as (n: string, a: unknown) => unknown)(step.name, rec.args); answers[at] = rec.result; });
51
+ yield attempt(i, rec, () => { rec.args = resolveAnswers(step.args, answers.slice(0, at)); rec.result = (t.fire as unknown as (n: string, a: unknown, o?: { key?: string }) => unknown)(step.name, rec.args, step.key !== undefined ? { key: step.key } : undefined); answers[at] = rec.result; });
52
52
  break;
53
53
  }
54
54
  case "tick": { now = step.at ?? now + (step.after ?? TICK); yield { kind: "tick", to: now, index: i }; done.push({ kind: "tick", label: `+${fmt(now)}` }); break; }
@@ -61,7 +61,9 @@ export function* execute<T extends Testbed<any>>(steps: Line<T>[], t: T, o: { fr
61
61
  const c: CheckRecord = { label: step.label, ok: false, compare: step.compare };
62
62
  try {
63
63
  const got = step.read(t);
64
- if (step.compare) { c.got = got; c.want = step.want; c.ok = deepEqual(got, step.want); } else c.ok = got === true;
64
+ // A lazy want is read when the check runs, and recorded as the value it gave.
65
+ const want = typeof step.want === "function" ? (step.want as (t: T) => unknown)(t) : step.want;
66
+ if (step.compare) { c.got = got; c.want = want; c.ok = deepEqual(got, want); } else c.ok = got === true;
65
67
  } catch (e) { c.error = msg(e); }
66
68
  checks.push(c);
67
69
  yield { kind: "check", rec: c, index: i };
package/src/spec/take.ts CHANGED
@@ -38,7 +38,7 @@ export function asProgram(o: { spec: string; name: string; intent: string; steps
38
38
  expectAt(i);
39
39
  const where = rec.args && typeof rec.args === "object" && "entity" in (rec.args as object) ? `, { entity: ${json((rec.args as { entity: unknown }).entity)} }` : "";
40
40
  const ref = owner ? (owners.add(owner), `${ident(owner)}.${rec.label}`) : `/* ${rec.label} */ undefined as never`;
41
- if (rec.kind === "fire") lines.push(` fire(${json(rec.label)}, ${json(rec.args)}),`);
41
+ if (rec.kind === "fire") lines.push(` fire(${json(rec.label)}, ${json(rec.args)}${rec.key !== undefined ? `, { key: ${json(rec.key)} }` : ""}),`);
42
42
  else if (rec.kind === "tick") lines.push(` tick(${rec.args !== undefined ? json(rec.args) : ""}),`);
43
43
  else if (rec.kind === "done") lines.push(` done(${ref}, ${json(rec.result)}${where}),`);
44
44
  else if (rec.kind === "failed") lines.push(` failed(${ref}, ${json((rec.result as { error?: unknown })?.error ?? "")}${where}),`);
package/src/test/index.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  export { testbed, type Testbed } from "../ecs/testbed.ts";
4
4
  export { spawnParts, answer, resolveAnswers, type Line, type Words, type ScenarioDef } from "./steps.ts";
5
5
  export { vossDir } from "./voss-dir.ts";
6
- export { prove, proveAll, verdictsDir, type Verdicts, type ScenarioVerdict } from "./prove.ts";
6
+ export { prove, proveAll, verdictsDir, type Registrar, type Verdicts, type ScenarioVerdict } from "./prove.ts";
7
7
  export { specFiles, specsIn, type SpecFiles } from "./specs.ts";
8
8
  export type { StepRecord, CheckRecord } from "./records.ts";
package/src/test/prove.ts CHANGED
@@ -17,8 +17,11 @@ export interface Verdicts { spec: string; run: string; at: string; done: boolean
17
17
  /** Where a spec's verdicts go: under the project's .baba/.voss. */
18
18
  export const verdictsDir = (from?: string) => join(vossDir(from), "spec");
19
19
 
20
- /** Registers every scenario of `sp` with bun test and keeps the spec's verdicts current as they run, under `state` (the project's .baba/.voss/spec by default). */
21
- export function prove(sp: Spec, state = verdictsDir()): void {
20
+ /** What registers a scenario's test: bun's `test`, or the framework's own proof of the runner. */
21
+ export type Registrar = (name: string, fn: () => Promise<void>) => void;
22
+
23
+ /** Registers every scenario of `sp` with bun test (or `register`) and keeps the spec's verdicts current as they run, under `state` (the project's .baba/.voss/spec by default). */
24
+ export function prove(sp: Spec, state = verdictsDir(), register: Registrar = test): void {
22
25
  const file = join(state, sp.name, "verdicts.json");
23
26
  const run = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
24
27
  const kept: Verdicts = { spec: sp.name, run, at: new Date().toISOString(), done: false, scenarios: Object.fromEntries(sp.scenarios.map((sc) => [sc.name, { verdict: "unknown", intent: sc.intent, broke: null, steps: 0, checks: 0, failed: [], quiet: [], at: "" }])) };
@@ -27,7 +30,7 @@ export function prove(sp: Spec, state = verdictsDir()): void {
27
30
  let left = sp.scenarios.length;
28
31
  if (left === 0) { void write(); return; }
29
32
  for (const sc of sp.scenarios) {
30
- test(`${sp.name}: ${sc.name}`, async () => {
33
+ register(`${sp.name}: ${sc.name}`, async () => {
31
34
  if (!kept.at) kept.at = new Date().toISOString();
32
35
  const r = runScenario(sp, sc);
33
36
  const failed = [...r.setup.steps, ...r.steps].filter((s) => s.error).map((s) => `${s.kind} ${s.label}: ${s.error}`).concat(r.checks.filter((c) => !c.ok).map(describe));
@@ -45,11 +48,11 @@ export function prove(sp: Spec, state = verdictsDir()): void {
45
48
  * beside them. The one call in `.baba/spec.test.ts`:
46
49
  * `await proveAll(import.meta.dir)`.
47
50
  */
48
- export async function proveAll(baba: string): Promise<void> {
51
+ export async function proveAll(baba: string, register: Registrar = test): Promise<void> {
49
52
  const specs = await specsIn(baba);
50
53
  const names = new Set<string>();
51
54
  for (const sp of specs) { if (names.has(sp.name)) throw new Error(`two specs are named ${sp.name}`); names.add(sp.name); }
52
- for (const sp of specs) prove(sp, join(baba, ".voss", "spec"));
55
+ for (const sp of specs) prove(sp, join(baba, ".voss", "spec"), register);
53
56
  }
54
57
 
55
58
  const describe = (c: CheckRecord) => `${c.label}${c.error ? ` (${c.error})` : c.compare ? `: got ${JSON.stringify(c.got)}, want ${JSON.stringify(c.want)}` : ""}`;
@@ -6,6 +6,8 @@ export interface StepRecord {
6
6
  kind: "given" | "fire" | "tick" | "done" | "failed" | "list" | "watch";
7
7
  label: string;
8
8
  args?: unknown;
9
+ /** A fire's idempotency key, when it had one. */
10
+ key?: string;
9
11
  result?: unknown;
10
12
  error?: string;
11
13
  /** False for a given done by a function: the page cannot replay it. */
package/src/test/steps.ts CHANGED
@@ -7,20 +7,20 @@ import type { Effect } from "../ecs/state.ts";
7
7
  /** One line of a script: a given, a fire, a tick, an answer from the outside, or a check. */
8
8
  export type Line<T> =
9
9
  | { kind: "given"; label: string; run: ((t: T) => void) | Record<string, unknown> }
10
- | { kind: "fire"; name: string; args: unknown }
10
+ | { kind: "fire"; name: string; args: unknown; key?: string }
11
11
  | { kind: "tick"; after?: number; at?: number }
12
12
  | { kind: "ticks"; count: number; every: number }
13
13
  | { kind: "done"; effect: Effect; result: unknown; where?: unknown }
14
14
  | { kind: "failed"; effect: Effect; error: string; where?: unknown }
15
15
  | { kind: "list"; effect: Effect; items: unknown[]; where?: unknown }
16
16
  | { kind: "watch"; effect: Effect; events: unknown[]; where?: unknown }
17
- | { kind: "check"; label: string; read: (t: T) => unknown; want?: unknown; compare: boolean };
17
+ | { kind: "check"; label: string; read: (t: T) => unknown; /** The value wanted, or a function of the testbed that gives it when the check runs. */ want?: unknown; compare: boolean };
18
18
 
19
19
  /** The words of a scenario, typed by the system under test. */
20
20
  export interface Words<T extends Testbed<any>> {
21
21
  /** A precondition the contract cannot express. Parts by component name spawn one entity carrying them: the form the page can replay. A function does anything, and cannot be replayed. */
22
22
  given(label: string, run: ((t: T) => void) | Record<string, unknown>): Line<T>;
23
- /** An action, run now as its system. Its args may name an earlier fire's answer: \`answer(0, "entity")\`, or the string \`"$0.entity"\`, the first fire's entity. */
23
+ /** An action, run now as its system. Its args may name an earlier fire's answer: \`answer(0, "entity")\`, or the string \`"$0.entity"\`, the first fire's entity. A third argument \`{ key }\` is the idempotency key, as \`--key\` at the shell. */
24
24
  fire(...a: Parameters<T["fire"]>): Line<T>;
25
25
  /** An earlier fire's answer, or a field of it by a dotted path, to put in a later fire's args: \`fire("note-remove", { note: answer(0, "entity") })\`. Fires count from 0, in the order they are listed. */
26
26
  answer(index: number, path?: string): any;
@@ -34,8 +34,12 @@ export interface Words<T extends Testbed<any>> {
34
34
  /** A source's listing, or the events its watch yields, landing at the next tick. */
35
35
  list(...a: Parameters<T["list"]>): Line<T>;
36
36
  watch(...a: Parameters<T["watch"]>): Line<T>;
37
- /** A claim about the state now: with `want`, what `read` gives must deep-equal it; without, it must be true. */
38
- check(label: string, read: (t: T) => unknown, ...want: [unknown?]): Line<T>;
37
+ /** A claim about the state now: without `want`, what `read` gives must be true. */
38
+ check(label: string, read: (t: T) => unknown): Line<T>;
39
+ /** With a `want` that is a function of the testbed: read when the check runs, so a check can want what a `given` captured; recorded as the value it gave. */
40
+ check(label: string, read: (t: T) => unknown, want: (t: T) => unknown): Line<T>;
41
+ /** With `want`, what `read` gives must deep-equal it. */
42
+ check(label: string, read: (t: T) => unknown, want: unknown): Line<T>;
39
43
  }
40
44
 
41
45
  export interface ScenarioDef<T extends Testbed<any>, P extends Record<string, unknown> = Record<string, never>> {
@@ -53,14 +57,14 @@ export interface ScenarioDef<T extends Testbed<any>, P extends Record<string, un
53
57
 
54
58
  export const words: Words<any> = {
55
59
  given: (label, run) => ({ kind: "given", label, run }),
56
- fire: (name: string, args?: unknown) => ({ kind: "fire", name, args: args ?? {} }),
60
+ fire: (name: string, args?: unknown, o?: { key?: string }) => ({ kind: "fire", name, args: args ?? {}, ...(o?.key !== undefined ? { key: o.key } : {}) }),
57
61
  tick: (o = {}) => ({ kind: "tick", ...o }),
58
62
  ticks: (count, every) => ({ kind: "ticks", count, every }),
59
63
  done: (effect: Effect, result: unknown, where?: unknown) => ({ kind: "done", effect, result, where }),
60
64
  failed: (effect: Effect, error: string, where?: unknown) => ({ kind: "failed", effect, error, where }),
61
65
  list: (effect: Effect, items: unknown[], where?: unknown) => ({ kind: "list", effect, items, where }),
62
66
  watch: (effect: Effect, events: unknown[], where?: unknown) => ({ kind: "watch", effect, events, where }),
63
- check: (label, read, ...want) => ({ kind: "check", label, read, want: want[0], compare: want.length > 0 }),
67
+ check: (label: string, read: (t: any) => unknown, ...want: [unknown?]) => ({ kind: "check", label, read, want: want[0], compare: want.length > 0 }),
64
68
  answer: (index: number, path?: string) => `$${index}${path ? `.${path}` : ""}`,
65
69
  };
66
70