@event4u/agent-config 10.1.0 → 10.3.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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +110 -167
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/roadmap/create.md +4 -1
  6. package/dist/agent-src/commands/roadmap/next.md +38 -0
  7. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
  8. package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
  9. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  10. package/dist/agent-src/rules/active-remediation.md +53 -2
  11. package/dist/agent-src/rules/code-provenance.md +66 -4
  12. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  13. package/dist/agent-src/rules/context-hygiene.md +39 -0
  14. package/dist/agent-src/rules/design-fidelity.md +49 -3
  15. package/dist/agent-src/rules/token-budget-discipline.md +3 -2
  16. package/dist/agent-src/rules/token-efficiency.md +22 -0
  17. package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
  18. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  20. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  23. package/dist/agent-src/skills/roadmap-writing/SKILL.md +9 -9
  24. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  25. package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
  26. package/dist/agent-src/templates/agent-settings.md +12 -28
  27. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  28. package/dist/agent-src/templates/persona.md +26 -0
  29. package/dist/agent-src/templates/roadmaps.md +27 -7
  30. package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  31. package/dist/agent-src/templates/skill.md +35 -1
  32. package/dist/cli/commands/commands.candidates.test.js +257 -0
  33. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  34. package/dist/cli/commands/commands.js +199 -0
  35. package/dist/cli/commands/commands.js.map +1 -1
  36. package/dist/cli/main.js +1 -0
  37. package/dist/cli/main.js.map +1 -1
  38. package/dist/cli/registry.js +2 -2
  39. package/dist/cli/registry.js.map +1 -1
  40. package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
  41. package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
  42. package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
  43. package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
  44. package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
  45. package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-FV34EGEX.js} +166 -39
  46. package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
  47. package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
  48. package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
  49. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  50. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  51. package/dist/cli-delegate/cmd_conformance.js +881 -204
  52. package/dist/cli-delegate/cmd_doctor.js +3 -3
  53. package/dist/cli-delegate/cmd_explain.js +2 -2
  54. package/dist/cli-delegate/cmd_export.js +4 -4
  55. package/dist/cli-delegate/cmd_handoff.js +6 -5
  56. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  57. package/dist/cli-delegate/cmd_migrate.js +2 -2
  58. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  59. package/dist/cli-delegate/cmd_prune.js +4 -4
  60. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  61. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  62. package/dist/cli-delegate/cmd_session_recycle.js +44 -9
  63. package/dist/cli-delegate/cmd_settings_get.js +2 -2
  64. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  65. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  66. package/dist/cli-delegate/cmd_sync.js +4 -4
  67. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  68. package/dist/cli-delegate/cmd_update.js +1 -1
  69. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  70. package/dist/cli-delegate/cmd_validate.js +4 -4
  71. package/dist/cli-delegate/cmd_versions.js +1 -1
  72. package/dist/discovery/deprecation-report.md +1 -1
  73. package/dist/discovery/discovery-manifest.json +21 -21
  74. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  75. package/dist/discovery/discovery-manifest.summary.md +1 -1
  76. package/dist/discovery/dormancy-report.md +1 -1
  77. package/dist/discovery/orphan-report.md +1 -1
  78. package/dist/discovery/packs.json +2 -2
  79. package/dist/discovery/trust-report.md +1 -1
  80. package/dist/discovery/workspaces.json +2 -2
  81. package/dist/hooks/dispatch.js +204 -44
  82. package/dist/install/install.mjs +9 -21
  83. package/dist/mcp/registry-manifest.json +2 -2
  84. package/dist/mcp/server.json +2 -2
  85. package/dist/mcp/server.mjs +1337 -490
  86. package/dist/router.json +1 -1
  87. package/dist/scripts/_lib/agent_settings.js +25 -2
  88. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  89. package/dist/scripts/_lib/compile_time_toggles.js +4 -3
  90. package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
  91. package/dist/server/io/yamlIO.js +0 -2
  92. package/dist/server/io/yamlIO.js.map +1 -1
  93. package/dist/server/schemas/settings.js +3 -9
  94. package/dist/server/schemas/settings.js.map +1 -1
  95. package/dist/shared/settingsClasses.js +64 -2
  96. package/dist/shared/settingsClasses.js.map +1 -1
  97. package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
  98. package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
  99. package/dist/ui/index.html +1 -1
  100. package/docs/contracts/settings-classes.md +307 -145
  101. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  102. package/docs/guidelines/agent-infra/layered-settings.md +1 -2
  103. package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
  104. package/docs/guidelines/design-antipatterns.md +79 -10
  105. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  106. package/package.json +1 -1
  107. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  108. package/src/config/agent-settings.template.yml +12 -30
  109. package/src/config/gate-coverage.yml +42 -8
  110. package/src/config/gate-violation-baselines.json +15 -3
  111. package/src/config/profiles/balanced.ini +0 -2
  112. package/src/config/profiles/full.ini +1 -3
  113. package/src/config/profiles/minimal.ini +0 -2
  114. package/src/config/review-prompt-binding-baseline.json +19 -0
  115. package/src/config/rule-enforcement-baseline.json +0 -1
  116. package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
  117. package/src/scripts/_cli/handoff_sessions.ts +3 -2
  118. package/src/scripts/_dispatch.bash +1 -0
  119. package/src/scripts/_lib/agent_settings.ts +25 -2
  120. package/src/scripts/_lib/bench_telegraph.ts +1 -1
  121. package/src/scripts/_lib/cc_transcript.ts +25 -0
  122. package/src/scripts/_lib/compile_time_toggles.ts +4 -3
  123. package/src/scripts/ai_council/clients.ts +243 -34
  124. package/src/scripts/cache_realization_report.ts +3 -2
  125. package/src/scripts/check_ci_local_parity.ts +60 -6
  126. package/src/scripts/check_review_prompt_binding.ts +553 -0
  127. package/src/scripts/check_rule_projection_integrity.ts +3 -1
  128. package/src/scripts/check_suppression_hygiene.ts +19 -3
  129. package/src/scripts/conformance_scan.ts +282 -9
  130. package/src/scripts/cost/track.mjs +10 -4
  131. package/src/scripts/council_cli.ts +33 -3
  132. package/src/scripts/design_slop_rules.test.ts +37 -6
  133. package/src/scripts/design_slop_rules.ts +150 -22
  134. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  135. package/src/scripts/external_sources_denylist.json +3 -1
  136. package/src/scripts/hook_manifest.yaml +17 -6
  137. package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
  138. package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
  139. package/src/scripts/hooks/evidence_independence.ts +61 -9
  140. package/src/scripts/hooks/session_eol_hook.ts +123 -3
  141. package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
  142. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  143. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  144. package/src/scripts/lint_harvest_provenance.ts +483 -0
  145. package/src/scripts/lint_settings_classes.ts +273 -11
  146. package/src/scripts/lint_skill_router_head.ts +254 -0
  147. package/src/scripts/lint_symptom_intake.ts +241 -0
  148. package/src/scripts/report_consultation_rate.ts +128 -2
  149. package/src/scripts/report_skill_activation.ts +6 -4
  150. package/src/scripts/report_skill_obligation_violations.ts +2 -1
  151. package/src/scripts/run_skill_evals.ts +10 -5
  152. package/src/scripts/schemas/persona.schema.json +8 -0
  153. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  154. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  155. package/src/server/io/yamlIO.ts +0 -2
  156. package/src/server/schemas/settings.ts +3 -19
  157. package/src/shared/settingsClasses.ts +93 -2
  158. package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
