@event4u/agent-config 9.29.0 → 9.30.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 (122) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +108 -7
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
  18. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
  19. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
  20. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
  21. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  22. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  23. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  24. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  25. package/dist/cli-delegate/cmd_conformance.js +2798 -95
  26. package/dist/cli-delegate/cmd_doctor.js +2 -1
  27. package/dist/cli-delegate/cmd_export.js +2 -2
  28. package/dist/cli-delegate/cmd_handoff.js +86 -6
  29. package/dist/cli-delegate/cmd_prune.js +2 -2
  30. package/dist/cli-delegate/cmd_route_audit.js +2 -1
  31. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  32. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  33. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  34. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +7 -7
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/orphan-report.md +1 -1
  43. package/dist/discovery/packs.json +2 -2
  44. package/dist/discovery/trust-report.md +1 -1
  45. package/dist/discovery/workspaces.json +2 -2
  46. package/dist/hooks/dispatch.js +1904 -575
  47. package/dist/install/install.mjs +14 -0
  48. package/dist/mcp/registry-manifest.json +2 -2
  49. package/dist/mcp/server.json +2 -2
  50. package/dist/mcp/server.mjs +3760 -726
  51. package/dist/server/schemas/settings.js +6 -0
  52. package/dist/server/schemas/settings.js.map +1 -1
  53. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  54. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  55. package/dist/ui/index.html +1 -1
  56. package/docs/contracts/settings-classes.md +21 -5
  57. package/package.json +1 -1
  58. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  59. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  60. package/src/config/agent-settings.template.yml +36 -0
  61. package/src/config/dispatch-economy-metrics.json +61 -0
  62. package/src/config/evaluator-budgets.json +4 -4
  63. package/src/config/hook-token-budget.json +91 -0
  64. package/src/config/recycle-threshold-budget.json +12 -0
  65. package/src/config/rule-enforcement-baseline.json +86 -0
  66. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  67. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  68. package/src/scripts/_dispatch.bash +23 -0
  69. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  70. package/src/scripts/_lib/orchestration_record.ts +41 -10
  71. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  72. package/src/scripts/_lib/self_repair.ts +194 -0
  73. package/src/scripts/_lib/session_eol.ts +230 -0
  74. package/src/scripts/_lib/session_role.ts +42 -0
  75. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  76. package/src/scripts/_lib/subagent_response.ts +75 -0
  77. package/src/scripts/ai_council/clients.ts +50 -1
  78. package/src/scripts/ask_transport.ts +221 -0
  79. package/src/scripts/bench_hook_injection.ts +234 -0
  80. package/src/scripts/check_adr_frontmatter.ts +84 -40
  81. package/src/scripts/check_branch_freshness.ts +1 -0
  82. package/src/scripts/check_corpus_staleness.ts +18 -4
  83. package/src/scripts/check_detector_corpus.ts +17 -0
  84. package/src/scripts/check_gitignore_freshness.ts +42 -8
  85. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  86. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  87. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  88. package/src/scripts/check_static_layer_stability.ts +154 -0
  89. package/src/scripts/check_suppression_hygiene.ts +48 -0
  90. package/src/scripts/dispatch_economy_report.ts +448 -0
  91. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  92. package/src/scripts/explain_run.ts +206 -4
  93. package/src/scripts/generate_settings_reference.ts +1 -0
  94. package/src/scripts/handoff_context_hook.ts +112 -4
  95. package/src/scripts/hook_manifest.yaml +117 -10
  96. package/src/scripts/hooks/concern_registry.ts +8 -0
  97. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  98. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  99. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  100. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  101. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  102. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  103. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  104. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  105. package/src/scripts/install-hooks.sh +1 -1
  106. package/src/scripts/language_mirror_hook.ts +89 -0
  107. package/src/scripts/lint_decision_impact.ts +14 -1
  108. package/src/scripts/lint_hook_manifest.ts +82 -0
  109. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  110. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  111. package/src/scripts/measure_turn_end_gate.ts +194 -0
  112. package/src/scripts/orchestration_record.ts +4 -1
  113. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  114. package/src/scripts/report_conformance_funnel.ts +284 -0
  115. package/src/scripts/report_imperative_density.ts +1 -0
  116. package/src/scripts/routing_doctor.ts +97 -0
  117. package/src/scripts/self_repair_cli.ts +51 -3
  118. package/src/scripts/session_eol_report.ts +261 -0
  119. package/src/server/schemas/settings.ts +14 -0
  120. package/src/templates/compact-instructions.md +40 -0
  121. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  122. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * report_conformance_funnel.ts — delivery → activation → compliance, one view.
