session-orchestrator 3.23.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 (102) 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/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. package/skills/wave-executor/wave-loop.md +43 -5
@@ -0,0 +1,233 @@
1
+ import { matchBlockHeader } from './block-header.mjs';
2
+
3
+ /**
4
+ * remote-hosts.mjs — Parser for the `remote-hosts:` Session Config block (#1160).
5
+ *
6
+ * Lets a repo DECLARE ssh-reachable hosts that heavy wave roles (test / ui / perf)
7
+ * may be offloaded to instead of shrinking the wave under local resource pressure.
8
+ * This module only declares — it never probes the network, never dispatches, and
9
+ * never decides. Placement is `scripts/lib/wave-resource-gate.mjs`; the dispatch
10
+ * adapter lives in `scripts/lib/wave-executor/`.
11
+ *
12
+ * Exports:
13
+ * REMOTE_HOST_DEFAULTS — per-host defaults
14
+ * ALLOWED_REMOTE_ROLES — the agent-mapping roles a host may accept
15
+ * _parseRemoteHosts(content) — PURE, no side effects beyond a stderr WARN when a
16
+ * record is dropped. Returns [] when absent/empty.
17
+ *
18
+ * Block shape:
19
+ * remote-hosts:
20
+ * - alias: m5 # required; becomes `-H <alias>` argv
21
+ * roles-allowed: [test, ui, perf] # subset of ALLOWED_REMOTE_ROLES
22
+ * repo-path: ~/Projects/Alice # optional
23
+ * claude-path: ~/.local/bin/claude # optional
24
+ *
25
+ * Modelled on custom-phases.mjs (per-entry SAFE-regex validation, drop-with-warn,
26
+ * column-0 block termination, silent enum fallback). The one shape it adds is the
27
+ * inline `[a, b]` list for `roles-allowed`.
28
+ */
29
+
30
+ /** Per-host defaults. */
31
+ export const REMOTE_HOST_DEFAULTS = Object.freeze({
32
+ 'roles-allowed': ['test', 'ui', 'perf'],
33
+ 'repo-path': null,
34
+ 'claude-path': null,
35
+ });
36
+
37
+ /**
38
+ * The roles a remote host may accept. A deliberate SUBSET of `ALLOWED_ROLES` in
39
+ * scripts/lib/config.mjs — impl / db / security / compliance / docs stay local.
40
+ *
41
+ * NOT the wave-role enum ("Impl-Core", "Quality", …): those are two different
42
+ * enums and conflating them is the documented trap (see resolveApwCap's docstring
43
+ * in wave-resource-gate.mjs). The wave→agent-mapping-role translation is
44
+ * `OFFLOADABLE_WAVE_ROLES` in that same module.
45
+ *
46
+ * @type {readonly string[]}
47
+ */
48
+ export const ALLOWED_REMOTE_ROLES = Object.freeze(['test', 'ui', 'perf']);
49
+
50
+ // A host `alias` is an ssh destination that reaches argv as `-H <alias>`: letters,
51
+ // digits, hyphen, underscore, dot only. No spaces, no shell metacharacters.
52
+ //
53
+ // The first character is anchored separately from the rest because a charset
54
+ // cannot express a POSITION: an alias of `-H` passes any hyphen-inclusive
55
+ // allowlist and then reaches argv as an OPTION token, where the CLI swallows the
56
+ // operand behind it. Interior hyphens (`m5-box`) stay legal.
57
+ const SAFE_NAME_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
58
+
59
+ // `repo-path` / `claude-path` are filesystem paths handed to a remote shell —
60
+ // same allowlist as custom-phases' `review`, with the same leading-hyphen anchor
61
+ // as SAFE_NAME_RE above (`--x` is a flag, not a path).
62
+ const SAFE_PATH_RE = /^[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
63
+
64
+ /**
65
+ * Parse the top-level `remote-hosts:` YAML list block from markdown content.
66
+ *
67
+ * A record missing `alias`, or carrying an unsafe `alias` / `repo-path` /
68
+ * `claude-path`, is DROPPED with a stderr WARN. Unknown entries in
69
+ * `roles-allowed` are filtered out with a WARN; a record whose list is empty
70
+ * after filtering is dropped (it could never be selected anyway).
71
+ *
72
+ * @param {string} content — full CLAUDE.md / AGENTS.md file content
73
+ * @returns {Array<{alias: string, 'roles-allowed': string[], 'repo-path': string|null, 'claude-path': string|null}>}
74
+ */
75
+ export function _parseRemoteHosts(content) {
76
+ const lines = String(content ?? '').split(/\r?\n/);
77
+ let inBlock = false;
78
+ const blockLines = [];
79
+
80
+ for (const rawLine of lines) {
81
+ const line = rawLine.replace(/\r$/, '');
82
+
83
+ if (!inBlock) {
84
+ if (matchBlockHeader(line, 'remote-hosts')) inBlock = true;
85
+ continue;
86
+ }
87
+
88
+ // Block terminates at the first non-indented, non-empty line (next top-level key).
89
+ if (line.length > 0 && !/^\s/.test(line)) break;
90
+
91
+ blockLines.push(line);
92
+ }
93
+
94
+ if (blockLines.length === 0) return [];
95
+
96
+ const records = [];
97
+ /** @type {Record<string, string>|null} */
98
+ let current = null;
99
+
100
+ const flush = () => {
101
+ if (current === null) return;
102
+ const rec = _validateRecord(current);
103
+ if (rec !== null) records.push(rec);
104
+ current = null;
105
+ };
106
+
107
+ for (const rawLine of blockLines) {
108
+ // Strip inline comments + trailing whitespace, preserve leading indent.
109
+ const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
110
+ if (!clean.trim()) continue;
111
+
112
+ const dashMatch = clean.match(/^\s*-\s+(.*)$/);
113
+ if (dashMatch) {
114
+ flush();
115
+ current = {};
116
+ const inlineKv = dashMatch[1].match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
117
+ if (inlineKv) _assignKv(current, inlineKv[1], inlineKv[2]);
118
+ continue;
119
+ }
120
+
121
+ const kvMatch = clean.match(/^\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
122
+ if (kvMatch && current !== null) {
123
+ _assignKv(current, kvMatch[1], kvMatch[2]);
124
+ }
125
+ }
126
+
127
+ flush();
128
+
129
+ return records;
130
+ }
131
+
132
+ /**
133
+ * Assign a raw key/value onto a record being built, stripping surrounding quotes.
134
+ * Unknown keys are silently ignored (additive-friendly).
135
+ *
136
+ * @param {Record<string, string>} record
137
+ * @param {string} key
138
+ * @param {string} rawValue
139
+ */
140
+ function _assignKv(record, key, rawValue) {
141
+ let v = rawValue.trim();
142
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
143
+ else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
144
+
145
+ switch (key) {
146
+ case 'alias':
147
+ case 'roles-allowed':
148
+ case 'repo-path':
149
+ case 'claude-path':
150
+ record[key] = v;
151
+ break;
152
+ default:
153
+ break;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Parse an inline `[a, b]` (or bare `a, b`) list into trimmed non-empty strings.
159
+ *
160
+ * @param {string} raw
161
+ * @returns {string[]}
162
+ */
163
+ function _parseInlineList(raw) {
164
+ const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
165
+ return inner
166
+ .split(',')
167
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
168
+ .filter((s) => s !== '');
169
+ }
170
+
171
+ /**
172
+ * Validate + normalise a raw record.
173
+ *
174
+ * @param {Record<string, string>} raw
175
+ * @returns {{alias: string, 'roles-allowed': string[], 'repo-path': string|null, 'claude-path': string|null}|null}
176
+ */
177
+ function _validateRecord(raw) {
178
+ const alias = (raw.alias ?? '').trim();
179
+ if (alias === '') {
180
+ process.stderr.write('remote-hosts: dropped record missing required field: alias\n');
181
+ return null;
182
+ }
183
+ if (!SAFE_NAME_RE.test(alias)) {
184
+ process.stderr.write(
185
+ `remote-hosts: dropped record with unsafe alias: ${JSON.stringify(alias)}\n`,
186
+ );
187
+ return null;
188
+ }
189
+
190
+ let rolesAllowed = [...REMOTE_HOST_DEFAULTS['roles-allowed']];
191
+ const rawRoles = (raw['roles-allowed'] ?? '').trim();
192
+ if (rawRoles !== '') {
193
+ const declared = _parseInlineList(rawRoles);
194
+ const kept = declared.filter((r) => ALLOWED_REMOTE_ROLES.includes(r));
195
+ const dropped = declared.filter((r) => !ALLOWED_REMOTE_ROLES.includes(r));
196
+ if (dropped.length > 0) {
197
+ process.stderr.write(
198
+ `remote-hosts: host '${alias}' declares unknown role(s) ${dropped.join(', ')} ` +
199
+ `(allowed: ${ALLOWED_REMOTE_ROLES.join(', ')}) — ignored\n`,
200
+ );
201
+ }
202
+ if (kept.length === 0) {
203
+ process.stderr.write(
204
+ `remote-hosts: dropped record '${alias}' — roles-allowed is empty after filtering\n`,
205
+ );
206
+ return null;
207
+ }
208
+ rolesAllowed = kept;
209
+ }
210
+
211
+ const paths = {};
212
+ for (const key of ['repo-path', 'claude-path']) {
213
+ let value = REMOTE_HOST_DEFAULTS[key];
214
+ const rawPath = (raw[key] ?? '').trim();
215
+ if (rawPath !== '' && rawPath !== 'null' && rawPath !== 'none') {
216
+ if (!SAFE_PATH_RE.test(rawPath)) {
217
+ process.stderr.write(
218
+ `remote-hosts: dropped record '${alias}' with shell metacharacter in ${key}: ${JSON.stringify(rawPath)}\n`,
219
+ );
220
+ return null;
221
+ }
222
+ value = rawPath;
223
+ }
224
+ paths[key] = value;
225
+ }
226
+
227
+ return {
228
+ alias,
229
+ 'roles-allowed': rolesAllowed,
230
+ 'repo-path': paths['repo-path'],
231
+ 'claude-path': paths['claude-path'],
232
+ };
233
+ }
@@ -61,6 +61,7 @@ import { _parseAutoDream } from './config/auto-dream.mjs';
61
61
  import { _parseStateMdLock } from './config/state-md-lock.mjs';
62
62
  import { _parseHandoverGate } from './config/handover-gate.mjs';
63
63
  import { _parseIssueBudget } from './config/issue-budget.mjs';
64
+ import { _parseHealthEndpoints, _parseEcosystemHealthBlockEnabled } from './config/health-endpoints.mjs';
64
65
  import { _parseBrokenWindow } from './config/broken-window.mjs';
65
66
  import { _parseSlopcheck } from './config/slopcheck.mjs';
66
67
  import { _parseDiscoveryValidator } from './config/discovery-validator.mjs';
@@ -71,6 +72,7 @@ import { _parseEval } from './config/eval.mjs';
71
72
  import { _parseMemory } from './config/memory.mjs';
72
73
  import { _parseReconcile } from './config/reconcile.mjs';
73
74
  import { _parseCustomPhases } from './config/custom-phases.mjs';
75
+ import { _parseRemoteHosts } from './config/remote-hosts.mjs';
74
76
  import { _parseEvolve, _parseEvolveDecay } from './config/evolve.mjs';
75
77
  import { _parseSkillEvolution } from './config/skill-evolution.mjs';
76
78
  import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
@@ -198,7 +200,14 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
198
200
 
199
201
  // Boolean fields
200
202
  const persistence = _coerceBoolean(kv, 'persistence', true);
201
- const ecosystemHealth = _coerceBoolean(kv, 'ecosystem-health', false);
203
+ // ecosystem-health has TWO spellings and only the scalar one reaches `kv`:
204
+ // the wizard writes a valueless `ecosystem-health:` header opening a block,
205
+ // which carries no `key: value` pair and is therefore invisible to the KV
206
+ // map (#1174). Scalar wins when present; otherwise the block form decides.
207
+ const ecosystemHealth =
208
+ _getVal(kv, 'ecosystem-health', undefined) !== undefined
209
+ ? _coerceBoolean(kv, 'ecosystem-health', false)
210
+ : (_parseEcosystemHealthBlockEnabled(mdContent) ?? false);
202
211
  const discoveryOnClose = _coerceBoolean(kv, 'discovery-on-close', false);
203
212
  const reasoningOutput = _coerceBoolean(kv, 'reasoning-output', false);
204
213
  const groundingCheck = _coerceBoolean(kv, 'grounding-check', true);
@@ -214,7 +223,6 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
214
223
  const ssotFiles = _coerceList(kv, 'ssot-files', undefined);
215
224
  const discoveryProbes = _coerceList(kv, 'discovery-probes', '[all]');
216
225
  const discoveryExcludePaths = _coerceList(kv, 'discovery-exclude-paths', '[]');
217
- const healthEndpoints = _coerceList(kv, 'health-endpoints', undefined);
218
226
  const worktreeExclude = _coerceList(
219
227
  kv,
220
228
  'worktree-exclude',
@@ -230,6 +238,11 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
230
238
  // follow-up (see docs/session-config-reference.md). This wires the parser + value only.
231
239
  const worktreeCleanup = _coerceEnum(kv, 'worktree-cleanup', 'default', ['default', 'aggressive']);
232
240
 
241
+ // remote-hosts: opt-in ssh-reachable hosts heavy roles may be offloaded to (#1160).
242
+ // Parsed HERE, before agent-mapping, because the `ssh:<alias>` channel below
243
+ // validates its target against the declared aliases. Defaults to [].
244
+ const remoteHosts = _parseRemoteHosts(mdContent);
245
+
233
246
  // Object fields
234
247
  const agentMapping = _coerceObject(kv, 'agent-mapping');
235
248
  if (agentMapping !== null) {
@@ -240,10 +253,11 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
240
253
  // dispatched natively, exactly as before.
241
254
  // impl: code-implementer → native Agent dispatch (unchanged)
242
255
  // impl: cursor:composer-2.5 → foreign model over the Cursor channel
256
+ // test: ssh:m5 → same Claude, another host (#1160)
243
257
  // An unknown prefix is a typo or a channel this build cannot route, and
244
258
  // must fail loudly: silently accepting it would dispatch to an agent that
245
259
  // does not exist, which surfaces only as an empty wave much later.
246
- const KNOWN_CHANNELS = ['cursor', 'session-orchestrator'];
260
+ const KNOWN_CHANNELS = ['cursor', 'session-orchestrator', 'ssh'];
247
261
  // Accumulate every defect, then report once. A config with two bad entries
248
262
  // must name BOTH — throwing on the first one makes the operator re-run the
249
263
  // parse per defect, and each re-run hides the ones behind it.
@@ -272,6 +286,15 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
272
286
  `role '${k}' has channel '${channel}' but no target in value '${v}' ` +
273
287
  `(expected '${channel}:<model-or-agent>')`
274
288
  );
289
+ } else if (channel === 'ssh' && !remoteHosts.some((h) => h.alias === target)) {
290
+ // An ssh target is a host ALIAS, and the only place aliases are declared
291
+ // is the remote-hosts: block. An undeclared alias would reach argv as
292
+ // `-H <target>` and fail at connect time, one wave too late.
293
+ const declared = remoteHosts.map((h) => h.alias).join(', ') || 'none';
294
+ valueProblems.push(
295
+ `role '${k}' names ssh host '${target}' which is not declared in remote-hosts: ` +
296
+ `(declared: ${declared})`
297
+ );
275
298
  }
276
299
  }
277
300
  }
@@ -442,6 +465,10 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
442
465
  const evolveDecay = _parseEvolveDecay(mdContent);
443
466
 
444
467
  // persona-gate-wave: opt-in mid-wave persona-panel hook (#458). Returns null when absent.
468
+ // health-endpoints: content-scoped (NOT _coerceList, which bails to null on
469
+ // any '{' and cannot see the wizard's nested block form at all) — #1174.
470
+ const healthEndpoints = _parseHealthEndpoints(mdContent);
471
+
445
472
  const personaGateWave = _parsePersonaGateWave(mdContent);
446
473
  if (personaGateWave !== null && personaGateWave.enabled === true && personaGateWave.mode === 'off') {
447
474
  process.stderr.write(
@@ -538,6 +565,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
538
565
  'wave-reviewers': waveReviewers,
539
566
  'persona-gate-wave': personaGateWave,
540
567
  'custom-phases': customPhases,
568
+ 'remote-hosts': remoteHosts,
541
569
  'evolve.extra-sources': evolveExtraSources,
542
570
  'evolve.decay': evolveDecay,
543
571
  };
@@ -29,12 +29,12 @@
29
29
  */
30
30
 
31
31
  import { readdirSync, existsSync } from 'node:fs';
32
- import os from 'node:os';
33
32
  import path from 'node:path';
34
33
 
35
34
  import { getConfinementRoot, getCrossRepoProjects } from '../config/cross-repo.mjs';
36
35
  import { validatePathInsideProject } from '../path-utils.mjs';
37
36
  import { readLock, isLockLive } from '../session-lock.mjs';
37
+ import { expandTilde } from '../common.mjs';
38
38
 
39
39
  /**
40
40
  * @typedef {Object} Candidate
@@ -97,21 +97,6 @@ function shouldDescendInto(name) {
97
97
  return true;
98
98
  }
99
99
 
100
- /**
101
- * Expand a leading `~` to the current user's home directory. Mirrors the helper
102
- * in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
103
- * normalise config-declared cross-repo paths that may begin with `~/`.
104
- *
105
- * @param {string} p
106
- * @returns {string}
107
- */
108
- function expandHome(p) {
109
- if (typeof p !== 'string' || p.length === 0) return p;
110
- if (p === '~') return os.homedir();
111
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
112
- return p;
113
- }
114
-
115
100
  /**
116
101
  * Derive free/busy status for a single repo from its session.lock lease.
117
102
  * Mirrors board-writer.mjs collectRows semantics exactly:
@@ -322,7 +307,7 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
322
307
  if (Array.isArray(declared)) {
323
308
  for (const raw of declared) {
324
309
  if (typeof raw !== 'string' || raw.length === 0) continue;
325
- const expanded = expandHome(raw);
310
+ const expanded = expandTilde(raw);
326
311
  const abs = path.resolve(expanded);
327
312
  // Confinement-filter against the same root as the FS scan.
328
313
  const guard = validatePathInsideProjectFn(abs, root);
@@ -12,6 +12,54 @@
12
12
  * Pure functions, no filesystem access — safe to import anywhere.
13
13
  */
14
14
 
15
+ /**
16
+ * Current events.jsonl record schema version (#1177). Mirrors
17
+ * `subagents-schema.mjs § CURRENT_SCHEMA_VERSION`.
18
+ *
19
+ * Records written before #1177 carry NO `schema_version` key at all; an absent
20
+ * key therefore reads as "pre-versioned", never as version 0. Stamping is
21
+ * strictly additive — see `stampEventSchemaVersion()`.
22
+ */
23
+ export const CURRENT_SCHEMA_VERSION = 1;
24
+
25
+ /**
26
+ * Thrown by `emitEvent()` when a record fails `validateEventRecord()`.
27
+ *
28
+ * Carries the individual validator messages so a CLI/hook caller can surface
29
+ * them without re-running the validator.
30
+ */
31
+ export class EventValidationError extends Error {
32
+ /**
33
+ * @param {string} message — human-readable summary.
34
+ * @param {string[]} [errors=[]] — the validator's individual error strings.
35
+ * @param {string} [eventType] — the offending event name, when known.
36
+ */
37
+ constructor(message, errors = [], eventType) {
38
+ super(message);
39
+ this.name = 'EventValidationError';
40
+ this.errors = errors;
41
+ this.eventType = eventType;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Return a shallow copy of `record` with `schema_version` stamped to
47
+ * `CURRENT_SCHEMA_VERSION` — but ONLY when the field is absent
48
+ * (`undefined`/`null`). An existing value is never overwritten, so a caller
49
+ * (or a migration re-writing historical records) keeps authority over its own
50
+ * version field.
51
+ *
52
+ * @param {object} record
53
+ * @returns {object} shallow copy, `schema_version` guaranteed present.
54
+ */
55
+ export function stampEventSchemaVersion(record) {
56
+ const out = { ...record };
57
+ if (out.schema_version === undefined || out.schema_version === null) {
58
+ out.schema_version = CURRENT_SCHEMA_VERSION;
59
+ }
60
+ return out;
61
+ }
62
+
15
63
  /** ISO-8601 UTC timestamp with trailing Z (e.g. 2026-05-28T14:35:13.123Z). */
16
64
  const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
17
65