@event4u/agent-config 9.29.0 → 9.31.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  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-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -145,7 +145,7 @@ fi
145
145
  # Roadmap dashboard sync — only fires when staged changes touch a roadmap
146
146
  # file or the dashboard itself, so unrelated commits stay fast.
147
147
  if git diff --cached --name-only | grep -qE '^agents/roadmaps(-progress\.md|/)'; then
148
- ./scripts-run .augment/scripts/update_roadmap_progress --check
148
+ ./scripts-run src/scripts/roadmap_progress_hook --check
149
149
  rstatus=$?
150
150
  if [ $rstatus -ne 0 ]; then
151
151
  # The check fails for THREE distinct reasons and prints the real one
@@ -72,7 +72,7 @@ import fs from "node:fs";
72
72
  import path from "node:path";
73
73
  import { fileURLToPath, pathToFileURL } from "node:url";
74
74
 
75
- import { atomic_write_json } from "./hooks/state_io.js";
75
+ import { atomic_write_json, atomic_write_text, is_replay_mode } from "./hooks/state_io.js";
76
76
  import { readHookStdin } from "./hooks/hook_stdin.js";
77
77
  import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
78
78
 
@@ -87,6 +87,27 @@ const EXIT_WARN = 2;
87
87
 
88
88
  export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
89
89
 
90
+ /**
91
+ * Marker written at `pre_compact` and cleared by the single `post_tool_use`
92
+ * re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
93
+ * what bounds the re-injection to ONE per compaction event.
94
+ */
95
+ export const PIN_LOST_DIR = path.join("agents", "state", "language-mirror.pin-lost");
96
+
97
+ /**
98
+ * Per-SESSION marker path. R2 finding 10: a single shared marker made the
99
+ * mechanism cross-talk between concurrent sessions — the normal shape for this
100
+ * repo's worktree workflow. Session B's next `post_tool_use` consumed the marker
101
+ * session A's compaction had set, so A never got the one re-emit that is the
102
+ * whole point of the mechanism, and B got a pin notice derived from whichever
103
+ * session last wrote the shared state. An empty id degrades to a literal
104
+ * bucket rather than colliding silently.
105
+ */
106
+ export function pinLostMarker(session_id: string): string {
107
+ const safe = (session_id || "unknown-session").replace(/[^A-Za-z0-9._-]/g, "_");
108
+ return path.join(PIN_LOST_DIR, `${safe}.marker`);
109
+ }
110
+
90
111
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
91
112
  type JsonObject = { [k: string]: JsonValue };
92
113
 
@@ -380,6 +401,103 @@ function _extractPrompt(payload: JsonObject): string {
380
401
  return "";
381
402
  }
382
403
 
