session-orchestrator 3.17.0 → 3.20.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 (221) 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 +185 -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/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  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 +17 -1
  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/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -19,6 +19,9 @@
19
19
 
20
20
  import { spawnSync } from 'node:child_process';
21
21
 
22
+ import { normalizeLabel } from './label-scope.mjs';
23
+ import { resolveRepoSpec } from './vcs-repo-spec.mjs';
24
+
22
25
  export const STALE_THRESHOLD_DAYS = 30;
23
26
 
24
27
  /** Module-level cache. Keyed by JSON.stringify({vcs, limit}). */
@@ -108,8 +111,12 @@ export function summarizeIssues(issues, nowMs = Date.now()) {
108
111
 
109
112
  for (const name of labelNames) {
110
113
  byLabel[name] = (byLabel[name] || 0) + 1;
111
- if (name === 'priority:critical') criticalCount += 1;
112
- else if (name === 'priority:high') highCount += 1;
114
+ // Scope-tolerant: the canonical spelling is the scoped `priority::<level>`,
115
+ // but the label-data migration trails the producer migration, so issues
116
+ // carrying the legacy `priority:<level>` must keep counting.
117
+ const key = normalizeLabel(name);
118
+ if (key === 'priority:critical') criticalCount += 1;
119
+ else if (key === 'priority:high') highCount += 1;
113
120
  }
114
121
 
115
122
  const updated = issue.updated_at || issue.updatedAt || null;
@@ -127,7 +134,10 @@ export function summarizeIssues(issues, nowMs = Date.now()) {
127
134
 
128
135
  /**
129
136
  * Scan the project's open backlog and return a structural summary suitable for
130
- * `signals.backlog`. Caches the result per (vcs, limit) within the running process.
137
+ * `signals.backlog`. Caches the result per (vcs, limit, spec) within the
138
+ * running process — `spec` (the resolved `-R`/`--repo` host-pinning value,
139
+ * #872) is part of the cache key so two different repos scanning the SAME
140
+ * (vcs, limit) pair never collide on a shared cache entry.
131
141
  *
132
142
  * Returns null on any of:
133
143
  * - VCS cannot be detected (no git origin)
@@ -136,7 +146,20 @@ export function summarizeIssues(issues, nowMs = Date.now()) {
136
146
  *
137
147
  * Never throws.
138
148
  *
139
- * @param {{limit?: number, vcs?: 'github'|'gitlab'|null, nowMs?: number}} [opts]
149
+ * @param {{
150
+ * limit?: number,
151
+ * vcs?: 'github'|'gitlab'|null,
152
+ * nowMs?: number,
153
+ * repoRoot?: string,
154
+ * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined,
155
+ * runJsonFn?: (bin: string, args: string[]) => Array | null
156
+ * }} [opts]
157
+ * `repoRoot` defaults to `process.cwd()`. `resolveRepoSpecFn` is the
158
+ * injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
159
+ * defaults to the real `resolveRepoSpec` (shells out to `git remote
160
+ * get-url`); tests inject a stub instead of shelling out. `runJsonFn` is
161
+ * the injectable seam for the CLI runner — defaults to the real `runJson`
162
+ * (shells out to `glab`/`gh`).
140
163
  * @returns {Promise<null | {criticalCount: number, highCount: number, staleCount: number, byLabel: Record<string, number>, total: number, vcs: string, limit: number}>}
141
164
  */
142
165
  export async function scanBacklog(opts = {}) {
@@ -149,7 +172,16 @@ export async function scanBacklog(opts = {}) {
149
172
 
150
173
  if (vcs !== 'github' && vcs !== 'gitlab') return null;
151
174
 
152
- const cacheKey = JSON.stringify({ vcs, limit });
175
+ const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
176
+ const resolveRepoSpecFn =
177
+ typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
178
+ const runJsonFn = typeof opts.runJsonFn === 'function' ? opts.runJsonFn : runJson;
179
+
180
+ // Resolve the -R/--repo host-pinning spec ONCE (#872), mirroring the
181
+ // #839 idiom in spiral-carryover.mjs / issue-close-strip-labels.mjs.
182
+ const spec = resolveRepoSpecFn({ repoRoot, vcs });
183
+
184
+ const cacheKey = JSON.stringify({ vcs, limit, spec });
153
185
  if (_cache.has(cacheKey)) return _cache.get(cacheKey);
154
186
 
155
187
  const bin = vcs === 'github' ? 'gh' : 'glab';
@@ -157,8 +189,9 @@ export async function scanBacklog(opts = {}) {
157
189
  vcs === 'github'
158
190
  ? ['issue', 'list', '--limit', String(limit), '--json', 'number,labels,updatedAt,state']
159
191
  : ['issue', 'list', '--per-page', String(limit), '--output', 'json'];
192
+ if (spec) args.push('-R', spec);
160
193
 
161
- const issues = runJson(bin, args);
194
+ const issues = runJsonFn(bin, args);
162
195
  if (issues === null) {
163
196
  _cache.set(cacheKey, null);
164
197
  return null;
@@ -0,0 +1,340 @@
1
+ /**
2
+ * scripts/lib/blocked-commands-policy.mjs — policy-floor merge for the
3
+ * destructive-command guard (#972).
4
+ *
5
+ * Before #972 the guard's policy loader was first-hit-wins over
6
+ * [cwd, projectDir, pluginRoot]/.orchestrator/policy/blocked-commands.json.
7
+ * Consequence: an empty (or hostile) consumer policy in cwd SILENTLY disarmed
8
+ * the plugin's own policy — `{"version":1,"rules":[]}` switched the guard off.
9
+ *
10
+ * This module replaces first-hit-wins with a FLOOR/OVERLAY merge:
11
+ * - FLOOR = the plugin-root policy (the plugin's own blocklist).
12
+ * - OVERLAY = the first existing policy from [cwd, projectDir] (a consumer
13
+ * repo's local policy).
14
+ * - The overlay can only ADD rules or ESCALATE severity — never remove a
15
+ * floor rule, never downgrade a floor `block`, never swap a floor rule's
16
+ * pattern/path-allowlist for its own (field-merge would be the backdoor).
17
+ *
18
+ * Invariant: merged.rules ⊇ floor.rules for EVERY overlay input (including
19
+ * `{}`, `{"rules":[]}`, malformed JSON, and a missing file) — every overlay
20
+ * failure mode fails TO THE FLOOR, not open.
21
+ *
22
+ * All exported functions are TOTAL: they never throw. The consuming hook keeps
23
+ * its documented `main().catch → exit 0` fail-open for internal errors, so any
24
+ * throw here would silently disarm the guard — totality is load-bearing.
25
+ */
26
+
27
+ import { existsSync, promises as fsp } from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ const POLICY_REL = path.join('.orchestrator', 'policy', 'blocked-commands.json');
31
+
32
+ /** Severity ordering for the escalate-only merge: warn < block. */
33
+ const SEVERITY_RANK = { warn: 0, block: 1 };
34
+
35
+ /** Module-level default per-path cache (each hook invocation is an isolated
36
+ * Node subprocess, so this is fresh per process — issue #250 contract). */
37
+ const _defaultCache = new Map();
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Path resolution
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Resolve the floor and overlay policy paths from the three injected roots.
45
+ *
46
+ * Roles are purely PATH-based (no schema marker): the plugin root supplies the
47
+ * floor; the first existing policy among [cwd, projectDir] is the overlay.
48
+ * When both roles resolve to the SAME file (the common case inside the plugin
49
+ * repo itself, where cwd == pluginRoot), callers must treat it as ONE policy
50
+ * (identity merge) — `loadEffectivePolicy` does exactly that.
51
+ *
52
+ * @param {{cwd?: string, projectDir?: string, pluginRoot?: string}} [roots]
53
+ * @returns {{floorPath: string|null, overlayPath: string|null}} existing paths only
54
+ */
55
+ export function resolvePolicyPaths({ cwd, projectDir, pluginRoot } = {}) {
56
+ const candidate = (root) => {
57
+ if (typeof root !== 'string' || root.length === 0) return null;
58
+ try {
59
+ const p = path.join(root, POLICY_REL);
60
+ return existsSync(p) ? p : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ const floorPath = candidate(pluginRoot);
67
+
68
+ let overlayPath = null;
69
+ for (const root of [cwd, projectDir]) {
70
+ const p = candidate(root);
71
+ if (p) { overlayPath = p; break; }
72
+ }
73
+
74
+ return { floorPath, overlayPath };
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Rule sanitation (per-file)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Sanitize one file's rules array: drop broken individual rules (missing
83
+ * string id/pattern/severity) and duplicate ids (first occurrence wins), each
84
+ * with a warning — never kill the whole list for one bad entry.
85
+ *
86
+ * Valid rules pass through UNCHANGED (extra fields like `path-allowlist` and
87
+ * `allow-override-flag` are preserved verbatim).
88
+ *
89
+ * @param {unknown} rules
90
+ * @param {string} label - 'floor' | 'overlay' | 'policy' (for warning text)
91
+ * @param {string[]} warnings - appended to in place
92
+ * @returns {object[]}
93
+ */
94
+ function sanitizeRules(rules, label, warnings) {
95
+ const out = [];
96
+ const seen = new Set();
97
+ if (!Array.isArray(rules)) return out;
98
+
99
+ for (let i = 0; i < rules.length; i++) {
100
+ const rule = rules[i];
101
+ const broken =
102
+ rule === null ||
103
+ typeof rule !== 'object' ||
104
+ typeof rule.id !== 'string' || rule.id.length === 0 ||
105
+ typeof rule.pattern !== 'string' || rule.pattern.length === 0 ||
106
+ typeof rule.severity !== 'string';
107
+ if (broken) {
108
+ const ident = rule && typeof rule === 'object' && typeof rule.id === 'string'
109
+ ? `'${rule.id}'`
110
+ : `at index ${i}`;
111
+ warnings.push(`${label} rule ${ident} skipped (missing/invalid id, pattern, or severity)`);
112
+ continue;
113
+ }
114
+ if (seen.has(rule.id)) {
115
+ warnings.push(`${label} rule '${rule.id}' duplicated within one file — first occurrence wins`);
116
+ continue;
117
+ }
118
+ seen.add(rule.id);
119
+ out.push(rule);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Merge
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Merge a floor policy with an overlay policy — union by rule id, floor rules
130
+ * first, escalate-only semantics:
131
+ *
132
+ * (a) overlay-only id → appended (additive).
133
+ * (b) id collision, floor `block` → the WHOLE floor rule wins (no field
134
+ * merge — an overlay path-allowlist/pattern would be the backdoor);
135
+ * overlay definition dropped + warning.
136
+ * (c) id collision, floor `warn` → max(floor, overlay) severity with
137
+ * warn < block, on the FLOOR rule's fields; unknown/missing overlay
138
+ * severity → floor wins + warning.
139
+ * (d) duplicate ids WITHIN one file → first occurrence wins + warning.
140
+ * (e) broken individual rule → skipped with warning, list survives.
141
+ *
142
+ * Total function: any non-policy input is treated as an empty rules list.
143
+ *
144
+ * @param {{rules?: unknown}|null|undefined} floor
145
+ * @param {{rules?: unknown}|null|undefined} overlay
146
+ * @returns {{rules: object[], warnings: string[]}}
147
+ */
148
+ export function mergePolicies(floor, overlay) {
149
+ const warnings = [];
150
+ const floorRules = sanitizeRules(floor?.rules, 'floor', warnings);
151
+ const overlayRules = sanitizeRules(overlay?.rules, 'overlay', warnings);
152
+
153
+ const merged = [...floorRules];
154
+ const indexById = new Map(merged.map((r, i) => [r.id, i]));
155
+
156
+ for (const rule of overlayRules) {
157
+ const idx = indexById.get(rule.id);
158
+ if (idx === undefined) {
159
+ // (a) additive overlay-only rule
160
+ indexById.set(rule.id, merged.length);
161
+ merged.push(rule);
162
+ continue;
163
+ }
164
+
165
+ const floorRule = merged[idx];
166
+ if (floorRule.severity === 'block') {
167
+ // (b) floor block is immutable — whole floor rule wins
168
+ warnings.push(
169
+ `overlay rule '${rule.id}' shadowed by floor block rule — overlay definition ignored`
170
+ );
171
+ continue;
172
+ }
173
+
174
+ if (floorRule.severity === 'warn') {
175
+ // (c) escalate-only on the floor rule's fields.
176
+ // Object.hasOwn, not `in`: `in` walks the prototype chain, so an
177
+ // inherited key like severity:'toString' passed as "known" and silently
178
+ // swallowed the unknown-severity warning (W4 F4).
179
+ if (!Object.hasOwn(SEVERITY_RANK, rule.severity)) {
180
+ warnings.push(
181
+ `overlay rule '${rule.id}' has unknown severity '${rule.severity}' — floor severity kept`
182
+ );
183
+ continue;
184
+ }
185
+ if (SEVERITY_RANK[rule.severity] > SEVERITY_RANK[floorRule.severity]) {
186
+ merged[idx] = { ...floorRule, severity: rule.severity };
187
+ }
188
+ continue;
189
+ }
190
+
191
+ // Floor rule carries an unknown severity string — conservative: keep it.
192
+ warnings.push(
193
+ `overlay rule '${rule.id}' collides with floor rule of unknown severity ` +
194
+ `'${floorRule.severity}' — floor rule kept`
195
+ );
196
+ }
197
+
198
+ return { rules: merged, warnings };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Load (with per-path mtime cache)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Read + parse one policy file with a per-path mtime cache.
207
+ *
208
+ * @param {string|null} policyPath
209
+ * @param {Map<string, {mtimeMs: number, policy: object}>} cache
210
+ * @returns {Promise<{status: 'valid', policy: object}
211
+ * | {status: 'invalid', reason: string}
212
+ * | {status: 'missing'}>}
213
+ */
214
+ async function readPolicyFile(policyPath, cache) {
215
+ if (!policyPath) return { status: 'missing' };
216
+
217
+ let mtimeMs;
218
+ try {
219
+ mtimeMs = (await fsp.stat(policyPath)).mtimeMs;
220
+ const cached = cache.get(policyPath);
221
+ if (cached && cached.mtimeMs === mtimeMs) {
222
+ return { status: 'valid', policy: cached.policy };
223
+ }
224
+ } catch {
225
+ // stat failure → fall through to an uncached read attempt (fail-safe;
226
+ // mirrors the pre-#972 loadPolicyCached contract).
227
+ mtimeMs = null;
228
+ }
229
+
230
+ let policy;
231
+ try {
232
+ policy = JSON.parse(await fsp.readFile(policyPath, 'utf8'));
233
+ } catch (err) {
234
+ if (err?.code === 'ENOENT') return { status: 'missing' };
235
+ return { status: 'invalid', reason: 'malformed (invalid JSON)' };
236
+ }
237
+ if (!policy || typeof policy !== 'object' || !Array.isArray(policy.rules)) {
238
+ return { status: 'invalid', reason: 'missing .rules array' };
239
+ }
240
+ if (mtimeMs !== null) cache.set(policyPath, { mtimeMs, policy });
241
+ return { status: 'valid', policy };
242
+ }
243
+
244
+ /**
245
+ * Load the EFFECTIVE policy: floor + overlay, merged escalate-only.
246
+ *
247
+ * Failure modes (all fail-to-floor, never fail-open where a floor exists):
248
+ * - overlay malformed / missing .rules / EMPTY rules → floor alone + warning
249
+ * (`rules: []` is explicitly a warning, not a legitimate empty policy —
250
+ * an empty overlay must not disarm the floor).
251
+ * - floor unavailable (plugin root unresolvable or file missing) → overlay
252
+ * alone + warning.
253
+ * - floor malformed + valid overlay → overlay alone + loud warning.
254
+ * - both unavailable → `{ rules: null, warnings: [...] }` — the hook keeps
255
+ * its documented fail-open + stderr warning for this case.
256
+ * - floorPath === overlayPath (degenerate, e.g. cwd == pluginRoot) → the one
257
+ * file IS the policy (identity merge; no shadow warnings against itself).
258
+ *
259
+ * Total function: never throws.
260
+ *
261
+ * @param {{cwd?: string, projectDir?: string, pluginRoot?: string,
262
+ * cache?: Map<string, {mtimeMs: number, policy: object}>}} [opts]
263
+ * @returns {Promise<{rules: object[]|null, warnings: string[]}>}
264
+ */
265
+ export async function loadEffectivePolicy({ cwd, projectDir, pluginRoot, cache = _defaultCache } = {}) {
266
+ const warnings = [];
267
+ try {
268
+ const { floorPath, overlayPath } = resolvePolicyPaths({ cwd, projectDir, pluginRoot });
269
+
270
+ // Degenerate case: both roles are the same file → ONE policy.
271
+ if (floorPath !== null && floorPath === overlayPath) {
272
+ const res = await readPolicyFile(floorPath, cache);
273
+ if (res.status === 'valid') {
274
+ return { rules: sanitizeRules(res.policy.rules, 'policy', warnings), warnings };
275
+ }
276
+ warnings.push(
277
+ res.status === 'invalid' && res.reason === 'missing .rules array'
278
+ ? 'policy file missing .rules array — skipping guard'
279
+ : 'policy file is malformed (invalid JSON) — skipping guard'
280
+ );
281
+ return { rules: null, warnings };
282
+ }
283
+
284
+ const floorRes = await readPolicyFile(floorPath, cache);
285
+ const overlayRes = await readPolicyFile(overlayPath, cache);
286
+
287
+ const floorValid = floorRes.status === 'valid';
288
+ const overlayValid = overlayRes.status === 'valid';
289
+ const overlayEmpty = overlayValid && overlayRes.policy.rules.length === 0;
290
+
291
+ if (floorValid && overlayValid && !overlayEmpty) {
292
+ const merge = mergePolicies(floorRes.policy, overlayRes.policy);
293
+ return { rules: merge.rules, warnings: [...warnings, ...merge.warnings] };
294
+ }
295
+
296
+ if (floorValid) {
297
+ // fail-to-floor: overlay missing is the normal no-overlay case (silent);
298
+ // overlay invalid or empty is a warning — it can never disarm the floor.
299
+ if (overlayRes.status === 'invalid') {
300
+ warnings.push(`overlay policy ignored (${overlayRes.reason}) — floor policy enforced alone`);
301
+ } else if (overlayEmpty) {
302
+ warnings.push(
303
+ 'overlay policy ignored (empty rules array — an empty overlay cannot disarm the floor) ' +
304
+ '— floor policy enforced alone'
305
+ );
306
+ }
307
+ return { rules: sanitizeRules(floorRes.policy.rules, 'floor', warnings), warnings };
308
+ }
309
+
310
+ if (overlayValid) {
311
+ warnings.push(
312
+ floorRes.status === 'invalid'
313
+ ? `floor policy is ${floorRes.reason} — enforcing overlay policy alone`
314
+ : 'floor policy unavailable (plugin root unresolvable or file missing) — enforcing overlay policy alone'
315
+ );
316
+ if (overlayEmpty) {
317
+ warnings.push('overlay policy has an empty rules array — guard has no rules to enforce');
318
+ }
319
+ return { rules: sanitizeRules(overlayRes.policy.rules, 'overlay', warnings), warnings };
320
+ }
321
+
322
+ // Neither side usable.
323
+ if (floorRes.status === 'missing' && overlayRes.status === 'missing') {
324
+ warnings.push(
325
+ 'policy file not found (.orchestrator/policy/blocked-commands.json) — skipping guard'
326
+ );
327
+ } else {
328
+ const describe = (res) =>
329
+ res.status === 'missing' ? 'missing' : res.reason;
330
+ warnings.push(
331
+ `no usable policy (floor: ${describe(floorRes)}; overlay: ${describe(overlayRes)}) — skipping guard`
332
+ );
333
+ }
334
+ return { rules: null, warnings };
335
+ } catch (err) {
336
+ // Totality backstop — never throw into the hook's fail-open catch.
337
+ warnings.push(`policy load failed unexpectedly (${err?.message || err}) — skipping guard`);
338
+ return { rules: null, warnings };
339
+ }
340
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { execFile as _execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
+ import { resolveRepoSpec, resolveRepoHost } from './vcs-repo-spec.mjs';
15
16
 
16
17
  const execFileAsync = promisify(_execFile);
17
18
 
@@ -77,14 +78,19 @@ async function getHeadSha(repoRoot, deps = {}) {
77
78
  /**
78
79
  * Get GitLab project ID via glab.
79
80
  *
81
+ * #872: pins to `deps.repoSpec` via `-R` when resolved (host-pinning —
82
+ * `glab repo view` otherwise falls back to the ambient `GITLAB_HOST`).
83
+ *
80
84
  * @param {string} repoRoot
81
- * @param {{ execFile?: Function, timeoutMs?: number }} deps
85
+ * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string }} deps
82
86
  * @returns {Promise<number>}
83
87
  */
84
88
  async function getGlabProjectId(repoRoot, deps = {}) {
89
+ const args = ['repo', 'view', '--output', 'json'];
90
+ if (deps.repoSpec) args.push('-R', deps.repoSpec);
85
91
  const result = await execWithTimeout(
86
92
  'glab',
87
- ['repo', 'view', '--output', 'json'],
93
+ args,
88
94
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
89
95
  );
90
96
  const parsed = JSON.parse(result.stdout);
@@ -94,15 +100,21 @@ async function getGlabProjectId(repoRoot, deps = {}) {
94
100
  /**
95
101
  * Run `glab api <path>` and return parsed JSON.
96
102
  *
103
+ * #872: `glab api` has no repo/`-R` concept — it accepts only `--hostname`
104
+ * to pin which GitLab instance the request targets. Pinned via
105
+ * `deps.repoHost` when resolved.
106
+ *
97
107
  * @param {string} apiPath
98
108
  * @param {string} repoRoot
99
- * @param {{ execFile?: Function, timeoutMs?: number }} deps
109
+ * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
100
110
  * @returns {Promise<unknown>}
101
111
  */
102
112
  async function glabApi(apiPath, repoRoot, deps = {}) {
113
+ const args = ['api', apiPath];
114
+ if (deps.repoHost) args.push('--hostname', deps.repoHost);
103
115
  const result = await execWithTimeout(
104
116
  'glab',
105
- ['api', apiPath],
117
+ args,
106
118
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
107
119
  );
108
120
  return JSON.parse(result.stdout);
@@ -111,15 +123,20 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
111
123
  /**
112
124
  * Run `gh api <path>` and return parsed JSON.
113
125
  *
126
+ * #872: `gh api` has no repo/`-R` concept either — pinned via `--hostname`
127
+ * from `deps.repoHost` when resolved.
128
+ *
114
129
  * @param {string} apiPath
115
130
  * @param {string} repoRoot
116
- * @param {{ execFile?: Function, timeoutMs?: number }} deps
131
+ * @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
117
132
  * @returns {Promise<unknown>}
118
133
  */
119
134
  async function ghApi(apiPath, repoRoot, deps = {}) {
135
+ const args = ['api', apiPath];
136
+ if (deps.repoHost) args.push('--hostname', deps.repoHost);
120
137
  const result = await execWithTimeout(
121
138
  'gh',
122
- ['api', apiPath],
139
+ args,
123
140
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
124
141
  );
125
142
  return JSON.parse(result.stdout);
@@ -144,7 +161,7 @@ function ageDaysFrom(isoDate, now) {
144
161
  *
145
162
  * @param {string} repoRoot
146
163
  * @param {number} now
147
- * @param {{ execFile?: Function, timeoutMs?: number }} deps
164
+ * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
148
165
  * @returns {Promise<object|null>}
149
166
  */
150
167
  async function checkGitlab(repoRoot, now, deps = {}) {
@@ -176,9 +193,32 @@ async function checkGitlab(repoRoot, now, deps = {}) {
176
193
  const pipelineStatus = currentPipeline.status;
177
194
 
178
195
  if (pipelineStatus === 'success') {
196
+ // A pipeline reports `success` even when jobs marked `allow_failure: true`
197
+ // failed. Those jobs are invisible at the pipeline level, so a permanently
198
+ // red allow-failure job (observed: 4/4 consecutive pipelines) would never
199
+ // surface. Inspect the job list to name them. Non-fatal: a failed job query
200
+ // still yields a plain green result.
201
+ let allowFailureJobs;
202
+ try {
203
+ const jobs = await glabApi(
204
+ `projects/${projectId}/pipelines/${currentPipeline.id}/jobs`,
205
+ repoRoot,
206
+ deps,
207
+ );
208
+ if (Array.isArray(jobs)) {
209
+ const softFailed = jobs
210
+ .filter((j) => j.status === 'failed' && j.allow_failure === true)
211
+ .map((j) => j.name);
212
+ if (softFailed.length > 0) allowFailureJobs = softFailed;
213
+ }
214
+ } catch {
215
+ // Non-fatal — report green without the allow-failure detail.
216
+ }
217
+
179
218
  return {
180
219
  status: 'green',
181
220
  ok: true,
221
+ ...(allowFailureJobs ? { allowFailureJobs } : {}),
182
222
  details: {
183
223
  currentPipelineId: currentPipeline.id,
184
224
  cliUsed: 'glab',
@@ -268,15 +308,21 @@ async function checkGitlab(repoRoot, now, deps = {}) {
268
308
  /**
269
309
  * GitHub CI status check (v1 — red/green only; lastGreen not implemented).
270
310
  *
311
+ * #872: pins the `gh repo view` lookup to `deps.repoSpec` via `-R` when
312
+ * resolved (host-pinning — `gh repo view` otherwise falls back to the
313
+ * ambient `GH_HOST`).
314
+ *
271
315
  * @param {string} repoRoot
272
- * @param {{ execFile?: Function, timeoutMs?: number }} deps
316
+ * @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
273
317
  * @returns {Promise<object|null>}
274
318
  */
275
319
  async function checkGithub(repoRoot, deps = {}) {
276
320
  // Resolve owner/repo from gh to keep the API path generic.
321
+ const repoViewArgs = ['repo', 'view', '--json', 'nameWithOwner'];
322
+ if (deps.repoSpec) repoViewArgs.push('-R', deps.repoSpec);
277
323
  const repoViewResult = await execWithTimeout(
278
324
  'gh',
279
- ['repo', 'view', '--json', 'nameWithOwner'],
325
+ repoViewArgs,
280
326
  { cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
281
327
  );
282
328
  const { nameWithOwner } = JSON.parse(repoViewResult.stdout);
@@ -354,7 +400,12 @@ async function checkGithub(repoRoot, deps = {}) {
354
400
  * }} opts
355
401
  * @param {{
356
402
  * execFile?: Function,
357
- * }} deps Dependency-injection seam for testing.
403
+ * resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
404
+ * resolveRepoHost?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
405
+ * }} deps Dependency-injection seam for testing. `resolveRepoSpec`/
406
+ * `resolveRepoHost` default to the real `vcs-repo-spec.mjs` exports
407
+ * (#872 host-pinning — see that module for the `-R` vs `--hostname`
408
+ * contract).
358
409
  * @returns {Promise<null | {
359
410
  * status: 'green'|'red'|'unknown',
360
411
  * ok: boolean,
@@ -381,6 +432,9 @@ export async function checkCiStatus(opts = {}, deps = {}) {
381
432
  ? promisify(deps.execFile)
382
433
  : execFileAsync;
383
434
 
435
+ const resolveRepoSpecDep = deps.resolveRepoSpec ?? resolveRepoSpec;
436
+ const resolveRepoHostDep = deps.resolveRepoHost ?? resolveRepoHost;
437
+
384
438
  const depsWithExec = { execFile: execFileDep, timeoutMs };
385
439
 
386
440
  try {
@@ -395,13 +449,22 @@ export async function checkCiStatus(opts = {}, deps = {}) {
395
449
  }
396
450
  }
397
451
 
452
+ // Step 1b (#872): resolve the -R/--hostname host-pinning spec ONCE per
453
+ // checkCiStatus call — a bare glab/gh spawn falls back to the ambient
454
+ // GITLAB_HOST/GH_HOST env var, which can silently target the wrong
455
+ // instance on a multi-instance host. `cwd: repoRoot` alone does not fix
456
+ // this (ambient env still wins over cwd).
457
+ const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
458
+ const repoHost = resolveRepoHostDep({ repoRoot, vcs });
459
+ const depsWithPinning = { ...depsWithExec, repoSpec, repoHost };
460
+
398
461
  // Step 2: dispatch to VCS-specific implementation.
399
462
  if (vcs === 'gitlab') {
400
- return await checkGitlab(repoRoot, now, depsWithExec);
463
+ return await checkGitlab(repoRoot, now, depsWithPinning);
401
464
  }
402
465
 
403
466
  if (vcs === 'github') {
404
- return await checkGithub(repoRoot, depsWithExec);
467
+ return await checkGithub(repoRoot, depsWithPinning);
405
468
  }
406
469
 
407
470
  // Unknown VCS value — silent no-op.