@event4u/agent-config 14.18.0 → 14.20.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 (210) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +120 -129
  5. package/README.md +71 -149
  6. package/dist/agent-src/README.md +1 -1
  7. package/dist/agent-src/commands/agent-handoff.md +44 -43
  8. package/dist/agent-src/commands/review/changes.md +19 -19
  9. package/dist/agent-src/commands/review.md +2 -2
  10. package/dist/agent-src/contexts/execution/host-capability-manifest.md +37 -4
  11. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  12. package/dist/agent-src/rules/git-history-discipline.md +2 -2
  13. package/dist/agent-src/rules/guidelines.md +1 -1
  14. package/dist/agent-src/rules/session-canary.md +2 -2
  15. package/dist/agent-src/rules/token-efficiency.md +1 -1
  16. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  17. package/dist/agent-src/skills/adversarial-review/SKILL.md +0 -8
  18. package/dist/agent-src/skills/canvas-design/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-intelligence/SKILL.md +1 -1
  20. package/dist/agent-src/skills/design-intelligence/data/app-interface.csv +1 -1
  21. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -17
  22. package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +2 -2
  23. package/dist/agent-src/skills/design-intelligence/references/context-and-registers.md +17 -0
  24. package/dist/agent-src/skills/design-intelligence/references/design-rules-checklist.md +4 -4
  25. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  26. package/dist/agent-src/skills/design-variations/SKILL.md +1 -1
  27. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  28. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  29. package/dist/agent-src/skills/fe-design/SKILL.md +32 -5
  30. package/dist/agent-src/skills/fe-design/references/craft-floor.md +7 -0
  31. package/dist/agent-src/skills/fe-design/references/design-patterns.md +47 -2
  32. package/dist/agent-src/skills/laravel-scheduling/SKILL.md +3 -1
  33. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  34. package/dist/agent-src/skills/project-analyzer/SKILL.md +0 -6
  35. package/dist/agent-src/skills/react-native-setup/SKILL.md +3 -3
  36. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  37. package/dist/agent-src/skills/sequential-thinking/SKILL.md +18 -8
  38. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  39. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  40. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  41. package/dist/agent-src/skills/ui-apply-generic/SKILL.md +1 -1
  42. package/dist/agent-src/skills/ui-component-architect/SKILL.md +1 -1
  43. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +17 -2
  44. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  45. package/dist/agent-src/templates/roadmaps.md +14 -1
  46. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  47. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  48. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  49. package/dist/cli/commands/recordTriggerEval.js +2 -2
  50. package/dist/cli/main.js +1 -1
  51. package/dist/cli/mcp/dispatch.js +10 -0
  52. package/dist/cli/mcp/stdio.js +33 -2
  53. package/dist/cli/mcp/telemetry.js +227 -0
  54. package/dist/cli-delegate/{chunk-AKJGUQNO.js → chunk-C6IPZRDA.js} +484 -353
  55. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  56. package/dist/cli-delegate/{chunk-7RXIKF62.js → chunk-NUMQHLS5.js} +304 -136
  57. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
  58. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  59. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  60. package/dist/cli-delegate/cmd_adr_effective.js +318 -81
  61. package/dist/cli-delegate/cmd_conformance.js +489 -121
  62. package/dist/cli-delegate/cmd_doctor.js +2 -2
  63. package/dist/cli-delegate/cmd_export.js +2 -2
  64. package/dist/cli-delegate/cmd_handoff.js +42 -12
  65. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  66. package/dist/cli-delegate/cmd_prune.js +2 -2
  67. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  68. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  69. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +2 -2
  71. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  72. package/dist/cli-delegate/cmd_validate.js +2 -2
  73. package/dist/discovery/deprecation-report.md +1 -1
  74. package/dist/discovery/discovery-manifest.json +34 -33
  75. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  76. package/dist/discovery/discovery-manifest.summary.md +1 -1
  77. package/dist/discovery/dormancy-report.md +1 -1
  78. package/dist/discovery/orphan-report.md +1 -1
  79. package/dist/discovery/packs.json +2 -2
  80. package/dist/discovery/trust-report.md +1 -1
  81. package/dist/discovery/workspaces.json +2 -2
  82. package/dist/hooks/dispatch.js +4455 -3025
  83. package/dist/install/install.mjs +975 -689
  84. package/dist/mcp/registry-manifest.json +2 -2
  85. package/dist/mcp/server.json +2 -2
  86. package/dist/mcp/server.mjs +2087 -1134
  87. package/dist/scripts/_lib/model_tier.js +1 -1
  88. package/dist/scripts/mcp_server/telemetry.js +148 -0
  89. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +1 -1
  90. package/docs/guidelines/agent-infra/naming.md +1 -1
  91. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +1 -1
  92. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  93. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  94. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  95. package/docs/guidelines/design-antipatterns.md +87 -64
  96. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  97. package/package.json +1 -1
  98. package/src/agent-src/README.md +1 -1
  99. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  100. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  101. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  102. package/src/config/assurance-capability-registry.json +9 -0
  103. package/src/config/ci-local-parity.yml +6 -0
  104. package/src/config/gate-coverage.yml +225 -0
  105. package/src/config/gate-violation-baselines.json +18 -6
  106. package/src/config/release-gate-locality.yml +12 -0
  107. package/src/scripts/_cli/cmd_doctor.ts +2 -2
  108. package/src/scripts/_cli/cmd_handoff.ts +1 -1
  109. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  110. package/src/scripts/_cli/cmd_update.ts +1 -1
  111. package/src/scripts/_cli/cmd_versions.ts +1 -1
  112. package/src/scripts/_lib/adr_frontmatter.ts +44 -0
  113. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  114. package/src/scripts/_lib/consequence_objects.ts +152 -0
  115. package/src/scripts/_lib/exec_evidence.ts +7 -0
  116. package/src/scripts/_lib/global_deploy_inventory.ts +2 -2
  117. package/src/scripts/_lib/host_capability.ts +38 -0
  118. package/src/scripts/_lib/host_permission_checks.ts +251 -0
  119. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  120. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  121. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  122. package/src/scripts/_lib/model_tier.ts +1 -1
  123. package/src/scripts/_lib/obligation_frequency.ts +2 -2
  124. package/src/scripts/_lib/orchestration_record.ts +23 -1
  125. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  126. package/src/scripts/_lib/release_highlights.ts +93 -0
  127. package/src/scripts/_lib/release_material.ts +64 -1
  128. package/src/scripts/_lib/release_position.ts +96 -0
  129. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  130. package/src/scripts/_lib/runtime_journal.ts +26 -3
  131. package/src/scripts/_lib/runtime_wiring_checks.ts +12 -1
  132. package/src/scripts/_lib/security_lint.ts +179 -7
  133. package/src/scripts/_lib/self_repair.ts +141 -6
  134. package/src/scripts/_lib/self_repair_store.ts +273 -7
  135. package/src/scripts/_lib/session_register.ts +11 -6
  136. package/src/scripts/_lib/sqlite_schema.ts +22 -0
  137. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  138. package/src/scripts/_lib/surface.ts +86 -0
  139. package/src/scripts/_lib/ui_authority.ts +116 -0
  140. package/src/scripts/adr/regenerate_index.ts +54 -1
  141. package/src/scripts/adr_cite_check.ts +71 -15
  142. package/src/scripts/ai_council/clients.ts +1 -1
  143. package/src/scripts/autonomy_friction_corpus.ts +229 -0
  144. package/src/scripts/build_proof.ts +15 -2
  145. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  146. package/src/scripts/check_beta_review_markers.ts +141 -17
  147. package/src/scripts/check_enforcement_coverage.ts +1 -1
  148. package/src/scripts/check_enforcement_denominator.ts +121 -11
  149. package/src/scripts/check_finding_dispositions.ts +66 -9
  150. package/src/scripts/check_gate_coverage.ts +96 -0
  151. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  152. package/src/scripts/check_pack_size.ts +321 -3
  153. package/src/scripts/check_release_highlights.ts +74 -1
  154. package/src/scripts/check_skill_activation_claim.ts +179 -0
  155. package/src/scripts/check_trigger_evals.ts +1 -1
  156. package/src/scripts/conformance_scan.ts +44 -3
  157. package/src/scripts/explain_run.ts +1 -1
  158. package/src/scripts/generate_capability_matrix.ts +1 -1
  159. package/src/scripts/git_authorization_hook.ts +75 -1
  160. package/src/scripts/handoff_context_hook.ts +54 -7
  161. package/src/scripts/hook_manifest.json +1 -1
  162. package/src/scripts/hook_manifest.yaml +14 -11
  163. package/src/scripts/hooks/augment-dispatcher.sh +1 -1
  164. package/src/scripts/hooks/category_a.ts +291 -0
  165. package/src/scripts/hooks/cline-dispatcher.sh +1 -1
  166. package/src/scripts/hooks/cursor-dispatcher.sh +11 -5
  167. package/src/scripts/hooks/dispatch_hook.ts +17 -22
  168. package/src/scripts/hooks/git_command_classifier.ts +156 -1
  169. package/src/scripts/hooks/host_lowering.ts +215 -0
  170. package/src/scripts/hooks/host_lowering.yaml +185 -0
  171. package/src/scripts/hooks/host_semantics.ts +105 -37
  172. package/src/scripts/hooks/journal_record_hook.ts +10 -0
  173. package/src/scripts/hooks/permission_gate.ts +117 -0
  174. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  175. package/src/scripts/hooks_status.ts +12 -10
  176. package/src/scripts/install.ts +57 -86
  177. package/src/scripts/install_anthropic_key.sh +2 -2
  178. package/src/scripts/lint_agent_security.ts +316 -25
  179. package/src/scripts/lint_confusables.ts +42 -6
  180. package/src/scripts/lint_design_antipattern_parity.ts +80 -0
  181. package/src/scripts/lint_evidence_artifacts.ts +45 -1
  182. package/src/scripts/lint_handoffs.ts +0 -132
  183. package/src/scripts/lint_hidden_unicode.ts +49 -7
  184. package/src/scripts/lint_hook_manifest.ts +87 -3
  185. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  186. package/src/scripts/lint_mcp_config_security.ts +42 -6
  187. package/src/scripts/lint_motion_authority_drift.ts +731 -0
  188. package/src/scripts/lint_readme_size.ts +158 -12
  189. package/src/scripts/lint_reasoning_fixtures.ts +216 -0
  190. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  191. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  192. package/src/scripts/lint_skill_trigger_contradiction.ts +161 -0
  193. package/src/scripts/mcp_server/resources.ts +41 -0
  194. package/src/scripts/mcp_server/telemetry.ts +19 -4
  195. package/src/scripts/measure_sibling_naming.ts +150 -0
  196. package/src/scripts/orchestration_record.ts +12 -1
  197. package/src/scripts/redact_hook_capture.ts +1 -1
  198. package/src/scripts/release.ts +24 -24
  199. package/src/scripts/release_publication.ts +6 -2
  200. package/src/scripts/report_skill_activation.ts +319 -7
  201. package/src/scripts/schemas/review-findings.schema.json +5 -0
  202. package/src/scripts/schemas/skill.schema.json +2 -2
  203. package/src/scripts/self_repair_cli.ts +31 -1
  204. package/src/scripts/session_canary_hook.ts +1 -1
  205. package/src/scripts/session_register_hook.ts +4 -3
  206. package/src/scripts/skill_scout.ts +69 -0
  207. package/src/scripts/trigger_eval_grandfather.json +5 -1
  208. package/src/scripts/validate_agent_settings.ts +1 -1
  209. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  210. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -39,9 +39,26 @@
