session-orchestrator 5.11.0 → 5.13.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 (394) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +180 -3
  11. package/README.md +126 -117
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +4 -2
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +102 -25
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +55 -21
  47. package/docs/session-config-template.md +32 -24
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +158 -59
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -3
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +73 -3
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/remote-hosts.mjs +2 -2
  143. package/scripts/lib/config/section-extractor.mjs +65 -11
  144. package/scripts/lib/config/template-keys.mjs +153 -0
  145. package/scripts/lib/config/vault-integration.mjs +1 -0
  146. package/scripts/lib/config.mjs +3 -3
  147. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  148. package/scripts/lib/discovery/triage-state.mjs +2 -1
  149. package/scripts/lib/dispatcher/rank.mjs +5 -7
  150. package/scripts/lib/ecosystem-health.mjs +2 -2
  151. package/scripts/lib/eval/engine.mjs +3 -2
  152. package/scripts/lib/eval/session-resolve.mjs +6 -2
  153. package/scripts/lib/eval/sink.mjs +4 -3
  154. package/scripts/lib/events-retention-banner.mjs +3 -2
  155. package/scripts/lib/events-schema.mjs +10 -0
  156. package/scripts/lib/events.mjs +23 -12
  157. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  158. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  159. package/scripts/lib/fleet-protocol.mjs +21 -0
  160. package/scripts/lib/gates/gate-full.mjs +172 -11
  161. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  162. package/scripts/lib/git-config-drift.mjs +141 -7
  163. package/scripts/lib/github-export.mjs +447 -0
  164. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  165. package/scripts/lib/handover-queue-marks.mjs +159 -0
  166. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  167. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  168. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  169. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  170. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  171. package/scripts/lib/host-identity.mjs +9 -5
  172. package/scripts/lib/http-request.mjs +64 -0
  173. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  174. package/scripts/lib/io.mjs +6 -3
  175. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  176. package/scripts/lib/issue-budget.mjs +36 -7
  177. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  178. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  179. package/scripts/lib/learnings/io.mjs +289 -15
  180. package/scripts/lib/learnings/select.mjs +53 -4
  181. package/scripts/lib/learnings/store-role.mjs +209 -0
  182. package/scripts/lib/learnings/surface.mjs +46 -7
  183. package/scripts/lib/ledger-path.mjs +113 -0
  184. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  185. package/scripts/lib/memory-banner.mjs +5 -6
  186. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  187. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  188. package/scripts/lib/memory-proposals/store.mjs +22 -5
  189. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  190. package/scripts/lib/no-follow-open.mjs +57 -0
  191. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  192. package/scripts/lib/own-lock-root.mjs +95 -0
  193. package/scripts/lib/owner-interview.mjs +2 -1
  194. package/scripts/lib/owner-yaml.mjs +70 -7
  195. package/scripts/lib/platform.mjs +161 -9
  196. package/scripts/lib/plugin-update-banner.mjs +15 -11
  197. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  198. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  199. package/scripts/lib/process-group.mjs +270 -34
  200. package/scripts/lib/project-hygiene.mjs +28 -0
  201. package/scripts/lib/quality-gate.mjs +33 -9
  202. package/scripts/lib/reconcile/backlog.mjs +29 -15
  203. package/scripts/lib/reconcile/engine.mjs +26 -2
  204. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  205. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  206. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  207. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  208. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  209. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  210. package/scripts/lib/rule-loader.mjs +20 -10
  211. package/scripts/lib/scope-baseline.mjs +123 -8
  212. package/scripts/lib/scope-echo.mjs +5 -2
  213. package/scripts/lib/scope-gate.mjs +55 -20
  214. package/scripts/lib/session-close-backfill.mjs +75 -7
  215. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  216. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  217. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  218. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  219. package/scripts/lib/session-id.mjs +9 -1
  220. package/scripts/lib/session-identity/own-session.mjs +63 -0
  221. package/scripts/lib/session-invocation.mjs +14 -3
  222. package/scripts/lib/session-lock.mjs +114 -0
  223. package/scripts/lib/session-record-repair.mjs +245 -35
  224. package/scripts/lib/session-registry.mjs +85 -12
  225. package/scripts/lib/session-schema/constants.mjs +9 -0
  226. package/scripts/lib/session-schema/filters.mjs +56 -1
  227. package/scripts/lib/session-schema/validator.mjs +15 -5
  228. package/scripts/lib/session-start-probes.mjs +149 -22
  229. package/scripts/lib/session-token-rollup.mjs +2 -1
  230. package/scripts/lib/session-work-trees.mjs +166 -0
  231. package/scripts/lib/sessions-canonical.mjs +61 -3
  232. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  233. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  234. package/scripts/lib/sh-quote.mjs +1 -0
  235. package/scripts/lib/skill-evidence-window.mjs +5 -1
  236. package/scripts/lib/skill-health/join.mjs +10 -9
  237. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  238. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  239. package/scripts/lib/snapshot-probe.mjs +414 -0
  240. package/scripts/lib/so-hud/collect.mjs +174 -0
  241. package/scripts/lib/so-hud/runtime.mjs +87 -0
  242. package/scripts/lib/spiral-carryover.mjs +8 -1
  243. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  244. package/scripts/lib/state-md/idle-reset.mjs +248 -0
  245. package/scripts/lib/state-md/mission-status.mjs +5 -1
  246. package/scripts/lib/state-md/recommendations.mjs +26 -4
  247. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  248. package/scripts/lib/state-md.mjs +31 -8
  249. package/scripts/lib/sunset/walker.mjs +4 -3
  250. package/scripts/lib/telemetry/consent.mjs +4 -2
  251. package/scripts/lib/telemetry/queue.mjs +3 -3
  252. package/scripts/lib/telemetry/sync.mjs +45 -9
  253. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  254. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  255. package/scripts/lib/ux-grill/paths.mjs +5 -1
  256. package/scripts/lib/validate/check-agents.mjs +37 -6
  257. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  258. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  259. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  260. package/scripts/lib/validate/check-json-files.mjs +3 -1
  261. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  262. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  263. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  264. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  265. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  266. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  267. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  268. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  269. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  270. package/scripts/lib/validate/confidential-names.mjs +3 -1
  271. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  272. package/scripts/lib/vault-mirror/namespace.mjs +22 -9
  273. package/scripts/lib/vault-mirror/process.mjs +197 -65
  274. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  275. package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
  276. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  277. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  278. package/scripts/lib/vault-mirror/utils.mjs +106 -13
  279. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  280. package/scripts/lib/vault-status/board-writer.mjs +34 -5
  281. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  282. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  283. package/scripts/lib/win-process-tree.mjs +137 -0
  284. package/scripts/lib/workspace.mjs +1 -1
  285. package/scripts/lib/worktree/constants.mjs +1 -1
  286. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  287. package/scripts/lib/worktree/listing.mjs +1 -1
  288. package/scripts/lib/zx.mjs +50 -0
  289. package/scripts/mcp-server.mjs +16 -7
  290. package/scripts/mcp-server.sh +13 -83
  291. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  292. package/scripts/memory-propose.mjs +10 -3
  293. package/scripts/migrate-cold-start-seed.mjs +7 -0
  294. package/scripts/migrate-legacy-learnings.sh +22 -0
  295. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  296. package/scripts/migrate-vault-paths.mjs +29 -13
  297. package/scripts/parse-config.mjs +71 -14
  298. package/scripts/print-learnings-index.mjs +25 -2
  299. package/scripts/release-session-lock.mjs +94 -7
  300. package/scripts/release.mjs +451 -24
  301. package/scripts/relocate-vault-corpus.mjs +11 -7
  302. package/scripts/repair-invalid-sessions.mjs +68 -3
  303. package/scripts/resolve-session-invocation.mjs +57 -8
  304. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  305. package/scripts/run-quality-gate.mjs +33 -7
  306. package/scripts/self-update.mjs +56 -7
  307. package/scripts/session-commit-files.mjs +133 -0
  308. package/scripts/site-numbers.mjs +52 -9
  309. package/scripts/snapshot-probe.mjs +156 -0
  310. package/scripts/so-hud.mjs +68 -0
  311. package/scripts/state-md-idle-reset.mjs +82 -0
  312. package/scripts/sweep-expired-learnings.mjs +54 -6
  313. package/scripts/sweep-expired-rules.mjs +4 -1
  314. package/scripts/validate-plugin.mjs +22 -0
  315. package/scripts/vault-integration-watcher.mjs +54 -6
  316. package/scripts/vault-mirror.mjs +9 -0
  317. package/scripts/vault-orphans.mjs +171 -0
  318. package/skills/_shared/fleet-protocol.md +166 -3
  319. package/skills/_shared/instruction-file-resolution.md +1 -1
  320. package/skills/_shared/monitor-patterns.md +44 -25
  321. package/skills/_shared/platform-tools.md +7 -0
  322. package/skills/_shared/state-ownership.md +5 -4
  323. package/skills/architecture/references/domain-model.md +1 -1
  324. package/skills/autopilot/SKILL.md +3 -2
  325. package/skills/bootstrap/SKILL.md +3 -1
  326. package/skills/bootstrap/_shared-template.md +4 -4
  327. package/skills/bootstrap/fast-template.md +1 -1
  328. package/skills/bootstrap/private-contract.md +1 -1
  329. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  330. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  331. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  332. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  333. package/skills/claude-md-drift-check/SKILL.md +3 -3
  334. package/skills/claude-md-drift-check/checker.mjs +61 -101
  335. package/skills/close/SKILL.md +20 -7
  336. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  337. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  338. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  339. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  340. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  341. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  342. package/skills/discovery/probes-session.md +3 -0
  343. package/skills/discovery/probes-vault.md +2 -0
  344. package/skills/dispatcher/SKILL.md +1 -1
  345. package/skills/eval/SKILL.md +3 -1
  346. package/skills/evolve/SKILL.md +2 -0
  347. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  348. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  349. package/skills/gitlab-ops/SKILL.md +7 -6
  350. package/skills/gitlab-portfolio/SKILL.md +2 -2
  351. package/skills/go/SKILL.md +13 -1
  352. package/skills/npm-publish/SKILL.md +5 -2
  353. package/skills/plan/SKILL.md +23 -10
  354. package/skills/plan/mode-feature.md +13 -8
  355. package/skills/plan/mode-new.md +6 -14
  356. package/skills/plan/prd-feature-template.md +19 -0
  357. package/skills/plan/prd-full-template.md +12 -5
  358. package/skills/plan/prd-premortem-panel.md +48 -0
  359. package/skills/plan/prd-reviewer-prompt.md +11 -3
  360. package/skills/plan/soul.md +1 -1
  361. package/skills/portfolio/SKILL.md +5 -6
  362. package/skills/quality-gates/SKILL.md +2 -4
  363. package/skills/reconcile/SKILL.md +27 -4
  364. package/skills/release/SKILL.md +16 -8
  365. package/skills/session-end/SKILL.md +97 -13
  366. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  367. package/skills/session-end/phase-3-6-tail.md +11 -7
  368. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  369. package/skills/session-end/plan-verification.md +16 -1
  370. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  371. package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
  372. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  373. package/skills/session-end/references/session-summary-template.md +8 -0
  374. package/skills/session-end/session-metrics-write.md +7 -4
  375. package/skills/session-plan/SKILL.md +5 -3
  376. package/skills/session-start/SKILL.md +10 -4
  377. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  378. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  379. package/skills/session-start/phase-8-5-express-path.md +4 -3
  380. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  381. package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
  382. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  383. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  384. package/skills/tmux-layout/SKILL.md +2 -2
  385. package/skills/vault-sync/validator.mjs +3 -2
  386. package/skills/wave-executor/SKILL.md +5 -4
  387. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  388. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  389. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  390. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  391. package/AGENTS.md +0 -207
  392. package/assets/og-card.png +0 -0
  393. package/assets/og-card.svg +0 -47
  394. package/assets/wave-lifecycle.svg +0 -98
