session-orchestrator 5.12.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  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 +162 -4
  11. package/README.md +126 -127
  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 +1 -1
  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 +90 -17
  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 +49 -15
  47. package/docs/session-config-template.md +30 -22
  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 +139 -49
  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 -2
  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 +39 -27
  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/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -29,9 +29,9 @@ Working **in this repo** does not need the installer — those paths are committ
29
29
 
30
30
  ## Configuration
31
31
 
32
- **Where Cursor picks up project instructions — measured-or-unknown, 2026-09-06.** This page used to state flatly that "Cursor reads CLAUDE.md natively". That claim is **not substantiated**: the current Cursor rules documentation names `.cursor/rules` and `AGENTS.md` as the project-instruction surfaces and does not name `CLAUDE.md`, and nothing in this repo measures the behaviour. Treat native `CLAUDE.md` pickup as **unverified** rather than as fact.
32
+ **Where Cursor picks up project instructions (documented, checked 2026-10-21).** Cursor reads a `CLAUDE.md` in the project root the same way it reads `AGENTS.md` and applies it to every conversation ([Cursor rules help](https://cursor.com/help/customization/rules), section "How does CLAUDE.md work in Cursor?"). Project rules in `.cursor/rules/` stay available for conditional rules. An earlier version of this page (2026-09-06) called native `CLAUDE.md` pickup unverified because the Cursor documentation of that time named only `.cursor/rules` and `AGENTS.md`.
33
33
 
34
- What follows from that, practically: put the `## Session Config` block where you can verify Cursor reads it. `scripts/cursor-install.mjs` and the orchestrator's own config parser (`scripts/parse-config.mjs`) read the block out of the repo's project-instruction file regardless of which client loaded it, so the block below is correct wherever you put it — but if Cursor is not surfacing your session settings, mirror the block into `AGENTS.md` (the Codex/Cursor-facing alias, see [`skills/_shared/instruction-file-resolution.md`](../skills/_shared/instruction-file-resolution.md)) before assuming a parser bug.
34
+ Put the `## Session Config` block in `CLAUDE.md`, or in `AGENTS.md` if the project has no `CLAUDE.md`. `scripts/cursor-install.mjs` and the orchestrator's own config parser (`scripts/parse-config.mjs`) read the block from the project-instruction file regardless of which client loaded it ([`skills/_shared/instruction-file-resolution.md`](../skills/_shared/instruction-file-resolution.md) defines the precedence).
35
35
 
36
36
  Add a `## Session Config` section:
37
37
 
@@ -68,7 +68,7 @@ Cursor has no Skill tool. Commands tell the agent to **Read** `skills/<name>/SKI
68
68
  | Interactive choices | AskUserQuestion tool | Numbered Markdown lists |
69
69
  | Agent dispatch | Agent() tool (parallel) | Sequential execution (no subagents) |
70
70
  | State directory | .claude/ | .cursor/ |
71
- | Config file | CLAUDE.md | CLAUDE.md — native pickup **unverified**; Cursor's rules doc names `.cursor/rules` + `AGENTS.md` (see § Configuration) |
71
+ | Config file | CLAUDE.md | CLAUDE.md, read like `AGENTS.md` (Cursor rules help, checked 2026-10-21; see § Configuration) |
72
72
  | Task tracking | TaskCreate/TaskUpdate | Text-based checklists |
73
73
  | Pre-edit enforcement | PreToolUse hook (blocks) | `preToolUse` via cursor-hook-bridge (blocks) |
74
74
  | Command enforcement | PreToolUse hook (blocks) | `beforeShellExecution` via cursor-hook-bridge (blocks) |
@@ -157,15 +157,16 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
157
157
 
158
158
  | Event | Emitter | Hook / trigger |
159
159
  |---|---|---|
160
- | `orchestrator.session.started` | `hooks/on-session-start.mjs` | SessionStart. **Optional, additive:** `peers_superseded` (number, GH#67) — the count of mechanically-detected peers (`mechanicalPeers`) whose `lockSuperseded === true` (a LIVE lock at this root is held by a different raw session_id than that registry-only peer — a HINT, not a verdict; see the GH#67 discussion above). Computed as `mechanicalPeersSuperseded` and rendered inline per peer via `supersessionMarker()` in the banner text. `peer_count` is deliberately left unchanged by this addition, so the supersession rate is measurable (`peers_superseded` / `peer_count`) instead of only inferred from banner prose (HR-105). **Optional, additive (#1091, 2026-09-09):** `native_source` (string) — the harness-supplied SessionStart `source` (`startup` \| `resume` \| `clear` \| `compact` on Claude Code; other harnesses' enums pass through verbatim), OMITTED when stdin carries no `source`, never `null`/`""`. Measurement only: it makes "does the same raw `session_id` repeat under `source: resume`?" answerable from this ledger; no continuity logic reads it. The Claude Code matcher in `hooks/hooks.json` was widened to `startup\|resume\|clear\|compact` in the same change — before it, a native resume never invoked this hook at all. **Consequently, since this matcher widening (2026-09-09), this row fires once per SessionStart SOURCE, not once per logical session** — a session that resumes N times contributes N rows, so count DISTINCT `session_id` values to count sessions, and read `native_source` to tell which source produced each row. Companion field `resume_linkage` (`raw-id` \| `semantic` \| `none`, omitted together with `native_source`): `raw-id` = the stdin `session_id` equals the one in `current-session.json`, so the prior semantic id is REUSED and the wave high-water marks (`last_wave`, `last_batch`, `wave_start_sha`) are preserved; `semantic` = the pre-existing semantic-match branch; `none` = unverified restart, fresh id, no preservation (the #1091 contract: never guess continuity). **Optional, additive (#1443, 2026-09-25):** `head_sha` (string) — the full commit SHA of `HEAD` in the project root at SessionStart (`git rev-parse HEAD`; 40 hex, 64 on a SHA-256 repository), OMITTED when it cannot be measured (no repository, unborn `HEAD`, git unavailable), never `null`/`""`. `scripts/emit-session.mjs` reads it as the fallback for `session_start_ref` when STATE.md carries no `session-start-ref` — from the FIRST row whose `session_id` is the session's own raw id, never a later resume/compact row (a first row without `head_sha` yields no ref rather than a later row's sha); no usable sha from either source → the field is omitted with a WARN |
160
+ | `orchestrator.session.started` | `hooks/on-session-start.mjs` | SessionStart. **Additive (#1650, 2026-10-17):** `recent_starts_count` (number) — foreign session-registry entries whose `started_at` lies within the last `recent_starts_window_min` minutes (number, currently `5`); self excluded, always present. The start-wave signal: the banner's WARN icon fires at `recent_starts_count >= 5` instead of on `peer_count`, which stays reported as information. Report only — it caps nothing (HR-104). **Optional, additive:** `peers_superseded` (number, GH#67) — the count of mechanically-detected peers (`mechanicalPeers`) whose `lockSuperseded === true` (a LIVE lock at this root is held by a different raw session_id than that registry-only peer — a HINT, not a verdict; see the GH#67 discussion above). Computed as `mechanicalPeersSuperseded` and rendered inline per peer via `supersessionMarker()` in the banner text. `peer_count` is deliberately left unchanged by this addition, so the supersession rate is measurable (`peers_superseded` / `peer_count`) instead of only inferred from banner prose (HR-105). **Optional, additive (#1091, 2026-09-09):** `native_source` (string) — the harness-supplied SessionStart `source` (`startup` \| `resume` \| `clear` \| `compact` on Claude Code; other harnesses' enums pass through verbatim), OMITTED when stdin carries no `source`, never `null`/`""`. Measurement only: it makes "does the same raw `session_id` repeat under `source: resume`?" answerable from this ledger; no continuity logic reads it. The Claude Code matcher in `hooks/hooks.json` was widened to `startup\|resume\|clear\|compact` in the same change — before it, a native resume never invoked this hook at all. **Consequently, since this matcher widening (2026-09-09), this row fires once per SessionStart SOURCE, not once per logical session** — a session that resumes N times contributes N rows, so count DISTINCT `session_id` values to count sessions, and read `native_source` to tell which source produced each row. Companion field `resume_linkage` (`raw-id` \| `semantic` \| `none`, omitted together with `native_source`): `raw-id` = the stdin `session_id` equals the one in `current-session.json`, so the prior semantic id is REUSED and the wave high-water marks (`last_wave`, `last_batch`, `wave_start_sha`) are preserved; `semantic` = the pre-existing semantic-match branch; `none` = unverified restart, fresh id, no preservation (the #1091 contract: never guess continuity). **Optional, additive (#1443, 2026-09-25):** `head_sha` (string) — the full commit SHA of `HEAD` in the project root at SessionStart (`git rev-parse HEAD`; 40 hex, 64 on a SHA-256 repository), OMITTED when it cannot be measured (no repository, unborn `HEAD`, git unavailable), never `null`/`""`. `scripts/emit-session.mjs` reads it as the fallback for `session_start_ref` when STATE.md carries no `session-start-ref` — from the FIRST row whose `session_id` is the session's own raw id, never a later resume/compact row (a first row without `head_sha` yields no ref rather than a later row's sha), and never when the session's own registry entry shows `work_root` ≠ `session_root` (#1652 — the sha was measured in the SessionStart root, not the tree the session works in; omitted with a WARN); no usable sha from either source → the field is omitted with a WARN. **Optional, additive (#1668, 2026-10-17):** `upstream_sha` (string, full sha of `@{u}`) and `behind` (non-negative integer, `git rev-list --count HEAD..@{u}`) — read from LOCAL refs only (no fetch, 1 s bound per call), written together or OMITTED together (no upstream, timeout, unparseable output), never `0`/`null` as a stand-in for "not measured". `scripts/emit-session.mjs` drops the `head_sha` fallback ref with a WARN when `behind > 0` is measured: after a pull, `<head_sha>..HEAD` would also list the pulled-in upstream commits, and no sha known at session start (merge-base included, which equals `head_sha` for a checkout that is merely behind) bounds the range correctly. `behind: 0` and rows without the fields keep the previous behaviour |
161
161
  | `orchestrator.session.ended` | `hooks/on-session-end.mjs` | SessionEnd. **Payload:** `reason` (always), plus `session_id` / `semantic_session_id` / `duration_ms` — **each OMITTED, never fabricated, when it could not be measured** (#1068 AC1; `duration_ms` since the W5 F1 sweep). `duration_ms` is written ONLY when the ending session IS the one `.orchestrator/current-session.json` records (the `isRecordedSession` predicate, decided on the RAW stdin UUID) AND that file's start timestamp parsed. Until that fix it fell back to a hard `0`: **1082 of 1498** fleet records (72,2 % — 415 nonzero, 1 key absent, measured 2026-09-02) carried a zero that reads as a MEASURED zero-length session and is indistinguishable from one. An ABSENT `duration_ms` means NOT MEASURED, never "instant" — same omit-never-fabricate contract as `session.stopped` / `agent.stopped` above |
162
162
  | `orchestrator.telemetry.flush` | `hooks/on-session-end.mjs` (`flushTelemetry()`; result shaped by `classifyFlush()`) | SessionEnd, exactly once per teardown and LAST (#1138) — after backfill, lock release and deregistration are durable, because it is the only step that may touch the network. Emitted whatever the outcome. **Payload:** exactly `outcome`, `reason` and `reason_class` — no queue payload, no anon id — plus the `session_id` / `semantic_session_id` / `wave` / `schema_version` envelope `emitEvent()` stamps. `outcome`: `sent`, `queued`, `gated` or `skipped`. `reason`: `flush()`'s own reason IN FULL, bounded to `FLUSH_REASON_MAX_CHARS` (200) — so a sandbox refusal is written as `sandbox:temp-root`, not `sandbox` — or `persistence-disabled` (Session Config `persistence: false`; `flush()` is never called), or `error` (`flush()` threw). `reason_class`: the head token before the first `:` (`sandbox`, `gated`, `build-error`, `persistence-disabled`, `error`), for aggregation. **Until #1392 (2026-09-20) `classifyFlush()` CUT `reason` at its first `:`, so the health banner's `startsWith('sandbox:')` predicate could never match a record from this emitter** — the wire is now proven by `tests/hooks/on-session-end.test.mjs` § "telemetry-flush breadcrumb → flush-health banner (wiring, #1392)", which drives a real `flush()` refusal through the real writer into the real banner. Readers: `scripts/lib/telemetry-flush-health-banner.mjs` via its `session-start-probes.mjs` registry entry (newest record; warns on a `reason` starting with `sandbox:`), and `scripts/lib/sessions-staleness-banner.mjs`, which SKIPS it as a closing diagnostic |
163
163
  | `orchestrator.turn.stopped` | `hooks/on-stop.mjs` (`handleStop`) | Stop, once per ASSISTANT TURN. **The canonical name for this event since 2026-09-06 (GitLab #1234); payload identical to the deprecated `orchestrator.session.stopped` row below, minus its `deprecated` marker.** **Why the rename:** the old name says *session*, the emitter fires per *turn*. Measured 2026-09-06 over the 90-day fleet window: **15.538 records against 2.016 distinct `orchestrator.session.started` ids = 7,7 per session**, with **184 for a single id**. Six consumers read it as a session-lifecycle signal and were therefore wrong by that factor — any "sessions stopped" count derived from it is a turn count. **A turn is not a session, and the count is not a rate:** to count sessions, count `session.started` ids; to count closes, count `sessions.jsonl` records with `status: completed`. **Migration:** both names carry the same payload for one generation; the legacy name additionally carries `deprecated: true`. Removal of `orchestrator.session.stopped`: **2027-03-06**. Readers should switch the name they match on and change nothing else. **NOT affected:** the SubagentStop branch keeps emitting `orchestrator.agent.stopped` — a different event whose per-agent cardinality is correct |
164
164
  | `orchestrator.session.stopped` | `hooks/on-stop.mjs` (`handleStop`) | Stop. **Payload:** `session_id`, `semantic_session_id`, `wave`, optional `branch` / `commit`, optional `reaper_trigger` (`throttled` \| `spawned` \| `spawned-unthrottled` \| `error` — the outcome `maybeTriggerOrphanScan()` from `scripts/lib/orphan-reaper/trigger.mjs` returned for THIS fire; the key is ABSENT when `reaper.enabled` is false, which is configuration, not a measurement — and when the project dir cannot be resolved (no `*_PROJECT_DIR` and a deleted cwd): no config can be read then, so the trigger answers `disabled`, never `error` (#1498); `spawned-unthrottled` is a throttle marker the trigger could not stamp, and the healthy values are recorded too so it has a denominator — #1489 Pkt 10, HR-105/HR-106), plus the pair `duration_ms` + `duration_source` (`stdin-start-ms` \| `session-lock`). **The pair is written TOGETHER or omitted together — never `0`.** Until this change `duration_ms` was a hard `0` in **8.127 of 8.127** fleet records (measured 2026-09-02): the expression fell back to `0` because the harness never sends `start_ms`, and a fabricated zero reads as a MEASURED zero-length turn, indistinguishable from one. The span is now derived from `.orchestrator/session.lock` `started_at` — **ownership-gated on the RAW stdin `session_id`**, because a lock in this working copy routinely names a live PEER session (the resolved id is deliberately not used: it falls back to `current-session.json`, which is the foreign-identity inheritance the guard refuses). No owned, readable, parseable lock ⇒ both keys absent, which means NOT MEASURED and never "instant". **`duration_source` says WHICH span the number is, and the two are not the same quantity:** `session-lock` is SESSION-elapsed measured at this turn's end — Stop fires per TURN while `started_at` is stamped once per SESSION, so it GROWS MONOTONICALLY across a session's turns (the last Stop of a 3-hour session reports ~3 hours, not its final turn); `stdin-start-ms` is TURN-elapsed, the only first-party measurement of the turn itself, and the harness has never sent it. Do not sum `session-lock` spans over a session — that double-counts. **DEPRECATED since 2026-09-06 (GitLab #1234), removal 2027-03-06** — superseded by `orchestrator.turn.stopped` above, which carries the identical payload under the name that matches what the emitter actually measures. Every record emitted under this name since the rename additionally carries `deprecated: true`, so a reader can tell at a glance that it matched the legacy name; a record WITHOUT that key predates 2026-09-06. Both names are emitted from the same payload object, so they can never disagree |
165
165
  | `orchestrator.session.backfill_completed` | `hooks/on-session-end.mjs` (`emitBackfillOutcome`) · `scripts/backfill-abandoned-sessions.mjs` (`emitBackfillCompleted`, the startup/CLI path, #1167) | SessionEnd, once per backfill call (#1068 AC2 — the backfill outcome is canonically queryable, the side-log is no longer the only result source) — and separately, once per record the startup/CLI path itself writes, which until #1167 wrote SILENTLY: the SessionEnd hook was the only emitter, so nothing distinguished "the backfill never ran" from "it ran at startup". Both producers mirror the same payload shape so one filter queries either. Payload: `kind` (`abandoned`\|`state-md-completed`), `action` (the backfill result action, e.g. `appended`\|`superseded`\|`skipped-already-recorded`\|`skipped-key-occupied`\|`unknown`; `skipped-key-occupied` (#1388 P8) is the key-occupancy guard — the closing session's own identity was classified absent, yet a canonical non-stub record of a DIFFERENT session already holds `record_id`, so appending would have displaced it — distinct from `skipped-already-recorded`, which means this very session is already on file), plus `session_id`, `semantic_session_id`, `record_id`, `supersedes`, `reason` — each OMITTED when unknown, never `null`. Best-effort on both paths: emission is wrapped in try/catch so a telemetry failure never blocks the backfill it describes |
166
+ | `orchestrator.session.work_tree` | `hooks/pre-bash-staging-fence.mjs` (`recordWorkTrees`) | PreToolUse(Bash), BEFORE every `git commit` the command contains, for coordinator and wave agents alike (#1659). One record per commit and per targeted tree; the reader dedupes. **Payload:** `session_id` (the RAW harness id — a dispatched subagent carries the coordinator's raw id, so its commits count for the coordinator), `tree` (realpath of the commit's toplevel, resolved from the hook cwd, `cd <dir>` statements and `git -C <dir>`), `start_ref` (that tree's full `HEAD` sha before the commit). Reader: `scripts/lib/session-work-trees.mjs` (`readSessionWorkTrees`, first record per tree wins; `sessionCommitFiles` diffs each tree only inside itself), surfaced by `scripts/session-commit-files.mjs`. **Not recorded:** commits nested in `bash -c "…"`, a `cd` operand needing shell expansion, any commit after `pushd`/`popd`/`chdir`/`builtin cd` in the same command (directory unknown), the first commit on an unborn HEAD, anything while `SO_DISABLED_HOOKS=pre-bash-staging-fence`. Codex sessions record too since #1669 (the fence is wired in `hooks/hooks-codex.json`). **Misattributed:** subshell scope is not modelled — in `(cd x && git commit); git commit` the second commit is recorded for `x` too. A commit inside `$( … )` or backticks is judged after all outer statements (the lexer hoists the body, #1667): it is recorded for the start directory when no outer statement changed directory, and NOT recorded otherwise (#1670), so `echo $(git commit) && cd sub` never lands on `sub` |
166
167
  | `orchestrator.session.root_left` | `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) | a session left a repo root for good — the process-boundary teardown of Worktree-Auto-Promotion (#1069), emitted into the OLD root's stream after `deregisterSelf()` + `release()`, whether or not either found anything. NOT emitted when the teardown ABORTED (invalid args, a lock owned by another session, an unparseable lock): the event asserts a root was left, and on those branches none was. Payload: `session_id`, `semantic_session_id` (OMITTED when unknown, never `null`), `from_root_hash` (`repoPathHash()` of the abandoned root — the SAME hash the session registry keys its entries by, so a departure joins to the entry it removed), `from_root_basename` (matching the registry's `repo_name`), `reason` (e.g. `worktree-promotion`). **Never the absolute root:** this payload also travels over the optional Clank webhook with no redaction, and an absolute root on a developer host is `/Users/<operator>/…` — same rule as `board_written` / `mirror_completed` and `relativeWorktreePath` in `worktree-pipeline.mjs`. Its absence beside a live-looking registry entry is the phantom-peer signature the event exists to make visible |
167
168
  | `orchestrator.fleet.checkin` | `scripts/fleet-checkin.mjs` | session-start Phase 7.6 / operations-contract peer preflight (#1462), once per written check-in. Payload: `session`, `repo`, `modus`, `kandidaten`, `navigator_state` (`active`\|`none`\|`unreadable`), plus the `emitEvent()` envelope. Never paths, quota or `auftrag_ref` |
168
- | `orchestrator.session.lock.acquired` | `hooks/_lib/lock-bootstrap.mjs` | SessionStart. Payload: `session_id`, `semantic_session_id`, `mode`, `pid`, `host`, `ttl_hours`; `reclaimed` (`stale-heartbeat`\|`corrupt`) only when this start took over a FOREIGN stale lock or an unparseable one (a session re-acquiring its own stale lock under the same raw id is a refresh and carries no key) — the bootstrap's stderr WARN is discarded by the harness, so this key counts the takeovers that went through the FORCE path — not every takeover: a start whose FIRST `acquire()` runs while a rival holds the stale lock in its tombstone finds the path empty, creates its lock as on a fresh start and emits a bare `lock.acquired` (no `reclaimed`), while the rival loses its create and emits nothing (reproduced deterministically; seen in 3 of 220 two-racer trials by the #1494 review, 2026-10-02). The takeover is single-winner since #1494: of two starts that reclaim the same lock at once, the loser emits nothing here, gets `active` with the winner's lock and records `conflict_with_session_id` in `.orchestrator/current-session.json` — so two `reclaimed` records for one checkout within a second mean the race is back |
169
+ | `orchestrator.session.lock.acquired` | `hooks/_lib/lock-bootstrap.mjs` | SessionStart. Payload: `session_id`, `semantic_session_id`, `mode`, `pid`, `host`, `ttl_hours`; `foreign_work_root` (`true`\|`false`\|`null`, #1636 Pkt 2) — a fresh registry peer works in this checkout while its lock sits in another one (ExitWorktree, #1634), `null` when the registry could not be read; a measurement for HR-101 before any conflict banner, absent only when its reader failed to load; `reclaimed` (`stale-heartbeat`\|`corrupt`\|`own-process`) only when this start took over a FOREIGN stale lock, an unparseable one, or (`own-process`, #1641) the live lock of its own predecessor in the same Claude Code process after `/clear` (a session re-acquiring its own stale lock under the same raw id is a refresh and carries no key) — the bootstrap's stderr WARN is discarded by the harness, so this key counts the takeovers that went through the FORCE path — not every takeover: a start whose FIRST `acquire()` runs while a rival holds the stale lock in its tombstone finds the path empty, creates its lock as on a fresh start and emits a bare `lock.acquired` (no `reclaimed`), while the rival loses its create and emits nothing (reproduced deterministically; seen in 3 of 220 two-racer trials by the #1494 review, 2026-10-02). The takeover is single-winner since #1494: of two starts that reclaim the same lock at once, the loser emits nothing here, gets `active` with the winner's lock and records `conflict_with_session_id` in `.orchestrator/current-session.json` — so two `reclaimed` records for one checkout within a second mean the race is back |
169
170
  | `orchestrator.session.lock.released` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) · `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) · `scripts/release-session-lock.mjs` (#1395, the verifying CLI that session-end Phase 3.8 invokes; `caller: 'session-end-phase-3-8'`, both ids passed EXPLICITLY because the lock is gone before the emit, and NO event on `outcome: 'absent'`) | after a `release()` that matched ownership (#952). **The fourth producer exists because Phase 3.8 was PROSE:** the coordinator deleted the lock by hand, so the SessionEnd hook later found `status: 'absent'` and emitted nothing — measured 2026-09-19 in this repo, 5 `lock.acquired` against 0 `lock.released`, and fleet-wide 110 terminal events against 702 acquisitions (15,7 %). Payload: `session_id`, `caller` (`on-session-end`\|`worktree-pipeline`\|`session-transition`\|`session-end-phase-3-8`), `outcome` (`deleted`\|`already-gone`), `verified`; hook-side additionally `lock_session_id`, `semantic_session_id`, `end_reason` (the SessionEnd reason — deliberately NOT `reason`, which the sibling `release_failed` uses for the failure reason); pipeline-side additionally `worktree_path`, `issue_iid`. The `session-transition` caller emits only this event and no `release_failed` sibling — a failed release there is reported to its caller as `{ ok: false, reason: 'lock-<reason>' }`, which the promotion prose must WARN on, so the stream is not the only witness. `outcome: 'already-gone'` means the lock had ALREADY vanished between `readLock()` and `release()` — the forensically interesting case, since a successful release previously left no trace at all and a missing lock was therefore indistinguishable from a lock someone else deleted (#914 residual 3) |
170
171
  | `orchestrator.session.lock.release_failed` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) | ownership matched but `release()` did NOT delete the lock (#724). Payload: `session_id`, `reason` (`fs-error`\|`session-mismatch`\|`not-deleted`\|`threw`), `caller`; pipeline-side additionally `worktree_path`, `issue_iid` |
171
172
  | `orchestrator.session.lock.read_anomaly` | `hooks/on-session-end.mjs` (teardown step (b), via `readLockDetailed()` from `scripts/lib/session-lock.mjs`); `hooks/_lib/lock-bootstrap.mjs` (SessionStart, `unreadable` only, #1494) | SessionStart emits it once when `acquire()` answers `unreadable`: the lock path exists but cannot be read, so the session runs without a lock (payload as below; `session_id` is the starting session's raw id). SessionEnd, at most once, when `.orchestrator/session.lock` EXISTS but cannot be used: the read failed with anything but ENOENT (`unreadable` — e.g. EISDIR, EACCES), or the content does not parse as a lock (`corrupt`). Neither release nor reconciliation runs then; before this event an unusable lock was indistinguishable from "no lock". **Payload:** `session_id` (the ending session's id as `resolveSession()` resolves it — the raw stdin id, falling back to the one `.orchestrator/current-session.json` records; `null` when neither names one), `status` (`'unreadable'` or `'corrupt'`), and `error` for `unreadable` only (the raw `err.message` — for an open failure it contains the absolute lock path). Because the payload supplies `session_id`, `emitEvent()` stamps NO `semantic_session_id` (a caller key suppresses the whole correlation envelope). An error path, so 0 records is a correct reading (this repo's `events.jsonl{.1,}`, 2026-09-19: 0). `scripts/lib/session-transition.mjs` deliberately does NOT emit it — it returns `lock-unreadable` / `lock-corrupt` to a caller that must WARN. Read only by `scripts/lib/sessions-staleness-banner.mjs`, which SKIPS it as a closing diagnostic |
@@ -175,7 +176,7 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
175
176
  | `orchestrator.memory.propose_invoked` | `hooks/pre-bash-memory-propose-audit.mjs` | PreToolUse(Bash), once per `node … memory-propose.mjs …` invocation the G3 regex matches — observe-only, the hook never denies. **Payload:** `session_id` (or `null`), `wave`, `cwd` (the working directory RELATIVE to the resolved project dir — `.` when equal, `scripts/lib` from a subdirectory; a directory OUTSIDE the project is written as `sha256:<12 hex>` of its resolved path, and so is EVERY cwd when the project dir is the filesystem root, the home dir or an ancestor of it (`/Users`), where the relative form would read `Users/<name>/…`; that 12-hex sha256 prefix is PSEUDONYMISATION, not anonymisation — a guessable path (a home dir, a usual checkout location) is recovered offline by hashing candidates; **never an absolute path** — until 2026-09-25 (#1439) it was `process.cwd()` verbatim, i.e. the operator's home path in an `events.jsonl` line; zero production readers), `exit_code` (always `null` — a PreToolUse hook runs BEFORE the command), plus the #1415 argv summary: `command_hash` (**sha256 of the RAW command, truncated to 16 hex — same recipe as `enforce-commands` after `8f15f77b`; never the command text**), `flags_present` (string[], the subset of `memory-propose.mjs`'s OWN flag NAMES — `type`, `subject`, `insight`, `evidence`, `confidence`, `dry-run`, `file-paths`, `help` — present in the command, in that fixed order; built by intersecting a CLOSED list, never by scraping `--\S+`, so a flag-shaped VALUE can never be reported as a flag) and `argv_length` (number, the raw command's character count). Until 2026-09-21 this row carried `argv_truncated`: 512 characters of command TEXT, redacted only per-VALUE for five flags, whose own caveat named its gaps (`--insight=$VAR`, `--insight=$(cat secret)`, a secret written anywhere else on the line). It went into an `events.jsonl` line (gitignored here via `.gitignore:57`; consumer repos may track it) and, with the Clank webhook configured, onto the network. **Measured 2026-09-21** (`rg argv_truncated` over `scripts/ hooks/ skills/ docs/`): **zero production readers** — the field's only consumers were this hook's own tests. Pinned in `tests/hooks/pre-bash-memory-propose-audit.test.mjs` in BOTH directions (summary fields present AND `argv_truncated`/`command` undefined), plus a negative test that string-searches the serialized record for every sentinel value — so nobody puts the raw field back beside the hash |
176
177
  | `orchestrator.wave.completed` | `hooks/post-tool-batch-wave-signal.mjs` · `hooks/on-session-end.mjs` (`emitFinalWaveCompleted`) | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612), closing wave N at the N→N+1 transition; an explicit injected `wave_signal: 'wave-complete'` still takes precedence. **The former `started` sibling was REMOVED 2026-09-19** (operator decision, #1202 §11): it carried no measurement, its only reader was `scripts/lib/convergence-monitor.mjs`, and there it cost the one signal it could not feed — the batch hook wrote it in the same millisecond as completed{N}, so both shared a tail tick and it masked the (N-1, N) `shrinking_diff` pair. An injected `wave_signal: 'wave-start'` is still accepted and emits nothing; historical records stay in the ledgers and nothing reads them. **Re-emission ceiling (open):** a session that does not own `.orchestrator/current-session.json` never advances the marks below, so each of its batches re-emits completed{lastWave} until its own SessionStart takes the file over — measured 2026-09-19 as up to 579 emissions for one (session_id, wave_number) group. **Second `.completed` emitter (#1193):** the batch hook closes wave N-1 only at an N-1→N transition, so the LAST wave of every session never received a completion — measured fleet-wide 2026-09-02 as **296 gaps over 296 wave runs** (1018 started vs 722 completed), exactly one missing final completion per run. SessionEnd now emits it. **Payload of that record:** `wave_number` (the `current-session.json` `last_wave`), `reason: 'session-end'`, `emitted_by: 'on-session-end'`, plus `session_id` / `semantic_session_id` — **omitted when unattested**, never fabricated. **Idempotent** via the `last_wave_completed` high-water mark in `.orchestrator/current-session.json`, written by BOTH emitters and preserved across `/clear`+compact by `on-session-start.mjs`; `last_wave` absent or `0` emits nothing (an Express-Path or coordinator-direct session never batched, and zero waves is the correct reading, not a gap). **Two gates on the SessionEnd emitter, both load-bearing:** (a) OWNERSHIP — `current-session.json` is a single repo-global file describing whichever session most recently ran SessionStart, routinely a different still-live session in a shared working copy, so the emit reuses `resolveSession()`'s `isRecordedSession` predicate; when false it emits nothing AND writes nothing, since writing the marker into a peer's file would silence the peer's own SessionEnd and preserve this very gap on the wrong session. (b) REASON — `reason === 'clear'` **and `reason === 'resume'`** are SKIPPED: the SessionEnd matcher is empty, so `/clear` fires the hook mid-wave while the LOGICAL session continues, and `on-session-start.mjs` preserves `last_wave` / `last_wave_completed` across a resume of the SAME logical session exactly as it does across a clear — so both end the HARNESS session, not the logical one. Closing the live wave on either is premature, and the preserved marker would then suppress the real completion later. Resume is the MORE common of the two (fleet n = 1498 `session.ended`, 2026-09-02: 12 `resume` vs 9 `clear`). Deliberately SessionEnd-only (`on-stop.mjs` is not mirrored) so the closed-vs-abandoned split stays measurable. **Diff-size keys on the BATCH-HOOK emitter only (#980):** `files_changed` (integer) + `files_changed_source: 'worktree-vs-wave-start-sha'` (the only value emitted today; present iff `files_changed` is). Measurement: the DEDUPED union of `git diff --name-only <wave_start_sha>` and `git ls-files --others --exclude-standard`, run in the project dir at the N→N+1 transition, where `wave_start_sha` is the `git rev-parse HEAD` the batch hook persisted into `.orchestrator/current-session.json` when wave N was OPENED (same ownership gate as the wave keys; written as `null` when git is unreadable, so a previous wave's sha can never inflate the next count). Worktree-vs-sha rather than `<sha>..HEAD` because the coordinator commits at session close, not per wave — a commit-only diff reads 0 for every wave of a normal session. **Both keys are OPTIONAL and absent-is-not-zero:** any git failure, a 1.5 s timeout, or a missing `wave_start_sha` omits them, and `scripts/lib/convergence-monitor.mjs` reads an absent key as `null`, so the `shrinking_diff` signal simply does not fire (it never reads a fabricated 0). The `on-session-end.mjs` final-wave `.completed` carries NEITHER key by design — no wave-open transition runs there, so it has no start sha to measure against |
177
178
  | `orchestrator.wave.final_refused` | `hooks/on-session-end.mjs` (`emitFinalWaveCompleted` → `emitFinalRefused`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`, wrapped in its OWN try/catch — independent of the caller's outer catch — so a telemetry failure on one refusal can never surface as a teardown failure) | the SIBLING event to `orchestrator.wave.completed` above (#1201 Part B / Discovery D8) — fires exactly once per SessionEnd in which the final `.completed` was NOT emitted, one row per refusal. Deliberately a SEPARATE event name rather than `.completed` carrying `emitted:false`: existing consumers of `.completed` treat every row as a finished wave, and overloading it would silently corrupt that count. **Payload:** `reason` (always present, closed enum — verified against `emitFinalWaveCompleted`'s own call sites) `not-recorded` \| `clear` \| `resume` \| `unreadable` \| `session-id-mismatch` \| `no-wave` \| `already-completed` \| `exception`, `emitted_by: 'on-session-end'` (always), plus `session_id` / `semantic_session_id` — omitted when `null`, never fabricated. **Optional, absent-is-not-zero:** `wave_number` — present ONLY on the `already-completed` reason, the sole call site that passes a resolved `last_wave` through to `emitFinalRefused`; every other reason, INCLUDING `no-wave`, omits it (that path never resolved a wave number at all — the function's own JSDoc pins this: "only when `last_wave` was resolved to a positive number before the refusal (currently only `already-completed`)"). Six of the eight reasons were previously SILENT refusal paths with no trace anywhere (`.claude/rules/host-resources.md` § HR-105: "a refusal that writes nothing is unfalsifiable") |
178
- | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **Both emitters:** optional `wave_number` (positive safe integer from the running own-session manifest at the event root). **CLI only:** `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **CLI only, reporting variants only (`baseline` \| `incremental` \| `per-file`, #1487 point 3):** `check_failed` (boolean — `true` when the envelope's `typecheck` or `test` object carries `status: 'fail'`, `false` when both are `pass`/`skip`; **omitted** when the envelope is unparseable or a status is unknown, and always on `full-gate`, whose event name already carries the verdict). The event name and exit code of a reporting variant are unchanged — `check_failed` is where its check verdict lives. **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (both emitters, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is not a positive safe integer; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
179
+ | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Name (CLI, #1459, 2026-10-01):** `.failed` when `exit_code` ≠ 0 OR the joined kill fields say `timed_out: true`, in every variant. The reporting variants (`baseline` \| `incremental` \| `per-file`) exit 0 even after a failing check, so for them `.passed` means "not killed", not "checks green" — the check verdict lives in the gate output, not in the event name. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **Pre-push emitters (outside the CLI):** `scripts/lib/pre-push-remote-gate.mjs` (`variant: 'full-gate'`, plus `remote_host`, `host`, `host_source`, `pushed_sha`) and `scripts/lib/pre-push-local-partial.mjs` (#1642, `variant: 'local-partial'`, `host: 'local'`, `host_source: 'fallback'`, `pushed_sha`, `base_sha`, `changed_count`, and `failed_checks` — a subset of `typecheck`\|`lint`\|`test` — only on `.failed`). `local-partial` blocks on red like `full-gate` but checks only the changed files, so gate-health (`variant == 'full-gate'`) does not count it; verification-evidence counts it like every other variant, because it is a blocking check that ran (#1643). **Both emitters:** optional `wave_number` (positive safe integer from the running own-session manifest at the event root). **CLI only:** `failed_files` (string[] — the failing test FILES `gate-full.mjs` names when the runner printed a file-level summary; **omitted, never `[]`**, when nothing named a file — an empty array would read as "no file failed"). **CLI and remote pre-push route, `full-gate` only (#1673):** `rerun` (`{files: string[], status: 'pass'|'fail'}` — the timeout-only failures `gate-full.mjs` re-ran serially and how that re-run ended; **omitted** when nothing was re-run). Beside `rerun.status: 'pass'` the event is `.passed` while `counts.failed` and `failed_files` still describe the first, parallel run. **CLI only, reporting variants only (`baseline` \| `incremental` \| `per-file`, #1487 point 3):** `check_failed` (boolean — `true` when the envelope's `typecheck` or `test` object carries `status: 'fail'`, `false` when both are `pass`/`skip`; **omitted** when the envelope is unparseable or a status is unknown, and always on `full-gate`, whose event name already carries the verdict). The event name and exit code of a reporting variant are unchanged — `check_failed` is where its check verdict lives. **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Kill-ladder fields (#1439, #1457, 2026-09-25):** `timed_out` (boolean — the run hit its wall-clock ceiling and was killed), `survivors` (integer — pids still alive after the SIGKILL step, `0` when none; `null` when the post-kill check could not be measured, which on Windows means the CIM process snapshot failed, never read it as `0` (#1612); the pids themselves stay in the gate output / diagnostics bundle) and `kill_signals` (string[] — the signals actually sent, in order, e.g. `["SIGTERM","SIGKILL"]`; `[]` when none). Both emitters start from the ONE shared `gateKillFields()` (`scripts/lib/quality-gate.mjs`); from the library emitter `false` / `0` / `[]` are therefore measurements ("not killed"), not absence — the CLI emitter additionally passes them through `joinGateKillFields()` and may omit the keys (see below, #1457). The library always carries all three. The CLI JOINS two ladders with `joinGateKillFields()` (`scripts/lib/gates/gate-helpers.mjs`): the OUTER one over the gate sub-script as a whole (that run exits 124), and the INNER per-command ones the gate's JSON envelope publishes — every variant (`gate-full.mjs` on `typecheck`/`test`/`lint`, `gate-{baseline,incremental,per-file}.mjs` on `typecheck`/`test` since #1457 pkt 12) puts `timed_out` / `kill_signals` / `survivors` (pids) on each command object it actually spawned, so a single command killed by the per-command ceiling (exit 2) now reads `timed_out: true`. Join: `timed_out` = outer OR any inner, `kill_signals` = outer followed by each command's in execution order, `survivors` = outer count plus every inner pid count. **The CLI omits all three keys** when the inner side was not measured — an envelope without the per-command ladder (all four variants publish it since #1457 pkt 12, so today this means an older gate script) or an unparseable envelope — and the outer ladder did not fire; a fired outer ladder is reported alone. **Leftover groups (CLI and remote pre-push route, #1708):** when a command's process group was reaped after a NORMAL exit (#1704), the join adds `leftover_groups` (`[{command, pgid, signals, survivors}]`, one entry per such command, `command` ∈ `typecheck`\|`test`\|`lint`); the key is **omitted, never `[]`**, when no command left one, and its `survivors` pids are NOT counted in the joined `survivors`. Not carried yet (#1708): a leftover of the outer gate sub-script group and of the library emitter (`gateKillFields()` does not read `leftoverGroup`), and one from the serial re-run (`test.rerun`). The library reports the failing gate command of the last attempt, and `false` / `0` / `[]` on the passing path (every gate exited 0, so none was killed). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (both emitters, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is not a positive safe integer; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
179
180
  | `orchestrator.scope.coordinator_carveout_allowed` | `hooks/enforce-scope.mjs` (coordinator carveout, #245 / #1361) | exactly once per ALLOWED write BY THE COORDINATOR into one of the harness-owned in-repo files the carveout covers — `.claude/STATE.md` and its `.codex/` / `.cursor/` / `.pi/` siblings, plus the exact relative path of the live `wave-scope.json` the hook itself just read. Since #1361 a payload carrying `agent_id` is a dispatched subagent: it gets no carveout and falls through to Gate 7, where a manifest that does not grant the path is a DENY (a subagent permitted to write `wave-scope.json` could rewrite its own file scope and disarm every later gate of the wave; STATE.md is coordinator-owned per `skills/_shared/state-ownership.md`). **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the project-RELATIVE, forward-slash-normalized path — the carveout set is in-repo by construction, so no host-local absolute path enters the ledger) and `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — same enum and same meanings as `orchestrator.scope.memory_dir_allowed`: `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carveout and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. Before #1361 this branch was a bare `emitAllow()` with no log and no event, so whether it ever fired was unfalsifiable after the fact (HR-105) | <!-- path-check: example -->
180
181
  | `orchestrator.scope.foreign_session_ignored` | `hooks/enforce-scope.mjs` (Gate 3b, #1123) · `hooks/enforce-commands.mjs` · `hooks/post-bash-write-verify.mjs` (both Gate 3b, #1153 P1) | exactly once per gated tool call while a FOREIGN-session `wave-scope.json` is live: the manifest's `session_id`/`semantic_session_id` provably name another session (legacy `session`/`semantic_session` still read, #1153 P2), so the hook stands down instead of enforcing. Since #1504 point 6 only when EVERY existing candidate (`.pi` > `.cursor` > `.codex` > `.claude`) is foreign — a peer's higher-precedence manifest no longer hides this session's own one (`findOwnScopeFile()`, `scripts/lib/scope-gate.mjs`); `manifest` then names the first foreign candidate. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`; additionally `file_path` from `enforce-scope` (PreToolUse Edit/Write) and `command_hash` from `enforce-commands` (PreToolUse Bash) — **sha256 auf 16 Hex gekuerzt, NIE das rohe Kommando.** Bis 2026-09-19 stand hier `command` im Klartext, und zwar als dokumentierter Vertrag, nicht als Versehen. Gemessen in EventDrop.at (#1140): **3.816 Zeilen** der GETRACKTEN `.orchestrator/metrics/events.jsonl` trugen ein rohes Kommando, darin **24 distinkte echte Produktions-Share-Codes** aus **17 fremden Kundenkonten** plus ein protokollierter `select access_pin_hash` — bei 23 dieser Events ist der Share-Code die VOLLSTAENDIGE Capability (`/event/<code>` oeffnet das Album ohne Anmeldung), und das Journal geht bei jedem Klon mit. Der Geschwisterhook `pre-bash-destructive-guard.mjs` fuehrte von Anfang an `command_hash` und sagt den Grund im eigenen Kopf; diese Zeile war die Ausnahme. Der Hash haelt das Ereignis zaehlbar und gruppierbar — genau die Eigenschaft, fuer die es laut `docs/scope-collision-guard.md` existiert; das Rohfeld hatte keinen Leser. Gepinnt in `tests/hooks/enforce-commands.test.mjs` in BEIDE Richtungen (Hash vorhanden UND `command` undefined), damit niemand das Rohfeld neben den Hash zuruecklegt. **Der zweite rohe Schreiber derselben Klasse — `hooks/pre-bash-staging-fence.mjs` (512 Zeichen rohes Kommando je Eintrag) — ist seit #1404 (2026-09-20) geschlossen:** der Fence speichert jetzt die Pfad-Operanden plus `command_hash`, nie den Kommandotext. Belegt mit einem Test-Secret im Kommando: `grep -c` auf die Fence-Datei findet es 0-mal, ebenso 0 Vorkommen eines `command`-Feldes. Der einzige Leser (`hooks/wave-scope-commit-guard.mjs`) vergleicht seitdem Pfadlisten statt den Rohtext per Regex zu durchsuchen — was nebenbei `git add -A` abdeckt, das der Regex-Leser nie erkannte. **Die DRITTE und letzte Stelle derselben Klasse — `hooks/pre-bash-memory-propose-audit.mjs` (`argv_truncated`: 512 Zeichen nur flag-redigiertes Kommando in `orchestrator.memory.propose_invoked`, also in die GETRACKTE `events.jsonl` und optional an den Webhook) — ist seit #1415 (2026-09-21) geschlossen, analog `8f15f77b` und #1404:** der Hook schreibt jetzt `command_hash` + `flags_present` + `argv_length`, nie den Kommandotext. Gemessen vor dem Fix: 0 Produktionsleser des Rohfelds. Damit ist die Klasse zu — kein Hook dieses Repos legt mehr rohen Kommandotext in ein Event oder eine Kontrolldatei. `post-bash-write-verify` (PostToolUse Bash) carries neither — it reports on the working tree, not on one tool input. Legacy manifests without a session-binding field never emit this — they stay enforced |
181
182
  | `orchestrator.scope.memory_dir_allowed` | `hooks/enforce-scope.mjs` (Gate 5c, #1295) | exactly once per ALLOWED write BY THE COORDINATOR (#1352 — a payload carrying `agent_id` is a dispatched subagent, gets no carve-out and falls through to the normal gates) into THIS repo's harness auto-memory directory `~/.claude/projects/<encodeProjectDir(repoRoot)>/memory/` — the single out-of-repo carveout, evaluated only on the out-of-root branch (Gate 6) and only when a `wave-scope.json` manifest is live, so the in-repo gates are untouched. **Payload:** `hook`, `manifest` (the manifest path), `wave`, `file_path` (the REALPATH-resolved candidate) and, since #1352, `discriminator` (`'coordinator' \| 'malformed' \| 'absent'` — `'coordinator'` = `agent_type` present without `agent_id`; `'malformed'` = an `agent_id` key present but unusable (number, object, array, blank string), a fail-open that must stay rare; `'absent'` = no `agent_id` key at all, the harness's documented main-thread shape. `'subagent'` never appears here BECAUSE a subagent gets no carve-out and so emits no allow) — exactly the five keys the `emitEvent` call passes, with `{ repoRoot: projectRoot }` as options. **Fail-safe:** awaited BEFORE `emitAllow()` (which calls `process.exit()` and would discard a pending append) and wrapped in its own `try {} catch {}`, so a telemetry failure can never flip the decision. This row is the audit trail for the carveout: it is the only place an out-of-repo ALLOW becomes measurable after the fact |
@@ -211,7 +212,7 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
211
212
  | `orchestrator.learnings.malformed_dropped` | `scripts/sweep-expired-learnings.mjs` (`emitMalformedDropped`, called from `runDropMalformed` after `dropMalformedLines()` in `scripts/lib/learnings/expiry-sweep.mjs`; name const `DROP_MALFORMED_EVENT`) | once per APPLIED `--drop-malformed` run (#1500) — the one sanctioned removal of an unparseable `learnings.jsonl` line, which every rewrite otherwise keeps verbatim (#1489) and the store WARN forbids hand-editing. Emitted only after the store was snapshotted and rewritten, so a record's presence is proof the drop happened; a dry run (the DEFAULT), a refused line (exit 1 — not unparseable, or still holding a readable record behind a BOM or fused after a torn one while `--accept-embedded` was not given), a store changed since the dry run (exit 3) and an I/O or validation failure (exit 2) emit nothing. `--apply` refuses to start without `--repo-root` (and with `--file` outside it), so an applied drop always leaves exactly one record — no ambient root (#1119). **Payload, all ALWAYS present:** `file` (the store path RELATIVE to `--repo-root`), `dropped` (lines removed), `lines` (their 1-based store line numbers as of the dry run's generation), `remaining_malformed` (unparseable lines the rewrite still kept — `0` when none), `record_bearing` (dropped lines that still held a readable record, which only `--accept-embedded` lets through — `0` when none; non-zero means a learning went with the line and lives on only in the snapshot), `snapshot` (the full pre-drop copy of the store, `<stem>.pre-drop-malformed.jsonl.bak-<ISO>` beside it, RELATIVE to `--repo-root`, both judged on realpaths; outside the keep-3 `.bak` rotation, so the dropped text stays recoverable. `.orchestrator/metrics/*.jsonl.bak-*` gitignores it in this repo, and in a consumer repo once bootstrap's `#store-lock-ignore` snippet has run — before that, a `git add -A` commits it), `source` (always `sweep-expired-learnings-cli`), plus `session_id` / `semantic_session_id` via `sessionAttribution(repoRoot)`, both OMITTED when no `session.lock` is readable. **Not in the payload, deliberately:** the dropped text — the record travels over the optional webhook unredacted; the snapshot holds it. Best-effort: a telemetry failure never changes the exit code. |
212
213
  | `orchestrator.rules.expiry_sweep_applied` | `scripts/sweep-expired-rules.mjs` (emit at the tail of `main`, after the writes; name const `RULE_EXPIRY_SWEEP_EVENT` in `scripts/lib/reconcile/rule-expiry-sweep.mjs`) | once per APPLIED generated-rule expiry sweep (#1377) — the `.claude/rules/*.md` counterpart of `orchestrator.learnings.sweep_applied` one corpus over: that one archives expired LEARNINGS, this one removes the expired ENTRIES the reconcile engine generated from them. Emitted on the `--apply` path ONLY and AFTER the rewrites/deletes, so a record's presence is proof that tracked rule files actually changed; a `--dry-run` (the DEFAULT) emits nothing, which is why the discriminator here is the event's presence and not a `dry_run` field. This event exists because the removal half did not: `rule-loader.mjs` stopped INJECTING an expired generated rule at read time and nothing ever removed one from disk, so an expired file stayed tracked and kept counting against `generated-byte-ceiling` while shipping to no wave. **Payload, all ALWAYS present including as `0`:** `rewritten` (files whose expired prose blocks were removed — their `## Provenance` pairs are KEPT as `markers only`, because `/reconcile` dedupes on those markers and dropping one re-proposes the learning), `deleted` (files whose every substantive entry expired; each one's pairs are stamped terminal via `markCandidateProcessed` BEFORE the unlink), `stamped` (candidate records stamped for those deletes — `0` whenever `deleted` is `0`), `write_errors` (per-file failures; a non-zero value also makes the CLI exit 2), `expired_entries` (entries the plan judged expired across all files), `files_scanned` (machine-generated rule files enumerated), `source` (always `sweep-expired-rules-cli`, separating this producer from any future session-end caller). A ZERO-WRITE APPLY IS EMITTED: `rewritten: 0` + `deleted: 0` is *nothing was expired*, while a non-zero `write_errors` beside them is *every write was refused* — outcomes a success-only emitter would collapse into one silence (`.claude/rules/host-resources.md` § HR-105). **Not in the payload, deliberately:** the per-file plans, including the `no-1to1-mapping` skips and the unresolvable `learning-id`s. Those carry learning subjects and rule slugs, and this record travels verbatim over the optional Clank webhook with no redaction — the full plan stays on stdout (`--json`). The emit is try/catch-wrapped because `emitEvent` THROWS `EventValidationError`, which must never turn a completed sweep into a failed one; `session_id` / `semantic_session_id` / `wave` / `schema_version` are stamped by `emitEvent()` |
213
214
  | `orchestrator.session.shape_resolved` | `scripts/lib/session-shape.mjs` (`resolveAndRecordSessionShape`, emit in `_emitShapeResolved`; name const `SESSION_SHAPE_EVENT`), reachable as an entrypoint via `scripts/session-shape.mjs` | once per session-shape resolution, at the moment the coordinator turns the confirmed mode into an execution plan. Until this event existed the shape lived in PROSE at 27 sites contradicting each other in 8 answers (measured 2026-09-09), so "how many waves did this session actually run" was unanswerable from the ledger — which is why 6 consumer-repo `housekeeping` sessions ran the full 5-wave deep shape unnoticed. **Payload:** `session_type` (always), `total_waves`, `waves_config_honored` (false exactly when the ultradeep profile ignored the Session Config `waves` value — the fixed 7-wave shape of `skills/session-plan/SKILL.md` § Role-to-Wave Mapping; PRD AC-9's `waves < 7` rejection was dropped 2026-09-09), `discovery`, `agent_caps` (one clamped cap per wave, in wave order — `min(tier raw, agents-per-wave)`, `0` on a coordinator-direct wave), `coordinator_direct_waves` (the 1-based `n` of every coordinator-direct wave; `[]` is a MEASURED empty list, not an omission — a housekeeping shape must read `[1]` and a plain deep shape `[]`), `shape_version` (the `SESSION_SHAPE_VERSION` contract the record was produced under). **Optional, absent-is-not-zero:** `session_profile` (OMITTED, never `null`/`''`, when the session has no profile — a written null would read as "measured, no profile"; value set is the closed `VALID_SESSION_PROFILES`), `task_count`, plus `session_id`/`semantic_session_id` via `sessionAttribution(repoRoot)` — the SAME root the record is pinned to, omitted rather than fabricated without a readable `session.lock`. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling through to `SO_PROJECT_DIR` (#941), and `--no-event` skips it entirely so a planning dry-run cannot record a session that never ran. `events.mjs` is imported lazily so a pure-resolver consumer does not gain `platform.mjs` and its module-load filesystem walk-ups |
214
- | `orchestrator.issue_budget.reconciled` | `scripts/lib/issue-budget-reconcile.mjs` (`reconcileIssueBudget`, emit in `emitIssueBudgetReconciled`; name const `ISSUE_BUDGET_RECONCILED_EVENT`) | once per session close — the cross-check between what the session RECORDED as created (`record.issues_created.length`) and what the issue-budget ledger CHARGED. It exists because `readBudgetState` returns a ZEROED state for a MISSING counter file, so "the hook never ran for a single create" and "the session created nothing" are byte-identical in its return value; measured 2026-09-09 on a real session record with **26** recorded creations, **0** charged and no counter file under either accounting key. **Payload:** `verdict` (always — `match` \| `escaped` \| `no-ledger` \| `stale-record`; `no-ledger` is the absent-ledger case above, `stale-record` the inverse), `recorded`, `charged`, `exempt`, `overflow`, `escaped` (= `max(0, recorded − charged − exempt)`), and `ledgers` — one record per accounting key looked up (`key`: `semantic`\|`raw`, `path` — **repo-RELATIVE** `.orchestrator/runtime/issue-budget/<hash>.json`, never absolute: this payload also travels over the optional Clank webhook with no redaction, and an absolute ledger path names the operator's home directory and the private repo slug; the absolute form stays in the local WARN text only, `found`, `charged`, `exempt`); a fifth verdict `corrupt-ledger` marks a file that exists but has a non-integer `count`. **BOTH keys are read and SUMMED**, because the accounting key is semantic only when `current-session.json` verified the raw id (`resolveIssueBudgetSessionId`) — measured in one consumer repo: 25 of 36 counter files keyed semantic, 11 keyed raw, so reading one key reports a phantom escape for every session that used the other. `found` is measured with `existsSync` BEFORE the read and is the only thing that separates `no-ledger` from a real zero — read an absent `found` as "not measured", never as "no spend". Plus `session_id`/`semantic_session_id` via `sessionAttribution`. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling through to `SO_PROJECT_DIR` (#941); `events.mjs` is imported lazily so no consumer of the reconcile module gains `platform.mjs`'s module-load filesystem walk-ups |
215
+ | `orchestrator.issue_budget.reconciled` | `scripts/lib/issue-budget-reconcile.mjs` (`reconcileIssueBudget`, emit in `emitIssueBudgetReconciled`; name const `ISSUE_BUDGET_RECONCILED_EVENT`) | once per session close — the cross-check between what the session RECORDED as created (`record.issues_created.length`) and what the issue-budget ledger CHARGED. It exists because `readBudgetState` returns a ZEROED state for a MISSING counter file, so "the hook never ran for a single create" and "the session created nothing" are byte-identical in its return value; measured 2026-09-09 on a real session record with **26** recorded creations, **0** charged and no counter file under either accounting key. **Payload:** `verdict` (always — `match` \| `escaped` \| `no-ledger` \| `stale-record`; `no-ledger` is the absent-ledger case above, `stale-record` the inverse), `recorded`, `charged`, `exempt`, `overflow`, `escaped` (= `max(0, recorded − charged − exempt)`), and `ledgers` — one record per accounting key looked up (`key`: `semantic`\|`raw`, `path` — **repo-RELATIVE** `.orchestrator/runtime/issue-budget/<hash>.json`, never absolute: this payload also travels over the optional Clank webhook with no redaction, and an absolute ledger path names the operator's home directory and the private repo slug; the absolute form stays in the local WARN text only, `found`, `charged`, `exempt`); a fifth verdict `corrupt-ledger` marks a file that exists but has a non-integer `count`. **BOTH keys are read and SUMMED**, because the accounting key is semantic only when `current-session.json` verified the raw id (`resolveIssueBudgetSessionId`) — measured in one consumer repo: 25 of 36 counter files keyed semantic, 11 keyed raw, so reading one key reports a phantom escape for every session that used the other. `found` is measured with `existsSync` BEFORE the read and is the only thing that separates `no-ledger` from a real zero — read an absent `found` as "not measured", never as "no spend". The counter file is looked up under `resolveEventsLedgerRoot(repoRoot)` (#1729): from a linked worktree that is the main checkout, where the PreToolUse hook charged it; before #1729 every worktree session that filed an issue read a false `no-ledger` here. Plus `session_id`/`semantic_session_id` via `sessionAttribution`. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling through to `SO_PROJECT_DIR` (#941); `events.mjs` is imported lazily because the verdict path does not need it (`platform.mjs` is reached statically through `issue-budget.mjs` since #1729) |
215
216
  | `orchestrator.events.rotated` | `scripts/lib/events-rotation.mjs` (`maybeRotate()`, called once per SessionStart) | **The first line of every new active `events.jsonl`, written synchronously between the rename and any other append.** It is the ledger's record of its own break: before #1401 a rotation wrote nothing durable — only a `console.error` whose stderr the harness discards — so a rotation and a DELETED archive were byte-identical from outside, and a 53.896-line archive destroyed on 2026-09-19 left no trace anywhere. **Payload:** `archived_as` (absolute path of the archive — **PROVENANCE ONLY since #1411, not a pointer to resolve**: `readEventsWithRotations()` in `events.mjs` resolves the tombstone by BASENAME against the active file's own sibling `_archive/`, and deliberately ignores an absolute hit outside it, because that hit can only be a FOREIGN checkout — validating this ledger against another repo's archive was a silent false negative. Since #1423 EXISTENCE is not reading: the sibling counts only when it was an actual SOURCE of the read (a name matching `ARCHIVE_NAME_RE`); a sibling that EXISTS but was never read yields the gap kind `unindexed-archive` (with `path`), not silence. The reader's return contract is three-state: `complete` is `true` (measured whole), `false` (measured with a gap — kinds `missing-archive` \| `unindexed-archive` \| `ring-hole` \| `unreadable-source`) or `null` (not measured: no readable source at all); `notices` is a separate array (`unindexed-archive-file` for a hand-placed `_archive/` file with no tombstone) that never moves `complete`. The stored absolute path breaks the moment the checkout moves or a sibling git worktree reads it, which is routine here; archives are never renamed after creation, which is why the `.1`..`.N` ring was retired), `size_before` (bytes of the rotated file), `lines` (records + unreadable lines), `first_ts` / `last_ts` (earliest/latest parseable timestamp in the archive — `null`, never absent, when the archive carried none: "range unknown" must be distinguishable from "field not written by this version"), `malformed_lines` (lines that were not readable JSON objects; `0` is a measured zero). **Optional, additive:** `pruned` (array of archive paths deleted to honour `events-rotation.max-backups`) — present only when something was pruned, so the deletion is itself in the ledger. Emitted by a raw `appendFileSync`, not `emitEvent()`: it must be the first line, and the correlation envelope describes a session rather than a file operation. It still passes through `stampEventSchemaVersion()` + `validateEventRecord()` |
216
217
  | `discovery_validator_violation` | `hooks/post-subagent-discovery-validator.mjs` | SubagentStop, when `discovery-validator.enabled: true` (default `false`). One record per distinct normalized claim per session; log + warn only, the hook never blocks and always exits 0. Legacy bare name, like `stagnation_detected`. Field contract in § `discovery_validator_violation` below |
217
218
  | `orchestrator.issue_budget.refunded` | `hooks/post-bash-issue-budget-refund.mjs` (`emitRefundDecision`; name const `ISSUE_BUDGET_REFUNDED_EVENT`) | once per refund DECISION — one record per `PostToolUseFailure` delivery whose command contains at least one `gh`/`glab issue create` statement, emitted on the no-op branches too so a census over N sessions has a denominator and not only a numerator. Before #1353 a refund wrote a stderr line only, which under exit 0 reaches the debug log alone: the refund path was unfalsifiable in the sense of `.claude/rules/host-resources.md` HR-105. **Payload:** `reason` (always, CLOSED enum — `refunded` \| `not-charged` \| `chain-not-attributable` \| `counter-at-zero` \| `no-signal`), `unit` (`count` \| `exempt` \| `null` — which counter was given back, read off the honoured charge records; `exempt` only when EVERY refund landed on the exempt counter, `null` whenever nothing was refunded), `statement_count` (issue-create statements `findIssueCreateStatements` found), plus `session_id`/`semantic_session_id` via `sessionAttribution`. Branch mapping: `no-signal` = G2b (a failure event carrying no failure FIELD), `chain-not-attributable` = G3b (the create is not the whole command, so the exit code judges neither), `refunded` = G5 with at least one honoured charge record, `not-charged` = G5 with none (parked at the cap, a re-delivered failure, or an identity-less call `refundBooking` answers `no-session` for). **`counter-at-zero` has no branch in the hook today**: a matched record whose counter is already 0 is absorbed by `refundBooking`'s never-below-zero guard and returns `refunded` like any other match, so the case is not observable without a new field on the shared core's verdict (`scripts/lib/issue-budget.mjs`) — the enum value is reserved, not dead. `mode: off` emits NOTHING (G4 returns before it, and there was no charge to give back). **No command text, issue title or path is in the payload** — it travels verbatim over the optional Clank webhook with no redaction, same rule as `orchestrator.issue_budget.reconciled`. `events.mjs` is imported lazily, awaited AND caught: a throwing emit must never change the hook's exit code or output |
@@ -0,0 +1,63 @@
1
+ # Optional evidence candidates
2
+
3
+ The per-agent learning index can consume an explicitly supplied local candidate
4
+ artifact. This feature is **off by default**: no artifact discovery, provider
5
+ process, network request, model loading or additional source read occurs without
6
+ `--evidence-candidates`. The memory banner and the pure affinity function are
7
+ unchanged.
8
+
9
+ ```sh
10
+ node scripts/print-learnings-index.mjs \
11
+ --file-scope ./agent-scope.json \
12
+ --task-text 'Find the adapter boundary' \
13
+ --source-repo example-repo \
14
+ --learnings ./.orchestrator/metrics/learnings.jsonl \
15
+ --evidence-candidates ./candidates.json
16
+ ```
17
+
18
+ The artifact must conform to `urn:local-evidence:candidate-artifact:1`, whose
19
+ boundary is validated with Zod in
20
+ `scripts/lib/learnings/evidence-candidates.mjs`. Its query text must exactly
21
+ match `--task-text`, and `query.source_repos` must contain only `--source-repo`.
22
+ Version 1 supports **private-audience artifacts only**. Do not commit or publicly
23
+ export actual artifacts: even IDs and relative paths can disclose private data.
24
+ The adapter ships no provider configuration, repository mapping or corpus.
25
+
26
+ An artifact is a regular JSON file of at most 128 KiB with at most 40 candidates.
27
+ Its envelope contains `schema_version: 1`, `status`, `query`, `freshness`,
28
+ `generation`, `source_generations`, `retrieval` and `candidates`. A non-`ok`
29
+ status must have no candidates. `generation` may be null on those results.
30
+ `freshness.mode` is `live_sources`; `checked_at` is an ISO timestamp.
31
+ `retrieval.requested` and `effective` distinguish `bm25`, `eg2` and `rrf`;
32
+ an explicitly reported semantic-to-BM25 fallback is usable when status is `ok`.
33
+
34
+ Only `learning` hints whose `source_repo` matches the explicit current repo
35
+ are considered. The adapter resolves `source_record_id` against `id` in the
36
+ current canonical JSONL. One source read supplies both the baseline pool and
37
+ candidate mapping; the existing JSONL parser and surface policy are reused.
38
+ It checks the original JSONL bytes (`sha256:<full-file-digest>`, retaining BOM and
39
+ CRLF), and rejects invalid UTF-8 before adding candidates. This generation is checked against
40
+ `source_generations[repo]`, `source_generation` and `source_sha256`, and binds
41
+ each `index_generation` to the envelope generation. Missing sources, generation
42
+ drift, ambiguous IDs and unmapped hints cannot add records. Candidate paths and
43
+ line metadata never select files to read. `skill` and `adr` hits remain separate
44
+ on-demand evidence; they are not converted into learning records.
45
+
46
+ Resolved original records extend the existing candidate pool without duplicates.
47
+ The existing selector still owns scope/global budgets, character limits,
48
+ confidence, expiry, time decay, delivered-provenance filtering and sanitizing.
49
+ No similarity value replaces confidence or grants execution/installation rights.
50
+ The usual index wrapper and retrieval pointer still refer to the explicit local
51
+ JSONL. A malformed, unavailable or stale artifact preserves baseline stdout;
52
+ status and counts appear only on stderr as `[evidence-candidates] {…}`. `accepted`
53
+ counts resolved hints before selection, not injected entries. The selector's
54
+ optional `evidence` field exposes the same diagnostics to programmatic callers;
55
+ without opt-in its return shape is unchanged.
56
+
57
+ This is candidate plumbing, not a proven ranking improvement. If the existing
58
+ pool already contains every eligible record, adding the same IDs changes nothing.
59
+ The integration test demonstrates a valid original record outside the default
60
+ 200-record pool and checks rejection/fallback through the real CLI. Default or
61
+ permanent activation still requires a separate quality and workflow-benefit
62
+ evaluation. Source structure maps are complementary orientation aids; this
63
+ adapter neither implements nor replaces them.
@@ -133,16 +133,19 @@ snapshot bytes or input paths.
133
133
  | --- | --- |
134
134
  | 0 | Measured report, including reports with findings; help or version |
135
135
  | 1 | Invalid arguments, rejected paths, malformed JSON or UTF-8 |
136
- | 2 | Unavailable input, unsupported safe-open constants or system/output I/O |
136
+ | 2 | Unavailable input or system/output I/O |
137
137
  | 3 | Incomplete or otherwise unmeasurable snapshot evidence |
138
138
 
139
139
  ## File boundary and API
140
140
 
141
141
  The CLI accepts only regular files strictly inside `--input-root`. It checks both
142
142
  paths before reading either file's contents, rejects lexical escapes, symlinks
143
- in the root, parents or final component, and nonregular inputs. Opening requires
144
- native `O_NOFOLLOW` and `O_NONBLOCK`; absent constants fail closed. It verifies
145
- the opened file's device/inode against the inspected file. Concurrent parent
143
+ in the root, parents or final component, and nonregular inputs. Opening uses
144
+ native `O_NOFOLLOW` and `O_NONBLOCK` where the platform has them. Where it lacks
145
+ them (win32), the reader lstats the final path, opens it read-only and compares
146
+ the handle's device/inode (bigint) with the lstat. A swap, a symlink or a
147
+ nonregular file found at open time reports `changed-input-file` (exit 1). It
148
+ verifies the opened file's device/inode against the inspected file. Concurrent parent
146
149
  directory renames are not atomically secured by this portable path-based reader:
147
150
  use stable trusted snapshot copies. A Linux result is not a universal Windows or
148
151
  macOS portability proof.
@@ -25,8 +25,16 @@ github https://github.com/Kanevry/session-orchestrator.git # p
25
25
  the moment a `github` remote exists, unless the Session Config sets
26
26
  `mirror: none` (#1034: an absent key still pushes, so this repo — which sets
27
27
  no `mirror:` key — mirrors on every `/close`).
28
- `scripts/release.mjs` (`:1333-1338`) does the same for `main` + the tag on
29
- every `--publish`.
28
+ **Since #1631 this repo sets `mirror: curated`:** the block no longer pushes
29
+ `HEAD` but one verified snapshot commit (`git push github <sha>:refs/heads/main`)
30
+ built by `scripts/github-export.mjs` without the paths in
31
+ `.orchestrator/policy/public-export.json` and without the private history
32
+ (see § Curated public mirror below). It is still a direct push with no MR.
33
+ Publish only through `/close` (session-end § 4.4): pushing the export by hand
34
+ skips its source anchor, push-URL check and pre-push verification.
35
+ `scripts/release.mjs` (`tagAndPush`) pushes `main` + the tag on every
36
+ `--publish`; under `mirror: curated` it pushes the export snapshot and tags
37
+ that snapshot instead (see § Releases under `mirror: curated`).
30
38
  - **The push runs on the operator's machine, not in CI.** There is no GitLab CI
31
39
  mirror job and no CI variable holding a GitHub token (`glab api
32
40
  projects/:id/variables` → only `SCHEMA_DRIFT_TOKEN`, measured 2026-09-09).
@@ -122,7 +130,7 @@ sequence — (1) push path, (2) token, (3) `enforce_admins`:
122
130
 
123
131
  Change **both** direct writers so neither targets `main`: the
124
132
  `github-mirror-push` block in `skills/session-end/SKILL.md` (`:238`) and the
125
- remote loop in `scripts/release.mjs` (`:1333-1338`). Two options, either is
133
+ remote loop in `scripts/release.mjs` (`tagAndPush`). Two options, either is
126
134
  acceptable:
127
135
 
128
136
  - **Option A — `mirror/<session-id>` branch + PR with auto-merge.** Push to
@@ -237,3 +245,142 @@ Run it any time you change branch protection, rotate the mirror token, or
237
245
  touch the `github-mirror-push` block in `skills/session-end/SKILL.md` — it is
238
246
  the fastest way to confirm a change had the intended effect without hand
239
247
  re-deriving the `gh api` output.
248
+
249
+ ## Curated public mirror (`mirror: curated`, #1631, #1594)
250
+
251
+ GitLab stays the full private repository. The GitHub mirror receives a curated
252
+ state: `scripts/github-export.mjs` builds ONE squash commit per export with
253
+ plumbing on a temporary index (the real index, working tree and `HEAD` are
254
+ untouched):
255
+
256
+ 1. Graph integrity: every git call runs with `GIT_NO_REPLACE_OBJECTS=1`, and
257
+ the export refuses when `refs/replace/*` or `info/grafts` exist — either
258
+ would let the checks read a different object graph than `git push` sends.
259
+ 2. Remote anchor: the parent is ALWAYS the public GitHub main.
260
+ `refs/remotes/github/main` must resolve and must equal
261
+ `git ls-remote github refs/heads/main` (one read-only network call); a
262
+ mismatch or a failed call refuses ("fetch github first").
263
+ `refs/heads/github-export` is only the output slot — overwritten each run,
264
+ never read as a parent — so no older, forged or stale export commit can sit
265
+ between the public main and the new snapshot.
266
+ 3. `read-tree <source>^{tree}`, drop every path the policy excludes (exact
267
+ file, or directory prefix ending in `/` — `docs/audits/` never drops a
268
+ sibling file whose name merely starts with `docs/audits`), `write-tree`.
269
+ 4. `commit-tree <tree> -p <verified github main>` with the trailer
270
+ `Source-Commit: <full source sha>`.
271
+ 5. Strict leakage scan of the snapshot (`check-owner-leakage.mjs
272
+ --public-export` in a temporary detached worktree, removed afterwards),
273
+ then the citation guard on the same worktree: validate-plugin's path-check
274
+ and relative-link modules run on the snapshot, and any finding that points
275
+ into an excluded path refuses the export — the GitHub CI runs the same
276
+ checks, so a public doc citing a private path would otherwise turn it red.
277
+ 6. `update-ref refs/heads/github-export <new> <old-or-zero>` — compare-and-swap.
278
+
279
+ The anchor in step 2 is read through the FETCH URL, but `git push github` goes
280
+ to the PUSH URL (`remote.github.pushurl`, `url.*.pushInsteadOf`). When the two
281
+ differ, the verified main describes a different repository than the one that
282
+ receives the push, so the export refuses (a `--dry-run` only warns, because it
283
+ publishes nothing). The URLs are compared, never printed.
284
+
285
+ **This repo today:** `remote.github.pushurl` is deliberately set to a blocking
286
+ placeholder, so a real curated export refuses here until that block is lifted.
287
+ That is intended — mirror pushes stay blocked until #1645 — and lifting the
288
+ pushurl block (so the push URL equals the fetch URL again) is a required step
289
+ before the first curated push.
290
+
291
+ Exit codes: `0` created (prints the sha), `3` nothing to publish (the curated
292
+ tree equals github main's; the export ref is not touched), `1` refused with a
293
+ one-line reason (graph integrity, remote anchor, the published snapshot's
294
+ `Source-Commit` not an ancestor of the source, an excluded path surviving in
295
+ the written tree, the leakage scan, an excluded-path citation, or a lost
296
+ compare-and-swap). `--dry-run` prints the counts and scans without moving the
297
+ ref.
298
+
299
+ Session-end (`skills/session-end/SKILL.md` § 4.4) adds the source anchor and a
300
+ final check:
301
+
302
+ - It exports only when `HEAD` is on branch `main` AND equals
303
+ `git ls-remote origin refs/heads/main` (after the 4.3 push), and passes that
304
+ sha as `--source`. A feature branch, or a local main ahead of origin's main,
305
+ skips with exit 0 and pushes nothing — GitHub main deploys production, so
306
+ only the reviewed GitLab main may reach it. An unreadable origin is a WARN
307
+ with exit 1.
308
+ - The push is bound to the scanned commit: the block reads the export sha from
309
+ the CLI's stdout only (stderr goes straight to the operator), requires the
310
+ push URL to equal the fetch URL, and runs
311
+ `github-export.mjs --verify <sha> --source <origin main>` right before the
312
+ push — graph integrity, github main re-verified via `ls-remote`, exactly one
313
+ commit on it with `Source-Commit: <origin main>`, and no excluded path in
314
+ `git ls-tree -r <sha>`. It then pushes that sha, never the export ref name,
315
+ so moving the ref after the scan changes nothing.
316
+ - The push is a plain `git push github <sha>:refs/heads/main`.
317
+ No lease and no force flag: the new commit's parent IS the verified sha, and
318
+ a plain push is fast-forward-only, so GitHub itself rejects it when its main
319
+ moved to anything that is not an ancestor of that sha — the same anchor a
320
+ lease would enforce, and branch protection (`allow_force_pushes: false`)
321
+ stays untouched.
322
+
323
+ A blob that only exists under an excluded path is never reachable from GitHub:
324
+ the one new commit's tree omits it and its parent is already public. The
325
+ harness-audit check `github-mirror-sync` counts "ahead" from the
326
+ `Source-Commit` trailer of `github/main` in this mode, not from `github/main`
327
+ itself.
328
+
329
+ ### Releases under `mirror: curated` (#1645)
330
+
331
+ `scripts/release.mjs` reads `mirror` the same way session-end does
332
+ (`readMirrorSetting` in `scripts/lib/github-export.mjs`, i.e. `parse-config.mjs`
333
+ inside the repo):
334
+
335
+ - Preflight `head-pushed-github` compares github/main's `Source-Commit` trailer
336
+ with `HEAD`, not the shas (the public main is a snapshot and never equals
337
+ `HEAD`). The GitHub CI row asks about that snapshot sha, the commit GitHub
338
+ actually built. A release therefore needs `/close` to have published the
339
+ snapshot of the release commit first; otherwise the row is red.
340
+ - Preflight `github-push-url` (#1693) is red while `remote.github.pushurl` or
341
+ `pushInsteadOf` diverges from the fetch URL (the #1631 lock). Without it the
342
+ lock surfaced only in the publish tail, after the irreversible npm receipt.
343
+ Lift the lock before `--check`, and set it again only after `--publish` ends.
344
+ - `--publish` pushes `main` and the annotated tag to `origin` as before. To
345
+ `github` it never pushes `main`, `HEAD` or the release commit: it fetches
346
+ github main, runs the export with `--source <release commit>`, re-verifies a
347
+ newly built snapshot by sha and pushes that sha to `refs/heads/main` (normally
348
+ nothing is built: github main already is the snapshot of the release commit).
349
+ - Before `git tag`, the publish tail refuses (nothing tagged, nothing pushed)
350
+ unless `HEAD` is still the commit the preflight checked, on branch `main`,
351
+ and equal to `git ls-remote origin refs/heads/main` — the npm publish sits
352
+ between preflight and tag, and a parallel session switching branch in that
353
+ window would otherwise get an unreviewed commit snapshotted onto GitHub main.
354
+ - `mirror: none` skips GitHub entirely: no push, no tag, no GitHub release; the
355
+ github preflight rows report `skipped`.
356
+ - **Tag decision: same tag name, different target sha.** On GitLab `v<X.Y.Z>`
357
+ is the annotated tag on the release commit; on GitHub `v<X.Y.Z>` is a
358
+ lightweight tag on the public snapshot whose `Source-Commit` is that release
359
+ commit. The GitHub release (`gh release create --verify-tag`) attaches to the
360
+ GitHub tag. A snapshot whose tree matches but whose trailer names another
361
+ commit is refused rather than tagged.
362
+
363
+ ### Keyless checkouts are fail-closed (#1645)
364
+
365
+ The `mirror` key lives in the working tree's CLAUDE.md, so a branch or checkout
366
+ cut before the repo adopted `mirror: curated` has no key and used to fall back
367
+ to `git push github HEAD` — the full private history. Every plain push (key
368
+ absent or `mirror: github`) now first runs `github-export.mjs --plain-guard`
369
+ (session-end § 4.4) or `plainPushGuard` (release preflight row
370
+ `github-mirror-mode` and the publish tail): when `HEAD` or origin's main
371
+ (`git ls-remote`, fetched when the object is not local) carries
372
+ `.orchestrator/policy/public-export.json`, the plain push is refused with a
373
+ WARN and exit 1, and nothing is pushed. An unreadable origin refuses too, and
374
+ so does a repo with a `github` but no `origin` remote unless `mirror: github`
375
+ is set explicitly — there is no upstream policy to check against.
376
+
377
+ Why the policy file and not `origin/main:CLAUDE.md`: the policy file is the one
378
+ artefact that exists exactly when the repo publishes curated, its presence is a
379
+ single `git cat-file -e`, and it needs none of parse-config's file resolution
380
+ (CLAUDE.md vs AGENTS.md, `SO_CONFIG_FILE`). Reading the key from origin would
381
+ also override an intended branch-local config, while the guard only ever
382
+ blocks.
383
+
384
+ **Still not covered:** a hand-typed `git push github …` bypasses all of this;
385
+ only the pushurl block stops it.
386
+