session-orchestrator 3.17.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) 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 +185 -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/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. 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
 
@@ -30,6 +30,7 @@
30
30
 
31
31
  import { dirname } from 'node:path';
32
32
 
33
+ import { learningKeyOf } from '../learnings/kebab.mjs';
33
34
  import { deriveExpiresAt } from '../learnings/schema.mjs';
34
35
 
35
36
  const DAY_MS = 86400 * 1000;
@@ -44,34 +45,88 @@ const DESCRIPTION_MAX = 120;
44
45
  // shortens a healthy future one.
45
46
  const MIN_RULE_DAYS_DEFAULT = 7;
46
47
 
48
+ // ── #1015: field-shape gates (VALIDATION, never transformation) ──────────────
49
+ // This emitter REJECTS agent-authored values whose SHAPE cannot be serialised
50
+ // into frontmatter safely. It deliberately does NOT escape or neutralise
51
+ // content: neutralisation of agent text happens exactly once, in the renderer's
52
+ // render path. Escaping here as well would double-escape the output — a defect
53
+ // invisible to a file-level diff, because each file still looks locally correct.
54
+ // Rejection is idempotent, so it composes safely with the renderer's sanitiser.
55
+
56
+ /**
57
+ * Accepted shape for `learning.host_class`.
58
+ *
59
+ * `host-class` is by far the highest-escalation field this emitter copies: the
60
+ * renderer serialises it UNQUOTED as `host-class: <value>`, so a newline in it
61
+ * injects sibling TOP-LEVEL frontmatter keys. An injected `alwaysApply: true`
62
+ * plus `expires-at: 2099-01-01` converts a scoped, expiring auto-generated rule
63
+ * into a permanent always-on one — defeating BOTH the never-always-on
64
+ * brandmauer below AND the expiry sweep, because each of those inspects the
65
+ * emitter's in-memory values rather than the serialised document.
66
+ *
67
+ * `validateLearning` (scripts/lib/learnings/schema.mjs) only asserts "string or
68
+ * null" for this field — ANY string, newlines included — and
69
+ * `validateProposalRecord` performs no unknown-key rejection, so every key on a
70
+ * proposal record is spread verbatim into the learning
71
+ * (scripts/lib/memory-proposals/sink.mjs). That is the mechanism that makes an
72
+ * attacker-shaped `host_class` reachable at all; this gate is where it stops.
73
+ *
74
+ * The pattern admits every value `classifyHost()` in scripts/lib/host-identity.mjs
75
+ * can produce (`macos-arm64-m4pro`, `linux-x86_64`, `windows-arm64`,
76
+ * `<osName>-<arch>`) with generous headroom, and rejects everything that could
77
+ * alter frontmatter structure: control chars, whitespace, `:`, `#`, quotes, and
78
+ * a leading `-` (a YAML sequence marker). The 64-char ceiling bounds the emitted
79
+ * line; the longest real value today is 17 chars, so the ceiling is inert in
80
+ * practice. Revisit if a future host taxonomy needs richer tokens.
81
+ */
82
+ const HOST_CLASS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
83
+
84
+ // Characters that make a `file_paths[]` entry unsafe to serialise as a glob.
85
+ // The renderer emits each glob as ` - "<value>"`, so a control char (a newline
86
+ // above all) breaks out of the block sequence into new top-level frontmatter
87
+ // keys, and a quote character breaks out of the quoted scalar. Entries carrying
88
+ // either are SKIPPED — the same defense-in-depth posture as the
89
+ // glob-metacharacter guard below, and for the same reason: this emitter also
90
+ // processes learnings.jsonl records that predate the #900 argv/schema guards.
91
+ const UNSAFE_PATH_QUOTE_RE = /["']/;
92
+
47
93
  // Control chars: C0 range (U+0000–U+001F, includes \n \r \t) plus DEL (U+007F).
48
94
  // A newline in frontmatter would corrupt the YAML parse — stripping these is
49
95
  // critical. Written with \u escapes so the source stays pure ASCII.
50
96
  // eslint-disable-next-line no-control-regex
51
97
  const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F]/g;
52
98
 
