@event4u/agent-config 14.7.0 → 14.8.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/CHANGELOG.md +245 -388
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  6. package/dist/agent-src/commands/pr/create.md +14 -2
  7. package/dist/agent-src/commands/pr/merge.md +379 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  9. package/dist/agent-src/commands/roadmap.md +1 -1
  10. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  11. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  14. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  15. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  16. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  17. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  18. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  19. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  20. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  21. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  22. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  23. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  24. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  25. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  26. package/dist/agent-src/skills/security/SKILL.md +8 -0
  27. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +20 -2
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  33. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
  34. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  35. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  36. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  37. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  38. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  39. package/dist/cli-delegate/cmd_conformance.js +14 -14
  40. package/dist/cli-delegate/cmd_converge.js +3 -3
  41. package/dist/cli-delegate/cmd_doctor.js +7 -7
  42. package/dist/cli-delegate/cmd_export.js +6 -6
  43. package/dist/cli-delegate/cmd_handoff.js +7 -6
  44. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  45. package/dist/cli-delegate/cmd_migrate.js +2 -2
  46. package/dist/cli-delegate/cmd_preflight.js +5 -5
  47. package/dist/cli-delegate/cmd_prune.js +6 -6
  48. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  49. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  50. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  51. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  52. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  53. package/dist/cli-delegate/cmd_sync.js +6 -6
  54. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  55. package/dist/cli-delegate/cmd_update.js +3 -3
  56. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  57. package/dist/cli-delegate/cmd_validate.js +6 -6
  58. package/dist/cli-delegate/cmd_versions.js +3 -3
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +53 -22
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +806 -681
  69. package/dist/install/globalRuleLayers.js +169 -0
  70. package/dist/install/globalRuleLayers.js.map +1 -0
  71. package/dist/install/partitionEligibility.js +156 -0
  72. package/dist/install/partitionEligibility.js.map +1 -1
  73. package/dist/install/ruleLayerPartition.js +157 -0
  74. package/dist/install/ruleLayerPartition.js.map +1 -0
  75. package/dist/mcp/registry-manifest.json +3 -3
  76. package/dist/mcp/server.json +2 -2
  77. package/dist/mcp/server.mjs +654 -578
  78. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  79. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  80. package/dist/scripts/condense.js +52 -89
  81. package/dist/scripts/condense.js.map +1 -1
  82. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  83. package/docs/guidelines/agent-infra/false-green.md +47 -1
  84. package/package.json +1 -1
  85. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  86. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  87. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  88. package/src/config/ci-local-parity.yml +21 -0
  89. package/src/config/estate-count-budget.json +275 -2
  90. package/src/config/evaluator-budgets.json +3 -3
  91. package/src/config/gate-coverage.yml +220 -12
  92. package/src/config/gate-violation-baselines.json +73 -63
  93. package/src/config/gitignore-block.txt +15 -2
  94. package/src/config/hook-latency-budget.json +5 -0
  95. package/src/config/rule-stub-ceilings.json +17 -3
  96. package/src/install/globalRuleLayers.ts +197 -0
  97. package/src/install/partitionEligibility.ts +177 -0
  98. package/src/install/ruleLayerPartition.ts +212 -0
  99. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  100. package/src/scripts/_dispatch.bash +11 -0
  101. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  102. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  103. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  104. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  105. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  106. package/src/scripts/adr/evidence_census.ts +899 -0
  107. package/src/scripts/adr/regenerate_index.ts +12 -32
  108. package/src/scripts/adr_cite_check.ts +540 -39
  109. package/src/scripts/audit_adr_coverage.ts +103 -17
  110. package/src/scripts/bench_hook_latency.ts +204 -0
  111. package/src/scripts/build_archive_index.ts +34 -0
  112. package/src/scripts/check_adr_frontmatter.ts +574 -39
  113. package/src/scripts/check_hook_bundle_content.ts +219 -0
  114. package/src/scripts/check_new_adr_evidence.ts +529 -0
  115. package/src/scripts/check_references.ts +18 -0
  116. package/src/scripts/check_rule_layer_partition.ts +531 -0
  117. package/src/scripts/check_single_delivery.ts +137 -4
  118. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  119. package/src/scripts/condense.ts +61 -91
  120. package/src/scripts/external_sources_denylist.json +4 -1
  121. package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
  122. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  123. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  124. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  125. package/src/scripts/hooks/host_semantics.ts +21 -0
  126. package/src/scripts/hooks/injection_budget.ts +13 -3
  127. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  128. package/src/scripts/hooks/state_io.ts +88 -1
  129. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  130. package/src/scripts/injection_scan_hook.ts +137 -5
  131. package/src/scripts/lint_hook_manifest.ts +49 -0
  132. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  133. package/src/scripts/routing_doctor.ts +5 -1
  134. package/src/scripts/sync_pr_branch.ts +163 -20
  135. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  136. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  137. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
  138. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "_comment": "Per-rule stub ceilings (road-to-rule-stub-projection Phase 2). One exact-BPE token ceiling per rule carrying a migration pointer. SHRINK-ONLY: a rule may fall below its ceiling freely, and may not rise above it. Raising a ceiling to clear a failing check is the config-weakening move this repo blocks by construction \u2014 every raise needs a `history` entry whose `reason` is a real sentence. The ceilings are a floor-plus-residue SNAPSHOT taken at adoption, not a target: a rule at its ceiling is not thereby correct, only not worse. Regenerate with `check_rule_stub_ceiling --write-baseline`; never hand-edit the counts.",