404
+ /** The envelope's session id, or a literal bucket when the host sends none. */
405
+ function _sessionId(envelope: JsonObject): string {
406
+ const v = envelope["session_id"];
407
+ return typeof v === "string" && v.trim() !== "" ? v : "unknown-session";
408
+ }
409
+
410
+ /**
411
+ * Record that compaction is about to drop the context copy of the pin.
412
+ *
413
+ * R2 finding 14: this used to write with a raw `mkdirSync` + `writeFileSync`,
414
+ * bypassing both conventions the surrounding code follows — the module's own
415
+ * atomic writer, and the `is_replay_mode()` guard that keeps a replayed event
416
+ * from mutating live state. A replayed compaction wrote the marker for real.
417
+ */
418
+ /**
419
+ * The pre-R2 layout wrote a single FILE at the path that is now a DIRECTORY.
420
+ * A consumer upgrading with a leftover legacy marker — compaction happened, no
421
+ * `post_tool_use` followed — hits `EEXIST` on the mkdir and `ENOTDIR` on the
422
+ * write (reproduced against Node), which the catch below swallows as
423
+ * "observability only". The failure would then be silent AND permanent for that
424
+ * root: `existsSync` always false, so the mechanism is dead with no diagnostic.
425
+ * One unlink at the moment of collision, and only when the path is a file.
426
+ */
427
+ function _migrateLegacyMarker(consumer_root: string): void {
428
+ const legacy = path.join(consumer_root, PIN_LOST_DIR);
429
+ try {
430
+ if (fs.statSync(legacy).isFile()) {
431
+ fs.rmSync(legacy, { force: true });
432
+ }
433
+ } catch {
434
+ // Absent, already a directory, or unreadable — nothing to migrate.
435
+ }
436
+ }
437
+
438
+ export function _setPinLost(consumer_root: string, session_id: string): void {
439
+ if (is_replay_mode()) return;
440
+ _migrateLegacyMarker(consumer_root);
441
+ try {
442
+ atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
443
+ } catch {
444
+ // Observability only — a failed marker write costs one un-restored pin,
445
+ // never a broken turn.
446
+ }
447
+ }
448
+
449
+ export function _pinLost(consumer_root: string, session_id: string): boolean {
450
+ try {
451
+ return fs.existsSync(path.join(consumer_root, pinLostMarker(session_id)));
452
+ } catch {
453
+ return false;
454
+ }
455
+ }
456
+
457
+ function _clearPinLost(consumer_root: string, session_id: string): void {
458
+ // R2 round 2, finding 8: the replay guard covered only the WRITE. A replayed
459
+ // `post_tool_use` therefore deleted a live marker it was forbidden to create,
460
+ // and the real session lost the single re-emit this whole mechanism exists to
461
+ // deliver — replay destroying live state while blocked from writing it, which
462
+ // is worse than either direction alone.
463
+ if (is_replay_mode()) return;
464
+ try {
465
+ fs.rmSync(path.join(consumer_root, pinLostMarker(session_id)), { force: true });
466
+ } catch {
467
+ // If it cannot be cleared the next tool call re-emits once more. One
468
+ // duplicate line is the failure mode; a loop is not, because the marker
469
+ // is only ever SET by a compaction event.
470
+ }
471
+ }
472
+
473
+ /**
474
+ * The `post_tool_use` half. Emits the pin exactly once per compaction event
475
+ * and only when a pin actually exists — an absent or undetermined pin is not
476
+ * an obligation, and inventing one here would be worse than the gap.
477
+ */
478
+ export function _reEmitAfterCompaction(consumer_root: string, session_id: string): number {
479
+ if (!_pinLost(consumer_root, session_id)) {
480
+ return EXIT_ALLOW;
481
+ }
482
+ const previous = _loadState(path.join(consumer_root, STATE_FILE));
483
+ const language = previous.language as Verdict | undefined;
484
+ const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
485
+ // Clear FIRST: if the write below throws, the marker must not survive to
486
+ // re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
487
+ _clearPinLost(consumer_root, session_id);
488
+ if (language !== "de" && language !== "en") {
489
+ return EXIT_ALLOW;
490
+ }
491
+ process.stdout.write(
492
+ `${JSON.stringify({
493
+ decision: "warn",
494
+ reason: noticeText(language, source),
495
+ additional_context: pinText(language, source),
496
+ })}\n`,
497
+ );
498
+ return EXIT_WARN;
499
+ }
500
+
383
501
  export function run(
384
502
  stdin_text: string,
385
503
  options: { consumer_root: string; env?: NodeJS.ProcessEnv },
@@ -397,6 +515,30 @@ export function run(
397
515
  }
398
516
 
399
517
  const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
518
+
519
+ // round-5 § 6.1 — the compaction boundary.
520
+ //
521
+ // 4 of the 23 post-merge violations happened with NO pin in context: a
522
+ // `compact_boundary` fired and the first violation followed 26 seconds
523
+ // later. Compaction removes the context copy of the pin, not this hook's
524
+ // state file — so this is a state defect with a deterministic fix, aimed
525
+ // only at the subset where the pin was measurably MISSING, never at the 19
526
+ // where it was measurably ignored.
527
+ //
528
+ // The guard IS the design, and is not optional: `pre_compact` sets a
529
+ // marker, `post_tool_use` re-emits once while it is set and clears it. That
530
+ // is one extra injection per compaction EVENT. A re-pin on every tool call
531
+ // is what § 6.2 refuses — "the same failed mechanism running more often" —
532
+ // and this marker is the entire difference between the two.
533
+ const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
534
+ if (event === "pre_compact") {
535
+ _setPinLost(options.consumer_root, _sessionId(envelope));
536
+ return EXIT_ALLOW;
537
+ }
538
+ if (event === "post_tool_use") {
539
+ return _reEmitAfterCompaction(options.consumer_root, _sessionId(envelope));
540
+ }
541
+
400
542
  const prompt = _extractPrompt(payload);
401
543
  if (!prompt) {
402
544
  return EXIT_ALLOW;
@@ -41,6 +41,7 @@ import * as path from 'node:path';
41
41
  import { fileURLToPath, pathToFileURL } from 'node:url';
42
42
  import { parse as parseYaml } from 'yaml';
43
43
 
44
+ import { GateLedger } from './_lib/gate_ledger.js';
44
45
  import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
45
46
  import { reportScanned } from './_lib/scan_scope.js';
46
47
 
@@ -188,15 +189,26 @@ export function main(argv?: readonly string[]): number {
188
189
  names = [];
189
190
  }
190
191
 
192
+ // Per-rule completeness: every listed rule reaches exactly one outcome, so
193
+ // the frontmatter-less `continue` below is a named skip, not a silent one.
194
+ const ledger = new GateLedger('lint_decision_impact');
195
+ ledger.plan(names);
191
196
  const violations: Violation[] = [];
192
197
  let scanned = 0;
193
198
  for (const n of names) {
194
199
  const fm = frontmatterOf(fs.readFileSync(path.join(dir, n), 'utf-8'));
195
200
  if (fm === null) {
201
+ ledger.skip(n, 'not_applicable_kind');
196
202
  continue;
197
203
  }
198
204
  scanned += 1;
199
- violations.push(...checkRule(n.slice(0, -3), fm));
205
+ const found = checkRule(n.slice(0, -3), fm);
206
+ violations.push(...found);
207
+ if (found.length > 0) {
208
+ ledger.fail(n, `${String(found.length)} issue(s)`);
209
+ } else {
210
+ ledger.complete(n);
211
+ }
200
212
  }
201
213
 
202
214
  if (violations.length > 0) {
@@ -214,6 +226,7 @@ export function main(argv?: readonly string[]): number {
214
226
  );
215
227
  }
216
228
 
229
+ ledger.report();
217
230
  reportScanned({
218
231
  gate: 'lint_decision_impact',
219
232
  scanned,
@@ -318,6 +318,87 @@ function _check_dead_concerns(
318
318
  }
319
319
  }
320
320
 
321
+ /**
322
+ * Role-axis validation (road-to-token-economy-dispatch Phase 2). Errors on:
323
+ * - a `roles` block that is not a mapping of role → { drop: [names] }
324
+ * - a drop entry naming an unknown concern
325
+ * - a drop entry naming a concern bound to ANY platform's `pre_tool_use`
326
+ * slot — the safety-guard slot is undroppable (Phase 2.3: "the manifest
327
+ * diff must show zero pre_tool_use guard removals, CI-checked"). The
328
+ * dispatcher also refuses pre_tool_use drops at runtime; this check
329
+ * makes the attempt a red build instead of a silent no-op.
330
+ */
331
+ export function _check_roles(
332
+ manifest: YamlObject,
333
+ concernNames: Set<string>,
334
+ errors: string[],
335
+ ): void {
336
+ const rolesRaw = manifest["roles"];
337
+ if (rolesRaw === undefined || rolesRaw === null) {
338
+ return; // no role axis — valid (every chain is the orchestrator default)
339
+ }
340
+ if (!isObject(rolesRaw)) {
341
+ errors.push(`roles: must be a mapping, got ${pyTypeName(rolesRaw)}`);
342
+ return;
343
+ }
344
+ // Concerns bound on any platform's pre_tool_use slot — the undroppable set.
345
+ const guardBound = new Set<string>();
346
+ const platformsRaw = manifest["platforms"];
347
+ if (isObject(platformsRaw)) {
348
+ for (const block of Object.values(platformsRaw)) {
349
+ if (!isObject(block)) continue;
350
+ const pre = block["pre_tool_use"];
351
+ if (Array.isArray(pre)) {
352
+ for (const n of pre) {
353
+ if (typeof n === "string") guardBound.add(n);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ for (const [role, spec] of Object.entries(rolesRaw)) {
359
+ if (!isObject(spec)) {
360
+ errors.push(`roles.${role}: must be a mapping, got ${pyTypeName(spec)}`);
361
+ continue;
362
+ }
363
+ const drop = spec["drop"];
364
+ if (drop === undefined || drop === null) {
365
+ continue; // a role without a drop list is a no-op entry — allowed
366
+ }
367
+ if (!Array.isArray(drop)) {
368
+ errors.push(`roles.${role}: 'drop' must be a list, got ${pyTypeName(drop)}`);
369
+ continue;
370
+ }
371
+ for (const name of drop) {
372
+ if (typeof name !== "string" || !concernNames.has(name)) {
373
+ errors.push(`roles.${role}: drop entry '${String(name)}' is not a known concern`);
374
+ continue;
375
+ }
376
+ if (guardBound.has(name)) {
377
+ errors.push(
378
+ `roles.${role}: drop entry '${name}' is bound to a pre_tool_use slot — ` +
379
+ `safety guards are undroppable on every role`,
380
+ );
381
+ }
382
+ // Severity-based twin of the slot check (review hardening 2026-08-10):
383
+ // a fail_closed or non-advisory concern is guard-shaped regardless of
384
+ // which slot it binds to — dropping one from a role chain would be a
385
+ // safety removal wearing an economy label.
386
+ const concernsRaw = manifest["concerns"];
387
+ const spec = isObject(concernsRaw) ? concernsRaw[name] : undefined;
388
+ if (isObject(spec)) {
389
+ const advisory = String(spec["severity"] ?? "").trim().toLowerCase() === "advisory";
390
+ const failClosed = spec["fail_closed"] === true;
391
+ if (failClosed || !advisory) {
392
+ errors.push(
393
+ `roles.${role}: drop entry '${name}' is ${failClosed ? "fail_closed" : "not severity: advisory"} — ` +
394
+ `only advisory, fail-open concerns may be role-dropped`,
395
+ );
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+
321
402
  export function lint(manifestPath: string, strict: boolean): number {
322
403
  if (!_isFile(manifestPath)) {
323
404
  process.stderr.write(
@@ -342,6 +423,7 @@ export function lint(manifestPath: string, strict: boolean): number {
342
423
  const warnings: string[] = [];
343
424
  const concernNames = _check_concerns(manifest, errors);
344
425
  const bound = _check_platforms(manifest, concernNames, errors, warnings);
426
+ _check_roles(manifest, concernNames, errors);
345
427
  _check_aliases(manifest, errors);
346
428
  _check_orphan_trampolines(manifest, errors);
347
429
  _check_dead_concerns(concernNames, bound, warnings);
@@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
 
23
23
  import { assertWatchlistResolves, DeadScopeError } from './_lib/scan_scope.js';
24
24
 
25
+ // ledger-exempt: single-artefact prose scan — the whole corpus is the one file README.md, and every hit is a line offset inside it that the failure output already prints in full; there is no enumerated target collection whose members could be silently continued past, and the file-absent / wrong-cwd case is a hard failure via assertWatchlistResolves rather than a green run over zero targets.
25
26
  const _HERE = fileURLToPath(import.meta.url);
26
27
 
27
28
  const README = 'README.md';
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `enforced_by`-declaration ratchet — a NEW rule states its enforcement, or
4
+ * states the gap; only the legacy set may stay silent.
5
+ *
6
+ * The enforcement-coverage number moved backwards across 9.23→9.29 (13.5% →
7
+ * 12.9%) for exactly one reason: rules were added faster than backstops, and
8
+ * nothing required a new rule to even *declare* where it stands. Coverage
9
+ * itself cannot be gated (whether a backstop is buildable is a per-rule
10
+ * judgment), but the *declaration* can: `enforced_by:` accepts an honest
11
+ * `none`, so requiring the key costs a new rule's author one line of truth,
12
+ * never a mechanism.
13
+ *
14
+ * Ratchet shape (the established baseline-the-legacy pattern):
15
+ * - The 84 rules that predate this gate are recorded in
16
+ * `src/config/rule-enforcement-baseline.json`. They stay legal while
17
+ * undeclared; the list may only shrink.
18
+ * - A rule NOT in the baseline must carry `enforced_by:` — a validator/
19
+ * hook/test/observer reference, or the explicit `none`.
20
+ * - A rule declaring `none` must also state the gap in its body (the
21
+ * `enforced_by: none` honesty convention the existing none-rules use) —
22
+ * a frontmatter-only `none` nobody reads is a silent gap again.
23
+ * - A baseline entry whose rule gained a declaration (or was deleted) must
24
+ * leave the baseline in the same change — a stale entry is a freed slot
25
+ * a later silent rule could hide in.
26
+ *
27
+ * Deliberately NOT checked here: whether a declared validator resolves, is
28
+ * wired, or can fail a build — that is `check_enforcement_coverage`'s job
29
+ * (resolution), and `check_backstop_debt`'s (red backstops). This gate owns
30
+ * the declaration boundary only, on `src/rules/` only — gate scripts and
31
+ * their `// ledger-exempt:` markers are a different surface
32
+ * (`check_gate_completeness`) and are not read here.
33
+ *
34
+ * **Where the baseline lives, and why it ships.** `src/config/` is a directory
35
+ * entry in `package.json`'s `files[]`, so `rule-enforcement-baseline.json` is in
36
+ * the published tarball alongside its nine siblings — `gate-violation-baselines`,
37
+ * the four `*-budget` files, and the rest. All ten are repo-internal gate state:
38
+ * this gate is their consumer, it ships too (via `src/scripts/`), and in a
39
+ * consumer checkout it finds no `src/rules/` and does nothing. That is inert, not
40
+ * orphaned, and it is the pre-existing shape rather than something this file
41
+ * introduced — an automated review flagged it as "ships into dist with no
42
+ * consumer", which is doubly wrong: the file is not in `dist/` at all, and the
43
+ * consumer is this script. Whether ten repo-internal baselines belong in the
44
+ * tarball is a real packaging question; it is one decision about ten files, not a
45
+ * carve-out for the tenth.
46
+ *
47
+ * CLI contract: exit 0 = clean, 1 = a finding (or dead scan root), 2 = usage.
48
+ * `--quiet` mutes the clean-path line; `--root <dir>` re-roots for fixtures;
49
+ * `--write-baseline` records the current undeclared set (bootstrap/re-anchor —
50
+ * a deliberate maintainer act, never CI).
51
+ */
52
+ import * as fs from 'node:fs';
53
+ import * as os from 'node:os';
54
+ import * as path from 'node:path';
55
+ import { fileURLToPath, pathToFileURL } from 'node:url';
56
+
57
+ import { read_frontmatter } from './check_enforcement_coverage.js';
58
+ import { GateLedger } from './_lib/gate_ledger.js';
59
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
60
+ import { is_kernel_rule } from './_lib/kernel_rules.js';
61
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
62
+
63
+ const GATE = 'lint_rule_enforcement_declaration';
64
+
65
+ const QUIET = process.argv.slice(2).includes('--quiet');
66
+
67
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
68
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
69
+
70
+ const RULES_RELATIVE = path.join('src', 'rules');
71
+ const BASELINE_RELATIVE = path.join('src', 'config', 'rule-enforcement-baseline.json');
72
+
73
+ function _rootOverride(argv: readonly string[]): string | null {
74
+ const i = argv.indexOf('--root');
75
+ const value = i === -1 ? undefined : argv[i + 1];
76
+ return value === undefined ? null : path.resolve(value);
77
+ }
78
+
79
+ /** enforced_by as declared, normalised to a string array; null = absent. */
80
+ function _declaration(fm: Record<string, string | string[]>): string[] | null {
81
+ const raw = fm['enforced_by'];
82
+ if (raw === undefined) return null;
83
+ const list = Array.isArray(raw) ? raw : [raw];
84
+ return list.length === 0 ? null : list;
85
+ }
86
+
87
+ /** The body must state a `none` gap in prose — the honesty convention. */
88
+ function _bodyStatesNone(text: string): boolean {
89
+ const end = text.indexOf('\n---\n', 4);
90
+ const body = end === -1 ? text : text.slice(end + 5);
91
+ return body.includes('enforced_by: none') || body.includes('`enforced_by: none`');
92
+ }
93
+
94
+ function _readBaseline(root: string): string[] | null {
95
+ try {
96
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(root, BASELINE_RELATIVE), 'utf-8'));
97
+ if (!Array.isArray(parsed) || !parsed.every((e) => typeof e === 'string')) return null;
98
+ return parsed as string[];
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ interface Findings {
105
+ findings: string[];
106
+ undeclared: string[];
107
+ }
108
+
109
+ function _scan(root: string, baseline: ReadonlySet<string>, ledger: GateLedger | null): Findings {
110
+ const rulesDir = path.join(root, RULES_RELATIVE);
111
+ const files = fs
112
+ .readdirSync(rulesDir)
113
+ .filter((f) => f.endsWith('.md'))
114
+ .sort();
115
+ ledger?.plan(files);
116
+
117
+ const findings: string[] = [];
118
+ const undeclared: string[] = [];
119
+ const seen = new Set<string>();
120
+
121
+ for (const file of files) {
122
+ seen.add(file);
123
+ const text = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
124
+ const decl = _declaration(read_frontmatter(text));
125
+
126
+ if (decl === null) {
127
+ undeclared.push(file);
128
+ if (baseline.has(file)) {
129
+ ledger?.outOfScope(file, 'declared_exemption');
130
+ continue;
131
+ }
132
+ const f =
133
+ `src/rules/${file} declares no \`enforced_by:\`. A new rule states its enforcement ` +
134
+ `(validator:/hook:/test:/observer:) or states the gap (\`none\` + a body line saying so). ` +
135
+ `The baseline covers only rules that predate this gate.`;
136
+ findings.push(f);
137
+ ledger?.fail(file, f);
138
+ continue;
139
+ }
140
+
141
+ // Kernel rules are exempt from the body-prose convention, derived from
142
+ // _lib/kernel_rules.ts (never a hardcoded list) so the exemption closes
143
+ // itself the moment a rule leaves the kernel — the same self-closing
144
+ // shape validate_frontmatter uses for obligation_frequency. Their
145
+ // projected bytes are stability-gated, so requiring a prose line here
146
+ // would force an un-editable edit.
147
+ if (decl.includes('none') && !_bodyStatesNone(text) && !is_kernel_rule(file)) {
148
+ const f =
149
+ `src/rules/${file} declares \`enforced_by: none\` in frontmatter but never states the gap ` +
150
+ `in its body — the honesty convention is one prose line naming \`enforced_by: none\`, so a ` +
151
+ `reader of the rule sees the gap, not only a machine.`;
152
+ findings.push(f);
153
+ ledger?.fail(file, f);
154
+ continue;
155
+ }
156
+
157
+ ledger?.complete(file);
158
+ }
159
+
160
+ for (const entry of [...baseline].sort()) {
161
+ if (!seen.has(entry)) {
162
+ findings.push(
163
+ `${BASELINE_RELATIVE} baseline entry \`${entry}\` names no existing rule — remove it in the ` +
164
+ `same change that renamed/deleted the rule. A stale entry is a freed slot a later ` +
165
+ `undeclared rule could silently occupy.`,
166
+ );
167
+ continue;
168
+ }
169
+ if (!undeclared.includes(entry)) {
170
+ findings.push(
171
+ `${BASELINE_RELATIVE} baseline entry \`${entry}\` now declares \`enforced_by:\` — remove it ` +
172
+ `from the baseline in the same change. The list may only shrink.`,
173
+ );
174
+ }
175
+ }
176
+
177
+ return { findings, undeclared };
178
+ }
179
+
180
+ /** Prove against the real CLI that the ratchet rejects what it must. */
181
+ export function selfTest(): number {
182
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lred-selftest-'));
183
+ const declared = '---\ntype: "auto"\nenforced_by:\n - "validator:src/scripts/example.ts"\n---\n\n# R\n\nBody.\n';
184
+ const noneHonest =
185
+ '---\ntype: "auto"\nenforced_by:\n - "none"\n---\n\n# R\n\nThis rule ships `enforced_by: none` — the obligation is model-carried.\n';
186
+ const noneSilent = '---\ntype: "auto"\nenforced_by:\n - "none"\n---\n\n# R\n\nBody with no gap statement.\n';
187
+ const silent = '---\ntype: "auto"\n---\n\n# R\n\nBody.\n';
188
+
189
+ const seed = (name: string, rules: Record<string, string>, baseline: string[]): string => {
190
+ const root = path.join(tmp, name);
191
+ fs.mkdirSync(path.join(root, RULES_RELATIVE), { recursive: true });
192
+ fs.mkdirSync(path.join(root, 'src', 'config'), { recursive: true });
193
+ for (const [file, text] of Object.entries(rules)) {
194
+ fs.writeFileSync(path.join(root, RULES_RELATIVE, file), text, 'utf-8');
195
+ }
196
+ fs.writeFileSync(path.join(root, BASELINE_RELATIVE), `${JSON.stringify(baseline, null, 2)}\n`, 'utf-8');
197
+ return root;
198
+ };
199
+ const run = (cwd: string): number =>
200
+ runGateCli(REPO_ROOT, `src/scripts/${GATE}.ts`, ['--quiet', '--root', cwd], REPO_ROOT);
201
+
202
+ try {
203
+ return runSelfTest({
204
+ gate: GATE,
205
+ minCases: 6,
206
+ minRejectCases: 4,
207
+ cases: [
208
+ {
209
+ name: 'a declared new rule and a baselined legacy rule pass',
210
+ expect: 'accept',
211
+ run: () => run(seed('clean', { 'a.md': declared, 'legacy.md': silent }, ['legacy.md'])),
212
+ },
213
+ {
214
+ name: 'an honest none (frontmatter + body gap line) passes',
215
+ expect: 'accept',
216
+ run: () => run(seed('none-honest', { 'a.md': noneHonest }, [])),
217
+ },
218
+ {
219
+ name: 'a NEW rule without enforced_by is rejected',
220
+ expect: 'reject',
221
+ run: () => run(seed('new-silent', { 'a.md': silent }, [])),
222
+ },
223
+ {
224
+ name: 'a none declaration without the body gap line is rejected',
225
+ expect: 'reject',
226
+ run: () => run(seed('none-silent', { 'a.md': noneSilent }, [])),
227
+ },
228
+ {
229
+ name: 'a stale baseline entry (rule deleted/renamed) is rejected',
230
+ expect: 'reject',
231
+ run: () => run(seed('stale', { 'a.md': declared }, ['gone.md'])),
232
+ },
233
+ {
234
+ name: 'a baseline entry whose rule gained a declaration is rejected until removed',
235
+ expect: 'reject',
236
+ run: () => run(seed('shrink', { 'a.md': declared }, ['a.md'])),
237
+ },
238
+ {
239
+ name: 'a dead scan root is rejected, never passed as nothing-to-check',
240
+ expect: 'reject',
241
+ run: () => run(path.join(tmp, 'dead-root-does-not-exist')),
242
+ },
243
+ ],
244
+ });
245
+ } finally {
246
+ fs.rmSync(tmp, { recursive: true, force: true });
247
+ }
248
+ }
249
+
250
+ function main(): number {
251
+ const argv = process.argv.slice(2);
252
+ if (argv.includes('--self-test')) {
253
+ return selfTest();
254
+ }
255
+ const root = _rootOverride(argv) ?? REPO_ROOT;
256
+
257
+ const rulesDir = path.join(root, RULES_RELATIVE);
258
+ let isDir = false;
259
+ try {
260
+ isDir = fs.statSync(rulesDir).isDirectory();
261
+ } catch {
262
+ isDir = false;
263
+ }
264
+ if (!isDir) {
265
+ process.stderr.write(`❌ ${GATE}: rules dir not found at ${RULES_RELATIVE} — the scan root is dead.\n`);
266
+ return 1;
267
+ }
268
+
269
+ if (argv.includes('--write-baseline')) {
270
+ const { undeclared } = _scan(root, new Set(), null);
271
+ fs.writeFileSync(
272
+ path.join(root, BASELINE_RELATIVE),
273
+ `${JSON.stringify(undeclared, null, 2)}\n`,
274
+ 'utf-8',
275
+ );
276
+ process.stdout.write(`${GATE}: baseline written — ${undeclared.length} legacy undeclared rule(s).\n`);
277
+ return 0;
278
+ }
279
+
280
+ const baseline = _readBaseline(root);
281
+ if (baseline === null) {
282
+ process.stderr.write(
283
+ `❌ ${GATE}: ${BASELINE_RELATIVE} missing or not a string array — run --write-baseline once ` +
284
+ `(a deliberate maintainer act) or restore the committed file.\n`,
285
+ );
286
+ return 1;
287
+ }
288
+
289
+ const ledger = new GateLedger(GATE);
290
+ const { findings } = _scan(root, new Set(baseline), ledger);
291
+
292
+ try {
293
+ reportScanned({
294
+ gate: GATE,
295
+ scanned: fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md')).length,
296
+ units: 'rule file(s)',
297
+ roots: [RULES_RELATIVE],
298
+ });
299
+ } catch (e) {
300
+ if (e instanceof DeadScopeError) {
301
+ process.stderr.write(`❌ ${e.message}\n`);
302
+ return 1;
303
+ }
304
+ throw e;
305
+ }
306
+ ledger.report();
307
+
308
+ if (findings.length > 0) {
309
+ for (const f of findings) {
310
+ process.stderr.write(`❌ ${f}\n`);
311
+ }
312
+ return 1;
313
+ }
314
+ if (!QUIET) {
315
+ process.stdout.write(
316
+ `✅ ${GATE}: every non-baselined rule declares its enforcement (baseline: ${baseline.length}, shrink-only).\n`,
317
+ );
318
+ }
319
+ return 0;
320
+ }
321
+
322
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
323
+ function _isCliEntry(): boolean {
324
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
325
+ return false;
326
+ }
327
+ if (process.argv[1] === undefined) {
328
+ return false;
329
+ }
330
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
331
+ if (import.meta.url === argvUrl) {
332
+ return true;
333
+ }
334
+ try {
335
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
336
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
337
+ return here === argv1;
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ if (_isCliEntry()) {
344
+ process.exit(main());
345
+ }
346
+
347
+ export { GATE, REPO_ROOT, BASELINE_RELATIVE, main };