@event4u/agent-config 9.22.0 → 9.24.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 (90) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +84 -238
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/inbox.md +190 -0
  6. package/dist/agent-src/commands/analyze.md +4 -3
  7. package/dist/agent-src/commands/optimize/deep.md +13 -0
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
  10. package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
  11. package/dist/agent-src/rules/context-hygiene.md +20 -0
  12. package/dist/agent-src/rules/downstream-changes.md +26 -0
  13. package/dist/agent-src/rules/think-before-action.md +25 -0
  14. package/dist/agent-src/rules/token-budget-discipline.md +46 -3
  15. package/dist/agent-src/rules/token-efficiency.md +13 -0
  16. package/dist/agent-src/rules/ui-audit-gate.md +33 -2
  17. package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
  18. package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
  19. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
  20. package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
  23. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
  24. package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
  25. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  26. package/dist/cli/registry.js +1 -0
  27. package/dist/cli/registry.js.map +1 -1
  28. package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
  29. package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
  30. package/dist/cli-delegate/cmd_export.js +2 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -1
  32. package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
  33. package/dist/cli-delegate/cmd_settings_set.js +3370 -0
  34. package/dist/cli-delegate/cmd_sync.js +2 -1
  35. package/dist/cli-delegate/cmd_uninstall.js +2 -1
  36. package/dist/cli-delegate/cmd_validate.js +2 -1
  37. package/dist/discovery/deprecation-report.md +1 -1
  38. package/dist/discovery/discovery-manifest.json +54 -27
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +3 -3
  41. package/dist/discovery/orphan-report.md +1 -1
  42. package/dist/discovery/packs.json +6 -5
  43. package/dist/discovery/trust-report.md +2 -2
  44. package/dist/discovery/workspaces.json +5 -4
  45. package/dist/hooks/dispatch.js +79 -1
  46. package/dist/mcp/registry-manifest.json +3 -3
  47. package/dist/mcp/server.json +2 -2
  48. package/dist/scripts/condense.js +47 -2
  49. package/dist/scripts/condense.js.map +1 -1
  50. package/dist/server/routes/settings.js +82 -0
  51. package/dist/server/routes/settings.js.map +1 -1
  52. package/dist/shared/settingsClasses.js +191 -0
  53. package/dist/shared/settingsClasses.js.map +1 -0
  54. package/docs/contracts/settings-classes.md +342 -0
  55. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
  56. package/docs/guidelines/agent-infra/false-green.md +187 -0
  57. package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
  58. package/package.json +3 -1
  59. package/src/config/ci-local-parity.yml +12 -0
  60. package/src/config/evaluator-budgets.json +4 -4
  61. package/src/config/gate-coverage.yml +54 -12
  62. package/src/config/gate-violation-baselines.json +6 -0
  63. package/src/config/pack-size-budget.json +4 -3
  64. package/src/scripts/_cli/cmd_settings_set.ts +509 -0
  65. package/src/scripts/_dispatch.bash +16 -0
  66. package/src/scripts/_lib/gate_ledger.ts +295 -0
  67. package/src/scripts/_lib/gate_result.ts +101 -0
  68. package/src/scripts/_lib/gate_self_test.ts +129 -0
  69. package/src/scripts/_lib/measured_render.ts +97 -0
  70. package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
  71. package/src/scripts/check_gate_completeness.ts +244 -0
  72. package/src/scripts/check_gate_coverage.ts +35 -6
  73. package/src/scripts/check_references.ts +125 -15
  74. package/src/scripts/check_review_dispositions.ts +14 -1
  75. package/src/scripts/check_suppression_hygiene.ts +503 -0
  76. package/src/scripts/condense.ts +47 -2
  77. package/src/scripts/hook_manifest.yaml +23 -0
  78. package/src/scripts/hooks/dispatch_hook.ts +65 -1
  79. package/src/scripts/hooks/host_semantics.ts +140 -0
  80. package/src/scripts/lint_framework_leakage.ts +147 -15
  81. package/src/scripts/lint_mandated_lines.ts +339 -0
  82. package/src/scripts/lint_roadmap_complexity.ts +102 -11
  83. package/src/scripts/lint_roadmap_family_cap.ts +191 -0
  84. package/src/scripts/lint_settings_classes.ts +397 -0
  85. package/src/scripts/lint_token_budget_discipline.ts +109 -1
  86. package/src/scripts/measure_rule_absoluta.ts +231 -0
  87. package/src/scripts/report_imperative_density.ts +229 -0
  88. package/src/scripts/verify_physical_move.ts +10 -1
  89. package/src/server/io/yamlIO.ts +173 -0
  90. package/src/shared/settingsClasses.ts +235 -0
@@ -18,6 +18,7 @@ concerns:
18
18
  script: src/scripts/chat_history.ts
19
19
  args: [hook-dispatch]
20
20
  fail_closed: false
21
+ severity: advisory
21
22
  # Hot-context working-memory cache (road-to-second-brain Phase 1).
