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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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
|
-
|
|
112
|
-
|
|
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
|
|
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 {{
|
|
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
|
|
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 =
|
|
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
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { resolve } from 'node:path';
|
|
18
18
|
import { parseStateMd, parseRecommendations } from './state-md.mjs';
|
|
19
|
-
import { normalizeSession } from './session-schema.mjs';
|
|
19
|
+
import { normalizeSession, tailRealSessions } from './session-schema.mjs';
|
|
20
20
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
21
21
|
import { scanBacklog } from './backlog-scan.mjs';
|
|
22
22
|
|
|
@@ -98,9 +98,12 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
98
98
|
.split('\n')
|
|
99
99
|
.map((l) => l.trim())
|
|
100
100
|
.filter((l) => l.length > 0);
|
|
101
|
-
|
|
101
|
+
// #834: parse ALL lines (not just the naive last-N) before windowing —
|
|
102
|
+
// `status: 'abandoned'` phantom stubs must be filtered out BEFORE the
|
|
103
|
+
// tail is taken, or sessionTailN silently means "last N LINES" instead
|
|
104
|
+
// of "last N REAL sessions". tailRealSessions() does the filter + tail.
|
|
102
105
|
const parsed = [];
|
|
103
|
-
for (const line of
|
|
106
|
+
for (const line of lines) {
|
|
104
107
|
try {
|
|
105
108
|
const obj = JSON.parse(line);
|
|
106
109
|
parsed.push(normalizeSession(obj));
|
|
@@ -108,7 +111,7 @@ export async function buildLiveSignals(opts = {}) {
|
|
|
108
111
|
// Branch 4: skip malformed lines silently
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
|
-
recentSessions = parsed;
|
|
114
|
+
recentSessions = tailRealSessions(parsed, sessionTailN);
|
|
112
115
|
}
|
|
113
116
|
} catch {
|
|
114
117
|
// Branch 3: file unreadable — recentSessions stays []
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
463
|
+
return await checkGitlab(repoRoot, now, depsWithPinning);
|
|
401
464
|
}
|
|
402
465
|
|
|
403
466
|
if (vcs === 'github') {
|
|
404
|
-
return await checkGithub(repoRoot,
|
|
467
|
+
return await checkGithub(repoRoot, depsWithPinning);
|
|
405
468
|
}
|
|
406
469
|
|
|
407
470
|
// Unknown VCS value — silent no-op.
|