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
@@ -1,4 +1,5 @@
1
1
  import { matchBlockHeader } from './block-header.mjs';
2
+ import { isSessionConfigHeading } from './section-extractor.mjs';
2
3
 
3
4
  /**
4
5
  * config-protection.mjs — Parser for the top-level `config-protection:` YAML
@@ -109,7 +110,7 @@ export function _isConfigWeakeningAllowed(content) {
109
110
  const lines = content.split(/\r?\n/);
110
111
  let inConfig = false;
111
112
  for (const line of lines) {
112
- if (line === '## Session Config') { inConfig = true; continue; }
113
+ if (isSessionConfigHeading(line)) { inConfig = true; continue; }
113
114
  if (inConfig && /^## /.test(line)) break;
114
115
  if (inConfig) {
115
116
  const m = line.match(/^\s*(?:-\s+\*\*)?allow-config-weakening(?::\*\*)?\s*:\s*(\S+)/);
@@ -99,7 +99,15 @@ export function _parseDriftCheck(content) {
99
99
  dcEnabled = v.toLowerCase() === 'true';
100
100
  break;
101
101
  case 'mode':
102
- if (['hard', 'warn', 'off'].includes(v)) dcMode = v;
102
+ // Enum: strict|warn|off. `hard` is a legacy alias for `strict`,
103
+ // normalized here. This is the drift-check half of the #217 enum
104
+ // migration (vault-sync moved hard→strict on 2026-04-21; drift-check
105
+ // was forgotten, so a committed `mode: strict` silently fell back to
106
+ // the `warn` default and the narrative-drift gate ran unenforced).
107
+ // Invalid values still fall back to the 'warn' default below.
108
+ if (['strict', 'hard', 'warn', 'off'].includes(v)) {
109
+ dcMode = v === 'hard' ? 'strict' : v;
110
+ }
103
111
  break;
104
112
  case 'include-paths':
105
113
  if (!v) inIncludeList = true;
@@ -9,7 +9,7 @@ export const GITLAB_PORTFOLIO_DEFAULTS = {
9
9
  enabled: false,
10
10
  mode: 'warn',
11
11
  'stale-days': 30,
12
- 'critical-labels': ['priority:critical', 'priority:high'],
12
+ 'critical-labels': ['priority::critical', 'priority::high'],
13
13
  };
14
14
 
15
15
  const VALID_MODES = new Set(['warn', 'strict', 'off']);
@@ -0,0 +1,123 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * issue-budget.mjs — Parser for the `issue-budget:` block-style Session Config key.
5
+ *
6
+ * Bounds how many issues ONE session may create. Motivation (measured on the
7
+ * private instance over four weeks): 1784 issues created against 1285 closed
8
+ * (net +499), median inter-creation gap 2.6 s, 62 % of creations inside bursts
9
+ * of >= 5 per minute. The pre-existing `discovery-confidence-threshold` /
10
+ * `discovery-severity-threshold` knobs do NOT bound this: they are per-finding
11
+ * QUALITY filters (and the `low` default filters nothing), they are only read
12
+ * in skill prose, and the largest producers never consult them at all.
13
+ *
14
+ * This key is a QUANTITY cap, enforced mechanically by
15
+ * `hooks/pre-bash-issue-budget.mjs` (shell path) and
16
+ * `scripts/lib/spiral-carryover.mjs` `runCli()` (programmatic path), both via
17
+ * `scripts/lib/issue-budget.mjs`.
18
+ *
19
+ * Returns `{ "max-per-session", mode, overflow }`.
20
+ * Tolerant parser: malformed values silently fall back to defaults (the
21
+ * `reconcile.min-rule-days` posture, NOT the noisier handover-gate WARN) —
22
+ * a hook must never spam stderr on every single Bash call.
23
+ *
24
+ * Consumers: `scripts/lib/config.mjs`, `scripts/lib/issue-budget.mjs`,
25
+ * `hooks/pre-bash-issue-budget.mjs`, `skills/session-end/SKILL.md` Phase 5.
26
+ */
27
+
28
+ /** Valid `mode` values. */
29
+ const MODES = ['strict', 'warn', 'off'];
30
+
31
+ /** Valid `overflow` values. */
32
+ const OVERFLOW_SINKS = ['collect-issue', 'vault-note'];
33
+
34
+ /**
35
+ * Parse the `issue-budget:` block from markdown content.
36
+ *
37
+ * Like every sibling block parser this scans the FULL content rather than the
38
+ * `## Session Config` fence, so the block resolves identically whether it is
39
+ * nested inside `## Session Config` (the documented home) or lifted to a
40
+ * top-level block. Bold-bullet headers (`- **issue-budget:**`) are tolerated
41
+ * via the shared `matchBlockHeader` (#830).
42
+ *
43
+ * Defaults:
44
+ * max-per-session: 12 integer >= 0; 0 means "no issue may be created"
45
+ * (a valid, deliberately harsh setting). Malformed
46
+ * or negative input falls back to 12.
47
+ * mode: strict strict | warn | off
48
+ * overflow: collect-issue collect-issue | vault-note
49
+ *
50
+ * YAML shape:
51
+ * issue-budget:
52
+ * max-per-session: 12
53
+ * mode: strict
54
+ * overflow: collect-issue
55
+ *
56
+ * @param {string} content — full file contents
57
+ * @returns {{ "max-per-session": number, mode: string, overflow: string }}
58
+ */
59
+ export function _parseIssueBudget(content) {
60
+ const defaults = {
61
+ 'max-per-session': 12,
62
+ mode: 'strict',
63
+ overflow: 'collect-issue',
64
+ };
65
+
66
+ if (typeof content !== 'string' || content === '') return { ...defaults };
67
+
68
+ const lines = content.split(/\r?\n/);
69
+ let inBlock = false;
70
+ const blockLines = [];
71
+
72
+ for (const rawLine of lines) {
73
+ const line = rawLine.replace(/\r$/, '');
74
+ if (!inBlock) {
75
+ if (matchBlockHeader(line, 'issue-budget')) inBlock = true;
76
+ continue;
77
+ }
78
+ // Stop at next column-0 non-empty line (sibling top-level key or H2 heading)
79
+ if (line.length > 0 && !/^\s/.test(line)) break;
80
+ blockLines.push(line);
81
+ }
82
+
83
+ if (blockLines.length === 0) return { ...defaults };
84
+
85
+ let maxPerSession = defaults['max-per-session'];
86
+ let mode = defaults.mode;
87
+ let overflow = defaults.overflow;
88
+
89
+ for (const rawLine of blockLines) {
90
+ // Strip inline comments and trailing whitespace
91
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
92
+ if (!clean.trim()) continue;
93
+
94
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
95
+ if (!kvMatch) continue;
96
+
97
+ const k = kvMatch[1];
98
+ let v = kvMatch[2].trim();
99
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
100
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
101
+
102
+ switch (k) {
103
+ case 'max-per-session':
104
+ // Non-negative integer only; a leading '-' fails \d+ → default.
105
+ if (/^\d+$/.test(v)) maxPerSession = parseInt(v, 10);
106
+ break;
107
+
108
+ case 'mode':
109
+ if (MODES.includes(v.toLowerCase())) mode = v.toLowerCase();
110
+ break;
111
+
112
+ case 'overflow':
113
+ if (OVERFLOW_SINKS.includes(v.toLowerCase())) overflow = v.toLowerCase();
114
+ break;
115
+ }
116
+ }
117
+
118
+ return {
119
+ 'max-per-session': maxPerSession,
120
+ mode,
121
+ overflow,
122
+ };
123
+ }
@@ -16,6 +16,7 @@ import { matchBlockHeader } from './block-header.mjs';
16
16
  * 'confidence-floor': number,
