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.
- 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 +105 -412
- package/README.md +12 -9
- 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/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- 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/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- 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/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- 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/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/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/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- 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/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 +9 -3
- 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/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-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/validator.mjs +16 -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/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/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/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 +17 -3
- 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/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 +17 -1
- 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 +34 -13
- 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 +73 -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/validator.mjs +108 -7
- 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
|
@@ -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
|
|
82
|
+
* Returns whether the Session Config section already contains an
|
|
79
83
|
* ecosystem-health key.
|
|
80
|
-
* @param {
|
|
81
|
-
* @param {RegExpMatchArray|null} startMatch
|
|
84
|
+
* @param {ReturnType<typeof findSessionConfigBlock>} block
|
|
82
85
|
* @returns {boolean}
|
|
83
86
|
*/
|
|
84
|
-
function _ecosystemBlockPresent(
|
|
85
|
-
if (!
|
|
86
|
-
|
|
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 {
|
|
107
|
+
* @param {ReturnType<typeof findSessionConfigBlock>} block
|
|
108
108
|
* @param {string[]} snippetLines
|
|
109
109
|
* @returns {string}
|
|
110
110
|
*/
|
|
111
|
-
function _insertEcosystemBlock(text,
|
|
111
|
+
function _insertEcosystemBlock(text, block, snippetLines) {
|
|
112
112
|
const snippet = '\n' + snippetLines.join('\n');
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
45
|
-
export const
|
|
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
|
-
/**
|
|
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))
|
|
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
|
}
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
136
|
+
* Matches a runner summary line that reports TEST-CASE counts.
|
|
137
137
|
*
|
|
138
|
-
*
|
|
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
|
-
*
|
|
141
|
-
*
|
|
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
|
|
147
|
-
const
|
|
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`"
|
|
98
|
-
*
|
|
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 (
|
|
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
|
-
|
|
148
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
108
|
-
*
|
|
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
|
-
|
|
120
|
+
s = s.replace(/^priority::?/, '').trim();
|
|
117
121
|
return VALID_PRIORITIES.has(s) ? /** @type {any} */ (s) : null;
|
|
118
122
|
}
|
|
119
123
|
|