babavoss 0.0.0-stage → 0.0.2

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.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/bin/voss.ts +48 -0
  4. package/gui/babavoss-web.js +234 -0
  5. package/gui/chunk-5hpp1ypv.js +11710 -0
  6. package/gui/chunk-9rq662fd.js +8627 -0
  7. package/gui/chunk-k3cm8j6r.js +433 -0
  8. package/gui/chunk-pk09y98y.js +50 -0
  9. package/gui/chunk-smz02qa6.js +186 -0
  10. package/gui/chunk-wwqypxre.js +49 -0
  11. package/gui/gui.js +2015 -0
  12. package/gui/react-compiler-runtime.js +39 -0
  13. package/gui/react-dom-client.js +21 -0
  14. package/gui/react-dom.js +44 -0
  15. package/gui/react-jsx-runtime.js +19 -0
  16. package/gui/react.js +105 -0
  17. package/gui/theme.css +3053 -0
  18. package/index.ts +15 -0
  19. package/package.json +52 -4
  20. package/src/baba/check.ts +90 -0
  21. package/src/baba/config.ts +261 -0
  22. package/src/baba/find.ts +12 -0
  23. package/src/baba/init.ts +176 -0
  24. package/src/baba/node.ts +483 -0
  25. package/src/baba/project.ts +63 -0
  26. package/src/baba/registry.ts +35 -0
  27. package/src/baba/worker.ts +55 -0
  28. package/src/bench/index.ts +6 -0
  29. package/src/bench/measure.ts +132 -0
  30. package/src/bench/scenarios.ts +136 -0
  31. package/src/build/builder.ts +74 -0
  32. package/src/build/failure.ts +78 -0
  33. package/src/build/guard.ts +85 -0
  34. package/src/build/mdx-register.ts +3 -0
  35. package/src/build/mdx.ts +38 -0
  36. package/src/build/project.ts +38 -0
  37. package/src/build/views.ts +146 -0
  38. package/src/builder/main.ts +29 -0
  39. package/src/desktop/bob.ts +76 -0
  40. package/src/desktop/desktop.css +111 -0
  41. package/src/desktop/icons.ts +50 -0
  42. package/src/desktop/index.ts +323 -0
  43. package/src/desktop/routes.ts +98 -0
  44. package/src/desktop/view.tsx +673 -0
  45. package/src/door/core.ts +384 -0
  46. package/src/ecs/baba.ts +431 -0
  47. package/src/ecs/codec.ts +334 -0
  48. package/src/ecs/handles.ts +91 -0
  49. package/src/ecs/replica.ts +150 -0
  50. package/src/ecs/runtime.ts +603 -0
  51. package/src/ecs/scheduler.ts +75 -0
  52. package/src/ecs/snapshot.ts +102 -0
  53. package/src/ecs/state.ts +759 -0
  54. package/src/ecs/system.ts +256 -0
  55. package/src/ecs/table.ts +420 -0
  56. package/src/ecs/testbed.ts +97 -0
  57. package/src/exec/host.ts +177 -0
  58. package/src/exec/main.ts +98 -0
  59. package/src/exec/watch.ts +7 -0
  60. package/src/exec/wire.ts +29 -0
  61. package/src/generated/build.ts +4 -0
  62. package/src/gui/css.d.ts +1 -0
  63. package/src/gui/gui.tsx +245 -0
  64. package/src/gui/index.ts +51 -0
  65. package/src/gui/inspector.tsx +47 -0
  66. package/src/gui/levels.tsx +73 -0
  67. package/src/gui/promptware.tsx +68 -0
  68. package/src/gui/runner.tsx +118 -0
  69. package/src/gui/theme.css +498 -0
  70. package/src/gui/theme.ts +25 -0
  71. package/src/gui/wizard.tsx +227 -0
  72. package/src/guide/add-a-desktop.mdx +100 -0
  73. package/src/guide/compose-an-interface.mdx +84 -0
  74. package/src/guide/index.ts +13 -0
  75. package/src/guide/reach-outside.mdx +112 -0
  76. package/src/guide/spec-a-system.mdx +93 -0
  77. package/src/guide/systems-together.mdx +72 -0
  78. package/src/guide/write-a-system.mdx +183 -0
  79. package/src/guide/write-promptware.mdx +90 -0
  80. package/src/http/server.ts +310 -0
  81. package/src/kernel/build.ts +21 -0
  82. package/src/kernel/builder.ts +105 -0
  83. package/src/kernel/children.ts +117 -0
  84. package/src/kernel/context.ts +90 -0
  85. package/src/kernel/lock.ts +46 -0
  86. package/src/kernel/names.ts +14 -0
  87. package/src/kernel/schema.ts +130 -0
  88. package/src/kernel/where.ts +12 -0
  89. package/src/kit/index.ts +232 -0
  90. package/src/maker/system.ts +213 -0
  91. package/src/mcp/daemon.ts +61 -0
  92. package/src/mcp/main.ts +208 -0
  93. package/src/mcp/rpc.ts +64 -0
  94. package/src/mcp/tools.ts +125 -0
  95. package/src/prompt/evals.ts +42 -0
  96. package/src/prompt/index.ts +242 -0
  97. package/src/prompt/jsx-dev-runtime.ts +1 -0
  98. package/src/prompt/jsx-runtime.ts +49 -0
  99. package/src/prompt/mdx.d.ts +1 -0
  100. package/src/promptware/compile.ts +183 -0
  101. package/src/promptware/define.ts +16 -0
  102. package/src/promptware/disk.ts +72 -0
  103. package/src/promptware/markdown.d.ts +6 -0
  104. package/src/promptware/sync.ts +437 -0
  105. package/src/promptware/system.ts +215 -0
  106. package/src/runtime/bridge.ts +85 -0
  107. package/src/runtime/connect.ts +54 -0
  108. package/src/runtime/env.ts +35 -0
  109. package/src/runtime/harness.ts +80 -0
  110. package/src/runtime/main.ts +119 -0
  111. package/src/runtime/worker.ts +33 -0
  112. package/src/server/edge.ts +332 -0
  113. package/src/server/main.ts +45 -0
  114. package/src/server/messages.ts +97 -0
  115. package/src/server/protocol.ts +37 -0
  116. package/src/services/args.ts +45 -0
  117. package/src/services/exec.ts +69 -0
  118. package/src/services/fs.ts +139 -0
  119. package/src/services/http.ts +30 -0
  120. package/src/services/index.ts +113 -0
  121. package/src/services/secrets.ts +18 -0
  122. package/src/shell/address.ts +21 -0
  123. package/src/shell/args.ts +219 -0
  124. package/src/shell/client.ts +107 -0
  125. package/src/shell/codes.ts +26 -0
  126. package/src/shell/positional.ts +20 -0
  127. package/src/shell/run.ts +470 -0
  128. package/src/shell/service.ts +167 -0
  129. package/src/shell/state.ts +204 -0
  130. package/src/spec/adapters.ts +72 -0
  131. package/src/spec/diff.ts +26 -0
  132. package/src/spec/files.ts +17 -0
  133. package/src/spec/index.ts +155 -0
  134. package/src/spec/run.ts +97 -0
  135. package/src/spec/take.ts +54 -0
  136. package/src/test/index.ts +8 -0
  137. package/src/test/prove.ts +56 -0
  138. package/src/test/records.ts +23 -0
  139. package/src/test/specs.ts +56 -0
  140. package/src/test/steps.ts +100 -0
  141. package/src/test/voss-dir.ts +17 -0
  142. package/src/transport/messages.ts +110 -0
  143. package/src/transport/transport.ts +62 -0
  144. package/src/wall/probe.ts +67 -0
  145. package/src/wall/profile.ts +103 -0
  146. package/src/wall/spawn.ts +59 -0
  147. package/src/web/app.tsx +53 -0
  148. package/src/web/core.tsx +140 -0
  149. package/src/web/form.ts +155 -0
  150. package/src/web/hooks.ts +135 -0
  151. package/src/web/index.tsx +17 -0
  152. package/src/web/list.ts +19 -0
  153. package/src/web/maker.tsx +766 -0
  154. package/src/web/objects.tsx +213 -0
  155. package/src/web/socket.ts +84 -0
  156. package/src/web/state.tsx +69 -0
  157. package/src/web/store.ts +221 -0
  158. package/src/web/ui.tsx +135 -0
  159. package/README.md +0 -4
