session-orchestrator 5.0.0 → 5.2.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 (298) hide show
  1. package/.agents/skills/autopilot/SKILL.md +1 -0
  2. package/.agents/skills/bootstrap/SKILL.md +2 -0
  3. package/.agents/skills/brainstorm/SKILL.md +3 -0
  4. package/.agents/skills/close/SKILL.md +17 -0
  5. package/.agents/skills/debug/SKILL.md +2 -0
  6. package/.agents/skills/discovery/SKILL.md +2 -1
  7. package/.agents/skills/dispatcher/SKILL.md +2 -0
  8. package/.agents/skills/eli5/SKILL.md +2 -0
  9. package/.agents/skills/eval/SKILL.md +1 -0
  10. package/.agents/skills/evolve/SKILL.md +2 -1
  11. package/.agents/skills/go/SKILL.md +18 -0
  12. package/.agents/skills/grill/SKILL.md +2 -0
  13. package/.agents/skills/harness-audit/SKILL.md +16 -0
  14. package/.agents/skills/memory-cleanup/SKILL.md +1 -0
  15. package/.agents/skills/persona-panel/SKILL.md +1 -0
  16. package/.agents/skills/plan/SKILL.md +3 -1
  17. package/.agents/skills/portfolio/SKILL.md +17 -0
  18. package/.agents/skills/reconcile/SKILL.md +1 -0
  19. package/.agents/skills/release/SKILL.md +18 -0
  20. package/.agents/skills/repo-audit/SKILL.md +1 -0
  21. package/.agents/skills/spinout/SKILL.md +1 -0
  22. package/.agents/skills/sunset-review/SKILL.md +2 -0
  23. package/.agents/skills/test/SKILL.md +17 -0
  24. package/.agents/skills/ux-grill/SKILL.md +2 -0
  25. package/.claude-plugin/marketplace.json +3 -3
  26. package/.claude-plugin/plugin.json +2 -2
  27. package/.codex-plugin/plugin.json +2 -2
  28. package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
  29. package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
  30. package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
  31. package/.codex-plugin/skills/close/SKILL.md +3 -3
  32. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +2 -0
  33. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +5 -0
  34. package/.codex-plugin/skills/debug/SKILL.md +11 -4
  35. package/.codex-plugin/skills/discovery/SKILL.md +8 -4
  36. package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
  37. package/.codex-plugin/skills/eli5/SKILL.md +9 -4
  38. package/.codex-plugin/skills/eval/SKILL.md +9 -4
  39. package/.codex-plugin/skills/evolve/SKILL.md +9 -4
  40. package/.codex-plugin/skills/go/SKILL.md +3 -3
  41. package/.codex-plugin/skills/grill/SKILL.md +11 -4
  42. package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
  43. package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
  44. package/.codex-plugin/skills/npm-publish/SKILL.md +2 -0
  45. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +5 -0
  46. package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
  47. package/.codex-plugin/skills/plan/SKILL.md +8 -4
  48. package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
  49. package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
  50. package/.codex-plugin/skills/release/SKILL.md +3 -3
  51. package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
  52. package/.codex-plugin/skills/spinout/SKILL.md +4 -4
  53. package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
  54. package/.codex-plugin/skills/test/SKILL.md +3 -3
  55. package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
  56. package/.cursor/commands/autopilot.md +4 -4
  57. package/.cursor/commands/bootstrap.md +5 -4
  58. package/.cursor/commands/brainstorm.md +5 -4
  59. package/.cursor/commands/close.md +4 -3
  60. package/.cursor/commands/convergence-monitoring.md +13 -0
  61. package/.cursor/commands/debug.md +4 -4
  62. package/.cursor/commands/discovery.md +4 -4
  63. package/.cursor/commands/dispatcher.md +4 -4
  64. package/.cursor/commands/eli5.md +4 -4
  65. package/.cursor/commands/eval.md +4 -4
  66. package/.cursor/commands/evolve.md +4 -4
  67. package/.cursor/commands/go.md +4 -3
  68. package/.cursor/commands/grill.md +4 -4
  69. package/.cursor/commands/harness-audit.md +3 -3
  70. package/.cursor/commands/memory-cleanup.md +4 -4
  71. package/.cursor/commands/npm-publish.md +13 -0
  72. package/.cursor/commands/persona-panel.md +4 -4
  73. package/.cursor/commands/plan.md +5 -4
  74. package/.cursor/commands/portfolio.md +3 -3
  75. package/.cursor/commands/reconcile.md +4 -4
  76. package/.cursor/commands/release.md +4 -3
  77. package/.cursor/commands/repo-audit.md +4 -4
  78. package/.cursor/commands/spinout.md +4 -4
  79. package/.cursor/commands/sunset-review.md +4 -4
  80. package/.cursor/commands/test.md +3 -3
  81. package/.cursor/commands/ux-grill.md +4 -4
  82. package/.cursor/rules/010-session-workflow.mdc +2 -2
  83. package/.cursor/skills/bootstrap/SKILL.md +1 -0
  84. package/.cursor/skills/close/SKILL.md +13 -0
  85. package/.cursor/skills/debug/SKILL.md +0 -1
  86. package/.cursor/skills/discovery/SKILL.md +0 -1
  87. package/.cursor/skills/dispatcher/SKILL.md +0 -1
  88. package/.cursor/skills/eli5/SKILL.md +0 -1
  89. package/.cursor/skills/evolve/SKILL.md +0 -1
  90. package/.cursor/skills/go/SKILL.md +13 -0
  91. package/.cursor/skills/grill/SKILL.md +0 -1
  92. package/.cursor/skills/harness-audit/SKILL.md +12 -0
  93. package/.cursor/skills/portfolio/SKILL.md +12 -0
  94. package/.cursor/skills/release/SKILL.md +13 -0
  95. package/.cursor/skills/repo-audit/SKILL.md +0 -1
  96. package/.cursor/skills/sunset-review/SKILL.md +0 -1
  97. package/.cursor/skills/test/SKILL.md +12 -0
  98. package/.cursor/skills/ux-grill/SKILL.md +0 -1
  99. package/.cursor-plugin/plugin.json +2 -2
  100. package/.orchestrator/policy/blocked-commands.json +10 -0
  101. package/AGENTS.md +1 -1
  102. package/CHANGELOG.md +80 -0
  103. package/README.md +74 -235
  104. package/commands/session.md +10 -0
  105. package/docs/USER-GUIDE.md +24 -0
  106. package/docs/ci-setup.md +53 -0
  107. package/docs/codex-setup.md +1 -1
  108. package/docs/components.md +12 -5
  109. package/docs/events-schema.md +5 -1
  110. package/docs/install.md +128 -0
  111. package/docs/persona-panel.md +1 -1
  112. package/docs/pi-setup.md +1 -1
  113. package/docs/rule-authoring.md +83 -14
  114. package/docs/scope-collision-guard.md +2 -0
  115. package/docs/session-config-reference.md +6 -4
  116. package/docs/session-config-template.md +38 -0
  117. package/docs/telemetry.md +15 -0
  118. package/hooks/_lib/hook-import-set.json +46 -6
  119. package/hooks/_lib/subagent-paths.mjs +15 -0
  120. package/hooks/_lib/vcs-create-matcher.mjs +217 -62
  121. package/hooks/enforce-scope.mjs +42 -1
  122. package/hooks/hooks-codex.json +1 -1
  123. package/hooks/hooks.json +1 -1
  124. package/hooks/on-session-end.mjs +14 -2
  125. package/hooks/on-stop.mjs +43 -1
  126. package/hooks/post-bash-write-verify.mjs +3 -0
  127. package/hooks/pre-auq-clarity.mjs +3 -0
  128. package/hooks/pre-bash-issue-budget.mjs +103 -17
  129. package/hooks/pre-task-scope-disjoint.mjs +152 -3
  130. package/hooks/skill-invocation-telemetry.mjs +2 -1
  131. package/package.json +3 -2
  132. package/pi/prompts/autopilot.md +3 -3
  133. package/pi/prompts/bootstrap.md +3 -3
  134. package/pi/prompts/brainstorm.md +3 -3
  135. package/pi/prompts/close.md +2 -2
  136. package/pi/prompts/convergence-monitoring.md +11 -0
  137. package/pi/prompts/debug.md +3 -3
  138. package/pi/prompts/discovery.md +3 -3
  139. package/pi/prompts/dispatcher.md +3 -3
  140. package/pi/prompts/eli5.md +3 -3
  141. package/pi/prompts/eval.md +3 -3
  142. package/pi/prompts/evolve.md +3 -3
  143. package/pi/prompts/go.md +2 -2
  144. package/pi/prompts/grill.md +3 -3
  145. package/pi/prompts/harness-audit.md +2 -3
  146. package/pi/prompts/memory-cleanup.md +3 -3
  147. package/pi/prompts/npm-publish.md +11 -0
  148. package/pi/prompts/persona-panel.md +3 -3
  149. package/pi/prompts/plan.md +3 -3
  150. package/pi/prompts/portfolio.md +2 -2
  151. package/pi/prompts/reconcile.md +3 -3
  152. package/pi/prompts/release.md +3 -3
  153. package/pi/prompts/repo-audit.md +3 -4
  154. package/pi/prompts/session.md +1 -1
  155. package/pi/prompts/spinout.md +3 -3
  156. package/pi/prompts/sunset-review.md +3 -3
  157. package/pi/prompts/templates-ack.md +1 -1
  158. package/pi/prompts/test.md +3 -3
  159. package/pi/prompts/ux-grill.md +3 -3
  160. package/scripts/archive-closed-prds.mjs +2 -2
  161. package/scripts/auq-audit.mjs +2 -3
  162. package/scripts/backfill-abandoned-sessions.mjs +57 -3
  163. package/scripts/backfill-evidence-digest.mjs +2 -1
  164. package/scripts/backfill-learnings-from-vault.mjs +2 -2
  165. package/scripts/check-package-manager.mjs +2 -2
  166. package/scripts/ci/assert-vitest-green.mjs +2 -1
  167. package/scripts/emit-session.mjs +2 -3
  168. package/scripts/export-hw-learnings.mjs +2 -1
  169. package/scripts/express-path.mjs +1 -1
  170. package/scripts/gc-stale-worktrees.mjs +2 -1
  171. package/scripts/generate-codex-skills.mjs +48 -4
  172. package/scripts/generate-cursor-adapter.mjs +173 -9
  173. package/scripts/generate-hook-import-set.mjs +12 -27
  174. package/scripts/generate-pi-prompts.mjs +183 -13
  175. package/scripts/github-protection-audit.mjs +2 -3
  176. package/scripts/lib/agent-frontmatter.mjs +23 -1
  177. package/scripts/lib/claude-md-budget-lint.mjs +2 -5
  178. package/scripts/lib/command-blocker.mjs +209 -9
  179. package/scripts/lib/config/drift-check.mjs +19 -0
  180. package/scripts/lib/convergence-monitor.mjs +2 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +2 -2
  182. package/scripts/lib/description-surface.mjs +2 -5
  183. package/scripts/lib/dispatcher/cli.mjs +2 -1
  184. package/scripts/lib/ecosystem-wizard.mjs +2 -1
  185. package/scripts/lib/fetch-baseline.mjs +3 -8
  186. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
  187. package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
  188. package/scripts/lib/instruction-budget-guard.mjs +186 -46
  189. package/scripts/lib/is-main-module.mjs +82 -0
  190. package/scripts/lib/locks/index.mjs +32 -25
  191. package/scripts/lib/maintenance-due-banner.mjs +69 -3
  192. package/scripts/lib/peer-discovery.mjs +2 -5
  193. package/scripts/lib/playwright-driver/runner.mjs +63 -2
  194. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +642 -0
  195. package/scripts/lib/rules-sync.mjs +2 -5
  196. package/scripts/lib/scope-echo.mjs +392 -7
  197. package/scripts/lib/session-close-backfill.mjs +58 -6
  198. package/scripts/lib/state-md.mjs +84 -3
  199. package/scripts/lib/sunset/walker.mjs +31 -4
  200. package/scripts/lib/tests-src-ratio.mjs +2 -6
  201. package/scripts/lib/tmux-layout/telemetry-stats.mjs +2 -1
  202. package/scripts/lib/user-invocable-skills.mjs +185 -0
  203. package/scripts/lib/validate/check-banner-parity.mjs +2 -2
  204. package/scripts/lib/validate/check-cursor-adapter.mjs +2 -2
  205. package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
  206. package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
  207. package/scripts/lib/validate/check-entry-guard.mjs +366 -0
  208. package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
  209. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
  210. package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
  211. package/scripts/lib/validate/check-skill-links.mjs +27 -6
  212. package/scripts/lib/validate/check-skill-script-paths.mjs +2 -2
  213. package/scripts/lib/validate/check-test-git-config-target.mjs +2 -2
  214. package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
  215. package/scripts/lib/validate/check-untracked-test-deps.mjs +2 -2
  216. package/scripts/lib/validate/check-unwired-features.mjs +266 -11
  217. package/scripts/lib/validate/check-validator-registration.mjs +2 -2
  218. package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
  219. package/scripts/lib/validate-vendored-rules.mjs +35 -9
  220. package/scripts/lib/wave-transcript-tail.mjs +2 -2
  221. package/scripts/lock-reaper.mjs +2 -1
  222. package/scripts/materialize-wave-scope.mjs +87 -4
  223. package/scripts/migrate-sessions-jsonl.mjs +2 -1
  224. package/scripts/migrate-vault-paths.mjs +2 -3
  225. package/scripts/release.mjs +124 -35
  226. package/scripts/relocate-vault-corpus.mjs +2 -3
  227. package/scripts/repair-invalid-sessions.mjs +2 -2
  228. package/scripts/session-shape.mjs +2 -2
  229. package/scripts/site-numbers.mjs +35 -11
  230. package/scripts/sweep-expired-rules.mjs +216 -0
  231. package/scripts/validate-plugin.mjs +9 -0
  232. package/scripts/vault-consolidate.mjs +2 -2
  233. package/scripts/vault-mirror.mjs +2 -3
  234. package/scripts/wave-scope-binding.mjs +2 -3
  235. package/skills/_shared/bootstrap-gate.md +1 -1
  236. package/skills/_shared/monitor-patterns.md +1 -1
  237. package/skills/_shared/research-evidence.md +53 -0
  238. package/skills/_shared/state-ownership.md +3 -0
  239. package/skills/autopilot/SKILL.md +58 -4
  240. package/skills/bootstrap/SKILL.md +51 -1
  241. package/skills/brainstorm/SKILL.md +16 -0
  242. package/skills/claude-md-drift-check/checker.mjs +49 -11
  243. package/{commands/close.md → skills/close/SKILL.md} +9 -3
  244. package/skills/debug/SKILL.md +10 -0
  245. package/skills/discovery/SKILL.md +24 -1
  246. package/skills/discovery/probes-session.md +2 -2
  247. package/skills/dispatcher/SKILL.md +38 -7
  248. package/skills/eli5/SKILL.md +11 -0
  249. package/skills/eval/SKILL.md +14 -0
  250. package/skills/evolve/SKILL.md +8 -1
  251. package/skills/evolve/references/evolve-dialectic-mode.md +6 -2
  252. package/{commands/go.md → skills/go/SKILL.md} +9 -1
  253. package/skills/grill/SKILL.md +19 -0
  254. package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
  255. package/skills/hook-development/SKILL.md +46 -41
  256. package/skills/memory-cleanup/SKILL.md +7 -0
  257. package/skills/npm-publish/SKILL.md +1 -1
  258. package/skills/persona-panel/SKILL.md +56 -1
  259. package/skills/persona-panel/persona-format.md +1 -1
  260. package/skills/plan/SKILL.md +28 -1
  261. package/skills/playwright-driver/SKILL.md +7 -10
  262. package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
  263. package/skills/reconcile/SKILL.md +10 -0
  264. package/{commands/release.md → skills/release/SKILL.md} +16 -2
  265. package/skills/repo-audit/SKILL.md +7 -0
  266. package/skills/session-end/plan-verification.md +2 -2
  267. package/skills/session-plan/SKILL.md +1 -1
  268. package/skills/session-start/SKILL.md +5 -4
  269. package/skills/session-start/phase-8-5-express-path.md +6 -6
  270. package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
  271. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
  272. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  273. package/skills/spinout/SKILL.md +12 -1
  274. package/skills/sunset-review/SKILL.md +13 -0
  275. package/{commands/test.md → skills/test/SKILL.md} +10 -4
  276. package/skills/ux-grill/SKILL.md +19 -1
  277. package/skills/wave-executor/SKILL.md +7 -4
  278. package/skills/wave-executor/references/wave-executor-state-init.md +13 -1
  279. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  280. package/skills/wave-executor/references/wave-loop-review.md +17 -1
  281. package/commands/autopilot.md +0 -80
  282. package/commands/bootstrap.md +0 -56
  283. package/commands/brainstorm.md +0 -48
  284. package/commands/debug.md +0 -36
  285. package/commands/discovery.md +0 -32
  286. package/commands/dispatcher.md +0 -59
  287. package/commands/eli5.md +0 -33
  288. package/commands/eval.md +0 -28
  289. package/commands/evolve.md +0 -10
  290. package/commands/grill.md +0 -45
  291. package/commands/memory-cleanup.md +0 -26
  292. package/commands/persona-panel.md +0 -121
  293. package/commands/plan.md +0 -15
  294. package/commands/reconcile.md +0 -23
  295. package/commands/repo-audit.md +0 -24
  296. package/commands/spinout.md +0 -15
  297. package/commands/sunset-review.md +0 -27
  298. package/commands/ux-grill.md +0 -51
