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
@@ -12,7 +12,18 @@
12
12
  * full via tmp-file + atomic rename. It is NEVER appended to with `>>` semantics,
13
13
  * because supersession reconciliation must mutate existing lines (set
14
14
  * `superseded_by`) — an append would leave stale duplicates and corrupt the
15
- * supersession graph.
15
+ * supersession graph. The store is OWNED by this module: nothing else — no
16
+ * report, no analysis run, no agent — may append to it; a read-side shape guard
17
+ * ({@link isRepairCandidateShape}) drops any record that is not a
18
+ * RepairCandidate, COUNTS the drop, and WARNs on stderr.
19
+ *
20
+ * The warning lives in {@link readStore} — the ONE seam every public function
21
+ * reads through — rather than in each caller's return shape, because only
22
+ * `mergeCandidates` has a result object with room for a count: `markProcessed`
23
+ * returns `{ok, reason}` and `isProcessed` returns a boolean, yet both rewrite
24
+ * or gate on a store whose foreign lines the rewrite DELETES. Those two run far
25
+ * more often than a merge, so a count reported only by `mergeCandidates` would
26
+ * leave the most-travelled deletion path silent.
16
27
  *
17
28
  * Three responsibilities:
18
29
  * 1. Persistence — `loadCandidates` (read) + atomic full rewrite (internal).
@@ -63,12 +74,90 @@ function resolveStorePath(repoRoot, storePath) {
63
74
  return rel;
64
75
  }
65
76
 
