@event4u/agent-config 14.16.0 → 14.18.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 (93) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +177 -213
  5. package/README.md +2 -2
  6. package/dist/agent-src/commands/analyze/inbox.md +172 -6
  7. package/dist/agent-src/commands/fix/commit-messages.md +336 -0
  8. package/dist/agent-src/commands/fix.md +5 -3
  9. package/dist/agent-src/commands/pr/merge.md +35 -44
  10. package/dist/agent-src/rules/commit-conventions.md +1 -1
  11. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  12. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  13. package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
  14. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  15. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  16. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
  17. package/dist/agent-src/skills/design-review/SKILL.md +2 -0
  18. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  19. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  20. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  21. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  22. package/dist/agent-src/skills/terraform/SKILL.md +29 -0
  23. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  24. package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
  25. package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
  26. package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
  27. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
  28. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  29. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  30. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  31. package/dist/cli-delegate/cmd_conformance.js +335 -659
  32. package/dist/cli-delegate/cmd_explain.js +24 -112
  33. package/dist/discovery/deprecation-report.md +1 -1
  34. package/dist/discovery/discovery-manifest.json +55 -29
  35. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  36. package/dist/discovery/discovery-manifest.summary.md +3 -3
  37. package/dist/discovery/dormancy-report.md +1 -1
  38. package/dist/discovery/orphan-report.md +1 -1
  39. package/dist/discovery/packs.json +6 -5
  40. package/dist/discovery/trust-report.md +2 -2
  41. package/dist/discovery/workspaces.json +5 -4
  42. package/dist/hooks/dispatch.js +959 -1304
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/dist/mcp/server.json +2 -2
  45. package/dist/mcp/server.mjs +671 -1002
  46. package/dist/shared/settingsConsent.js +1 -1
  47. package/package.json +1 -1
  48. package/src/config/conformance-claim-baseline.json +9 -0
  49. package/src/config/gate-violation-baselines.json +3 -3
  50. package/src/config/release-gate-locality.yml +129 -0
  51. package/src/config/web-launch-readiness.json +1 -1
  52. package/src/scripts/_cli/explain_last/index.ts +1 -1
  53. package/src/scripts/_cli/explain_last/memory.ts +8 -104
  54. package/src/scripts/_lib/release_highlights.ts +172 -1
  55. package/src/scripts/_lib/release_material.ts +66 -0
  56. package/src/scripts/_lib/repeated_run.ts +77 -0
  57. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  58. package/src/scripts/_lib/unified_diff.ts +121 -0
  59. package/src/scripts/check_artefact_count_messaging.ts +9 -0
  60. package/src/scripts/check_finding_dispositions.ts +179 -3
  61. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  62. package/src/scripts/check_release_highlights.ts +89 -4
  63. package/src/scripts/check_suppression_hygiene.ts +11 -0
  64. package/src/scripts/check_web_launch_readiness.ts +295 -15
  65. package/src/scripts/code_graph/build.ts +230 -33
  66. package/src/scripts/code_graph/extract.ts +168 -33
  67. package/src/scripts/code_graph/types.ts +37 -2
  68. package/src/scripts/code_graph/validate.ts +12 -1
  69. package/src/scripts/conformance_scan.ts +2 -2
  70. package/src/scripts/detect_silent_catch.ts +403 -0
  71. package/src/scripts/detect_verification_tampering.ts +538 -0
  72. package/src/scripts/git_authorization_hook.ts +153 -17
  73. package/src/scripts/hook_manifest.json +1 -1
  74. package/src/scripts/hook_manifest.yaml +33 -19
  75. package/src/scripts/hooks/block_no_verify.ts +3 -3
  76. package/src/scripts/hooks/concern_registry.ts +2 -2
  77. package/src/scripts/hooks/evidence_independence.ts +21 -2
  78. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
  79. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  80. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  81. package/src/scripts/install-hooks.sh +134 -1
  82. package/src/scripts/language_mirror_hook.ts +27 -5
  83. package/src/scripts/measure_release_mix.ts +384 -0
  84. package/src/scripts/release.ts +31 -143
  85. package/src/scripts/release_drill.ts +71 -1
  86. package/src/scripts/release_mix_taxonomy.json +172 -0
  87. package/src/scripts/release_publication.ts +243 -3
  88. package/src/scripts/release_verify.ts +220 -0
  89. package/src/scripts/schemas/review-findings.schema.json +1 -1
  90. package/src/scripts/schemas/skill.schema.json +11 -1
  91. package/src/scripts/self_review_gate.ts +10 -1
  92. package/src/shared/settingsConsent.ts +1 -1
  93. package/src/scripts/attest_artifact.ts +0 -193
