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
@@ -2,8 +2,10 @@
2
2
  * enumerate.mjs — Candidate-repo enumeration + free/busy resolution.
3
3
  *
4
4
  * Epic #673 Phase 2 (issue #676, PRD §2 P2.1+P2.2, §4). Enumerates candidate
5
- * repos one level below a confinement root and resolves each as free or busy
6
- * via its per-repo `session.lock` v2 lease (heartbeat-based liveness).
5
+ * repos below a confinement root (recursive walk, fixed at a depth of
6
+ * {@link DEFAULT_MAX_DEPTH} see that constant for the measurement backing
7
+ * the value) and resolves each as free or busy via its per-repo
8
+ * `session.lock` v2 lease (heartbeat-based liveness).
7
9
  *
8
10
  * Source of truth for free/busy: the same lease semantics as
9
11
  * `scripts/lib/vault-status/board-writer.mjs` collectRows —
@@ -15,8 +17,9 @@
15
17
  * the {@link Candidate} contract defined here.
16
18
  *
17
19
  * Exports:
18
- * enumerateCandidates — scan immediate children of a startDir, resolve each
19
- * repo's free/busy status from its lease.
20
+ * enumerateCandidates — walk a startDir up to {@link DEFAULT_MAX_DEPTH}
21
+ * levels deep, resolve each repo's free/busy status
22
+ * from its lease.
20
23
  * freeCandidates — filter helper: keep only `free === true` candidates.
21
24
  *
22
25
  * No top-level side effects. All filesystem + lock access is dependency-injected
@@ -47,6 +50,53 @@ const STATUS_FREI = 'frei';
47
50
  const STATUS_IN_PROGRESS = 'in-progress';
48
51
  const STATUS_FORCE_CLOSED = 'force-closed';
49
52
 