@@ -15,7 +15,9 @@
15
15
  * 4. every B row's template default is a conservative value
16
16
  * (`false` / `""` / `0` / `[]` / `null` / `{}`) — the invariant that makes
17
17
  * "absent" and "declined" the same outcome in a sparse settings file;
18
- * 5. the contract's own Counts table matches the tallies computed here.
18
+ * 5. the contract's own Counts table matches the tallies computed here;
19
+ * 6. every `REMOVED_KEYS` entry names what decides instead, and no key is
20
+ * both removed and live in the template.
19
21
  *
20
22
  * **Landed at error, not advisory.** The authoring guideline's advisory-first
21
23
  * rule exists so a gate cannot be wired to error over a corpus whose hits are
@@ -48,16 +50,24 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
48
50
  import { load as parseYaml } from 'js-yaml';
49
51
 
50
52
  import {
53
+ dispositionOf,
51
54
  getSettingsLeaf,
52
55
  isConservativeDefault,
53
56
  isSettingsClass,
57
+ isSettingsDisposition,
54
58
  parseDeclaredClassCounts,
59
+ parseDeclaredDispositionCounts,
55
60
  parseSettingsClassRows,
61
+ replacementOf,
56
62
  SETTINGS_CLASSES,
63
+ SETTINGS_DISPOSITIONS,
57
64
  type SettingsClass,
58
65
  type SettingsClassRow,
66
+ type SettingsDisposition,
59
67
  settingsLeafPaths,
60
68
  } from '../shared/settingsClasses.js';