@@ -12,7 +12,25 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
12
12
  # (where $PROJECT_ROOT/.git is a file, not a dir, and a literal .git/hooks path
13
13
  # would break under `set -e`). --git-common-dir points at the main .git even from
14
14
  # a worktree; fall back to the literal path only outside a git repo.
15
- if COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
15
+ #
16
+ # AGENT_CONFIG_HOOKS_DIR overrides the destination. It exists so that
17
+ # `check_installed_hooks_fresh` can render what this installer WOULD write into
18
+ # a scratch directory and byte-compare it against what is installed — the only
19
+ # way to answer "is the installed copy stale?" without re-parsing these
20
+ # heredocs, which carry an appended block (post-merge / post-checkout) that no
21
+ # heredoc slice can reproduce. It is a test/inspection seam; a normal install
22
+ # never sets it.
23
+ if [ -n "${AGENT_CONFIG_HOOKS_DIR+set}" ]; then
24
+ # SET-BUT-EMPTY is refused rather than ignored. A caller that meant to
25
+ # redirect an inspection run and passed an empty value would otherwise fall
26
+ # through to the real .git/hooks and INSTALL over it — including over a
27
+ # pre-push that is running the inspection, which truncates it silently.
28
+ if [ -z "$AGENT_CONFIG_HOOKS_DIR" ]; then
29
+ echo "❌ AGENT_CONFIG_HOOKS_DIR is set but empty — refusing to fall back to the real .git/hooks." >&2
30
+ exit 2
31
+ fi
32
+ HOOKS_DIR="$AGENT_CONFIG_HOOKS_DIR"
33
+ elif COMMON_GIT_DIR="$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null)"; then
16
34
  case "$COMMON_GIT_DIR" in /*) : ;; *) COMMON_GIT_DIR="$PROJECT_ROOT/$COMMON_GIT_DIR" ;; esac
17
35
  HOOKS_DIR="$COMMON_GIT_DIR/hooks"
18
36
  else
@@ -20,6 +38,12 @@ else
20
38
  fi
21
39
 
22
40
  mkdir -p "$HOOKS_DIR"
41
+ # Name the destination. AGENT_CONFIG_HOOKS_DIR is a global override, and a
42
+ # stray non-empty value would otherwise redirect a real `task install-hooks`
43
+ # while it printed six "hook installed" lines that name no path at all — the
44
+ # freshness check would then report every hook missing with nothing pointing at
45
+ # the cause.
46
+ echo "→ installing git hooks into $HOOKS_DIR" >&2
23
47
 
24
48
  cat > "$HOOKS_DIR/pre-push" << 'EOF'
25
49
  #!/usr/bin/env bash
@@ -85,6 +109,96 @@ fi
85
109
 
86
110
  fail=0
87
111
 
112
+ # BASE FRESHNESS — first, and it EXITS rather than setting fail=1.
113
+ #
114
+ # The header above has promised "when this hook refuses for staleness" since the
115
+ # hook was written, and nothing below ever asked. Measured over the 50 PRs
116
+ # preceding 2026-09-04: 25 of them carried at least one `Merge branch 'main'
117
+ # into <branch>` commit (52 such commits in total), and 17 of the 36 PRs whose
118
+ # workflow runs were still readable had at least one FAILED run — the three
119
+ # workflows that failed most (Tests 18, Consistency 16, Rule Backstops 13) are
120
+ # exactly the base-relative ones: ratchets, budgets and generated counts that
121
+ # were green against the base the branch forked from and red against the base it
122
+ # merges into. A branch pushed behind its base buys a CONFLICTING PR, a red run,
123
+ # or both, and the contributor pays for it after the work looks finished.
124
+ #
125
+ # It runs FIRST because everything after it is invalidated by the merge that has
126
+ # to happen anyway: `task consistency` regenerates derived files against the
127
+ # stale base, the merge then re-dirties them, and the ~10s is spent twice. And
128
+ # it EXITS instead of accumulating into `fail`, for the same reason — there is
129
+ # no useful second finding to collect from a tree that is about to move.
130
+ #
131
+ # It never merges. `check_branch_freshness` asks the REMOTE (one `ls-remote`,
132
+ # measured 4.5s) and returns 1 only on a VERIFIED behind state; offline, in CI,
133
+ # on a detached HEAD, or standing on the base itself it returns 0 and says so.
134
+ # So an unreachable network cannot block a push, and a stale local tracking ref
135
+ # cannot fake a green.
136
+ echo "🔍 Base freshness — is this branch behind the base it will merge into?"
137
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS:-}" = "1" ]; then
138
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1"
139
+ elif [ ! -x ./scripts-run ]; then
140
+ echo "⚠️ ./scripts-run not found — skipping the freshness check for this push."
141
+ elif ! ./scripts-run src/scripts/check_branch_freshness --quiet; then
142
+ echo ""
143
+ echo " Push blocked — the branch is behind its base, and the gates you just"
144
+ echo " passed were answered against a base that no longer exists."
145
+ echo ""
146
+ echo " task push-ready # fetch → integrate the base SET → regenerate"
147
+ echo " # → verify → re-check freshness, then push"
148
+ echo " task push-ready DRY=1 # the same steps, read-only"
149
+ echo ""
150
+ echo " This hook refuses; it never merges. Bypass a genuine WIP push with"
151
+ echo " AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1."
152
+ exit 1
153
+ fi
154
+
155
+ # INSTALLED-HOOK FRESHNESS — ADVISORY, and deliberately AFTER base freshness.
156
+ #
157
+ # What it answers: are the hooks in .git/hooks still what THIS checkout's
158
+ # install-hooks.sh would write? Measured 2026-09-05: the installed pre-push here
159
+ # was 146 lines against a 189-line source body, missing the entire base-freshness
160
+ # gate above, merged five days earlier. It read as live in the source, in CI and
161
+ # in the skill documenting it, and did not run here.
162
+ #
163
+ # WHY IT DOES NOT REFUSE. The predicate is "installed == what this checkout
164
+ # renders", and .git/hooks is SHARED by every linked worktree through the common
165
+ # dir. With two worktrees whose installer bodies differ, at most one can satisfy
166
+ # it at a time and each re-install flips which — so a refusal would fire on
167
+ # ordinary parallel work, and the documented skip variable would become routine,
168
+ # which retires the gate rather than enforcing it. An AI council (claude-sonnet-4-5
169
+ # + codex-default, 2026-09-05, 2 of 2 seats) chose advisory unanimously after an
170
+ # independent review raised exactly that. Blocking returns only with a
171
+ # single-referent predicate — per-worktree hook isolation via core.hooksPath, or a
172
+ # branch-independent dispatcher installed once in the common dir.
173
+ #
174
+ # WHY AFTER BASE FRESHNESS. Being behind a base that moved the installer is the
175
+ # most common cause of a mismatch, and the repair for THAT is to merge, not to
176
+ # re-install: re-installing from a behind-base checkout writes the OLDER hook set
177
+ # over the shared directory. Base freshness exits first, so that contributor is
178
+ # told the correct thing and never reaches this notice.
179
+ echo "🔍 Installed-hook freshness — do .git/hooks still match this checkout?"
180
+ if [ "${AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH:-}" = "1" ]; then
181
+ echo "⏭️ skipped via AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH=1"
182
+ elif [ ! -x ./scripts-run ] || [ ! -d node_modules ] \
183
+ || [ ! -f src/scripts/check_installed_hooks_fresh.ts ]; then
184
+ # Three guards, and each has been a real miss. `-d node_modules`: without
185
+ # dependencies ./scripts-run cannot start tsx and the non-zero exit would
186
+ # read as staleness. `-f`: .git/hooks is shared, so this file also runs in a
187
+ # worktree standing on a branch that predates the gate, and calling a script
188
+ # that is not on that checkout would report the wrong thing.
189
+ echo "⏭️ check_installed_hooks_fresh not runnable on this checkout — skipping."
190
+ else
191
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet
192
+ hookfresh_rc=$?
193
+ # The gate's three exit codes mean three different things, and collapsing
194
+ # them reports a false cause: 0 = match or nothing installed, 1 = mismatch,
195
+ # 2 = it could not render (installer failed, unwritable TMPDIR, usage error).
196
+ if [ "$hookfresh_rc" = "2" ]; then
197
+ echo "⚠️ could not compare the installed hooks (see above) — not a staleness verdict."
198
+ fi
199
+ # Advisory in both cases: nothing here sets fail=1 or exits.
200
+ fi
201
+
88
202
  if ! command -v task >/dev/null 2>&1; then
89
203
  echo "⚠️ 'task' (go-task) not found — cannot run the consistency gate locally."
90
204
  echo " Install it (https://taskfile.dev) so pre-push can mirror the CI check;"
@@ -381,6 +495,25 @@ if [ -n "\$prev" ] && [ -n "\$new" ] && [ "\$prev" != "\$new" ]; then
381
495
  if command -v npm >/dev/null 2>&1 && [ -d node_modules ]; then
382
496
  npm run build:cli >/dev/null 2>&1 || true
383
497
  fi
498
+ # Installed-hook staleness — REPORT it here, never repair it here.
499
+ #
500
+ # This is the event that CAUSES the staleness, so it is where the news
501
+ # belongs. It is NOT where the repair belongs: linked worktrees SHARE
502
+ # .git/hooks through the common dir, so re-running the installer from a
503
+ # checkout would let one worktree silently redefine the gates every
504
+ # other worktree runs, and an older branch would reinstall older hooks
505
+ # over newer ones. An AI council (claude-sonnet-4-5 + codex-default,
506
+ # 2026-09-05, 2 of 2 seats) refused that mutation unanimously.
507
+ #
508
+ # Output goes to stderr and is NOT swallowed: the rest of this block is
509
+ # "|| true" so a pull is never blocked, and a repair notice nobody can
510
+ # see is the same silence this check exists to remove.
511
+ # The -f guard is not belt-and-braces: this hook is shared across
512
+ # linked worktrees, so it also runs on checkouts that predate the gate.
513
+ if [ -x ./scripts-run ] && [ -d node_modules ] \
514
+ && [ -f src/scripts/check_installed_hooks_fresh.ts ]; then
515
+ ./scripts-run src/scripts/check_installed_hooks_fresh --quiet >&2 || true
516
+ fi
384
517
  fi
385
518
  fi
386
519
  EOF
@@ -630,9 +630,17 @@ export function _setPinLost(consumer_root: string, session_id: string): void {
630
630
  _migrateLegacyMarker(consumer_root);
631
631
  try {
632
632
  atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
633
- } catch {
634
- // Observability only — a failed marker write costs one un-restored pin,
635
- // never a broken turn.
633
+ } catch (err) {
634
+ // The COST is unchanged and still bounded — one un-restored pin, never a
635
+ // broken turn. What changed on 2026-09-04 is that it stops being silent.
636
+ // The legacy-marker migration above documents exactly how this catch can
637
+ // become PERMANENT for a root (`EEXIST` then `ENOTDIR`, reproduced against
638
+ // Node), and a permanently dead mechanism that never says so is the failure
639
+ // the sibling swallows in `git_authorization_hook` were repaired for.
640
+ process.stderr.write(
641
+ `language-mirror: pin-lost marker write failed (${_errText(err)}) — ` +
642
+ "the pin will NOT be re-emitted after this compaction. Turn outcome unchanged.\n",
643
+ );
636
644
  }
637
645
  }
638
646
 
@@ -1124,8 +1132,16 @@ export function run(
1124
1132
  // per tool call — and never before the write it is cleaning up after.
1125
1133
  _pruneLegacyState(options.consumer_root);
1126
1134
  _pruneStaleSessions(options.consumer_root, Date.now());
1127
- } catch {
1128
- // Observability only — a failed state write never blocks the turn.
1135
+ } catch (err) {
1136
+ // Direction unchanged: the turn is never blocked. But the pin lives ONLY
1137
+ // in this file, so a failed write silently reverts the next turn to
1138
+ // re-derivation — the user re-states a language they already pinned and
1139
+ // has no way to know why. Named rather than swallowed.
1140
+ process.stderr.write(
1141
+ `language-mirror: pin-state write failed (${_errText(err)}) — ` +
1142
+ "the language pin did NOT persist and the next turn re-derives it. " +
1143
+ "This turn's notice is unchanged.\n",
1144
+ );
1129
1145
  }
1130
1146
  }
1131
1147
 
@@ -1146,6 +1162,12 @@ export function run(
1146
1162
  */
