session-orchestrator 3.22.0 → 3.23.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 (268) 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/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -9,9 +9,13 @@
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}.
12
16
  */
13
17
 
14
- import { readFileSync, unlinkSync } from 'node:fs';
18
+ import { readdirSync, readFileSync, unlinkSync } from 'node:fs';
15
19
  import { resolve } from 'node:path';
16
20
  import { fileURLToPath } from 'node:url';
17
21
  import { writeJsonAtomicSync } from './lib/io.mjs';
@@ -27,7 +31,12 @@ Required:
27
31
  Positive wave number used in filescopes/wave-N/.
28
32
 
29
33
  Options:
30
- --json Emit {ok, aggregatePath, perAgentPaths} to stdout.
34
+ --session <id> This session's id (session_id or its semantic twin). Used
35
+ ONLY to prove ownership before an orphaned per-agent
36
+ declaration of the same wave is removed (#1103). Without
37
+ it, orphans are reported and RETAINED, never deleted.
38
+ --json Emit {ok, aggregatePath, perAgentPaths, removedOrphans,
39
+ retainedOrphans} to stdout.
31
40
  -h, --help Show this help and exit 0.
32
41
 
33
42
  Output:
@@ -43,6 +52,12 @@ Writes:
43
52
  <state-dir>/filescopes/wave-N/<id>.json Bare string[] for each record
44
53
  <state-dir>/filescopes/wave-N.scopes.json Aggregate [{id, files}, ...]
45
54
 
55
+ Removes (only with a proven owner — see --session):
56
+ <state-dir>/filescopes/wave-N/<stale-id>.json Per-agent declarations of this
57
+ wave whose id is absent from the new record array. An
58
+ orphan that cannot be proven owned is named on stderr and
59
+ LEFT IN PLACE; that is a WARN, never a failure.
60
+
46
61
  Exit codes:
47
62
  0 All declaration files and the aggregate sidecar were written.
48
63
  1 Usage or input validation error; no write was attempted.
@@ -55,11 +70,12 @@ class WriteError extends Error {}
55
70
 
56
71
  /**
57
72
  * @param {string[]} argv
58
- * @returns {{ stateDir: string, wave: number, json: boolean, help: boolean }}
73
+ * @returns {{ stateDir: string, wave: number, session: string|null, json: boolean, help: boolean }}
59
74
  */
60
75
  export function parseCliArgs(argv) {
61
76
  let stateDir;
62
77
  let waveRaw;
78
+ let session = null;
63
79
  let json = false;
64
80
  let help = false;
65
81
 
@@ -73,7 +89,7 @@ export function parseCliArgs(argv) {
73
89
  help = true;
74
90
  continue;
75
91
  }
76
- if (arg === '--state-dir' || arg === '--wave') {
92
+ if (arg === '--state-dir' || arg === '--wave' || arg === '--session') {
77
93
  const value = argv[index + 1];
78
94
  if (value === undefined || value.startsWith('--')) {
79
95
  throw new InputError(`${arg} requires a value`);
@@ -81,6 +97,12 @@ export function parseCliArgs(argv) {
81
97
  if (arg === '--state-dir') {
82
98
  if (stateDir !== undefined) throw new InputError('--state-dir may be specified only once');
83
99
  stateDir = value;
100
+ } else if (arg === '--session') {
101
+ if (session !== null) throw new InputError('--session may be specified only once');
102
+ // An empty or whitespace-only id proves nothing and must not be read as
103
+ // an owner: it would make every orphan deletable by any caller.
104
+ if (value.trim().length === 0) throw new InputError('--session must be a non-empty id');
105
+ session = value.trim();
84
106
  } else {
85
107
  if (waveRaw !== undefined) throw new InputError('--wave may be specified only once');
86
108
  waveRaw = value;
@@ -91,7 +113,7 @@ export function parseCliArgs(argv) {
91
113
  throw new InputError(`unknown argument: ${arg}`);
92
114
  }
93
115
 
94
- if (help) return { stateDir: '', wave: 0, json, help: true };
116
+ if (help) return { stateDir: '', wave: 0, session, json, help: true };
95
117
  if (stateDir === undefined) throw new InputError('--state-dir is required');
96
118
  if (waveRaw === undefined) throw new InputError('--wave is required');
97
119
  if (stateDir.length === 0 || /[\0\r\n]/.test(stateDir)) {
@@ -103,7 +125,7 @@ export function parseCliArgs(argv) {
103
125
 
104
126
  const wave = Number(waveRaw);
105
127
  if (!Number.isSafeInteger(wave)) throw new InputError('--wave must be a safe positive integer');
106
- return { stateDir, wave, json, help: false };
128
+ return { stateDir, wave, session, json, help: false };
107
129
  }
108
130
 
109
131
  /**
@@ -183,6 +205,141 @@ export function validateScopeRecords(value) {
183
205
  return value;
184
206
  }
185
207
 
208
+
209
+ /**
210
+ * Read the session ids the sibling manifest `<state-dir>/wave-scope.json`
211
+ * declares for this state directory (#1123 wrote both `session` — the raw
212
+ * `session_id` — and its human-readable twin `semantic_session`).
213
+ *
214
+ * A caller may legitimately hold either spelling, so BOTH are returned and a
215
+ * match against either proves ownership. Any failure to read or parse the
216
+ * manifest returns an empty list, which the caller must treat as "ownership NOT
217
+ * established" — never as "no owner, therefore mine".
218
+ *
219
+ * @param {string} stateDir
220
+ * @param {typeof readFileSync} [readFile]
221
+ * @returns {string[]}
222
+ */
223
+ export function manifestSessionIds(stateDir, readFile = readFileSync) {
224
+ let manifest;
225
+ try {
226
+ manifest = JSON.parse(readFile(resolve(stateDir, 'wave-scope.json'), 'utf8'));
227
+ } catch {
228
+ return [];
229
+ }
230
+ if (!isRecord(manifest)) return [];
231
+ return ['session', 'semantic_session']
232
+ .map((key) => manifest[key])
233
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
234
+ .map((value) => value.trim());
235
+ }
236
+
237
+ /**
238
+ * Reconcile per-agent declarations left behind by an earlier materialization of
239
+ * the SAME wave (#1103).
240
+ *
241
+ * The write loop is a pure upsert over the new id set, so a file whose id was
242
+ * dropped from the plan survives: it is in no aggregate — `--assert-disjoint`
243
+ * and `--union` cannot see it — while every by-id consumer (FILE-SCOPE
244
+ * injection, the Learnings-Index, `--assert-subset`) still reads it. A live
245
+ * scope claim with zero aggregate coverage.
246
+ *
247
+ * ## Why this is NOT a directory wipe
248
+ *
249
+ * The wave number is not a session-unique key: two sessions sharing one working
250
+ * copy both call their first wave `wave-1` and both resolve to
251
+ * `<state-dir>/filescopes/wave-1/`. Clearing the directory would convert a
252
+ * stale-read bug into cross-session data loss — the class
253
+ * `.claude/rules/parallel-sessions.md` PSA-003 forbids by name ("Did I create
254
+ * this file? If not, it is not mine to touch"). So removal needs a two-part
255
+ * test, and only the second part is about the file:
256
+ *
257
+ * 1. the id is absent from the new record array (it is an orphan), AND
258
+ * 2. this invocation can PROVE it owns the state directory — `sessionId`
259
+ * matches an id the sibling manifest declares.
260
+ *
261
+ * Failing (2) is not an error and never blocks: the orphan is returned in
262
+ * `retained` WITH its reason so the caller can name the file. A silent skip is
263
+ * the one outcome forbidden here, because it is byte-identical to a clean run.
264
+ *
265
+ * ## Named ceiling (BV-004)
266
+ *
267
+ * Bounded by WHEN materialization runs, not by what it inspects: a session that
268
+ * re-materializes wave N *while its own agents are still in flight* would delete
269
+ * the scope files those agents are reading. That is safe today only because
270
+ * `skills/wave-executor/wave-loop.md` § Scope Manifest 3.2 places
271
+ * (re-)materialization strictly PRE-dispatch, so no reader exists yet. REVISIT
272
+ * TRIGGER: any caller that materializes a wave after its dispatch has begun — a
273
+ * mid-wave scope amendment, a repair pass reusing the same wave number, or a
274
+ * dispatch loop that re-runs the materializer per agent. Ownership does not
275
+ * protect against that case; the ordering does.
276
+ *
277
+ * @param {object} params
278
+ * @param {string} params.scopeDir `<state-dir>/filescopes/wave-N`
279
+ * @param {string[]} params.keepIds ids present in the new record array
280
+ * @param {string[]} params.ownerIds session ids the manifest declares
281
+ * @param {string|null} params.sessionId this invocation's session id
282
+ * @param {typeof readdirSync} [params.readDir]
283
+ * @param {typeof unlinkSync} [params.removeFile]
284
+ * @returns {{removed: string[], retained: Array<{file: string, reason: string}>}}
285
+ */
286
+ export function reconcileOrphans({
287
+ scopeDir,
288
+ keepIds,
289
+ ownerIds,
290
+ sessionId,
291
+ readDir = readdirSync,
292
+ removeFile = unlinkSync,
293
+ }) {
294
+ let entries;
295
+ try {
296
+ entries = readDir(scopeDir, { withFileTypes: true });
297
+ } catch {
298
+ // No directory yet (first materialization) or unreadable — nothing to
299
+ // reconcile. Not a failure: the aggregate is already published.
300
+ return { removed: [], retained: [] };
301
+ }
302
+
303
+ // Case-INSENSITIVE keep set. validateScopeRecords already rejects two ids that
304
+ // differ only in case within one input, so this cannot hide a real orphan —
305
+ // but on a case-insensitive filesystem `A2.json` and `a2.json` are ONE file,
306
+ // and deleting the "orphan" would delete the declaration just written.
307
+ const keep = new Set(keepIds.map((id) => `${id}.json`.toLowerCase()));
308
+ const orphans = [];
309
+ for (const entry of entries) {
310
+ if (typeof entry?.isFile === 'function' && !entry.isFile()) continue;
311
+ const name = typeof entry === 'string' ? entry : entry?.name;
312
+ if (typeof name !== 'string' || !name.endsWith('.json')) continue;
313
+ if (keep.has(name.toLowerCase())) continue;
314
+ orphans.push(name);
315
+ }
316
+ if (orphans.length === 0) return { removed: [], retained: [] };
317
+
318
+ const proven = typeof sessionId === 'string' && sessionId.length > 0 && ownerIds.includes(sessionId);
319
+ if (!proven) {
320
+ const reason = sessionId === null || sessionId === undefined
321
+ ? 'no --session given, so this state directory has no provable owner'
322
+ : ownerIds.length === 0
323
+ ? 'wave-scope.json declares no session, so ownership cannot be established'
324
+ : `wave-scope.json is owned by a different session (${ownerIds.join(' / ')})`;
325
+ return { removed: [], retained: orphans.map((file) => ({ file, reason })) };
326
+ }
327
+
328
+ const removed = [];
329
+ const retained = [];
330
+ for (const file of orphans) {
331
+ try {
332
+ removeFile(resolve(scopeDir, file));
333
+ removed.push(file);
334
+ } catch (error) {
335
+ // A failed unlink leaves a live orphan behind — report it, never throw:
336
+ // the aggregate is already published and the materialization succeeded.
337
+ retained.push({ file, reason: `could not remove: ${error instanceof Error ? error.message : String(error)}` });
338
+ }
339
+ }
340
+ return { removed, retained };
341
+ }
342
+
186
343
  /**
187
344
  * Materialize validated declarations in their required write order.
188
345
  *
@@ -193,10 +350,22 @@ export function validateScopeRecords(value) {
193
350
  * has succeeded.
194
351
  *
195
352
  * @param {Array<{id: string, files: string[]}>} records
196
- * @param {{ stateDir: string, wave: number, writeJson?: typeof writeJsonAtomicSync }} options
197
- * @returns {{ aggregatePath: string, perAgentPaths: string[] }}
353
+ * @param {{ stateDir: string, wave: number, session?: string|null,
354
+ * writeJson?: typeof writeJsonAtomicSync,
355
+ * readDir?: typeof readdirSync, removeFile?: typeof unlinkSync,
356
+ * readFile?: typeof readFileSync }} options
357
+ * @returns {{ aggregatePath: string, perAgentPaths: string[],
358
+ * removedOrphans: string[], retainedOrphans: Array<{file: string, reason: string}> }}
198
359
  */
199
- export function materializeWaveScope(records, { stateDir, wave, writeJson = writeJsonAtomicSync }) {
360
+ export function materializeWaveScope(records, {
361
+ stateDir,
362
+ wave,
363
+ session = null,
364
+ writeJson = writeJsonAtomicSync,
365
+ readDir = readdirSync,
366
+ removeFile = unlinkSync,
367
+ readFile = readFileSync,
368
+ }) {
200
369
  const scopeDir = resolve(stateDir, 'filescopes', `wave-${wave}`);
201
370
  const aggregatePath = resolve(stateDir, 'filescopes', `wave-${wave}.scopes.json`);
202
371
  const perAgentPaths = records.map(({ id }) => resolve(scopeDir, `${id}.json`));
@@ -220,7 +389,21 @@ export function materializeWaveScope(records, { stateDir, wave, writeJson = writ
220
389
  if (!aggregateResult?.ok) {
221
390
  throw new WriteError(`cannot write aggregate declaration ${aggregatePath}: ${aggregateResult?.error ?? 'unknown write failure'}`);
222
391
  }
223
- return { aggregatePath, perAgentPaths };
392
+
393
+ // #1103 — strictly AFTER the aggregate write. The aggregate is this command's
394
+ // publication marker (see the write-order note above), so reconciling before
395
+ // it would remove a live declaration while the run could still fail and leave
396
+ // no aggregate at all — deleting coverage that nothing replaced.
397
+ const { removed, retained } = reconcileOrphans({
398
+ scopeDir,
399
+ keepIds: records.map(({ id }) => id),
400
+ ownerIds: manifestSessionIds(stateDir, readFile),
401
+ sessionId: session,
402
+ readDir,
403
+ removeFile,
404
+ });
405
+
406
+ return { aggregatePath, perAgentPaths, removedOrphans: removed, retainedOrphans: retained };
224
407
  }
225
408
 
226
409
  /**
@@ -256,10 +439,24 @@ export function main() {
256
439
  return;
257
440
  }
258
441
  const records = validateScopeRecords(readStdinJson());
259
- const { aggregatePath, perAgentPaths } = materializeWaveScope(records, args);
442
+ const { aggregatePath, perAgentPaths, removedOrphans, retainedOrphans } =
443
+ materializeWaveScope(records, args);
444
+
445
+ // stderr carries ONLY the anomalous cases. Measured constraint, not taste:
446
+ // the corpus pins byte-empty stderr on this command's success path
447
+ // (tests/scripts/materialize-wave-scope.test.mjs and
448
+ // tests/integration/wave-scope-producer.test.mjs), and a wave with no
449
+ // orphans IS the success path. Both lists always reach --json.
450
+ for (const file of removedOrphans) {
451
+ process.stderr.write(`materialize-wave-scope: removed orphaned declaration ${file} (id absent from this wave's records)\n`);
452
+ }
453
+ for (const { file, reason } of retainedOrphans) {
454
+ process.stderr.write(`materialize-wave-scope: WARN orphaned declaration ${file} RETAINED — ${reason}\n`);
455
+ }
456
+
260
457
  process.stdout.write(
261
458
  args.json
262
- ? `${JSON.stringify({ ok: true, aggregatePath, perAgentPaths })}\n`
459
+ ? `${JSON.stringify({ ok: true, aggregatePath, perAgentPaths, removedOrphans, retainedOrphans })}\n`
263
460
  : `${aggregatePath}\n`,
264
461
  );
265
462
  } 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
 
@@ -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
 
@@ -141,8 +141,15 @@ if [ -f "$CLAUDE_SETTINGS" ]; then
141
141
  fi
142
142
  fi
143
143
 
144
- # Count enabled plugins
145
- plugin_count=$(grep -c ': true' "$CLAUDE_SETTINGS" 2>/dev/null || echo "0")
144
+ # Count enabled plugins.
145
+ # `|| true` rather than `|| echo "0"`: grep -c ALREADY prints 0 on no-match and
146
+ # still exits 1, so an echo fallback appends a second line — the capture becomes
147
+ # "0\n0", which every numeric test downstream rejects (bash-harness-pitfalls.md §1).
148
+ plugin_count=$(grep -c ': true' "$CLAUDE_SETTINGS" 2>/dev/null || true)
149
+ # NOTE: after the :-0 default an unreadable file prints the same as "zero
150
+ # plugins" — accepted, the only consumer is the informational echo below.
151
+ # The #1077 fix here is the removed double-"0" line, not a failure sentinel.
152
+ plugin_count=${plugin_count:-0}
146
153
  echo " Enabled plugins: $plugin_count"
147
154
  fi
148
155
  echo ""
@@ -244,6 +244,9 @@ if (runCheck('check-pi-package.mjs') !== 0) checkFailed = 1;
244
244
  process.stdout.write('\n');
245
245
  if (runCheck('check-pi-prompts.mjs') !== 0) checkFailed = 1;
246
246
 
247
+ process.stdout.write('\n');
248
+ if (runCheck('check-cursor-adapter.mjs') !== 0) checkFailed = 1;
249
+
247
250
  process.stdout.write('\n');
248
251
  if (runCheck('check-session-plan-routing.mjs') !== 0) checkFailed = 1;
249
252
 
@@ -252,6 +252,72 @@ function validateRequired(obj, errors) {
252
252
  }
253
253
  }
254
254
 
255
+ /**
256
+ * Shape-check ONE optional identifier field: present ⇒ a non-empty string.
257
+ *
258
+ * Returns whether the key was PRESENT at all (regardless of validity), so the
259
+ * caller can distinguish "absent" from "present but malformed" — the two need
260
+ * different treatment and only the first is a warning.
261
+ *
262
+ * An EMPTY string is an error rather than a second flavour of absent, for the
263
+ * reason `sessionAttribution()` (scripts/lib/events.mjs) already states about
264
+ * omitting the key: an empty id satisfies a truthiness check while attributing
265
+ * to nothing. A reader comparing `manifest.session === <own session id>` would
266
+ * then treat the manifest as FOREIGN (ignore it) where the writer meant
267
+ * UNBOUND (enforce it) — the two dispositions are opposites, so the ambiguity
268
+ * is not cosmetic. Absence is the only honest encoding of "not session-bound".
269
+ *
270
+ * @param {Record<string, unknown>} obj
271
+ * @param {string} key
272
+ * @param {string[]} errors
273
+ * @returns {boolean} true when the key is present (valid or not)
274
+ */
275
+ function validateOptionalSessionId(obj, key, errors) {
276
+ if (!(key in obj) || obj[key] === undefined) return false;
277
+ const value = obj[key];
278
+ const t = value === null ? 'null' : typeof value;
279
+ if (t !== 'string') {
280
+ errors.push(`${key} must be a non-empty string, got type: ${t}`);
281
+ return true;
282
+ }
283
+ if (/** @type {string} */ (value).length === 0) {
284
+ errors.push(
285
+ `${key} must be a non-empty string, got: "" — an empty id attributes to nothing; omit the key entirely to declare the manifest unbound`,
286
+ );
287
+ }
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * Validate the OPTIONAL session binding (#1123): `session` (the raw
293
+ * `session_id` of the session that WROTE this manifest) and its human-readable
294
+ * twin `semantic_session`. Both come from one `sessionAttribution(repoRoot)`
295
+ * call — see `skills/wave-executor/wave-loop.md` § Scope Manifest.
296
+ *
297
+ * Deliberately NOT part of {@link validateRequired}, and that is a compatibility
298
+ * constraint rather than a preference: `wave-scope.json` is a shared
299
+ * working-copy artefact, every manifest written before #1123 lacks the field,
300
+ * and the pre-union skeleton of § Scope Manifest 3.3 is fed through this very
301
+ * validator before the union exists. Requiring it would reject manifests the
302
+ * documented procedure itself produces. The absent case therefore WARNS — the
303
+ * flip to an error belongs to a later release, once no legacy writer remains.
304
+ *
305
+ * @param {Record<string, unknown>} obj
306
+ * @param {string[]} errors
307
+ * @param {string[]} warnings
308
+ */
309
+ function validateSession(obj, errors, warnings) {
310
+ const present = validateOptionalSessionId(obj, 'session', errors);
311
+ validateOptionalSessionId(obj, 'semantic_session', errors);
312
+ if (!present) {
313
+ warnings.push(
314
+ 'no session field — manifest is not session-bound (legacy, #1123), so every session sharing this ' +
315
+ 'working copy is enforced against it. The writer derives it from sessionAttribution() — see ' +
316
+ 'skills/wave-executor/wave-loop.md § Scope Manifest.',
317
+ );
318
+ }
319
+ }
320
+
255
321
  /**
256
322
  * Literal filesystem-root forms — POSIX "/" and the Windows equivalents "\"
257
323
  * and a bare drive root ("C:\", "C:\\", ...). Checked independently of
@@ -683,6 +749,7 @@ function validate(
683
749
  const warnings = [];
684
750
 
685
751
  validateRequired(obj, errors);
752
+ validateSession(obj, errors, warnings);
686
753
  validateAllowedPaths(obj, errors, warnings);
687
754
  validateBlockedCommands(obj, errors);
688
755
  validateGates(obj, errors);