@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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Is the INSTALLED copy of this repository's git hooks still what the installer
|
|
4
|
+
* would write?
|
|
5
|
+
*
|
|
6
|
+
* WHY. `src/scripts/install-hooks.sh` writes six hooks into the shared
|
|
7
|
+
* `.git/hooks` directory. Afterwards it runs only on the package manager's
|
|
8
|
+
* `prepare` lifecycle step (git clones only) or when a human runs
|
|
9
|
+
* `task install-hooks`. Between those two events the installed copy drifts from
|
|
10
|
+
* the source that writes it, silently and without bound. Measured in this
|
|
11
|
+
* repository on 2026-09-05, before this gate existed: the installed `pre-push`
|
|
12
|
+
* was 146 lines against a 189-line source body, and the 43 missing lines were
|
|
13
|
+
* the entire base-freshness gate merged five days earlier. In the source, in
|
|
14
|
+
* CI, and in the skill documenting it, that gate read as live. On the checkout
|
|
15
|
+
* that actually runs it, it did not exist.
|
|
16
|
+
*
|
|
17
|
+
* That is the general shape of an INSTALLED copy of a generated file: the
|
|
18
|
+
* source is gated, the copy is not, and the copy is what runs.
|
|
19
|
+
*
|
|
20
|
+
* HOW. Not by re-parsing the heredocs. `install-hooks.sh` writes `post-merge`
|
|
21
|
+
* and `post-checkout` as a heredoc PLUS an appended auto-sync block, and it
|
|
22
|
+
* interpolates the hook name into both — so no slice of the source file equals
|
|
23
|
+
* a hook body, and a comparison built on slicing would be wrong for two of six
|
|
24
|
+
* hooks and brittle for the rest. Instead the gate runs the real installer with
|
|
25
|
+
* `AGENT_CONFIG_HOOKS_DIR` pointed at a scratch directory and byte-compares
|
|
26
|
+
* what it produced against what is installed. Anything the installer does
|
|
27
|
+
* deterministically — interpolation, appends, future additions — compares equal
|
|
28
|
+
* by construction, which is what keeps this from being the too-strict check its
|
|
29
|
+
* roadmap's risk register warned about.
|
|
30
|
+
*
|
|
31
|
+
* WHERE IT IS BOUND. Two carriers, and NEITHER REFUSES. The pre-push hook body
|
|
32
|
+
* calls it after the base-freshness gate, advisory (silence it with
|
|
33
|
+
* `AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH=1`); the `post-merge` / `post-checkout`
|
|
34
|
+
* auto-sync block calls it at the event that CAUSES the staleness. The
|
|
35
|
+
* predicate is "installed == what THIS checkout renders", and `.git/hooks` is
|
|
36
|
+
* shared by every linked worktree, so it has no unique referent and a refusal
|
|
37
|
+
* would fire on ordinary parallel work — council 2026-09-05, 2 of 2 seats.
|
|
38
|
+
*
|
|
39
|
+
* WHAT THE EVENT CARRIER DOES NOT REACH. `git pull --rebase` fires neither
|
|
40
|
+
* `post-merge` nor `post-checkout` — it fires `post-rewrite`, which carries no
|
|
41
|
+
* detector. A rebase-pull that moves the installer is therefore reported at the
|
|
42
|
+
* next push and not before. Named rather than closed: `post-rewrite` also fires
|
|
43
|
+
* on every `git commit --amend`, so wiring it there trades a real gap for a
|
|
44
|
+
* notice on an operation that cannot have moved the installer.
|
|
45
|
+
*
|
|
46
|
+
* WHY NOT IN CI. Deliberately NOT in `task ci` or `task preflight`: CI cannot
|
|
47
|
+
* observe a contributor's `.git/hooks`, so a CI-registered run would scan
|
|
48
|
+
* nothing and report a green that means "no data" — the vacuous-pass class this
|
|
49
|
+
* repository audited in 2026-07. Its correctness is covered by
|
|
50
|
+
* `tests/scripts/check_installed_hooks_fresh.test.ts`, which does run in CI,
|
|
51
|
+
* and which asserts BOTH directions.
|
|
52
|
+
*
|
|
53
|
+
* EMPTY IS A REAL STATE. A checkout that never ran the installer has no managed
|
|
54
|
+
* hook to compare. That exits 0 and says so in one line, rather than pretending
|
|
55
|
+
* to have checked something.
|
|
56
|
+
*
|
|
57
|
+
* CLI contract: exit 0 = every installed managed hook matches, or none is
|
|
58
|
+
* installed; 1 = at least one is missing or differs; 2 = the installer could
|
|
59
|
+
* not be rendered (usage error, or the installer itself failed).
|
|
60
|
+
*
|
|
61
|
+
* Usage:
|
|
62
|
+
* ./scripts-run src/scripts/check_installed_hooks_fresh
|
|
63
|
+
* ./scripts-run src/scripts/check_installed_hooks_fresh --quiet
|
|
64
|
+
* ./scripts-run src/scripts/check_installed_hooks_fresh --hooks-dir <path>
|
|
65
|
+
*/
|
|
66
|
+
// ledger-exempt: this gate has one target — the installed hook set — and it
|
|
67
|
+
// reports every managed hook by name with a fingerprint on both the clean and
|
|
68
|
+
// the failing path, so per-target accounting is already the whole output.
|
|
69
|
+
|
|
70
|
+
import { spawnSync } from 'node:child_process';
|
|
71
|
+
import * as crypto from 'node:crypto';
|
|
72
|
+
import * as fs from 'node:fs';
|
|
73
|
+
import * as os from 'node:os';
|
|
74
|
+
import * as path from 'node:path';
|
|
75
|
+
import process from 'node:process';
|
|
76
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
77
|
+
|
|
78
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
79
|
+
|
|
80
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
81
|
+
const REPO = path.resolve(path.dirname(_HERE), '..', '..');
|
|
82
|
+
const INSTALLER_REL = 'src/scripts/install-hooks.sh';
|
|
83
|
+
const FIX = 'task install-hooks';
|
|
84
|
+
const SKIP_ENV = 'AGENT_CONFIG_SKIP_PREPUSH_HOOKFRESH';
|
|
85
|
+
// Both child processes run on the push path. Unbounded, a wedged `git` or an
|
|
86
|
+
// unwritable TMPDIR leaves the push hanging with no diagnostic, and the
|
|
87
|
+
// "could not render" branch is only reached if the child returns at all.
|
|
88
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
89
|
+
const INSTALLER_TIMEOUT_MS = 30_000;
|
|
90
|
+
|
|
91
|
+
export interface HookVerdict {
|
|
92
|
+
name: string;
|
|
93
|
+
/**
|
|
94
|
+
* `not-executable` is its own state rather than a flavour of `differs`:
|
|
95
|
+
* git SKIPS a hook without the executable bit, so a byte-identical file
|
|
96
|
+
* that lost +x is exactly the installed-but-inert case this gate exists
|
|
97
|
+
* to find, and it needs a different sentence than "the content drifted".
|
|
98
|
+
* `unreadable` is separated from `missing` for the same reason — EACCES
|
|
99
|
+
* and ENOENT need different fixes (#7).
|
|
100
|
+
*/
|
|
101
|
+
state: 'match' | 'differs' | 'missing' | 'not-executable' | 'unreadable';
|
|
102
|
+
expectedSha: string;
|
|
103
|
+
installedSha: string | null;
|
|
104
|
+
/** Error code when `state` is `unreadable`. */
|
|
105
|
+
readError?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface FreshnessReport {
|
|
109
|
+
hooksDir: string;
|
|
110
|
+
/** Managed hooks the installer would write. */
|
|
111
|
+
verdicts: HookVerdict[];
|
|
112
|
+
/** True when NONE of the managed hooks is installed — a fresh clone. */
|
|
113
|
+
neverInstalled: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sha(buf: Buffer): string {
|
|
117
|
+
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The hooks directory git will actually execute from.
|
|
122
|
+
*
|
|
123
|
+
* `--git-path hooks` rather than `--git-common-dir` + "/hooks": the two agree
|
|
124
|
+
* on the ordinary case AND on a linked worktree (verified here — from a
|
|
125
|
+
* worktree both resolve to the SHARED common-dir hooks, which is the property
|
|
126
|
+
* the whole shared-state argument rests on), and they diverge when
|
|
127
|
+
* `core.hooksPath` is set, where only `--git-path` follows it. Composing the
|
|
128
|
+
* path by hand would compare a directory git never runs and report a match over
|
|
129
|
+
* hooks that are unrelated — the vacuous green this gate exists to refuse.
|
|
130
|
+
*
|
|
131
|
+
* Honest limit: the `core.hooksPath` branch is git-documented and NOT exercised
|
|
132
|
+
* here. This repository's own `block-no-verify` guard refuses any command
|
|
133
|
+
* carrying `core.hooksPath`, read-only probes included, so it cannot be tested
|
|
134
|
+
* from inside a session. The worktree and default cases are verified.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveHooksDir(repoRoot: string): string {
|
|
137
|
+
const res = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--git-path', 'hooks'], {
|
|
138
|
+
encoding: 'utf-8',
|
|
139
|
+
timeout: GIT_TIMEOUT_MS,
|
|
140
|
+
});
|
|
141
|
+
const out = (res.stdout ?? '').trim();
|
|
142
|
+
if (res.status !== 0 || out === '') return path.join(repoRoot, '.git', 'hooks');
|
|
143
|
+
return path.isAbsolute(out) ? out : path.join(repoRoot, out);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render every hook the installer would write, into a scratch directory.
|
|
148
|
+
*
|
|
149
|
+
* Shells out to the real installer rather than reimplementing it: an
|
|
150
|
+
* independent renderer would be a second source of truth, and the drift between
|
|
151
|
+
* the two would be invisible in exactly the way this gate exists to catch.
|
|
152
|
+
*/
|
|
153
|
+
export function renderExpected(repoRoot: string): Map<string, Buffer> {
|
|
154
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'hookfresh-'));
|
|
155
|
+
// The seam FAILS OPEN by construction: install-hooks.sh treats an empty or
|
|
156
|
+
// absent AGENT_CONFIG_HOOKS_DIR as "install normally", so a redirect that
|
|
157
|
+
// does not take effect turns this read-only inspection into a real install
|
|
158
|
+
// over the pre-push that is currently executing it.
|
|
159
|
+
//
|
|
160
|
+
// THIS CHECK IS THE WEAKER HALF, and saying so matters on a path whose
|
|
161
|
+
// stated risk is that overwrite. It inspects `stage` — a value mkdtempSync
|
|
162
|
+
// produced two lines up — and therefore catches a caller that supplies an
|
|
163
|
+
// unusable path. It CANNOT observe whether the variable reached the child.
|
|
164
|
+
// The defence that can is the installer's own refusal of a set-but-empty
|
|
165
|
+
// value (install-hooks.sh), which is where the fall-through happens.
|
|
166
|
+
if (!path.isAbsolute(stage) || stage.trim() === '') {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`refusing to run the installer: scratch dir ${JSON.stringify(stage)} is not an absolute path, ` +
|
|
169
|
+
'and an unredirected run would overwrite the real .git/hooks.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const res = spawnSync('bash', [path.join(repoRoot, INSTALLER_REL)], {
|
|
174
|
+
cwd: repoRoot,
|
|
175
|
+
encoding: 'buffer',
|
|
176
|
+
timeout: INSTALLER_TIMEOUT_MS,
|
|
177
|
+
env: { ...process.env, AGENT_CONFIG_HOOKS_DIR: stage },
|
|
178
|
+
});
|
|
179
|
+
if (res.error !== undefined) {
|
|
180
|
+
throw new Error(`${INSTALLER_REL} did not complete: ${res.error.message}`);
|
|
181
|
+
}
|
|
182
|
+
if (res.status !== 0) {
|
|
183
|
+
const err = res.stderr ? res.stderr.toString('utf-8') : '';
|
|
184
|
+
throw new Error(`${INSTALLER_REL} exited ${String(res.status)}\n${err}`);
|
|
185
|
+
}
|
|
186
|
+
const out = new Map<string, Buffer>();
|
|
187
|
+
for (const name of fs.readdirSync(stage).sort()) {
|
|
188
|
+
out.set(name, fs.readFileSync(path.join(stage, name)));
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function inspect(repoRoot: string, hooksDirOverride?: string): FreshnessReport {
|
|
197
|
+
const hooksDir = hooksDirOverride ?? resolveHooksDir(repoRoot);
|
|
198
|
+
const expected = renderExpected(repoRoot);
|
|
199
|
+
if (expected.size === 0) {
|
|
200
|
+
// The installer produced nothing — it cannot have been read correctly.
|
|
201
|
+
throw new DeadScopeError(
|
|
202
|
+
'check_installed_hooks_fresh',
|
|
203
|
+
`${INSTALLER_REL} wrote no hooks — the installer, not the installed copy, is the problem.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const verdicts: HookVerdict[] = [];
|
|
207
|
+
for (const [name, body] of expected) {
|
|
208
|
+
const target = path.join(hooksDir, name);
|
|
209
|
+
let installed: Buffer | null = null;
|
|
210
|
+
let executable = false;
|
|
211
|
+
let readError: string | undefined;
|
|
212
|
+
try {
|
|
213
|
+
installed = fs.readFileSync(target);
|
|
214
|
+
// The installer chmod +x's every hook. Git silently skips one that
|
|
215
|
+
// is not executable, so mode is part of what "installed" means.
|
|
216
|
+
//
|
|
217
|
+
// POSIX only. Windows reports 0o666 / 0o444 and has no executable
|
|
218
|
+
// bit, so every hook would resolve `not-executable` and the gate
|
|
219
|
+
// would exit 1 forever — a permanent false red on the platform the
|
|
220
|
+
// test suite explicitly accommodates.
|
|
221
|
+
executable =
|
|
222
|
+
process.platform === 'win32' || (fs.statSync(target).mode & 0o111) !== 0;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
const code = (e as NodeJS.ErrnoException).code ?? 'UNKNOWN';
|
|
225
|
+
installed = null;
|
|
226
|
+
// ENOENT is absence; anything else is a file that exists and could
|
|
227
|
+
// not be read, which a re-install will not fix on its own.
|
|
228
|
+
if (code !== 'ENOENT') readError = code;
|
|
229
|
+
}
|
|
230
|
+
const state: HookVerdict['state'] =
|
|
231
|
+
installed === null
|
|
232
|
+
? readError === undefined
|
|
233
|
+
? 'missing'
|
|
234
|
+
: 'unreadable'
|
|
235
|
+
: !installed.equals(body)
|
|
236
|
+
? 'differs'
|
|
237
|
+
: executable
|
|
238
|
+
? 'match'
|
|
239
|
+
: 'not-executable';
|
|
240
|
+
verdicts.push({
|
|
241
|
+
name,
|
|
242
|
+
state,
|
|
243
|
+
expectedSha: sha(body),
|
|
244
|
+
installedSha: installed === null ? null : sha(installed),
|
|
245
|
+
...(readError === undefined ? {} : { readError }),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
hooksDir,
|
|
250
|
+
verdicts,
|
|
251
|
+
// "Never installed" means nothing is THERE. An unreadable hook is
|
|
252
|
+
// there, so it must not be swallowed by the fresh-clone branch.
|
|
253
|
+
neverInstalled: verdicts.every((v) => v.state === 'missing'),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function render(report: FreshnessReport, quiet: boolean, write: (s: string) => void): number {
|
|
258
|
+
// Outside the quiet guard on purpose: CI-shaped callers pass --quiet, and a
|
|
259
|
+
// gate that reports no count there reads as silent.
|
|
260
|
+
const collect = ((chunk: string | Uint8Array): boolean => {
|
|
261
|
+
write(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'));
|
|
262
|
+
return true;
|
|
263
|
+
}) as typeof process.stdout.write;
|
|
264
|
+
reportScanned(
|
|
265
|
+
{
|
|
266
|
+
gate: 'check_installed_hooks_fresh',
|
|
267
|
+
scanned: report.verdicts.length,
|
|
268
|
+
units: 'managed hook(s)',
|
|
269
|
+
roots: [INSTALLER_REL],
|
|
270
|
+
},
|
|
271
|
+
collect,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (report.neverInstalled) {
|
|
275
|
+
write(
|
|
276
|
+
`✅ check_installed_hooks_fresh: no managed hook is installed at ${report.hooksDir} — ` +
|
|
277
|
+
`nothing to compare. Run '${FIX}' to install them.\n`,
|
|
278
|
+
);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const stale = report.verdicts.filter((v) => v.state !== 'match');
|
|
283
|
+
if (stale.length === 0) {
|
|
284
|
+
if (!quiet) {
|
|
285
|
+
for (const v of report.verdicts) {
|
|
286
|
+
write(` ${v.name} ${v.expectedSha}\n`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
write(
|
|
290
|
+
`✅ check_installed_hooks_fresh: ${String(report.verdicts.length)} installed hook(s) match ${INSTALLER_REL}.\n`,
|
|
291
|
+
);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
write(`⚠️ check_installed_hooks_fresh: .git/hooks does not match this checkout.\n`);
|
|
296
|
+
write(` hooks dir: ${report.hooksDir} (shared by every linked worktree)\n`);
|
|
297
|
+
write(` worktree: ${REPO}\n`);
|
|
298
|
+
for (const v of stale) {
|
|
299
|
+
switch (v.state) {
|
|
300
|
+
case 'missing':
|
|
301
|
+
write(` ${v.name} MISSING (this checkout renders ${v.expectedSha})\n`);
|
|
302
|
+
break;
|
|
303
|
+
case 'unreadable':
|
|
304
|
+
write(` ${v.name} UNREADABLE (${String(v.readError)}) — exists but cannot be read\n`);
|
|
305
|
+
break;
|
|
306
|
+
case 'not-executable':
|
|
307
|
+
write(` ${v.name} NOT EXECUTABLE — byte-identical, and git skips it\n`);
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
write(
|
|
311
|
+
` ${v.name} installed ${String(v.installedSha)} ≠ this checkout ${v.expectedSha}\n`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Deliberately NOT "run the installer". A mismatch proves difference, never
|
|
316
|
+
// that THIS checkout is the newer side: linked worktrees share one hooks
|
|
317
|
+
// directory, so on a sibling-worktree mismatch a re-install only moves the
|
|
318
|
+
// mismatch to the other worktree, and on a behind-base checkout it writes
|
|
319
|
+
// the OLDER hook set over the shared one.
|
|
320
|
+
write(`\n A mismatch does not by itself say which side is newer.\n`);
|
|
321
|
+
write(` · behind the base? merge it first — the installer moves with it\n`);
|
|
322
|
+
write(` · sibling worktree on a different branch? re-installing only moves the mismatch\n`);
|
|
323
|
+
write(` · otherwise ${FIX}\n`);
|
|
324
|
+
write(` Gates the installed hooks are missing are inert on this checkout.\n`);
|
|
325
|
+
write(` Silence this notice with ${SKIP_ENV}=1.\n`);
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
330
|
+
let quiet = false;
|
|
331
|
+
let hooksDir: string | undefined;
|
|
332
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
333
|
+
const a = argv[i];
|
|
334
|
+
if (a === '--quiet') quiet = true;
|
|
335
|
+
else if (a === '-h' || a === '--help') {
|
|
336
|
+
process.stdout.write(
|
|
337
|
+
'usage: check_installed_hooks_fresh [--quiet] [--hooks-dir PATH]\n',
|
|
338
|
+
);
|
|
339
|
+
return 0;
|
|
340
|
+
} else if (a === '--hooks-dir') {
|
|
341
|
+
i += 1;
|
|
342
|
+
const next = argv[i];
|
|
343
|
+
// `--hooks-dir --quiet` must be a usage error, not a hooks
|
|
344
|
+
// directory literally named "--quiet" — which would report every
|
|
345
|
+
// managed hook missing and exit 1 with a staleness verdict, the
|
|
346
|
+
// exact misdiagnosis the undefined check below was written against.
|
|
347
|
+
if (next === undefined || next.startsWith('--')) {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
'check_installed_hooks_fresh: --hooks-dir needs a path, got ' +
|
|
350
|
+
`${next === undefined ? 'nothing' : JSON.stringify(next)}\n`,
|
|
351
|
+
);
|
|
352
|
+
return 2;
|
|
353
|
+
}
|
|
354
|
+
hooksDir = next;
|
|
355
|
+
} else {
|
|
356
|
+
process.stderr.write(`check_installed_hooks_fresh: unknown argument ${a}\n`);
|
|
357
|
+
return 2;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let report: FreshnessReport;
|
|
362
|
+
try {
|
|
363
|
+
report = inspect(REPO, hooksDir);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
`❌ check_installed_hooks_fresh: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
367
|
+
);
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const chunks: string[] = [];
|
|
372
|
+
const code = render(report, quiet, (s) => chunks.push(s));
|
|
373
|
+
const text = chunks.join('');
|
|
374
|
+
if (code === 0) process.stdout.write(text);
|
|
375
|
+
else process.stderr.write(text);
|
|
376
|
+
return code;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
380
|
+
process.exit(main());
|
|
381
|
+
}
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Highlight plausibility gate (release-truth Phase 2).
|
|
3
3
|
*
|
|
4
4
|
* The recorded failure: the curated release head said `_none_` twice while
|
|
5
|
-
* false — 9.13.0 shipped
|
|
5
|
+
* false — 9.13.0 shipped behavior changes, a removed public trigger type, a
|
|
6
6
|
* security fix, and honest nulls; the head claimed none of it. Same at
|
|
7
7
|
* 9.14.0.
|
|
8
8
|
*
|
|
9
9
|
* This gate derives GENERATED evidence categories from the release span
|
|
10
|
-
* (security-tagged commits,
|
|
10
|
+
* (security-tagged commits, behavior/default changes from conventional
|
|
11
11
|
* commit types + rule/schema diffs, honest-null markers, removed public
|
|
12
12
|
* surface) and FAILS when a populated generated category meets a `_none_`
|
|
13
13
|
* curated field. It blocks the contradiction only — a human still writes the
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* ## REVERSED 2026-09-01 — an unrewritten derived line now BLOCKS
|
|
24
24
|
*
|
|
25
25
|
* This header used to end the paragraph above with *"An unrewritten derived
|
|
26
|
-
* line warns; it never blocks."* That is no longer the
|
|
26
|
+
* line warns; it never blocks."* That is no longer the behavior, and the
|
|
27
27
|
* sentence is replaced rather than deleted so the reversal is legible.
|
|
28
28
|
*
|
|
29
29
|
* **The premise was never wrong; the conclusion drawn from it was.** A derived
|
|
@@ -71,9 +71,11 @@ import {
|
|
|
71
71
|
collect_span_commits,
|
|
72
72
|
derive_categories,
|
|
73
73
|
parse_git_log,
|
|
74
|
+
mix_response_blockers,
|
|
74
75
|
previous_release_tag,
|
|
75
76
|
stale_draft_labels,
|
|
76
77
|
} from './_lib/release_highlights.js';
|
|
78
|
+
import { loadTaxonomy, measureRange } from './measure_release_mix.js';
|
|
77
79
|
import {
|
|
78
80
|
CURATED_HEAD_INSTRUCTION,
|
|
79
81
|
extract_changelog_section,
|
|
@@ -149,7 +151,7 @@ export function highlight_contradictions(
|
|
|
149
151
|
* Exported so the advisory-vs-blocking decision is a fixture, not a claim.
|
|
150
152
|
*
|
|
151
153
|
* CORRECTED 2026-09-01 with the reversal. This comment used to say that
|
|
152
|
-
* `stale_draft_labels` firing while the exit code stays 0 is the
|
|
154
|
+
* `stale_draft_labels` firing while the exit code stays 0 is the behavior the
|
|
153
155
|
* cadence blocker adjudicates. That is no longer what happens — the branch at
|
|
154
156
|
* the call site returns 1 — and the sentence is replaced rather than deleted
|
|
155
157
|
* because it was the load-bearing claim on the other side.
|
|
@@ -237,6 +239,18 @@ export function main(argv: readonly string[]): number {
|
|
|
237
239
|
);
|
|
238
240
|
return 1;
|
|
239
241
|
}
|
|
242
|
+
// Hoisted above the curated-head branches on purpose. It used to sit inside
|
|
243
|
+
// `contradictions.length === 0`, so CI reached it only for a section that
|
|
244
|
+
// HAS a head, carries no unrewritten draft and contradicts nothing — while
|
|
245
|
+
// the local guards call the same predicate unconditionally. A section
|
|
246
|
+
// without a curated head was therefore refused locally and waved through by
|
|
247
|
+
// CI: one predicate, two different gatekeepers, which is the drift this
|
|
248
|
+
// change exists to remove.
|
|
249
|
+
const mix = check_governance_mix_response(section.body, from, to, version);
|
|
250
|
+
if (mix !== 0) {
|
|
251
|
+
return mix;
|
|
252
|
+
}
|
|
253
|
+
|
|
240
254
|
const curated = parse_curated_head(section.body);
|
|
241
255
|
if (!curated) {
|
|
242
256
|
process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
|
|
@@ -332,6 +346,77 @@ export function main(argv: readonly string[]): number {
|
|
|
332
346
|
return 1;
|
|
333
347
|
}
|
|
334
348
|
|
|
349
|
+
/**
|
|
350
|
+
* The governance-versus-product response, checked for PRESENCE, never for the
|
|
351
|
+
* ratio (ADR-253).
|
|
352
|
+
*
|
|
353
|
+
* `measure_release_mix` publishes a level. This is the one place that level has
|
|
354
|
+
* a consequence, and the consequence is deliberately about completeness of the
|
|
355
|
+
* written answer: when governance-only commits strictly outnumber consumer-only
|
|
356
|
+
* commits over the release span, the section under release must carry a written
|
|
357
|
+
* response naming either the next cycle's consumer work or a maintainer
|
|
358
|
+
* justification. No number is enforced, and no number can be — both council
|
|
359
|
+
* seats refused a threshold on fewer than two readings, and a gate with no
|
|
360
|
+
* threshold that blocks on the ratio is a smuggled threshold.
|
|
361
|
+
*
|
|
362
|
+
* The response lives OUTSIDE the curated head, as its own `> **Governance
|
|
363
|
+
* mix:**` line, so it does not consume the head's ten-line cap and does not
|
|
364
|
+
* become a sixth label every historical section would suddenly lack.
|
|
365
|
+
*
|
|
366
|
+
* Scope matches every other check here: only the section under release is read.
|
|
367
|
+
*
|
|
368
|
+
* A measurement failure DEGRADES to a printed warning rather than to a refusal.
|
|
369
|
+
* This is a governance signal, not a correctness or security control, and a
|
|
370
|
+
* shallow clone or a missing tag on the release path would otherwise turn an
|
|
371
|
+
* unrelated environment fact into a blocked release.
|
|
372
|
+
*/
|
|
373
|
+
export function check_governance_mix_response(
|
|
374
|
+
body: string,
|
|
375
|
+
from: string,
|
|
376
|
+
to: string,
|
|
377
|
+
version: string,
|
|
378
|
+
): number {
|
|
379
|
+
let reading;
|
|
380
|
+
try {
|
|
381
|
+
reading = measureRange(from, to, loadTaxonomy(), version, REPO_ROOT);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
process.stdout.write(
|
|
384
|
+
`⚠️ governance mix not measured for ${version}: ${(err as Error).message}\n`,
|
|
385
|
+
);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
const o = reading.response_obligation;
|
|
389
|
+
const level = `governance-only ${o.governance_only} vs consumer-only ${o.consumer_only} (taxonomy ${reading.taxonomy_version})`;
|
|
390
|
+
if (!o.triggered) {
|
|
391
|
+
process.stdout.write(`✅ governance mix for ${version}: ${level} — no response owed\n`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
// ONE predicate, shared with `guard_release_branch_push` — see
|
|
395
|
+
// `mix_response_blockers`. This side prints and exits; the local side dies
|
|
396
|
+
// before anything leaves the machine. Neither owns the rule.
|
|
397
|
+
const blockers = mix_response_blockers(body, version, `\`release/${version}\``, {
|
|
398
|
+
triggered: true,
|
|
399
|
+
level,
|
|
400
|
+
});
|
|
401
|
+
if (blockers.length === 0) {
|
|
402
|
+
process.stdout.write(`✅ governance mix for ${version}: ${level} — response present\n`);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
process.stderr.write(
|
|
406
|
+
`❌ the ${version} section owes a governance-versus-product response: ${level}.\n` +
|
|
407
|
+
blockers.map((b) => ` - ${b}\n`).join('') +
|
|
408
|
+
' Governance-only commits outnumber consumer-only commits over the release span, so\n' +
|
|
409
|
+
' the section carries a written response naming either the next cycle\'s consumer work\n' +
|
|
410
|
+
' or a maintainer justification (docs/contracts/CHANGELOG-conventions.md § Governance-\n' +
|
|
411
|
+
' versus-product response; the decline this replaces is ADR-253).\n' +
|
|
412
|
+
' Only that section is read; historical sections are out of scope. No ratio is\n' +
|
|
413
|
+
' enforced — this blocks a MISSING answer, never a particular number.\n' +
|
|
414
|
+
' Reproduce locally, before any push:\n' +
|
|
415
|
+
` ./scripts-run src/scripts/check_release_highlights --version ${version}\n`,
|
|
416
|
+
);
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
|
|
335
420
|
const _isMain = (() => {
|
|
336
421
|
const entry = process.argv[1];
|
|
337
422
|
if (!entry) return false;
|
|
@@ -260,6 +260,17 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
|
|
|
260
260
|
// mistyped path as a fresh baseline.
|
|
261
261
|
what: 'review prompts whose recorded prompt_hash does not re-derive',
|
|
262
262
|
},
|
|
263
|
+
{
|
|
264
|
+
file: 'src/config/conformance-claim-baseline.json',
|
|
265
|
+
listKey: 'undeclared',
|
|
266
|
+
tier: 'string_list',
|
|
267
|
+
// Absent at the base ref because this change introduces it. Remove the
|
|
268
|
+
// flag on the next change once the introducing commit is an ancestor of
|
|
269
|
+
// main — the scan below fails while it is set and the file resolves,
|
|
270
|
+
// so the flag closes itself rather than needing to be remembered.
|
|
271
|
+
newInThisChange: true,
|
|
272
|
+
what: 'skill and rule artefacts carrying a standard-conformance claim with no enforced_by declaration',
|
|
273
|
+
},
|
|
263
274
|
];
|
|
264
275
|
|
|
265
276
|
export interface SuppressionFinding {
|