39
39
  * Escape hatch: `<!-- enforcement-count-ok: <reason> -->` on the line, reason
40
40
  * mandatory. A bare marker stays a finding.
41
41
  *
42
+ * THE SECOND HALF — the projection owes the split. Exempting the generated file
43
+ * settles WHERE the number may be published; it says nothing about whether the
44
+ * published number is readable. `undeclared` quoted bare reads as a backlog
45
+ * somebody forgot to wire, and for nine of them that is not what it is:
46
+ * `block_kernel_rule_writes` refuses an `enforced_by` write on a kernel rule
47
+ * with no agent-accessible override, so those cannot declare one at all. The
48
+ * resolver has always computed `kernel_denied`; the published line dropped it
49
+ * and appended the word `yet`, promising a future that does not exist for that
50
+ * nine. So a line in the sanctioned projection that states an `undeclared`
51
+ * figure without its kernel-denied split is the SAME class of failure as a
52
+ * hand-written count elsewhere: a figure the reader cannot resolve.
53
+ *
54
+ * This deliberately does NOT check the split's value, for the same reason the
55
+ * first half does not check the count's — the resolver is the only source, and
56
+ * a value comparison here would be a second place the number lives.
57
+ *
42
58
  * Exit codes:
43
- * 0 — no hand-written enforcement count in the published corpus
44
- * 1 — at least one (or a marker with no reason)
59
+ * 0 — no hand-written enforcement count in the published corpus, and every
60
+ * published `undeclared` figure carries its kernel-denied split
61
+ * 1 — at least one (or a marker with no reason, or a split-less figure)
45
62
  * 2 — the gate could not run (dead scan scope, bad args)