53
- /**
54
- * Slugify a string into a stable kebab-case token.
55
- * Lowercases, collapses every run of non-`[a-z0-9]` chars into a single `-`,
56
- * and trims leading/trailing `-`. UUID subjects produce ugly-but-stable keys.
57
- *
58
- * @param {string} s
59
- * @returns {string}
60
- */
61
- function kebab(s) {
62
- return String(s)
63
- .toLowerCase()
64
- .replace(/[^a-z0-9]+/g, '-')
65
- .replace(/^-+|-+$/g, '');
66
- }
99
+ // Non-global twin of CONTROL_CHARS_RE for stateless `.test()` calls. A /g regex
100
+ // carries `lastIndex` across calls, so testing with CONTROL_CHARS_RE directly
101
+ // would alternate true/false on identical input. Derived from the same `.source`
102
+ // so the two can never drift — and so this line stays pure ASCII (a literal
103
+ // control char here would be a NUL-class corruption; see
104
+ // .claude/rules/proven-pattern-nul-byte-corruption...).
105
+ const CONTROL_CHARS_TEST_RE = new RegExp(CONTROL_CHARS_RE.source);
106
+
107
+ // Glob metacharacters (issue #900-follow-up, Q3-MED). A top-level
108
+ // (dirname==='.') file_paths entry is emitted AS THE GLOB ITSELF below — so a
109
+ // stray '**' or '[ab]' entry (e.g. from an OLD learning record predating the
110
+ // #900 C / schema.mjs argv-boundary guards) must never reach the renderer
111
+ // verbatim, or it would produce an effectively always-on rule glob. Skipped
112
+ // here as defense-in-depth even though the argv (memory-propose.mjs) and
113
+ // schema (memory-proposals/schema.mjs) layers already reject these at write
114
+ // time — this emitter also processes learnings.jsonl entries that predate
115
+ // those guards.
116
+ const GLOB_METACHAR_RE = /[*?[\]{}]/;
67
117
 
68
118
  /**
69
119
  * Derive non-empty directory globs from a learning's `file_paths`.
70
120
  *
71
121
  * For each path: take its directory (`path.dirname`) and emit `<dir>/**`. When
72
122
  * 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.
123
+ * basename pattern instead of `./**` (e.g. `"foo.mjs"`). Entries containing a
124
+ * glob metacharacter (`* ? [ ] { }`) are skipped entirely — see
125
+ * {@link GLOB_METACHAR_RE} — as are entries carrying a control char or a quote
126
+ * character, which would break out of the renderer's ` - "<glob>"` sequence
127
+ * item into new top-level frontmatter keys (#1015; see
128
+ * {@link CONTROL_CHARS_TEST_RE} / {@link UNSAFE_PATH_QUOTE_RE}). Results are
129
+ * deduped, order-preserving on first occurrence.
75
130
  *
76
131
  * @param {string[]} filePaths
77
132
  * @returns {string[]}
@@ -81,6 +136,10 @@ function globsFromFilePaths(filePaths) {
81
136
  const seen = new Set();
82
137
  for (const raw of filePaths) {
83
138
  if (typeof raw !== 'string' || raw === '') continue;
139
+ if (GLOB_METACHAR_RE.test(raw)) continue;
140
+ // #1015: frontmatter-structure guard. Skipping (never escaping) keeps this
141
+ // idempotent and non-overlapping with the renderer's sanitiser.
142
+ if (CONTROL_CHARS_TEST_RE.test(raw) || UNSAFE_PATH_QUOTE_RE.test(raw)) continue;
84
143
  const normalized = raw.replace(/\\/g, '/');
85
144
  const dir = dirname(normalized);
86
145
  const pattern = dir === '.' ? normalized : `${dir}/**`;
@@ -181,8 +240,13 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
181
240
  * @param {object} [opts]
182
241
  * @param {number} [opts.ruleExpiryDays] - explicit expiry window in days; when a
183
242
  * 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).
243
+ * @param {number|Date} [opts.now] - injectable clock (defaults to `Date.now()`).
244
+ * Used for BOTH the born-dead expiry floor (`now + minRuleDays`, applied on
245
+ * every call — see {@link computeExpiresAt}) and, secondarily, as the base
246
+ * date when `created_at` is missing/unparseable. Because the floor consults
247
+ * it unconditionally, any test asserting a literal `expiresAt` MUST inject
248
+ * `now` — otherwise the wall clock eventually raises the floor past the
249
+ * derived expiry and the assertion silently starts measuring the floor.
186
250
  * @param {number} [opts.minRuleDays] - floor window (days) applied to the
187
251
  * emitted expiry so it never falls in the past — see
188
252
  * {@link computeExpiresAt} and {@link MIN_RULE_DAYS_DEFAULT}. Defaults
@@ -199,6 +263,9 @@ function computeExpiresAt(learning, ruleExpiryDays, now, minRuleDays) {
199
263
  * }}
200
264
  * @throws {Error} when no activation axis can be produced (empty globs AND no
201
265
  * hostClass) — the never-always-on invariant.
266
+ * @throws {Error} when the learning has no derivable `learning_key` (no usable
267
+ * `type`, or no `title`/`subject` that slugs to a non-empty token) — a rule
268
+ * whose key resolves to no learning is worse than no rule.
202
269
  */
