@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,460 @@
1
+ // ship.mjs — the default way a change goes out: commit, publish, push.
2
+ //
3
+ // augur ship commit everything, publish, push
4
+ // augur ship -m "message" with your own commit message
5
+ // … --space <id> from outside the space folder
6
+ // … --no-push commit + publish only (offline)
7
+ // … --dry-run say what would happen, change nothing
8
+ // … --allow-unpublish let this ship take live public pages down
9
+ // … --legacy on a workspace that serves drafts, ship a tree anyway
10
+ // (one release; the way there is `augur open` / `augur land`)
11
+ //
12
+ // Three jobs used to be three decisions, and skipping any of them was silent:
13
+ // commit local, instant, cannot fail — the step that makes losing work
14
+ // structurally impossible. Untracked files included, deliberately:
15
+ // work that reached the live site while existing in no repository is
16
+ // exactly how two prototypes ended up one `git clean` from gone.
17
+ // publish makes the live URL true, in seconds. This is what people look at,
18
+ // so it runs BEFORE the push — a network problem must never stand
19
+ // between someone and seeing their own work. (A quick fetch+merge
20
+ // runs first when origin is reachable, so a stale checkout ships the
21
+ // union instead of briefly reverting whoever shipped since.)
22
+ // push makes GitHub true: how everyone else (and their agents) learn what
23
+ // changed. Retried, because it is the only step that can legitimately
24
+ // fail — someone else may have pushed first.
25
+ //
26
+ // The last line of stdout is the live URL, so an agent can hand it straight to a
27
+ // human. Progress goes to stderr. Exit code is truth.
28
+
29
+ import { spawn, execFileSync } from "node:child_process";
30
+ import { draftsServed } from "./lib/draft.mjs";
31
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
32
+ import path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
36
+
37
+ // The checks contract: a developer-time gate (e.g. a prototype-lint ratchet) must
38
+ // NEVER wall the path between a prompt and its live URL, and never reach the human —
39
+ // the live URL + durability are the user's promise, not a decision to punt back. That
40
+ // guarantee is the ENGINE's, not each space's hook. A space opts in by declaring
41
+ // `augur:generate` / `augur:gate` npm scripts (see below): ship then owns the commit
42
+ // (runs them, commits with --no-verify) so no hook can block it. AUGUR_SHIP stays set
43
+ // as the interim bridge for spaces that haven't migrated — their hook can read it to
44
+ // degrade a gate to advisory. Manual `git commit` and CI keep the gate's teeth either
45
+ // way. Generic + inert for spaces that declare neither.
46
+ process.env.AUGUR_SHIP = "1";
47
+
48
+ const log = (msg) => console.error(`\x1b[35m[ship]\x1b[0m ${msg}`);
49
+ const warn = (msg) => console.error(`\x1b[33m[ship]\x1b[0m ${msg}`);
50
+ const die = (msg) => { console.error(`\x1b[31m[ship]\x1b[0m ${msg}`); process.exit(1); };
51
+ // Appended when publish itself couldn't happen this run: the sanctioned meanwhile is
52
+ // the real local shell, never a bare file:// path — see MEANWHILE in publish.mjs.
53
+ // publish.mjs's own stderr (inherited below) already carries this, but its die()
54
+ // message is the one an agent that only reads ship's own output would otherwise miss.
55
+ const MEANWHILE = "Meanwhile: `node scripts/dev.mjs` runs a full local preview " +
56
+ "(chrome, login, canvas) — always local-only, not shipped, nobody else can see it. " +
57
+ "Never hand over a file:// path.";
58
+
59
+ const args = process.argv.slice(2);
60
+ const flag = (f) => args.includes(f);
61
+ const opt = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
62
+ const MSG = opt("-m") || opt("--message");
63
+ const NO_PUSH = flag("--no-push");
64
+ const DRY = flag("--dry-run");
65
+ const ALLOW_UNPUBLISH = flag("--allow-unpublish");
66
+
67
+ // ── which space ──────────────────────────────────────────────────────────────
68
+ const idOf = (dir) => {
69
+ try { return JSON.parse(readFileSync(path.join(dir, "space.json"), "utf8")).id || path.basename(dir); }
70
+ catch (e) { return path.basename(dir); }
71
+ };
72
+ let dir = null;
73
+ if (existsSync(path.join(process.cwd(), "space.json"))) dir = process.cwd();
74
+ const want = opt("--space");
75
+ if (want) {
76
+ const parent = path.join(ROOT, "..");
77
+ for (const root of [parent, path.join(ROOT, "spaces")]) {
78
+ let entries = [];
79
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch (e) { continue; }
80
+ for (const e of entries) {
81
+ const d = path.join(root, e.name);
82
+ if (e.isDirectory() && existsSync(path.join(d, "space.json")) && idOf(d) === want) dir = d;
83
+ }
84
+ }
85
+ if (!dir) die(`no space "${want}" next to this engine.`);
86
+ }
87
+ if (!dir) die("run this from a space folder, or name one with --space <id>.");
88
+ const SPACE = idOf(dir);
89
+
90
+ // ── drafts that land: where the instance serves drafts, ship is not the way ─
91
+ // One fact, read from the instance's public well-known file: does it serve drafts? Where
92
+ // it does, a prototype is changed by opening it, editing and landing — a whole tree is no
93
+ // longer what goes live, and shipping one here would put every session's half-done work
94
+ // on the site at once. Where it does not (every self-hosted instance today) nothing here
95
+ // changes. `--legacy` runs the old path for one release, and says so.
96
+ const LEGACY = flag("--legacy");
97
+ {
98
+ let origin = process.env.AUGUR_ORIGIN || "";
99
+ try { origin = origin || JSON.parse(readFileSync(path.join(dir, "space.json"), "utf8")).siteOrigin || ""; } catch (e) { /* no origin known: nothing to ask */ }
100
+ if (origin && await draftsServed(origin)) {
101
+ if (LEGACY) warn("this workspace serves drafts; shipping a whole tree here is the legacy path and goes away next release.");
102
+ else {
103
+ console.error(`\x1b[31m[ship]\x1b[0m ${origin} serves drafts, so a prototype is changed by opening it, not by shipping a tree:\n\n` +
104
+ ` augur open <opportunity>/<prototype> # a folder of its own, live at once at its draft address\n` +
105
+ ` …edit; every save is live there…\n` +
106
+ ` augur land # the real URL moves; the last line printed is the live URL\n\n` +
107
+ `If the landing is refused because main moved: augur sync, then augur land again.\n` +
108
+ `Read agents/drafts.md in the engine clone. \`augur ship --legacy\` runs the old path for one release.`);
109
+ process.exit(1);
110
+ }
111
+ }
112
+ }
113
+
114
+ const git = (...a) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" }).trim();
115
+ const gitQuiet = (...a) => {
116
+ try { return { ok: true, out: git(...a) }; }
117
+ catch (e) { return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}`.trim() }; }
118
+ };
119
+
120
+ // ── ⚠️ THIS FOLDER MAY NOT BE A REPO, AND THAT IS NOT AN ERROR ───────────────
121
+ //
122
+ // `C-repo-less-ship`. "Repo-less multi-editor at v1, not phase two" is settled: a hosted
123
+ // workspace may never have a git repo, and `augur clone` already produces a folder with no
124
+ // `.git` in it on purpose. Until now the FIRST thing this script did after resolving the
125
+ // workspace was `git rev-parse`, so shipping such a folder died with an uncaught
126
+ // execFileSync throw — a stack trace, from the one command a person is told always works.
127
+ //
128
+ // So git is OPTIONAL here. Without it, two of the three steps have nothing to do: there is
129
+ // nothing to commit to and nothing to push to. Publishing is the whole of it, and publish.mjs
130
+ // was already git-tolerant — `repoState()` and `refuseShallow()` both no-op when git fails.
131
+ //
132
+ // ⚠️ AND THE CONFLICT PATH MOVES TO THE STORE RATHER THAN DISAPPEARING. A git-backed ship
133
+ // resolves a concurrent edit locally, from evidence. A repo-less one has no evidence, so it
134
+ // asks the store to resolve it (`--fork-on-conflict`, C-fork-on-conflict) and prints the
135
+ // result in the same words. What must not happen is a second, quieter code path where a
136
+ // concurrent edit silently overwrites somebody: same event, same vocabulary, same outcome.
137
+ // `.git` is checked with existsSync rather than isDirectory because a worktree and a
138
+ // submodule both make it a FILE, and both are real repositories.
139
+ const HAS_GIT = existsSync(path.join(dir, ".git"));
140
+
141
+ const BRANCH = HAS_GIT ? git("rev-parse", "--abbrev-ref", "HEAD") : null;
142
+ // Who forked, for the folder name. `git var GIT_AUTHOR_IDENT` rather than
143
+ // `git config user.email`, because that config is often unset — git then derives
144
+ // an identity from the machine and stamps commits with it regardless. Asking for
145
+ // the config gives you an empty string and a folder called "-conflict-someone";
146
+ // asking git what it will ACTUALLY sign as gives you the person.
147
+ const whoami = (() => {
148
+ if (!HAS_GIT) return "someone"; // unused without git: the store names the fork's owner
149
+ const ident = gitQuiet("var", "GIT_AUTHOR_IDENT").out || "";
150
+ const email = (/<([^>]*)>/.exec(ident) || [, ""])[1];
151
+ const n = email.split("@")[0] || (ident.split("<")[0] || "").trim() || "someone";
152
+ return n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "someone";
153
+ })();
154
+
155
+ // ── the checks contract: ship owns the commit path when the space opts in ────
156
+ // A space declares two optional npm scripts:
157
+ // augur:generate — regenerate its derived files (indexes, allowlists). MUST succeed;
158
+ // a failure is a real correctness error, surfaced to the AGENT (never
159
+ // the human) and aborts the ship. Its output is staged by `git add -A`.
160
+ // augur:gate — run its quality gates (lint ratchets). ADVISORY on the ship path:
161
+ // findings are printed for the agent, they never block. The SAME gate
162
+ // is blocking on a manual `git commit` (the hook) and in CI — teeth
163
+ // stay for anyone editing source by hand; the vibecoder is shielded.
164
+ // When either is declared, ship commits with `--no-verify` so no hook can wall the path.
165
+ // A space with neither keeps today's behaviour (the hook runs and may block).
166
+ const spaceScripts = (() => {
167
+ try { return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")).scripts || {}; }
168
+ catch { return {}; }
169
+ })();
170
+ const HAS_GENERATE = !!spaceScripts["augur:generate"];
171
+ const HAS_GATE = !!spaceScripts["augur:gate"];
172
+ const SHIP_OWNS = HAS_GENERATE || HAS_GATE;
173
+ const runInSpace = (script) => execFileSync("npm", ["run", "--silent", script], { cwd: dir, stdio: "inherit" });
174
+ function runGenerate() {
175
+ if (!HAS_GENERATE) return;
176
+ log("augur:generate — refreshing derived files");
177
+ try { runInSpace("augur:generate"); }
178
+ catch { die(`augur:generate failed (a generator, not a gate) — nothing committed, nothing lost. Fix and re-run. ${MEANWHILE}`); }
179
+ }
180
+ function runGateAdvisory() {
181
+ if (!HAS_GATE) return;
182
+ try { runInSpace("augur:gate"); }
183
+ catch { warn("augur:gate reported findings — committing anyway (advisory on the ship path). Clean up any real new debt in a follow-up; it is never the human's decision."); }
184
+ }
185
+ // A commit a developer-time gate cannot wall, once the space owns the path.
186
+ const commit = (...a) => git("commit", "-q", ...(SHIP_OWNS ? ["--no-verify"] : []), ...a);
187
+
188
+ // ── 1. commit ────────────────────────────────────────────────────────────────
189
+ // A prototype's folder is the unit people think in, so name the commit after the
190
+ // folders that moved rather than a file count.
191
+ function foldersOf(paths) {
192
+ const out = new Set();
193
+ for (const p of paths) {
194
+ const m = /^([a-z0-9][a-z0-9._-]*)\/prototypes\/([a-z0-9][a-z0-9._-]*)\//.exec(p)
195
+ || /^(playground)\/([a-z0-9][a-z0-9._-]*)\//.exec(p);
196
+ out.add(m ? m[2] : p.split("/")[0]);
197
+ }
198
+ return [...out];
199
+ }
200
+
201
+ const porcelainLines = HAS_GIT ? git("status", "--porcelain").split("\n").filter(Boolean) : [];
202
+ // Stale conflict-fork folders never ride into a person's commit: publish
203
+ // (protocol 5) never writes tree forks, so an UNTRACKED `*-conflict-*` folder is
204
+ // leftover litter — sweeping it would stamp this person's face and "edited just
205
+ // now" on pages they never touched (2026-08-19/22, three times). Tracked ones
206
+ // follow git normally (deleting them is a real change worth committing).
207
+ const LITTER_RE = /(^|\/)[^/]+-conflict-[a-z0-9][a-z0-9-]*(\/|$)/;
208
+ const untrackedLitter = porcelainLines
209
+ .filter((l) => l.startsWith("??"))
210
+ .map((l) => l.slice(3).replace(/^"|"$/g, ""))
211
+ .filter((p) => LITTER_RE.test(p));
212
+ const dirtyPaths = porcelainLines
213
+ .map((l) => l.slice(3).replace(/^"|"$/g, ""))
214
+ .filter((p) => !untrackedLitter.includes(p));
215
+ let committed = null;
216
+
217
+ if (untrackedLitter.length) {
218
+ warn(`${untrackedLitter.length} stale conflict folder(s) left uncommitted — they never publish; fold what matters into the real folder, then delete them`);
219
+ }
220
+ if (!HAS_GIT) {
221
+ // The generator still runs — derived files are content, not bookkeeping, and a workspace
222
+ // that declares one expects its indexes refreshed before what it publishes is decided.
223
+ // The gate still runs advisory, for the same reason it does on the git path.
224
+ if (!DRY) { runGenerate(); runGateAdvisory(); }
225
+ log("no git here — publishing the folder as it stands");
226
+ } else if (dirtyPaths.length) {
227
+ const touched = foldersOf(dirtyPaths);
228
+ const subject = MSG || `Ship ${touched.slice(0, 3).join(", ")}${touched.length > 3 ? ` +${touched.length - 3} more` : ""}`;
229
+ log(`${dirtyPaths.length} change(s) in ${touched.length} folder(s) — committing`);
230
+ if (!DRY) {
231
+ runGenerate(); // refresh derived files first, so `git add -A` stages them
232
+ git("add", "-A");
233
+ // `add -A` staged any untracked litter too; put it back to untracked.
234
+ if (untrackedLitter.length) gitQuiet("reset", "-q", "--", ...untrackedLitter);
235
+ runGateAdvisory(); // gates see the staged tree; findings never block the ship
236
+ const body = MSG ? "" : "\n\nCommitted automatically by `augur ship` so the live site is never\nserving anything that exists only in a working folder.";
237
+ commit("-m", subject + body);
238
+ committed = git("rev-parse", "--short", "HEAD");
239
+ log(`committed ${committed}`);
240
+ }
241
+ } else {
242
+ log("nothing to commit — working folder is clean");
243
+ }
244
+
245
+ // ── 1.5 catch up with origin BEFORE the tree defines the live site ───────────
246
+ // Reconciling used to happen only when the PUSH was rejected — one step after
247
+ // this tree had already replaced the whole live space, reverting whoever shipped
248
+ // since the checkout last pulled (for the seconds until the post-merge republish,
249
+ // or for good when nothing forced a rejection). Pulling first means the publish
250
+ // below ships the union. An unreachable remote is fine: publishing must never
251
+ // wait on the git host, and publish's own store guard still stands between a
252
+ // stale tree and everyone's live work.
253
+ let forks = [];
254
+ if (!HAS_GIT) {
255
+ // Nothing to fetch and nothing to reconcile against. The equivalent guarantee — that a
256
+ // concurrent edit is resolved rather than overwritten — comes from the store instead, on
257
+ // the publish below.
258
+ } else if (DRY) {
259
+ log("would fetch origin and reconcile if behind");
260
+ } else {
261
+ const f = gitQuiet("fetch", "origin", BRANCH);
262
+ if (!f.ok) {
263
+ warn("could not reach the remote — publishing this tree as-is; the push step will retry");
264
+ } else {
265
+ const behind = gitQuiet("rev-list", "--count", `HEAD..origin/${BRANCH}`).out;
266
+ if (behind && behind !== "0") {
267
+ warn(`origin has ${behind} commit(s) this checkout hasn't seen — reconciling before publish`);
268
+ const res = await reconcile({ alreadyLive: false });
269
+ forks = res.forks;
270
+ }
271
+ }
272
+ }
273
+
274
+ // ── 2. publish ───────────────────────────────────────────────────────────────
275
+ async function publish() {
276
+ if (DRY) { log("would publish"); return null; }
277
+ let tail = "";
278
+ const code = await new Promise((resolve) => {
279
+ // --allow-unpublish passes straight through: deleting a prototype means
280
+ // committing the deletion and shipping it, and ship is how that goes out.
281
+ const p = spawn(process.execPath,
282
+ [path.join(ROOT, "scripts", "publish.mjs"), "--space", SPACE,
283
+ ...(ALLOW_UNPUBLISH ? ["--allow-unpublish"] : []),
284
+ // ⚠️ ONLY WITHOUT GIT. With a repo, the client resolves a concurrent edit from
285
+ // evidence it can actually check, and asking the store instead would move the
286
+ // decision away from the only place that knows what this person edited.
287
+ ...(HAS_GIT ? [] : ["--fork-on-conflict"])],
288
+ { cwd: dir, stdio: ["ignore", "pipe", "inherit"] });
289
+ p.stdout.on("data", (d) => { tail += d.toString(); });
290
+ p.on("close", resolve);
291
+ });
292
+ if (code !== 0) die(`publish failed (exit ${code}) — nothing was lost, your work is committed. Fix and re-run. ${MEANWHILE}`);
293
+ return tail.trim().split("\n").filter(Boolean).pop() || null;
294
+ }
295
+ let liveLine = await publish();
296
+
297
+ // ── 2.5 without git, fold live back into the folder ──────────────────────────
298
+ //
299
+ // A git-backed ship reconciles BEFORE publishing and ends with a tree that matches what it
300
+ // shipped. A repo-less one cannot: if the store resolved a concurrent edit, live now has
301
+ // somebody else's version at the canonical path and mine at a fork — and this folder still
302
+ // has mine at the canonical path. Left alone it diverges silently, and the next ship
303
+ // re-publishes the same contested bytes and forks again, forever.
304
+ //
305
+ // So it pulls. `augur pull` is three-way, not last-writer-wins: it overwrites only where the
306
+ // local file still matches what this machine last synced (a fast-forward), reports anything
307
+ // that changed on both sides, and writes files it has never seen — which is exactly the fork
308
+ // folder. With no conflict it is a no-op, because live is what was just shipped.
309
+ if (!HAS_GIT && !DRY) {
310
+ const code = await new Promise((resolve) => {
311
+ const p = spawn(process.execPath, [path.join(ROOT, "scripts", "clone.mjs"), "--space", SPACE],
312
+ { cwd: dir, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, AUGUR_CLONE_MODE: "pull" } });
313
+ p.on("close", resolve);
314
+ });
315
+ // Exit 2 is `pull`'s "some files changed on both sides" — reported, not written. The
316
+ // publish already happened and is live; this is the folder catching up, so a partial
317
+ // catch-up is a warning and never a failed ship.
318
+ if (code === 2) warn("some local files differ from live and were left alone — see the conflict lines above");
319
+ else if (code !== 0) warn(`could not fold live back into this folder (exit ${code}) — your publish is live; run \`augur pull\` when you can`);
320
+ }
321
+
322
+ // ── 3. push, and the conflict it may hit ─────────────────────────────────────
323
+ // A rejected push means someone else shipped first. Prototype HTML must not be
324
+ // textually merged — git will happily interleave two edits into markup that
325
+ // renders wrong and nobody notices until a demo — so the decision is made per
326
+ // PROTOTYPE FOLDER, which is the unit of both publishing and the UI.
327
+ function conflictedFolders(files) {
328
+ const proto = [], other = [];
329
+ for (const f of files) {
330
+ const m = /^([a-z0-9][a-z0-9._-]*)\/prototypes\/([a-z0-9][a-z0-9._-]*)\//.exec(f)
331
+ || /^(playground)\/([a-z0-9][a-z0-9._-]*)\//.exec(f);
332
+ if (m) proto.push(`${m[1]}${m[1] === "playground" ? "" : "/prototypes"}/${m[2]}`);
333
+ else other.push(f);
334
+ }
335
+ return { folders: [...new Set(proto)], other };
336
+ }
337
+
338
+ async function reconcile({ alreadyLive }) {
339
+ const merge = gitQuiet("merge", "--no-edit", `origin/${BRANCH}`);
340
+ if (merge.ok) {
341
+ log("their work merged cleanly with yours");
342
+ return { merged: true, forks: [] };
343
+ }
344
+
345
+ const unmerged = gitQuiet("diff", "--name-only", "--diff-filter=U").out.split("\n").filter(Boolean);
346
+ const { folders, other } = conflictedFolders(unmerged);
347
+
348
+ // Anything outside a prototype folder — a design-system file, space.json — is
349
+ // not safe to resolve mechanically, and forking it makes no sense. Back all the
350
+ // way out and leave a human a clean tree to work in.
351
+ if (other.length) {
352
+ gitQuiet("merge", "--abort");
353
+ die(`this needs you: ${other.length} conflict(s) outside a prototype folder:\n` +
354
+ other.map((o) => ` ${o}`).join("\n") +
355
+ (alreadyLive
356
+ ? `\n\n Your work is committed and already live. Nothing was merged.`
357
+ : `\n\n Your work is committed but NOT published — resolving first means the live\n site never serves a tree that reverts anyone.`) +
358
+ `\n Resolve in ${dir}, then run \`augur ship\` again.`);
359
+ }
360
+
361
+ // Their version keeps the real path — a shared URL must stay pointing at the
362
+ // shared truth. Yours forks to a sibling folder, so it stays live, stays
363
+ // reviewable, and shows up as its own card next to theirs.
364
+ const forks = [];
365
+ for (const folder of folders) {
366
+ const fork = `${folder}-conflict-${whoami}`;
367
+ const ourFiles = gitQuiet("ls-tree", "-r", "--name-only", "HEAD", "--", folder).out.split("\n").filter(Boolean);
368
+ for (const rel of ourFiles) {
369
+ const dest = path.join(dir, rel.replace(folder, fork));
370
+ mkdirSync(path.dirname(dest), { recursive: true });
371
+ writeFileSync(dest, execFileSync("git", ["-C", dir, "show", `HEAD:${rel}`], { encoding: "buffer" }));
372
+ }
373
+ const theirs = gitQuiet("log", "-1", "--format=%an", `origin/${BRANCH}`).out || "someone else";
374
+ writeFileSync(path.join(dir, fork.replace(/\/$/, ""), "CONFLICT.md"),
375
+ `# Live edit conflict\n\n` +
376
+ `You and **${theirs}** changed \`${folder}\` at the same time, in ways that overlap.\n\n` +
377
+ `Rather than interleave the two into markup that renders wrong, \`augur ship\` kept\n` +
378
+ `**their** version at \`${folder}\` — so any shared link still resolves — and moved\n` +
379
+ `**your** version here.\n\n` +
380
+ `Both are live. Compare them, fold in whatever should survive, then delete this\n` +
381
+ `folder. Nothing has been lost.\n`);
382
+ // Take their whole folder for the canonical path — this resolves every
383
+ // conflicted file in it at once.
384
+ gitQuiet("checkout", "MERGE_HEAD", "--", folder);
385
+ gitQuiet("add", "--", folder, fork);
386
+ forks.push({ folder, fork, theirs });
387
+ }
388
+ git("add", "-A");
389
+ // Augur-Mechanical: this is repo surgery, and the build's date/credit pass must
390
+ // skip it — counting it would put the ship-runner's face on every folder the
391
+ // reconcile touched. (A merge commit is skipped anyway; the trailer makes it
392
+ // explicit rather than an accident of `git log`'s merge handling.)
393
+ commit("-m",
394
+ `Reconcile a live edit conflict in ${forks.map((f) => path.basename(f.folder)).join(", ")}\n\n` +
395
+ forks.map((f) => `${f.folder} kept ${f.theirs}'s version; yours forked to ${f.fork}.`).join("\n") +
396
+ `\n\nResolved by \`augur ship\`: prototype HTML is not textually merged.\n\n` +
397
+ `Augur-Mechanical: true`);
398
+ return { merged: true, forks };
399
+ }
400
+
401
+ let pushed = false;
402
+ if (!HAS_GIT) {
403
+ // Nowhere to push to. Not a warning: this is the normal shape of a hosted workspace, and
404
+ // "GitHub does not know about this yet" would be advice about a thing that does not exist.
405
+ } else if (NO_PUSH) {
406
+ warn("--no-push: GitHub does not know about this yet. Run `augur ship` again when you're back online.");
407
+ } else if (DRY) {
408
+ log("would push");
409
+ } else {
410
+ for (let attempt = 1; attempt <= 3 && !pushed; attempt++) {
411
+ const r = gitQuiet("push", "origin", BRANCH);
412
+ if (r.ok) { pushed = true; break; }
413
+ if (/\[rejected\]|non-fast-forward|fetch first/i.test(r.out)) {
414
+ warn("someone else shipped while this was publishing — reconciling");
415
+ const f = gitQuiet("fetch", "origin", BRANCH);
416
+ if (!f.ok) die(`could not reach the remote:\n${f.out}`);
417
+ const res = await reconcile({ alreadyLive: true });
418
+ forks = forks.concat(res.forks);
419
+ // The merge changed the tree, so the live site must be caught up to it.
420
+ liveLine = await publish();
421
+ continue;
422
+ }
423
+ if (attempt < 3) { warn(`push failed, retrying (${attempt}/3)…`); await new Promise((r2) => setTimeout(r2, attempt * 1500)); }
424
+ else {
425
+ warn(`could not push:\n${r.out}`);
426
+ warn("Your work IS committed and IS live. Only GitHub is behind — re-run `augur ship` to retry.");
427
+ }
428
+ }
429
+ if (pushed) log(`pushed to ${BRANCH}`);
430
+ }
431
+
432
+ // ── report ───────────────────────────────────────────────────────────────────
433
+ // By the time we get here, commit and publish have already SUCCEEDED — either one
434
+ // failing calls die() upstream, which exits before this point is ever reached. So a
435
+ // push that didn't land is the only outcome left to report, and it must not read as
436
+ // the ship itself having failed: the live site, the part people look at, is already
437
+ // true. Contract: exit code is truth, and from here on the truth is always "it's
438
+ // live" — a stuck push is an `augur ship` rerun away, not a redo. The live URL still
439
+ // has to be the LAST line of stdout (agents hand it straight to a human), so a push
440
+ // failure gets its own line ahead of it rather than folded into it.
441
+ // Pure on purpose — test/ship-exit-code.test.mjs lifts it out of this file and runs it, so
442
+ // every input it depends on is a parameter. `hasGit` is one of them: a folder with no repo
443
+ // has nowhere to push to, and telling somebody GitHub is behind would be advice about a
444
+ // thing that does not exist.
445
+ function pushFailureNotice(pushed, noPush, hasGit) {
446
+ if (!hasGit) return null;
447
+ if (pushed || noPush) return null;
448
+ return "published (live), but git push failed — re-run `augur ship` to sync GitHub.";
449
+ }
450
+
451
+ if (forks.length) {
452
+ for (const f of forks) {
453
+ warn(`conflict: ${f.folder} kept ${f.theirs}'s version — yours is now ${f.fork} (both live)`);
454
+ }
455
+ }
456
+ if (DRY) { console.log("(dry run, nothing changed)"); process.exit(0); }
457
+ const notice = pushFailureNotice(pushed, NO_PUSH, HAS_GIT);
458
+ if (notice) console.log(notice);
459
+ console.log(liveLine || `${SPACE} published`);
460
+ process.exit(0);