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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- 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
|
+
}
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|