session-orchestrator 3.16.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) 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 +130 -412
  6. package/README.md +17 -12
  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/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -46,6 +46,7 @@ import { join } from 'node:path';
46
46
  import { randomBytes } from 'node:crypto';
47
47
 
48
48
  import { readPeerCards } from './lib/peer-cards/reader.mjs';
49
+ import { filterRealSessions } from './lib/session-schema/filters.mjs';
49
50
 
50
51
  // ---------------------------------------------------------------------------
51
52
  // Constants
@@ -392,7 +393,11 @@ async function readTopLearnings(repoRoot, topN) {
392
393
  }
393
394
 
394
395
  /**
395
- * Read and rank sessions: sort by `completed_at` DESC, take last K.
396
+ * Read and rank sessions: filter out phantom `status: 'abandoned'` stubs
397
+ * (#834, session-close-backfill), sort by `completed_at` DESC, take last K.
398
+ *
399
+ * Without the filter, phantoms (0 waves, seconds of runtime) can displace
400
+ * REAL session context out of the K-window the derivation prompt sees.
396
401
  *
397
402
  * @param {string} repoRoot
398
403
  * @param {number} lastK
@@ -400,7 +405,7 @@ async function readTopLearnings(repoRoot, topN) {
400
405
  */
401
406
  async function readLastSessions(repoRoot, lastK) {
402
407
  const path = join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
403
- const entries = await readJsonlBestEffort(path);
408
+ const entries = filterRealSessions(await readJsonlBestEffort(path));
404
409
  entries.sort((a, b) => {
405
410
  const ta = typeof a?.completed_at === 'string' ? a.completed_at : '';
406
411
  const tb = typeof b?.completed_at === 'string' ? b.completed_at : '';
@@ -10,6 +10,36 @@
10
10
  * - Data → stdout, diagnostics → stderr.
11
11
  * - Exit codes: 0 success/match · 1 user-error/drift · 2 system error.
12
12
  *
13
+ * ## Append failures are NOT exit 0 (GitLab #969)
14
+ *
15
+ * `appendEvalRecord` is a never-throw sink: it returns
16
+ * `{ ok:false, reason:'validation'|'fs-error' }` instead of raising. This CLI
17
+ * used to log that WARN and still `process.exit(0)` — the caller saw success
18
+ * while the journal had no record, one surface reporting a fact the other did
19
+ * not carry. A failed append now exits non-zero, split by reason:
20
+ *
21
+ * - `fs-error` → EXIT_SYSTEM (2). The filesystem refused the append
22
+ * (permissions, ENOTDIR, full disk) — "system error" per cli-design.md.
23
+ * - `validation` → EXIT_USER (1). Reachable from the invocation: `--handle ""`
24
+ * survives the `?? null` default as an empty string and trips the
25
+ * "handle must be a non-empty string or null" rule in eval/schema.mjs.
26
+ * Malformed metrics input reaches the same path, and cli-design.md maps
27
+ * "bad args, invalid file" to 1. This matches the rest of this file, where
28
+ * every input-derived failure is EXIT_USER and only engine crashes are
29
+ * EXIT_SYSTEM. (An engine-internal invariant breach also lands here — a
30
+ * mild under-classification, accepted so the reachable case stays honest.)
31
+ *
32
+ * Both branches emit the `--json` / human payload to stdout BEFORE exiting, and
33
+ * do so via `writeStdoutLineSync` rather than a queued write: `process.stdout`
34
+ * is asynchronous on a pipe on macOS, so `process.exit()` discards whatever is
35
+ * still in libuv's queue past the 65 536-byte kernel buffer. Losing the record
36
+ * alongside the error is the exact fail-open shape #906 fixed in the hook layer.
37
+ * Real records measure ≤ 5 210 bytes today, but `evidence` strings are
38
+ * engine-generated free text, so the bound is empirical, not structural.
39
+ *
40
+ * Session-end Phase 3.7d remains advisory and MUST NOT gate on this exit code —
41
+ * it catches a non-zero exit and logs a WARN (skills/session-end/SKILL.md).
42
+ *
13
43
  * Usage:
14
44
  * eval-session.mjs [--session <id>] [--json] [--no-write]
15
45
  * [--metrics-dir <path>] [--rubric <path>]
@@ -29,6 +59,7 @@ import { fileURLToPath } from 'node:url';
29
59
 
30
60
  import { evaluateSession, diffDimensions, DEFAULT_RUBRIC_PATH, RUBRIC_VERSION } from './lib/eval/engine.mjs';
31
61
  import { appendEvalRecord, readEvalRecords } from './lib/eval/sink.mjs';
62
+ import { writeStdoutLineSync } from './lib/io.mjs';
32
63
  import { VALID_MODEL_SOURCES } from './lib/eval/schema.mjs';
33
64
  import { SessionResolutionError } from './lib/eval/session-resolve.mjs';
34
65
 
@@ -69,8 +100,9 @@ OPTIONS
69
100
 
70
101
  EXIT CODES
71
102
  0 success / verify match
72
- 1 user error (session not found, unknown run-id) / verify drift
73
- 2 system error
103
+ 1 user error (session not found, unknown run-id, record failed validation)
104
+ / verify drift
105
+ 2 system error (could not append the record to the eval journal)
74
106
  `;
75
107
 
76
108
  function fail(exitCode, message) {
@@ -144,18 +176,27 @@ function runEvaluate(values) {
144
176
  const { record, summary } = result;
145
177
 
146
178
  let writeResult = null;
179
+ let appendFailureReason = null;
147
180
  if (!values['no-write']) {
148
181
  writeResult = appendEvalRecord(record, { path: path.join(metricsDir, 'eval.jsonl') });
149
182
  if (!writeResult.ok) {
150
- // never-throw sink already emitted a stderr WARN; surface it as a soft note.
183
+ // The never-throw sink already emitted its own stderr WARN; add the
184
+ // CLI-level line, then carry the reason to the exit code below.
151
185
  process.stderr.write(`[eval-session] append failed (${writeResult.reason}): ${writeResult.error}\n`);
186
+ appendFailureReason = writeResult.reason;
152
187
  }
153
188
  }
154
189
 
190
+ // stdout FIRST, synchronously — the caller must not lose the payload along
191
+ // with the error (see the append-failure note in the module docblock).
155
192
  if (values.json) {
156
- process.stdout.write(`${JSON.stringify(record)}\n`);
193
+ writeStdoutLineSync(JSON.stringify(record));
157
194
  } else {
158
- process.stdout.write(`${renderHuman(record, summary, writeResult)}\n`);
195
+ writeStdoutLineSync(renderHuman(record, summary, writeResult));
196
+ }
197
+
198
+ if (appendFailureReason !== null) {
199
+ process.exit(appendFailureReason === 'validation' ? EXIT_USER : EXIT_SYSTEM);
159
200
  }
160
201
  process.exit(EXIT_OK);
161
202
  }
@@ -195,15 +236,15 @@ function runVerify(runId, values) {
195
236
 
196
237
  if (diffs.length === 0) {
197
238
  if (values.json) {
198
- process.stdout.write(`${JSON.stringify({ run_id: runId, match: true, dimensions: fresh.dimensions.length })}\n`);
239
+ writeStdoutLineSync(JSON.stringify({ run_id: runId, match: true, dimensions: fresh.dimensions.length }));
199
240
  } else {
200
- process.stdout.write(`MATCH: ${runId} re-evaluates identically across ${fresh.dimensions.length} dimension(s).\n`);
241
+ writeStdoutLineSync(`MATCH: ${runId} re-evaluates identically across ${fresh.dimensions.length} dimension(s).`);
201
242
  }
202
243
  process.exit(EXIT_OK);
203
244
  }
204
245
 
205
246
  if (values.json) {
206
- process.stdout.write(`${JSON.stringify({ run_id: runId, match: false, diffs })}\n`);
247
+ writeStdoutLineSync(JSON.stringify({ run_id: runId, match: false, diffs }));
207
248
  } else {
208
249
  const out = [`DRIFT: ${runId} re-evaluation differs from the stored record:`];
209
250
  for (const d of diffs) {
@@ -213,7 +254,7 @@ function runVerify(runId, values) {
213
254
  out.push(` ${d.id}.${d.field}: stored=${JSON.stringify(d.stored)} fresh=${JSON.stringify(d.fresh)}`);
214
255
  }
215
256
  }
216
- process.stdout.write(`${out.join('\n')}\n`);
257
+ writeStdoutLineSync(out.join('\n'));
217
258
  }
218
259
  process.exit(EXIT_USER);
219
260
  }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fleet-instruction-scan.mjs — always-on instruction load across every repo on the host.
4
+ *
5
+ * `scripts/measure-context-overhead.sh` measures one directory precisely but
6
+ * costs an API call per measurement. This scans the whole fleet for free by
7
+ * counting bytes and classifying them the way `scripts/lib/rule-loader.mjs`
8
+ * does: a rule file is always-on unless its frontmatter carries `globs:` or
9
+ * `paths:` (issue #795), and it reaches wave agents unless it is tagged
10
+ * `tier: coordinator-only` (issue #692).
11
+ *
12
+ * Token estimates use the ratio measured 2026-07-30 on claude-opus-5[1m]:
13
+ * 108589 always-on bytes in this repo produced roughly 67396 context tokens,
14
+ * i.e. about 1 token per 1.6 bytes. Treat the estimate as an order of
15
+ * magnitude and confirm a specific repo with measure-context-overhead.sh.
16
+ *
17
+ * Usage:
18
+ * node scripts/fleet-instruction-scan.mjs
19
+ * node scripts/fleet-instruction-scan.mjs ~/Projects/intern ~/Projects/extern
20
+ * node scripts/fleet-instruction-scan.mjs --json
21
+ */
22
+
23
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { basename, join } from 'node:path';
26
+
27
+ const BYTES_PER_TOKEN = 1.6;
28
+
29
+ const argv = process.argv.slice(2);
30
+ const asJson = argv.includes('--json');
31
+ const roots = argv.filter((a) => !a.startsWith('--'));
32
+ const ROOTS = roots.length
33
+ ? roots
34
+ : [
35
+ join(homedir(), 'Projects', 'extern'),
36
+ join(homedir(), 'Projects', 'intern'),
37
+ join(homedir(), 'Projects', 'Ventures'),
38
+ join(homedir(), 'Projects', 'Bernhard'),
39
+ ];
40
+
41
+ /** Classify one rule file the way rule-loader.mjs does. */
42
+ function classify(path) {
43
+ const body = readFileSync(path, 'utf8');
44
+ const fmMatch = body.match(/^---\n([\s\S]*?)\n---/);
45
+ const fm = fmMatch ? fmMatch[1] : '';
46
+ const tierMatch = fm.match(/^tier:\s*(\S+)/m);
47
+ return {
48
+ scoped: /^(globs|paths):/m.test(fm),
49
+ tier: tierMatch ? tierMatch[1] : 'always',
50
+ bytes: Buffer.byteLength(body),
51
+ };
52
+ }
53
+
54
+ function scanRepo(dir) {
55
+ let alwaysBytes = 0;
56
+ let waveBytes = 0;
57
+ let files = 0;
58
+ let scopedFiles = 0;
59
+ const rulesDir = join(dir, '.claude', 'rules');
60
+ if (existsSync(rulesDir)) {
61
+ for (const f of readdirSync(rulesDir).filter((n) => n.endsWith('.md'))) {
62
+ const c = classify(join(rulesDir, f));
63
+ files++;
64
+ if (c.scoped) {
65
+ scopedFiles++;
66
+ continue;
67
+ }
68
+ alwaysBytes += c.bytes;
69
+ if (c.tier !== 'coordinator-only') waveBytes += c.bytes;
70
+ }
71
+ }
72
+ const instructionFile = ['CLAUDE.md', 'AGENTS.md'].map((f) => join(dir, f)).find(existsSync);
73
+ const cmBytes = instructionFile ? statSync(instructionFile).size : 0;
74
+ return { files, scopedFiles, alwaysBytes, waveBytes, cmBytes, total: alwaysBytes + cmBytes };
75
+ }
76
+
77
+ const rows = [];
78
+ for (const root of ROOTS) {
79
+ if (!existsSync(root)) continue;
80
+ for (const name of readdirSync(root)) {
81
+ const dir = join(root, name);
82
+ try {
83
+ if (!statSync(dir).isDirectory()) continue;
84
+ } catch {
85
+ continue;
86
+ }
87
+ if (!existsSync(join(dir, '.git'))) continue;
88
+ rows.push({ repo: `${basename(root)}/${name}`, ...scanRepo(dir) });
89
+ }
90
+ }
91
+ rows.sort((a, b) => b.total - a.total);
92
+
93
+ if (asJson) {
94
+ console.log(JSON.stringify({ bytesPerToken: BYTES_PER_TOKEN, repos: rows }, null, 2));
95
+ process.exit(0);
96
+ }
97
+
98
+ const pad = (s, n) => String(s).padStart(n);
99
+ console.log(
100
+ 'REPO'.padEnd(36),
101
+ pad('RULES', 6),
102
+ pad('SCOPED', 7),
103
+ pad('ALWAYS_B', 9),
104
+ pad('WAVE_B', 8),
105
+ pad('INSTR_B', 8),
106
+ pad('EST_TOK', 8),
107
+ );
108
+ for (const r of rows) {
109
+ console.log(
110
+ r.repo.padEnd(36),
111
+ pad(r.files, 6),
112
+ pad(r.scopedFiles, 7),
113
+ pad(r.alwaysBytes, 9),
114
+ pad(r.waveBytes, 8),
115
+ pad(r.cmBytes, 8),
116
+ pad(Math.round(r.total / BYTES_PER_TOKEN), 8),
117
+ );
118
+ }
119
+
120
+ const totalFiles = rows.reduce((a, r) => a + r.files, 0);
121
+ const totalScoped = rows.reduce((a, r) => a + r.scopedFiles, 0);
122
+ const median = (xs) => {
123
+ const s = [...xs].sort((a, b) => a - b);
124
+ return s.length ? s[Math.floor(s.length / 2)] : 0;
125
+ };
126
+ const withRules = rows.filter((r) => r.files > 0);
127
+
128
+ console.log(`\nrepos scanned : ${rows.length} (${withRules.length} with rule files)`);
129
+ console.log(`rule files : ${totalFiles} (${totalScoped} glob/path-scoped, ${totalFiles - totalScoped} always-on)`);
130
+ console.log(`median always-on bytes : ${median(withRules.map((r) => r.alwaysBytes))}`);
131
+ console.log(`median est. tokens/session: ${Math.round(median(withRules.map((r) => r.total)) / BYTES_PER_TOKEN)}`);
132
+
133
+ // A value shared by many repos means a vendored rule set: trimming it at the
134
+ // source propagates everywhere, which is the cheapest lever in the fleet.
135
+ const byBytes = new Map();
136
+ for (const r of withRules) byBytes.set(r.alwaysBytes, (byBytes.get(r.alwaysBytes) || 0) + 1);
137
+ const shared = [...byBytes.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]);
138
+ if (shared.length) {
139
+ console.log('\nidentical always-on totals (vendored rule sets — trim once, propagate widely):');
140
+ for (const [bytes, n] of shared.slice(0, 5)) console.log(` ${bytes} B in ${n} repos`);
141
+ }
@@ -22,6 +22,8 @@ import { existsSync } from 'node:fs';
22
22
  import { randomUUID } from 'node:crypto';
23
23
  import path from 'node:path';
24
24
 
25
+ import { filterRealSessions } from './session-schema.mjs';
26
+
25
27
  // ---------------------------------------------------------------------------
26
28
  // Constants
27
29
  // ---------------------------------------------------------------------------
@@ -121,13 +123,20 @@ export async function readDialecticSignals({ repoRoot } = {}) {
121
123
  try {
122
124
  const raw = await readFile(sessionsPath, 'utf8');
123
125
  const lines = raw.split('\n').filter((l) => l.length > 0);
126
+ const entries = [];
124
127
  for (const line of lines) {
125
- let entry;
126
128
  try {
127
- entry = JSON.parse(line);
129
+ entries.push(JSON.parse(line));
128
130
  } catch {
129
131
  continue; // malformed line — skip silently
130
132
  }
133
+ }
134
+ // Abandoned-session filter (#834): mirrors auto-dream.mjs
135
+ // readDreamSignals() — phantom `status: 'abandoned'` stubs are
136
+ // legitimate DATA but not legitimate SIGNAL, and must not fire the
137
+ // /evolve --dialectic cadence off zero real work.
138
+ const realEntries = filterRealSessions(entries);
139
+ for (const entry of realEntries) {
131
140
  const startedAt = entry.started_at;
132
141
  if (typeof startedAt !== 'string' || startedAt.length === 0) continue;
133
142
  if (lastRunAt === null || startedAt > lastRunAt) {
@@ -25,6 +25,8 @@ import { existsSync, statSync } from 'node:fs';
25
25
  import { randomUUID } from 'node:crypto';
26
26
  import path from 'node:path';
27
27
 
28
+ import { filterRealSessions } from './session-schema.mjs';
29
+
28
30
  // ---------------------------------------------------------------------------
29
31
  // Signal reader — MEMORY.md size + sessions-since-last-cleanup
30
32
  // ---------------------------------------------------------------------------
@@ -74,8 +76,16 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
74
76
  }
75
77
  }
76
78
 
77
- // Find the most recent memory_cleanup_at timestamp across all entries.
78
- for (const entry of entries) {
79
+ // Abandoned-session filter (#834): sessions.jsonl carries phantom
80
+ // `status: 'abandoned'` stubs (session-close-backfill records for
81
+ // sessions that ended without a real close). They are legitimate DATA
82
+ // but not legitimate SIGNAL — a burst of abandoned stubs must not fire
83
+ // /memory-cleanup off zero real work. filterRealSessions() is the
84
+ // shared, tested implementation (scripts/lib/session-schema/filters.mjs).
85
+ const realEntries = filterRealSessions(entries);
86
+
87
+ // Find the most recent memory_cleanup_at timestamp across all REAL entries.
88
+ for (const entry of realEntries) {
79
89
  const ts = entry.memory_cleanup_at;
80
90
  if (typeof ts === 'string' && ts.length > 0) {
81
91
  if (lastCleanupAt === null || ts > lastCleanupAt) {
@@ -84,11 +94,12 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
84
94
  }
85
95
  }
86
96
 
87
- // Count entries newer than the last cleanup (or total when no cleanup ever ran).
97
+ // Count REAL entries newer than the last cleanup (or total REAL entries
98
+ // when no cleanup ever ran).
88
99
  if (lastCleanupAt === null) {
89
- sessionsSinceCleanup = entries.length;
100
+ sessionsSinceCleanup = realEntries.length;
90
101
  } else {
91
- for (const entry of entries) {
102
+ for (const entry of realEntries) {
92
103
  const startedAt = entry.started_at;
93
104
  if (typeof startedAt === 'string' && startedAt > lastCleanupAt) {
94
105
  sessionsSinceCleanup += 1;
@@ -13,6 +13,8 @@
13
13
  import { execFile as execFileCb } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
15
 
16
+ import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
17
+
16
18
  const realExecFile = promisify(execFileCb);
17
19
 
18
20
  // ---------------------------------------------------------------------------
@@ -112,13 +114,22 @@ export function validateMrInputs(title, description) {
112
114
  * @param {'glab'|'gh'} opts.vcs
113
115
  * @param {string} opts.branchName
114
116
  * @param {Function} [opts.execFile] - defaults to promisified execFile from node:child_process
117
+ * @param {string} [opts.repoRoot] - defaults to process.cwd()
118
+ * @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
119
+ * Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
120
+ * defaults to the real `resolveRepoSpec` (shells out to `git remote
121
+ * get-url`); tests inject a stub instead of shelling out.
115
122
  * @returns {Promise<{hasMR: boolean, mrIid: number|null, mrUrl: string|null}>}
116
123
  */
117
124
  export async function checkExistingMR(opts) {
118
- const { vcs, branchName } = opts;
125
+ const { vcs, branchName, repoRoot = process.cwd() } = opts;
119
126
  const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
127
+ const resolveRepoSpecFn =
128
+ typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
120
129
 
121
130
  if (vcs === 'glab') {
131
+ // Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
132
+ const spec = resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
122
133
  const args = [
123
134
  'mr',
124
135
  'list',
@@ -129,6 +140,7 @@ export async function checkExistingMR(opts) {
129
140
  '--output',
130
141
  'json',
131
142
  ];
143
+ if (spec) args.push('-R', spec);
132
144
  const { stdout } = await execFile('glab', args, { shell: false, timeout: 5_000 });
133
145
  let mrs;
134
146
  try {
@@ -148,7 +160,10 @@ export async function checkExistingMR(opts) {
148
160
  }
149
161
 
150
162
  if (vcs === 'gh') {
163
+ // Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
164
+ const spec = resolveRepoSpecFn({ repoRoot, vcs: 'github' });
151
165
  const args = ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,url'];
166
+ if (spec) args.push('-R', spec);
152
167
  const { stdout } = await execFile('gh', args, { shell: false, timeout: 5_000 });
153
168
  let prs;
154
169
  try {
@@ -391,6 +406,11 @@ export function buildMrBody(ctx) {
391
406
  * @param {object} [opts]
392
407
  * @param {Function} [opts.execFile]
393
408
  * @param {(level: string, msg: string) => void} [opts.log]
409
+ * @param {string} [opts.repoRoot] - defaults to process.cwd()
410
+ * @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
411
+ * Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
412
+ * defaults to the real `resolveRepoSpec` (shells out to `git remote
413
+ * get-url`); tests inject a stub instead of shelling out.
394
414
  * @returns {Promise<{created: boolean, deferred?: boolean, existing?: boolean, mrUrl?: string|null, error?: string}>}
395
415
  */
396
416
  export async function maybeCreateDraftMR(loop, opts = {}) {
@@ -399,6 +419,9 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
399
419
  ? opts.log
400
420
  : (_level, _msg) => {}; // no-op default
401
421
  const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
422
+ const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
423
+ const resolveRepoSpecFn =
424
+ typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
402
425
 
403
426
  const { draftMrPolicy, vcs, issueIid, issueTitle, branchName, parentRunId, worktreePath } = loop;
404
427
 
@@ -442,6 +465,11 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
442
465
  worktreePath,
443
466
  });
444
467
 
468
+ // Resolve the -R/--repo host-pinning spec ONCE (#872); reuse the same
469
+ // resolved value for both the collision check and the create call below
470
+ // (mirrors #839's spiral-carryover.mjs / issue-close-strip-labels.mjs idiom).
471
+ const spec = resolveRepoSpecFn({ repoRoot, vcs });
472
+
445
473
  // Collision check — skip if MR already exists
446
474
  let existingCheck;
447
475
  try {
@@ -449,6 +477,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
449
477
  vcs: vcsBin, // 'glab' or 'gh'
450
478
  branchName,
451
479
  execFile,
480
+ resolveRepoSpecFn: () => spec,
452
481
  });
453
482
  } catch (err) {
454
483
  log('error', `mr-draft: collision check failed — ${err.message}`);
@@ -491,6 +520,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
491
520
  branchName,
492
521
  ];
493
522
  }
523
+ if (spec) createArgs.push('-R', spec);
494
524
 
495
525
  // Execute MR/PR creation
496
526
  try {
@@ -32,7 +32,8 @@ import fs, { realpathSync } from 'node:fs';
32
32
  import { runLoop } from './loop.mjs';
33
33
  import { maybeCreateDraftMR } from './mr-draft.mjs';
34
34
  import { validateWorkspacePath } from '../worktree/lifecycle.mjs';
35
- import { acquire, release } from '../session-lock.mjs';
35
+ import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
36
+ import { emitEvent } from '../events.mjs';
36
37
  import { main as gcMain } from '../../gc-stale-worktrees.mjs';
37
38
  import { SEMANTIC_ID_RE } from '../session-id.mjs';
38
39
 
@@ -65,6 +66,9 @@ import { SEMANTIC_ID_RE } from '../session-id.mjs';
65
66
  * @property {string|null} killSwitch
66
67
  * @property {string|null} killSwitchDetail
67
68
  * @property {number} iterationsCompleted
69
+ * @property {boolean} fallbackToManual - Mirrors loop.mjs's `fallback_to_manual` (#905): true
70
+ * when the inner loop broke out with no mode recommendation instead of doing real work.
71
+ * The caller (autopilot-multi.mjs) MUST NOT treat a true value as a completed loop.
68
72
  * @property {number} spiralRecoveryCount
69
73
  * @property {string|null} commitDependency
70
74
  * @property {boolean} abortedByCohort
@@ -128,6 +132,42 @@ function repoBasename(repoRoot) {
128
132
  return path.basename(repoRoot);
129
133
  }
130
134
 
135
+ /**
136
+ * Render a worktree path for an event record: repo-relative, never absolute.
137
+ *
138
+ * `events.jsonl` records get quoted into issues and MR descriptions, and this
139
+ * repo mirrors to a PUBLIC GitHub remote — so an absolute worktree path ships
140
+ * the operator's home directory (`/Users/<name>/…`) into a public artefact
141
+ * (#957 finding 3). The forensic value of the field is "WHICH worktree", which
142
+ * the repo-relative form carries in full.
143
+ *
144
+ * Escaping the repo root is expected, not an error: the default worktree root
145
+ * is `~/.so-worktrees`, so the normal output is a `../…` chain. That form is
146
+ * still host-agnostic — `path.relative` strips the COMMON PREFIX, which is
147
+ * exactly where `/Users/<name>` lives.
148
+ *
149
+ * Pure string math: no `realpathSync`, no fs access, cannot throw. This runs on
150
+ * the teardown path where the worktree may already have been removed.
151
+ *
152
+ * Residual, deliberately not defended against: a repo root OUTSIDE the home
153
+ * directory combined with a worktree root INSIDE it (e.g. `/srv/repo` +
154
+ * `~/.so-worktrees`) shares no prefix, so the `../` chain descends back through
155
+ * `Users/<name>/`. That configuration is not reachable from this module's
156
+ * defaults and a heuristic scrub would be less predictable than the plain
157
+ * relative path.
158
+ *
159
+ * @param {string} repoRoot absolute path to the primary repo
160
+ * @param {string|null|undefined} worktreePath absolute worktree path, if known
161
+ * @returns {string|null} repo-relative path, or null when either input is unusable
162
+ */
163
+ export function relativeWorktreePath(repoRoot, worktreePath) {
164
+ if (typeof worktreePath !== 'string' || worktreePath.length === 0) return null;
165
+ // Without a usable base there is no way to strip the host prefix. Report
166
+ // "unknown" rather than fall back to the absolute path this exists to avoid.
167
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
168
+ return path.relative(repoRoot, worktreePath);
169
+ }
170
+
131
171
  // ---------------------------------------------------------------------------
132
172
  // setupWorktree
133
173
  // ---------------------------------------------------------------------------
@@ -230,6 +270,9 @@ export async function setupWorktree(context, opts = {}) {
230
270
  * - If `result.killSwitch` is `'stall-timeout'`: leave the worktree intact
231
271
  * for retry. Only release the lock.
232
272
  * - Otherwise: call `opts.gcOnExit` to clean up, then release the lock.
273
+ * - The lock-release outcome is evaluated and emitted as an
274
+ * `orchestrator.session.lock.released` / `…release_failed` breadcrumb
275
+ * (`caller: 'worktree-pipeline'`) into the PARENT repo's events stream (#952).
233
276
  * - Errors during teardown are swallowed (logged to stderr). Never re-throws.
234
277
  *
235
278
  * @param {StoryContext} context
@@ -267,13 +310,69 @@ export async function teardownWorktree(context, result, opts = {}) {
267
310
 
268
311
  // Always attempt to release the per-worktree lock.
269
312
  if (result._lockSessionId) {
313
+ /** @type {{ ok: boolean, deleted?: boolean, reason?: string, verified?: boolean }|null} */
314
+ let releaseResult = null;
270
315
  try {
271
- release({ sessionId: result._lockSessionId, repoRoot: result.worktreePath });
316
+ // #987 defense-in-depth: pass the in-memory genesis proof (captured by
317
+ // runStoryPipeline right after acquire — same process, no file I/O)
318
+ // so the delete is double-gated at the fs layer. Spread-guarded:
319
+ // release()'s proof gate triggers on `proof !== undefined`, so a
320
+ // null/absent proof MUST be omitted entirely or EVERY release would
321
+ // be refused with 'proof-mismatch'.
322
+ releaseResult = release({
323
+ sessionId: result._lockSessionId,
324
+ repoRoot: result.worktreePath,
325
+ ...(result._lockOwnerProof ? { proof: result._lockOwnerProof } : {}),
326
+ });
272
327
  } catch (lockErr) {
273
328
  console.error(
274
329
  `worktree-pipeline: lock release error for issue #${context.issueIid}: ${lockErr?.message ?? String(lockErr)}`,
275
330
  );
276
331
  }
332
+
333
+ // #952 (A) — observability breadcrumb for the SECOND release() call-site.
334
+ // Before this, the call above was fire-and-forget: its structured result was
335
+ // discarded entirely, so a per-worktree lock that failed to release — or
336
+ // that had already vanished — left NO trace anywhere. That is the blind
337
+ // spot behind the "#914 lock disappears in the start chain" class for
338
+ // worktree-isolated runs.
339
+ //
340
+ // Emitted at the CALL-SITE, never inside release(): session-lock.mjs
341
+ // deliberately carries no dependency on events.mjs, and release() is
342
+ // synchronous while emitEvent() is async. `caller` keeps this path
343
+ // distinguishable from hooks/on-session-end.mjs in the single stream.
344
+ //
345
+ // Destination is PINNED to the parent repo (`repoRoot` via the #941
346
+ // `opts.repoRoot` interface — same pattern as scripts/lib/lock-reaper.mjs)
347
+ // rather than the ambient SO_PROJECT_DIR: this pipeline deliberately runs
348
+ // across worktrees with an explicit repoRoot (#219 CWD-drift), and the
349
+ // worktree itself may already have been removed by the gc step above, so
350
+ // its own `.orchestrator/` is not a valid destination.
351
+ const benignAlreadyGone = releaseResult?.ok === true && releaseResult.reason === 'no-lock';
352
+ const releaseFailed = releaseResult === null
353
+ || (!benignAlreadyGone && (releaseResult.ok !== true || releaseResult.deleted !== true));
354
+ try {
355
+ if (releaseFailed) {
356
+ await emitEvent('orchestrator.session.lock.release_failed', {
357
+ session_id: result._lockSessionId,
358
+ reason: releaseResult === null
359
+ ? 'threw'
360
+ : (releaseResult.reason ?? (releaseResult.ok === true ? 'not-deleted' : 'fs-error')),
361
+ caller: 'worktree-pipeline',
362
+ worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
363
+ issue_iid: context.issueIid,
364
+ }, { repoRoot });
365
+ } else {
366
+ await emitEvent('orchestrator.session.lock.released', {
367
+ session_id: result._lockSessionId,
368
+ caller: 'worktree-pipeline',
369
+ outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
370
+ verified: releaseResult.verified === true,
371
+ worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
372
+ issue_iid: context.issueIid,
373
+ }, { repoRoot });
374
+ }
375
+ } catch { /* observability is best-effort — teardown must never throw */ }
277
376
  }