@@ -37,11 +37,11 @@
37
37
  * node scripts/lib/scope-echo.mjs --help
38
38
  */
39
39
 
40
- import { readFileSync } from 'node:fs';
41
- import { resolve } from 'node:path';
42
- import { fileURLToPath } from 'node:url';
40
+ import { readFileSync, readdirSync } from 'node:fs';
41
+ import { join, resolve } from 'node:path';
43
42
 
44
43
  import { digestSha256Short } from './crypto-digest-utils.mjs';
44
+ import { isMainModule } from './is-main-module.mjs';
45
45
 
46
46
  /** Marker the agent must emit. Case-sensitive by design — a lowercase lookalike is not an echo. */
47
47
  export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
@@ -49,6 +49,15 @@ export const SCOPE_ECHO_MARKER = 'SCOPE-DIGEST:';
49
49
  /** Event name for the post-wave verdict. */
50
50
  export const SCOPE_ECHO_EVENT = 'orchestrator.wave_dispatch.scope_echo_checked';
51
51
 
52
+ /** The SEND-side record `hooks/pre-task-scope-disjoint.mjs` writes per dispatch. */
53
+ export const SCOPE_CHECKED_EVENT = 'orchestrator.wave_dispatch.scope_checked';
54
+
55
+ /** The DEGRADATION record `scripts/materialize-wave-scope.mjs` writes per wave. */
56
+ export const SCOPE_MATERIALIZED_EVENT = 'orchestrator.wave_dispatch.scope_materialized';
57
+
58
+ /** The per-WAVE join verdict `--verify` emits. */
59
+ export const SCOPE_VERIFIED_EVENT = 'orchestrator.wave_dispatch.scope_verified';
60
+
52
61
  /** Max characters retained for `agent_id` in the payload (same clamp as the send-side hook). */
