@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,74 @@
1
+ // instance.mjs — how a script in this engine finds ITS instance's config.
2
+ //
3
+ // The engine is generic and shared by every instance: it carries no account ids, no
4
+ // worker names, no origins. Those live in the DEPLOY SHELL — a sibling repo holding
5
+ // identity.json + deploy.config.json — whose repo NAME differs per instance. So the
6
+ // shell is resolved by SHAPE, never by name: a sibling dir with an identity.json at its
7
+ // root that is not a space and is not the engine. Explicit env always wins; a raw engine
8
+ // clone with no shell resolves to nothing, and callers fall back or fail loudly.
9
+ //
10
+ // build.js takes the same values through GV_IDENTITY_PATH / GV_DEPLOY_CONFIG_PATH (the
11
+ // shell's CI passes them explicitly); this is the local-script equivalent.
12
+
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const ENGINE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+
19
+ export function findShellDir(root = ENGINE_ROOT, originHost = "") {
20
+ const parent = path.join(root, "..");
21
+ try {
22
+ const shells = readdirSync(parent, { withFileTypes: true })
23
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".")
24
+ && path.resolve(parent, e.name) !== path.resolve(root)
25
+ && !existsSync(path.join(parent, e.name, "space.json"))
26
+ && existsSync(path.join(parent, e.name, "identity.json")))
27
+ .map((e) => path.join(parent, e.name)).sort();
28
+ // Several instances can share one parent folder. When the caller knows which
29
+ // ORIGIN it is talking to, the shell whose declared siteOrigin matches that
30
+ // host wins; with one shell, or no host, it is the first by name.
31
+ if (originHost && shells.length > 1) {
32
+ const match = shells.find((s) => {
33
+ try {
34
+ const o = JSON.parse(readFileSync(path.join(s, "deploy.config.json"), "utf8")).siteOrigin || "";
35
+ return new URL(o).host === originHost;
36
+ } catch { return false; }
37
+ });
38
+ if (match) return match;
39
+ }
40
+ return shells[0] || null;
41
+ } catch { return null; }
42
+ }
43
+
44
+ // The host of an origin URL, or "" if it isn't one. Shared so every caller feeds
45
+ // findShellDir the same shape.
46
+ export function originHost(origin) {
47
+ try { return new URL(origin).host; } catch { return ""; }
48
+ }
49
+
50
+ // The instance's deploy.config.json (siteOrigin, realtimeOrigin, …) — the same file the
51
+ // build injects into the worker. GV_DEPLOY_CONFIG_PATH wins, then the shell's, then one
52
+ // at the engine root (a single-repo instance). Missing or unreadable → {}.
53
+ //
54
+ // `preferHost` disambiguates when several instances share one parent folder. Without
55
+ // it findShellDir falls back to the first shell BY NAME, so with two shells checked
56
+ // out side by side the alphabetically-first one answered for every space in the
57
+ // folder — and since this config's siteOrigin outranks the space's own in every
58
+ // origin-resolving caller, `augur publish` and `augur login` from space B aimed
59
+ // themselves at instance A. Which live instance a publish reached came down to
60
+ // directory sort order.
61
+ //
62
+ // Callers pass the host of the space's OWN declared siteOrigin: the space repo names
63
+ // the instance it belongs to, so it is the right tiebreak. It is only ever a
64
+ // tiebreak — with one shell (the ordinary case, and anyone who cloned a starter
65
+ // space to run their own instance) the answer is that shell either way, so a fork
66
+ // still publishes to ITS shell rather than to the origin its space.json inherited.
67
+ export function deployConfig(root = ENGINE_ROOT, preferHost = "") {
68
+ const shell = findShellDir(root, preferHost);
69
+ const file = process.env.GV_DEPLOY_CONFIG_PATH
70
+ || [shell && path.join(shell, "deploy.config.json"), path.join(root, "deploy.config.json")]
71
+ .filter(Boolean).find((f) => existsSync(f));
72
+ if (!file || !existsSync(file)) return {};
73
+ try { return JSON.parse(readFileSync(file, "utf8")); } catch { return {}; }
74
+ }
@@ -0,0 +1,107 @@
1
+ // Working marks, client side — one definition of the path spelling and one of the phrasing.
2
+ //
3
+ // `F-presence-marks`. Three commands surface marks (`mark`, `status`, `pull`) and a fourth
4
+ // will. If each spelled a path its own way, two agents naming the same folder would write
5
+ // two rows and read past each other — which is the exact failure the feature exists to
6
+ // prevent, arriving through the tool that was supposed to prevent it. So the normalization
7
+ // here MIRRORS `normalizeMarkPath` in src/_worker.js on purpose, and the server's answer is
8
+ // always the one printed back: the client never assumes its own spelling won.
9
+ //
10
+ // ⚠️ A MARK REFUSES NOTHING. Nothing in this file returns a verdict, sets an exit code, or
11
+ // gives a caller something to branch on that would let it block. It reads, and it prints.
12
+
13
+ /** Leading and trailing slash. Same rule as the worker, for the same containment reason. */
14
+ export function normalizeMarkPath(p) {
15
+ const s = String(p == null ? "" : p).trim().slice(0, 300);
16
+ if (!s) return "";
17
+ const t = s.replace(/^\.\//, "").replace(/\/{2,}/g, "/");
18
+ if (!t || t === "/") return "/";
19
+ return `/${t.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
20
+ }
21
+
22
+ /**
23
+ * A REPO folder, as the URL it publishes to.
24
+ *
25
+ * `<project>/prototypes/<name>` is the nesting `discoverSpaces()` looks in, and it is
26
+ * served at `/<project>/<name>/`. An agent has just been editing the folder, so it is the
27
+ * folder it will type; taking it without translation would mark a path no card and no
28
+ * published unit will ever match.
29
+ */
30
+ export function markPathFor(input) {
31
+ return normalizeMarkPath(String(input == null ? "" : input).replace(/\/prototypes\//g, "/"));
32
+ }
33
+
34
+ /** Does either path contain the other? The whole overlap test. */
35
+ export function marksOverlap(a, b) {
36
+ const x = normalizeMarkPath(a), y = normalizeMarkPath(b);
37
+ if (!x || !y) return false;
38
+ return x === y || x.startsWith(y) || y.startsWith(x);
39
+ }
40
+
41
+ /**
42
+ * Of the marks that were there before you wrote yours, whose are worth telling you about.
43
+ *
44
+ * ⚠️ "SOMEBODY ELSE" IS DECIDED BY WHO, NEVER BY WHERE, and this function exists so that
45
+ * decision has somewhere to be tested. The obvious way to stop your own renewal warning at
46
+ * you is to drop the exact path from the list — and that silently drops the ONE case the
47
+ * whole feature exists to surface: two agents on the same prototype. It shipped that way
48
+ * once and printed nothing at all for an exact collision, which is worse than not having
49
+ * the warning, because it reads as an all-clear.
50
+ *
51
+ * `mine` is the id the INSTANCE resolved from the credential and handed back, never one the
52
+ * client worked out for itself — the same rule the row's authorship follows.
53
+ */
54
+ export function othersOverlapping(before, path, mine) {
55
+ return (before || []).filter((m) => m && m.personId !== mine && marksOverlap(m.path, path));
56
+ }
57
+
58
+ /**
59
+ * Every live mark at an instance. NEVER THROWS: a `status` or a `pull` that died because
60
+ * the coordination note could not be fetched would make the note the most fragile thing in
61
+ * the toolchain. An older instance answers 404 and gets an empty list, which reads exactly
62
+ * like "nobody is working on anything" — and is the right answer there, because on that
63
+ * instance nobody can be.
64
+ */
65
+ export async function fetchMarks(req) {
66
+ try {
67
+ const r = await req("_marks/list");
68
+ const body = await r.json();
69
+ return Array.isArray(body.marks) ? body.marks : [];
70
+ } catch (e) { return []; }
71
+ }
72
+
73
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
74
+
75
+ /** "4 minutes ago" / "just now", from a millisecond age. */
76
+ export function since(ms) {
77
+ const s = Math.max(0, Math.round(ms / 1000));
78
+ if (s < 45) return "just now";
79
+ if (s < 5400) return `${plural(Math.round(s / 60), "minute")} ago`;
80
+ return `${plural(Math.round(s / 3600), "hour")} ago`;
81
+ }
82
+
83
+ /**
84
+ * "for another 6 minutes", from a millisecond remainder.
85
+ *
86
+ * Switches to hours at exactly 3600s rather than at the 90 minutes `since` uses, because
87
+ * a mark's ceiling IS an hour: at the other threshold the longest mark anybody can ask
88
+ * for would read "for another 60 minutes", and the hours branch could never fire at all.
89
+ */
90
+ export function forAnother(ms) {
91
+ const s = Math.max(0, Math.round(ms / 1000));
92
+ if (s < 60) return `for another ${plural(s, "second")}`;
93
+ if (s < 3600) return `for another ${plural(Math.round(s / 60), "minute")}`;
94
+ return `for another ${plural(Math.round(s / 3600), "hour")}`;
95
+ }
96
+
97
+ /**
98
+ * One line per mark. `by` is null when the id behind the mark resolves to nobody on the
99
+ * roster — a token an admin labelled by hand, or somebody who has since left — and
100
+ * "Someone" is the honest rendering of that, never a guess.
101
+ */
102
+ export function markLine(m) {
103
+ const who = m.by || "Someone";
104
+ const started = Date.parse(m.startedAt);
105
+ const age = Number.isFinite(started) ? since(Date.now() - started) : "";
106
+ return `${m.path} ${who}${age ? ` · started ${age}` : ""} · ${forAnother(m.expiresIn)}`;
107
+ }
@@ -0,0 +1,138 @@
1
+ // Live URL → source path. The reverse of what build.js does one way.
2
+ //
3
+ // `C-clone-pull-materializer`. A published manifest is a map of URLs to content hashes.
4
+ // Turning it back into an editable tree means undoing build.js's mapping, and that mapping
5
+ // is LOSSY in one specific place: `/<folder>/<name>/` is a prototype whose source lives at
6
+ // `<folder>/prototypes/<name>/`, while `/base/<name>/` is a gallery tier whose source is
7
+ // the URL verbatim. Nothing in the path itself distinguishes them.
8
+ //
9
+ // SO IT DOES NOT GUESS. The manifest carries `routing.publicPrefixes`, which is exactly
10
+ // the list of prototype folders — the same list the unpublish guard is built on. A URL
11
+ // under one of those is a prototype; a URL under a tier name is a tier; everything else is
12
+ // either verbatim or generated. Reading the answer out of the manifest is what makes this
13
+ // correct rather than a heuristic that works until somebody names a project "base".
14
+ //
15
+ // WHAT IS GENERATED AND MUST NOT BE WRITTEN. A publish ships build OUTPUT as well as
16
+ // source: the landing page, each project's index, each tier's index, the whole /tokens/
17
+ // page and the search index. Writing those into a source tree would produce a tree that
18
+ // rebuilds into something different from what it came from — a clone that cannot be
19
+ // published back. They are skipped, and `skipped` reports what and why, because a silent
20
+ // omission in a tool whose whole promise is "leaving is free" is the wrong kind of quiet.
21
+
22
+ /** Gallery tiers: authored folders whose URL is their source path. */
23
+ export const TIERS = Object.freeze(["base", "components", "pages", "patterns"]);
24
+
25
+ /**
26
+ * Files at the root of a space that are real source.
27
+ *
28
+ * `registry.json` is the overlay catalog — the design system's contract, which the build
29
+ * REQUIRES beside a skill and refuses to build without — and `prototype-status.json` is
30
+ * the dev-status baseline every project index is rendered from. Both ship with the publish
31
+ * for exactly this reason (`C-clone-publish-roundtrip`): a clone that came back without them
32
+ * was a tree that could not be published again — the build said so only after the clone had
33
+ * said everything was fine — or one that rebuilt into a different site. The rule is the
34
+ * build's own: what it READS to reproduce a space travels; what it WRITES is skipped here.
35
+ */
36
+ const ROOT_SOURCE = new Set(["space-icon.png", "space-icon.svg", "registry.json", "prototype-status.json"]);
37
+
38
+ /** Generated pages a publish carries that no source tree contains. */
39
+ const GENERATED_EXACT = new Set(["/index.html", "/__search.json"]);
40
+
41
+ /**
42
+ * Decide what one published URL is.
43
+ * Returns { kind: "source", path } | { kind: "generated", why }.
44
+ *
45
+ * `prefixes` is the manifest's routing.publicPrefixes.
46
+ */
47
+ export function classify(url, prefixes = []) {
48
+ if (!url || url[0] !== "/") return { kind: "generated", why: "not a rooted URL" };
49
+ if (GENERATED_EXACT.has(url)) return { kind: "generated", why: "the built landing page or search index" };
50
+
51
+ const rel = url.slice(1);
52
+ const parts = rel.split("/");
53
+
54
+ // Root-level source files.
55
+ if (parts.length === 1) {
56
+ return ROOT_SOURCE.has(parts[0])
57
+ ? { kind: "source", path: parts[0] }
58
+ : { kind: "generated", why: "a built file at the site root" };
59
+ }
60
+
61
+ // The design system's own assets ship verbatim under their declared prefix — with one
62
+ // exception build.js writes INTO the skill folder rather than beside it: graph.js, the
63
+ // composition graph parsed from the very stylesheets sitting next to it. It lives there
64
+ // because it is space content rather than shared chrome, and it is derived, so a clone
65
+ // that wrote it back would put build output into a source tree.
66
+ if (parts[0] === "skills") {
67
+ if (parts[parts.length - 1] === "graph.js") {
68
+ return { kind: "generated", why: "the composition graph, parsed from the stylesheets beside it" };
69
+ }
70
+ return { kind: "source", path: rel };
71
+ }
72
+
73
+ // The tokens page is generated FROM the stylesheet; the stylesheet itself lives under
74
+ // skills/ and is caught above.
75
+ if (parts[0] === "tokens") return { kind: "generated", why: "the tokens page is derived from the stylesheet" };
76
+
77
+ // A tier: /base/<name>/… is source at the same path. /base/index.html is the tier index,
78
+ // which build.js writes.
79
+ if (TIERS.includes(parts[0])) {
80
+ if (parts.length === 2 && parts[1] === "index.html") return { kind: "generated", why: `the ${parts[0]} tier index` };
81
+ return { kind: "source", path: rel };
82
+ }
83
+
84
+ // Playground ships verbatim; its folder index is generated.
85
+ if (parts[0] === "playground") {
86
+ if (parts.length === 2 && parts[1] === "index.html") return { kind: "generated", why: "the playground index" };
87
+ return { kind: "source", path: rel };
88
+ }
89
+
90
+ // Anything else is a project folder. A file directly inside it is that project's
91
+ // generated index; a file inside a NAMED sub-folder is a prototype — but only if the
92
+ // manifest says so, which is the whole point of consulting publicPrefixes.
93
+ if (parts.length === 2) return { kind: "generated", why: `the ${parts[0]} project index` };
94
+ const prefix = `/${parts[0]}/${parts[1]}/`;
95
+ if (prefixes.includes(prefix)) {
96
+ return { kind: "source", path: [parts[0], "prototypes", ...parts.slice(1)].join("/") };
97
+ }
98
+ return { kind: "generated", why: `not a published prototype (${prefix} is not in publicPrefixes)` };
99
+ }
100
+
101
+ /**
102
+ * Classify a whole manifest. Returns { files: [{url, path, h, s}], skipped: [{url, why}] }.
103
+ * Deterministic order, so two runs produce the same plan and a diff of the plan is readable.
104
+ */
105
+ export function materializePlan(manifest) {
106
+ const prefixes = (manifest && manifest.routing && manifest.routing.publicPrefixes) || [];
107
+ const files = [], skipped = [];
108
+ for (const [url, meta] of Object.entries((manifest && manifest.files) || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
109
+ const c = classify(url, prefixes);
110
+ if (c.kind === "source") files.push({ url, path: c.path, h: meta && meta.h, s: (meta && meta.s) || 0 });
111
+ else skipped.push({ url, why: c.why });
112
+ }
113
+ return { files, skipped };
114
+ }
115
+
116
+ /**
117
+ * A space.json for a cloned tree. space.json is not a served asset and is in no manifest
118
+ * as a file, so it has to be synthesized — and it is deliberately minimal: anything
119
+ * INFERRED rather than known would be a guess written into the file that decides how the
120
+ * space builds.
121
+ *
122
+ * What the manifest does KNOW is carried (`C-clone-publish-roundtrip`): the build records
123
+ * the space's own record on every manifest (`manifest.space` — name, badge, description,
124
+ * projects label, help sections), and each of those is baked into the generated pages, so
125
+ * a tree without them rebuilds into a site with a different name on it. They are copied
126
+ * only when the record actually carries them; an empty value is the build's default, not
127
+ * a fact about the space. `siteOrigin` is always the origin this clone was taken from —
128
+ * the address the workspace answers at now — never one recorded before a move.
129
+ */
130
+ export function synthesizeSpaceJson(id, origin, space = null) {
131
+ const out = { id, default: true, siteOrigin: origin };
132
+ const s = space && typeof space === "object" ? space : {};
133
+ for (const k of ["name", "badge", "description", "projectsLabel"]) {
134
+ if (typeof s[k] === "string" && s[k].trim()) out[k] = s[k];
135
+ }
136
+ if (Array.isArray(s.help) && s.help.length) out.help = s.help;
137
+ return out;
138
+ }
@@ -0,0 +1,124 @@
1
+ // merge3.mjs — line diff and three-way merge, for `augur sync`.
2
+ //
3
+ // The server never merges (docs/drafts-that-land.md). What the CLI may do, on the agent's
4
+ // own disk and for the agent to check, is fold a landing on main into a draft where the two
5
+ // sets of changes touch DIFFERENT lines. Where they touch the same lines it stops and says
6
+ // so — both versions are handed over, nothing is guessed.
7
+ //
8
+ // Plain Node, no dependencies. The diff trims the common prefix and suffix first — an
9
+ // agent's edit is a few hunks in a mostly identical file — and runs a plain longest-common-
10
+ // subsequence table over what is left. A middle region too large for the table (a rewrite,
11
+ // not an edit) becomes ONE hunk: coarser, so it conflicts more readily, and never wrong.
12
+
13
+ /** Cells the LCS table may hold before the middle is treated as one hunk (2000 × 2000). */
14
+ export const LCS_CELLS = 4_000_000;
15
+
16
+ /** Replace hunks turning `a` into `b`, both arrays of lines. */
17
+ export function diffLines(a, b) {
18
+ let pre = 0;
19
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
20
+ let suf = 0;
21
+ while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;
22
+ const A = a.slice(pre, a.length - suf), B = b.slice(pre, b.length - suf);
23
+ return hunksLCS(A, B).map((h) => ({ aStart: h.aStart + pre, aEnd: h.aEnd + pre, bStart: h.bStart + pre, bEnd: h.bEnd + pre }));
24
+ }
25
+
26
+ function hunksLCS(a, b) {
27
+ const N = a.length, M = b.length;
28
+ if (!N && !M) return [];
29
+ if (!N) return [{ aStart: 0, aEnd: 0, bStart: 0, bEnd: M }];
30
+ if (!M) return [{ aStart: 0, aEnd: N, bStart: 0, bEnd: 0 }];
31
+ if (N * M > LCS_CELLS) return [{ aStart: 0, aEnd: N, bStart: 0, bEnd: M }];
32
+ // dp[i][j] = length of the longest common subsequence of a[i..] and b[j..]
33
+ const dp = new Array(N + 1);
34
+ for (let i = 0; i <= N; i++) dp[i] = new Uint32Array(M + 1);
35
+ for (let i = N - 1; i >= 0; i--) {
36
+ for (let j = M - 1; j >= 0; j--) {
37
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
38
+ }
39
+ }
40
+ const hunks = [];
41
+ let i = 0, j = 0, open = null;
42
+ const close = () => { if (open) { open.aEnd = i; open.bEnd = j; hunks.push(open); open = null; } };
43
+ while (i < N || j < M) {
44
+ if (i < N && j < M && a[i] === b[j]) { close(); i++; j++; continue; }
45
+ if (!open) open = { aStart: i, aEnd: i, bStart: j, bEnd: j };
46
+ if (j < M && (i >= N || dp[i][j + 1] >= dp[i + 1][j])) j++; // a line of b is inserted
47
+ else i++; // a line of a is deleted
48
+ }
49
+ close();
50
+ return hunks;
51
+ }
52
+
53
+ const split = (s) => {
54
+ const str = String(s);
55
+ if (str === "") return { lines: [], trailing: false };
56
+ const lines = str.split("\n");
57
+ const trailing = lines[lines.length - 1] === "";
58
+ if (trailing) lines.pop();
59
+ return { lines, trailing };
60
+ };
61
+
62
+ /** Push `arr`'s elements onto `out` without spreading — a spread throws past ~120k args. */
63
+ function append(out, arr) {
64
+ for (let i = 0; i < arr.length; i += 10000) push.apply(out, arr.slice(i, i + 10000));
65
+ return out;
66
+ }
67
+ const push = Array.prototype.push;
68
+
69
+ /** Three-way merge of strings. Overlaps are conflicts, never guesses. */
70
+ export function merge3(base, mine, theirs) {
71
+ const B = split(base), A = split(mine), C = split(theirs);
72
+ const ha = diffLines(B.lines, A.lines).map((h) => ({ ...h, side: "mine", lines: A.lines.slice(h.bStart, h.bEnd) }));
73
+ const hc = diffLines(B.lines, C.lines).map((h) => ({ ...h, side: "theirs", lines: C.lines.slice(h.bStart, h.bEnd) }));
74
+ const out = [];
75
+ const conflicts = [];
76
+ let pos = 0;
77
+ let i = 0, j = 0;
78
+ const overlaps = (p, q) => p.aStart < q.aEnd && q.aStart < p.aEnd || (p.aStart === p.aEnd && q.aStart === q.aEnd && p.aStart === q.aStart);
79
+ while (i < ha.length || j < hc.length) {
80
+ const p = ha[i], q = hc[j];
81
+ let take, region;
82
+ if (p && q && overlaps(p, q)) {
83
+ // Grow the region until neither side overlaps it any further.
84
+ let start = Math.min(p.aStart, q.aStart), end = Math.max(p.aEnd, q.aEnd);
85
+ let ii = i + 1, jj = j + 1;
86
+ for (;;) {
87
+ let grew = false;
88
+ while (ii < ha.length && ha[ii].aStart < end) { end = Math.max(end, ha[ii].aEnd); ii++; grew = true; }
89
+ while (jj < hc.length && hc[jj].aStart < end) { end = Math.max(end, hc[jj].aEnd); jj++; grew = true; }
90
+ if (!grew) break;
91
+ }
92
+ const mineLines = applyHunks(B.lines, ha.slice(i, ii), start, end);
93
+ const theirLines = applyHunks(B.lines, hc.slice(j, jj), start, end);
94
+ const identical = mineLines.length === theirLines.length && mineLines.every((l, k) => l === theirLines[k]);
95
+ if (!identical) conflicts.push({ baseStart: start, baseEnd: end, mine: mineLines, theirs: theirLines });
96
+ take = mineLines; region = { aStart: start, aEnd: end };
97
+ i = ii; j = jj;
98
+ } else if (!q || (p && p.aStart <= q.aStart)) {
99
+ take = p.lines; region = p; i++;
100
+ } else {
101
+ take = q.lines; region = q; j++;
102
+ }
103
+ append(out, B.lines.slice(pos, region.aStart));
104
+ append(out, take);
105
+ pos = region.aEnd;
106
+ }
107
+ append(out, B.lines.slice(pos));
108
+ const trailing = A.trailing || C.trailing;
109
+ const text = out.length === 0 ? "" : out.join("\n") + (trailing ? "\n" : "");
110
+ return { ok: conflicts.length === 0, text, conflicts };
111
+ }
112
+
113
+ /** The lines `base[start..end)` become after applying `hunks` (all inside that range). */
114
+ function applyHunks(base, hunks, start, end) {
115
+ const out = [];
116
+ let pos = start;
117
+ for (const h of hunks) {
118
+ append(out, base.slice(pos, h.aStart));
119
+ append(out, h.lines);
120
+ pos = h.aEnd;
121
+ }
122
+ append(out, base.slice(pos, end));
123
+ return out;
124
+ }
@@ -0,0 +1,120 @@
1
+ // Which store a local preview talks to, and whether its canvas joins the real rooms.
2
+ //
3
+ // WHY THIS IS ITS OWN FILE. It used to be twenty lines in the middle of offline.mjs with
4
+ // no coverage at all, and it decides the one thing about local development that can hurt
5
+ // somebody else: whether `npm run offline` reads and writes the LIVE KV namespace — real
6
+ // comments, real pins, real rosters, real canvas boards — or a local sandbox.
7
+ //
8
+ // Getting it backwards is silent in both directions. A run that thinks it is live but is
9
+ // not shows an empty site and wastes an afternoon. A run that thinks it is a sandbox but
10
+ // is not writes a stranger's comment thread from a half-finished branch. Neither prints
11
+ // a warning, because both are working exactly as the code says.
12
+ //
13
+ // A refactor of exactly this code is how the second one ships green, which is why it is
14
+ // now a pure function with a table of cases under it.
15
+ //
16
+ // THE RULE, and it is one rule with two halves that must agree:
17
+ //
18
+ // LIVE the credentials for the real namespace are all present, so the worker talks
19
+ // to production KV through the REST shim. If the instance's realtime secret is
20
+ // there too, the canvas joins the real rooms; without it the realtime worker
21
+ // answers 403 and the canvas degrades to solo, which is correct but worth
22
+ // knowing.
23
+ // SANDBOX no credentials, so KV is local — AND realtime is disabled outright. A
24
+ // "sandbox" whose boards still broadcast into the shared production rooms is
25
+ // not a sandbox, and that half is the one somebody would drop by accident.
26
+
27
+ /** Parse a KEY=value env file. Tolerant by design: a missing file is an empty posture. */
28
+ export function parseEnvFile(text) {
29
+ const out = {};
30
+ for (const line of String(text || "").split("\n")) {
31
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
32
+ if (m) out[m[1]] = m[2].trim();
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Decide the posture from a parsed env file.
39
+ *
40
+ * Returns {live, realtime, reason, kv, secrets} where `secrets` is a name→value map the
41
+ * caller turns into bindings. `reason` is a sentence for a human: this is the value that
42
+ * gets printed, and a posture nobody prints is a posture nobody checks.
43
+ */
44
+ export function derivePosture(envFile = {}, opts = {}) {
45
+ const e = envFile || {};
46
+ const wanted = ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "GV_KV_NS"];
47
+ const present = wanted.filter((k) => e[k]);
48
+ const live = present.length === wanted.length;
49
+
50
+ // An explicit sandbox request beats credentials that happen to be lying around. There
51
+ // is deliberately no inverse flag: nothing may turn a sandbox into a live run except
52
+ // the credentials themselves being present.
53
+ if (opts.forceSandbox) {
54
+ return {
55
+ live: false, realtime: "disabled",
56
+ reason: "sandbox (forced): local KV, realtime disabled",
57
+ kv: "local", secrets: { GV_RT_DISABLE: "1" },
58
+ };
59
+ }
60
+
61
+ if (!live) {
62
+ // PARTIAL credentials are the case worth naming. Two of three present means somebody
63
+ // is halfway through configuring, and silently running as a sandbox reads as "the
64
+ // credentials do not work".
65
+ const partial = present.length > 0;
66
+ return {
67
+ live: false, realtime: "disabled",
68
+ reason: partial
69
+ ? `sandbox: local KV, realtime disabled — ${wanted.filter((k) => !e[k]).join(", ")} missing from .env.deploy`
70
+ : "sandbox: local KV, realtime disabled",
71
+ kv: "local", secrets: { GV_RT_DISABLE: "1" },
72
+ missing: wanted.filter((k) => !e[k]),
73
+ };
74
+ }
75
+
76
+ const secrets = {
77
+ GV_KV_TOKEN: e.CLOUDFLARE_API_TOKEN,
78
+ GV_KV_ACCOUNT: e.CLOUDFLARE_ACCOUNT_ID,
79
+ GV_KV_NS: e.GV_KV_NS,
80
+ };
81
+ if (e.RT_SHARED_SECRET) {
82
+ secrets.RT_SHARED_SECRET = e.RT_SHARED_SECRET;
83
+ return {
84
+ live: true, realtime: "joined",
85
+ reason: "LIVE production KV, and the canvas joins the real rooms",
86
+ kv: "live", secrets,
87
+ };
88
+ }
89
+ return {
90
+ live: true, realtime: "solo",
91
+ reason: "LIVE production KV; no RT_SHARED_SECRET, so boards run solo (the realtime worker will 403)",
92
+ kv: "live", secrets,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * The wrangler argv a posture implies: `--var K:V` per secret.
98
+ *
99
+ * ON ARGV RATHER THAN IN THE GENERATED CONFIG. These values are a Cloudflare API token and
100
+ * the shared realtime secret, read out of `.env.deploy`. Putting them in the wrangler.toml
101
+ * the offline server writes would be a SECOND copy of a live credential on disk, in a file
102
+ * nobody edits and everybody forgets — the first copy at least announces itself by name.
103
+ *
104
+ * `--var K:V` splits on the FIRST colon and keeps the rest, so a secret containing colons
105
+ * survives (verified against the wrangler this repo spawns). `--var K=V` is silently
106
+ * ignored — it binds nothing and reports nothing, which is why this is one function with a
107
+ * test rather than a string built at the call site.
108
+ */
109
+ export function postureVars(posture) {
110
+ return Object.entries(posture.secrets || {}).flatMap(([k, v]) => ["--var", `${k}:${v}`]);
111
+ }
112
+
113
+ /**
114
+ * The line a human reads before deciding whether to type anything. It must never carry a
115
+ * credential — the whole point is that it is printed on every run, and a token echoed on
116
+ * every local start ends up in a screen recording.
117
+ */
118
+ export function postureBanner(posture) {
119
+ return `[offline] KV: ${posture.kv} — ${posture.reason}`;
120
+ }
@@ -0,0 +1,18 @@
1
+ // Respawn policy for the offline server's wrangler child. Pure on purpose: the
2
+ // decision ("respawn in N ms" / "give up") is the part worth testing, and importing
3
+ // this must never spawn anything. See test/offline-sandbox.test.mjs.
4
+ //
5
+ // A crash LOOP (the port is taken, the built worker doesn't parse) must not turn
6
+ // into an infinite restart storm: five crashes inside a minute means retrying won't
7
+ // help and a human needs the log.
8
+ const LOOP_WINDOW_MS = 60_000;
9
+ const LOOP_LIMIT = 5;
10
+
11
+ // crashTimes: ms timestamps of previous unexpected exits (any monotonic clock).
12
+ // Returns the delay in ms before the next spawn, or null to give up.
13
+ export function respawnDelay(crashTimes, now) {
14
+ const recent = crashTimes.filter((t) => now - t < LOOP_WINDOW_MS).length;
15
+ if (recent >= LOOP_LIMIT) return null;
16
+ // 1s, 2s, 4s, 8s… per recent crash — quick after a one-off, calmer under repeats.
17
+ return 1000 * Math.pow(2, recent);
18
+ }
@@ -0,0 +1,78 @@
1
+ // The wrangler config `npm run offline` writes for itself.
2
+ //
3
+ // `A-offline-worker`. The offline server used to spawn `wrangler pages dev dist`, which
4
+ // needs no config at all — Pages takes the asset directory as an argument and infers the
5
+ // rest. A plain Worker cannot: the asset directory, its binding, the KV namespace and the
6
+ // entry all have to be declared, and one of those declarations IS THE LOGIN GATE.
7
+ //
8
+ // WHY LOCAL DEVELOPMENT MOVES TO THE WORKER FRONT DOOR AT ALL. Pages and Workers invert
9
+ // request precedence, silently. Pages runs the worker first and lets it decide what is
10
+ // public; a Worker serves a MATCHING STATIC ASSET FIRST and only invokes the worker on a
11
+ // miss, unless `run_worker_first = true`. The asset directory here is `dist`, and
12
+ // `dist/__config/instance.json` carries the roster INCLUDING SEED PASSWORDS. So the
13
+ // difference between the two front doors is the difference between a gated site and a
14
+ // published roster, and until now nobody ran the second one except in production.
15
+ //
16
+ // Running it locally every day is how that difference gets found by a person rather than
17
+ // by a stranger. It is the same reasoning as scripts/wrangler-preflight.mjs, one step
18
+ // earlier: the preflight refuses a bad deploy config, and this makes the good shape the
19
+ // one every local run exercises.
20
+ //
21
+ // It is GENERATED rather than committed for the same reason the shell template is not a
22
+ // live wrangler.toml: a config in the repo root is a config a stray `npx wrangler` picks
23
+ // up. This one is written into `.wrangler/`, which is gitignored, and rewritten on every
24
+ // start so it can never be stale.
25
+ //
26
+ // WHAT IS DELIBERATELY NOT IN IT: the posture secrets. Those are a live Cloudflare API
27
+ // token and the shared realtime secret, and they ride on argv (see postureVars) so this
28
+ // file never becomes a second copy of a credential on disk.
29
+
30
+ /** The `[assets]` line the whole gate depends on. Exported so a test can name it. */
31
+ export const RUN_WORKER_FIRST = "run_worker_first = true";
32
+
33
+ /**
34
+ * The TOML text. Absolute paths throughout, because wrangler resolves them relative to the
35
+ * CONFIG rather than to the cwd, and this config does not live beside the things it names.
36
+ *
37
+ * @param {{root: string, compatibilityDate?: string, kvId?: string}} opts
38
+ */
39
+ export function offlineWranglerConfig({ root, compatibilityDate = "2024-09-01", kvId = "offline-local" } = {}) {
40
+ if (!root) throw new Error("offlineWranglerConfig needs the engine root");
41
+ return `# GENERATED by scripts/offline.mjs on every start — edit that, not this.
42
+ # Not a deploy config: no account, no real namespace id, no route. See
43
+ # templates/shell/wrangler.example.toml for the shape a real instance uses.
44
+
45
+ name = "augur-offline"
46
+ # The REAL deploy entry, the same file a live Worker runs and the same one
47
+ # scripts/no-tenant-globals.mjs scans — so a local run exercises the deployed module graph
48
+ # rather than the copy build.js leaves in dist/.
49
+ main = ${JSON.stringify(join(root, "src/entry.js"))}
50
+ compatibility_date = ${JSON.stringify(compatibilityDate)}
51
+
52
+ [assets]
53
+ directory = ${JSON.stringify(join(root, "dist"))}
54
+ binding = "ASSETS"
55
+ # ⛔ THE GATE. Without this, a Worker serves a matching static asset BEFORE the worker runs
56
+ # — including dist/__config/instance.json, which carries the roster with seed passwords,
57
+ # and every built page. Pages had the opposite default, which is why this line is new and
58
+ # why it is the one line in this file worth reading twice.
59
+ ${RUN_WORKER_FIRST}
60
+ # A miss is a miss, so the worker's own 404 branches fire and dist/404.html is reachable.
61
+ not_found_handling = "none"
62
+ # /foo, /foo/ and /foo/index.html all resolve — what published prototype folders rely on.
63
+ html_handling = "auto-trailing-slash"
64
+
65
+ # Comments, pins, statuses, rosters, boards. LOCAL: a live run reaches production KV
66
+ # through the REST shim in the worker (GV_KV_* on argv), never through this binding, so
67
+ # there is nothing here that a stray local write could reach.
68
+ [[kv_namespaces]]
69
+ binding = "COMMENTS"
70
+ id = ${JSON.stringify(kvId)}
71
+ `;
72
+ }
73
+
74
+ // No node:path import: this module is pure text and its only path work is a join, which
75
+ // keeps it testable without a filesystem.
76
+ function join(a, b) {
77
+ return a.replace(/\/+$/, "") + "/" + b;
78
+ }