session-orchestrator 3.20.0 → 3.22.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* site-numbers.mjs — the census that fills the website's "Measured" block.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists
|
|
6
|
+
*
|
|
7
|
+
* `site/index.html` carries a proof block of counted repository facts, and that
|
|
8
|
+
* block is the load-bearing evidence for the whole page's honesty argument. It
|
|
9
|
+
* was counted BY HAND on 2026-08-03 and then drifted: measured on 2026-08-19 at
|
|
10
|
+
* `3981267`, six of eight tiles disagreed with the repository they describe
|
|
11
|
+
* (tests 556→580, sessions 210→252, learnings 95→135, and so on). A product whose
|
|
12
|
+
* thesis is mechanical rigour was shipping stale measurements — the worst content
|
|
13
|
+
* to get wrong, because a reader can check it in one command.
|
|
14
|
+
*
|
|
15
|
+
* The fix is not "recount more carefully". It is to stop hand-maintaining a
|
|
16
|
+
* derived number: the page declares WHICH fact each slot holds, and this script
|
|
17
|
+
* derives the value from the repository.
|
|
18
|
+
*
|
|
19
|
+
* ## The markup contract
|
|
20
|
+
*
|
|
21
|
+
* <span class="num" data-metric="skills">46</span>
|
|
22
|
+
*
|
|
23
|
+
* Any element order / extra attributes are fine; only `data-metric` and the
|
|
24
|
+
* element's text content are read. The metric ids are frozen in `METRIC_IDS` —
|
|
25
|
+
* an unknown id is an ERROR, never a silent skip, because a typo'd
|
|
26
|
+
* `data-metric="skils"` would otherwise leave a hand-maintained number on the
|
|
27
|
+
* page forever, which is the exact failure this script exists to end.
|
|
28
|
+
*
|
|
29
|
+
* ## Where each number comes from
|
|
30
|
+
*
|
|
31
|
+
* Every count below mirrors an EXISTING census in this repo rather than
|
|
32
|
+
* inventing a new one — `skills/claude-md-drift-check/checker.mjs` is the SSOT
|
|
33
|
+
* for the four surface counts, and the per-metric `source` string (shipped in
|
|
34
|
+
* `--json`) names the shell command that defines it. See METRIC_DEFS.
|
|
35
|
+
*
|
|
36
|
+
* ## The census snapshot (`site/_census.json`)
|
|
37
|
+
*
|
|
38
|
+
* Three of the thirteen metrics read a source that a FRESH CLONE does not have:
|
|
39
|
+
* `.orchestrator/metrics/{sessions,learnings}.jsonl` are gitignored
|
|
40
|
+
* (`.gitignore` — local-only observability data) and `counted-sha` needs a
|
|
41
|
+
* `.git` directory that a tarball / `docker COPY` build does not carry. Those
|
|
42
|
+
* three therefore carry `snapshotFallback: true` and fall back to a TRACKED
|
|
43
|
+
* snapshot written by `--write`.
|
|
44
|
+
*
|
|
45
|
+
* Precedence is always LIVE FIRST, snapshot only when live returns `null` —
|
|
46
|
+
* uniformly, including `counted-sha`. A snapshot that outranked the live source
|
|
47
|
+
* would freeze the page on the last release's numbers, which is the same
|
|
48
|
+
* hand-maintained-derived-number failure this file exists to end.
|
|
49
|
+
*
|
|
50
|
+
* The fallback is per-metric opt-in and NEVER blanket: the other ten stay loud.
|
|
51
|
+
* A blanket fallback would make `collect()` blind to "wrong root" — any
|
|
52
|
+
* directory carrying a copied snapshot would answer all thirteen and exit 0,
|
|
53
|
+
* which is exactly what the "refusing to publish a partial census" guard below
|
|
54
|
+
* exists to prevent.
|
|
55
|
+
*
|
|
56
|
+
* The snapshot is PUBLICLY SERVED (`vercel.json` `outputDirectory: "site"`) —
|
|
57
|
+
* deliberately, it is the machine-readable receipt for the numbers on the page.
|
|
58
|
+
* Its schema is therefore frozen to exactly `METRIC_IDS` under `metrics`, plus a
|
|
59
|
+
* `$schema` tag: no paths, no hostnames, no cwd, no raw ledger lines. It is
|
|
60
|
+
* written ONLY when the site directory is the repo's own `site/` (see `main()`),
|
|
61
|
+
* so a `--write --site <tmpdir>` fixture run can never touch the real one.
|
|
62
|
+
*
|
|
63
|
+
* ## Modes
|
|
64
|
+
*
|
|
65
|
+
* --check read-only; exit 1 on drift. The CI/build guard.
|
|
66
|
+
* --write rewrite the span contents in place, and refresh `site/_census.json`
|
|
67
|
+
* from the same measurement (only when --site is the repo's `site/`).
|
|
68
|
+
*
|
|
69
|
+
* Exit codes (`.claude/rules/cli-design.md`):
|
|
70
|
+
* 0 — no drift (--check) / files updated or already current (--write)
|
|
71
|
+
* 1 — drift found (--check), or a contract violation in either mode
|
|
72
|
+
* (no `data-metric` spans anywhere, unknown metric id, malformed cell)
|
|
73
|
+
* 2 — tool error (bad argv, missing repo/site directory, unreadable census input)
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
77
|
+
import { join, resolve, relative } from 'node:path';
|
|
78
|
+
import { execFileSync } from 'node:child_process';
|
|
79
|
+
import { fileURLToPath } from 'node:url';
|
|
80
|
+
|
|
81
|
+
import { writeStdoutLineSync, writeJsonAtomicSync } from './lib/io.mjs';
|
|
82
|
+
|
|
83
|
+
/** Machine-readable schema tag for the --json envelope. */
|
|
84
|
+
export const SCHEMA = 'site-numbers/1';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The tracked census snapshot, relative to the repo root.
|
|
88
|
+
*
|
|
89
|
+
* Under `site/` and NOT `.orchestrator/metrics/` on purpose: `checkStaleArtifacts`
|
|
90
|
+
* in `scripts/lib/project-hygiene.mjs` counts EVERY file below `.orchestrator/`
|
|
91
|
+
* with an mtime older than 30 days as a prune candidate, with no tracked /
|
|
92
|
+
* untracked distinction (#979) — a tracked snapshot living there would be
|
|
93
|
+
* offered for deletion between releases, which is precisely when it matters.
|
|
94
|
+
*/
|
|
95
|
+
export const CENSUS_FILE = ['site', '_census.json'];
|
|
96
|
+
|
|
97
|
+
/** Schema tag written into the snapshot; mirrors `site/leaderboard.json`'s shape. */
|
|
98
|
+
export const CENSUS_SCHEMA = 'site-numbers/1 census snapshot';
|
|
99
|
+
|
|
100
|
+
const USAGE =
|
|
101
|
+
'Usage: site-numbers.mjs [<repo-root>] [--check|--write] [--json] [--site <dir>]';
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Census primitives — each one mirrors an existing command, none invents a new
|
|
105
|
+
// counting basis. Every function returns `null` when its input surface is
|
|
106
|
+
// missing, which the caller turns into a LOUD tool error (exit 2) rather than a
|
|
107
|
+
// zero: "0 skills" is a plausible-looking number that would silently ship.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** Recursively visit every file below `dir`, passing the absolute path. */
|
|
111
|
+
function walk(dir, visit) {
|
|
112
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
113
|
+
if (entry.name.startsWith('.')) continue;
|
|
114
|
+
const abs = join(dir, entry.name);
|
|
115
|
+
if (entry.isDirectory()) walk(abs, visit);
|
|
116
|
+
else if (entry.isFile()) visit(abs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDir(p) {
|
|
121
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One entry per `skills/<name>/SKILL.md` — see this metric's `source` string in
|
|
126
|
+
* METRIC_DEFS for the exact shell equivalent.
|
|
127
|
+
*
|
|
128
|
+
* A directory is a skill when it CARRIES a `SKILL.md`, not when it exists.
|
|
129
|
+
* `skills/_shared/` is a support directory with no SKILL.md and is therefore not
|
|
130
|
+
* a skill — counting bare directories yields 47 where the answer is 46, and that
|
|
131
|
+
* off-by-one is exactly what a hand count produces. Same basis as
|
|
132
|
+
* `countSkills()` in skills/claude-md-drift-check/checker.mjs.
|
|
133
|
+
*/
|
|
134
|
+
export function countSkills(root) {
|
|
135
|
+
const dir = join(root, 'skills');
|
|
136
|
+
if (!isDir(dir)) return null;
|
|
137
|
+
let n = 0;
|
|
138
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
139
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
140
|
+
const f = join(dir, entry.name, 'SKILL.md');
|
|
141
|
+
if (existsSync(f) && statSync(f).isFile()) n++;
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `ls commands/*.md | wc -l` — same basis as checker.mjs `command-count`. */
|
|
147
|
+
export function countCommands(root) {
|
|
148
|
+
const dir = join(root, 'commands');
|
|
149
|
+
if (!isDir(dir)) return null;
|
|
150
|
+
return readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('.')).length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `ls .claude/rules/*.md | wc -l`
|
|
155
|
+
*
|
|
156
|
+
* The site's Measured footnote claims a rule-file count. It drifted (page said
|
|
157
|
+
* 29, repo has 30) for the same reason every other tile did — it was typed once.
|
|
158
|
+
*/
|
|
159
|
+
export function countRuleFiles(root) {
|
|
160
|
+
const dir = join(root, '.claude', 'rules');
|
|
161
|
+
if (!isDir(dir)) return null;
|
|
162
|
+
return readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('.')).length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `grep -l 'generated-by: reconciliation-engine' .claude/rules/*.md | wc -l`
|
|
167
|
+
*
|
|
168
|
+
* The subset of rule files the reconciliation engine wrote rather than a human.
|
|
169
|
+
* The page names this split, so it is a claim and needs the same treatment.
|
|
170
|
+
*/
|
|
171
|
+
export function countGeneratedRuleFiles(root) {
|
|
172
|
+
const dir = join(root, '.claude', 'rules');
|
|
173
|
+
if (!isDir(dir)) return null;
|
|
174
|
+
let n = 0;
|
|
175
|
+
for (const f of readdirSync(dir)) {
|
|
176
|
+
if (!f.endsWith('.md') || f.startsWith('.')) continue;
|
|
177
|
+
try {
|
|
178
|
+
if (readFileSync(join(dir, f), 'utf8').includes('generated-by: reconciliation-engine')) n += 1;
|
|
179
|
+
} catch {
|
|
180
|
+
// Unreadable file: not counted. Returning null for the whole metric would
|
|
181
|
+
// turn one bad file into a missing number; a short count is caught by the
|
|
182
|
+
// reviewer, a null is caught by nobody.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return n;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Rule count in `.orchestrator/policy/blocked-commands.json`.
|
|
190
|
+
*
|
|
191
|
+
* Parsed, never pattern-matched: a regex over the file would keep matching after
|
|
192
|
+
* the schema changed and report a stale number as current.
|
|
193
|
+
*/
|
|
194
|
+
export function countBlockedCommands(root) {
|
|
195
|
+
const file = join(root, '.orchestrator', 'policy', 'blocked-commands.json');
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
198
|
+
if (Array.isArray(parsed)) return parsed.length;
|
|
199
|
+
if (Array.isArray(parsed?.rules)) return parsed.rules.length;
|
|
200
|
+
return null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `ls agents/*.md | grep -v AGENTS.md | wc -l`
|
|
208
|
+
*
|
|
209
|
+
* `agents/AGENTS.md` is the AUTHORING SPEC for agent definitions, not an agent.
|
|
210
|
+
* Same exclusion as `countAgents()` in checker.mjs.
|
|
211
|
+
*/
|
|
212
|
+
export function countAgents(root) {
|
|
213
|
+
const dir = join(root, 'agents');
|
|
214
|
+
if (!isDir(dir)) return null;
|
|
215
|
+
return readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'AGENTS.md' && !f.startsWith('.'))
|
|
216
|
+
.length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `ls hooks/*.mjs | wc -l` — ON-DISK hook files, deliberately NOT the
|
|
221
|
+
* plugin-wired count.
|
|
222
|
+
*
|
|
223
|
+
* THE DECISION, because the two numbers differ and the page must pick one:
|
|
224
|
+
* on-disk is 25, `hooks/hooks.json` wires 24 distinct handler files. The 25th,
|
|
225
|
+
* `hooks/wave-scope-commit-guard.mjs`, is not dead code and not an oversight —
|
|
226
|
+
* it is wired through a DIFFERENT channel, the repository's Husky Git
|
|
227
|
+
* pre-commit hook (`.husky/pre-commit`), because it guards git index/commit
|
|
228
|
+
* state rather than a plugin lifecycle event (#821, documented in
|
|
229
|
+
* `.orchestrator/steering/structure.md`).
|
|
230
|
+
*
|
|
231
|
+
* On-disk wins on three grounds:
|
|
232
|
+
* 1. It is what the page's own label says — "hook files".
|
|
233
|
+
* 2. It is the census a sceptical reader reproduces in ONE command. A page
|
|
234
|
+
* claiming 24 is falsified by `ls hooks/*.mjs | wc -l`, and being
|
|
235
|
+
* falsifiable-by-one-command is the specific damage this script prevents.
|
|
236
|
+
* 3. No dead file is counted: all 25 are wired, 24 via hooks.json and 1 via
|
|
237
|
+
* Husky. Only the claim "25 PLUGIN hooks" would be false, and the page
|
|
238
|
+
* does not make it.
|
|
239
|
+
*
|
|
240
|
+
* The cost of the choice, named: the tile pairs this number with "10 event
|
|
241
|
+
* types", and one of the 25 has no event type. If the page ever rephrases the
|
|
242
|
+
* label to "plugin hooks", this metric must switch to the hooks.json wiring
|
|
243
|
+
* count (distinct `.mjs` filenames referenced inside `hooks/hooks.json`).
|
|
244
|
+
*/
|
|
245
|
+
export function countHookFiles(root) {
|
|
246
|
+
const dir = join(root, 'hooks');
|
|
247
|
+
if (!isDir(dir)) return null;
|
|
248
|
+
return readdirSync(dir).filter((f) => f.endsWith('.mjs') && !f.startsWith('.')).length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** `find tests -name '*.test.mjs' | wc -l` — same basis as checker.mjs `countTestFiles()`. */
|
|
252
|
+
export function countTestFiles(root) {
|
|
253
|
+
const dir = join(root, 'tests');
|
|
254
|
+
if (!isDir(dir)) return null;
|
|
255
|
+
let n = 0;
|
|
256
|
+
walk(dir, (f) => {
|
|
257
|
+
if (f.endsWith('.test.mjs')) n++;
|
|
258
|
+
});
|
|
259
|
+
return n;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `grep -c . <file>` — non-empty lines of a JSONL ledger.
|
|
264
|
+
*
|
|
265
|
+
* Non-empty rather than `wc -l` so the count does not depend on whether the
|
|
266
|
+
* file ends with a newline. Lines that do not parse as JSON are still COUNTED
|
|
267
|
+
* (they are records, however broken) but reported in `malformed` so a corrupt
|
|
268
|
+
* ledger surfaces as a warning instead of silently shrinking the number.
|
|
269
|
+
*
|
|
270
|
+
* @returns {{entries:number, malformed:number}|null}
|
|
271
|
+
*/
|
|
272
|
+
export function countJsonlEntries(file) {
|
|
273
|
+
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
274
|
+
const lines = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0);
|
|
275
|
+
let malformed = 0;
|
|
276
|
+
for (const l of lines) {
|
|
277
|
+
try {
|
|
278
|
+
JSON.parse(l);
|
|
279
|
+
} catch {
|
|
280
|
+
malformed++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { entries: lines.length, malformed };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `node -p "require('./package.json').version"` */
|
|
287
|
+
export function readPackageVersion(root) {
|
|
288
|
+
const f = join(root, 'package.json');
|
|
289
|
+
if (!existsSync(f)) return null;
|
|
290
|
+
try {
|
|
291
|
+
const v = JSON.parse(readFileSync(f, 'utf8')).version;
|
|
292
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `git rev-parse --short HEAD`, or null outside a git repo. */
|
|
299
|
+
export function headRef(root) {
|
|
300
|
+
try {
|
|
301
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
302
|
+
cwd: root,
|
|
303
|
+
encoding: 'utf8',
|
|
304
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
305
|
+
}).trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Whether the working tree has uncommitted tracked changes.
|
|
313
|
+
*
|
|
314
|
+
* Load-bearing for `counted-sha`: the counts read the WORKING TREE, so stamping
|
|
315
|
+
* them with HEAD on a dirty tree publishes a SHA at which nobody can reproduce
|
|
316
|
+
* the numbers (PSA-006 "measured WHEN"). `--no-optional-locks` is required, not
|
|
317
|
+
* tidiness — a plain `git status` refreshes and therefore LOCKS `.git/index`,
|
|
318
|
+
* racing a parallel session's index write (PSA-007).
|
|
319
|
+
*
|
|
320
|
+
* @returns {boolean|null} null when git cannot answer
|
|
321
|
+
*/
|
|
322
|
+
export function isDirty(root) {
|
|
323
|
+
try {
|
|
324
|
+
const out = execFileSync(
|
|
325
|
+
'git',
|
|
326
|
+
['--no-optional-locks', 'status', '--porcelain', '--untracked-files=no'],
|
|
327
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
328
|
+
);
|
|
329
|
+
return out.split('\n').filter(Boolean).length > 0;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// The metric table — the part that must never be re-derived by hand
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* `provenance: true` marks a stamp about the PAST rather than a fact about the
|
|
341
|
+
* present. See `collect()` for why those two are warn-only under --check.
|
|
342
|
+
*
|
|
343
|
+
* `snapshotFallback: true` marks a metric whose live source is absent from a
|
|
344
|
+
* fresh clone, and which may therefore fall back to `site/_census.json` when —
|
|
345
|
+
* and only when — the live read returns `null`. It is OPT-IN: an omitted field
|
|
346
|
+
* means "no fallback", i.e. this metric stays loud and a missing source is a
|
|
347
|
+
* tool error. Exactly three carry it; see the header for why a blanket fallback
|
|
348
|
+
* would be a defect rather than a convenience.
|
|
349
|
+
*/
|
|
350
|
+
export const METRIC_DEFS = Object.freeze([
|
|
351
|
+
{
|
|
352
|
+
id: 'version',
|
|
353
|
+
provenance: false,
|
|
354
|
+
// BARE, no leading "v". The "v" is presentation and belongs to the markup,
|
|
355
|
+
// which writes `v<span data-metric="version">X.Y.Z</span>`. A prefix baked
|
|
356
|
+
// into the data layer produced `vvX.Y.Z` on the first --write against the
|
|
357
|
+
// real page — caught before it shipped, but only because --check was run.
|
|
358
|
+
// `site/llms.txt` agrees: its `Version:` line carries the same bare form,
|
|
359
|
+
// and `scripts/release.mjs` bumps it from the same target.
|
|
360
|
+
// The `X.Y.Z` placeholders are deliberate. A concrete version in a comment
|
|
361
|
+
// is a number nobody bumps, and it is outside `release.mjs`'s
|
|
362
|
+
// HISTORY_ALLOWLIST — so it would be reported as release drift that is not
|
|
363
|
+
// drift, or worse, hand-bumped until the comment claims a version it does
|
|
364
|
+
// not mean.
|
|
365
|
+
source: 'node -p "require(\'./package.json\').version"',
|
|
366
|
+
compute: (root) => readPackageVersion(root),
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: 'skills',
|
|
370
|
+
provenance: false,
|
|
371
|
+
source: 'ls -d skills/*/SKILL.md | wc -l',
|
|
372
|
+
compute: (root) => fmtCount(countSkills(root)),
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: 'commands',
|
|
376
|
+
provenance: false,
|
|
377
|
+
source: 'ls commands/*.md | wc -l',
|
|
378
|
+
compute: (root) => fmtCount(countCommands(root)),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'agents',
|
|
382
|
+
provenance: false,
|
|
383
|
+
source: "ls agents/*.md | grep -v '^agents/AGENTS.md$' | wc -l",
|
|
384
|
+
compute: (root) => fmtCount(countAgents(root)),
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: 'hooks',
|
|
388
|
+
provenance: false,
|
|
389
|
+
source: 'ls hooks/*.mjs | wc -l (on-disk, NOT the 24 wired in hooks/hooks.json — see countHookFiles)',
|
|
390
|
+
compute: (root) => fmtCount(countHookFiles(root)),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: 'tests',
|
|
394
|
+
provenance: false,
|
|
395
|
+
source: "find tests -name '*.test.mjs' | wc -l",
|
|
396
|
+
compute: (root) => fmtCount(countTestFiles(root)),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: 'sessions',
|
|
400
|
+
provenance: false,
|
|
401
|
+
// Gitignored ledger (`.gitignore`: local-only observability data) — absent
|
|
402
|
+
// in every fresh clone, CI checkout and tarball build.
|
|
403
|
+
snapshotFallback: true,
|
|
404
|
+
source: 'grep -c . .orchestrator/metrics/sessions.jsonl',
|
|
405
|
+
compute: (root) => {
|
|
406
|
+
const r = countJsonlEntries(join(root, '.orchestrator', 'metrics', 'sessions.jsonl'));
|
|
407
|
+
return r === null ? null : fmtCount(r.entries);
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
id: 'learnings',
|
|
412
|
+
provenance: false,
|
|
413
|
+
// Same gitignored ledger family as `sessions`.
|
|
414
|
+
snapshotFallback: true,
|
|
415
|
+
source: 'grep -c . .orchestrator/metrics/learnings.jsonl',
|
|
416
|
+
compute: (root) => {
|
|
417
|
+
const r = countJsonlEntries(join(root, '.orchestrator', 'metrics', 'learnings.jsonl'));
|
|
418
|
+
return r === null ? null : fmtCount(r.entries);
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
id: 'rules',
|
|
423
|
+
provenance: false,
|
|
424
|
+
source: 'ls .claude/rules/*.md | wc -l',
|
|
425
|
+
compute: (root) => fmtCount(countRuleFiles(root)),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: 'rules-generated',
|
|
429
|
+
provenance: false,
|
|
430
|
+
source: "grep -l 'generated-by: reconciliation-engine' .claude/rules/*.md | wc -l",
|
|
431
|
+
compute: (root) => fmtCount(countGeneratedRuleFiles(root)),
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
id: 'blocked-commands',
|
|
435
|
+
provenance: false,
|
|
436
|
+
source: 'jq length .orchestrator/policy/blocked-commands.json',
|
|
437
|
+
compute: (root) => fmtCount(countBlockedCommands(root)),
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: 'counted-at',
|
|
441
|
+
provenance: true,
|
|
442
|
+
source: 'date -u +%Y-%m-%d',
|
|
443
|
+
compute: () => new Date().toISOString().slice(0, 10),
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: 'counted-sha',
|
|
447
|
+
provenance: true,
|
|
448
|
+
// A tarball / `docker COPY` build carries the tree but not `.git`, so the
|
|
449
|
+
// live read is null there. The fallback restores the SHA the snapshot was
|
|
450
|
+
// stamped at — which is the honest answer for such a build, because that is
|
|
451
|
+
// when these numbers were last counted. No special precedence: a real `.git`
|
|
452
|
+
// still wins, like every other metric.
|
|
453
|
+
snapshotFallback: true,
|
|
454
|
+
source: 'git rev-parse --short HEAD',
|
|
455
|
+
compute: (root) => headRef(root),
|
|
456
|
+
},
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
/** The frozen allowlist of `data-metric` values. Anything else is an error. */
|
|
460
|
+
export const METRIC_IDS = Object.freeze(METRIC_DEFS.map((m) => m.id));
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Plain integers, no locale grouping. `toLocaleString()` would make the output
|
|
464
|
+
* depend on the build machine's locale — a generator that emits "1.234" on one
|
|
465
|
+
* host and "1,234" on another manufactures drift instead of removing it.
|
|
466
|
+
*/
|
|
467
|
+
function fmtCount(n) {
|
|
468
|
+
return n === null || n === undefined ? null : String(n);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* What a value is allowed to contain before it is written into HTML — and, since
|
|
473
|
+
* the snapshot exists, before it is accepted OUT of `site/_census.json`.
|
|
474
|
+
*
|
|
475
|
+
* Eleven of the thirteen metrics are digits (`fmtCount`) or hex (`git
|
|
476
|
+
* rev-parse --short`) by construction, but `version` is whatever
|
|
477
|
+
* `package.json` says and `readPackageVersion` only checks that it is a
|
|
478
|
+
* non-empty string. The `/[<>]/` test in `rewrite()` guards the OLD cell
|
|
479
|
+
* content, never the NEW value — so nothing stopped a crafted version literal
|
|
480
|
+
* from closing the span and opening a tag. The precondition is write access to
|
|
481
|
+
* `package.json`, which in this repo's trust model already means full access,
|
|
482
|
+
* so this is defence in depth rather than a live hole; it is cheap, and it is
|
|
483
|
+
* the one place where the page's `script-src 'unsafe-inline'` would stop being
|
|
484
|
+
* theoretical.
|
|
485
|
+
*/
|
|
486
|
+
const SAFE_VALUE_RE = /^[\w.+-]+$/;
|
|
487
|
+
|
|
488
|
+
/** Absolute path of the census snapshot for `root`. */
|
|
489
|
+
export function censusPath(root) {
|
|
490
|
+
return join(root, ...CENSUS_FILE);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Read the tracked snapshot's `metrics` map, or `null` when it is absent,
|
|
495
|
+
* unparseable, or not shaped like a census.
|
|
496
|
+
*
|
|
497
|
+
* Deliberately forgiving about EXTRA keys and lenient about missing ones: the
|
|
498
|
+
* lookup is per-metric, so a snapshot written by an older version that knows
|
|
499
|
+
* eleven ids still serves those eleven, and the twelfth simply stays `missing`
|
|
500
|
+
* (a loud tool error) instead of poisoning the whole read.
|
|
501
|
+
*
|
|
502
|
+
* Values are filtered through `SAFE_VALUE_RE` — the same allowlist `rewrite()`
|
|
503
|
+
* applies before injecting a value into HTML. Rejecting here rather than at
|
|
504
|
+
* write time turns a corrupt snapshot into "this metric has no value" (exit 2,
|
|
505
|
+
* naming the metric) instead of "refusing to write this file" (exit 1, naming
|
|
506
|
+
* the version literal), which is the accurate diagnosis.
|
|
507
|
+
*/
|
|
508
|
+
export function readCensusSnapshot(root) {
|
|
509
|
+
let parsed;
|
|
510
|
+
try {
|
|
511
|
+
parsed = JSON.parse(readFileSync(censusPath(root), 'utf8'));
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
const metrics = parsed?.metrics;
|
|
516
|
+
if (metrics === null || typeof metrics !== 'object' || Array.isArray(metrics)) return null;
|
|
517
|
+
const out = {};
|
|
518
|
+
for (const id of METRIC_IDS) {
|
|
519
|
+
const v = metrics[id];
|
|
520
|
+
if (typeof v === 'string' && v.length > 0 && SAFE_VALUE_RE.test(v)) out[id] = v;
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Write the snapshot for `root` from an already-computed `values` map.
|
|
527
|
+
*
|
|
528
|
+
* Takes `values` rather than re-running `collect()` on purpose: a second census
|
|
529
|
+
* inside the same run can disagree with the first one — `counted-at` flips at
|
|
530
|
+
* midnight, and any ledger appended to between the two reads shifts. The page
|
|
531
|
+
* and its receipt must be produced from ONE measurement.
|
|
532
|
+
*
|
|
533
|
+
* Only the frozen `METRIC_IDS` are emitted, in table order. The file is served
|
|
534
|
+
* publicly, so anything not on that list (paths, cwd, hostnames, raw records) is
|
|
535
|
+
* dropped by construction rather than by review.
|
|
536
|
+
*
|
|
537
|
+
* Every VALUE is checked against `SAFE_VALUE_RE` here, at the write, rather than
|
|
538
|
+
* inherited from `rewrite()`. That inheritance was the defect: `rewrite()` only
|
|
539
|
+
* ever sees a metric that HAS a `data-metric` span, and three of the thirteen
|
|
540
|
+
* (`rules`, `rules-generated`, `blocked-commands` — measured 2026-08-19: 10 ids
|
|
541
|
+
* carry a span, 13 exist) have none. Their values reached this public file
|
|
542
|
+
* unvetted, while the header above promises "no paths, no hostnames, no cwd" for
|
|
543
|
+
* the WHOLE file. All three are counters today, so this is defence in depth and
|
|
544
|
+
* not a live hole — but the promise is made per file, so the check belongs per
|
|
545
|
+
* file, not per span.
|
|
546
|
+
*
|
|
547
|
+
* A violation refuses the ENTIRE write rather than dropping the offending key: a
|
|
548
|
+
* census missing one id is a snapshot that silently stops answering that metric
|
|
549
|
+
* in a fresh clone, which is the quiet-failure shape this file exists to end.
|
|
550
|
+
*
|
|
551
|
+
* @returns {{ ok: true } | { ok: false, reason: string, error?: string }}
|
|
552
|
+
*/
|
|
553
|
+
export function writeCensusSnapshot(root, values) {
|
|
554
|
+
const metrics = {};
|
|
555
|
+
const unsafe = [];
|
|
556
|
+
for (const id of METRIC_IDS) {
|
|
557
|
+
if (!Object.hasOwn(values, id)) continue;
|
|
558
|
+
const v = String(values[id]);
|
|
559
|
+
if (!SAFE_VALUE_RE.test(v)) {
|
|
560
|
+
unsafe.push(id);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
metrics[id] = v;
|
|
564
|
+
}
|
|
565
|
+
if (unsafe.length > 0) {
|
|
566
|
+
return {
|
|
567
|
+
ok: false,
|
|
568
|
+
reason: 'unsafe-value',
|
|
569
|
+
error:
|
|
570
|
+
`value(s) rejected by the safe-value allowlist: ${unsafe.join(', ')} — ` +
|
|
571
|
+
'refusing to publish them in a served file',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return writeJsonAtomicSync(censusPath(root), { $schema: CENSUS_SCHEMA, metrics }, { tmpPrefix: '.tmp-census' });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Compute every metric for `root`.
|
|
579
|
+
*
|
|
580
|
+
* @returns {{values: Record<string,string>, missing: string[], warnings: string[],
|
|
581
|
+
* fromSnapshot: string[]}}
|
|
582
|
+
* `missing` lists metrics whose census input is absent — the caller MUST treat
|
|
583
|
+
* a non-empty `missing` as a tool error rather than writing a partial page.
|
|
584
|
+
* `fromSnapshot` lists the metrics answered by `site/_census.json` because
|
|
585
|
+
* their live source was absent.
|
|
586
|
+
*/
|
|
587
|
+
export function collect(root) {
|
|
588
|
+
const values = {};
|
|
589
|
+
const missing = [];
|
|
590
|
+
const warnings = [];
|
|
591
|
+
const fromSnapshot = [];
|
|
592
|
+
// Read at most once, and only if some metric actually needs it — the common
|
|
593
|
+
// case (a working copy) never touches the file at all.
|
|
594
|
+
let snapshot;
|
|
595
|
+
|
|
596
|
+
for (const def of METRIC_DEFS) {
|
|
597
|
+
// LIVE FIRST, always. The snapshot is a fallback, never a cache: reading it
|
|
598
|
+
// first (or memoising a live value into it) would freeze the page on the
|
|
599
|
+
// last release's numbers while the repository moved on.
|
|
600
|
+
let v = def.compute(root);
|
|
601
|
+
if ((v === null || v === undefined || v === '') && def.snapshotFallback === true) {
|
|
602
|
+
if (snapshot === undefined) snapshot = readCensusSnapshot(root);
|
|
603
|
+
const s = snapshot?.[def.id];
|
|
604
|
+
if (s !== undefined) {
|
|
605
|
+
v = s;
|
|
606
|
+
fromSnapshot.push(def.id);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (v === null || v === undefined || v === '') missing.push(def.id);
|
|
610
|
+
else values[def.id] = String(v);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (fromSnapshot.length > 0) {
|
|
614
|
+
warnings.push(
|
|
615
|
+
`${fromSnapshot.join(', ')} read from ${CENSUS_FILE.join('/')} — the live source is absent under ${root} ` +
|
|
616
|
+
'(expected in a fresh clone / tarball build; the snapshot is only as current as the last --write)',
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
for (const [name, file] of [
|
|
621
|
+
['sessions', 'sessions.jsonl'],
|
|
622
|
+
['learnings', 'learnings.jsonl'],
|
|
623
|
+
]) {
|
|
624
|
+
const r = countJsonlEntries(join(root, '.orchestrator', 'metrics', file));
|
|
625
|
+
if (r && r.malformed > 0) {
|
|
626
|
+
warnings.push(`${file}: ${r.malformed} non-empty line(s) are not valid JSON — the ${name} count includes them`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return { values, missing, warnings, fromSnapshot };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Markup
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Matches `<span … data-metric="…" …>content</span>`.
|
|
639
|
+
*
|
|
640
|
+
* Groups: 1 = attrs before, 2 = quote char, 3 = metric id, 4 = attrs after,
|
|
641
|
+
* 5 = text content. Capturing the attribute halves verbatim is what lets
|
|
642
|
+
* `rewrite()` replace ONLY the content and hand the surrounding markup back
|
|
643
|
+
* byte-for-byte.
|
|
644
|
+
*
|
|
645
|
+
* Assumption, named: no `>` inside an attribute value on these spans, and no
|
|
646
|
+
* nested element inside the cell. A cell containing `<` is reported as
|
|
647
|
+
* `malformed` rather than silently rewritten.
|
|
648
|
+
*/
|
|
649
|
+
export const SPAN_RE =
|
|
650
|
+
/<span\b([^>]*?)\bdata-metric\s*=\s*(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/span>/gi;
|
|
651
|
+
|
|
652
|
+
function lineOf(html, index) {
|
|
653
|
+
let n = 1;
|
|
654
|
+
for (let i = 0; i < index; i++) if (html.charCodeAt(i) === 10) n++;
|
|
655
|
+
return n;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Read every metric span out of one HTML document and judge it against `values`.
|
|
660
|
+
*
|
|
661
|
+
* `unresolved` is the third contract violation, next to `!known` and `malformed`:
|
|
662
|
+
* a KNOWN metric id for which `values` carries nothing. It used to be the
|
|
663
|
+
* quietest defect in the file — `expected` fell to `null`, so `differs` was
|
|
664
|
+
* false, `rewrite()` handed the cell back byte-identical without counting it,
|
|
665
|
+
* and `--check` printed "N metric cell(s) current" over a number nobody had
|
|
666
|
+
* measured. It was unreachable through `main()` only because the exit-2
|
|
667
|
+
* partial-census guard fired first; now that three metrics may be answered from
|
|
668
|
+
* a snapshot instead, that guard is no longer the only thing standing between a
|
|
669
|
+
* gap in `values` and a green report. So it is named and counted here.
|
|
670
|
+
*
|
|
671
|
+
* @returns {Array<{metric:string, actual:string, expected:string|null, line:number,
|
|
672
|
+
* known:boolean, malformed:boolean, unresolved:boolean,
|
|
673
|
+
* drift:boolean, stale:boolean}>}
|
|
674
|
+
*/
|
|
675
|
+
export function inspectHtml(html, values) {
|
|
676
|
+
const out = [];
|
|
677
|
+
for (const m of html.matchAll(SPAN_RE)) {
|
|
678
|
+
const metric = m[3];
|
|
679
|
+
const actual = m[5];
|
|
680
|
+
const def = METRIC_DEFS.find((d) => d.id === metric);
|
|
681
|
+
const known = def !== undefined;
|
|
682
|
+
const malformed = /[<>]/.test(actual);
|
|
683
|
+
const resolved = known && Object.hasOwn(values, metric);
|
|
684
|
+
const expected = resolved ? values[metric] : null;
|
|
685
|
+
const differs = known && !malformed && expected !== null && actual.trim() !== expected;
|
|
686
|
+
out.push({
|
|
687
|
+
metric,
|
|
688
|
+
actual,
|
|
689
|
+
expected,
|
|
690
|
+
line: lineOf(html, m.index),
|
|
691
|
+
known,
|
|
692
|
+
malformed,
|
|
693
|
+
unresolved: known && !resolved,
|
|
694
|
+
// A provenance stamp that lags is NOT drift — see below.
|
|
695
|
+
drift: differs && !(def && def.provenance),
|
|
696
|
+
stale: differs && Boolean(def && def.provenance),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* WHY `counted-at` / `counted-sha` are warn-only under --check:
|
|
704
|
+
*
|
|
705
|
+
* They are claims about the PAST ("counted on X at Y"), and a claim about the
|
|
706
|
+
* past does not become false when HEAD moves. Treating them as drift would make
|
|
707
|
+
* `--check` exit 1 on every single commit — a signal that is red always is a
|
|
708
|
+
* signal nobody reads, and this script exists precisely because an ignored
|
|
709
|
+
* measurement rots. They cannot silently diverge from the numbers either, since
|
|
710
|
+
* `--write` stamps all ten together in one pass.
|
|
711
|
+
*
|
|
712
|
+
* Revisit trigger: if the page ever ships values that were NOT produced by
|
|
713
|
+
* `--write` (a hand edit of a count), the stamp becomes a real lie and this must
|
|
714
|
+
* become a hard failure.
|
|
715
|
+
*/
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Rewrite every KNOWN, well-formed metric span to its computed value.
|
|
719
|
+
* The value allowlist it enforces is `SAFE_VALUE_RE`, declared above the census
|
|
720
|
+
* functions because `readCensusSnapshot()` filters through the same one.
|
|
721
|
+
*
|
|
722
|
+
* @returns {{html:string, replaced:number, spans:number, rejected:number}}
|
|
723
|
+
*/
|
|
724
|
+
export function rewrite(html, values) {
|
|
725
|
+
let replaced = 0;
|
|
726
|
+
let spans = 0;
|
|
727
|
+
let rejected = 0;
|
|
728
|
+
const next = html.replace(SPAN_RE, (whole, pre, q, metric, post, content) => {
|
|
729
|
+
spans++;
|
|
730
|
+
// No computed value for this id. TWO distinct cases reach here, and the
|
|
731
|
+
// caller errors out on BOTH: an id outside METRIC_DEFS (`known: false`) and
|
|
732
|
+
// a known id absent from `values` (`unresolved: true`). The second used to
|
|
733
|
+
// be undocumented here, which read as if it could not happen.
|
|
734
|
+
if (!Object.hasOwn(values, metric)) return whole;
|
|
735
|
+
if (/[<>]/.test(content)) return whole; // malformed cell → caller errors out
|
|
736
|
+
const value = values[metric];
|
|
737
|
+
if (!SAFE_VALUE_RE.test(value)) {
|
|
738
|
+
rejected++;
|
|
739
|
+
return whole; // caller errors out — never write an unvetted value
|
|
740
|
+
}
|
|
741
|
+
if (content === value) return whole;
|
|
742
|
+
replaced++;
|
|
743
|
+
return `<span${pre}data-metric=${q}${metric}${q}${post}>${value}</span>`;
|
|
744
|
+
});
|
|
745
|
+
return { html: next, replaced, spans, rejected };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Every `*.html` below `dir`, repo-relative-sorted for deterministic output. */
|
|
749
|
+
export function listHtmlFiles(dir) {
|
|
750
|
+
if (!isDir(dir)) return null;
|
|
751
|
+
const files = [];
|
|
752
|
+
walk(dir, (f) => {
|
|
753
|
+
if (f.endsWith('.html')) files.push(f);
|
|
754
|
+
});
|
|
755
|
+
return files.sort();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
// CLI
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
export function parseArgs(argv) {
|
|
763
|
+
const KNOWN = new Set(['--check', '--write', '--json', '--site', '--help', '--version']);
|
|
764
|
+
const positionals = [];
|
|
765
|
+
let check = false;
|
|
766
|
+
let write = false;
|
|
767
|
+
let json = false;
|
|
768
|
+
let help = false;
|
|
769
|
+
let version = false;
|
|
770
|
+
let site = null;
|
|
771
|
+
|
|
772
|
+
for (let i = 0; i < argv.length; i++) {
|
|
773
|
+
const a = argv[i];
|
|
774
|
+
if (!a.startsWith('--')) {
|
|
775
|
+
positionals.push(a);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (!KNOWN.has(a)) return { error: `Unknown flag: ${a}` };
|
|
779
|
+
if (a === '--check') check = true;
|
|
780
|
+
else if (a === '--write') write = true;
|
|
781
|
+
else if (a === '--json') json = true;
|
|
782
|
+
else if (a === '--help') help = true;
|
|
783
|
+
else if (a === '--version') version = true;
|
|
784
|
+
else if (a === '--site') {
|
|
785
|
+
site = argv[++i];
|
|
786
|
+
if (!site || site.startsWith('--')) return { error: '--site requires a directory path' };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (check && write) return { error: '--check and --write are mutually exclusive' };
|
|
790
|
+
if (positionals.length > 1) return { error: 'at most one positional <repo-root> is accepted' };
|
|
791
|
+
// Read-only is the safe default: a bare invocation never edits the site.
|
|
792
|
+
return { check: check || !write, write, json, help, version, site, root: positionals[0] };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function printHelp() {
|
|
796
|
+
writeStdoutLineSync(USAGE);
|
|
797
|
+
writeStdoutLineSync('');
|
|
798
|
+
writeStdoutLineSync('Fills the website\'s measured numbers from the repository instead of by hand.');
|
|
799
|
+
writeStdoutLineSync('Reads/writes <span data-metric="…">…</span> cells in every *.html under site/.');
|
|
800
|
+
writeStdoutLineSync('');
|
|
801
|
+
writeStdoutLineSync(' --check (default) report drift, change nothing; exit 1 on drift');
|
|
802
|
+
writeStdoutLineSync(` --write rewrite the cells in place; also refreshes ${CENSUS_FILE.join('/')}`);
|
|
803
|
+
writeStdoutLineSync(' (skipped when --site points outside the repo\'s own site/)');
|
|
804
|
+
writeStdoutLineSync(' --json machine-readable envelope on stdout');
|
|
805
|
+
writeStdoutLineSync(' --site <dir> site directory (default <repo-root>/site)');
|
|
806
|
+
writeStdoutLineSync(' --version print the package version');
|
|
807
|
+
writeStdoutLineSync('');
|
|
808
|
+
writeStdoutLineSync(` metrics: ${METRIC_IDS.join(', ')}`);
|
|
809
|
+
writeStdoutLineSync('');
|
|
810
|
+
for (const d of METRIC_DEFS) writeStdoutLineSync(` ${d.id.padEnd(12)} ${d.source}`);
|
|
811
|
+
writeStdoutLineSync('');
|
|
812
|
+
writeStdoutLineSync('Exit: 0 ok · 1 drift or contract violation · 2 tool error');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function main(argv = process.argv.slice(2), env = {}) {
|
|
816
|
+
const stdout = env.stdout ?? writeStdoutLineSync;
|
|
817
|
+
const stderr = env.stderr ?? ((s) => process.stderr.write(`${s}\n`));
|
|
818
|
+
|
|
819
|
+
const args = parseArgs(argv);
|
|
820
|
+
if (args.error) {
|
|
821
|
+
stderr(`Error: ${args.error}`);
|
|
822
|
+
stderr(USAGE);
|
|
823
|
+
return 2;
|
|
824
|
+
}
|
|
825
|
+
if (args.help) {
|
|
826
|
+
printHelp();
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const root = resolve(args.root ?? process.cwd());
|
|
831
|
+
if (!existsSync(root)) {
|
|
832
|
+
stderr(`Error: repo root does not exist: ${root}`);
|
|
833
|
+
return 2;
|
|
834
|
+
}
|
|
835
|
+
if (args.version) {
|
|
836
|
+
const v = readPackageVersion(root);
|
|
837
|
+
if (v === null) {
|
|
838
|
+
stderr(`Error: no readable package.json under ${root}`);
|
|
839
|
+
return 2;
|
|
840
|
+
}
|
|
841
|
+
stdout(v);
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const siteDir = resolve(args.site ?? join(root, 'site'));
|
|
846
|
+
const files = listHtmlFiles(siteDir);
|
|
847
|
+
if (files === null) {
|
|
848
|
+
stderr(`Error: site directory does not exist: ${siteDir}`);
|
|
849
|
+
return 2;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const { values, missing, warnings, fromSnapshot } = collect(root);
|
|
853
|
+
if (missing.length > 0) {
|
|
854
|
+
stderr(
|
|
855
|
+
`Error: could not measure ${missing.join(', ')} under ${root} — ` +
|
|
856
|
+
'refusing to publish a partial census (is this the repository root?)',
|
|
857
|
+
);
|
|
858
|
+
return 2;
|
|
859
|
+
}
|
|
860
|
+
for (const w of warnings) stderr(`WARN: ${w}`);
|
|
861
|
+
|
|
862
|
+
const dirty = isDirty(root);
|
|
863
|
+
if (args.write && dirty) {
|
|
864
|
+
stderr(
|
|
865
|
+
`WARN: working tree is dirty — counted-sha ${values['counted-sha']} will not reproduce these numbers`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const report = [];
|
|
870
|
+
let spanTotal = 0;
|
|
871
|
+
let driftTotal = 0;
|
|
872
|
+
let contractTotal = 0;
|
|
873
|
+
let writtenTotal = 0;
|
|
874
|
+
// Values the safe-value allowlist refused. A rejection is a hard failure:
|
|
875
|
+
// the source producing it is corrupt, and writing the rest would be a
|
|
876
|
+
// partial write reported as success.
|
|
877
|
+
let rejectedTotal = 0;
|
|
878
|
+
|
|
879
|
+
for (const abs of files) {
|
|
880
|
+
// Repo-relative when the file is inside the repo; absolute otherwise (a
|
|
881
|
+
// `--site` fixture in $TMPDIR would otherwise render as ../../../../var/...).
|
|
882
|
+
const r = relative(root, abs);
|
|
883
|
+
const rel = r && !r.startsWith('..') ? r : abs;
|
|
884
|
+
const html = readFileSync(abs, 'utf8');
|
|
885
|
+
const spans = inspectHtml(html, values);
|
|
886
|
+
spanTotal += spans.length;
|
|
887
|
+
|
|
888
|
+
const drift = spans.filter((s) => s.drift);
|
|
889
|
+
const stale = spans.filter((s) => s.stale);
|
|
890
|
+
const unknown = spans.filter((s) => !s.known);
|
|
891
|
+
const malformed = spans.filter((s) => s.known && s.malformed);
|
|
892
|
+
const unresolved = spans.filter((s) => s.unresolved);
|
|
893
|
+
driftTotal += drift.length;
|
|
894
|
+
contractTotal += unknown.length + malformed.length + unresolved.length;
|
|
895
|
+
|
|
896
|
+
let written = 0;
|
|
897
|
+
if (
|
|
898
|
+
args.write &&
|
|
899
|
+
unknown.length === 0 &&
|
|
900
|
+
malformed.length === 0 &&
|
|
901
|
+
unresolved.length === 0 &&
|
|
902
|
+
spans.length > 0
|
|
903
|
+
) {
|
|
904
|
+
const res = rewrite(html, values);
|
|
905
|
+
if (res.rejected > 0) {
|
|
906
|
+
// A value that fails SAFE_VALUE_RE is a corrupt or hostile source, not
|
|
907
|
+
// a formatting nit. Refuse the whole file rather than write the subset
|
|
908
|
+
// that happened to pass — a partial write is the silent-failure shape.
|
|
909
|
+
stderr(
|
|
910
|
+
`Error: ${res.rejected} computed value(s) rejected by the safe-value allowlist in ${rel} — refusing to write. Check the version literal in package.json.`,
|
|
911
|
+
);
|
|
912
|
+
rejectedTotal += res.rejected;
|
|
913
|
+
} else if (res.replaced > 0) {
|
|
914
|
+
writeFileSync(abs, res.html, 'utf8');
|
|
915
|
+
written = res.replaced;
|
|
916
|
+
writtenTotal += res.replaced;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
report.push({
|
|
921
|
+
file: rel,
|
|
922
|
+
spans: spans.length,
|
|
923
|
+
drift: drift.map((s) => ({ metric: s.metric, line: s.line, actual: s.actual, expected: s.expected })),
|
|
924
|
+
stale: stale.map((s) => ({ metric: s.metric, line: s.line, actual: s.actual, expected: s.expected })),
|
|
925
|
+
unknown: unknown.map((s) => ({ metric: s.metric, line: s.line })),
|
|
926
|
+
malformed: malformed.map((s) => ({ metric: s.metric, line: s.line })),
|
|
927
|
+
unresolved: unresolved.map((s) => ({ metric: s.metric, line: s.line })),
|
|
928
|
+
written,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// The named silent-failure class: a generator that matches nothing, changes
|
|
933
|
+
// nothing, and reports success. Zero spans means the markup contract is not in
|
|
934
|
+
// the page — that is a defect, in BOTH modes, never a no-op.
|
|
935
|
+
const noSpans = spanTotal === 0;
|
|
936
|
+
if (noSpans) {
|
|
937
|
+
stderr(
|
|
938
|
+
`Error: no <span data-metric="…"> cells found in ${files.length} HTML file(s) under ${siteDir} — ` +
|
|
939
|
+
`the markup contract is missing (expected one of: ${METRIC_IDS.join(', ')})`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
for (const f of report) {
|
|
943
|
+
for (const u of f.unknown) {
|
|
944
|
+
stderr(
|
|
945
|
+
`Error: ${f.file}:${u.line}: unknown data-metric "${u.metric}" — allowed: ${METRIC_IDS.join(', ')}`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
for (const m of f.malformed) {
|
|
949
|
+
stderr(`Error: ${f.file}:${m.line}: data-metric "${m.metric}" cell contains markup, not a plain value`);
|
|
950
|
+
}
|
|
951
|
+
for (const u of f.unresolved) {
|
|
952
|
+
stderr(
|
|
953
|
+
`Error: ${f.file}:${u.line}: data-metric "${u.metric}" is a known metric with no computed value — ` +
|
|
954
|
+
'the cell would keep its hand-maintained number while this run reported it as current',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Under --write, remaining drift is not a failure — it was just written. Under
|
|
960
|
+
// --check it is the whole point. A contract violation fails in either mode.
|
|
961
|
+
const ok = !noSpans && contractTotal === 0 && rejectedTotal === 0 && (args.write || driftTotal === 0);
|
|
962
|
+
const exitCode = ok ? 0 : 1;
|
|
963
|
+
|
|
964
|
+
// The census snapshot rides on the SAME `values` as the cells above — never a
|
|
965
|
+
// second collect(), which could disagree with the first across midnight or a
|
|
966
|
+
// concurrent ledger append (see writeCensusSnapshot).
|
|
967
|
+
//
|
|
968
|
+
// Root-anchored, and written only when the site directory IS the repo's own:
|
|
969
|
+
// `collect()` reads the snapshot from `<root>/site/` regardless of `--site`,
|
|
970
|
+
// so writing it on a `--site <tmpdir>` fixture run would reach back into the
|
|
971
|
+
// real `site/` from a test — dirtying the working copy and tripping the
|
|
972
|
+
// release preflight's clean-tree check. A fixture run is a read of the repo,
|
|
973
|
+
// never a write to it.
|
|
974
|
+
let censusWritten = false;
|
|
975
|
+
const writesCensus = args.write && resolve(siteDir) === resolve(join(root, 'site'));
|
|
976
|
+
if (writesCensus && ok) {
|
|
977
|
+
const res = writeCensusSnapshot(root, values);
|
|
978
|
+
if (res.ok) {
|
|
979
|
+
censusWritten = true;
|
|
980
|
+
} else {
|
|
981
|
+
stderr(`Error: could not write ${CENSUS_FILE.join('/')}: ${res.error ?? res.reason}`);
|
|
982
|
+
return 2;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (args.json) {
|
|
987
|
+
stdout(
|
|
988
|
+
JSON.stringify(
|
|
989
|
+
{
|
|
990
|
+
schema: SCHEMA,
|
|
991
|
+
mode: args.write ? 'write' : 'check',
|
|
992
|
+
root,
|
|
993
|
+
siteDir,
|
|
994
|
+
ref: values['counted-sha'],
|
|
995
|
+
dirty,
|
|
996
|
+
metrics: METRIC_DEFS.map((d) => ({
|
|
997
|
+
metric: d.id,
|
|
998
|
+
value: values[d.id],
|
|
999
|
+
source: d.source,
|
|
1000
|
+
provenance: d.provenance,
|
|
1001
|
+
snapshotFallback: d.snapshotFallback === true,
|
|
1002
|
+
fromSnapshot: fromSnapshot.includes(d.id),
|
|
1003
|
+
})),
|
|
1004
|
+
files: report,
|
|
1005
|
+
spanCount: spanTotal,
|
|
1006
|
+
driftCount: driftTotal,
|
|
1007
|
+
contractViolations: contractTotal,
|
|
1008
|
+
written: writtenTotal,
|
|
1009
|
+
rejected: rejectedTotal,
|
|
1010
|
+
fromSnapshot,
|
|
1011
|
+
censusWritten,
|
|
1012
|
+
ok,
|
|
1013
|
+
},
|
|
1014
|
+
null,
|
|
1015
|
+
2,
|
|
1016
|
+
),
|
|
1017
|
+
);
|
|
1018
|
+
} else if (args.write) {
|
|
1019
|
+
stdout(
|
|
1020
|
+
`site-numbers: wrote ${writtenTotal} value(s) across ${files.length} file(s) in ${siteDir}` +
|
|
1021
|
+
` (${spanTotal} metric cells @ ${values['counted-sha']}${dirty ? '+dirty' : ''})` +
|
|
1022
|
+
(censusWritten ? ` + ${CENSUS_FILE.join('/')}` : ''),
|
|
1023
|
+
);
|
|
1024
|
+
} else {
|
|
1025
|
+
for (const f of report) {
|
|
1026
|
+
for (const d of f.drift) {
|
|
1027
|
+
stdout(`DRIFT ${f.file}:${d.line} ${d.metric}: page says "${d.actual}", repo says "${d.expected}"`);
|
|
1028
|
+
}
|
|
1029
|
+
for (const s of f.stale) {
|
|
1030
|
+
stderr(`stale ${f.file}:${s.line} ${s.metric}: "${s.actual}" → would become "${s.expected}" on --write`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
stdout(
|
|
1034
|
+
driftTotal === 0 && !noSpans && contractTotal === 0
|
|
1035
|
+
? `site-numbers: ${spanTotal} metric cell(s) current across ${files.length} file(s)`
|
|
1036
|
+
: `site-numbers: ${driftTotal} drifted / ${contractTotal} contract violation(s) in ${spanTotal} cell(s)`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return exitCode;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const isMain =
|
|
1044
|
+
typeof process !== 'undefined' &&
|
|
1045
|
+
process.argv[1] !== null &&
|
|
1046
|
+
process.argv[1] !== undefined &&
|
|
1047
|
+
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
1048
|
+
|
|
1049
|
+
if (isMain) process.exit(main());
|