@event4u/agent-config 9.32.0 → 9.34.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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -72
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/commands/review/routing.md +3 -4
  5. package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
  7. package/dist/agent-src/rules/delegation-policy.md +6 -0
  8. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  9. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  10. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  11. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  12. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  13. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  14. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  15. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  16. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  18. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  19. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  22. package/dist/cli/registry.js +4 -4
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  25. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  26. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  27. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  28. package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
  29. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  30. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  31. package/dist/cli-delegate/cmd_conformance.js +256 -226
  32. package/dist/cli-delegate/cmd_doctor.js +3 -2
  33. package/dist/cli-delegate/cmd_export.js +1 -1
  34. package/dist/cli-delegate/cmd_handoff.js +21 -28
  35. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  36. package/dist/cli-delegate/cmd_prune.js +1 -1
  37. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  38. package/dist/cli-delegate/cmd_session_recycle.js +20 -5
  39. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  40. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  41. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  42. package/dist/cli-delegate/cmd_sync.js +1 -1
  43. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +1 -1
  45. package/dist/discovery/deprecation-report.md +1 -1
  46. package/dist/discovery/discovery-manifest.json +17 -17
  47. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  48. package/dist/discovery/discovery-manifest.summary.md +2 -2
  49. package/dist/discovery/dormancy-report.md +17 -0
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +3 -3
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +3 -3
  54. package/dist/hooks/dispatch.js +114 -7
  55. package/dist/install/install.mjs +7 -4
  56. package/dist/mcp/registry-manifest.json +3 -3
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +3755 -1168
  59. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  60. package/package.json +2 -2
  61. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  62. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  63. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  64. package/src/config/cost-parity-budget.json +167 -0
  65. package/src/config/gate-coverage.yml +44 -5
  66. package/src/config/quorum-attendance-budget.json +39 -0
  67. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  68. package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
  69. package/src/scripts/_dispatch.bash +5 -3
  70. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  71. package/src/scripts/_lib/orchestration_record.ts +45 -0
  72. package/src/scripts/_lib/security_lint.ts +18 -4
  73. package/src/scripts/_lib/self_repair.ts +60 -0
  74. package/src/scripts/_lib/self_repair_store.ts +65 -5
  75. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  76. package/src/scripts/ai_council/clients.ts +31 -0
  77. package/src/scripts/ai_council/events_log.ts +130 -8
  78. package/src/scripts/ai_council/orchestrator.ts +50 -3
  79. package/src/scripts/ai_council/pricing.ts +48 -1
  80. package/src/scripts/ai_council/prompts.ts +165 -3
  81. package/src/scripts/ai_council/quorum.ts +30 -0
  82. package/src/scripts/ai_council/session.ts +11 -0
  83. package/src/scripts/ai_team/review_gate.ts +2 -2
  84. package/src/scripts/ask_transport.ts +20 -1
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/cache_realization_report.ts +38 -0
  87. package/src/scripts/check_claims.ts +51 -2
  88. package/src/scripts/check_release_highlights.ts +7 -1
  89. package/src/scripts/ci_time_ratio.ts +3 -1
  90. package/src/scripts/conformance_scan.ts +105 -0
  91. package/src/scripts/cost/track.mjs +39 -0
  92. package/src/scripts/cost_summary.ts +104 -1
  93. package/src/scripts/council_cli.ts +131 -13
  94. package/src/scripts/discovery_graph.ts +201 -27
  95. package/src/scripts/dispatch_economy_report.ts +5 -2
  96. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  97. package/src/scripts/install.ts +20 -6
  98. package/src/scripts/lint_budget_ownership.ts +81 -0
  99. package/src/scripts/lint_handoffs.ts +92 -2
  100. package/src/scripts/lint_hedge_words.ts +317 -0
  101. package/src/scripts/lint_mcp_config_security.ts +1 -1
  102. package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
  103. package/src/scripts/lint_workflow_paths.ts +384 -0
  104. package/src/scripts/preamble_byte_census.ts +9 -1
  105. package/src/scripts/report_carrier_divergence.ts +10 -0
  106. package/src/scripts/report_conformance_funnel.ts +40 -0
  107. package/src/scripts/schemas/subagent.schema.json +3 -2
  108. package/src/scripts/self_repair_cli.ts +26 -1
  109. package/src/scripts/skill_linter.ts +58 -2
@@ -90,6 +90,8 @@ import * as installed_tools from '../_lib/installed_tools.js';
90
90
  import * as user_global_paths from '../_lib/user_global_paths.js';
91
91
  import * as global_deploy_inventory from '../_lib/global_deploy_inventory.js';
92
92
  import { censusDuplicateScope } from '../_lib/duplicate_scope_census.js';