3
- "measured_at_commit": "955378e83",
3
+ "measured_at_commit": "05eeb5ca3",
4
4
  "tokens_exact": true,
5
5
  "ceilings": {
6
6
  "active-remediation": 1601,
7
7
  "analysis-skill-routing": 76,
8
8
  "architecture": 955,
9
- "artifact-drafting-protocol": 611,
9
+ "artifact-drafting-protocol": 680,
10
10
  "augment-edit-discipline": 137,
11
11
  "autonomous-execution": 1633,
12
12
  "brand-consistency": 133,
@@ -37,7 +37,7 @@
37
37
  "provider-lifecycle-discipline": 472,
38
38
  "reviewer-awareness": 131,
39
39
  "roadmap-ci-steps-policy": 564,
40
- "roadmap-progress-sync": 2185,
40
+ "roadmap-progress-sync": 2479,
41
41
  "rule-type-governance": 87,
42
42
  "skill-improvement-trigger": 81,
43
43
  "skill-quality": 78,
@@ -68,6 +68,20 @@
68
68
  "from": 2117,
69
69
  "to": 2185,
70
70
  "reason": "One sentence in Iron Law 1, +68 tokens, and it is obligation surface rather than explanation: `roadmap:progress` now passes `--archive`, so the law's operational meaning changed \u2014 a complete roadmap is moved by the same run instead of reported to a human, and the two exclusions a reader must know before trusting that (`--check` never archives, the PostToolUse hook never archives) are exactly the part that cannot be deferred to a pointer. The prose was NOT moved to docs/guidelines/agent-infra/roadmap-progress-mechanics.md, which is where this rule's migration note sends its mechanics, because that file has roughly a hundred characters of headroom under the 16,000-char depth ceiling and check_depth_budget is shrink-only with four files already over it \u2014 a FIFTH over-ceiling file reds, so moving 400 characters there would have traded this gate for that one. The full contract, the council record and its recorded dissent live with the code in src/agent-src/scripts/archival_sweep.ts, which is the third home and the one with room."
71
+ },
72
+ {
73
+ "date": "2026-08-20",
74
+ "rule": "artifact-drafting-protocol",
75
+ "from": 611,
76
+ "to": 680,
77
+ "reason": "Three lines plus a pointer extension, +69 tokens, for a new contract field that governs what a run may author without asking: an artifact discovered AFTER an accepted execution contract halts by default, and `late_artifacts: auto-research` is the only declared value that continues, capped at three per run. The five-step auto-research procedure, the reasoning for the default and its reversibility argument all went to the pointer target (artifact-drafting-protocol-mechanics gains a Late artifacts section) rather than here, which is what held the growth to a bullet. The residue is obligation surface and cannot live in the guideline: this rule's own batch-mode bullet already tells the reader that a mid-run artifact triggers the full interactive protocol, so a reader who never opens the guideline would conclude that a declared value cannot change it. Named default rather than described behaviour, because the default direction is the decision -- AI council 2/2 (anthropic + openai) chose `halt` over `auto-research` on the autonomy-defaults-sheet fork of road-to-user-out-of-the-loop, record agents/evidence/council/drain-blocker-dispositions-a.md."
78
+ },
79
+ {
80
+ "date": "2026-08-20",
81
+ "rule": "roadmap-progress-sync",
82
+ "from": 2185,
83
+ "to": 2479,
84
+ "reason": "One subsection, +294 tokens, for the `deferred_policy` contract field: a declared policy removes the synchronous options ROUND at closure without moving any row of the preservation test's routing table. It is obligation surface and it cannot live anywhere else. Not in the pointer target -- roadmap-progress-mechanics.md sits at 16,039 chars against check_depth_budget's 16,000-char ceiling, and that gate is a count ratchet whose next failure is a FIFTH over-ceiling file, so prose pushed there is the one move that turns a warn into a red. Not in the contract context either: Iron Law 3 is the gate being amended, and a reader who learns from roadmap-execution-contract that a field exists but never reads this rule would not know that `cancel-with-memo` still stops for the user. The three-row table is the delta in full; the reasoning, both alternatives and the reversibility argument went to the contract context's own decision comment. Condensed once before this raise (2509 -> 2411) by cutting a paragraph that restated the preservation test. AI council 2/2 (anthropic + openai) on the autonomy-defaults-sheet fork of road-to-user-out-of-the-loop, record agents/evidence/council/drain-blocker-dispositions-a.md. RE-BASED on merge with origin/main, 2026-08-20: this entry was authored against a base of 2117 and raised to 2411. main had independently raised the same ceiling 2117 -> 2185 for its own reason, so the merged tree measures 2479 and the honest arithmetic for THIS raise is 2185 -> 2479 -- the same +294 for the same subsection, re-based rather than re-argued. Recorded rather than silently regenerated because the gate's own failure text names a history-versus-ceilings disagreement as the signature of a hand-edited count, and a merge that quietly adopted one side's numbers would produce exactly that signature with nothing saying why."
71
85
  }
