@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,322 @@
1
+ // restore.mjs — put an `augur export` copy back into a bundle store.
2
+ //
3
+ // augur restore <dir> every space in the export
4
+ // … --space <id> one space
5
+ // … --dry-run report what would ship, send nothing
6
+ // … --force overwrite live content that is NEWER than the copy
7
+ // … --allow-unpublish permit taking live public pages down — the copy is
8
+ // the truth, and the target may hold pages it never had
9
+ // … --state ALSO replay the workspace state a `--full` export
10
+ // carries: roster, invites, publish tokens, statuses,
11
+ // card names, boards, comment threads, pins, images
12
+ //
13
+ // A restore is an ordinary publish: blobs first, then one manifest commit per
14
+ // space, so it lands atomically and gets its own version number. It does NOT
15
+ // rewrite history — the versions the store already holds stay where they are, and
16
+ // the restored state arrives on top. That is deliberate: a restore run by mistake
17
+ // is then undone by `rollback`, not by another restore.
18
+ //
19
+ // ⚠️ AND THE COPY'S OWN HISTORY IS NOT REPLAYED EITHER. `augur export --history` walks
20
+ // every retained version's manifest and downloads every blob any of them referenced; this
21
+ // command reads none of it. The target ends up holding ONE version — the restored one —
22
+ // whatever the copy carries, so a workspace that arrives by restore or by `augur migrate`
23
+ // can be rolled back exactly zero publishes. Said out loud at the end of a run rather than
24
+ // discovered on the day somebody needs to roll one back. The archive is still worth
25
+ // taking: the manifests under `versions/` name every file of every past publish and the
26
+ // blobs are all on disk, so a specific past publish can be reconstructed by hand.
27
+ //
28
+ // Provenance survives. The manifest carries `source` (the space repo sha and the
29
+ // dirty flag) through unchanged, so a restored site still reports honestly in
30
+ // /_build.json what it was built from — including that it came from a working tree
31
+ // if it did.
32
+ //
33
+ // Target and token resolve exactly as `augur publish` does; see lib/store.mjs.
34
+ // Recovery walkthrough: `docs/2026-08-09-bundle-store-recovery.md`.
35
+
36
+ import { readFile, readdir } from "node:fs/promises";
37
+ import { existsSync } from "node:fs";
38
+ import path from "node:path";
39
+ import { target, apiClient, buildStamp, CLIENT_PROTOCOL } from "./lib/store.mjs";
40
+
41
+ const log = (msg) => console.error(`\x1b[36m[restore]\x1b[0m ${msg}`);
42
+ const die = (msg) => { log(msg); process.exit(1); };
43
+
44
+ const args = process.argv.slice(2);
45
+ const flag = (f) => args.includes(f);
46
+ const opt = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
47
+ // The first bare argument is the directory. Walked by index rather than found by
48
+ // value so a value that happens to equal an option's argument can't confuse it.
49
+ let DIR = null;
50
+ for (let i = 0; i < args.length; i++) {
51
+ if (args[i].startsWith("--")) { if (args[i] === "--space") i++; continue; }
52
+ DIR = args[i];
53
+ break;
54
+ }
55
+ const ONE = opt("--space");
56
+ const DRY = flag("--dry-run");
57
+ const FORCE = flag("--force");
58
+ // Opt-in, and deliberately not implied by the copy having state in it. Replaying the
59
+ // roster and the publish tokens changes WHO CAN GET IN — a different and larger act than
60
+ // putting content back, and the case where somebody wants only the content is real (a
61
+ // content restore onto a workspace whose membership has moved on since).
62
+ const STATE = flag("--state");
63
+ // A restore's commit can legitimately take live pages down — the copy is the truth and
64
+ // the target may hold pages the source never had. Same escape hatch, and the same
65
+ // transport-only field, as publish: nothing is persisted.
66
+ const ALLOW_UNPUBLISH = flag("--allow-unpublish");
67
+ if (!DIR) die("name the export directory: augur restore <dir>");
68
+ if (!existsSync(path.join(DIR, "export.json"))) die(`${DIR} has no export.json — not an augur export.`);
69
+
70
+ // Lists what would go dark and names the way out. A restore that takes pages down is a
71
+ // real case (the copy predates them), which is why the flag exists — and a restore run
72
+ // against the wrong target is the other case, which is why it is not the default.
73
+ function dieUnpublish(id, removed, count) {
74
+ const shown = removed.slice(0, 12);
75
+ die(`${id}: this restore would REMOVE ${count} public page(s) that are live right now:\n` +
76
+ shown.map((p) => ` ${p}`).join("\n") +
77
+ (count > shown.length ? `\n … and ${count - shown.length} more` : "") + "\n\n" +
78
+ ` ${id} was not committed. The copy does not carry these pages, so restoring it\n` +
79
+ ` takes them off the site — anyone's shared links and embeds for them would start\n` +
80
+ ` showing the login page the moment they go. Check this is the target you meant.\n\n` +
81
+ ` If you really are taking them down, re-run with --allow-unpublish.`);
82
+ }
83
+
84
+ let origin, token;
85
+ try { ({ origin, token } = target()); } catch (e) { die(e.message); }
86
+ const req = apiClient(origin, token);
87
+
88
+ const meta = JSON.parse(await readFile(path.join(DIR, "export.json"), "utf8"));
89
+ log(`${DIR} (taken ${meta.exportedAt} from ${meta.origin}) → ${origin}${DRY ? " [dry run]" : ""}`);
90
+ if (meta.origin && meta.origin !== origin) {
91
+ log(`\x1b[33m⚠ this copy was taken from ${meta.origin}, you are restoring to ${origin}\x1b[0m`);
92
+ }
93
+
94
+ const files = (await readdir(path.join(DIR, "manifests"))).filter((f) => f.endsWith(".json"));
95
+ const ids = ONE ? [ONE] : files.map((f) => f.replace(/\.json$/, ""));
96
+
97
+ // What is live now — so a restore can refuse to bury newer content by accident.
98
+ let live = {};
99
+ try {
100
+ const stamp = await buildStamp(origin);
101
+ live = { ...stamp.spaces, _engine: stamp.engine };
102
+ } catch (e) {
103
+ // ⚠️ NAMED AS A GUARD THAT IS OFF, not as a shrug. An empty store is the expected reason
104
+ // and it is not the only one — a target behind a 500, a wrong origin, a CDN serving an
105
+ // error for `/_build.json` all land here, and in every one of those cases the next few
106
+ // lines will publish over whatever is live without ever comparing dates. "Proceeding" on
107
+ // its own reads as "nothing to worry about", which is true exactly once.
108
+ log("\x1b[33m⚠ could not read the live build stamp at " + origin + " — an EMPTY STORE looks "
109
+ + "like this, and so does a target that is merely unreachable. The newer-than-this-copy "
110
+ + "guard is OFF for this run: nothing will stop a restore burying live content.\x1b[0m");
111
+ }
112
+
113
+ // Spaces the TARGET refuses because they are not a workspace's to hold. Today that is one
114
+ // space and one reason: on a deployment that resolves workspaces from the Host, the engine
115
+ // chrome (`_engine`) is a single bundle serving every workspace, so no workspace's own
116
+ // publish token may write it — see `sharedChromeRefusal` in src/_worker.js. A copy taken
117
+ // from a single-workspace instance always carries `_engine`, and `augur migrate` is exactly
118
+ // the command that moves such a copy onto a shared deployment, so this is the ordinary case
119
+ // rather than an error. Named and counted, never silent: a restore that quietly dropped a
120
+ // space would be indistinguishable from a complete one.
121
+ const declined = [];
122
+ const DECLINED_REASON = "chrome-not-writable-here";
123
+
124
+ let restored = 0;
125
+ for (const id of ids) {
126
+ const file = path.join(DIR, "manifests", `${id}.json`);
127
+ if (!existsSync(file)) die(`${id}: no manifest in this export.`);
128
+ const m = JSON.parse(await readFile(file, "utf8"));
129
+ const total = Object.keys(m.files || {}).length;
130
+ if (!total) die(`${id}: the exported manifest has no files — refusing to publish an empty space.`);
131
+
132
+ const now = live[id];
133
+ if (now && now.publishedAt && meta.exportedAt && now.publishedAt > meta.exportedAt && !FORCE) {
134
+ // ⚠️ "NEWER" IS NOT THE QUESTION — "DIFFERENT" IS, and the difference is what makes a
135
+ // re-run possible. A restore stamps `publishedAt` at the moment it commits, so its own
136
+ // result is by definition newer than the copy that produced it. Read literally, the date
137
+ // guard therefore fires on the SECOND run of any restore — including the one an operator
138
+ // makes after a run that died halfway, and including `augur migrate`'s, which passes no
139
+ // `--force` and whose header promises re-running is safe. It was not: a migration that
140
+ // failed after committing one space of two could not be repeated at all.
141
+ //
142
+ // So when the date says bury, ask what would be buried. The live manifest's file map
143
+ // against this copy's: identical means live IS this copy and there is nothing to lose.
144
+ // Anything else and the guard stands exactly as it did.
145
+ // Compared on the CONTENT ADDRESSING and nothing else — path → hash, sorted. The rest
146
+ // of a file's record is server-assigned: the commit handler stamps `{by, editedAt}` on
147
+ // every file whose bytes changed, so a live manifest is never field-for-field equal to
148
+ // the copy that produced it even when every byte it serves came from there. Comparing
149
+ // whole records would make this recognise nothing and re-runs would stay broken.
150
+ const addressing = (files) => JSON.stringify(
151
+ Object.keys(files || {}).sort().map((p) => [p, (files[p] || {}).h]),
152
+ );
153
+ let same = false;
154
+ try {
155
+ const liveManifest = await (await req(`${id}/manifest`)).json();
156
+ same = addressing(liveManifest.files) === addressing(m.files);
157
+ } catch (e) { /* cannot read it ⇒ cannot claim it is the same ⇒ refuse below */ }
158
+ if (!same) {
159
+ die(`${id}: live content (published ${now.publishedAt}) is NEWER than this copy (${meta.exportedAt}) ` +
160
+ `and its files DIFFER from it. Restoring would bury it. Re-run with --force if that is what you mean.`);
161
+ }
162
+ log(`${id}: live is newer than this copy but byte-identical to it — this is a re-run, continuing`);
163
+ }
164
+
165
+ // Ask the store which blobs it is missing, then send only those. This is also the first
166
+ // request of the space, so it is where a target that will not take this space at all says
167
+ // so — before a single blob is uploaded.
168
+ let check;
169
+ try {
170
+ check = await (await req(`${id}/check`, {
171
+ method: "POST",
172
+ headers: { "content-type": "application/json" },
173
+ body: JSON.stringify({ files: m.files }),
174
+ })).json();
175
+ } catch (e) {
176
+ if (!String(e.message).includes(DECLINED_REASON)) throw e;
177
+ // Explicitly asked for ⇒ a refusal is an answer to the question, not a detour around it.
178
+ if (ONE === id) {
179
+ die(`${id}: this target will not take it — the page chrome there is one build shared by `
180
+ + "every workspace, so no workspace's publish token may write it.");
181
+ }
182
+ log(`\x1b[33m⚠ ${id}: this target serves it from a shared build and will not take a `
183
+ + "workspace's copy of it — SKIPPED, and nothing else in this copy is affected\x1b[0m");
184
+ declined.push(id);
185
+ continue;
186
+ }
187
+ const missing = [...new Set(check.missing || [])];
188
+ log(`${id}: ${total} files, ${missing.length} blobs to upload, live v${check.liveVersion || 0}`);
189
+ if (DRY) continue;
190
+
191
+ const queue = [...missing];
192
+ let failed = 0;
193
+ await Promise.all(Array.from({ length: 8 }, async () => {
194
+ while (queue.length) {
195
+ const h = queue.pop();
196
+ const blob = path.join(DIR, "blobs", h);
197
+ if (!existsSync(blob)) { failed++; log(`${id}: blob ${h.slice(0, 12)} is not in this export`); continue; }
198
+ const body = await readFile(blob);
199
+ for (let attempt = 0; ; attempt++) {
200
+ try { await req(`${id}/blob/${h}`, { method: "PUT", body }); break; }
201
+ catch (e) {
202
+ if (attempt >= 2) { failed++; log(`${id}: blob ${h.slice(0, 12)} failed: ${e.message}`); break; }
203
+ }
204
+ }
205
+ }
206
+ }));
207
+ // Nothing is committed on a partial upload, so the live site is untouched and the
208
+ // run can simply be repeated once the copy is complete.
209
+ if (failed) die(`${id}: ${failed} blob(s) missing or failed — nothing committed, live untouched.`);
210
+
211
+ // Strip the fields the store assigns on commit; everything else (files, routing,
212
+ // space meta, source) is restored verbatim.
213
+ const { version, publishedAt, publishedBy, ...body } = m;
214
+ let res;
215
+ try {
216
+ res = await (await req(`${id}/commit`, {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ // Same protocol declaration a normal publish makes — a restore is an ordinary
220
+ // publish, so an instance with a floor must be able to judge this client too.
221
+ body: JSON.stringify({ ...body, clientProtocol: CLIENT_PROTOCOL,
222
+ ...(ALLOW_UNPUBLISH ? { allowUnpublish: true } : {}) }),
223
+ })).json();
224
+ } catch (e) {
225
+ // The store's unpublish guard, in words rather than a status code — the same guard,
226
+ // and the same way out, a publish gets. Every space committed before this one stays
227
+ // restored; this one and the rest are untouched.
228
+ const refused = /→ 422 (\{.*\})/.exec(String(e && e.message || ""));
229
+ let detail = null;
230
+ try { detail = refused && JSON.parse(refused[1]); } catch (x) { /* not the guard */ }
231
+ if (detail && detail.error === "unpublish-refused") dieUnpublish(id, detail.removed || [], detail.count || 0);
232
+ throw e;
233
+ }
234
+ log(`${id}: restored as v${res.version}`);
235
+ restored++;
236
+ }
237
+
238
+ // ── the workspace state ────────────────────────────────────────────────────
239
+ // After the content, because the content is the thing a half-finished restore most needs
240
+ // to have landed: a workspace with its pages back and its comments missing is recoverable
241
+ // by re-running this, and the other way round is a site serving nothing.
242
+ let stateReport = null;
243
+ let boardPaths = [];
244
+ if (STATE) {
245
+ if (!meta.full) {
246
+ die("--state needs a copy taken with `augur export --full`; this one carries content only.");
247
+ }
248
+ const file = path.join(DIR, "state.json");
249
+ if (!existsSync(file)) die(`${DIR} says it is a full copy but has no state.json — do not trust it.`);
250
+ const doc = JSON.parse(await readFile(file, "utf8"));
251
+
252
+ // The canvas images first. A board that references an image the store does not have
253
+ // renders a hole, and the board doc is what arrives in the same breath.
254
+ const hashes = doc.assets || [];
255
+ // The type each image was served as, recorded by the export beside the bytes. The
256
+ // endpoint takes the type from THIS HEADER and stores anything that is not an image type
257
+ // as `image/jpeg` — so a PUT that sends none silently re-labels every PNG, GIF and WebP
258
+ // on the way in, and content addressing cannot notice because the bytes are right. An
259
+ // older copy has no sidecar; it keeps the old behaviour and says so.
260
+ let types = {};
261
+ try { types = JSON.parse(await readFile(path.join(DIR, "assets.json"), "utf8")); } catch (e) {}
262
+ const untyped = hashes.filter((h) => !types[h]);
263
+ if (untyped.length) {
264
+ log(`\x1b[33m⚠ ${untyped.length} of ${hashes.length} image(s) carry no recorded content type `
265
+ + `(a copy taken before assets.json existed) — they will be stored as image/jpeg. `
266
+ + `Re-run \`augur export --full\` against the source to record them.\x1b[0m`);
267
+ }
268
+ let sent = 0, missing = 0;
269
+ for (const h of hashes) {
270
+ const blob = path.join(DIR, "assets", h);
271
+ if (!existsSync(blob)) { missing++; log(`image ${h.slice(0, 12)} is not in this export`); continue; }
272
+ if (DRY) { sent++; continue; }
273
+ try {
274
+ await req(`_state/asset/${h}`, {
275
+ method: "PUT",
276
+ body: await readFile(blob),
277
+ ...(types[h] ? { headers: { "content-type": types[h] } } : {}),
278
+ });
279
+ sent++;
280
+ } catch (e) { missing++; log(`image ${h.slice(0, 12)} failed: ${e.message}`); }
281
+ }
282
+ if (missing) die(`${missing} canvas image(s) missing or failed — nothing replayed, live state untouched.`);
283
+
284
+ const families = Object.keys(doc.families || {});
285
+ boardPaths = Object.keys((doc.families || {})["board:"] || {});
286
+ log(`workspace state: ${families.length} famil(y/ies), ${hashes.length} image(s)`);
287
+ if (!DRY) {
288
+ const res = await (await req("_state/import", {
289
+ method: "POST",
290
+ headers: { "content-type": "application/json" },
291
+ body: JSON.stringify(doc),
292
+ })).json();
293
+ if (!res.ok) die(`the instance refused the state: ${res.reason}${res.failed ? ` (${res.failed.join(", ")})` : ""}`);
294
+ if (res.skipped && res.skipped.length) log(`\x1b[33mskipped (not in the instance's inventory): ${res.skipped.join(", ")}\x1b[0m`);
295
+ stateReport = res;
296
+ }
297
+ }
298
+
299
+ if (DRY) { console.log("(dry run, nothing shipped)"); process.exit(0); }
300
+ console.log(`${origin} ${restored} space(s) restored`
301
+ + (declined.length ? `, ${declined.length} declined by the target (${declined.join(", ")})` : "")
302
+ + (stateReport ? `, ${stateReport.written.length} state famil(y/ies) replayed` : ""));
303
+ if (!STATE && meta.full) {
304
+ log("\x1b[33mthis copy also carries the roster, comments, boards and pins — pass --state to replay them\x1b[0m");
305
+ }
306
+
307
+ // ── the two things a restore does NOT put back, said every time ──────────────
308
+ // Both are silent by nature: the site comes up, everything anybody looks at is there, and
309
+ // what is missing is only missing the day somebody reaches for it. A line each is cheap.
310
+ if (meta.history) {
311
+ log("\x1b[33m⚠ this copy carries publish HISTORY and a restore does not replay it — "
312
+ + `${origin} now holds one version per space, so \`augur rollback\` reaches nothing. `
313
+ + "The archive is intact on disk under versions/.\x1b[0m");
314
+ }
315
+ if (stateReport && boardPaths.length) {
316
+ log(`\x1b[33m⚠ ${boardPaths.length} canvas board(s) came from the KV MIRROR, which lags the `
317
+ + "room that owns them and has been measured minutes behind. Nothing here read the room. "
318
+ + "Per board, against the SOURCE, before you trust this:\x1b[0m");
319
+ for (const p of boardPaths) {
320
+ log(` node scripts/board-snapshot.mjs move --from ${meta.origin || "<source>"} --to ${origin} --path ${p}`);
321
+ }
322
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * review.mjs — read & moderate prototype review comments straight from production.
4
+ *
5
+ * The user just drops pins on the live site (Shift+C → click an element → type).
6
+ * This tool reads them directly from the worker's secret-guarded export endpoint
7
+ * (no site password needed) and can resolve/close threads once addressed.
8
+ *
9
+ * Requires in .env.deploy (gitignored):
10
+ * REVIEW_SITE_URL=https://your-project.pages.dev
11
+ * REVIEW_EXPORT_KEY=<same value set as the Pages REVIEW_EXPORT_KEY secret>
12
+ *
13
+ * Usage:
14
+ * node scripts/review.mjs # list every thread (rich: anchor, view, position, messages)
15
+ * node scripts/review.mjs --open # only unresolved threads
16
+ * node scripts/review.mjs resolve <path> <id> ["note"] # mark resolved (+ optional reply explaining the fix)
17
+ * node scripts/review.mjs reply <path> <id> "note" # post a reply without resolving
18
+ * node scripts/review.mjs reopen <path> <id> # mark it open again
19
+ * node scripts/review.mjs delete <path> <id> # remove a thread
20
+ *
21
+ * Convention: when resolving, pass a very brief note saying HOW it was fixed — it
22
+ * posts as a "Claude" reply on the thread so the reviewer sees the resolution inline.
23
+ */
24
+ import { promises as fs } from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
29
+
30
+ async function loadEnv() {
31
+ const env = { ...process.env };
32
+ try {
33
+ const raw = await fs.readFile(path.join(ROOT, ".env.deploy"), "utf8");
34
+ for (const line of raw.split("\n")) {
35
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
36
+ if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, "");
37
+ }
38
+ } catch {}
39
+ return env;
40
+ }
41
+
42
+ function fmt(iso) {
43
+ try { return new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }); }
44
+ catch { return iso || ""; }
45
+ }
46
+
47
+ async function main() {
48
+ const env = await loadEnv();
49
+ const base = (env.REVIEW_SITE_URL || "").replace(/\/$/, "");
50
+ const key = env.REVIEW_EXPORT_KEY;
51
+ if (!base || !key) {
52
+ console.error("Missing REVIEW_SITE_URL / REVIEW_EXPORT_KEY in .env.deploy.");
53
+ process.exit(1);
54
+ }
55
+ const exportUrl = `${base}/__review/api/export?key=${encodeURIComponent(key)}`;
56
+ const [cmd, argPath, argId, argNote] = process.argv.slice(2).filter((a) => a !== "--open");
57
+ const openOnly = process.argv.includes("--open");
58
+
59
+ const postOp = async (op) => {
60
+ const res = await fetch(exportUrl, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(op),
64
+ });
65
+ if (!res.ok) { console.error(`Failed: ${res.status} ${res.statusText}`); process.exit(1); }
66
+ return res.json();
67
+ };
68
+
69
+ // ---- moderation ops (resolve / reopen / delete / reply) ----
70
+ if (cmd === "resolve" || cmd === "reopen" || cmd === "delete" || cmd === "reply") {
71
+ if (!argPath || !argId || (cmd === "reply" && !argNote)) {
72
+ console.error(`Usage: node scripts/review.mjs ${cmd} <path> <id>${cmd === "reply" ? ' "note"' : cmd === "resolve" ? ' ["note"]' : ""}`);
73
+ process.exit(1);
74
+ }
75
+ // Post the explanation reply first (resolve w/ note, or a bare reply).
76
+ if ((cmd === "resolve" || cmd === "reply") && argNote) {
77
+ await postOp({ path: argPath, op: "reply", id: argId,
78
+ message: { author: "Claude", body: argNote, at: new Date().toISOString() } });
79
+ }
80
+ if (cmd === "reply") {
81
+ console.log(`✓ replied on ${argId} (${argPath}).`);
82
+ return;
83
+ }
84
+ const op = cmd === "delete"
85
+ ? { path: argPath, op: "delete", id: argId }
86
+ : { path: argPath, op: "resolve", id: argId, resolved: cmd === "resolve" };
87
+ const data = await postOp(op);
88
+ console.log(`✓ ${cmd} ${argId} on ${argPath}${argNote ? " (+note)" : ""} — ${(data.threads || []).length} thread(s) remain on that page.`);
89
+ return;
90
+ }
91
+
92
+ // ---- read ----
93
+ const res = await fetch(exportUrl);
94
+ if (!res.ok) { console.error(`Export failed: ${res.status} ${res.statusText}`); process.exit(1); }
95
+ const data = await res.json();
96
+ const pages = data.pages || {};
97
+ const keys = Object.keys(pages).filter((p) => (pages[p] || []).length).sort();
98
+ console.log(`Review comments — pulled ${fmt(data.generatedAt)} from ${base}\n`);
99
+ const isAnno = (t) => !!t.annotation;
100
+ let total = 0, open = 0, annos = 0;
101
+ if (!keys.length) { console.log("No comments yet."); return; }
102
+ for (const p of keys) {
103
+ // Annotations are always-on dev-delivery notes, not feedback — never list them
104
+ // as actionable. --open hides them; the full list shows them flagged.
105
+ const threads = (pages[p] || []).filter((t) => (openOnly ? (!t.resolved && !isAnno(t)) : true));
106
+ if (!threads.length) continue;
107
+ console.log(`══ ${p}`);
108
+ threads.forEach((t) => {
109
+ total++;
110
+ if (isAnno(t)) annos++; else if (!t.resolved) open++;
111
+ const pos = (t.fx || t.fy)
112
+ ? `at ${Math.round(t.fx * 100)}%×${Math.round(t.fy * 100)}% of the element`
113
+ : `page ${Math.round(t.px)},${Math.round(t.py)}`;
114
+ const badge = isAnno(t) ? "📌 ANNOTATION (dev note — do NOT resolve)" : (t.resolved ? "✅" : "🟠");
115
+ console.log(` • ${badge} id=${t.id}`);
116
+ console.log(` module: ${t.sel || "(page)"}`);
117
+ console.log(` view: ${t.view || "(base)"} pin: ${pos}`);
118
+ (t.messages || []).forEach((m) => console.log(` “${m.body}” — ${m.author}, ${fmt(m.at)}`));
119
+ });
120
+ console.log("");
121
+ }
122
+ console.log(`${total} thread(s)${openOnly ? "" : `, ${open} open comment(s), ${annos} annotation(s)`}.`);
123
+ }
124
+
125
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // augur save — push every changed file in this draft folder. Live at the draft address on
3
+ // return. Exit 1 with the reason when the instance refuses. See docs/drafts-that-land.md §4.
4
+ import { resolveOrigin, resolveToken } from "./lib/store.mjs";
5
+ import { readState, unitClient, doSave } from "./lib/draft.mjs";
6
+
7
+ const die = (m) => { console.error(`\x1b[31m[save]\x1b[0m ${m}`); process.exit(1); };
8
+ const dir = process.cwd();
9
+ const st = readState(dir);
10
+ if (!st) die("not a draft folder — run `augur open <prototype>` first.");
11
+ const origin = st.origin || resolveOrigin();
12
+ const token = resolveToken(origin);
13
+ if (!token) die("no publish token — run `augur connect` once.");
14
+ const client = unitClient({ origin, token, space: st.space, session: st.session });
15
+ const r = await doSave({ client, dir });
16
+ if (!r.ok) {
17
+ if (r.error === "stale-draft" || r.error === "stale-draft-revision") die("this draft moved under you (another process saved to it) — run `augur sync`.");
18
+ die(`save refused: ${r.error || r.status}`);
19
+ }
20
+ if (r.changed.length) console.error(`\x1b[35m[save]\x1b[0m ${r.changed.length} file(s) live at ${origin}${st.address}`);
21
+ console.log(`${origin}${st.address}`);
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // shell-lint — a template-drift canary for deploy shells.
3
+ //
4
+ // health.yml asks "is what's LIVE what's in git?" for space content. This asks the
5
+ // same question one layer up: is what a shell is RUNNING what the engine's template
6
+ // says it should be running? Those workflows were hand-authored per shell for a long
7
+ // time, so they drift silently — a fix lands in templates/shell/ and the shells that
8
+ // already had a copy never take it, or a shell edits its own copy and the change is
9
+ // invisible to everyone else.
10
+ //
11
+ // Run it FROM A SHELL, where both halves are already on disk:
12
+ //
13
+ // node engine/scripts/shell-lint.mjs
14
+ //
15
+ // The engine submodule is the shell's pinned engine, so the templates it compares
16
+ // against are the ones that shell is actually on — no fetching, no sha juggling.
17
+ //
18
+ // --shell <path> shell root (default: cwd)
19
+ // --templates <path> template dir (default: <engine>/templates/shell)
20
+ // --strict treat comment-only drift as failure too
21
+ // --quiet only print problems
22
+ //
23
+ // Exit 1 on logic drift (or any drift under --strict). Missing/extra workflows are
24
+ // reported but never fatal: which workflows a shell runs is that shell's call, and a
25
+ // private prototype-delete or reseed workflow has no template by design.
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const ENGINE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
32
+ const argv = process.argv.slice(2);
33
+ const opt = (f, d) => { const i = argv.indexOf(f); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
34
+ const has = (f) => argv.includes(f);
35
+
36
+ const SHELL = path.resolve(opt("--shell", process.cwd()));
37
+ const TEMPLATES = path.resolve(opt("--templates", path.join(ENGINE, "templates", "shell")));
38
+ const STRICT = has("--strict");
39
+ const QUIET = has("--quiet");
40
+
41
+ const say = (s) => { if (!QUIET) console.log(s); };
42
+ const warn = (s) => console.log(s);
43
+
44
+ // A template line whose VALUE is meant to be replaced before first use. The convention
45
+ // across templates/shell is a `your-…` stand-in (your-site-origin.example,
46
+ // your-pages-project), sometimes with a `# ←` pointer at what to put there; `<angled>`
47
+ // is the older form. A shell that filled one of these in has done the right thing, so
48
+ // a difference on such a line is expected, not drift.
49
+ const isPlaceholder = (line) => /your-[a-z0-9-]+/i.test(line) || /#\s*←/.test(line) || /<[a-z][a-z0-9-]*>/i.test(line);
50
+
51
+ // Not every fill-in has a `your-…` stand-in: some are a real, working value that a
52
+ // shell is nonetheless expected to CHOOSE (engine-bump's TRACK is the case in point —
53
+ // `release` is a legitimate setting, and so is `main`). Those are marked the way the
54
+ // templates already mark them, with a `SET BEFORE USE` note in the comment block
55
+ // directly above. Walk up the contiguous comment run to find it.
56
+ function markedSetBeforeUse(lines, i) {
57
+ for (let k = i - 1; k >= 0; k--) {
58
+ const s = lines[k].trim();
59
+ if (!s.startsWith("#")) return false; // left the comment block without finding it
60
+ if (/SET BEFORE USE/i.test(s)) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ // Comments and blank lines carry no behaviour. A shell rewording a comment is worth
66
+ // seeing but is not the thing this canary exists to catch, so it is reported apart
67
+ // from logic drift unless --strict.
68
+ const isProse = (line) => { const s = line.trim(); return s === "" || s.startsWith("#"); };
69
+
70
+ // Longest common subsequence over lines — enough to align two versions of the same
71
+ // file so an inserted block does not report every following line as changed.
72
+ function align(a, b) {
73
+ const n = a.length, m = b.length;
74
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
75
+ for (let i = n - 1; i >= 0; i--) {
76
+ for (let j = m - 1; j >= 0; j--) {
77
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
78
+ }
79
+ }
80
+ const ops = [];
81
+ let i = 0, j = 0;
82
+ while (i < n && j < m) {
83
+ if (a[i] === b[j]) { ops.push({ t: "=", a: a[i], i, j }); i++; j++; }
84
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "-", a: a[i], i }); i++; }
85
+ else { ops.push({ t: "+", b: b[j], j }); j++; }
86
+ }
87
+ while (i < n) ops.push({ t: "-", a: a[i], i: i++ });
88
+ while (j < m) ops.push({ t: "+", b: b[j], j: j++ });
89
+ return ops;
90
+ }
91
+
92
+ // A removed line immediately followed by an added line is one line REWRITTEN. That
93
+ // pairing is what lets a filled-in placeholder be recognised: the template side is the
94
+ // line with `your-…` in it, and the shell side is whatever replaced it.
95
+ const yamlKey = (line) => { const m = /^(\s*)([A-Za-z_][\w-]*):/.exec(line); return m ? `${m[1].length}:${m[2]}` : null; };
96
+
97
+ function compare(templateSrc, shellSrc) {
98
+ const tLines = templateSrc.split("\n");
99
+ const ops = align(tLines, shellSrc.split("\n"));
100
+
101
+ // Pair each removal with the addition that REPLACED it, so a rewritten line reads as
102
+ // one change rather than two. Adjacency catches most of it. Same-key pairing catches
103
+ // the rest: when a shell also rewrites the comment block above a setting, the two
104
+ // halves drift apart in the op stream, and `TRACK: release` → `TRACK: main` would
105
+ // otherwise report as an unexplained insertion with nothing to compare it against.
106
+ const dels = [], adds = [];
107
+ for (let k = 0; k < ops.length; k++) {
108
+ const op = ops[k];
109
+ if (op.t === "-") dels.push({ ...op, adjacent: ops[k + 1] && ops[k + 1].t === "+" ? ops[k + 1] : null });
110
+ else if (op.t === "+") adds.push(op);
111
+ }
112
+ const pairs = [], usedAdd = new Set(), usedDel = new Set();
113
+ for (const d of dels) {
114
+ // Adjacency alone is not enough to call two lines the same line rewritten: when a
115
+ // shell expands the comment block above a setting, the template's last comment line
116
+ // sits immediately before the shell's new SETTING, and pairing those two would both
117
+ // mis-report the comment and starve the setting of its real counterpart. Only pair
118
+ // adjacent lines that agree about being a keyed setting.
119
+ if (!d.adjacent || usedAdd.has(d.adjacent.j)) continue;
120
+ if (yamlKey(d.a) !== yamlKey(d.adjacent.b)) continue;
121
+ pairs.push([d, d.adjacent]); usedAdd.add(d.adjacent.j); usedDel.add(d.i);
122
+ }
123
+ for (const d of dels) {
124
+ if (usedDel.has(d.i)) continue;
125
+ const key = yamlKey(d.a);
126
+ if (!key) continue;
127
+ const a = adds.find((x) => !usedAdd.has(x.j) && yamlKey(x.b) === key);
128
+ if (a) { pairs.push([d, a]); usedAdd.add(a.j); usedDel.add(d.i); }
129
+ }
130
+
131
+ const out = [];
132
+ for (const [d, a] of pairs) {
133
+ if (isPlaceholder(d.a) || markedSetBeforeUse(tLines, d.i)) continue; // filled in / chosen, as intended
134
+ out.push({ kind: isProse(d.a) && isProse(a.b) ? "prose" : "logic", line: d.i + 1, from: d.a, to: a.b });
135
+ }
136
+ for (const d of dels) if (!usedDel.has(d.i)) out.push({ kind: isProse(d.a) ? "prose" : "logic", line: d.i + 1, from: d.a, to: null });
137
+ for (const a of adds) if (!usedAdd.has(a.j)) out.push({ kind: isProse(a.b) ? "prose" : "logic", line: a.j + 1, from: null, to: a.b });
138
+ return out.sort((x, y) => x.line - y.line);
139
+ }
140
+
141
+ const wfDir = path.join(SHELL, ".github", "workflows");
142
+ if (!fs.existsSync(wfDir)) {
143
+ console.error(`[shell-lint] no .github/workflows/ under ${SHELL} — is this a deploy shell? (pass --shell <path>)`);
144
+ process.exit(1);
145
+ }
146
+ if (!fs.existsSync(TEMPLATES)) {
147
+ console.error(`[shell-lint] no templates at ${TEMPLATES} — is the engine submodule checked out? (pass --templates <path>)`);
148
+ process.exit(1);
149
+ }
150
+
151
+ const templates = fs.readdirSync(TEMPLATES).filter((f) => f.endsWith(".yml")).sort();
152
+ const installed = fs.readdirSync(wfDir).filter((f) => f.endsWith(".yml")).sort();
153
+
154
+ let logicDrift = 0, proseDrift = 0;
155
+ const missing = [], extra = [];
156
+
157
+ for (const f of templates) {
158
+ if (!installed.includes(f)) { missing.push(f); continue; }
159
+ const diffs = compare(
160
+ fs.readFileSync(path.join(TEMPLATES, f), "utf8"),
161
+ fs.readFileSync(path.join(wfDir, f), "utf8"),
162
+ );
163
+ const logic = diffs.filter((d) => d.kind === "logic");
164
+ const prose = diffs.filter((d) => d.kind === "prose");
165
+ logicDrift += logic.length;
166
+ proseDrift += prose.length;
167
+ if (!diffs.length) { say(` ok ${f}`); continue; }
168
+ const bad = STRICT ? diffs : logic;
169
+ warn(` ${bad.length ? "DRIFT " : "reworded"} ${f} (${logic.length} logic, ${prose.length} prose)`);
170
+ for (const d of bad.slice(0, 12)) {
171
+ warn(` line ${d.line}:`);
172
+ if (d.from !== null) warn(` template: ${d.from.trim().slice(0, 120)}`);
173
+ if (d.to !== null) warn(` shell: ${d.to.trim().slice(0, 120)}`);
174
+ }
175
+ if (bad.length > 12) warn(` … and ${bad.length - 12} more`);
176
+ }
177
+ for (const f of installed) if (!templates.includes(f)) extra.push(f);
178
+
179
+ say("");
180
+ if (missing.length) warn(` not installed (this shell's call, not an error): ${missing.join(", ")}`);
181
+ if (extra.length) warn(` no template (instance-specific, expected): ${extra.join(", ")}`);
182
+
183
+ const fatal = STRICT ? logicDrift + proseDrift : logicDrift;
184
+ if (fatal) {
185
+ console.log("");
186
+ console.error(`::error::shell-lint: ${fatal} line(s) diverge from the engine's templates — a fix in templates/shell/ has not reached this shell, or this shell edited its copy without sending it upstream.`);
187
+ process.exit(1);
188
+ }
189
+ console.log(`shell-lint: clean — ${templates.length - missing.length} workflow(s) match their template${proseDrift ? ` (${proseDrift} comment line(s) reworded; --strict to fail on those)` : ""}.`);