@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,12 @@
1
+ // Moved to src/publish-compose.mjs, re-exported here so no CLI import had to change.
2
+ //
3
+ // ⚠️ IT MOVED BECAUSE THE WORKER RUNS IT. `C-fork-on-conflict` resolves a stale base inside
4
+ // the commit handler using the SAME composition the CLI uses — two implementations of "who
5
+ // keeps the URL" would disagree on exactly the publishes a conflict is about. `src/` is what
6
+ // the deploy copies beside `_worker.js`; a module the worker imports from `scripts/` resolves
7
+ // nowhere at the edge, and the build's derived copy list would have to reach outside `src/`
8
+ // to fix it. See the header of src/publish-compose.mjs for the composition rules themselves.
9
+ export { composePublish, filterLitter, forkLanded, LITTER_RE } from "../../src/publish-compose.mjs";
10
+ // The unit vocabulary rode along here before the move (compose re-exported what it imported),
11
+ // and CLI modules import it from this path. Kept, pointing at its one definition.
12
+ export { authoredUnits, unitOfPath, unitPaths } from "../../src/publish-units.mjs";
@@ -0,0 +1,78 @@
1
+ // Build-decoration tolerance + the repo-dir mapping — pure, shared by the publish stack.
2
+ //
3
+ // ⚠️ THE UNIT VOCABULARY MOVED TO src/publish-units.mjs and is re-exported below, so no CLI
4
+ // import had to change. It moved because the SERVER needs it: `C-fork-on-conflict` resolves
5
+ // a stale base inside the commit handler, and two definitions of "which folder does this
6
+ // path belong to" would disagree on exactly the paths a conflict is about.
7
+ //
8
+ // A unit is a prototype/playground folder — the thing a URL names and a person edits, never
9
+ // a lone file — and units are exactly the routing fragment's publicPrefixes (galleries get
10
+ // versionMap entries but no prefix, which is what keeps them out). Since protocol 5 the
11
+ // per-unit decisions live in publish-compose.mjs; what remains here is the repo-dir mapping
12
+ // and the two views of what the build decorates authored HTML with:
13
+ // stripVolatileHead (a COMPARATOR: does this content really differ?) and
14
+ // stripBuildDecorations (a TRANSFORMER: undo the decoration exactly — kept for
15
+ // repo-debake tooling, e.g. peeling dist-flavored bytes out of a space repo).
16
+
17
+ export { authoredUnits, unitOfPath, unitPaths } from "../../src/publish-units.mjs";
18
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
19
+
20
+ // A unit URL back to the repo folder(s) it could live in, best guess first:
21
+ // prototypes elide the /prototypes/ segment on the URL side, playground does not,
22
+ // and a non-default space carries its base as the first segment.
23
+ export function repoDirCandidates(unit, { spaceBase = "" } = {}) {
24
+ let u = dec(unit);
25
+ const base = dec(spaceBase || "");
26
+ if (base && (u === base + "/" || u.startsWith(base + "/"))) u = u.slice(base.length);
27
+ const segs = u.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
28
+ if (!segs.length) return [];
29
+ if (segs[0] === "playground" || segs.length !== 2) return [segs.join("/")];
30
+ return [`${segs[0]}/prototypes/${segs[1]}`, segs.join("/")];
31
+ }
32
+
33
+ // The build decorates authored HTML on its way to dist: marker-delimited chrome
34
+ // (the review overlay, the pet layer, offline reload) plus og/twitter meta.
35
+ // stripInjectedChrome removes exactly the marker blocks — used when writing live
36
+ // bytes back into a tree, so adopted sources stay close to what their author
37
+ // wrote (og meta stays: the injector skips pages that already carry it, so it is
38
+ // stable across rebuilds).
39
+ export function stripInjectedChrome(html) {
40
+ return String(html).replace(/<!--gv-([a-z0-9-]+)-start-->[\s\S]*?<!--gv-\1-end-->/g, "");
41
+ }
42
+
43
+ // Tolerant equality for "did the content really change": a live blob and a git
44
+ // source must compare equal when they differ only by injected chrome, social
45
+ // meta (og:url legitimately varies with the configured origin), the linked-assets
46
+ // stamp, the skills-path depth rewrite (dist mounts prototypes one level shallower
47
+ // than the repo), and the whitespace those injections leave behind. Every one of
48
+ // these leaked through this comparator on 2026-08-19 and a reconcile then adopted
49
+ // 169 chrome-baked pages into a space repo as one collaborator's authorship.
50
+ // A comparator, not a transformer.
51
+ export function stripVolatileHead(html) {
52
+ return stripInjectedChrome(html)
53
+ .replace(/[ \t]*<meta\s+(?:property|name)="(?:og|twitter):[^"]*"\s+content="[^"]*"\s*\/?>[ \t]*/g, "")
54
+ .replace(/<script>window\.__GV_LINKED=\[[^\n]*?\];<\/script>/g, "")
55
+ // The build stamps the card emoji onto <title> (idempotently) — not an edit.
56
+ .replace(/(<title>)\s*(?:[\p{Extended_Pictographic}‍️]+\s*)+/gu, "$1")
57
+ // Depth is layout, not content: collapse any ../-run before skills/ so the
58
+ // repo form (../../../skills/…) and the dist form (../../skills/…) compare equal.
59
+ .replace(/(?:\.\.\/)+skills\//g, "skills/")
60
+ .replace(/\s+/g, " ").replace(/>\s+</g, "><").trim();
61
+ }
62
+
63
+ // The WRITER'S peel — everything the build decorates authored HTML with on its way
64
+ // to dist, undone, so an adopted source is byte-shaped like what its author wrote:
65
+ // marker chrome out, og/twitter meta out (the build re-derives it), the linked-assets
66
+ // stamp out, the title emoji off, and the skills-path depth rewrite reversed for the
67
+ // file's REPO location (`relDir`, the file's directory relative to the space root).
68
+ // Without the full peel, an adopt writes dist bytes into git — pages whose relative
69
+ // asset paths no longer resolve in the repo layout, plus baked meta that reads as an
70
+ // edit forever after. A transformer, not a comparator: depth must come out exact.
71
+ export function stripBuildDecorations(html, relDir) {
72
+ const up = "../".repeat(String(relDir || "").split("/").filter(Boolean).length);
73
+ return stripInjectedChrome(String(html))
74
+ .replace(/[ \t]*<meta\s+(?:property|name)="(?:og|twitter):[^"]*"\s+content="[^"]*"\s*\/?>\s*\n?/g, "")
75
+ .replace(/[ \t]*<script>window\.__GV_LINKED=\[[^\n]*?\];<\/script>\s*\n?/g, "")
76
+ .replace(/(<title>)\s*(?:[\p{Extended_Pictographic}‍️]+\s*)+/gu, "$1")
77
+ .replace(/(?:\.\.\/)+skills\//g, up + "skills/");
78
+ }
@@ -0,0 +1,144 @@
1
+ // Git evidence for composed publish (protocol 5) — the impure half.
2
+ //
3
+ // composePublish (publish-compose.mjs) is pure; this module answers its three
4
+ // questions from the one source of truth we trust: git.
5
+ //
6
+ // ffUnits which units are a FAST-FORWARD of live — live's recorded unit
7
+ // source is a clean commit in this tree's history, so shipping my
8
+ // build cannot revert anyone (their content is already mine).
9
+ // editedUnits which units I have local evidence of editing: uncommitted paths
10
+ // (porcelain, untracked included) plus — when live's base is a
11
+ // provable ancestor — commits since that base. When the base is
12
+ // unknowable the answer degrades to porcelain only: fail CLOSED
13
+ // (my committed edit stays local with a note) rather than sprawl.
14
+ // deletedUnits live units provably deleted here: the folder existed at live's
15
+ // base, the base is my ancestor, and the folder is gone from the
16
+ // tree. Anything less keeps live's URLs up.
17
+ //
18
+ // Per-unit live provenance comes from routing.unitSources (written by every
19
+ // protocol-5 publish), falling back to the space-level manifest source for
20
+ // manifests that predate the field.
21
+ //
22
+ // seed units a live unit the PLATFORM wrote (provenance `isSeedSource`: the
23
+ // seed pack a fresh workspace arrives with) is nobody's work. Its
24
+ // recorded sha is an ENGINE commit no space repo has in its history,
25
+ // so to git it looks exactly like an unpushed stranger's commit — and
26
+ // a person's first edit to a start-here page was filed "unprovable"
27
+ // and quietly stayed local. It is NOT unprovable: the platform's
28
+ // provenance is a provenance. This module only declines to call it
29
+ // one; WHETHER IT YIELDS IS THE COMPOSER'S RULE (publish-compose.mjs),
30
+ // so the store, which runs the same composer with its own evidence,
31
+ // reaches the same verdict for a repo-less publisher.
32
+
33
+ import { execFileSync } from "node:child_process";
34
+ import { existsSync } from "node:fs";
35
+ import path from "node:path";
36
+ import { authoredUnits, unitPaths } from "./publish-compose.mjs";
37
+ import { repoDirCandidates } from "./publish-conflict.mjs";
38
+ import { isSeedSource } from "../../src/provenance.mjs";
39
+
40
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
41
+
42
+ const gitq = (dir, ...a) => {
43
+ try {
44
+ return execFileSync("git", ["-C", dir, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
45
+ } catch (e) { return null; }
46
+ };
47
+
48
+ export const isAncestor = (dir, sha) =>
49
+ !!sha && gitq(dir, "merge-base", "--is-ancestor", sha, "HEAD") !== null;
50
+ const haveCommit = (dir, sha) => !!sha && gitq(dir, "cat-file", "-e", `${sha}^{commit}`) !== null;
51
+
52
+ // Repo path → the unit URL it belongs to (the inverse of the build's URL scheme:
53
+ // prototypes elide /prototypes/ on the URL side, playground does not).
54
+ export function unitOfRepoPath(repoPath, spaceBase = "") {
55
+ const m = /^([^/]+)\/prototypes\/([^/]+)\//.exec(repoPath)
56
+ || /^(playground)\/([^/]+)\//.exec(repoPath);
57
+ if (!m) return null;
58
+ const enc = encodeURIComponent;
59
+ return m[1] === "playground"
60
+ ? `${spaceBase}/playground/${enc(m[2])}/`
61
+ : `${spaceBase}/${enc(m[1])}/${enc(m[2])}/`;
62
+ }
63
+
64
+ export function unitRepoDir(unit, spaceBase, sourceDir) {
65
+ const candidates = repoDirCandidates(unit, { spaceBase });
66
+ for (const c of candidates) if (existsSync(path.join(sourceDir, c))) return c;
67
+ return candidates[0];
68
+ }
69
+
70
+ const porcelainPaths = (dir) => (gitq(dir, "status", "--porcelain") || "").split("\n")
71
+ .filter(Boolean)
72
+ .map((l) => l.slice(3).replace(/^"|"$/g, ""))
73
+ .flatMap((p) => p.split(" -> ")); // renames evidence both sides
74
+
75
+ export function collectEvidence({ sourceDir, spaceBase, mine, live }) {
76
+ const liveUnits = authoredUnits(live);
77
+ const mineUnits = authoredUnits(mine);
78
+ const unitSources = ((live || {}).routing || {}).unitSources || {};
79
+ const spaceSrc = (live || {}).source || {};
80
+ const srcOf = (u) => unitSources[u]
81
+ || { sha: spaceSrc.sha || null, dirty: !!spaceSrc.dirty, actor: spaceSrc.actor, seed: spaceSrc.seed };
82
+
83
+ const editedUnits = new Set();
84
+ const dirtyUnits = new Set();
85
+ const editedPaths = new Set();
86
+ const unprovable = []; // committed-looking divergence we refused to ship — for one honest log line
87
+
88
+ for (const rel of porcelainPaths(sourceDir)) {
89
+ const u = unitOfRepoPath(rel, spaceBase);
90
+ if (u) { editedUnits.add(u); dirtyUnits.add(u); }
91
+ if (!spaceBase && rel.startsWith("skills/")) editedPaths.add("/" + rel);
92
+ }
93
+
94
+ const ffUnits = new Set();
95
+ const ancestorCache = new Map();
96
+ const provenAncestor = (sha) => {
97
+ if (!sha) return false;
98
+ if (!ancestorCache.has(sha)) ancestorCache.set(sha, haveCommit(sourceDir, sha) && isAncestor(sourceDir, sha));
99
+ return ancestorCache.get(sha);
100
+ };
101
+
102
+ for (const u of new Set([...liveUnits, ...mineUnits])) {
103
+ const src = srcOf(u);
104
+ if (!src.dirty && provenAncestor(src.sha)) {
105
+ ffUnits.add(u);
106
+ continue; // FF ships regardless; no need to weigh evidence
107
+ }
108
+ if (editedUnits.has(u)) continue; // porcelain already says edited
109
+ // Dirty-or-unknown live base: committed evidence only counts against a
110
+ // provable ancestor (diffing against an unrelated commit would count THEIR
111
+ // work as mine). Without one, a committed local edit stays local — noted.
112
+ if (src.sha && provenAncestor(src.sha)) {
113
+ const dirRel = unitRepoDir(u, spaceBase, sourceDir);
114
+ const names = (gitq(sourceDir, "diff", "--name-only", src.sha, "HEAD", "--", dirRel) || "").trim();
115
+ if (names) editedUnits.add(u);
116
+ } else if (mineUnits.has(u) && liveUnits.has(u) && !isSeedSource(src)) {
117
+ // The seed is excluded because its provenance is KNOWN — it is the platform's —
118
+ // and the composer decides what that means. Everything else here is a stranger's.
119
+ unprovable.push(u);
120
+ }
121
+ }
122
+
123
+ // Skill files: committed evidence against the space-level base when provable.
124
+ if (!spaceBase && spaceSrc.sha && !spaceSrc.dirty && provenAncestor(spaceSrc.sha)) {
125
+ for (const rel of (gitq(sourceDir, "diff", "--name-only", spaceSrc.sha, "HEAD", "--", "skills") || "").split("\n")) {
126
+ if (rel.trim()) editedPaths.add("/" + rel.trim());
127
+ }
128
+ }
129
+
130
+ // Deletions: only what git can prove — existed at a provable ancestor base,
131
+ // gone from the tree now.
132
+ const deletedUnits = new Set();
133
+ for (const u of liveUnits) {
134
+ if (mineUnits.has(u)) continue;
135
+ const src = srcOf(u);
136
+ if (!provenAncestor(src.sha)) continue;
137
+ const dirRel = unitRepoDir(u, spaceBase, sourceDir);
138
+ if (existsSync(path.join(sourceDir, dirRel))) continue;
139
+ const then = (gitq(sourceDir, "ls-tree", "-r", "--name-only", src.sha, "--", dirRel) || "").trim();
140
+ if (then) deletedUnits.add(u);
141
+ }
142
+
143
+ return { editedUnits, dirtyUnits, deletedUnits, editedPaths, ffUnits, unprovable };
144
+ }
@@ -0,0 +1,140 @@
1
+ // refine-compare — the measurement the refine harness reports, and the only thing that
2
+ // decides a component's verdict.
3
+ //
4
+ // THE RULE THIS FILE ENFORCES: no self-assessment. A verdict is `diffRatio <= threshold`
5
+ // computed from two images, and nothing else. There is no argument, no field and no file
6
+ // through which an agent's opinion of its own work reaches the answer — `verdict()` takes
7
+ // pixels and a number. Anything that reads a stored verdict must re-derive it here (see
8
+ // `refine-ledger.mjs`, which stores measurements and never a pass).
9
+ //
10
+ // THE METRIC. Per pixel, the YIQ-weighted colour distance — the same perceptual delta the
11
+ // well-known browser-screenshot comparators use — against `pixelTolerance`; then the
12
+ // fraction of pixels that exceed it, against `threshold`. Two numbers rather than one
13
+ // because they catch different lies: a shifted padding moves a large NUMBER of pixels a
14
+ // long way, and a wrong hue moves a large number of pixels a SHORT way. A single mean
15
+ // delta hides both under a big flat background.
16
+ //
17
+ // IT IS DELIBERATELY NAIVE ABOUT ANTIALIASING, and that is a posture, not an oversight.
18
+ // Antialiasing-aware comparators exist so that a golden PNG captured on one machine can
19
+ // be compared against a render on another. This harness would rather not be in that
20
+ // business at all: in `source`/`url` reference mode both sides are rendered by the same
21
+ // browser in the same run, so text antialiasing is byte-identical and there is nothing to
22
+ // forgive — and a comparator that forgives nothing is the sensitive one. The cost is
23
+ // stated where it is paid: an `image` reference captured on another machine WILL produce
24
+ // font-edge noise here, and `docs/canon-refine.md` says to keep the threshold for that
25
+ // mode empirical rather than hopeful.
26
+
27
+ import { decodePng, encodePng } from "./refine-png.mjs";
28
+
29
+ // Largest possible squared YIQ distance between two 8-bit colours, used to normalise.
30
+ const MAX_YIQ_DELTA = 35215;
31
+
32
+ // THE TWO NUMBERS, AND WHY THESE ONES.
33
+ //
34
+ // `pixelTolerance` 0.02 puts the per-pixel cutoff at 35215 · 0.02² ≈ 14.1 squared YIQ
35
+ // units. In plain terms that forgives a uniform grey shift of about five levels out of
36
+ // 255 and catches six — tight, because in same-run mode two renders of the same thing are
37
+ // bit-identical and there is nothing legitimate to forgive. The widely-used default of 0.1
38
+ // was measured against this fixture and was too loose to be worth running: it puts the
39
+ // cutoff at 352, which lets a twenty-level channel error through unremarked, and a wrong
40
+ // hue is exactly a twenty-level channel error.
41
+ //
42
+ // `threshold` 0.02 lets 2% of a frame differ before the component fails. It is a fraction
43
+ // of the FRAME, so it only means anything if the component fills the frame — a small
44
+ // component photographed on a large empty page can be badly wrong in 1% of the pixels and
45
+ // pass. Size each component's viewport to the component. `docs/canon-refine.md` repeats
46
+ // this because it is the one way to get a green run that means nothing.
47
+ export const DEFAULT_THRESHOLD = 0.02; // ≤2% of the frame may differ
48
+ export const DEFAULT_PIXEL_TOLERANCE = 0.02; // how far one pixel may move before it counts
49
+
50
+ const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
51
+ const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
52
+ const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
53
+
54
+ /** Composite an RGBA sample over white, because a screenshot's transparent pixels are what the page shows through. */
55
+ function flatten(data, p) {
56
+ const a = data[p + 3] / 255;
57
+ return [
58
+ data[p] * a + 255 * (1 - a),
59
+ data[p + 1] * a + 255 * (1 - a),
60
+ data[p + 2] * a + 255 * (1 - a),
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * Compare two decoded images.
66
+ *
67
+ * Returns `{width, height, pixels, diffPixels, diffRatio, maxDelta, meanDelta, diff}`
68
+ * where the deltas are normalised 0..1 and `diff` is an RGBA buffer marking every
69
+ * counted pixel — for a human to look at AFTER the fact, never for the verdict.
70
+ */
71
+ export function compareImages(a, b, { pixelTolerance = DEFAULT_PIXEL_TOLERANCE, withDiff = false } = {}) {
72
+ if (a.width !== b.width || a.height !== b.height) {
73
+ return {
74
+ sizeMismatch: true,
75
+ width: a.width, height: a.height,
76
+ referenceSize: `${b.width}x${b.height}`, candidateSize: `${a.width}x${a.height}`,
77
+ pixels: 0, diffPixels: 0, diffRatio: 1, maxDelta: 1, meanDelta: 1, diff: null,
78
+ };
79
+ }
80
+ const pixels = a.width * a.height;
81
+ const cutoff = MAX_YIQ_DELTA * pixelTolerance * pixelTolerance;
82
+ const diff = withDiff ? Buffer.alloc(pixels * 4) : null;
83
+ let diffPixels = 0, maxDelta = 0, sumDelta = 0;
84
+
85
+ for (let p = 0; p < pixels; p++) {
86
+ const o = p * 4;
87
+ const [ar, ag, ab] = flatten(a.data, o);
88
+ const [br, bg, bb] = flatten(b.data, o);
89
+ const dy = y(ar, ag, ab) - y(br, bg, bb);
90
+ const di = i(ar, ag, ab) - i(br, bg, bb);
91
+ const dq = q(ar, ag, ab) - q(br, bg, bb);
92
+ const delta = 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq;
93
+ if (delta > maxDelta) maxDelta = delta;
94
+ sumDelta += delta;
95
+ const counted = delta > cutoff;
96
+ if (counted) diffPixels++;
97
+ if (diff) {
98
+ if (counted) { diff[o] = 255; diff[o + 1] = 0; diff[o + 2] = 200; diff[o + 3] = 255; }
99
+ else {
100
+ // Ghost the reference so the marks have somewhere to sit.
101
+ const grey = Math.round(255 - (255 - y(br, bg, bb)) * 0.15);
102
+ diff[o] = diff[o + 1] = diff[o + 2] = grey; diff[o + 3] = 255;
103
+ }
104
+ }
105
+ }
106
+ return {
107
+ sizeMismatch: false,
108
+ width: a.width, height: a.height, pixels, diffPixels,
109
+ diffRatio: diffPixels / pixels,
110
+ maxDelta: maxDelta / MAX_YIQ_DELTA,
111
+ meanDelta: sumDelta / pixels / MAX_YIQ_DELTA,
112
+ diff: diff ? { width: a.width, height: a.height, data: diff } : null,
113
+ };
114
+ }
115
+
116
+ /** Same, from PNG bytes. */
117
+ export function comparePngs(candidateBuf, referenceBuf, opts) {
118
+ return compareImages(decodePng(candidateBuf), decodePng(referenceBuf), opts);
119
+ }
120
+
121
+ /**
122
+ * The verdict. Derived, never stored, never supplied.
123
+ *
124
+ * A measurement with no `diffRatio` is not a pass and not a fail — it is `incomplete`,
125
+ * so a ledger line that carries an opinion but no number can never be mistaken for work
126
+ * that was done.
127
+ */
128
+ export function verdict(measurement, threshold = DEFAULT_THRESHOLD) {
129
+ if (!measurement || typeof measurement.diffRatio !== "number" || !Number.isFinite(measurement.diffRatio)) {
130
+ return { state: "incomplete", pass: false, reason: "no measurement" };
131
+ }
132
+ if (measurement.error) return { state: "error", pass: false, reason: measurement.error };
133
+ if (measurement.sizeMismatch) {
134
+ return { state: "fail", pass: false, reason: `size mismatch — reference ${measurement.referenceSize}, candidate ${measurement.candidateSize}` };
135
+ }
136
+ const pass = measurement.diffRatio <= threshold;
137
+ return { state: pass ? "pass" : "fail", pass, reason: pass ? "" : `${(measurement.diffRatio * 100).toFixed(2)}% of pixels differ (threshold ${(threshold * 100).toFixed(2)}%)` };
138
+ }
139
+
140
+ export { encodePng, decodePng };
@@ -0,0 +1,150 @@
1
+ // refine-ledger — the record that makes a refine run resumable, and auditable.
2
+ //
3
+ // WHY APPEND-ONLY JSONL AND NOT A JSON FILE. A run over five hundred components is an
4
+ // overnight run, and an overnight run gets killed: the laptop sleeps, the socket drops,
5
+ // the agent's own process is stopped. Rewriting one JSON document after each component
6
+ // means five hundred chances to be interrupted mid-write and lose the whole record; one
7
+ // appended line per component means the worst a kill can cost is the line being written.
8
+ // A torn final line is DISCARDED on read rather than crashing the next run, which is the
9
+ // only sane reading of a file that a `kill -9` was allowed to end.
10
+ //
11
+ // WHAT A LINE HOLDS: a measurement and the digests of the two images it was measured
12
+ // from. WHAT IT NEVER HOLDS: a verdict. Pass and fail are derived by
13
+ // `refine-compare.mjs#verdict` every time the report is drawn, so a line edited to say
14
+ // `"pass": true` changes nothing at all — the field is not read. That is the mechanical
15
+ // form of "no self-assessment": there is no key an agent can write to make its own work
16
+ // count as done, and `--audit` re-measures from the saved PNGs to catch a line whose
17
+ // NUMBER was edited instead.
18
+ //
19
+ // RESUME IS BY CONTENT, NOT BY POSITION. A component is skipped only when the ledger
20
+ // already holds a completed measurement for it whose candidate digest, reference digest
21
+ // and settings fingerprint all still match. So a re-run after editing one component
22
+ // re-verifies that one and skips the other four hundred and ninety-nine, and a re-run
23
+ // after changing the threshold re-verifies everything, because the settings changed.
24
+
25
+ import { promises as fs } from "node:fs";
26
+ import path from "node:path";
27
+ import crypto from "node:crypto";
28
+ import { verdict } from "./refine-compare.mjs";
29
+
30
+ export const LEDGER_NAME = "ledger.jsonl";
31
+
32
+ export const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
33
+
34
+ /** A stable digest of a settings object — what invalidates every entry when it changes. */
35
+ export function settingsFingerprint(settings) {
36
+ const ordered = Object.keys(settings).sort().reduce((o, k) => (o[k] = settings[k], o), {});
37
+ return sha256(JSON.stringify(ordered)).slice(0, 16);
38
+ }
39
+
40
+ /**
41
+ * Content digest of a folder or file. Names AND bytes, so a renamed file counts as a
42
+ * change; mtime is deliberately not read, because a fresh clone rewrites every mtime and
43
+ * would invalidate a whole ledger for no reason.
44
+ */
45
+ export async function contentDigest(target) {
46
+ const h = crypto.createHash("sha256");
47
+ const walk = async (p, rel) => {
48
+ const st = await fs.stat(p);
49
+ if (st.isDirectory()) {
50
+ for (const name of (await fs.readdir(p)).sort()) {
51
+ if (name.startsWith(".")) continue;
52
+ await walk(path.join(p, name), rel ? `${rel}/${name}` : name);
53
+ }
54
+ } else {
55
+ h.update(rel + "\0");
56
+ h.update(await fs.readFile(p));
57
+ }
58
+ };
59
+ await walk(target, "");
60
+ return h.digest("hex").slice(0, 16);
61
+ }
62
+
63
+ /** Read a ledger, tolerating a torn final line. Later lines win over earlier ones for the same id. */
64
+ export async function readLedger(file) {
65
+ let text = "";
66
+ try { text = await fs.readFile(file, "utf8"); } catch { return { entries: new Map(), lines: 0, torn: 0 }; }
67
+ const entries = new Map();
68
+ let lines = 0, torn = 0;
69
+ for (const line of text.split("\n")) {
70
+ if (!line.trim()) continue;
71
+ lines++;
72
+ let rec;
73
+ try { rec = JSON.parse(line); } catch { torn++; continue; }
74
+ if (rec && typeof rec.id === "string") entries.set(rec.id, rec);
75
+ }
76
+ return { entries, lines, torn };
77
+ }
78
+
79
+ /**
80
+ * Append one measurement. Opened, written and closed per call with an fsync, so the line
81
+ * is on disk before the next component starts rendering — the property the whole resume
82
+ * story rests on.
83
+ */
84
+ export async function appendLedger(file, record) {
85
+ await fs.mkdir(path.dirname(file), { recursive: true });
86
+ const fh = await fs.open(file, "a");
87
+ try {
88
+ await fh.write(JSON.stringify(record) + "\n");
89
+ await fh.sync();
90
+ } finally {
91
+ await fh.close();
92
+ }
93
+ }
94
+
95
+ /** Is this ledger entry still a usable answer for this component, under these settings? */
96
+ export function isFresh(entry, { candidateDigest, referenceDigest, fingerprint }) {
97
+ if (!entry || typeof entry.diffRatio !== "number") return false;
98
+ // ⚠️ AN ERRORED LINE IS NEVER FRESH, and this is not a nicety. A failed render records
99
+ // `diffRatio: 1` so the component counts as failed rather than as missing — which means
100
+ // it carries a number, and a number is what "already measured" is made of. Without this
101
+ // line, the browser dying at component 200 of 500 writes a permanent FAIL for the other
102
+ // 300, and every resume afterwards skips them because the ledger looks complete. The
103
+ // overnight run then reports 40% forever and nothing re-renders. A transient failure has
104
+ // to be retried by the next run; a real one costs a re-render and says the same thing.
105
+ if (entry.error) return false;
106
+ if (entry.fingerprint !== fingerprint) return false;
107
+ // No digest means the caller could not tell what this side is made of — a URL with
108
+ // nothing on disk behind it. Two nulls must never compare equal and count as unchanged:
109
+ // that is how a component fixed at 2am keeps its 11pm verdict all night.
110
+ if (!candidateDigest || !referenceDigest) return false;
111
+ if (entry.candidateDigest !== candidateDigest) return false;
112
+ if (entry.referenceDigest !== referenceDigest) return false;
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Turn a ledger into the per-component report. Verdicts are computed HERE, from the
118
+ * numbers, every time — a stored `pass` field is not consulted and never will be.
119
+ */
120
+ export function report(components, entries, threshold) {
121
+ const rows = components.map((c) => {
122
+ const e = entries.get(c.id);
123
+ const v = verdict(e, c.threshold ?? threshold);
124
+ return {
125
+ id: c.id,
126
+ state: v.state,
127
+ pass: v.pass,
128
+ reason: v.reason,
129
+ diffRatio: e && typeof e.diffRatio === "number" ? e.diffRatio : null,
130
+ diffPixels: e?.diffPixels ?? null,
131
+ pixels: e?.pixels ?? null,
132
+ maxDelta: e?.maxDelta ?? null,
133
+ threshold: c.threshold ?? threshold,
134
+ measuredAt: e?.measuredAt ?? null,
135
+ };
136
+ });
137
+ const measured = rows.filter((r) => r.state !== "incomplete");
138
+ const passed = rows.filter((r) => r.pass);
139
+ return {
140
+ total: rows.length,
141
+ measured: measured.length,
142
+ passed: passed.length,
143
+ failed: measured.length - passed.length,
144
+ incomplete: rows.length - measured.length,
145
+ // The pass-rate is over EVERY component in the manifest, not over the ones that got
146
+ // measured. A run that fell over after ten of five hundred otherwise reports 100%.
147
+ passRate: rows.length ? passed.length / rows.length : 0,
148
+ rows,
149
+ };
150
+ }