22
23
  # stop: deterministic, redacted 400-word overwrite of
23
24
  # agents/runtime/state/hot-context.md from the chat-history JSONL.
@@ -30,6 +31,7 @@ concerns:
30
31
  script: src/scripts/hot_context_hook.ts
31
32
  args: []
32
33
  fail_closed: false
34
+ severity: advisory
33
35
  # One-shot handoff injection (road-to-agent-handoff-resume Phase 3).
34
36
  # session_start only: if `agent-config handoff` wrote
35
37
  # agents/runtime/state/handoff-context.md, inject it as spotlighted DATA
@@ -40,18 +42,22 @@ concerns:
40
42
  script: src/scripts/handoff_context_hook.ts
41
43
  args: []
42
44
  fail_closed: false
45
+ severity: advisory
43
46
  roadmap-progress:
44
47
  script: src/scripts/roadmap_progress_hook.ts
45
48
  args: []
46
49
  fail_closed: false
50
+ severity: advisory
47
51
  onboarding-gate:
48
52
  script: src/scripts/onboarding_gate_hook.ts
49
53
  args: []
50
54
  fail_closed: false
55
+ severity: advisory
51
56
  context-hygiene:
52
57
  script: src/scripts/context_hygiene_hook.ts
53
58
  args: []
54
59
  fail_closed: false
60
+ severity: advisory
55
61
  # Phase 5 — Tier-1 hook for the verify-before-complete rule. Records
56
62
  # observable evidence of verification commands (tests, quality tools,
57
63
  # builds) into agents/runtime/state/verify-before-complete.json. The rule body
@@ -60,6 +66,7 @@ concerns:
60
66
  script: src/scripts/before_complete_hook.ts
61
67
  args: []
62
68
  fail_closed: false
69
+ severity: advisory
63
70
  # Phase 5 — Tier-1 hook for the minimal-safe-diff rule. Counts unique
64
71
  # files touched per turn into agents/runtime/state/minimal-safe-diff.json and
65
72
  # flips warning=true past hooks.minimal_safe_diff.threshold.
@@ -67,6 +74,7 @@ concerns:
67
74
  script: src/scripts/minimal_safe_diff_hook.ts
68
75
  args: []
69
76
  fail_closed: false
77
+ severity: advisory
70
78
  # road-to-security-pillar.md P3.2 — PostToolUse prompt-injection scanner.
71
79
  # Warn-in-context (exit 2), never blocks. Default-OFF: no-ops unless
72
80
  # hooks.injection_scan.enabled is true in .agent-settings.yml. The runtime
@@ -75,6 +83,7 @@ concerns:
75
83
  script: src/scripts/injection_scan_hook.ts
76
84
  args: []
77
85
  fail_closed: false
86
+ severity: advisory
78
87
  # road-to-security-hardening Phase 3 — PreToolUse guard turning the
79
88
  # git-history-discipline Iron Law into a deterministic gate. Blocks
80
89
  # `git … --no-verify` / `-n` and `git -c core.hooksPath=…` (which would
@@ -86,6 +95,7 @@ concerns:
86
95
  script: src/scripts/hooks/block_no_verify.ts
87
96
  args: []
88
97
  fail_closed: true
98
+ severity: blocking
89
99
  # road-to-ai-employee-borrowings Phase 1 — kernel immutability Layer 1.
90
100
  # PreToolUse guard: DENIES Write/Edit/NotebookEdit (and cross-platform
91
101
  # equivalents) whose target path is a kernel rule file — the source tree
@@ -101,6 +111,7 @@ concerns:
101
111
  script: src/scripts/hooks/block_kernel_rule_writes.ts
102
112
  args: []
103
113
  fail_closed: true
114
+ severity: blocking
104
115
  # road-to-renewal-leverage Phase 2 — "fix the code, not the config".
105
116
  # PreToolUse guard over the recorded allowlist-growth antipattern
106
117
  # (autonomous-execution § Antipattern): warns as an allowlist grows within a
@@ -115,6 +126,7 @@ concerns:
115
126
  script: src/scripts/hooks/block_config_weakening.ts
116
127
  args: []
117
128
  fail_closed: false
129
+ severity: blocking
118
130
  # token-saving Phase 3 — PreToolUse RTK-wrap nudge. DETERMINISTIC: keys off a
119
131
  # live `which rtk` probe (not the self-reported flag). When rtk is installed
120
132
  # and the agent is about to run a single verbose CLI command that is NOT
@@ -127,6 +139,7 @@ concerns:
127
139
  script: src/scripts/hooks/rtk_wrap_hook.ts
128
140
  args: []
129
141
  fail_closed: false
142
+ severity: advisory
130
143
  # road-to-anti-slop-detector Phase 3 — OPTIONAL PreToolUse anti-slop nudge.
131
144
  # Runs the lint_design_slop registry against about-to-be-written UI content
132
145
  # and WARNS (exit 2) on P0/P1 aesthetic tells. Never blocks (flags, not
