@event4u/agent-config 9.22.0 → 9.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- 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
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
|