53
+ /**
54
+ * Fixed walk depth. `1` = immediate children of the scan root only (the
55
+ * pre-#832 behaviour); `2` additionally covers `<root>/<org>/<repo>`.
56
+ *
57
+ * Measured on the reference host (~/Projects, 2026-07-19; warm dentry cache —
58
+ * a cold first walk costs roughly 10x these figures at either depth):
59
+ * depth 1 → 1 of 47 repos (2%) — the real topology is `<org>/<repo>`,
60
+ * so the scan missed 46 repos including
61
+ * session-orchestrator itself.
62
+ * depth 2 → 45 of 47 repos, ~0.9-1.9ms
63
+ * depth 3 → 47 of 47 repos, ~8ms (node_modules is pruned, but the extra
64
+ * level still multiplies the node count)
65
+ * for two additional repos, both archived.
66
+ * Depth 2 is therefore the fixed bound: it recovers 96% of the host's repos
67
+ * at negligible cost, while depth 3 costs ~8x the walk for two dead repos.
68
+ *
69
+ * This was previously exposed as a caller-tunable `maxDepth` option (clamped
70
+ * 1..3). Issue #838 removed the knob: all three production callers
71
+ * (`scripts/lib/dispatcher/cli.mjs`, `scripts/lib/lock-reaper.mjs`,
72
+ * `scripts/lib/vault-status/board-writer.mjs`) always omitted it and relied on
73
+ * this default, and no CLI surface ever forwarded a caller-supplied value —
74
+ * a config knob whose only exercised path was its own test suite. The walk
75
+ * BOUND is unchanged; only the ability to override it at the call boundary
76
+ * is gone.
77
+ */
78
+ const DEFAULT_MAX_DEPTH = 2;
79
+
80
+ /**
81
+ * Directory names never DESCENDED into during the walk.
82
+ *
83
+ * Applied to the descent decision ONLY — never to repo emission — so the
84
+ * depth-1 contract stays byte-identical to the pre-#832 scan (`.orchestrator`,
85
+ * `.claude` etc. are still probed for a `.git` marker at depth 1; they simply
86
+ * have none). `node_modules` is the dominant cost driver at depth 3 and can
87
+ * legitimately contain vendored `.git` directories that are not host repos.
88
+ *
89
+ * @param {string} name — a single path segment (Dirent.name).
90
+ * @returns {boolean} true iff the walk may recurse into this directory.
91
+ */
92
+ function shouldDescendInto(name) {
93
+ if (typeof name !== 'string' || name.length === 0) return false;
94
+ if (name === 'node_modules') return false;
95
+ // Dot-directories (.git, .claude, .orchestrator, .venv, …) hold no host repos.
96
+ if (name.startsWith('.')) return false;
97
+ return true;
98
+ }
99
+
50
100
  /**
51
101
  * Expand a leading `~` to the current user's home directory. Mirrors the helper
52
102
  * in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
@@ -126,15 +176,26 @@ function isGitRepo(childAbs, existsSyncFn) {
126
176
  * free or busy via its local lease.
127
177
  *
128
178
  * Algorithm:
129
- * 1. Scan the IMMEDIATE children (one level deep) of `startDir` that are git
130
- * repos (a child is a repo iff `<child>/.git` exists dir or file).
131
- * 2. Drop any child failing the confinement guard
132
- * (`validatePathInsideProject(childAbs, startDir)`).
179
+ * 1. Depth-first walk of `startDir`, up to {@link DEFAULT_MAX_DEPTH} levels
180
+ * deep (depth 1 = immediate children). Every directory node is a repo
181
+ * candidate iff `<node>/.git` exists (dir or file — the file form covers
182
+ * worktrees).
183
+ * 2. Confinement guard (`validatePathInsideProject(nodeAbs, startDir)`) runs
184
+ * on EVERY node BEFORE it is emitted AND before it is opened — see the
185
+ * security notes on the walk body below.
133
186
  * 3. OPTIONAL secondary source: union with `getCrossRepoProjects()`
134
187
  * config-declared paths (leading `~/` expanded, then confinement-filtered),
135
188
  * deduped by `path.resolve()`. Additive, applied AFTER the FS scan.
136
189
  * 4. Resolve free/busy per repo from its `session.lock` lease.
137
190
  *
191
+ * A git repo does NOT terminate the descent: on a measured reference host, an
192
+ * org-level directory one level under the confinement root was itself a git
193
+ * repo (a small umbrella notes repo) that CONTAINED 16 independent repos —
194
+ * this plugin's own checkout among them. "A repo's children are not separate
195
+ * repos" is empirically false there, and an early-exit-on-`.git` walk dropped
196
+ * 45 discoverable repos to 29. Pruning is therefore by NAME
197
+ * ({@link shouldDescendInto}), never by `.git` presence.
198
+ *
138
199
  * ALL repos are returned (busy ones LISTED, not dropped — downstream rank.mjs
139
200
  * filters). Returns a plain serialisable {@link Candidate}[].
140
201
  *
@@ -179,32 +240,77 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
179
240
  repoPaths.push(resolved);
180
241
  };
181
242
 
182
- // ── 1+2. FS scan of immediate children, confinement-guarded. ──
183
- let entries;
184
- try {
185
- entries = readdirSyncFn(root, { withFileTypes: true });
186
- } catch {
187
- // Unreadable/absent startDir no FS-scanned repos. The config-declared
188
- // secondary source below may still contribute.
189
- entries = [];
190
- }
243
+ // ── 1+2. Depth-capped FS walk, confinement-guarded at every node. ──
244
+ //
245
+ // SECURITY (three load-bearing invariants — do not relax without re-reading
246
+ // validatePathInsideProject at scripts/lib/path-utils.mjs):
247
+ //
248
+ // (i) Every node is validated against the ORIGINAL `root`, NEVER against
249
+ // its own parent. The guard's Phase 2 calls realpathSync, which
250
+ // resolves EVERY intermediate component — so validating a grandchild
251
+ // against the original root is both sufficient and complete at any
252
+ // depth. Re-rooting per level (`validate(grandchild, childDir)`) would
253
+ // validate a symlinked subtree against ITSELF and defeat the guard.
254
+ //
255
+ // (ii) The guard runs BEFORE `readdirSync`, not merely before emission.
256
+ // Pre-#832 the guard ran only after `isGitRepo` passed, which was safe
257
+ // because a non-repo directory was never opened. Under recursion an
258
+ // unguarded non-repo directory WOULD be opened, so an `ok:false` node
259
+ // must be refused for descent as well as for emission.
260
+ //
261
+ // (iii) The guard call is wrapped in try/catch. path-utils.mjs rethrows any
262
+ // non-ENOENT realpath error, so a single mode-000 directory under the
263
+ // scan root would otherwise throw straight out of enumerateCandidates
264
+ // — and runDispatch (scripts/lib/dispatcher/cli.mjs) has no try/catch
265
+ // around this call. The walk now validates ~52 nodes instead of 1, so
266
+ // a throwing guard is treated as "skip this node".
267
+ //
268
+ // Unbounded recursion is impossible: `Dirent.isDirectory()` is false for a
269
+ // symlink-to-directory (verified empirically), so symlink cycles never enter
270
+ // the walk — and `DEFAULT_MAX_DEPTH` bounds it regardless, including for
271
+ // stubbed entries that do not implement isDirectory().
272
+ const walk = (dirAbs, depth) => {
273
+ let entries;
274
+ try {
275
+ entries = readdirSyncFn(dirAbs, { withFileTypes: true });
276
+ } catch {
277
+ // Unreadable/absent directory → this subtree contributes nothing.
278
+ // Siblings and the config-declared secondary source are unaffected.
279
+ return;
280
+ }
191
281
 
192
- for (const entry of entries) {
193
- // Only directories can be repos. Dirent.isDirectory() guards against files,
194
- // sockets, etc. A stubbed entry may not implement isDirectory — fall back
195
- // to treating it as a directory candidate (existsSync('.git') gates anyway).
196
- const isDir = typeof entry?.isDirectory === 'function' ? entry.isDirectory() : true;
197
- if (!isDir) continue;
282
+ for (const entry of entries) {
283
+ // Only directories can be repos. Dirent.isDirectory() guards against
284
+ // files, sockets, etc. A stubbed entry may not implement isDirectory —
285
+ // fall back to treating it as a directory candidate (the depth cap and
286
+ // existsSync('.git') gate the consequences).
287
+ const isDir = typeof entry?.isDirectory === 'function' ? entry.isDirectory() : true;
288
+ if (!isDir) continue;
198
289
 
199
- const childAbs = path.join(root, entry.name);
200
- if (!isGitRepo(childAbs, existsSyncFn)) continue;
290
+ const childAbs = path.join(dirAbs, entry.name);
201
291
 
202
- // Confinement guard: drop anything not strictly inside startDir.
203
- const guard = validatePathInsideProjectFn(childAbs, root);
204
- if (!guard || guard.ok !== true) continue;
292
+ // Confinement guard invariants (i)+(ii)+(iii) above.
293
+ let guard;
294
+ try {
295
+ guard = validatePathInsideProjectFn(childAbs, root);
296
+ } catch {
297
+ continue;
298
+ }
299
+ if (!guard || guard.ok !== true) continue;
205
300
 
206
- addRepo(childAbs);
207
- }
301
+ if (isGitRepo(childAbs, existsSyncFn)) addRepo(childAbs);
302
+
303
+ // Descent is INDEPENDENT of repo-ness: a repo may contain further repos
304
+ // (the umbrella-repo case documented above). Prune by name only, and only
305
+ // for the descent decision — emission above is untouched, which keeps the
306
+ // depth-1 contract byte-identical to the pre-#832 scan.
307
+ if (depth < DEFAULT_MAX_DEPTH && shouldDescendInto(entry.name)) {
308
+ walk(childAbs, depth + 1);
309
+ }
310
+ }
311
+ };
312
+
313
+ walk(root, 1);
208
314
 
209
315
  // ── 3. Optional secondary source: config-declared cross-repo projects. ──
210
316
  let declared;
@@ -35,6 +35,7 @@ import path from 'node:path';
35
35
  import { scanBacklog } from '../backlog-scan.mjs';
36
36
  import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
37
37
  import { probe as realProbe, evaluate as realEvaluate } from '../resource-probe.mjs';
38
+ import { isRealSession } from '../session-schema/filters.mjs';
38
39
 
39
40
  /** Staleness cap (days). Beyond this, additional age does not raise the score. */