53
62
  const AGENT_ID_MAX = 120;
54
63
 
@@ -212,6 +221,308 @@ export function scopeEchoPayload(verdict, meta = {}) {
212
221
  };
213
222
  }
214
223
 
224
+ // ---------------------------------------------------------------------------
225
+ // --verify — join the two halves of one wave ON THE DIGEST (#1092)
226
+ // ---------------------------------------------------------------------------
227
+
228
+ /**
229
+ * Closed verdict enum. Order is PRECEDENCE, not preference — the first matching
230
+ * row wins, and every (file, dispatch, echo) triple lands on exactly one row:
231
+ *
232
+ * duplicate-claim ≥2 DISTINCT agent ids claimed one digest at dispatch.
233
+ * That is agent A's scope reported for agent B, and it is
234
+ * a defect whatever the other two bits say — so it is
235
+ * checked first.
236
+ * echoed-not-injected an echo names a digest NO dispatch claimed.
237
+ * digest-unknown no scope FILE on disk carries this digest: the record
238
+ * exists but the artefact that would ground it does not
239
+ * (a scope file rewritten or reconciled away after the
240
+ * dispatch).
241
+ * matched file ∧ dispatch ∧ echo.
242
+ * injected-not-echoed file ∧ dispatch, no echo — the normal state DURING a
243
+ * wave, before the reports land.
244
+ * injection-missing file ∧ neither — a scope file no dispatch claimed and
245
+ * no agent echoed. This is AC-2: the omitted injection.
246
+ *
247
+ * Every member is kebab-case. `echo-only` is NOT one of the six: it is the
248
+ * DEGRADED value every verdict collapses to when the wave's transport is
249
+ * unobservable (see {@link verifyWaveScope}), so it is never produced by the
250
+ * precedence chain above and never belongs in this array.
251
+ *
252
+ * `injection-missing` was spelled `injection_missing` until 2026-09-16 — the
253
+ * one snake_case member of an otherwise kebab-case enum. Ledger records written
254
+ * before that date may still carry the old spelling in `by_verdict`; there is no
255
+ * dual-emit, so a consumer reading historical rows must accept both keys.
256
+ */
257
+ export const SCOPE_VERDICTS = Object.freeze([
258
+ 'duplicate-claim',
259
+ 'echoed-not-injected',
260
+ 'digest-unknown',
261
+ 'matched',
262
+ 'injected-not-echoed',
263
+ 'injection-missing',
264
+ ]);
265
+
266
+ /** A well-formed digest. Anything else is ignored rather than joined on. */
267
+ const DIGEST_RE = /^[0-9a-f]{8}$/;
268
+
269
+ /**
270
+ * Parse a JSONL ledger leniently: a malformed line is skipped, never thrown on
271
+ * — and COUNTED, which is the load-bearing half.
272
+ *
273
+ * A crashed writer leaves a truncated final line (`{"event":"…","wave":4` with
274
+ * no closing brace) — a measured shape in this repo's own ledger. Skipping it
275
+ * silently truncates the join, and a truncated join reports
276
+ * `dispatches N / injected N / echoed N` with every digest `matched`: a CLEAN
277
+ * wave, from the instrument built to detect silent failure. The count is what
278
+ * separates "nothing was wrong" from "I could not read part of the evidence".
279
+ *
280
+ * A malformed line is any NON-BLANK line that does not yield a plain object:
281
+ * one that does not start with `{`, one `JSON.parse` rejects, and one that
282
+ * parses to an array or a scalar. Blank lines are not malformed — a trailing
283
+ * newline is the normal end of a JSONL file.
284
+ *
285
+ * @param {string} raw
286
+ * @returns {{records: Array<Record<string, unknown>>, malformed: number}}
287
+ */
288
+ function parseJsonl(raw) {
289
+ /** @type {Array<Record<string, unknown>>} */
290
+ const records = [];
291
+ let malformed = 0;
292
+ if (typeof raw !== 'string') return { records, malformed };
293
+ for (const line of raw.split('\n')) {
294
+ const trimmed = line.trim();
295
+ if (trimmed.length === 0) continue;
296
+ if (trimmed[0] !== '{') {
297
+ malformed += 1;
298
+ continue;
299
+ }
300
+ try {
301
+ const rec = JSON.parse(trimmed);
302
+ if (rec !== null && typeof rec === 'object' && !Array.isArray(rec)) records.push(rec);
303
+ else malformed += 1;
304
+ } catch {
305
+ malformed += 1; // a half-written line is not a verdict — but it is not nothing either
306
+ }
307
+ }
308
+ return { records, malformed };
309
+ }
310
+
311
+ /**
312
+ * Digest every per-agent scope file of one wave — shape (a) of the two scope
313
+ * shapes (CLAUDE.md / AGENTS.md § allowedPaths).
314
+ *
315
+ * @param {string} stateDir
316
+ * @param {number} wave
317
+ * @param {{readDir?: typeof readdirSync, readFile?: typeof readFileSync}} [io]
318
+ * @returns {Map<string, string[]>} digest → agent ids (file basenames)
319
+ */
320
+ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFile = readFileSync } = {}) {
321
+ const out = new Map();
322
+ const dir = resolve(String(stateDir), 'filescopes', `wave-${wave}`);
323
+ let names;
324
+ try {
325
+ names = readDir(dir);
326
+ } catch {
327
+ return out; // no directory yet is not a failure — it is "nothing materialized"
328
+ }
329
+ for (const name of names) {
330
+ const file = typeof name === 'string' ? name : name?.name;
331
+ if (typeof file !== 'string' || !file.endsWith('.json')) continue;
332
+ let parsed;
333
+ try {
334
+ parsed = JSON.parse(readFile(join(dir, file), 'utf8'));
335
+ } catch { continue; }
336
+ if (!Array.isArray(parsed) || normalizeScopePaths(parsed).length === 0) continue;
337
+ const digest = scopeDigest(parsed);
338
+ const ids = out.get(digest) ?? [];
339
+ ids.push(file.slice(0, -'.json'.length));
340
+ out.set(digest, ids);
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /**
346
+ * Verify ONE wave's FILE-SCOPE injection end to end.
347
+ *
348
+ * ## Why the join key is the DIGEST and never `agent_id`
349
+ *
350
+ * Measured 2026-09-16 over this host's `.orchestrator/metrics/events.jsonl`
351
+ * corpus: 609 `scope_checked` records against 51 `scope_echo_checked`, and the
352
+ * two agent-id sets OVERLAP IN ZERO ELEMENTS — the send side records the
353
+ * dispatch tool's `description` plus `subagent_type`
354
+ * (`"i-3 #1353 refund event (session-orchestrator:code-implementer)"`), the
355
+ * receive side the coordinator's short handle (`"i-3"`). One session showed 23
356
+ * echoes against 18 injections and the halves could not be joined at all. The
357
+ * digest is the one value both halves derive from the SAME artefact, so it joins
358
+ * them by construction.
359
+ *
360
+ * ## Observability, not enforcement
361
+ *
362
+ * Every verdict exits 0. This tool reports; the wave-executor turns
363
+ * `injection-missing` / `duplicate-claim` into a STATE.md deviation
364
+ * (`wave-loop-review.md` step 3d-bis), never into a block.
365
+ *
366
+ * ## A partial read is never a clean wave
367
+ *
368
+ * `malformed_lines` counts the ledger lines that could not be parsed (a crashed
369
+ * writer's truncated append is the measured shape). It is ALWAYS present,
370
+ * including as `0`: the ledger read either happened or the file was absent, and
371
+ * both are measurements. A non-zero count means the join ran on INCOMPLETE
372
+ * evidence — the counts and verdicts below are a floor, not a census — and the
373
+ * human table says so beside them.
374
+ *
375
+ * ## Transport degradation
376
+ *
377
+ * On a platform with no `PreToolUse` `Agent` matcher (Codex, Cursor, Pi) the
378
+ * send-side record cannot exist at all, so a missing dispatch is NOT evidence of
379
+ * a missing injection. When the wave's `scope_materialized` record says
380
+ * `transport_observable: false`, `transport` reads `unobservable` and every
381
+ * verdict degrades to `echo-only` — the receive half is all the platform can
382
+ * carry, and reporting `injection-missing` there would be an accusation derived
383
+ * from an instrument that is not installed.
384
+ *
385
+ * @param {object} params
386
+ * @param {number} params.wave
387
+ * @param {string} params.stateDir
388
+ * @param {string} [params.session] semantic (or raw) session id to filter on
389
+ * @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl`
390
+ * @param {typeof readFileSync} [params.readFile]
391
+ * @param {typeof readdirSync} [params.readDir]
392
+ * @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
393
+ * injected: number, echoed: number, malformed_lines: number,
394
+ * by_verdict: Record<string, number>,
395
+ * agents: Array<{agent_id: string, verdict: string, digest: string}>}}
396
+ */
397
+ export function verifyWaveScope({
398
+ wave,
399
+ stateDir,
400
+ session,
401
+ eventsPath,
402
+ readFile = readFileSync,
403
+ readDir = readdirSync,
404
+ }) {
405
+ const ledgerPath = eventsPath || join('.orchestrator', 'metrics', 'events.jsonl');
406
+ let records = [];
407
+ let malformedLines = 0;
408
+ try {
409
+ ({ records, malformed: malformedLines } = parseJsonl(readFile(ledgerPath, 'utf8')));
410
+ } catch { /* no ledger yet — every count is legitimately 0 */ }
411
+
412
+ const mine = (rec) => {
413
+ if (rec.wave !== wave) return false;
414
+ if (!session) return true;
415
+ return rec.semantic_session_id === session || rec.session_id === session;
416
+ };
417
+
418
+ /** @type {Map<string, Set<string>>} digest → distinct dispatching agent ids */
419
+ const dispatchIds = new Map();
420
+ /** @type {Map<string, string>} digest → first echoing agent id */
421
+ const echoIds = new Map();
422
+ let dispatches = 0;
423
+ let injected = 0;
424
+ let echoed = 0;
425
+ // A wave may be materialized more than once (#1103); the LAST record wins,
426
+ // because that is the state the dispatches below actually ran against.
427
+ let transportObservable = null;
428
+
429
+ for (const rec of records) {
430
+ if (rec.event === SCOPE_MATERIALIZED_EVENT && mine(rec)) {
431
+ if (typeof rec.transport_observable === 'boolean') transportObservable = rec.transport_observable;
432
+ continue;
433
+ }
434
+ if (rec.event === SCOPE_CHECKED_EVENT && mine(rec)) {
435
+ dispatches += 1;
436
+ if (rec.injected === true) injected += 1;
437
+ const digest = rec.scope_digest;
438
+ if (typeof digest === 'string' && DIGEST_RE.test(digest)) {
439
+ const ids = dispatchIds.get(digest) ?? new Set();
440
+ ids.add(typeof rec.agent_id === 'string' ? rec.agent_id : 'unnamed-agent');
441
+ dispatchIds.set(digest, ids);
442
+ }
443
+ continue;
444
+ }
445
+ if (rec.event === SCOPE_ECHO_EVENT && mine(rec)) {
446
+ if (rec.echoed === true) echoed += 1;
447
+ // The agent's OWN claim first (`actual_digest`); the coordinator-side
448
+ // expectation only as a fallback, so a mismatched echo still joins to the
449
+ // scope file it was checked against rather than vanishing from the report.
450
+ const digest = [rec.actual_digest, rec.expected_digest]
451
+ .find((d) => typeof d === 'string' && DIGEST_RE.test(d));
452
+ if (digest !== undefined && !echoIds.has(digest)) {
453
+ echoIds.set(digest, typeof rec.agent_id === 'string' ? rec.agent_id : 'unnamed-agent');
454
+ }
455
+ }
456
+ }
457
+
458
+ const fileIds = digestScopeFiles(stateDir, wave, { readDir, readFile });
459
+ const digests = [...new Set([...fileIds.keys(), ...dispatchIds.keys(), ...echoIds.keys()])].sort();
460
+
461
+ const unobservable = transportObservable === false;
462
+ /** @type {Record<string, number>} */
463
+ const byVerdict = {};
464
+ const agents = digests.map((digest) => {
465
+ const claimants = dispatchIds.get(digest);
466
+ const hasDispatch = claimants !== undefined && claimants.size > 0;
467
+ const hasEcho = echoIds.has(digest);
468
+ const hasFile = fileIds.has(digest);
469
+
470
+ let verdict;
471
+ if (hasDispatch && claimants.size > 1) verdict = 'duplicate-claim';
472
+ else if (hasEcho && !hasDispatch) verdict = 'echoed-not-injected';
473
+ else if (!hasFile) verdict = 'digest-unknown';
474
+ else if (hasDispatch && hasEcho) verdict = 'matched';
475
+ else if (hasDispatch) verdict = 'injected-not-echoed';
476
+ else verdict = 'injection-missing';
477
+ if (unobservable) verdict = 'echo-only';
478
+
479
+ byVerdict[verdict] = (byVerdict[verdict] ?? 0) + 1;
480
+ const agentId = (hasDispatch ? [...claimants].sort().join(' + ') : null)
481
+ ?? echoIds.get(digest)
482
+ ?? (fileIds.get(digest) ?? []).join(' + ')
483
+ ?? 'unknown';
484
+ return { agent_id: agentId, verdict, digest };
485
+ });
486
+
487
+ return {
488
+ wave,
489
+ transport: unobservable ? 'unobservable' : 'observable',
490
+ dispatches,
491
+ injected,
492
+ echoed,
493
+ malformed_lines: malformedLines,
494
+ by_verdict: byVerdict,
495
+ agents,
496
+ };
497
+ }
498
+
499
+ /**
500
+ * Telemetry payload for a `--verify` run. Counts, closed enums and 8-hex digests
501
+ * ONLY — no agent id, no path, no prompt text: this record travels over the same
502
+ * unredacted Clank Event-Bus webhook as its two halves, and an `agent_id` is a
503
+ * free-form coordinator string that has carried private project slugs before.
504
+ *
505
+ * `malformed_lines` travels with the counts and, like them, is ALWAYS present
506
+ * including as `0` — it is the denominator's honesty check: without it a join
507
+ * that silently dropped half the ledger is indistinguishable in the record from
508
+ * a wave where nothing went wrong (`.claude/rules/host-resources.md` § HR-105).
509
+ *
510
+ * @param {ReturnType<typeof verifyWaveScope>} report
511
+ * @returns {Record<string, unknown>}
512
+ */
513
+ export function scopeVerifiedPayload(report) {
514
+ return {
515
+ wave: report.wave,
516
+ transport_observable: report.transport === 'observable',
517
+ dispatches: report.dispatches,
518
+ injected: report.injected,
519
+ echoed: report.echoed,
520
+ malformed_lines: report.malformed_lines ?? 0,
521
+ by_verdict: report.by_verdict,
522
+ digests: report.agents.map((a) => a.digest),
523
+ };
524
+ }
525
+
215
526
  // ---------------------------------------------------------------------------
216
527
  // CLI
217
528
  // ---------------------------------------------------------------------------
@@ -229,9 +540,19 @@ const USAGE = `Usage:
229
540
  an ${SCOPE_ECHO_EVENT} row to .orchestrator/metrics/events.jsonl
230
541
  (best-effort: a failed emit never changes the verdict or the exit code).
231
542
 
543
+ scope-echo --verify --wave <N> --state-dir <dir> [--session <id>]
544
+ [--events <path>] [--json] [--emit]
545
+ Join ONE wave's send side (${SCOPE_CHECKED_EVENT}),
546
+ its scope files and its echoes ON THE DIGEST, and print the per-agent
547
+ verdict table. --emit additionally appends one ${SCOPE_VERIFIED_EVENT}
548
+ row per wave (counts and 8-hex digests only).
549
+ Verdicts: ${SCOPE_VERDICTS.join(' · ')} — plus echo-only when the wave's
550
+ transport is unobservable.
551
+
232
552
  scope-echo --help
233
553
 
234
- Exit codes: 0 for every verdict, 1 for a missing --scope-file.
554
+ Exit codes: 0 for every verdict, 1 for a missing --scope-file (or, under
555
+ --verify, a missing --wave / --state-dir).
235
556
  `;
236
557
 
237
558
  /**
@@ -259,6 +580,68 @@ function parseArgv(argv) {
259
580
  return out;
260
581
  }
261
582
 
583
+ /**
584
+ * `--verify` mode — the per-wave join (see {@link verifyWaveScope}).
585
+ *
586
+ * Exit 0 for EVERY verdict, including `injection-missing`: this is an
587
+ * observability tool, and the wave-executor decides what a verdict costs
588
+ * (`wave-loop-review.md` step 3d-bis writes a STATE.md deviation, never a
589
+ * block). Exit 1 is reserved for the caller's own mistake — a missing required
590
+ * flag — per `.claude/rules/cli-design.md`.
591
+ *
592
+ * @param {Record<string, string|boolean>} args
593
+ * @returns {Promise<number>}
594
+ */
595
+ async function mainVerify(args) {
596
+ const wave = Number(args.wave);
597
+ const stateDir = typeof args['state-dir'] === 'string' ? args['state-dir'] : '';
598
+ if (!Number.isSafeInteger(wave) || wave <= 0 || stateDir === '') {
599
+ process.stderr.write('scope-echo: --verify requires --wave <positive-int> and --state-dir <dir>\n');
600
+ process.stderr.write(USAGE);
601
+ return 1;
602
+ }
603
+
604
+ const report = verifyWaveScope({
605
+ wave,
606
+ stateDir,
607
+ session: typeof args.session === 'string' ? args.session : undefined,
608
+ eventsPath: typeof args.events === 'string' ? args.events : undefined,
609
+ });
610
+
611
+ if (args.emit) {
612
+ try {
613
+ const repoRoot = resolve(stateDir, '..');
614
+ const { emitEvent, sessionAttribution } = await import('./events.mjs');
615
+ await emitEvent(
616
+ SCOPE_VERIFIED_EVENT,
617
+ { ...scopeVerifiedPayload(report), ...sessionAttribution(repoRoot) },
618
+ { repoRoot },
619
+ );
620
+ } catch (err) {
621
+ process.stderr.write(`scope-echo: emit failed — ${err?.message ?? err}\n`);
622
+ }
623
+ }
624
+
625
+ if (args.json) {
626
+ process.stdout.write(`${JSON.stringify(report)}\n`);
627
+ return 0;
628
+ }
629
+ const lines = [
630
+ `wave ${report.wave} — transport: ${report.transport} — `
631
+ + `${report.injected}/${report.dispatches} injected, ${report.echoed} echoed`,
632
+ // A partial read must never read as a clean wave: the counts above are a
633
+ // FLOOR when lines were dropped, and this is the only place the human table
634
+ // can say so.
635
+ ...(report.malformed_lines > 0
636
+ ? [` WARNING: ${report.malformed_lines} malformed ledger line(s) skipped — `
637
+ + 'counts and verdicts below are a floor, not a census']
638
+ : []),
639
+ ...report.agents.map((a) => ` ${a.digest} ${a.verdict.padEnd(20)} ${a.agent_id}`),
640
+ ];
641
+ process.stdout.write(`${lines.join('\n')}\n`);
642
+ return 0;
643
+ }
644
+
262
645
  /**
263
646
  * @param {string[]} [argv]
264
647
  * @returns {Promise<number>} process exit code: 0 for every verdict (this is an
@@ -272,6 +655,10 @@ export async function main(argv = process.argv.slice(2)) {
272
655
  process.stdout.write(USAGE);
273
656
  return 0;
274
657
  }
658
+ // --verify is its own mode and takes no --scope-file: it reads EVERY scope
659
+ // file of the wave, so the check below must not reject it first.
660
+ if (args.verify) return mainVerify(args);
661
+
275
662
  const scopeFilePath = typeof args['scope-file'] === 'string' ? args['scope-file'] : '';
276
663
  if (!scopeFilePath) {
277
664
  process.stderr.write('scope-echo: --scope-file <path> is required\n');
@@ -337,9 +724,7 @@ export async function main(argv = process.argv.slice(2)) {
337
724
  return 0;
338
725
  }
339
726
 
340
- const invokedAsCli =
341
- process.argv[1] !== undefined &&
342
- resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
727
+ const invokedAsCli = isMainModule(import.meta.url);
343
728
 
344
729
  if (invokedAsCli) {
345
730
  process.exitCode = await main();
@@ -14,7 +14,7 @@
14
14
  * `scripts/backfill-abandoned-sessions.mjs`.
15
15
  *
16
16
  * A second, sibling export — `backfillCompletedFromStateMd` — closes a
17
- * different gap (#429): `commands/close.md`'s Pre-Check treats STATE.md
17
+ * different gap (#429): `skills/close/SKILL.md`'s Pre-Check treats STATE.md
18
18
  * `status: completed` as proof the session-end writer already ran and refuses
19
19
  * to invoke it again. When that status was set by hand (or by any path that
20
20
  * never reached Phase 3.7), no sessions.jsonl record is EVER written — the
@@ -545,13 +545,38 @@ function isBackfillStub(record) {
545
545
  return BACKFILL_STUB_STATUSES.has(record.status);
546
546
  }
547
547
 
548
+ /**
549
+ * How far a caller-supplied `started_at` may drift from a legacy-label record's
550
+ * own `started_at` before the two are judged to name DIFFERENT sessions (#1368).
551
+ *
552
+ * Six hours is deliberately wider than any plausible write-lag and narrower
553
+ * than a day, because the legacy label the drift gate corroborates
554
+ * (`<branch>-<YYYY-MM-DD>-<mode>-<n>`) ALREADY carries the date: two records
555
+ * that share a label and sit inside the same six-hour window are the same
556
+ * session, and two that share a label across a day boundary cannot exist,
557
+ * because the label's date component would differ.
558
+ */
559
+ export const STARTED_AT_DRIFT_TOLERANCE_MS = 6 * 3600 * 1000;
560
+
548
561
  /**
549
562
  * Find the newest ledger record for one physical session. Shared by the close
550
563
  * precheck and backfill dedupe; no file I/O or mutation.
551
564
  *
552
565
  * Native identity wins over the attribution label. A conflicting native UUID
553
- * vetoes a label match. Legacy records without a native join remain readable;
554
- * when both start times are known they must name the same instant.
566
+ * vetoes a label match. Legacy records without a native join remain readable.
567
+ *
568
+ * `started_at` is a CORROBORATING SIGNAL on the legacy-label path, never an
569
+ * identity key (#1368). The two timestamps being compared are written by
570
+ * different producers at different moments — STATE.md's is written by hand
571
+ * when the coordinator initialises the file (measured 2026-09-13: 48 minutes
572
+ * after the lock's own `started_at`), while the ledger's comes from the
573
+ * `session.started` event or from STATE.md itself. Demanding an
574
+ * EXACT-millisecond match therefore vetoed a correct label match on the only
575
+ * path that can reach it, which is what made `/close`'s #429 pre-check report
576
+ * "no matching record" for a session that WAS recorded. Only a drift larger
577
+ * than {@link STARTED_AT_DRIFT_TOLERANCE_MS} now vetoes — a window, not an
578
+ * off-switch, so two genuinely different same-label sessions still fail to
579
+ * match.
555
580
  *
556
581
  * @param {object[]} records parsed JSONL records, in append order
557
582
  * @param {{sessionId?: string|null, semanticSessionId?: string|null, startedAt?: string|null}} ids
@@ -581,7 +606,8 @@ export function findRecordedSession(records, { sessionId = null, semanticSession
581
606
  const incompleteStart = Array.isArray(record._backfill_incomplete_fields)
582
607
  && record._backfill_incomplete_fields.includes('started_at');
583
608
  const recordStart = !incompleteStart && typeof record.started_at === 'string' ? Date.parse(record.started_at) : NaN;
584
- if (Number.isFinite(startMs) && Number.isFinite(recordStart) && startMs !== recordStart) continue;
609
+ if (Number.isFinite(startMs) && Number.isFinite(recordStart)
610
+ && Math.abs(startMs - recordStart) > STARTED_AT_DRIFT_TOLERANCE_MS) continue;
585
611
  legacyMatch ??= record;
586
612
  }
587
613
  return legacyMatch;
@@ -670,6 +696,21 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
670
696
  * historical migration CLI only — `hooks/on-session-end.mjs` must NEVER
671
697
  * pass this (a hook-time foreign lock is by definition a real, active
672
698
  * session, not stale history).
699
+ * @param {boolean} [args.ownSessionIsEnding=false]
700
+ * #1376 — the caller IS the session this candidate names, and it is tearing
701
+ * itself down right now. An OWN lock that is still live means "this session
702
+ * is RUNNING" only when the asker is somebody else: at `SessionEnd` the
703
+ * ending session's own heartbeat is by construction fresh (it was refreshed
704
+ * minutes ago, TTL 4h), so the `skipped-own-live-lock` branch below rejected
705
+ * every unclosed session's own stub and the ledger never got one. Pass
706
+ * `true` ONLY from `hooks/on-session-end.mjs`, and ONLY inside the
707
+ * `isRecordedSession` guard that already gates `duration_ms` /
708
+ * `semantic_session_id` — a FOREIGN terminating window that inherited this
709
+ * session's semantic id from the shared `current-session.json` (#863 defect
710
+ * (b)) must never set it. The migration CLI and the SessionStart recovery
711
+ * path (`scripts/backfill-abandoned-sessions.mjs`) NEVER pass it: there the
712
+ * candidate's live own lock genuinely means a running session.
713
+ * The FOREIGN-live-lock guard is untouched by this flag.
673
714
  * @param {number|null} [args.assumeDeadBeforeMs=null]
674
715
  * #731 — operator-supplied cutoff (ms-since-epoch). A candidate whose
675
716
  * `lastEventMs` strictly predates this value bypasses a foreign live lock
@@ -685,6 +726,7 @@ export async function backfillAbandonedSession({
685
726
  now = Date.now(),
686
727
  dryRun = false,
687
728
  relaxDeadByAge = false,
729
+ ownSessionIsEnding = false,
688
730
  assumeDeadBeforeMs = null,
689
731
  deps = {},
690
732
  } = {}) {
@@ -827,7 +869,17 @@ export async function backfillAbandonedSession({
827
869
  // only ever fires when `foreign` is true). A STALE own lock
828
870
  // (isLockLive === false) falls through unchanged — this is a
829
871
  // liveness gate, not a blanket own-session off-switch.
830
- if (own && isLockLive(lock, nowMs)) {
872
+ //
873
+ // #1376 — "own + live" is a RUNNING session only when the caller is
874
+ // NOT that session's own teardown. At SessionEnd the ending session's
875
+ // heartbeat is still fresh by construction, so without
876
+ // `ownSessionIsEnding` this branch swallowed the stub of every session
877
+ // that never reached /close — the exact gap the close-through backfill
878
+ // exists to close. The caller carries the attestation (see the param
879
+ // docblock); this module never re-derives it, because the only signal
880
+ // available here — the lock's own liveness — is what is being
881
+ // qualified.
882
+ if (own && isLockLive(lock, nowMs) && !ownSessionIsEnding) {
831
883
  return { action: 'skipped-own-live-lock', sessionId: recordId };
832
884
  }
833
885
 
@@ -934,7 +986,7 @@ export async function backfillAbandonedSession({
934
986
  * matching record (#429).
935
987
  *
936
988
  * ── THE GAP THIS CLOSES ──────────────────────────────────────────────────
937
- * `commands/close.md`'s Pre-Check treats `STATE.md status: completed` as
989
+ * `skills/close/SKILL.md`'s Pre-Check treats `STATE.md status: completed` as
938
990
  * PROOF that the session-end skill's Phase 3.7 writer already ran, and
939
991
  * stops before invoking it — including when `status: completed` was set by
940
992
  * hand (or by any path that never reached Phase 3.7). The session then has
@@ -10,13 +10,15 @@
10
10
  * @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
11
11
  * @see scripts/lib/state-md/recommendations.mjs parseRecommendations
12
12
  *
13
- * Plus ONE small non-re-export surface: the `session-profile` frontmatter
14
- * accessors at the bottom of this file (see their docblock for why they are
15
- * composed here rather than added as a fourth mutator module).
13
+ * Plus TWO small non-re-export surfaces at the bottom of this file (each with
14
+ * its own docblock explaining why it is composed here rather than added as a
15
+ * fourth mutator module): the `session-profile` frontmatter accessors, and the
16
+ * `started_at` / `session-id` frontmatter SOURCES (#1368).
16
17
  */
17
18
 
18
19
  import { parseStateMd as _parseStateMd } from './state-md/yaml-parser.mjs';
19
20
  import { updateFrontmatterFields as _updateFrontmatterFields } from './state-md/frontmatter-mutators.mjs';
21
+ import { readLock as _readLock } from './session-lock.mjs';
20
22
 
21
23
  export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
22
24
 
@@ -140,3 +142,82 @@ export function setSessionProfile(contents, profile) {
140
142
  [SESSION_PROFILE_FIELD]: profile === null ? null : profile.trim(),
141
143
  });
142
144
  }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // STATE.md frontmatter SOURCES (#1368) — `started_at` / `session-id`.