4
+ *
5
+ * ⚠️ REPORT ONLY — THIS SCRIPT MUST NEVER BE WIRED INTO A CI WORKFLOW WITHOUT
6
+ * A MEASURED FALSE-POSITIVE RATE FIRST (locked policy; council requirement,
7
+ * road-to-feedback-9-29 Phase 4.2). Every source it joins is itself advisory
8
+ * and says so; a join of advisories does not become a gate by aggregation.
9
+ *
10
+ * Executes road-to-feedback-9-29 Phase 4.2: one report join over the existing
11
+ * conformance sources, so the three questions that were answered in three
12
+ * places read as the funnel they are:
13
+ *
14
+ * DELIVERY what rule text the carriers put in front of the model, and
15
+ * whether the two carriers agree —
16
+ * `conformance_scan.measureDelivered` (carrier census) +
17
+ * `report_carrier_divergence.compareCarriers` (divergence).
18
+ * ACTIVATION whether the shipped skills are invoked at all —
19
+ * `report_skill_activation.censusSkills` + `measureUsage`.
20
+ * COMPLIANCE whether a loaded skill's obligations were violated (SK-2) —
21
+ * `report_skill_obligation_violations.scanStore`.
22
+ *
23
+ * JOINS, NEVER RE-DERIVES. Every number below comes from an EXPORTED function
24
+ * of the source that owns it. A second copy of a classifier is the "second
25
+ * artefact to keep in sync" this repo forbids — it is how the 8 → 30 spread
26
+ * and the 303-vs-626 detector defect happened, and both are cited in the
27
+ * sources this file imports.
28
+ *
29
+ * Each axis also carries the honest-scope statement its source already
30
+ * carries (SK-2's mechanisable-subset ratio, activation's catalogue-not-
31
+ * persisted caveat, delivery's not-per-session caveat). Where a source has no
32
+ * local data, the axis prints its honest "no data" line rather than
33
+ * disappearing — an absent store is an answer, not a zero.
34
+ *
35
+ * Reached via `agent-config conformance --funnel` (no new top-level verb —
36
+ * the flag hangs off the existing `conformance` verb per the dispatcher-verb
37
+ * registry trap named in the roadmap's risk table).
38
+ *
39
+ * Exit: 0 always, except a usage error (1). Deliberate — see the header.
40
+ */
41
+ import * as fs from 'node:fs';
42
+ import * as os from 'node:os';
43
+ import * as path from 'node:path';
44
+ import process from 'node:process';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+
47
+ import {
48
+ measureDelivered,
49
+ defaultStore,
50
+ type DeliveredPayload,
51
+ } from './conformance_scan.js';
52
+ import {
53
+ compareCarriers,
54
+ GLOBAL_RULES,
55
+ PROJECT_RULES,
56
+ PROJECTION_SOURCE,
57
+ type CarrierDivergence,
58
+ } from './report_carrier_divergence.js';
59
+ import {
60
+ censusSkills,
61
+ measureUsage,
62
+ SKILLS_ROOT,
63
+ type SkillCensus,
64
+ type UsageReport,
65
+ } from './report_skill_activation.js';
66
+ import { scanStore as scanObligationStore, type Sk2Report } from './report_skill_obligation_violations.js';
67
+
68
+ const _HERE = fileURLToPath(import.meta.url);
69
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
70
+
71
+ export interface FunnelOptions {
72
+ /** The checkout whose skills and projection source are measured. */
73
+ repoRoot?: string;
74
+ /** The project rule carrier — what the host reads at project scope. */
75
+ projectRulesDir?: string;
76
+ /** The machine-global rule carrier. */
77
+ globalRulesDir?: string;
78
+ /** The projection source, read only to attribute manual-only absences. */
79
+ sourceDir?: string;
80
+ /** Transcript store for the activation and compliance axes. */
81
+ store?: string;
82
+ /** Most-recent sessions to scan. */
83
+ limit?: number;
84
+ }
85
+
86
+ export interface FunnelReport {
87
+ /** DELIVERY — carrier census (conformance_scan) + divergence (report_carrier_divergence). */
88
+ delivered: DeliveredPayload;
89
+ divergence: CarrierDivergence;
90
+ /** ACTIVATION — skill census + usage (report_skill_activation). */
91
+ skills: SkillCensus;
92
+ usage: UsageReport;
93
+ /** `false` is the no-data branch for both transcript-fed axes. */
94
+ storePresent: boolean;
95
+ /** COMPLIANCE — SK-2 loaded-but-violated (report_skill_obligation_violations). */
96
+ sk2: Sk2Report;
97
+ }
98
+
99
+ export function buildFunnel(opts: FunnelOptions = {}): FunnelReport {
100
+ const repoRoot = opts.repoRoot ?? REPO_ROOT;
101
+ const projectRulesDir = opts.projectRulesDir ?? path.join(repoRoot, PROJECT_RULES);
102
+ const globalRulesDir = opts.globalRulesDir ?? path.join(os.homedir(), GLOBAL_RULES);
103
+ const sourceDir = opts.sourceDir ?? path.join(repoRoot, PROJECTION_SOURCE);
104
+ const store = opts.store ?? defaultStore(repoRoot);
105
+ const limit = opts.limit ?? 30;
106
+ return {
107
+ delivered: measureDelivered(projectRulesDir, globalRulesDir),
108
+ divergence: compareCarriers(projectRulesDir, globalRulesDir, sourceDir),
109
+ skills: censusSkills(path.join(repoRoot, SKILLS_ROOT)),
110
+ usage: measureUsage(store, limit),
111
+ storePresent: fs.existsSync(store),
112
+ sk2: scanObligationStore(repoRoot, store, limit),
113
+ };
114
+ }
115
+
116
+ function _pct(n: number, d: number): string {
117
+ return d === 0 ? 'n/a' : `${((100 * n) / d).toFixed(1)}%`;
118
+ }
119
+
120
+ function _carrierLine(label: string, c: { present: boolean; files: number; tokens: number }): string {
121
+ return c.present
122
+ ? ` ${label} ${String(c.files).padStart(4)} rules ${String(c.tokens).padStart(7)} tok`
123
+ : ` ${label} ABSENT — not a measured zero, this carrier does not exist here`;
124
+ }
125
+
126
+ export function render(r: FunnelReport): string {
127
+ const lines: string[] = [];
128
+ lines.push('conformance funnel — delivery → activation → compliance (report, not a gate)');
129
+ lines.push('');
130
+
131
+ // ── DELIVERY ───────────────────────────────────────────────────────────
132
+ lines.push('DELIVERY — rule text the carriers put in front of the model');
133
+ lines.push(_carrierLine('project', r.delivered.project));
134
+ lines.push(_carrierLine('global ', r.delivered.global));
135
+ lines.push(` union ${String(r.delivered.union_tokens).padStart(7)} tok`);
136
+ lines.push(' Carriers as they stand NOW — a session\'s own payload is NOT recoverable:');
137
+ lines.push(' the transcript records no system or tools field (conformance_scan).');
138
+ const d = r.divergence;
139
+ if (!d.globalPresent) {
140
+ lines.push(' Divergence: the global carrier is absent — this machine loads the project');
141
+ lines.push(' projection only, so there is no second copy to diverge from. A real');
142
+ lines.push(' answer, not a skipped check.');
143
+ } else if (!d.projectPresent) {
144
+ lines.push(' Divergence: the project rule tree does not exist in this checkout — it is');
145
+ lines.push(' generated and gitignored. Run `task generate-tools`, then re-run. Not');
146
+ lines.push(' substituted with dist/, which would answer a different question.');
147
+ } else {
148
+ lines.push(
149
+ ` Divergence: ${d.shared} shared · ${d.bodyDiff.length} body-diff · ` +
150
+ `${d.provenanceOnly.length} provenance-only · ${d.projectOnly.length} project-only · ` +
151
+ `${d.globalOnly.length} global-only` +
152
+ (d.manualOnlyGlobal.length > 0 ? ` (${d.manualOnlyGlobal.length} by design, ADR-004 manual)` : ''),
153
+ );
154
+ if (d.bodyDiff.length > 0) {
155
+ lines.push(` BODY DIVERGENCE — both copies reach the model: ${d.bodyDiff.join(', ')}`);
156
+ lines.push(' Precedence: the project projection is generated from src/ at this commit');
157
+ lines.push(' and wins; the global install is a release snapshot.');
158
+ }
159
+ if (d.unreadable.length > 0) {
160
+ lines.push(` Unreadable on one side (${d.unreadable.length}) — a broken install, not a disagreement.`);
161
+ }
162
+ }
163
+ lines.push('');
164
+
165
+ // ── ACTIVATION ─────────────────────────────────────────────────────────
166
+ lines.push('ACTIVATION — are the delivered skills invoked?');
167
+ lines.push(` skills shipped ${r.skills.total}`);
168
+ lines.push(
169
+ ` with a machine-matchable trigger key ${r.skills.withTriggerKey.length} ` +
170
+ `(${_pct(r.skills.withTriggerKey.length, r.skills.total)})`,
171
+ );
172
+ if (!r.storePresent) {
173
+ lines.push(` NO DATA: no transcript store at ${r.usage.store} — usage is not`);
174
+ lines.push(' measurable here. An absent store is an answer, not a zero.');
175
+ } else {
176
+ const distinct = Object.keys(r.usage.bySkill).length;
177
+ lines.push(
178
+ ` sessions=${r.usage.sessions} assistant turns=${r.usage.assistantTurns} ` +
179
+ `Skill invocations=${r.usage.invocations} distinct skills=${distinct} of ${r.skills.total} ` +
180
+ `(${_pct(distinct, r.skills.total)})`,
181
+ );
182
+ }
183
+ if (r.skills.withTriggerKey.length === 0) {
184
+ lines.push(' Honest scope: activation is NOT measurable as a rate — no skill declares');
185
+ lines.push(' a machine-matchable trigger, and matching description prose is the class');
186
+ lines.push(' this suite excludes (report_skill_activation).');
187
+ }
188
+ lines.push(' NOT MEASURED: whether each skill reached the model WITH its description —');
189
+ lines.push(' the host\'s injected catalogue is not persisted in the transcript, so the');
190
+ lines.push(' bare-name (truncation) hypothesis stays a single-session observation.');
191
+ lines.push('');
192
+
193
+ // ── COMPLIANCE ─────────────────────────────────────────────────────────
194
+ const c = r.sk2.census;
195
+ lines.push('COMPLIANCE — SK-2 loaded-but-violated (coverage is the headline)');
196
+ lines.push(` skills with a deterministic obligation ${c.skills.length}`);
197
+ lines.push(
198
+ ` testable without judgement ${c.forbidden.length} artefact(s) over ` +
199
+ `${c.totalLines} obligation line(s)`,
200
+ );
201
+ lines.push(' The rest are deterministic in WORDING and need a reading to test — the');
202
+ lines.push(' FC-8 class this suite excludes, reported as uncovered, not approximated');
203
+ lines.push(' (report_skill_obligation_violations).');
204
+ if (!r.storePresent) {
205
+ lines.push(' NO DATA: no transcript store — loaded-but-violated cannot be observed');
206
+ lines.push(' here. The coverage census above is static and still holds.');
207
+ } else {
208
+ lines.push(
209
+ ` sessions scanned=${r.sk2.sessions} with a skill in context=${r.sk2.sessionsWithASkill} ` +
210
+ `flags=${r.sk2.flags.length}`,
211
+ );
212
+ for (const f of r.sk2.flags) {
213
+ lines.push(` ${f.session} [${f.skill}] ${f.artefact} via ${f.tool}`);
214
+ }
215
+ }
216
+ lines.push(' PRECISION: every flag is hand-read before any number here is cited — the');
217
+ lines.push(' detector cannot state its false-positive rate, so it ships detection-only.');
218
+ lines.push('');
219
+
220
+ lines.push('This is a REPORT. It gates on nothing and must never be wired into a CI');
221
+ lines.push('workflow without a measured false-positive rate first (locked policy).');
222
+ return lines.join('\n');
223
+ }
224
+
225
+ export function main(argv?: readonly string[]): number {
226
+ const args = argv ?? process.argv.slice(2);
227
+ const opts: FunnelOptions = {};
228
+ // A value must not itself be a flag — the same hole the sibling reports
229
+ // close on both the flag-name and flag-value halves.
230
+ const value = (i: number): string | null => {
231
+ const v = args[i + 1];
232
+ return v === undefined || v.startsWith('-') ? null : v;
233
+ };
234
+ for (let i = 0; i < args.length; i += 1) {
235
+ const a = args[i];
236
+ if (a === '--store' || a === '--project' || a === '--global' || a === '--source') {
237
+ const v = value(i);
238
+ if (v === null) {
239
+ process.stderr.write(`report_conformance_funnel: ${a} needs a value\n`);
240
+ return 1;
241
+ }
242
+ const resolved = path.resolve(v);
243
+ if (a === '--store') opts.store = resolved;
244
+ else if (a === '--project') opts.projectRulesDir = resolved;
245
+ else if (a === '--global') opts.globalRulesDir = resolved;
246
+ else opts.sourceDir = resolved;
247
+ i += 1;
248
+ } else if (a === '--limit') {
249
+ const v = value(i);
250
+ const n = v === null ? Number.NaN : Number.parseInt(v, 10);
251
+ if (!Number.isFinite(n) || n <= 0) {
252
+ process.stderr.write('report_conformance_funnel: --limit must be a positive integer\n');
253
+ return 1;
254
+ }
255
+ opts.limit = n;
256
+ i += 1;
257
+ } else if (a === '--help' || a === '-h') {
258
+ process.stdout.write(
259
+ 'usage: report_conformance_funnel [--store PATH] [--limit N]\n' +
260
+ ' [--project DIR] [--global DIR] [--source DIR]\n',
261
+ );
262
+ return 0;
263
+ } else if (a !== undefined && a.startsWith('--') && a !== '--quiet') {
264
+ process.stderr.write(`report_conformance_funnel: unknown flag ${a}\n`);
265
+ return 1;
266
+ }
267
+ }
268
+ process.stdout.write(`${render(buildFunnel(opts))}\n`);
269
+ return 0;
270
+ }
271
+
272
+ function _isCliEntry(): boolean {
273
+ if (process.argv[1] === undefined) return false;
274
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
275
+ try {
276
+ return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ if (_isCliEntry()) {
283
+ process.exit(main());
284
+ }
@@ -32,6 +32,7 @@ import * as fs from 'node:fs';
32
32
  import * as path from 'node:path';
33
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
34
34
 
35
+ // ledger-exempt: advisory report that gates on nothing BY DESIGN (exit 0 always, no threshold, no baseline entry, no gate-coverage row — see the header); there is no pass/fail outcome per target to account, and acquiring one would be the promotion this report must not undergo.
35
36
  const _HERE = fileURLToPath(import.meta.url);
36
37
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
37
38
 
@@ -35,6 +35,7 @@
35
35
  * processes are the two freshness checks, both themselves read-only.
36
36
  */
37
37
  import fs from "node:fs";
38
+ import os from "node:os";
38
39
  import path from "node:path";
39
40
  import { spawnSync } from "node:child_process";
40
41
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -62,6 +63,11 @@ import {
62
63
  type ActivationInputs,
63
64
  } from "./_lib/auto_dispatch.js";
64
65
  import { TIER_ORDER, readCooldowns } from "./_lib/tier_budget_routing.js";
66
+ import {
67
+ instructionsLoadedRecord,
68
+ measureStandingDelivery,
69
+ type StandingDeliveryMeasure,
70
+ } from "./check_standing_rule_delivery.js";
65
71
 
66
72
  const REPO_ROOT = path.resolve(
67
73
  path.dirname(fileURLToPath(import.meta.url)),
@@ -197,9 +203,93 @@ export interface DoctorReport {
197
203
  gates: GateReport[];
198
204
  freshness: FreshnessReport;
199
205
  orchestration: OrchestrationReport;
206
+ standing_delivery: StandingDeliveryMeasure | null;
200
207
  bridge_table: string;
201
208
  }
202
209
 
210
+ /**
211
+ * Standing-rule-delivery savings for the doctor surface — the measurement is
212
+ * `check_standing_rule_delivery`'s, reused rather than re-derived, so the gate
213
+ * and the doctor can never report different numbers for the same layers.
214
+ * Read-only, like everything else here. `null` = no rule layer installed.
215
+ */
216
+ export function collect_standing_delivery(
217
+ workspace_root: string,
218
+ global_rules_dir: string = path.join(os.homedir(), ".claude", "rules"),
219
+ ): StandingDeliveryMeasure | null {
220
+ return measureStandingDelivery(
221
+ global_rules_dir,
222
+ path.join(workspace_root, ".claude", "rules"),
223
+ instructionsLoadedRecord(workspace_root),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Render the delivery line in one of its states — scoped-clean (one layer,
229
+ * nothing duplicated) or both-layers-active (red, pointing at
230
+ * `install --layer`). Every number printed is one the measurement produced:
231
+ * the received total, the measured overlap, and the received-minus-overlap
232
+ * total suppression would leave. No baseline is invented for the layer that
233
+ * is not installed, so no percentage is derived from one either. The
234
+ * filesystem-vs-host-confirmed caveat is the gate's own honest-scope
235
+ * sentence, carried here so the doctor never claims the host confirmed what
236
+ * only the filesystem projects.
237
+ */
238
+ export function render_standing_delivery(m: StandingDeliveryMeasure | null): string[] {
239
+ if (m === null) {
240
+ return [
241
+ "Standing delivery: no rule layer installed (neither ~/.claude/rules nor .claude/rules) — nothing delivered, nothing to save.",
242
+ ];
243
+ }
244
+ const lines: string[] = [];
245
+ if (m.layers.length === 2 && m.overlap_rules > 0) {
246
+ const scoped = m.received_tokens - m.overlap_tokens;
247
+ lines.push(
248
+ `⚠️ Standing delivery: both rule layers active — ${m.overlap_rules} rule(s) delivered twice ` +
249
+ `(~${m.overlap_tokens} tok). This session's host receives ${m.received_tokens} rule tokens; ` +
250
+ `scoped delivery would be ~${scoped} — run \`agent-config install --layer=<global|project>\`.`,
251
+ );
252
+ if (m.divergent_rules > 0) {
253
+ // Suppressing a layer whose copies differ drops whichever obligations only
254
+ // the suppressed copy carried — the same guard `decideLayerAction` applies.
255
+ lines.push(
256
+ ` ${m.divergent_rules} of the shared rule(s) differ in body — refresh before suppressing, ` +
257
+ "or obligations only the suppressed copy carries are lost.",
258
+ );
259
+ }
260
+ } else if (m.layers.length === 2) {
261
+ lines.push(
262
+ `Standing delivery: this session's host receives ${m.received_tokens} rule tokens across two ` +
263
+ "non-overlapping layers — nothing delivered twice, Δ0 to save.",
264
+ );
265
+ } else {
266
+ // One layer = the scoped state, and the honest report of it is the received
267
+ // total plus the measured fact that nothing is delivered twice.
268
+ //
269
+ // This line used to invent an "unscoped both-layers baseline" of
270
+ // `received × 2` and report the arithmetic consequence — which is always
271
+ // exactly 50%, for every non-zero input, i.e. a tautology dressed as a
272
+ // measurement. It also assumed the absent second layer would carry an
273
+ // identical corpus; the branch directly above exists because two installed
274
+ // layers can be wholly disjoint, so even with both present the doubling
275
+ // does not follow. A saving needs a second layer to measure against, and
276
+ // here there is none.
277
+ const layer = m.layers[0]?.label ?? "one";
278
+ lines.push(
279
+ `Standing delivery: this session's host receives ${m.received_tokens} rule tokens (scoped, ` +
280
+ `${layer} layer only); 0 tokens delivered twice — one layer is installed, so there is no ` +
281
+ "second layer to measure a saving against.",
282
+ );
283
+ }
284
+ if (m.input === "filesystem") {
285
+ lines.push(
286
+ " note: filesystem projection, not host-confirmed — no InstructionsLoaded record; a rule " +
287
+ "projected but never loaded, or loaded from an unprojected layer, is invisible to this input.",
288
+ );
289
+ }
290
+ return lines;
291
+ }
292
+
203
293
  /**
204
294
  * Orchestration-routing state: settings gates, host capability, budget
205
295
  * inputs, and (optionally) a dry-run classification for a sample prompt.
@@ -444,6 +534,8 @@ export function collect_report(options: {
444
534
  workspace_root: string;
445
535
  no_freshness: boolean;
446
536
  classify?: string | null;
537
+ /** Test seam: fixture global rules dir. Defaults to the real `~/.claude/rules`. */
538
+ global_rules_dir?: string;
447
539
  }): DoctorReport {
448
540
  const manifest = _load_yaml(MANIFEST_PATH);
449
541
  const chain = resolve_chain(manifest, options.platform);
@@ -477,6 +569,9 @@ export function collect_report(options: {
477
569
  options.platform,
478
570
  options.platform_assumed ?? true,
479
571
  ),
572
+ standing_delivery: options.global_rules_dir === undefined
573
+ ? collect_standing_delivery(options.workspace_root)
574
+ : collect_standing_delivery(options.workspace_root, options.global_rules_dir),
480
575
  bridge_table,
481
576
  };
482
577
  }
@@ -499,6 +594,8 @@ function _render(report: DoctorReport): string {
499
594
  ` · dispatch bundle=${report.freshness.dispatch_bundle}`,
500
595
  );
501
596
  lines.push("");
597
+ lines.push(...render_standing_delivery(report.standing_delivery));
598
+ lines.push("");
502
599
  const o = report.orchestration;
503
600
  lines.push(
504
601
  `Orchestration: halted=${o.halted} · downshift=${o.downshift} · ` +
@@ -34,12 +34,15 @@ import {
34
34
  chooseEgress,
35
35
  type DefectRecord,
36
36
  type EgressCapability,
37
+ type EgressChoice,
37
38
  type EgressRoute,
38
39
  egressBlockedReason,
40
+ patchDeniedReason,
39
41
  type PushVia,
40
42
  renderReport,
41
43
  sanitizeEvidence,
42
44
  } from './_lib/self_repair.js';
45
+ import { buildSettingsClassIndex, parseSettingsClassRows } from '../shared/settingsClasses.js';
43
46
  import {
44
47
  attachReleaseErrors,
45
48
  listRecords,
@@ -177,21 +180,61 @@ export interface ReleasePlan {
177
180
  route: EgressRoute;
178
181
  pushVia: PushVia | null;
179
182
  blocked: string | null;
183
+ /**
184
+ * Non-null when the record targets a may-not-modify surface (kernel rules,
185
+ * safety floors, settings class C, CI enforcement, self-repair's own
186
+ * policy/code): the patch/PR path is refused with this reason and the
187
+ * route degrades to report-only. Detection and the issue path stay open.
188
+ */
189
+ patch_denied: string | null;
180
190
  title: string;
181
191
  body: string;
182
192
  }
183
193
 
194
+ /**
195
+ * Class-C settings keys from the contract in the agent-config checkout, for
196
+ * the deny-list's dynamic key match. `null` when there is no checkout or the
197
+ * contract is unreadable — the deny-list's static path matches still apply.
198
+ */
199
+ export function readClassCKeys(checkout: string | null): ReadonlySet<string> | null {
200
+ if (checkout === null) {
201
+ return null;
202
+ }
203
+ try {
204
+ const md = fs.readFileSync(
205
+ path.join(checkout, 'docs', 'contracts', 'settings-classes.md'),
206
+ 'utf-8',
207
+ );
208
+ const index = buildSettingsClassIndex(parseSettingsClassRows(md));
209
+ return new Set([...index].filter(([, cls]) => cls === 'C').map(([key]) => key));
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
184
215
  /** Pure: everything `release` decides before it touches the network. */
185
- export function planRelease(record: DefectRecord, probe: Probe, repoRoot: string): ReleasePlan {
216
+ export function planRelease(
217
+ record: DefectRecord,
218
+ probe: Probe,
219
+ repoRoot: string,
220
+ opts?: { classCKeys?: ReadonlySet<string> | null },
221
+ ): ReleasePlan {
186
222
  const blocked = egressBlockedReason(record, repoRoot);
187
- const choice =
223
+ const patch_denied = blocked === null ? patchDeniedReason(record, opts) : null;
224
+ let choice: EgressChoice =
188
225
  blocked !== null
189
226
  ? { route: 'local-only' as const, pushVia: null }
190
227
  : chooseEgress(capabilityOf(probe));
228
+ if (patch_denied !== null && choice.route === 'pull-request') {
229
+ // chooseEgress only returns pull-request when gh is authenticated, so
230
+ // report-only degrades to an issue, never to a dropped record.
231
+ choice = { route: 'issue', pushVia: null };
232
+ }
191
233
  return {
192
234
  route: choice.route,
193
235
  pushVia: choice.pushVia,
194
236
  blocked,
237
+ patch_denied,
195
238
  title: titleFor(record),
196
239
  body: renderReport(record, choice.route),
197
240
  };
@@ -391,13 +434,18 @@ function releaseCmd(root: string, argv: string[]): number {
391
434
  const repoArg = argv.indexOf('--repo');
392
435
  const repo = repoArg >= 0 ? (argv[repoArg + 1] ?? UPSTREAM_REPO) : UPSTREAM_REPO;
393
436
  const probe = probeMachine(process.env, process.cwd());
394
- const plan = planRelease(record, probe, root);
437
+ const plan = planRelease(record, probe, root, {
438
+ classCKeys: readClassCKeys(probe.agentConfigCheckout),
439
+ });
395
440
 
396
441
  const via = plan.pushVia !== null ? ` via=${plan.pushVia}` : '';
397
442
  process.stdout.write(`self-repair:release ${fp} -> route=${plan.route}${via}\n`);
398
443
  if (plan.blocked !== null) {
399
444
  process.stdout.write(` WARN ${plan.blocked}\n`);
400
445
  }
446
+ if (plan.patch_denied !== null) {
447
+ process.stdout.write(` DENY ${plan.patch_denied}\n`);
448
+ }
401
449
  if (dryRun) {
402
450
  process.stdout.write(`\n--- ${plan.title} ---\n${plan.body}\n`);
403
451
  return EXIT_OK;