@augurworks/augur 0.15.1

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 (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ui-copy-lint — the empty states are a ghost and ONE short line, in a human voice.
4
+ *
5
+ * WHY THIS EXISTS. Nothing in this repo has ever constrained the text of an empty state:
6
+ * no test, no script, no build assertion. Every commit that ever touched `emptyState` is
7
+ * build.js-only, including 607c3db6, which fixed a first sentence that described a page
8
+ * that does not exist. That bug was found by a person opening the page in a browser, and
9
+ * the fix left behind no mechanism that would find the next one. Meanwhile the copy grew:
10
+ * eight surfaces, two paragraphs each, a mean sentence of 49.8 words, and two of them
11
+ * naming another company's product. The instruction that reset it was
12
+ * "novels full of claudisms (em dashes, expressions, terrible)".
13
+ *
14
+ * WHAT IT CAN AND CANNOT DO. It cannot tell whether a sentence is TRUE — the false first
15
+ * sentence would have passed every rule below — and it cannot tell whether writing is
16
+ * good. It catches the specific tics that produced the last one, and the structural rule
17
+ * that keeps a surface from growing back into a novel: one paragraph, twenty words.
18
+ * Truth is still a review question, and the Tokens renderer carries a comment saying so
19
+ * for the three readers who land on its empty branch.
20
+ *
21
+ * WHAT IT READS. Two closed sets. The `emptyState(...)` call sites and the `addHint:`
22
+ * strings in build.js, which are the copy a person meets on a blank workspace. And the
23
+ * copy-bearing fields of src/mail.mjs — `subject:`, `footer:`, `detail:`, `message:` and
24
+ * every string a function returns — which are the copy a person meets in their inbox and
25
+ * the copy an operator meets beside the invite link. The mail file was written after this
26
+ * guard and so was never in front of it. That is the failure to watch for: a copy surface
27
+ * this file does not name is a copy surface nothing checks.
28
+ *
29
+ * THE MAIL EXTRACTOR IS FIELD-SHAPED, NOT A PARSER, AND THAT IS THE SAFE CHOICE. mail.mjs
30
+ * holds a regex containing a quote character and comments full of apostrophes, so a
31
+ * hand-rolled string tokenizer desynchronises on it and starts reporting a developer's
32
+ * prose as a user's — a wrong scan is worse than a narrow one. Only the DASH rule is
33
+ * applied there: the semicolon rule would fire on the inline CSS in the HTML mail shell,
34
+ * which is markup, not writing.
35
+ *
36
+ * Run: node scripts/ui-copy-lint.mjs [ROOT] (exit 1 on any finding)
37
+ */
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ const ROOT = process.argv[2] || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
43
+ const BUILD = path.join(ROOT, "build.js");
44
+
45
+ const MAX_WORDS = 20; // A house sentence runs ~11 words; 20 is the ceiling, not the target.
46
+ const MAX_PARAS = 1; // The ghost does the explaining. A second paragraph is the novel growing back.
47
+ const MAX_HINT_WORDS = 34; // A populated tab's caption may say a little more; it is read beside content.
48
+
49
+ /** Pull the template literals passed to each `emptyState(` call, with their line numbers. */
50
+ function emptyStateCalls(src) {
51
+ const out = [];
52
+ const NEEDLE = "emptyState(";
53
+ for (let i = src.indexOf(NEEDLE); i !== -1; i = src.indexOf(NEEDLE, i + 1)) {
54
+ // Skip a mention inside a comment or a name like `renderEmptyState(`.
55
+ const prev = src[i - 1];
56
+ if (prev && /[A-Za-z0-9_$]/.test(prev)) continue;
57
+ let d = 1, j = i + NEEDLE.length;
58
+ const paras = [];
59
+ while (j < src.length && d > 0) {
60
+ const c = src[j];
61
+ if (c === "(") d++;
62
+ else if (c === ")") d--;
63
+ else if (c === "`" && d === 1) {
64
+ // Read to the closing backtick, honouring escapes and ${...} spans.
65
+ let k = j + 1, lit = "";
66
+ while (k < src.length) {
67
+ if (src[k] === "\\") { lit += src[k + 1]; k += 2; continue; }
68
+ if (src[k] === "`") break;
69
+ if (src[k] === "$" && src[k + 1] === "{") {
70
+ let dd = 1; k += 2;
71
+ while (k < src.length && dd > 0) { if (src[k] === "{") dd++; else if (src[k] === "}") dd--; k++; }
72
+ lit += "X"; // an interpolation stands in as one word
73
+ continue;
74
+ }
75
+ lit += src[k]; k++;
76
+ }
77
+ paras.push(lit);
78
+ j = k;
79
+ }
80
+ j++;
81
+ }
82
+ out.push({ line: src.slice(0, i).split("\n").length, paras });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** `addHint: "..."` — the caption on a POPULATED tier. */
88
+ function addHints(src) {
89
+ const out = [];
90
+ const re = /addHint:\s*"((?:[^"\\]|\\.)*)"/g;
91
+ for (const m of src.matchAll(re)) {
92
+ out.push({ line: src.slice(0, m.index).split("\n").length, text: m[1].replace(/\\"/g, '"') });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** The copy-bearing fields of src/mail.mjs, with their line numbers. A `subject:`,
98
+ * `footer:`, `detail:` or `message:` key followed by a literal, and every literal a
99
+ * function returns. No comment in that file contains either shape, so nothing here needs
100
+ * a parser and nothing here mistakes a developer's sentence for a user's. */
101
+ const LIT = String.raw`("(?:[^"\\]|\\.)*"|\x60(?:[^\x60\\]|\\.)*\x60)`;
102
+ const MAIL_FIELDS = [
103
+ new RegExp(String.raw`\b(?:subject|footer|detail|message):\s*` + LIT, "g"),
104
+ new RegExp(String.raw`\breturn\s+` + LIT + String.raw`\s*;`, "g"),
105
+ ];
106
+ function mailCopy(src) {
107
+ const out = [], seen = new Set();
108
+ for (const re of MAIL_FIELDS) {
109
+ for (const m of src.matchAll(re)) {
110
+ const line = src.slice(0, m.index).split("\n").length;
111
+ const key = line + "|" + m[1];
112
+ if (seen.has(key)) continue;
113
+ seen.add(key);
114
+ out.push({ line, text: m[1].slice(1, -1) });
115
+ }
116
+ }
117
+ return out.sort((a, b) => a.line - b.line);
118
+ }
119
+
120
+ const stripTags = (s) => s.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
121
+ const words = (s) => stripTags(s).split(/\s+/).filter(Boolean).length;
122
+
123
+ // The tics, each one quoted from the copy this replaced.
124
+ const TICS = [
125
+ [/[—–]/, "an em or en dash. Standing instruction, given three times: use a comma or a full stop."],
126
+ [/;/, "a semicolon. It is the pivot the generated voice leans on, and it is close to absent from the writing this copy is meant to sound like."],
127
+ [/\bthe honest way\b/i, '"the honest way" — an epistemic flourish, not information.'],
128
+ [/\bwhich is the point\b/i, '"which is the point" — a closing clause that re-states the opening.'],
129
+ [/\bworth having\b/i, '"worth having".'],
130
+ [/\breads? as\b/i, '"reads as" — the house way of describing an impression. Say what it IS.'],
131
+ [/\bnot\s+[a-z][\w' ]{0,28},\s*(?:but|it is|these are|they are)\b/i, 'the "not X, it is Y" antithesis. Define positively.'],
132
+ [/<em>/i, "an <em>. That styling exists to make a quoted agent prompt liftable, and pasteable prompts are out."],
133
+ [/agents\/[\w-]+\.md|registry\.json|space\.json|component-meta\.json|skill\.json/, "a filename. This copy is for a person instructing an agent, and an agent finds the contract by reading the repo."],
134
+ [/&ldquo;|&rdquo;|“|”/, "a quoted incantation. No pasteable agent prompts in the UI."],
135
+ ];
136
+
137
+ const findings = [];
138
+ const push = (line, what, detail, text, file = "build.js") => findings.push({ line, what, detail, text, file });
139
+
140
+ const src = fs.readFileSync(BUILD, "utf8");
141
+
142
+ for (const call of emptyStateCalls(src)) {
143
+ if (call.paras.length > MAX_PARAS) {
144
+ push(call.line, "too many paragraphs",
145
+ `${call.paras.length} paragraphs; an empty state is ${MAX_PARAS}. The ghost above it is the explanation.`,
146
+ stripTags(call.paras[0]).slice(0, 80));
147
+ }
148
+ for (const p of call.paras) {
149
+ const n = words(p);
150
+ if (n > MAX_WORDS) push(call.line, "too long", `${n} words; the ceiling is ${MAX_WORDS}.`, stripTags(p).slice(0, 120));
151
+ for (const [re, why] of TICS) if (re.test(p)) push(call.line, "claudism", why, stripTags(p).slice(0, 120));
152
+ }
153
+ }
154
+
155
+ for (const h of addHints(src)) {
156
+ const n = words(h.text);
157
+ if (n > MAX_HINT_WORDS) push(h.line, "hint too long", `${n} words; the ceiling is ${MAX_HINT_WORDS}.`, stripTags(h.text).slice(0, 120));
158
+ for (const [re, why] of TICS) {
159
+ if (re.source.includes("em>") || re.source.includes("ldquo")) continue; // a hint carries no prompt
160
+ if (re.test(h.text)) push(h.line, "claudism", why, stripTags(h.text).slice(0, 120));
161
+ }
162
+ }
163
+
164
+ // ── the second surface: the mail a person receives, and the notice an operator reads ──
165
+ const MAIL = path.join(ROOT, "src", "mail.mjs");
166
+ const DASH = TICS[0];
167
+ let mailRead = 0;
168
+ if (fs.existsSync(MAIL)) {
169
+ const msrc = fs.readFileSync(MAIL, "utf8");
170
+ const strings = mailCopy(msrc);
171
+ mailRead = strings.length;
172
+ // Same refusal as below, for the same reason: a scan that found nothing because it
173
+ // looked at nothing is the failure this file exists to prevent.
174
+ if (!mailRead) {
175
+ console.log("ui-copy-lint: FAILED — src/mail.mjs exists but no subject/footer/detail/return copy was found in it. The extractor is broken, not the copy.");
176
+ process.exit(1);
177
+ }
178
+ for (const s of strings) {
179
+ if (DASH[0].test(s.text)) push(s.line, "claudism", DASH[1], s.text.slice(0, 120), "src/mail.mjs");
180
+ }
181
+ }
182
+
183
+ // A guard that finds nothing because it LOOKED at nothing is the failure mode this whole
184
+ // file exists to prevent, so say what was read and refuse to pass on an empty scan.
185
+ const scanned = emptyStateCalls(src).length;
186
+ if (!scanned) {
187
+ console.log("ui-copy-lint: FAILED — found no emptyState() call sites in build.js. The extractor is broken, not the copy.");
188
+ process.exit(1);
189
+ }
190
+
191
+ if (!findings.length) {
192
+ console.log(`ui-copy-lint: OK — ${scanned} empty state(s), ${addHints(src).length} tier hint(s) and ${mailRead} mail string(s) read, all within one paragraph and ${MAX_WORDS} words`);
193
+ process.exit(0);
194
+ }
195
+ for (const f of findings) {
196
+ console.log(`${f.file}:${f.line} [${f.what}]`);
197
+ console.log(` ${f.detail}`);
198
+ console.log(` ${f.text}`);
199
+ }
200
+ console.log(`\n${findings.length} finding(s) across ${scanned} empty state(s).`);
201
+ console.log("This guard cannot tell whether a sentence is TRUE — the false first sentence that shipped here would have passed every rule above. That is still a review question.");
202
+ process.exit(1);
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // augur watch — save this draft folder on every burst of changes, until Ctrl-C. For people
3
+ // editing by hand in an editor that runs no hooks. See docs/drafts-that-land.md §7.
4
+ import { resolveOrigin, resolveToken } from "./lib/store.mjs";
5
+ import { readState, unitClient, doSave, watchFolder } from "./lib/draft.mjs";
6
+
7
+ const log = (m) => console.error(`\x1b[35m[watch]\x1b[0m ${m}`);
8
+ const die = (m) => { console.error(`\x1b[31m[watch]\x1b[0m ${m}`); process.exit(1); };
9
+ const dir = process.cwd();
10
+ const st = readState(dir);
11
+ if (!st) die("not a draft folder — run `augur open <prototype>` first.");
12
+ const origin = st.origin || resolveOrigin();
13
+ const token = resolveToken(origin);
14
+ if (!token) die("no publish token — run `augur connect` once.");
15
+ const client = unitClient({ origin, token, space: st.space, session: st.session });
16
+ let running = false, again = false;
17
+ async function save() {
18
+ if (running) { again = true; return; }
19
+ running = true;
20
+ try {
21
+ const r = await doSave({ client, dir });
22
+ if (!r.ok) log(`save refused: ${r.error || r.status}${r.error === "stale-draft" ? " — run `augur sync`" : ""}`);
23
+ else if (r.changed.length) log(`${r.changed.length} file(s) live at ${origin}${st.address}`);
24
+ } finally {
25
+ running = false;
26
+ if (again) { again = false; save(); }
27
+ }
28
+ }
29
+ log(`watching ${dir} — live at ${origin}${st.address} (Ctrl-C to stop)`);
30
+ const w = watchFolder(dir, save);
31
+ process.on("SIGINT", () => { w.close(); process.exit(0); });
32
+ await save();
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wrangler-preflight — refuse a worker config that would open the site.
4
+ *
5
+ * WHY THIS IS NOT A STYLE CHECK. Pages and Workers invert request precedence. Pages runs
6
+ * the worker first and lets it decide what is public. Workers serves a matching static
7
+ * asset first and only invokes the worker on a miss, unless `run_worker_first = true`.
8
+ *
9
+ * The asset directory is `dist`, and `dist/__config/instance.json` holds the instance's
10
+ * user roster INCLUDING SEED PASSWORDS — build.js writes `users: IDENTITY` unstripped and
11
+ * says so in its own comment beside the write. `dist/admin/` is the admin page. In assets
12
+ * mode `dist` is every built page.
13
+ *
14
+ * So a config missing one line serves the roster and the admin panel to strangers, while
15
+ * every test in this repo stays green, both existing deploy gates stay green, and the
16
+ * shell's health check still answers "healthy" — because /_build.json is a static file
17
+ * that a bare host serves correctly with no worker running at all. Nothing else in this
18
+ * repo can tell those two deploys apart. That is what this script is for.
19
+ *
20
+ * ⚠️ WHERE IT ACTUALLY RUNS, because this header used to claim otherwise: NOT in `check`.
21
+ * The config it judges lives in a SHELL repo, and this repo has none, so there is nothing
22
+ * here for a repo-wide gate to read. It runs at `templates/shell/deploy.yml`, gating each
23
+ * shell's plain-Worker deploy with the wrangler.toml that shell actually holds, and in
24
+ * `test/wrangler-preflight.test.mjs` against fixtures. The consequence worth knowing: a
25
+ * green `check` in the engine says nothing about whether any instance's config is honest.
26
+ *
27
+ * WHAT IT DOES NOT DO: it does not parse TOML. Node has no TOML parser and this repo
28
+ * carries no dependencies for tooling (scripts/shell-lint.mjs works the same way). It is
29
+ * line-oriented, which means it can be fooled by a config written to fool it — an inline
30
+ * table, a multi-line array, a key inside a string. It is a floor under an honest config,
31
+ * not a proof against a hostile one. `scripts/frontdoor-parity.mjs` is what actually
32
+ * asks a running deployment whether a gated path is gated.
33
+ *
34
+ * Run: node scripts/wrangler-preflight.mjs -c <path/to/wrangler.toml> (exit 1 on any finding)
35
+ */
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+
39
+ const argv = process.argv.slice(2);
40
+ const cfgArg = argv[(argv.indexOf("-c") + 1) || (argv.indexOf("--config") + 1)] || argv[0];
41
+ if (!cfgArg) {
42
+ console.error("usage: node scripts/wrangler-preflight.mjs -c <wrangler.toml>");
43
+ process.exit(2);
44
+ }
45
+ const CFG = path.resolve(cfgArg);
46
+ if (!fs.existsSync(CFG)) {
47
+ console.error(`wrangler-preflight: no such config: ${CFG}`);
48
+ process.exit(2);
49
+ }
50
+ const SHELL = path.dirname(CFG);
51
+ const raw = fs.readFileSync(CFG, "utf8");
52
+
53
+ // Strip whole-line comments only. A `#` inside a quoted value is a legitimate character
54
+ // (a password in a var, a fragment in a URL) and treating it as a comment is how a
55
+ // 24-character secret became a 6-character one somewhere else in this project.
56
+ const lines = raw.split(/\r?\n/).map((l) => (/^\s*#/.test(l) ? "" : l));
57
+ const body = lines.join("\n");
58
+
59
+ /**
60
+ * A value, with a TRAILING comment removed and a quoted `#` left alone.
61
+ *
62
+ * Both halves are load-bearing and they pull in opposite directions. The shipped
63
+ * template annotates almost every line (`binding = "ASSETS" # the worker reads its own
64
+ * config through this`), so a reader that keeps the comment sees the binding as
65
+ * `"ASSETS" # the worker…` and refuses a correct config — and a guard that fires on the
66
+ * template it tells you to copy is a guard somebody deletes. But a `#` INSIDE a quoted
67
+ * value is a legitimate character, and treating it as a comment is how a 24-character
68
+ * secret became a 6-character one somewhere else in this project.
69
+ *
70
+ * So: a quoted value ends at its closing quote and whatever follows is a comment; an
71
+ * unquoted one ends at the first `#`.
72
+ */
73
+ function stripTrailingComment(v) {
74
+ const q = /^\s*(["'])((?:\\.|(?!\1).)*)\1/.exec(v);
75
+ if (q) return q[2];
76
+ const hash = v.indexOf("#");
77
+ return (hash === -1 ? v : v.slice(0, hash)).trim();
78
+ }
79
+
80
+ /** A bare `key = value` at top level or inside the named table. */
81
+ function valueOf(key, table = null) {
82
+ let cur = null;
83
+ for (const line of lines) {
84
+ const t = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/);
85
+ if (t) { cur = t[1].trim(); continue; }
86
+ if (cur !== table) continue;
87
+ const m = line.match(new RegExp(String.raw`^\s*${key}\s*=\s*(.+?)\s*$`));
88
+ if (m) return stripTrailingComment(m[1]);
89
+ }
90
+ return null;
91
+ }
92
+ // `key` because the tables do not agree: KV, R2 and queues name theirs `binding`, and
93
+ // Durable Objects name theirs `name`.
94
+ const bindings = (table, key = "binding") => lines
95
+ .join("\n")
96
+ .split(/^\s*\[\[/m)
97
+ .filter((chunk) => chunk.startsWith(`${table}]]`))
98
+ .map((chunk) => (chunk.match(new RegExp(String.raw`^\s*${key}\s*=\s*["']([^"']+)["']`, "m")) || [])[1])
99
+ .filter(Boolean);
100
+
101
+ const findings = [];
102
+ const fail = (rule, detail) => findings.push({ rule, detail });
103
+
104
+ // ── the headline rule ────────────────────────────────────────────────────────
105
+ const assetsDir = valueOf("directory", "assets");
106
+ const runWorkerFirst = valueOf("run_worker_first", "assets");
107
+ if (assetsDir) {
108
+ const abs = path.resolve(SHELL, assetsDir);
109
+ const sensitive = ["__config/instance.json", "__config/routing.json", "admin/index.html"]
110
+ .filter((p) => fs.existsSync(path.join(abs, p)));
111
+ if (runWorkerFirst !== "true") {
112
+ fail("run-worker-first",
113
+ `[assets] run_worker_first is ${runWorkerFirst === null ? "not set" : runWorkerFirst}. Workers serves a matching asset BEFORE the worker runs, so the gate never executes.`
114
+ + (sensitive.length
115
+ ? `\n The asset directory (${assetsDir}) currently contains: ${sensitive.join(", ")}.`
116
+ + "\n __config/instance.json carries the user roster WITH SEED PASSWORDS. This config would publish it."
117
+ : `\n The asset directory (${assetsDir}) is not built yet, so what it will contain could not be read — build first, or fix this anyway.`));
118
+ }
119
+ if (!fs.existsSync(path.join(abs, ".assetsignore")) && fs.existsSync(path.join(abs, "_worker.js"))) {
120
+ fail("assetsignore",
121
+ `${assetsDir}/_worker.js exists with no .assetsignore beside it. wrangler refuses to upload a Pages worker as an asset, and without the ignore file the deploy fails. build.js emits it; a stale dist does not have it.`);
122
+ }
123
+ }
124
+ if (!assetsDir) fail("assets", "no [assets] directory. The worker reads its own instance config through env.ASSETS and cannot start without it.");
125
+
126
+ const nfh = valueOf("not_found_handling", "assets");
127
+ if (nfh === "single-page-application") {
128
+ fail("not-found-handling",
129
+ 'not_found_handling = "single-page-application" answers every unknown path with the index page at status 200. dist/404.html exists precisely so a miss is a miss; this turns every typo and every unpublished URL into a page that looks real.');
130
+ }
131
+
132
+ // ── bindings the worker cannot run without ───────────────────────────────────
133
+ const kv = bindings("kv_namespaces");
134
+ const r2 = bindings("r2_buckets");
135
+ if (valueOf("binding", "assets") !== "ASSETS") fail("binding-assets", "[assets] binding must be \"ASSETS\" — src/_worker.js reads env.ASSETS by that name.");
136
+ if (!kv.includes("COMMENTS")) fail("binding-comments", "no COMMENTS KV binding. Sessions, rosters, comments, pins and publish tokens all live there, and effectiveSecret fails CLOSED on a KV error, so nobody can sign in.");
137
+ if (!r2.includes("BUNDLES")) fail("binding-bundles", "no BUNDLES R2 binding. Published content is served from the bundle store; without it the site has nothing in it.");
138
+
139
+ // ── one workspace or many, and the pieces that have to agree ─────────────────
140
+ // TENANT_HOST_SUFFIX is the switch: unset, the deployment serves the one workspace its
141
+ // build named; set, the workspace comes from the Host header. The two halves are declared
142
+ // in different tables, so it is easy to add one and not the other — and each half alone
143
+ // fails in a way nobody would connect to this file.
144
+ const suffix = valueOf("TENANT_HOST_SUFFIX", "vars");
145
+ const dos = bindings("durable_objects.bindings", "name");
146
+ if (suffix !== null && suffix.trim() !== "" && !dos.includes("TENANTS")) {
147
+ fail("tenants-binding",
148
+ `TENANT_HOST_SUFFIX = "${suffix}" makes the workspace come from the Host header, but there is no TENANTS Durable Object binding for those workspaces to live in. Every hostname would resolve to a workspace with nowhere to keep anything.`);
149
+ }
150
+ // A deployment that provisions workspaces furnishes each one from the seed pack in its own
151
+ // asset bundle (`F-seed-pack-at-provision`). A worker deployed without one refuses every
152
+ // provisioning (`seed-pack-unavailable`) — loud, but a signup door that refuses everybody
153
+ // is still a broken signup door, and the build that forgot the pack is the one to stop.
154
+ // build.js emits it on every engine-only build; a hand-rolled or stale dist does not have it.
155
+ if (suffix !== null && suffix.trim() !== "" && dos.includes("TENANTS") && assetsDir) {
156
+ const abs = path.resolve(SHELL, assetsDir);
157
+ if (fs.existsSync(path.join(abs, "__config")) && !fs.existsSync(path.join(abs, "__seed", "pack.json"))) {
158
+ fail("seed-pack",
159
+ `${assetsDir} is built but carries no __seed/pack.json. This deployment provisions workspaces (TENANT_HOST_SUFFIX + TENANTS), and every provisioning furnishes the new workspace from that pack — without it the workspace object refuses every create with seed-pack-unavailable. Build with GV_ENGINE_ONLY=1 (which emits it), or GV_SEED_PACK=1.`);
160
+ }
161
+ }
162
+ if (suffix !== null && suffix.trim() === "") {
163
+ fail("tenants-suffix-empty",
164
+ 'TENANT_HOST_SUFFIX is set to an empty string. That reads as "multi-workspace" to a person and as "single workspace" to the resolver. Delete the line, or give it the real suffix.');
165
+ }
166
+
167
+ // ── the jurisdiction a workspace object is addressed in ──────────────────────
168
+ // A Durable Object's jurisdiction is chosen when the object is ADDRESSED, not when the
169
+ // namespace is declared, so there is nothing in this file for the platform to check and
170
+ // nothing in the platform for this file to read: `ns.idFromName(x)` and
171
+ // `ns.jurisdiction("eu").idFromName(x)` are two different objects and a deployment picks
172
+ // which by setting this variable or not. Storage belongs to an id, so the wrong choice is
173
+ // not a bug that gets fixed later — it is a migration, and one nobody can do for a
174
+ // workspace they cannot find.
175
+ //
176
+ // THIS LIST IS THE ENGINE'S COPY OF SOMEBODY ELSE'S, and it exists HERE and not in the
177
+ // request path on purpose. In the worker the value is handed straight to the platform,
178
+ // which is the only authority on what it accepts — a copy running on every request would
179
+ // eventually refuse a jurisdiction that was added after it was written. A copy in a deploy
180
+ // gate has the opposite failure: whoever hits a stale entry is a person, holding the repo,
181
+ // one line from adding it, and in exchange a typo is caught before a single request rather
182
+ // than by an outage. Measured against the platform: it accepts these four and refuses
183
+ // everything else, case-sensitively, including the empty string.
184
+ const JURISDICTIONS = ["eu", "fedramp", "fedramp-high", "us"];
185
+ const jurisdiction = valueOf("TENANT_JURISDICTION", "vars");
186
+ if (jurisdiction !== null && jurisdiction.trim() === "") {
187
+ fail("tenants-jurisdiction-empty",
188
+ 'TENANT_JURISDICTION is set to an empty string, which the engine reads as "no jurisdiction" and a person reads as "restricted". Delete the line if this deployment places no restriction, or name the jurisdiction.');
189
+ }
190
+ if (jurisdiction !== null && jurisdiction.trim() !== "") {
191
+ const j = jurisdiction.trim();
192
+ if (!JURISDICTIONS.includes(j)) {
193
+ fail("tenants-jurisdiction-unknown",
194
+ `TENANT_JURISDICTION = "${j}" is not a jurisdiction the platform accepts (${JURISDICTIONS.join(", ")}) — and it is case-sensitive, so "EU" is not "eu". A value it refuses fails every request; a value it accepts but nothing else uses creates every workspace where nothing else is looking. If the platform has added one since this list was written, add it here.`);
195
+ }
196
+ if (!dos.includes("TENANTS")) {
197
+ fail("tenants-jurisdiction-binding",
198
+ `TENANT_JURISDICTION = "${j}" says where this deployment's workspace objects live, but there is no TENANTS Durable Object binding for them to live in. The variable does nothing here, which is the dangerous kind of nothing: adding the binding later would look like the jurisdiction had been in force all along.`);
199
+ }
200
+ }
201
+
202
+ // ── the entry ────────────────────────────────────────────────────────────────
203
+ const main = valueOf("main");
204
+ if (!main) fail("main", "no `main`. wrangler has no entry to bundle.");
205
+ else if (!/src\/entry\.js$/.test(main)) {
206
+ fail("main", `main = "${main}". The deploy entry is src/entry.js — the file scripts/no-tenant-globals.mjs scans and the only one that may export a Durable Object class. Pointing main elsewhere silently un-scans the module graph.`);
207
+ } else if (!fs.existsSync(path.resolve(SHELL, main))) {
208
+ fail("main", `main = "${main}" does not resolve from ${SHELL}. It is relative to this config, not to the engine.`);
209
+ }
210
+
211
+ // ── credentials that must never be a plaintext var ───────────────────────────
212
+ let table = null;
213
+ lines.forEach((line, i) => {
214
+ const t = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/);
215
+ if (t) { table = t[1].trim(); return; }
216
+ const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/);
217
+ if (!m) return;
218
+ const key = m[1];
219
+ if (key === "PUBLISH_BOOTSTRAP_TOKEN") {
220
+ fail("bootstrap-token", `line ${i + 1}: PUBLISH_BOOTSTRAP_TOKEN. It is compared as a bare string and answers with star scope — a plaintext credential that can overwrite every space's published content. It has no place on a live instance at all.`);
221
+ }
222
+ if (table === "vars" && /SECRET|TOKEN|PASSWORD|API_KEY/.test(key)) {
223
+ fail("secret-in-vars", `line ${i + 1}: ${key} under [vars]. [vars] is plaintext in a git repo. Use \`wrangler secret put ${key}\`.`);
224
+ }
225
+ if (key === "GV_KV_TOKEN") {
226
+ fail("kv-token", `line ${i + 1}: GV_KV_TOKEN short-circuits the env.COMMENTS binding and talks to the KV REST API with an account credential. It is a local-development escape hatch, not a deploy setting.`);
227
+ }
228
+ });
229
+
230
+ // `remote = true` sends a local dev binding to the PRODUCTION resource.
231
+ lines.forEach((line, i) => {
232
+ if (/^\s*remote\s*=\s*true\s*$/.test(line)) {
233
+ fail("remote-binding", `line ${i + 1}: remote = true points this binding at the live resource. A local run then writes production comments, rosters and boards.`);
234
+ }
235
+ });
236
+
237
+ if (!findings.length) {
238
+ console.log(`wrangler-preflight: OK — ${path.basename(CFG)} runs the worker first, binds ASSETS + COMMENTS + BUNDLES, and carries no plaintext credential`);
239
+ process.exit(0);
240
+ }
241
+ for (const f of findings) {
242
+ console.log(`${path.relative(process.cwd(), CFG)} [${f.rule}]`);
243
+ console.log(` ${f.detail}`);
244
+ }
245
+ console.log(`\n${findings.length} finding(s). This is a floor under an honest config, not a proof: it is line-oriented and does not parse TOML. Ask a RUNNING deployment with scripts/frontdoor-parity.mjs.`);
246
+ process.exit(1);
package/seed/CANON.md ADDED
@@ -0,0 +1,43 @@
1
+ # The canon
2
+
3
+ The screens and parts in this workspace that are meant to be **pulled by name** —
4
+ "build it the way `invoice-detail` is built", "pull `invoice-list`,
5
+ `invoice-detail` and `invoice-empty` and wire them together".
6
+
7
+ A canonical name is a directory name, in one of four places at this root:
8
+
9
+ | Where | What it holds |
10
+ | --- | --- |
11
+ | `base/<name>/` | one atom, every state on one page |
12
+ | `components/<name>/` | one composed component |
13
+ | `patterns/<name>/` | an arrangement several screens repeat |
14
+ | `pages/<name>/` | a whole screen |
15
+
16
+ Anything else is not the canon and is never what a bare name means: a folder under
17
+ `<project>/prototypes/` is a working prototype, `playground/` is scratch.
18
+
19
+ Names are lowercase and hyphenated, **subject first and qualifier last** —
20
+ `invoice-list`, `invoice-detail`, `invoice-empty`, never `list-of-invoices` — so
21
+ a directory listing sorts a subject's screens together and reads as a table of
22
+ contents. The qualifier comes from one closed set:
23
+
24
+ `-list` · `-detail` · `-new` · `-edit` · `-empty` · `-error` · `-loading` · `-confirm` · `-success`
25
+
26
+ A name never carries a version, a date or a ticket id: it says what, never when.
27
+ Every entry carries one sentence in `<meta name="description">` saying what it
28
+ shows.
29
+
30
+ **To pull `<name>`, look for `<name>/` in those four directories.** The entry
31
+ page is `<tier>/<name>/index.html` and it opens on its own, from disk, with the
32
+ workspace's design system already on it. That is the whole resolution rule, and
33
+ it needs no tool.
34
+
35
+ With the engine's CLI on hand, the same thing plus the description and the
36
+ design-system assets each entry links:
37
+
38
+ augur canon find <name> # → the tier, the folder, the entry, what it links
39
+ augur canon list # → every canonical name and its description
40
+ augur canon save <path> # → promote a working screen into the canon
41
+ augur canon check # → names that will not be found the same way twice
42
+
43
+ The full rules, with the reasoning, are the engine's `agents/canon.md`.
package/seed/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # The seed workspace
2
+
3
+ What a brand-new hosted workspace contains on the day it is created. It is a
4
+ complete, buildable space — a design system, six prototypes, a comment thread or
5
+ two — copied wholesale into a tenant at provisioning, so the first thing a person
6
+ sees is a working workspace rather than an empty one.
7
+
8
+ The three prototypes under `start-here/` **are** the onboarding. There is no
9
+ wizard to write instead: they teach the loop by being it, and the person who
10
+ finishes them has a connected terminal, a design system they have already
11
+ changed, and a comment they have already replied to.
12
+
13
+ ```
14
+ seed/
15
+ ├── space.json the workspace's contract with the build
16
+ ├── CANON.md how a canonical screen is named, for the agent that arrives cold
17
+ ├── registry.json the design system's overlay catalog
18
+ ├── prototype-status.json the dev-status chips
19
+ ├── threads.json the comment threads that ship with the content
20
+ ├── skills/starter-ui/ the design system: tokens, components, one behaviour
21
+ ├── start-here/prototypes/
22
+ │ ├── connect-your-terminal the fork: one command, or fifteen minutes
23
+ │ ├── set-up-your-design-system the tokens, live, and how to make them yours
24
+ │ └── sample-with-comments a real screen with real pins on it
25
+ └── worked-examples/prototypes/
26
+ ├── specimen-viewer 3D, from a pinned CDN script
27
+ ├── slide-deck a keyboard-driven deck
28
+ └── field-readings charts with no chart library
29
+ ```
30
+
31
+ ## How it reaches a workspace
32
+
33
+ It is built ONCE PER ENGINE PIN, not composed per signup. Every engine-only build
34
+ (what a deploy shell runs) composes this tree with the real build — a child
35
+ `build.js` over `seed/`, exactly what `augur publish` would run over a clone of it —
36
+ and folds the result into one document, `dist/__seed/pack.json`, which ships inside
37
+ the worker's own asset bundle and is sealed from the outside like `/__config/`
38
+ (`scripts/lib/seed-pack-build.mjs`; `node scripts/build-seed-pack.mjs --print` shows
39
+ what is in it).
40
+
41
+ At provisioning, the workspace object writes that pack into its own segment of the
42
+ bundle store FIRST — every blob, then `versions/1.json`, then `manifest.json`, as
43
+ version 1 of the workspace's space — and only then commits the first admin, the
44
+ threads and the version row in one transaction (`src/seed-pack.mjs`; the control
45
+ plane asks for it with `seedPack: true` on `provision` and carries none of the
46
+ content). Published content and the workspace's own rows live in two stores with no
47
+ transaction between them, so the order is what makes it safe: a workspace the front
48
+ door will serve is one whose commit landed, and an object left unprovisioned by a
49
+ crash in between resolves to nobody, content or no content. There is no state where a
50
+ workspace exists with an admin and no content.
51
+
52
+ Every seed version is stamped as the platform's, never as a person's: `source` is
53
+ the seed sentinel (`src/provenance.mjs`), `publishedBy` is the seed actor, each
54
+ unit's `routing.unitSources` entry is the sentinel too, and no file carries an author
55
+ id — the pack builder strips the git-derived stamp, so the engine's author is not the
56
+ author of every workspace's welcome content.
57
+
58
+ Three things are substituted on the way in:
59
+
60
+ | What | Where | Substituted with |
61
+ | --- | --- | --- |
62
+ | The connect command | `CONNECT_COMMAND` in `start-here/prototypes/connect-your-terminal/index.html` | `npx augur connect --origin https://<label><suffix>`, the workspace's real address, filled the moment the page is published |
63
+ | Comment timestamps | `at` in `threads.json` | the provisioning time, so day-one threads do not read as months old |
64
+ | File timestamps | `editedAt` on every file in the manifest | the same provisioning time, all of them |
65
+
66
+ The space id and name are the pack's own (`space.json` here): the workspace IS the
67
+ space, and a workspace's label is its address, not its space id. The connect page
68
+ falls back to deriving the command from the URL it is served on, so with no
69
+ substitution it is never wrong, only less specific.
70
+
71
+ **Start Here has to be the first card.** The gallery orders projects
72
+ most-recently-worked-on first and falls back to A→Z, which is why the second
73
+ folder is named `worked-examples` rather than `examples`: with one timestamp
74
+ across the whole seed — what a single atomic write produces — Start Here leads
75
+ and the examples follow. Provisioning stamps every file with the one provisioning
76
+ instant, which is that single timestamp.
77
+
78
+ ## Rules for editing it
79
+
80
+ - **Generic, always.** This ships from the engine to every workspace on every
81
+ instance. No instance, product, or personal names — CI scans this tree along
82
+ with the rest of the repo.
83
+ - **The prototype contract holds** (`../agents/prototype-contract.md`):
84
+ self-contained static HTML, no build step, opens straight from disk, and a
85
+ one-line `<meta name="description">` that says what the page shows.
86
+ - **Link the design system, do not copy it.** The canonical relative path
87
+ (`../../../skills/starter-ui/…`) resolves on disk and the build rewrites it for
88
+ the site, which is what keeps six prototypes wearing one system.
89
+ - **A CDN script must be pinned to an exact version.** `specimen-viewer` is the
90
+ worked example: an immutable versioned URL, never a moving tag, and a visible
91
+ fallback when the network is not there.
92
+ - **Copy is instruction, not decoration.** Every step says what you should see
93
+ when it worked, and every step that can fail carries its own way out.
94
+ - **`CANON.md` is written by the tool, not by hand.** It is byte-for-byte the
95
+ `NOTE` string in `scripts/canon.mjs` — the same one `augur init` scaffolds and
96
+ `augur canon save` writes into a workspace that has none, so a hosted workspace
97
+ and a self-hosted one describe their names identically.
98
+ `test/canon-naming.test.mjs` fails when any of the three drift. Edit the string
99
+ and regenerate this copy; the rules it summarises live in `agents/canon.md`.
100
+ - **The four canon tiers are ABSENT here, not empty.** There is no `base/`,
101
+ `components/`, `patterns/` or `pages/` in this tree, and adding one would be
102
+ wrong: a workspace's canon is what its own work promoted into it, so a seeded
103
+ canon would be six screens nobody chose claiming to be the ones worth copying.
104
+ `augur canon save` creates the tier directory on the first promotion. What the
105
+ seed ships instead is the SCHEME — `CANON.md` — so the canon a workspace grows
106
+ is named the same way as every other workspace's.
107
+
108
+ ## Working on it locally
109
+
110
+ From this directory, with a raw engine clone around it:
111
+
112
+ ```bash
113
+ GV_SPACES_ROOT="$PWD" node ../build.js
114
+ ```
115
+
116
+ That composes the workspace into the engine's `dist/`, exactly as an instance
117
+ would serve it — the galleries, the library tier derived from the skill, and the
118
+ six prototypes. Each prototype also opens on its own by double-clicking its
119
+ `index.html`.
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "Dev-status baseline per prototype, keyed <project>/<prototype>. Values: in-progress | dev-ready | ignore. Live chips cycle on click (KV) over this baseline.",
3
+ "start-here/connect-your-terminal": "dev-ready",
4
+ "start-here/set-up-your-design-system": "dev-ready",
5
+ "start-here/sample-with-comments": "in-progress",
6
+ "worked-examples/specimen-viewer": "dev-ready",
7
+ "worked-examples/slide-deck": "dev-ready",
8
+ "worked-examples/field-readings": "in-progress"
9
+ }