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
|
@@ -76,6 +76,46 @@ export function writeMissionStatus(contents, missionStatusArray) {
|
|
|
76
76
|
return updateFrontmatterFields(contents, { 'mission-status': missionStatusArray.slice() });
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Mirrors `status` onto the frontmatter `mission-status` entry whose `id` matches
|
|
81
|
+
* `taskId`, returning a NEW frontmatter object (copy-on-write at object, array and
|
|
82
|
+
* entry level — the input is never mutated, which keeps `parseMissionStatus`'s
|
|
83
|
+
* shallow-copy contract intact for anything else holding the same nested entries).
|
|
84
|
+
*
|
|
85
|
+
* UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
|
|
86
|
+
* with a matching `id`, the frontmatter is returned unchanged. It is deliberately
|
|
87
|
+
* neither created nor an error, because `setMissionStatus(contents, taskId, status)`
|
|
88
|
+
* knows only `id` and `status` — it lacks the `task` and `wave` fields that
|
|
89
|
+
* `validateMissionStatusEntry` (mission-status-schema.mjs) requires, so a synthesised
|
|
90
|
+
* entry would be schema-invalid yet look authoritative to frontmatter consumers such
|
|
91
|
+
* as `vault-status/narrative-mirror.mjs`. Throwing is likewise excluded by the
|
|
92
|
+
* never-throw contract of `setMissionStatus`.
|
|
93
|
+
*
|
|
94
|
+
* `status` is mirrored verbatim without an enum check on purpose: gating it would
|
|
95
|
+
* reintroduce the exact divergence (body says X, frontmatter says Y) this sync exists
|
|
96
|
+
* to remove. An out-of-enum value now lands on BOTH surfaces, where the repo's own
|
|
97
|
+
* validator can see it.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} frontmatter
|
|
100
|
+
* @param {string} taskId
|
|
101
|
+
* @param {string} status
|
|
102
|
+
* @returns {object}
|
|
103
|
+
*/
|
|
104
|
+
function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
|
|
105
|
+
if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
106
|
+
return frontmatter;
|
|
107
|
+
}
|
|
108
|
+
const raw = frontmatter['mission-status'];
|
|
109
|
+
if (!Array.isArray(raw)) return frontmatter;
|
|
110
|
+
const idx = raw.findIndex(
|
|
111
|
+
(e) => e !== null && typeof e === 'object' && !Array.isArray(e) && e.id === taskId
|
|
112
|
+
);
|
|
113
|
+
if (idx === -1) return frontmatter;
|
|
114
|
+
const entries = raw.slice();
|
|
115
|
+
entries[idx] = { ...raw[idx], status };
|
|
116
|
+
return { ...frontmatter, 'mission-status': entries };
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
/**
|
|
80
120
|
* Sets (or updates) the mission status for a single task in the `## Mission Status` body
|
|
81
121
|
* section of STATE.md. Creates the section if it does not exist.
|
|
@@ -83,6 +123,13 @@ export function writeMissionStatus(contents, missionStatusArray) {
|
|
|
83
123
|
* Format of each entry in the section:
|
|
84
124
|
* - <taskId>: <status> (updated <ISO timestamp>)
|
|
85
125
|
*
|
|
126
|
+
* Also mirrors `status` into the frontmatter `mission-status` entry with the same `id`
|
|
127
|
+
* (issue #960 — one writer, two sinks). The body section is what the coordinator writes
|
|
128
|
+
* during a wave; the frontmatter array is what `parseMissionStatus` consumers read
|
|
129
|
+
* (`vault-status/narrative-mirror.mjs`, session-end Phase 1.9/1.10). Before this sync the
|
|
130
|
+
* live writer and the reader sat on different surfaces and drifted apart in both
|
|
131
|
+
* directions. Frontmatter mirroring is UPDATE-ONLY — see `syncFrontmatterMissionStatus`.
|
|
132
|
+
*
|
|
86
133
|
* Pure function — no I/O. Returns original `contents` unchanged on bad input.
|
|
87
134
|
*
|
|
88
135
|
* @param {string} contents - Current STATE.md file contents (string)
|
|
@@ -97,6 +144,9 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
97
144
|
const parsed = parseStateMd(contents);
|
|
98
145
|
if (parsed === null) return contents;
|
|
99
146
|
|
|
147
|
+
// Computed once so every return path below emits the same synced frontmatter.
|
|
148
|
+
const frontmatter = syncFrontmatterMissionStatus(parsed.frontmatter, taskId, status);
|
|
149
|
+
|
|
100
150
|
const timestamp = new Date().toISOString();
|
|
101
151
|
const bullet = `- ${taskId}: ${status} (updated ${timestamp})`;
|
|
102
152
|
const lines = parsed.body.split('\n');
|
|
@@ -115,7 +165,7 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
115
165
|
let bodyOut = parsed.body;
|
|
116
166
|
if (!bodyOut.endsWith('\n')) bodyOut += '\n';
|
|
117
167
|
bodyOut += `\n## Mission Status\n\n${bullet}\n`;
|
|
118
|
-
return serializeStateMd({ frontmatter
|
|
168
|
+
return serializeStateMd({ frontmatter, body: bodyOut });
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
// Find end of section: next ## heading or end of lines
|
|
@@ -155,11 +205,11 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
155
205
|
const before = lines.slice(0, headingIdx + 1);
|
|
156
206
|
const after = lines.slice(insertAt);
|
|
157
207
|
const rebuilt = [...before, '', bullet, ...after];
|
|
158
|
-
return serializeStateMd({ frontmatter
|
|
208
|
+
return serializeStateMd({ frontmatter, body: rebuilt.join('\n') });
|
|
159
209
|
}
|
|
160
210
|
}
|
|
161
211
|
|
|
162
|
-
return serializeStateMd({ frontmatter
|
|
212
|
+
return serializeStateMd({ frontmatter, body: lines.join('\n') });
|
|
163
213
|
}
|
|
164
214
|
|
|
165
215
|
/**
|
|
@@ -14,7 +14,20 @@
|
|
|
14
14
|
* schema_version 1 (integer)
|
|
15
15
|
*
|
|
16
16
|
* Required for event='stop':
|
|
17
|
-
* duration_ms positive integer — wall-clock time from start to stop
|
|
17
|
+
* duration_ms positive integer | null — wall-clock time from start to stop.
|
|
18
|
+
* null means "unknown": the producer could not recover a
|
|
19
|
+
* matching 'start' record to measure against (#917). null is
|
|
20
|
+
* an honest absence and is NOT interchangeable with 0.
|
|
21
|
+
*
|
|
22
|
+
* duration_ms provenance (#917) — why the write path and the read path differ:
|
|
23
|
+
* Before #917 the producer defaulted duration_ms to 0 whenever the harness
|
|
24
|
+
* omitted it, which it always does. Every stop record written up to that point
|
|
25
|
+
* therefore carries a fabricated 0 (measured 2026-07-30: 2770 of 2770 stop
|
|
26
|
+
* records in .orchestrator/metrics/subagents.jsonl). Those records still have
|
|
27
|
+
* to read and migrate cleanly, so validation is lenient BY DEFAULT and accepts
|
|
28
|
+
* 0. Callers on the WRITE path opt into `{ strictDuration: true }`, which
|
|
29
|
+
* rejects 0 outright — appendSubagent() does this, so no new record can
|
|
30
|
+
* reintroduce the fabricated zero.
|
|
18
31
|
*
|
|
19
32
|
* Optional:
|
|
20
33
|
* agent_type string | null — e.g. 'explore', 'writer', 'test-writer'
|
|
@@ -70,14 +83,21 @@ export class ValidationError extends Error {
|
|
|
70
83
|
* violation. Does NOT mutate the input.
|
|
71
84
|
*
|
|
72
85
|
* Required fields: timestamp, event, agent_id, schema_version.
|
|
73
|
-
* Additional requirement when event='stop': duration_ms (
|
|
86
|
+
* Additional requirement when event='stop': duration_ms (integer or null).
|
|
74
87
|
* Optional: agent_type, parent_session_id, token_input, token_output.
|
|
75
88
|
*
|
|
76
89
|
* @param {object} entry
|
|
90
|
+
* @param {object} [options]
|
|
91
|
+
* @param {boolean} [options.strictDuration=false] — when true, a stop record's
|
|
92
|
+
* duration_ms must be a POSITIVE integer or null; 0 is rejected. Write-path
|
|
93
|
+
* callers set this; readers/migrations leave it false so the pre-#917 corpus
|
|
94
|
+
* (every stop record carrying a fabricated 0) still validates. See the module
|
|
95
|
+
* header § duration_ms provenance.
|
|
77
96
|
* @returns {object} the entry (unchanged) — validation is side-effect-free
|
|
78
97
|
* @throws {ValidationError}
|
|
79
98
|
*/
|
|
80
|
-
export function validateSubagent(entry) {
|
|
99
|
+
export function validateSubagent(entry, options = {}) {
|
|
100
|
+
const strictDuration = options?.strictDuration === true;
|
|
81
101
|
if (!entry || typeof entry !== 'object') {
|
|
82
102
|
throw new ValidationError('subagent record must be a non-null object');
|
|
83
103
|
}
|
|
@@ -114,15 +134,24 @@ export function validateSubagent(entry) {
|
|
|
114
134
|
throw new ValidationError('agent_id must be a non-empty string', 'agent_id');
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
// duration_ms — required for stop events
|
|
118
|
-
|
|
137
|
+
// duration_ms — required for stop events.
|
|
138
|
+
//
|
|
139
|
+
// null is an explicit, honest "duration unknown" (#917) — the producer found no
|
|
140
|
+
// matching 'start' record to measure against. It is deliberately distinct from
|
|
141
|
+
// 0, which under strictDuration is rejected as the signature of the pre-#917
|
|
142
|
+
// fabricated default. `undefined` still throws in BOTH modes: a stop record
|
|
143
|
+
// that omits the field entirely is a producer bug, not an unknown duration.
|
|
144
|
+
if (entry.event === 'stop' && entry.duration_ms !== null) {
|
|
145
|
+
const floor = strictDuration ? 1 : 0;
|
|
119
146
|
if (
|
|
120
147
|
typeof entry.duration_ms !== 'number' ||
|
|
121
148
|
!Number.isInteger(entry.duration_ms) ||
|
|
122
|
-
entry.duration_ms <
|
|
149
|
+
entry.duration_ms < floor
|
|
123
150
|
) {
|
|
124
151
|
throw new ValidationError(
|
|
125
|
-
|
|
152
|
+
strictDuration
|
|
153
|
+
? 'duration_ms must be a positive integer or null when event=stop (0 is not a measurement)'
|
|
154
|
+
: 'duration_ms must be a non-negative integer or null when event=stop',
|
|
126
155
|
'duration_ms',
|
|
127
156
|
);
|
|
128
157
|
}
|
|
@@ -252,11 +281,16 @@ export function migrateLegacySubagent(entry) {
|
|
|
252
281
|
* Steps:
|
|
253
282
|
* 1. Stamp schema_version if missing.
|
|
254
283
|
* 2. Normalize (apply optional-field defaults).
|
|
255
|
-
* 3. Validate — throws ValidationError on bad input.
|
|
284
|
+
* 3. Validate with strictDuration — throws ValidationError on bad input.
|
|
256
285
|
* 4. Serialize to JSON + newline.
|
|
257
286
|
* 5. mkdir(dirname, recursive: true).
|
|
258
287
|
* 6. appendFile (POSIX append ≤ PIPE_BUF is atomic for typical JSONL lines).
|
|
259
288
|
*
|
|
289
|
+
* This is the WRITE path, so it validates with `{ strictDuration: true }`: a stop
|
|
290
|
+
* record may carry a positive duration or an explicit null, never 0 (#917). The
|
|
291
|
+
* historic corpus is unaffected — it is only ever read, and readers/migrations use
|
|
292
|
+
* the lenient default. See the module header § duration_ms provenance.
|
|
293
|
+
*
|
|
260
294
|
* @param {string} filePath — absolute path to target .jsonl file
|
|
261
295
|
* @param {object} entry — candidate subagent record
|
|
262
296
|
* @returns {Promise<object>} the validated + normalized entry that was written
|
|
@@ -268,7 +302,7 @@ export async function appendSubagent(filePath, entry) {
|
|
|
268
302
|
schema_version: entry?.schema_version ?? CURRENT_SCHEMA_VERSION,
|
|
269
303
|
};
|
|
270
304
|
const normalized = normalizeSubagent(stamped);
|
|
271
|
-
const validated = validateSubagent(normalized);
|
|
305
|
+
const validated = validateSubagent(normalized, { strictDuration: true });
|
|
272
306
|
const line = JSON.stringify(validated) + '\n';
|
|
273
307
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
274
308
|
await appendFile(filePath, line, 'utf8');
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { execFile as execFileCb } from 'node:child_process';
|
|
39
39
|
import { promisify } from 'node:util';
|
|
40
40
|
import { fingerprintFinding } from './fingerprint.mjs';
|
|
41
|
+
import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
|
|
41
42
|
|
|
42
43
|
const realExecFile = promisify(execFileCb);
|
|
43
44
|
|
|
@@ -293,6 +294,9 @@ function levenshtein(a, b) {
|
|
|
293
294
|
* @param {Set<string>} opts.existingFingerprints - fingerprints of issues already filed
|
|
294
295
|
* @param {boolean} [opts.dryRun] - if true, return command without spawning
|
|
295
296
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
297
|
+
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
|
|
298
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
299
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
296
300
|
* @returns {Promise<{action: 'create'|'noop', iid?: number, command?: string[]}>}
|
|
297
301
|
*/
|
|
298
302
|
export async function reconcileFinding({
|
|
@@ -300,6 +304,9 @@ export async function reconcileFinding({
|
|
|
300
304
|
existingFingerprints,
|
|
301
305
|
dryRun = false,
|
|
302
306
|
execFile: execFileOpt,
|
|
307
|
+
project,
|
|
308
|
+
repoRoot = process.cwd(),
|
|
309
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
303
310
|
}) {
|
|
304
311
|
// Resolve execFile — DI seam for tests; never caller-supplied binary path (ADR-364 §C5 HIGH fix).
|
|
305
312
|
const execFileFn = typeof execFileOpt === 'function' ? execFileOpt : realExecFile;
|
|
@@ -354,6 +361,18 @@ export async function reconcileFinding({
|
|
|
354
361
|
body,
|
|
355
362
|
];
|
|
356
363
|
|
|
364
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
365
|
+
// from the local git remotes (host-pinning — a bare glab spawn falls back
|
|
366
|
+
// to the ambient GITLAB_HOST, which can silently target the wrong
|
|
367
|
+
// instance on a multi-instance host).
|
|
368
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
369
|
+
if (effectiveProject !== undefined) {
|
|
370
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
371
|
+
throw new ReconcileError('project contains forbidden characters', 'VALIDATION');
|
|
372
|
+
}
|
|
373
|
+
cmd.push('--repo', effectiveProject);
|
|
374
|
+
}
|
|
375
|
+
|
|
357
376
|
if (dryRun) {
|
|
358
377
|
return { action: 'create', command: cmd };
|
|
359
378
|
}
|
|
@@ -385,10 +404,12 @@ export async function reconcileFinding({
|
|
|
385
404
|
* fingerprints from issue bodies for downstream dedup.
|
|
386
405
|
*
|
|
387
406
|
* @param {object} [opts]
|
|
388
|
-
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided)
|
|
407
|
+
* @param {string} [opts.project] - GitLab project path (passed via --repo if provided; #872: otherwise auto-detected)
|
|
389
408
|
* @param {string} [opts.label='from:test-runner'] - label filter for the query
|
|
390
409
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB default, #389)
|
|
391
410
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
411
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
412
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
392
413
|
* @returns {Promise<
|
|
393
414
|
* {ok: true, issues: Array<{iid: number, title: string, body: string}>, fingerprints: Set<string>}
|
|
394
415
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -399,6 +420,8 @@ export async function listExistingFindings({
|
|
|
399
420
|
label = 'from:test-runner',
|
|
400
421
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
401
422
|
execFile: execFileOpt,
|
|
423
|
+
repoRoot = process.cwd(),
|
|
424
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
402
425
|
} = {}) {
|
|
403
426
|
// Binary is always the allowlisted value — never caller-supplied (ADR-364 §C5 HIGH fix).
|
|
404
427
|
const bin = RECONCILE_ALLOWLISTED_BINS.glab;
|
|
@@ -414,15 +437,18 @@ export async function listExistingFindings({
|
|
|
414
437
|
|
|
415
438
|
const args = ['issue', 'list', '--label', label, '--output', 'json'];
|
|
416
439
|
|
|
417
|
-
// Optionally scope to a specific project (--repo flag)
|
|
418
|
-
|
|
419
|
-
|
|
440
|
+
// Optionally scope to a specific project (--repo flag). #872: explicit
|
|
441
|
+
// project always wins; otherwise auto-detect a --repo spec from the local
|
|
442
|
+
// git remotes (host-pinning).
|
|
443
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
444
|
+
if (effectiveProject !== undefined) {
|
|
445
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
420
446
|
return {
|
|
421
447
|
ok: false,
|
|
422
448
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
423
449
|
};
|
|
424
450
|
}
|
|
425
|
-
args.push('--repo',
|
|
451
|
+
args.push('--repo', effectiveProject);
|
|
426
452
|
}
|
|
427
453
|
|
|
428
454
|
let stdout;
|
|
@@ -501,7 +527,7 @@ export async function listExistingFindings({
|
|
|
501
527
|
* Create a new finding as a GitLab issue.
|
|
502
528
|
*
|
|
503
529
|
* @param {object} opts
|
|
504
|
-
* @param {string} [opts.project] - GitLab project path (--repo)
|
|
530
|
+
* @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
|
|
505
531
|
* @param {string} opts.fingerprint - 16-hex fingerprint (appended as sentinel)
|
|
506
532
|
* @param {string} opts.title - issue title (no [Test] prefix added here — caller decides)
|
|
507
533
|
* @param {string} opts.body - issue description body; must not exceed 65536 bytes (#389)
|
|
@@ -509,6 +535,8 @@ export async function listExistingFindings({
|
|
|
509
535
|
* @param {boolean} [opts.dryRun=false] - if true, return command without spawning
|
|
510
536
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
|
|
511
537
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
538
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
539
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
512
540
|
* @returns {Promise<
|
|
513
541
|
* {ok: true, action: 'create', iid?: number, command?: string[]}
|
|
514
542
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -523,6 +551,8 @@ export async function createFinding({
|
|
|
523
551
|
dryRun = false,
|
|
524
552
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
525
553
|
execFile: execFileOpt,
|
|
554
|
+
repoRoot = process.cwd(),
|
|
555
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
526
556
|
}) {
|
|
527
557
|
// --- Input validation ---
|
|
528
558
|
if (typeof title !== 'string' || title.length === 0) {
|
|
@@ -562,14 +592,17 @@ export async function createFinding({
|
|
|
562
592
|
|
|
563
593
|
const args = ['issue', 'create', '--title', title, '--label', labels, '--description', body];
|
|
564
594
|
|
|
565
|
-
|
|
566
|
-
|
|
595
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
596
|
+
// from the local git remotes (host-pinning).
|
|
597
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
598
|
+
if (effectiveProject !== undefined) {
|
|
599
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
567
600
|
return {
|
|
568
601
|
ok: false,
|
|
569
602
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
570
603
|
};
|
|
571
604
|
}
|
|
572
|
-
args.push('--repo',
|
|
605
|
+
args.push('--repo', effectiveProject);
|
|
573
606
|
}
|
|
574
607
|
|
|
575
608
|
if (dryRun) {
|
|
@@ -616,12 +649,14 @@ export async function createFinding({
|
|
|
616
649
|
* Add a comment to an existing finding issue.
|
|
617
650
|
*
|
|
618
651
|
* @param {object} opts
|
|
619
|
-
* @param {string} [opts.project] - GitLab project path (--repo)
|
|
652
|
+
* @param {string} [opts.project] - GitLab project path (--repo; #872: otherwise auto-detected)
|
|
620
653
|
* @param {number} opts.iid - issue IID to comment on
|
|
621
654
|
* @param {string} opts.comment - comment body; must not exceed 65536 bytes (#389)
|
|
622
655
|
* @param {boolean} [opts.dryRun=false] - if true, return command without spawning
|
|
623
656
|
* @param {number} [opts.maxBuffer=4194304] - maxBuffer for execFile (4 MB, #389)
|
|
624
657
|
* @param {Function} [opts.execFile] - DI seam for testing; defaults to node:child_process execFile
|
|
658
|
+
* @param {string} [opts.repoRoot=process.cwd()] - cwd passed to resolveRepoSpecFn for remote auto-detection
|
|
659
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab' }) => string|undefined} [opts.resolveRepoSpecFn] - injectable `--repo` auto-detector; defaults to the real `resolveRepoSpec`
|
|
625
660
|
* @returns {Promise<
|
|
626
661
|
* {ok: true, action: 'comment', command?: string[]}
|
|
627
662
|
* | {ok: false, error: {code: string, message: string}}
|
|
@@ -634,6 +669,8 @@ export async function updateFinding({
|
|
|
634
669
|
dryRun = false,
|
|
635
670
|
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
636
671
|
execFile: execFileOpt,
|
|
672
|
+
repoRoot = process.cwd(),
|
|
673
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
637
674
|
}) {
|
|
638
675
|
// --- Input validation ---
|
|
639
676
|
if (!Number.isInteger(iid) || iid < 1) {
|
|
@@ -657,14 +694,17 @@ export async function updateFinding({
|
|
|
657
694
|
// iid is a number — safe to convert to string; no arg-boundary concerns
|
|
658
695
|
const args = ['issue', 'note', String(iid), '--message', comment];
|
|
659
696
|
|
|
660
|
-
|
|
661
|
-
|
|
697
|
+
// #872: explicit project always wins; otherwise auto-detect a --repo spec
|
|
698
|
+
// from the local git remotes (host-pinning).
|
|
699
|
+
const effectiveProject = project ?? resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
700
|
+
if (effectiveProject !== undefined) {
|
|
701
|
+
if (ARG_BOUNDARY_DANGEROUS.test(effectiveProject)) {
|
|
662
702
|
return {
|
|
663
703
|
ok: false,
|
|
664
704
|
error: { code: 'VALIDATION', message: 'project contains forbidden characters' },
|
|
665
705
|
};
|
|
666
706
|
}
|
|
667
|
-
args.push('--repo',
|
|
707
|
+
args.push('--repo', effectiveProject);
|
|
668
708
|
}
|
|
669
709
|
|
|
670
710
|
if (dryRun) {
|