@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,599 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * augur canon — resolve a canonical name to files, and promote a working screen into
4
+ * the canon.
5
+ *
6
+ * WHY THIS EXISTS. "Pull screens X, Y and Z and wire them together" is the instruction
7
+ * that makes a workspace's design system worth having, and it presumes X names something
8
+ * an agent can find COLD — no human pointing, in a session that has never seen this
9
+ * workspace before. Names decide whether that resolves, and names drift the moment they
10
+ * are only a habit: one screen lands as `checkout-v2`, the next as `new_Checkout`, a
11
+ * third as a scratch folder wearing the same name as a canonical page, and the
12
+ * instruction stops resolving. Renaming everything afterwards works and costs a day.
13
+ *
14
+ * So the scheme is written down once (agents/canon.md), this command applies it, and
15
+ * `canon save` is the reason a canon grows without anybody scheduling it: the screen
16
+ * somebody just built is one command away from being canonical — correctly named, its
17
+ * description carried over, and its design-system references repointed for the depth it
18
+ * now sits at. That repoint is the hand-edit that used to be the reason promotion did
19
+ * not happen, because skipping it produces a page that still LOOKS right on the site and
20
+ * opens unstyled from disk.
21
+ *
22
+ * augur canon list [--tier <t>] [--json]
23
+ * augur canon find <name>… [--json]
24
+ * augur canon save <path> [--as <name>] [--tier <t>] [--desc "…"] [--replace] [--dry-run]
25
+ * augur canon check
26
+ *
27
+ * NOTHING IS CACHED AND NOTHING IS GENERATED. Every answer is read from the tree at the
28
+ * moment it is asked, so there is no index file to regenerate and therefore none to go
29
+ * stale: the directory names ARE the index. That is only true while they obey the
30
+ * scheme, which is the whole job of `check`.
31
+ */
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+ import { spawnSync } from "node:child_process";
35
+ import { pathToFileURL, fileURLToPath } from "node:url";
36
+
37
+ const C = { dim: "\x1b[2m", warn: "\x1b[33m", bad: "\x1b[31m", ok: "\x1b[32m", off: "\x1b[0m" };
38
+ const log = (m) => console.log(`\x1b[35m[canon]\x1b[0m ${m}`);
39
+ const die = (m) => { console.error(`${C.bad}[canon] ${m}${C.off}`); process.exit(1); };
40
+
41
+ const argv = process.argv.slice(2);
42
+ const flag = (n) => argv.includes(n);
43
+ const opt = (n, d = null) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
44
+ const positional = () => {
45
+ const out = [];
46
+ for (let i = 1; i < argv.length; i++) {
47
+ if (argv[i].startsWith("--")) { if (VALUE_FLAGS.has(argv[i])) i++; continue; }
48
+ out.push(argv[i]);
49
+ }
50
+ return out;
51
+ };
52
+ const VALUE_FLAGS = new Set(["--tier", "--as", "--desc", "--space"]);
53
+
54
+ // ── The canon's four tiers ───────────────────────────────────────────────────
55
+ // The same four directories build.js scans at a workspace root (PAGES_SRC /
56
+ // COMPONENTS_SRC / BASE_SRC / PATTERNS_SRC) and publishes as their own tabs. This
57
+ // command adds no tier and invents no directory: a tier that is not one of these is not
58
+ // part of the contract the site already serves.
59
+ const TIERS = ["base", "components", "patterns", "pages"];
60
+ const TIER_IS = {
61
+ base: "one atom, every state on one page",
62
+ components: "one composed component",
63
+ patterns: "an arrangement several screens repeat",
64
+ pages: "a whole screen",
65
+ };
66
+
67
+ // ── The name ─────────────────────────────────────────────────────────────────
68
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
69
+ const MAX_NAME = 40;
70
+ const MAX_WORDS = 4;
71
+
72
+ // The closed set of state qualifiers. Spelling the same idea the same way in every
73
+ // workspace is the point: an agent told "the empty state of the invoice list" can write
74
+ // down `invoice-empty` without asking.
75
+ const STATES = ["list", "detail", "new", "edit", "empty", "error", "loading", "confirm", "success"];
76
+
77
+ // The near misses — every one of these means a word already in STATES. Reported, never
78
+ // rewritten: which of two words a team says is theirs to settle, and a tool that renamed
79
+ // a folder on a hunch would be worse than the drift.
80
+ const NEAR_STATES = {
81
+ index: "list", all: "list", overview: "list",
82
+ view: "detail", show: "detail", single: "detail", item: "detail",
83
+ create: "new", add: "new",
84
+ blank: "empty", none: "empty", zero: "empty",
85
+ fail: "error", failure: "error", broken: "error",
86
+ busy: "loading", skeleton: "loading", pending: "loading",
87
+ done: "success", ok: "success", saved: "success",
88
+ };
89
+
90
+ // A canonical name says WHAT, never WHEN. Everything here encodes a moment in somebody's
91
+ // afternoon — a version, a date, a state of mind — and a name carrying one cannot be
92
+ // guessed by the person who has to pull it tomorrow. `-new` is absent on purpose: it is a
93
+ // STATE (the create screen), so only `-new2` and friends are refused.
94
+ const SCRATCH_RE = /-(?:v\d+|\d{4,}|\d{4}-\d{2}(?:-\d{2})?|(?:old|final|copy|dupe|wip|tmp|temp|draft|bak|backup|fixed|updated|latest|test)\d*|(?:new)\d+)$/;
95
+
96
+ const words = (n) => n.split("-").filter(Boolean);
97
+
98
+ /** Every reason a name is not a canonical name. Empty array = it is one. */
99
+ function nameErrors(name) {
100
+ const out = [];
101
+ if (!name) out.push("empty");
102
+ else {
103
+ if (!NAME_RE.test(name)) out.push("not lowercase-hyphen (a-z, 0-9 and single hyphens only)");
104
+ if (name.length > MAX_NAME) out.push(`${name.length} characters (the ceiling is ${MAX_NAME})`);
105
+ if (SCRATCH_RE.test(name)) out.push(`ends in a version or a date — a canonical name says what, never when`);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Advice: true of names that resolve fine but will not be guessed the same way twice. */
111
+ function nameNotes(name) {
112
+ const out = [];
113
+ const w = words(name);
114
+ if (w.length > MAX_WORDS) out.push(`${w.length} words — ${MAX_WORDS} is the outside`);
115
+ const last = w[w.length - 1];
116
+ if (NEAR_STATES[last]) out.push(`ends in "-${last}"; the word for that state is "-${NEAR_STATES[last]}"`);
117
+ return out;
118
+ }
119
+
120
+ const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
121
+
122
+ /**
123
+ * The name a source folder should be promoted under, and what had to change to get
124
+ * there. Announced on every run rather than applied quietly — a promotion that renames
125
+ * without saying so is a promotion nobody can predict.
126
+ */
127
+ function normalizeName(raw) {
128
+ const changes = [];
129
+ let name = String(raw || "");
130
+ const slug = slugify(name);
131
+ if (slug !== name) { changes.push(`slug: "${name}" → "${slug}"`); name = slug; }
132
+ for (let i = 0; i < 4 && SCRATCH_RE.test(name); i++) {
133
+ const stripped = name.replace(SCRATCH_RE, "");
134
+ if (!stripped) break;
135
+ changes.push(`dropped "${name.slice(stripped.length)}" — a canonical name carries no version`);
136
+ name = stripped;
137
+ }
138
+ return { name, changes };
139
+ }
140
+
141
+ // ── Reading a workspace ──────────────────────────────────────────────────────
142
+
143
+ /** The nearest ancestor carrying a space.json — the same thing every other script means by "the space". */
144
+ function spaceRoot(start) {
145
+ let dir = path.resolve(start);
146
+ for (;;) {
147
+ if (fs.existsSync(path.join(dir, "space.json"))) return dir;
148
+ const up = path.dirname(dir);
149
+ if (up === dir) return null;
150
+ dir = up;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The workspace's UI skill directory, by the same detection build.js runs: the dir under
156
+ * skills/ named `<prefix>-ui` carrying `<dirname>.css`, overridable by space.json.
157
+ * Re-derived rather than imported because build.js is not a module.
158
+ */
159
+ function skillDirName(root) {
160
+ let declared = null;
161
+ try { declared = (JSON.parse(fs.readFileSync(path.join(root, "space.json"), "utf8")).designSystem || {}).skill || null; } catch { /* no space.json is caught upstream */ }
162
+ if (declared) return declared;
163
+ const skills = path.join(root, "skills");
164
+ if (!fs.existsSync(skills)) return null;
165
+ return fs.readdirSync(skills)
166
+ .filter((d) => d.endsWith("-ui") && fs.existsSync(path.join(skills, d, `${d}.css`)))
167
+ .sort()[0] || null;
168
+ }
169
+
170
+ const decode = (s) => s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'");
171
+ const encodeAttr = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
172
+
173
+ const DESC_TAG_RE = /<meta\s+[^>]*name\s*=\s*["']description["'][^>]*>/i;
174
+
175
+ /** The one-line blurb the gallery card, the link preview and the canvas picker already read. */
176
+ function readDesc(html) {
177
+ const tag = (html.match(DESC_TAG_RE) || [])[0];
178
+ if (!tag) return "";
179
+ const m = tag.match(/content\s*=\s*["']([^"']*)["']/i);
180
+ return m ? decode(m[1]).trim() : "";
181
+ }
182
+
183
+ function writeDesc(html, desc) {
184
+ const tag = `<meta name="description" content="${encodeAttr(desc)}">`;
185
+ if (DESC_TAG_RE.test(html)) return html.replace(DESC_TAG_RE, tag);
186
+ const i = html.toLowerCase().indexOf("</head>");
187
+ return i === -1 ? tag + "\n" + html : html.slice(0, i) + tag + "\n" + html.slice(i);
188
+ }
189
+
190
+ /** Which of the workspace's design-system assets a folder's entry page references. */
191
+ function linkedAssets(dir, skill) {
192
+ if (!skill) return [];
193
+ const idx = path.join(dir, "index.html");
194
+ if (!fs.existsSync(idx)) return [];
195
+ const html = fs.readFileSync(idx, "utf8");
196
+ const re = new RegExp(String.raw`(?:href|src)\s*=\s*["']([^"']*skills/${skill}/[^"']+)["']`, "gi");
197
+ const out = new Set();
198
+ let m;
199
+ while ((m = re.exec(html))) out.add(m[1].split("/").pop());
200
+ return [...out];
201
+ }
202
+
203
+ /** Every skill reference in a page, with whether it resolves on disk from where it sits. */
204
+ function brokenSkillRefs(dir, skill) {
205
+ if (!skill) return [];
206
+ const idx = path.join(dir, "index.html");
207
+ if (!fs.existsSync(idx)) return [];
208
+ const html = fs.readFileSync(idx, "utf8");
209
+ const re = new RegExp(String.raw`(?:href|src)\s*=\s*["']((?:\.\./)+skills/${skill}/[^"']+)["']`, "gi");
210
+ const bad = [];
211
+ let m;
212
+ while ((m = re.exec(html))) if (!fs.existsSync(path.resolve(dir, m[1]))) bad.push(m[1]);
213
+ return bad;
214
+ }
215
+
216
+ /** Every canonical entry in the workspace, tier by tier, in the order the site shows them. */
217
+ function scanCanon(root, skill) {
218
+ const out = [];
219
+ for (const tier of TIERS) {
220
+ const dir = path.join(root, tier);
221
+ if (!isDir(dir)) continue;
222
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
223
+ if (!e.isDirectory() || e.name.startsWith(".")) continue;
224
+ const entryDir = path.join(dir, e.name);
225
+ const entry = path.join(entryDir, "index.html");
226
+ const html = fs.existsSync(entry) ? fs.readFileSync(entry, "utf8") : null;
227
+ out.push({
228
+ name: e.name,
229
+ tier,
230
+ dir: rel(root, entryDir),
231
+ entry: html === null ? null : rel(root, entry),
232
+ description: html === null ? "" : readDesc(html),
233
+ links: linkedAssets(entryDir, skill),
234
+ });
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /**
241
+ * The folders that are NOT the canon: working prototypes and scratch. A bare name never
242
+ * means one of these — but when a name resolves to nothing, or to a canon entry AND one
243
+ * of these, saying so is the difference between "not found" and "somebody built a second
244
+ * copy instead of promoting the first".
245
+ */
246
+ function scanWorking(root) {
247
+ const out = [];
248
+ const push = (dir, kind) => {
249
+ if (!fs.existsSync(path.join(dir, "index.html"))) return;
250
+ out.push({ name: path.basename(dir), kind, dir: rel(root, dir) });
251
+ };
252
+ for (const e of dirsIn(root)) {
253
+ if (TIERS.includes(e) || e === "skills" || e === "node_modules") continue;
254
+ if (e === "playground") { for (const p of dirsIn(path.join(root, e))) push(path.join(root, e, p), "playground"); continue; }
255
+ const protos = path.join(root, e, "prototypes");
256
+ if (!isDir(protos)) continue;
257
+ for (const p of dirsIn(protos)) push(path.join(protos, p), "prototype");
258
+ }
259
+ return out;
260
+ }
261
+
262
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
263
+ const dirsIn = (p) => (isDir(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name).sort() : []);
264
+ const rel = (root, p) => path.relative(root, p).split(path.sep).join("/");
265
+
266
+ /** Levenshtein, for "did you mean" — a wrong name should cost a suggestion, not a search. */
267
+ function distance(a, b) {
268
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
269
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
270
+ for (let i = 1; i <= a.length; i++)
271
+ for (let j = 1; j <= b.length; j++)
272
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
273
+ return dp[a.length][b.length];
274
+ }
275
+
276
+ // ── Copying ──────────────────────────────────────────────────────────────────
277
+ // Mirrors build.js's isInternalOnly: research and context material stays on the machine,
278
+ // and a repo or a credential checked out inside a folder is never content. A promotion
279
+ // copies into a PUBLISHED tier, so the same floor has to hold here.
280
+ const SECRET_FILE_RE = /(^\.env(\.|$)|\.env$|\.(pem|key|p12|pfx|ppk|keystore|jks)$|(^|[._-])(secret|secrets|credentials?)([._-]|$)|^id_(rsa|dsa|ecdsa|ed25519)$|^\.(npmrc|netrc|pgpass|htpasswd|ssh|aws|gnupg)$)/i;
281
+ const VCS_DIR_RE = /^\.(git|hg|svn|bzr)$/i;
282
+ const isInternalOnly = (name) =>
283
+ name === "research" || name === "context" || name === "research.md" || name === "context.md" ||
284
+ name === ".DS_Store" || name.endsWith(".zip") || VCS_DIR_RE.test(name) || SECRET_FILE_RE.test(name);
285
+
286
+ /**
287
+ * The plan for a promotion: every file that would be written, and every skill reference
288
+ * that would be repointed. Built before anything is touched so --dry-run and the real run
289
+ * report the identical thing.
290
+ */
291
+ function planCopy(srcDir, destDir, root, skill, desc) {
292
+ const files = [];
293
+ const walk = (from, to) => {
294
+ for (const e of fs.readdirSync(from, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
295
+ if (isInternalOnly(e.name)) continue;
296
+ const s = path.join(from, e.name), d = path.join(to, e.name);
297
+ if (e.isDirectory()) { walk(s, d); continue; }
298
+ if (!e.isFile()) continue;
299
+ if (e.name.endsWith(".html")) {
300
+ let html = fs.readFileSync(s, "utf8");
301
+ let repointed = 0;
302
+ if (skill) {
303
+ // The same depth-aware rewrite build.js runs when it copies into dist — done
304
+ // here against the SOURCE tree so the promoted page also opens from disk, which
305
+ // dist never had to care about.
306
+ const want = path.relative(path.dirname(d), path.join(root, "skills", skill)).split(path.sep).join("/") + "/";
307
+ html = html.replace(new RegExp(String.raw`(?:\.\./)+skills/${skill}/`, "g"), (hit) => { if (hit !== want) repointed++; return want; });
308
+ }
309
+ if (e.name === "index.html" && desc) html = writeDesc(html, desc);
310
+ files.push({ dest: d, text: html, repointed });
311
+ } else {
312
+ files.push({ dest: d, from: s });
313
+ }
314
+ }
315
+ };
316
+ walk(srcDir, destDir);
317
+ return files;
318
+ }
319
+
320
+ // ── The workspace's own note ─────────────────────────────────────────────────
321
+ // One short file at the workspace root, written on the first promotion. It is what an
322
+ // agent that has never seen this workspace finds by looking at it, and the only reason it
323
+ // repeats anything from agents/canon.md is that the engine clone is not guaranteed to be
324
+ // beside every checkout. It lists no entries: an enumerated index is a second copy of the
325
+ // tree, and a second copy of the tree is a thing that goes stale.
326
+ //
327
+ // EXPORTED because a workspace has to carry it from birth, not from its first promotion.
328
+ // `augur init` writes it beside the space.json it scaffolds and the seed ships a copy, so
329
+ // the two ways a workspace can be born — self-hosted and hosted — describe their names the
330
+ // same way. Written once here and imported by both: a second copy is a thing that drifts,
331
+ // and `test/canon-naming.test.mjs` fails if either ever does.
332
+ export const NOTE = `# The canon
333
+
334
+ The screens and parts in this workspace that are meant to be **pulled by name** —
335
+ "build it the way \`invoice-detail\` is built", "pull \`invoice-list\`,
336
+ \`invoice-detail\` and \`invoice-empty\` and wire them together".
337
+
338
+ A canonical name is a directory name, in one of four places at this root:
339
+
340
+ | Where | What it holds |
341
+ | --- | --- |
342
+ | \`base/<name>/\` | ${TIER_IS.base} |
343
+ | \`components/<name>/\` | ${TIER_IS.components} |
344
+ | \`patterns/<name>/\` | ${TIER_IS.patterns} |
345
+ | \`pages/<name>/\` | ${TIER_IS.pages} |
346
+
347
+ Anything else is not the canon and is never what a bare name means: a folder under
348
+ \`<project>/prototypes/\` is a working prototype, \`playground/\` is scratch.
349
+
350
+ Names are lowercase and hyphenated, **subject first and qualifier last** —
351
+ \`invoice-list\`, \`invoice-detail\`, \`invoice-empty\`, never \`list-of-invoices\` — so
352
+ a directory listing sorts a subject's screens together and reads as a table of
353
+ contents. The qualifier comes from one closed set:
354
+
355
+ ${STATES.map((s) => `\`-${s}\``).join(" · ")}
356
+
357
+ A name never carries a version, a date or a ticket id: it says what, never when.
358
+ Every entry carries one sentence in \`<meta name="description">\` saying what it
359
+ shows.
360
+
361
+ **To pull \`<name>\`, look for \`<name>/\` in those four directories.** The entry
362
+ page is \`<tier>/<name>/index.html\` and it opens on its own, from disk, with the
363
+ workspace's design system already on it. That is the whole resolution rule, and
364
+ it needs no tool.
365
+
366
+ With the engine's CLI on hand, the same thing plus the description and the
367
+ design-system assets each entry links:
368
+
369
+ augur canon find <name> # → the tier, the folder, the entry, what it links
370
+ augur canon list # → every canonical name and its description
371
+ augur canon save <path> # → promote a working screen into the canon
372
+ augur canon check # → names that will not be found the same way twice
373
+
374
+ The full rules, with the reasoning, are the engine's \`agents/canon.md\`.
375
+ `;
376
+
377
+ function ensureNote(root) {
378
+ const p = path.join(root, "CANON.md");
379
+ if (fs.existsSync(p)) return null;
380
+ fs.writeFileSync(p, NOTE);
381
+ return "CANON.md";
382
+ }
383
+
384
+ // ── Commands ─────────────────────────────────────────────────────────────────
385
+
386
+ function resolveRoot() {
387
+ const root = spaceRoot(opt("--space") || process.cwd());
388
+ if (!root) die(`no space.json above ${process.cwd()} — run this inside a workspace clone, or pass --space <dir>.`);
389
+ return root;
390
+ }
391
+
392
+ function cmdList() {
393
+ const root = resolveRoot();
394
+ const skill = skillDirName(root);
395
+ const only = opt("--tier");
396
+ if (only && !TIERS.includes(only)) die(`unknown tier "${only}" — one of ${TIERS.join(", ")}.`);
397
+ const entries = scanCanon(root, skill).filter((e) => !only || e.tier === only);
398
+ if (flag("--json")) { console.log(JSON.stringify(entries, null, 2)); return; }
399
+ if (!entries.length) {
400
+ log(`no canonical entries yet in ${root}`);
401
+ log(`the canon starts the first time you run \`augur canon save <path>\` on a screen worth pulling again.`);
402
+ return;
403
+ }
404
+ for (const tier of TIERS) {
405
+ const rows = entries.filter((e) => e.tier === tier);
406
+ if (!rows.length) continue;
407
+ console.log(`\n${tier}/ ${C.dim}${TIER_IS[tier]}${C.off}`);
408
+ const w = Math.max(...rows.map((r) => r.name.length));
409
+ for (const r of rows) console.log(` ${r.name.padEnd(w)} ${C.dim}${r.description || "(no description — augur canon check)"}${C.off}`);
410
+ }
411
+ console.log("");
412
+ }
413
+
414
+ function cmdFind() {
415
+ const root = resolveRoot();
416
+ const skill = skillDirName(root);
417
+ const names = positional();
418
+ if (!names.length) die(`usage: augur canon find <name>… — the canonical name, exactly as you were told it.`);
419
+ const entries = scanCanon(root, skill);
420
+ const working = scanWorking(root);
421
+ const results = [];
422
+ let missing = 0;
423
+
424
+ for (const raw of names) {
425
+ const name = slugify(raw);
426
+ const hits = entries.filter((e) => e.name === name);
427
+ const shadows = working.filter((w) => w.name === name);
428
+ if (hits.length) {
429
+ for (const h of hits) results.push({ ...h, query: raw, shadows });
430
+ if (!flag("--json")) {
431
+ for (const h of hits) {
432
+ console.log(`\n${h.name} ${C.dim}${h.tier}${C.off} ${h.dir}/`);
433
+ console.log(` what ${h.description || `${C.warn}(none — an entry with no description cannot be found by anyone who does not already know it)${C.off}`}`);
434
+ console.log(` entry ${h.entry || `${C.bad}(no index.html)${C.off}`}`);
435
+ if (h.links.length) console.log(` links ${h.links.join(", ")}`);
436
+ for (const s of shadows) console.log(` also ${s.dir}/ ${C.dim}— a ${s.kind}, not the canon${C.off}`);
437
+ }
438
+ }
439
+ if (hits.length > 1) console.error(`${C.bad}[canon] "${name}" is in ${hits.length} tiers at once — one name resolves to one thing. Rename one.${C.off}`);
440
+ continue;
441
+ }
442
+ missing++;
443
+ if (flag("--json")) { results.push({ query: raw, name, tier: null, dir: null, shadows }); continue; }
444
+ console.log(`\n${name} ${C.bad}not in the canon${C.off}`);
445
+ for (const s of shadows) console.log(` ${s.dir}/ ${C.dim}— a ${s.kind} of that name. \`augur canon save ${s.dir}\` makes it canonical.${C.off}`);
446
+ const near = entries
447
+ .map((e) => ({ e, d: distance(name, e.name) }))
448
+ .filter((x) => x.d <= 3 || x.e.name.startsWith(words(name)[0] + "-"))
449
+ .sort((a, b) => a.d - b.d).slice(0, 4);
450
+ if (near.length) console.log(` did you mean ${near.map((x) => `${x.e.name} (${x.e.tier})`).join(", ")}`);
451
+ else if (!shadows.length) console.log(` ${C.dim}\`augur canon list\` prints every name this workspace has.${C.off}`);
452
+ }
453
+
454
+ if (flag("--json")) console.log(JSON.stringify(results, null, 2));
455
+ else console.log("");
456
+ if (missing) process.exit(1);
457
+ }
458
+
459
+ function cmdSave() {
460
+ const root = resolveRoot();
461
+ const skill = skillDirName(root);
462
+ const dry = flag("--dry-run");
463
+ const args = positional();
464
+ const src = path.resolve(args[0] || process.cwd());
465
+ const srcDir = isDir(src) ? src : path.dirname(src);
466
+
467
+ if (!fs.existsSync(path.join(srcDir, "index.html")))
468
+ die(`${rel(root, srcDir)}/ has no index.html — a canon entry is a folder with an entry page (agents/prototype-contract.md).`);
469
+ if (path.resolve(srcDir) === path.resolve(root)) die(`that is the workspace root, not a screen.`);
470
+ const inTier = TIERS.find((t) => path.resolve(srcDir).startsWith(path.join(root, t) + path.sep));
471
+ if (inTier) die(`${rel(root, srcDir)}/ is already canonical (${inTier}/). To rename it, move the folder — the folder name is the name.`);
472
+
473
+ const tier = opt("--tier") || "pages";
474
+ if (!TIERS.includes(tier)) die(`unknown tier "${tier}" — one of ${TIERS.join(", ")}.`);
475
+
476
+ const { name, changes } = normalizeName(opt("--as") || path.basename(srcDir));
477
+ const errs = nameErrors(name);
478
+ if (errs.length) die(`"${name}" is not a canonical name: ${errs.join("; ")}. Pass --as <name>.`);
479
+
480
+ const destDir = path.join(root, tier, name);
481
+ if (fs.existsSync(destDir) && !flag("--replace"))
482
+ die(`${tier}/${name}/ already exists. Pass --replace to overwrite it, or --as <name> to promote this as something else.`);
483
+
484
+ const clash = TIERS.filter((t) => t !== tier && fs.existsSync(path.join(root, t, name)));
485
+ if (clash.length) die(`"${name}" is already the name of ${clash.map((t) => `${t}/${name}/`).join(" and ")} — one name resolves to one thing. Pass --as <name>.`);
486
+
487
+ const srcHtml = fs.readFileSync(path.join(srcDir, "index.html"), "utf8");
488
+ const desc = opt("--desc") || readDesc(srcHtml);
489
+ if (!desc)
490
+ die(`${rel(root, srcDir)}/index.html has no <meta name="description">, so the promoted entry would carry no description — and a canon entry nobody can read the point of is one nobody pulls. Add the tag, or pass --desc "one sentence saying what it shows".`);
491
+
492
+ const files = planCopy(srcDir, destDir, root, skill, opt("--desc") ? desc : null);
493
+ const repointed = files.reduce((n, f) => n + (f.repointed || 0), 0);
494
+
495
+ for (const c of changes) log(`${C.warn}${c}${C.off}`);
496
+ log(`${dry ? "would promote" : "promoting"} ${rel(root, srcDir)}/ → ${tier}/${name}/`);
497
+ for (const f of files) console.log(` ${rel(root, f.dest)}`);
498
+ if (repointed) log(`${repointed} design-system reference${repointed === 1 ? "" : "s"} repointed for the new depth (${tier}/${name}/ sits two levels down, not three)`);
499
+ log(`what it shows: ${desc}`);
500
+
501
+ if (dry) { log(`${C.dim}--dry-run: nothing written.${C.off}`); return; }
502
+
503
+ for (const f of files) {
504
+ fs.mkdirSync(path.dirname(f.dest), { recursive: true });
505
+ if (f.text != null) fs.writeFileSync(f.dest, f.text, "utf8");
506
+ else fs.copyFileSync(f.from, f.dest);
507
+ }
508
+ const wrote = ensureNote(root);
509
+ if (wrote) log(`wrote ${wrote} — the note that tells the next agent how names in this workspace work`);
510
+
511
+ for (const n of nameNotes(name)) log(`${C.warn}${n}${C.off}`);
512
+ log(`${C.ok}${name} is canonical${C.off} — it appears under /${tier}/${name}/ on the next publish, and \`augur canon find ${name}\` resolves it now.`);
513
+ log(`${C.dim}the source folder is untouched: a prototype is the record of an exploration, a canon entry is the thing to copy.${C.off}`);
514
+ }
515
+
516
+ function cmdCheck() {
517
+ const root = resolveRoot();
518
+ const skill = skillDirName(root);
519
+ const entries = scanCanon(root, skill);
520
+ const working = scanWorking(root);
521
+ const errors = [], notes = [];
522
+
523
+ if (!entries.length) {
524
+ log(`no canonical entries in ${root} — nothing to check.`);
525
+ log(`${C.dim}the canon starts the first time you run \`augur canon save <path>\`.${C.off}`);
526
+ return;
527
+ }
528
+
529
+ const seen = new Map();
530
+ for (const e of entries) {
531
+ const where = `${e.tier}/${e.name}/`;
532
+ for (const err of nameErrors(e.name)) errors.push(`${where} ${err}`);
533
+ for (const n of nameNotes(e.name)) notes.push(`${where} ${n}`);
534
+ if (!e.entry) errors.push(`${where} no index.html — nothing to pull`);
535
+ else if (!e.description) errors.push(`${where} no <meta name="description"> — nobody can find it who does not already know it`);
536
+ for (const bad of brokenSkillRefs(path.join(root, e.dir), skill))
537
+ errors.push(`${where} references ${bad}, which does not resolve from there — the page opens unstyled from disk (wrong depth for this tier)`);
538
+ if (seen.has(e.name)) errors.push(`${where} and ${seen.get(e.name)} share a name — one name resolves to one thing`);
539
+ else seen.set(e.name, where);
540
+ }
541
+ for (const w of working) if (seen.has(w.name)) notes.push(`${w.dir}/ shadows ${seen.get(w.name)} — the canon wins, so this copy is one nobody will be sent to`);
542
+ if (!fs.existsSync(path.join(root, "CANON.md"))) notes.push(`no CANON.md at the workspace root — an agent arriving cold has nothing to read. \`augur canon save\` writes one.`);
543
+
544
+ for (const n of notes) console.log(`${C.warn}note${C.off} ${n}`);
545
+ for (const e of errors) console.log(`${C.bad}fail${C.off} ${e}`);
546
+ log(`${entries.length} canonical entr${entries.length === 1 ? "y" : "ies"} · ${errors.length} to fix · ${notes.length} to look at`);
547
+ if (!errors.length && !notes.length) log(`${C.ok}every name resolves, and every entry says what it is.${C.off}`);
548
+ // A green run says the names are FINDABLE, never that they are the right names for the
549
+ // things — whether `invoice-detail` is the screen a person would ask for by that name is
550
+ // a review question, and no lint has ever been able to answer it.
551
+ if (errors.length) process.exit(1);
552
+ }
553
+
554
+ // `augur canon` is ONE surface over two scripts, because it is one job: the extractor
555
+ // gets a design system INTO the workspace, and the verbs in this file keep it findable
556
+ // and let it grow. Splitting them into two commands would put the two halves of that job
557
+ // behind different words, which is the exact failure the naming scheme exists to prevent.
558
+ //
559
+ // The extractor lives in canon-extract.mjs and is SPAWNED rather than imported — the same
560
+ // router shape cli.mjs uses, so `node scripts/canon-extract.mjs grade` keeps working and
561
+ // its exit code is this command's exit code.
562
+ //
563
+ // ⚠️ ITS GRADE VERB IS `grade`, NOT `check`, AND THE SPLIT IS DELIBERATE. Both scripts
564
+ // arrived with a `check` meaning different things — here, "do the directory names obey
565
+ // the scheme"; there, "grade this design system's tokens". `check` stayed with the names
566
+ // because that word is already load-bearing: agents/canon.md documents it, init.mjs bakes
567
+ // it into the CANON.md every scaffolded space is born with, and test/canon-naming.test.mjs
568
+ // asserts the seed copy has not drifted from the NOTE string here. `grade` is the
569
+ // extractor's own word — its help says "Grade the answer" and its reporter takes `graded`.
570
+ const EXTRACT_VERBS = ["start", "collect", "snippet", "grade", "apply"];
571
+
572
+ // Only when this file IS the command. Imported (for NOTE), it must do nothing.
573
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
574
+ const sub = argv[0];
575
+ if (EXTRACT_VERBS.includes(sub)) {
576
+ const here = path.dirname(fileURLToPath(import.meta.url));
577
+ const r = spawnSync(process.execPath, [path.join(here, "canon-extract.mjs"), ...argv], { stdio: "inherit" });
578
+ process.exit(r.status === null ? 1 : r.status);
579
+ }
580
+ const run = { list: cmdList, find: cmdFind, save: cmdSave, check: cmdCheck }[sub];
581
+ if (!run) {
582
+ console.error("usage: augur canon <list|find|save|check|start|collect|snippet|grade|apply> [options]");
583
+ console.error("");
584
+ console.error(" the canon you have — resolve a name, and grow the canon as a side effect of working");
585
+ console.error(" augur canon find <name>… resolve a canonical name to files");
586
+ console.error(" augur canon list [--tier <t>] [--json] every canonical name and what it shows");
587
+ console.error(" augur canon save <path> [--as <name>] [--tier <t>] [--desc \"…\"] [--replace] [--dry-run]");
588
+ console.error(" augur canon check names that will not be found the same way twice");
589
+ console.error("");
590
+ console.error(" the canon you do not have yet — copy it out of a product you have a login for");
591
+ console.error(" augur canon start <url> make the working folder and your agent's brief");
592
+ console.error(" augur canon collect <url> read a PUBLIC page into evidence");
593
+ console.error(" augur canon snippet the collector to paste into your own browser");
594
+ console.error(" augur canon grade [--space <workspace>] grade the answer, or a live design system");
595
+ console.error(" augur canon apply [--space <workspace>] write it into the design system");
596
+ process.exit(sub ? 1 : 0);
597
+ }
598
+ run();
599
+ }