@event4u/agent-config 14.17.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 +170 -79
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +35 -44
- 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/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- 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/testing-anti-patterns/SKILL.md +40 -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/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +15 -15
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +959 -1304
- package/dist/mcp/registry-manifest.json +2 -2
- 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/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_finding_dispositions.ts +179 -3
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +25 -13
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- 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/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -71,6 +71,7 @@ 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';
|
|
@@ -238,6 +239,18 @@ export function main(argv: readonly string[]): number {
|
|
|
238
239
|
);
|
|
239
240
|
return 1;
|
|
240
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
|
+
|
|
241
254
|
const curated = parse_curated_head(section.body);
|
|
242
255
|
if (!curated) {
|
|
243
256
|
process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
|
|
@@ -314,10 +327,6 @@ export function main(argv: readonly string[]): number {
|
|
|
314
327
|
}
|
|
315
328
|
const contradictions = highlight_contradictions(curated, derived);
|
|
316
329
|
if (contradictions.length === 0) {
|
|
317
|
-
const mix = check_governance_mix_response(section.body, from, to, version);
|
|
318
|
-
if (mix !== 0) {
|
|
319
|
-
return mix;
|
|
320
|
-
}
|
|
321
330
|
process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
|
|
322
331
|
return 0;
|
|
323
332
|
}
|
|
@@ -361,9 +370,6 @@ export function main(argv: readonly string[]): number {
|
|
|
361
370
|
* shallow clone or a missing tag on the release path would otherwise turn an
|
|
362
371
|
* unrelated environment fact into a blocked release.
|
|
363
372
|
*/
|
|
364
|
-
export const MIX_RESPONSE_MARKER = '**Governance mix:**';
|
|
365
|
-
const MIX_RESPONSE_MIN_CHARS = 40;
|
|
366
|
-
|
|
367
373
|
export function check_governance_mix_response(
|
|
368
374
|
body: string,
|
|
369
375
|
from: string,
|
|
@@ -385,22 +391,28 @@ export function check_governance_mix_response(
|
|
|
385
391
|
process.stdout.write(`✅ governance mix for ${version}: ${level} — no response owed\n`);
|
|
386
392
|
return 0;
|
|
387
393
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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) {
|
|
391
402
|
process.stdout.write(`✅ governance mix for ${version}: ${level} — response present\n`);
|
|
392
403
|
return 0;
|
|
393
404
|
}
|
|
394
405
|
process.stderr.write(
|
|
395
406
|
`❌ the ${version} section owes a governance-versus-product response: ${level}.\n` +
|
|
407
|
+
blockers.map((b) => ` - ${b}\n`).join('') +
|
|
396
408
|
' Governance-only commits outnumber consumer-only commits over the release span, so\n' +
|
|
397
409
|
' the section carries a written response naming either the next cycle\'s consumer work\n' +
|
|
398
410
|
' or a maintainer justification (docs/contracts/CHANGELOG-conventions.md § Governance-\n' +
|
|
399
411
|
' versus-product response; the decline this replaces is ADR-253).\n' +
|
|
400
|
-
` Add one line under the ${version} curated head, for example:\n` +
|
|
401
|
-
` > ${MIX_RESPONSE_MARKER} ${level}. Next cycle ships <the consumer work>.\n` +
|
|
402
412
|
' Only that section is read; historical sections are out of scope. No ratio is\n' +
|
|
403
|
-
' enforced — this blocks a MISSING answer, never a particular number.\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`,
|
|
404
416
|
);
|
|
405
417
|
return 1;
|
|
406
418
|
}
|
|
@@ -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 {
|