babavoss 0.12.3 → 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 +4 -4
- package/src/baba/config.ts +8 -1
- package/src/ecs/runtime.ts +22 -13
- package/src/ecs/testbed.ts +7 -4
- package/src/generated/build.ts +1 -1
- package/src/guide/spec-a-system.mdx +3 -1
- package/src/guide/write-promptware.mdx +3 -2
- package/src/promptware/compile.ts +19 -4
- package/src/promptware/sync.ts +24 -6
- package/src/promptware/system.ts +1 -1
- package/src/server/edge.ts +19 -0
- package/src/spec/run.ts +5 -3
- package/src/spec/take.ts +1 -1
- package/src/test/index.ts +1 -1
- package/src/test/prove.ts +8 -5
- package/src/test/records.ts +2 -0
- package/src/test/steps.ts +11 -7
- package/gui/babavoss-web.js +0 -234
- package/gui/chunk-5hpp1ypv.js +0 -11710
- package/gui/chunk-9rq662fd.js +0 -8627
- package/gui/chunk-k3cm8j6r.js +0 -433
- package/gui/chunk-pk09y98y.js +0 -50
- package/gui/chunk-smz02qa6.js +0 -186
- package/gui/chunk-wwqypxre.js +0 -49
- package/gui/gui.js +0 -2015
- package/gui/react-compiler-runtime.js +0 -39
- package/gui/react-dom-client.js +0 -21
- package/gui/react-dom.js +0 -44
- package/gui/react-jsx-runtime.js +0 -19
- package/gui/react.js +0 -105
- package/gui/theme.css +0 -3053
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "babavoss",
|
|
3
|
-
"version": "0.12.
|
|
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
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
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"
|
package/src/baba/config.ts
CHANGED
|
@@ -58,13 +58,20 @@ export function viewsConfig(): string {
|
|
|
58
58
|
// The whole DOM: a view runs in the browser, whose sandbox is its own.
|
|
59
59
|
return JSON.stringify({
|
|
60
60
|
"//": note,
|
|
61
|
-
compilerOptions: { lib: ["ESNext", "DOM", "DOM.Iterable"], ...rest, jsx: "react-jsx", jsxImportSource: "react", paths: alias, composite: true, declaration: true, emitDeclarationOnly: true, rootDir: "..", outDir: "./types/views" },
|
|
61
|
+
compilerOptions: { lib: ["ESNext", "DOM", "DOM.Iterable"], ...rest, jsx: "react-jsx", jsxImportSource: "react", paths: { ...alias, ...frameworkPaths }, composite: true, declaration: true, emitDeclarationOnly: true, rootDir: "..", outDir: "./types/views" },
|
|
62
62
|
// A composite program lists every file it reaches: the backend modules a view imports types from too.
|
|
63
63
|
include: ["assets.d.ts", "../**/*.ts", "../**/*.tsx"],
|
|
64
64
|
exclude: ["../node_modules/**", "../.voss/types/**", "../**/*.test.ts", "../**/*.test.tsx"],
|
|
65
65
|
}, null, 2) + "\n";
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The framework's files by package name. A composite program names the types a spec or an app exports; with `@/*`
|
|
70
|
+
* reaching into node_modules as well, the checker would name a framework type through the alias and refuse it as
|
|
71
|
+
* not portable (TS2883). This pattern names it as `babavoss/src/…` instead, with or without the package in node_modules.
|
|
72
|
+
*/
|
|
73
|
+
const frameworkPaths: Record<string, string[]> = { "babavoss/*": ["../node_modules/babavoss/*"] };
|
|
74
|
+
|
|
68
75
|
const doc = (s: string) => `/** ${s} */`;
|
|
69
76
|
const quote = (s: string) => JSON.stringify(s);
|
|
70
77
|
|
package/src/ecs/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/ecs/testbed.ts
CHANGED
|
@@ -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:
|
|
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(); },
|
package/src/generated/build.ts
CHANGED
|
@@ -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 = {
|
|
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
|
|
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[
|
|
105
|
-
else if (lists.length) merges[
|
|
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
|
|
package/src/promptware/sync.ts
CHANGED
|
@@ -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
|
|
13
|
-
// is voss's only for its
|
|
14
|
-
//
|
|
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" });
|
package/src/promptware/system.ts
CHANGED
|
@@ -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/server/edge.ts
CHANGED
|
@@ -47,6 +47,20 @@ const failed = (f: Failure) => json({ error: { code: f.code, message: f.message
|
|
|
47
47
|
|
|
48
48
|
const RAW = new Set(["spawn", "add", "remove", "despawn", "set"]);
|
|
49
49
|
|
|
50
|
+
/** On every answer: no other site frames a page (clickjacking), loads a bundle, an asset or an answer into its own (CORP), or has one sniffed as another type. */
|
|
51
|
+
const GUARDS: [string, string][] = [
|
|
52
|
+
["content-security-policy", "frame-ancestors 'none'"],
|
|
53
|
+
["x-frame-options", "DENY"],
|
|
54
|
+
["cross-origin-resource-policy", "same-origin"],
|
|
55
|
+
["x-content-type-options", "nosniff"],
|
|
56
|
+
["referrer-policy", "no-referrer"],
|
|
57
|
+
];
|
|
58
|
+
function guarded(r: Response): Response {
|
|
59
|
+
const headers = new Headers(r.headers);
|
|
60
|
+
for (const [k, v] of GUARDS) headers.set(k, v);
|
|
61
|
+
return new Response(r.body, { status: r.status, statusText: r.statusText, headers });
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
// ---- the sockets ------------------------------------------------------------
|
|
51
65
|
|
|
52
66
|
/** A window's socket: which baba it follows, where it stands, and what it asks for. */
|
|
@@ -235,6 +249,11 @@ export function edge(state: Serve, ask: Ask): Edge {
|
|
|
235
249
|
}
|
|
236
250
|
|
|
237
251
|
const fetch = async (req: globalThis.Request, server: Server<Peer>): Promise<Response | undefined> => {
|
|
252
|
+
const r = await route(req, server);
|
|
253
|
+
return r && guarded(r);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const route = async (req: globalThis.Request, server: Server<Peer>): Promise<Response | undefined> => {
|
|
238
257
|
const host = req.headers.get("host") ?? "";
|
|
239
258
|
if (!localHost(host)) return text("host", 403);
|
|
240
259
|
const origin = req.headers.get("origin");
|
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
|
-
|
|
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
|
-
/**
|
|
21
|
-
export
|
|
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
|
-
|
|
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)}` : ""}`;
|
package/src/test/records.ts
CHANGED
|
@@ -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:
|
|
38
|
-
check(label: string, read: (t: T) => unknown
|
|
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
|
|