@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.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- 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
|
+
}
|