1147
1163
  export { isSyntheticPrompt };
1148
1164
 
1165
+ /** One-line, bounded rendering of a caught value for a stderr diagnostic. */
1166
+ function _errText(err: unknown): string {
1167
+ const t = err instanceof Error ? err.message : String(err);
1168
+ return t.replace(/\s+/g, " ").trim().slice(0, 200);
1169
+ }
1170
+
1149
1171
  export function main(argv?: string[]): number {
1150
1172
  const args = argv ?? process.argv.slice(2);
1151
1173
  let consumer_root = process.cwd();
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Release governance-vs-product mix — measured from changed paths, published as
4
+ * two views, with no threshold.
5
+ *
6
+ * Why this exists, and what it deliberately is not.
7
+ *
8
+ * An external reviewer asked twice for a per-PR rule: "no meta/governance
9
+ * feature without a user-facing artifact in the same PR". The AI council
10
+ * DECLINED that proposal (2026-09-04, anthropic/claude-sonnet-4-5 +
11
+ * openai/codex-default, 2 rounds, quorum 2/2, $0.00 — both seats
12
+ * subscription-authed) on two grounds: a same-PR gate measures packaging rather
13
+ * than progress, since one token consumer-file edit legitimises an otherwise
14
+ * governance-only pull request; and it rejects legitimate work by construction,
15
+ * since a CI fix, a dependency bump and an analysis round producing only
16
+ * roadmaps are all real work with no consumer surface. The decline is recorded
17
+ * in `docs/decisions/ADR-253-per-pr-user-artifact-gate-declined.md`.
18
+ *
19
+ * This script is the replacement both seats specified: classify at RELEASE
20
+ * level, publish two views, attach a response obligation, and refuse to pick a
21
+ * threshold until at least two readings exist. It therefore **enforces
22
+ * nothing about the ratio**. It measures and it publishes. The one enforced
23
+ * consequence lives in `check_release_highlights.ts` and is about the
24
+ * completeness of the written response, never about the number.
25
+ *
26
+ * The two views.
27
+ *
28
+ * - **Commit view** — exclusive counts per category, plus `mixed`, plus
29
+ * `unclassified`. A commit is classified from the files it touches.
30
+ * - **Diff view** — added/deleted lines per category over the whole range.
31
+ *
32
+ * Generated projections and lockfiles (`dist/`, `.augment/`, `.claude/`,
33
+ * `package-lock.json`, …) are removed from BOTH views by name. In this
34
+ * repository `dist/` is a byte-exact projection of `src/`, so leaving it in
35
+ * would let a mechanical regeneration dominate every reading. A commit left
36
+ * with no path after that exclusion lands in the `generated_only` diagnostic
37
+ * rather than in maintenance — otherwise the ratio would depend on whether the
38
+ * regeneration was committed separately from the source edit that caused it.
39
+ *
40
+ * Classification reads paths, never subjects.
41
+ *
42
+ * Commit subjects are mutable, inconsistently formatted, and can contradict the
43
+ * files a commit touches. Nothing in this file reads `%s`, `%b` or any other
44
+ * message field; the only git output consumed is a name list and a numstat.
45
+ * `tests/scripts/measure_release_mix.test.ts` pins that by rewriting a
46
+ * subject with `git commit --amend` and asserting the reading is byte-identical.
47
+ *
48
+ * `mixed` is never collapsed into a category.
49
+ *
50
+ * Collapsing `mixed` into product would recreate the exact gaming the declined
51
+ * rule permits. A commit touching two classified categories is `mixed` and is
52
+ * reported as its own bucket in both views.
53
+ *
54
+ * `unclassified` is separate from `mixed` and does not cause it. A commit whose
55
+ * paths are all unmatched is `unclassified`; a commit with one classified
56
+ * category plus unmatched paths keeps that category and carries
57
+ * `has_unclassified`. Conflating taxonomy uncertainty with genuine
58
+ * cross-category work would hide both.
59
+ *
60
+ * Usage.
61
+ *
62
+ * measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]
63
+ * measure_release_mix --audit # every top-level tracked path resolves
64
+ *
65
+ * Exit codes: 0 clean · 1 an unresolved path in `--audit` · 2 usage or git error.
66
+ */
67
+ import { spawnSync } from 'node:child_process';
68
+ import * as fs from 'node:fs';
69
+ import * as path from 'node:path';
70
+ import process from 'node:process';
71
+ import { fileURLToPath, pathToFileURL } from 'node:url';
72
+
73
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
74
+ export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
75
+ const TAXONOMY_PATH = path.join(_HERE, '..', 'release_mix_taxonomy.json');
76
+
77
+ export type Category = 'consumer' | 'governance' | 'maintenance' | 'unclassified';
78
+ export const CLASSIFIED: readonly Category[] = ['consumer', 'governance', 'maintenance'];
79
+
80
+ export interface Taxonomy {
81
+ taxonomy_version: string;
82
+ excluded_generated: string[];
83
+ rules: { prefix: string; category: Category }[];
84
+ governance_contracts: string[];
85
+ }
86
+
87
+ export function loadTaxonomy(file = TAXONOMY_PATH): Taxonomy {
88
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Taxonomy;
89
+ if (!raw.taxonomy_version) throw new Error('taxonomy carries no taxonomy_version');
90
+ return raw;
91
+ }
92
+
93
+ /** True when the path is a generated projection or a lockfile — dropped from both views. */
94
+ export function isExcluded(p: string, tax: Taxonomy): boolean {
95
+ return tax.excluded_generated.some((pre) => (pre.endsWith('/') ? p.startsWith(pre) : p === pre));
96
+ }
97
+
98
+ /**
99
+ * Longest-prefix-wins. A path matching no rule is `unclassified` — a reported
100
+ * state, never a silent default into some category.
101
+ */
102
+ export function classifyPath(p: string, tax: Taxonomy): Category {
103
+ return classifyPathDetailed(p, tax).category;
104
+ }
105
+
106
+ /**
107
+ * The same decision, with the rule that made it. `matched: false` is the
108
+ * failure `--audit` looks for: a path that fell through every rule, which is
109
+ * not the same thing as a path the taxonomy deliberately calls unclassified.
110
+ */
111
+ export function classifyPathDetailed(
112
+ p: string,
113
+ tax: Taxonomy,
114
+ ): { category: Category; matched: boolean; rule: string } {
115
+ if (tax.governance_contracts.includes(p)) {
116
+ return { category: 'governance', matched: true, rule: `governance_contracts:${p}` };
117
+ }
118
+ let best: { len: number; category: Category; rule: string } | null = null;
119
+ for (const rule of tax.rules) {
120
+ if (!p.startsWith(rule.prefix)) continue;
121
+ if (best === null || rule.prefix.length > best.len) {
122
+ best = { len: rule.prefix.length, category: rule.category, rule: rule.prefix };
123
+ }
124
+ }
125
+ return best === null
126
+ ? { category: 'unclassified', matched: false, rule: '' }
127
+ : { category: best.category, matched: true, rule: best.rule };
128
+ }
129
+
130
+ export interface CommitVerdict {
131
+ /** The bucket the commit lands in. `generated_only` is a diagnostic, not a view bucket. */
132
+ bucket: Category | 'mixed' | 'generated_only';
133
+ categories: Category[];
134
+ has_unclassified: boolean;
135
+ }
136
+
137
+ /** Classify ONE commit from its changed paths. Nothing here reads the subject. */
138
+ export function classifyCommit(paths: readonly string[], tax: Taxonomy): CommitVerdict {
139
+ const kept = paths.filter((p) => !isExcluded(p, tax));
140
+ if (kept.length === 0) {
141
+ return { bucket: 'generated_only', categories: [], has_unclassified: false };
142
+ }
143
+ const seen = new Set<Category>();
144
+ for (const p of kept) seen.add(classifyPath(p, tax));
145
+ const has_unclassified = seen.has('unclassified');
146
+ const classified = [...seen].filter((c) => c !== 'unclassified').sort();
147
+ if (classified.length === 0) return { bucket: 'unclassified', categories: [], has_unclassified: true };
148
+ if (classified.length === 1) return { bucket: classified[0]!, categories: classified, has_unclassified };
149
+ return { bucket: 'mixed', categories: classified, has_unclassified };
150
+ }
151
+
152
+ function git(args: string[], cwd = REPO_ROOT): string {
153
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
154
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
155
+ return r.stdout;
156
+ }
157
+
158
+ export interface Reading {
159
+ schema_version: 1;
160
+ taxonomy_version: string;
161
+ classifier_version: string;
162
+ label: string;
163
+ range: { from: string; to: string; from_sha: string; to_sha: string };
164
+ merge_handling: 'no-merges';
165
+ rename_handling: 'git-default';
166
+ commit_view: Record<string, number>;
167
+ diff_view: Record<string, { added: number; deleted: number }>;
168
+ diagnostics: {
169
+ generated_only_commits: number;
170
+ commits_with_unclassified: number;
171
+ unclassified_paths: string[];
172
+ mixed_combinations: Record<string, number>;
173
+ largest_contributors: Record<string, [string, number][]>;
174
+ };
175
+ response_obligation: { triggered: boolean; rule: string; governance_only: number; consumer_only: number };
176
+ }
177
+
178
+ /** The classifier's own version, bumped independently of the taxonomy's. */
179
+ export const CLASSIFIER_VERSION = '1.0.0';
180
+
181
+ /**
182
+ * The response obligation's trigger, stated once so the gate and the report
183
+ * cannot drift: governance-only commits STRICTLY outnumber consumer-only
184
+ * commits over the span. Strict inequality is the literal reading of the
185
+ * roadmap's own wording and both council seats named it explicitly.
186
+ */
187
+ export const OBLIGATION_RULE = 'governance-only commits > consumer-only commits';
188
+
189
+ export function measureRange(from: string, to: string, tax: Taxonomy, label = '', cwd = REPO_ROOT): Reading {
190
+ const shas = git(['log', '--no-merges', '--format=%H', `${from}..${to}`], cwd)
191
+ .split('\n')
192
+ .map((s) => s.trim())
193
+ .filter(Boolean);
194
+
195
+ const commit_view: Record<string, number> = {
196
+ consumer: 0, governance: 0, maintenance: 0, unclassified: 0, mixed: 0,
197
+ };
198
+ const mixed_combinations: Record<string, number> = {};
199
+ const unclassified_paths = new Set<string>();
200
+ let generated_only_commits = 0;
201
+ let commits_with_unclassified = 0;
202
+
203
+ for (const sha of shas) {
204
+ const paths = git(['show', '--name-only', '--format=', sha], cwd)
205
+ .split('\n').map((s) => s.trim()).filter(Boolean);
206
+ const v = classifyCommit(paths, tax);
207
+ if (v.bucket === 'generated_only') { generated_only_commits += 1; continue; }
208
+ commit_view[v.bucket] = (commit_view[v.bucket] ?? 0) + 1;
209
+ if (v.has_unclassified) commits_with_unclassified += 1;
210
+ if (v.bucket === 'mixed') {
211
+ const key = v.categories.join('+');
212
+ mixed_combinations[key] = (mixed_combinations[key] ?? 0) + 1;
213
+ }
214
+ for (const p of paths) {
215
+ if (!isExcluded(p, tax) && classifyPath(p, tax) === 'unclassified') unclassified_paths.add(p);
216
+ }
217
+ }
218
+
219
+ const diff_view: Record<string, { added: number; deleted: number }> = {
220
+ consumer: { added: 0, deleted: 0 },
221
+ governance: { added: 0, deleted: 0 },
222
+ maintenance: { added: 0, deleted: 0 },
223
+ unclassified: { added: 0, deleted: 0 },
224
+ };
225
+ const touchCount: Record<string, Record<string, number>> = {
226
+ consumer: {}, governance: {}, maintenance: {}, unclassified: {},
227
+ };
228
+
229
+ for (const line of git(['diff', '--numstat', `${from}..${to}`], cwd).split('\n')) {
230
+ const parts = line.split('\t');
231
+ if (parts.length < 3) continue;
232
+ const [addRaw, delRaw, pathRaw] = parts as [string, string, string];
233
+ // `git diff --numstat` renders a rename as `old => new`; take the new path.
234
+ const p = pathRaw.includes('=>') ? pathRaw.replace(/^.*=>\s*/, '').replace(/[{}]/g, '') : pathRaw;
235
+ if (isExcluded(p, tax)) continue;
236
+ const cat = classifyPath(p, tax);
237
+ // A binary file renders as `-`; it contributes a touch, not a line count.
238
+ diff_view[cat]!.added += addRaw === '-' ? 0 : Number(addRaw);
239
+ diff_view[cat]!.deleted += delRaw === '-' ? 0 : Number(delRaw);
240
+ const top = p.split('/').slice(0, 2).join('/');
241
+ touchCount[cat]![top] = (touchCount[cat]![top] ?? 0) + 1;
242
+ }
243
+
244
+ const largest_contributors: Record<string, [string, number][]> = {};
245
+ for (const cat of Object.keys(touchCount)) {
246
+ largest_contributors[cat] = Object.entries(touchCount[cat]!)
247
+ .sort((a, b) => b[1] - a[1])
248
+ .slice(0, 5) as [string, number][];
249
+ }
250
+
251
+ const governance_only = commit_view.governance ?? 0;
252
+ const consumer_only = commit_view.consumer ?? 0;
253
+
254
+ return {
255
+ schema_version: 1,
256
+ taxonomy_version: tax.taxonomy_version,
257
+ classifier_version: CLASSIFIER_VERSION,
258
+ label: label || `${from}..${to}`,
259
+ range: {
260
+ from, to,
261
+ from_sha: git(['rev-parse', from], cwd).trim(),
262
+ to_sha: git(['rev-parse', to], cwd).trim(),
263
+ },
264
+ merge_handling: 'no-merges',
265
+ rename_handling: 'git-default',
266
+ commit_view,
267
+ diff_view,
268
+ diagnostics: {
269
+ generated_only_commits,
270
+ commits_with_unclassified,
271
+ unclassified_paths: [...unclassified_paths].sort().slice(0, 50),
272
+ mixed_combinations,
273
+ largest_contributors,
274
+ },
275
+ response_obligation: {
276
+ triggered: governance_only > consumer_only,
277
+ rule: OBLIGATION_RULE,
278
+ governance_only,
279
+ consumer_only,
280
+ },
281
+ };
282
+ }
283
+
284
+ export function renderReading(r: Reading): string {
285
+ const cv = r.commit_view;
286
+ const lines: string[] = [];
287
+ lines.push(`release-mix · ${r.label} · taxonomy ${r.taxonomy_version} · classifier ${r.classifier_version}`);
288
+ lines.push(` range ${r.range.from_sha.slice(0, 9)}..${r.range.to_sha.slice(0, 9)} (${r.merge_handling})`);
289
+ lines.push(` commit view consumer ${cv.consumer} · governance ${cv.governance} · maintenance ${cv.maintenance} · mixed ${cv.mixed} · unclassified ${cv.unclassified}`);
290
+ for (const cat of ['consumer', 'governance', 'maintenance', 'unclassified'] as const) {
291
+ const d = r.diff_view[cat]!;
292
+ lines.push(` diff view ${cat.padEnd(13)} +${d.added} / -${d.deleted}`);
293
+ }
294
+ lines.push(` diagnostics generated-only commits ${r.diagnostics.generated_only_commits} · commits carrying unclassified paths ${r.diagnostics.commits_with_unclassified}`);
295
+ lines.push(` response owed ${r.response_obligation.triggered ? 'YES' : 'no'} (${r.response_obligation.rule}: ${r.response_obligation.governance_only} vs ${r.response_obligation.consumer_only})`);
296
+ lines.push(' NOTE a level, not a verdict — no threshold is committed to this repository.');
297
+ return lines.join('\n');
298
+ }
299
+
300
+ /**
301
+ * AC-2 — every tracked path resolves to exactly one category, or to a rule that
302
+ * says `unclassified` on purpose. A path that matches NO rule is the failure:
303
+ * it would otherwise be counted as taxonomy uncertainty when it is really a
304
+ * taxonomy hole. Reported grouped by the level at which the taxonomy actually
305
+ * decides, so `src/` shows its subdirectories rather than one aggregate row.
306
+ */
307
+ export function auditTree(tax: Taxonomy, cwd = REPO_ROOT): {
308
+ rows: { unit: string; category: Category; files: number }[];
309
+ unmatched: string[];
310
+ } {
311
+ const byUnit = new Map<string, { category: Category; files: number }>();
312
+ const unmatched: string[] = [];
313
+ for (const f of git(['ls-files'], cwd).split('\n')) {
314
+ const file = f.trim();
315
+ if (!file || isExcluded(file, tax)) continue;
316
+ const d = classifyPathDetailed(file, tax);
317
+ if (!d.matched) {
318
+ unmatched.push(file);
319
+ continue;
320
+ }
321
+ const unit = d.rule.startsWith('governance_contracts:') ? 'docs/contracts/ (exception list)' : d.rule;
322
+ const cur = byUnit.get(unit);
323
+ if (cur) cur.files += 1;
324
+ else byUnit.set(unit, { category: d.category, files: 1 });
325
+ }
326
+ const rows = [...byUnit.entries()]
327
+ .map(([unit, v]) => ({ unit, category: v.category, files: v.files }))
328
+ .sort((a, b) => a.unit.localeCompare(b.unit));
329
+ return { rows, unmatched };
330
+ }
331
+
332
+ function usage(msg: string): never {
333
+ process.stderr.write(`measure_release_mix: ${msg}\n`);
334
+ process.stderr.write('usage: measure_release_mix --from <ref> --to <ref> [--label <tag>] [--json <path>] [--quiet]\n');
335
+ process.stderr.write(' measure_release_mix --audit\n');
336
+ process.exit(2);
337
+ }
338
+
339
+ export function main(argv: readonly string[]): number {
340
+ const args = [...argv];
341
+ const flag = (name: string): string | undefined => {
342
+ const i = args.indexOf(name);
343
+ return i === -1 ? undefined : args[i + 1];
344
+ };
345
+ const tax = loadTaxonomy();
346
+
347
+ if (args.includes('--audit')) {
348
+ const { rows, unmatched } = auditTree(tax);
349
+ for (const r of rows) {
350
+ process.stdout.write(` ${r.category.padEnd(13)} ${String(r.files).padStart(5)} ${r.unit}\n`);
351
+ }
352
+ if (unmatched.length > 0) {
353
+ process.stderr.write(`\nmeasure_release_mix: ${unmatched.length} tracked path(s) match no rule:\n`);
354
+ for (const u of unmatched.slice(0, 20)) process.stderr.write(` ${u}\n`);
355
+ return 1;
356
+ }
357
+ const files = rows.reduce((n, r) => n + r.files, 0);
358
+ process.stdout.write(
359
+ `\nOK — ${files} tracked paths over ${rows.length} classification units, all resolved (taxonomy ${tax.taxonomy_version}).\n`,
360
+ );
361
+ return 0;
362
+ }
363
+
364
+ const from = flag('--from');
365
+ const to = flag('--to');
366
+ if (!from || !to) usage('--from and --to are required');
367
+ const reading = measureRange(from, to, tax, flag('--label') ?? '');
368
+ const jsonPath = flag('--json');
369
+ if (jsonPath) {
370
+ fs.mkdirSync(path.dirname(path.resolve(jsonPath)), { recursive: true });
371
+ fs.writeFileSync(path.resolve(jsonPath), `${JSON.stringify(reading, null, 2)}\n`, 'utf8');
372
+ }
373
+ if (!args.includes('--quiet')) process.stdout.write(`${renderReading(reading)}\n`);
374
+ return 0;
375
+ }
376
+
377
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
378
+ try {
379
+ process.exit(main(process.argv.slice(2)));
380
+ } catch (e) {
381
+ process.stderr.write(`measure_release_mix: ${(e as Error).message}\n`);
382
+ process.exit(2);
383
+ }
384
+ }