69
+ import { REMOVED_KEYS } from './_lib/agent_settings.js';
70
+ import { checkRatchet } from './_lib/gate_baseline.js';
61
71
  import { GateLedger } from './_lib/gate_ledger.js';
62
72
  import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
63
73
  import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
@@ -117,15 +127,27 @@ export function selfTest(): number {
117
127
  `\n## Counts\n\n| Class | Keys |\n|---|---|\n| A — preference | ${String(a)} |\n` +
118
128
  `| B — consent | ${String(b)} |\n| C — guarded | ${String(c)} |\n` +
119
129
  `| **Total** | **${String(total)}** |\n`;
120
- const table = (rows: string): string => `# Fixture\n${counts(0, 1, 1, 2)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n${rows}`;
130
+ // The disposition tallies, in the section the parser scopes to. Defaulted to
131
+ // the clean fixture's shape so a case that does not care about them says so
132
+ // by omission rather than by restating four rows.
133
+ const dispositions = (d: number, u: number, co: number, p: number, total: number): string =>
134
+ `\n## Dispositions\n\n| Disposition | Keys |\n|---|---|\n| derivable | ${String(d)} |\n` +
135
+ `| un-inferrable | ${String(u)} |\n| consent | ${String(co)} |\n| policy | ${String(p)} |\n` +
136
+ `| **Total** | **${String(total)}** |\n`;
137
+ const table = (rows: string, disp = dispositions(0, 1, 1, 0, 2)): string =>
138
+ `# Fixture\n${counts(0, 1, 1, 2)}\n${disp}\n## The table\n\n` +
139
+ `| Key | Class | Default | Why | Disposition |\n|---|---|---|---|---|\n${rows}`;
121
140
 
122
- const clean = table('| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n');
141
+ const clean = table(
142
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
143
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
144
+ );
123
145
 
124
146
  try {
125
147
  return runSelfTest({
126
148
  gate: GATE,
127
- minCases: 6,
128
- minRejectCases: 5,
149
+ minCases: 12,
150
+ minRejectCases: 10,
129
151
  cases: [
130
152
  {
131
153
  name: 'a template whose every leaf is classified passes',
@@ -140,7 +162,7 @@ export function selfTest(): number {
140
162
  seed(
141
163
  'unclassified',
142
164
  template,
143
- table('| `beta` | C | `"keep"` | fixture |\n') + counts(0, 0, 1, 1),
165
+ table('| `beta` | C | `"keep"` | fixture | un-inferrable |\n') + counts(0, 0, 1, 1),
144
166
  ),
145
167
  ),
146
168
  },
@@ -153,8 +175,9 @@ export function selfTest(): number {
153
175
  'stale-row',
154
176
  template,
155
177
  table(
156
- '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n' +
157
- '| `gamma.gone` | A | `1` | fixture |\n',
178
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
179
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n' +
180
+ '| `gamma.gone` | A | `1` | fixture | policy |\n',
158
181
  ),
159
182
  ),
160
183
  ),
@@ -167,7 +190,90 @@ export function selfTest(): number {
167
190
  seed(
168
191
  'bad-class',
169
192
  template,
170
- table('| `alpha.one` | D | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n'),
193
+ table(
194
+ '| `alpha.one` | D | `false` | fixture | consent |\n' +
195
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
196
+ ),
197
+ ),
198
+ ),
199
+ },
200
+ {
201
+ name: 'a leaf with a class but no disposition is rejected',
202
+ expect: 'reject',
203
+ run: () =>
204
+ run(
205
+ seed(
206
+ 'no-disposition',
207
+ template,
208
+ table(
209
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
210
+ '| `beta` | C | `"keep"` | fixture |\n',
211
+ dispositions(0, 0, 1, 0, 2),
212
+ ),
213
+ ),
214
+ ),
215
+ },
216
+ {
217
+ name: 'a disposition outside the closed vocabulary is rejected',
218
+ expect: 'reject',
219
+ run: () =>
220
+ run(
221
+ seed(
222
+ 'bad-disposition',
223
+ template,
224
+ table(
225
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
226
+ '| `beta` | C | `"keep"` | fixture | obsolete |\n',
227
+ dispositions(0, 0, 1, 0, 2),
228
+ ),
229
+ ),
230
+ ),
231
+ },
232
+ {
233
+ name: 'a `derivable` row that names no replacement is rejected — the clause IS the falsifier',
234
+ expect: 'reject',
235
+ run: () =>
236
+ run(
237
+ seed(
238
+ 'derivable-without-replacement',
239
+ template,
240
+ table(
241
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
242
+ '| `beta` | C | `"keep"` | fixture | derivable |\n',
243
+ dispositions(1, 0, 1, 0, 2),
244
+ ),
245
+ ),
246
+ ),
247
+ },
248
+ {
249
+ name: 'a `derivable` row that names its replacement passes',
250
+ expect: 'accept',
251
+ run: () =>
252
+ run(
253
+ seed(
254
+ 'derivable-with-replacement',
255
+ template,
256
+ table(
257
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
258
+ '| `beta` | C | `"keep"` | fixture | derivable — the probe reads it |\n',
259
+ dispositions(1, 0, 1, 0, 2),
260
+ ),
261
+ ),
262
+ ),
263
+ },
264
+ {
265
+ name: 'a Dispositions table that disagrees with the rows is rejected',
266
+ expect: 'reject',
267
+ run: () =>
268
+ run(
269
+ seed(
270
+ 'bad-disposition-counts',
271
+ template,
272
+ table(
273
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
274
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
275
+ dispositions(7, 7, 7, 7, 28),
276
+ ),
171
277
  ),
172
278
  ),
173
279
  },
@@ -191,8 +297,10 @@ export function selfTest(): number {
191
297
  seed(
192
298
  'bad-counts',
193
299
  template,
194
- `# Fixture\n${counts(9, 9, 9, 27)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n` +
195
- '| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n',
300
+ `# Fixture\n${counts(9, 9, 9, 27)}\n${dispositions(0, 1, 1, 0, 2)}\n` +
301
+ '## The table\n\n| Key | Class | Default | Why | Disposition |\n|---|---|---|---|---|\n' +
302
+ '| `alpha.one` | B | `false` | fixture | consent |\n' +
303
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
196
304
  ),
197
305
  ),
198
306
  },
@@ -201,6 +309,29 @@ export function selfTest(): number {
201
309
  expect: 'reject',
202
310
  run: () => run(seed('dead-root', null, clean)),
203
311
  },
312
+ {
313
+ name: 'a template leaf that is also a REMOVED_KEYS entry is rejected (check 6)',
314
+ expect: 'reject',
315
+ // `REMOVED_KEYS` is compiled into the loader, so a fixture root
316
+ // cannot seed it — but it can seed the other side of the
317
+ // contradiction. A fixture that declares `quality.wait_for_remote_ci`
318
+ // as a live, fully classified leaf is a tree where the loader
319
+ // would warn "ignored" about a value the schema honours, which
320
+ // is exactly the state check 6 exists to refuse. Classified and
321
+ // counted correctly on purpose: every other check passes here,
322
+ // so a rejection can only come from check 6.
323
+ run: () =>
324
+ run(
325
+ seed(
326
+ 'removed-and-live',
327
+ 'quality:\n wait_for_remote_ci: false\nbeta: "keep"\n',
328
+ table(
329
+ '| `quality.wait_for_remote_ci` | B | `false` | fixture | consent |\n' +
330
+ '| `beta` | C | `"keep"` | fixture | un-inferrable |\n',
331
+ ),
332
+ ),
333
+ ),
334
+ },
204
335
  ],
205
336
  });
