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
@@ -26,6 +26,8 @@
26
26
 
27
27
  import { randomUUID } from 'node:crypto';
28
28
 
29
+ import { filterRealSessions } from '../session-schema.mjs';
30
+
29
31
  // ---------------------------------------------------------------------------
30
32
  // Constants
31
33
  // ---------------------------------------------------------------------------
@@ -69,18 +71,40 @@ function isoPlusDays(nowIso, days) {
69
71
  }
70
72
  }
71
73
 
74
+ /**
75
+ * Return the `effectiveness` sub-object of a session record, or an empty object
76
+ * when absent/malformed. Session records NEST their effectiveness metrics under
77
+ * this key — see `skills/session-end/metrics-collection.md` (the writer) and
78
+ * `scripts/lib/eval/session-resolve.mjs` (a sibling reader).
79
+ *
80
+ * @param {object} s
81
+ * @returns {object} the nested block, or `{}` when there is none
82
+ */
83
+ function effectivenessOf(s) {
84
+ const e = s?.effectiveness;
85
+ return e && typeof e === 'object' && !Array.isArray(e) ? e : {};
86
+ }
87
+
72
88
  /**
73
89
  * Extract a completion ratio from a session record. Sessions encode this in a
74
90
  * few historically-evolved shapes; we tolerate all of them and fall back to
75
91
  * `null` (excluded from the average) when nothing usable is present.
76
92
  *
93
+ * Shape precedence (#835): the NESTED `effectiveness.completion_rate` is the
94
+ * shape the session-end writer actually emits and is therefore read FIRST. The
95
+ * top-level read is retained as a fallback for legacy/hand-written records —
96
+ * dropping it would silently zero out any record predating the nesting.
97
+ *
77
98
  * @param {object} s
78
99
  * @returns {number|null} ratio in [0, 1], or null when unknown
79
100
  */
