@marver-design/marver 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +17 -4
  3. package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BTGNPVMx.mjs +187 -0
  6. package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
  7. package/dist/cli.mjs +67 -8
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-DUAENlJ6.mjs +600 -0
  10. package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
  15. package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
  16. package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
  19. package/docs/boards-and-folders.md +161 -0
  20. package/docs/context.md +117 -0
  21. package/docs/sharing.md +12 -2
  22. package/package.json +1 -1
  23. package/src/client/shell/BoardList.tsx +143 -54
  24. package/src/client/shell/ContextMenu.tsx +16 -5
  25. package/src/client/shell/StatusPicker.tsx +91 -0
  26. package/src/client/shell/board-icons.tsx +75 -0
  27. package/src/client/shell/store.ts +93 -14
  28. package/src/client/shell/styles.css +41 -9
  29. package/src/shared/board-tree.ts +291 -143
  30. package/src/shared/board-types.ts +103 -0
  31. package/src/shared/context.ts +265 -0
  32. package/src/shared/status.ts +157 -0
  33. package/templates/AGENTS-embedded.md +4 -3
  34. package/templates/AGENTS-studio.md +4 -3
  35. package/templates/context/INDEX.md +50 -0
  36. package/templates/context/map.json +6 -0
  37. package/templates/context/shipped-knowledge.md +19 -0
  38. package/templates/context/shipped.md +28 -0
  39. package/templates/instructions/boards.md +78 -22
  40. package/templates/instructions/context.md +114 -0
  41. package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
  42. package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
  43. package/templates/playbooks/reorganize-context/eval.md +93 -0
  44. package/dist/boards-BmxcT3Lc.mjs +0 -290
  45. package/dist/boards-PuVzw5Wp.mjs +0 -62
