@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,135 @@
1
+ /**
2
+ * One definition of "these two carriers deliver the same rule" — shared by the
3
+ * C-3 reachability harness and the advisory divergence report.
4
+ *
5
+ * WHY THIS IS A LIBRARY AND NOT TWO COPIES. Two surfaces need the comparison
6
+ * and they ask different questions of it. `measure_scope_dedup` asks *how many*
7
+ * installed twins a byte-identity dedup could skip, anchored on the projection
8
+ * source. `report_carrier_divergence` asks *which* rules a reader must look at,
9
+ * anchored on the union of both carriers. The directory walks are legitimately
10
+ * different; the comparison is not, and a second copy of it is how "differs only
11
+ * in the ownership stamp" and "differs in body" drift into meaning two things.
12
+ *
13
+ * The comparison itself is the load-bearing part, so it is stated once:
14
+ *
15
+ * 1. bytes equal → `identical`
16
+ * 2. equal after removing the two
17
+ * installer ownership keys → `provenance-only`
18
+ * 3. otherwise → `body-diff`
19
+ *
20
+ * Class 2 exists because `install.ts` stamps `package:` and `source_path:` into
21
+ * every rule it installs while the in-repo projection stamps nothing, so a
22
+ * provenance-only difference survives even a perfect version alignment. Keeping
23
+ * it apart from class 3 is what turns a bare "0 identical twins" into an answer:
24
+ * `provenanceOnly == shared` means aligning versions would buy nothing, and
25
+ * `bodyDiff > 0` means the two carriers are delivering different text — the only
26
+ * class where a reader has to act.
27
+ *
28
+ * NOT A LICENCE TO DEDUP. Classifying a pair `provenance-only` says the bodies
29
+ * match; it does NOT say the rule is safe to drop from one carrier. Byte-identity
30
+ * remains the dedup predicate, deliberately — see
31
+ * `agents/settings/contexts/dedup-reachability-refusal.md`, which records that
32
+ * decision and its five reopen conditions. Nothing here relaxes it.
33
+ */
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+
37
+ /**
38
+ * The keys `install.ts` adds to every installed rule (`_set_key(fm_lines,
39
+ * 'package', …)` and `source_path` at install.ts:2733). Removing them is what
40
+ * separates an installer stamp from a content change.
41
+ */
42
+ export const OWNERSHIP_KEYS = ['package', 'source_path'] as const;
43
+
44
+ export type PairVerdict = 'identical' | 'provenance-only' | 'body-diff';
45
+
46
+ /**
47
+ * Drop the ownership stamp lines, wherever they appear in the FILE.
48
+ *
49
+ * Not scoped to the frontmatter, and the docstring says so because the scope is
50
+ * wider than the intent. Two reasons it stays this way: `install.ts` writes the
51
+ * keys without any guarantee about fence position, and `scope_dedup.test.ts`
52
+ * deliberately pins the fence-less shape (a stamp with no `---` around it) as the
53
+ * install condition. Residual risk, stated rather than hidden: a rule body that
54
+ * contains a line literally starting `package:` or `source_path:` — a YAML
55
+ * example, an indent-free config snippet — has that line removed from both sides,
56
+ * so a genuine difference confined to such a line would read `provenance-only`
57
+ * instead of `body-diff`. Measured over the shipped corpus: no rule body carries
58
+ * one. Re-check that before treating the class as impossible rather than absent.
59
+ */
60
+ export function stripOwnershipKeys(text: string): string {
61
+ return text
62
+ .split('\n')
63
+ .filter((line) => !OWNERSHIP_KEYS.some((k) => line.startsWith(`${k}:`)))
64
+ .join('\n');
65
+ }
66
+
67
+ /**
68
+ * Compare one rule's two copies.
69
+ *
70
+ * Takes buffers rather than paths: the callers already hold them, and a pure
71
+ * function is what lets both surfaces pin the same three-way split in tests
72
+ * without a filesystem.
73
+ */
74
+ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
75
+ if (a.equals(b)) {
76
+ return 'identical';
77
+ }
78
+ if (stripOwnershipKeys(a.toString('utf-8')) === stripOwnershipKeys(b.toString('utf-8'))) {
79
+ return 'provenance-only';
80
+ }
81
+ return 'body-diff';
82
+ }
83
+
84
+ export interface RuleDirCensus {
85
+ files: number;
86
+ /**
87
+ * BYTES, not characters — `statSync().size`. The name is kept because every
88
+ * consumer of this figure (`preamble_byte_census`, `measure_scope_dedup`, the
89
+ * conformance scan) already divides it by 4 under that name, and one honest
90
+ * docstring beats a rename that would silently move three published baselines.
91
+ *
92
+ * The gap is measured, not assumed: over the 110-rule project tree, 406 502
93
+ * bytes against 402 823 characters — **0.91 %** overhead from the em-dashes,
94
+ * arrows and status glyphs the prose uses. So the published token estimate
95
+ * reads 101 626 where a character basis gives 100 706. Under 1 % and in the
96
+ * conservative direction for a payload figure, which is why it is documented
97
+ * rather than repaired; re-measure before quoting it as negligible on a corpus
98
+ * with more non-ASCII than this one.
99
+ */
100
+ chars: number;
101
+ }
102
+
103
+ /**
104
+ * How much rule text one carrier delivers.
105
+ *
106
+ * `statSync`, not `lstatSync`, on purpose: a project-scope entry is a symlink
107
+ * into `dist/`, and the bytes the HOST reads are the target's. Measuring the link
108
+ * would report ~50 bytes per rule and make the whole payload vanish.
109
+ *
110
+ * Moved here from `preamble_byte_census` when `conformance_scan` needed it:
111
+ * that module pulls `yaml` and the cold-start report, and importing a dependency
112
+ * chain into a bundled CLI path to reach fifteen lines of `statSync` is the wrong
113
+ * trade. `preamble_byte_census` re-exports this so its own callers are unchanged
114
+ * and there is still exactly one definition.
115
+ */
116
+ export function censusRuleDir(dir: string): RuleDirCensus {
117
+ let files = 0;
118
+ let chars = 0;
119
+ let entries: string[];
120
+ try {
121
+ entries = fs.readdirSync(dir);
122
+ } catch {
123
+ return { files: 0, chars: 0 };
124
+ }
125
+ for (const name of entries) {
126
+ if (!name.endsWith('.md')) continue;
127
+ try {
128
+ chars += fs.statSync(path.join(dir, name)).size;
129
+ files += 1;
130
+ } catch {
131
+ // Unreadable entry — skip rather than fail the whole census.
132
+ }
133
+ }
134
+ return { files, chars };
135
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Host-capability manifest normalizer.
2
+ * Host-capability manifest normalizer + committed capability registry.
3
3
  *
4
4
  * Pure, no-I/O helper that takes a partial / unknown object (typically the
5
5
  * `subagents.host_capabilities` override from `.agent-settings.yml`, or the
@@ -11,6 +11,18 @@
11
11
  * have no subagent primitive.
12
12
  * - A missing or non-boolean field resolves to `false`, never `true`.
13
13
  * - `schema_version` is always forced to `1`.
14
+ *
15
+ * `resolveHostCapabilities` (F5, road-to-orchestrator-discipline-carriers)
16
+ * adds a resolution order in front of the safe default so a fresh install on
17
+ * a KNOWN host does not ship delegation dead by default:
18
+ *
19
+ * explicit `subagents.host_capabilities` override (wins, whole-object,
20
+ * unchanged semantics) → committed registry row for the detected host
21
+ * (`HOST_CAPABILITY_REGISTRY`) → `SAFE_DEFAULT` (all false).
22
+ *
23
+ * The registry lists only OBSERVED capabilities for a host this repo has
24
+ * actually measured (see the roadmap's transcript evidence) — never a
25
+ * speculative entry for a host nobody has verified.
14
26
  */
15
27
 
16
28
  /** Resolved host-capability manifest. All booleans, `schema_version` fixed at 1. */
@@ -57,3 +69,74 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
57
69
  separate_quota_pool: asBool(src.separate_quota_pool),
58
70
  };
59
71
  }
72
+
73
+ /**
74
+ * Committed registry — known host identifiers → OBSERVED capability sets.
75
+ *
76
+ * Keyed by the platform identifier this repo already uses for the same host
77
+ * across the hook/dispatch layer (`--platform claude`, `platforms['claude']`
78
+ * in `claude_settings_hooks.ts`, `TOOL_IDS` in `probe_skill_registration.ts`)
79
+ * — NOT the separate `toolDetection.ts` binary-detection id (`'claude-code'`),
80
+ * which names a different namespace (installed-CLI presence, not the running
81
+ * host identity a hook/dispatch envelope carries).
82
+ *
83
+ * Each row is partial: an omitted field stays at the `SAFE_DEFAULT` `false`
84
+ * when the row is applied. Claude Code is the one host this roadmap's
85
+ * transcript evidence measured spawning and running subagents concurrently
86
+ * (`Agent` tool dispatch, parallel tool-use blocks); polling and a separate
87
+ * quota pool were never observed, so they stay `false` — add a field only
88
+ * once it is itself observed, never by inference.
89
+ */
90
+ const HOST_CAPABILITY_REGISTRY: Readonly<Record<string, Partial<HostCapabilityManifest>>> = {
91
+ claude: { subagent_spawn: true, parallel_spawn: true },
92
+ };
93
+
94
+ /**
95
+ * Resolve the effective host-capability manifest for `hostId`.
96
+ *
97
+ * Resolution order (highest wins), per
98
+ * `host-capability-manifest.md § Resolution`:
99
+ *
100
+ * 1. `override` — the `subagents.host_capabilities` settings value, when
101
+ * present as a non-null object. Normalized exactly as {@link normalizeHostManifest}
102
+ * always has: a WHOLE-OBJECT override, each field independently coerced,
103
+ * a field absent from the override object resolves to `false`. This is
104
+ * the pre-existing, unchanged behavior — a present override still wins
105
+ * outright over the registry row, it does not merge field-by-field with it.
106
+ * 2. The committed {@link HOST_CAPABILITY_REGISTRY} row for `hostId`, when no
107
+ * override is present and the host is known.
108
+ * 3. `SAFE_DEFAULT` — unknown `hostId`, or no override and no registry row.
109
+ *
110
+ * `hostId` is a caller-supplied identifier (e.g. the same string a hook
111
+ * envelope's `platform` field carries) — this function does no environment
112
+ * probing of its own; see `host-capability-manifest.md` for why resolution
113
+ * stays a per-session, agent/caller-supplied fact rather than a live probe.
114
+ *
115
+ * An array override does NOT count as present (F8, review): `typeof [] ===
116
+ * 'object'` in JS, so a bare `typeof override === 'object'` check let a
117
+ * stray array reach `normalizeHostManifest`, which reads named properties
118
+ * off it (`.subagent_spawn`, …) that an array never has — every field
119
+ * silently coerces to `false`, exactly like an explicit empty-object
120
+ * override, even though nobody supplied one. An array is not a manifest
121
+ * shape at all, so it is treated the same as `null`/non-object: fall
122
+ * through to the registry row.
123
+ */
124
+ export function resolveHostCapabilities(
125
+ hostId: string | null | undefined,
126
+ override?: unknown,
127
+ ): HostCapabilityManifest {
128
+ if (
129
+ override !== undefined &&
130
+ override !== null &&
131
+ typeof override === 'object' &&
132
+ !Array.isArray(override)
133
+ ) {
134
+ return normalizeHostManifest(override);
135
+ }
136
+ const row =
137
+ hostId !== null && hostId !== undefined ? HOST_CAPABILITY_REGISTRY[hostId] : undefined;
138
+ if (row === undefined) {
139
+ return { ...SAFE_DEFAULT };
140
+ }
141
+ return { ...SAFE_DEFAULT, ...row, schema_version: 1 };
142
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What a "prompt" actually is — shared by the language hook and the conformance
3
+ * scanner so the two cannot classify the same entry differently.
4
+ *
5
+ * They did. Round 6 measured it: `isSyntheticPrompt` lived in
6
+ * `language_mirror_hook` and was never referenced from `conformance_scan`, whose
7
+ * only general net was `length > 2500 && english`. So the hook skipped
8
+ * harness-generated turns and the scanner counted them, and every era split
9
+ * argued about two different populations without knowing it.
10
+ *
11
+ * The asymmetry ran the other way too, and that half had no net on either side:
12
+ * a prompt whose bulk is pasted content in another language pinned to the paste.
13
+ * That fired on the very session that received the round-6 review — the pin read
14
+ * English because an English draft was pasted below German prose.
15
+ *
16
+ * Nothing here is language-specific. A rule that names a language cannot answer
17
+ * a bidirectional question.
18
+ */
19
+
20
+ /**
21
+ * Is this "prompt" a harness-generated turn rather than something a human typed?
22
+ *
23
+ * MEASURED, IN THE LANGUAGE HOOK'S OWN AUDIT (round 5, 2026-08-07). The state
24
+ * file read mid-session:
25
+ *
26
+ * { "language": "en", "source": "prompt", "prompt_chars": 6627,
27
+ * "de_markers": 0, "en_markers": 63 }
28
+ *
29
+ * No human wrote those 6,627 characters. They were a background-task completion
30
+ * notification, injected as a user turn, which `user_prompt_submit` sees and the
31
+ * hook classified as the trigger — flipping a German session to `en` for every
32
+ * later turn, and recording `source: "prompt"`, a false provenance claim.
33
+ *
34
+ * The markers are structural, not linguistic: the harness stamps these turns
35
+ * with fixed envelope tags. Matching prose would misfire on a human quoting a
36
+ * notification; matching the tags cannot, because a human typing
37
+ * `<task-notification>` is quoting, and a quote does not open at character zero.
38
+ *
39
+ * Deliberately conservative: an unrecognised synthetic shape falls through and
40
+ * is treated as a chat message. Under-filtering keeps prior behaviour;
41
+ * over-filtering would silently stop pinning real prompts, which is worse.
42
+ * Measured across 1 540 user-role entries: 509 open at character zero with a
43
+ * marker, and in 0 of them does human text survive wrapper removal.
44
+ */
45
+ export function isSyntheticPrompt(prompt: string): boolean {
46
+ const head = prompt.trimStart().slice(0, 400);
47
+ return (
48
+ head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
49
+ head.startsWith("<task-notification>") ||
50
+ head.startsWith("<system-reminder>") ||
51
+ head.startsWith("<local-command-caveat>") ||
52
+ /^\[SYSTEM NOTIFICATION\b/.test(head)
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A line that starts pasted DOCUMENT content rather than typed instruction.
58
+ *
59
+ * Distinct from the output-shaped heads `instructionText` already removes (stack
60
+ * traces, diffs, `remote:` lines). Those look like machine output. This catches
61
+ * the other paste shape, which looks like prose: a roadmap, a review, a spec —
62
+ * pasted whole, under its own headings, below one typed sentence.
63
+ *
64
+ * The markers are structural for the same reason as above: a heading, a
65
+ * frontmatter fence, or a bare tag line is document furniture. A typed chat turn
66
+ * rarely carries one; a pasted document essentially always does.
67
+ */
68
+ const DOCUMENT_HEAD = /^\s*(#{1,6}\s|-{3,}\s*$|={3,}\s*$|<[A-Za-z][\w-]*>\s*$)/;
69
+
70
+ /**
71
+ * The part of a prompt a human plausibly typed: everything before the first
72
+ * pasted document begins.
73
+ *
74
+ * Callers pass text that has already had output-shaped pastes removed. Returning
75
+ * the LEAD rather than a filtered whole is deliberate — a paste can be
76
+ * interleaved, and reassembling the human fragments would invent a text nobody
77
+ * wrote. The lead is the one span whose authorship is not in question.
78
+ */
79
+ export function humanAuthoredLead(text: string): string {
80
+ const kept: string[] = [];
81
+ for (const line of text.split("\n")) {
82
+ if (DOCUMENT_HEAD.test(line)) {
83
+ break;
84
+ }
85
+ kept.push(line);
86
+ }
87
+ return kept.join("\n");
88
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `review_skipped` telemetry line builder (pure, no I/O).
3
+ *
4
+ * Purpose-built, MINIMAL extension of the audit-log-v1 envelope
5
+ * (`docs/contracts/audit-log-v1.md`) for the F4-lite end-review-nudge concern
6
+ * (road-to-orchestrator-discipline-carriers Phase 5). It reuses the same
7
+ * envelope shape and the same file location
8
+ * (`agents/runtime/state/audit/<YYYY-MM>.jsonl`) as
9
+ * `_lib/orchestration_record.ts`'s `orchestration` object, but does not
10
+ * import that module: `buildOrchestrationLine` requires `spawn_count`, which
11
+ * has no meaning for a review-skip event, and its enum-constant tables are
12
+ * private to that file. Adding a second additive top-level object
13
+ * (`review_skipped`) is exactly the pattern the contract already uses for
14
+ * `orchestration` — "Unknown trailing fields are forward-compat extensions;
15
+ * readers MUST NOT raise on them."
16
+ *
17
+ * PRIVACY BY CONSTRUCTION: the `review_skipped` object carries `diff_lines`
18
+ * (a non-negative integer count) and `mutation_measure` (a closed 2-value
19
+ * enum — never free text). There is no field capable of holding a file
20
+ * path, a prompt, or any free-form content — never add one (mirrors
21
+ * `domain-safety-pii` § Surface 2 / the `artifact-engagement` event shape).
22
+ */
23
+
24
+ /**
25
+ * Whether `diff_lines` is an exact sum or a guaranteed-over-threshold
26
+ * approximation (`end_review_nudge_hook.ts`'s `UNTRACKED_FILE_CAP` path —
27
+ * past that many untracked non-doc files, the hook refuses to spawn one
28
+ * `git diff --no-index` subprocess per file and reports a value merely
29
+ * guaranteed to exceed the fire threshold, not an exact count). Mixing
30
+ * exact and approximated counts in this telemetry stream, unlabeled, would
31
+ * corrupt any future blocking-threshold calibration that reads it — hence
32
+ * this field, rather than folding the approximation silently into
33
+ * `diff_lines`.
34
+ */
35
+ export type MutationMeasure = 'exact' | 'capped_approximation';
36
+
37
+ export interface ReviewSkippedInput {
38
+ /** Non-doc tracked-file mutation size that triggered the nudge (count only). */
39
+ diff_lines: number;
40
+ /** Whether `diff_lines` is exact or a capped approximation — see above. */
41
+ mutation_measure: MutationMeasure;
42
+ /** ISO-8601 UTC timestamp; caller supplies (keeps this fn pure/deterministic). */
43
+ ts: string;
44
+ /** Stable id (ULID, UUID, or content hash); caller supplies. */
45
+ id: string;
46
+ work_id?: string | undefined;
47
+ }
48
+
49
+ export interface BuiltReviewSkippedLine {
50
+ line: Record<string, unknown> | null;
51
+ errors: string[];
52
+ }
53
+
54
+ function isNonNegInt(n: unknown): n is number {
55
+ return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n) && n >= 0;
56
+ }
57
+
58
+ /**
59
+ * High-risk threshold for the line's own `risk_class` (audit-log-v1 field,
60
+ * not the hook's fire threshold): a skipped review over a large diff carries
61
+ * more residual risk than one just past the fire threshold.
62
+ */
63
+ const HIGH_RISK_DIFF_LINES = 200;
64
+
65
+ /**
66
+ * Build + validate ONE audit-log-v1 line carrying a `review_skipped` object.
67
+ * Returns `{line, errors}`; when `errors` is non-empty, `line` is null and
68
+ * the caller must NOT write.
69
+ */
70
+ export function buildReviewSkippedLine(input: ReviewSkippedInput): BuiltReviewSkippedLine {
71
+ const errors: string[] = [];
72
+ if (!isNonNegInt(input.diff_lines)) {
73
+ errors.push('diff_lines must be a non-negative integer');
74
+ }
75
+ if (input.mutation_measure !== 'exact' && input.mutation_measure !== 'capped_approximation') {
76
+ errors.push('mutation_measure must be "exact" or "capped_approximation"');
77
+ }
78
+ if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
79
+ if (!input.id) errors.push('id (ULID, UUID, or content hash) is required');
80
+
81
+ if (errors.length) return { line: null, errors };
82
+
83
+ const line: Record<string, unknown> = {
84
+ schema_version: 1,
85
+ id: input.id,
86
+ ts: input.ts,
87
+ work_id: input.work_id ?? `review-skipped-${input.ts}`,
88
+ phase: 'report',
89
+ outcome: 'skipped',
90
+ confidence_band: 'medium',
91
+ risk_class: input.diff_lines >= HIGH_RISK_DIFF_LINES ? 'high' : 'medium',
92
+ memory: { asks: 0, hits: 0 },
93
+ verify: { claims: 0, first_try_passes: 0 },
94
+ rules_applied: ['delegation-policy'],
95
+ persona: null,
96
+ input_kind: 'prompt',
97
+ type: 'note',
98
+ review_skipped: { diff_lines: input.diff_lines, mutation_measure: input.mutation_measure },
99
+ };
100
+
101
+ return { line, errors: [] };
102
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Rule-layer overlap detection — is the corpus about to be delivered twice?
3
+ *
4
+ * Claude Code loads `~/.claude/rules/` and `<project>/.claude/rules/` **both**,
5
+ * user layer first, with no dedup between them: a rule present in both is in
6
+ * standing context twice. Measured on a maintainer machine 2026-08-08 — 112
7
+ * global entries + 92 project entries, 91 shared basenames, 176,354 exact-BPE
8
+ * tokens of which 74,137 (42%) redundant. Evidence:
9
+ * `agents/evidence/analysis/standing-rule-delivery-topologies.md`; the host's
10
+ * loading contract: `agents/evidence/analysis/claude-code-rules-dir-contract.md`.
11
+ *
12
+ * This module answers three questions and nothing else:
13
+ *
14
+ * 1. **Do two layers overlap, and by how much?** ({@link compareLayers})
15
+ * 2. **Is the overlap a pure duplicate, or a content skew?** — the difference
16
+ * decides whether suppression is enough or a refresh is also needed.
17
+ * 3. **How is a layer suppressed without deleting it?** — the host's own
18
+ * `claudeMdExcludes` settings key ({@link claudeMdExcludesGlob},
19
+ * {@link mergeClaudeMdExcludes}).
20
+ *
21
+ * The comparison half is pure so it is testable from fixtures; only
22
+ * {@link readRuleLayer} touches the filesystem, and nothing here ever writes or
23
+ * unlinks. Deleting a user's `~/.claude/rules/` would be a Hard-Floor action
24
+ * (`non-destructive-by-default`), so no function in this module can reach one.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+
30
+ /**
31
+ * Frontmatter keys the installer stamps onto its OWN copies, and which therefore
32
+ * differ between a global (installed) copy and a project (projected) copy of the
33
+ * same rule without either being stale.
34
+ *
35
+ * Kept in lockstep with `install.ts`'s `_set_key(fm_lines, 'package', …)` /
36
+ * `_set_key(fm_lines, 'source_path', …)` pair. Adding a third stamped key there
37
+ * without adding it here makes every rule read as divergent.
38
+ */
39
+ export const INSTALLER_PROVENANCE_KEYS: readonly string[] = ['package', 'source_path'];
40
+
41
+ /** One rule layer: its directory and `basename → text` for every `.md` in it. */
42
+ export interface RuleLayer {
43
+ readonly dir: string;
44
+ readonly files: ReadonlyMap<string, string>;
45
+ }
46
+
47
+ /** What {@link compareLayers} found. Counts are chars; token conversion is the caller's. */
48
+ export interface OverlapReport {
49
+ readonly schema_version: 1;
50
+ /** Basenames present in both layers. */
51
+ readonly overlap: readonly string[];
52
+ /** Of {@link overlap}: bodies equal once the provenance keys are stripped — pure duplicates. */
53
+ readonly duplicate: readonly string[];
54
+ /** Of {@link overlap}: bodies genuinely differ — a content skew. Suppression alone is NOT enough. */
55
+ readonly divergent: readonly string[];
56
+ /** Basenames only in the first (global) layer. */
57
+ readonly global_only: readonly string[];
58
+ /** Basenames only in the second (project) layer. */
59
+ readonly project_only: readonly string[];
60
+ /** Chars the SECOND layer contributes redundantly — what suppressing it recovers. */
61
+ readonly redundant_chars: number;
62
+ }
63
+
64
+ /** The three answers a user may give when an overlap is detected. */
65
+ export type LayerChoice = 'global' | 'project' | 'both-acknowledged';
66
+
67
+ /**
68
+ * Strip the installer's provenance keys from a rule's frontmatter block.
69
+ *
70
+ * Only lines inside the leading `---` … `---` fence are considered, and only
71
+ * those whose key is in {@link INSTALLER_PROVENANCE_KEYS}. A body line that
72
+ * happens to start with `package:` survives — stripping it would make two
73
+ * genuinely different rules compare equal, which is the failure direction that
74
+ * matters here.
75
+ *
76
+ * A file with no frontmatter fence is returned unchanged.
77
+ */
78
+ export function stripProvenance(text: string): string {
79
+ const lines = text.split('\n');
80
+ if (lines[0]?.trim() !== '---') return text;
81
+ let close = -1;
82
+ for (let i = 1; i < lines.length; i++) {
83
+ if (lines[i]?.trim() === '---') {
84
+ close = i;
85
+ break;
86
+ }
87
+ }
88
+ if (close === -1) return text;
89
+ const kept: string[] = [];
90
+ for (let i = 0; i < lines.length; i++) {
91
+ if (i > 0 && i < close) {
92
+ const key = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(lines[i] ?? '')?.[1];
93
+ if (key !== undefined && INSTALLER_PROVENANCE_KEYS.includes(key)) continue;
94
+ }
95
+ kept.push(lines[i] ?? '');
96
+ }
97
+ return kept.join('\n');
98
+ }
99
+
100
+ /** Sorted basenames of `a` that are absent from `b`. */
101
+ function only_in(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): string[] {
102
+ return [...a.keys()].filter((k) => !b.has(k)).sort();
103
+ }
104
+
105
+ /**
106
+ * Compare two rule layers. Pure — no filesystem, no clock, no environment.
107
+ *
108
+ * `global_layer` is the user-level layer and `project_layer` the project one,
109
+ * because {@link OverlapReport.redundant_chars} is measured on the SECOND
110
+ * argument: it answers "what does suppressing the project layer recover", which
111
+ * is the direction the installer offers first (the project layer is the one it
112
+ * is usually about to write).
113
+ */
114
+ export function compareLayers(
115
+ global_layer: ReadonlyMap<string, string>,
116
+ project_layer: ReadonlyMap<string, string>,
117
+ ): OverlapReport {
118
+ const overlap = [...global_layer.keys()].filter((k) => project_layer.has(k)).sort();
119
+ const duplicate: string[] = [];
120
+ const divergent: string[] = [];
121
+ let redundant_chars = 0;
122
+ for (const name of overlap) {
123
+ const g = stripProvenance(global_layer.get(name) ?? '');
124
+ const p = stripProvenance(project_layer.get(name) ?? '');
125
+ if (g === p) {
126
+ duplicate.push(name);
127
+ redundant_chars += (project_layer.get(name) ?? '').length;
128
+ } else {
129
+ divergent.push(name);
130
+ }
131
+ }
132
+ return {
133
+ schema_version: 1,
134
+ overlap,
135
+ duplicate,
136
+ divergent,
137
+ global_only: only_in(global_layer, project_layer),
138
+ project_only: only_in(project_layer, global_layer),
139
+ redundant_chars,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Read every `.md` in `dir` into a {@link RuleLayer}, or `null` when the
145
+ * directory does not exist.
146
+ *
147
+ * Symlinks are followed, because the project layer is a symlink projection into
148
+ * `dist/agent-src/rules/` and its CONTENT is what lands in context. An entry
149
+ * whose target is unreadable (a dangling symlink) is skipped rather than
150
+ * throwing — a broken link contributes no bytes to the session either.
151
+ */
152
+ export function readRuleLayer(dir: string): RuleLayer | null {
153
+ let names: string[];
154
+ try {
155
+ names = fs.readdirSync(dir);
156
+ } catch {
157
+ return null;
158
+ }
159
+ const files = new Map<string, string>();
160
+ for (const name of names.filter((n) => n.endsWith('.md')).sort()) {
161
+ try {
162
+ files.set(name, fs.readFileSync(path.join(dir, name), 'utf-8'));
163
+ } catch {
164
+ continue;
165
+ }
166
+ }
167
+ return { dir, files };
168
+ }
169
+
170
+ /**
171
+ * The `claudeMdExcludes` glob that suppresses one rules directory.
172
+ *
173
+ * The host matches this key against ABSOLUTE paths, so the directory is
174
+ * resolved before the `/**` suffix is appended. Documented example from the
175
+ * host's own docs: `"/home/user/monorepo/other-team/.claude/rules/**"`.
176
+ */
177
+ export function claudeMdExcludesGlob(dir: string): string {
178
+ return `${path.resolve(dir).replace(/\/+$/, '')}/**`;
179
+ }
180
+
181
+ /**
182
+ * Append `entry` to an existing `claudeMdExcludes` value, preserving everything
183
+ * already there.
184
+ *
185
+ * `claudeMdExcludes` arrays MERGE across settings layers, so an install that
186
+ * replaced the array would silently drop another layer's exclusions — a
187
+ * different defect from the one being fixed. Non-string members of a
188
+ * hand-edited array are preserved in order; an absent or non-array value starts
189
+ * a fresh single-entry array. Already present → returned unchanged, so the
190
+ * operation is idempotent across re-installs.
191
+ */
192
+ export function mergeClaudeMdExcludes(existing: unknown, entry: string): unknown[] {
193
+ if (!Array.isArray(existing)) return [entry];
194
+ if (existing.includes(entry)) return [...existing];
195
+ return [...existing, entry];
196
+ }
197
+
198
+ /** What the installer should do, given an overlap report and the user's choice. */
199
+ export interface LayerAction {
200
+ /** Which layer this install writes. */
201
+ readonly write: 'global' | 'project' | 'both';
202
+ /** Directory to suppress via `claudeMdExcludes`, or `null` when nothing is suppressed. */
203
+ readonly suppress_dir: string | null;
204
+ /** True when the overlap contains divergent bodies — suppression alone loses obligations. */
205
+ readonly refresh_required: boolean;
206
+ /** One-line human summary; the installer prints it verbatim. */
207
+ readonly note: string;
208
+ }
209
+
210
+ /**
211
+ * Turn a report plus a {@link LayerChoice} into the action to take. Pure.
212
+ *
213
+ * `both-acknowledged` writes both and suppresses nothing — a deliberate
214
+ * two-layer setup is legitimate and the flag exists so the installer does not
215
+ * have to guess. The other two choices name the layer to KEEP and suppress the
216
+ * other; suppression never deletes.
217
+ *
218
+ * `refresh_required` fires on any divergent body: suppressing a layer whose
219
+ * copies differ would drop whichever obligations only the suppressed copy
220
+ * carried, so the caller must refresh before suppressing. This is the guard
221
+ * against the version-skew case, which the 2026-08-08 measurement found absent
222
+ * but nothing prevents later.
223
+ */
224
+ export function decideLayerAction(
225
+ report: OverlapReport,
226
+ choice: LayerChoice,
227
+ global_dir: string,
228
+ project_dir: string,
229
+ ): LayerAction {
230
+ const refresh_required = report.divergent.length > 0;
231
+ const skew = refresh_required
232
+ ? ` ${report.divergent.length} shared rule(s) differ in body — refresh before suppressing, or obligations only the suppressed copy carries are lost.`
233
+ : '';
234
+ if (choice === 'both-acknowledged') {
235
+ return {
236
+ write: 'both',
237
+ suppress_dir: null,
238
+ refresh_required,
239
+ note:
240
+ `Keeping both rule layers by request: ${report.overlap.length} shared rule(s), `
241
+ + `${report.redundant_chars} chars delivered twice per session. Nothing suppressed, nothing deleted.${skew}`,
242
+ };
243
+ }
244
+ const keep = choice === 'global' ? global_dir : project_dir;
245
+ const drop = choice === 'global' ? project_dir : global_dir;
246
+ return {
247
+ write: choice,
248
+ suppress_dir: drop,
249
+ refresh_required,
250
+ note:
251
+ `Keeping ${choice} rule layer (${keep}); suppressing ${drop} via claudeMdExcludes — `
252
+ + `recovers ${report.redundant_chars} chars per session. No file is deleted or rewritten.${skew}`,
253
+ };
254
+ }