@event4u/agent-config 9.27.0 → 9.28.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 (138) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +12 -11
  3. package/CHANGELOG.md +99 -0
  4. package/README.md +1 -1
  5. package/dist/agent-src/commands/analyze/conformance.md +24 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
  7. package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
  8. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  9. package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
  10. package/dist/agent-src/rules/context-hygiene.md +3 -1
  11. package/dist/agent-src/rules/council-availability.md +93 -0
  12. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  13. package/dist/agent-src/rules/delegation-policy.md +29 -5
  14. package/dist/agent-src/rules/design-fidelity.md +3 -0
  15. package/dist/agent-src/rules/evaluator-independence.md +2 -1
  16. package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
  17. package/dist/agent-src/rules/finance-safety-floor.md +1 -1
  18. package/dist/agent-src/rules/git-history-discipline.md +1 -1
  19. package/dist/agent-src/rules/history-discipline.md +3 -1
  20. package/dist/agent-src/rules/legal-safety-floor.md +1 -1
  21. package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
  22. package/dist/agent-src/rules/onboarding-gate.md +3 -1
  23. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
  24. package/dist/agent-src/rules/role-mode-adherence.md +1 -1
  25. package/dist/agent-src/rules/scale-discipline.md +3 -1
  26. package/dist/agent-src/rules/self-repair-loop.md +103 -0
  27. package/dist/agent-src/rules/session-canary.md +2 -1
  28. package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
  29. package/dist/agent-src/rules/token-budget-discipline.md +3 -1
  30. package/dist/agent-src/rules/tool-safety.md +5 -1
  31. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  32. package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
  33. package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
  34. package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
  35. package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
  36. package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
  37. package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
  38. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  39. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  40. package/dist/agent-src/templates/AGENTS.md +6 -10
  41. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  42. package/dist/cli/registry.js +7 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
  45. package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
  46. package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
  47. package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
  48. package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
  49. package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
  50. package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
  51. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  52. package/dist/cli-delegate/cmd_brand_status.js +134 -0
  53. package/dist/cli-delegate/cmd_conformance.js +7 -7
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +4 -4
  56. package/dist/cli-delegate/cmd_explain.js +80 -330
  57. package/dist/cli-delegate/cmd_export.js +5 -5
  58. package/dist/cli-delegate/cmd_fleet.js +3 -3
  59. package/dist/cli-delegate/cmd_handoff.js +2 -2
  60. package/dist/cli-delegate/cmd_mcp_available.js +433 -0
  61. package/dist/cli-delegate/cmd_migrate.js +3 -3
  62. package/dist/cli-delegate/cmd_packs_active.js +153 -0
  63. package/dist/cli-delegate/cmd_preflight.js +1 -1
  64. package/dist/cli-delegate/cmd_prune.js +5 -5
  65. package/dist/cli-delegate/cmd_route_audit.js +8 -8
  66. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  67. package/dist/cli-delegate/cmd_settings_get.js +272 -0
  68. package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
  69. package/dist/cli-delegate/cmd_settings_set.js +7 -2967
  70. package/dist/cli-delegate/cmd_sync.js +5 -5
  71. package/dist/cli-delegate/cmd_uninstall.js +5 -5
  72. package/dist/cli-delegate/cmd_update.js +4 -4
  73. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  74. package/dist/cli-delegate/cmd_validate.js +5 -5
  75. package/dist/cli-delegate/cmd_versions.js +4 -4
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +100 -40
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -3
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +7 -5
  82. package/dist/discovery/trust-report.md +11 -11
  83. package/dist/discovery/workspaces.json +6 -4
  84. package/dist/hooks/dispatch.js +9004 -6132
  85. package/dist/install/install.mjs +697 -507
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +127 -20
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +80 -15
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/docs/contracts/settings-classes.md +29 -8
  93. package/docs/guidelines/agent-infra/memory-access.md +14 -3
  94. package/package.json +1 -1
  95. package/src/agent-src/templates/AGENTS.md +6 -10
  96. package/src/config/budgets.yml +29 -0
  97. package/src/config/evaluator-budgets.json +4 -4
  98. package/src/config/gate-coverage.yml +26 -0
  99. package/src/scripts/_cli/cmd_brand_status.ts +174 -0
  100. package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
  101. package/src/scripts/_cli/cmd_packs_active.ts +225 -0
  102. package/src/scripts/_cli/cmd_settings_get.ts +324 -0
  103. package/src/scripts/_dispatch.bash +57 -0
  104. package/src/scripts/_lib/agent_settings.ts +82 -16
  105. package/src/scripts/_lib/carrier_divergence.ts +135 -0
  106. package/src/scripts/_lib/host_capability.ts +84 -1
  107. package/src/scripts/_lib/prompt_shape.ts +88 -0
  108. package/src/scripts/_lib/review_skipped_record.ts +102 -0
  109. package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
  110. package/src/scripts/_lib/self_repair.ts +342 -0
  111. package/src/scripts/_lib/self_repair_store.ts +100 -0
  112. package/src/scripts/_lib/transcript_entry.ts +80 -0
  113. package/src/scripts/check_branch_freshness.ts +255 -0
  114. package/src/scripts/check_council_config_location.ts +125 -1
  115. package/src/scripts/check_standing_rule_delivery.ts +314 -0
  116. package/src/scripts/config/profiles.ts +13 -3
  117. package/src/scripts/conformance_scan.ts +272 -24
  118. package/src/scripts/council_availability_hook.ts +188 -0
  119. package/src/scripts/council_cli.ts +126 -7
  120. package/src/scripts/hook_manifest.yaml +107 -26
  121. package/src/scripts/hooks/block_no_verify.ts +39 -2
  122. package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
  123. package/src/scripts/hooks/concern_registry.ts +10 -0
  124. package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
  125. package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
  126. package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
  127. package/src/scripts/install.ts +151 -0
  128. package/src/scripts/language_mirror_hook.ts +30 -37
  129. package/src/scripts/measure_scope_dedup.ts +19 -13
  130. package/src/scripts/onboarding_gate_hook.ts +40 -1
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/preamble_byte_census.ts +9 -20
  133. package/src/scripts/report_carrier_divergence.ts +364 -0
  134. package/src/scripts/report_skill_activation.ts +257 -0
  135. package/src/scripts/report_skill_obligation_violations.ts +579 -0
  136. package/src/scripts/routing_doctor.ts +77 -6
  137. package/src/scripts/self_repair_cli.ts +240 -0
  138. package/src/scripts/self_repair_hook.ts +241 -0
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Deterministic PostToolUse orchestration-telemetry capture
4
+ * (road-to-orchestrator-discipline-carriers Phase 3 / F6).
5
+ *
6
+ * Why this exists. `orchestration_record` (the CLI) is a model-carried emit
7
+ * step: the orchestrating agent runs it by hand after a dispatch. Measured
8
+ * 2026-08-07: the agent was used on real delegable work 370 times in this
9
+ * repo over one month and the CLI captured exactly 1 of them (0.27%). The
10
+ * capture rate is a hook-reachability defect, not a usage defect — the same
11
+ * data `orchestration_backfill.ts` already reads out of the host transcript
12
+ * (`toolUseResult.{resolvedModel,totalTokens,totalDurationMs,isAsync,status}`)
13
+ * is available live, on the `post_tool_use` slot, the moment the dispatch
14
+ * completes. This concern makes the capture structural instead of hoped-for.
15
+ *
16
+ * Fires only on an `Agent` / `Task` tool-use completion (the two names this
17
+ * repo has observed for the subagent-dispatch tool across hosts/versions).
18
+ * Every other tool call is a silent no-op — same tool-filter discipline as
19
+ * `code-graph-nudge` (`tools: [Agent, Task]` in the manifest entry keeps the
20
+ * dispatcher from paying this concern's cost on unrelated calls).
21
+ *
22
+ * Sync vs async (road-to-orchestrator-first-execution measured: 326 of 370
23
+ * dispatches are async launches that never write cost back to the parent
24
+ * transcript):
25
+ * - sync completion → the tool_response carries real usage fields
26
+ * (`resolvedModel`, `totalTokens`, `totalDurationMs`) — recorded as
27
+ * measured numbers (`dispatch_tokens`, `wall_clock_ms`, `tiers`).
28
+ * - async launch ack → `isAsync: true` / `status: "async_launched"` and NO
29
+ * usage fields — the dispatch FACT is still recorded (`spawn_count: 1`)
30
+ * but every metric field is left absent (null/omitted). Never fabricated.
31
+ *
32
+ * `token_delta` has no honest value at this layer: it is the net cost of the
33
+ * dispatch versus an in-session baseline, and this hook has no baseline to
34
+ * compare against (same limitation `orchestration_backfill.ts` documents for
35
+ * its own read-only pass — "No in-session counterfactual exists on disk").
36
+ * It is therefore always written as `0` with `token_delta_provenance:
37
+ * "estimated"` — an explicit "no delta claimed" rather than a silent zero
38
+ * that could be misread as "measured no-op". The real absolute cost (when
39
+ * known) rides `dispatch_tokens`, which exists precisely for this case.
40
+ *
41
+ * PRIVACY BY CONSTRUCTION — never widen this file to read/emit free text.
42
+ * The host's own tool_use_id / session_id are high-entropy opaque tokens
43
+ * that `check_secret_leak` correctly flags as candidate credentials (hit
44
+ * during `orchestration_backfill.ts` authoring); this hook never reads them
45
+ * into the record. `id` is generated locally via `crypto.randomUUID()` —
46
+ * our own randomness, not a host-supplied value. `subagent_type` is the one
47
+ * host string recorded, and it is an id-shaped enum
48
+ * (`Explore` / `general-purpose` / `production-validator` / …), never a
49
+ * prompt or description.
50
+ *
51
+ * Uses the existing `_lib/orchestration_record.js` validation
52
+ * (`buildOrchestrationLine`) and the existing CLI's write convention
53
+ * (`orchestration_record.ts`'s `DEFAULT_DIR` + `${ts.slice(0,7)}.jsonl` +
54
+ * plain `fs.appendFileSync`, no per-line schema of its own). `chat_history.ts`
55
+ * — the other post_tool_use concern that appends a JSONL line per call —
56
+ * establishes the precedent that a hook-owned append checks
57
+ * `is_replay_mode()` and skips the dispatcher's shared state lock (the lock
58
+ * exists for atomic *overwrite*; an append is not a torn-write risk the way
59
+ * a rewritten JSON state file is).
60
+ *
61
+ * Exit code is ALWAYS 0 — on every path, including a malformed envelope, an
62
+ * unparseable tool_response, or a write failure. A hook `warn` (exit 2) is
63
+ * read as a hard BLOCK on this host (recorded trap); this concern has
64
+ * nothing to say to the model, so it never warns either.
65
+ */
66
+ import * as crypto from 'node:crypto';
67
+ import * as fs from 'node:fs';
68
+ import * as path from 'node:path';
69
+ import { fileURLToPath, pathToFileURL } from 'node:url';
70
+
71
+ import { buildOrchestrationLine, type RecordInput } from '../_lib/orchestration_record.js';
72
+ import { DEFAULT_DIR } from '../orchestration_record.js';
73
+ import { is_replay_mode } from './state_io.js';
74
+ import { readHookStdin } from './hook_stdin.js';
75
+
76
+ const EXIT_ALLOW = 0;
77
+
78
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
79
+ type JsonObject = { [k: string]: JsonValue };
80
+
81
+ function isObject(v: unknown): v is JsonObject {
82
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
83
+ }
84
+
85
+ /** The two subagent-dispatch tool names observed across hosts/versions. */
86
+ export const DISPATCH_TOOL_NAMES: ReadonlySet<string> = new Set(['Agent', 'Task']);
87
+
88
+ /**
89
+ * Unwrap the dispatcher envelope (`{schema_version, platform, event,
90
+ * payload}`) down to the platform-native payload. Falls back to the
91
+ * top-level object for direct/legacy invocation (same shape both
92
+ * `pr_url_reminder_hook.ts` and `code_graph_nudge_hook.ts` handle).
93
+ */
94
+ export function unwrapPayload(envelope: JsonObject): JsonObject {
95
+ const inner = envelope['payload'];
96
+ return isObject(inner) ? inner : envelope;
97
+ }
98
+
99
+ /** Best-effort read of the tool name off a payload, across host key variants. */
100
+ export function extractToolName(payload: JsonObject): string | null {
101
+ const v = payload['tool_name'] ?? payload['toolName'] ?? payload['tool'];
102
+ return typeof v === 'string' && v ? v : null;
103
+ }
104
+
105
+ /** Best-effort read of the tool_input object (the ORIGINAL dispatch request). */
106
+ function extractToolInput(payload: JsonObject): JsonObject | null {
107
+ const v = payload['tool_input'] ?? payload['toolInput'];
108
+ return isObject(v) ? v : null;
109
+ }
110
+
111
+ /**
112
+ * Best-effort read of the tool's result object off a payload. Tries the
113
+ * established hook-envelope keys first, then the raw transcript key
114
+ * (`toolUseResult`) `orchestration_backfill.ts` reads from the JSONL corpus —
115
+ * some hosts may preserve that shape verbatim in the dispatcher payload.
116
+ * A string value (e.g. shell stdout on unrelated tools) is never parsed as
117
+ * dispatch metrics — only an already-decoded object counts.
118
+ */
119
+ export function extractToolResult(payload: JsonObject): JsonObject | null {
120
+ for (const key of ['tool_response', 'toolResponse', 'tool_result', 'toolUseResult']) {
121
+ const v = payload[key];
122
+ if (isObject(v)) return v;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /** One dispatch-completion event, reduced to what this hook will record. */
128
+ export interface DispatchFacts {
129
+ subagentType: string | null;
130
+ resolvedModel: string | null;
131
+ /** Absolute measured tokens the dispatched slice consumed. `null` = not observed (async). */
132
+ totalTokens: number | null;
133
+ totalDurationMs: number | null;
134
+ isAsync: boolean;
135
+ isError: boolean;
136
+ }
137
+
138
+ function numOrNull(v: JsonValue | undefined): number | null {
139
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
140
+ }
141
+
142
+ /**
143
+ * Model FAMILY names the `audit-log-v1` `orchestration.tiers` contract
144
+ * expects (`orchestration-telemetry.md`: `["sonnet","opus"]`, "Tier names
145
+ * only, no prompts") — never a full model id like
146
+ * `claude-sonnet-4-5-20250929` or `us.anthropic.claude-opus-4-…` (F9,
147
+ * review). Ordered so a longer/more-specific family never loses to a
148
+ * substring of another (none currently overlap, but `haiku`/`sonnet`/`opus`/
149
+ * `fable` are checked as whole tokens via case-insensitive substring, not
150
+ * prefix, so any future id shape that embeds the family name anywhere still
151
+ * resolves).
152
+ */
153
+ const MODEL_FAMILIES: readonly string[] = ['haiku', 'sonnet', 'opus', 'fable'];
154
+
155
+ /**
156
+ * Reduce a host-reported `resolvedModel` (a full model id) to the tier-name
157
+ * vocabulary the contract expects. No match → `null`, never the raw id —
158
+ * `buildRecordInput` below treats `null` as "omit `tiers` entirely" rather
159
+ * than writing a value the schema does not define.
160
+ */
161
+ export function extractModelFamily(resolvedModel: string): string | null {
162
+ const lower = resolvedModel.toLowerCase();
163
+ for (const family of MODEL_FAMILIES) {
164
+ if (lower.includes(family)) return family;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * Read dispatch facts from an already-identified Agent/Task tool-use payload.
171
+ * Returns `null` when the payload carries no result object at all — this
172
+ * still counts as a completion (e.g. a bare async ack with an empty body
173
+ * would be unusual, but a missing result should never fabricate metrics).
174
+ */
175
+ export function extractDispatchFacts(payload: JsonObject): DispatchFacts {
176
+ const input = extractToolInput(payload);
177
+ const subagentType =
178
+ input && typeof input['subagent_type'] === 'string' ? (input['subagent_type'] as string) : null;
179
+
180
+ const result = extractToolResult(payload);
181
+ const resolvedModel =
182
+ result && typeof result['resolvedModel'] === 'string' ? (result['resolvedModel'] as string) : null;
183
+
184
+ // `usage.input_tokens` + `usage.output_tokens` is the fallback source the
185
+ // orchestration-telemetry contract names when `totalTokens` itself is
186
+ // absent (§ "token_delta sourcing priority" — the same host usage object,
187
+ // read here for the absolute count rather than a delta).
188
+ let totalTokens = result ? numOrNull(result['totalTokens']) : null;
189
+ if (totalTokens === null && result && isObject(result['usage'])) {
190
+ const usage = result['usage'] as JsonObject;
191
+ const inTok = numOrNull(usage['input_tokens']);
192
+ const outTok = numOrNull(usage['output_tokens']);
193
+ if (inTok !== null || outTok !== null) {
194
+ totalTokens = (inTok ?? 0) + (outTok ?? 0);
195
+ }
196
+ }
197
+
198
+ const totalDurationMs = result ? numOrNull(result['totalDurationMs']) : null;
199
+ const isAsync = result !== null && (result['isAsync'] === true || result['status'] === 'async_launched');
200
+ const isError = payload['is_error'] === true || payload['isError'] === true || (result !== null && result['is_error'] === true);
201
+
202
+ return { subagentType, resolvedModel, totalTokens, totalDurationMs, isAsync, isError };
203
+ }
204
+
205
+ /**
206
+ * Build the `RecordInput` for one dispatch-completion event. Pure — no I/O,
207
+ * no `Date.now()` / random call inside (both are supplied by the caller so
208
+ * the mapping itself stays independently testable).
209
+ */
210
+ export function buildRecordInput(facts: DispatchFacts, ts: string, id: string): RecordInput {
211
+ const input: RecordInput = {
212
+ spawn_count: 1,
213
+ // No in-session baseline exists at this layer to compute a delta
214
+ // against (see file header) — 0/"estimated" is an explicit
215
+ // "no delta claimed", never a measured zero.
216
+ token_delta: 0,
217
+ token_delta_provenance: 'estimated',
218
+ // This concern performs no verification of the subagent's output —
219
+ // "none" is the honest value, not the schema's "deterministic" default.
220
+ verify_mode: 'none',
221
+ ts,
222
+ id,
223
+ };
224
+ if (facts.subagentType) input.agent_combo = [facts.subagentType];
225
+ if (facts.isError) input.dispatch_outcome = 'killed';
226
+
227
+ if (!facts.isAsync) {
228
+ if (facts.resolvedModel) {
229
+ const family = extractModelFamily(facts.resolvedModel);
230
+ if (family) input.tiers = [family];
231
+ }
232
+ if (facts.totalTokens !== null) input.dispatch_tokens = facts.totalTokens;
233
+ if (facts.totalDurationMs !== null) input.wall_clock_ms = facts.totalDurationMs;
234
+ }
235
+ // Async launch ack: every metric field stays absent (undefined → the
236
+ // schema's own null/0 default) rather than fabricated from nothing.
237
+
238
+ return input;
239
+ }
240
+
241
+ /** `${ts.slice(0,7)}.jsonl` — identical file-naming convention to the CLI. */
242
+ function auditFileFor(consumerRoot: string, ts: string): string {
243
+ return path.join(consumerRoot, DEFAULT_DIR, `${ts.slice(0, 7)}.jsonl`);
244
+ }
245
+
246
+ /**
247
+ * Append one built line to the monthly audit file. Mirrors the CLI's own
248
+ * write path (`mkdirSync` + `appendFileSync`, no dispatcher lock — appends
249
+ * are not the torn-write risk `atomic_write_json`/`atomic_write_text` guard
250
+ * against) and honours `AGENT_CONFIG_REPLAY=1` the same way `chat_history.ts`
251
+ * does for its own per-call JSONL append.
252
+ */
253
+ function appendLine(consumerRoot: string, ts: string, line: Record<string, unknown>): void {
254
+ if (is_replay_mode()) return;
255
+ const file = auditFileFor(consumerRoot, ts);
256
+ fs.mkdirSync(path.dirname(file), { recursive: true });
257
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
258
+ }
259
+
260
+ /**
261
+ * Process an ALREADY-PARSED envelope. Both `run` (the test-facing,
262
+ * string-in entry point) and `main` (the real stdin path) fun this — F13
263
+ * (review): `main` used to parse stdin once to resolve `consumer_root`
264
+ * and then hand the same raw string to `run`, which parsed it a SECOND
265
+ * time. Parsing here, once, and passing the decoded value through removes
266
+ * the duplicate `JSON.parse` on the hot path without touching `run`'s
267
+ * public string-in signature (tests call it directly with a raw string).
268
+ */
269
+ function processEnvelope(envelope: JsonValue, consumer_root: string): number {
270
+ try {
271
+ if (!isObject(envelope)) return EXIT_ALLOW;
272
+
273
+ const payload = unwrapPayload(envelope);
274
+ const toolName = extractToolName(payload);
275
+ if (toolName === null || !DISPATCH_TOOL_NAMES.has(toolName)) return EXIT_ALLOW;
276
+
277
+ const facts = extractDispatchFacts(payload);
278
+ const ts = new Date().toISOString();
279
+ const id = crypto.randomUUID();
280
+ const input = buildRecordInput(facts, ts, id);
281
+
282
+ const { line, errors } = buildOrchestrationLine(input);
283
+ if (errors.length || line === null) return EXIT_ALLOW; // never block on our own validation
284
+
285
+ appendLine(consumer_root, ts, line);
286
+ } catch {
287
+ // Malformed payload, unreadable disk, anything — never block the tool call.
288
+ return EXIT_ALLOW;
289
+ }
290
+ return EXIT_ALLOW;
291
+ }
292
+
293
+ export function run(stdin_text: string, options: { consumer_root: string }): number {
294
+ let envelope: JsonValue;
295
+ try {
296
+ const raw = stdin_text.trim();
297
+ if (!raw) return EXIT_ALLOW;
298
+ envelope = JSON.parse(raw) as JsonValue;
299
+ } catch {
300
+ return EXIT_ALLOW;
301
+ }
302
+ return processEnvelope(envelope, options.consumer_root);
303
+ }
304
+
305
+ function _resolveRoot(envelope: JsonValue): string {
306
+ if (isObject(envelope)) {
307
+ const cwd = envelope['cwd'];
308
+ if (typeof cwd === 'string' && cwd) return cwd;
309
+ const pr = envelope['workspace_root'] ?? envelope['project_root'];
310
+ if (typeof pr === 'string' && pr) return pr;
311
+ }
312
+ return process.cwd();
313
+ }
314
+
315
+ export function main(): number {
316
+ const raw = readHookStdin();
317
+ let envelope: JsonValue = {};
318
+ try {
319
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
320
+ } catch {
321
+ return EXIT_ALLOW;
322
+ }
323
+ return processEnvelope(envelope, _resolveRoot(envelope));
324
+ }
325
+
326
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
327
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
328
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
329
+ function _isCliEntry(): boolean {
330
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
331
+ return false;
332
+ }
333
+ if (process.argv[1] === undefined) return false;
334
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
335
+ if (import.meta.url === argvUrl) return true;
336
+ try {
337
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+ if (_isCliEntry()) process.exit(main());
@@ -77,6 +77,7 @@ import * as installed_lock from './_lib/installed_lock.js';
77
77
  import * as scoped_projection from './_lib/scoped_projection.js';
78
78
  import * as surface_tiers from './_lib/surface_tiers.js';
79
79
  import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
80
+ import * as rule_layer_overlap from './_lib/rule_layer_overlap.js';
80
81
  import * as installed_tools from './_lib/installed_tools.js';
81
82
  import { collect_drift, format_drift_report } from './_lib/install_drift.js';
82
83
  import * as user_global_paths from './_lib/user_global_paths.js';
@@ -2113,6 +2114,137 @@ interface Options {
2113
2114
  no_ui: boolean;
2114
2115
  dry_run: boolean;
2115
2116
  apply_payload: string | null;
2117
+ /**
2118
+ * Which rule layer owns the corpus when both `~/.claude/rules/` and
2119
+ * `<project>/.claude/rules/` would carry it.
2120
+ *
2121
+ * `global` | `project` keep that layer and suppress the other via the host's
2122
+ * `claudeMdExcludes`; `both-acknowledged` keeps both and pays the doubled
2123
+ * standing context knowingly. `null` (absent) with an overlap detected is a
2124
+ * refusal, not a default — see `_gate_rule_layer_overlap`.
2125
+ */
2126
+ layer: string | null;
2127
+ }
2128
+
2129
+ /** Valid `--layer` values. Mirrors `rule_layer_overlap.LayerChoice`. */
2130
+ export const RULE_LAYER_CHOICES: readonly string[] = ['global', 'project', 'both-acknowledged'];
2131
+
2132
+ /**
2133
+ * Suppress one rules directory via the host's own `claudeMdExcludes`, written
2134
+ * into `.claude/settings.local.json`.
2135
+ *
2136
+ * `local`, not `settings.json`: the choice is per-machine (it depends on what
2137
+ * else that machine has installed), so it must not land on the shared tree.
2138
+ * The merge APPENDS — `claudeMdExcludes` arrays merge across settings layers, so
2139
+ * replacing the array would drop another layer's exclusions. Re-running is a
2140
+ * no-op. **Nothing is deleted or rewritten outside this one JSON key.**
2141
+ */
2142
+ function _suppress_rule_layer(project_root: string, suppress_dir: string): void {
2143
+ const target = path.join(project_root, '.claude', 'settings.local.json');
2144
+ const existing = pathExists(target) ? read_json_file(target) : {};
2145
+ // Realpath before building the glob. `claudeMdExcludesGlob` stays pure and
2146
+ // only normalizes, and `path.resolve` does NOT dereference symlinks — so on a
2147
+ // symlinked home or checkout (macOS `/var` → `/private/var`; the host docs
2148
+ // explicitly support reaching a file through a symlinked project path) the
2149
+ // glob would name a path the host never reports and the exclusion would
2150
+ // silently match nothing. An unresolvable path falls back to the literal.
2151
+ let real_dir = suppress_dir;
2152
+ try {
2153
+ real_dir = fs.realpathSync(suppress_dir);
2154
+ } catch {
2155
+ real_dir = suppress_dir;
2156
+ }
2157
+ const entry = rule_layer_overlap.claudeMdExcludesGlob(real_dir);
2158
+ const merged = rule_layer_overlap.mergeClaudeMdExcludes(existing['claudeMdExcludes'], entry);
2159
+ if (JSON.stringify(existing['claudeMdExcludes'] ?? null) === JSON.stringify(merged)) return;
2160
+ write_json_file(target, { ...existing, claudeMdExcludes: merged });
2161
+ success(`.claude/settings.local.json: claudeMdExcludes += ${entry}`);
2162
+ }
2163
+
2164
+ /**
2165
+ * Refuse to create a silently doubled rule corpus.
2166
+ *
2167
+ * Claude Code loads `~/.claude/rules/` and `<project>/.claude/rules/` **both**,
2168
+ * user layer first, with no dedup — a rule in both sits in standing context
2169
+ * twice. Measured on a maintainer machine 2026-08-08: 91 shared rules, 176,354
2170
+ * exact-BPE tokens standing, 74,137 of them redundant. Evidence +
2171
+ * host-contract citations:
2172
+ * `agents/evidence/analysis/standing-rule-delivery-topologies.md`.
2173
+ *
2174
+ * Contract:
2175
+ *
2176
+ * - **No overlap** → silent pass. The common case pays nothing.
2177
+ * - **Overlap and no `--layer`** → print the overlap and every flag value, then
2178
+ * return `false`. The caller exits non-zero. An overlap is not something to
2179
+ * resolve by guessing; a wrong guess either doubles the context or hides a
2180
+ * layer the user wanted.
2181
+ * - **Overlap and `--layer`** → apply it. `global` / `project` keep that layer
2182
+ * and suppress the other; `both-acknowledged` keeps both and states the cost.
2183
+ * - **Divergent bodies** → warn and refuse even WITH a `--layer`, unless the
2184
+ * choice is `both-acknowledged`. Suppressing a layer whose copies differ
2185
+ * drops whatever only the suppressed copy carried; that is a lost obligation,
2186
+ * not a saved token.
2187
+ *
2188
+ * This function never deletes and never rewrites a rule file. Deleting a user's
2189
+ * `~/.claude/rules/` would be a Hard-Floor action per
2190
+ * `non-destructive-by-default`, so no path here can reach one.
2191
+ *
2192
+ * **Known limitation — `--dry-run` does not exercise this gate.** `main()`
2193
+ * returns from `_dry_run_summary` before scope and tools are resolved, so the
2194
+ * call site below is unreachable in a dry run. The `dry_run` parameter is
2195
+ * therefore always `false` today; it is kept because it states the correct
2196
+ * contract for the day the dry-run summary moves, and because a suppression
2197
+ * write during a dry run would be the wrong behaviour whatever the ordering.
2198
+ * Surfacing the overlap in the dry-run report needs scope resolution hoisted
2199
+ * above that early return — a `main()` restructure, deliberately not bundled
2200
+ * here. Tests therefore call this function directly rather than through the CLI.
2201
+ */
2202
+ export function _gate_rule_layer_overlap(
2203
+ project_root: string,
2204
+ layer: string | null,
2205
+ dry_run: boolean,
2206
+ ): boolean {
2207
+ const global_dir = path.join(os.homedir(), '.claude', 'rules');
2208
+ const project_dir = path.join(project_root, '.claude', 'rules');
2209
+ const g = rule_layer_overlap.readRuleLayer(global_dir);
2210
+ const p = rule_layer_overlap.readRuleLayer(project_dir);
2211
+ if (g === null || p === null) return true; // only one layer exists — nothing to double
2212
+ const report = rule_layer_overlap.compareLayers(g.files, p.files);
2213
+ if (report.overlap.length === 0) return true;
2214
+
2215
+ if (layer === null) {
2216
+ warn(
2217
+ `rule layers overlap: ${report.overlap.length} rule(s) are in BOTH `
2218
+ + `${global_dir} and ${project_dir}, so Claude Code loads them twice `
2219
+ + `(${report.redundant_chars} chars of standing context per session).`,
2220
+ );
2221
+ info('Pick which layer owns the corpus — no file is deleted either way:');
2222
+ info(` --layer=global keep ${global_dir}, suppress the project one`);
2223
+ info(` --layer=project keep ${project_dir}, suppress the global one`);
2224
+ info(' --layer=both-acknowledged keep both and pay the doubled context knowingly');
2225
+ return false;
2226
+ }
2227
+
2228
+ const action = rule_layer_overlap.decideLayerAction(
2229
+ report,
2230
+ layer as rule_layer_overlap.LayerChoice,
2231
+ global_dir,
2232
+ project_dir,
2233
+ );
2234
+ if (action.refresh_required && action.suppress_dir !== null) {
2235
+ warn(
2236
+ `${report.divergent.length} shared rule(s) differ in body between the two layers `
2237
+ + `(${report.divergent.slice(0, 5).join(', ')}${report.divergent.length > 5 ? ', …' : ''}). `
2238
+ + 'Suppressing a layer now would drop whatever only that copy carries. '
2239
+ + 'Re-run the projection first, or pass --layer=both-acknowledged.',
2240
+ );
2241
+ return false;
2242
+ }
2243
+ info(action.note);
2244
+ if (action.suppress_dir !== null && !dry_run) {
2245
+ _suppress_rule_layer(project_root, action.suppress_dir);
2246
+ }
2247
+ return true;
2116
2248
  }
2117
2249
 
2118
2250
  function _resolve_scope(
@@ -3849,6 +3981,7 @@ const USAGE =
3849
3981
  ' [--tools TOOLS] [--ai AI] [--packs PACKS] [--core-only]\n' +
3850
3982
  ' [--no-smoke] [--global]\n' +
3851
3983
  ' [--scope {project,global,prompt,auto}]\n' +
3984
+ ' [--layer {global,project,both-acknowledged}]\n' +
3852
3985
  ' [--custom-path CUSTOM_PATH] [--offline] [--minimal]\n' +
3853
3986
  ' [--interactive] [--no-ui] [--dry-run]\n' +
3854
3987
  ' [--apply-payload APPLY_PAYLOAD]\n';
@@ -3884,6 +4017,7 @@ const _VALUE_FLAGS: Record<string, keyof Options> = {
3884
4017
  '--scope': 'scope',
3885
4018
  '--custom-path': 'custom_path',
3886
4019
  '--apply-payload': 'apply_payload',
4020
+ '--layer': 'layer',
3887
4021
  };
3888
4022
 
3889
4023
  function _argError(msg: string): never {
@@ -3920,6 +4054,7 @@ function parse_options(argv: string[]): Options {
3920
4054
  no_ui: false,
3921
4055
  dry_run: false,
3922
4056
  apply_payload: null,
4057
+ layer: null,
3923
4058
  };
3924
4059
 
3925
4060
  const positionals: string[] = [];
@@ -3960,6 +4095,12 @@ function parse_options(argv: string[]): Options {
3960
4095
  "(choose from 'project', 'global', 'prompt', 'auto')",
3961
4096
  );
3962
4097
  }
4098
+ if (flag === '--layer' && !RULE_LAYER_CHOICES.includes(value)) {
4099
+ _argError(
4100
+ `argument --layer: invalid choice: '${value}' ` +
4101
+ `(choose from ${RULE_LAYER_CHOICES.map((c) => `'${c}'`).join(', ')})`,
4102
+ );
4103
+ }
3963
4104
  opts[valueDest] = value;
3964
4105
  i += 1;
3965
4106
  continue;
@@ -4731,6 +4872,16 @@ function main(argv: string[]): number {
4731
4872
  const tools_was_all = _tools_was_all(opts.tools);
4732
4873
  parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all);
4733
4874
 
4875
+ // Refuse a silently doubled Claude Code rule corpus. Runs before EITHER
4876
+ // scope branch writes: a global install can double an existing project layer
4877
+ // and a project install can double an existing global one, and the gate
4878
+ // reads both directories regardless of which one this run is about to write.
4879
+ // Claude-only, because `claudeMdExcludes` and the two-rules-dir load order
4880
+ // are that host's semantics — a cursor-only install is not affected.
4881
+ if (parsed_tools.has('claude-code') && !_gate_rule_layer_overlap(detect_root, opts.layer, opts.dry_run)) {
4882
+ return 2;
4883
+ }
4884
+
4734
4885
  const wizard_handoff = _wizard_should_launch(opts)[0];
4735
4886
 
4736
4887
  if (scope === 'global') {
@@ -74,6 +74,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
74
74
 
75
75
  import { atomic_write_json } from "./hooks/state_io.js";
76
76
  import { readHookStdin } from "./hooks/hook_stdin.js";
77
+ import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
77
78
 
78
79
  const EXIT_ALLOW = 0;
79
80
  // Severity is taken from the EXIT CODE, not from the `decision` field in the
@@ -162,8 +163,32 @@ export function instructionText(prompt: string): string {
162
163
  }
163
164
 
164
165
 
166
+ /**
167
+ * Classify a prompt, reading the TYPED LEAD before the whole body.
168
+ *
169
+ * `instructionText` removes output-shaped pastes — traces, diffs, `remote:`
170
+ * lines. It does not remove the other paste shape, which looks like prose: a
171
+ * document pasted whole under its own headings below one typed sentence. That
172
+ * gap is not hypothetical; it fired on the session that received the round-6
173
+ * review, pinning English because an English draft sat under German prose.
174
+ *
175
+ * So the lead is classified first, and only if it is undetermined does the full
176
+ * text decide. This is bidirectional by construction — it names no language, and
177
+ * an English instruction over a German paste resolves to English by the same
178
+ * step. The fallback is what keeps it conservative: a prompt with no document
179
+ * marker classifies exactly as it did before.
180
+ */
165
181
  export function classify(prompt: string): Classification {
166
182
  const text = instructionText(prompt);
183
+ const lead = humanAuthoredLead(text);
184
+ const leadVerdict = _score(lead);
185
+ if (leadVerdict.language !== "und") {
186
+ return leadVerdict;
187
+ }
188
+ return _score(text);
189
+ }
190
+
191
+ function _score(text: string): Classification {
167
192
  const de = (text.match(DE_MARKERS) ?? []).length;
168
193
  const en = (text.match(EN_MARKERS) ?? []).length;
169
194
  if (de < MIN_MARKERS && en < MIN_MARKERS) {
@@ -424,44 +449,12 @@ export function run(
424
449
  }
425
450
 
426
451
  /**
427
- * Is this "prompt" a harness-generated turn rather than something the human typed?
428
- *
429
- * MEASURED, IN THIS HOOK'S OWN AUDIT (round 5, 2026-08-07). The state file read
430
- * mid-session:
431
- *
432
- * { "language": "en", "source": "prompt", "prompt_chars": 6627,
433
- * "de_markers": 0, "en_markers": 63 }
434
- *
435
- * No human wrote those 6,627 characters. They were a background-task completion
436
- * notification, injected as a user turn, which `user_prompt_submit` sees and this
437
- * hook classified as the trigger — flipping a German session to `en` for every
438
- * later turn, and recording `source: "prompt"`, a false provenance claim.
439
- *
440
- * This is the SAME defect the hook was built to remove: non-chat content read as
441
- * the trigger. The header above explains at length why a skill body must not set
442
- * the pin; `user_prompt_submit` was chosen because "a skill body never reaches
443
- * it". Synthetic turns do reach it, so the event alone was never sufficient — the
444
- * content test has to exist too.
445
- *
446
- * The markers are structural, not linguistic: the harness stamps these turns
447
- * with fixed envelope tags. Matching prose would misfire on a human quoting a
448
- * notification; matching the tags cannot, because a human typing `<task-notification>`
449
- * is quoting, and a quote does not open at character zero of the turn.
450
- *
451
- * Deliberately conservative: an unrecognised synthetic shape falls through and
452
- * pins as before. Under-filtering keeps today's behaviour; over-filtering would
453
- * silently stop pinning real prompts, which is the worse failure.
452
+ * Re-exported from the shared module so this hook and `conformance_scan` cannot
453
+ * classify the same entry differently. It lived here, and the scanner never
454
+ * imported it — that divergence is what Phase 2.1 of round 6 closes. Kept as a
455
+ * named export because it is part of this module's tested surface.
454
456
  */
455
- export function isSyntheticPrompt(prompt: string): boolean {
456
- const head = prompt.trimStart().slice(0, 400);
457
- return (
458
- head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
459
- head.startsWith("<task-notification>") ||
460
- head.startsWith("<system-reminder>") ||
461
- head.startsWith("<local-command-caveat>") ||
462
- /^\[SYSTEM NOTIFICATION\b/.test(head)
463
- );
464
- }
457
+ export { isSyntheticPrompt };
465
458
 
466
459
  export function main(argv?: string[]): number {
467
460
  const args = argv ?? process.argv.slice(2);