session-orchestrator 3.16.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. 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+)/);
@@ -0,0 +1,82 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * context-coverage.mjs — Parser for the top-level `context-coverage:` YAML block.
5
+ *
6
+ * Config block shape (see docs/session-config-template.md):
7
+ * context-coverage:
8
+ * enabled: false
9
+ * mode: warn
10
+ *
11
+ * Modelled on `./docs-staleness.mjs`'s parser design (issue #781, Epic #774) —
12
+ * the block is scoped from the raw file content, independent of the
13
+ * `## Session Config` section boundary, so a baseline using either the plain
14
+ * `key:` header or the bold-bullet markdown rendering
15
+ * (`- **context-coverage:**`, tolerated via `matchBlockHeader()`) parses
16
+ * identically.
17
+ *
18
+ * ZERO imports other than `./block-header.mjs` — `tests/lib/config/cycle-guard.test.mjs`
19
+ * forbids importing `../config.mjs` from this directory; this module keeps a
20
+ * clean leaf with no other dependencies at all.
21
+ *
22
+ * Not registered in `scripts/lib/config.mjs` here — the coordinator wires
23
+ * `_parseContextCoverage` into the orchestrator's config-object assembly
24
+ * separately (see `context-coverage-banner.mjs`'s header for the exact
25
+ * import + call lines to add there).
26
+ *
27
+ * Shipped default: `{ enabled: false, mode: 'warn' }` — opt-in (issue #831).
28
+ */
29
+
30
+ /**
31
+ * Parse the top-level `context-coverage:` YAML block from markdown content.
32
+ * Defaults: enabled=false, mode="warn". Malformed values fall back per key.
33
+ *
34
+ * @param {string} content — full file contents
35
+ * @returns {{enabled: boolean, mode: string}}
36
+ */
37
+ export function _parseContextCoverage(content) {
38
+ const defaults = { enabled: false, mode: 'warn' };
39
+
40
+ const lines = content.split(/\r?\n/);
41
+ let inBlock = false;
42
+ const blockLines = [];
43
+
44
+ for (const rawLine of lines) {
45
+ const line = rawLine.replace(/\r$/, '');
46
+ if (!inBlock) {
47
+ if (matchBlockHeader(line, 'context-coverage')) inBlock = true;
48
+ continue;
49
+ }
50
+ if (line.length > 0 && !/^\s/.test(line)) break;
51
+ blockLines.push(line);
52
+ }
53
+
54
+ if (blockLines.length === 0) return defaults;
55
+
56
+ let ccEnabled = false;
57
+ let ccMode = 'warn';
58
+
59
+ for (const rawLine of blockLines) {
60
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
61
+ if (!clean.trim()) continue;
62
+
63
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
64
+ if (!kvMatch) continue;
65
+
66
+ const k = kvMatch[1];
67
+ let v = kvMatch[2].trim();
68
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
69
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
70
+
71
+ switch (k) {
72
+ case 'enabled':
73
+ ccEnabled = v.toLowerCase() === 'true';
74
+ break;
75
+ case 'mode':
76
+ if (['strict', 'warn', 'off'].includes(v)) ccMode = v;
77
+ break;
78
+ }
79
+ }
80
+
81
+ return { enabled: ccEnabled, mode: ccMode };
82
+ }
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * moc-staleness.mjs — Parser for the top-level `moc-staleness:` YAML block.
5
+ *
6
+ * Config block shape (see docs/session-config-template.md):
7
+ * moc-staleness:
8
+ * enabled: false
9
+ * thresholds:
10
+ * moc: 90
11
+ * mode: warn
12
+ *
13
+ * Mirrors the docs-staleness.mjs parser design (issue #831) — a single
14
+ * "moc" tier threshold (days), not a per-tier map like vault-staleness.mjs.
15
+ *
16
+ * ZERO IMPORTS other than ./block-header.mjs by design:
17
+ * tests/lib/config/cycle-guard.test.mjs forbids any scripts/lib/config/*.mjs
18
+ * from importing ../config.mjs.
19
+ */
20
+
21
+ /**
22
+ * Parse the top-level `moc-staleness:` YAML block from markdown content.
23
+ * Defaults: enabled=false, thresholds={moc:90}, mode="warn".
24
+ * @param {string} content — full file contents
25
+ * @returns {{enabled: boolean, thresholds: {moc: number}, mode: string}}
26
+ */
27
+ export function _parseMocStaleness(content) {
28
+ const defaults = {
29
+ enabled: false,
30
+ thresholds: { moc: 90 },
31
+ mode: 'warn',
32
+ };
33
+
34
+ const lines = content.split(/\r?\n/);
35
+ let inBlock = false;
36
+ const blockLines = [];
37
+
38
+ for (const rawLine of lines) {
39
+ const line = rawLine.replace(/\r$/, '');
40
+ if (!inBlock) {
41
+ if (matchBlockHeader(line, 'moc-staleness')) inBlock = true;
42
+ continue;
43
+ }
44
+ if (line.length > 0 && !/^\s/.test(line)) break;
45
+ blockLines.push(line);
46
+ }
47
+
48
+ if (blockLines.length === 0) return defaults;
49
+
50
+ let msEnabled = false;
51
+ let msMode = 'warn';
52
+ const msThresholds = { moc: 90 };
53
+ let inThresholdsBlock = false;
54
+
55
+ for (const rawLine of blockLines) {
56
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
57
+ if (!clean.trim()) continue;
58
+
59
+ // Deeper indented key (thresholds sub-keys)
60
+ const deepMatch = clean.match(/^\s{4,}([a-zA-Z_-]+):\s*(.*)/);
61
+ if (deepMatch && inThresholdsBlock) {
62
+ const k = deepMatch[1];
63
+ let v = deepMatch[2].trim();
64
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
65
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
66
+ if (k === 'moc') {
67
+ const n = parseFloat(v);
68
+ if (Number.isFinite(n) && n > 0) msThresholds[k] = n;
69
+ }
70
+ continue;
71
+ }
72
+
73
+ // Top-level key under moc-staleness (2-space indent)
74
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
75
+ if (!kvMatch) continue;
76
+
77
+ inThresholdsBlock = false;
78
+
79
+ const k = kvMatch[1];
80
+ let v = kvMatch[2].trim();
81
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
82
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
83
+
84
+ switch (k) {
85
+ case 'enabled':
86
+ msEnabled = v.toLowerCase() === 'true';
87
+ break;
88
+ case 'mode':
89
+ if (['strict', 'warn', 'off'].includes(v)) msMode = v;
90
+ break;
91
+ case 'thresholds':
92
+ inThresholdsBlock = true;
93
+ break;
94
+ }
95
+ }
96
+
97
+ return { enabled: msEnabled, thresholds: msThresholds, mode: msMode };
98
+ }
@@ -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
  }