148
+ //
149
+ // These two helpers exist because the frontmatter they feed is written by the
150
+ // coordinator LLM from prose, and prose with no named source gets written from
151
+ // `new Date()` at WRITE time. `started_at` then names the moment STATE.md was
152
+ // created, not the moment the session began — measured 2026-09-13: 48 minutes
153
+ // apart. Every other producer of that timestamp (the lock's own `started_at`,
154
+ // `orchestrator.session.lock.acquired`, `orchestrator.session.started`) agrees
155
+ // within 1 ms, because all three descend from `buildLock()`. So the lock is
156
+ // the source, and these functions are how the template cites it.
157
+ //
158
+ // They live in this barrel rather than in `state-md/`: the barrel is the
159
+ // documented entry point for STATE.md work, and the edge barrel → session-lock
160
+ // already exists (state-md/frontmatter-mutators.mjs imports `withStateMdLock`
161
+ // from it), so no cycle is created — session-lock.mjs imports nothing from
162
+ // this module or its submodules.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * The canonical `started_at` for a STATE.md written during THIS session.
167
+ *
168
+ * Returns the live lock's `started_at`, normalised to ISO-8601 UTC ("Z"), so
169
+ * STATE.md, the lock, both lock events and the ledger all name the SAME
170
+ * instant. Falls back to `new Date().toISOString()` ONLY when no usable lock
171
+ * exists — a missing lock means `persistence: false`, a lock that could not be
172
+ * acquired, or a corrupt lock file, and in all three the writer has no better
173
+ * source than the present moment. The fallback is deliberately silent: it is a
174
+ * degradation of PRECISION, not a failure.
175
+ *
176
+ * Never throws.
177
+ *
178
+ * @param {{ repoRoot?: string }} [opts]
179
+ * @returns {string} ISO-8601 UTC timestamp.
180
+ */
181
+ export function resolveSessionStartedAt({ repoRoot } = {}) {
182
+ try {
183
+ const lock = _readLock({ repoRoot });
184
+ const raw = lock?.started_at;
185
+ if (typeof raw === 'string' && raw.length > 0) {
186
+ const ms = Date.parse(raw);
187
+ if (Number.isFinite(ms)) return new Date(ms).toISOString();
188
+ }
189
+ } catch {
190
+ /* readLock is no-throw by contract; the contract is not ours to trust */
191
+ }
192
+ return new Date().toISOString();
193
+ }
194
+
195
+ /**
196
+ * The session ids a STATE.md writer may stamp into frontmatter: `session-id`
197
+ * (the RAW/native harness id) and `session` (the semantic attribution label),
198
+ * both read from the live lock.
199
+ *
200
+ * Both fields are `null` when no usable lock exists — ABSENCE IS NEVER
201
+ * COERCED, and a writer must then OMIT the key rather than write a
202
+ * placeholder. Neither id grants ownership of anything (see
203
+ * `skills/_shared/state-ownership.md`); `session-id` exists so `/close`'s #429
204
+ * pre-check can join STATE.md to sessions.jsonl on a native identity instead
205
+ * of falling through to the label + timestamp path.
206
+ *
207
+ * Never throws.
208
+ *
209
+ * @param {{ repoRoot?: string }} [opts]
210
+ * @returns {{ session_id: string|null, semantic_session_id: string|null }}
211
+ */
212
+ export function resolveSessionIds({ repoRoot } = {}) {
213
+ const pick = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null);
214
+ try {
215
+ const lock = _readLock({ repoRoot });
216
+ return {
217
+ session_id: pick(lock?.session_id),
218
+ semantic_session_id: pick(lock?.semantic_session_id),
219
+ };
220
+ } catch {
221
+ return { session_id: null, semantic_session_id: null };
222
+ }
223
+ }