@event4u/agent-config 14.5.0 → 14.7.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 (203) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +384 -3
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/decision.md +7 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  6. package/dist/agent-src/commands/analyze.md +1 -1
  7. package/dist/agent-src/commands/optimize/project.md +10 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +132 -4
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  10. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
  11. package/dist/agent-src/rules/context-hygiene.md +2 -0
  12. package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
  13. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  14. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  15. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  16. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  17. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  18. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  19. package/dist/agent-src/rules/skill-quality.md +0 -2
  20. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  21. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  22. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  23. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  24. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  25. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  27. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  28. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  29. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  30. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  31. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  32. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  33. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  34. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  35. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  36. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  37. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  38. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  39. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  40. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  41. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  42. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  44. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  45. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  46. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  47. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  48. package/dist/agent-src/templates/roadmaps.md +45 -2
  49. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  50. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  51. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  52. package/dist/cli/registry.js +1 -1
  53. package/dist/cli/registry.js.map +1 -1
  54. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  55. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  56. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  57. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  58. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  59. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  60. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  61. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  62. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
  63. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  64. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  65. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  66. package/dist/cli-delegate/cmd_conformance.js +459 -709
  67. package/dist/cli-delegate/cmd_doctor.js +6 -6
  68. package/dist/cli-delegate/cmd_explain.js +1 -1
  69. package/dist/cli-delegate/cmd_export.js +7 -6
  70. package/dist/cli-delegate/cmd_handoff.js +6 -6
  71. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  72. package/dist/cli-delegate/cmd_preflight.js +2 -2
  73. package/dist/cli-delegate/cmd_prune.js +7 -6
  74. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  75. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  76. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  77. package/dist/cli-delegate/cmd_sync.js +7 -6
  78. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  79. package/dist/cli-delegate/cmd_update.js +1 -1
  80. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  81. package/dist/cli-delegate/cmd_validate.js +7 -6
  82. package/dist/discovery/deprecation-report.md +1 -1
  83. package/dist/discovery/discovery-manifest.json +72 -29
  84. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  85. package/dist/discovery/discovery-manifest.summary.md +4 -4
  86. package/dist/discovery/dormancy-report.md +1 -1
  87. package/dist/discovery/orphan-report.md +1 -1
  88. package/dist/discovery/packs.json +10 -8
  89. package/dist/discovery/trust-report.md +3 -3
  90. package/dist/discovery/workspaces.json +8 -6
  91. package/dist/hooks/dispatch.js +3127 -2022
  92. package/dist/install/claudePathsPlan.js +187 -0
  93. package/dist/install/claudePathsPlan.js.map +1 -0
  94. package/dist/install/hostLayerFingerprint.js +136 -0
  95. package/dist/install/hostLayerFingerprint.js.map +1 -0
  96. package/dist/install/install.mjs +553 -425
  97. package/dist/install/partitionEligibility.js +276 -0
  98. package/dist/install/partitionEligibility.js.map +1 -0
  99. package/dist/mcp/registry-manifest.json +3 -3
  100. package/dist/mcp/server.json +2 -2
  101. package/dist/mcp/server.mjs +1744 -1191
  102. package/dist/router.json +1 -1
  103. package/dist/scripts/_lib/agent_src.js +41 -0
  104. package/dist/scripts/_lib/agent_src.js.map +1 -1
  105. package/dist/scripts/_lib/install_layout.js +83 -0
  106. package/dist/scripts/_lib/install_layout.js.map +1 -0
  107. package/dist/scripts/_lib/installed_lock.js +444 -0
  108. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  109. package/dist/scripts/condense.js +13 -164
  110. package/dist/scripts/condense.js.map +1 -1
  111. package/dist/server/schemas/settings.js +4 -0
  112. package/dist/server/schemas/settings.js.map +1 -1
  113. package/docs/contracts/settings-classes.md +12 -6
  114. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  115. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  116. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  117. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  118. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  119. package/package.json +1 -1
  120. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  121. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  122. package/src/agent-src/scripts/gate_budget.ts +282 -0
  123. package/src/agent-src/scripts/gate_execute.ts +72 -31
  124. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  125. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  126. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  127. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  128. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  129. package/src/config/agent-settings.template.yml +27 -2
  130. package/src/config/ci-local-parity.yml +18 -0
  131. package/src/config/estate-count-budget.json +367 -3
  132. package/src/config/gate-violation-baselines.json +41 -41
  133. package/src/config/pack-size-budget.json +3 -2
  134. package/src/config/rule-activation-census.json +14 -6
  135. package/src/config/rule-stub-ceilings.json +19 -6
  136. package/src/install/claudePathsPlan.ts +196 -0
  137. package/src/install/hostLayerFingerprint.ts +145 -0
  138. package/src/install/partitionEligibility.ts +325 -0
  139. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  140. package/src/scripts/_dispatch.bash +6 -1
  141. package/src/scripts/_lib/ac_heading.ts +30 -0
  142. package/src/scripts/_lib/agent_src.ts +42 -0
  143. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  144. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  145. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  146. package/src/scripts/_lib/installed_lock.ts +50 -5
  147. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  148. package/src/scripts/_lib/md_section.ts +129 -0
  149. package/src/scripts/_lib/scope_guard.sh +86 -4
  150. package/src/scripts/_lib/self_repair.ts +19 -0
  151. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  152. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  153. package/src/scripts/adr/regenerate_index.ts +13 -2
  154. package/src/scripts/adr_cite_check.ts +424 -0
  155. package/src/scripts/ai_council/cli_help.ts +55 -0
  156. package/src/scripts/before_complete_hook.ts +153 -28
  157. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  158. package/src/scripts/check_adr_frontmatter.ts +187 -1
  159. package/src/scripts/check_references.ts +60 -0
  160. package/src/scripts/check_release_published.ts +32 -11
  161. package/src/scripts/check_single_delivery.ts +517 -0
  162. package/src/scripts/ci_settle.ts +170 -0
  163. package/src/scripts/condense.ts +20 -170
  164. package/src/scripts/council_cli.ts +3 -3
  165. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  166. package/src/scripts/hook_manifest.json +1 -1
  167. package/src/scripts/hook_manifest.yaml +77 -14
  168. package/src/scripts/hooks/block_no_verify.ts +61 -10
  169. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  170. package/src/scripts/hooks/concern_registry.ts +4 -0
  171. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  172. package/src/scripts/hooks/run_continuation_hook.ts +887 -68
  173. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  174. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  175. package/src/scripts/hooks/state_io.ts +782 -20
  176. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  178. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  179. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  181. package/src/scripts/install.sh +12 -2
  182. package/src/scripts/install.ts +16 -11
  183. package/src/scripts/language_mirror_hook.ts +561 -25
  184. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  185. package/src/scripts/lint_plan_risk_register.ts +4 -3
  186. package/src/scripts/lint_skill_router_head.ts +4 -4
  187. package/src/scripts/mcp_server/tools.ts +2 -2
  188. package/src/scripts/memory_consolidation.ts +189 -0
  189. package/src/scripts/memory_eviction.ts +384 -0
  190. package/src/scripts/new_roadmap.ts +136 -0
  191. package/src/scripts/pack_mcp_content.ts +20 -5
  192. package/src/scripts/release.ts +37 -38
  193. package/src/scripts/report_layer_overlap.ts +78 -0
  194. package/src/scripts/report_memory_pointers.ts +622 -0
  195. package/src/scripts/self_repair_hook.ts +37 -5
  196. package/src/scripts/session_register_hook.ts +115 -15
  197. package/src/scripts/skill_usage_collect.ts +11 -1
  198. package/src/scripts/skill_usage_report.ts +278 -8
  199. package/src/scripts/sync_pr_branch.ts +7 -0
  200. package/src/server/schemas/settings.ts +8 -0
  201. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  202. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  203. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -0,0 +1,517 @@