@@ -138,6 +151,7 @@ concerns:
138
151
  script: src/scripts/hooks/design_slop_hook.ts
139
152
  args: []
140
153
  fail_closed: false
154
+ severity: advisory
141
155
  # ADR-124 Phase 4 — OPTIONAL PreToolUse code-graph nudge. When a code-graph
142
156
  # source is present and the agent is about to search/read source, WARNS
143
157
  # (exit 2) once per session to query the graph first (or rebuild if stale,
@@ -148,6 +162,7 @@ concerns:
148
162
  script: src/scripts/hooks/code_graph_nudge_hook.ts
149
163
  args: []
150
164
  fail_closed: false
165
+ severity: advisory
151
166
  # road-to-reachable-code-memory Phase 4 — session_end learning-sidecar
152
167
  # aggregation. Joins the EXISTING session_end concern set (no new platform
153
168
  # wiring). Aggregates agents/memory/intake/*.jsonl through the learning
@@ -161,6 +176,7 @@ concerns:
161
176
  script: src/scripts/memory_learn_hook.ts
162
177
  args: []
163
178
  fail_closed: false
179
+ severity: advisory
164
180
  # Phase 2 of road-to-hooks-actually-fire-in-consumers — session_start
165
181
  # gate that surfaces the marketplace-install-but-unscaffolded shape.
166
182
  # Writes .augment/.first-run-action-needed.md + one stderr line so
@@ -170,6 +186,7 @@ concerns:
170
186
  script: src/scripts/first_run_gate_hook.ts
171
187
  args: []
172
188
  fail_closed: false
189
+ severity: advisory
173
190
  # session-profile-activation Phase 1 — emits a one-line staleness notice
174
191
  # when a new session starts with a `runtime.active_packs` overlay carried
175
192
  # over from a previous session. Never resets (option a, explicit
@@ -178,6 +195,7 @@ concerns:
178
195
  script: src/scripts/profile_staleness_hook.ts
179
196
  args: []
180
197
  fail_closed: false
198
+ severity: advisory
181
199
  # Defense-in-depth twin of the update-command wrapper refresh
182
200
  # (cmd_upgrade / cmd_refresh). On session_start, re-stamps a stale
183
201
  # project-local ./agent-config wrapper from the canonical template so
@@ -187,6 +205,7 @@ concerns:
187
205
  script: src/scripts/wrapper_freshness_hook.ts
188
206
  args: []
189
207
  fail_closed: false
208
+ severity: advisory
190
209
  # road-to-install-path-convergence Phase 4 — duplicate-surface probe.
191
210
  # Reads src/config/surface-matrix.yml, existence-checks declared duplicate
192
211
  # classes, nudges ONCE per day (state: agents/runtime/state/
@@ -196,6 +215,7 @@ concerns:
196
215
  script: src/scripts/surface_probe_hook.ts
197
216
  args: []
198
217
  fail_closed: false
218
+ severity: advisory
199
219
  # road-to-team-mode Phase 4 — managed Review-Gate circuit breaker.
200
220
  # Runs AFTER the plugin's own Stop gate: reads the persisted gate job
201
221
  # record, counts consecutive BLOCK verdicts per session, injects the
@@ -207,6 +227,7 @@ concerns:
207
227
  script: src/scripts/team_review_gate_hook.ts
208
228
  args: []
209
229
  fail_closed: false
230
+ severity: advisory
210
231
  # Reply-shape backstop for the direct-answers rule (reply-close): a PR
211
232
  # created THIS turn must end the reply as the literal last raw URL. This
212
233
  # PostToolUse hook fires right after a PR *create* (gh pr create, or a
@@ -218,6 +239,7 @@ concerns:
218
239
  script: src/scripts/pr_url_reminder_hook.ts
219
240
  args: []
220
241
  fail_closed: false
242
+ severity: advisory
221
243
  # session_start canary injection — deterministic carrier for the
222
244
  # session-canary rule (greet-by-name at task start + reply-close markers
223
245
  # as a user-observable context-degradation signal). Gated on
@@ -229,6 +251,7 @@ concerns:
229
251
  script: src/scripts/session_canary_hook.ts
230
252
  args: []
231
253
  fail_closed: false
254
+ severity: advisory
232
255
 
233
256
  platforms:
234
257
  augment:
@@ -47,6 +47,7 @@ import {
47
47
  setHookStdinOverride,
48
48
  clearHookStdinOverride,
49
49
  } from "./hook_stdin.js";
50
+ import { emitFor, type Severity } from "./host_semantics.js";
50
51
 
51
52
  // Free-form JSON values flow through every helper here; a documented
52
53
  // alias keeps the surface honest without `any` (ADR-200 § strict TS).
@@ -103,6 +104,19 @@ export function _severity_for(rc: number): string {
103
104
  return _SEVERITY_BY_EXIT[rc] ?? "error";
104
105
  }
105
106
 
