@event4u/agent-config 14.17.0 → 14.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +207 -77
  5. package/README.md +72 -150
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +2 -2
  8. package/dist/agent-src/commands/pr/merge.md +35 -44
  9. package/dist/agent-src/commands/review/changes.md +19 -19
  10. package/dist/agent-src/commands/review.md +2 -2
  11. package/dist/agent-src/contexts/execution/host-capability-manifest.md +30 -3
  12. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  13. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  14. package/dist/agent-src/rules/guidelines.md +1 -1
  15. package/dist/agent-src/rules/session-canary.md +2 -2
  16. package/dist/agent-src/rules/token-efficiency.md +1 -1
  17. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  19. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  20. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  21. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  22. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  23. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  24. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  25. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  26. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  27. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  28. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  29. package/dist/agent-src/skills/design-review/SKILL.md +3 -1
  30. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  31. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  32. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  33. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  34. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  35. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  36. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  37. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  38. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  39. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  40. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  41. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  42. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  43. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  44. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  45. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  46. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  47. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  48. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +29 -2
  49. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  50. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  51. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  52. package/dist/cli/commands/recordTriggerEval.js +2 -2
  53. package/dist/cli/main.js +1 -1
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-GZTMRYCN.js} +405 -341
  55. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-IDHUQIXP.js} +283 -132
  56. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  57. package/dist/cli-delegate/cmd_conformance.js +749 -669
  58. package/dist/cli-delegate/cmd_doctor.js +1 -1
  59. package/dist/cli-delegate/cmd_export.js +1 -1
  60. package/dist/cli-delegate/cmd_handoff.js +1 -1
  61. package/dist/cli-delegate/cmd_prune.js +1 -1
  62. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  63. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  64. package/dist/cli-delegate/cmd_sync.js +1 -1
  65. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  66. package/dist/cli-delegate/cmd_validate.js +1 -1
  67. package/dist/discovery/deprecation-report.md +1 -1
  68. package/dist/discovery/discovery-manifest.json +39 -38
  69. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  70. package/dist/discovery/discovery-manifest.summary.md +1 -1
  71. package/dist/discovery/dormancy-report.md +1 -1
  72. package/dist/discovery/orphan-report.md +1 -1
  73. package/dist/discovery/packs.json +2 -2
  74. package/dist/discovery/trust-report.md +1 -1
  75. package/dist/discovery/workspaces.json +2 -2
  76. package/dist/hooks/dispatch.js +2764 -2439
  77. package/dist/install/install.mjs +588 -527
  78. package/dist/mcp/registry-manifest.json +2 -2
  79. package/dist/mcp/server.json +2 -2
  80. package/dist/mcp/server.mjs +1381 -1138
  81. package/dist/scripts/_lib/model_tier.js +1 -1
  82. package/dist/shared/settingsConsent.js +1 -1
  83. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  84. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  85. package/docs/guidelines/design-antipatterns.md +87 -64
  86. package/package.json +1 -1
  87. package/src/agent-src/README.md +1 -1
  88. package/src/config/ci-local-parity.yml +6 -0
  89. package/src/config/conformance-claim-baseline.json +9 -0
  90. package/src/config/gate-coverage.yml +170 -0
  91. package/src/config/gate-violation-baselines.json +4 -4
  92. package/src/config/release-gate-locality.yml +141 -0
  93. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  94. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  95. package/src/scripts/_cli/cmd_update.ts +1 -1
  96. package/src/scripts/_cli/cmd_versions.ts +1 -1
  97. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  98. package/src/scripts/_lib/consequence_objects.ts +152 -0
  99. package/src/scripts/_lib/exec_evidence.ts +7 -0
  100. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  101. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  102. package/src/scripts/_lib/model_tier.ts +1 -1
  103. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  104. package/src/scripts/_lib/release_highlights.ts +265 -1
  105. package/src/scripts/_lib/release_material.ts +129 -0
  106. package/src/scripts/_lib/repeated_run.ts +77 -0
  107. package/src/scripts/_lib/runtime_journal.ts +26 -3
  108. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  109. package/src/scripts/_lib/session_register.ts +11 -6
  110. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  111. package/src/scripts/_lib/surface.ts +86 -0
  112. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  113. package/src/scripts/_lib/ui_authority.ts +116 -0
  114. package/src/scripts/_lib/unified_diff.ts +121 -0
  115. package/src/scripts/adr/regenerate_index.ts +54 -1
  116. package/src/scripts/adr_cite_check.ts +71 -15
  117. package/src/scripts/ai_council/clients.ts +1 -1
  118. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  119. package/src/scripts/build_proof.ts +15 -2
  120. package/src/scripts/check_beta_review_markers.ts +141 -17
  121. package/src/scripts/check_enforcement_coverage.ts +1 -1
  122. package/src/scripts/check_enforcement_denominator.ts +121 -11
  123. package/src/scripts/check_finding_dispositions.ts +239 -6
  124. package/src/scripts/check_gate_coverage.ts +96 -0
  125. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  126. package/src/scripts/check_release_highlights.ts +99 -14
  127. package/src/scripts/check_skill_activation_claim.ts +179 -0
  128. package/src/scripts/check_suppression_hygiene.ts +11 -0
  129. package/src/scripts/check_trigger_evals.ts +1 -1
  130. package/src/scripts/code_graph/build.ts +230 -33
  131. package/src/scripts/code_graph/extract.ts +168 -33
  132. package/src/scripts/code_graph/types.ts +37 -2
  133. package/src/scripts/code_graph/validate.ts +12 -1
  134. package/src/scripts/conformance_scan.ts +45 -4
  135. package/src/scripts/detect_silent_catch.ts +403 -0
  136. package/src/scripts/detect_verification_tampering.ts +538 -0
  137. package/src/scripts/explain_run.ts +1 -1
  138. package/src/scripts/generate_capability_matrix.ts +1 -1
  139. package/src/scripts/git_authorization_hook.ts +228 -18
  140. package/src/scripts/hook_manifest.json +1 -1
  141. package/src/scripts/hook_manifest.yaml +47 -30
  142. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  143. package/src/scripts/hooks/block_no_verify.ts +3 -3
  144. package/src/scripts/hooks/category_a.ts +291 -0
  145. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  146. package/src/scripts/hooks/concern_registry.ts +2 -2
  147. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  148. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  149. package/src/scripts/hooks/evidence_independence.ts +21 -2
  150. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +175 -364
  151. package/src/scripts/hooks/host_lowering.ts +215 -0
  152. package/src/scripts/hooks/host_lowering.yaml +185 -0
  153. package/src/scripts/hooks/host_semantics.ts +105 -37
  154. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  155. package/src/scripts/hooks/permission_gate.ts +117 -0
  156. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  157. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  158. package/src/scripts/hooks_status.ts +12 -10
  159. package/src/scripts/install-hooks.sh +134 -1
  160. package/src/scripts/install.ts +53 -82
  161. package/src/scripts/install_anthropic_key.sh +2 -2
  162. package/src/scripts/language_mirror_hook.ts +27 -5
  163. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  164. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  165. package/src/scripts/lint_hook_manifest.ts +87 -3
  166. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  167. package/src/scripts/lint_readme_size.ts +158 -12
  168. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  169. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  170. package/src/scripts/measure_sibling_naming.ts +150 -0
  171. package/src/scripts/redact_hook_capture.ts +1 -1
  172. package/src/scripts/release.ts +31 -143
  173. package/src/scripts/release_drill.ts +71 -1
  174. package/src/scripts/release_publication.ts +243 -3
  175. package/src/scripts/release_verify.ts +220 -0
  176. package/src/scripts/report_skill_activation.ts +319 -7
  177. package/src/scripts/schemas/review-findings.schema.json +6 -1
  178. package/src/scripts/schemas/skill.schema.json +12 -2
  179. package/src/scripts/self_review_gate.ts +10 -1
  180. package/src/scripts/session_canary_hook.ts +1 -1
  181. package/src/scripts/session_register_hook.ts +4 -3
  182. package/src/scripts/trigger_eval_grandfather.json +5 -1
  183. package/src/scripts/validate_agent_settings.ts +1 -1
  184. 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,13 +71,18 @@ import {
71
71
  collect_span_commits,
72
72
  derive_categories,
73
73
  parse_git_log,
74
+ mix_response_blockers,
75
+ previous_promise,
74
76
  previous_release_tag,
77
+ promise_readback_blockers,
75
78
  stale_draft_labels,
76
79
  } from './_lib/release_highlights.js';