17
17
  * 'min-rule-days': number,
18
18
  * 'min-insight-chars': number,
19
+ * 'max-proposals-per-run': number,
19
20
  * }
20
21
  *
21
22
  * Tolerant parser: malformed values silently fall back to defaults.
@@ -51,6 +52,12 @@ import { matchBlockHeader } from './block-header.mjs';
51
52
  * gating the eligibility placeholder-insight check
52
53
  * (issue #741.2). Malformed, absent, or negative
53
54
  * values fall back to 24.
55
+ * reconcile.max-proposals-per-run: 10 — positive integer (min 1); volume brake (issue
56
+ * #900 D) — the reconcile engine sorts eligible
57
+ * learnings by confidence DESC and proposes at
58
+ * most this many per run, recording the cut count
59
+ * in the run summary (`summary.capped`). Malformed,
60
+ * absent, or non-positive values fall back to 10.
54
61
  *
55
62
  * YAML shape:
56
63
  * reconcile:
@@ -61,6 +68,7 @@ import { matchBlockHeader } from './block-header.mjs';
61
68
  * confidence-floor: 0.5
62
69
  * min-rule-days: 7
63
70
  * min-insight-chars: 24
71
+ * max-proposals-per-run: 10
64
72
  *
65
73
  * @param {string} content — full file contents
66
74
  * @returns {{
@@ -71,6 +79,7 @@ import { matchBlockHeader } from './block-header.mjs';
71
79
  * 'confidence-floor': number,
72
80
  * 'min-rule-days': number,
73
81
  * 'min-insight-chars': number,
82
+ * 'max-proposals-per-run': number,
74
83
  * }}
75
84
  */
76
85
  export function _parseReconcile(content) {
@@ -82,6 +91,7 @@ export function _parseReconcile(content) {
82
91
  'confidence-floor': 0.5,
83
92
  'min-rule-days': 7,
84
93
  'min-insight-chars': 24,
94
+ 'max-proposals-per-run': 10,
85
95
  };
86
96
 
87
97
  const lines = content.split(/\r?\n/);
@@ -108,6 +118,7 @@ export function _parseReconcile(content) {
108
118
  let confidenceFloor = 0.5;
109
119
  let minRuleDays = 7;
110
120
  let minInsightChars = 24;
121
+ let maxProposalsPerRun = 10;
111
122
 
112
123
  for (const rawLine of blockLines) {
113
124
  // Strip inline comments and trailing whitespace
@@ -183,6 +194,15 @@ export function _parseReconcile(content) {
183
194
  break;
184
195
  }
185
196
 
197
+ case 'max-proposals-per-run': {
198
+ if (/^\d+$/.test(v)) {
199
+ const n = parseInt(v, 10);
200
+ if (n >= 1) maxProposalsPerRun = n;
201
+ // 0 or malformed: silently ignore, keep default 10
202
+ }
203
+ break;
204
+ }
205
+
186
206
  // targets inline-list with no brackets (e.g. targets: repo-local) — single value
187
207
  case 'targets': {
188
208
  if (v && !v.startsWith('[')) {
@@ -201,5 +221,6 @@ export function _parseReconcile(content) {
201
221
  'confidence-floor': confidenceFloor,
202
222
  'min-rule-days': minRuleDays,
203
223
  'min-insight-chars': minInsightChars,
224
+ 'max-proposals-per-run': maxProposalsPerRun,
204
225
  };
205
226
  }
@@ -8,6 +8,126 @@
8
8
  // Section extraction (ported from config-yaml-parser.sh)
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
+ /**
12
+ * THE canonical predicate for "this line opens the `## Session Config` block".
13
+ *
14
+ * Exported (#959 follow-up) because this repo previously carried FIVE
15
+ * independent copies of this fact, and the loosest of them —
16
+ * `scripts/lib/claude-md-budget-lint.mjs` — was the module whose whole job is
17
+ * to MEASURE the block. That asymmetry was not academic: the lint accepted a
18
+ * heading decorated with a trailing HTML comment (`## Session Config <!-- … -->`,
19
+ * a decoration this repo's own convention encourages on sibling headings),
20
+ * while `_extractConfigSection` below rejected it. An author following the
21
+ * cited convention therefore got a CLAUDE.md (or AGENTS.md on Codex CLI —
22
+ * transparent aliases, see skills/_shared/instruction-file-resolution.md)
23
+ * whose every runtime config key
24
+ * silently fell back to its default, while the lint simultaneously reported
25
+ * "148 exempt: '## Session Config'" — the instrument affirming the block is
26
+ * present and runtime-critical at the exact moment the runtime cannot see it.
27
+ *
28
+ * The invariant this export exists to hold: **no consumer's predicate may
29
+ * accept a heading this one rejects.** Importing beats re-deriving; a copy
30
+ * that drifts loose is worse than no check at all.
31
+ *
32
+ * Intentionally EXACT (not a fuzzy regex) apart from CRLF tolerance — it is
33
+ * the literal comparison `_extractConfigSection` has always used, merely
34
+ * given a name. `## Session Config` (two spaces), a trailing HTML comment,
35
+ * and `## Session Config Convention` are all correctly rejected.
36
+ *
37
+ * Dependency note: this module imports NOTHING. Keep it that way — the budget
38
+ * lint consumes this predicate at bootstrap-scaffold time, before
39
+ * `.claude/rules/` (or most of the repo) exists.
40
+ *
41
+ * @param {string} line - a single line, with or without a trailing '\r'.
42
+ * @returns {boolean}
43
+ */
44
+ export function isSessionConfigHeading(line) {
45
+ return typeof line === 'string' && line.replace(/\r$/, '') === SESSION_CONFIG_HEADING;
46
+ }
47
+
48
+ /**
49
+ * The literal heading text, for the one site that legitimately WRITES it
50
+ * (`scripts/lib/ecosystem-wizard/config-writer.mjs` appends a fresh
51
+ * `## Session Config` section when none exists).
52
+ *
53
+ * A producer and a comparator agreeing by coincidence is the same disease
54
+ * `isSessionConfigHeading` exists to cure, one level down: a writer that
55
+ * emits a heading the reader rejects manufactures exactly the silent
56
+ * everything-falls-back-to-defaults failure described above. Writers import
57
+ * this; comparators import the predicate. Neither re-types the string.
58
+ *
59
+ * @type {string}
60
+ */
61
+ export const SESSION_CONFIG_HEADING = '## Session Config';
62
+
63
+ /**
64
+ * Locate the `## Session Config` block and return its body span.
65
+ *
66
+ * THE canonical block-extractor, and the companion to
67
+ * `isSessionConfigHeading` above. Four consumers previously re-derived this
68
+ * span with four different regexes, and the divergence was not academic:
69
+ * `harness-audit/categories/category4.mjs` used
70
+ * `/^## Session Config\s*\n([\s\S]*?)(?=^## |\s*$)/m`, whose `\s*$` lookahead
71
+ * (with `/m`, `$` matches at every line end) makes the lazy body quantifier
72
+ * stop at the FIRST line of the block. On this repo's own CLAUDE.md that
73
+ * captured 17 characters — `persistence: true` — instead of 2877, so the
74
+ * `vault-integration:` block three lines below was structurally invisible and
75
+ * the c4.4 check scored 2/2 with "vault-integration not enabled — skip" in a
76
+ * repo where it is enabled. A capture bug in a scoring instrument reads as a
77
+ * pass, never as an error.
78
+ *
79
+ * The body runs from the line AFTER the heading up to (not including) the
80
+ * next `## ` heading, or EOF. Line-based over `isSessionConfigHeading`, so
81
+ * the heading predicate cannot drift from the span extractor.
82
+ *
83
+ * Offsets are indices into the ORIGINAL `content` string, so callers that
84
+ * need to splice (insert/replace) can do so without re-finding the block.
85
+ *
86
+ * @param {string} content - full markdown document
87
+ * @param {{ occurrence?: 'first'|'last' }} [options] - which heading to match
88
+ * when a document carries several (docs/session-config-template.md carries
89
+ * two, both inside fenced examples).
90
+ * @returns {{ body: string, bodyStart: number, bodyEnd: number,
91
+ * headingLine: number } | null} null when no heading matches.
92
+ */
93
+ export function findSessionConfigBlock(content, { occurrence = 'first' } = {}) {
94
+ if (typeof content !== 'string' || content.length === 0) return null;
95
+
96
+ const lines = content.split('\n');
97
+
98
+ // Byte offset of the start of each line in `content`.
99
+ const lineStarts = [];
100
+ let offset = 0;
101
+ for (const line of lines) {
102
+ lineStarts.push(offset);
103
+ offset += line.length + 1; // +1 for the '\n' consumed by split
104
+ }
105
+
106
+ const headingIdxs = [];
107
+ for (let i = 0; i < lines.length; i++) {
108
+ if (isSessionConfigHeading(lines[i])) headingIdxs.push(i);
109
+ }
110
+ if (headingIdxs.length === 0) return null;
111
+
112
+ const targetIdx = occurrence === 'last' ? headingIdxs[headingIdxs.length - 1] : headingIdxs[0];
113
+
114
+ // Body ends at the next `## ` heading (any H2 closes the block), or EOF.
115
+ let endIdx = lines.length;
116
+ for (let i = targetIdx + 1; i < lines.length; i++) {
117
+ if (/^## /.test(lines[i].replace(/\r$/, ''))) { endIdx = i; break; }
118
+ }
119
+
120
+ const bodyStart = targetIdx + 1 < lines.length ? lineStarts[targetIdx + 1] : content.length;
121
+ const bodyEnd = endIdx < lines.length ? lineStarts[endIdx] : content.length;
122
+
123
+ return {
124
+ body: lines.slice(targetIdx + 1, endIdx).join('\n'),
125
+ bodyStart,
126
+ bodyEnd,
127
+ headingLine: targetIdx + 1, // 1-based, for error reporting
128
+ };
129
+ }
130
+
11
131
  /**
12
132
  * Extract the raw ## Session Config block lines from markdown content.
13
133
  * - CRLF-tolerant
@@ -24,7 +144,7 @@ export function _extractConfigSection(content) {
24
144
  for (const rawLine of lines) {
25
145
  const line = rawLine.replace(/\r$/, '');
26
146
 
27
- if (line === '## Session Config') {
147
+ if (isSessionConfigHeading(line)) {
28
148
  inSection = true;
29
149
  continue;
30
150
  }
@@ -9,7 +9,11 @@
9
9
  */
10
10
 
11
11
  const ENFORCEMENT_VALUES = new Set(['strict', 'warn', 'off']);
12
- const VAULT_MODE_VALUES = new Set(['strict', 'warn', 'off']);
12
+ // Exported (issue #836) so cross-layer mode-vocabulary parity tests can use
13
+ // this Set as the single source of truth for "the modes a Session Config
14
+ // `*.mode` key accepts" — rather than re-deriving a duplicate literal list
15
+ // that could silently drift from this schema.
16
+ export const VAULT_MODE_VALUES = new Set(['strict', 'warn', 'off']);
13
17
 
14
18
  const REQUIRED_STRING_FIELDS = [
15
19
  'test-command',
@@ -25,11 +29,13 @@ const REQUIRED_STRING_FIELDS = [
25
29
  */
26
30
  export function validateSessionConfig(config) {
27
31
  const errors = [];
32
+ const warnings = [];
28
33
 
29
34
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
30
35
  return {
31
36
  ok: false,
32
37
  errors: [{ path: '$', rule: 'type', message: 'config must be a JSON object' }],
38
+ warnings,
33
39
  };
34
40
  }
35
41
 
@@ -90,10 +96,24 @@ export function validateSessionConfig(config) {
90
96
  for (const msg of gpErrs) errors.push({ path: 'gitlab-portfolio', rule: 'object', message: msg });
91
97
  }
92
98
 
99
+ // HR-003 cross-field anti-pattern (baseline #60): a heavy repo left on
100
+ // isolation: auto|none accumulates worktrees without the aggressive cleanup
101
+ // HR-003 recommends — this is advisory (warn-level), it never flips `ok`.
102
+ if (config['heavy-repo'] === true) {
103
+ const isolationVal = config['isolation'];
104
+ if (isolationVal === 'auto' || isolationVal === 'none') {
105
+ warnings.push({
106
+ path: 'isolation',
107
+ rule: 'heavy-repo-cross-field',
108
+ message: `heavy-repo: true but isolation is '${isolationVal}' — heavy repos should pin isolation: worktree (HR-003)`,
109
+ });
110
+ }
111
+ }
112
+
93
113
  if (errors.length > 0) {
94
- return { ok: false, errors };
114
+ return { ok: false, errors, warnings };
95
115
  }
96
- return { ok: true, config };
116
+ return { ok: true, config, warnings };
97
117
  }
98
118
 
99
119
  function validateAgentsPerWave(value, errors) {
@@ -56,6 +56,7 @@ import { _parseColdStart } from './config/cold-start.mjs';
56
56
  import { _parseAutoDream } from './config/auto-dream.mjs';
57
57
  import { _parseStateMdLock } from './config/state-md-lock.mjs';
58
58
  import { _parseHandoverGate } from './config/handover-gate.mjs';
59
+ import { _parseIssueBudget } from './config/issue-budget.mjs';
59
60
  import { _parseBrokenWindow } from './config/broken-window.mjs';
60
61
  import { _parseSlopcheck } from './config/slopcheck.mjs';
61
62
  import { _parseDiscoveryValidator } from './config/discovery-validator.mjs';
@@ -195,6 +196,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
195
196
  const allowDestructiveOps = _coerceBoolean(kv, 'allow-destructive-ops', false);
196
197
  const resourceAwareness = _coerceBoolean(kv, 'resource-awareness', true);
197
198
  const enableHostBanner = _coerceBoolean(kv, 'enable-host-banner', true);
199
+ // heavy-repo / worktree-cleanup: HR-003 preflight fields (templates/shared/.claude/rules/heavy-repo.md),
200
+ // documented but previously unwired — silently dropped by the parser (baseline issue #60).
201
+ const heavyRepo = _coerceBoolean(kv, 'heavy-repo', false);
198
202
 
199
203
  // List fields
200
204
  const crossRepos = _coerceList(kv, 'cross-repos', undefined);
@@ -212,6 +216,10 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
212
216
  const enforcement = _coerceEnum(kv, 'enforcement', 'warn', ['strict', 'warn', 'off']);
213
217
  const isolation = _coerceEnum(kv, 'isolation', 'auto', ['worktree', 'none', 'auto']);
214
218
  const discoverySeverityThreshold = _coerceEnum(kv, 'discovery-severity-threshold', 'low', ['critical', 'high', 'medium', 'low']);
219
+ // worktree-cleanup: HR-003 (baseline issue #60). NOTE: 'aggressive' currently behaves
220
+ // identically to 'default' at runtime — the per-wave aggressive sweep is a tracked
221
+ // follow-up (see docs/session-config-reference.md). This wires the parser + value only.
222
+ const worktreeCleanup = _coerceEnum(kv, 'worktree-cleanup', 'default', ['default', 'aggressive']);
215
223
 
216
224
  // Object fields
217
225
  const agentMapping = _coerceObject(kv, 'agent-mapping');
@@ -296,6 +304,12 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
296
304
  // Handover-Alignment-Gate — Epic #724)
297
305
  const handoverGate = _parseHandoverGate(mdContent);
298
306
 
307
+ // issue-budget: per-session issue-creation cap (QUANTITY gate). Distinct from
308
+ // discovery-severity-threshold / discovery-confidence-threshold above, which
309
+ // are per-finding QUALITY filters and cannot bound creation volume. Enforced
310
+ // by hooks/pre-bash-issue-budget.mjs + scripts/lib/spiral-carryover.mjs.
311
+ const issueBudget = _parseIssueBudget(mdContent);
312
+
299
313
  // broken-window-budget: parsed from full content (#730/H5 — session-end Phase 2.6)
300
314
  const brokenWindow = _parseBrokenWindow(mdContent);
301
315
 
@@ -437,6 +451,8 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
437
451
  'allow-destructive-ops': allowDestructiveOps,
438
452
  'resource-awareness': resourceAwareness,
439
453
  'enable-host-banner': enableHostBanner,
454
+ 'heavy-repo': heavyRepo,
455
+ 'worktree-cleanup': worktreeCleanup,
440
456
  'resource-thresholds': resourceThresholds,
441
457
  'worktree-exclude': worktreeExclude,
442
458
  'vault-integration': vaultIntegration,
@@ -445,6 +461,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
445
461
  'auto-dream': autoDream,
446
462
  'state-md-lock': stateMdLock,
447
463
  'handover-gate': handoverGate,
464
+ 'issue-budget': issueBudget,
448
465
  'broken-window-budget': brokenWindow,
449
466
  'slopcheck': slopcheck,
450
467
  'skill-evolution': skillEvolution,
@@ -44,6 +44,7 @@
44
44
 
45
45
  import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
46
46
  import { join } from 'node:path';
47
+ import { pathToFileURL } from 'node:url';
47
48
 
48
49
  const DEFAULT_INTERVAL_S = 2;
49
50
  const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
@@ -138,6 +139,27 @@ function tailRead(absPath, prevOffset) {
138
139
  }
139
140
  }
140
141
 
142
+ /**
143
+ * Event types this monitor is meant to classify. Anything else is ignored even
144
+ * when it carries a wave number.
145
+ *
146
+ * The allowlist is a wave-LIFECYCLE prefix plus the agent-dispatch counters —
147
+ * i.e. exactly the records that can carry the three measurements
148
+ * `evaluateSignals` compares (`files_changed`, `test.passed`,
149
+ * `agents_dispatched`). It is deliberately fail-CLOSED: a new event type that
150
+ * gains a `wave_number` field must be added here consciously.
151
+ */
152
+ const WAVE_EVENT_PREFIX = 'orchestrator.wave.';
153
+ const WAVE_EVENT_NAMES = new Set(['agent.dispatched', 'orchestrator.agent.dispatched']);
154
+
155
+ /**
156
+ * @param {string} evType
157
+ * @returns {boolean}
158
+ */
159
+ function isWaveScopedEvent(evType) {
160
+ return evType.startsWith(WAVE_EVENT_PREFIX) || WAVE_EVENT_NAMES.has(evType);
161
+ }
162
+
141
163
  /**
142
164
  * Classify a raw events.jsonl record and update the in-memory wave summary
143
165
  * map. Returns the wave number affected, or null if the record is not a
@@ -148,11 +170,30 @@ function tailRead(absPath, prevOffset) {
148
170
  * - { event_type, wave, wave_number, files_changed, test.passed,
149
171
  * agent.dispatched, agents_dispatched }
150
172
  *
173
+ * ## Event-type gate (#966 step 1)
174
+ *
175
+ * A wave number alone is NOT sufficient to classify — the type gate runs first.
176
+ * `orchestrator.quality_gate.*` is by far the highest-volume record in
177
+ * `events.jsonl` and was ignored here only ACCIDENTALLY, because it happened to
178
+ * carry no wave number. The moment `run-quality-gate.mjs` started emitting
179
+ * `wave_number`, every gate run would have instantiated or refreshed a
180
+ * `WaveSummary`, advanced `latestWave`, and burnt the once-per-wave
181
+ * `alreadyEmitted` keys — suppressing the genuine signal when the real wave
182
+ * record arrived later. Several other high-volume types (`session.stopped`,
183
+ * `memory.propose_invoked`) already carry a wave and sat in the same trap.
184
+ *
185
+ * Note the measurement keys are read FLAT (`rec['test.passed']`) and are
186
+ * deliberately NOT reconciled with the gate event's nested `counts.passed` —
187
+ * folding one into the other would silently change what this monitor measures.
188
+ *
151
189
  * @param {Record<string, unknown>} rec
152
190
  * @param {Map<number, WaveSummary>} state
153
191
  * @returns {number | null}
154
192
  */
155
193
  function classify(rec, state) {
194
+ const evType = String(rec.event_type ?? rec.event ?? '');
195
+ if (!isWaveScopedEvent(evType)) return null;
196
+
156
197
  const waveNumber = pickInt(rec.wave_number ?? rec.wave ?? rec.waveId);
157
198
  if (waveNumber === null) return null;
158
199
  let summary = state.get(waveNumber);
@@ -170,8 +211,7 @@ function classify(rec, state) {
170
211
  const testPassed = pickInt(rec['test.passed'] ?? rec.test_passed ?? rec.testsPassed);
171
212
  if (testPassed !== null) summary.testPassed = testPassed;
172
213
  // Count one agent.dispatched event toward this wave's dispatch count.
173
- const evType = String(rec.event_type ?? rec.event ?? '');
174
- if (evType === 'agent.dispatched') {
214
+ if (WAVE_EVENT_NAMES.has(evType)) {
175
215
  summary.agentDispatchCount = (summary.agentDispatchCount ?? 0) + 1;
176
216
  } else {
177
217
  const dispatched = pickInt(rec.agents_dispatched ?? rec.agentsDispatched);
@@ -386,4 +426,10 @@ function main() {
386
426
  });
387
427
  }
388
428
 
389
- main();
429
+ // Run the tail loop only when executed as a script. Importing the module (for
430
+ // unit tests over `classify`) must not parse vitest's argv and exit 1.
431
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
432
+ main();
433
+ }
434
+
435
+ export { classify, isWaveScopedEvent };