@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,251 @@
1
+ /**
2
+ * host_permission_checks — report the host permission settings that produce
3
+ * confirmation prompts, and write none of them.
4
+ *
5
+ * WHY REPORT AND NOT WRITE. Three settings decide how often a normal coding
6
+ * task is interrupted, and all three belong to the consumer:
7
+ * `permissions.deny` rules that cover reads, the working tree's absence from
8
+ * `permissions.additionalDirectories`, and `permissions.defaultMode`. Writing
9
+ * any of them would be this package deciding a consumer's permission posture
10
+ * from inside their repository, which is the one thing a permission surface
11
+ * must never do quietly. `src/templates/consumer-settings/claude-settings.json`
12
+ * carries `enabledPlugins` and `hooks` and zero `permissions` keys; this check
13
+ * exists so that stays true and the consumer still gets the answer.
14
+ *
15
+ * WHY IT NAMES ALL THREE EVEN WHEN ALL THREE ARE ABSENT. Absent is a real
16
+ * answer and the most common one. A check that goes silent on the default
17
+ * project reports "nothing found" and "I did not look" identically, which is
18
+ * the measured-zero-versus-not-measured failure this tree's own report
19
+ * contracts argue against. So each setting is named on every run, with its
20
+ * resolved value or the word absent.
21
+ *
22
+ * READ ONLY, AND FAILURE-TOLERANT. An unreadable or malformed settings file is
23
+ * reported, never repaired: a consumer's JSON is theirs, and a doctor that
24
+ * rewrites what it came to inspect is not a doctor.
25
+ */
26
+ import * as fs from "node:fs";
27
+ import * as os from "node:os";
28
+ import * as path from "node:path";
29
+
30
+ import type { WiringCheck } from "./runtime_wiring_checks.js";
31
+
32
+ /** Injected so a test never reads the developer's own home directory. */
33
+ export interface HostPermissionDeps {
34
+ projectRoot: string;
35
+ homeDir: string;
36
+ readFile: (p: string) => string | null;
37
+ isDirectory: (p: string) => boolean;
38
+ }
39
+
40
+ /** The permission block as the host writes it; every field optional. */
41
+ interface PermissionBlock {
42
+ allow?: unknown;
43
+ deny?: unknown;
44
+ ask?: unknown;
45
+ additionalDirectories?: unknown;
46
+ defaultMode?: unknown;
47
+ }
48
+
49
+ /** One settings layer, in the host's own precedence order. */
50
+ export interface SettingsLayer {
51
+ label: string;
52
+ file: string;
53
+ present: boolean;
54
+ unreadable: boolean;
55
+ permissions: PermissionBlock;
56
+ }
57
+
58
+ /**
59
+ * Tool names whose every documented effect is a read, plus the read-shaped
60
+ * `Bash(<cmd>:*)` heads. A deny covering one of these is the class that turns a
61
+ * category-A call back into a prompt, which is why it is worth naming
62
+ * separately from a deny on a write.
63
+ */
64
+ const READ_SHAPED = [
65
+ "Read", "Glob", "Grep", "LS", "NotebookRead",
66
+ "Bash(cat", "Bash(ls", "Bash(head", "Bash(tail", "Bash(grep",
67
+ "Bash(rg", "Bash(find", "Bash(wc", "Bash(git status", "Bash(git log",
68
+ "Bash(git diff", "Bash(git show",
69
+ ];
70
+
71
+ /** The four `defaultMode` values probed from Claude Code 2.1.263. */
72
+ const KNOWN_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
73
+
74
+ function _strings(value: unknown): string[] {
75
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
76
+ }
77
+
78
+ /** Default deps — the only place the real filesystem is touched. */
79
+ export function defaultHostPermissionDeps(projectRoot: string): HostPermissionDeps {
80
+ return {
81
+ projectRoot,
82
+ homeDir: os.homedir(),
83
+ readFile: (p) => {
84
+ try {
85
+ return fs.readFileSync(p, "utf8");
86
+ } catch {
87
+ return null;
88
+ }
89
+ },
90
+ isDirectory: (p) => {
91
+ try {
92
+ return fs.statSync(p).isDirectory();
93
+ } catch {
94
+ return false;
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Resolve the three settings layers the host reads, in its precedence order.
102
+ *
103
+ * There was no resolver for this before — `security_audit_config.ts` is the
104
+ * only place all three paths appear together, and only as glob strings — so
105
+ * this is the first code in the tree that reads `permissions` at all.
106
+ */
107
+ export function readSettingsLayers(deps: HostPermissionDeps): SettingsLayer[] {
108
+ const candidates: Array<[string, string]> = [
109
+ ["user-global", path.join(deps.homeDir, ".claude", "settings.json")],
110
+ ["project", path.join(deps.projectRoot, ".claude", "settings.json")],
111
+ ["project-local", path.join(deps.projectRoot, ".claude", "settings.local.json")],
112
+ ];
113
+ return candidates.map(([label, file]) => {
114
+ const raw = deps.readFile(file);
115
+ if (raw === null) {
116
+ return { label, file, present: false, unreadable: false, permissions: {} };
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
120
+ const perms = parsed["permissions"];
121
+ return {
122
+ label,
123
+ file,
124
+ present: true,
125
+ unreadable: false,
126
+ permissions:
127
+ perms !== null && typeof perms === "object" ? (perms as PermissionBlock) : {},
128
+ };
129
+ } catch {
130
+ return { label, file, present: true, unreadable: true, permissions: {} };
131
+ }
132
+ });
133
+ }
134
+
135
+ /** Deny rules that cover a read, with the layer each came from. */
136
+ export function readDenyRules(layers: readonly SettingsLayer[]): Array<[string, string]> {
137
+ const hits: Array<[string, string]> = [];
138
+ for (const layer of layers) {
139
+ for (const rule of _strings(layer.permissions.deny)) {
140
+ if (READ_SHAPED.some((prefix) => rule.startsWith(prefix))) {
141
+ hits.push([layer.label, rule]);
142
+ }
143
+ }
144
+ }
145
+ return hits;
146
+ }
147
+
148
+ /** Every `additionalDirectories` entry across the layers; the host unions them. */
149
+ export function additionalDirectories(layers: readonly SettingsLayer[]): string[] {
150
+ const seen: string[] = [];
151
+ for (const layer of layers) {
152
+ for (const dir of _strings(layer.permissions.additionalDirectories)) {
153
+ if (!seen.includes(dir)) seen.push(dir);
154
+ }
155
+ }
156
+ return seen;
157
+ }
158
+
159
+ /** The winning `defaultMode`, or `null` when no layer sets one. */
160
+ export function resolvedDefaultMode(layers: readonly SettingsLayer[]): string | null {
161
+ let winner: string | null = null;
162
+ for (const layer of layers) {
163
+ const mode = layer.permissions.defaultMode;
164
+ if (typeof mode === "string" && mode.trim()) winner = mode.trim();
165
+ }
166
+ return winner;
167
+ }
168
+
169
+ /**
170
+ * Is the project root a LINKED worktree?
171
+ *
172
+ * A linked worktree's `.git` is a file holding a `gitdir:` pointer, never a
173
+ * directory. It matters here because a worktree root is a working directory the
174
+ * host was not started in, which is exactly the case
175
+ * `additionalDirectories` exists to cover — and the case a consumer is least
176
+ * likely to have thought about.
177
+ */
178
+ export function isLinkedWorktree(deps: HostPermissionDeps): boolean {
179
+ const dotGit = path.join(deps.projectRoot, ".git");
180
+ const raw = deps.readFile(dotGit);
181
+ if (raw === null) return false;
182
+ return !deps.isDirectory(dotGit) && raw.trimStart().startsWith("gitdir:");
183
+ }
184
+
185
+ /** The copyable snippet — the consumer pastes it, this package never writes it. */
186
+ export function permissionSnippet(projectRoot: string): string {
187
+ return JSON.stringify(
188
+ { permissions: { additionalDirectories: [projectRoot], defaultMode: "default" } },
189
+ null,
190
+ 2,
191
+ );
192
+ }
193
+
194
+ /**
195
+ * The doctor row.
196
+ *
197
+ * `warn` when something reportable was found, `ok` otherwise. Never `fail`:
198
+ * these are the consumer's own choices, and a doctor that fails a build over a
199
+ * setting it is forbidden to write would be asking for a change it will not
200
+ * make.
201
+ */
202
+ export function checkHostPermissionSettings(deps: HostPermissionDeps): WiringCheck {
203
+ const layers = readSettingsLayers(deps);
204
+ const unreadable = layers.filter((l) => l.unreadable);
205
+ const denies = readDenyRules(layers);
206
+ const dirs = additionalDirectories(layers);
207
+ const mode = resolvedDefaultMode(layers);
208
+ const worktree = isLinkedWorktree(deps);
209
+ const rootListed = dirs.some(
210
+ (d) => path.resolve(d) === path.resolve(deps.projectRoot),
211
+ );
212
+
213
+ const lines: string[] = [];
214
+ lines.push(
215
+ denies.length === 0
216
+ ? "read-deny rules: absent — no permissions.deny rule covers a read"
217
+ : `read-deny rules: ${denies.length} — ` +
218
+ denies.map(([layer, rule]) => `${rule} (${layer})`).join(", "),
219
+ );
220
+ lines.push(
221
+ rootListed
222
+ ? `working tree in additionalDirectories: present (${deps.projectRoot})`
223
+ : `working tree in additionalDirectories: absent (${deps.projectRoot}` +
224
+ `${worktree ? ", a linked worktree" : ""})`,
225
+ );
226
+ lines.push(
227
+ mode === null
228
+ ? "permissions.defaultMode: absent — the host's own built-in default applies"
229
+ : `permissions.defaultMode: ${mode}` +
230
+ (KNOWN_MODES.includes(mode) ? "" : " — not a value this tree has probed"),
231
+ );
232
+ for (const layer of unreadable) {
233
+ lines.push(`unreadable: ${layer.file} (${layer.label}) is not valid JSON`);
234
+ }
235
+
236
+ // A linked worktree unlisted in additionalDirectories is the one finding
237
+ // worth surfacing on its own: the host was started somewhere else, so every
238
+ // call into this tree is a call into a directory it was not granted.
239
+ const reportable =
240
+ denies.length > 0 || unreadable.length > 0 || (worktree && !rootListed);
241
+
242
+ return {
243
+ id: "host-permission-settings",
244
+ status: reportable ? "warn" : "ok",
245
+ message: lines.join("; "),
246
+ remedy:
247
+ "agent-config never writes permissions — paste this into " +
248
+ `${path.join(deps.projectRoot, ".claude", "settings.local.json")} yourself if you want it: ` +
249
+ permissionSnippet(deps.projectRoot).replace(/\s+/g, " "),
250
+ };
251
+ }
@@ -11,7 +11,7 @@
11
11
  * - the repo generator (`condense.py generate_claude_skills`), which
12
12
  * builds the package's own `.claude/skills/` tree, and
13
13
  * - the consumer install finalizer
14
- * (`install.py finalize_claude_model_tiers`), which rewrites the
14
+ * (the installer's `finalize_claude_model_tiers`), which rewrites the
15
15
  * installed `.claude/skills/` tree so Claude Code performs the
16
16
  * per-turn model switch on a consumer with `model.auto_switch: auto`.
17
17
  *
@@ -275,7 +275,7 @@ export function slot_frequency(platform: string, slot: Slot): Frequency {
275
275
  }
276
276
 
277
277
  /**
278
- * Platforms with no hook surface at all.
278
+ * Platforms this package binds nothing on.
279
279
  *
280
280
  * Copilot declares `fallback_only: true` in the manifest — concerns route
281
281
  * through rule-only fallback and the dispatcher no-ops. Every hook-declared
@@ -294,7 +294,7 @@ export function is_hook_capable(platform_block: Record<string, unknown>): boolea
294
294
  export interface PlatformBinding {
295
295
  /** Platform id → slot → concern ids bound in that slot. */
296
296
  slots: Map<string, Map<Slot, string[]>>;
297
- /** Platform ids that carry no hook surface at all. */
297
+ /** Platform ids declared `fallback_only` — this package binds nothing there. */
298
298
  fallback_only: Set<string>;
299
299
  }
300
300
 
@@ -25,7 +25,15 @@
25
25
  */
26
26
  import { spawnSync } from 'node:child_process';
27
27
 
28
- import { CURATED_HEAD_INSTRUCTION } from './release_material.js';
28
+ import {
29
+ CURATED_HEAD_INSTRUCTION,
30
+ MIX_RESPONSE_MARKER,
31
+ MIX_RESPONSE_PLACEHOLDERS,
32
+ PROMISE_OUTCOMES,
33
+ PROMISE_PHRASE,
34
+ PROMISE_READBACK_MARKER,
35
+ extract_changelog_section,
36
+ } from './release_material.js';
29
37
 
30
38
  /** The five curated labels, in the order an operator reads them. */
31
39
  export const HEAD_LABELS: readonly string[] = [
@@ -398,6 +406,7 @@ export function publication_blockers(
398
406
  sectionBody: string,
399
407
  version: string,
400
408
  where = '`main`',
409
+ mix: MixObligation | null = null,
401
410
  ): string[] {
402
411
  const out: string[] = [];
403
412
  if (sectionBody.includes(DERIVED_MARKER)) {
@@ -414,9 +423,264 @@ export function publication_blockers(
414
423
  'comment line from the section and re-run.',
415
424
  );
416
425
  }
426
+ out.push(...mix_response_blockers(sectionBody, version, where, mix));
417
427
  return out;
418
428
  }
419
429
 
430
+ /**
431
+ * Everything that makes a whole SECTION unpublishable — the head-level
432
+ * blockers above plus the two obligations that only exist for a full section.
433
+ *
434
+ * Two levels and not one, because `publication_blockers` is legitimately called
435
+ * with a bare curated head (the prefill tests do exactly that), and a head
436
+ * fragment has no tests footer by construction. Folding a section obligation
437
+ * into the head predicate would make those callers red on a rule that does not
438
+ * apply to what they passed.
439
+ *
440
+ * The TWO cheapest guard sites call this one — `guard_release_curation` (before
441
+ * any commit) and `guard_release_branch_push` (before any remote state). The
442
+ * two publication sites reached through `_refuse_unpublishable` (the annotated
443
+ * tag and the GitHub Release) stay on the head-level predicate: by then the
444
+ * section has already shipped in a merged PR, and adding a refusal there would
445
+ * block a resumed publication over an obligation the release cannot still act
446
+ * on. Stated rather than implied, because "the guard sites call this one" read
447
+ * as all four and is not true of any of them but the two named here.
448
+ */
449
+ export function section_publication_blockers(
450
+ sectionBody: string,
451
+ version: string,
452
+ where = '`main`',
453
+ mix: MixObligation | null = null,
454
+ ): string[] {
455
+ const out = publication_blockers(sectionBody, version, where, mix);
456
+ if (!TEST_FOOTER_RE.test(sectionBody)) {
457
+ out.push(
458
+ `the ${version} section carries no \`Tests: N (+M since PREV)\` footer. ` +
459
+ '`_render_test_trend_line` degrades to `null` when the vitest collection ' +
460
+ 'fails, so a missing line means the collection failed during `task release`. ' +
461
+ 'Fix the collection, then render the number and put it on the section — ' +
462
+ `the release commit already exists on ${where}, so amend it there. Do not ` +
463
+ 'invent the count: `npx vitest list | wc -l` is the same probe the writer runs.',
464
+ );
465
+ }
466
+ return out;
467
+ }
468
+
469
+ /**
470
+ * The tests-footer convention, in force for every release since 9.1.0.
471
+ *
472
+ * Duplicated from `release-validation.yml`'s inline check until 2026-09-05,
473
+ * where it was the ONLY copy — so the absence was red at release-PR time and
474
+ * invisible before it. 9.8.0 shipped without the line. The regex is the same
475
+ * one the workflow uses; the workflow's own comment already recorded that the
476
+ * writer "degrades SILENTLY to null on collection failure", which is exactly a
477
+ * defect a local guard should see first.
478
+ */
479
+ const TEST_FOOTER_RE = /^Tests: \d+/mu;
480
+
481
+ /**
482
+ * The measured governance-versus-product level, as the writer and the guards
483
+ * both receive it.
484
+ *
485
+ * A plain record and not the measurement itself: this module is reached by the
486
+ * push guard, the pre-commit ask and the CI gate, and only the callers have git
487
+ * to measure with. Passing the reading in keeps the predicate pure and keeps
488
+ * `measure_release_mix`'s git access out of a module three guards import.
489
+ */
490
+ export interface MixObligation {
491
+ triggered: boolean;
492
+ level: string;
493
+ }
494
+
495
+ /** Minimum written characters after the marker — a bare marker is not an answer. */
496
+ export const MIX_RESPONSE_MIN_CHARS = 40;
497
+
498
+ /**
499
+ * The ONE predicate for the governance-versus-product obligation.
500
+ *
501
+ * `check_release_highlights` refuses this from the CI side and
502
+ * `guard_release_branch_push` from the local side, and until 2026-09-05 only
503
+ * the first of the two knew the obligation existed. That asymmetry is what the
504
+ * contract itself recorded — *"the earliest refusal for this one obligation is
505
+ * the PR, not the push"* (`docs/contracts/CHANGELOG-conventions.md`) — and what
506
+ * cost 14.17.0 a red release PR. Both sides now read this function, so a change
507
+ * to the obligation cannot reach one guard and miss the other.
508
+ *
509
+ * `null` means the measurement did not run (shallow clone, missing tag). That
510
+ * degrades to no blocker, matching the gate's documented stance: this is a
511
+ * governance signal, not a correctness control, and an environment fact must
512
+ * not block a release.
513
+ */
514
+ export function mix_response_blockers(
515
+ sectionBody: string,
516
+ version: string,
517
+ where = '`main`',
518
+ mix: MixObligation | null = null,
519
+ ): string[] {
520
+ if (!mix || !mix.triggered) {
521
+ return [];
522
+ }
523
+ const idx = sectionBody.indexOf(MIX_RESPONSE_MARKER);
524
+ if (idx === -1) {
525
+ return [
526
+ `the ${version} section owes a governance-versus-product response ` +
527
+ `(${mix.level}) and carries none. Add one \`> ${MIX_RESPONSE_MARKER}\` line ` +
528
+ `immediately under the curated head on ${where}, naming the next cycle's ` +
529
+ 'consumer work or a maintainer justification.',
530
+ ];
531
+ }
532
+ const block = mix_response_block(sectionBody, idx);
533
+ const left = MIX_RESPONSE_PLACEHOLDERS.filter((t) => block.includes(t));
534
+ if (left.length > 0) {
535
+ return [
536
+ `the ${version} governance-versus-product response still carries the writer's ` +
537
+ `placeholder(s) (${left.map((t) => `\`${t}\``).join(', ')}) — the measured level ` +
538
+ 'is the generator\'s, the answer is not, and `CHANGELOG.md` is published to npm. ' +
539
+ `Name the next cycle's consumer work (or the justification) on ${where} and re-run.`,
540
+ ];
541
+ }
542
+ if (human_answer(block, mix.level).length < MIX_RESPONSE_MIN_CHARS) {
543
+ return [
544
+ `the ${version} governance-versus-product response carries no written answer ` +
545
+ `beyond the measured level (${mix.level}). The level is the generator's; the ` +
546
+ `response has to name the next cycle's consumer work or a maintainer ` +
547
+ `justification. Write it on ${where}, in the \`> \` block under the marker.`,
548
+ ];
549
+ }
550
+ return [];
551
+ }
552
+
553
+ /**
554
+ * The promise the PREVIOUS release head made, or null when it made none.
555
+ *
556
+ * Scoped to that section's governance-response block, not to its whole body: a
557
+ * product line that happens to contain the phrase is not a promise the project
558
+ * made about the next cycle.
559
+ */
560
+ export function previous_promise(changelogText: string, previousVersion: string): string | null {
561
+ const section = extract_changelog_section(changelogText, previousVersion);
562
+ if (section === null) return null;
563
+ const idx = section.body.indexOf(MIX_RESPONSE_MARKER);
564
+ if (idx === -1) return null;
565
+ const block = mix_response_block(section.body, idx);
566
+ return block.includes(PROMISE_PHRASE) ? block : null;
567
+ }
568
+
569
+ /**
570
+ * The ONE predicate for "did this head answer the previous head's promise?".
571
+ *
572
+ * The defect: `_lib/release_material.ts` generates a `Next cycle ships …` line
573
+ * and nothing has ever read the previous one back, so an unmet promise can be
574
+ * restated indefinitely at zero cost — the debt is booked and never called. A
575
+ * promise nobody reads is not a commitment, it is a sentence.
576
+ *
577
+ * Three outcomes are accepted and one of them must be chosen, which is the
578
+ * whole mechanism: it does not require the promise to have been KEPT, and no
579
+ * ratio, deadline or count is enforced here. Refusing an unkept promise would
580
+ * make the honest answer the expensive one and teach an author to promise less
581
+ * rather than to answer truthfully.
582
+ *
583
+ * Length floor and placeholder scan reuse the governance-response discipline
584
+ * for the same reason it exists there: a bare marker, or the writer's own
585
+ * scaffolding, is not an answer a human wrote.
586
+ */
587
+ export function promise_readback_blockers(
588
+ sectionBody: string,
589
+ version: string,
590
+ previousVersion: string,
591
+ promise: string | null,
592
+ where = '`main`',
593
+ ): string[] {
594
+ if (promise === null) return [];
595
+ const idx = sectionBody.indexOf(PROMISE_READBACK_MARKER);
596
+ if (idx === -1) {
597
+ return [
598
+ `the ${version} section does not answer the ${previousVersion} head's ` +
599
+ `\`${PROMISE_PHRASE}\` promise. Add one \`> ${PROMISE_READBACK_MARKER}\` line ` +
600
+ `immediately under the curated head on ${where}, naming the promise and whether ` +
601
+ `it ${PROMISE_OUTCOMES.join(', ')} (the last one with its reason).`,
602
+ ];
603
+ }
604
+ const block = mix_response_block(sectionBody, idx);
605
+ const left = MIX_RESPONSE_PLACEHOLDERS.filter((t) => block.includes(t));
606
+ if (left.length > 0) {
607
+ return [
608
+ `the ${version} previous-cycle answer still carries the writer's placeholder(s) ` +
609
+ `(${left.map((t) => `\`${t}\``).join(', ')}). \`CHANGELOG.md\` is published to npm. ` +
610
+ `Write the outcome on ${where} and re-run.`,
611
+ ];
612
+ }
613
+ const lower = block.toLowerCase();
614
+ const named = PROMISE_OUTCOMES.filter((o) => lower.includes(o));
615
+ if (named.length === 0) {
616
+ return [
617
+ `the ${version} previous-cycle answer names no outcome. It has to say which of ` +
618
+ `${PROMISE_OUTCOMES.join(', ')} applies to the ${previousVersion} promise — an ` +
619
+ 'answer that restates the promise without resolving it is the failure this reads ' +
620
+ 'back for.',
621
+ ];
622
+ }
623
+ if (readback_answer(block).length < MIX_RESPONSE_MIN_CHARS) {
624
+ return [
625
+ `the ${version} previous-cycle answer is shorter than a written answer ` +
626
+ `(${String(MIX_RESPONSE_MIN_CHARS)} characters after the marker). Name what was ` +
627
+ `promised and what became of it on ${where}, not the outcome word alone.`,
628
+ ];
629
+ }
630
+ return [];
631
+ }
632
+
633
+ /** What the author wrote under the read-back marker, marker and quoting removed. */
634
+ export function readback_answer(block: string): string {
635
+ return block
636
+ .replace(PROMISE_READBACK_MARKER, '')
637
+ .replace(/^[>\s.]+|[>\s.]+$/gu, '')
638
+ .replace(/[>\s]+/gu, ' ')
639
+ .trim();
640
+ }
641
+
642
+ /**
643
+ * The whole response block: the marker's line plus every blockquote line under it.
644
+ *
645
+ * Reading only the FIRST line after the marker was wrong in both directions, and
646
+ * both were reproduced before this was written. It **accepted** a section whose
647
+ * placeholder line had simply been deleted, because the machine-written level
648
+ * alone is 54 characters and cleared the floor — the smuggled auto-approval the
649
+ * placeholder exists to prevent. And it **refused** the writer's own two-line
650
+ * template, where the level sits on line 1 and the answer on line 2.
651
+ *
652
+ * Scoping to the block also fixes the placeholder scan, which searched the
653
+ * entire section body: a legitimate changelog line containing
654
+ * `<roadmap or issue>` blocked the release.
655
+ */
656
+ export function mix_response_block(sectionBody: string, markerIdx: number): string {
657
+ const lines = sectionBody.slice(markerIdx).split('\n');
658
+ const out: string[] = [lines[0] ?? ''];
659
+ for (const line of lines.slice(1)) {
660
+ if (!line.trimStart().startsWith('>')) break;
661
+ out.push(line);
662
+ }
663
+ return out.join('\n');
664
+ }
665
+
666
+ /**
667
+ * What is left of the response block once the generator's own contribution is
668
+ * removed — i.e. the part a human had to write.
669
+ *
670
+ * The measured level is stripped because the writer emitted it; a floor that
671
+ * counted it would be satisfied by the tool on the author's behalf, which is
672
+ * the whole failure this obligation guards against.
673
+ */
674
+ export function human_answer(block: string, level: string): string {
675
+ return block
676
+ .replace(MIX_RESPONSE_MARKER, '')
677
+ .split(level)
678
+ .join('')
679
+ .replace(/^[>\s.]+|[>\s.]+$/gu, '')
680
+ .replace(/[>\s]+/gu, ' ')
681
+ .trim();
682
+ }
683
+
420
684
  /** Labels whose curated value is still the generator's unedited draft. */
421
685
  export function stale_draft_labels(
422
686
  curated: Readonly<Record<string, string>>,