80
101
  function completionOf(s) {
81
102
  if (!s || typeof s !== 'object') return null;
82
- // Direct ratio fields
83
- const direct = num(s.completion_rate ?? s.completion_ratio);
103
+ const eff = effectivenessOf(s);
104
+ // Direct ratio fields — nested (canonical) first, then legacy top-level.
105
+ const direct = num(
106
+ eff.completion_rate ?? eff.completion_ratio ?? s.completion_rate ?? s.completion_ratio,
107
+ );
84
108
  if (direct !== null && direct >= 0 && direct <= 1) return direct;
85
109
  // Planned vs completed counts
86
110
  const planned = num(s.planned_count ?? s.tasks_planned);
@@ -93,15 +117,18 @@ function completionOf(s) {
93
117
 
94
118
  /**
95
119
  * Extract a carryover ratio from a session record. Carryover = work not closed
96
- * within the session that flowed to a follow-up. Same tolerance pattern as
97
- * `completionOf`.
120
+ * within the session that flowed to a follow-up. Same tolerance pattern
121
+ * and the same nested-first precedence (#835) — as `completionOf`.
98
122
  *
99
123
  * @param {object} s
100
124
  * @returns {number|null} ratio in [0, 1], or null when unknown
101
125
  */
102
126
  function carryoverOf(s) {
103
127
  if (!s || typeof s !== 'object') return null;
104
- const direct = num(s.carryover_ratio ?? s.carryover_rate);
128
+ const eff = effectivenessOf(s);
129
+ const direct = num(
130
+ eff.carryover_ratio ?? eff.carryover_rate ?? s.carryover_ratio ?? s.carryover_rate,
131
+ );
105
132
  if (direct !== null && direct >= 0 && direct <= 1) return direct;
106
133
  const planned = num(s.planned_count ?? s.tasks_planned);
107
134
  const carried = num(s.carryover_count ?? s.tasks_carried_over);
@@ -132,6 +159,18 @@ function mean(values) {
132
159
  * whose autopilot_run_id matches a known run). For the skeleton it is used
133
160
  * only as a non-emptiness signal.
134
161
  *
162
+ * Abandoned-session filtering (#834): `sessions` is filtered to REAL (non-
163
+ * phantom) records via `filterRealSessions()` before bucketing. Phantom
164
+ * `status: 'abandoned'` stubs are legitimate DATA but not legitimate SIGNAL —
165
+ * they must not inflate `n_manual`/`n_autopilot` or dilute the
166
+ * completion/carryover averages. See `scripts/lib/session-schema/filters.mjs`.
167
+ *
168
+ * Mode resolution (#834): `mode` is a LEGACY alias for the canonical
169
+ * `session_type` field (`session-schema/constants.mjs` `SESSION_KEY_ALIASES`).
170
+ * `session_type` is read first; `mode` is a fallback for legacy records that
171
+ * predate the rename (production ledgers overwhelmingly carry `session_type`,
172
+ * not `mode` — reading `mode` alone left this analyzer largely inert).
173
+ *
135
174
  * @param {Array} autopilotRuns
136
175
  * @param {Array} sessions
137
176
  * @returns {Map<string, {n_manual:number, n_autopilot:number,
@@ -142,6 +181,9 @@ export function groupByMode(autopilotRuns, sessions) {
142
181
  const out = new Map();
143
182
  if (!Array.isArray(sessions) || sessions.length === 0) return out;
144
183
 
184
+ const realSessions = filterRealSessions(sessions);
185
+ if (realSessions.length === 0) return out;
186
+
145
187
  // Optional: known autopilot_run_id set for stricter pairing. Empty set means
146
188
  // accept any session with a non-empty autopilot_run_id field.
147
189
  const knownRunIds = new Set();
@@ -156,9 +198,14 @@ export function groupByMode(autopilotRuns, sessions) {
156
198
 
157
199
  // Bucket: mode → {manual: [], autopilot: []}
158
200
  const buckets = new Map();
159
- for (const s of sessions) {
201
+ for (const s of realSessions) {
160
202
  if (!s || typeof s !== 'object') continue;
161
- const mode = typeof s.mode === 'string' ? s.mode : null;
203
+ const mode =
204
+ typeof s.session_type === 'string'
205
+ ? s.session_type
206
+ : typeof s.mode === 'string'
207
+ ? s.mode
208
+ : null;
162
209
  if (!mode) continue;
163
210
  const apId = typeof s.autopilot_run_id === 'string' ? s.autopilot_run_id : null;
164
211
  const isAutopilot =
@@ -40,11 +40,14 @@ const tcErrorCount =
40
40
  : 0;
41
41
 
42
42
  // --- Test ---
43
+ // NOTE: `testFailed`, NOT `failed` — a local `failed` is already bound near the
44
+ // bottom of this file and drives `process.exit(failed ? 2 : 0)`. Shadowing it
45
+ // would corrupt the gate's exit code.
43
46
  const testResult = runCheck(testCmd);
44
- const { passed: testPassed, total: testTotal } =
47
+ const { passed: testPassed, failed: testFailed, total: testTotal } =
45
48
  testResult.status !== 'skip'
46
49
  ? extractTestCounts(testResult.output)
47
- : { passed: 0, total: 0 };
50
+ : { passed: 0, failed: 0, total: 0 };
48
51
 
49
52
  // --- Lint ---
50
53
  const lintResult = runCheck(lintCmd);
@@ -70,7 +73,16 @@ const output = {
70
73
  variant: 'full-gate',
71
74
  duration_seconds: durationSeconds,
72
75
  typecheck: { status: tcResult.status, error_count: tcErrorCount },
73
- test: { status: testResult.status, total: testTotal, passed: testPassed },
76
+ // `failed` is published explicitly (#967 item 1) rather than left to be
77
+ // re-derived as `total - passed` downstream: the derivation and the parse can
78
+ // disagree, and only an explicit third number lets a consumer check
79
+ // `passed + failed === total` as a real producer/consumer drift guard.
80
+ test: {
81
+ status: testResult.status,
82
+ total: testTotal,
83
+ passed: testPassed,
84
+ failed: testFailed,
85
+ },
74
86
  lint: { status: lintResult.status, warnings: lintWarnings },
75
87
  debug_artifacts: debugArtifacts,
76
88
  stubbed,
@@ -133,18 +133,61 @@ export function extractCount(output, pattern) {
133
133
  }
134
134
 
135
135
  /**
136
- * Parse vitest-style output for pass/fail/total counts.
136
+ * Matches a runner summary line that reports TEST-CASE counts.
137
137
  *
138
- * Looks for patterns like "42 passed", "3 failed".
138
+ * Deliberately anchored on `Tests` + a word boundary so it matches vitest's
139
+ * ` Tests 12904 passed | 11 skipped (12915)` and jest's
140
+ * `Tests: 1 failed, 4 passed, 5 total`, but NOT vitest's preceding
141
+ * ` Test Files 550 passed (550)` line ("Test" is not followed by "s").
142
+ */
143
+ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
144
+
145
+ /**
146
+ * Parse test-runner output for pass/fail/total TEST-CASE counts.
139
147
  *
140
- * @param {string} output
141
- * @returns {{ passed: number, failed: number, total: number }}
148
+ * ## Which line is parsed
149
+ *
150
+ * Real vitest prints TWO `<N> passed` summary lines, files first:
151
+ *
152
+ * ```
153
+ * Test Files 550 passed (550)
154
+ * Tests 12904 passed | 11 skipped (12915)
155
+ * ```
156
+ *
157
+ * A naive whole-output scan hits the FILE count (550) and publishes it as the
158
+ * test count — the number then rides `gate-full.mjs`'s `test.passed` into the
159
+ * `orchestrator.quality_gate.*` event stream looking authoritative. So: when a
160
+ * `Tests`-anchored summary line exists, ONLY that line is parsed (the LAST one,
161
+ * which is the final summary after any rerun). Terse or non-vitest output with
162
+ * no such line falls back to scanning the whole string, which preserves the
163
+ * bare `"42 passed"` / `"10 passed, 5 failed"` forms.
164
+ *
165
+ * ## What `total` means — passed + failed, NOT vitest's parenthesised number
166
+ *
167
+ * `total` is `passed + failed` and deliberately EXCLUDES skipped/todo tests.
168
+ * This is load-bearing, not an oversight: `scripts/run-quality-gate.mjs`
169
+ * reconstructs the third number from the published envelope as
170
+ * `failed = total - passed`. Adopting vitest's parenthesised total (12915,
171
+ * which counts the 11 skipped) would make that derivation report 11 phantom
172
+ * FAILURES. `total` therefore answers "how many test cases produced a verdict",
173
+ * and `total - passed === failed` holds by construction for every consumer.
174
+ *
175
+ * Skipped is not returned: neither call site (`gate-full.mjs`,
176
+ * `quality-gate.mjs`) consumes it, and its only use would be to rebuild the
177
+ * parenthesised total — precisely the number the downstream derivation must not
178
+ * see. Add it together with a consumer, never ahead of one.
179
+ *
180
+ * @param {string} output - Captured test-runner stdout/stderr (or a tail of it).
181
+ * @returns {{ passed: number, failed: number, total: number }} `total === passed + failed`.
142
182
  */
143
183
  export function extractTestCounts(output) {
144
184
  if (!output) return { passed: 0, failed: 0, total: 0 };
145
185
 
146
- const passMatch = output.match(/(\d+)\s+passed/);
147
- const failMatch = output.match(/(\d+)\s+failed/);
186
+ const summaryLines = output.split('\n').filter((line) => TEST_SUMMARY_LINE.test(line));
187
+ const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
188
+
189
+ const passMatch = scope.match(/(\d+)\s+passed/);
190
+ const failMatch = scope.match(/(\d+)\s+failed/);
148
191
 
149
192
  const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
150
193
  const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
@@ -153,6 +196,89 @@ export function extractTestCounts(output) {
153
196
  return { passed, failed, total };
154
197
  }
155
198
 
199
+ /**
200
+ * Admit a suite-count triple, or refuse to claim a measurement.
201
+ *
202
+ * ## Why this exists (#967 item 2)
203
+ *
204
+ * Two functions used to write the `counts` field of the SAME
205
+ * `orchestrator.quality_gate.*` event under DIFFERENT admission policies:
206
+ * `suiteCountsFromGateStdout` (`scripts/run-quality-gate.mjs`) rejected
207
+ * inconsistent triples, while `suiteCountsFromOutput` (`scripts/lib/quality-gate.mjs`)
208
+ * admitted `passed > total` and a negative `passed`. A consumer therefore had to
209
+ * know two policies to read one field. This is the single shared policy; both
210
+ * callers keep only their own input adapter.
211
+ *
212
+ * ## Absent-not-null
213
+ *
214
+ * Returns `null` — NEVER a zero triple — for any unmeasured or inconsistent
215
+ * input. `{passed: 0, failed: 0, total: 0}` would be indistinguishable from a
216
+ * real all-skipped run and would publish a phantom measurement. Callers spread
217
+ * the result so the field is OMITTED rather than zero-filled:
218
+ *
219
+ * ```js
220
+ * const counts = admitSuiteCounts(raw);
221
+ * await emitEvent(name, { ...(counts ? { counts } : {}) });
222
+ * ```
223
+ *
224
+ * ## ONE channel for "the gate did not run" — a NULL `raw` (#969 MED-2)
225
+ *
226
+ * This function used to take a second `opts.measured` channel: `false` was an
227
+ * unconditional refusal, meant to carry the caller's positional evidence that no
228
+ * test gate ran. It was dead. Both adapters ALREADY convert that evidence to a
229
+ * null `raw` before the policy sees it (`suiteCountsFromOutput` returns
230
+ * `admitSuiteCounts(null)` for a null/empty output; the CLI's
231
+ * `suiteCountsFromGateStdout` returns `null` from its own envelope checks and
232
+ * never passed the opt at all), so no production path could ever reach here with
233
+ * a non-null triple AND `measured: false` — only test rows exercised the flag.
234
+ *
235
+ * Keeping both was the real cost: "the gate did not run" was expressible two
236
+ * ways and checked in two places, so a future caller could pass a real triple
237
+ * with `measured: false`, or a STALE triple with `measured: true`, and the two
238
+ * channels would disagree with the unverifiable boolean silently winning. A
239
+ * null `raw` is the channel that survives because the policy must reject
240
+ * non-objects anyway — it is structural, not an extra parameter, and it is
241
+ * expressible by every caller including the one that never opted in.
242
+ *
243
+ * ## What stays with the CALLER (deliberately not absorbed)
244
+ *
245
+ * This function sees a candidate triple and nothing else. It performs no I/O and
246
+ * reads no files. The following are per-caller INPUT ADAPTERS and must not
247
+ * migrate here: JSON-envelope parsing; the `test`-object-vs-status-string
248
+ * variant discrimination (non-full-gate variants emit a bare status string);
249
+ * `test.status ∈ {pass, fail}`; the `parsed.stubbed?.test` short-circuit; the
250
+ * raw-text tail parse ({@link extractTestCounts}); and the positional evidence
251
+ * that the test gate ran at all — which each caller expresses by handing over
252
+ * `null` rather than a triple.
253
+ *
254
+ * `failed` is accepted when the caller parsed one and DERIVED as `total - passed`
255
+ * when it did not, so both the parsed-`failed` path and the derived-`failed` path
256
+ * land on one consistency check (`passed + failed === total`).
257
+ *
258
+ * @param {{passed?: unknown, failed?: unknown, total?: unknown}|null|undefined} raw
259
+ * `null`/`undefined` is the caller's evidence that there is no measurement to
260
+ * admit (gate skipped, stub command, fail-fast before the test step, or no
261
+ * parseable count in the output).
262
+ * @returns {{passed: number, failed: number, total: number}|null}
263
+ */
264
+ export function admitSuiteCounts(raw) {
265
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
266
+
267
+ const { passed, total } = raw;
268
+ if (!Number.isFinite(passed) || !Number.isFinite(total)) return null;
269
+
270
+ const failed = Number.isFinite(raw.failed) ? raw.failed : total - passed;
271
+
272
+ if (total <= 0) return null;
273
+ if (passed < 0 || failed < 0) return null;
274
+ // Redundant given the sum check below while `failed >= 0`, but kept explicit:
275
+ // it is the check the looser of the two former policies was missing.
276
+ if (passed > total) return null;
277
+ if (passed + failed !== total) return null;
278
+
279
+ return { passed, failed, total };
280
+ }
281
+
156
282
  /**
157
283
  * Scan changed files (since `ref`) for debug artifacts: `console.log`, `debugger`, `TODO`, `FIXME`.
158
284
  *
@@ -23,6 +23,7 @@ import { execFile as execFileCb } from 'node:child_process';
23
23
  import { promisify } from 'node:util';
24
24
 
25
25
  import { detectVcsForRepo, discoverVaultRepos } from '../gitlab-portfolio/vcs-detect.mjs';
26
+ import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
26
27
 
27
28
  const realExecFile = promisify(execFileCb);
28
29
 
@@ -94,22 +95,31 @@ export function filterStaleMRs(mrs, opts = {}) {
94
95
  * 2. `detectVcsForRepo({ repo })` if `opts.repo` is provided
95
96
  * 3. 'gitlab' (cross-project default per CLAUDE.md / AGENTS.md Session Config `vcs: gitlab`) —
96
97
  * only reached when neither vcs nor repo is provided, i.e. the caller
97
- * intends "the repo at `repoRoot`", and glab/gh both auto-detect the
98
- * remote from the local git checkout when no --repo flag is given.
98
+ * intends "the repo at `repoRoot`".
99
+ *
100
+ * `--repo` host-pinning (#872): a bare `glab`/`gh` spawn (no `--repo`) falls
101
+ * back to the ambient `GITLAB_HOST`/`GH_HOST` env var to pick a host, which
102
+ * can silently target the WRONG instance on a multi-instance host — `cwd:
103
+ * repoRoot` alone does not fix this (ambient env still wins over cwd). When
104
+ * `opts.repo` is not explicitly provided, this function auto-detects a
105
+ * `--repo` spec via `resolveRepoSpecFn` (default: the real `resolveRepoSpec`
106
+ * from `vcs-repo-spec.mjs`, which shells out to `git remote get-url`).
107
+ * Explicit `opts.repo` always wins over the auto-detected spec.
99
108
  *
100
109
  * Never throws: CLI failure (missing binary, non-zero exit, timeout) and
101
110
  * malformed JSON output both resolve to a graceful `{ ok: false, error, stale: [], total: 0 }`
102
111
  * result plus a diagnostic line on stderr — never an unhandled rejection.
103
112
  *
104
113
  * @param {object} [opts]
105
- * @param {string} [opts.repoRoot=process.cwd()] - cwd for the CLI invocation (used for local-checkout auto-detection by glab/gh)
106
- * @param {string} [opts.repo] - explicit "owner/repo" or GitLab path identifier; when set, passed via `--repo`
114
+ * @param {string} [opts.repoRoot=process.cwd()] - cwd for the CLI invocation (also passed to resolveRepoSpecFn for remote auto-detection)
115
+ * @param {string} [opts.repo] - explicit "owner/repo" or GitLab path identifier; when set, passed via `--repo` and skips auto-detection
107
116
  * @param {'gitlab'|'github'} [opts.vcs] - explicit VCS override (skips detectVcsForRepo)
108
117
  * @param {number} [opts.thresholdDays=14]
109
118
  * @param {'created'|'updated'} [opts.field='updated']
110
119
  * @param {Function} [opts.exec] - injectable execFile-like function `(cmd, args, options) => Promise<{stdout, stderr}>`; defaults to the real promisified execFile
111
120
  * @param {number} [opts.now=Date.now()]
112
121
  * @param {number} [opts.timeoutMs=8000]
122
+ * @param {(o: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
113
123
  * @returns {Promise<
114
124
  * { ok: true, repo: string, vcs: 'gitlab'|'github', total: number, stale: Array<object> } |
115
125
  * { ok: false, error: string, repo: string|null, vcs: 'gitlab'|'github'|null, total: 0, stale: [] }
@@ -125,6 +135,7 @@ export async function findStaleMRs(opts = {}) {
125
135
  exec = realExecFile,
126
136
  now = Date.now(),
127
137
  timeoutMs = DEFAULT_TIMEOUT_MS,
138
+ resolveRepoSpecFn = resolveRepoSpec,
128
139
  } = opts;
129
140
 
130
141
  const resolvedVcs = vcsOverride ?? (repo ? detectVcsForRepo({ repo }) : 'gitlab');
@@ -144,11 +155,15 @@ export async function findStaleMRs(opts = {}) {
144
155
  cmd = 'gh';
145
156
  args = ['pr', 'list', '--state', 'open', '--json', GH_PR_JSON_FIELDS];
146
157
  }
147
- if (repo) {
148
- args.push('--repo', repo);
158
+
159
+ // #872: explicit repo always wins; otherwise auto-detect a --repo spec
160
+ // from the local git remotes (host-pinning — see docblock above).
161
+ const effectiveRepo = repo ?? resolveRepoSpecFn({ repoRoot, vcs: resolvedVcs });
162
+ if (effectiveRepo) {
163
+ args.push('--repo', effectiveRepo);
149
164
  }
150
165
 
151
- const repoLabel = repo ?? repoRoot;
166
+ const repoLabel = effectiveRepo ?? repoRoot;
152
167
 
153
168
  let stdout;
154
169
  try {
@@ -209,6 +224,7 @@ export async function findStaleMRs(opts = {}) {
209
224
  * @param {number} [opts.now=Date.now()]
210
225
  * @param {number} [opts.timeoutMs=8000]
211
226
  * @param {typeof discoverVaultRepos} [opts.discoverRepos] - injectable for tests
227
+ * @param {(o: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined} [opts.resolveRepoSpecFn] - passed through to each per-repo `findStaleMRs` call; unused in practice since discovered repos always carry an explicit `repo`
212
228
  * @returns {Promise<Array<{ slug: string } & Awaited<ReturnType<typeof findStaleMRs>>>>}
213
229
  */
214
230
  export async function findStaleMRsMultiRepo(opts = {}) {
@@ -220,6 +236,7 @@ export async function findStaleMRsMultiRepo(opts = {}) {
220
236
  now = Date.now(),
221
237
  timeoutMs = DEFAULT_TIMEOUT_MS,
222
238
  discoverRepos = discoverVaultRepos,
239
+ resolveRepoSpecFn = resolveRepoSpec,
223
240
  } = opts;
224
241
 
225
242
  const repos = await discoverRepos({ vaultDir });
@@ -227,7 +244,7 @@ export async function findStaleMRsMultiRepo(opts = {}) {
227
244
 
228
245
  const results = await Promise.all(
229
246
  repos.map((r) =>
230
- findStaleMRs({ repo: r.repo, vcs: r.vcs, thresholdDays, field, exec, now, timeoutMs }),
247
+ findStaleMRs({ repo: r.repo, vcs: r.vcs, thresholdDays, field, exec, now, timeoutMs, resolveRepoSpecFn }),
231
248
  ),
232
249
  );
233
250
 
@@ -362,6 +379,7 @@ export async function main(argv, deps = {}) {
362
379
  repoRoot = process.cwd(),
363
380
  discoverRepos = discoverVaultRepos,
364
381
  homedir = () => process.env.HOME ?? '',
382
+ resolveRepoSpecFn = resolveRepoSpec,
365
383
  } = deps;
366
384
 
367
385
  const flags = parseArgs(argv);
@@ -386,6 +404,7 @@ export async function main(argv, deps = {}) {
386
404
  exec,
387
405
  now,
388
406
  discoverRepos,
407
+ resolveRepoSpecFn,
389
408
  });
390
409
 
391
410
  if (flags.json) {
@@ -410,6 +429,7 @@ export async function main(argv, deps = {}) {
410
429
  field: flags.field,
411
430
  exec,
412
431
  now,
432
+ resolveRepoSpecFn,
413
433
  });
414
434
 
415
435
  if (!result.ok) {
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { spawn as _spawn } from 'node:child_process';
14
14
 
15
+ import { normalizeLabel, normalizedLabelSet } from '../label-scope.mjs';
16
+
15
17
  /** Default timeout per repo CLI invocation (ms). */
16
18
  export const DEFAULT_TIMEOUT_MS = 15_000;
17
19
 
@@ -332,7 +334,11 @@ export function summarizeRepo(issues, opts) {
332
334
 
333
335
  const nowMs = now instanceof Date ? now.getTime() : Date.now();
334
336
  const staleThresholdMs = staleDays * 86_400_000;
335
- const criticalSet = new Set(Array.isArray(criticalLabels) ? criticalLabels : []);
337
+ // Scope-tolerant matching: defaults use the canonical scoped spelling
338
+ // (`priority::critical`), but issues labelled before the migration still
339
+ // carry `priority:critical`. Normalising both sides keeps those counted —
340
+ // see scripts/lib/label-scope.mjs.
341
+ const criticalSet = normalizedLabelSet(criticalLabels);
336
342
 
337
343
  let criticalCount = 0;
338
344
  let staleCount = 0;
@@ -341,7 +347,7 @@ export function summarizeRepo(issues, opts) {
341
347
 
342
348
  for (const issue of issues) {
343
349
  // Critical
344
- if (issue.labels.some((l) => criticalSet.has(l))) {
350
+ if (issue.labels.some((l) => criticalSet.has(normalizeLabel(l)))) {
345
351
  criticalCount++;
346
352
  }
347
353
 
@@ -289,7 +289,7 @@ EXIT CODES
289
289
 
290
290
  // ── Fetch issues in parallel ───────────────────────────────────────────────
291
291
  const staleDays = portfolioConfig['stale-days'] ?? 30;
292
- const criticalLabels = portfolioConfig['critical-labels'] ?? ['priority:critical', 'priority:high'];
292
+ const criticalLabels = portfolioConfig['critical-labels'] ?? ['priority::critical', 'priority::high'];
293
293
  const mode = portfolioConfig.mode ?? 'warn';
294
294
 
295
295
  let resultsMap;
@@ -104,8 +104,12 @@ function coerceOriginIssue(v) {
104
104
  }
105
105
 
106
106
  /**
107
- * Coerce a priority field: lowercase, trim, strip a leading `priority:` label,
108
- * validate against the closed enum. Anything invalid null.
107
+ * Coerce a priority field: lowercase, trim, strip a leading `priority::` (the
108
+ * canonical scoped form) or legacy `priority:` label prefix, validate against
109
+ * the closed enum. Anything invalid → null.
110
+ *
111
+ * Both spellings are accepted on INPUT because the label-data migration to the
112
+ * scoped form trails the producer migration — see `scripts/lib/label-scope.mjs`.
109
113
  *
110
114
  * @param {unknown} v
111
115
  * @returns {'critical'|'high'|'medium'|'low'|null}
@@ -113,7 +117,7 @@ function coerceOriginIssue(v) {
113
117
  function coercePriority(v) {
114
118
  if (v === null || v === undefined) return null;
115
119
  let s = String(v).trim().toLowerCase();
116
- if (s.startsWith('priority:')) s = s.slice('priority:'.length).trim();
120
+ s = s.replace(/^priority::?/, '').trim();
117
121
  return VALID_PRIORITIES.has(s) ? /** @type {any} */ (s) : null;
118
122
  }
119
123
 
@@ -11,6 +11,8 @@ import { existsSync } from 'node:fs';
11
11
  import { join, relative, sep } from 'node:path';
12
12
 
13
13
  import { parseFrontmatter, safeRead, parseJsonl, pass, fail } from './helpers.mjs';
14
+ import { isRealSession } from '../../session-schema/filters.mjs';
15
+ import { findSessionConfigBlock } from '../../config/section-extractor.mjs';
14
16
 
15
17
  // Default session-lock TTL in hours — mirrors DEFAULT_TTL_HOURS in
16
18
  // scripts/lib/session-lock.mjs. Inlined to keep this category stdlib-only
@@ -93,9 +95,19 @@ export function runCategory4(root) {
93
95
  { latestCompletedAt: null, ageInDays: null },
94
96
  'sessions.jsonl is empty'));
95
97
  } else {
96
- const lastLine = lines[lines.length - 1];
98
+ // Scan backward past any trailing `status: 'abandoned'` phantom stubs
99
+ // (#834, session-close-backfill) to the last REAL session — otherwise a
100
+ // recent phantom lets a dormant repo pass a check meant to certify
101
+ // recent REAL engagement.
97
102
  let lastObj = null;
98
- try { lastObj = JSON.parse(lastLine); } catch { /* ignore */ }
103
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
104
+ let parsed = null;
105
+ try { parsed = JSON.parse(lines[i]); } catch { /* ignore */ }
106
+ if (parsed && isRealSession(parsed)) {
107
+ lastObj = parsed;
108
+ break;
109
+ }
110
+ }
99
111
  const completedAt = lastObj ? lastObj.completed_at : null;
100
112
  if (!completedAt) {
101
113
  checks.push(fail('sessions-jsonl-recent', 3, relPath,
@@ -148,9 +160,14 @@ export function runCategory4(root) {
148
160
  const relPath = 'skills/vault-sync/validator.mjs';
149
161
  // Parse CLAUDE.md Session Config for vault-integration.enabled
150
162
  const claudeMd = safeRead(join(root, 'CLAUDE.md')) || safeRead(join(root, 'AGENTS.md')) || '';
151
- // Extract ## Session Config block
152
- const scMatch = /^## Session Config\s*\n([\s\S]*?)(?=^## |\s*$)/m.exec(claudeMd);
153
- const scBlock = scMatch ? scMatch[1] : '';
163
+ // Extract ## Session Config block via the SSOT (#968). The previous local
164
+ // regex `/^## Session Config\s*\n([\s\S]*?)(?=^## |\s*$)/m` was not merely
165
+ // a looser heading test its `\s*$` lookahead (with /m, `$` matches at
166
+ // every line end) truncated the body to the block's FIRST line. On this
167
+ // repo's own CLAUDE.md it captured 17 chars ("persistence: true") of 2877,
168
+ // so `vault-integration.enabled: true` was invisible and this check
169
+ // reported "not enabled — skip" at full 2/2 credit.
170
+ const scBlock = findSessionConfigBlock(claudeMd)?.body ?? '';
154
171
  const vaultEnabledMatch = /vault-integration\.enabled:\s*true/i.exec(scBlock) ||
155
172
  /vault-integration:\s*\n\s+enabled:\s*true/im.exec(scBlock);
156
173
  const vaultEnabled = Boolean(vaultEnabledMatch);