206
337
  } finally {
@@ -331,6 +462,98 @@ function main(): number {
331
462
  ledger.complete(target);
332
463
  }
333
464
 
465
+ // --- the disposition axis -------------------------------------------------
466
+ // Orthogonal to A/B/C: that answers who may WRITE a key, this answers whether
467
+ // the key should EXIST. Checked against the same leaf set, in the same gate,
468
+ // so a key with no disposition fails the mechanism that already counts it —
469
+ // rather than sitting in a second artefact that can drift from the template.
470
+ const dispositionTally: Record<SettingsDisposition, number> = {
471
+ derivable: 0,
472
+ 'un-inferrable': 0,
473
+ consent: 0,
474
+ policy: 0,
475
+ };
476
+ for (const leaf of leaves) {
477
+ const row = rowByKey.get(leaf);
478
+ if (row === undefined) {
479
+ continue; // already reported as unclassified above
480
+ }
481
+ const label = dispositionOf(row.disposition);
482
+ if (label === null || label === '') {
483
+ findings.push(
484
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` has no disposition — add one of ` +
485
+ `${SETTINGS_DISPOSITIONS.join(' | ')}. A key with no disposition is the finding: ` +
486
+ 'nobody has said whether it should exist.',
487
+ );
488
+ continue;
489
+ }
490
+ if (!isSettingsDisposition(label)) {
491
+ findings.push(
492
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` has disposition '${label}' — ` +
493
+ `allowed: ${SETTINGS_DISPOSITIONS.join(' | ')}.`,
494
+ );
495
+ continue;
496
+ }
497
+ dispositionTally[label] += 1;
498
+ if (label === 'derivable' && replacementOf(row.disposition) === '') {
499
+ // The replacement clause is what makes `derivable` falsifiable. Without
500
+ // it the label degrades into a synonym for "inconvenient to keep", and
501
+ // the direction of this axis pushes every borderline row toward it.
502
+ findings.push(
503
+ `${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` is \`derivable\` but names no ` +
504
+ 'replacement — write `derivable — <what decides instead>`. A derivable row that ' +
505
+ 'cannot name its replacement is reclassified, not deleted.',
506
+ );
507
+ }
508
+ }
509
+
510
+ // The anti-regrowth ratchet. `derivable` is a deletion queue, so its size is
511
+ // a debt count and may only fall: a NEW key classified `derivable` is a key
512
+ // that should not have been added, and it raises the number. The 56-day
513
+ // non-stagnation clause in the shared baseline mechanism is the other half —
514
+ // a queue that never drains is a direction nobody is walking.
515
+ //
516
+ // Deliberately scoped to the real repo root. Under `--root` (self-test
517
+ // fixtures) the baseline describes a different tree entirely, and judging a
518
+ // two-key fixture against this repository's surface would make every fixture
519
+ // report a loose ratchet.
520
+ if (_rootOverride(process.argv.slice(2)) === null) {
521
+ const verdict = checkRatchet({
522
+ gate: `${GATE}:derivable-surface`,
523
+ actual: dispositionTally.derivable,
524
+ repoRoot: REPO_ROOT,
525
+ });
526
+ if (!verdict.ok) {
527
+ findings.push(verdict.message);
528
+ } else if (verdict.status === 'improved' && !QUIET) {
529
+ process.stdout.write(`ℹ️ ${verdict.message}\n`);
530
+ }
531
+ }
532
+
533
+ const declaredDisposition = parseDeclaredDispositionCounts(contractText);
534
+ const dispositionExpected: Array<[string, number | null, number]> = [
535
+ ['derivable', declaredDisposition.derivable, dispositionTally.derivable],
536
+ ['un-inferrable', declaredDisposition['un-inferrable'], dispositionTally['un-inferrable']],
537
+ ['consent', declaredDisposition.consent, dispositionTally.consent],
538
+ ['policy', declaredDisposition.policy, dispositionTally.policy],
539
+ ['Total', declaredDisposition.total, leaves.length],
540
+ ];
541
+ for (const [label, stated, actual] of dispositionExpected) {
542
+ if (stated === null) {
543
+ findings.push(
544
+ `${CONTRACT_RELATIVE} the Dispositions table has no '${label}' row — a published count ` +
545
+ 'with no row is a count nothing can check.',
546
+ );
547
+ continue;
548
+ }
549
+ if (stated !== actual) {
550
+ findings.push(
551
+ `${CONTRACT_RELATIVE} Dispositions says ${label} = ${String(stated)}, the table holds ` +
552
+ `${String(actual)} — a derived number beside a mechanism that can compute it.`,
553
+ );
554
+ }
555
+ }
556
+
334
557
  const declared = parseDeclaredClassCounts(contractText);
335
558
  const expected: Array<[string, number | null, number]> = [
336
559
  ['A', declared.A, tally.A],
@@ -354,6 +577,45 @@ function main(): number {
354
577
  }
355
578
  }
356
579
 
580
+ // Check 6 — the DELETION side of the surface.
581
+ //
582
+ // Checks 1-5 all police what is added: a new leaf needs a row, a class, a
583
+ // disposition, a conservative default. Nothing policed what is removed, and
584
+ // that is the asymmetry this axis lives on — the whole direction of the
585
+ // roadmap behind it is deletion, so the un-gated half was the half doing the
586
+ // work. Two contradictions are decidable here and neither is visible to the
587
+ // loader that prints the warning:
588
+ //
589
+ // a. a reason that names no replacement. The `derivable` rows above are
590
+ // already held to "name what decides instead" because that clause is the
591
+ // falsifier; a deleted key is the same claim after the fact, and a
592
+ // warning that says only "removed" sends the reader looking for a
593
+ // successor key that does not exist.
594
+ // b. a key that is BOTH removed and live. Re-adding a leaf while its
595
+ // `REMOVED_KEYS` entry stands makes the loader warn "ignored" about a
596
+ // value the schema now honours — the reader is told the opposite of what
597
+ // happens.
598
+ //
599
+ // What this does NOT check, stated because the step it serves asks for it:
600
+ // that the replacement mechanism actually EXISTS, and that its commit
601
+ // precedes the deletion. Neither is decidable from these two files; both stay
602
+ // model-carried, exactly as the A/B/C judgement itself does.
603
+ const liveLeaves = new Set(leaves);
604
+ for (const [removedKey, reason] of REMOVED_KEYS) {
605
+ if (reason.trim() === '') {
606
+ findings.push(
607
+ `src/scripts/_lib/agent_settings.ts \`${removedKey}\` is in REMOVED_KEYS with an empty ` +
608
+ 'reason — name what decides instead, not that it is gone.',
609
+ );
610
+ }
611
+ if (liveLeaves.has(removedKey)) {
612
+ findings.push(
613
+ `${TEMPLATE_RELATIVE} \`${removedKey}\` is a live template leaf AND a REMOVED_KEYS ` +
614
+ 'entry — the loader would warn that a honoured value is ignored. Drop one side.',
615
+ );
616
+ }
617
+ }
618
+
357
619
  ledger.report();
358
620
 
359
621
  if (findings.length > 0) {
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Router-head gate — makes the published K6 cap true for NEW skills.
4
+ *
5
+ * `src/agent-src/templates/skill.md` has carried "K6: Under 400 lines" as a
6
+ * checklist item since long before this gate. A checklist item is a statement
7
+ * of intent that nothing checks; four skills exceed it today. This gate turns
8
+ * the existing contract into an enforced one, without re-litigating the number.
9
+ *
10
+ * The rule: a SKILL.md over MAX_HEAD_LINES must be a router head — an entry
11
+ * file (when-to-use, mode table, routing) plus per-mode bodies in `tasks/` or
12
+ * `references/` inside the skill folder. Over the cap with neither directory
13
+ * present is a monolith and fails.
14
+ *
15
+ * WHAT THIS GATE DOES NOT CLAIM. It is not an activation or token argument.
16
+ * The host loads SKILL.md whole on trigger, and whether it then follows a
17
+ * pointer into `tasks/` is host behaviour nothing in this tree observes — so
18
+ * "splitting saves tokens on trigger" is unmeasurable here and is not asserted.
19
+ * ADR-202's lesson binds: an activation claim goes behind a measured instrument
20
+ * or it is not made. The defensible claim is narrower and sufficient: the cap
21
+ * was already the repo's position, and now a new skill cannot quietly ignore it.
22
+ *
23
+ * GRANDFATHERING is a shrink-only ratchet. GRANDFATHERED below is seeded with
24
+ * the measured offender set. Entries may be REMOVED when a skill is
25
+ * restructured or drops under the cap; adding one is the move this gate exists
26
+ * to prevent, and the diff makes any addition visible in review. The list is an
27
+ * inline constant rather than a JSON baseline on purpose — a baseline file
28
+ * grows without anyone reading it, and this one should be read every time.
29
+ *
30
+ * Usage:
31
+ * ./scripts-run src/scripts/lint_skill_router_head
32
+ * ./scripts-run src/scripts/lint_skill_router_head --quiet
33
+ *
34
+ * Exit codes: 0 = clean · 2 = any finding OR usage error.
35
+ */
36
+
37
+ import * as fs from 'node:fs';
38
+ import * as path from 'node:path';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
+
41
+ import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
42
+
43
+ const _FILE = fileURLToPath(import.meta.url);
44
+ const _HERE = path.dirname(_FILE);
45
+ const REPO = path.resolve(_HERE, '..', '..');
46
+ const SKILLS_REL = 'src/skills';
47
+
48
+ /** The K6 cap, in lines. Mirrors src/agent-src/templates/skill.md § K6. */
49
+ export const MAX_HEAD_LINES = 400;
50
+
51
+ /** Directories a router head may route into. `references/` is the established
52
+ * convention (9 skills use it today); `tasks/` is permitted by the contract
53
+ * for per-mode procedure bodies. */
54
+ export const MODE_BODY_DIRS: readonly string[] = ['tasks', 'references'];
55
+
56
+ /**
57
+ * Shrink-only allowlist — skills already over the cap when the gate landed.
58
+ * Measured, not guessed. REMOVE entries as skills are restructured; never add.
59
+ */
60
+ export const GRANDFATHERED: readonly string[] = [
61
+ 'ai-council', // 1055
62
+ 'skill-writing', // 767
63
+ 'roadmap-management', // 552
64
+ 'quality-tools', // 445
65
+ ];
66
+
67
+ export interface Finding {
68
+ readonly skill: string;
69
+ readonly lines: number;
70
+ readonly message: string;
71
+ }
72
+
73
+ /** Count lines the way `wc -l` does, so the gate and a shell check agree. */
74
+ export function countLines(text: string): number {
75
+ if (text.length === 0) return 0;
76
+ const n = text.split('\n').length;
77
+ return text.endsWith('\n') ? n - 1 : n;
78
+ }
79
+
80
+ /** Does this skill folder carry at least one mode-body directory? */
81
+ export function hasModeBodies(skillDir: string, dirs: readonly string[] = MODE_BODY_DIRS): boolean {
82
+ return dirs.some((d) => {
83
+ try {
84
+ return fs.statSync(path.join(skillDir, d)).isDirectory();
85
+ } catch {
86
+ return false;
87
+ }
88
+ });
89
+ }
90
+
91
+ export interface SkillHead {
92
+ readonly name: string;
93
+ readonly lines: number;
94
+ readonly hasBodies: boolean;
95
+ }
96
+
97
+ /** Read every `<skillsRoot>/<name>/SKILL.md`. */
98
+ export function collectSkillHeads(skillsRoot: string): SkillHead[] {
99
+ let entries: fs.Dirent[];
100
+ try {
101
+ entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
102
+ } catch {
103
+ return [];
104
+ }
105
+ const heads: SkillHead[] = [];
106
+ for (const e of entries) {
107
+ if (!e.isDirectory()) continue;
108
+ const dir = path.join(skillsRoot, e.name);
109
+ const head = path.join(dir, 'SKILL.md');
110
+ let text: string;
111
+ try {
112
+ text = fs.readFileSync(head, 'utf-8');
113
+ } catch {
114
+ continue;
115
+ }
116
+ heads.push({ name: e.name, lines: countLines(text), hasBodies: hasModeBodies(dir) });
117
+ }
118
+ return heads.sort((a, b) => a.name.localeCompare(b.name));
119
+ }
120
+
121
+ /**
122
+ * A head fails when it is over the cap, carries no mode-body directory, and is
123
+ * not grandfathered. Two separate results are reported so the caller can
124
+ * distinguish a live violation from allowlist drift.
125
+ */
126
+ export function evaluate(
127
+ heads: readonly SkillHead[],
128
+ grandfathered: readonly string[] = GRANDFATHERED,
129
+ max: number = MAX_HEAD_LINES,
130
+ ): { readonly findings: Finding[]; readonly staleAllowlist: string[] } {
131
+ const allow = new Set(grandfathered);
132
+ const findings: Finding[] = [];
133
+ const stillOver = new Set<string>();
134
+
135
+ for (const h of heads) {
136
+ if (h.lines <= max) continue;
137
+ stillOver.add(h.name);
138
+ if (allow.has(h.name)) continue;
139
+ if (h.hasBodies) continue;
140
+ findings.push({
141
+ skill: h.name,
142
+ lines: h.lines,
143
+ message:
144
+ `SKILL.md is ${h.lines} lines (cap ${max}) with no ${MODE_BODY_DIRS.map((d) => `${d}/`).join(' or ')} ` +
145
+ 'directory — over the cap a skill restructures as a router head (entry head + one file per mode), ' +
146
+ 'per templates/skill.md § The router-head contract. The allowlist is shrink-only and does not accept new entries.',
147
+ });
148
+ }
149
+
150
+ // An allowlist entry for a skill that no longer exceeds the cap (or no
151
+ // longer exists) is the ratchet failing to tighten: the entry silently
152
+ // buys nothing and would re-permit a regression. Reported, not fatal —
153
+ // removing it is a one-line follow-up, not a reason to red the build.
154
+ const staleAllowlist = grandfathered.filter((g) => !stillOver.has(g));
155
+
156
+ return { findings, staleAllowlist };
157
+ }
158
+
159
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
160
+
161
+ class ExitCode extends Error {
162
+ readonly code: number;
163
+ constructor(code: number) {
164
+ super(`exit ${code}`);
165
+ this.code = code;
166
+ }
167
+ }
168
+
169
+ function parseArgs(argv: string[]): { readonly quiet: boolean } {
170
+ let quiet = false;
171
+ for (const a of argv) {
172
+ if (a === '--quiet') {
173
+ quiet = true;
174
+ } else if (a === '-h' || a === '--help') {
175
+ process.stdout.write('usage: lint_skill_router_head [--quiet]\n');
176
+ throw new ExitCode(0);
177
+ } else {
178
+ process.stderr.write(`❌ lint_skill_router_head: unrecognized argument: ${a}\n`);
179
+ throw new ExitCode(2);
180
+ }
181
+ }
182
+ return { quiet };
183
+ }
184
+
185
+ export function main(argv: string[] = process.argv.slice(2)): number {
186
+ const args = parseArgs(argv);
187
+
188
+ // The scanned root is the scope signal. A moved or renamed src/skills would
189
+ // otherwise produce zero heads and a clean green over nothing at all.
190
+ try {
191
+ assertWatchlistResolves({
192
+ gate: 'lint_skill_router_head',
193
+ candidates: [`${SKILLS_REL}/skill-writing/SKILL.md`],
194
+ repoRoot: REPO,
195
+ });
196
+ } catch (exc) {
197
+ if (exc instanceof DeadScopeError) {
198
+ process.stderr.write(`❌ ${exc.message}\n`);
199
+ return 2;
200
+ }
201
+ throw exc;
202
+ }
203
+
204
+ const heads = collectSkillHeads(path.join(REPO, SKILLS_REL));
205
+ const { findings, staleAllowlist } = evaluate(heads);
206
+
207
+ for (const s of staleAllowlist) {
208
+ process.stderr.write(
209
+ `⚠️ lint_skill_router_head: '${s}' is grandfathered but no longer over the cap — remove it from GRANDFATHERED so the ratchet keeps its teeth\n`,
210
+ );
211
+ }
212
+
213
+ if (findings.length > 0) {
214
+ for (const f of findings) {
215
+ process.stderr.write(`❌ ${SKILLS_REL}/${f.skill}/SKILL.md: ${f.message}\n`);
216
+ }
217
+ process.stderr.write(`❌ lint_skill_router_head: ${findings.length} finding(s)\n`);
218
+ return 2;
219
+ }
220
+
221
+ if (!args.quiet) {
222
+ const over = heads.filter((h) => h.lines > MAX_HEAD_LINES).length;
223
+ process.stdout.write(
224
+ `✅ lint_skill_router_head: ${heads.length} skill head(s) scanned · ` +
225
+ `${over} over the ${MAX_HEAD_LINES}-line cap, all grandfathered or routed · ` +
226
+ `allowlist holds ${GRANDFATHERED.length} entry(ies), shrink-only\n`,
227
+ );
228
+ }
229
+ return 0;
230
+ }
231
+
232
+ /** Robust "am I the entry script?" — realpath-compares argv[1] to this file. */
233
+ function _isCliEntry(): boolean {
234
+ const a = process.argv[1];
235
+ if (!a) return false;
236
+ if (a === _FILE || pathToFileURL(path.resolve(a)).href === import.meta.url) return true;
237
+ try {
238
+ return fs.realpathSync(a) === fs.realpathSync(_FILE);
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+ if (_isCliEntry()) {
244
+ try {
245
+ process.exit(main());
246
+ } catch (exc) {
247
+ if (exc instanceof ExitCode) {
248
+ process.exit(exc.code);
249
+ }
250
+ throw exc;
251
+ }
252
+ }
253
+
254
+ export { REPO, SKILLS_REL, ExitCode };