session-orchestrator 3.17.0 → 3.19.0

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