session-orchestrator 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -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
 
@@ -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