203
270
  export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDays } = {}) {
204
271
  if (learning === null || typeof learning !== 'object' || Array.isArray(learning)) {
@@ -208,10 +275,25 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
208
275
  const filePaths = Array.isArray(learning.file_paths) ? learning.file_paths : [];
209
276
  const globs = globsFromFilePaths(filePaths);
210
277
 
211
- const hostClass =
212
- typeof learning.host_class === 'string' && learning.host_class !== ''
213
- ? learning.host_class
214
- : undefined;
278
+ // #1015: `host_class` is copied straight into an UNQUOTED `host-class:`
279
+ // frontmatter line by the renderer, so its SHAPE is load-bearing. Reject a
280
+ // malformed value loudly rather than degrade to "no host axis" — a
281
+ // structurally-invalid host_class is a corruption/injection signal, and
282
+ // silently emitting the rest of the rule from the same record would hide it.
283
+ // The engine wraps each learning in its own try/catch, so this throw degrades
284
+ // to ONE recorded rejection with an auditable reason, never a crashed run.
285
+ let hostClass;
286
+ if (typeof learning.host_class === 'string' && learning.host_class !== '') {
287
+ if (!HOST_CLASS_RE.test(learning.host_class)) {
288
+ throw new Error(
289
+ `emitter: host_class has an unsafe shape (${JSON.stringify(learning.host_class)}) — refusing to emit; ` +
290
+ 'the renderer serialises it as an unquoted `host-class:` line, so a newline / colon / quote there ' +
291
+ 'injects sibling top-level frontmatter keys (e.g. alwaysApply: true + a far-future expires-at), ' +
292
+ `defeating the never-always-on brandmauer and the expiry sweep. Expected ${HOST_CLASS_RE.source}.`,
293
+ );
294
+ }
295
+ hostClass = learning.host_class;
296
+ }
215
297
 
216
298
  // The brandmauer: an auto-generated rule must carry ≥1 activation axis. If we
217
299
  // could derive neither a glob nor a host-class, refuse — never emit always-on.
@@ -221,8 +303,30 @@ export function toActivationMetadata(learning, { ruleExpiryDays, now, minRuleDay
221
303
  );
222
304
  }
223
305
 
224
- const subjectOrTitle = learning.title || learning.subject || '';
225
- const learningKey = `${learning.type}/${kebab(subjectOrTitle)}`;
306
+ // THE key. Derived through the shared `learningKeyOf` so the writer and every
307
+ // reader (engine's rejection path, validate/check-learning-provenance,
308
+ // learnings/candidates dedupe, claude-md-drift-check) compute one identity
309
+ // from one rule. This line previously kebab'd the TYPE half, which no reader
310
+ // does: harmless while every live type is kebab-identical, and silently
311
+ // divergent in both directions the moment one is not — the writer stamps one
312
+ // key into `.claude/rules/*.md` + `reconcile-candidates.jsonl` while every
313
+ // reader computes another, so dedupe stops matching with nothing failing.
314
+ //
315
+ // The injection hazard that motivated the kebab is closed downstream and
316
+ // LOUDLY: the key becomes an unquoted `learning-key:` frontmatter scalar, and
317
+ // `renderer.mjs` asserts `LEARNING_KEY_RE` (`/^[a-z0-9/-]+$/`) on it before
318
+ // rendering. A type carrying a newline/colon/quote is therefore rejected with
319
+ // an auditable reason instead of being silently re-keyed here — the same
320
+ // reject-don't-degrade posture as the `host_class` guard above.
321
+ const learningKey = learningKeyOf(learning);
322
+ if (learningKey === null) {
323
+ throw new Error(
324
+ 'emitter: unkeyable learning (no usable type, or no title/subject that slugs to a non-empty token) — ' +
325
+ 'refusing to emit; `learning_key` is the dedupe identity, and a record without one would either ' +
326
+ 'stamp a rule no reader can resolve back to a learning or collide with every other unkeyable record ' +
327
+ 'of its type.',
328
+ );
329
+ }
226
330
 
227
331
  const confidence = typeof learning.confidence === 'number' ? learning.confidence : 0;
228
332