77
80
  import { loadTaxonomy, measureRange } from './measure_release_mix.js';
78
81
  import {
79
82
  CURATED_HEAD_INSTRUCTION,
83
+ PROMISE_READBACK_MARKER,
80
84
  extract_changelog_section,
85
+ previous_changelog_version,
81
86
  } from './_lib/release_material.js';
82
87
  import { assertScanned, assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
83
88
 
@@ -206,7 +211,8 @@ export function main(argv: readonly string[]): number {
206
211
  }
207
212
  throw err;
208
213
  }
209
- const section = extract_changelog_section(fs.readFileSync(changelogPath, 'utf-8'), version);
214
+ const changelogText = fs.readFileSync(changelogPath, 'utf-8');
215
+ const section = extract_changelog_section(changelogText, version);
210
216
  if (!section) {
211
217
  process.stderr.write(`CHANGELOG carries no section for ${version}\n`);
212
218
  return 2;
@@ -238,6 +244,23 @@ export function main(argv: readonly string[]): number {
238
244
  );
239
245
  return 1;
240
246
  }
247
+ // Hoisted above the curated-head branches on purpose. It used to sit inside
248
+ // `contradictions.length === 0`, so CI reached it only for a section that
249
+ // HAS a head, carries no unrewritten draft and contradicts nothing — while
250
+ // the local guards call the same predicate unconditionally. A section
251
+ // without a curated head was therefore refused locally and waved through by
252
+ // CI: one predicate, two different gatekeepers, which is the drift this
253
+ // change exists to remove.
254
+ const mix = check_governance_mix_response(section.body, from, to, version);
255
+ if (mix !== 0) {
256
+ return mix;
257
+ }
258
+
259
+ const readback = check_previous_promise_readback(changelogText, section.body, version);
260
+ if (readback !== 0) {
261
+ return readback;
262
+ }
263
+
241
264
  const curated = parse_curated_head(section.body);
