@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +177 -213
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +172 -6
- package/dist/agent-src/commands/fix/commit-messages.md +336 -0
- package/dist/agent-src/commands/fix.md +5 -3
- package/dist/agent-src/commands/pr/merge.md +35 -44
- package/dist/agent-src/rules/commit-conventions.md +1 -1
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
- package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
- package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
- package/dist/agent-src/skills/design-review/SKILL.md +2 -0
- package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
- package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
- package/dist/agent-src/skills/terraform/SKILL.md +29 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
- package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +335 -659
- package/dist/cli-delegate/cmd_explain.js +24 -112
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +55 -29
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +959 -1304
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +671 -1002
- package/dist/shared/settingsConsent.js +1 -1
- package/package.json +1 -1
- package/src/config/conformance-claim-baseline.json +9 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +129 -0
- package/src/config/web-launch-readiness.json +1 -1
- package/src/scripts/_cli/explain_last/index.ts +1 -1
- package/src/scripts/_cli/explain_last/memory.ts +8 -104
- package/src/scripts/_lib/release_highlights.ts +172 -1
- package/src/scripts/_lib/release_material.ts +66 -0
- package/src/scripts/_lib/repeated_run.ts +77 -0
- package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
- package/src/scripts/_lib/unified_diff.ts +121 -0
- package/src/scripts/check_artefact_count_messaging.ts +9 -0
- package/src/scripts/check_finding_dispositions.ts +179 -3
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +89 -4
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- package/src/scripts/check_web_launch_readiness.ts +295 -15
- package/src/scripts/code_graph/build.ts +230 -33
- package/src/scripts/code_graph/extract.ts +168 -33
- package/src/scripts/code_graph/types.ts +37 -2
- package/src/scripts/code_graph/validate.ts +12 -1
- package/src/scripts/conformance_scan.ts +2 -2
- package/src/scripts/detect_silent_catch.ts +403 -0
- package/src/scripts/detect_verification_tampering.ts +538 -0
- package/src/scripts/git_authorization_hook.ts +153 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +33 -19
- package/src/scripts/hooks/block_no_verify.ts +3 -3
- package/src/scripts/hooks/concern_registry.ts +2 -2
- package/src/scripts/hooks/evidence_independence.ts +21 -2
- package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
- package/src/scripts/hooks/push_settle_hook.ts +250 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
- package/src/scripts/install-hooks.sh +134 -1
- package/src/scripts/language_mirror_hook.ts +27 -5
- package/src/scripts/measure_release_mix.ts +384 -0
- package/src/scripts/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- package/src/scripts/release_mix_taxonomy.json +172 -0
- package/src/scripts/release_publication.ts +243 -3
- package/src/scripts/release_verify.ts +220 -0
- package/src/scripts/schemas/review-findings.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +11 -1
- package/src/scripts/self_review_gate.ts +10 -1
- package/src/shared/settingsConsent.ts +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
635
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|