77
+ /**
78
+ * Minimal shape guard for a persisted store line. A record is accepted only when
79
+ * it carries the three fields THIS store's own consumers read:
80
+ * - `id` — THE idempotency key (`mergeCandidates` byId index,
81
+ * `markProcessed`, `isProcessed` all look records up by it).
82
+ * - `source` — one half of the supersession key (`mergeCandidates`
83
+ * compares `rec.source === cand.source`).
84
+ * - `target_path` — the other half of the supersession key.
85
+ *
86
+ * Deliberately NOT a full schema check, and NOT a copy of the reconcile store's
87
+ * guard: the two record types share only 5 fields, and the reconcile guard keys
88
+ * on `learning_key`, which a {@link RepairCandidate} does not have at all —
89
+ * applying it here would reject 100% of legitimate repair candidates. The fields
90
+ * above were chosen by what consumers actually READ, not by the typedef: a grep
91
+ * of `scripts/lib/skill-evolution/` (2026-07-31) found ZERO consumer reads of
92
+ * `created_at` — only the producer `candidate-intake.mjs` sets it — so requiring
93
+ * it would be schema validation, not a load-bearing check. The store is a
94
+ * mutable work-queue whose records may gain fields across schema versions, so
95
+ * over-strict validation would silently drop legitimate future records.
96
+ *
97
+ * What it DOES reject is a record no writer in this repo produces — the concrete
98
+ * incident class being a hand-written report artefact landing in the sibling
99
+ * reconcile store using `candidate_id` / `generated_at` / `status:"candidate"`
100
+ * (2026-07-31, GitLab #955; this store is the near-twin that kept the old
101
+ * permissive `typeof parsed === 'object'` check).
102
+ * @param {unknown} rec
103
+ * @returns {boolean}
104
+ */
105
+ function isRepairCandidateShape(rec) {
106
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
107
+ const r = /** @type {Record<string, unknown>} */ (rec);
108
+ if (typeof r.id !== 'string' || r.id.length === 0) return false;
109
+ if (typeof r.source !== 'string' || r.source.length === 0) return false;
110
+ if (typeof r.target_path !== 'string' || r.target_path.length === 0) return false;
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Per-store memo of the skip count already WARNed about, keyed by resolved
116
+ * absolute store path. Without it a caller that reads the same store in a loop
117
+ * (the engine calls {@link isProcessed} once per candidate) would emit one
118
+ * identical line per read. A read that comes back CLEAN clears the entry, so a
119
+ * store contaminated again later warns again instead of being muted forever.
120
+ * @type {Map<string, number>}
121
+ */
122
+ const warnedSkips = new Map();
123
+
124
+ /**
125
+ * Emit ONE stderr WARN per (store, skip-count) transition. Called from every
126
+ * {@link readStore} exit — including the ENOENT path, whose `skipped: 0` clears
127
+ * the memo. Never throws: a stderr write failure must not break a store read.
128
+ * @param {string} absPath
129
+ * @param {number} skipped
130
+ * @returns {void}
131
+ */
132
+ function warnOnSkipped(absPath, skipped) {
133
+ if (skipped <= 0) {
134
+ warnedSkips.delete(absPath);
135
+ return;
136
+ }
137
+ if (warnedSkips.get(absPath) === skipped) return;
138
+ warnedSkips.set(absPath, skipped);
139
+ try {
140
+ process.stderr.write(
141
+ `WARN repair-candidate store: ${skipped} unreadable record(s) skipped in ${absPath} — ` +
142
+ 'not RepairCandidate shape (need id/source/target_path) or unparseable JSON. ' +
143
+ 'The next full rewrite (mergeCandidates/markProcessed) DELETES them from disk.\n',
144
+ );
145
+ } catch {
146
+ // stderr unavailable (closed pipe) — the count is still returned to callers.
147
+ }
148
+ }
149
+
66
150
  /**
67
151
  * Read + defensively parse the store's JSONL lines into RepairCandidate records.
68
- * Malformed lines (bad JSON, non-object) are skipped silently. A missing file
69
- * yields `[]`. Never throws.
152
+ * Malformed lines (bad JSON) and shape-foreign records (see
153
+ * {@link isRepairCandidateShape}) are skipped — and COUNTED, because every
154
+ * writer in this module rewrites the store in FULL, so a skipped line is dropped
155
+ * from disk on the next merge or stamp; a silent drop would be unattributable
156
+ * data loss. Every skip is ALSO reported on stderr via {@link warnOnSkipped},
157
+ * which is what covers the three callers whose return contract has no room for
158
+ * the count. A missing file yields `{ records: [], skipped: 0 }`. Never throws.
70
159
  * @param {string} absPath
71
- * @returns {RepairCandidate[]}
160
+ * @returns {{ records: RepairCandidate[], skipped: number }}
72
161
  */
73
162
  function readStore(absPath) {
74
163
  let raw;
@@ -76,11 +165,13 @@ function readStore(absPath) {
76
165
  raw = readFileSync(absPath, 'utf8');
77
166
  } catch {
78
167
  // ENOENT or any read error → empty store.
79
- return [];
168
+ warnOnSkipped(absPath, 0);
169
+ return { records: [], skipped: 0 };
80
170
  }
81
171
 
82
172
  /** @type {RepairCandidate[]} */
83
173
  const records = [];
174
+ let skipped = 0;
84
175
  for (const line of raw.split('\n')) {
85
176
  const trimmed = line.trim();
86
177
  if (trimmed.length === 0) continue;
@@ -88,13 +179,17 @@ function readStore(absPath) {
88
179
  try {
89
180
  parsed = JSON.parse(trimmed);
90
181
  } catch {
182
+ skipped += 1;
91
183
  continue; // skip malformed line
92
184
  }
93
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
185
+ if (isRepairCandidateShape(parsed)) {
94
186
  records.push(/** @type {RepairCandidate} */ (parsed));
187
+ } else {
188
+ skipped += 1;
95
189
  }
96
190
  }
97
- return records;
191
+ warnOnSkipped(absPath, skipped);
192
+ return { records, skipped };
98
193
  }
99
194
 
100
195
  /**
@@ -127,8 +222,15 @@ function writeStore(absPath, records) {
127
222
 
128
223
  /**
129
224
  * Load every RepairCandidate currently persisted in the store. Reads JSONL,
130
- * skips malformed lines, returns `[]` for a missing file. Does NOT create the
131
- * runtime dir (mkdir -p happens only on write). Never throws.
225
+ * skips malformed lines AND shape-foreign records (missing `id` / `source` /
226
+ * `target_path`), returns `[]` for a missing file. Does NOT create the runtime
227
+ * dir (mkdir -p happens only on write). Never throws.
228
+ *
229
+ * Read-only: the skipped lines are still on disk after this call. The count is
230
+ * not in the return value — this function's plain-array contract is unchanged —
231
+ * but it is never silent: {@link readStore} WARNs it on stderr, and a caller
232
+ * that needs the number programmatically gets it from {@link mergeCandidates}
233
+ * as `dropped_on_read`.
132
234
  * @param {Object} params
133
235
  * @param {string} [params.repoRoot] - repo root; relative `storePath` is resolved against it.
134
236
  * @param {string} [params.storePath] - store path (relative ⇒ joined to repoRoot). Defaults to {@link DEFAULT_STORE_PATH}.
@@ -136,7 +238,7 @@ function writeStore(absPath, records) {
136
238
  */
137
239
  export function loadCandidates({ repoRoot, storePath } = {}) {
138
240
  const absPath = resolveStorePath(repoRoot, storePath);
139
- return readStore(absPath);
241
+ return readStore(absPath).records;
140
242
  }
141
243
 
142
244
  /**
@@ -157,15 +259,25 @@ export function loadCandidates({ repoRoot, storePath } = {}) {
157
259
  * The store is rewritten in full (read-all → merge → atomic tmp+rename), never
158
260
  * appended. Never throws; a write failure still returns counts with
159
261
  * `total` reflecting the in-memory final line count.
262
+ *
263
+ * `dropped_on_read` reports how many PERSISTED lines the read-side guard
264
+ * rejected (malformed JSON, or a record missing `id`/`source`/`target_path`).
265
+ * Because the store is rewritten in full, those lines are DROPPED from disk by
266
+ * this call — the count is what makes that loss attributable instead of silent.
267
+ * It is deliberately NOT named `skipped`: `skipped_processed` above already
268
+ * means "an INCOMING candidate was skipped because its id is already processed",
269
+ * a different population (incoming, not persisted) with a different cause
270
+ * (terminal verdict, not contamination). Confusing the two would read a
271
+ * contaminated store as a healthy idempotent no-op.
160
272
  * @param {Object} params
161
273
  * @param {RepairCandidate[]} params.candidates - newly minted candidates to merge.
162
274
  * @param {string} [params.repoRoot]
163
275
  * @param {string} [params.storePath]
164
- * @returns {{ written: number, superseded: number, skipped_processed: number, total: number }}
276
+ * @returns {{ written: number, superseded: number, skipped_processed: number, dropped_on_read: number, total: number }}
165
277
  */
166
278
  export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
167
279
  const absPath = resolveStorePath(repoRoot, storePath);
168
- const store = readStore(absPath);
280
+ const { records: store, skipped: droppedOnRead } = readStore(absPath);
169
281
 
170
282
  // Index existing records by id for O(1) lookup.
171
283
  /** @type {Map<string, RepairCandidate>} */
@@ -219,6 +331,7 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
219
331
  written,
220
332
  superseded,
221
333
  skipped_processed: skippedProcessed,
334
+ dropped_on_read: droppedOnRead,
222
335
  total: store.length,
223
336
  };
224
337
  }