@@ -29,6 +29,178 @@ import {
29
29
  deriveExpiresAt,
30
30
  ValidationError,
31
31
  } from './schema.mjs';
32
+ import {
33
+ assertStoreRoleUsable,
34
+ resolveStoreRole,
35
+ isCanonicalStore,
36
+ inboxPathFor,
37
+ receiptsPathFor,
38
+ archivePathFor,
39
+ } from './store-role.mjs';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Inbox store role (GitLab #1516)
43
+ // ---------------------------------------------------------------------------
44
+ //
45
+ // Only the canonical `learnings.jsonl` takes part. A host on the `inbox` role
46
+ // (see store-role.mjs) never rewrites it: appends and rewrite-new records go to
47
+ // the sibling `learnings-inbox.jsonl`, which readers union in and which the
48
+ // `writer` host ingests on its next rewrite, recording each ingested id in
49
+ // `learnings-inbox-ingested.jsonl`.
50
+ //
51
+ // Ceiling (BV-004): the inbox host DROPS updates to existing learnings
52
+ // (confidence boosts, decay, prune) — there is no operation-receipt contract,
53
+ // only "new id" records travel. The inbox file is never truncated, because
54
+ // deleting lines from a merge=union file resurrects them on the next merge;
55
+ // receipts hide ingested records instead. Compaction is future work.
56
+ // Revisit-trigger: the inbox file exceeds 1 MiB, or a resurrected record is
57
+ // measured.
58
+
59
+ /**
60
+ * Raw text of `filePath`, or `null` when the file does not exist.
61
+ * @param {string} filePath
62
+ * @returns {Promise<string|null>}
63
+ */
64
+ async function readRawOrNull(filePath) {
65
+ try {
66
+ return await readFile(filePath, 'utf8');
67
+ } catch (err) {
68
+ if (err?.code === 'ENOENT') return null;
69
+ throw err;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Ids of every JSONL line in `raw` that parses to an object with a non-empty
75
+ * string `id`. Malformed lines are tolerated (skipped).
76
+ * @param {string|null} raw
77
+ * @returns {Set<string>}
78
+ */
79
+ function idsOfJsonl(raw) {
80
+ const ids = new Set();
81
+ if (!raw) return ids;
82
+ for (const line of raw.split('\n')) {
83
+ if (line.trim().length === 0) continue;
84
+ try {
85
+ const rec = JSON.parse(line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
86
+ if (rec && typeof rec.id === 'string' && rec.id.length > 0) ids.add(rec.id);
87
+ } catch {
88
+ // another file's malformed line — not ours to report
89
+ }
90
+ }
91
+ return ids;
92
+ }
93
+
94
+ /**
95
+ * The inbox view of a canonical store: which inbox records are visible beside
96
+ * `liveEntries`. Returns `null` when `filePath` is not the canonical store or
97
+ * no inbox file exists — callers then behave exactly as before #1516.
98
+ *
99
+ * A record is visible iff it has a non-empty string `id` that is not in the
100
+ * live entries, the archive or the receipts, and it is the first inbox record
101
+ * with that id.
102
+ *
103
+ * @param {string} filePath
104
+ * @param {object[]} liveEntries
105
+ * @returns {Promise<null | {visible: object[], visibleIds: Set<string>,
106
+ * unreceiptedArchivedIds: Set<string>, unreceiptedLiveIds: Set<string>,
107
+ * counts: {visible: number, hidden: number, malformed: number}, tokenText: string}>}
108
+ */
109
+ async function readInboxView(filePath, liveEntries) {
110
+ if (!isCanonicalStore(filePath)) return null;
111
+ const inboxRaw = await readRawOrNull(inboxPathFor(filePath));
112
+ if (inboxRaw === null) return null;
113
+ const receiptsRaw = (await readRawOrNull(receiptsPathFor(filePath))) ?? '';
114
+ const archiveRaw = (await readRawOrNull(archivePathFor(filePath))) ?? '';
115
+ const receiptedIds = idsOfJsonl(receiptsRaw);
116
+ const archivedIds = idsOfJsonl(archiveRaw);
117
+ const excluded = new Set([...receiptedIds, ...archivedIds]);
118
+ for (const e of liveEntries) {
119
+ if (typeof e?.id === 'string' && e.id.length > 0) excluded.add(e.id);
120
+ }
121
+ const parsed = parseLearningsText(inboxRaw);
122
+ const visible = [];
123
+ const visibleIds = new Set();
124
+ // Inbox ids the LOCAL archive hides but no receipt covers: a writer that
125
+ // died between its archive append and its receipt (expiry-sweep.mjs
126
+ // archiveThenRewrite) left them so. The archive is not synced, so the inbox
127
+ // host still shows them; the next writer rewrite receipts them.
128
+ const unreceiptedArchivedIds = new Set();
129
+ // Inbox ids already LIVE but not receipted: a writer that died between its
130
+ // rename and its receipt (rewriteLearnings) left them so. Without a receipt
131
+ // a later removal from live (no archive) would resurrect them from the inbox.
132
+ const liveIds = new Set(
133
+ liveEntries.map((e) => e?.id).filter((id) => typeof id === 'string' && id.length > 0)
134
+ );
135
+ const unreceiptedLiveIds = new Set();
136
+ for (const e of parsed.entries) {
137
+ const id = e?.id;
138
+ if (typeof id !== 'string' || id.length === 0) continue;
139
+ if (archivedIds.has(id) && !receiptedIds.has(id)) unreceiptedArchivedIds.add(id);
140
+ if (liveIds.has(id) && !receiptedIds.has(id)) unreceiptedLiveIds.add(id);
141
+ if (excluded.has(id) || visibleIds.has(id)) continue;
142
+ visibleIds.add(id);
143
+ visible.push(e);
144
+ }
145
+ return {
146
+ visible,
147
+ visibleIds,
148
+ unreceiptedArchivedIds,
149
+ unreceiptedLiveIds,
150
+ counts: {
151
+ visible: visible.length,
152
+ hidden: parsed.entries.length - visible.length,
153
+ malformed: parsed.malformed.length,
154
+ },
155
+ tokenText: `\u0000${inboxRaw}\u0000${receiptsRaw}\u0000${archiveRaw}`,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Write inbox receipts (#1516) for `ids` — the single receipt writer, used for
161
+ * ids the writer carries into the live store AND for ids it archives straight
162
+ * from the inbox. The archive is not synced between hosts, so without a
163
+ * receipt an archived inbox record turns visible again on the inbox host.
164
+ *
165
+ * No-op unless `storePath` is the canonical store and its inbox file exists.
166
+ * Only ids present in the inbox file and not yet receipted get a line, so a
167
+ * repeated call never duplicates a receipt. The caller MUST hold the store
168
+ * lock (`withLearningsLock(storePath, …)`): the read-filter-append below is
169
+ * not atomic on its own.
170
+ *
171
+ * @param {string} storePath — the canonical `learnings.jsonl`
172
+ * @param {Iterable<string>} ids
173
+ * @returns {Promise<number>} receipt lines appended
174
+ */
175
+ export async function appendInboxReceipts(storePath, ids) {
176
+ if (!isCanonicalStore(storePath)) return 0;
177
+ const inboxRaw = await readRawOrNull(inboxPathFor(storePath));
178
+ if (inboxRaw === null) return 0;
179
+ const inInbox = idsOfJsonl(inboxRaw);
180
+ const receiptsPath = receiptsPathFor(storePath);
181
+ const receipted = idsOfJsonl((await readRawOrNull(receiptsPath)) ?? '');
182
+ const fresh = [...new Set(ids)].filter(
183
+ (id) => typeof id === 'string' && id.length > 0 && inInbox.has(id) && !receipted.has(id)
184
+ );
185
+ if (fresh.length === 0) return 0;
186
+ const ingestedAt = new Date().toISOString();
187
+ await appendWithTornTailGuard(
188
+ receiptsPath,
189
+ fresh.map((id) => `${JSON.stringify({ id, ingested_at: ingestedAt })}\n`).join('')
190
+ );
191
+ return fresh.length;
192
+ }
193
+
194
+ /**
195
+ * Append newline-terminated `text` to `filePath`, first closing a torn last
196
+ * line (#1489). Caller holds the lock guarding `filePath`.
197
+ * @param {string} filePath
198
+ * @param {string} text
199
+ */
200
+ async function appendWithTornTailGuard(filePath, text) {
201
+ const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
202
+ await appendFile(filePath, lead + text, 'utf8');
203
+ }
32
204
 
33
205
  // ---------------------------------------------------------------------------
34
206
  // Store lock (GitLab #1447 point 8)
@@ -224,12 +396,23 @@ function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}
224
396
  * their raw text preserved in the result's `malformed` array, and their
225
397
  * 1-based line numbers in `malformedLineNumbers` (same order).
226
398
  *
399
+ * For the canonical `learnings.jsonl` with a sibling `learnings-inbox.jsonl`
400
+ * (#1516), visible inbox records are appended AFTER the live entries and an
401
+ * `inbox: {visible, hidden, malformed}` count object is added. Inbox malformed
402
+ * lines are never part of `malformed` — they belong to another file. Without
403
+ * an inbox file the result is unchanged (no `inbox` key).
404
+ *
227
405
  * @param {string} filePath — absolute or project-relative path to learnings.jsonl
228
- * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[]}>}
406
+ * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
407
+ * inbox?: {visible: number, hidden: number, malformed: number}}>}
229
408
  */
230
409
  export async function readLearnings(filePath) {
231
- if (!existsSync(filePath)) return { entries: [], malformed: [], malformedLineNumbers: [] };
232
- return parseLearningsText(await readFile(filePath, 'utf8'));
410
+ const base = existsSync(filePath)
411
+ ? parseLearningsText(await readFile(filePath, 'utf8'))
412
+ : { entries: [], malformed: [], malformedLineNumbers: [] };
413
+ const view = await readInboxView(filePath, base.entries);
414
+ if (!view) return base;
415
+ return { ...base, entries: [...base.entries, ...view.visible], inbox: view.counts };
233
416
  }
234
417
 
235
418
  /**
@@ -240,13 +423,25 @@ export async function readLearnings(filePath) {
240
423
  * or sweep changes it. An absent store reads as empty text — zero records
241
424
  * either way, so the two states share a token.
242
425
  *
426
+ * With an inbox file beside the canonical store (#1516) the records are the
427
+ * same union {@link readLearnings} returns, and the token also covers the
428
+ * inbox, receipts and archive text, so it changes when any of them changes.
429
+ *
243
430
  * @param {string} filePath
244
431
  * @returns {Promise<{entries: object[], malformed: string[], malformedLineNumbers: number[],
245
- * generation: string}>}
432
+ * generation: string, inbox?: {visible: number, hidden: number, malformed: number}}>}
246
433
  */
247
434
  export async function readLearningsSnapshot(filePath) {
248
435
  const raw = existsSync(filePath) ? await readFile(filePath, 'utf8') : '';
249
- return { ...parseLearningsText(raw), generation: `sha256:${digestSha256(raw)}` };
436
+ const base = parseLearningsText(raw);
437
+ const view = await readInboxView(filePath, base.entries);
438
+ if (!view) return { ...base, generation: `sha256:${digestSha256(raw)}` };
439
+ return {
440
+ ...base,
441
+ entries: [...base.entries, ...view.visible],
442
+ inbox: view.counts,
443
+ generation: `sha256:${digestSha256(raw + view.tokenText)}`,
444
+ };
250
445
  }
251
446
 
252
447
  /**
@@ -324,6 +519,8 @@ export function parseLearningsText(raw) {
324
519
  * {@link LEARNINGS_LOCK_TIMEOUT_MS}
325
520
  * @returns {Promise<object>} validated entry
326
521
  * @throws {LearningsLockError} when the store lock is not acquired (nothing appended)
522
+ * @throws {LearningsStoreRoleError} canonical store while the host's store role
523
+ * is invalid (#1516; nothing appended)
327
524
  */
328
525
  export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
329
526
  // Ensure created_at is set first — many writers omit it, and expires_at
@@ -351,16 +548,19 @@ export async function appendLearning(filePath, entry, { lockTimeoutMs } = {}) {
351
548
  // parses back AND re-validates before any append touches disk. Throws
352
549
  // ValidationError on a non-round-tripping record — file is left untouched.
353
550
  const line = serializeLearningLineChecked(validated);
551
+ // Fail-closed (#1516): an invalid store role refuses before any lock or write.
552
+ assertStoreRoleUsable(filePath);
553
+ // Inbox role (#1516): the canonical store is never written on this host —
554
+ // the record goes to the sibling inbox file under that file's own lock.
555
+ const target =
556
+ isCanonicalStore(filePath) && resolveStoreRole().role === 'inbox'
557
+ ? inboxPathFor(filePath)
558
+ : filePath;
354
559
  // The lock path resolution creates the parent directory. The torn-tail check
355
560
  // reads inside the lock, so no other writer can change the tail in between.
356
- await withLearningsLock(
357
- filePath,
358
- async () => {
359
- const lead = (await endsWithoutNewline(filePath)) ? '\n' : '';
360
- await appendFile(filePath, lead + line, 'utf8');
361
- },
362
- { timeoutMs: lockTimeoutMs }
363
- );
561
+ await withLearningsLock(target, () => appendWithTornTailGuard(target, line), {
562
+ timeoutMs: lockTimeoutMs,
563
+ });
364
564
  return validated;
365
565
  }
366
566
 
@@ -527,6 +727,8 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
527
727
  * @param {object[]} entries
528
728
  * @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean, malformedLines?: string[]}} [opts]
529
729
  * @returns {Promise<object[]>} validated entries (always returned, even dryRun)
730
+ * @throws {LearningsStoreRoleError} non-dry rewrite of the canonical store while
731
+ * the host's store role is invalid (#1516; nothing written)
530
732
  */
531
733
  export async function rewriteLearnings(
532
734
  filePath,
@@ -560,15 +762,28 @@ export async function rewriteLearnings(
560
762
  // rewrite, no backup — and hand the validated entries back to the caller.
561
763
  if (dryRun) return validated;
562
764
 
765
+ // Fail-closed (#1516): an invalid store role refuses before any lock or write.
766
+ assertStoreRoleUsable(filePath);
767
+ const canonical = isCanonicalStore(filePath);
768
+ if (canonical && resolveStoreRole().role === 'inbox') {
769
+ await rewriteAsInbox(filePath, validated, lines);
770
+ return validated;
771
+ }
772
+
563
773
  // Under the store lock (#1447 point 8) so no append lands between the backup
564
774
  // and the rename. The CALLER's read must sit inside the same lock for a
565
775
  // read-modify-write to be safe — this call joins it reentrantly.
566
776
  await withLearningsLock(filePath, async () => {
777
+ const liveRaw = canonical || malformedLines === undefined ? await readRawOrNull(filePath) : null;
567
778
  // Default: the store's own malformed lines as they stand right now, read
568
779
  // under the lock that guards the rename below.
569
780
  const kept =
570
- malformedLines ??
571
- (existsSync(filePath) ? parseLearningsText(await readFile(filePath, 'utf8')).malformed : []);
781
+ malformedLines ?? (liveRaw !== null ? parseLearningsText(liveRaw).malformed : []);
782
+ // Inbox records visible BEFORE this rewrite (#1516); those the batch now
783
+ // carries into the live store get a receipt after the rename.
784
+ const inboxView = canonical
785
+ ? await readInboxView(filePath, liveRaw !== null ? parseLearningsText(liveRaw).entries : [])
786
+ : null;
572
787
  // Malformed lines last, each newline-terminated: a truncated final line
573
788
  // otherwise fuses with the next append into one more unparseable line.
574
789
  const body = lines.join('') + kept.map((l) => `${l}\n`).join('');
@@ -591,6 +806,65 @@ export async function rewriteLearnings(
591
806
  const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
592
807
  await writeFile(tmp, body, 'utf8');
593
808
  await rename(tmp, filePath);
809
+
810
+ // Receipts (#1516): an ingested inbox record stays hidden even if a later
811
+ // rewrite drops it from the live store without archiving it. Archived
812
+ // inbox ids a crashed sweep left without a receipt are healed here too,
813
+ // and so are ids a crashed rewrite left live without one (they were live
814
+ // at read time, i.e. ingested — receipting them is correct).
815
+ if (
816
+ inboxView &&
817
+ (inboxView.visibleIds.size > 0 ||
818
+ inboxView.unreceiptedArchivedIds.size > 0 ||
819
+ inboxView.unreceiptedLiveIds.size > 0)
820
+ ) {
821
+ await appendInboxReceipts(filePath, [
822
+ ...validated.map((e) => e.id).filter((id) => inboxView.visibleIds.has(id)),
823
+ ...inboxView.unreceiptedArchivedIds,
824
+ ...inboxView.unreceiptedLiveIds,
825
+ ]);
826
+ }
594
827
  });
595
828
  return validated;
596
829
  }
830
+
831
+ /**
832
+ * The `inbox`-role half of {@link rewriteLearnings} (#1516): the live store is
833
+ * not touched (no backup, no rename). Every validated entry whose id is in
834
+ * neither the live store nor the inbox is appended to the inbox; updates to
835
+ * existing records are dropped (see the BV-004 ceiling at the top of this
836
+ * module) and one stderr notice per call says so.
837
+ *
838
+ * Lock order is store → inbox; `appendLearning` takes only the inbox lock, so
839
+ * the two never wait on each other in reverse.
840
+ *
841
+ * @param {string} filePath — the canonical store
842
+ * @param {object[]} validated
843
+ * @param {string[]} lines — the checked serialized lines, same order
844
+ */
845
+ async function rewriteAsInbox(filePath, validated, lines) {
846
+ const inboxPath = inboxPathFor(filePath);
847
+ await withLearningsLock(filePath, () =>
848
+ withLearningsLock(inboxPath, async () => {
849
+ const liveRaw = await readRawOrNull(filePath);
850
+ const known = new Set([
851
+ ...(liveRaw !== null ? parseLearningsText(liveRaw).entries : [])
852
+ .map((e) => e?.id)
853
+ .filter((id) => typeof id === 'string' && id.length > 0),
854
+ ...idsOfJsonl(await readRawOrNull(inboxPath)),
855
+ ]);
856
+ const fresh = [];
857
+ validated.forEach((e, i) => {
858
+ if (typeof e?.id !== 'string' || e.id.length === 0 || known.has(e.id)) return;
859
+ known.add(e.id);
860
+ fresh.push(lines[i]);
861
+ });
862
+ if (fresh.length > 0) await appendWithTornTailGuard(inboxPath, fresh.join(''));
863
+ console.warn(
864
+ `[learnings] store-role inbox: rewrite of ${filePath} skipped ` +
865
+ `(${validated.length - fresh.length} existing records unchanged, ` +
866
+ `${fresh.length} new appended to learnings-inbox.jsonl)`
867
+ );
868
+ })
869
+ );
870
+ }
@@ -126,6 +126,7 @@
126
126
  */
127
127
 
128
128
  import { readFileSync, readdirSync } from 'node:fs';
129
+ import { readFile } from 'node:fs/promises';
129
130
  import { join } from 'node:path';
130
131
 
131
132
  import { affinity } from './affinity.mjs';
@@ -136,7 +137,13 @@ import {
136
137
  } from '../reconcile/sanitize.mjs';
137
138
  import { learningKeyOf } from './kebab.mjs';
138
139
  import { extractProvenance } from '../validate/check-learning-provenance.mjs';
139
- import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
140
+ import {
141
+ DECAY_DEFAULTS,
142
+ effectiveScore,
143
+ surfaceTopN,
144
+ surfaceLearningsText,
145
+ withVisibleInboxText,
146
+ } from './surface.mjs';
140
147
 
141
148
  // ---------------------------------------------------------------------------
142
149
  // Constants — exported so a later wave can wire Session Config keys onto them
@@ -674,6 +681,8 @@ export function selectLearnings(entries, scope, opts = {}) {
674
681
  * the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
675
682
  * `filePath`: guessing a repo root from a metrics path is the hand-maintained
676
683
  * fact this repo keeps getting wrong. Omit it and nothing is filtered.
684
+ * `evidenceCandidates` opts into one explicit candidate JSON artifact, bound
685
+ * to `sourceRepo` and `scope.text`. No artifact discovery or I/O when absent.
677
686
  * @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
678
687
  */
679
688
  export async function selectLearningsFromFile(filePath, scope, opts = {}) {
@@ -692,12 +701,52 @@ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
692
701
  ? readDeliveredProvenance(o.rulesDir)
693
702
  : undefined;
694
703
 
695
- const entries = await surfaceTopN(filePath, poolSize, {
704
+ const surfaceOpts = {
696
705
  now: nowMs,
697
706
  confidenceFloor,
698
707
  decay: o.decay,
699
- });
700
- return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
708
+ };
709
+ let entries;
710
+ let evidence;
711
+ if (o.evidenceCandidates === undefined) {
712
+ entries = await surfaceTopN(filePath, poolSize, surfaceOpts);
713
+ } else {
714
+ let sourceBytes;
715
+ try {
716
+ sourceBytes = await readFile(filePath);
717
+ } catch {
718
+ // Keep the existing empty-baseline behaviour, but do not describe a
719
+ // missing source as a successfully verified empty candidate store.
720
+ }
721
+ // Preserve baseline decoding; candidate additions separately require
722
+ // strict UTF-8 and the exact original bytes from this same read.
723
+ // The inbox records (#1516) join the surfaced text only; `sourceBytes`
724
+ // stays the live store's exact bytes for the candidate check below.
725
+ entries = surfaceLearningsText(
726
+ await withVisibleInboxText(filePath, sourceBytes?.toString('utf8') ?? ''),
727
+ poolSize,
728
+ surfaceOpts,
729
+ );
730
+ try {
731
+ const { readEvidenceCandidates } = await import('./evidence-candidates.mjs');
732
+ const resolved = await readEvidenceCandidates({
733
+ artifactPath: o.evidenceCandidates, sourceRepo: o.sourceRepo,
734
+ query: scope?.text, sourceBytes,
735
+ });
736
+ evidence = resolved.diagnostics;
737
+ const ids = new Set(entries.map((entry) => entry.id));
738
+ for (const entry of resolved.entries) {
739
+ if (!ids.has(entry.id)) {
740
+ entries.push(entry);
741
+ ids.add(entry.id);
742
+ }
743
+ }
744
+ } catch {
745
+ evidence = { status: 'error', reason: 'provider_failed', accepted: 0, dropped: 0 };
746
+ }
747
+ }
748
+ const selection = selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
749
+ return evidence ? { ...selection, evidence } : selection;
701
750
  } catch {
702
751
  return emptySelection();
703
752
  }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * learnings/store-role.mjs — host-local role of the canonical learnings store (#1516).
3
+ *
4
+ * A vault repo synced between two hosts must not have BOTH hosts rewrite
5
+ * `learnings.jsonl` wholesale (tmp+rename): every rewrite conflicts on merge,
6
+ * and `merge=union` cannot apply to a rewritten file. One host stays the
7
+ * `writer` (default); the other opts into `inbox` and only ever APPENDS to the
8
+ * sibling `learnings-inbox.jsonl`, which the writer ingests.
9
+ *
10
+ * Fail-closed: ONLY "nothing configured" means writer. A role that is set but
11
+ * unusable — an invalid `SO_LEARNINGS_STORE_ROLE`, an owner.yaml that exists
12
+ * but cannot be read, or one that names a top-level `learnings` key whose
13
+ * `store-role` did not survive loading (typo in the value or the key,
14
+ * unparseable file, missing YAML parser) — resolves to `invalid`, and every WRITE path for the canonical
15
+ * store refuses via {@link assertStoreRoleUsable}. Readers keep working.
16
+ *
17
+ * Deliberately import-light: the only repo import is `../owner-yaml.mjs`.
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { loadOwnerConfig, resolveOwnerYamlPath } from '../owner-yaml.mjs';
23
+
24
+ /** Valid store roles. `writer` is the default when nothing is configured. */
25
+ export const STORE_ROLES = Object.freeze(['writer', 'inbox']);
26
+
27
+ const ENV_KEY = 'SO_LEARNINGS_STORE_ROLE';
28
+ const CANONICAL_STORE = 'learnings.jsonl';
29
+
30
+ /**
31
+ * A top-level `learnings` key in the RAW owner.yaml text (block style, plain
32
+ * or quoted key). Ceiling (BV-004): a flow-style top-level mapping
33
+ * (`{learnings: {...}}`) is not detected, so a broken one of those still
34
+ * resolves to writer. Revisit-trigger: an owner.yaml written in flow style.
35
+ */
36
+ const LEARNINGS_KEY_RE = /^["']?learnings["']?\s*:/m;
37
+
38
+ /**
39
+ * @typedef {{ role: 'writer'|'inbox', source: 'env'|'owner-yaml'|'default' }
40
+ * | { role: 'invalid', source: 'env'|'owner-yaml', reason: string }} StoreRoleResult
41
+ */
42
+
43
+ /** @type {Map<string, StoreRoleResult>} */
44
+ const cache = new Map();
45
+
46
+ /**
47
+ * Thrown by {@link assertStoreRoleUsable} when a write to the canonical store
48
+ * is attempted while this host's store role is invalid. Nothing was written.
49
+ */
50
+ export class LearningsStoreRoleError extends Error {
51
+ /**
52
+ * @param {'env'|'owner-yaml'} source
53
+ * @param {string} reason
54
+ */
55
+ constructor(source, reason) {
56
+ super(
57
+ `learnings store role is invalid (${source}: ${reason}) — nothing written; ` +
58
+ `fix owner.yaml learnings.store-role or ${ENV_KEY}`,
59
+ );
60
+ this.name = 'LearningsStoreRoleError';
61
+ this.source = source;
62
+ this.reason = reason;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The owner.yaml half of {@link resolveStoreRole}.
68
+ *
69
+ * @param {string} ownerPath
70
+ * @param {(opts: {path: string}) => any} loadOwner
71
+ * @returns {StoreRoleResult}
72
+ */
73
+ function resolveFromOwnerYaml(ownerPath, loadOwner) {
74
+ const loaded = loadOwner({ path: ownerPath });
75
+ const section = loaded?.config?.learnings;
76
+ const fromOwner = section?.['store-role'];
77
+ if (STORE_ROLES.includes(fromOwner)) return { role: fromOwner, source: 'owner-yaml' };
78
+
79
+ let raw;
80
+ try {
81
+ raw = readFileSync(ownerPath, 'utf8');
82
+ } catch (err) {
83
+ if (/** @type {NodeJS.ErrnoException} */ (err)?.code === 'ENOENT') return { role: 'writer', source: 'default' };
84
+ return { role: 'invalid', source: 'owner-yaml', reason: `owner.yaml unreadable (${err?.code ?? 'error'})` };
85
+ }
86
+ if (!LEARNINGS_KEY_RE.test(raw)) return { role: 'writer', source: 'default' };
87
+ // A learnings section that loaded as an object without a store-role, or as
88
+ // an empty `learnings:` (null), is "key absent" — writer (#1686). A section
89
+ // with any other key was dropped by owner-yaml validation, so it is
90
+ // undefined here. Anything else means the configured value was lost.
91
+ if (section === null && loaded?.config && 'learnings' in loaded.config) return { role: 'writer', source: 'default' };
92
+ const isObject = section !== null && typeof section === 'object' && !Array.isArray(section);
93
+ if (isObject && (fromOwner === undefined || fromOwner === null)) return { role: 'writer', source: 'default' };
94
+ const dropped = Array.isArray(loaded?.droppedSections)
95
+ ? loaded.droppedSections.find((d) => d?.section === 'learnings')
96
+ : undefined;
97
+ const reason =
98
+ dropped?.errors?.[0] ??
99
+ (loaded?.reason ? `owner.yaml ${loaded.reason}` : null) ??
100
+ (fromOwner !== undefined ? `store-role ${JSON.stringify(fromOwner)} not one of ${STORE_ROLES.join(', ')}` : null) ??
101
+ 'learnings section present but unusable';
102
+ return { role: 'invalid', source: 'owner-yaml', reason };
103
+ }
104
+
105
+ /**
106
+ * Resolve this host's learnings-store role.
107
+ *
108
+ * Precedence: env `SO_LEARNINGS_STORE_ROLE` (trimmed) > owner.yaml
109
+ * `learnings.store-role` > `writer`. A non-empty env value that is not a role
110
+ * resolves to `invalid` (no fallback to owner.yaml); so does an owner.yaml
111
+ * that exists but is unreadable, or that names a top-level `learnings` key
112
+ * whose `store-role` did not load as a valid role. Only an absent file, or one
113
+ * without a `learnings` key / `store-role` (an empty `learnings:` included),
114
+ * means `writer`. Memoized per
115
+ * process on (env value, owner.yaml path). Never throws: an unexpected error
116
+ * resolves to `invalid` (fail-closed), not to writer.
117
+ *
118
+ * @param {{ env?: NodeJS.ProcessEnv | Record<string, string|undefined>,
119
+ * loadOwner?: (opts: {path: string}) => {config?: any} }} [opts] — `loadOwner`
120
+ * defaults to `loadOwnerConfig` (test seam)
121
+ * @returns {StoreRoleResult} `role: 'writer'|'inbox'|'invalid'`; `reason` only on invalid
122
+ */
123
+ export function resolveStoreRole({ env = process.env, loadOwner } = {}) {
124
+ let source = /** @type {'env'|'owner-yaml'} */ ('owner-yaml');
125
+ try {
126
+ const envValue = (env[ENV_KEY] || '').trim();
127
+ const ownerPath = resolveOwnerYamlPath(env);
128
+ const key = `${envValue}\u0000${ownerPath}`;
129
+ const hit = cache.get(key);
130
+ if (hit) return hit;
131
+
132
+ /** @type {StoreRoleResult} */
133
+ let result;
134
+ if (envValue !== '') {
135
+ source = 'env';
136
+ result = STORE_ROLES.includes(envValue)
137
+ ? { role: /** @type {'writer'|'inbox'} */ (envValue), source: 'env' }
138
+ : {
139
+ role: 'invalid',
140
+ source: 'env',
141
+ reason: `${ENV_KEY}=${JSON.stringify(envValue)} not one of ${STORE_ROLES.join(', ')}`,
142
+ };
143
+ } else {
144
+ result = resolveFromOwnerYaml(ownerPath, loadOwner ?? loadOwnerConfig);
145
+ }
146
+ cache.set(key, result);
147
+ return result;
148
+ } catch (err) {
149
+ return { role: 'invalid', source, reason: `store role not resolvable (${err?.message ?? String(err)})` };
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Refuse a write to the canonical store while this host's role is invalid.
155
+ * Call it on every WRITE path before any lock or disk write; other files and
156
+ * readers are unaffected.
157
+ *
158
+ * @param {string} filePath
159
+ * @throws {LearningsStoreRoleError} iff `filePath` is the canonical store and the role is invalid
160
+ */
161
+ export function assertStoreRoleUsable(filePath) {
162
+ if (!isCanonicalStore(filePath)) return;
163
+ const resolved = resolveStoreRole();
164
+ if (resolved.role === 'invalid') throw new LearningsStoreRoleError(resolved.source, resolved.reason);
165
+ }
166
+
167
+ /** Clear the memo. Tests only. */
168
+ export function _resetStoreRoleCacheForTests() {
169
+ cache.clear();
170
+ }
171
+
172
+ /**
173
+ * True iff `filePath` names the canonical store (`learnings.jsonl`). Only that
174
+ * file gets the inbox behaviour; any other path keeps the plain store semantics.
175
+ *
176
+ * @param {string} filePath
177
+ * @returns {boolean}
178
+ */
179
+ export function isCanonicalStore(filePath) {
180
+ return typeof filePath === 'string' && path.basename(filePath) === CANONICAL_STORE;
181
+ }
182
+
183
+ /**
184
+ * Sibling inbox file the `inbox` role appends to.
185
+ * @param {string} storePath
186
+ * @returns {string}
187
+ */
188
+ export function inboxPathFor(storePath) {
189
+ return path.join(path.dirname(storePath), 'learnings-inbox.jsonl');
190
+ }
191
+
192
+ /**
193
+ * Sibling receipts file the writer appends `{id, ingested_at}` lines to once
194
+ * an inbox record has been ingested into the live store.
195
+ * @param {string} storePath
196
+ * @returns {string}
197
+ */
198
+ export function receiptsPathFor(storePath) {
199
+ return path.join(path.dirname(storePath), 'learnings-inbox-ingested.jsonl');
200
+ }
201
+
202
+ /**
203
+ * Sibling archive file (expired learnings).
204
+ * @param {string} storePath
205
+ * @returns {string}
206
+ */
207
+ export function archivePathFor(storePath) {
208
+ return path.join(path.dirname(storePath), 'learnings-archive.jsonl');
209
+ }