93
+ import * as preamble_byte_census from '../preamble_byte_census.js';
94
+ import * as dispatch_economy_report from '../dispatch_economy_report.js';
93
95
  import { git_common_dir } from '../_lib/git_common_dir.js';
94
96
  import * as sync_gitattributes from '../sync_gitattributes.js';
95
97
  import {
@@ -3244,6 +3246,7 @@ interface Options {
3244
3246
  check: string | null;
3245
3247
  trace_root: boolean;
3246
3248
  context: boolean;
3249
+ anatomy: boolean;
3247
3250
  repair: string | null;
3248
3251
  }
3249
3252
 
@@ -3254,13 +3257,15 @@ const PROG = 'agent-config doctor';
3254
3257
  // terminal width; golden tests assert the `usage:` token + exit code only.
3255
3258
  const USAGE =
3256
3259
  `usage: ${PROG} [-h] [--project PROJECT] [--json] [--ci] [--check ID]\n` +
3257
- ' [--trace-root] [--context] [--repair ID]\n';
3260
+ ' [--trace-root] [--context] [--anatomy]\n' +
3261
+ ' [--repair ID]\n';
3258
3262
 
3259
3263
  const _STORE_TRUE_FLAGS: Record<string, keyof Options> = {
3260
3264
  '--json': 'json',
3261
3265
  '--ci': 'ci',
3262
3266
  '--trace-root': 'trace_root',
3263
3267
  '--context': 'context',
3268
+ '--anatomy': 'anatomy',
3264
3269
  };
3265
3270
 
3266
3271
  const _VALUE_FLAGS: Record<string, keyof Options> = {
@@ -3283,6 +3288,7 @@ function _parse(argv: string[]): Options {
3283
3288
  check: null,
3284
3289
  trace_root: false,
3285
3290
  context: false,
3291
+ anatomy: false,
3286
3292
  repair: null,
3287
3293
  };
3288
3294
 
@@ -3408,6 +3414,65 @@ function _run_context(opts: Options): number {
3408
3414
  return 0;
3409
3415
  }
3410
3416
 
3417
+ /**
3418
+ * `--anatomy` — the injection anatomy, rendered from measurements that already
3419
+ * exist. Composes `preamble_byte_census` (what occupies the always-loaded
3420
+ * preamble) with `dispatch_economy_report` (what the dispatch legs cost). It
3421
+ * takes NO new measurement and adds no threshold: both sources are advisory
3422
+ * reports and stay advisory here.
3423
+ *
3424
+ * Neither module runs anything at import time — `preamble_byte_census` gained
3425
+ * the `__AGENT_CONFIG_BUNDLE__` guard for exactly this import, since inside the
3426
+ * `build:cli-delegate` bundle its old entry check would have matched whenever
3427
+ * this command was invoked directly.
3428
+ */
3429
+ function _run_anatomy(opts: Options): number {
3430
+ const census = preamble_byte_census;
3431
+ const economy = dispatch_economy_report;
3432
+
3433
+ const byteCensus = census.buildReport(census.parseArgs([]));
3434
+
3435
+ // The economy half reads transcripts and a registration file; either can be
3436
+ // absent on a consumer machine. An absent half is reported as absent, never
3437
+ // as a zero — a fabricated zero is the failure both source reports already
3438
+ // refuse to make.
3439
+ let economyReport: ReturnType<typeof economy.buildReport> | null = null;
3440
+ let economyError: string | null = null;
3441
+ try {
3442
+ economyReport = economy.buildReport({
3443
+ root: economy.DEFAULT_PROJECTS_ROOT,
3444
+ auditDir: path.join(process.cwd(), economy.DEFAULT_AUDIT_DIR),
3445
+ maxAgeDays: 14,
3446
+ });
3447
+ } catch (err) {
3448
+ economyError = err instanceof Error ? err.message : String(err);
3449
+ }
3450
+
3451
+ if (opts.json) {
3452
+ print(
3453
+ _jsonDumpsIndentAscii(
3454
+ {
3455
+ preamble_byte_census: byteCensus,
3456
+ dispatch_economy: economyReport,
3457
+ dispatch_economy_unavailable: economyError,
3458
+ } as unknown as Dict,
3459
+ 2,
3460
+ ),
3461
+ );
3462
+ return 0;
3463
+ }
3464
+
3465
+ print('── injection anatomy ─────────────────────────────────────────');
3466
+ print(census.renderText(byteCensus));
3467
+ print('');
3468
+ print(
3469
+ economyReport !== null
3470
+ ? economy.renderText(economyReport)
3471
+ : `dispatch-economy report unavailable: ${economyError}`,
3472
+ );
3473
+ return 0;
3474
+ }
3475
+
3411
3476
  function _run_repair(opts: Options): number {
3412
3477
  const target = opts.repair;
3413
3478
  if (target === 'wizard-state') {
@@ -3464,6 +3529,9 @@ function main(argv: string[] | null = null): number {
3464
3529
  if (opts.context) {
3465
3530
  return _run_context(opts);
3466
3531
  }
3532
+ if (opts.anatomy) {
3533
+ return _run_anatomy(opts);
3534
+ }
3467
3535
  let project_root: string;
3468
3536
  let origin: string;
3469
3537
  try {
@@ -57,8 +57,13 @@ export function templateEnvelope(): Record<string, unknown> {
57
57
  acceptance_criteria: ['<what done means, one line each>'],
58
58
  remaining: ['<open work, one line each>'],
59
59
  not_carried_forward: ['<what the successor must re-derive from source>'],
60
- decisions: ['<decision — one-line rationale>'],
60
+ decisions: ['<decision — one-line rationale> [reversible]'],
61
61
  constraints: ['<binding constraint>'],
62
+ // Ships EMPTY, unlike the prose lists above: the entries are path refs,
63
+ // and a `<placeholder>` left in by a hurried writer would be a fake path
64
+ // rather than obvious filler. An empty list is also the honest default —
65
+ // nothing off limits is the common case.
66
+ do_not_touch: [],
62
67
  open_worker_envelopes: [],
63
68
  artifact_paths: ['<path to a deliverable / note / evidence file>'],
64
69
  assumptions: [],
@@ -73,6 +78,7 @@ interface ParsedArgv {
73
78
  message?: string;
74
79
  file?: string;
75
80
  template?: boolean;
81
+ verify?: boolean;
76
82
  }
77
83
 
78
84
  export function parseArgv(argv: readonly string[]): ParsedArgv {
@@ -81,6 +87,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
81
87
  const a = argv[i] as string;
82
88
  if (a === '--template') {
83
89
  parsed.template = true;
90
+ } else if (a === '--verify') {
91
+ parsed.verify = true;
84
92
  } else if (a === '--file') {
85
93
  const value = argv[i + 1];
86
94
  if (!value) return { ok: false, message: '--file requires a path' };
@@ -102,7 +110,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgv {
102
110
  */
103
111
  export function runSessionRecycle(
104
112
  input: string,
105
- opts: { cwd: string; now?: Date },
113
+ opts: { cwd: string; now?: Date; verify?: boolean },
106
114
  ): RecycleResult {
107
115
  const out: string[] = [];
108
116
  const err: string[] = [];
@@ -171,6 +179,15 @@ export function runSessionRecycle(
171
179
  };
172
180
  }
173
181
 
182
+ // `--verify` stops HERE: every rejection above has already run, and the
183
+ // only thing skipped is the write. Validating through a different path
184
+ // than the one that writes would make the check a second implementation
185
+ // to keep in sync — the failure this suite refuses elsewhere.
186
+ if (opts.verify === true) {
187
+ out.push(`recycle envelope VALID — ${bytes} bytes, not written (--verify).`);
188
+ return { code: 0, out, err };
189
+ }
190
+
174
191
  const target = path.join(projectRoot, RECYCLE_ENVELOPE_REL);
175
192
  try {
176
193
  atomic_write_json(target, envelope);
@@ -193,9 +210,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
193
210
  const parsed = parseArgv(argv);
194
211
  if (!parsed.ok) {
195
212
  const usage = [
196
- 'usage: agent-config session:recycle [--file <envelope.json>] [--template]',
213
+ 'usage: agent-config session:recycle [--file <envelope.json>] [--template] [--verify]',
197
214
  ' --file <path> read the envelope JSON from a file (default: stdin)',
198
215
  ' --template print a skeleton envelope and exit',
216
+ ' --verify validate only — same rejections, no write',
199
217
  ].join('\n');
200
218
  if (parsed.message === 'usage') {
201
219
  process.stdout.write(`${usage}\n`);
@@ -225,7 +243,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
225
243
  return 2;
226
244
  }
227
245
 
228
- const result = runSessionRecycle(input, { cwd: process.cwd() });
246
+ const result = runSessionRecycle(input, {
247
+ cwd: process.cwd(),
248
+ ...(parsed.verify === true ? { verify: true } : {}),
249
+ });
229
250
  for (const line of result.out) process.stdout.write(`${line}\n`);
230
251
  for (const line of result.err) process.stderr.write(`${line}\n`);
231
252
  return result.code;
@@ -130,7 +130,7 @@ Tier 1 — power-user (release shape, audit, migration):
130
130
  user-authored files; persists standing consent
131
131
  (install.auto_converge) on first use.
132
132
  Flags: --dry-run | --yes
133
- conformance:behavior Replay local transcripts through the mechanised conformance checks
133
+ conformance:behavior Replay local transcripts through the mechanised conformance checks (--why <id> traces one)
134
134
  conformance Consumer conformance contract: doctor --ci +
135
135
  installed-and-firing checks. Exits non-zero on
136
136
  a failed check.
@@ -197,7 +197,7 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
197
197
  session register). Flags: --json
198
198
  sessions:claim Claim a roadmap for this session so other sessions skip
199
199
  it; --release clears the claim
200
- session:recycle Validate + write the main-session recycle envelope
200
+ session:recycle Validate + write the main-session recycle envelope (--verify validates only)
201
201
  (deliberate recycle instead of auto-compact). Flags:
202
202
  --file <json> | --template; default reads stdin
203
203
  roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
@@ -207,7 +207,9 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
207
207
  gates Open decisions that need you, rendered as actions —
208
208
  roadmap blockers filtered by owner, most-unblocking
209
209
  first. Flags: --all (include maintainer/external),
210
- --json
210
+ --json, --reply (reply-close form: the one blocking
211
+ decision in full, the rest as a count; prints
212
+ nothing when none are yours)
211
213
  capabilities:index Regenerate CAPABILITIES.yaml — the package coverage index
212
214
  (capability area → coverage → backing skills/commands → gaps).
213
215
  Pass --check to fail if stale (for CI). Reads src/ (package repo).
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One definition of "is this env kill-switch on?".
3
+ *
4
+ * WHY THIS EXISTS: the predicate was written out twice, byte-for-byte —
5
+ * `ai_council/events_log.ts` and `ai_team/review_gate.ts` each carried a
6
+ * private `_kill_switch_active()` with the identical body AND the identical
7
+ * env var (`AGENT_CONFIG_NO_EVENTS_LOG`). `diff` over the two blocks was empty
8
+ * and no shared helper existed. Two copies of a boolean coercion is two places
9
+ * for the next accepted spelling to land in only one.
10
+ *
11
+ * The subtle half is why `'false'` is listed at all: `process.env` values are
12
+ * strings, so the obvious `Boolean(process.env.X)` reads the *string* `'false'`
13
+ * as true and silently arms a switch the operator explicitly disarmed. The
14
+ * off-set is therefore explicit — unset, `''`, `'0'`, `'false'`, `'False'` —
15
+ * and everything else is on, so a typo fails toward the safer direction (the
16
+ * switch suppresses a log; a spurious suppression is visible, a spurious write
17
+ * is not).
18
+ *
19
+ * SCOPE, stated because the source over-claimed it. `grep -rn "=== 'false'"
20
+ * src/scripts/` returns 10 hits; only the two migrated here share one
21
+ * predicate. The others are deliberately different and stay put:
22
+ * `orchestration_record.ts` is a CLI-flag coercion whose invalid-string
23
+ * fall-through is documented, `turn_end_gate_hook.ts` additionally accepts
24
+ * `yes`/`on`, and `validate_frontmatter.ts` is a YAML scalar parser. Folding
25
+ * those in would change three behaviours to remove three lines.
26
+ */
27
+
28
+ /** The env values that mean "off". Everything else — including a typo — is on. */
29
+ const _OFF_VALUES: ReadonlySet<string> = new Set(['', '0', 'false', 'False']);
30
+
31
+ /**
32
+ * True when the named environment variable is set to anything outside the
33
+ * off-set. Unset reads as off.
34
+ */
35
+ export function isEnvKillSwitchActive(name: string): boolean {
36
+ return !_OFF_VALUES.has(process.env[name] ?? '');
37
+ }
@@ -64,6 +64,20 @@ export interface RecordInput {
64
64
  dispatch_tokens?: number | null | undefined;
65
65
  /** The orchestrator's own tier — the baseline the downshift cost-% measures against. */
66
66
  session_tier?: string | null | undefined;
67
+ /**
68
+ * The model id that was ASKED for. `tier_chosen`, `tier_source`,
69
+ * `session_tier` and the downshift cost-percentage are all read off the
70
+ * requested tier, so on an alias or a provider substitution every one of
71
+ * them attributes the saving to a model that never ran. Recording the
72
+ * requested id beside the served one is what makes that detectable.
73
+ */
74
+ model_requested?: string | null | undefined;
75
+ /**
76
+ * The model id the provider reported SERVING. `''` / null when the
77
+ * transport reports none (every CLI client) — absent is honest, not a
78
+ * defect, and is NOT a divergence.
79
+ */
80
+ model_served?: string | null | undefined;
67
81
  dispatch_outcome?: DispatchOutcome | undefined;
68
82
  verify_mode?: VerifyMode | undefined;
69
83
  /** QUALITY: subagent return adopted without parent rework. Optional boolean (null = not measured). */
@@ -176,6 +190,23 @@ function isInt(n: unknown): n is number {
176
190
  return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n);
177
191
  }
178
192
 
193
+ /**
194
+ * Did the provider answer with a different model than the one requested?
195
+ *
196
+ * `null` — not decidable — whenever either id is missing or empty. That is the
197
+ * common case (every CLI transport reports no served id) and it is not the
198
+ * same as "no divergence": a `false` here would claim a comparison that never
199
+ * happened, which is the exact mis-attribution this field exists to expose.
200
+ */
201
+ function modelDivergent(
202
+ requested: string | null | undefined,
203
+ served: string | null | undefined,
204
+ ): boolean | null {
205
+ if (typeof requested !== 'string' || typeof served !== 'string') return null;
206
+ if (!requested || !served) return null;
207
+ return requested !== served;
208
+ }
209
+
179
210
  /**
180
211
  * Build + validate the audit-log-v1 line. Returns `{line, errors}`; when
181
212
  * `errors` is non-empty, `line` is null and the caller must NOT write.
@@ -290,6 +321,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
290
321
  errors.push(`trigger_arm_earlier must be one of ${TRIGGER_ARMS.join(' | ')} (or omitted / null when neither arm fired)`);
291
322
  }
292
323
 
324
+ for (const [key, v] of [
325
+ ['model_requested', input.model_requested],
326
+ ['model_served', input.model_served],
327
+ ] as const) {
328
+ if (v != null && typeof v !== 'string') errors.push(`${key} must be a string or omitted`);
329
+ }
330
+
293
331
  if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
294
332
  if (!input.id) errors.push('id (ULID or content hash) is required');
295
333
 
@@ -310,6 +348,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
310
348
  tier_source: input.tier_source ?? null,
311
349
  dispatch_tokens: input.dispatch_tokens ?? null,
312
350
  session_tier: input.session_tier ?? null,
351
+ // Served-model attribution. `model_divergent` is DERIVED, never
352
+ // supplied: `null` when either id is absent — a transport that reports
353
+ // no served id cannot disagree with anything, and calling that `false`
354
+ // would read as "checked, and they matched".
355
+ model_requested: input.model_requested ?? null,
356
+ model_served: input.model_served ?? null,
357
+ model_divergent: modelDivergent(input.model_requested, input.model_served),
313
358
  first_pass_success: input.first_pass_success ?? null,
314
359
  escalated: input.escalated ?? null,
315
360
  agent_combo: input.agent_combo ?? [],
@@ -408,10 +408,13 @@ export function* iter_corpus(
408
408
  * Exit 1 iff at least one finding `is_fail` (HIGH + full weight). WARN-level
409
409
  * (weighted-down or < HIGH) findings print but never fail the build.
410
410
  */
411
- export function report(findings: readonly Finding[], opts: { check_label: string }): number {
411
+ export function report(
412
+ findings: readonly Finding[],
413
+ opts: { check_label: string; scanned_roots?: readonly string[] },
414
+ ): number {
412
415
  const check_label = opts.check_label;
413
416
  if (findings.length === 0) {
414
- process.stdout.write(`✅ ${check_label}: clean (${_corpus_note()}).\n`);
417
+ process.stdout.write(`✅ ${check_label}: clean (${_corpus_note(opts.scanned_roots)}).\n`);
415
418
  return 0;
416
419
  }
417
420
 
@@ -459,8 +462,19 @@ function _pyG(n: number): string {
459
462
  return s;
460
463
  }
461
464
 
462
- function _corpus_note(): string {
463
- return 'scanned ' + DEFAULT_SCAN_ROOTS.join(', ');
465
+ // The clean-path line names the corpus the caller ACTUALLY walked, not the
466
+ // shared default. Two of five callers diverge from `DEFAULT_SCAN_ROOTS`
467
+ // (`lint_skill_frontmatter_safety` replaces the set outright,
468
+ // `lint_mcp_config_security` appends `src/templates`), so a hardcoded note made
469
+ // this gate report a scope in both directions at once: it claimed `src/rules`
470
+ // and `dist/agent-src`, which it never reads, and omitted `src/subagents`,
471
+ // which is the one root holding the artefact its over-broad-grant check exists
472
+ // for. A reader verifying that root by running the gate read the opposite of
473
+ // the truth. Same contract as `_lib/scan_scope.reportScanned` — the emitted
474
+ // scope is the scope that was walked.
475
+ function _corpus_note(scanned_roots?: readonly string[]): string {
476
+ const roots = scanned_roots && scanned_roots.length > 0 ? scanned_roots : DEFAULT_SCAN_ROOTS;
477
+ return 'scanned ' + roots.join(', ');
464
478
  }
465
479
 
466
480
  // ---------------------------------------------------------------------
@@ -538,6 +538,66 @@ export function fingerprint(defect_class: DefectClass, evidence: string): string
538
538
  }
539
539
 
540
540
  /** Fold a finding into an existing record, or open a new one. */
541
+ // ── creation rate cap ──────────────────────────────────────────────
542
+ //
543
+ // `upsertFinding` folds on fingerprint, so a *recurring* defect increments one
544
+ // record and adds no file. What nothing bounded is **creation**: the fingerprint
545
+ // is `defect_class + evidence`, and evidence is a quoted span from the turn, so a
546
+ // defect whose span varies per turn mints a fresh record every time. The only
547
+ // cap in the module pair was `MAX_EVIDENCE`, which bounds a span's *length* and
548
+ // says nothing about how many records may exist.
549
+ //
550
+ // The cap is per `source` because the two intake paths have different plausible
551
+ // rates and a runaway on one must not consume the other's headroom: a
552
+ // `user-reported` record needs the user to complain, and `self-detected` fires at
553
+ // most once per turn per detector. `NEW_RECORDS_PER_SOURCE_PER_WINDOW` sits above
554
+ // any plausible day (three detector classes, a few evidence variants each) and
555
+ // far below a runaway (one per turn over a long session).
556
+ export const NEW_RECORDS_PER_SOURCE_PER_WINDOW = 20;
557
+ export const CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
558
+
559
+ /**
560
+ * How many records of `source` were CREATED inside the window ending at `now`.
561
+ * Counts `first_seen`, never `last_seen` — an old record still being folded into
562
+ * is not creation and must not consume the budget.
563
+ */
564
+ export function recentCreations(
565
+ existing: readonly DefectRecord[],
566
+ source: DefectSource,
567
+ now: string,
568
+ ): number {
569
+ const end = Date.parse(now);
570
+ if (Number.isNaN(end)) {
571
+ return 0;
572
+ }
573
+ return existing.filter((r) => {
574
+ if (r.source !== source) {
575
+ return false;
576
+ }
577
+ const t = Date.parse(r.first_seen);
578
+ // An unparseable stamp is counted: a malformed record is exactly what a
579
+ // runaway writer produces, and failing open there would uncap the store.
580
+ return Number.isNaN(t) || (end - t < CREATION_WINDOW_MS && end - t >= 0);
581
+ }).length;
582
+ }
583
+
584
+ /**
585
+ * Whether opening a NEW record for `finding` is over budget. A fingerprint that
586
+ * already exists is a fold, never a creation, so it is never capped — the cap
587
+ * must not silence a defect the store is already tracking.
588
+ */
589
+ export function creationCapReached(
590
+ existing: readonly DefectRecord[],
591
+ finding: DefectFinding,
592
+ now: string,
593
+ ): boolean {
594
+ const fp = fingerprint(finding.defect_class, finding.evidence);
595
+ if (existing.some((r) => r.fingerprint === fp)) {
596
+ return false;
597
+ }
598
+ return recentCreations(existing, finding.source, now) >= NEW_RECORDS_PER_SOURCE_PER_WINDOW;
599
+ }
600
+
541
601
  export function mergeRecord(
542
602
  existing: DefectRecord | null,
543
603
  finding: DefectFinding,
@@ -11,7 +11,14 @@
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
- import { type DefectFinding, type DefectRecord, fingerprint, mergeRecord } from './self_repair.js';
14
+ import {
15
+ creationCapReached,
16
+ type DefectFinding,
17
+ type DefectRecord,
18
+ type DefectSource,
19
+ fingerprint,
20
+ mergeRecord,
21
+ } from './self_repair.js';
15
22
 
16
23
  export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
17
24
 
@@ -61,7 +68,10 @@ export function listRecords(root: string): DefectRecord[] {
61
68
  }
62
69
  const out: DefectRecord[] = [];
63
70
  for (const n of names.sort()) {
64
- if (!n.endsWith('.json')) {
71
+ // The overflow counter shares the directory and the extension but is not
72
+ // a record. `isRecord` would reject it anyway; naming it here keeps the
73
+ // exclusion intentional rather than incidental.
74
+ if (!n.endsWith('.json') || n === OVERFLOW_FILE) {
65
75
  continue;
66
76
  }
67
77
  const rec = readRecord(root, n.slice(0, -'.json'.length));
@@ -77,13 +87,63 @@ export function openRecords(root: string): DefectRecord[] {
77
87
  return listRecords(root).filter((r) => r.status === 'open');
78
88
  }
79
89
 
90
+ /**
91
+ * Per-source counters for findings the creation cap refused. ONE file for the
92
+ * whole store, so the overflow record is bounded by construction — it is the
93
+ * counter a runaway writer increments instead of the file it would otherwise
94
+ * mint. Deliberately NOT a `DefectRecord`: an overflow is not a defect and must
95
+ * not need a `DefectClass`, an issue-form dropdown entry, or an egress route.
96
+ */
97
+ export const OVERFLOW_FILE = '_overflow.json';
98
+
99
+ export type OverflowCounts = Partial<Record<DefectSource, { dropped: number; last_seen: string }>>;
100
+
101
+ function overflowPath(root: string): string {
102
+ return path.join(storeDir(root), OVERFLOW_FILE);
103
+ }
104
+
105
+ export function readOverflow(root: string): OverflowCounts {
106
+ try {
107
+ const parsed: unknown = JSON.parse(fs.readFileSync(overflowPath(root), 'utf-8'));
108
+ return typeof parsed === 'object' && parsed !== null ? (parsed as OverflowCounts) : {};
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ function bumpOverflow(root: string, source: DefectSource, now: string): void {
115
+ const counts = readOverflow(root);
116
+ const prev = counts[source]?.dropped ?? 0;
117
+ counts[source] = { dropped: prev + 1, last_seen: now };
118
+ const dir = storeDir(root);
119
+ fs.mkdirSync(dir, { recursive: true });
120
+ fs.writeFileSync(overflowPath(root), `${JSON.stringify(counts, null, 2)}\n`, 'utf-8');
121
+ }
122
+
80
123
  /**
81
124
  * Fold a finding into the store: increments the existing record when the
82
- * fingerprint matches, otherwise opens a new one. Returns the stored record.
125
+ * fingerprint matches, otherwise opens a new one. Returns the stored record, or
126
+ * `null` when the per-source creation cap refused to open a new one.
127
+ *
128
+ * A refusal is COUNTED, never silent — `readOverflow` carries the per-source
129
+ * drop tally, so "the cap fired" is a readable fact rather than a defect that
130
+ * disappeared. A cap that quietly discarded reports would break this loop's own
131
+ * Iron Law (a defect is queued, never shrugged off) in the name of bounding it.
83
132
  */
84
- export function upsertFinding(root: string, finding: DefectFinding, now: string): DefectRecord {
133
+ export function upsertFinding(
134
+ root: string,
135
+ finding: DefectFinding,
136
+ now: string,
137
+ ): DefectRecord | null {
85
138
  const fp = fingerprint(finding.defect_class, finding.evidence);
86
- const merged = mergeRecord(readRecord(root, fp), finding, now);
139
+ const existing = readRecord(root, fp);
140
+ // The fold path never consults the cap: a fingerprint already in the store
141
+ // adds no file, so capping it would silence a tracked defect for no bound.
142
+ if (existing === null && creationCapReached(listRecords(root), finding, now)) {
143
+ bumpOverflow(root, finding.source, now);
144
+ return null;
145
+ }
146
+ const merged = mergeRecord(existing, finding, now);
87
147
  writeRecord(root, merged);
88
148
  return merged;
89
149
  }
@@ -232,10 +232,35 @@ export interface MainSessionRecycleEnvelope {
232
232
  * of silent. Required; an empty list is a claim, not a default.
233
233
  */
234
234
  not_carried_forward: string[];
235
- /** Choices already made, each with its one-line rationale in the same line. */
235
+ /**
236
+ * Choices already made, each with its one-line rationale in the same line.
237
+ *
238
+ * A line MAY close with a reversibility tag — `[reversible]` or
239
+ * `[irreversible]` — so the successor can tell "we picked A over B, easy to
240
+ * revisit" from "we already migrated the data". The tag is OPTIONAL, so no
241
+ * committed envelope is retroactively invalid; when present its spelling is
242
+ * validated, which is what keeps it a checkable field rather than a doctrine
243
+ * nobody can verify. No claim is made that it improves resumption — that
244
+ * stays the registered, unmeasured `envelope_resume_success` metric.
245
+ */
236
246
  decisions?: string[];
237
247
  /** Binding constraints the successor must not silently re-open. */
238
248
  constraints?: string[];
249
+ /**
250
+ * Paths the successor must NOT modify — a parallel worktree, a file another
251
+ * session holds, a generated projection whose source lives elsewhere.
252
+ *
253
+ * Distinct from `constraints`, which carries decisions in prose: this is a
254
+ * list of path refs, so "was this path off limits?" is answerable by
255
+ * comparison instead of by reading. That checkability is the whole reason it
256
+ * is a field rather than a sentence — the same bar the handoff-envelope
257
+ * adjudication set (a field whose presence is checkable survives where a
258
+ * doctrine whose effect is unmeasured did not).
259
+ *
260
+ * Optional: an envelope with nothing off limits states nothing here, and an
261
+ * empty list is not a claim that everything is writable.
262
+ */
263
+ do_not_touch?: string[];
239
264
  /** Open worker CHECKPOINT envelopes by path — never inlined bodies. */
240
265
  open_worker_envelopes?: string[];
241
266
  /** Artifact paths (deliverables, notes, evidence) — refs, never bodies. */
@@ -444,6 +469,54 @@ export function hasBoundaryMarker(block: string): boolean {
444
469
  );
445
470
  }
446
471
 
472
+ /** The two accepted reversibility tags, exact spellings. */
473
+ export const DECISION_REVERSIBILITY_TAGS: readonly string[] = ['reversible', 'irreversible'];
474
+
475
+ /**
476
+ * Validate the OPTIONAL trailing reversibility tag on a decision line.
477
+ *
478
+ * Fires only on a line that closes with a bracket whose content is trying to be
479
+ * one of the two tags — `[reversble]`, `[Irreversible!]`, `[reversible?]`. A
480
+ * bracket used for anything else (`[ADR-109]`, `[see #1273]`) is left alone, and
481
+ * a line with no bracket at all is the untagged default. So the check catches a
482
+ * typo in a tag someone MEANT to write, which is the only failure a
483
+ * presence-checkable optional field can actually have: a silently misspelled tag
484
+ * reads as "untagged" and loses the distinction the tag exists to carry.
485
+ */
486
+ export function decisionTagErrors(decisions: unknown): string[] {
487
+ if (!Array.isArray(decisions)) {
488
+ return [];
489
+ }
490
+ const errors: string[] = [];
491
+ for (const line of decisions) {
492
+ if (typeof line !== 'string') {
493
+ continue;
494
+ }
495
+ const m = /\[([^\]]*)\]\s*$/.exec(line.trimEnd());
496
+ if (m === null) {
497
+ continue;
498
+ }
499
+ const inner = (m[1] ?? '').trim();
500
+ const lowered = inner.toLowerCase();
501
+ if (DECISION_REVERSIBILITY_TAGS.includes(lowered)) {
502
+ if (inner !== lowered) {
503
+ errors.push(
504
+ `decisions: reversibility tag must be lower-case — got \`[${inner}]\`, expected \`[${lowered}]\``,
505
+ );
506
+ }
507
+ continue;
508
+ }
509
+ if (/^i?rr?ever[a-z]*[^a-z]*$/i.test(lowered)) {
510
+ errors.push(
511
+ `decisions: \`[${inner}]\` is not a reversibility tag — use exactly ` +
512
+ DECISION_REVERSIBILITY_TAGS.map((t) => `\`[${t}]\``).join(' or ') +
513
+ ', or drop the bracket',
514
+ );
515
+ }
516
+ }
517
+ return errors;
518
+ }
519
+
447
520
  /** Every key `validateRecycleEnvelope` accepts — anything else is schema-invalid. */
448
521
  const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
449
522
  'capsule_version',
@@ -457,6 +530,7 @@ const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
457
530
  'not_carried_forward',
458
531
  'decisions',
459
532
  'constraints',
533
+ 'do_not_touch',
460
534
  'open_worker_envelopes',
461
535
  'artifact_paths',
462
536
  'assumptions',
@@ -519,7 +593,10 @@ export function validateRecycleEnvelope(input: unknown): string[] {
519
593
  checkList(errors, 'remaining', e['remaining'], MAX_LINE_CHARS, true);
520
594
  checkList(errors, 'not_carried_forward', e['not_carried_forward'], MAX_LINE_CHARS, true);
521
595
  checkList(errors, 'decisions', e['decisions'], MAX_LINE_CHARS, false);
596
+ errors.push(...decisionTagErrors(e['decisions']));
522
597
  checkList(errors, 'constraints', e['constraints'], MAX_LINE_CHARS, false);
598
+ // Path refs, so the REF budget applies — not the prose one `constraints` uses.
599
+ checkList(errors, 'do_not_touch', e['do_not_touch'], MAX_REF_CHARS, false);
523
600
  checkList(errors, 'open_worker_envelopes', e['open_worker_envelopes'], MAX_REF_CHARS, false);
524
601
  checkList(errors, 'artifact_paths', e['artifact_paths'], MAX_REF_CHARS, false);
525
602
  checkList(errors, 'suggested_skills', e['suggested_skills'], MAX_REF_CHARS, false);