@@ -0,0 +1,600 @@
1
+ import { r as NAME } from "./cli.mjs";
2
+ import { C as tables, S as proseLines, _ as isRecordTable, b as looseTables, c as CITED_FILE, d as LEVEL, f as availableLevels, g as hasGlob, h as globRe, k as checkRealDirs, l as EVIDENCE_COLUMN, m as frontMatter, p as capabilityTable, s as CITATION, tt as readStatusWord, u as GENERATED, v as levelsIn, w as wordCount, x as parseMap, y as lf } from "./board-status-CWGHIdo_.mjs";
3
+ import { pkgDir } from "./managed-HwHVNI3h.mjs";
4
+ import { t as writeManaged } from "./managed-write-Bo-oPc-i.mjs";
5
+ import { r as scanFrames } from "./manifest-BqBcMJcd.mjs";
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, join, normalize, relative, sep } from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ //#region src/server/publish-graph.ts
10
+ /**
11
+ * What a canvas build carries, as repository paths - the same selection the build makes, so a
12
+ * check against it judges what actually ships: the published boards' frames (every frame when
13
+ * `all-scenes` ships), each frame's `_layout` chain, `design/providers`, and everything they import -
14
+ * relative paths, `design/tsconfig.json` aliases, `?raw` and asset strings. An import that looks
15
+ * local and does not resolve is reported, never silently skipped.
16
+ */
17
+ const IMPORT = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)|require\(\s*['"]([^'"]+)['"]\s*\)|['"]((?:\.{1,2}\/|\/)[^'"\s]+\.[a-z0-9]{1,5})(?:\?[a-z]+)?['"]/g;
18
+ const CODE = /\.(tsx?|jsx?|mjs|cjs|mdx?)$/;
19
+ /** The published board names, straight from design/publish.json (an unreadable policy ships nothing). */
20
+ function publishedBoards(root) {
21
+ try {
22
+ return Object.keys(JSON.parse(readFileSync(join(root, "design", "publish.json"), "utf8")).boards ?? {});
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+ /** JSON with comments and trailing commas, as tsconfig allows - comments stripped outside strings only
28
+ * (a "$schema": "https://..." is a string, not a comment). */
29
+ function parseJsonc(text) {
30
+ let out = "", i = 0, str = false;
31
+ const t = text.replace(/^\uFEFF/, "");
32
+ while (i < t.length) {
33
+ const c = t[i];
34
+ if (str) {
35
+ out += c;
36
+ if (c === "\\") {
37
+ out += t[i + 1] ?? "";
38
+ i += 2;
39
+ continue;
40
+ }
41
+ if (c === "\"") str = false;
42
+ i++;
43
+ continue;
44
+ }
45
+ if (c === "\"") {
46
+ str = true;
47
+ out += c;
48
+ i++;
49
+ continue;
50
+ }
51
+ if (c === "/" && t[i + 1] === "/") {
52
+ while (i < t.length && t[i] !== "\n") i++;
53
+ continue;
54
+ }
55
+ if (c === "/" && t[i + 1] === "*") {
56
+ i += 2;
57
+ while (i < t.length && !(t[i] === "*" && t[i + 1] === "/")) i++;
58
+ i += 2;
59
+ continue;
60
+ }
61
+ out += c;
62
+ i++;
63
+ }
64
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
65
+ }
66
+ /** design/tsconfig.json's path aliases, resolved against the config that declares them - its own,
67
+ * or one it `extends` (the host's tsconfig), as TypeScript and Vite would. */
68
+ function aliases(root) {
69
+ const read = (rel, depth) => {
70
+ if (depth > 4) return [];
71
+ let cfg;
72
+ try {
73
+ cfg = parseJsonc(readFileSync(join(root, rel), "utf8"));
74
+ } catch {
75
+ return [];
76
+ }
77
+ const dir = dirname(rel);
78
+ const base = typeof cfg.compilerOptions?.baseUrl === "string" ? normalize(join(dir, cfg.compilerOptions.baseUrl)) : dir;
79
+ const own = Object.entries(cfg.compilerOptions?.paths ?? {}).flatMap(([k, v]) => Array.isArray(v) ? [{
80
+ prefix: k.replace(/\*$/, ""),
81
+ targets: v.map((t) => normalize(join(base, t.replace(/\*$/, ""))))
82
+ }] : []);
83
+ const ext = (Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends]).filter((e) => typeof e === "string" && e.startsWith("."));
84
+ let inherited = [];
85
+ for (const e of ext) {
86
+ const got = read(normalize(join(dir, e.endsWith(".json") ? e : `${e}.json`)), depth + 1);
87
+ inherited = [...got, ...inherited.filter((p) => !got.some((g) => g.prefix === p.prefix))];
88
+ }
89
+ return [...own, ...inherited.filter((p) => !own.some((o) => o.prefix === p.prefix))];
90
+ };
91
+ return read("design/tsconfig.json", 0);
92
+ }
93
+ function publishGraph(root) {
94
+ const files = /* @__PURE__ */ new Set();
95
+ const unresolved = [];
96
+ const boards = publishedBoards(root);
97
+ if (!boards.length) return {
98
+ files,
99
+ unresolved
100
+ };
101
+ const rel = (abs) => relative(root, abs).split(sep).join("/");
102
+ const isFile = (p) => {
103
+ try {
104
+ return statSync(join(root, p)).isFile();
105
+ } catch {
106
+ return false;
107
+ }
108
+ };
109
+ const manifest = scanFrames(root);
110
+ let wanted = /* @__PURE__ */ new Set();
111
+ for (const b of boards) {
112
+ if (b === "all-scenes") {
113
+ wanted = null;
114
+ break;
115
+ }
116
+ try {
117
+ const j = JSON.parse(readFileSync(join(root, "design", "boards", `${b}.json`), "utf8"));
118
+ for (const n of Array.isArray(j?.nodes) ? j.nodes : []) if (typeof n?.frame === "string") wanted.add(n.frame);
119
+ } catch {}
120
+ }
121
+ const entries = manifest.frames.filter((f) => !wanted || wanted.has(f.id));
122
+ const frames = entries.map((f) => rel(join(root, f.file)));
123
+ const queue = [...frames];
124
+ for (const [i, f] of frames.entries()) {
125
+ const scene = entries[i].scene;
126
+ for (const dir of /* @__PURE__ */ new Set([scene ? `design/scenes/${scene}` : dirname(f), dirname(f)])) for (const n of ["_brief.md", "_note.md"]) if (isFile(`${dir}/${n}`)) queue.push(`${dir}/${n}`);
127
+ const note = f.replace(/\.(tsx|jsx|html)$/, ".note.md");
128
+ if (isFile(note)) queue.push(note);
129
+ }
130
+ for (const f of frames) {
131
+ const base = f.startsWith("design/components/") ? "design/components" : "design/scenes";
132
+ let dir = dirname(f);
133
+ while (dir.length >= base.length) {
134
+ for (const ext of ["tsx", "jsx"]) if (isFile(`${dir}/_layout.${ext}`)) queue.push(`${dir}/_layout.${ext}`);
135
+ if (dir === base) break;
136
+ dir = dirname(dir);
137
+ }
138
+ }
139
+ for (const ext of ["tsx", "jsx"]) if (isFile(`design/providers.${ext}`)) queue.push(`design/providers.${ext}`);
140
+ const al = aliases(root);
141
+ const resolve = (from, spec) => {
142
+ const bare = spec.replace(/\?[a-z]+$/, "");
143
+ let bases = [];
144
+ if (bare.startsWith(".")) bases = [normalize(join(dirname(from), bare))];
145
+ else if (bare.startsWith("/")) bases = [normalize(bare.slice(1))];
146
+ else {
147
+ const a = al.find((x) => bare.startsWith(x.prefix) && x.prefix);
148
+ if (!a) return "";
149
+ bases = a.targets.map((t) => normalize(join(t, bare.slice(a.prefix.length))));
150
+ }
151
+ for (const b of bases) for (const cand of [
152
+ b,
153
+ `${b}.ts`,
154
+ `${b}.tsx`,
155
+ `${b}.js`,
156
+ `${b}.jsx`,
157
+ `${b}/index.ts`,
158
+ `${b}/index.tsx`
159
+ ]) if (!cand.startsWith("..") && isFile(cand)) return cand.split(sep).join("/");
160
+ return null;
161
+ };
162
+ while (queue.length) {
163
+ const p = queue.pop();
164
+ if (files.has(p)) continue;
165
+ files.add(p);
166
+ if (!CODE.test(p)) continue;
167
+ let text = "";
168
+ try {
169
+ text = readFileSync(join(root, p), "utf8");
170
+ } catch {
171
+ continue;
172
+ }
173
+ for (const m of text.matchAll(IMPORT)) {
174
+ const spec = m[1] || m[2] || m[3] || m[4] || "";
175
+ if (!spec) continue;
176
+ const r = resolve(p, spec);
177
+ if (r === "") continue;
178
+ if (r === null) {
179
+ if (spec.startsWith(".") || spec.startsWith("/") || al.some((x) => x.prefix && spec.startsWith(x.prefix))) unresolved.push({
180
+ from: p,
181
+ spec
182
+ });
183
+ continue;
184
+ }
185
+ queue.push(r);
186
+ }
187
+ }
188
+ return {
189
+ files,
190
+ unresolved
191
+ };
192
+ }
193
+ //#endregion
194
+ //#region src/cli/context.ts
195
+ /**
196
+ * `marver context` (spec 19) - the conventions P0 proved, for any repository:
197
+ * init create context/ (the index, the shipped record, the map, the playbooks Marver maintains)
198
+ * check keep it true - deterministic, run in ci; exit 0 pass, 1 fail, 2 cannot determine
199
+ * index rewrite the index's generated capability table from the map
200
+ * The parsing is shared with the dev server (shared/context.ts), so the canvas's statuses and the
201
+ * check read the files one way.
202
+ */
203
+ const INDEX_BUDGET = 800;
204
+ const CONTEXT = "context";
205
+ /** Create context/ - never overwriting. The playbooks are managed: pristine ones take Marver's
206
+ * updates on a re-run, edited ones are kept and the new version staged for a merge. */
207
+ function contextInit(root, kind = "product") {
208
+ const created = [];
209
+ const templates = join(pkgDir(), "templates");
210
+ const name = projectName(root);
211
+ const bad = checkRealDirs(root, [[join(root, CONTEXT), `${CONTEXT}/`], [join(root, CONTEXT, "playbooks"), `${CONTEXT}/playbooks/`]]);
212
+ if (bad) throw new Error(bad);
213
+ const plain = (rel, body) => {
214
+ const file = join(root, CONTEXT, rel);
215
+ mkdirFor(file);
216
+ try {
217
+ writeFileSync(file, body, { flag: "wx" });
218
+ created.push(`${CONTEXT}/${rel}`);
219
+ } catch (e) {
220
+ if (e.code !== "EEXIST") throw e;
221
+ }
222
+ };
223
+ plain("INDEX.md", readFileSync(join(templates, "context", "INDEX.md"), "utf8").replaceAll("{{NAME}}", name).replace("{{RECORD_QUESTION}}", kind === "knowledge" ? "Was it delivered, to whom, and when?" : "Is it available, where, for whom, since when?"));
224
+ plain("shipped.md", readFileSync(join(templates, "context", kind === "knowledge" ? "shipped-knowledge.md" : "shipped.md"), "utf8"));
225
+ plain("map.json", readFileSync(join(templates, "context", "map.json"), "utf8"));
226
+ const pbRoot = join(templates, "playbooks");
227
+ for (const pb of readdirSync(pbRoot)) for (const f of readdirSync(join(pbRoot, pb)).filter((x) => x.endsWith(".md"))) {
228
+ const rel = `playbooks/${pb}/${f}`;
229
+ writeManaged({
230
+ base: join(root, CONTEXT),
231
+ rel,
232
+ body: readFileSync(join(pbRoot, pb, f), "utf8"),
233
+ shown: `${CONTEXT}/${rel}`,
234
+ stageDir: join(root, "design", ".local", "latest", CONTEXT),
235
+ created,
236
+ rerun: `\`npx ${NAME} context init\``
237
+ });
238
+ }
239
+ if (!existsSync(join(root, "design", "instructions", "context.md"))) writeManaged({
240
+ base: join(root, CONTEXT),
241
+ rel: "README.md",
242
+ body: readFileSync(join(templates, "instructions", "context.md"), "utf8"),
243
+ shown: `${CONTEXT}/README.md`,
244
+ stageDir: join(root, "design", ".local", "latest", CONTEXT),
245
+ created,
246
+ rerun: `\`npx ${NAME} context init\``
247
+ });
248
+ const agents = join(root, "AGENTS.md");
249
+ const route = "Any question about the product - what is available, how it works, why, what is next: start at context/INDEX.md.";
250
+ for (let attempt = 0; attempt < 5; attempt++) {
251
+ const cur = existsSync(agents) ? readFileSync(agents, "utf8") : null;
252
+ if (cur !== null && cur.includes("context/INDEX.md")) break;
253
+ if (cur === null) try {
254
+ writeFileSync(agents, `# ${name}\n\n${route}\n`, { flag: "wx" });
255
+ created.push("AGENTS.md");
256
+ break;
257
+ } catch (e) {
258
+ if (e.code === "EEXIST") continue;
259
+ throw e;
260
+ }
261
+ const tmp = `${agents}.${process.pid}.${attempt}.tmp`;
262
+ writeFileSync(tmp, cur.replace(/\n*$/, "") + `\n\n${route}\n`, { flag: "wx" });
263
+ if (readFileSync(agents, "utf8") !== cur) {
264
+ rmSync(tmp, { force: true });
265
+ continue;
266
+ }
267
+ renameSync(tmp, agents);
268
+ created.push("AGENTS.md (updated)");
269
+ break;
270
+ }
271
+ return created;
272
+ }
273
+ function projectName(root) {
274
+ try {
275
+ const n = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name;
276
+ if (typeof n === "string" && n) return n.replace(/^@[^/]+\//, "");
277
+ } catch {}
278
+ return basename(root);
279
+ }
280
+ const mkdirFor = (file) => mkdirSync(dirname(file), { recursive: true });
281
+ /** Rewrite the index's generated table from the map. Returns the words now in the index. */
282
+ function contextIndex(root) {
283
+ const indexFile = join(root, CONTEXT, "INDEX.md");
284
+ const mapFile = join(root, CONTEXT, "map.json");
285
+ if (!existsSync(indexFile)) throw new Error(`${CONTEXT}/INDEX.md is missing - run \`npx ${NAME} context init\``);
286
+ if (!existsSync(mapFile)) throw new Error(`${CONTEXT}/map.json is missing - run \`npx ${NAME} context init\``);
287
+ const map = parseMap(readFileSync(mapFile, "utf8"));
288
+ if (typeof map === "string") throw new Error(map);
289
+ const text = readFileSync(indexFile, "utf8");
290
+ const m = GENERATED.exec(text);
291
+ if (!m) throw new Error(`${CONTEXT}/INDEX.md has no <!-- generated --> ... <!-- /generated --> fences for the capability table`);
292
+ const opening = text.slice(m.index, m.index + m[0].indexOf("-->") + 3);
293
+ const next = text.slice(0, m.index) + `${opening}\n${capabilityTable(map)}\n<!-- /generated -->` + text.slice(m.index + m[0].length);
294
+ if (next !== text) writeFileSync(indexFile, next);
295
+ return {
296
+ words: wordCount(next),
297
+ changed: next !== text
298
+ };
299
+ }
300
+ /** The supersession smells a current document never carries. */
301
+ const SUPERSEDED = /wins where it differs|wins over sections?|overrides (section|§)|section \d+ wins|supersedes section|amended by section/i;
302
+ /** Availability is the shipped record's alone - a contract saying what is live is the smell. */
303
+ const AVAILABILITY = /\b(on production|on staging|in production since|live since|went live|deployed to|released to|rolled out to|shipped (on|to|in|since)|(was|were|been) delivered (to|on)|delivered on \d|handed over (to|on))\b/i;
304
+ const CONTRACT_STATES = [
305
+ "current",
306
+ "proposed",
307
+ "historical"
308
+ ];
309
+ const FEEDBACK_STATES = [
310
+ "new",
311
+ "triaged",
312
+ "proposed",
313
+ "planned",
314
+ "shipped",
315
+ "declined"
316
+ ];
317
+ const LINK = /\[[^\]]*\]\(([^)\s]+)\)/g;
318
+ /** A `path:line` citation - a path from the root, or a bare name the resolver looks up by basename
319
+ * (`run.ts:12` when one tracked file is run.ts; shorthand for the file named before it when several are). */
320
+ const CITE = /`((?:\.{0,2}[\w@.-]+\/)*[\w@.$-]+\.[a-z]{1,5}):(\d+)(?:-(\d+))?`/g;
321
+ function contextCheck(root, opts = {}) {
322
+ const failures = [], unsure = [], notes = [];
323
+ const fail = (rule, where, what) => failures.push({
324
+ rule,
325
+ where,
326
+ what
327
+ });
328
+ const cannot = (rule, what) => unsure.push({
329
+ rule,
330
+ where: "",
331
+ what
332
+ });
333
+ const note = (rule, what) => notes.push({
334
+ rule,
335
+ where: "",
336
+ what
337
+ });
338
+ const exists = (p) => existsSync(join(root, p));
339
+ const read = (p) => readFileSync(join(root, p), "utf8");
340
+ const git = (...args) => execFileSync("git", args, {
341
+ cwd: root,
342
+ encoding: "utf8",
343
+ stdio: [
344
+ "ignore",
345
+ "pipe",
346
+ "pipe"
347
+ ],
348
+ maxBuffer: 536870912
349
+ });
350
+ if (!exists(CONTEXT)) {
351
+ fail("context", `${CONTEXT}/`, `missing - run \`npx ${NAME} context init\``);
352
+ return {
353
+ exit: 1,
354
+ failures,
355
+ unsure,
356
+ notes
357
+ };
358
+ }
359
+ const walk = (dir) => {
360
+ if (!exists(dir)) return [];
361
+ const out = [];
362
+ for (const n of readdirSync(join(root, dir))) {
363
+ const p = `${dir}/${n}`;
364
+ const st = lstatSync(join(root, p));
365
+ if (st.isSymbolicLink()) continue;
366
+ if (st.isDirectory()) out.push(...walk(p));
367
+ else out.push(p);
368
+ }
369
+ return out;
370
+ };
371
+ const docs = walk(CONTEXT).filter((p) => p.endsWith(".md")).map((path) => {
372
+ const text = read(path);
373
+ return {
374
+ path,
375
+ text,
376
+ ...frontMatter(text)
377
+ };
378
+ });
379
+ for (const d of docs) if (d.error) fail("front-matter", d.path, d.error);
380
+ const isContract = (d) => d.data?.state === "current" && /^context\/(product|system)\//.test(d.path);
381
+ for (const d of docs.filter(isContract)) for (const [n, line] of proseLines(d.body, d.offset)) {
382
+ if (/^\s*\**status\**\s*:/i.test(line)) fail("status-line", `${d.path}:${n}`, `a status line in a current contract - status lives in ${CONTEXT}/shipped.md`);
383
+ const a = line.match(AVAILABILITY);
384
+ if (a) fail("availability", `${d.path}:${n}`, `"${a[0]}" - availability lives in ${CONTEXT}/shipped.md`);
385
+ }
386
+ for (const d of docs.filter((x) => x.data?.state === "current")) for (const [n, line] of proseLines(d.body, d.offset)) {
387
+ const m = line.match(SUPERSEDED);
388
+ if (m) fail("supersession", `${d.path}:${n}`, `"${m[0]}" - fold the amendment in; one answer per rule`);
389
+ }
390
+ let tracked = null;
391
+ try {
392
+ tracked = new Set(git("ls-files", "-z").split("\0").filter(Boolean));
393
+ } catch {
394
+ cannot("git", "git ls-files failed - not a repository?");
395
+ }
396
+ /** A cited file, resolved: a path from the root, or a bare name (`run.test.ts`) that one tracked
397
+ * file carries - several mean the citation is shorthand for the one named before it. Returns the
398
+ * path, 'ambiguous', or null when nothing carries it. */
399
+ const byBase = /* @__PURE__ */ new Map();
400
+ if (tracked) for (const t of tracked) {
401
+ const b = t.slice(t.lastIndexOf("/") + 1);
402
+ byBase.set(b, [...byBase.get(b) ?? [], t]);
403
+ }
404
+ const resolveCited = (f) => {
405
+ if (exists(f)) return statSync(join(root, f)).isFile() ? f : null;
406
+ if (f.includes("/")) return null;
407
+ const hits = byBase.get(f) ?? [];
408
+ return hits.length === 1 ? hits[0] : hits.length > 1 ? "ambiguous" : null;
409
+ };
410
+ const lines = /* @__PURE__ */ new Map();
411
+ const linesOf = (p) => {
412
+ if (!lines.has(p)) lines.set(p, read(p).split("\n").length);
413
+ return lines.get(p);
414
+ };
415
+ /** What is wrong with a citation - a file nothing carries, a line past its end, a range backwards - or null. */
416
+ const citationProblem = (file, from, to) => {
417
+ const f = file.replace(/^\.\//, "");
418
+ const r = resolveCited(f);
419
+ if (r === null) return `${f} does not exist`;
420
+ const a = Number(from), z = Number(to ?? from);
421
+ if (from && z < a) return `${f}:${from}-${to} runs backwards`;
422
+ if (r === "ambiguous" || !from) return null;
423
+ if (z > linesOf(r)) return `${f}:${from}${to ? `-${to}` : ""} is past the end of ${r} (${linesOf(r)} lines)`;
424
+ return null;
425
+ };
426
+ const shippedPath = `${CONTEXT}/shipped.md`;
427
+ if (!exists(shippedPath)) fail("shipped", shippedPath, "missing");
428
+ else {
429
+ const text = read(shippedPath);
430
+ if (!tables(text).some(isRecordTable)) fail("shipped-shape", shippedPath, "no Capability (or Project) table with an Available (or Delivered) column - the canvas cannot read a status from it");
431
+ for (const n of looseTables(text)) fail("table-format", `${shippedPath}:${n}`, "a table without outer pipes - start and end every row with |");
432
+ for (const t of tables(text)) for (const r of t.rows) {
433
+ const where = `${shippedPath}:${r.line}`;
434
+ if (!r.cells.some((c) => levelsIn(c).length)) fail("shipped-level", where, "a row with no evidence level");
435
+ r.cells.forEach((c, i) => {
436
+ if (EVIDENCE_COLUMN.test(t.header[i] ?? "") && !/^nowhere\b/i.test(c) && !levelsIn(c).length) fail("shipped-level", where, `the ${t.header[i]} cell has no evidence level`);
437
+ for (const m of c.matchAll(LEVEL)) if (m[1] !== "unknown" && !CITATION.test(c)) fail("shipped-citation", where, `\`${m[1]}\` with no citation in its cell`);
438
+ if (levelsIn(c).length) for (const m of c.matchAll(CITED_FILE)) {
439
+ const problem = citationProblem(m[1], m[2], m[3]);
440
+ if (problem) fail("dead-citation", where, problem);
441
+ }
442
+ });
443
+ }
444
+ }
445
+ for (const d of docs) for (const [n, line] of proseLines(d.body, d.offset)) {
446
+ for (const m of line.matchAll(LINK)) {
447
+ const href = m[1].split("#")[0];
448
+ if (!href || /^[a-z]+:/i.test(href)) continue;
449
+ if (!exists(normalize(join(dirname(d.path), href)))) fail("dead-link", `${d.path}:${n}`, `${m[1]} does not exist`);
450
+ }
451
+ if (d.data?.state === "historical") continue;
452
+ for (const m of line.matchAll(CITE)) {
453
+ const problem = citationProblem(m[1], m[2], m[3]);
454
+ if (problem) fail("dead-citation", `${d.path}:${n}`, problem);
455
+ }
456
+ }
457
+ let map = null;
458
+ const mapPath = `${CONTEXT}/map.json`;
459
+ if (!exists(mapPath)) fail("map", mapPath, "missing");
460
+ else {
461
+ const m = parseMap(read(mapPath));
462
+ if (typeof m === "string") fail("map", mapPath, m);
463
+ else map = m;
464
+ }
465
+ const indexPath = `${CONTEXT}/INDEX.md`;
466
+ if (!exists(indexPath)) fail("index", indexPath, "missing");
467
+ else {
468
+ const text = read(indexPath);
469
+ const words = wordCount(text);
470
+ if (words > 800) fail("index-budget", indexPath, `${words} words, budget 800`);
471
+ else note("index", `${words} / 800 words`);
472
+ if (map) {
473
+ const fenced = GENERATED.exec(text)?.[1];
474
+ if (fenced === void 0) fail("index-table", indexPath, "no <!-- generated --> fences around the capability table");
475
+ else if (capabilityTable(map).trim() !== lf(fenced).trim()) fail("index-table", indexPath, `the capability table differs from ${mapPath} - run \`npx ${NAME} context index\``);
476
+ }
477
+ }
478
+ if (tracked) {
479
+ for (const d of docs) if (d.data?.audience === "restricted" && tracked.has(d.path)) fail("audience", d.path, "restricted, yet tracked by git");
480
+ }
481
+ const graph = publishGraph(root);
482
+ let inContext = 0;
483
+ for (const p of graph.files) {
484
+ const declared = /\.mdx?$/.test(p) ? frontMatter(read(p)).data?.audience : void 0;
485
+ if (p.startsWith(`${CONTEXT}/`)) {
486
+ inContext++;
487
+ const audience = typeof declared === "string" ? declared : "team";
488
+ if (audience !== "publishable") fail("audience", p, `${audience} material reachable from design/publish.json`);
489
+ } else if (typeof declared === "string" && declared !== "publishable") fail("audience", p, `${declared} material reachable from design/publish.json`);
490
+ }
491
+ for (const u of graph.unresolved) if (/context\//.test(u.spec)) cannot("audience", `${u.from} imports ${u.spec}, which does not resolve - cannot tell what it would publish`);
492
+ note("audience", `${graph.files.size} files reachable from design/publish.json; ${inContext} under ${CONTEXT}/`);
493
+ for (const d of docs) {
494
+ if (d.path.startsWith(`${CONTEXT}/product/`) && d.data && !CONTRACT_STATES.includes(String(d.data.state))) fail("state", d.path, `state "${String(d.data.state)}" is not a contract state (${CONTRACT_STATES.join(", ")})`);
495
+ if (!d.path.startsWith(`${CONTEXT}/feedback/`)) continue;
496
+ for (const n of looseTables(d.text)) fail("table-format", `${d.path}:${n}`, "a table without outer pipes - start and end every row with |");
497
+ for (const t of tables(d.text)) {
498
+ const si = t.header.findIndex((h) => /^state$/i.test(h));
499
+ if (si < 0) continue;
500
+ let ri = t.header.findIndex((h) => /resolv/i.test(h));
501
+ if (ri < 0) ri = t.header.length - 1;
502
+ for (const r of t.rows) {
503
+ const state = (r.cells[si] ?? "").replace(/`/g, "").trim();
504
+ const where = `${d.path}:${r.line}`;
505
+ if (!FEEDBACK_STATES.includes(state)) fail("state", where, `"${state}" is not a feedback state (${FEEDBACK_STATES.join(", ")})`);
506
+ const resolution = r.cells[ri] ?? "";
507
+ if (state !== "shipped") continue;
508
+ const claims = resolution.split(";").filter((c) => !/\b(implement(ed|ation)|built|merged|coded|tested|test suite|ci)\b/i.test(c) || /\b(available|availability|production|live|delivered)\b/i.test(c)).join(";");
509
+ if (ri === si || !availableLevels(claims).includes("confirmed") || !CITATION.test(resolution) || /availab\w*[^;]*`unknown`/i.test(resolution)) fail("feedback-closed", where, "shipped without a cited `confirmed` availability in its resolution");
510
+ for (const m of resolution.matchAll(CITED_FILE)) {
511
+ const problem = citationProblem(m[1], m[2], m[3]);
512
+ if (problem) fail("dead-citation", where, problem);
513
+ }
514
+ }
515
+ }
516
+ }
517
+ if (map) {
518
+ for (const [cap, c] of Object.entries(map.capabilities)) {
519
+ if (c.contract && !exists(c.contract)) fail("map", mapPath, `${cap}: contract ${c.contract} does not exist`);
520
+ for (const t of c.tests ?? []) if (!(hasGlob(t) ? tracked ? [...tracked].some((f) => globRe(t).test(f)) : true : exists(t))) fail("map", mapPath, `${cap}: test ${t} ${hasGlob(t) ? "matches no tracked file" : "does not exist"}`);
521
+ }
522
+ if (tracked && map.source?.length) {
523
+ const inSource = map.source.map(globRe);
524
+ const covered = [...Object.values(map.capabilities).flatMap((c) => [...c.paths, ...c.tests ?? []]), ...map.excluded.map((e) => e.path)].map(globRe);
525
+ const loose = [...tracked].filter((p) => inSource.some((r) => r.test(p)) && !covered.some((r) => r.test(p)));
526
+ note("map", loose.length ? `${loose.length} source files neither mapped nor excluded, e.g. ${loose.slice(0, 5).join(", ")}` : "every source file is mapped or excluded");
527
+ }
528
+ }
529
+ const boardsDir = join(root, "design", "boards");
530
+ if (existsSync(boardsDir)) for (const f of readdirSync(boardsDir).filter((x) => x.endsWith(".json") && !x.startsWith("_"))) try {
531
+ const j = JSON.parse(readFileSync(join(boardsDir, f), "utf8"));
532
+ if (readStatusWord(j?.status) === "done") fail("board-status", `design/boards/${f}`, "\"status\": \"done\" - Done comes only from the shipped record; remove it");
533
+ } catch {}
534
+ for (const d of docs.filter((x) => /\/playbooks\/[^/]+\/PLAYBOOK\.md$/.test(x.path))) {
535
+ const ls = d.data?.last_success;
536
+ const rev = typeof ls === "object" && ls ? ls.revision : void 0;
537
+ const all = [].concat(d.data?.depends_on ?? []).filter((x) => typeof x === "string");
538
+ const deps = all.filter((x) => !/^(service|account)\s*:/.test(x));
539
+ const outside = all.length - deps.length;
540
+ if (typeof rev !== "string") {
541
+ note("playbook", `${d.path}: unknown - no last_success.revision`);
542
+ continue;
543
+ }
544
+ try {
545
+ git("cat-file", "-e", `${rev}^{commit}`);
546
+ const changed = deps.length ? git("diff", "--name-only", rev, "--", ...deps).trim() : "";
547
+ note("playbook", `${d.path}: ${changed ? `stale - ${changed.split("\n").length} dependencies changed since ${rev}` : outside ? `unknown - its files are unchanged since ${rev}, ${outside} dependenc${outside === 1 ? "y" : "ies"} outside git (a service, an account) cannot be checked` : `current since ${rev} (working tree included)`}`);
548
+ } catch {
549
+ note("playbook", `${d.path}: unknown - ${rev} is not in this clone's history`);
550
+ }
551
+ }
552
+ if (opts.prError) cannot("pr", opts.prError);
553
+ if (opts.base && map) {
554
+ let changed = null;
555
+ try {
556
+ changed = git("diff", "--name-only", "-z", `${opts.base}...${opts.head ?? "HEAD"}`).split("\0").filter(Boolean);
557
+ } catch {
558
+ cannot("pr", `no merge base between ${opts.base} and ${opts.head ?? "HEAD"} - fetch full history (fetch-depth: 0)`);
559
+ }
560
+ if (changed) for (const [cap, c] of Object.entries(map.capabilities)) {
561
+ if (!c.contract) continue;
562
+ const res = c.paths.map(globRe);
563
+ const hit = changed.filter((p) => res.some((r) => r.test(p)));
564
+ if (!hit.length || changed.includes(c.contract)) continue;
565
+ if (!new RegExp(`no-contract-change:\\s*${cap}\\s*-\\s*\\S`, "i").test(opts.body ?? "")) fail("pr", cap, `changes ${hit.length} mapped file(s) (${hit.slice(0, 3).join(", ")}) without ${c.contract} - update it, or write "no-contract-change: ${cap} - <why>" in the pull request`);
566
+ }
567
+ }
568
+ return {
569
+ exit: failures.length ? 1 : unsure.length ? 2 : 0,
570
+ failures,
571
+ unsure,
572
+ notes
573
+ };
574
+ }
575
+ /** The pull request this ci run checks, from GitHub's event payload: base, head and body. */
576
+ function pullRequestFromEnv(env = process.env) {
577
+ if (!/^pull_request(_target)?$/.test(env.GITHUB_EVENT_NAME ?? "")) return null;
578
+ try {
579
+ const pr = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH ?? "", "utf8")).pull_request;
580
+ if (typeof pr?.base?.sha !== "string" || typeof pr?.head?.sha !== "string") throw new Error("no base and head in the event");
581
+ return {
582
+ base: pr.base.sha,
583
+ head: pr.head.sha,
584
+ body: typeof pr.body === "string" ? pr.body : ""
585
+ };
586
+ } catch (e) {
587
+ return { prError: `the pull request event (GITHUB_EVENT_PATH) could not be read: ${e.message}` };
588
+ }
589
+ }
590
+ /** The human report. */
591
+ function printCheck(r) {
592
+ for (const n of r.notes) console.log(` ${n.rule} ${n.what}`);
593
+ for (const u of r.unsure) console.log(`? ${u.rule} cannot determine: ${u.what}`);
594
+ for (const f of r.failures) console.log(`✗ ${f.rule} ${f.where} ${f.what}`);
595
+ console.log(`\ncontext check: ${r.exit === 0 ? "pass" : r.exit === 1 ? `${r.failures.length} failing` : "cannot determine"}`);
596
+ }
597
+ /** Where this repository's context conventions live - the canvas's instruction, else context/README.md. */
598
+ const conventionsPath = (root) => existsSync(join(root, "design", "instructions", "context.md")) ? "design/instructions/context.md" : `${CONTEXT}/README.md`;
599
+ //#endregion
600
+ export { INDEX_BUDGET, contextCheck, contextIndex, contextInit, conventionsPath, printCheck, pullRequestFromEnv };
@@ -1,7 +1,7 @@
1
1
  import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
3
  import { n as localProfile } from "./profile-BjAPAJSb.mjs";
4
- import { a as toFrameId } from "./manifest-B01PSyDc.mjs";
4
+ import { a as toFrameId } from "./manifest-BqBcMJcd.mjs";
5
5
  import { workActivity } from "./work-CLrmY-vQ.mjs";
6
6
  import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
7
7
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
@@ -1,6 +1,6 @@
1
1
  import { i as PKG, r as NAME } from "./cli.mjs";
2
- import { c as loadConfig, u as detectHost } from "./manifest-B01PSyDc.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-y4Ch7o_A.mjs";
2
+ import { i as detectHost, n as loadConfig } from "./config-DJxMRVD8.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-8XntSCx_.mjs";
4
4
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -413,7 +413,9 @@ async function dev(root, portFlag) {
413
413
  "**/coverage/**",
414
414
  "**/.gstack/**",
415
415
  "**/.git/**",
416
- "**/.playwright-mcp/**"
416
+ "**/.playwright-mcp/**",
417
+ "**/context/map.json",
418
+ "**/context/sync/**"
417
419
  ] },
418
420
  warmup: { clientFiles: [
419
421
  join(clientDir, "frame-host", "main.tsx"),
@@ -452,12 +454,12 @@ async function dev(root, portFlag) {
452
454
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
453
455
  setTimeout(async () => {
454
456
  try {
455
- await (await import("./shot-iw3SEcpn.mjs")).sweepGhosts((m) => console.log(` ${m}`));
457
+ await (await import("./shot-C22Ues04.mjs")).sweepGhosts((m) => console.log(` ${m}`));
456
458
  } catch {}
457
459
  }, 0);
458
460
  try {
459
461
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
460
- const { installedVersion } = await import("./plugin-y4Ch7o_A.mjs").then((n) => n.i);
462
+ const { installedVersion } = await import("./plugin-8XntSCx_.mjs").then((n) => n.i);
461
463
  const stale = staleManagedInstructions(root);
462
464
  if (stale.length) {
463
465
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -495,7 +497,7 @@ async function dev(root, portFlag) {
495
497
  });
496
498
  }
497
499
  if (config.jam) {
498
- const { startJam } = await import("./daemon-DbHvLQUL.mjs");
500
+ const { startJam } = await import("./daemon-BmkwErpC.mjs");
499
501
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
500
502
  if (jam) {
501
503
  const close = server.close.bind(server);