107
+ /**
108
+ * P0.2 (road-to-rule-coherence) — is this concern declared advisory?
109
+ *
110
+ * An advisory concern MUST never produce a BLOCK verdict on any host. Four
111
+ * PreToolUse concerns document themselves as advisory in prose
112
+ * (`design_slop_hook`: "FLAGS, NEVER A BLOCK") while the transport happily
113
+ * turned their WARN into a host-level deny. Prose is not enforcement: the
114
+ * manifest now declares severity and the dispatcher enforces the ceiling.
115
+ */
116
+ export function _is_advisory(concern: JsonObject): boolean {
117
+ return String(concern["severity"] ?? "").trim().toLowerCase() === "advisory";
118
+ }
119
+
106
120
  function _now_iso(): string {
107
121
  // Python: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ").
108
122
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -976,6 +990,11 @@ export function main(argv?: string[]): number {
976
990
  // stdout-injection; harmless surfacing elsewhere). All other events keep
977
991
  // the swallow-stdout contract unchanged.
978
992
  const context_blocks: string[] = [];
993
+ // Per-concern message for the host emission (P0.1). Kept OUT of
994
+ // feedback_entries on purpose: that record is written to disk with a
995
+ // fixed-field schema (PII-exclusion-by-construction), and a concern's raw
996
+ // stderr is free-form content. This array is in-memory only.
997
+ const concern_messages: Array<{ rc: number; text: string }> = [];
979
998
  for (const concern of concerns) {
980
999
  const concern_started = _now_iso();
981
1000
  const { rc: rawRcResult, stderr: stderr_text, stdout: stdout_text, duration_ms } =
@@ -992,8 +1011,30 @@ export function main(argv?: string[]): number {
992
1011
  process.stderr.write(stderr_text);
993
1012
  }
994
1013
  }
1014
+ // P0.2 severity ceiling: an advisory concern can never block, on any host.
1015
+ // This covers BOTH the concern emitting EXIT_BLOCK directly and the
1016
+ // fail_closed promotion above turning a crash into a block.
1017
+ if (rc === EXIT_BLOCK && _is_advisory(concern)) {
1018
+ process.stderr.write(
1019
+ `dispatch_hook: concern '${String(concern["name"])}' is declared ` +
1020
+ `severity: advisory but returned BLOCK — downgraded to warn.\n`,
1021
+ );
1022
+ rc = EXIT_WARN;
1023
+ }
995
1024
  rcs.push(rc);
996
1025
  const reply = _parse_concern_stdout(stdout_text);
1026
+ // A concern states its reason either as JSON {"reason": …} on stdout
1027
+ // (advisory concerns) or as a formatted stderr line (the block guards, e.g.
1028
+ // `block-no-verify: BLOCKED — …`). Both are captured here so the emission
1029
+ // layer can surface the REAL message instead of a generic label; before
1030
+ // this, a rc=1 block discarded the concern's stderr entirely.
1031
+ const stated =
1032
+ typeof reply["reason"] === "string" && (reply["reason"] as string).trim()
1033
+ ? (reply["reason"] as string).trim()
1034
+ : stderr_text.trim();
1035
+ if (stated) {
1036
+ concern_messages.push({ rc, text: stated });
1037
+ }
997
1038
  if (
998
1039
  args.event === "session_start" &&
999
1040
  rc === EXIT_ALLOW &&
@@ -1021,7 +1062,30 @@ export function main(argv?: string[]): number {
1021
1062
  if (context_blocks.length > 0 && final_rc === EXIT_ALLOW) {
1022
1063
  process.stdout.write(context_blocks.join("\n\n") + "\n");
1023
1064
  }
1024
- return final_rc;
1065
+
1066
+ // P0.1 host-semantics translation. The internal ladder (0/1/2) is written to
1067
+ // the feedback dir verbatim above — that record is unchanged. What leaves the
1068
+ // process is now the HOST's native contract, because the two languages
1069
+ // disagree: on Claude Code exit 1 does not block and exit 2 does, which
1070
+ // inverted every verdict (see host_semantics.ts for the documented mapping).
1071
+ // Unverified platforms keep the legacy pass-through byte-for-byte.
1072
+ const decidingReasons = concern_messages
1073
+ .filter((m) => m.rc === final_rc)
1074
+ .map((m) => m.text);
1075
+ const emission = emitFor(
1076
+ args.platform,
1077
+ args.event,
1078
+ _severity_for(final_rc) as Severity,
1079
+ decidingReasons,
1080
+ final_rc,
1081
+ );
1082
+ if (emission.stdout) {
1083
+ process.stdout.write(emission.stdout);
1084
+ }
1085
+ if (emission.stderr) {
1086
+ process.stderr.write(emission.stderr);
1087
+ }
1088
+ return emission.exit;
1025
1089
  }
1026
1090
 
1027
1091
  function _isDir(p: string): boolean {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * host_semantics — translate the dispatcher's internal severity ladder into
3
+ * each host's NATIVE hook contract.
4
+ *
5
+ * WHY THIS EXISTS (road-to-rule-coherence P0.1). The dispatcher speaks one
6
+ * internal language: `0 allow · 1 block · 2 warn` (`dispatch_hook.EXIT_*`).
7
+ * Claude Code's hook contract does NOT share that language, and the mismatch
8
+ * inverted every verdict on that host:
9
+ *
10
+ * - Claude Code treats exit **1** as a NON-blocking error and proceeds with
11
+ * the action. So `EXIT_BLOCK = 1` did not block: `block-no-verify` and
12
+ * `block-kernel-rule-writes` — both `fail_closed: true` guards — were
13
+ * inert on Claude Code.
14
+ * - Claude Code treats exit **2** on PreToolUse as a BLOCKING error. So a
15
+ * purely advisory `EXIT_WARN = 2` hard-denied the tool call, even though
16
+ * `design_slop_hook` documents "FLAGS, NEVER A BLOCK" three times.
17
+ * - JSON on stdout is parsed ONLY on exit 0. The old code wrote
18
+ * `{"decision":"warn","reason":…}` to stdout AND exited 2, so the reason
19
+ * was discarded — the observed symptom was a bare
20
+ * `PreToolUse:Edit hook error: … No stderr output`.
21
+ *
22
+ * Source: Claude Code hooks documentation — "For most hook events, only exit
23
+ * code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking
24
+ * error and proceeds with the action, even though 1 is the conventional Unix
25
+ * failure code. If your hook is meant to enforce a policy, use `exit 2`." and
26
+ * "Claude Code only processes JSON on exit 0."
27
+ *
28
+ * DESIGN. The pure reducer in `dispatch_hook._reduce` is unchanged — concerns
29
+ * keep emitting the internal ladder and need no rewrite. Only the final
30
+ * emission is translated, here, per (platform, event). Hosts whose native
31
+ * contract has NOT been verified against documentation keep the legacy
32
+ * pass-through, so this fix cannot silently change behaviour on a host nobody
33
+ * measured. That is deliberate: a speculative mapping is the same class of bug
34
+ * this module exists to remove.
35
+ */
36
+
37
+ /** Internal severity, mirroring dispatch_hook's EXIT_ALLOW / BLOCK / WARN. */
38
+ export type Severity = "allow" | "warn" | "block";
39
+
40
+ /** What the dispatcher should actually do on the way out. */
41
+ export interface Emission {
42
+ /** Process exit code to hand the host. */
43
+ exit: number;
44
+ /** Text for stdout (already serialized). Empty = write nothing. */
45
+ stdout: string;
46
+ /** Text for stderr. Empty = write nothing. */
47
+ stderr: string;
48
+ }
49
+
50
+ /**
51
+ * Hosts with a documented, verified native hook contract. Anything absent
52
+ * from this set falls through to the legacy pass-through.
53
+ */
54
+ export const VERIFIED_PLATFORMS: ReadonlySet<string> = new Set(["claude"]);
55
+
56
+ /** Internal event name -> Claude Code `hookEventName` for JSON output. */
57
+ const CLAUDE_HOOK_EVENT_NAME: Record<string, string> = {
58
+ pre_tool_use: "PreToolUse",
59
+ post_tool_use: "PostToolUse",
60
+ user_prompt_submit: "UserPromptSubmit",
61
+ session_start: "SessionStart",
62
+ session_end: "SessionEnd",
63
+ stop: "Stop",
64
+ pre_compact: "PreCompact",
65
+ };
66
+
67
+ /**
68
+ * Events where Claude Code honours exit 2 as a real block. Everywhere else
69
+ * exit 2 either cannot block (PostToolUse: "the tool already ran";
70
+ * SessionStart: "shows stderr to user only") or is far too blunt, so a block
71
+ * degrades to visible advisory context instead of a silent no-op.
72
+ */
73
+ const CLAUDE_BLOCK_CAPABLE_EVENTS: ReadonlySet<string> = new Set([
74
+ "pre_tool_use",
75
+ "user_prompt_submit",
76
+ "stop",
77
+ ]);
78
+
79
+ function _joinReasons(reasons: readonly string[]): string {
80
+ return reasons.map((r) => r.trim()).filter((r) => r.length > 0).join(" · ");
81
+ }
82
+
83
+ /**
84
+ * Build the Claude Code `hookSpecificOutput.additionalContext` envelope.
85
+ *
86
+ * Exit 0 + this JSON is the documented way to surface advisory feedback
87
+ * WITHOUT blocking — the mapping the four advisory PreToolUse concerns
88
+ * (design-slop, code-graph-nudge, rtk-wrap, block-config-weakening's
89
+ * advisory path) always intended.
90
+ */
91
+ export function claudeAdditionalContext(event: string, text: string): string {
92
+ const hookEventName = CLAUDE_HOOK_EVENT_NAME[event] ?? event;
93
+ return `${JSON.stringify({
94
+ hookSpecificOutput: { hookEventName, additionalContext: text },
95
+ })}\n`;
96
+ }
97
+
98
+ /**
99
+ * Translate (platform, event, severity, reasons) into a native emission.
100
+ *
101
+ * `legacyExit` is the internal reduced code; unverified platforms get it back
102
+ * verbatim so their behaviour is byte-identical to before this module landed.
103
+ */
104
+ export function emitFor(
105
+ platform: string,
106
+ event: string,
107
+ severity: Severity,
108
+ reasons: readonly string[],
109
+ legacyExit: number,
110
+ ): Emission {
111
+ if (!VERIFIED_PLATFORMS.has(platform)) {
112
+ return { exit: legacyExit, stdout: "", stderr: "" };
113
+ }
114
+ const reason = _joinReasons(reasons);
115
+
116
+ if (severity === "allow") {
117
+ return { exit: 0, stdout: "", stderr: "" };
118
+ }
119
+
120
+ if (severity === "block") {
121
+ if (CLAUDE_BLOCK_CAPABLE_EVENTS.has(event)) {
122
+ // Exit 2 + stderr: the ONLY documented way to make Claude Code
123
+ // refuse the action and feed the reason back to the model.
124
+ const label = reason || "blocked by agent-config hook policy";
125
+ return { exit: 2, stdout: "", stderr: `${label}\n` };
126
+ }
127
+ // Not block-capable (post_tool_use, session_start, …). Exit 2 here
128
+ // would discard stdout and still not block, so surface the reason as
129
+ // context rather than losing it.
130
+ const label = reason || "agent-config hook reported a blocking finding";
131
+ return { exit: 0, stdout: claudeAdditionalContext(event, label), stderr: "" };
132
+ }
133
+
134
+ // severity === "warn" — advisory. NEVER exit 2 on a block-capable event:
135
+ // that is precisely the inversion this module removes.
136
+ if (!reason) {
137
+ return { exit: 0, stdout: "", stderr: "" };
138
+ }
139
+ return { exit: 0, stdout: claudeAdditionalContext(event, reason), stderr: "" };
140
+ }
@@ -8,10 +8,13 @@
8
8
  // (logical-path matching via strip_source_prefix), JSON/quiet modes, the
9
9
  // path-does-not-exist exit(2), and the `_`-prefixed-file skip.
10
10
  import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
11
12
  import * as path from 'node:path';
12
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
14
 
14
15
  import { artefact_roots, strip_source_prefix } from './_lib/agent_src.js';
16
+ import { GateLedger } from './_lib/gate_ledger.js';
17
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
15
18
 
16
19
  const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
17
20
  const REAL_REPO_ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
@@ -295,7 +298,19 @@ export function has_framework_frontmatter(p: string): string | null {
295
298
  interface AllowlistEntry {
296
299
  file?: string;
297
300
  lines?: number[] | '*';
301
+ /**
302
+ * Content anchor — a substring of the exempted line.
303
+ *
304
+ * Position-keyed entries (`lines: [100]`) are drift-fragile by
305
+ * construction: inserting a paragraph above line 100 silently re-fires the
306
+ * ratchet on an entry nobody touched, which this repository has recorded as
307
+ * a real cost. An anchor keys the exemption to what is actually being
308
+ * exempted, so the entry survives the edit that moves it. Prefer it for new
309
+ * entries; `lines` stays supported for the existing ones.
310
+ */
311
+ anchor?: string;
298
312
  reason?: string;
313
+ falsifier?: string;
299
314
  }
300
315
  interface Allowlist {
301
316
  entries: AllowlistEntry[];
@@ -313,7 +328,12 @@ function _load_allowlist(): Allowlist {
313
328
  }
314
329
  }
315
330
 
316
- function _allowlisted(rel_path: string, line_no: number, allowlist: Allowlist): boolean {
331
+ function _allowlisted(
332
+ rel_path: string,
333
+ line_no: number,
334
+ allowlist: Allowlist,
335
+ line_text = '',
336
+ ): boolean {
317
337
  const logical = strip_source_prefix(rel_path);
318
338
  for (const entry of allowlist.entries ?? []) {
319
339
  const entry_file = entry.file;
@@ -326,6 +346,12 @@ function _allowlisted(rel_path: string, line_no: number, allowlist: Allowlist):
326
346
  if (lines === '*') {
327
347
  return true;
328
348
  }
349
+ // Content anchor before line number: an anchored entry keeps matching
350
+ // after an insertion moves the line, which a position key cannot.
351
+ const anchor = entry.anchor;
352
+ if (typeof anchor === 'string' && anchor !== '' && line_text.includes(anchor)) {
353
+ return true;
354
+ }
329
355
  if (Array.isArray(lines) && lines.includes(line_no)) {
330
356
  return true;
331
357
  }
@@ -465,10 +491,12 @@ function* iter_md_files(paths: Iterable<string>): Generator<string> {
465
491
  const pb = toPosix(b);
466
492
  return pa < pb ? -1 : pa > pb ? 1 : 0;
467
493
  });
494
+ // Underscore-prefixed files are NOT filtered here. They used to be, and
495
+ // that made them invisible to every count downstream: the generator
496
+ // dropped them before `scannedFiles` ever saw one. The exclusion is
497
+ // still applied — in `main`, where the ledger records it as a named
498
+ // out-of-scope outcome instead of a silent `continue`.
468
499
  for (const f of collected) {
469
- if (path.basename(f).startsWith('_')) {
470
- continue;
471
- }
472
500
  yield f;
473
501
  }
474
502
  }
@@ -510,38 +538,122 @@ function parse_args(argv: readonly string[]): { json: boolean; quiet: boolean; p
510
538
  return { json, quiet, paths: paths.length > 0 ? paths : _default_paths() };
511
539
  }
512
540
 
541
+ /**
542
+ * The allowlist entry that would silence one finding, ready to paste.
543
+ *
544
+ * Anchored on the matched token rather than on the line number: a
545
+ * position-keyed entry re-fires the moment an insertion above moves the line,
546
+ * on an entry nobody touched, which `check_suppression_hygiene` counts every
547
+ * run until the existing 18 migrate.
548
+ */
549
+ export function suppressionKey(rel: string, hit: Hit): string {
550
+ const anchor = hit.snippet.trim().slice(0, 60);
551
+ return JSON.stringify({
552
+ file: rel,
553
+ anchor,
554
+ reason: '<why this token is quoted content rather than a mandate>',
555
+ falsifier: `./scripts-run src/scripts/lint_framework_leakage --paths ${rel}`,
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Prove, against the real CLI, that this gate still rejects what it must.
561
+ *
562
+ * The floor is 3 cases with at least 2 rejecting: a clean file alone would be a
563
+ * suite that only ever passes, which proves nothing about detection.
564
+ */
565
+ export function selfTest(): number {
566
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fwl-selftest-'));
567
+ const write = (rel: string, body: string): string => {
568
+ const p = path.join(tmp, rel);
569
+ fs.mkdirSync(path.dirname(p), { recursive: true });
570
+ fs.writeFileSync(p, body, 'utf-8');
571
+ return p;
572
+ };
573
+ const run = (target: string): number =>
574
+ runGateCli(REAL_REPO_ROOT, 'src/scripts/lint_framework_leakage.ts', ['--paths', target, '--quiet'], REAL_REPO_ROOT);
575
+
576
+ try {
577
+ const leak = write('generic/skills/demo/SKILL.md', '# Demo\n\nAlways run `php artisan migrate` here.\n');
578
+ const clean = write('clean/skills/demo/SKILL.md', '# Demo\n\nRun the project migration command.\n');
579
+ const framed = write(
580
+ 'framed/skills/demo/SKILL.md',
581
+ '---\nframework: laravel\n---\n\n# Demo\n\nAlways run `php artisan migrate` here.\n',
582
+ );
583
+ return runSelfTest({
584
+ gate: 'lint_framework_leakage',
585
+ minCases: 3,
586
+ minRejectCases: 2,
587
+ cases: [
588
+ { name: 'a framework mandate in a generic artefact is rejected', expect: 'reject', run: () => run(leak) },
589
+ { name: 'a neutral artefact passes', expect: 'accept', run: () => run(clean) },
590
+ {
591
+ name: 'a second family (PHP tooling, not Laravel) is rejected — detection is not one pattern',
592
+ expect: 'reject',
593
+ run: () =>
594
+ run(write('generic2/skills/demo/SKILL.md', '# Demo\n\nRun PHPStan before every commit.\n')),
595
+ },
596
+ {
597
+ name: 'a `framework:`-declared artefact is exempt',
598
+ expect: 'accept',
599
+ run: () => run(framed),
600
+ },
601
+ ],
602
+ });
603
+ } finally {
604
+ fs.rmSync(tmp, { recursive: true, force: true });
605
+ }
606
+ }
607
+
513
608
  export function main(argv?: readonly string[]): number {
514
- const args = parse_args(argv ?? process.argv.slice(2));
609
+ const raw = argv ?? process.argv.slice(2);
610
+ if (raw.includes('--self-test')) {
611
+ return selfTest();
612
+ }
613
+ const args = parse_args(raw);
515
614
 
516
615
  const allowlist = _load_allowlist();
517
616
  const file_hits: Array<[string, Hit[]]> = [];
518
617
  let total_hits = 0;
519
618
  let allowlisted_total = 0;
520
- // gate-coverage contract (src/config/gate-coverage.yml): files actually
521
- // inspected. NOTE the "N files" in the human summary is files WITH HITS —
522
- // reading that as coverage is exactly how a blind gate looks healthy.
523
- let scannedFiles = 0;
524
-
525
- for (const f of iter_md_files(args.paths)) {
526
- scannedFiles += 1;
619
+ // Every enumerated file is planned up front, so the five exemption branches
620
+ // below stop being invisible `continue`s. Before the ledger, `scannedFiles`
621
+ // counted files *enumerated*, not files *judged* — a carve-out and a clean
622
+ // file were indistinguishable in the published number.
623
+ const ledger = new GateLedger('lint_framework_leakage');
624
+ const enumerated = [...iter_md_files(args.paths)];
625
+ ledger.plan(enumerated.map(relToRepo));
626
+
627
+ for (const f of enumerated) {
628
+ const rel = relToRepo(f);
629
+ // Partials are fragments included into a parent artefact; the parent is
630
+ // the unit this gate judges.
631
+ if (path.basename(f).startsWith('_')) {
632
+ ledger.outOfScope(rel, 'not_applicable_kind');
633
+ continue;
634
+ }
527
635
  if (is_carve_out(f)) {
636
+ ledger.outOfScope(rel, 'declared_exemption');
528
637
  continue;
529
638
  }
530
639
  if (is_inventory_file(f)) {
640
+ ledger.outOfScope(rel, 'not_applicable_kind');
531
641
  continue;
532
642
  }
533
643
  if (has_framework_frontmatter(f)) {
644
+ ledger.outOfScope(rel, 'declared_exemption');
534
645
  continue;
535
646
  }
536
647
  // Self-exemption: the neutrality rule itself enumerates the forbidden
537
648
  // tokens (frontmatter triggers + fix table) — it can never be clean
538
649
  // by construction.
539
650
  if (f.endsWith(`${path.sep}framework-neutrality-in-generic-skills.md`)) {
651
+ ledger.outOfScope(rel, 'declared_exemption');
540
652
  continue;
541
653
  }
542
- const rel = relToRepo(f);
543
654
  const raw_hits = scan_file(f);
544
655
  if (raw_hits.length === 0) {
656
+ ledger.complete(rel);
545
657
  continue;
546
658
  }
547
659
  const kept: Hit[] = [];
@@ -549,7 +661,7 @@ export function main(argv?: readonly string[]): number {
549
661
  if (h.cross_stack) {
550
662
  continue;
551
663
  }
552
- if (_allowlisted(rel, h.line, allowlist)) {
664
+ if (_allowlisted(rel, h.line, allowlist, h.snippet)) {
553
665
  h.allowlisted = true;
554
666
  allowlisted_total += 1;
555
667
  continue;
@@ -560,6 +672,10 @@ export function main(argv?: readonly string[]): number {
560
672
  if (kept.length > 0) {
561
673
  file_hits.push([f, kept]);
562
674
  total_hits += kept.length;
675
+ ledger.fail(rel, `${String(kept.length)} un-allowlisted leakage hit(s)`);
676
+ } else {
677
+ // Every raw hit was cross-stack or allowlisted: the file WAS judged.
678
+ ledger.complete(rel);
563
679
  }
564
680
  }
565
681
 
@@ -569,6 +685,10 @@ export function main(argv?: readonly string[]): number {
569
685
  allowlisted: allowlisted_total,
570
686
  };
571
687
 
688
+ // Finalize before either output branch: an unaccounted target must throw
689
+ // rather than let a formatted result be printed over it.
690
+ const tally = ledger.finalize();
691
+
572
692
  if (args.json) {
573
693
  const out = {
574
694
  version: 1,
@@ -576,6 +696,7 @@ export function main(argv?: readonly string[]): number {
576
696
  hits.map((h) => ({ file: relToRepo(p), ...h })),
577
697
  ),
578
698
  summary,
699
+ ledger: tally,
579
700
  };
580
701
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
581
702
  return total_hits ? 1 : 0;
@@ -590,6 +711,11 @@ export function main(argv?: readonly string[]): number {
590
711
  const lineCol = String(h.line).padStart(4, ' ');
591
712
  const catCol = h.category.padEnd(16, ' ');
592
713
  process.stdout.write(` L${lineCol} ${catCol} /${h.pattern}/ ${h.snippet}\n`);
714
+ // The suppression key travels WITH the finding, copy-pasteable and
715
+ // anchor-keyed. Friction in the narrow path is what pushes a
716
+ // maintainer to the blunt off-switch instead; and an anchor keeps
717
+ // matching after an edit moves the line, which `lines: [N]` cannot.
718
+ process.stdout.write(` suppress: ${suppressionKey(rel, h)}\n`);
593
719
  }
594
720
  }
595
721
  }
@@ -598,7 +724,13 @@ export function main(argv?: readonly string[]): number {
598
724
  `\n${total_hits} hits across ${file_hits.length} files ` +
599
725
  `(${allowlisted_total} allowlisted)\n`,
600
726
  );
601
- process.stderr.write(`scanned: ${String(scannedFiles)}\n`);
727
+ ledger.report();
728
+ // gate-coverage contract (src/config/gate-coverage.yml): files actually
729
+ // JUDGED — completed plus failed. It used to be files *enumerated*, which
730
+ // over-reported coverage by every exempted file (438 published against 368
731
+ // judged) and is the same "reading M as coverage" trap the human summary
732
+ // carries. The ledger makes the honest number the cheap one to publish.
733
+ process.stderr.write(`scanned: ${String(tally.completed + tally.failed)}\n`);
602
734
  return total_hits ? 1 : 0;
603
735
  }
604
736