1
+ /**
2
+ * check_single_delivery — assert that no artefact is delivered from both layers.
3
+ *
4
+ * THE INVARIANT (ADR-236, one-artefact-one-layer): every rule and every skill is
5
+ * delivered from exactly ONE layer. Claude Code loads `~/.claude/**` AND
6
+ * `<project>/.claude/**`, both, user layer first, with no dedup — so an artefact
7
+ * present in both is delivered twice, in every session, forever.
8
+ *
9
+ * WHY THIS EXISTS AS A SEPARATE CHECK, rather than inside a producer. Two
10
+ * producers write these layers and neither can see the other:
11
+ *
12
+ * - the INSTALLER writes the global layer, and `_gate_rule_layer_overlap`
13
+ * (install.ts) checks the overlap at install time — rules only, Claude only,
14
+ * and never again afterwards;
15
+ * - `task generate-tools` writes the project layer, and contains zero
16
+ * references to that gate or to `rule_layer_overlap`.
17
+ *
18
+ * So the overlap is created by whichever producer runs LAST, and an install-time
19
+ * gate cannot see a layer written afterwards. This check is deliberately outside
20
+ * both: it reads the filesystem and does not care who wrote it.
21
+ *
22
+ * WHAT IT COUNTS, and why the two halves are separate:
23
+ *
24
+ * 1. RAW OVERLAP — names present in both layers. The token cost.
25
+ * 2. SCOPE DEFEAT — shared rules where one copy carries `paths:` and the other
26
+ * does not. `report_carrier_divergence` names the mechanism: a copy that
27
+ * LACKS `paths:` defeats the other copy's scoping, so the rule loads
28
+ * unconditionally however carefully the other copy was scoped. This is a
29
+ * correctness fact rather than a token fact, and it needs a different
30
+ * remedy, so it is never folded into the overlap number.
31
+ *
32
+ * REPORT BY DEFAULT, ENFORCE ON REQUEST — and that is not timidity. A blocking
33
+ * default would red every run on a defect a given machine may not be able to
34
+ * fix, which teaches readers to ignore it — the exact failure this estate has
35
+ * recorded twice already.
36
+ *
37
+ * The reason used to be that `road-to-single-delivery` Phase 2 was HALTED on
38
+ * blocker `partition-current-layer-undecidable`. That is no longer true and the
39
+ * sentence is corrected rather than left standing: the blocker reads
40
+ * `**Status:** resolved`, its roadmap is archived, and the partition shipped
41
+ * under ADR-236 Phase 2. What replaced the halt is a per-machine activation —
42
+ * the partition withholds artefacts only where the host layer is verified
43
+ * against `installed.lock`, so a machine whose install predates the fingerprint
44
+ * keeps the full projection BY DESIGN and the overlap this gate reports there is
45
+ * the fail-safe working, not a regression.
46
+ *
47
+ * WHERE THIS CHECK IS MEANINGFUL — read this before quoting one of its numbers
48
+ * (road-to-session-closeout 3.3). It is a DEVELOPER-MACHINE check. It reads two
49
+ * host layers off the filesystem, and in CI both are absent: a fresh checkout
50
+ * carries no `<project>/.claude/**` (zero tracked files under `.claude/`) and no
51
+ * `~/.claude/**` because no CI leg installs at user scope. So in CI this gate
52
+ * finds zero layers and has nothing to compare — which is reported as `absent`
53
+ * rather than as a pass, per the paragraph below, but is still not a statement
54
+ * about any developer's machine.
55
+ *
56
+ * How large that gap is, measured on ONE tree in ONE session on 2026-08-20:
57
+ * `check_standing_rule_delivery` read the same repository twice and reported
58
+ * 115 781 tok with one layer installed and 209 767 tok with two — a 1.8x
59
+ * difference produced entirely by which layers existed where. Any figure from
60
+ * this gate or that one describes the machine it ran on. Cite it with the layer
61
+ * counts attached, never as a repository property.
62
+ *
63
+ * A LAYER THAT DOES NOT EXIST IS NOT A PASS. Where a layer is absent there is no
64
+ * overlap to find, and saying "invariant holds" would be a gate reading nothing
65
+ * and reporting green. Absent layers are named, and `--enforce` refuses rather
66
+ * than passing when BOTH layers of every type are missing.
67
+ */
68
+
69
+ import * as fs from 'node:fs';
70
+ import * as os from 'node:os';
71
+ import * as path from 'node:path';
72
+ import process from 'node:process';
73
+ import { fileURLToPath, pathToFileURL } from 'node:url';
74
+
75
+ import { reportScanned } from './_lib/scan_scope.js';
76
+
77
+ // ledger-exempt: this gate has no skip semantics to account for. `_lib/gate_ledger`
78
+ // exists so a gate that PASSES OVER targets says which and why; here the target set
79
+ // is four fixed artefact types, every one of them appears in the output, and a type
80
+ // whose layer is missing is printed as `absent` rather than quietly dropped — see
81
+ // the `absent` field and the `types_compared=N of 4` ledger line, which together
82
+ // already carry what a per-target ledger would. Adding one would restate the render
83
+ // in a second format, and the gate's own docstring warns that the degenerate pass
84
+ // here is a marker with no argument behind it. If a future version gains a real skip
85
+ // path — a type it declines to compare for a reason — this exemption is void and the
86
+ // ledger is the right answer.
87
+
88
+ const _HERE = fileURLToPath(import.meta.url);
89
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
90
+
91
+ /** Artefact types the host loads from both layers. */
92
+ const TYPES = ['rules', 'skills', 'commands', 'agents'] as const;
93
+ type ArtefactType = (typeof TYPES)[number];
94
+
95
+ /**
96
+ * What a layer's entries physically ARE — symlinks, directories, or regular files.
97
+ *
98
+ * R2 finding 2: without this, name-equality was reported as "delivered twice" for
99
+ * two layers holding entirely different payload shapes. Measured on this repo:
100
+ * project `.claude/commands/` is **40 directories** of symlinks into
101
+ * `src/domains/**` while global `.claude/commands/` is **52 regular files**. Those
102
+ * 40 shared names are not 40 duplicated artefacts in the sense the rules row means,
103
+ * and a check that prints one number for both teaches the reader they are.
104
+ */
105
+ export interface LayerShape {
106
+ symlinks: number;
107
+ dirs: number;
108
+ files: number;
109
+ }
110
+
111
+ export interface TypeReading {
112
+ type: ArtefactType;
113
+ /** null when that layer's directory does not exist — distinct from empty. */
114
+ globalNames: string[] | null;
115
+ projectNames: string[] | null;
116
+ globalShape: LayerShape | null;
117
+ projectShape: LayerShape | null;
118
+ /**
119
+ * True when the two layers' dominant entry shape differs, so a shared NAME is
120
+ * not evidence of a shared artefact. The overlap is still reported — it is
121
+ * still a delivery collision — but it is labelled, never summed with the rest.
122
+ */
123
+ shapeMismatch: boolean;
124
+ both: string[];
125
+ globalOnly: string[];
126
+ projectOnly: string[];
127
+ /** Shared rules whose two copies disagree on `paths:`. Rules only. */
128
+ scopeDefeat: string[];
129
+ }
130
+
131
+ function dominant(s: LayerShape): 'symlink' | 'dir' | 'file' | 'empty' {
132
+ const max = Math.max(s.symlinks, s.dirs, s.files);
133
+ if (max === 0) return 'empty';
134
+ if (s.symlinks === max) return 'symlink';
135
+ if (s.dirs === max) return 'dir';
136
+ return 'file';
137
+ }
138
+
139
+ interface LayerReading {
140
+ names: string[];
141
+ shape: LayerShape;
142
+ }
143
+
144
+ function readLayer(dir: string): LayerReading | null {
145
+ let entries: fs.Dirent[];
146
+ try {
147
+ if (!fs.statSync(dir).isDirectory()) return null;
148
+ entries = fs.readdirSync(dir, { withFileTypes: true });
149
+ } catch {
150
+ return null;
151
+ }
152
+ const shape: LayerShape = { symlinks: 0, dirs: 0, files: 0 };
153
+ for (const e of entries) {
154
+ // isSymbolicLink() is checked FIRST and deliberately: readdir with
155
+ // withFileTypes does not follow links, but a reader who tested isDirectory
156
+ // first would classify a symlink-to-directory as a directory and lose the
157
+ // distinction this whole field exists for.
158
+ if (e.isSymbolicLink()) shape.symlinks += 1;
159
+ else if (e.isDirectory()) shape.dirs += 1;
160
+ else shape.files += 1;
161
+ }
162
+ return { names: entries.map((e) => e.name).sort(), shape };
163
+ }
164
+
165
+ /**
166
+ * Does this rule file declare a `paths:` key in its frontmatter?
167
+ *
168
+ * Frontmatter only, deliberately: a `paths:` mentioned in prose is not a load
169
+ * schedule, and matching it would manufacture scope-defeat findings out of
170
+ * documentation.
171
+ */
172
+ export function declaresPaths(file: string): 'yes' | 'no' | 'unreadable' {
173
+ // Three-valued on purpose (R2 finding): a boolean collapsed "no paths: key"
174
+ // and "could not read the file" into the same answer, so an unreadable copy
175
+ // silently matched an unscoped one and the pair vanished from the scope-defeat
176
+ // set — the direction that hides a finding rather than inventing one.
177
+ let text: string;
178
+ try {
179
+ text = fs.readFileSync(file, 'utf8');
180
+ } catch {
181
+ return 'unreadable';
182
+ }
183
+ if (!text.startsWith('---')) return 'no';
184
+ const end = text.indexOf('\n---', 3);
185
+ if (end < 0) return 'no';
186
+ return /^paths:/m.test(text.slice(3, end)) ? 'yes' : 'no';
187
+ }
188
+
189
+ export function readType(type: ArtefactType, globalRoot: string, projectRoot: string): TypeReading {
190
+ const gDir = path.join(globalRoot, type);
191
+ const pDir = path.join(projectRoot, type);
192
+ const gLayer = readLayer(gDir);
193
+ const pLayer = readLayer(pDir);
194
+ const globalNames = gLayer?.names ?? null;
195
+ const projectNames = pLayer?.names ?? null;
196
+ const globalShape = gLayer?.shape ?? null;
197
+ const projectShape = pLayer?.shape ?? null;
198
+ const shapeMismatch =
199
+ gLayer !== null && pLayer !== null && dominant(gLayer.shape) !== dominant(pLayer.shape);
200
+ const g = new Set(globalNames ?? []);
201
+ const p = new Set(projectNames ?? []);
202
+
203
+ const both = [...g].filter((n) => p.has(n)).sort();
204
+ const globalOnly = [...g].filter((n) => !p.has(n)).sort();
205
+ const projectOnly = [...p].filter((n) => !g.has(n)).sort();
206
+
207
+ // Scope defeat is a rules-only property: `paths:` is the one frontmatter key
208
+ // this host reads as a load schedule.
209
+ const scopeDefeat =
210
+ type === 'rules'
211
+ ? both
212
+ .filter((n) => n.endsWith('.md'))
213
+ .filter((n) => {
214
+ const g2 = declaresPaths(path.join(gDir, n));
215
+ const p2 = declaresPaths(path.join(pDir, n));
216
+ // An unreadable copy is reported as a disagreement rather
217
+ // than dropped: not knowing whether a rule is scoped is a
218
+ // finding, and the alternative silently shrinks the set.
219
+ if (g2 === 'unreadable' || p2 === 'unreadable') return true;
220
+ return g2 !== p2;
221
+ })
222
+ .sort()
223
+ : [];
224
+
225
+ return {
226
+ type,
227
+ globalNames,
228
+ projectNames,
229
+ globalShape,
230
+ projectShape,
231
+ shapeMismatch,
232
+ both,
233
+ globalOnly,
234
+ projectOnly,
235
+ scopeDefeat,
236
+ };
237
+ }
238
+
239
+ export interface Verdict {
240
+ readings: TypeReading[];
241
+ /**
242
+ * Shared names in types where BOTH layers hold the same dominant shape — the
243
+ * number that means "this artefact is delivered twice".
244
+ */
245
+ duplicated: number;
246
+ /**
247
+ * Shared names in types whose layers hold DIFFERENT shapes. Still a delivery
248
+ * collision on the same name, but not the same claim, so it is reported on its
249
+ * own line and never summed into `duplicated` (R2 finding 2).
250
+ */
251
+ nameOverlapDifferentShape: number;
252
+ /** Shared rules whose copies disagree on `paths:`. */
253
+ defeated: number;
254
+ /** Types where at least one layer's directory is missing. */
255
+ absent: string[];
256
+ /**
257
+ * Types where BOTH layers were present, i.e. where a comparison actually
258
+ * happened. Carried explicitly rather than derived as
259
+ * `readings.length - absent.length`: `absent` counts LAYERS (up to two per
260
+ * type) while readings counts TYPES, so that subtraction goes negative. It
261
+ * printed `types_compared=-2` before this field existed.
262
+ */
263
+ typesCompared: number;
264
+ /**
265
+ * Entry names read across every present layer — the gate's dead-scope unit.
266
+ *
267
+ * Deliberately NOT `typesCompared`: a machine with only one layer is a normal
268
+ * consumer state, and crashing the gate there would punish the topology this
269
+ * roadmap is trying to reach. Zero names read, on the other hand, means every
270
+ * directory the gate knows about is gone, which is blindness rather than
271
+ * cleanliness — so it has no `allowEmpty` reason and `reportScanned` throws.
272
+ */
273
+ namesRead: number;
274
+ /** True when no type had BOTH layers present — nothing was actually compared. */
275
+ readNothing: boolean;
276
+ }
277
+
278
+ export function evaluate(globalRoot: string, projectRoot: string): Verdict {
279
+ const readings = TYPES.map((t) => readType(t, globalRoot, projectRoot));
280
+ const absent: string[] = [];
281
+ let compared = 0;
282
+ for (const r of readings) {
283
+ if (r.globalNames === null) absent.push(`${r.type} (global)`);
284
+ if (r.projectNames === null) absent.push(`${r.type} (project)`);
285
+ if (r.globalNames !== null && r.projectNames !== null) compared += 1;
286
+ }
287
+ return {
288
+ readings,
289
+ duplicated: readings.reduce((n, r) => n + (r.shapeMismatch ? 0 : r.both.length), 0),
290
+ nameOverlapDifferentShape: readings.reduce(
291
+ (n, r) => n + (r.shapeMismatch ? r.both.length : 0),
292
+ 0,
293
+ ),
294
+ defeated: readings.reduce((n, r) => n + r.scopeDefeat.length, 0),
295
+ absent,
296
+ typesCompared: compared,
297
+ namesRead: readings.reduce(
298
+ (n, r) => n + (r.globalNames?.length ?? 0) + (r.projectNames?.length ?? 0),
299
+ 0,
300
+ ),
301
+ readNothing: compared === 0,
302
+ };
303
+ }
304
+
305
+ export function render(v: Verdict, globalRoot: string, projectRoot: string): string {
306
+ const out: string[] = [];
307
+ out.push('check_single_delivery · invariant: one artefact, one layer (ADR-236)');
308
+ out.push(` global: ${globalRoot}`);
309
+ out.push(` project: ${projectRoot}`);
310
+ out.push('');
311
+ const shapeOf = (s: LayerShape | null): string =>
312
+ s === null ? 'absent' : `${dominant(s)} (l${s.symlinks}/d${s.dirs}/f${s.files})`;
313
+ for (const r of v.readings) {
314
+ const g = r.globalNames === null ? 'absent' : String(r.globalNames.length);
315
+ const p = r.projectNames === null ? 'absent' : String(r.projectNames.length);
316
+ out.push(
317
+ ` ${r.type.padEnd(9)} global ${g.padStart(6)} · project ${p.padStart(6)}` +
318
+ ` · BOTH ${String(r.both.length).padStart(4)}` +
319
+ ` · global-only ${String(r.globalOnly.length).padStart(4)}` +
320
+ ` · project-only ${String(r.projectOnly.length).padStart(4)}`,
321
+ );
322
+ out.push(` shape: global ${shapeOf(r.globalShape)} · project ${shapeOf(r.projectShape)}`);
323
+ if (r.shapeMismatch && r.both.length > 0) {
324
+ out.push(
325
+ ` ⚠️ SHAPE MISMATCH — this check cannot establish that the` +
326
+ ` ${r.both.length} shared name(s) are the same artefact.`,
327
+ );
328
+ out.push(
329
+ ' It does not establish that they differ either: a project symlink into' +
330
+ ' dist/ and a real',
331
+ );
332
+ out.push(
333
+ ' directory installed globally can hold identical content reached two' +
334
+ ' ways. Counted on its own',
335
+ );
336
+ out.push(
337
+ ' line rather than as duplication, because only the same-shape rows' +
338
+ ' support that claim.',
339
+ );
340
+ }
341
+ if (r.scopeDefeat.length > 0) {
342
+ out.push(
343
+ ` scope defeat: ${r.scopeDefeat.length} shared rule(s) where one copy` +
344
+ ' carries `paths:` and the other does not —',
345
+ );
346
+ out.push(
347
+ ' the unscoped copy DEFEATS the other copy\'s scoping, so the rule' +
348
+ ' loads unconditionally:',
349
+ );
350
+ for (const n of r.scopeDefeat) out.push(` - ${n}`);
351
+ }
352
+ }
353
+ if (v.absent.length > 0) {
354
+ out.push('');
355
+ out.push(` layers not present (nothing to compare there): ${v.absent.join(', ')}`);
356
+ }
357
+ out.push('');
358
+ out.push(
359
+ `check_single_delivery ledger: duplicated=${v.duplicated}` +
360
+ ` name_overlap_different_shape=${v.nameOverlapDifferentShape}` +
361
+ ` scope_defeat=${v.defeated}` +
362
+ ` types_compared=${v.typesCompared} of ${v.readings.length}` +
363
+ ` names_read=${v.namesRead}`,
364
+ );
365
+ return out.join('\n');
366
+ }
367
+
368
+ export function main(argv?: readonly string[]): number {
369
+ const args = argv ?? process.argv.slice(2);
370
+ let globalRoot = path.join(os.homedir(), '.claude');
371
+ let projectRoot = path.join(REPO_ROOT, '.claude');
372
+ let enforce = false;
373
+ let quiet = false;
374
+
375
+ const value = (i: number): string | null => {
376
+ const v = args[i + 1];
377
+ return v === undefined || v.startsWith('-') ? null : v;
378
+ };
379
+ for (let i = 0; i < args.length; i += 1) {
380
+ const a = args[i];
381
+ if (a === '--global' || a === '--project') {
382
+ const v = value(i);
383
+ if (v === null) {
384
+ process.stderr.write(`check_single_delivery: ${a} needs a directory\n`);
385
+ return 1;
386
+ }
387
+ if (a === '--global') globalRoot = path.resolve(v);
388
+ else projectRoot = path.resolve(v);
389
+ i += 1;
390
+ } else if (a === '--enforce') {
391
+ enforce = true;
392
+ } else if (a === '--quiet') {
393
+ // R2 finding: the full per-type table is ~30 lines, and preflight is a
394
+ // `silent: true` task. A gate that floods a quiet chain is a gate people
395
+ // learn to scroll past, which is the same end state as an unbound one.
396
+ quiet = true;
397
+ } else if (a === '--help' || a === '-h') {
398
+ process.stdout.write(
399
+ 'usage: check_single_delivery [--global DIR] [--project DIR] [--enforce] [--quiet]\n' +
400
+ '\n' +
401
+ 'Reports by default and exits 0: the partition SHIPPED (ADR-236, Phase 2)\n' +
402
+ 'but activates per machine — only where a verified host layer exists, so a\n' +
403
+ 'checkout without one keeps the full projection BY DESIGN and reports overlap.\n' +
404
+ '--enforce exits 1 on any overlap; registering it would fail every machine\n' +
405
+ 'that has not re-run `agent-config install`, which is why it stays opt-in.\n',
406
+ );
407
+ return 0;
408
+ } else if (a !== undefined) {
409
+ // R2 finding: this used to reject only `--`-prefixed tokens, so
410
+ // `-enforce` (one dash) and a bare positional both fell through
411
+ // SILENTLY — the gate then reported without enforcing while the caller
412
+ // believed it had asked for enforcement. A typo that turns a blocking
413
+ // gate advisory is worse than a crash, so anything unrecognised is an
414
+ // error, dash or not.
415
+ process.stderr.write(
416
+ `check_single_delivery: unexpected argument ${a}` +
417
+ ' (did you mean --enforce, --global DIR or --project DIR?)\n',
418
+ );
419
+ return 1;
420
+ }
421
+ }
422
+
423
+ const v = evaluate(globalRoot, projectRoot);
424
+ if (!quiet) process.stdout.write(`${render(v, globalRoot, projectRoot)}\n`);
425
+ // Dead-scope assertion via the shared primitive rather than a hand-rolled
426
+ // `scanned:` line: `check_gate_coverage`'s hardening ratchet accepts an
427
+ // emitted line only from a gate registered in the enforced manifest, and this
428
+ // one is deliberately unregistered while step 4.3's binding surface is an open
429
+ // blocker. No `allowEmpty` reason — see the `namesRead` docstring for why zero
430
+ // is blindness here rather than a valid empty corpus.
431
+ //
432
+ // Caught rather than allowed to propagate: an uncaught `DeadScopeError` prints
433
+ // a Node stack trace, which is the correct exit code wearing an unusable
434
+ // message. The error carries the gate name and the roots, so printing it plus
435
+ // the repoint hint gives the reader what the trace was burying.
436
+ reportScanned({
437
+ gate: 'check_single_delivery',
438
+ scanned: v.namesRead,
439
+ units: 'artefact name(s) across both layers',
440
+ roots: [globalRoot, projectRoot],
441
+ // R2 finding 1: without this reason the gate returned 1 UNCONDITIONALLY
442
+ // wherever it is meant to be bound. `.claude/` is gitignored and no CI leg
443
+ // installs at user scope, so `namesRead` is 0 there, the DeadScopeError
444
+ // fired before both the `readNothing` branch and `--enforce`, and the
445
+ // gate contradicted its own `--help` ("Reports by default and exits 0").
446
+ //
447
+ // OPTIONAL_SURFACE is the correct one of the three prefixes: these two
448
+ // directories are an INSTALL-TIME surface, not a corpus this repo owns.
449
+ // Applying the doc's operational test — if the scan root were deleted,
450
+ // would the reason still make sense? — yes: no layers installed means
451
+ // there is no delivery to duplicate, which is a true answer rather than
452
+ // blindness. The blindness case is narrower and is still refused below:
453
+ // layers that exist but never pair up (`readNothing`).
454
+ allowEmpty:
455
+ 'OPTIONAL_SURFACE: both host layers are install-time surfaces, absent by' +
456
+ ' construction in CI (.claude/ is gitignored) and on any machine that has' +
457
+ ' not installed. Zero names means nothing is delivered, so nothing can be' +
458
+ ' delivered twice — a true verdict, not a dead scope. The unverifiable' +
459
+ ' case (layers present but never paired) is refused separately.',
460
+ });
461
+
462
+ if (v.readNothing) {
463
+ // Never a pass. A gate that compared nothing has not verified anything,
464
+ // and saying "invariant holds" here is the false-green shape this repo
465
+ // has recorded from other gates.
466
+ process.stdout.write(
467
+ '⚠️ check_single_delivery: no artefact type had BOTH layers present, so nothing was' +
468
+ ' compared. This is NOT a pass — repoint with --global/--project.\n',
469
+ );
470
+ return enforce ? 1 : 0;
471
+ }
472
+
473
+ if (v.duplicated === 0 && v.defeated === 0 && v.nameOverlapDifferentShape === 0) {
474
+ process.stdout.write('✅ check_single_delivery: one artefact, one layer — no overlap.\n');
475
+ return 0;
476
+ }
477
+
478
+ const parts: string[] = [];
479
+ if (v.duplicated > 0) parts.push(`${v.duplicated} artefact(s) delivered twice`);
480
+ if (v.nameOverlapDifferentShape > 0) {
481
+ parts.push(
482
+ `${v.nameOverlapDifferentShape} name collision(s) across layers of DIFFERENT shape` +
483
+ ' (a delivery collision, not a duplicated artefact)',
484
+ );
485
+ }
486
+ if (v.defeated > 0) {
487
+ parts.push(`${v.defeated} shared rule(s) with defeated \`paths:\` scoping`);
488
+ }
489
+ const detail = parts.join(', and ');
490
+ if (enforce) {
491
+ process.stdout.write(`❌ check_single_delivery: ${detail}. Invariant ADR-236 violated.\n`);
492
+ return 1;
493
+ }
494
+ process.stdout.write(
495
+ `⚠️ check_single_delivery: ${detail}. Reported, not enforced — ` +
496
+ 'the partition shipped (ADR-236 Phase 2) but activates per machine: it withholds ' +
497
+ 'artefacts only where the host layer is verified against `installed.lock`, so a ' +
498
+ 'machine whose install predates the fingerprint keeps the full projection BY DESIGN ' +
499
+ 'and this overlap is the fail-safe working. Run `agent-config install` to enable it ' +
500
+ 'here; use --enforce where every machine is known to be verified.\n',
501
+ );
502
+ return 0;
503
+ }
504
+
505
+ function _isCliEntry(): boolean {
506
+ if (process.argv[1] === undefined) return false;
507
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
508
+ try {
509
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+
515
+ if (_isCliEntry()) {
516
+ process.exit(main());
517
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Wait for a PR's checks to settle, and never report a verdict it did not read.
4
+ *
5
+ * WHY THIS EXISTS. Agents write this loop by hand, and the exit condition is
6
+ * the part that is easy to get wrong. Measured 2026-08-20:
7
+ *
8
+ * until ! gh pr checks 1459 | grep -q "pending"; do sleep 60; done
9
+ *
10
+ * The API then failed. Its error text — `error connecting to api.github.com` —
11
+ * contains no `pending`, so the loop exited, the run reported a settle that had
12
+ * not happened, and the claim had to be retracted to the user in the next
13
+ * reply. The bug is not the sleep or the grep: it is that **absence of the word
14
+ * `pending` was treated as evidence of completion**, and an error is neither
15
+ * settled nor pending.
16
+ *
17
+ * The rule this encodes, and the reason it belongs in one place rather than in
18
+ * every agent's head: `not settled` and `settled` are different from `could not
19
+ * read`, and only the first two may end a wait.
20
+ *
21
+ * Exit codes: 0 = settled, every check succeeded (or was skipped) ·
22
+ * 1 = settled, at least one check failed ·
23
+ * 2 = did not settle within the budget, or the API could not be
24
+ * read — NEVER a verdict, and the reason is printed.
25
+ *
26
+ * Usage: ./scripts-run src/scripts/ci_settle <pr> [--timeout-min N] [--interval-sec N]
27
+ */
28
+
29
+ import { spawnSync } from 'node:child_process';
30
+
31
+ /** What one poll told us. `unreadable` is deliberately not a verdict. */
32
+ export type PollState =
33
+ | { kind: 'unreadable'; reason: string }
34
+ | { kind: 'pending'; total: number; done: number }
35
+ | { kind: 'settled'; failing: string[]; total: number };
36
+
37
+ /** Markers that mean the API did not answer — not that nothing is pending. */
38
+ const _UNREADABLE_MARKERS = [
39
+ 'error connecting to',
40
+ 'could not resolve host',
41
+ 'connection refused',
42
+ 'timeout',
43
+ 'timed out',
44
+ 'bad gateway',
45
+ 'service unavailable',
46
+ 'rate limit',
47
+ 'gh auth login',
48
+ 'not logged into',
49
+ 'HTTP 5',
50
+ ];
51
+
52
+ /**
53
+ * Classify one `gh pr view --json statusCheckRollup` result.
54
+ *
55
+ * Pure, so the failure this file exists to prevent is testable without a
56
+ * network: feed it an API error and it must answer `unreadable`, never
57
+ * `settled`.
58
+ */
59
+ export function classifyPoll(stdout: string, stderr: string, status: number | null): PollState {
60
+ const combined = `${stdout}\n${stderr}`.toLowerCase();
61
+ for (const marker of _UNREADABLE_MARKERS) {
62
+ if (combined.includes(marker.toLowerCase())) {
63
+ return { kind: 'unreadable', reason: marker };
64
+ }
65
+ }
66
+ if (status !== 0) {
67
+ return { kind: 'unreadable', reason: `gh exited ${String(status)}` };
68
+ }
69
+ let rows: { name?: string; conclusion?: string | null; status?: string | null }[];
70
+ try {
71
+ const parsed = JSON.parse(stdout) as { statusCheckRollup?: unknown };
72
+ const roll = parsed.statusCheckRollup;
73
+ if (!Array.isArray(roll)) {
74
+ return { kind: 'unreadable', reason: 'no statusCheckRollup in response' };
75
+ }
76
+ rows = roll as typeof rows;
77
+ } catch {
78
+ return { kind: 'unreadable', reason: 'unparseable JSON' };
79
+ }
80
+ if (rows.length === 0) {
81
+ // Zero checks is not "settled green" — a run that has not registered
82
+ // its checks yet looks exactly like this.
83
+ return { kind: 'pending', total: 0, done: 0 };
84
+ }
85
+ const terminal = (r: (typeof rows)[number]): boolean =>
86
+ typeof r.conclusion === 'string' && r.conclusion.length > 0;
87
+ const done = rows.filter(terminal).length;
88
+ if (done < rows.length) {
89
+ return { kind: 'pending', total: rows.length, done };
90
+ }
91
+ const bad = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
92
+ const failing = rows
93
+ .filter((r) => bad.has(String(r.conclusion).toUpperCase()))
94
+ .map((r) => String(r.name ?? '?'));
95
+ return { kind: 'settled', failing, total: rows.length };
96
+ }
97
+
98
+ function poll(pr: string): PollState {
99
+ const r = spawnSync('gh', ['pr', 'view', pr, '--json', 'statusCheckRollup'], {
100
+ encoding: 'utf-8',
101
+ timeout: 60_000,
102
+ });
103
+ return classifyPoll(r.stdout ?? '', r.stderr ?? '', r.status);
104
+ }
105
+
106
+ function sleepSync(seconds: number): void {
107
+ // Deliberately synchronous: this is a CLI whose whole job is to block.
108
+ spawnSync(process.execPath, ['-e', `setTimeout(()=>{}, ${String(seconds * 1000)})`], {
109
+ timeout: (seconds + 5) * 1000,
110
+ });
111
+ }
112
+
113
+ export function main(argv: readonly string[]): number {
114
+ const positional = argv.filter((a) => !a.startsWith('--'));
115
+ const pr = positional[0];
116
+ if (pr === undefined) {
117
+ process.stderr.write('usage: ci_settle <pr> [--timeout-min N] [--interval-sec N]\n');
118
+ return 2;
119
+ }
120
+ const num = (flag: string, dflt: number): number => {
121
+ const i = argv.indexOf(flag);
122
+ if (i === -1) return dflt;
123
+ const v = argv[i + 1];
124
+ const n = v === undefined ? NaN : parseInt(v, 10);
125
+ return Number.isFinite(n) && n > 0 ? n : dflt;
126
+ };
127
+ const timeoutMin = num('--timeout-min', 45);
128
+ const intervalSec = num('--interval-sec', 60);
129
+
130
+ const deadline = Date.now() + timeoutMin * 60_000;
131
+ let unreadableStreak = 0;
132
+
133
+ for (;;) {
134
+ const state = poll(pr);
135
+ if (state.kind === 'settled') {
136
+ if (state.failing.length === 0) {
137
+ process.stdout.write(`ci_settle: SETTLED GREEN — ${String(state.total)} check(s)\n`);
138
+ return 0;
139
+ }
140
+ process.stdout.write(
141
+ `ci_settle: SETTLED RED — ${String(state.failing.length)} of ${String(state.total)} failing:\n` +
142
+ state.failing.map((f) => ` ${f}\n`).join(''),
143
+ );
144
+ return 1;
145
+ }
146
+ if (state.kind === 'unreadable') {
147
+ unreadableStreak += 1;
148
+ process.stdout.write(
149
+ `ci_settle: could not read the API (${state.reason}) — NOT a verdict, still waiting ` +
150
+ `[${String(unreadableStreak)}]\n`,
151
+ );
152
+ } else {
153
+ unreadableStreak = 0;
154
+ process.stdout.write(
155
+ `ci_settle: pending — ${String(state.done)}/${String(state.total)} settled\n`,
156
+ );
157
+ }
158
+ if (Date.now() >= deadline) {
159
+ process.stdout.write(
160
+ `ci_settle: DID NOT SETTLE within ${String(timeoutMin)} min — no verdict is claimed.\n`,
161
+ );
162
+ return 2;
163
+ }
164
+ sleepSync(intervalSec);
165
+ }
166
+ }
167
+
168
+ if (process.argv[1] !== undefined && process.argv[1].endsWith('ci_settle.ts')) {
169
+ process.exitCode = main(process.argv.slice(2));
170
+ }