@@ -230,7 +343,11 @@ export function mergeCandidates({ candidates, repoRoot, storePath } = {}) {
230
343
  * - id not found → `{ ok: false, reason: 'not-found' }`
231
344
  * - already processed → `{ ok: true }` (idempotent no-op; store untouched)
232
345
  * - filesystem write failure → `{ ok: false, reason: 'fs-error' }`
233
- * Never throws.
346
+ * Never throws. Like {@link mergeCandidates} this rewrites the store in full, so
347
+ * shape-foreign lines are DELETED from disk here too. This API's result shape
348
+ * has no room to report the count (it is `{ ok, reason }` by contract), so the
349
+ * deletion is surfaced by {@link readStore}'s stderr WARN instead — this is the
350
+ * most-travelled of the three rewrite paths, so it must not be the silent one.
234
351
  * @param {Object} params
235
352
  * @param {string} params.id - candidate id to stamp.
236
353
  * @param {string} [params.repoRoot]
@@ -244,7 +361,7 @@ export function markProcessed({ id, repoRoot, storePath, now } = {}) {
244
361
  }
245
362
 
246
363
  const absPath = resolveStorePath(repoRoot, storePath);
247
- const store = readStore(absPath);
364
+ const { records: store } = readStore(absPath);
248
365
 
249
366
  const target = store.find((rec) => rec && rec.id === id);
250
367
  if (!target) return { ok: false, reason: 'not-found' };
@@ -263,7 +380,9 @@ export function markProcessed({ id, repoRoot, storePath, now } = {}) {
263
380
 
264
381
  /**
265
382
  * True iff a candidate with the given id exists in the store AND has been
266
- * processed (`processed_at !== null`). Never throws.
383
+ * processed (`processed_at !== null`). Read-only — it does not itself rewrite
384
+ * the store — but it reads through the same shape guard, so a contaminated
385
+ * store is reported here too via {@link readStore}'s stderr WARN. Never throws.
267
386
  * @param {Object} params
268
387
  * @param {string} params.id
269
388
  * @param {string} [params.repoRoot]
@@ -273,7 +392,7 @@ export function markProcessed({ id, repoRoot, storePath, now } = {}) {
273
392
  export function isProcessed({ id, repoRoot, storePath } = {}) {
274
393
  if (typeof id !== 'string' || id.length === 0) return false;
275
394
  const absPath = resolveStorePath(repoRoot, storePath);
276
- const store = readStore(absPath);
395
+ const { records: store } = readStore(absPath);
277
396
  const rec = store.find((r) => r && r.id === id);
278
397
  return !!rec && rec.processed_at !== null && rec.processed_at !== undefined;
279
398
  }
@@ -30,6 +30,9 @@
30
30
  * - opts.vcsDetect — ({ config, projectRoot }) => { bin } VCS detector
31
31
  * - opts.createMr — async (loop, draftOpts) => { created, mrUrl } MR opener
32
32
  * - opts.log — (level, msg) => void diagnostic logger
33
+ * - opts.resolveRepoSpecFn — optional passthrough to the createMr seam's own
34
+ * -R/--repo host-pinning resolver (#872); undefined lets createMr fall
35
+ * back to its own real-resolveRepoSpec default.
33
36
  */
34
37
 
35
38
  import { execFile as execFileCb, spawnSync } from 'node:child_process';
@@ -468,6 +471,11 @@ export async function openRepairMr(
468
471
  }
469
472
 
470
473
  // Delegate MR creation to the mr-draft seam (collision check + draft create).
474
+ // repoRoot is passed through so maybeCreateDraftMR's -R/--repo host-pinning
475
+ // resolution (#872) resolves against THIS repo, not process.cwd() (which
476
+ // may differ inside a dispatched agent/worktree context); resolveRepoSpecFn
477
+ // is an optional passthrough seam for tests — undefined here simply means
478
+ // maybeCreateDraftMR falls back to its own real-resolveRepoSpec default.
471
479
  let mrResult;
472
480
  try {
473
481
  mrResult = await createMr(
@@ -480,7 +488,7 @@ export async function openRepairMr(
480
488
  parentRunId: `repair/${candidate.id}`,
481
489
  worktreePath: candidate.target_path ?? '(unknown)',
482
490
  },
483
- { execFile: opts.execFile, log },
491
+ { execFile: opts.execFile, log, repoRoot, resolveRepoSpecFn: opts.resolveRepoSpecFn },
484
492
  );
485
493
  } catch (err) {
486
494
  const msg = err instanceof MrDraftError ? err.message : String(err?.message ?? err);
@@ -15,6 +15,18 @@
15
15
  * the session is still in progress, or the record was never written), the session
16
16
  * contributes `unknown` to the outcome tally — it is never silently dropped.
17
17
  *
18
+ * ABANDONED-SESSION HANDLING (#834): a session_id CAN be found in sessions.jsonl
19
+ * yet be a phantom stub (`status: 'abandoned'`, written by session-close-backfill
20
+ * for a session that ended without a real close — 0 waves, all-zero agent_summary).
21
+ * Counting such a join as `sessionsJoined` would inflate the skill's join
22
+ * denominator with zero real contribution — the join "succeeds" but carries no
23
+ * signal. Per this module's own "never silently dropped" contract, an abandoned
24
+ * join is routed to a DISTINCT `abandoned` outcome bucket rather than either (a)
25
+ * silently folding into `sessionsJoined`/the numeric outcome fields with zero
26
+ * contribution (inflates the denominator invisibly), or (b) folding into
27
+ * `unknown` (which means "not found in the ledger at all" — a different failure
28
+ * mode a caller may want to distinguish from "found but phantom").
29
+ *
18
30
  * Part of Epic #645 — Skill Self-Evolution Foundation, Layer 2.
19
31
  */
20
32
 
@@ -22,6 +34,8 @@ import { promises as fs } from 'node:fs';
22
34
  import path from 'node:path';
23
35
  import { fileURLToPath } from 'node:url';
24
36
 
37
+ import { isRealSession } from '../session-schema/filters.mjs';
38
+
25
39
  const DEFAULT_INVOCATIONS_PATH = path.resolve(
26
40
  fileURLToPath(import.meta.url),
27
41
  '../../../../.orchestrator/metrics/skill-invocations.jsonl',
@@ -61,18 +75,21 @@ async function readJsonl(filePath) {
61
75
  }
62
76
 
63
77
  /**
64
- * Builds a Map<session_id, agent_summary> from sessions.jsonl records.
78
+ * Builds a Map<session_id, { agentSummary, real }> from sessions.jsonl records.
65
79
  * Records without a session_id or with a non-object agent_summary are skipped.
80
+ * `real` is false for phantom `status: 'abandoned'` stubs (#834) — callers use
81
+ * it to route the join to the `abandoned` outcome bucket instead of counting
82
+ * a zero-signal join as `sessionsJoined`.
66
83
  *
67
84
  * @param {object[]} sessionRecords
68
- * @returns {Map<string, { complete: number, partial: number, failed: number, spiral: number }>}
85
+ * @returns {Map<string, { agentSummary: { complete: number, partial: number, failed: number, spiral: number }, real: boolean }>}
69
86
  */
70
87
  function buildSessionMap(sessionRecords) {
71
88
  const map = new Map();
72
89
  for (const rec of sessionRecords) {
73
90
  if (typeof rec.session_id !== 'string' || !rec.session_id) continue;
74
91
  if (rec.agent_summary && typeof rec.agent_summary === 'object') {
75
- map.set(rec.session_id, rec.agent_summary);
92
+ map.set(rec.session_id, { agentSummary: rec.agent_summary, real: isRealSession(rec) });
76
93
  }
77
94
  }
78
95
  return map;
@@ -91,12 +108,13 @@ function buildSessionMap(sessionRecords) {
91
108
  * skill: string,
92
109
  * selections: number,
93
110
  * sessions: string[],
94
- * outcomes: { complete: number, partial: number, failed: number, spiral: number, unknown: number }
111
+ * outcomes: { complete: number, partial: number, failed: number, spiral: number, unknown: number, abandoned: number }
95
112
  * }
96
113
  * },
97
114
  * totalSelections: number,
98
115
  * sessionsJoined: number,
99
- * sessionsUnknown: number
116
+ * sessionsUnknown: number,
117
+ * sessionsAbandoned: number
100
118
  * }>}
101
119
  */
102
120
  export async function joinSkillOutcomes({
@@ -116,6 +134,7 @@ export async function joinSkillOutcomes({
116
134
  let totalSelections = 0;
117
135
  let sessionsJoined = 0;
118
136
  let sessionsUnknown = 0;
137
+ let sessionsAbandoned = 0;
119
138
 
120
139
  for (const inv of invocations) {
121
140
  // Only process skill-selection events with a valid skill field
@@ -132,7 +151,7 @@ export async function joinSkillOutcomes({
132
151
  skill,
133
152
  selectionCount: 0,
134
153
  sessions: new Set(),
135
- outcomes: { complete: 0, partial: 0, failed: 0, spiral: 0, unknown: 0 },
154
+ outcomes: { complete: 0, partial: 0, failed: 0, spiral: 0, unknown: 0, abandoned: 0 },
136
155
  });
137
156
  }
138
157
 
@@ -144,14 +163,21 @@ export async function joinSkillOutcomes({
144
163
  record.sessions.add(sessionId);
145
164
 
146
165
  if (isNew) {
147
- const summary = sessionMap.get(sessionId);
148
- if (summary) {
166
+ const entry = sessionMap.get(sessionId);
167
+ if (entry && entry.real) {
168
+ const summary = entry.agentSummary;
149
169
  // Sum session-level aggregate outcomes into this skill's buckets
150
170
  record.outcomes.complete += typeof summary.complete === 'number' ? summary.complete : 0;
151
171
  record.outcomes.partial += typeof summary.partial === 'number' ? summary.partial : 0;
152
172
  record.outcomes.failed += typeof summary.failed === 'number' ? summary.failed : 0;
153
173
  record.outcomes.spiral += typeof summary.spiral === 'number' ? summary.spiral : 0;
154
174
  sessionsJoined += 1;
175
+ } else if (entry && !entry.real) {
176
+ // Found in sessions.jsonl but a phantom abandoned stub (#834) — a
177
+ // zero-signal join. Route to a distinct bucket instead of inflating
178
+ // sessionsJoined or conflating with "not found at all" (unknown).
179
+ record.outcomes.abandoned += 1;
180
+ sessionsAbandoned += 1;
155
181
  } else {
156
182
  // Session id not found in sessions.jsonl — count as unknown, never drop
157
183
  record.outcomes.unknown += 1;
@@ -177,5 +203,5 @@ export async function joinSkillOutcomes({
177
203
  };
178
204
  }
179
205
 
180
- return { bySkill: bySkillObj, totalSelections, sessionsJoined, sessionsUnknown };
206
+ return { bySkill: bySkillObj, totalSelections, sessionsJoined, sessionsUnknown, sessionsAbandoned };
181
207
  }