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
@@ -0,0 +1,138 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * worktree-orphans.mjs — Parser for the top-level `worktree-orphans:` YAML block.
5
+ *
6
+ * Config block shape (see docs/session-config-template.md):
7
+ * worktree-orphans:
8
+ * enabled: false
9
+ * base-branch: main
10
+ * mode: warn
11
+ *
12
+ * Mirrors the docs-staleness.mjs parser design (issue #831 / B5), but flat —
13
+ * there is no nested threshold map, just three scalar keys.
14
+ *
15
+ * Opt-in by design: `enabled` defaults to `false`, so a repo that has never
16
+ * heard of this block never pays a single git invocation for it.
17
+ *
18
+ * ZERO IMPORTS beyond ./block-header.mjs: tests/lib/config/cycle-guard.test.mjs
19
+ * forbids any scripts/lib/config/*.mjs from importing ../config.mjs.
20
+ */
21
+
22
+ /**
23
+ * Valid `base-branch` character set — mirrors `ENTER_WORKTREE_BRANCH_RE` in
24
+ * scripts/lib/autopilot/worktree-pipeline.mjs (itself mirroring the private
25
+ * `isValidBranch()` in scripts/lib/session-id.mjs). Duplicated rather than
26
+ * imported because this parser is dependency-free by contract (see header) and
27
+ * `isValidBranch` is module-private; worktree-pipeline.mjs sets the precedent
28
+ * for mirroring it locally.
29
+ */
30
+ const BASE_BRANCH_CHARSET = /^[A-Za-z0-9._/-]+$/;
31
+
32
+ /**
33
+ * Decide whether a `base-branch` value is safe to hand to the Phase 4b sweep.
34
+ *
35
+ * The consumer (scripts/lib/session-end/worktree-orphan-sweep.mjs) interpolates
36
+ * this value into the argv token `` `${baseBranch}..${branch}` `` for
37
+ * `git rev-list --count`. Two failure modes make a charset check load-bearing:
38
+ *
39
+ * 1. OPTION-SHAPED values. `git rev-list` parses a leading-`-` token as an
40
+ * OPTION, not a revision. `--glob=refs/heads/*` makes rev-list answer about
41
+ * a completely different ref set and exit 0 with `0` — a silent WRONG
42
+ * answer, not an error, so the sweep's conservative-on-error guard never
43
+ * fires and every worktree is reported as a 0-ahead orphan.
44
+ * 2. RANGE-CORRUPTING values. A value containing `..` yields `a..b..branch`.
45
+ *
46
+ * No shell-out: `git check-ref-format --branch` is the semantic reference, but
47
+ * a config parser must stay a pure function. The charset + prefix/suffix rules
48
+ * below are a conservative SUBSET of what git accepts — a rejected value falls
49
+ * back to the `main` default rather than failing the parse, because a
50
+ * mistyped branch name must never escalate into a broken session config.
51
+ *
52
+ * @param {unknown} v
53
+ * @returns {boolean}
54
+ */
55
+ export function _isSafeBaseBranch(v) {
56
+ if (typeof v !== 'string' || v.length === 0) return false;
57
+ // Rejects whitespace and every shell-ish character (= * ; | & $ ` ' " ( ) < >),
58
+ // which also rejects `--glob=refs/heads/*` on the `=` and `*` alone.
59
+ if (!BASE_BRANCH_CHARSET.test(v)) return false;
60
+ // The charset permits `-` so that `my-branch` works; a LEADING `-` is the
61
+ // option-shaped case and must be rejected explicitly.
62
+ if (v.startsWith('-')) return false;
63
+ // Would corrupt the `<base>..<branch>` range token at the sink.
64
+ if (v.includes('..')) return false;
65
+ // git check-ref-format: no leading/trailing `/` or `.`, no `.lock` suffix.
66
+ if (v.startsWith('/') || v.endsWith('/')) return false;
67
+ if (v.startsWith('.') || v.endsWith('.')) return false;
68
+ if (v.endsWith('.lock')) return false;
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Parse the top-level `worktree-orphans:` YAML block from markdown content.
74
+ * Defaults: enabled=false, base-branch="main", mode="warn".
75
+ *
76
+ * @param {string} content — full file contents
77
+ * @returns {{enabled: boolean, 'base-branch': string, mode: string}}
78
+ */
79
+ export function _parseWorktreeOrphans(content) {
80
+ const defaults = {
81
+ enabled: false,
82
+ 'base-branch': 'main',
83
+ mode: 'warn',
84
+ };
85
+
86
+ if (typeof content !== 'string' || content === '') return defaults;
87
+
88
+ const lines = content.split(/\r?\n/);
89
+ let inBlock = false;
90
+ const blockLines = [];
91
+
92
+ for (const rawLine of lines) {
93
+ const line = rawLine.replace(/\r$/, '');
94
+ if (!inBlock) {
95
+ // #830: bold-tolerant header match — never a hand-rolled regex.
96
+ if (matchBlockHeader(line, 'worktree-orphans')) inBlock = true;
97
+ continue;
98
+ }
99
+ if (line.length > 0 && !/^\s/.test(line)) break;
100
+ blockLines.push(line);
101
+ }
102
+
103
+ if (blockLines.length === 0) return defaults;
104
+
105
+ let woEnabled = false;
106
+ let woBaseBranch = 'main';
107
+ let woMode = 'warn';
108
+
109
+ for (const rawLine of blockLines) {
110
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
111
+ if (!clean.trim()) continue;
112
+
113
+ const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
114
+ if (!kvMatch) continue;
115
+
116
+ const k = kvMatch[1];
117
+ let v = kvMatch[2].trim();
118
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
119
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
120
+
121
+ switch (k) {
122
+ case 'enabled':
123
+ woEnabled = v.toLowerCase() === 'true';
124
+ break;
125
+ case 'base-branch':
126
+ // NOT "any non-empty scalar": an option-shaped or range-corrupting
127
+ // value is silently dropped in favour of the safe `main` default.
128
+ // See _isSafeBaseBranch() for why this is a security boundary.
129
+ if (_isSafeBaseBranch(v)) woBaseBranch = v;
130
+ break;
131
+ case 'mode':
132
+ if (['warn', 'off'].includes(v)) woMode = v;
133
+ break;
134
+ }
135
+ }
136
+
137
+ return { enabled: woEnabled, 'base-branch': woBaseBranch, mode: woMode };
138
+ }
@@ -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) {
@@ -8,6 +8,9 @@
8
8
  * drift-check.mjs — _parseDriftCheck
9
9
  * docs-orchestrator.mjs — _parseDocsOrchestrator
10
10
  * vault-staleness.mjs — _parseVaultStaleness
11
+ * moc-staleness.mjs — _parseMocStaleness
12
+ * context-coverage.mjs — _parseContextCoverage
13
+ * worktree-orphans.mjs — _parseWorktreeOrphans
11
14
  * events-rotation.mjs — _parseEventsRotation
12
15
  * vault-integration.mjs — _parseVaultIntegration + _parseResourceThresholds
13
16
  *
@@ -38,6 +41,9 @@ import { _parseDriftCheck } from './config/drift-check.mjs';
38
41
  import { _parseDocsOrchestrator } from './config/docs-orchestrator.mjs';
39
42
  import { _parseVaultStaleness } from './config/vault-staleness.mjs';
40
43
  import { _parseDocsStaleness } from './config/docs-staleness.mjs';
44
+ import { _parseMocStaleness } from './config/moc-staleness.mjs';
45
+ import { _parseContextCoverage } from './config/context-coverage.mjs';
46
+ import { _parseWorktreeOrphans } from './config/worktree-orphans.mjs';
41
47
  import { _parseEventsRotation } from './config/events-rotation.mjs';
42
48
  import { _parseVaultIntegration, _parseResourceThresholds } from './config/vault-integration.mjs';
43
49
  import { _parseTest } from './config/test.mjs';
@@ -50,6 +56,7 @@ import { _parseColdStart } from './config/cold-start.mjs';
50
56
  import { _parseAutoDream } from './config/auto-dream.mjs';
51
57
  import { _parseStateMdLock } from './config/state-md-lock.mjs';
52
58
  import { _parseHandoverGate } from './config/handover-gate.mjs';
59
+ import { _parseIssueBudget } from './config/issue-budget.mjs';
53
60
  import { _parseBrokenWindow } from './config/broken-window.mjs';
54
61
  import { _parseSlopcheck } from './config/slopcheck.mjs';
55
62
  import { _parseDiscoveryValidator } from './config/discovery-validator.mjs';
@@ -189,6 +196,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
189
196
  const allowDestructiveOps = _coerceBoolean(kv, 'allow-destructive-ops', false);
190
197
  const resourceAwareness = _coerceBoolean(kv, 'resource-awareness', true);
191
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);
192
202
 
193
203
  // List fields
194
204
  const crossRepos = _coerceList(kv, 'cross-repos', undefined);
@@ -206,6 +216,10 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
206
216
  const enforcement = _coerceEnum(kv, 'enforcement', 'warn', ['strict', 'warn', 'off']);
207
217
  const isolation = _coerceEnum(kv, 'isolation', 'auto', ['worktree', 'none', 'auto']);
208
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']);
209
223
 
210
224
  // Object fields
211
225
  const agentMapping = _coerceObject(kv, 'agent-mapping');
@@ -262,6 +276,12 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
262
276
  const vaultStaleness = _parseVaultStaleness(mdContent);
263
277
  // docs-staleness: parsed from full content (standalone top-level block, #781)
264
278
  const docsStaleness = _parseDocsStaleness(mdContent);
279
+ // moc-staleness: parsed from full content (standalone top-level block, #831/B2)
280
+ const mocStaleness = _parseMocStaleness(mdContent);
281
+ // context-coverage: parsed from full content (standalone top-level block, #831/B4)
282
+ const contextCoverage = _parseContextCoverage(mdContent);
283
+ // worktree-orphans: parsed from full content (standalone top-level block, #831/B5)
284
+ const worktreeOrphans = _parseWorktreeOrphans(mdContent);
265
285
 
266
286
  // events-rotation: parsed from full content (standalone top-level block)
267
287
  const eventsRotation = _parseEventsRotation(mdContent);
@@ -284,6 +304,12 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
284
304
  // Handover-Alignment-Gate — Epic #724)
285
305
  const handoverGate = _parseHandoverGate(mdContent);
286
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
+
287
313
  // broken-window-budget: parsed from full content (#730/H5 — session-end Phase 2.6)
288
314
  const brokenWindow = _parseBrokenWindow(mdContent);
289
315
 
@@ -425,6 +451,8 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
425
451
  'allow-destructive-ops': allowDestructiveOps,
426
452
  'resource-awareness': resourceAwareness,
427
453
  'enable-host-banner': enableHostBanner,
454
+ 'heavy-repo': heavyRepo,
455
+ 'worktree-cleanup': worktreeCleanup,
428
456
  'resource-thresholds': resourceThresholds,
429
457
  'worktree-exclude': worktreeExclude,
430
458
  'vault-integration': vaultIntegration,
@@ -433,6 +461,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
433
461
  'auto-dream': autoDream,
434
462
  'state-md-lock': stateMdLock,
435
463
  'handover-gate': handoverGate,
464
+ 'issue-budget': issueBudget,
436
465
  'broken-window-budget': brokenWindow,
437
466
  'slopcheck': slopcheck,
438
467
  'skill-evolution': skillEvolution,
@@ -449,6 +478,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
449
478
  'docs-orchestrator': docsOrchestrator,
450
479
  'vault-staleness': vaultStaleness,
451
480
  'docs-staleness': docsStaleness,
481
+ 'moc-staleness': mocStaleness,
482
+ 'context-coverage': contextCoverage,
483
+ 'worktree-orphans': worktreeOrphans,
452
484
  'events-rotation': eventsRotation,
453
485
  'test': testConfig,
454
486
  'gitlab-portfolio': gitlabPortfolio,
@@ -0,0 +1,223 @@
1
+ /**
2
+ * context-coverage-banner.mjs — Session-start banner for issue #831 (building
3
+ * block B4).
4
+ *
5
+ * Surfaces a `warn` banner during session-start Phase 4 when a REGISTERED
6
+ * vault project — a `<vaultDir>/01-projects/<slug>/` directory that contains
7
+ * an `_overview.md` — has neither a `context.md` nor a `_passive.md` file. A
8
+ * manual audit found 11 such gap folders in one vault; this probe makes the
9
+ * gap mechanically visible at session-start instead of relying on manual
10
+ * sweeps.
11
+ *
12
+ * "Registered" is deliberately NOT reinvented here. `discoverVaultRepos()`
13
+ * (`scripts/lib/gitlab-portfolio/vcs-detect.mjs`) already establishes the
14
+ * exact convention this probe reuses: a `01-projects/<slug>/` directory
15
+ * without an `_overview.md` is silently skipped — it is not a project, and
16
+ * therefore it can never be a "gap".
17
+ *
18
+ * Design notes:
19
+ * - Mirrors the contract used by every other Phase 4 banner
20
+ * (`scripts/lib/vault-staleness-banner.mjs`, `scripts/lib/loop-readiness-banner.mjs`,
21
+ * `scripts/lib/reconcile-nudge-banner.mjs`): a single `checkXxx()` entry
22
+ * point that returns `null` (silent no-op) or `{ severity, message, ... }`
23
+ * — never an array, never `undefined`, never a throw.
24
+ * - Synchronous — the probe only touches `existsSync`/`readdirSync`/`statSync`,
25
+ * so unlike the async peer-cards/reconcile-nudge probes this one needs no
26
+ * `await` at the call site (mirrors `checkLoopReadiness`).
27
+ * - Never throws. Wrapped in an outermost defensive `try/catch`; every
28
+ * individually-fallible filesystem call additionally gets its own inner
29
+ * bare (no-binding) catch with a one-line explanatory comment.
30
+ * - `vault-dir` resolution mirrors the host-local-override pattern used
31
+ * throughout the plugin (issue #653): an injected `opts.vaultDir` test
32
+ * seam wins, then `config['vault-integration']['vault-dir']`, else the
33
+ * probe silently no-ops (no vault configured — nothing to check).
34
+ * - The committed repo default for `vault-integration.vault-dir` is
35
+ * tilde-prefixed (`~/Projects/vault`) and is NOT pre-expanded anywhere
36
+ * upstream of this module — `expandTilde()` from `./common.mjs` is applied
37
+ * unconditionally before the first `path.join`, exactly as
38
+ * `discoverVaultRepos()` does inline for the same reason.
39
+ *
40
+ * Cross-references:
41
+ * - `scripts/lib/gitlab-portfolio/vcs-detect.mjs` (`discoverVaultRepos`) —
42
+ * the canonical "registered" definition this probe reuses.
43
+ * - `scripts/lib/config/context-coverage.mjs` (`_parseContextCoverage`) —
44
+ * the `context-coverage:` Session Config block parser. NOT wired into
45
+ * `scripts/lib/config.mjs` by this module — the coordinator registers it
46
+ * separately. The exact lines to add there:
47
+ *
48
+ * import { _parseContextCoverage } from './config/context-coverage.mjs';
49
+ * // ... later, alongside the other top-level block parses:
50
+ * const contextCoverage = _parseContextCoverage(mdContent);
51
+ * // ... in the returned config object:
52
+ * 'context-coverage': contextCoverage,
53
+ *
54
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site (wiring
55
+ * snippet supplied separately; this module does not edit that file).
56
+ * - Issue #831 (building block B4).
57
+ */
58
+
59
+ import { existsSync, readdirSync, statSync } from 'node:fs';
60
+ import path from 'node:path';
61
+
62
+ import { expandTilde } from './common.mjs';
63
+
64
+ /** Vault-relative projects directory (mirrors discoverVaultRepos()'s own constant). */
65
+ const PROJECTS_SUBDIR = '01-projects';
66
+
67
+ /** File marking a `01-projects/<slug>/` directory as REGISTERED (discoverVaultRepos() convention). */
68
+ const OVERVIEW_FILE = '_overview.md';
69
+
70
+ /** Either file's presence satisfies "coverage" for a registered project. */
71
+ const COVERAGE_FILES = ['context.md', '_passive.md'];
72
+
73
+ /**
74
+ * Above this many gap slugs, the message truncates the name list and says so
75
+ * explicitly rather than silently dropping names past the limit.
76
+ */
77
+ const MAX_GAP_NAMES_IN_MESSAGE = 20;
78
+
79
+ /**
80
+ * Resolve the vault directory to scan.
81
+ *
82
+ * Precedence: `opts.vaultDir` (test seam) > `config['vault-integration']['vault-dir']` > null.
83
+ *
84
+ * @param {string|undefined} vaultDir
85
+ * @param {unknown} config
86
+ * @returns {string|null} raw (not-yet-tilde-expanded) vault dir, or null when unresolvable
87
+ */
88
+ function _resolveRawVaultDir(vaultDir, config) {
89
+ if (typeof vaultDir === 'string' && vaultDir.length > 0) return vaultDir;
90
+
91
+ if (config && typeof config === 'object') {
92
+ const vaultIntegration = /** @type {Record<string, unknown>} */ (config)['vault-integration'];
93
+ if (vaultIntegration && typeof vaultIntegration === 'object') {
94
+ const raw = /** @type {Record<string, unknown>} */ (vaultIntegration)['vault-dir'];
95
+ if (typeof raw === 'string' && raw.length > 0) return raw;
96
+ }
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Format the gap-slug list for the banner message, truncating (with an
104
+ * explicit note) past `MAX_GAP_NAMES_IN_MESSAGE`.
105
+ *
106
+ * @param {string[]} slugs
107
+ * @returns {string}
108
+ */
109
+ function _formatGapNames(slugs) {
110
+ if (slugs.length <= MAX_GAP_NAMES_IN_MESSAGE) return slugs.join(', ');
111
+ const shown = slugs.slice(0, MAX_GAP_NAMES_IN_MESSAGE).join(', ');
112
+ const hiddenCount = slugs.length - MAX_GAP_NAMES_IN_MESSAGE;
113
+ return `${shown}, and ${hiddenCount} more (name list truncated)`;
114
+ }
115
+
116
+ /**
117
+ * Check context-coverage and produce a session-start banner.
118
+ *
119
+ * @param {object} [opts]
120
+ * @param {string} [opts.repoRoot] — REQUIRED absolute path to the repo root.
121
+ * @param {string} [opts.vaultDir] — test seam; overrides the config-resolved vault dir.
122
+ * @param {object} [opts.config] — optional already-parsed Session Config (avoids
123
+ * a second CLAUDE.md (or AGENTS.md on Codex CLI) read; caller passes `$CONFIG`, mirrors `checkReconcileNudge`).
124
+ * Read keys: `config['context-coverage']` (`.enabled`, `.mode`) and
125
+ * `config['vault-integration']['vault-dir']`.
126
+ * @returns {null | {severity: 'warn', message: string, gaps: Array<{slug: string}>, registered: number, covered: number}}
127
+ */
128
+ export function checkContextCoverage({ repoRoot, vaultDir, config } = {}) {
129
+ try {
130
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
131
+
132
+ const cfg =
133
+ config &&
134
+ typeof config === 'object' &&
135
+ config['context-coverage'] &&
136
+ typeof config['context-coverage'] === 'object'
137
+ ? config['context-coverage']
138
+ : {};
139
+
140
+ // Config gate — returns null BEFORE any filesystem I/O. Explicit opt-in
141
+ // required: `cfg.enabled` must be the literal `true`, not merely
142
+ // truthy/absent. A config block that is entirely absent (or present
143
+ // without an `enabled` key) must fail CLOSED, not open — see issue #831
144
+ // fail-open regression (a config carrying `vault-integration.vault-dir`
145
+ // but no `context-coverage` block previously ran the probe unsolicited,
146
+ // because `undefined !== false`).
147
+ if (cfg?.enabled !== true || cfg?.mode === 'off') return null;
148
+
149
+ const rawVaultDir = _resolveRawVaultDir(vaultDir, config);
150
+ if (!rawVaultDir) return null;
151
+
152
+ const resolvedVaultDir = expandTilde(rawVaultDir);
153
+ const projectsDir = path.join(resolvedVaultDir, PROJECTS_SUBDIR);
154
+
155
+ let entries;
156
+ try {
157
+ entries = readdirSync(projectsDir);
158
+ } catch {
159
+ return null;
160
+ }
161
+ if (!Array.isArray(entries) || entries.length === 0) return null;
162
+
163
+ entries = [...entries].sort();
164
+
165
+ let registered = 0;
166
+ const gaps = [];
167
+
168
+ for (const entry of entries) {
169
+ if (typeof entry !== 'string' || entry.startsWith('.')) continue;
170
+
171
+ const entryPath = path.join(projectsDir, entry);
172
+
173
+ let stat;
174
+ try {
175
+ stat = statSync(entryPath);
176
+ } catch {
177
+ continue;
178
+ }
179
+ if (!stat || !stat.isDirectory()) continue;
180
+
181
+ // "Registered" is defined ELSEWHERE (discoverVaultRepos()) — a
182
+ // directory lacking `_overview.md` is not a project, and therefore not
183
+ // a gap. Do not invent a second definition here.
184
+ let hasOverview = false;
185
+ try {
186
+ hasOverview = existsSync(path.join(entryPath, OVERVIEW_FILE));
187
+ } catch {
188
+ // best-effort — treat an unreadable path as "no _overview.md".
189
+ }
190
+ if (!hasOverview) continue;
191
+
192
+ registered += 1;
193
+
194
+ let isCovered = false;
195
+ for (const file of COVERAGE_FILES) {
196
+ try {
197
+ if (existsSync(path.join(entryPath, file))) {
198
+ isCovered = true;
199
+ break;
200
+ }
201
+ } catch {
202
+ // best-effort — treat an unreadable path as "not covered by this file".
203
+ }
204
+ }
205
+
206
+ if (!isCovered) gaps.push({ slug: entry });
207
+ }
208
+
209
+ if (registered === 0 || gaps.length === 0) return null;
210
+
211
+ const covered = registered - gaps.length;
212
+ const gapSlugs = gaps.map((g) => g.slug);
213
+
214
+ const finding = `${gaps.length} of ${registered} registered projects lack context.md and _passive.md`;
215
+ const remediation = 'add a context.md or mark the project passive with _passive.md.';
216
+ const message = `⚠ context-coverage: ${finding} — ${_formatGapNames(gapSlugs)} — ${remediation}`;
217
+
218
+ return { severity: 'warn', message, gaps, registered, covered };
219
+ } catch {
220
+ // Defensive catch-all — banner must never throw.
221
+ return null;
222
+ }
223
+ }
@@ -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 };