session-orchestrator 3.22.0 → 3.24.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 (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -9,12 +9,24 @@
9
9
  * each bare `files` array first, then writes the complete record array as the
10
10
  * aggregate sidecar consumed by validate-wave-scope's --assert-disjoint and
11
11
  * --union modes.
12
+ *
13
+ * After the aggregate is published, per-agent declarations left behind by an
14
+ * earlier materialization of the SAME wave are reconciled away (#1103) — but
15
+ * only against a proven session owner. See {@link reconcileOrphans}.
16
+ *
17
+ * PEER RECORDS (`peer-session-*`, #1195) get NO per-agent file: shape (a) is
18
+ * `$AGENT_FILESCOPE_JSON`, addressed by an agent id at dispatch time, and no
19
+ * agent is ever dispatched for a peer session — nothing reads it. They are
20
+ * still written into the aggregate, where `--assert-disjoint` and
21
+ * `hooks/post-bash-write-verify.mjs` do read them; `--union` excludes them so
22
+ * `allowedPaths` never grants a peer's territory to this wave's agents.
12
23
  */
13
24
 
14
- import { readFileSync, unlinkSync } from 'node:fs';
25
+ import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
15
26
  import { resolve } from 'node:path';
16
27
  import { fileURLToPath } from 'node:url';
17
28
  import { writeJsonAtomicSync } from './lib/io.mjs';
29
+ import { isPeerRecordId } from './lib/scope-gate.mjs';
18
30
 
19
31
  const HELP = `Usage: node scripts/materialize-wave-scope.mjs --state-dir <dir> --wave <positive-int> [--json]
20
32
 
@@ -27,7 +39,12 @@ Required:
27
39
  Positive wave number used in filescopes/wave-N/.
28
40
 
29
41
  Options:
30
- --json Emit {ok, aggregatePath, perAgentPaths} to stdout.
42
+ --session <id> This session's id (session_id or its semantic twin). Used
43
+ ONLY to prove ownership before an orphaned per-agent
44
+ declaration of the same wave is removed (#1103). Without
45
+ it, orphans are reported and RETAINED, never deleted.
46
+ --json Emit {ok, aggregatePath, perAgentPaths, removedOrphans,
47
+ retainedOrphans} to stdout.
31
48
  -h, --help Show this help and exit 0.
32
49
 
33
50
  Output:
@@ -41,8 +58,17 @@ Examples:
41
58
 
42
59
  Writes:
43
60
  <state-dir>/filescopes/wave-N/<id>.json Bare string[] for each record
61
+ EXCEPT a peer-session-* record (#1195), which is
62
+ aggregate-only: no agent is dispatched for it, so nothing
63
+ would read its per-agent file.
44
64
  <state-dir>/filescopes/wave-N.scopes.json Aggregate [{id, files}, ...]
45
65
 
66
+ Removes (only with a proven owner — see --session):
67
+ <state-dir>/filescopes/wave-N/<stale-id>.json Per-agent declarations of this
68
+ wave whose id is absent from the new record array. An
69
+ orphan that cannot be proven owned is named on stderr and
70
+ LEFT IN PLACE; that is a WARN, never a failure.
71
+
46
72
  Exit codes:
47
73
  0 All declaration files and the aggregate sidecar were written.
48
74
  1 Usage or input validation error; no write was attempted.
@@ -55,11 +81,12 @@ class WriteError extends Error {}
55
81
 
56
82
  /**
57
83
  * @param {string[]} argv
58
- * @returns {{ stateDir: string, wave: number, json: boolean, help: boolean }}
84
+ * @returns {{ stateDir: string, wave: number, session: string|null, json: boolean, help: boolean }}
59
85
  */
60
86
  export function parseCliArgs(argv) {
61
87
  let stateDir;
62
88
  let waveRaw;
89
+ let session = null;
63
90
  let json = false;
64
91
  let help = false;
65
92
 
@@ -73,7 +100,7 @@ export function parseCliArgs(argv) {
73
100
  help = true;
74
101
  continue;
75
102
  }
76
- if (arg === '--state-dir' || arg === '--wave') {
103
+ if (arg === '--state-dir' || arg === '--wave' || arg === '--session') {
77
104
  const value = argv[index + 1];
78
105
  if (value === undefined || value.startsWith('--')) {
79
106
  throw new InputError(`${arg} requires a value`);
@@ -81,6 +108,12 @@ export function parseCliArgs(argv) {
81
108
  if (arg === '--state-dir') {
82
109
  if (stateDir !== undefined) throw new InputError('--state-dir may be specified only once');
83
110
  stateDir = value;
111
+ } else if (arg === '--session') {
112
+ if (session !== null) throw new InputError('--session may be specified only once');
113
+ // An empty or whitespace-only id proves nothing and must not be read as
114
+ // an owner: it would make every orphan deletable by any caller.
115
+ if (value.trim().length === 0) throw new InputError('--session must be a non-empty id');
116
+ session = value.trim();
84
117
  } else {
85
118
  if (waveRaw !== undefined) throw new InputError('--wave may be specified only once');
86
119
  waveRaw = value;
@@ -91,7 +124,7 @@ export function parseCliArgs(argv) {
91
124
  throw new InputError(`unknown argument: ${arg}`);
92
125
  }
93
126
 
94
- if (help) return { stateDir: '', wave: 0, json, help: true };
127
+ if (help) return { stateDir: '', wave: 0, session, json, help: true };
95
128
  if (stateDir === undefined) throw new InputError('--state-dir is required');
96
129
  if (waveRaw === undefined) throw new InputError('--wave is required');
97
130
  if (stateDir.length === 0 || /[\0\r\n]/.test(stateDir)) {
@@ -103,7 +136,7 @@ export function parseCliArgs(argv) {
103
136
 
104
137
  const wave = Number(waveRaw);
105
138
  if (!Number.isSafeInteger(wave)) throw new InputError('--wave must be a safe positive integer');
106
- return { stateDir, wave, json, help: false };
139
+ return { stateDir, wave, session, json, help: false };
107
140
  }
108
141
 
109
142
  /**
@@ -183,6 +216,141 @@ export function validateScopeRecords(value) {
183
216
  return value;
184
217
  }
185
218
 
219
+
220
+ /**
221
+ * Read the session ids the sibling manifest `<state-dir>/wave-scope.json`
222
+ * declares for this state directory (#1123 wrote both `session` — the raw
223
+ * `session_id` — and its human-readable twin `semantic_session`).
224
+ *
225
+ * A caller may legitimately hold either spelling, so BOTH are returned and a
226
+ * match against either proves ownership. Any failure to read or parse the
227
+ * manifest returns an empty list, which the caller must treat as "ownership NOT
228
+ * established" — never as "no owner, therefore mine".
229
+ *
230
+ * @param {string} stateDir
231
+ * @param {typeof readFileSync} [readFile]
232
+ * @returns {string[]}
233
+ */
234
+ export function manifestSessionIds(stateDir, readFile = readFileSync) {
235
+ let manifest;
236
+ try {
237
+ manifest = JSON.parse(readFile(resolve(stateDir, 'wave-scope.json'), 'utf8'));
238
+ } catch {
239
+ return [];
240
+ }
241
+ if (!isRecord(manifest)) return [];
242
+ return ['session', 'semantic_session']
243
+ .map((key) => manifest[key])
244
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
245
+ .map((value) => value.trim());
246
+ }
247
+
248
+ /**
249
+ * Reconcile per-agent declarations left behind by an earlier materialization of
250
+ * the SAME wave (#1103).
251
+ *
252
+ * The write loop is a pure upsert over the new id set, so a file whose id was
253
+ * dropped from the plan survives: it is in no aggregate — `--assert-disjoint`
254
+ * and `--union` cannot see it — while every by-id consumer (FILE-SCOPE
255
+ * injection, the Learnings-Index, `--assert-subset`) still reads it. A live
256
+ * scope claim with zero aggregate coverage.
257
+ *
258
+ * ## Why this is NOT a directory wipe
259
+ *
260
+ * The wave number is not a session-unique key: two sessions sharing one working
261
+ * copy both call their first wave `wave-1` and both resolve to
262
+ * `<state-dir>/filescopes/wave-1/`. Clearing the directory would convert a
263
+ * stale-read bug into cross-session data loss — the class
264
+ * `.claude/rules/parallel-sessions.md` PSA-003 forbids by name ("Did I create
265
+ * this file? If not, it is not mine to touch"). So removal needs a two-part
266
+ * test, and only the second part is about the file:
267
+ *
268
+ * 1. the id is absent from the new record array (it is an orphan), AND
269
+ * 2. this invocation can PROVE it owns the state directory — `sessionId`
270
+ * matches an id the sibling manifest declares.
271
+ *
272
+ * Failing (2) is not an error and never blocks: the orphan is returned in
273
+ * `retained` WITH its reason so the caller can name the file. A silent skip is
274
+ * the one outcome forbidden here, because it is byte-identical to a clean run.
275
+ *
276
+ * ## Named ceiling (BV-004)
277
+ *
278
+ * Bounded by WHEN materialization runs, not by what it inspects: a session that
279
+ * re-materializes wave N *while its own agents are still in flight* would delete
280
+ * the scope files those agents are reading. That is safe today only because
281
+ * `skills/wave-executor/wave-loop.md` § Scope Manifest 3.2 places
282
+ * (re-)materialization strictly PRE-dispatch, so no reader exists yet. REVISIT
283
+ * TRIGGER: any caller that materializes a wave after its dispatch has begun — a
284
+ * mid-wave scope amendment, a repair pass reusing the same wave number, or a
285
+ * dispatch loop that re-runs the materializer per agent. Ownership does not
286
+ * protect against that case; the ordering does.
287
+ *
288
+ * @param {object} params
289
+ * @param {string} params.scopeDir `<state-dir>/filescopes/wave-N`
290
+ * @param {string[]} params.keepIds ids present in the new record array
291
+ * @param {string[]} params.ownerIds session ids the manifest declares
292
+ * @param {string|null} params.sessionId this invocation's session id
293
+ * @param {typeof readdirSync} [params.readDir]
294
+ * @param {typeof unlinkSync} [params.removeFile]
295
+ * @returns {{removed: string[], retained: Array<{file: string, reason: string}>}}
296
+ */
297
+ export function reconcileOrphans({
298
+ scopeDir,
299
+ keepIds,
300
+ ownerIds,
301
+ sessionId,
302
+ readDir = readdirSync,
303
+ removeFile = unlinkSync,
304
+ }) {
305
+ let entries;
306
+ try {
307
+ entries = readDir(scopeDir, { withFileTypes: true });
308
+ } catch {
309
+ // No directory yet (first materialization) or unreadable — nothing to
310
+ // reconcile. Not a failure: the aggregate is already published.
311
+ return { removed: [], retained: [] };
312
+ }
313
+
314
+ // Case-INSENSITIVE keep set. validateScopeRecords already rejects two ids that
315
+ // differ only in case within one input, so this cannot hide a real orphan —
316
+ // but on a case-insensitive filesystem `A2.json` and `a2.json` are ONE file,
317
+ // and deleting the "orphan" would delete the declaration just written.
318
+ const keep = new Set(keepIds.map((id) => `${id}.json`.toLowerCase()));
319
+ const orphans = [];
320
+ for (const entry of entries) {
321
+ if (typeof entry?.isFile === 'function' && !entry.isFile()) continue;
322
+ const name = typeof entry === 'string' ? entry : entry?.name;
323
+ if (typeof name !== 'string' || !name.endsWith('.json')) continue;
324
+ if (keep.has(name.toLowerCase())) continue;
325
+ orphans.push(name);
326
+ }
327
+ if (orphans.length === 0) return { removed: [], retained: [] };
328
+
329
+ const proven = typeof sessionId === 'string' && sessionId.length > 0 && ownerIds.includes(sessionId);
330
+ if (!proven) {
331
+ const reason = sessionId === null || sessionId === undefined
332
+ ? 'no --session given, so this state directory has no provable owner'
333
+ : ownerIds.length === 0
334
+ ? 'wave-scope.json declares no session, so ownership cannot be established'
335
+ : `wave-scope.json is owned by a different session (${ownerIds.join(' / ')})`;
336
+ return { removed: [], retained: orphans.map((file) => ({ file, reason })) };
337
+ }
338
+
339
+ const removed = [];
340
+ const retained = [];
341
+ for (const file of orphans) {
342
+ try {
343
+ removeFile(resolve(scopeDir, file));
344
+ removed.push(file);
345
+ } catch (error) {
346
+ // A failed unlink leaves a live orphan behind — report it, never throw:
347
+ // the aggregate is already published and the materialization succeeded.
348
+ retained.push({ file, reason: `could not remove: ${error instanceof Error ? error.message : String(error)}` });
349
+ }
350
+ }
351
+ return { removed, retained };
352
+ }
353
+
186
354
  /**
187
355
  * Materialize validated declarations in their required write order.
188
356
  *
@@ -193,13 +361,30 @@ export function validateScopeRecords(value) {
193
361
  * has succeeded.
194
362
  *
195
363
  * @param {Array<{id: string, files: string[]}>} records
196
- * @param {{ stateDir: string, wave: number, writeJson?: typeof writeJsonAtomicSync }} options
197
- * @returns {{ aggregatePath: string, perAgentPaths: string[] }}
364
+ * @param {{ stateDir: string, wave: number, session?: string|null,
365
+ * writeJson?: typeof writeJsonAtomicSync,
366
+ * readDir?: typeof readdirSync, removeFile?: typeof unlinkSync,
367
+ * readFile?: typeof readFileSync }} options
368
+ * @returns {{ aggregatePath: string, perAgentPaths: string[],
369
+ * removedOrphans: string[], retainedOrphans: Array<{file: string, reason: string}> }}
198
370
  */
199
- export function materializeWaveScope(records, { stateDir, wave, writeJson = writeJsonAtomicSync }) {
371
+ export function materializeWaveScope(records, {
372
+ stateDir,
373
+ wave,
374
+ session = null,
375
+ writeJson = writeJsonAtomicSync,
376
+ readDir = readdirSync,
377
+ removeFile = unlinkSync,
378
+ readFile = readFileSync,
379
+ }) {
200
380
  const scopeDir = resolve(stateDir, 'filescopes', `wave-${wave}`);
201
381
  const aggregatePath = resolve(stateDir, 'filescopes', `wave-${wave}.scopes.json`);
202
- const perAgentPaths = records.map(({ id }) => resolve(scopeDir, `${id}.json`));
382
+ // #1195 peer records are aggregate-only (see the header note): no reader
383
+ // exists for a per-agent file that no dispatch will ever address. Excluding
384
+ // them from `keepIds` below also lets a stale `peer-session-*.json` written
385
+ // before this rule be reconciled away like any other orphan.
386
+ const agentRecords = records.filter((r) => !isPeerRecordId(r.id));
387
+ const perAgentPaths = agentRecords.map(({ id }) => resolve(scopeDir, `${id}.json`));
203
388
 
204
389
  try {
205
390
  unlinkSync(aggregatePath);
@@ -209,8 +394,8 @@ export function materializeWaveScope(records, { stateDir, wave, writeJson = writ
209
394
  }
210
395
  }
211
396
 
212
- for (let index = 0; index < records.length; index++) {
213
- const result = writeJson(perAgentPaths[index], records[index].files, { tmpPrefix: '.materialize-wave-scope' });
397
+ for (let index = 0; index < agentRecords.length; index++) {
398
+ const result = writeJson(perAgentPaths[index], agentRecords[index].files, { tmpPrefix: '.materialize-wave-scope' });
214
399
  if (!result?.ok) {
215
400
  throw new WriteError(`cannot write per-agent declaration ${perAgentPaths[index]}: ${result?.error ?? 'unknown write failure'}`);
216
401
  }
@@ -220,7 +405,21 @@ export function materializeWaveScope(records, { stateDir, wave, writeJson = writ
220
405
  if (!aggregateResult?.ok) {
221
406
  throw new WriteError(`cannot write aggregate declaration ${aggregatePath}: ${aggregateResult?.error ?? 'unknown write failure'}`);
222
407
  }
223
- return { aggregatePath, perAgentPaths };
408
+
409
+ // #1103 — strictly AFTER the aggregate write. The aggregate is this command's
410
+ // publication marker (see the write-order note above), so reconciling before
411
+ // it would remove a live declaration while the run could still fail and leave
412
+ // no aggregate at all — deleting coverage that nothing replaced.
413
+ const { removed, retained } = reconcileOrphans({
414
+ scopeDir,
415
+ keepIds: agentRecords.map(({ id }) => id),
416
+ ownerIds: manifestSessionIds(stateDir, readFile),
417
+ sessionId: session,
418
+ readDir,
419
+ removeFile,
420
+ });
421
+
422
+ return { aggregatePath, perAgentPaths, removedOrphans: removed, retainedOrphans: retained };
224
423
  }
225
424
 
226
425
  /**
@@ -256,10 +455,24 @@ export function main() {
256
455
  return;
257
456
  }
258
457
  const records = validateScopeRecords(readStdinJson());
259
- const { aggregatePath, perAgentPaths } = materializeWaveScope(records, args);
458
+ const { aggregatePath, perAgentPaths, removedOrphans, retainedOrphans } =
459
+ materializeWaveScope(records, args);
460
+
461
+ // stderr carries ONLY the anomalous cases. Measured constraint, not taste:
462
+ // the corpus pins byte-empty stderr on this command's success path
463
+ // (tests/scripts/materialize-wave-scope.test.mjs and
464
+ // tests/integration/wave-scope-producer.test.mjs), and a wave with no
465
+ // orphans IS the success path. Both lists always reach --json.
466
+ for (const file of removedOrphans) {
467
+ process.stderr.write(`materialize-wave-scope: removed orphaned declaration ${file} (id absent from this wave's records)\n`);
468
+ }
469
+ for (const { file, reason } of retainedOrphans) {
470
+ process.stderr.write(`materialize-wave-scope: WARN orphaned declaration ${file} RETAINED — ${reason}\n`);
471
+ }
472
+
260
473
  process.stdout.write(
261
474
  args.json
262
- ? `${JSON.stringify({ ok: true, aggregatePath, perAgentPaths })}\n`
475
+ ? `${JSON.stringify({ ok: true, aggregatePath, perAgentPaths, removedOrphans, retainedOrphans })}\n`
263
476
  : `${aggregatePath}\n`,
264
477
  );
265
478
  } catch (error) {
@@ -40,9 +40,18 @@ text_content() {
40
40
  # ---------------------------------------------------------------------------
41
41
  # Verify jq is available (fatal — we cannot parse JSON without it)
42
42
  # ---------------------------------------------------------------------------
43
+ # The diagnostic goes to STDERR, never stdout. A `{"id":null,"error":...}` line
44
+ # on stdout is NOT a valid `initialize` response, so the client reports the same
45
+ # opaque "connection closed: initialize response" it reports when the script
46
+ # cannot be found at all — the two failure modes were indistinguishable
47
+ # (GH Kanevry/session-orchestrator#64). A named, actionable stderr line keeps
48
+ # them apart. Same shape as hooks/run-node.sh's Node-missing diagnostic.
43
49
  if ! command -v jq >/dev/null 2>&1; then
44
- # Emit a single error and exit; without jq we cannot operate
45
- printf '{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"jq is required but not found in PATH"}}\n'
50
+ {
51
+ echo "session-orchestrator: 'jq' not found in PATH — the MCP server did not start."
52
+ echo " Fix: install jq (macOS: brew install jq · Debian/Ubuntu: apt-get install jq), then restart the client."
53
+ echo " (MCP server shells do not source ~/.zshrc, so a jq that works in your terminal can still be invisible here.)"
54
+ } >&2
46
55
  exit 1
47
56
  fi
48
57
 
@@ -46,6 +46,8 @@ import { dirname, join, isAbsolute } from 'node:path';
46
46
  import { fileURLToPath } from 'node:url';
47
47
 
48
48
  import { isWaveAgentContext, WAVE_AGENT_ENV_VAR, WAVE_AGENT_ENV_VALUE } from './lib/wave-context.mjs';
49
+ import { readProcessLocalSessionIds } from './lib/session-identity/own-session.mjs';
50
+ import { readLockDetailed } from './lib/session-lock.mjs';
49
51
 
50
52
  // ---------------------------------------------------------------------------
51
53
  // Constants & defaults
@@ -259,11 +261,14 @@ const confidence = confidenceVal;
259
261
 
260
262
  let frontmatter = {};
261
263
  let stateMdReadOk = false;
264
+ /** Directory holding STATE.md — the same state-dir the wave manifest lives in (#1166). */
265
+ let stateDir = null;
262
266
 
263
267
  try {
264
268
  // resolveStateMdPath from state-md.mjs: falls back to .claude/STATE.md
265
269
  const stateMdMod = await import('./lib/state-md.mjs');
266
270
  const stateMdPath = stateMdMod.resolveStateMdPath(process.cwd());
271
+ stateDir = dirname(stateMdPath);
267
272
 
268
273
  if (!existsSync(stateMdPath)) {
269
274
  if (!dryRun) {
@@ -352,17 +357,136 @@ if (!dryRun && (currentWaveRaw === undefined || currentWaveRaw === null || curre
352
357
  }
353
358
 
354
359
  // ---------------------------------------------------------------------------
355
- // Step 3 — Build wave ID from STATE.md current-wave (guaranteed present by 2c
356
- // when !dryRun; falls back to a 'W-dryrun' placeholder under --dry-run when
357
- // STATE.md was absent/unparseable/missing the field — #741.3)
360
+ // Step 3 — Build the RUNNING wave ID (#1166)
358
361
  // ---------------------------------------------------------------------------
362
+ //
363
+ // #1166 — two writers, two semantics: `skills/wave-executor/wave-loop.md` § 3a
364
+ // writes STATE.md `current-wave` = the JUST-COMPLETED wave, while the
365
+ // coordinator's `<state-dir>/wave-scope.json` carries `wave` = the RUNNING
366
+ // wave. Stamping proposals off `current-wave` verbatim filed every wave-N+1
367
+ // proposal into the W<N> quota bucket. Priority: the manifest ONLY when it is
368
+ // bound to THIS session, else `current-wave + 1`. An UNBOUND manifest is not
369
+ // trusted (#1177 FX1) — since #1123 both writers stamp the binding, so a
370
+ // binding-less manifest is a peer's or a stale artefact.
371
+ //
372
+ // The session binding compares `manifest.semantic_session` — the SEMANTIC label
373
+ // — against the semantic id looked up in `session.lock`, and that lookup is only
374
+ // honoured when this PROCESS's own raw session id equals the lock's raw
375
+ // `session_id` (#1188). STATE.md's `session` field is NOT a witness here: it is
376
+ // written by the lock owner, so under a peer's lock both "sides" name the peer.
377
+
378
+ /** @returns {string|undefined} */
379
+ function resolveRunningWaveId() {
380
+ if (stateDir !== null) {
381
+ try {
382
+ const manifestPath = join(stateDir, 'wave-scope.json');
383
+ if (existsSync(manifestPath)) {
384
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
385
+ const boundSession = manifest?.semantic_session ?? manifest?.session;
386
+ const unbound =
387
+ boundSession === undefined || boundSession === null || boundSession === '';
388
+ // BOTH sides must be non-empty strings. `undefined === undefined` is
389
+ // true, so a STATE.md WITHOUT a `session` key previously "matched" any
390
+ // manifest whose `semantic_session` was also absent-but-present-in-the
391
+ // -comparison — adopting a foreign coordinator's wave number as its own.
392
+ const mineSide = manifest?.semantic_session;
393
+ // #1188 — STATE.md is a working-copy artefact of the LOCK OWNER, not a
394
+ // process-local witness: when a peer holds the lock the peer wrote BOTH
395
+ // STATE.md and the manifest, so the two "independent" sides agree about
396
+ // the PEER. Same class as #1177 FX1; same shape as attributionForRecord
397
+ // (scripts/lib/events.mjs): the lock is a raw->semantic LOOKUP,
398
+ // authorised by a process-local match on the RAW id. Measured
399
+ // 2026-09-02: a wave-agent process carries the COORDINATOR's raw uuid in
400
+ // CLAUDE_CODE_SESSION_ID and no semantic id at all, so comparing the
401
+ // process-local id against `semantic_session` directly never matches.
402
+ // CEILING (BV-004): a harness exporting no session id (Codex CLI,
403
+ // Cursor) resolves no process-local id -> binding unprovable ->
404
+ // current-wave + 1 fallback with a stderr line. REVISIT if that fallback
405
+ // rate is measured non-trivial in events.jsonl.
406
+ const lock = readLockDetailed({ repoRoot: process.cwd() });
407
+ const processLocal = readProcessLocalSessionIds();
408
+ const lockRaw =
409
+ lock.status === 'ok' ? String(lock.lock.session_id ?? '').trim() : '';
410
+ const authorised = lockRaw !== '' && processLocal.includes(lockRaw);
411
+ const oursSide = authorised ? (lock.lock.semantic_session_id ?? '') : '';
412
+ if (!authorised) {
413
+ // A SILENT non-match is the exact failure mode this fix exists to make
414
+ // visible: without this line, a proposal bucketed by the fallback is
415
+ // indistinguishable from one bucketed by a trusted manifest.
416
+ process.stderr.write(
417
+ `⚠ memory-propose: session binding unprovable (session.lock ${lock.status}` +
418
+ `${lockRaw ? `, raw ${lockRaw}` : ''}; process-local ids: ` +
419
+ `${processLocal.length > 0 ? processLocal.join(', ') : 'none'}) — ` +
420
+ 'ignoring wave-scope.json, falling back to current-wave + 1\n',
421
+ );
422
+ }
423
+ const mine =
424
+ typeof mineSide === 'string' && mineSide.length > 0 &&
425
+ typeof oursSide === 'string' && oursSide.length > 0 &&
426
+ mineSide === oursSide;
427
+ // An UNBOUND manifest is NOT trusted (#1177 FX1). It used to be, as
428
+ // "legacy" — but since #1123 BOTH writers stamp the binding, so a
429
+ // manifest without one today is a PEER's or a stale artefact, and
430
+ // trusting it files this session's proposals into a foreign quota
431
+ // bucket. Unbound → say so once on stderr and fall back.
432
+ if (unbound) {
433
+ process.stderr.write(
434
+ 'memory-propose: wave-scope.json unbound (no semantic_session) — ignored\n',
435
+ );
436
+ }
437
+ if (mine) {
438
+ const raw = manifest?.wave;
439
+ const num =
440
+ typeof raw === 'number'
441
+ ? raw
442
+ : typeof raw === 'string' && raw.trim() !== ''
443
+ ? Number(raw)
444
+ : NaN;
445
+ if (Number.isInteger(num)) return `W${num}`;
446
+ }
447
+ }
448
+ } catch (err) {
449
+ // Unreadable / malformed manifest → fall through to the +1 fallback, but
450
+ // SAY SO. A swallowed parse error makes a wrongly-bucketed proposal look
451
+ // like ordinary fallback behaviour; the operator then has no signal that
452
+ // the coordinator's own manifest is corrupt. Diagnostics on stderr keeps
453
+ // the stdout JSON contract intact (cli-design.md § JSON-First Output).
454
+ process.stderr.write(
455
+ `⚠ memory-propose: cannot read ${join(stateDir, 'wave-scope.json')} ` +
456
+ `(${err?.message ?? String(err)}) — falling back to current-wave + 1\n`,
457
+ );
458
+ }
459
+ }
460
+
461
+ if (currentWaveRaw === undefined || currentWaveRaw === null || currentWaveRaw === '') {
462
+ return undefined;
463
+ }
464
+ const completed = Number(currentWaveRaw);
465
+ if (!Number.isFinite(completed)) return undefined; // never 'WNaN'
466
+ // CEILING (BV-004): `+1` assumes waves run consecutively and that STATE.md is
467
+ // maintained — it is WRONG when a wave is skipped, when the plan is adapted
468
+ // mid-session, or under `persistence: false` where `current-wave` never
469
+ // advances. It is the fallback precisely because wave-scope.json is the only
470
+ // artefact that KNOWS the running wave.
471
+ // REVISIT when a wave-scope.json manifest is present in every dispatch (then
472
+ // delete the fallback and reject instead), or when a proposal is observed in
473
+ // the wrong quota bucket while the manifest was readable.
474
+ return `W${completed + 1}`;
475
+ }
476
+
477
+ const resolvedWaveId = resolveRunningWaveId();
359
478
 
360
- const frontmatterWaveId =
361
- currentWaveRaw !== undefined && currentWaveRaw !== null && currentWaveRaw !== ''
362
- ? `W${currentWaveRaw}`
363
- : undefined;
479
+ if (!dryRun && resolvedWaveId === undefined) {
480
+ exit(
481
+ {
482
+ status: STATUS.REJECTED_WRONG_CONTEXT,
483
+ detail: `Cannot resolve the running wave: no usable wave-scope.json and STATE.md 'current-wave' is not numeric (got '${currentWaveRaw}')`,
484
+ },
485
+ 3,
486
+ );
487
+ }
364
488
 
365
- const waveId = dryRun ? (frontmatterWaveId ?? 'W-dryrun') : frontmatterWaveId;
489
+ const waveId = dryRun ? (resolvedWaveId ?? 'W-dryrun') : resolvedWaveId;
366
490
 
367
491
  // ---------------------------------------------------------------------------
368
492
  // Step 4 — Read Session Config (quota + floor)
@@ -9,6 +9,11 @@
9
9
  * Output: Single JSON object to stdout with ALL config fields (defaults applied).
10
10
  * Exit codes: 0 success, 1 error (message to stderr)
11
11
  *
12
+ * Unparsable lines inside `## Session Config` (#1097) are reported per line on
13
+ * stderr under `enforcement: warn` (the default) and refuse the run under
14
+ * `enforcement: strict`; `off` is silent. stdout is unchanged for any
15
+ * well-formed block — a warning never alters the emitted JSON.
16
+ *
12
17
  * Environment:
13
18
  * SO_CONFIG_FILE — override filename (e.g. "AGENTS.md") resolved from project root
14
19
  * SO_SKIP_CONFIG_VALIDATION — set to "1" to bypass validate-config.mjs
@@ -19,6 +24,8 @@ import { spawnSync } from 'node:child_process';
19
24
  import { join, dirname, resolve, parse as parsePath } from 'node:path';
20
25
  import { fileURLToPath } from 'node:url';
21
26
  import { parseSessionConfig } from './lib/config.mjs';
27
+ import { collectUnparsableLines } from './lib/config/section-extractor.mjs';
28
+ import { ENFORCEMENT_VALUES } from './lib/config-schema.mjs';
22
29
 
23
30
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
24
31
 
@@ -108,6 +115,64 @@ try {
108
115
  process.exit(1);
109
116
  }
110
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Unparsable-line gate (#1097) — never a silent default
120
+ // ---------------------------------------------------------------------------
121
+ //
122
+ // A line inside `## Session Config` that no parser can read is simply absent
123
+ // from the KV map, and every consumer then applies its own default — `false`
124
+ // for the booleans. A broken key therefore reads exactly like a deliberately
125
+ // disabled feature, in every log, forever. This gate is the only place that
126
+ // difference is ever stated out loud.
127
+ //
128
+ // It runs BEFORE the validator so a malformed block is reported in terms of the
129
+ // FILE (line number + text) rather than in terms of the defaults it silently
130
+ // produced. `off` stays silent by definition — that is what turning enforcement
131
+ // off means; `warn` (the default) reports and proceeds; `strict` refuses.
132
+
133
+ const unparsableLines = collectUnparsableLines(content);
134
+
135
+ if (unparsableLines.length > 0) {
136
+ // Belt-and-braces, not a live branch: `parseSessionConfig` already REFUSES
137
+ // an out-of-vocabulary enforcement value above (measured: `enforcement:
138
+ // banana` exits 1 with "must be strict|warn|off" and never reaches here), so
139
+ // `config.enforcement` is one of the three by the time this line runs. The
140
+ // fallback exists for the direction that matters if that ever loosens — an
141
+ // unknown value must degrade to `warn`, never arm the `strict` refusal.
142
+ const enforcement = ENFORCEMENT_VALUES.has(config.enforcement) ? config.enforcement : 'warn';
143
+
144
+ if (enforcement !== 'off') {
145
+ // Ceiling: 20 named lines. A whole prose section pasted into the block is
146
+ // one defect, not 200, and the strict path below exits immediately after
147
+ // writing — Node's stderr is async on a pipe, so an unbounded list is the
148
+ // write-then-exit truncation class this repo has already paid for once.
149
+ // Revisit if a legitimate config block ever carries >20 broken lines.
150
+ const SHOWN = 20;
151
+ for (const { line, text } of unparsableLines.slice(0, SHOWN)) {
152
+ process.stderr.write(
153
+ `parse-config.mjs: WARN unparsable Session Config line ${line}: ${text}\n`,
154
+ );
155
+ }
156
+ if (unparsableLines.length > SHOWN) {
157
+ process.stderr.write(
158
+ `parse-config.mjs: WARN … and ${unparsableLines.length - SHOWN} more unparsable line(s)\n`,
159
+ );
160
+ }
161
+ process.stderr.write(
162
+ `parse-config.mjs: ${unparsableLines.length} unparsable line(s) in ${configFile} — ` +
163
+ 'those keys fall back to their defaults, which for booleans is `false`.\n',
164
+ );
165
+ }
166
+
167
+ if (enforcement === 'strict') {
168
+ process.stderr.write(
169
+ 'parse-config.mjs: enforcement: strict — refusing to emit config parsed from an ' +
170
+ 'unparsable Session Config block.\n',
171
+ );
172
+ process.exit(1);
173
+ }
174
+ }
175
+
111
176
  // jq -n produces pretty-printed JSON without a trailing newline — match that format
112
177
  const assembledJson = JSON.stringify(config, null, 2);
113
178