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.
Files changed (202) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. 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());