72
86
  ]
73
87
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Where each host's GLOBAL rule layer lives on disk, and whether it carries a
3
+ * given set of rules.
4
+ *
5
+ * ## Why this module exists
6
+ *
7
+ * ADR-236 partitions rules between the global and the project layer: an artefact
8
+ * scoped to this package alone stays project-local, everything else is delivered
9
+ * globally and withheld from the project tree. `partitionEligibility.ts` decides
10
+ * WHETHER the partition is on, by verifying the claude host layer against
11
+ * `installed.lock`. Its own docstring then draws the only conclusion that
12
+ * evidence supports — it "says nothing about `~/.cursor`", so withholding a
13
+ * cursor artefact on the strength of a claude fingerprint "would deliver it
14
+ * nowhere".
15
+ *
16
+ * That reasoning is right and its scope was too narrow for rules. The rule
17
+ * filter sits in `condense.ts::_scoped_rule_basenames()`, which is per-RUN and
18
+ * not per-DIRECTORY, so it already withholds from `.clinerules` on a claude
19
+ * fingerprint — the exact extrapolation the docstring refuses. Measured
20
+ * 2026-08-22 in a freshly generated worktree with the partition active:
21
+ *
22
+ * ```
23
+ * .claude/rules 13 files, 13 package-only, 0 global-only
24
+ * .clinerules 14 files, 13 package-only, 0 global-only
25
+ * .cursor/rules 126 files, 26 package-only, 100 global-only
26
+ * .windsurf/rules 113 files, 13 package-only, 100 global-only
27
+ * .augment/rules 118 files, 15 package-only, 103 global-only
28
+ * ```
29
+ *
30
+ * Two hosts partition on borrowed evidence; three do not partition at all. This
31
+ * module supplies the evidence per host so neither case has to be guessed:
32
+ * {@link globalRuleLayerNames} reads the host's own global directory, and
33
+ * {@link hostLayerCarries} answers the one question a withhold depends on.
34
+ *
35
+ * ## Why the paths are written out rather than imported
36
+ *
37
+ * `USER_SCOPE_PATHS` in `src/scripts/install.ts` is the authority for a host's
38
+ * global scope ROOT, and it is the right authority. It is not imported here for
39
+ * two reasons: `install.ts` is a multi-thousand-line CLI entry point that pulls
40
+ * in the whole installer graph, and a scope root is not a rules path — cline's
41
+ * root already IS its rules directory (`~/Documents/Cline/Rules/`) while every
42
+ * other host appends a subdirectory. So the mapping is stated once here and
43
+ * PINNED against `USER_SCOPE_PATHS` by `tests/install/global_rule_layers.test.ts`,
44
+ * which fails if a root moves. A pinned restatement drifts loudly; an import
45
+ * would have cost the bundle.
46
+ */
47
+
48
+ import * as fs from 'node:fs';
49
+ import * as os from 'node:os';
50
+ import * as path from 'node:path';
51
+
52
+ /**
53
+ * Tool id → its global rule directory, relative to the user's home.
54
+ *
55
+ * Keys are the ids `USER_SCOPE_PATHS` and `TOOL_SIGNALS` use, so a row here is
56
+ * traceable to the installer's own scope table rather than invented.
57
+ *
58
+ * The five hosts below are the ones that receive a projected rule TREE. A host
59
+ * whose rules arrive as a single concatenated file (`.windsurfrules`,
60
+ * `GEMINI.md`, copilot instructions) has no directory to compare and is
61
+ * deliberately absent: {@link globalRuleLayerPath} returns null for it, which
62
+ * reads as "no evidence" and keeps the full projection.
63
+ */
64
+ export const GLOBAL_RULE_DIRS: Readonly<Record<string, string>> = {
65
+ 'claude-code': '.claude/rules',
66
+ cursor: '.cursor/rules',
67
+ augment: '.augment/rules',
68
+ // Windsurf's scope root is `~/.codeium/windsurf/`, not `~/.windsurf/`.
69
+ windsurf: '.codeium/windsurf/rules',
70
+ // Cline's scope root IS the rules directory — no `rules/` suffix.
71
+ cline: 'Documents/Cline/Rules',
72
+ };
73
+
74
+ /**
75
+ * Project rule directory → the tool id whose global layer covers it.
76
+ *
77
+ * Five entries, because five directories receive a projected rule TREE. Two are
78
+ * symlink trees (`condense.ts::TOOL_DIRS`); three are written by their own
79
+ * emitters — `generate_cursor_mdc_rules`, `generate_windsurf_modern_rules`, and
80
+ * `project_to_augment`. The `.cursor/rules` row covers both the `.mdc` files and
81
+ * the legacy `.md` symlinks that share that directory.
82
+ *
83
+ * It lives here rather than in either consumer so the gate that reports a
84
+ * duplicate and the emitters that must stop creating one read the same list. A
85
+ * sixth host directory added to one and not the other is exactly how a gate goes
86
+ * green over a tree nobody checks.
87
+ */
88
+ export const PROJECT_RULE_DIRS: Readonly<Record<string, string>> = {
89
+ '.claude/rules': 'claude-code',
90
+ '.cursor/rules': 'cursor',
91
+ '.clinerules': 'cline',
92
+ '.windsurf/rules': 'windsurf',
93
+ '.augment/rules': 'augment',
94
+ };
95
+
96
+ /** The tool id whose global layer covers a project rule directory, or null. */
97
+ export function toolIdForProjectRuleDir(dir: string): string | null {
98
+ return PROJECT_RULE_DIRS[dir] ?? null;
99
+ }
100
+
101
+ /** Every tool id that has a global rule directory this module can read. */
102
+ export function hostsWithGlobalRuleLayer(): readonly string[] {
103
+ return Object.keys(GLOBAL_RULE_DIRS).sort();
104
+ }
105
+
106
+ /**
107
+ * Absolute path to a host's global rule directory, or null when the host has no
108
+ * directory-shaped global layer.
109
+ *
110
+ * Null is a real answer and not an error: it means this module cannot supply
111
+ * evidence for that host, and every caller must read that as "do not withhold".
112
+ */
113
+ export function globalRuleLayerPath(
114
+ toolId: string,
115
+ home: string = process.env['HOME'] ?? os.homedir(),
116
+ ): string | null {
117
+ const rel = GLOBAL_RULE_DIRS[toolId];
118
+ return rel === undefined ? null : path.join(home, rel);
119
+ }
120
+
121
+ /**
122
+ * The rule basenames a host's global layer actually holds, normalised to `.md`.
123
+ *
124
+ * Returns null when the directory does not exist or cannot be read — the
125
+ * absent-tolerant contract Phase 1.1 requires. Normalisation matters because
126
+ * Cursor's tree is `.mdc`: comparing raw filenames would report every rule as
127
+ * missing there and turn the partition off for the one host whose duplication is
128
+ * largest.
129
+ */
130
+ export function globalRuleLayerNames(
131
+ toolId: string,
132
+ home: string = process.env['HOME'] ?? os.homedir(),
133
+ ): readonly string[] | null {
134
+ const dir = globalRuleLayerPath(toolId, home);
135
+ if (dir === null) {
136
+ return null;
137
+ }
138
+ let entries: string[];
139
+ try {
140
+ entries = fs.readdirSync(dir);
141
+ } catch {
142
+ return null;
143
+ }
144
+ return entries
145
+ .filter((f) => f.endsWith('.md') || f.endsWith('.mdc'))
146
+ .map((f) => (f.endsWith('.mdc') ? `${f.slice(0, -4)}.md` : f))
147
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
148
+ }
149
+
150
+ /** What {@link hostLayerCarries} concluded, and on what evidence. */
151
+ export interface CarriesVerdict {
152
+ /** True only when every requested name is present in the host's layer. */
153
+ readonly carries: boolean;
154
+ /** Absolute path read, or null when the host has no directory layer. */
155
+ readonly layerPath: string | null;
156
+ /** Names asked for that the layer does not hold. Empty when `carries`. */
157
+ readonly missing: readonly string[];
158
+ /** Why the verdict is what it is — for the caller's own reporting. */
159
+ readonly reason: 'carries' | 'no-layer-for-host' | 'layer-absent' | 'missing-names';
160
+ }
161
+
162
+ /**
163
+ * Does this host's global layer carry every rule that would be withheld from its
164
+ * project directory?
165
+ *
166
+ * This is the property `personaPartition`'s docstring states as the precondition
167
+ * for any withhold — "withholding is only safe once the surviving layer is known
168
+ * to carry what is withheld" — asked per host instead of inferred from claude.
169
+ *
170
+ * Fails safe in all three negative directions: a host with no directory layer, a
171
+ * directory that is absent, and a directory missing even one name all return
172
+ * `carries: false`, so the caller keeps the full projection. An empty
173
+ * `ruleNames` is vacuously carried, which is correct — withholding nothing needs
174
+ * no evidence.
175
+ */
176
+ export function hostLayerCarries(
177
+ toolId: string,
178
+ ruleNames: readonly string[],
179
+ home: string = process.env['HOME'] ?? os.homedir(),
180
+ ): CarriesVerdict {
181
+ const layerPath = globalRuleLayerPath(toolId, home);
182
+ if (layerPath === null) {
183
+ return { carries: false, layerPath: null, missing: [...ruleNames], reason: 'no-layer-for-host' };
184
+ }
185
+ const have = globalRuleLayerNames(toolId, home);
186
+ if (have === null) {
187
+ return { carries: false, layerPath, missing: [...ruleNames], reason: 'layer-absent' };
188
+ }
189
+ const present = new Set(have);
190
+ const missing = ruleNames.filter((n) => !present.has(n));
191
+ return {
192
+ carries: missing.length === 0,
193
+ layerPath,
194
+ missing,
195
+ reason: missing.length === 0 ? 'carries' : 'missing-names',
196
+ };
197
+ }
@@ -166,6 +166,31 @@ export function partitionVerdict(inputs: PartitionInputs): PartitionVerdict {
166
166
  * artefact is NOT package-only, so it is delivered globally and withheld from
167
167
  * the project layer. Both defaults resolve toward "the artefact is generally
168
168
  * useful"; only one of them is about withholding.
169
+ *
170
+ * ## The state space, MEASURED rather than enumerated defensively (2026-08-21)
171
+ *
172
+ * Three of this function's branches — unreadable file, absent `workspaces:`,
173
+ * empty list — all resolve to `false`, and the closure review asked whether one
174
+ * of them was resolving by accident. Counted over all 119 files in `src/rules/`:
175
+ *
176
+ * absent=0 · empty=0 · maintainer-only=16 · mixed=103 · scalar-or-other=0
177
+ *
178
+ * So exactly TWO states occur today, both deliberate, and the 16 is the figure
179
+ * ADR-236 partitions on.
180
+ *
181
+ * **The other three branches are CURRENTLY ABSENT from the generated tree, not
182
+ * unreachable** — an earlier revision of this note said "the state space is
183
+ * closed by that count", and a neutral review was right to refuse it. A file can
184
+ * become unreadable at runtime (permissions, a partial write, a truncated
185
+ * checkout) and a rule can acquire a malformed `workspaces:` on any commit. The
186
+ * count is a current inventory, never a proof of impossibility.
187
+ *
188
+ * What the count DOES buy is a bound on the defensive work worth doing now:
189
+ * fixtures for the absent and malformed cases would pin behaviour on inputs no
190
+ * committed rule produces, so they were not written. All three branches already
191
+ * resolve to `false` — deliver globally, withhold from the project layer — which
192
+ * is the over-delivery direction rather than the losing one, so the untested
193
+ * branches fail safe. Should that count move, this note is the thing that dates.
169
194
  */
170
195
  export function isExclusivelyPackageOnly(source_path: string): boolean {
171
196
  let meta: Record<string, unknown>;
@@ -323,3 +348,155 @@ export function stampHostLayerFingerprint(
323
348
  return null;
324
349
  }
325
350
  }
351
+
352
+ /**
353
+ * Per-tool-directory persona list under the partition — ADR-236's closure for the
354
+ * one family it never reached.
355
+ *
356
+ * The partition shipped for rules and skills and stopped there, so
357
+ * `<repo>/.claude/personas` kept being written while `~/.claude/personas` was
358
+ * installed from `_CLAUDE_SKILL_BUNDLE` (`install.ts:1916-1921`). Measured
359
+ * 2026-08-21 on a freshly regenerated tree with the partition ACTIVE: **29 shared
360
+ * names**, and neither `check_single_delivery` nor `_lib/layer_overlap_notice`
361
+ * looked, because `personas` was in neither's `TYPES`.
362
+ *
363
+ * ## Two properties the caller depends on
364
+ *
365
+ * **Scoped to `.claude/` only.** {@link partitionActive} verifies the CLAUDE host
366
+ * layer against `installed.lock`. It says nothing about `~/.cursor`, so
367
+ * withholding a cursor persona on the strength of a claude fingerprint would
368
+ * deliver it nowhere — the one failure the fail-safe design exists to prevent.
369
+ * Every other tool directory keeps the full projection.
370
+ *
371
+ * **Reconciliation is the empty list, not a second code path.** The caller's
372
+ * stale-symlink sweep removes any link whose name is absent from the list it was
373
+ * given for that directory, so returning `[]` empties a directory an earlier
374
+ * version populated. A gate that only declined to WRITE would leave the existing
375
+ * duplicate standing — a partition that stops new duplication and keeps the old
376
+ * is not a partition.
377
+ *
378
+ * Verified before shipping: every one of the 29 project personas is present in
379
+ * the global layer (32 there, a strict superset). The partition is a removal and
380
+ * has no repair path, so withholding is only safe once the surviving layer is
381
+ * known to carry what is withheld.
382
+ */
383
+ export function personaPartition(
384
+ projectRoot: string,
385
+ all: readonly string[],
386
+ ): {
387
+ readonly all: readonly string[];
388
+ listFor: (toolDir: string) => readonly string[];
389
+ readonly note: string;
390
+ /**
391
+ * Per-directory count, for the caller's success line.
392
+ *
393
+ * Added after a neutral review (2026-08-21) caught the message claiming
394
+ * `N personas each` across every tool directory while `.claude/` had
395
+ * received zero. The suffix said "withheld" and the count contradicted it in
396
+ * the same sentence — a reader trusting the number is misled by a line that
397
+ * also carries its own correction.
398
+ */
399
+ countFor: (toolDir: string) => number;
400
+ } {
401
+ const active = partitionActive(projectRoot);
402
+ return {
403
+ all,
404
+ listFor: (toolDir) => (personaWithheldFor(toolDir, active) ? [] : all),
405
+ note: active ? ' — .claude/ withheld: ADR-236 partition, personas arrive from ~/.claude' : '',
406
+ countFor: (toolDir) => (personaWithheldFor(toolDir, active) ? 0 : all.length),
407
+ };
408
+ }
409
+
410
+ /**
411
+ * The pure half of {@link personaPartition}: withhold iff the partition is active
412
+ * AND this is a Claude tool directory.
413
+ *
414
+ * Split out so the decision is testable in BOTH directions. `personaPartition`
415
+ * reads `installed.lock` through a memoized `partitionActive`, so a test over it
416
+ * can only assert whatever this machine happens to be — which is a test that
417
+ * passes either way and therefore proves nothing. The two properties worth
418
+ * pinning are exactly the two this signature exposes: `.claude/` is withheld when
419
+ * active, and NOTHING else ever is.
420
+ */
421
+ export function personaWithheldFor(toolDir: string, active: boolean): boolean {
422
+ return active && toolDir.startsWith('.claude/');
423
+ }
424
+
425
+ /**
426
+ * Does the project layer withhold the colon-form `/cluster:sub` commands?
427
+ *
428
+ * ## The claim this replaces, and the measurement that revised it
429
+ *
430
+ * `generate_claude_project_commands` was written on the reasoning that "Claude
431
+ * Code dedupes project and user scope by name, so the two copies of
432
+ * `/cluster:sub` collapse" — a host-behaviour claim with no first-party
433
+ * observation behind it. Its sibling claim about skills WAS probed under
434
+ * ADR-236's roadmap (Phase 5.2, `claudeMdExcludes`) and came back negative, so
435
+ * an unprobed host assumption in the same area was not a safe default.
436
+ *
437
+ * **MEASURED 2026-08-21, Claude Code 2.1.238.** Fixture: `/analyze:inbox`
438
+ * present in BOTH `~/.claude/commands/analyze/` and a temp project's
439
+ * `.claude/commands/analyze/`. The session reports `COUNT=1`. A control second
440
+ * entry (`/analyze:inboxctl`, project-only) makes the same probe report
441
+ * `COUNT=2` — so the 1 is an observation, not a probe that can only say one.
442
+ *
443
+ * **The half nobody had checked: the surviving copy is the GLOBAL one.** Asked
444
+ * for the command's description, the session returned the global body, not the
445
+ * project fixture's. So the dedup claim holds and its unstated corollary
446
+ * inverts the value of the project-layer copy: where a verified global layer
447
+ * exists, those 40 symlinks are written, deduped away, and LOSE. They are dead
448
+ * weight there — not a second listing, and not a reachability guarantee either.
449
+ *
450
+ * ## Why `partitionActive` is the right predicate rather than a new one
451
+ *
452
+ * It is true exactly when a global layer is present and verified against
453
+ * `installed.lock`, which is exactly the case where the project copy loses; and
454
+ * false on a fresh checkout, an unverified install, or a version mismatch —
455
+ * every case where the project copy is the only reachable one. So the
456
+ * fail-safe direction the whole module is built around already matches the
457
+ * measurement, and withholding needs no separate switch.
458
+ *
459
+ * ## Honest limits
460
+ *
461
+ * Self-report, n=1 per condition, one host version, one machine. What is NOT
462
+ * claimed: that older or newer hosts dedupe the same way, or that precedence is
463
+ * stable across them.
464
+ *
465
+ * **And a claim that WAS made here has been withdrawn as false** (neutral review,
466
+ * 2026-08-21). It read: *"A host that stopped deduping would show up as a
467
+ * double-listing, and `check_single_delivery` reports the overlap either way."*
468
+ * It cannot. When this predicate returns true the project copy is never written,
469
+ * so there is no second copy for the host to double-list or for the gate to
470
+ * count — it would report zero overlap while the assumption underneath had
471
+ * failed. Worse, `partitionActive` verifies the INSTALL (version + content
472
+ * fingerprint), never the HOST version whose behaviour was measured, so a host
473
+ * upgrade changes the premise and moves nothing this code reads.
474
+ *
475
+ * What actually holds: reachability survives a dedup change either way, because
476
+ * the global copy is delivered regardless. What is lost is only the project
477
+ * copy's redundancy, and nothing detects that. A real detector would have to
478
+ * pin the host version this measurement was taken against and re-probe when it
479
+ * moves — not built here, and named as absent rather than implied away.
480
+ */
481
+ export function commandsWithheld(projectRoot: string): boolean {
482
+ return partitionActive(projectRoot);
483
+ }
484
+
485
+ /**
486
+ * ## The caller's early return, flagged by review and kept
487
+ *
488
+ * `generate_claude_project_commands` applies this predicate and then returns
489
+ * early when `src/domains/` is absent — BEFORE its stale-link sweep. So a tree
490
+ * with no `src/domains` keeps whatever `.claude/commands` it already had, even
491
+ * when the commands are now withheld.
492
+ *
493
+ * Invariant rather than oversight: this package always ships `src/domains/`, the
494
+ * generator only runs from a checkout that has it, and a consumer never reaches
495
+ * this path. Documented rather than reordered, because hoisting the sweep above
496
+ * the guard would make a non-package tree lose a directory this code did not
497
+ * write — a worse failure than the one being avoided, and on a tree we do not own.
498
+ *
499
+ * The transition that DOES occur is covered: `partition_delivery_topology.test.ts`
500
+ * runs inactive→active over a fixture that has `src/domains`, and asserts both
501
+ * the links and the empty cluster directories are gone after one run.
502
+ */
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The ADR-236 rule partition, decided per HOST directory on that host's own
3
+ * evidence.
4
+ *
5
+ * ## Why the decision moved from per-run to per-directory
6
+ *
7
+ * The filter used to sit in `condense.ts::_scoped_rule_basenames()`, which runs
8
+ * once per generation, so `partitionActive` — a claude-only fingerprint against
9
+ * `installed.lock` — decided for every host at once. That contradicted
10
+ * `partitionEligibility.ts`'s own fail-safe reasoning, which refuses to withhold a
11
+ * cursor artefact on claude's evidence because it "would deliver it nowhere", and
12
+ * it was measurably wrong in both directions on 2026-08-22 (partition ACTIVE,
13
+ * freshly generated worktree):
14
+ *
15
+ * ```
16
+ * .claude/rules 13 files, 13 package-only, 0 global-only
17
+ * .clinerules 14 files, 13 package-only, 0 global-only ← narrowed on
18
+ * borrowed evidence
19
+ * .cursor/rules 126 files, 26 package-only, 100 global-only ← never narrowed
20
+ * .windsurf/rules 113 files, 13 package-only, 100 global-only ← never narrowed
21
+ * .augment/rules 118 files, 15 package-only, 103 global-only ← never narrowed
22
+ * ```
23
+ *
24
+ * So the narrowing is per directory and gated on real evidence:
25
+ * {@link hostLayerCarries} reads that host's global directory and answers whether
26
+ * every name about to be withheld is actually there. No layer, an unreadable layer,
27
+ * or one missing a single name → the full projection is returned. A withhold now
28
+ * costs a directory read; the alternative cost a rule.
29
+ *
30
+ * ## Why it lives here rather than in condense.ts
31
+ *
32
+ * Four call sites consume it — the symlink emit plan, the cursor `.mdc` emitter,
33
+ * the windsurf emitter, the augment projector — and four sites deciding this
34
+ * separately is exactly how three of them came to disagree. It is in `src/install/`
35
+ * beside {@link partitionActive} and {@link hostLayerCarries}, the two predicates
36
+ * it composes, rather than in the 2,700-line generator that calls it.
37
+ *
38
+ * ## The classification source is the PROJECTION, not the authored tree
39
+ *
40
+ * `rulesSource` is `dist/agent-src/rules` at every call site, deliberately. A rule
41
+ * absent from `dist/` is compile-disabled and never projected, so it can be neither
42
+ * withheld nor duplicated — classifying from `src/rules` would count
43
+ * `telegraph-speak.md` (compile-disabled: 119 rules / 16 package-only in `src/`,
44
+ * 118 / 15 in `dist/`) and disagree with the emitter by exactly one file.
45
+ */
46
+
47
+ import * as fs from 'node:fs';
48
+ import * as os from 'node:os';
49
+ import * as path from 'node:path';
50
+
51
+ import { isExclusivelyPackageOnly, partitionActive } from './partitionEligibility.js';
52
+ import { hostLayerCarries, toolIdForProjectRuleDir } from './globalRuleLayers.js';
53
+
54
+ export interface PartitionRulesOptions {
55
+ /** Project-relative host directory, e.g. `.cursor/rules`. */
56
+ readonly toolDir: string;
57
+ /** Candidate rule basenames, already scope- and manual-filtered. */
58
+ readonly rules: readonly string[];
59
+ /** Absolute project root — what `partitionActive` verifies against. */
60
+ readonly projectRoot: string;
61
+ /** Absolute directory the basenames resolve against for classification. */
62
+ readonly rulesSource: string;
63
+ readonly userHome?: string;
64
+ /** Where the kept-full notice goes. Silent when omitted. */
65
+ readonly announce?: (message: string) => void;
66
+ /**
67
+ * Override the partition-active decision instead of reading `installed.lock`.
68
+ *
69
+ * Split out for the reason `partitionEligibility.ts` states about its own
70
+ * `personaWithheldFor`: "so the decision is testable in BOTH directions". A
71
+ * test over the un-injected path can only assert whatever the machine happens
72
+ * to be — and this one bit differs between a maintainer checkout (installed,
73
+ * so active) and CI (nothing installed, so inactive), which made the first
74
+ * version of `rule_partition_per_host.test.ts` green locally and red on every
75
+ * CI shard. That is the environment-dependent gate this whole change exists to
76
+ * remove, reproduced in its own test suite.
77
+ *
78
+ * Production callers omit it.
79
+ */
80
+ readonly active?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Narrow one directory's rule list to the package-only set — but only when THIS
85
+ * host's global layer is verified to carry what would be withheld.
86
+ *
87
+ * Returns a copy of `rules` unchanged in every uncertain case. That direction is
88
+ * the whole design: an unnecessary duplicate costs context, a wrong withhold costs
89
+ * the rule.
90
+ */
91
+ export function partitionRulesForDir(opts: PartitionRulesOptions): string[] {
92
+ const { toolDir, rules, projectRoot, rulesSource } = opts;
93
+ const active = opts.active ?? partitionActive(projectRoot);
94
+ if (!active) {
95
+ return [...rules];
96
+ }
97
+ const toolId = toolIdForProjectRuleDir(toolDir);
98
+ if (toolId === null) {
99
+ // An unmapped directory has no global layer this code can point at, so
100
+ // there is no evidence to withhold on.
101
+ return [...rules];
102
+ }
103
+ const packageOnly = rules.filter((r) => isExclusivelyPackageOnly(path.join(rulesSource, r)));
104
+ const keep = new Set(packageOnly);
105
+ const withheld = rules.filter((r) => !keep.has(r));
106
+ const home = opts.userHome ?? process.env['HOME'] ?? os.homedir();
107
+ const verdict = hostLayerCarries(toolId, withheld, home);
108
+ if (!verdict.carries) {
109
+ opts.announce?.(
110
+ ` ⚠️ ${toolDir}: full projection kept — ${toolId}'s global layer ${verdict.reason}` +
111
+ (verdict.missing.length > 0
112
+ ? ` (${String(verdict.missing.length)} rule(s) not there, e.g. ` +
113
+ `${verdict.missing.slice(0, 3).join(', ')})`
114
+ : ''),
115
+ );
116
+ return [...rules];
117
+ }
118
+ return packageOnly;
119
+ }
120
+
121
+ // ── The older, weaker duplicate-avoidance mechanism ──────────────────────────
122
+ //
123
+ // `projection.scope_dedup` (default OFF) predates the partition and solves the
124
+ // same problem with a narrower method: skip a project copy only when its
125
+ // user-scope twin is BYTE-IDENTICAL. It moved here from condense.ts alongside the
126
+ // partition because the two are one subject — a reader deciding how a duplicate is
127
+ // avoided should find both in one file rather than one here and one in a
128
+ // 2,700-line generator. Behaviour is unchanged; only `MODULE_STATE` reads became
129
+ // parameters.
130
+
131
+ /** `fs.statSync(p).isFile()`, false on any error — condense.ts's `_isFile`. */
132
+ function isFile(p: string): boolean {
133
+ try {
134
+ return fs.statSync(p).isFile();
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Per-tool user-scope rule directory, for the scope-dedup below. Only tools
142
+ * whose host reads a user-scope rules directory can have a redundant twin;
143
+ * everything else has nothing to de-duplicate against.
144
+ */
145
+ const USER_SCOPE_RULE_DIRS: Readonly<Record<string, string>> = {
146
+ '.claude/rules': path.join('.claude', 'rules'),
147
+ };
148
+
149
+ /**
150
+ * Rules whose user-scope twin is BYTE-IDENTICAL to the source, i.e. safe to
151
+ * skip at project scope: the host still loads the same text, once instead of
152
+ * twice, so nothing the model sees changes.
153
+ *
154
+ * Byte-identity is the whole safety argument and is not negotiable. Keying on
155
+ * the filename alone would silently let a stale globally-installed copy win
156
+ * whenever the two scopes hold different versions — which is the NORMAL state
157
+ * while developing the package (measured: 110/110 shared filenames differing in
158
+ * bytes). This is deliberately not the thin-projection mechanism: no rule
159
+ * becomes trigger-gated, so the quality floor that disabled thin projection
160
+ * does not apply here.
161
+ */
162
+ export interface DedupOptions {
163
+ readonly toolDir: string;
164
+ readonly rules: readonly string[];
165
+ readonly userHome: string;
166
+ /** Absolute directory the basenames resolve against, for the byte compare. */
167
+ readonly rulesSource: string;
168
+ readonly announce?: (message: string) => void;
169
+ }
170
+
171
+ export function dedupableRules(opts: DedupOptions): Set<string> {
172
+ const { toolDir: tool_dir, rules, userHome, rulesSource } = opts;
173
+ const relative = USER_SCOPE_RULE_DIRS[tool_dir];
174
+ if (relative === undefined) {
175
+ return new Set();
176
+ }
177
+ // A hostile or simply absent $HOME must make the dedup inert, not
178
+ // adventurous. In a container `$HOME` is often unset (so `homedir()` can
179
+ // resolve to `/`) or world-writable, and this function decides which rules
180
+ // to STOP emitting — reading an unexpected tree there is how a projection
181
+ // silently loses a rule. Council review of PR #1055 raised exactly this.
182
+ let userDirStat: fs.Stats;
183
+ const userDir = path.join(userHome, relative);
184
+ try {
185
+ userDirStat = fs.statSync(userDir);
186
+ } catch {
187
+ return new Set();
188
+ }
189
+ if (!userDirStat.isDirectory()) {
190
+ return new Set();
191
+ }
192
+ // World-writable user scope: anyone on the box could plant a byte-identical
193
+ // twin and thereby delete a rule from the project projection. Refuse.
194
+ if ((userDirStat.mode & 0o002) !== 0) {
195
+ opts.announce?.(` ⚠️ ${tool_dir}: user-scope rules dir is world-writable — scope-dedup skipped`);
196
+ return new Set();
197
+ }
198
+ const skip = new Set<string>();
199
+ for (const rule of rules) {
200
+ const twin = path.join(userDir, rule);
201
+ const source = path.join(rulesSource, rule);
202
+ try {
203
+ if (!isFile(twin) || !isFile(source)) continue;
204
+ if (fs.readFileSync(twin).equals(fs.readFileSync(source))) {
205
+ skip.add(rule);
206
+ }
207
+ } catch {
208
+ // An unreadable twin is simply not de-duplicable — emit the project copy.
209
+ }
210
+ }
211
+ return skip;
212
+ }