46
63
  */
47
64
  import * as fs from 'node:fs';
@@ -57,8 +74,8 @@ const _HERE = fileURLToPath(import.meta.url);
57
74
  const DEFAULT_ROOT = path.join(path.dirname(_HERE), '..', '..');
58
75
  const SELF = 'src/scripts/check_enforcement_denominator.ts';
59
76
 
60
- const SELF_TEST_MIN_CASES = 7;
61
- const SELF_TEST_MIN_REJECT = 3;
77
+ const SELF_TEST_MIN_CASES = 10;
78
+ const SELF_TEST_MIN_REJECT = 5;
62
79
 
63
80
  /** Roots walked, repo-relative. */
64
81
  const SCAN_ROOTS = ['docs', 'README.md'] as const;
@@ -82,6 +99,19 @@ const MARKER = 'enforcement-count-ok:';
82
99
  const ENFORCEMENT_KEYWORDS =
83
100
  /undeclared|enforced_by|enforcement coverage|carry a backstop|backstop that fails/;
84
101
 
102
+ /**
103
+ * Shapes that state the `undeclared` figure itself. Narrower than
104
+ * {@link COUNT_SHAPES} on purpose: this is the ONE figure whose split is
105
+ * load-bearing, and a wider net would demand the split beside `blocking`.
106
+ */
107
+ const UNDECLARED_FIGURE_SHAPES: readonly RegExp[] = [
108
+ /\b\d+\s+undeclared\b/i,
109
+ /\bundeclared\s+rules?\s*\(\s*\d+/i,
110
+ ];
111
+
112
+ /** The split that makes the figure readable. Presence is checked, never value. */
113
+ const SPLIT_TOKEN = /kernel[-_]denied/i;
114
+
85
115
  const COUNT_SHAPES: readonly RegExp[] = [
86
116
  /\b\d+\s+of\s+\d+\s+(?:governed\s+)?rules?\b/,
87
117
  /\b\d+\s+(?:governed\s+)?rules?\b/,
@@ -93,8 +123,12 @@ export interface Finding {
93
123
  file: string;
94
124
  line: number;
95
125
  excerpt: string;
96
- /** `raw` — an unmarked count. `bare-marker` — a marker with no reason. */
97
- kind: 'raw' | 'bare-marker';
126
+ /**
127
+ * `raw` — an unmarked count. `bare-marker` — a marker with no reason.
128
+ * `missing-split` — the projection states `undeclared` without its
129
+ * kernel-denied split.
130
+ */
131
+ kind: 'raw' | 'bare-marker' | 'missing-split';
98
132
  }
99
133
 
100
134
  function markerOn(line: string): 'none' | 'bare' | 'reasoned' {
@@ -123,6 +157,30 @@ export function scanText(relPath: string, text: string): Finding[] {
123
157
  return out;
124
158
  }
125
159
 
160
+ /**
161
+ * The projection's own obligation: every line stating an `undeclared` figure
162
+ * carries the kernel-denied split. Run INSTEAD of {@link scanText} on a
163
+ * generated file — that file is allowed to state the number, and is the only
164
+ * one that owes the split.
165
+ */
166
+ export function scanGenerated(relPath: string, text: string): Finding[] {
167
+ const out: Finding[] = [];
168
+ const lines = text.split('\n');
169
+ for (let i = 0; i < lines.length; i += 1) {
170
+ const line = lines[i] ?? '';
171
+ if (!UNDECLARED_FIGURE_SHAPES.some((re) => re.test(line))) continue;
172
+ if (SPLIT_TOKEN.test(line)) continue;
173
+ if (markerOn(line) === 'reasoned') continue;
174
+ out.push({
175
+ file: relPath,
176
+ line: i + 1,
177
+ excerpt: line.trim().slice(0, 120),
178
+ kind: 'missing-split',
179
+ });
180
+ }
181
+ return out;
182
+ }
183
+
126
184
  /** `true` when the file declares itself generated — i.e. it IS the projection. */
127
185
  export function isGenerated(text: string): boolean {
128
186
  return text.split('\n', GENERATED_HEAD_LINES).join('\n').includes(GENERATED_MARKER);
@@ -177,7 +235,13 @@ export function check(root: string): { code: number; findings: Finding[]; scanne
177
235
  for (const rel of files) {
178
236
  const text = fs.readFileSync(path.join(root, rel), 'utf-8');
179
237
  if (isGenerated(text)) {
180
- ledger.complete(rel);
238
+ const split = scanGenerated(rel, text);
239
+ if (split.length > 0) {
240
+ findings.push(...split);
241
+ ledger.fail(rel, `${String(split.length)} undeclared figure(s) without the split`);
242
+ } else {
243
+ ledger.complete(rel);
244
+ }
181
245
  continue;
182
246
  }
183
247
  const found = scanText(rel, text);
@@ -259,8 +323,21 @@ export function selfTest(): number {
259
323
  }),
260
324
  },
261
325
  {
262
- name: "accepts the resolver's own generated projection",
326
+ name: "accepts the resolver's own generated projection when it carries the split",
263
327
  expect: 'accept',
328
+ run: () =>
329
+ run({
330
+ 'docs/proof.md':
331
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
332
+ '120 rules · 15 blocking · 86 undeclared — of which 9 kernel-denied.\n',
333
+ }),
334
+ },
335
+ {
336
+ // DENIAL POLARITY for the split half. Same file, same exemption, the
337
+ // split removed: the projection is allowed to state the number and is
338
+ // still a finding when it states it bare.
339
+ name: 'rejects the generated projection when the kernel-denied split is removed',
340
+ expect: 'reject',
264
341
  run: () =>
265
342
  run({
266
343
  'docs/proof.md':
@@ -268,6 +345,26 @@ export function selfTest(): number {
268
345
  '120 rules · 15 blocking · 86 undeclared (no enforced_by yet).\n',
269
346
  }),
270
347
  },
348
+ {
349
+ name: 'rejects a bare "Undeclared rules (N)" restatement in the projection',
350
+ expect: 'reject',
351
+ run: () =>
352
+ run({
353
+ 'docs/proof.md':
354
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
355
+ 'Undeclared rules (86) carry no row.\n',
356
+ }),
357
+ },
358
+ {
359
+ name: 'accepts a generated line about enforcement that states no undeclared figure',
360
+ expect: 'accept',
361
+ run: () =>
362
+ run({
363
+ 'docs/proof.md':
364
+ `<!-- ${GENERATED_MARKER} src/scripts/build_proof.ts -->\n\n` +
365
+ '120 rules · 15 blocking · the resolver prints its own frame.\n',
366
+ }),
367
+ },
271
368
  {
272
369
  name: 'accepts a dated ADR record (docs/decisions/ is excluded)',
273
370
  expect: 'accept',
@@ -318,12 +415,25 @@ export function main(argv?: readonly string[]): number {
318
415
  if (code === 2) return 2;
319
416
 
320
417
  if (findings.length > 0) {
321
- process.stdout.write('❌ hand-written enforcement count(s) in a published doc:\n\n');
418
+ process.stdout.write('❌ unresolvable enforcement figure(s) in a published doc:\n\n');
322
419
  for (const f of findings) {
323
420
  const why =
324
- f.kind === 'bare-marker' ? `\`${MARKER}\` with no reason` : 'hand-written count';
421
+ f.kind === 'bare-marker'
422
+ ? `\`${MARKER}\` with no reason`
423
+ : f.kind === 'missing-split'
424
+ ? 'published `undeclared` figure with no kernel-denied split'
425
+ : 'hand-written count';
325
426
  process.stdout.write(` ${f.file}:${String(f.line)} — ${why}\n ${f.excerpt}\n`);
326
427
  }
428
+ if (findings.some((f) => f.kind === 'missing-split')) {
429
+ process.stdout.write(
430
+ '\nThe projection may state the `undeclared` figure — it is the only surface\n' +
431
+ 'that may — but not bare. Nine of those rules are kernel-denied:\n' +
432
+ '`block_kernel_rule_writes` refuses an `enforced_by` write with no\n' +
433
+ 'agent-accessible override, so for them there is no *yet*. Print\n' +
434
+ "`summary.kernel_denied` on the same line (`build_proof.ts` § 4b does).\n",
435
+ );
436
+ }
327
437
  process.stdout.write(
328
438
  '\nExactly one enforcement denominator is quotable, and it comes from\n' +
329
439
  '`check_enforcement_coverage` — which resolves rather than counts, and prints\n' +
@@ -362,4 +472,4 @@ if (_isCliEntry() || process.argv[1] === _HERE) {
362
472
  process.exit(main());
363
473
  }
364
474
 
365
- export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER };
475
+ export { SCAN_ROOTS, EXCLUDED_PREFIXES, MARKER, SPLIT_TOKEN, UNDECLARED_FIGURE_SHAPES };
@@ -50,12 +50,22 @@
50
50
  * not override either, so in CI the local tag list is EMPTY. See releaseStatus
51
51
  * for the asymmetry and the remote fallback.
52
52
  *
53
+ * AN EMPTY LEDGER MUST SAY WHY IT IS EMPTY. The released/unreleased split above
54
+ * closed the missing-file half; it left the other half open, because
55
+ * `findings: []` still passed while asserting nothing. A writer could satisfy
56
+ * the gate by creating an empty file, which is the same silence wearing the
57
+ * shape of a record. `no_findings_reason` is now required whenever the file
58
+ * exists and the array is empty — see empty_ledger_problem for what the reason
59
+ * has to distinguish.
60
+ *
53
61
  * A future reader changing allowEmpty below is the person this is addressed to:
54
- * the EMPTY_VALID waiver still covers a ledger that exists and is empty, and an
55
- * unreleased version. It no longer covers a released version with no ledger.
62
+ * the EMPTY_VALID waiver now covers only an EXPLAINED empty ledger and an
63
+ * unreleased version. It covers neither a released version with no ledger nor an
64
+ * empty ledger with no reason.
56
65
  *
57
66
  * Exit codes: 0 complete · 1 missing/incomplete dispositions (an absent ledger
58
- * for a RELEASED version is one of them) · 2 usage error, schema error, or an
67
+ * for a RELEASED version and an unexplained empty ledger are both among them) ·
68
+ * 2 usage error, schema error, or an
59
69
  * undeterminable release status. Deliberately NOT fail-open: this script never calls a model;
60
70
  * a broken ledger file is a hard error, not a neutral skip.
61
71
  */
@@ -65,7 +75,7 @@ import * as path from 'node:path';
65
75
  import process from 'node:process';
66
76
  import { fileURLToPath } from 'node:url';
67
77
 
68
- import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
78
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
69
79
 
70
80
  const _HERE = fileURLToPath(import.meta.url);
71
81
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -91,6 +101,8 @@ export interface Ledger {
91
101
  schema_version: number;
92
102
  release: string;
93
103
  findings: LedgerFinding[];
104
+ /** Why the finding set is empty. Required when it is; see empty_ledger_problem. */
105
+ no_findings_reason?: string;
94
106
  }
95
107
 
96
108
  /** Mirrors self_review_gate.classifyBlocking — security/claim × critical/high. */
@@ -136,6 +148,36 @@ export function missing_dispositions(findings: readonly LedgerFinding[]): string
136
148
  return problems;
137
149
  }
138
150
 
151
+ /**
152
+ * An empty finding set must say why it is empty.
153
+ *
154
+ * `findings: []` and a missing file made the same claim to every reader — "nothing
155
+ * to disposition" — and only one of them is evidence. Worse, they collapse two
156
+ * states that differ entirely: a review that ran and reported nothing, and a review
157
+ * that never ran at all. The reason field is what forces the writer to pick one and
158
+ * makes the pick falsifiable, so a fabricated empty ledger has to assert something
159
+ * a reader can check rather than merely occupy the path the gate looks at.
160
+ *
161
+ * Applies ONLY to a ledger file that exists. An absent ledger on an unreleased
162
+ * version is the normal in-flight state and has nothing to carry a reason.
163
+ */
164
+ export function empty_ledger_problem(
165
+ ledger: Pick<Ledger, 'findings' | 'no_findings_reason'>,
166
+ ): string | null {
167
+ if (ledger.findings.length > 0) {
168
+ return null;
169
+ }
170
+ if ((ledger.no_findings_reason ?? '').trim() !== '') {
171
+ return null;
172
+ }
173
+ return (
174
+ 'the ledger exists with an empty `findings` array and no `no_findings_reason`. '
175
+ + 'An unexplained empty ledger is indistinguishable from an absent one — state '
176
+ + 'whether the review ran and reported nothing, or did not run, and how a reader '
177
+ + 'can check that.'
178
+ );
179
+ }
180
+
139
181
  export type ReleaseStatus = 'released' | 'unreleased' | 'undeterminable';
140
182
  export type RemoteReleaseAnswer = 'released' | 'unreleased' | 'unavailable';
141
183
 
@@ -410,6 +452,19 @@ function main(argv: readonly string[]): number {
410
452
  }
411
453
  }
412
454
 
455
+ // An existing ledger that records nothing must say why. Checked here rather
456
+ // than in the schema because the requirement is conditional on the finding
457
+ // count, which the repo's Draft-07 subset validator cannot express.
458
+ if (fs.existsSync(ledgerPath)) {
459
+ const emptyProblem = empty_ledger_problem(ledger);
460
+ if (emptyProblem !== null) {
461
+ process.stderr.write(
462
+ `❌ ${path.relative(REPO_ROOT, ledgerPath)}: ${emptyProblem}\n`,
463
+ );
464
+ return 1;
465
+ }
466
+ }
467
+
413
468
  // Scope declaration, not a scope guard. The ledger is written per release by
414
469
  // `--ingest`, so "no <release>.json" is the normal state of a release whose
415
470
  // self-review reported nothing — the gate's own success line says so. That
@@ -418,7 +473,7 @@ function main(argv: readonly string[]): number {
418
473
  // durable trigger for an un-ingested finding is `--pr` mode, which compares
419
474
  // the ledger against what the self-review actually reported.
420
475
  try {
421
- assertScanned({
476
+ reportScanned({
422
477
  gate: 'check_finding_dispositions',
423
478
  scanned: ledger.findings.length,
424
479
  units: 'recorded finding(s)',
@@ -426,11 +481,13 @@ function main(argv: readonly string[]): number {
426
481
  allowEmpty:
427
482
  'EMPTY_VALID: zero recorded findings IS the pass state for a release whose '
428
483
  + 'self-review reported none — the ledger file is created on first --ingest, so a '
429
- + 'clean release legitimately has no <version>.json. NARROWED: this no longer '
484
+ + 'clean release legitimately has no <version>.json. NARROWED TWICE: it no longer '
430
485
  + 'covers a RELEASED version with no ledger, which returns 1 above on the '
431
- + 'released/unreleased split; the waiver now covers an empty-but-present ledger '
432
- + 'and an unreleased version. Un-ingested findings on an in-flight release are '
433
- + 'caught by --pr (unrecorded_findings), never by this count.',
486
+ + 'released/unreleased split, and it no longer covers an empty ledger that does '
487
+ + 'not say why it is empty, which returns 1 above via empty_ledger_problem. What '
488
+ + 'remains is an EXPLAINED empty ledger and an unreleased version. Un-ingested '
489
+ + 'findings on an in-flight release are caught by --pr (unrecorded_findings), '
490
+ + 'never by this count.',
434
491
  });
435
492
  } catch (exc) {
436
493
  if (exc instanceof DeadScopeError) {
@@ -113,6 +113,21 @@ export interface GateSpec {
113
113
  * `pending` is reported instead of hidden.
114
114
  */
115
115
  unavailable_exit?: number;
116
+ /**
117
+ * Repo-relative workflow file this row's `argv` is pinned to.
118
+ *
119
+ * Rule 2 of this manifest — CI-IDENTICAL INVOCATION — was an instruction to
120
+ * the author and nothing else: the guard runs whatever `argv` says and never
121
+ * looked at how CI calls the gate, so a row could drift from its workflow, or
122
+ * the workflow step could be deleted outright, and this file would keep
123
+ * reporting healthy coverage for a gate CI no longer runs.
124
+ *
125
+ * Opt-in per row, because most gates here have several CI invocations with
126
+ * different arguments and one `argv` cannot be identical to all of them. Where
127
+ * a row DOES name a workflow, that workflow must contain an invocation of the
128
+ * gate whose argument list equals `argv` exactly.
129
+ */
130
+ ci_invocation?: string;
116
131
  note?: string;
117
132
  }
118
133
 
@@ -191,6 +206,9 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
191
206
  ...(e['unavailable_exit'] === undefined
192
207
  ? {}
193
208
  : { unavailable_exit: _require_int(e['unavailable_exit'], id, i) }),
209
+ ...(e['ci_invocation'] === undefined
210
+ ? {}
211
+ : { ci_invocation: _require_str(e['ci_invocation'], 'ci_invocation', id, i) }),
194
212
  ...(e['note'] === undefined ? {} : { note: String(e['note']) }),
195
213
  ...(e['canary'] === undefined ? {} : { canary: _require_canary(e['canary'], id, i) }),
196
214
  ...(e['no_canary_reason'] === undefined
@@ -405,6 +423,73 @@ function report_negative_control_inventory(specs: readonly GateSpec[]): void {
405
423
  }
406
424
  }
407
425
 
426
+ /**
427
+ * Invocations of `src/scripts/<id>` in a workflow, each as its argument list.
428
+ *
429
+ * Deliberately a text scan rather than a YAML walk: an invocation lives inside a
430
+ * `run:` block scalar, so parsing the workflow buys nothing and costs the ability
431
+ * to see a call split across a line continuation, which is how the release job
432
+ * writes this very gate. Backslash-newline joins are folded before matching for
433
+ * that reason.
434
+ */
435
+ export function ci_invocations(workflowText: string, id: string): string[][] {
436
+ const folded = workflowText.replace(/\\\n\s*/gu, ' ');
437
+ const out: string[][] = [];
438
+ const re = new RegExp(String.raw`src/scripts/${id}(?![A-Za-z0-9_])([^\n]*)`, 'gu');
439
+ for (const m of folded.matchAll(re)) {
440
+ out.push(
441
+ (m[1] ?? '')
442
+ .trim()
443
+ .split(/\s+/u)
444
+ .filter((t) => t !== ''),
445
+ );
446
+ }
447
+ return out;
448
+ }
449
+
450
+ /**
451
+ * The row's `argv` must be reproducible from the workflow it pins.
452
+ *
453
+ * Both directions are failures and they are different defects: no matching
454
+ * invocation means the manifest probes the gate in a way CI does not (or CI
455
+ * stopped calling it at all), which is the drift rule 2 asks for and nothing
456
+ * enforced.
457
+ */
458
+ export function ci_invocation_problem(
459
+ workflowText: string,
460
+ id: string,
461
+ argv: readonly string[],
462
+ ): string | null {
463
+ const found = ci_invocations(workflowText, id);
464
+ if (found.length === 0) {
465
+ return `no invocation of src/scripts/${id} — the workflow does not call this gate`;
466
+ }
467
+ const want = argv.join(' ');
468
+ if (found.some((a) => a.join(' ') === want)) {
469
+ return null;
470
+ }
471
+ return (
472
+ `argv [${want}] matches no invocation there; the workflow calls it as ` +
473
+ found.map((a) => `[${a.join(' ')}]`).join(', ')
474
+ );
475
+ }
476
+
477
+ /** Rows whose `ci_invocation` no longer describes their workflow. */
478
+ export function ci_invocation_drift(specs: readonly GateSpec[], repoRoot = REPO_ROOT): string[] {
479
+ const problems: string[] = [];
480
+ for (const s of specs) {
481
+ if (s.ci_invocation === undefined) continue;
482
+ const file = path.join(repoRoot, s.ci_invocation);
483
+ if (!fs.existsSync(file)) {
484
+ problems.push(`${s.id}: ci_invocation ${s.ci_invocation} does not exist`);
485
+ continue;
486
+ }
487
+ const problem = ci_invocation_problem(fs.readFileSync(file, 'utf-8'), s.id, s.argv);
488
+ if (problem !== null) problems.push(`${s.id}: ${s.ci_invocation} — ${problem}`);
489
+ }
490
+ return problems;
491
+ }
492
+
408
493
  export function main(argv: readonly string[]): number {
409
494
  const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
410
495
  const listOnly = argv.includes('--list');
@@ -523,12 +608,23 @@ export function main(argv: readonly string[]): number {
523
608
  );
524
609
  }
525
610
  report_negative_control_inventory(specs);
611
+ const drift = ci_invocation_drift(specs);
612
+ for (const d of drift) {
613
+ process.stdout.write(` ❌ ${d}\n`);
614
+ }
526
615
  const hardening = report_hardening_ratchet();
527
616
  const selfTest = report_self_test_ratchet();
528
617
  if (failed.length > 0) {
529
618
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
530
619
  return 1;
531
620
  }
621
+ if (drift.length > 0) {
622
+ process.stdout.write(
623
+ `❌ ${String(drift.length)} row(s) declare a ci_invocation their workflow no longer matches — ` +
624
+ `the manifest probes the gate differently from CI, or CI stopped calling it.\n`,
625
+ );
626
+ return 1;
627
+ }
532
628
  if (hardening !== 0) {
533
629
  return hardening;
534
630
  }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * The documented MCP client snippets must match the entry the installer writes.
4
+ *
5
+ * `docs/mcp-server.md` documented an absolute `tsx` path in four snippets while
6
+ * the installer wrote an `npx` invocation, so a consumer who followed the docs
7
+ * and a consumer who ran the installer landed on two different servers — for
8
+ * long enough that nobody noticed either was wrong.
9
+ *
10
+ * Scope is two literals in this tree. It asserts NOTHING about host behaviour:
11
+ * that a host reads the file at all is a separate question this gate does not
12
+ * touch and must not be cited for.
13
+ *
14
+ * Exit codes: 0 in sync · 1 drift · 2 a source is missing or unreadable.
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
+
21
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
22
+ import { MCP_PACKAGE_NAME, MCP_SERVER_KEY, mcpBridgeEntry } from './_lib/mcp_bridge.js';
23
+ import { reportScanned } from './_lib/scan_scope.js';
24
+
25
+ const _HERE = fileURLToPath(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
27
+ const DOC_REL = 'docs/mcp-server.md';
28
+
29
+ /**
30
+ * A snippet is any fenced block naming our server key.
31
+ *
32
+ * Deliberately not a JSON/YAML parse: the four documented clients use three
33
+ * different shapes (`command` as a string, `command` as an object with `path`,
34
+ * and a YAML list), and a parser per shape would drift from the docs as surely
35
+ * as the docs drifted from the installer. The two facts that matter — the
36
+ * runner and the package spec — are the same literals in all three.
37
+ */
38
+ export function snippetBlocks(text: string): string[] {
39
+ const out: string[] = [];
40
+ const lines = text.split('\n');
41
+ let open: string[] | null = null;
42
+ for (const line of lines) {
43
+ if (line.startsWith('```')) {
44
+ if (open === null) open = [];
45
+ else {
46
+ const body = open.join('\n');
47
+ if (body.includes(MCP_SERVER_KEY) || body.includes('mcpServers')) out.push(body);
48
+ open = null;
49
+ }
50
+ continue;
51
+ }
52
+ if (open !== null) open.push(line);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ export interface DocFinding {
58
+ snippet: number;
59
+ reason: string;
60
+ }
61
+
62
+ export function checkSnippets(text: string, packageRoot: string): DocFinding[] {
63
+ const server = (mcpBridgeEntry(packageRoot)['mcpServers'] as Record<string, unknown>)[
64
+ MCP_SERVER_KEY
65
+ ] as { command: string; args: string[] };
66
+ const findings: DocFinding[] = [];
67
+
68
+ snippetBlocks(text).forEach((body, i) => {
69
+ // Only blocks that actually configure the server — a block that merely
70
+ // mentions `mcpServers` in prose is not a client configuration.
71
+ if (!body.includes('command')) return;
72
+ if (!body.includes(server.command)) {
73
+ findings.push({
74
+ snippet: i + 1,
75
+ reason: `does not use the installer's runner \`${server.command}\``,
76
+ });
77
+ }
78
+ if (!body.includes(MCP_PACKAGE_NAME)) {
79
+ findings.push({
80
+ snippet: i + 1,
81
+ reason: `does not name the package \`${MCP_PACKAGE_NAME}\``,
82
+ });
83
+ }
84
+ // QUOTED, not a substring: `mcp-server` contains `serve`, so a plain
85
+ // `includes` reported a snippet as current after the entry had been
86
+ // changed to `serve` and the doc had not — measured, on this gate's own
87
+ // sensitivity probe. Every documented shape quotes the token.
88
+ const subcommand = server.args[server.args.length - 1] as string;
89
+ if (!body.includes(`"${subcommand}"`)) {
90
+ findings.push({
91
+ snippet: i + 1,
92
+ reason: `does not invoke \`${subcommand}\``,
93
+ });
94
+ }
95
+ if (body.includes('node_modules/.bin/tsx')) {
96
+ findings.push({
97
+ snippet: i + 1,
98
+ reason: 'documents an absolute tsx path the installer never writes',
99
+ });
100
+ }
101
+ });
102
+ return findings;
103
+ }
104
+
105
+ function main(): number {
106
+ if (process.argv.slice(2).includes('--self-test')) return selfTest();
107
+ const rootIdx = process.argv.indexOf('--root');
108
+ const root = rootIdx === -1 ? ROOT : (process.argv[rootIdx + 1] ?? ROOT);
109
+ const doc = path.join(root, DOC_REL);
110
+ if (!fs.existsSync(doc)) {
111
+ process.stderr.write(`❌ ${DOC_REL} not found under ${root}\n`);
112
+ return 2;
113
+ }
114
+ const text = fs.readFileSync(doc, 'utf-8');
115
+ const blocks = snippetBlocks(text).filter((b) => b.includes('command'));
116
+ const findings = checkSnippets(text, root);
117
+
118
+ reportScanned({
119
+ gate: 'check_mcp_doc_drift',
120
+ scanned: blocks.length,
121
+ units: 'documented client snippet(s)',
122
+ roots: [DOC_REL],
123
+ });
124
+
125
+ if (findings.length > 0) {
126
+ for (const f of findings) {
127
+ process.stdout.write(`❌ ${DOC_REL} snippet ${String(f.snippet)}: ${f.reason}\n`);
128
+ }
129
+ process.stdout.write(
130
+ `\n${String(findings.length)} finding(s). The documented entry and the entry ` +
131
+ '`mcpBridgeEntry()` writes have drifted apart.\n',
132
+ );
133
+ return 1;
134
+ }
135
+ process.stdout.write(
136
+ `✅ check_mcp_doc_drift: ${String(blocks.length)} documented snippet(s) match the ` +
137
+ 'installer entry.\n',
138
+ );
139
+ return 0;
140
+ }
141
+
142
+ /** One rejecting case per way the two literals can drift. */
143
+ function selfTest(): number {
144
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-doc-drift-'));
145
+ const write = (name: string, doc: string): string => {
146
+ const dir = path.join(tmp, name);
147
+ fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
148
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '1.0.0' }));
149
+ fs.writeFileSync(path.join(dir, DOC_REL), doc, 'utf-8');
150
+ return dir;
151
+ };
152
+ const run = (root: string): number =>
153
+ runGateCli(ROOT, 'src/scripts/check_mcp_doc_drift.ts', ['--root', root], root);
154
+
155
+ const good = [
156
+ '```json',
157
+ '{ "mcpServers": { "agent-config": {',
158
+ ' "command": "npx",',
159
+ ' "args": ["-y", "@event4u/agent-config@<version>", "mcp-server"] } } }',
160
+ '```',
161
+ '',
162
+ ].join('\n');
163
+ const tsxPath = good.replace('"npx"', '"/abs/agent-config/node_modules/.bin/tsx"');
164
+ const noPackage = good.replace('@event4u/agent-config@<version>', 'some-other-package');
165
+ const noSubcommand = good.replace('"mcp-server"', '"serve"');
166
+
167
+ try {
168
+ return runSelfTest({
169
+ gate: 'check_mcp_doc_drift',
170
+ minCases: 4,
171
+ minRejectCases: 3,
172
+ cases: [
173
+ {
174
+ name: 'an absolute tsx path is rejected',
175
+ expect: 'reject',
176
+ run: () => run(write('tsx', tsxPath)),
177
+ },
178
+ {
179
+ name: 'a snippet naming another package is rejected',
180
+ expect: 'reject',
181
+ run: () => run(write('pkg', noPackage)),
182
+ },
183
+ {
184
+ name: 'a snippet invoking another subcommand is rejected',
185
+ expect: 'reject',
186
+ run: () => run(write('sub', noSubcommand)),
187
+ },
188
+ {
189
+ name: 'the installer-shaped snippet passes',
190
+ expect: 'accept',
191
+ run: () => run(write('good', good)),
192
+ },
193
+ ],
194
+ });
195
+ } finally {
196
+ fs.rmSync(tmp, { recursive: true, force: true });
197
+ }
198
+ }
199
+
200
+ function _isCliEntry(): boolean {
201
+ if (process.argv[1] === undefined) return false;
202
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
203
+ try {
204
+ return (
205
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
206
+ fs.realpathSync(path.resolve(process.argv[1]))
207
+ );
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ if (_isCliEntry() || process.argv[1] === _HERE) {
214
+ process.exit(main());
215
+ }
216
+
217
+ export { DOC_REL, main };