@@ -0,0 +1,437 @@
1
+ // Compiled promptware onto disk, owning only what it wrote. The state file
2
+ // records the sha256 of every output the compiler last wrote, and of its
3
+ // part of a shared file; comparing it with the files gives each output a
4
+ // status:
5
+ //
6
+ // current the file holds what is wanted
7
+ // stale owned and differs from what is wanted, or owned and no longer wanted: rewritten or removed
8
+ // missing wanted and absent: written
9
+ // held owned but edited by hand, or not a regular file: never touched
10
+ // foreign present and never written by the compiler: never touched
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.
15
+ //
16
+ // The files are reached through `Files`: Bun's on the shell's offline path
17
+ // (disk.ts), the effect's ctx.fs inside a baba. plan() writes nothing;
18
+ // apply() refuses the whole plan when any path no longer has the hash it
19
+ // had when the plan was made.
20
+ import type { Fs } from "../kernel/context.ts";
21
+ import type { Compiled, Merge, Outputs } from "./compile.ts";
22
+
23
+ export type Status = "current" | "stale" | "missing" | "held" | "foreign";
24
+ export type Action = "write" | "remove";
25
+
26
+ /** Relative to the project root. */
27
+ export const statePath = ".baba/.voss/promptware.json";
28
+
29
+ /** What the compiler last wrote: output path to the hash of its text, or of its part of a shared file, and those parts. */
30
+ export interface Written {
31
+ written: Record<string, string>;
32
+ parts: Record<string, Merge>;
33
+ /** No record was found: the compiler's first run in this checkout. Never written to disk. */
34
+ first?: boolean;
35
+ }
36
+
37
+ export type Seen = { kind: "absent" } | { kind: "file"; text: string } | { kind: "blocked"; reason: string };
38
+
39
+ /** The files a plan reads and an apply writes, by path relative to the project. */
40
+ export interface Files {
41
+ look(path: string): Promise<Seen>;
42
+ write(path: string, text: string): Promise<void>;
43
+ remove(path: string): Promise<void>;
44
+ /** Removes a directory only when it is empty. */
45
+ prune(path: string): Promise<void>;
46
+ }
47
+
48
+ export interface Entry {
49
+ path: string;
50
+ status: Status;
51
+ /** What apply() does here; null leaves the path alone. */
52
+ action: Action | null;
53
+ /** The file's hash when the plan was made, null when absent: apply() refuses when it changed. */
54
+ before: string | null;
55
+ /** Why a path is held or foreign. */
56
+ reason?: string;
57
+ }
58
+
59
+ export interface Plan {
60
+ root: string;
61
+ io: Files;
62
+ entries: Entry[];
63
+ /** What each path written should hold: a whole file, or a shared file with the part merged. */
64
+ wanted: Outputs;
65
+ previous: Written;
66
+ /** The state once every change is applied. */
67
+ next: Written;
68
+ }
69
+
70
+ /** What a CLI prints: a plan, or a plan applied. No file contents. */
71
+ export interface Report {
72
+ root: string;
73
+ outputs: { path: string; status: Status; action: Action | null; reason?: string }[];
74
+ written: string[];
75
+ removed: string[];
76
+ held: string[];
77
+ foreign: string[];
78
+ }
79
+
80
+ export class ChangedError extends Error {
81
+ constructor(readonly paths: string[]) {
82
+ super(`changed since the plan was made, nothing written: ${paths.join(", ")}`);
83
+ }
84
+ }
85
+
86
+ export function hash(text: string): string {
87
+ return new Bun.CryptoHasher("sha256").update(text).digest("hex");
88
+ }
89
+
90
+ const byPath = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
91
+
92
+ function checkPath(path: string): void {
93
+ if (!path || path.startsWith("/") || path.split("/").some((p) => p === "" || p === "." || p === ".."))
94
+ throw new Error(`output path ${JSON.stringify(path)} is not a plain path under the project`);
95
+ }
96
+
97
+ async function readWritten(io: Files): Promise<Written> {
98
+ const seen = await io.look(statePath);
99
+ // No record at all: the compiler's first run in this checkout. A fresh worktree carries the repository's generated files, which another checkout's compiler wrote.
100
+ if (seen.kind !== "file") return { written: {}, parts: {}, first: true };
101
+ let v: unknown;
102
+ try { v = JSON.parse(seen.text); } catch (e) { throw new Error(`${statePath}: ${e instanceof Error ? e.message : String(e)}`); }
103
+ const { written, parts } = (v ?? {}) as { written?: unknown; parts?: unknown };
104
+ if (typeof written !== "object" || written === null) throw new Error(`${statePath}: expected { written: { PATH: HASH } }`);
105
+ return { written: { ...(written as Record<string, string>) }, parts: typeof parts === "object" && parts !== null ? { ...(parts as Record<string, Merge>) } : {} };
106
+ }
107
+
108
+ const hashOf = (s: Seen) => (s.kind === "file" ? hash(s.text) : null);
109
+
110
+ // ---- merges --------------------------------------------------------------
111
+
112
+ type Obj = Record<string, unknown>;
113
+ const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
114
+
115
+ /** JSON with sorted keys: equal values, equal text. */
116
+ export function stable(v: unknown): string {
117
+ if (Array.isArray(v)) return `[${v.map(stable).join(",")}]`;
118
+ if (isObj(v)) return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(",")}}`;
119
+ return JSON.stringify(v) ?? "null";
120
+ }
121
+
122
+ function parseJson(text: string | null): Obj {
123
+ if (text === null || !text.trim()) return {};
124
+ let v: unknown;
125
+ try { v = JSON.parse(text); } catch (e) { throw new Error(`not JSON: ${e instanceof Error ? e.message : String(e)}`); }
126
+ if (!isObj(v)) throw new Error("not a JSON object");
127
+ return v;
128
+ }
129
+
130
+ const getAt = (o: Obj, at: string[]): unknown => at.reduce<unknown>((v, k) => (isObj(v) ? v[k] : undefined), o);
131
+
132
+ /** The object at `at`, made when absent; throws when something else is there. */
133
+ function parentAt(o: Obj, at: string[]): Obj {
134
+ let cur = o;
135
+ for (const k of at) {
136
+ if (cur[k] === undefined) cur[k] = {};
137
+ if (!isObj(cur[k])) throw new Error(`${at.join(".")} is not an object`);
138
+ cur = cur[k] as Obj;
139
+ }
140
+ return cur;
141
+ }
142
+
143
+ /** Drops the empty objects and lists left along `at` once voss's part is gone. */
144
+ function prune(o: Obj, at: string[]): void {
145
+ for (let i = at.length; i > 0; i--) {
146
+ const parent = getAt(o, at.slice(0, i - 1));
147
+ const v = isObj(parent) ? parent[at[i - 1]!] : undefined;
148
+ if (!isObj(parent) || !((isObj(v) && Object.keys(v).length === 0) || (Array.isArray(v) && v.length === 0))) return;
149
+ delete parent[at[i - 1]!];
150
+ }
151
+ }
152
+
153
+ const tomlHeader = /^\s*\[\s*([^[\]]+?)\s*\]\s*(#.*)?$/;
154
+ const tomlName = (n: string) => n.split(".").map((p) => p.trim().replace(/^"(.*)"$/, "$1")).join(".");
155
+
156
+ /** The lines of `table` in a TOML text: from its header to the next table, trailing blank lines left out. */
157
+ function tomlBlock(lines: string[], table: string): { start: number; end: number } | null {
158
+ const start = lines.findIndex((l) => { const m = tomlHeader.exec(l); return m !== null && tomlName(m[1]!) === table; });
159
+ if (start < 0) return null;
160
+ let end = start + 1;
161
+ while (end < lines.length && !/^\s*\[/.test(lines[end]!)) end++;
162
+ while (end > start + 1 && !lines[end - 1]!.trim()) end--;
163
+ return { start, end };
164
+ }
165
+
166
+ /** Whether a TOML text sets `table` another way: a dotted key at the top, or a key of its parent table. */
167
+ function tomlElsewhere(text: string, table: string): boolean {
168
+ const lines = text.split("\n");
169
+ const parts = table.split(".");
170
+ const first = lines.findIndex((l) => /^\s*\[/.test(l));
171
+ const top = first < 0 ? lines : lines.slice(0, first);
172
+ if (top.some((l) => new RegExp(`^\\s*"?${parts[0]}"?\\s*[.=]`).test(l))) return true;
173
+ const parent = tomlBlock(lines, parts.slice(0, -1).join("."));
174
+ return parent !== null && lines.slice(parent.start + 1, parent.end).some((l) => new RegExp(`^\\s*"?${parts.at(-1)}"?\\s*[.=]`).test(l));
175
+ }
176
+
177
+ const tomlText = (m: Extract<Merge, { kind: "toml-table" }>) => `[${m.table}]\n${m.body.trim()}`;
178
+
179
+ /** Voss's part of a shared file as it is now, as text to hash; null when absent. Throws for a file that does not read. */
180
+ export function partOf(m: Merge, text: string): string | null {
181
+ if (m.kind === "toml-table") {
182
+ const lines = text.split("\n");
183
+ const b = tomlBlock(lines, m.table);
184
+ return b ? [`[${m.table}]`, ...lines.slice(b.start + 1, b.end)].join("\n").trim() : null;
185
+ }
186
+ const o = parseJson(text);
187
+ if (m.kind === "json-key") { const v = getAt(o, m.at); return v === undefined ? null : stable(v); }
188
+ 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))); });
189
+ // One list hashes as its values, as it always did; several as the list of them.
190
+ return stable(m.kind === "json-list" ? present[0] : present);
191
+ }
192
+
193
+ /** Voss's part whole, as partOf reads it once merged. */
194
+ 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));
196
+ }
197
+
198
+ /** The lists a list merge holds: one for the older single-list kind. */
199
+ const listsOf = (m: Merge): { at: string[]; values: unknown[] }[] => m.kind === "json-lists" ? m.lists : m.kind === "json-list" ? [{ at: m.at, values: m.values }] : [];
200
+
201
+ /**
202
+ * A shared file with voss's part taken out (`was`) and put in (`want`):
203
+ * the text to write, or null when nothing else is left in it.
204
+ */
205
+ export function mergeText(text: string | null, want: Merge | undefined, was: Merge | undefined): string | null {
206
+ const kind = (want ?? was)!.kind;
207
+ if (kind === "toml-table") {
208
+ let lines = (text ?? "").replace(/\r\n/g, "\n").split("\n");
209
+ const w = want as Extract<Merge, { kind: "toml-table" }> | undefined;
210
+ const old = was as Extract<Merge, { kind: "toml-table" }> | undefined;
211
+ if (old && old.table !== w?.table) { const b = tomlBlock(lines, old.table); if (b) lines = [...lines.slice(0, b.start), ...lines.slice(b.end)]; }
212
+ if (w) {
213
+ const b = tomlBlock(lines, w.table);
214
+ if (b) lines = [...lines.slice(0, b.start), ...tomlText(w).split("\n"), ...lines.slice(b.end)];
215
+ else {
216
+ while (lines.length && !lines.at(-1)!.trim()) lines.pop();
217
+ lines = [...lines, ...(lines.length ? [""] : []), ...tomlText(w).split("\n")];
218
+ }
219
+ }
220
+ const out = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
221
+ return out ? out + "\n" : null;
222
+ }
223
+ const o = parseJson(text);
224
+ if (kind === "json-key") {
225
+ const w = want as Extract<Merge, { kind: "json-key" }> | undefined;
226
+ const old = was as Extract<Merge, { kind: "json-key" }> | undefined;
227
+ if (old && (!w || stable(old.at) !== stable(w.at))) {
228
+ const parent = getAt(o, old.at.slice(0, -1));
229
+ if (isObj(parent)) delete parent[old.at.at(-1)!];
230
+ prune(o, old.at.slice(0, -1));
231
+ }
232
+ if (w) parentAt(o, w.at.slice(0, -1))[w.at.at(-1)!] = w.value;
233
+ } else {
234
+ // Lists: each of voss's entries, by content, taken out where no longer wanted and put in where missing; others' entries stay.
235
+ const wanted = want ? listsOf(want) : [];
236
+ for (const old of was ? listsOf(was) : []) {
237
+ const list = getAt(o, old.at);
238
+ const still = wanted.find((l) => stable(l.at) === stable(old.at));
239
+ const keep = new Set((still?.values ?? []).map(stable));
240
+ if (Array.isArray(list)) {
241
+ const parent = getAt(o, old.at.slice(0, -1)) as Obj;
242
+ const gone = new Set(old.values.map(stable));
243
+ parent[old.at.at(-1)!] = list.filter((x) => { const k = stable(x); return !(gone.has(k) && !keep.has(k)); });
244
+ prune(o, old.at);
245
+ }
246
+ }
247
+ for (const l of wanted) {
248
+ const parent = parentAt(o, l.at.slice(0, -1));
249
+ const key = l.at.at(-1)!;
250
+ if (parent[key] === undefined) parent[key] = [];
251
+ const list = parent[key];
252
+ if (!Array.isArray(list)) throw new Error(`${l.at.join(".")} is not a list`);
253
+ const have = new Set(list.map(stable));
254
+ for (const v of l.values) if (!have.has(stable(v))) { list.push(v); have.add(stable(v)); }
255
+ }
256
+ }
257
+ return Object.keys(o).length ? JSON.stringify(o, null, 2) + "\n" : null;
258
+ }
259
+
260
+ /** Whether two texts of a shared file say the same: equal JSON for JSON, equal text for TOML. */
261
+ function same(m: Merge, a: string, b: string): boolean {
262
+ if (a === b) return true;
263
+ if (m.kind === "toml-table") return a.trim() === b.trim();
264
+ try { return stable(JSON.parse(a)) === stable(JSON.parse(b)); } catch { return false; }
265
+ }
266
+
267
+ // ---- the plan ------------------------------------------------------------
268
+
269
+ /** Compares what is wanted with what is on disk and what was last written. Writes nothing. */
270
+ export async function plan(io: Files, wanted: Compiled, root = ""): Promise<Plan> {
271
+ for (const p of [...Object.keys(wanted.files), ...Object.keys(wanted.merges)]) checkPath(p);
272
+ const previous = await readWritten(io);
273
+ const next: Written = { written: {}, parts: {} };
274
+ const entries: Entry[] = [];
275
+ const texts: Outputs = {};
276
+ const paths = [...new Set([...Object.keys(wanted.files), ...Object.keys(wanted.merges), ...Object.keys(previous.written)])].sort(byPath);
277
+ for (const path of paths) {
278
+ const want = wanted.files[path];
279
+ const wm = wanted.merges[path];
280
+ const recorded = previous.written[path];
281
+ const was = previous.parts[path];
282
+ const owned = recorded !== undefined;
283
+ const keep = () => { if (owned && (want !== undefined || wm)) { next.written[path] = recorded; if (was && wm) next.parts[path] = was; } };
284
+ let seen: Seen;
285
+ try { checkPath(path); seen = await io.look(path); } catch { continue; } // a state entry that is not a path is forgotten
286
+ const now = hashOf(seen);
287
+ if (seen.kind === "blocked") {
288
+ entries.push({ path, status: "held", action: null, before: null, reason: seen.reason });
289
+ keep();
290
+ continue;
291
+ }
292
+ if (wm || (was && want === undefined)) {
293
+ const text = seen.kind === "file" ? seen.text : null;
294
+ let merged: string | null;
295
+ try {
296
+ if (text !== null) {
297
+ if (owned && was && hash(partOf(was, text) ?? "") !== recorded) {
298
+ entries.push({ path, status: "held", action: null, before: now, reason: "voss's part edited by hand" });
299
+ keep();
300
+ continue;
301
+ }
302
+ if (!owned && wm && wm.kind !== "json-list" && wm.kind !== "json-lists") {
303
+ const part = partOf(wm, text);
304
+ if ((part !== null && part !== wholePart(wm)) || (wm.kind === "toml-table" && tomlElsewhere(text, wm.table))) {
305
+ entries.push({ path, status: "held", action: null, before: now, reason: "an entry voss did not write" });
306
+ continue;
307
+ }
308
+ }
309
+ }
310
+ merged = mergeText(text, wm, owned ? was : undefined);
311
+ } catch (e) {
312
+ entries.push({ path, status: "held", action: null, before: now, reason: e instanceof Error ? e.message : String(e) });
313
+ keep();
314
+ continue;
315
+ }
316
+ if (wm) { next.written[path] = hash(wholePart(wm)); next.parts[path] = wm; }
317
+ if (text === null && merged === null) continue;
318
+ if (text !== null && merged !== null && same((wm ?? was)!, text, merged)) { entries.push({ path, status: "current", action: null, before: now }); continue; }
319
+ if (merged === null) { entries.push({ path, status: "stale", action: "remove", before: now }); continue; }
320
+ texts[path] = merged;
321
+ entries.push({ path, status: text === null ? "missing" : "stale", action: "write", before: now });
322
+ continue;
323
+ }
324
+ if (want !== undefined && seen.kind === "file" && seen.text === want) {
325
+ entries.push({ path, status: "current", action: null, before: now });
326
+ next.written[path] = now!;
327
+ continue;
328
+ }
329
+ if (seen.kind === "file" && !owned && previous.first && want !== undefined) {
330
+ // On its first run in a checkout the compiler adopts the file the repository carries at a path it writes: generated elsewhere, it is its own.
331
+ texts[path] = want;
332
+ entries.push({ path, status: "stale", action: "write", before: now, reason: "adopted on the first run in this checkout" });
333
+ next.written[path] = hash(want);
334
+ continue;
335
+ }
336
+ if (seen.kind === "file" && !owned) {
337
+ entries.push({ path, status: "foreign", action: null, before: now, reason: "never written by the compiler" });
338
+ continue;
339
+ }
340
+ if (seen.kind === "file" && now !== recorded) {
341
+ entries.push({ path, status: "held", action: null, before: now, reason: "edited by hand" });
342
+ keep();
343
+ continue;
344
+ }
345
+ if (want === undefined) {
346
+ if (seen.kind === "file") entries.push({ path, status: "stale", action: "remove", before: now });
347
+ continue;
348
+ }
349
+ texts[path] = want;
350
+ entries.push({ path, status: seen.kind === "file" ? "stale" : "missing", action: "write", before: now });
351
+ next.written[path] = hash(want);
352
+ }
353
+ return { root, io, entries, wanted: { ...wanted.files, ...texts }, previous, next };
354
+ }
355
+
356
+ const skillDir = /^\.[a-z]+\/skills\/[^/]+$/;
357
+
358
+ /**
359
+ * Applies a plan: writes and removes what it says, then records the state.
360
+ * Refuses, writing nothing, when any path it changes no longer has the hash
361
+ * it had when the plan was made. When a write fails midway the state
362
+ * records exactly what was applied, so an applied write is not mistaken for
363
+ * a hand edit.
364
+ */
365
+ export async function apply(p: Plan): Promise<Report> {
366
+ const io = p.io;
367
+ const changes = p.entries.filter((e) => e.action !== null);
368
+ const changed: string[] = [];
369
+ for (const e of changes) {
370
+ const seen = await io.look(e.path);
371
+ if (seen.kind === "blocked" || hashOf(seen) !== e.before) changed.push(e.path);
372
+ }
373
+ if (changed.length) throw new ChangedError(changed);
374
+
375
+ const written: string[] = [];
376
+ const removed: string[] = [];
377
+ let failure: unknown = null;
378
+ const next: Written = { written: { ...p.next.written }, parts: { ...p.next.parts } };
379
+ for (const [i, e] of changes.entries()) {
380
+ try {
381
+ if (e.action === "write") {
382
+ await io.write(e.path, p.wanted[e.path]!);
383
+ written.push(e.path);
384
+ } else {
385
+ await io.remove(e.path);
386
+ removed.push(e.path);
387
+ const dir = e.path.split("/").slice(0, -1).join("/");
388
+ if (skillDir.test(dir)) await io.prune(dir).catch(() => {});
389
+ }
390
+ } catch (err) {
391
+ failure = err;
392
+ for (const u of changes.slice(i)) {
393
+ const old = p.previous.written[u.path];
394
+ const part = p.previous.parts[u.path];
395
+ if (old !== undefined) next.written[u.path] = old; else delete next.written[u.path];
396
+ if (part !== undefined) next.parts[u.path] = part; else delete next.parts[u.path];
397
+ }
398
+ break;
399
+ }
400
+ }
401
+ const json = (st: Written) => JSON.stringify({ written: sortKeys(st.written), ...(Object.keys(st.parts).length ? { parts: sortKeys(st.parts) } : {}) }, null, 2) + "\n";
402
+ if (changes.length > 0 || json(p.previous) !== json(next)) await io.write(statePath, json(next));
403
+ if (failure) throw failure;
404
+ return report(p, { written, removed });
405
+ }
406
+
407
+ function sortKeys<T>(r: Record<string, T>): Record<string, T> {
408
+ return Object.fromEntries(Object.entries(r).sort(([a], [b]) => byPath(a, b)));
409
+ }
410
+
411
+ /** A plan, or what applying it did, as JSON a CLI prints. */
412
+ export function report(p: Plan, done: { written: string[]; removed: string[] } = { written: [], removed: [] }): Report {
413
+ return {
414
+ root: p.root,
415
+ outputs: p.entries.map((e) => ({ path: e.path, status: e.status, action: e.action, ...(e.reason ? { reason: e.reason } : {}) })),
416
+ written: done.written,
417
+ removed: done.removed,
418
+ held: p.entries.filter((e) => e.status === "held").map((e) => e.path),
419
+ foreign: p.entries.filter((e) => e.status === "foreign").map((e) => e.path),
420
+ };
421
+ }
422
+
423
+ /** The files as an effect reaches them: through ctx.fs, which keeps every path inside the project. */
424
+ export function contextFiles(fs: Fs): Files {
425
+ return {
426
+ async look(path) {
427
+ let st;
428
+ try { st = await fs.stat(path); } catch (e) { return { kind: "blocked", reason: e instanceof Error ? e.message : String(e) }; }
429
+ if (!st) return { kind: "absent" };
430
+ if (st.dir) return { kind: "blocked", reason: "not a regular file" };
431
+ return { kind: "file", text: await fs.read(path) };
432
+ },
433
+ write: (path, text) => fs.write(path, text),
434
+ remove: (path) => fs.remove(path),
435
+ async prune(path) { if ((await fs.list(path)).length === 0) await fs.remove(path); },
436
+ };
437
+ }
@@ -0,0 +1,215 @@
1
+ // The promptware system: voss gives it to every baba. The know-how is the
2
+ // baba's promptware, rendered into the manifest with its harness
3
+ // declaration; this system's state is the outputs' hashes; its effects
4
+ // compile the know-how for each harness and write the files through the
5
+ // kernel. The system is made per baba, given the manifest of the
6
+ // baba it joins.
7
+ import { effect, manifestOf, resource, baba, nameOf, type Manifest, type Baba } from "../ecs/baba.ts";
8
+ import { system, type StateOf } from "../ecs/system.ts";
9
+ import { s } from "../kernel/schema.ts";
10
+ import type { Prompt } from "../prompt/index.ts";
11
+ import type { Fs } from "../kernel/context.ts";
12
+ import type { Value } from "../ecs/state.ts";
13
+ import maker from "../maker/system.ts";
14
+ import { catalogOf, desktop } from "../desktop/index.ts";
15
+ import { guide } from "../guide/index.ts";
16
+ import { compile, harnessOf, type Merge } from "./compile.ts";
17
+ import { apply, contextFiles, hash, plan } from "./sync.ts";
18
+
19
+ const statuses = ["current", "stale", "held", "missing"] as const;
20
+
21
+ const scanned = s.object({
22
+ title: s.nullable(s.string()),
23
+ outputs: s.array(s.object({ path: s.string(), hash: s.string() })),
24
+ });
25
+ const entry = s.object({ path: s.string(), status: s.enum(...statuses), action: s.nullable(s.enum("write", "remove")), reason: s.string(), hash: s.string() });
26
+ const writeArgs = s.object({
27
+ key: s.string(),
28
+ apply: s.boolean(),
29
+ files: s.array(s.object({ path: s.string(), text: s.string() })),
30
+ merges: s.array(s.object({ path: s.string(), merge: s.unknown() })),
31
+ });
32
+ const written = s.object({ key: s.string(), applied: s.boolean(), written: s.array(s.string()), removed: s.array(s.string()), entries: s.array(entry) });
33
+
34
+ /** A directory's entries, none when it is absent: asked by stat first, so an absent directory is not a failed request in the log. */
35
+ const entries = async (fs: Fs, path: string) => ((await fs.stat(path).catch(() => null))?.dir ? fs.list(path) : []);
36
+
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
+ 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"];
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
+ const out: { path: string; hash: string }[] = [];
42
+ for (const path of paths) {
43
+ const st = await fs.stat(path).catch(() => null);
44
+ if (st && !st.dir) out.push({ path, hash: hash(await fs.read(path)) });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ async function scan(fs: Fs): Promise<typeof scanned extends { check(v: unknown): infer T } ? T : never> {
50
+ let title: string | null = null;
51
+ try {
52
+ const pkg = JSON.parse(await fs.read(".baba/package.json")) as { name?: unknown; baba?: { name?: unknown; title?: unknown } };
53
+ const name = pkg.baba?.name ?? pkg.name;
54
+ title = typeof pkg.baba?.title === "string" ? pkg.baba.title : typeof name === "string" ? name : null;
55
+ } catch { /* no title: the program heads the file */ }
56
+ return { title, outputs: await outputs(fs) };
57
+ }
58
+
59
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
60
+
61
+ const writtenRow = s.object({ path: s.string(), hash: s.string(), harness: s.string(), status: s.enum(...statuses), action: s.nullable(s.enum("write", "remove")), reason: s.string() });
62
+
63
+ /**
64
+ * The system for a baba whose manifest `manifest` answers; null renders
65
+ * nothing. Its rows are resources, not entities: voss's own system takes no
66
+ * ids from the baba's sequence and adds nothing to its entities.
67
+ */
68
+ export const promptwareSystem = (manifest: () => Manifest | null) => system({
69
+ // promptware, declared: the same for every baba; what it compiles is the manifest each implementation is given.
70
+ name: "promptware",
71
+ model: {
72
+ /** One row per output of the last plan: its hash on disk then, its harness, its status and what a sync does there. */
73
+ pwWritten: resource(s.array(writtenRow), []),
74
+ pwStatus: resource({
75
+ title: s.nullable(s.string()),
76
+ lastSync: s.nullable(s.integer()),
77
+ /** Syncs finished, written or failed: what a caller waits on. */
78
+ syncs: s.integer(),
79
+ pending: s.integer(),
80
+ /** The compiled outputs the last plan was made for; empty asks for a new plan. */
81
+ planned: s.string(),
82
+ /** A sync asked for and not yet run. */
83
+ sync: s.boolean(),
84
+ /** Whether a scan has landed since the baba loaded: nothing is planned before the outputs on disk are known. */
85
+ scanned: s.boolean(),
86
+ error: s.nullable(s.string()),
87
+ }, { title: null, lastSync: null, syncs: 0, pending: 0, planned: "", sync: false, scanned: false, error: null }),
88
+ },
89
+ effects: {
90
+ pwScan: effect({ summary: "read the title and the outputs' hashes", every: 2_000, result: scanned, run: (_, ctx) => scan(ctx.fs) }),
91
+ pwWrite: effect({
92
+ summary: "plan the outputs against the files, and write them when asked",
93
+ args: writeArgs,
94
+ result: written,
95
+ run: async (a, ctx) => {
96
+ const io = contextFiles(ctx.fs);
97
+ const wanted = { files: Object.fromEntries(a.files.map((f) => [f.path, f.text])), merges: Object.fromEntries(a.merges.map((m) => [m.path, m.merge as Merge])) };
98
+ let done = { written: [] as string[], removed: [] as string[] };
99
+ if (a.apply) { const r = await apply(await plan(io, wanted)); done = { written: r.written, removed: r.removed }; }
100
+ const p = await plan(io, wanted);
101
+ return {
102
+ key: a.key, applied: a.apply, ...done,
103
+ entries: p.entries.map((e) => ({ path: e.path, status: e.status === "foreign" ? "held" as const : e.status, action: e.action, reason: e.reason ?? "", hash: e.before ?? "" })),
104
+ };
105
+ },
106
+ }),
107
+ },
108
+ }, (promptware, p) => {
109
+ type W = StateOf<typeof promptware>;
110
+ const { pwWritten, pwStatus, pwScan, pwWrite } = promptware;
111
+ const setIf = <K extends typeof pwWritten | typeof pwStatus>(w: W, r: K, v: Value<K>) => { if (!same(w.get(r), v)) w.set(r, v); };
112
+
113
+ /** The outputs the state compiles to and their key; null before the baba has a manifest. */
114
+ const compiled = (w: W) => {
115
+ const m = manifest();
116
+ if (!m) return null;
117
+ const st = w.get(pwStatus);
118
+ const out = compile({ program: "voss baba", ...(st.title ? { title: st.title } : {}), manifest: m });
119
+ return { key: hash(JSON.stringify(out)), out };
120
+ };
121
+ const currentKey = (w: W) => { try { return compiled(w)?.key ?? null; } catch { return null; } };
122
+
123
+ return [
124
+ p.on(pwScan, {
125
+ done: (w, _, r) => {
126
+ const st = w.get(pwStatus);
127
+ // A file that differs from what the last plan saw asks for a new plan.
128
+ const disk = new Map(r.outputs.map((o) => [o.path, o.hash]));
129
+ const dirty = w.get(pwWritten).some((row) => (disk.get(row.path) ?? "") !== row.hash);
130
+ setIf(w, pwStatus, { ...st, title: r.title, scanned: true, planned: dirty ? "" : st.planned });
131
+ },
132
+ }),
133
+
134
+ p.on(pwWrite, {
135
+ done: (w, _, r) => {
136
+ setIf(w, pwWritten, r.entries.map((e) => ({ path: e.path, hash: e.hash, harness: harnessOf(e.path), status: e.status, action: e.action, reason: e.reason })));
137
+ const st = w.get(pwStatus);
138
+ w.set(pwStatus, {
139
+ ...st, planned: r.key, error: null,
140
+ pending: r.entries.filter((e) => e.action !== null).length,
141
+ ...(r.applied ? { lastSync: w.now, syncs: st.syncs + 1, sync: false } : {}),
142
+ });
143
+ },
144
+ failed: (w, _, error) => {
145
+ const st = w.get(pwStatus);
146
+ // The plan stays as asked: a file that changes asks again, a failure does not loop.
147
+ w.set(pwStatus, { ...st, planned: currentKey(w) ?? st.planned, error, ...(st.sync ? { syncs: st.syncs + 1, sync: false } : {}) });
148
+ },
149
+ }),
150
+
151
+ // What the baba's promptware compiles to, planned when it changed, written when the harness says auto or a sync asked.
152
+ p.step("plan", (w) => {
153
+ const st = w.get(pwStatus);
154
+ if (w.running(pwWrite) || !st.scanned) return;
155
+ let c;
156
+ try { c = compiled(w); } catch (e) {
157
+ const error = e instanceof Error ? e.message : String(e);
158
+ if (st.error !== error || st.sync) w.set(pwStatus, { ...st, error, sync: false, syncs: st.sync ? st.syncs + 1 : st.syncs });
159
+ return;
160
+ }
161
+ if (!c) return;
162
+ if (!st.sync && c.key === st.planned) return;
163
+ w.run(pwWrite, {
164
+ key: c.key, apply: st.sync || manifest()!.harness.auto,
165
+ files: Object.entries(c.out.files).map(([path, text]) => ({ path, text })),
166
+ merges: Object.entries(c.out.merges).map(([path, merge]) => ({ path, merge })),
167
+ });
168
+ }),
169
+
170
+ p.action("promptware-sync", {
171
+ summary: "compile the know-how for each harness and write the files now",
172
+ result: s.object({ asked: s.boolean(), outputs: s.array(writtenRow) }),
173
+ run: (w) => {
174
+ w.set(pwStatus, { ...w.get(pwStatus), sync: true });
175
+ return { asked: true, outputs: w.get(pwWritten) };
176
+ },
177
+ }),
178
+
179
+ p.query("promptware-plan", {
180
+ summary: "each output of each harness: its status and what a sync does there",
181
+ result: s.object({ auto: s.boolean(), lastSync: s.nullable(s.integer()), syncs: s.integer(), pending: s.integer(), error: s.nullable(s.string()), outputs: s.array(writtenRow) }),
182
+ read: (w) => {
183
+ const st = w.get(pwStatus);
184
+ return { auto: manifest()?.harness.auto ?? true, lastSync: st.lastSync, syncs: st.syncs, pending: st.pending, error: st.error, outputs: w.get(pwWritten) };
185
+ },
186
+ }),
187
+ ];
188
+ });
189
+
190
+ /** What voss tells agents in every baba, after the baba's own promptware: its skills. The word on the two systems it adds is the compiler's, in the shell of the context file. */
191
+ const voss: readonly Prompt[] = guide;
192
+
193
+ /** Voss's own systems, by identity: filtered from what a project's docs and tables show. */
194
+ export const ours = new WeakSet<object>();
195
+ export const isOurs = (c: object) => ours.has(c);
196
+
197
+ /** The baba with voss's own systems after its own: what the runtime runs. Every baba gets the shell, the desktop system, over the apps it declares. */
198
+ export function withPromptware(v: Baba): Baba {
199
+ if (v.systems.some((c) => ours.has(c))) return v;
200
+ for (const name of ["promptware", "maker"]) if (v.systems.some((c) => nameOf(c) === name)) throw new Error(`a system of this baba is named ${name}, which voss gives every baba; name it otherwise`);
201
+ // A baba from before the shell was voss's brings its own desktop system, and keeps it; one system or the other, never both.
202
+ const own = v.systems.find((c) => nameOf(c) === "desktop");
203
+ if (own && v.apps.length) throw new Error("this baba declares apps and also has a system named desktop; the shell is voss's now: drop the system and keep baba({ apps })");
204
+ let m: Manifest | null = null;
205
+ const pw = promptwareSystem(() => m);
206
+ const catalog = own ? v.apps : catalogOf(v.apps);
207
+ const shell = own ? null : desktop({ apps: catalog });
208
+ ours.add(pw);
209
+ ours.add(maker);
210
+ if (shell) ours.add(shell);
211
+ const composed = baba({ systems: [...v.systems, ...(shell ? [shell] : []), pw, maker], apps: catalog, promptware: [...v.promptware, ...voss], harness: v.harness, ...(v.tick !== null ? { tick: v.tick } : {}) });
212
+ m = manifestOf(composed);
213
+ return composed;
214
+ }
215
+