278
377
  }
279
378
 
@@ -393,6 +492,7 @@ export async function runStoryPipeline(context, opts = {}) {
393
492
  kill_switch: 'failed-wave',
394
493
  kill_switch_detail: err?.message ?? String(err),
395
494
  iterations_completed: 0,
495
+ fallback_to_manual: false,
396
496
  stall_recovery_count: 0,
397
497
  };
398
498
  }
@@ -409,11 +509,18 @@ export async function runStoryPipeline(context, opts = {}) {
409
509
  killSwitch: loopState.kill_switch ?? null,
410
510
  killSwitchDetail: loopState.kill_switch_detail ?? null,
411
511
  iterationsCompleted: loopState.iterations_completed ?? 0,
512
+ fallbackToManual: loopState.fallback_to_manual === true,
412
513
  spiralRecoveryCount: loopState.stall_recovery_count ?? 0,
413
514
  commitDependency: null,
414
515
  abortedByCohort: loopState.kill_switch === 'peer-abort',
415
516
  // Internal: used by teardownWorktree to release the lock.
416
517
  _lockSessionId: lockSessionId,
518
+ // Internal (#987): in-memory genesis proof for the proof-gated release in
519
+ // teardownWorktree. Same process as the acquire above, so no file I/O is
520
+ // needed — buildLockOwnerProof() returns null on a partial/absent lock
521
+ // (e.g. a DI test stub without a full lock body), and teardown's
522
+ // spread-guard then falls back to the session_id-only release.
523
+ _lockOwnerProof: buildLockOwnerProof(lockResult.lock),
417
524
  };
418
525
 
419
526
  await teardownWorktree(context, result, { gcOnExit });
@@ -423,11 +530,12 @@ export async function runStoryPipeline(context, opts = {}) {
423
530
  throw loopError;
424
531
  }
425
532
 
426
- // Strip internal field before returning to caller.
427
- const { _lockSessionId: _removed, ...publicResult } = result;
533
+ // Strip internal fields before returning to caller.
534
+ const { _lockSessionId: _removed, _lockOwnerProof: _removedProof, ...publicResult } = result;
428
535
 
429
- // Suppress unused-variable lint warning — _removed is intentionally discarded.
536
+ // Suppress unused-variable lint warning — both are intentionally discarded.
430
537
  void _removed;
538
+ void _removedProof;
431
539
 
432
540
  return publicResult;
433
541
  }