242
265
  if (!curated) {
243
266
  process.stdout.write(`ℹ️ no curated head in the ${version} section — nothing to check\n`);
@@ -314,10 +337,6 @@ export function main(argv: readonly string[]): number {
314
337
  }
315
338
  const contradictions = highlight_contradictions(curated, derived);
316
339
  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
340
  process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
322
341
  return 0;
323
342
  }
@@ -361,9 +380,6 @@ export function main(argv: readonly string[]): number {
361
380
  * shallow clone or a missing tag on the release path would otherwise turn an
362
381
  * unrelated environment fact into a blocked release.
363
382
  */
364
- export const MIX_RESPONSE_MARKER = '**Governance mix:**';
365
- const MIX_RESPONSE_MIN_CHARS = 40;
366
-
367
383
  export function check_governance_mix_response(
368
384
  body: string,
369
385
  from: string,
@@ -385,22 +401,91 @@ export function check_governance_mix_response(
385
401
  process.stdout.write(`✅ governance mix for ${version}: ${level} — no response owed\n`);
386
402
  return 0;
387
403
  }
388
- const idx = body.indexOf(MIX_RESPONSE_MARKER);
389
- const written = idx === -1 ? '' : body.slice(idx + MIX_RESPONSE_MARKER.length).split('\n')[0]!.trim();
390
- if (written.length >= MIX_RESPONSE_MIN_CHARS) {
404
+ // ONE predicate, shared with `guard_release_branch_push` — see
405
+ // `mix_response_blockers`. This side prints and exits; the local side dies
406
+ // before anything leaves the machine. Neither owns the rule.
407
+ const blockers = mix_response_blockers(body, version, `\`release/${version}\``, {
408
+ triggered: true,
409
+ level,
410
+ });
411
+ if (blockers.length === 0) {
391
412
  process.stdout.write(`✅ governance mix for ${version}: ${level} — response present\n`);
392
413
  return 0;
393
414
  }
394
415
  process.stderr.write(
395
416
  `❌ the ${version} section owes a governance-versus-product response: ${level}.\n` +
417
+ blockers.map((b) => ` - ${b}\n`).join('') +
396
418
  ' Governance-only commits outnumber consumer-only commits over the release span, so\n' +
397
419
  ' the section carries a written response naming either the next cycle\'s consumer work\n' +
398
420
  ' or a maintainer justification (docs/contracts/CHANGELOG-conventions.md § Governance-\n' +
399
421
  ' 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
422
  ' 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',
423
+ ' enforced — this blocks a MISSING answer, never a particular number.\n' +
424
+ ' Reproduce locally, before any push:\n' +
425
+ ` ./scripts-run src/scripts/check_release_highlights --version ${version}\n`,
426
+ );
427
+ return 1;
428
+ }
429
+
430
+ /**
431
+ * The previous release's promise, read back at the next release.
432
+ *
433
+ * `_lib/release_material.ts` generates `Next cycle ships …` and, until this
434
+ * check existed, nothing ever asked what became of it: 14.18.0 promised the MCP
435
+ * bridge repair, and no gate in the tree would have noticed the same promise
436
+ * being reprinted in 14.19.0 and 14.20.0. A commitment nothing reads back is
437
+ * free to restate, and one that is free to restate is not a commitment.
438
+ *
439
+ * Only the section under release is read, and only the previous section's
440
+ * governance-response block — the same scope every other check here uses, so a
441
+ * historical section cannot red a future release.
442
+ *
443
+ * The check is about ANSWERING, never about having kept the promise. A gate
444
+ * that refused an unkept promise would price honesty above silence and teach
445
+ * the next author to promise nothing.
446
+ */
447
+ export function check_previous_promise_readback(
448
+ changelogText: string,
449
+ body: string,
450
+ version: string,
451
+ ): number {
452
+ const previousVersion = previous_changelog_version(changelogText, version);
453
+ if (previousVersion === null) {
454
+ process.stdout.write(`✅ no previous CHANGELOG section before ${version} — no promise to read back\n`);
455
+ return 0;
456
+ }
457
+ const promise = previous_promise(changelogText, previousVersion);
458
+ if (promise === null) {
459
+ process.stdout.write(
460
+ `✅ the ${previousVersion} section made no next-cycle promise — nothing owed by ${version}\n`,
461
+ );
462
+ return 0;
463
+ }
464
+ const blockers = promise_readback_blockers(
465
+ body,
466
+ version,
467
+ previousVersion,
468
+ promise,
469
+ `\`release/${version}\``,
470
+ );
471
+ if (blockers.length === 0) {
472
+ process.stdout.write(`✅ ${version} answers the ${previousVersion} next-cycle promise\n`);
473
+ return 0;
474
+ }
475
+ process.stderr.write(
476
+ `❌ the ${version} section owes an answer to the ${previousVersion} next-cycle promise.\n` +
477
+ blockers.map((b) => ` - ${b}\n`).join('') +
478
+ ` The ${previousVersion} promise reads:\n` +
479
+ promise
480
+ .split('\n')
481
+ .map((l) => ` ${l}\n`)
482
+ .join('') +
483
+ ` Answering it is one blockquote line under the curated head, e.g.\n` +
484
+ ` > ${PROMISE_READBACK_MARKER} the <promise> did not ship; <what happened>.\n` +
485
+ ' Shipped, did not ship, or withdrawn with a reason — the check refuses a MISSING\n' +
486
+ ' answer, never a particular outcome. An unkept promise answered honestly passes.\n' +
487
+ ' Reproduce locally, before any push:\n' +
488
+ ` ./scripts-run src/scripts/check_release_highlights --version ${version}\n`,
404
489
  );
405
490
  return 1;
406
491
  }