40
41
  export const STALENESS_CAP_DAYS = 90;
@@ -156,13 +157,21 @@ async function defaultFetchPriority(repoRoot, nowMs) {
156
157
 
157
158
  /**
158
159
  * Default STALENESS source: read `<repoRoot>/.orchestrator/metrics/sessions.jsonl`,
159
- * take the LAST record, and compute days since `completed_at` (fallback
160
- * `started_at`). No file / no parsable record / no timestamp ⇒
161
- * `STALENESS_CAP_DAYS` (treat as maximally stale = most worthwhile).
160
+ * find the last REAL (non-phantom) record scanning backward from the tail, and
161
+ * compute days since `completed_at` (fallback `started_at`). No file / no
162
+ * parsable REAL record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as
163
+ * maximally stale = most worthwhile).
164
+ *
165
+ * Scans backward PAST any trailing `status: 'abandoned'` phantom stubs (#834)
166
+ * — session-close-backfill writes these for sessions that ended without a real
167
+ * close (0 waves, seconds of runtime). Stopping at the raw last LINE would let
168
+ * a single recent phantom make a genuinely neglected repo look freshly
169
+ * touched, defeating the dispatcher's whole purpose (this is the N=1 extreme
170
+ * case of the phantom-tail problem — one stub is enough to zero out staleness).
162
171
  *
163
172
  * @param {string} repoRoot
164
173
  * @param {number} nowMs
165
- * @returns {Promise<number>} days since last session (≥ 0)
174
+ * @returns {Promise<number>} days since last REAL session (≥ 0)
166
175
  */
167
176
  async function defaultStaleDaysFor(repoRoot, nowMs) {
168
177
  try {
@@ -171,14 +180,19 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
171
180
  const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
172
181
  if (lines.length === 0) return STALENESS_CAP_DAYS;
173
182
 
174
- // Last non-empty line = most recent session record.
183
+ // Scan backward for the last REAL (non-abandoned) session record, skipping
184
+ // both corrupt lines and phantom stubs.
175
185
  let last = null;
176
186
  for (let i = lines.length - 1; i >= 0; i -= 1) {
187
+ let parsed;
177
188
  try {
178
- last = JSON.parse(lines[i]);
179
- break;
189
+ parsed = JSON.parse(lines[i]);
180
190
  } catch {
181
- // Skip a corrupt trailing line and try the previous one.
191
+ continue; // Skip a corrupt line and try the previous one.
192
+ }
193
+ if (isRealSession(parsed)) {
194
+ last = parsed;
195
+ break;
182
196
  }
183
197
  }
184
198
  if (!last || typeof last !== 'object') return STALENESS_CAP_DAYS;
@@ -5,6 +5,10 @@
5
5
 
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import {
9
+ findSessionConfigBlock,
10
+ SESSION_CONFIG_HEADING,
11
+ } from '../config/section-extractor.mjs';
8
12
 
9
13
  // ---------------------------------------------------------------------------
10
14
  // Validation
@@ -75,18 +79,14 @@ export function validateEcosystemPolicy(policy) {
75
79
  // ---------------------------------------------------------------------------
76
80
 
77
81
  /**
78
- * Returns whether the Session Config section in `text` already contains an
82
+ * Returns whether the Session Config section already contains an
79
83
  * ecosystem-health key.
80
- * @param {string} text
81
- * @param {RegExpMatchArray|null} startMatch
84
+ * @param {ReturnType<typeof findSessionConfigBlock>} block
82
85
  * @returns {boolean}
83
86
  */
84
- function _ecosystemBlockPresent(text, startMatch) {
85
- if (!startMatch || startMatch.index === undefined) return false;
86
- const afterBlock = text.slice(startMatch.index + startMatch[0].length);
87
- const nextHeadingMatch = afterBlock.match(/^## /m);
88
- const blockContent = nextHeadingMatch ? afterBlock.slice(0, nextHeadingMatch.index) : afterBlock;
89
- return /^\s*ecosystem-health\s*:/m.test(blockContent);
87
+ function _ecosystemBlockPresent(block) {
88
+ if (!block) return false;
89
+ return /^\s*ecosystem-health\s*:/m.test(block.body);
90
90
  }
91
91
 
92
92
  /**
@@ -104,23 +104,21 @@ function _replaceEcosystemBlock(text, snippetLines) {
104
104
  * Inserts a new ecosystem-health snippet into `text` within the Session Config
105
105
  * section, or appends a new section when none exists.
106
106
  * @param {string} text
107
- * @param {RegExpMatchArray|null} startMatch
107
+ * @param {ReturnType<typeof findSessionConfigBlock>} block
108
108
  * @param {string[]} snippetLines
109
109
  * @returns {string}
110
110
  */
111
- function _insertEcosystemBlock(text, startMatch, snippetLines) {
111
+ function _insertEcosystemBlock(text, block, snippetLines) {
112
112
  const snippet = '\n' + snippetLines.join('\n');
113
- if (!startMatch || startMatch.index === undefined) {
114
- return text + '\n## Session Config\n' + snippet + '\n';
115
- }
116
- const afterStartIdx = startMatch.index + startMatch[0].length;
117
- const afterBlock = text.slice(afterStartIdx);
118
- const nextHeadingMatch = afterBlock.match(/^## /m);
119
- if (nextHeadingMatch && nextHeadingMatch.index !== undefined) {
120
- const insertAt = afterStartIdx + nextHeadingMatch.index;
121
- return text.slice(0, insertAt) + snippet + '\n' + text.slice(insertAt);
113
+ if (!block) {
114
+ // The one site in this repo that WRITES the heading. It takes the literal
115
+ // from the SSOT so a producer/comparator drift cannot open the same
116
+ // silent-fallback hole the predicate exists to close (#968).
117
+ return text + '\n' + SESSION_CONFIG_HEADING + '\n' + snippet + '\n';
122
118
  }
123
- return text + snippet + '\n';
119
+ // `bodyEnd` is the offset of the next `## ` heading, or EOF — i.e. exactly
120
+ // the end of the Session Config section, which is where the snippet goes.
121
+ return text.slice(0, block.bodyEnd) + snippet + '\n' + text.slice(block.bodyEnd);
124
122
  }
125
123
 
126
124
  /**
@@ -211,10 +209,14 @@ export function writeSessionConfigBlock(configFilePath, config, dryRun, overwrit
211
209
  return 'error';
212
210
  }
213
211
 
214
- const startMatch = text.match(/^## Session Config[ \t]*(?:\r?\n|$)/m);
212
+ // Heading + body span from the SSOT (#968) — the previous local regex
213
+ // `/^## Session Config[ \t]*(?:\r?\n|$)/m` accepted a trailing-whitespace
214
+ // heading the runtime parser rejects, so the wizard would write a well-formed
215
+ // ecosystem-health block into a section `parseSessionConfig` never reads.
216
+ const block = findSessionConfigBlock(text);
215
217
  const snippetLines = _buildEcosystemSnippetLines(config);
216
218
 
217
- if (_ecosystemBlockPresent(text, startMatch)) {
219
+ if (_ecosystemBlockPresent(block)) {
218
220
  if (!overwrite) return 'skipped';
219
221
  // Replace the existing ecosystem-health block with the new snippet.
220
222
  // The block spans from "ecosystem-health:" to the next top-level key or heading.
@@ -230,7 +232,7 @@ export function writeSessionConfigBlock(configFilePath, config, dryRun, overwrit
230
232
  }
231
233
 
232
234
  // Insert path: append ecosystem-health block to Session Config section
233
- const newText = _insertEcosystemBlock(text, startMatch, snippetLines);
235
+ const newText = _insertEcosystemBlock(text, block, snippetLines);
234
236
  if (!dryRun) {
235
237
  try { writeFileSync(configFilePath, newText, 'utf8'); } catch { return 'error'; }
236
238
  }
@@ -72,7 +72,7 @@ async function _promptInteractiveAnswers(existingConfig, ciProvider, packageMana
72
72
  : `CI pipeline identifiers (format "id" or "id:label", comma-separated, blank to skip):\n> `;
73
73
  const labelsPrompt = currentLabels
74
74
  ? `Critical issue labels [current: ${currentLabels}]:\n> `
75
- : `Critical issue labels (comma-separated, e.g. "priority:critical,severity:blocker", blank to skip):\n> `;
75
+ : `Critical issue labels (comma-separated, e.g. "priority::critical,severity:blocker", blank to skip):\n> `;
76
76
 
77
77
  const rl = createInterface({ input: process.stdin, output: process.stdout });
78
78
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
@@ -34,6 +34,7 @@ import { readFileSync, existsSync } from 'node:fs';
34
34
  import os from 'node:os';
35
35
  import path from 'node:path';
36
36
 
37
+ import { resolvePluginRoot } from '../common.mjs';
37
38
  import { readJsonlFile } from '../io.mjs';
38
39
  import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
39
40
  import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
@@ -41,8 +42,33 @@ import { resolveSession, computeWindow, findPeerOverlap } from './session-resolv
41
42
  /** The rubric version this engine scores against. */
42
43
  export const RUBRIC_VERSION = 'rubric-v1';
43
44
 
44
- /** Default rubric location (created in W3; absent until then ⇒ rubric_sha256 null). */
45
- export const DEFAULT_RUBRIC_PATH = 'skills/eval/rubric-v1.md';
45
+ /** Rubric location relative to the plugin root. */
46
+ export const RUBRIC_RELATIVE_PATH = 'skills/eval/rubric-v1.md';
47
+
48
+ /**
49
+ * Default rubric location, resolved against the PLUGIN root rather than the
50
+ * caller's cwd (#927).
51
+ *
52
+ * The previous cwd-relative literal only ever resolved when `/eval` happened to
53
+ * run from the plugin checkout itself. In a consumer repo — the normal install
54
+ * shape — it pointed at a non-existent `<consumer>/skills/eval/rubric-v1.md`,
55
+ * `computeRubricHash` returned null, and `validateEvalRecord` then rejected the
56
+ * record on `provenance.rubric_sha256` (schema.mjs), so the run produced no
57
+ * output at all.
58
+ *
59
+ * Resolution is best-effort by design: `resolvePluginRoot` THROWS when it cannot
60
+ * locate a plugin root, and this constant is evaluated at module load. A throw
61
+ * here would make the module unimportable, so we degrade to the relative literal
62
+ * — preserving the previous behaviour instead of turning a degraded path into a
63
+ * hard import failure.
64
+ */
65
+ export const DEFAULT_RUBRIC_PATH = (() => {
66
+ try {
67
+ return path.join(resolvePluginRoot(import.meta.url), RUBRIC_RELATIVE_PATH);
68
+ } catch {
69
+ return RUBRIC_RELATIVE_PATH;
70
+ }
71
+ })();
46
72
 
47
73
  /** Ordered rubric-v1 dimension ids — the canonical scoring order. */
48
74
  export const RUBRIC_DIMENSION_IDS = Object.freeze([
@@ -372,13 +398,29 @@ function extractKpis(record) {
372
398
  // Provenance / harness / model helpers
373
399
  // ---------------------------------------------------------------------------
374
400
 
375
- /** sha256 hex of the rubric file, or null when the file does not exist yet. */
401
+ /**
402
+ * sha256 hex of the rubric file, or null when it cannot be read.
403
+ *
404
+ * A null return is NOT benign: `validateEvalRecord` requires a non-empty
405
+ * `provenance.rubric_sha256`, so a miss here aborts the whole append downstream.
406
+ * The downstream WARNs name the validation failure but never the path that was
407
+ * tried, which is the one fact needed to diagnose it — so name it here (#927).
408
+ */
376
409
  function computeRubricHash(rubricPath) {
377
410
  try {
378
- if (!rubricPath || !existsSync(rubricPath)) return null;
411
+ if (!rubricPath || !existsSync(rubricPath)) {
412
+ process.stderr.write(
413
+ `[eval-engine] WARN: rubric not found at '${rubricPath ?? '<unset>'}' — ` +
414
+ 'provenance.rubric_sha256 will be null and the record will fail validation.\n',
415
+ );
416
+ return null;
417
+ }
379
418
  const buf = readFileSync(rubricPath);
380
419
  return createHash('sha256').update(buf).digest('hex');
381
- } catch {
420
+ } catch (err) {
421
+ process.stderr.write(
422
+ `[eval-engine] WARN: could not hash rubric at '${rubricPath}': ${err?.message ?? String(err)}\n`,
423
+ );
382
424
  return null;
383
425
  }
384
426
  }
@@ -12,17 +12,61 @@
12
12
  import { promises as fs } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { SO_PROJECT_DIR, SO_SHARED_DIR } from './platform.mjs';
15
+ import { readLock } from './session-lock.mjs';
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Public API
18
19
  // ---------------------------------------------------------------------------
19
20
 
20
21
  /**
21
- * Returns the absolute path to .orchestrator/metrics/events.jsonl in the project root.
22
+ * Returns the absolute path to `.orchestrator/metrics/events.jsonl` under `repoRoot`.
23
+ *
24
+ * `repoRoot` defaults to the module-level `SO_PROJECT_DIR` constant, so the
25
+ * zero-arg call is unchanged for every existing caller (#941). Pass an explicit
26
+ * `repoRoot` when the destination must be pinned to a tree other than the
27
+ * CWD/env-resolved project — e.g. a unit test running the gate against a tmp
28
+ * repo, which must NOT append synthetic records to the real fleet telemetry.
29
+ *
30
+ * @param {string} [repoRoot=SO_PROJECT_DIR] — project root the events log lives under.
22
31
  * @returns {string}
23
32
  */
24
- export function eventsFilePath() {
25
- return path.join(SO_PROJECT_DIR, SO_SHARED_DIR, 'metrics', 'events.jsonl');
33
+ export function eventsFilePath(repoRoot = SO_PROJECT_DIR) {
34
+ return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
35
+ }
36
+
37
+ /**
38
+ * Session attribution for gate/lifecycle telemetry (#928a, hoisted here #941).
39
+ *
40
+ * The natural shared home the two former call-site copies (`quality-gate.mjs`,
41
+ * `run-quality-gate.mjs`) both named. Emits BOTH the UUID `session_id` and the
42
+ * `semantic_session_id`, mirroring the field shape of
43
+ * `orchestrator.session.lock.acquired` so gate events join against the same keys
44
+ * existing consumers already read (see `session-close-backfill.mjs`).
45
+ *
46
+ * Without a lock (CI runs have none) BOTH keys are OMITTED rather than filled
47
+ * with a placeholder: a fabricated id would silently collide across every
48
+ * unattributed run and read as a real session; an empty string would satisfy a
49
+ * truthiness check while attributing to nothing. An absent key is the only
50
+ * honest encoding of "not attributable".
51
+ *
52
+ * @param {string} [repoRoot] — repo whose `session.lock` is read for attribution.
53
+ * @returns {{session_id?: string, semantic_session_id?: string}}
54
+ */
55
+ export function sessionAttribution(repoRoot) {
56
+ try {
57
+ const lock = readLock({ repoRoot });
58
+ if (!lock) return {};
59
+ const out = {};
60
+ if (typeof lock.session_id === 'string' && lock.session_id.trim()) {
61
+ out.session_id = lock.session_id;
62
+ }
63
+ if (typeof lock.semantic_session_id === 'string' && lock.semantic_session_id.trim()) {
64
+ out.semantic_session_id = lock.semantic_session_id;
65
+ }
66
+ return out;
67
+ } catch {
68
+ return {};
69
+ }
26
70
  }
27
71
 
28
72
  /**
@@ -43,6 +87,11 @@ export function eventsFilePath() {
43
87
  * Used by `scripts/emit-event.mjs --file` so shell callers (e.g.
44
88
  * compute-grounding-injection.sh) can target a pre-resolved EVENTS_JSONL path
45
89
  * without depending on platform.mjs CWD/env resolution (#611).
90
+ * @param {string} [opts.repoRoot] — pin the destination to `<repoRoot>/.orchestrator/
91
+ * metrics/events.jsonl` instead of the module-level `SO_PROJECT_DIR` default
92
+ * (#941). Ignored when `opts.filePath` is given (explicit path wins). This is
93
+ * the clean interface replacing the hand-built `join(repoRoot, …)` recipes that
94
+ * used to open-code this destination at each call-site.
46
95
  * @returns {Promise<void>}
47
96
  */
48
97
  export async function emitEvent(type, payload = {}, opts = {}) {
@@ -50,10 +99,13 @@ export async function emitEvent(type, payload = {}, opts = {}) {
50
99
  const record = { timestamp: new Date().toISOString(), event: type, ...payload };
51
100
  const line = JSON.stringify(record) + '\n';
52
101
 
53
- // Ensure the destination directory exists before appending. The default
54
- // resolution is unchanged for 2-arg callersonly an explicit opts.filePath
55
- // overrides it (#611, additive).
56
- const filePath = opts.filePath ?? eventsFilePath();
102
+ // Ensure the destination directory exists before appending. Resolution order:
103
+ // 1. explicit opts.filePath (a pre-resolved path#611)
104
+ // 2. opts.repoRoot <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
105
+ // 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
106
+ // eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default param,
107
+ // so a caller passing neither behaves EXACTLY as before (additive).
108
+ const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
57
109
  await fs.mkdir(path.dirname(filePath), { recursive: true });
58
110
  await fs.appendFile(filePath, line, 'utf8');
59
111
 
@@ -223,6 +223,11 @@ function readinessConfidence(autopilotSummary, judgmentSummary, score) {
223
223
  /**
224
224
  * Summarize autopilot run history plus type-8 mode effectiveness rollups.
225
225
  *
226
+ * Abandoned-session filtering (#834): `sessions` is passed straight through
227
+ * to `groupByMode()`, which filters phantom `status: 'abandoned'` stubs
228
+ * before bucketing — this function inherits that guarantee transitively and
229
+ * does not duplicate the filter. See `autopilot-effectiveness.mjs` `groupByMode()`.
230
+ *
226
231
  * @param {Array} autopilotRuns
227
232
  * @param {Array} sessions
228
233
  * @returns {object}