@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,363 @@
1
+ // Composed publish — the pure half of protocol 5.
2
+ //
3
+ // A publish used to ship one machine's tree as the WHOLE space, then spend
4
+ // hundreds of lines reconciling the damage that implied for every unit the
5
+ // publisher never touched (adopt-into-tree, peels, forks, mechanical commits).
6
+ // Protocol 5 inverts it: the LIVE manifest is the base, and this module decides,
7
+ // per authored unit, whether the publisher's build may replace what is live.
8
+ // The rule is git's own: a unit ships when it is a FAST-FORWARD of live —
9
+ // live's recorded source commit is in the publisher's history — or when there
10
+ // is local evidence of an edit. Everything else keeps live's bytes, verbatim.
11
+ // The working tree is never touched; a conflict forks in the MANIFEST only.
12
+ //
13
+ // Per-unit decision (unit in both, built bytes differ):
14
+ // 1. live IS my last publish (caller proves via cache+version) → whole tree safe,
15
+ // this module is not called at all.
16
+ // 2. live's unit is the SEED (`isSeedSource`, F-seed-yields-to-real-publish): the
17
+ // platform wrote it and it is nobody's work, so it reverts nobody → ship mine,
18
+ // outright — no evidence asked for. Unless only the decoration differs (same
19
+ // source hash, or tolerant-equal): then live's bytes AND its seed marker stay,
20
+ // because a page nobody edited must not become "theirs" on the first publish.
21
+ // 3. live's unit source: clean commit in my history → ship mine (fast-forward).
22
+ // 4. else: no local evidence I edited it → keep live's.
23
+ // evidence, but tolerant-equal → keep live's (chrome-only churn).
24
+ // evidence and really different → CONTESTED: theirs keeps the URL,
25
+ // mine composes at <unit>-conflict-<who>/ + a synthesized CONFLICT.md.
26
+ // Only in mine → new unit, ships. Only in live → stays live: implicit unpublish
27
+ // is impossible by construction. Removal needs an evidenced deletion AND
28
+ // --allow-unpublish: git-evidenced (the caller passes those units in
29
+ // `evidence.deletedUnits`), or a SEED unit the tree lacks — the platform's page
30
+ // is not a deletion anyone has to prove, but it is still an unpublish, so without
31
+ // the flag it stays live and is NAMED (removalBlocked) rather than kept in silence.
32
+ //
33
+ // ⚠️ THE SEED RULE LIVES HERE AND NOWHERE ELSE. The evidence is what each caller can
34
+ // prove — git for the CLI, the base manifest for the store — and the store runs this
35
+ // same composer to resolve a repo-less publisher's stale base (C-fork-on-conflict). A
36
+ // rule written into one caller's evidence would leave the other deciding by byte
37
+ // identity, which agrees only until a re-seed changes bytes under a seed marker.
38
+ //
39
+ // Hard rule, lint-grade: a tree folder named `*-conflict-*` NEVER ships
40
+ // implicitly (filterLitter below). Composed fork prefixes are added after the
41
+ // filter, so real conflicts still surface — but stale fork litter in a working
42
+ // tree cannot re-enter the live site, ever.
43
+ //
44
+ // Everything impure (git, the store, the tree) is the caller's problem: this
45
+ // module sees two manifests plus predicates and answers with a manifest.
46
+
47
+ // ⚠️ NO NODE IMPORTS. This module is the publish stack's BRAIN and the worker runs the same
48
+ // one — `C-fork-on-conflict` resolves a stale base inside the commit handler, and a second
49
+ // implementation of "who keeps the URL" would disagree with the client on exactly the
50
+ // publishes a conflict is about. So hashing arrives as a parameter and bytes are Uint8Array:
51
+ // `node:crypto` and `Buffer` exist in the CLI and in neither workerd nor a Durable Object.
52
+ import { authoredUnits, unitOfPath as unitOf, unitPaths } from "./publish-units.mjs";
53
+ import { isSeedSource } from "./provenance.mjs";
54
+ export { authoredUnits, unitPaths };
55
+
56
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
57
+ const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
58
+ /** The default hasher, for the CLI. A worker passes its own — see `sha256` in the options. */
59
+ async function nodeSha256(bytes) {
60
+ const { createHash } = await import("node:crypto");
61
+ return createHash("sha256").update(bytes).digest("hex");
62
+ }
63
+
64
+ export const LITTER_RE = /-conflict-[a-z0-9][a-z0-9-]*\/$/;
65
+
66
+ const sameUnitBytes = (a, b, unit) => {
67
+ const pa = unitPaths(a, unit), pb = unitPaths(b, unit);
68
+ if (pa.length !== pb.length) return false;
69
+ const bh = new Map(pb.map((p) => [p, (b.files[p] || {}).h]));
70
+ return pa.every((p) => bh.get(p) === (a.files[p] || {}).h);
71
+ };
72
+
73
+ // Same SOURCE, whatever the served bytes: `sh` is the hash build.js records of a page's
74
+ // bytes BEFORE it decorates them (og meta, the linked stamp — everything an engine change
75
+ // rewrites). It is the field the commit handler already judges per-file provenance on,
76
+ // so "did a person change this" means the same thing at both ends. A file without one
77
+ // (verbatim copies carry none) compares its served bytes.
78
+ const sameFileSource = (a, b) => (a && b && a.sh && b.sh) ? a.sh === b.sh : !!a && !!b && a.h === b.h;
79
+ const sameUnitSource = (a, b, unit) => {
80
+ const pa = unitPaths(a, unit), pb = unitPaths(b, unit);
81
+ if (pa.length !== pb.length) return false;
82
+ const bf = new Map(pb.map((p) => [p, b.files[p]]));
83
+ return pa.every((p) => bf.has(p) && sameFileSource(a.files[p], bf.get(p)));
84
+ };
85
+
86
+ // Has this live `-conflict-` fork's content reached its origin URL in `next`? True when
87
+ // the origin unit is in `next` and every file of the fork (CONFLICT.md aside) has the same
88
+ // source — `sh` when both carry it, served bytes otherwise — at the origin. This is the one
89
+ // verifiable reason a public page may vanish unasked, so the composer, the CLI's pre-check
90
+ // and the store's unpublish guard all ask this same function.
91
+ export function forkLanded(live, next, fork) {
92
+ const m = dec(fork).match(/^(.*)-conflict-([a-z0-9][a-z0-9-]*)\/$/);
93
+ if (!m) return false;
94
+ const origin = norm(m[1] + "/");
95
+ if (!(((next || {}).routing || {}).publicPrefixes || []).map(norm).includes(origin)) return false;
96
+ const strip = (u, p) => dec(p).slice(dec(u).length);
97
+ const fPaths = unitPaths(live, fork).filter((p) => !/\/CONFLICT\.md$/.test(dec(p)));
98
+ const of = new Map(unitPaths(next, origin).map((p) => [strip(origin, p), next.files[p]]));
99
+ return fPaths.length > 0 && fPaths.length === of.size
100
+ && fPaths.every((p) => sameFileSource(live.files[p], of.get(strip(fork, p))));
101
+ }
102
+
103
+ // Drop tree-derived `*-conflict-*` units from a built manifest, in place.
104
+ // Returns the unit prefixes it removed (for one summary log line).
105
+ export function filterLitter(manifest) {
106
+ const routing = (manifest || {}).routing || {};
107
+ const litter = (routing.publicPrefixes || []).map(norm).filter((u) => LITTER_RE.test(dec(u)));
108
+ if (!litter.length) return [];
109
+ const under = (p) => litter.some((u) => dec(p).startsWith(dec(u)));
110
+ routing.publicPrefixes = (routing.publicPrefixes || []).filter((u) => !LITTER_RE.test(dec(norm(u))));
111
+ for (const p of Object.keys(manifest.files || {})) if (under(p)) delete manifest.files[p];
112
+ for (const k of Object.keys(routing.versionMap || {})) if (under(k) || LITTER_RE.test(dec(norm(k)))) delete routing.versionMap[k];
113
+ if (routing.unitSources) for (const k of Object.keys(routing.unitSources)) if (LITTER_RE.test(dec(norm(k)))) delete routing.unitSources[k];
114
+ return litter;
115
+ }
116
+
117
+ const conflictNote = (unit, theirName) =>
118
+ `# Live edit conflict\n\n` +
119
+ `You and **${theirName}** changed \`${dec(unit)}\` at the same time. Their version was\n` +
120
+ `live from work your git history has never seen, so \`augur publish\` kept **theirs**\n` +
121
+ `at the original URL — any shared link still resolves — and published **yours** here.\n\n` +
122
+ `Your working tree was NOT touched: your copy still lives at its real folder.\n` +
123
+ `Compare the two pages, fold in whatever should survive, then ship — the next\n` +
124
+ `publish that fast-forwards live retires this URL. Nothing has been lost.\n`;
125
+
126
+ // Compose the manifest a publish will commit: live as the base, the publisher's
127
+ // build overlaid where it is allowed to land.
128
+ //
129
+ // mine, live — built manifest / live manifest (live non-empty; caller handles
130
+ // bootstrap and the live-is-my-last-publish case without us)
131
+ // who — fork suffix (git author identity)
132
+ // evidence — { editedUnits:Set, dirtyUnits:Set, deletedUnits:Set, editedPaths:Set }
133
+ // (git-derived by the caller; editedPaths covers shared skill files)
134
+ // ffUnits — Set of units where live's source is a clean commit in my history
135
+ // allowUnpublish — evidenced deletions actually drop (else they are kept + noted)
136
+ // tolerantEqual — async (unit) => bool: my built bytes vs live's, volatile head ignored
137
+ export async function composePublish({
138
+ mine, live, who = "someone", evidence, ffUnits,
139
+ allowUnpublish = false, tolerantEqual = async () => false,
140
+ // Injected so this module needs no runtime-specific import. The CLI gets node's; the
141
+ // worker passes one built on crypto.subtle, which is the only one it has.
142
+ sha256 = nodeSha256,
143
+ }) {
144
+ const ev = {
145
+ editedUnits: evidence?.editedUnits || new Set(),
146
+ dirtyUnits: evidence?.dirtyUnits || new Set(),
147
+ deletedUnits: evidence?.deletedUnits || new Set(),
148
+ editedPaths: evidence?.editedPaths || new Set(),
149
+ };
150
+ const ff = ffUnits || new Set();
151
+ const mineUnits = authoredUnits(mine);
152
+ const liveUnits = authoredUnits(live);
153
+ const allUnits = new Set([...mineUnits, ...liveUnits]);
154
+
155
+ const skillPrefixes = new Set();
156
+ for (const m of [mine, live]) {
157
+ for (const p of ((m || {}).routing || {}).publicSkillPrefixes || []) skillPrefixes.add(norm(p));
158
+ }
159
+ const underSkill = (p) => { const d = dec(p); return [...skillPrefixes].some((s) => d.startsWith(dec(s))); };
160
+
161
+ const mineFiles = (mine || {}).files || {};
162
+ const liveFiles = (live || {}).files || {};
163
+ const mineRouting = (mine || {}).routing || {};
164
+ const liveRouting = (live || {}).routing || {};
165
+
166
+ // Is live's copy of this unit the platform's seed? Asked through `isSeedSource()`, the
167
+ // one predicate, of the per-unit marker — falling back to the manifest's own source for
168
+ // a manifest that predates `unitSources`. After a real publish the manifest's source is
169
+ // a person's while the units nobody touched still carry the sentinel, so it is per unit.
170
+ const liveSeedUnit = (u) => isSeedSource((liveRouting.unitSources || {})[u] || (live || {}).source);
171
+ // Shared skill files carry no per-file marker; they are the seed's only while the whole
172
+ // live manifest still is (i.e. before the first real publish).
173
+ const liveSeedAll = isSeedSource((live || {}).source);
174
+
175
+ const out = {
176
+ ...mine,
177
+ files: {},
178
+ routing: { ...mineRouting, publicPrefixes: [], versionMap: { ...(mineRouting.versionMap || {}) }, unitSources: {} },
179
+ };
180
+ const readMap = {}; // composed path → path whose bytes exist in dist (fork re-keys)
181
+ const extraBlobs = {}; // hash → Uint8Array (synthesized CONFLICT.md)
182
+ // `seeded`: seed units this publish replaced. `seedKept`: seed units this tree carries
183
+ // unchanged — byte-identical, or with only their decoration changed — where live's bytes
184
+ // AND ITS SEED MARKER stay; NOT reported as kept (nothing was held back), but the caller's
185
+ // cache must know live is not exactly this tree, so the next publish composes again rather
186
+ // than fast-pathing the tree over it. The marker is the reason the byte-identical case is
187
+ // in here too: the built manifest stamps every unit as the publisher's, so a fast path
188
+ // that ships it whole would take the seed marker off five untouched pages while shipping
189
+ // one edit — measured, by the clone round trip, on the second publish from a clone.
190
+ const summary = { shipped: [], kept: [], forked: [], removed: [], removalBlocked: [], newUnits: [], keptDiffer: [], seeded: [], seedKept: [], healed: [], retired: [] };
191
+
192
+ const takeMine = (u) => {
193
+ for (const p of unitPaths(mine, u)) out.files[p] = mineFiles[p];
194
+ out.routing.publicPrefixes.push(u);
195
+ out.routing.unitSources[u] = {
196
+ sha: (mine.source || {}).sha || null,
197
+ dirty: ev.dirtyUnits.has(u),
198
+ };
199
+ };
200
+ const takeLive = (u) => {
201
+ for (const p of unitPaths(live, u)) out.files[p] = liveFiles[p];
202
+ out.routing.publicPrefixes.push(u);
203
+ const vm = (liveRouting.versionMap || {})[u];
204
+ if (vm) out.routing.versionMap[u] = vm;
205
+ const src = (liveRouting.unitSources || {})[u];
206
+ out.routing.unitSources[u] = src || {
207
+ sha: (live.source || {}).sha || null, dirty: !!(live.source || {}).dirty,
208
+ };
209
+ // My build may have generated different bytes for this unit; make sure none
210
+ // of mine leak in (paths only mine has under a kept unit stay out).
211
+ for (const p of unitPaths(mine, u)) if (!(p in out.files)) { /* dropped */ }
212
+ };
213
+
214
+ // Identical bytes from a clean tree are PROOF that the unit is this commit. When live's
215
+ // provenance for it is dirty or unknown, say so. This is what stops one dirty publish
216
+ // from poisoning every untouched unit for everybody: a legacy manifest (space-level
217
+ // `{sha, dirty:true}`, no `unitSources`) once got synthesized onto 158 units nobody had
218
+ // touched, and every clean edit after that forked as "contested" — three times for one
219
+ // person — because a dirty base can never fast-forward. A clean provenance is kept even
220
+ // when mine is newer: the older commit is the one more people can prove.
221
+ const healable = (u) => {
222
+ if (!(mine.source || {}).sha || ev.dirtyUnits.has(u)) return false;
223
+ const src = out.routing.unitSources[u];
224
+ return !(src && src.sha && !src.dirty);
225
+ };
226
+ const heal = (u) => {
227
+ if (!healable(u)) return;
228
+ out.routing.unitSources[u] = { sha: (mine.source || {}).sha, dirty: false };
229
+ summary.healed.push(u);
230
+ };
231
+
232
+ // My own earlier fork of the same unit is mine to replace: a person who forks twice
233
+ // gets ONE `-conflict-<who>` copy with their newest bytes, not -2, -3, -4. The anonymous
234
+ // fallback name is nobody's in particular, so it still numbers.
235
+ const forkName = (u) => {
236
+ const base = norm(dec(u).replace(/\/$/, "") + `-conflict-${who}`);
237
+ if (who !== "someone" && liveUnits.has(base) && !mineUnits.has(base)) return base;
238
+ let fork = base;
239
+ let n = 2;
240
+ while (liveUnits.has(fork) || mineUnits.has(fork)) fork = norm(dec(u).replace(/\/$/, "") + `-conflict-${who}-${n++}`);
241
+ return fork;
242
+ };
243
+
244
+ for (const u of [...allUnits].sort()) {
245
+ const inMine = mineUnits.has(u), inLive = liveUnits.has(u);
246
+ if (inMine && !inLive) {
247
+ takeMine(u);
248
+ summary.newUnits.push(u);
249
+ continue;
250
+ }
251
+ if (inLive && !inMine) {
252
+ // A seed unit the tree lacks needs no proof of deletion — but it is still an
253
+ // unpublish, so the flag still gates it and without the flag it is named.
254
+ if (ev.deletedUnits.has(u) || liveSeedUnit(u)) {
255
+ if (allowUnpublish) { summary.removed.push(u); continue; }
256
+ summary.removalBlocked.push(u);
257
+ } else {
258
+ // Reported as kept: live is NOT exactly this tree, so the caller's cache must not
259
+ // let the next publish fast-path the whole tree over it (which would drop this unit).
260
+ summary.kept.push(u);
261
+ }
262
+ takeLive(u);
263
+ continue;
264
+ }
265
+ // In both.
266
+ if (sameUnitBytes(mine, live, u)) {
267
+ takeLive(u);
268
+ if (liveSeedUnit(u)) summary.seedKept.push(u); // the marker is live's, and the cache must know
269
+ else heal(u);
270
+ continue;
271
+ }
272
+ if (liveSeedUnit(u)) {
273
+ // The platform's page yields to anybody's — unless nobody actually changed it.
274
+ if (sameUnitSource(mine, live, u) || await tolerantEqual(u)) { takeLive(u); summary.seedKept.push(u); continue; }
275
+ takeMine(u);
276
+ summary.seeded.push(u);
277
+ continue;
278
+ }
279
+ if (ff.has(u)) { takeMine(u); summary.shipped.push(u); continue; }
280
+ if (!ev.editedUnits.has(u)) {
281
+ takeLive(u); summary.kept.push(u);
282
+ // Same source under a different engine's decoration is the same proof as identical bytes.
283
+ if (healable(u) && (sameUnitSource(mine, live, u) || await tolerantEqual(u))) heal(u);
284
+ continue;
285
+ }
286
+ if (await tolerantEqual(u)) { takeLive(u); summary.kept.push(u); heal(u); continue; }
287
+ // Contested: theirs keeps the URL, mine composes at a fork prefix.
288
+ takeLive(u);
289
+ summary.keptDiffer.push(u);
290
+ const fork = forkName(u);
291
+ const theirName = (live && live.publishedBy) || ((live || {}).source || {}).actor || "a collaborator";
292
+ if (liveUnits.has(fork)) {
293
+ // Replacing my own earlier fork: its files were taken as a live-only unit above.
294
+ for (const p of unitPaths(live, fork)) delete out.files[p];
295
+ out.routing.publicPrefixes = out.routing.publicPrefixes.filter((x) => x !== fork);
296
+ delete out.routing.versionMap[fork];
297
+ }
298
+ for (const p of unitPaths(mine, u)) {
299
+ const fp = fork + dec(p).slice(dec(u).length);
300
+ out.files[fp] = mineFiles[p];
301
+ readMap[fp] = p;
302
+ }
303
+ const note = new TextEncoder().encode(conflictNote(u, theirName));
304
+ const noteHash = await sha256(note);
305
+ out.files[fork + "CONFLICT.md"] = { h: noteHash, ct: "text/markdown; charset=utf-8", s: note.length };
306
+ extraBlobs[noteHash] = note;
307
+ out.routing.publicPrefixes.push(fork);
308
+ const vm = (mineRouting.versionMap || {})[u];
309
+ if (vm) out.routing.versionMap[fork] = vm;
310
+ out.routing.unitSources[fork] = { sha: (mine.source || {}).sha || null, dirty: ev.dirtyUnits.has(u) };
311
+ summary.forked.push({ unit: u, fork, theirs: theirName });
312
+ }
313
+
314
+ // Shared skill files: per file — mine ships only with evidence; live's are never
315
+ // implicitly dropped; a file only I have is new and ships.
316
+ for (const p of new Set([...Object.keys(mineFiles), ...Object.keys(liveFiles)])) {
317
+ if (!underSkill(p)) continue;
318
+ const a = mineFiles[p], b = liveFiles[p];
319
+ if (a && !b) { out.files[p] = a; continue; }
320
+ if (!a && b) { out.files[p] = b; continue; }
321
+ if (a.h === b.h) { out.files[p] = a; continue; }
322
+ if (ev.editedPaths.has(p)) { out.files[p] = a; }
323
+ else if (liveSeedAll) { out.files[p] = sameFileSource(a, b) ? b : a; }
324
+ else { out.files[p] = b; summary.kept.push(p); }
325
+ }
326
+
327
+ // Everything else is generated output (galleries, indexes, landing, tokens):
328
+ // mine wins wholesale — any publish regenerates all of it.
329
+ for (const [p, f] of Object.entries(mineFiles)) {
330
+ if (p in out.files) continue;
331
+ if (unitOf(p, allUnits) || underSkill(p)) continue;
332
+ out.files[p] = f;
333
+ }
334
+
335
+ // A fork retires the moment it is redundant: its bytes are at the origin URL now
336
+ // (whoever shipped them), or its author just shipped the origin (their fold-in
337
+ // superseded it). CONFLICT.md promised exactly this. Nothing else ever removes one:
338
+ // a stranger's fork of a unit nobody shipped stays, like any live-only unit.
339
+ const shippedNow = new Set([...summary.shipped, ...summary.newUnits, ...summary.seeded]);
340
+ const forkedNow = new Set(summary.forked.map((f) => f.fork));
341
+ for (const f of [...out.routing.publicPrefixes]) {
342
+ const m = dec(f).match(/^(.*)-conflict-([a-z0-9][a-z0-9-]*)\/$/);
343
+ if (!m || forkedNow.has(f) || !liveUnits.has(f)) continue;
344
+ const origin = norm(m[1] + "/");
345
+ if (!out.routing.publicPrefixes.includes(origin)) continue;
346
+ const mineFork = who !== "someone" && (m[2] === who || m[2].startsWith(who + "-"));
347
+ // The author's own fork also retires when they ship the origin with FURTHER changes —
348
+ // the store cannot verify that one, so it needs --allow-unpublish; the CLI says so.
349
+ if (!forkLanded(live, out, f) && !(mineFork && shippedNow.has(origin))) continue;
350
+ for (const p of unitPaths(live, f)) delete out.files[p];
351
+ out.routing.publicPrefixes = out.routing.publicPrefixes.filter((x) => x !== f);
352
+ delete out.routing.versionMap[f];
353
+ delete out.routing.unitSources[f];
354
+ summary.retired.push(f);
355
+ }
356
+
357
+ // Carry unitSources for kept units even when live predates the field entirely
358
+ // (out.routing.unitSources was filled per unit above; nothing else to do), and
359
+ // keep prefix order stable for humans reading the manifest.
360
+ out.routing.publicPrefixes.sort();
361
+
362
+ return { manifest: out, readMap, extraBlobs, summary };
363
+ }
@@ -0,0 +1,221 @@
1
+ // Fork as a VERB — copying an artifact by aliasing its blobs, never by moving bytes.
2
+ //
3
+ // `F-fork-verb`. Forking already existed here twice, and neither one was a verb. It existed
4
+ // as conflict RESOLUTION (`C-fork-on-conflict`: two people edit the same unit, theirs keeps
5
+ // the URL, mine is re-keyed under a fork prefix), and it existed as "an agent copies a
6
+ // folder and publishes it" — which needs a repo, a checkout and an upload of every byte for
7
+ // content the store is already holding. Divergence-then-convergence as the collaboration
8
+ // model needs the deliberate one: take this artifact, give me my own copy of it, remember
9
+ // where it came from.
10
+ //
11
+ // ⚠️ IT IS THE SAME TRICK THE CONFLICT FORK USES, AND ON PURPOSE. A manifest maps a URL to a
12
+ // CONTENT HASH; the bytes live once in a content-addressed pool. So a copy is new manifest
13
+ // KEYS pointing at hashes the store already has — a hundred-file prototype forks without
14
+ // uploading, reading or hashing a single blob, and `bytesReferenced` does not move, because
15
+ // the fork references exactly the same blobs the source does. Inventing a second mechanism
16
+ // (re-upload, server-side blob copy) would cost bytes to produce a byte-identical result and
17
+ // would give the two forks two different definitions of what a copy is.
18
+ //
19
+ // ⚠️ THE COPY ESCAPES THE ORIGINAL'S ACL, which is the rule `B-resource-authz-hook` states:
20
+ // stamp the FORKER as the new unit's owner rather than inheriting the source's, or "fork it
21
+ // to get my own editable copy" hands the fork the same restriction it was forked to escape.
22
+ // The owner is a `personId`, never an address — see below.
23
+ //
24
+ // ⚠️ LINEAGE IS ADDITIVE AND OPTIONAL, so there is no flag day. `routing.forkedFrom` and
25
+ // `routing.unitOwners` are unit-keyed maps beside `unitSources`, which is already exactly
26
+ // this shape. A manifest that carries neither means today what it meant before they existed
27
+ // — `test/manifest-lineage-compat.test.mjs` renders a pre-lineage manifest against a
28
+ // baseline generated by the code that predates the fields, rather than asserting it.
29
+ //
30
+ // ⚠️ PEOPLE ARE `personId`, NEVER AN ADDRESS. Same deviation the per-file provenance stamp
31
+ // records at the commit handler, for the same reason: a manifest is read by more things than
32
+ // a comment thread is, and the store's manifests are readable by any holder of a publish
33
+ // token. The ONE field that carries an address is `publishedBy`, which predates all of this
34
+ // and has its own erasure path (`redactProvenance`); nothing this module writes adds a
35
+ // second one.
36
+ //
37
+ // ⚠️ NO NODE IMPORTS, and no hashing. The worker runs this module directly, so it may not
38
+ // reach for `node:crypto` — and it never needs to, because a fork computes no new bytes.
39
+
40
+ import { authoredUnits, unitPaths } from "./publish-units.mjs";
41
+ import { LITTER_RE } from "./publish-compose.mjs";
42
+
43
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
44
+ const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
45
+
46
+ /**
47
+ * A fork TARGET is a plain path, and that is a deliberate narrowing.
48
+ *
49
+ * Manifest file keys are the literal decoded paths the build wrote; routing prefixes are
50
+ * percent-encoded. Re-keying a hundred files across that seam is where an encoding bug would
51
+ * live, so a new verb simply declines the ambiguity: a fork target is ASCII path segments,
52
+ * and anything needing an escape is refused with a reason rather than guessed at. The SOURCE
53
+ * is under no such rule — it is matched against the manifest's own spelling of its units, so
54
+ * an already-published unit with an escaped name can still be forked.
55
+ */
56
+ const PLAIN_PATH = /^\/(?:[A-Za-z0-9._~-]+\/)+$/;
57
+
58
+ /**
59
+ * Compose the manifest a fork commits: live, plus one unit aliased at a new path.
60
+ *
61
+ * live — the live manifest (the fork is always OF what is live; forking a historical
62
+ * version would publish bytes no URL currently serves)
63
+ * from — the source unit, any spelling of a `routing.publicPrefixes` entry
64
+ * to — the new path
65
+ * by — the forker's `personId`, or null when the caller cannot name one
66
+ *
67
+ * Returns `{manifest, aliased, sourceVersion}` or `{error, …}`. It reads no bytes and
68
+ * computes no hashes: every entry it writes is a copy of an entry live already carries.
69
+ */
70
+ export function composeFork({ live, from, to, by = null }) {
71
+ const m = live || {};
72
+ const files = m.files || {};
73
+ const routing = m.routing || {};
74
+ const src0 = norm(from), dst = norm(to);
75
+ const bad = (reason) => ({ error: "bad-fork-path", reason });
76
+
77
+ if (!src0.startsWith("/") || !dst.startsWith("/")) return bad("a fork path is absolute");
78
+ if (dec(src0) === "/" || dec(dst) === "/") return bad("the site root is not an artifact");
79
+ if (!PLAIN_PATH.test(dst)) return bad("a fork target is plain ASCII path segments");
80
+ if (LITTER_RE.test(dec(dst))) return bad("`-conflict-` names belong to conflict forks");
81
+
82
+ // A fork that nests inside its source (or contains it) puts one unit's files under
83
+ // another's prefix, and `unitOfPath` resolves longest-prefix-wins — so the two would
84
+ // disagree about which unit half the files belong to for the rest of the space's life.
85
+ const sd = dec(src0), dd = dec(dst);
86
+ if (sd === dd) return bad("a fork needs a path of its own");
87
+ if (dd.startsWith(sd) || sd.startsWith(dd)) return bad("a fork cannot nest inside its source, or contain it");
88
+
89
+ // Match the source against the manifest's OWN spelling of its units, so a caller may name
90
+ // it either encoded or decoded and still hit the same unit.
91
+ const units = authoredUnits(m);
92
+ const src = [...units].find((u) => dec(u) === sd);
93
+ if (!src) return { error: "unknown-unit", path: src0 };
94
+ const paths = unitPaths(m, src);
95
+ // A prefix with no files behind it serves nothing already — copying it would publish an
96
+ // empty URL and call it an artifact.
97
+ if (!paths.length) return { error: "unknown-unit", path: src0 };
98
+
99
+ if (units.has(dst) || [...units].some((u) => dec(u) === dd)) return { error: "fork-target-exists", path: dst };
100
+ for (const p of Object.keys(files)) {
101
+ if (dec(p).startsWith(dd)) return { error: "fork-target-exists", path: dst };
102
+ }
103
+
104
+ const out = {
105
+ ...m,
106
+ files: { ...files },
107
+ routing: {
108
+ ...routing,
109
+ publicPrefixes: [...(routing.publicPrefixes || [])],
110
+ versionMap: { ...(routing.versionMap || {}) },
111
+ unitSources: { ...(routing.unitSources || {}) },
112
+ forkedFrom: { ...(routing.forkedFrom || {}) },
113
+ unitOwners: { ...(routing.unitOwners || {}) },
114
+ },
115
+ };
116
+
117
+ // The alias itself. The entry is copied WHOLE — hash, mime, size, and the per-file
118
+ // provenance stamp the source carries. The bytes did not change, so neither did who last
119
+ // changed them: restamping a hundred files with the forker is precisely the "a mass commit
120
+ // reset every card to edited-now" failure `C-manifest-provenance` exists to retire. WHO
121
+ // MADE THE COPY is a different fact, and it is `unitOwners` below.
122
+ const aliased = [];
123
+ for (const p of paths) {
124
+ const q = dst + dec(p).slice(sd.length);
125
+ out.files[q] = { ...files[p] };
126
+ aliased.push([q, p]);
127
+ }
128
+
129
+ out.routing.publicPrefixes = [...new Set([...out.routing.publicPrefixes, dst])].sort();
130
+ const vm = (routing.versionMap || {})[src];
131
+ if (vm) out.routing.versionMap[dst] = vm;
132
+ const us = (routing.unitSources || {})[src];
133
+ // The fork's bytes came from that commit, whatever the forker has (or has not) checked out.
134
+ if (us) out.routing.unitSources[dst] = { ...us };
135
+ else if (m.source) out.routing.unitSources[dst] = { sha: m.source.sha || null, dirty: !!m.source.dirty };
136
+
137
+ // The lineage record. The VERSION is live's, so "forked from /x/ at v41" names a manifest
138
+ // the store still holds — versions are never pruned, so the parent is always recoverable
139
+ // even after the source has moved on or been unpublished.
140
+ out.routing.forkedFrom[dst] = { path: src, version: Number(m.version) || 0 };
141
+ if (by) out.routing.unitOwners[dst] = by;
142
+
143
+ return { manifest: out, aliased, from: src, to: dst, sourceVersion: Number(m.version) || 0 };
144
+ }
145
+
146
+ /**
147
+ * Which unit-keyed lineage entries survive into the next manifest.
148
+ *
149
+ * ⚠️ THIS IS WHY A FORK DOES NOT EVAPORATE ON THE NEXT ORDINARY PUBLISH. A publisher's
150
+ * manifest is built from their own tree and knows nothing about a fork somebody else made,
151
+ * so a commit that took `routing` verbatim would drop the lineage and the owner while
152
+ * keeping the files — the fork would still serve, and would have quietly become an
153
+ * anonymous folder nobody owns.
154
+ *
155
+ * ⚠️ AND IT IS WHY OWNERSHIP IS NOT A FIELD YOU CAN TYPE. `B-resource-owner-schema-basis`'s
156
+ * first rule is that an owner is stamped from the session and never read from the body; the
157
+ * same has to hold here, or an ACL becomes a thing a publish can assert about itself. So
158
+ * whatever the incoming manifest says is DISCARDED, and the answer is derived from what the
159
+ * server already recorded, for the units that are still there.
160
+ */
161
+ /**
162
+ * The lineage a manifest may assert about ITSELF, when there is nothing to shadow.
163
+ *
164
+ * ⚠️ THIS EXISTS FOR ONE CALLER: A RESTORE INTO AN EMPTY STORE. `augur restore` (and the
165
+ * migration built on it) replays an exported manifest as an ordinary publish, so a
166
+ * workspace that is moved would otherwise arrive with its forks still serving and their
167
+ * parentage and owners gone — silently, since nothing renders them yet.
168
+ *
169
+ * ⚠️ AND IT IS TRUST-ON-FIRST-PUBLISH, the pattern this handler already uses for
170
+ * `space.adminOnly` and `space.default`: a claim is honoured only when the store holds no
171
+ * prior value it could be overwriting. The moment there is one, `carriedLineage` decides and
172
+ * the body is ignored, so no publish can ever reassign an owner by asserting one.
173
+ *
174
+ * The shapes are checked rather than trusted: an owner must look like a `personId` and MAY
175
+ * NOT contain an address, so even the one trusted path cannot put one in a manifest, and a
176
+ * lineage row must name a real declared unit.
177
+ */
178
+ const PERSON_ID = /^[0-9a-z]{1,32}$/;
179
+
180
+ export function assertedLineage(routing) {
181
+ const r = routing || {};
182
+ const declared = new Set();
183
+ for (const p of r.publicPrefixes || []) { declared.add(String(p)); declared.add(dec(p)); }
184
+ const out = {};
185
+ const from = r.forkedFrom;
186
+ if (from && typeof from === "object" && !Array.isArray(from)) {
187
+ const kept = {};
188
+ for (const [u, v] of Object.entries(from)) {
189
+ if (!declared.has(u) && !declared.has(dec(u))) continue;
190
+ if (!v || typeof v !== "object" || typeof v.path !== "string" || !Number.isInteger(v.version)) continue;
191
+ kept[u] = { path: v.path, version: v.version };
192
+ }
193
+ if (Object.keys(kept).length) out.forkedFrom = kept;
194
+ }
195
+ const owners = r.unitOwners;
196
+ if (owners && typeof owners === "object" && !Array.isArray(owners)) {
197
+ const kept = {};
198
+ for (const [u, v] of Object.entries(owners)) {
199
+ if (!declared.has(u) && !declared.has(dec(u))) continue;
200
+ if (typeof v !== "string" || !PERSON_ID.test(v)) continue;
201
+ kept[u] = v;
202
+ }
203
+ if (Object.keys(kept).length) out.unitOwners = kept;
204
+ }
205
+ return out;
206
+ }
207
+
208
+ export function carriedLineage(cur, nextRouting) {
209
+ const prev = (cur && cur.routing) || {};
210
+ const keep = new Set();
211
+ for (const p of (nextRouting && nextRouting.publicPrefixes) || []) { keep.add(String(p)); keep.add(dec(p)); }
212
+ const out = {};
213
+ for (const field of ["forkedFrom", "unitOwners"]) {
214
+ const from = prev[field];
215
+ if (!from || typeof from !== "object") continue;
216
+ const kept = {};
217
+ for (const [u, v] of Object.entries(from)) if (keep.has(u) || keep.has(dec(u))) kept[u] = v;
218
+ if (Object.keys(kept).length) out[field] = kept;
219
+ }
220
+ return out;
221
+ }
@@ -0,0 +1,44 @@
1
+ // The UNIT of publishing, as a pure module both the CLI and the WORKER can hold.
2
+ //
3
+ // A unit is a prototype/playground folder — the thing a URL names and a person edits, never
4
+ // a lone file — and units are exactly the routing fragment's `publicPrefixes`. Galleries get
5
+ // `versionMap` entries and no prefix, which is what keeps them out.
6
+ //
7
+ // ⚠️ IT LIVES IN src/ BECAUSE THE SERVER NEEDS IT TOO. `C-fork-on-conflict` resolves a stale
8
+ // base inside the commit handler, and it has to agree with the client about what a unit is
9
+ // down to the last character. Two definitions of "which folder does this path belong to"
10
+ // would disagree on exactly the paths a conflict is about. scripts/lib/publish-conflict.mjs
11
+ // re-exports these three so no CLI import has to move.
12
+
13
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
14
+ const norm = (p) => String(p == null ? "" : p).replace(/\/?$/, "/");
15
+
16
+ export function authoredUnits(manifest) {
17
+ const out = new Set();
18
+ for (const p of ((manifest || {}).routing || {}).publicPrefixes || []) out.add(norm(p));
19
+ return out;
20
+ }
21
+
22
+ /**
23
+ * Which unit owns this path, or null for a path outside every unit.
24
+ *
25
+ * ⚠️ THE LONGEST PREFIX WINS, and the first version of this returned the first match. With
26
+ * `/toolkit/` and `/toolkit/embed/` both units, a file under the second belonged to the
27
+ * first or the second depending on Set iteration order — which is insertion order, which is
28
+ * whatever the manifest happened to list. A conflict decided by JSON key order is a conflict
29
+ * decided at random.
30
+ */
31
+ export function unitOfPath(path, unitSet) {
32
+ const p = dec(path);
33
+ let best = null;
34
+ for (const u of unitSet) {
35
+ const d = dec(u);
36
+ if (p.startsWith(d) && (!best || d.length > dec(best).length)) best = u;
37
+ }
38
+ return best;
39
+ }
40
+
41
+ export function unitPaths(manifest, unit) {
42
+ const prefix = dec(unit);
43
+ return Object.keys((manifest || {}).files || {}).filter((p) => dec(p).startsWith(prefix));
44
+ }