session-orchestrator 3.17.0 → 3.19.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 (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -60,7 +60,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from '
60
60
  import { dirname, join } from 'node:path';
61
61
  import { fileURLToPath } from 'node:url';
62
62
 
63
+ import { emitEvent, sessionAttribution } from './events.mjs';
64
+ import { admitSuiteCounts, extractTestCounts } from './gates/gate-helpers.mjs';
63
65
  import { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
66
+
64
67
  export { redactDiagnosticsBundle } from './quality-gate/diagnostics.mjs';
65
68
 
66
69
  // ---------------------------------------------------------------------------
@@ -361,6 +364,44 @@ export function detectSharedLibTouch(opts) {
361
364
  return { touched: matched.length > 0, paths: matched };
362
365
  }
363
366
 
367
+ /**
368
+ * INPUT ADAPTER for this module's `counts` field (#954, #967 item 2).
369
+ *
370
+ * Holds NO admission policy of its own. Every verdict — including both
371
+ * rejections this function used to make itself — is delegated to
372
+ * {@link admitSuiteCounts}, the single policy shared with the CLI producer
373
+ * `suiteCountsFromGateStdout` (`scripts/run-quality-gate.mjs`). Before that
374
+ * convergence the two producers wrote the SAME event field under DIFFERENT
375
+ * rules (this one admitted `passed > total` and a negative `passed`), so a
376
+ * consumer had to know two policies to read one field.
377
+ *
378
+ * What stays here is the part the shared policy cannot see: the raw-text tail
379
+ * parse. `extractTestCounts` has no "did it match?" channel — it returns
380
+ * `0/0/0` both for "no `<N> passed` marker in the output" and for a genuinely
381
+ * empty run — so a text-less input is handed to the policy as `null` rather
382
+ * than as a zero triple, and the policy refuses it. The result is `null`, never
383
+ * a zero triple: `counts.failed === 0` means "measured, zero failures"; an
384
+ * ABSENT `counts` means "not measured".
385
+ *
386
+ * That null hand-over is ALSO how this adapter reports "the gate loop never
387
+ * reached the test step" (#969 MED-2). A `null` output fails the string check on
388
+ * the first line, so the positional evidence and the unparseable-text case land
389
+ * on the SAME channel the policy already has to check. The policy previously
390
+ * took a second `measured` boolean for the positional case; it was unreachable
391
+ * with a non-null triple precisely because this line runs first, and two ways to
392
+ * say "not measured" is one more than the field can be read with.
393
+ *
394
+ * @param {string|null} output — captured stdout+stderr tail from the test gate,
395
+ * or `null` when the gate loop never reached the test step.
396
+ * @returns {{ passed: number, failed: number, total: number }|null}
397
+ */
398
+ function suiteCountsFromOutput(output) {
399
+ if (typeof output !== 'string' || output.length === 0) {
400
+ return admitSuiteCounts(null);
401
+ }
402
+ return admitSuiteCounts(extractTestCounts(output));
403
+ }
404
+
364
405
  /**
365
406
  * Coerce `maxRetries` to [0, MAX_RETRIES_HARD_CAP] integer.
366
407
  *
@@ -379,6 +420,97 @@ function coerceMaxRetries(n) {
379
420
  // Public API
380
421
  // ---------------------------------------------------------------------------
381
422
 
423
+ /**
424
+ * Emit exactly one `orchestrator.quality_gate.{passed,failed}` event per
425
+ * `runQualityGateWithRetry` CALL (#928b).
426
+ *
427
+ * NO DOUBLE-COUNTING. The two gate paths never nest:
428
+ * - `scripts/run-quality-gate.mjs` spawns `scripts/lib/gates/gate-*.mjs`
429
+ * directly and does not import this module;
430
+ * - this module spawns the resolved gate COMMANDS (and `parse-config.mjs`)
431
+ * directly and does not invoke that wrapper.
432
+ * A single run therefore passes through exactly one emitter.
433
+ *
434
+ * Granularity is the CALL, not the attempt: the retry loop may run the gates
435
+ * up to `maxRetries + 1` times, but emitting per attempt would inflate every
436
+ * rate computed over these events. `attempts` carries that detail instead.
437
+ * `variant: 'auto-fix-loop'` distinguishes this path from the CLI's
438
+ * baseline/incremental/full-gate/per-file variants.
439
+ *
440
+ * The destination is pinned to the `repoRoot` this gate actually ran against,
441
+ * via the `opts.repoRoot` parameter (#941; formerly a hand-built `opts.filePath`
442
+ * recipe, #611). `emitEvent`'s BARE default resolution goes through the
443
+ * module-level `SO_PROJECT_DIR` constant, which ignores `repoRoot` entirely — so
444
+ * a caller running the gate against another tree (every unit test does, using a
445
+ * tmp `repoRoot`) would otherwise append synthetic records to the REAL repo's
446
+ * telemetry. That is not a test-hygiene nicety: injected `quality_gate.failed`
447
+ * records are exactly what `/eval`'s gate-health dimension reads, so the
448
+ * instrument would be scored against its own test fixtures.
449
+ *
450
+ * `counts` (#954) carries the suite numbers this gate already had in hand. It
451
+ * is OMITTED — never zero-filled — whenever the final attempt did not reach the
452
+ * test gate (fail-fast on lint/typecheck) or its output carried no parseable
453
+ * count. See {@link suiteCountsFromOutput}.
454
+ *
455
+ * IT DOES NOT YET REPLACE THE PROSE PATH (#957/F1 — the earlier wording here
456
+ * claimed it did). `waves[].suite_passed` / `suite_failed` still travel as prose
457
+ * through two LLM hops: `skills/wave-executor/wave-loop.md` step 7 hand-writes
458
+ * them, `skills/session-end/metrics-collection.md` § 1.7 parses them back out of
459
+ * the STATE.md Wave History header into sessions.jsonl. `counts` is a SECOND,
460
+ * machine-measured emission of the same fact, and as of 2026-07-31 it has zero
461
+ * readers (`grep -c '"counts"' .orchestrator/metrics/events.jsonl` → 0 across
462
+ * 4404 `orchestrator.quality_gate.*` records).
463
+ *
464
+ * Retiring the prose path needs a producer change no docblock can make:
465
+ * `waves[].*` is PER-WAVE, and gate events carry no `wave_number` (0 of those
466
+ * 4404 records). A session-end reader could only attribute a gate event to a
467
+ * wave by a wall-clock window whose own boundaries (`waves[].started_at` /
468
+ * `completed_at`) are themselves LLM-written — one LLM hop traded for another,
469
+ * against the posture `scripts/lib/eval/session-resolve.mjs` already documents
470
+ * for window-attributed gate events ("a contaminated window means
471
+ * gate-attribution is unsafe"). The concrete remaining work is named in
472
+ * `skills/session-end/metrics-collection.md` § 1.7.
473
+ *
474
+ * Note also that THIS emitter only runs under `verification-auto-fix.enabled:
475
+ * true` (default `false`, and `false` in this repo's Session Config). The gate
476
+ * that actually fires between waves is the `scripts/run-quality-gate.mjs`
477
+ * wrapper, which emits its own `counts` via `suiteCountsFromGateStdout` — under
478
+ * the SAME admission policy since #967 item 2 (`admitSuiteCounts`), so a
479
+ * consumer reads one field with one set of rules regardless of which producer
480
+ * wrote it.
481
+ *
482
+ * Extraction margin: {@link suiteCountsFromOutput} sees only the
483
+ * `OUTPUT_TAIL_LINES` (50) tail `runCheck` retains. Measured on `npm test`
484
+ * (vitest 2026-07-31), the `Tests` summary line sits 5 lines from the end — 45
485
+ * lines of headroom. A runner epilogue longer than that (coverage table, long
486
+ * unhandled-error dump) pushes the summary out of the window; `counts` is then
487
+ * omitted, which fails safe but is indistinguishable from "no test gate ran".
488
+ *
489
+ * Best-effort: never throws, never alters the gate verdict.
490
+ *
491
+ * @param {string} repoRoot
492
+ * @param {boolean} ok
493
+ * @param {number} attempts
494
+ * @param {string|null} gate
495
+ * @param {{passed: number, failed: number, total: number}|null} [counts]
496
+ */
497
+ async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
498
+ try {
499
+ await emitEvent(
500
+ `orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
501
+ {
502
+ variant: 'auto-fix-loop',
503
+ exit_code: ok ? 0 : 1,
504
+ attempts,
505
+ ...(gate ? { gate } : {}),
506
+ ...(counts ? { counts } : {}),
507
+ ...sessionAttribution(repoRoot),
508
+ },
509
+ { repoRoot },
510
+ );
511
+ } catch { /* best-effort telemetry — gate result is authoritative */ }
512
+ }
513
+
382
514
  /**
383
515
  * Run quality gate (lint → typecheck → test, fail-fast), dispatching a fixer
384
516
  * callback on each failure up to `maxRetries` times.
@@ -421,6 +553,14 @@ export async function runQualityGateWithRetry(opts) {
421
553
  const allFailures = [];
422
554
  let attempt = 0;
423
555
  let lastFailure = null;
556
+ /**
557
+ * Suite counts observed in the CURRENT attempt only (#954). Re-assigned
558
+ * unconditionally after every attempt's gate loop, so a failure that
559
+ * fail-fasts on lint can never re-report a stale count from an earlier
560
+ * attempt's test run.
561
+ * @type {{passed: number, failed: number, total: number}|null}
562
+ */
563
+ let testCounts = null;
424
564
 
425
565
  // Total loop budget = maxRetries + 1 (one initial run + up to maxRetries fixer-driven retries).
426
566
  const totalAttempts = maxRetries + 1;
@@ -428,10 +568,30 @@ export async function runQualityGateWithRetry(opts) {
428
568
  while (attempt < totalAttempts) {
429
569
  attempt += 1;
430
570
  let gateFailure = null;
571
+ /**
572
+ * The test gate's captured output for THIS attempt — `null` until the
573
+ * `test` branch below assigns it, which happens only if the gate loop
574
+ * actually reaches the test step. A fail-fast on lint or typecheck leaves
575
+ * it `null`.
576
+ *
577
+ * That null-ness is the positional evidence {@link admitSuiteCounts}
578
+ * cannot observe for itself: the shared policy sees a candidate triple and
579
+ * nothing else, never the control flow that produced it. It reaches the
580
+ * policy AS the null triple {@link suiteCountsFromOutput} hands over, so an
581
+ * unmeasured gate can never publish a zero triple attributed to a run that
582
+ * never happened.
583
+ * @type {string|null}
584
+ */
585
+ let testOutput = null;
431
586
 
432
587
  for (const gate of GATE_ORDER) {
433
588
  const cmd = commands[gate];
434
589
  const result = runGate(cmd, repoRoot);
590
+ if (gate === 'test') {
591
+ // The numbers are in hand right here — capture them at the seam rather
592
+ // than letting them travel as prose (#954).
593
+ testOutput = result.output;
594
+ }
435
595
  if (result.exitCode === 0) {
436
596
  process.stderr.write(`🔁 quality-gate attempt ${attempt}/${totalAttempts} (gate=${gate}): pass\n`);
437
597
  continue;
@@ -449,9 +609,14 @@ export async function runQualityGateWithRetry(opts) {
449
609
  break;
450
610
  }
451
611
 
612
+ // Non-fatal by construction: an unmeasured, unparseable or inconsistent
613
+ // run yields null, which the emitter omits rather than zero-fills.
614
+ testCounts = suiteCountsFromOutput(testOutput);
615
+
452
616
  if (gateFailure === null) {
453
617
  // All gates passed this attempt.
454
618
  writeLastGreenSha(repoRoot);
619
+ await emitGateEvent(repoRoot, true, attempt, null, testCounts);
455
620
  return { ok: true, attempts: attempt };
456
621
  }
457
622
 
@@ -516,6 +681,8 @@ export async function runQualityGateWithRetry(opts) {
516
681
  `❌ quality-gate exhausted retries (${attempt}), writing diagnostics to ${bundlePath ?? '<unwritable>'}\n`,
517
682
  );
518
683
 
684
+ await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts);
685
+
519
686
  const out = {
520
687
  ok: false,
521
688
  attempts: attempt,
@@ -26,7 +26,7 @@ const VALID_MODES = new Set([
26
26
  * @param {number} input.completionRate — 0.00..1.00; share of planned issues closed
27
27
  * @param {number} input.carryoverRatio — 0.00..1.00; carryover / planned (0 when planned=0)
28
28
  * @param {Array<number|string>} [input.carryoverIssues] — IDs/IIDs of carried-over issues,
29
- * pre-sorted (priority:critical/high first, FIFO tiebreak). Sliced to 0-5 entries for
29
+ * pre-sorted (priority::critical/high first, FIFO tiebreak). Sliced to 0-5 entries for
30
30
  * the `priorities` field.
31
31
  * @returns {{mode: string, priorities: number[], rationale: string}}
32
32
  */
@@ -135,7 +135,7 @@ export function classifyLearning(learning, { now, minInsightChars } = {}) {
135
135
  if (!Array.isArray(filePaths) || filePaths.length === 0) {
136
136
  return {
137
137
  eligible: false,
138
- reason: `eligible type '${type}' but empty file_paths[] — cannot scope a conditional rule`,
138
+ reason: `eligible type '${type}' but empty file_paths[] — cannot scope a conditional rule (propose with --file-paths to make this convertible)`,
139
139
  };
140
140
  }
141
141
 
@@ -65,13 +65,26 @@ function kebab(s) {
65
65
  .replace(/^-+|-+$/g, '');
66
66
  }
67
67
 
68
+ // Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
69
+ // (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
70
+ // stray '**' or '[ab]' entry (e.g. from an OLD learning record predating the
71
+ // #900 C / schema.mjs argv-boundary guards) must never reach the renderer
72
+ // verbatim, or it would produce an effectively always-on rule glob. Skipped
73
+ // here as defense-in-depth even though the argv (memory-propose.mjs) and
74
+ // schema (memory-proposals/schema.mjs) layers already reject these at write
75
+ // time — this emitter also processes learnings.jsonl entries that predate
76
+ // those guards.
77
+ const GLOB_METACHAR_RE = /[*?[\]{}]/;
78
+
68
79
  /**
69
80
  * Derive non-empty directory globs from a learning's `file_paths`.
70
81
  *
71
82
  * For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
72
83
  * the file sits at the repo top level (`dirname` === '.'), emit the bare
73
- * basename pattern instead of `./**` (e.g. `"foo.mjs"`). Results are deduped,
74
- * order-preserving on first occurrence.
84
+ * basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
85
+ * glob metacharacter (`* ? [ ] { }`) are skipped entirely — see
86
+ * {@link GLOB_METACHAR_RE}. Results are deduped, order-preserving on first
87
+ * occurrence.
75
88
  *
76
89
  * @param {string[]} filePaths
77
90
  * @returns {string[]}
@@ -81,6 +94,7 @@ function globsFromFilePaths(filePaths) {
81
94
  const seen = new Set();
82
95
  for (const raw of filePaths) {
83
96
  if (typeof raw !== 'string' || raw === '') continue;
97
+ if (GLOB_METACHAR_RE.test(raw)) continue;
84
98
  const normalized = raw.replace(/\\/g, '/');
85
99
  const dir = dirname(normalized);
86
100
  const pattern = dir === '.' ? normalized : `${dir}/**`;
@@ -181,8 +195,13 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
181
195
  * @param {object} [opts]
182
196
  * @param {number} [opts.ruleExpiryDays] - explicit expiry window in days; when a
183
197
  * finite number, overrides the per-type TTL.
184
- * @param {number|Date} [opts.now] - injectable clock used ONLY as a fallback when
185
- * `created_at` is missing/unparseable (keeps expiry deterministic in tests).
198
+ * @param {number|Date} [opts.now] - injectable clock (defaults to `Date.now()`).
199
+ * Used for BOTH the born-dead expiry floor (`now + minRuleDays`, applied on
200
+ * every call — see {@link computeExpiresAt}) and, secondarily, as the base
201
+ * date when `created_at` is missing/unparseable. Because the floor consults
202
+ * it unconditionally, any test asserting a literal `expiresAt` MUST inject
203
+ * `now` — otherwise the wall clock eventually raises the floor past the
204
+ * derived expiry and the assertion silently starts measuring the floor.
186
205
  * @param {number} [opts.minRuleDays] - floor window (days) applied to the
187
206
  * emitted expiry so it never falls in the past — see
188
207
  * {@link computeExpiresAt} and {@link MIN_RULE_DAYS_DEFAULT}. Defaults
@@ -62,7 +62,24 @@
62
62
  * @property {number} eligible
63
63
  * @property {number} proposed
64
64
  * @property {number} rejected
65
+ * @property {number} capped - count of eligible learnings that were NOT proposed
66
+ * this run purely because of the `maxProposalsPerRun` volume brake (issue
67
+ * #900 D — confidence-sorted, lowest-confidence entries cut first). Each
68
+ * capped learning is ALSO counted inside `rejected` (with a `capped — ...`
69
+ * reason) — `totalLearnings === proposed + rejected` still holds unchanged;
70
+ * `capped` is a diagnostic sub-count that lets a report distinguish
71
+ * "genuinely ineligible" rejections from "eligible but cut by the volume
72
+ * brake" ones at a glance.
65
73
  * @property {boolean} written
74
+ * @property {number} [skipped] - how many persisted sidecar lines the store's
75
+ * read-side shape guard rejected and this run therefore DROPPED from disk
76
+ * (`mergeCandidates` rewrites the store in full, never appends — see
77
+ * `idempotency.mjs`). PRESENT-vs-ABSENT is load-bearing and must not be
78
+ * collapsed: `0` means "the store was inspected and nothing was dropped",
79
+ * while ABSENCE means "the store was never inspected this run" — the case
80
+ * under `dryRun` (merge skipped entirely), on the empty short-circuit, on
81
+ * the top-level error path, and when the merge seam reports no count.
82
+ * Defaulting the absent case to `0` would be a false all-clear.
66
83
  *
67
84
  * @typedef {Object} ReconcileResult
68
85
  * @property {ReconcileProposal[]} proposals
@@ -78,11 +95,24 @@ import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mj
78
95
  import { filterEligible } from './eligibility.mjs';
79
96
  import { toActivationMetadata } from './emitter.mjs';
80
97
  import { renderRule } from './renderer.mjs';
81
- import { makeCandidateId, mergeCandidates as realMergeCandidates } from './idempotency.mjs';
98
+ import {
99
+ DEFAULT_STORE_PATH,
100
+ buildCandidate,
101
+ makeCandidateId,
102
+ mergeCandidates as realMergeCandidates,
103
+ } from './idempotency.mjs';
82
104
 
83
105
  /** Default repo-relative location of the learnings corpus. */
84
106
  const DEFAULT_LEARNINGS_PATH = '.orchestrator/metrics/learnings.jsonl';
85
107
 
108
+ /**
109
+ * Default volume brake (issue #900 D) — mirrors the `reconcile.max-proposals-
110
+ * per-run` Session Config default in `scripts/lib/config/reconcile.mjs`. Applied
111
+ * even when a caller omits `maxProposalsPerRun` entirely, so the engine never
112
+ * silently proposes an unbounded number of rules in one run.
113
+ */
114
+ const DEFAULT_MAX_PROPOSALS_PER_RUN = 10;
115
+
86
116
  /**
87
117
  * Build a fully-zeroed result (the empty / error shape). Touches no disk.
88
118
  * @param {string} [error]
@@ -98,6 +128,7 @@ function zeroedResult(error) {
98
128
  eligible: 0,
99
129
  proposed: 0,
100
130
  rejected: 0,
131
+ capped: 0,
101
132
  written: false,
102
133
  },
103
134
  };
@@ -186,33 +217,31 @@ function learningType(learning) {
186
217
  }
187
218
 
188
219
  /**
189
- * Build a sidecar ReconcileCandidate line-record (idempotency.mjs schema) for a
190
- * proposed or rejected learning. `created_at` is stamped from the injectable
191
- * clock so output stays deterministic under test.
220
+ * Surface a shape-guard drop on stderr. The sidecar is a mutable work-queue that
221
+ * `mergeCandidates` rewrites in FULL, so a record the read-side shape guard
222
+ * rejects is not merely ignored it is gone from disk after this run. The count
223
+ * alone makes that loss attributable; this WARN is what makes it VISIBLE, since
224
+ * the summary field only helps a caller that thinks to read it.
225
+ *
226
+ * Never throws: a failing diagnostic must not break the never-throws contract of
227
+ * {@link runReconcile} (a broken stderr pipe would otherwise zero the result).
192
228
  *
193
- * @param {Object} params
194
- * @param {string} params.id
195
- * @param {string|null} params.learningKey
196
- * @param {string} params.slug
197
- * @param {'proposed'|'rejected'} params.status
198
- * @param {string} params.reason
199
- * @param {number} params.confidence
200
- * @param {string} params.createdAt - ISO timestamp.
201
- * @returns {import('./idempotency.mjs').ReconcileCandidate}
229
+ * @param {number} skipped - drop count (> 0 by the time this is called).
230
+ * @returns {void}
202
231
  */
203
- function buildCandidate({ id, learningKey, slug, status, reason, confidence, createdAt }) {
204
- return {
205
- id,
206
- schema_version: 1,
207
- learning_key: typeof learningKey === 'string' ? learningKey : '',
208
- slug,
209
- status,
210
- reason,
211
- confidence,
212
- created_at: createdAt,
213
- processed_at: null,
214
- superseded_by: null,
215
- };
232
+ function warnDroppedStoreRecords(skipped) {
233
+ try {
234
+ console.warn(
235
+ `⚠️ reconcile: ${skipped} record(s) in ${DEFAULT_STORE_PATH} failed the ` +
236
+ `candidate shape guard and were DROPPED by this merge — the store is ` +
237
+ `rewritten in full, so they are no longer on disk. Expected shape: a ` +
238
+ `ReconcileCandidate with \`learning_key\` + \`created_at\` (see ` +
239
+ `scripts/lib/reconcile/idempotency.mjs). Only \`mergeCandidates\` may ` +
240
+ `write this store; hand-written or report records do not belong in it.`,
241
+ );
242
+ } catch {
243
+ // A diagnostic must never become the failure it reports on.
244
+ }
216
245
  }
217
246
 
218
247
  /**
@@ -236,6 +265,11 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
236
265
  * eligibility placeholder-insight check (forwarded to `filterEligible`). Inert
237
266
  * (no additional rejections) when omitted.
238
267
  * @param {number|Date} [params.now] - injectable clock (emitter fallback + candidate `created_at`).
268
+ * @param {number} [params.maxProposalsPerRun] - volume brake (issue #900 D): after
269
+ * sorting eligible learnings by confidence DESC, only the top N are proposed;
270
+ * the rest are recorded as `capped` rejections. Defaults to
271
+ * {@link DEFAULT_MAX_PROPOSALS_PER_RUN} (10) when omitted, non-finite, or < 1
272
+ * — the brake is ALWAYS active, matching the Session Config default.
239
273
  * @param {boolean} [params.dryRun] - when true, compute proposals but SKIP the merge entirely
240
274
  * (also accepted as `opts.dryRun`; either location sets it).
241
275
  * @param {Object} [opts] - DI seams (all default to real behaviour).
@@ -249,7 +283,15 @@ function buildCandidate({ id, learningKey, slug, status, reason, confidence, cre
249
283
  * @returns {Promise<ReconcileResult>}
250
284
  */
251
285
  export async function runReconcile(
252
- { repoRoot, ruleExpiryDays, minRuleDays, minInsightChars, now, dryRun: dryRunParam } = {},
286
+ {
287
+ repoRoot,
288
+ ruleExpiryDays,
289
+ minRuleDays,
290
+ minInsightChars,
291
+ now,
292
+ maxProposalsPerRun: maxProposalsPerRunParam,
293
+ dryRun: dryRunParam,
294
+ } = {},
253
295
  opts = {},
254
296
  ) {
255
297
  try {
@@ -258,6 +300,12 @@ export async function runReconcile(
258
300
  // (the documented DI seam) — either location flips it on.
259
301
  const dryRun = dryRunParam === true || opts.dryRun === true;
260
302
  const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
303
+ // Volume brake (#900 D) — always active; a missing/invalid override falls
304
+ // back to the same default the Session Config parser uses.
305
+ const maxProposalsPerRun =
306
+ Number.isFinite(maxProposalsPerRunParam) && maxProposalsPerRunParam >= 1
307
+ ? Math.floor(maxProposalsPerRunParam)
308
+ : DEFAULT_MAX_PROPOSALS_PER_RUN;
261
309
 
262
310
  // --- Pipeline step 1 — load learnings ----------------------------------
263
311
  /** @type {Array<Record<string, unknown>>} */
@@ -289,6 +337,21 @@ export async function runReconcile(
289
337
  minInsightChars,
290
338
  });
291
339
 
340
+ // --- Pipeline step 3b — volume brake (#900 D) ---------------------------
341
+ // Sort eligible learnings by confidence DESC (ties keep their original,
342
+ // stable relative order) and keep only the top `maxProposalsPerRun`. The
343
+ // rest are cut BEFORE they ever reach the emitter — never proposed this
344
+ // run — and recorded as `capped` rejections in step 4b below so a report
345
+ // stays honest about the cut instead of silently dropping them.
346
+ const confidenceOf = (l) =>
347
+ l && typeof l === 'object' && typeof l.confidence === 'number' ? l.confidence : 0;
348
+ const sortedEligible = eligible
349
+ .map((learning, index) => ({ learning, index }))
350
+ .sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
351
+ .map(({ learning }) => learning);
352
+ const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
353
+ const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
354
+
292
355
  /** @type {ReconcileProposal[]} */
293
356
  const proposals = [];
294
357
  /** @type {ReconcileRejection[]} */
@@ -297,7 +360,7 @@ export async function runReconcile(
297
360
  const candidates = [];
298
361
 
299
362
  // --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
300
- for (const learning of eligible) {
363
+ for (const learning of keptEligible) {
301
364
  try {
302
365
  const metadata = toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays });
303
366
  const { slug, path, content } = renderRule(learning, metadata);
@@ -352,6 +415,34 @@ export async function runReconcile(
352
415
  }
353
416
  }
354
417
 
418
+ // --- Pipeline step 4b — capped-eligible learnings (#900 D) --------------
419
+ // Learnings that passed eligibility but were cut by the volume brake are
420
+ // NEVER passed to the emitter — recorded directly as rejections (with a
421
+ // `capped — ...` reason) so a report distinguishes this from a genuine
422
+ // ineligibility rejection.
423
+ for (const learning of cappedEligible) {
424
+ const learningKey = rejectedLearningKey(learning);
425
+ const type = learningType(learning);
426
+ const reason = `capped — max-proposals-per-run (${maxProposalsPerRun}) reached; ${cappedEligible.length} lower-confidence eligible learning(s) not proposed this run`;
427
+ rejected.push({
428
+ learningKey,
429
+ type,
430
+ reason,
431
+ status: 'rejected',
432
+ });
433
+ candidates.push(
434
+ buildCandidate({
435
+ id: makeCandidateId(learningKey ?? '', `rejected-${type}`),
436
+ learningKey,
437
+ slug: '',
438
+ status: 'rejected',
439
+ reason,
440
+ confidence: confidenceOf(learning),
441
+ createdAt,
442
+ }),
443
+ );
444
+ }
445
+
355
446
  // --- Pipeline step 5 — per rejected learning (eligibility rejects) ------
356
447
  for (const { learning, reason } of rejectedLearnings) {
357
448
  const learningKey = rejectedLearningKey(learning);
@@ -382,28 +473,45 @@ export async function runReconcile(
382
473
  // The engine's ONLY disk write — and it is skipped entirely under dryRun.
383
474
  // It never writes `.claude/rules/` (FA3 / #696 owns that, post-approval).
384
475
  let written = false;
476
+ // `undefined` (NOT 0) until the merge actually inspects the store — absence
477
+ // means "not checked", 0 means "checked, nothing dropped". See the
478
+ // ReconcileSummary `skipped` typedef.
479
+ /** @type {number|undefined} */
480
+ let skipped;
385
481
  if (!dryRun) {
386
482
  try {
387
483
  const mergeResult = merge({ candidates, repoRoot });
388
484
  written = !!(mergeResult && mergeResult.written === true);
485
+ // Only a finite count from the seam counts as "inspected". A merge seam
486
+ // that reports nothing leaves `skipped` absent rather than fabricating 0.
487
+ if (mergeResult && Number.isFinite(mergeResult.skipped)) {
488
+ skipped = Number(mergeResult.skipped);
489
+ }
389
490
  } catch {
390
- // Merge failure is non-fatal; proposals still returned, written stays false.
491
+ // Merge failure is non-fatal; proposals still returned, written stays
492
+ // false and `skipped` stays absent (the store was never inspected).
391
493
  written = false;
392
494
  }
393
495
  }
394
496
 
497
+ // Make an attributable drop VISIBLE, not merely recorded (WARN, never throw).
498
+ if (typeof skipped === 'number' && skipped > 0) warnDroppedStoreRecords(skipped);
499
+
395
500
  // --- Pipeline step 7 — summary -----------------------------------------
396
- return {
397
- proposals,
398
- rejected,
399
- summary: {
400
- totalLearnings,
401
- eligible: eligible.length,
402
- proposed: proposals.length,
403
- rejected: rejected.length,
404
- written,
405
- },
501
+ /** @type {ReconcileSummary} */
502
+ const summary = {
503
+ totalLearnings,
504
+ eligible: eligible.length,
505
+ proposed: proposals.length,
506
+ rejected: rejected.length,
507
+ capped: cappedEligible.length,
508
+ written,
406
509
  };
510
+ // Additive + absence-preserving: the key exists ONLY when the store was
511
+ // actually inspected, so no consumer can read a false `skipped: 0`.
512
+ if (typeof skipped === 'number') summary.skipped = skipped;
513
+
514
+ return { proposals, rejected, summary };
407
515
  } catch (err) {
408
516
  // never-throws top-level guard.
409
517
  const msg = err && err.message ? err.message : String(err);