@sechroom/cli 2026.7.11 → 2026.7.12-rc.de836e14

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 (3) hide show
  1. package/README.md +43 -19
  2. package/dist/index.js +1339 -566
  3. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync12 } from "fs";
4
+ import { readFileSync as readFileSync13 } from "fs";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/auth.ts
@@ -439,6 +439,9 @@ var quiet = false;
439
439
  function setQuiet(q) {
440
440
  quiet = q;
441
441
  }
442
+ function isQuiet() {
443
+ return quiet;
444
+ }
442
445
  function colorOn() {
443
446
  return !quiet && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0" && Boolean(process.stdout.isTTY);
444
447
  }
@@ -615,6 +618,22 @@ function emitAction(summary, data, json) {
615
618
  process.stdout.write(`${ok("\u2713")} ${summary}
616
619
  `);
617
620
  }
621
+ var GOVERNANCE_QUEUED_PROBLEM_TYPE = "https://sechroom.dev/problems/governance-review-queued";
622
+ function isGovernanceQueued(body) {
623
+ return typeof body === "object" && body !== null && "type" in body && body.type === GOVERNANCE_QUEUED_PROBLEM_TYPE;
624
+ }
625
+ function formatQueuedForApproval(body, json) {
626
+ if (json) return JSON.stringify(body) + "\n";
627
+ const b = body ?? {};
628
+ const detail = typeof b.detail === "string" ? b.detail : void 0;
629
+ const requestId = typeof b.requestId === "string" ? b.requestId : void 0;
630
+ const message = detail ?? "This change requires operator approval before it takes effect.";
631
+ let out = `${warn("\u29D7")} Pending approval \u2014 ${message}
632
+ `;
633
+ if (requestId) out += ` ${style.dim(`request: ${requestId}`)}
634
+ `;
635
+ return out;
636
+ }
618
637
  async function runApi(label, fn) {
619
638
  const s = spinner(label);
620
639
  let res;
@@ -624,6 +643,12 @@ async function runApi(label, fn) {
624
643
  s.fail();
625
644
  fail(err2);
626
645
  }
646
+ const queuedBody = res.data ?? res.error;
647
+ if (res.response?.status === 202 && isGovernanceQueued(queuedBody)) {
648
+ s.stop();
649
+ process.stdout.write(formatQueuedForApproval(queuedBody, isQuiet()));
650
+ process.exit(0);
651
+ }
627
652
  const httpFailed = res.response !== void 0 && !res.response.ok;
628
653
  if (res.error !== void 0 && res.error !== null || httpFailed) {
629
654
  s.fail();
@@ -632,9 +657,14 @@ async function runApi(label, fn) {
632
657
  s.succeed();
633
658
  return res.data;
634
659
  }
635
- function fail(error) {
660
+ function formatFailureMessage(error) {
636
661
  let msg;
637
- if (typeof error === "object" && error !== null && "title" in error) {
662
+ if (error instanceof Error) {
663
+ msg = error.message || error.name;
664
+ if (error.cause instanceof Error && error.cause.message) {
665
+ msg += `: ${error.cause.message}`;
666
+ }
667
+ } else if (typeof error === "object" && error !== null && "title" in error) {
638
668
  const problem = error;
639
669
  msg = String(problem.title);
640
670
  if (problem.errors && typeof problem.errors === "object") {
@@ -649,6 +679,10 @@ ${detail.join("\n")}`;
649
679
  } else {
650
680
  msg = String(error);
651
681
  }
682
+ return msg;
683
+ }
684
+ function fail(error) {
685
+ const msg = formatFailureMessage(error);
652
686
  process.stderr.write(`error: ${msg}
653
687
  `);
654
688
  process.exit(1);
@@ -1076,14 +1110,23 @@ function bodyOf(row) {
1076
1110
  const m = row?.item ?? row;
1077
1111
  return m?.text ?? m?.Text ?? "";
1078
1112
  }
1113
+ function idOf(row) {
1114
+ const m = row?.item ?? row;
1115
+ return String(m?.id ?? m?.memoryId ?? "unknown");
1116
+ }
1079
1117
  function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
1080
1118
  const out = /* @__PURE__ */ new Map();
1081
1119
  for (const row of rows ?? []) {
1082
1120
  const tags = tagsOf(row);
1083
1121
  if (!tags.includes(roleTag)) continue;
1084
- if (tagValue(tags, "target:") !== surface) continue;
1122
+ if (!tags.includes(`target:${surface}`)) continue;
1085
1123
  const name = tagValue(tags, namePrefix);
1086
1124
  if (!name) continue;
1125
+ if (out.has(name)) {
1126
+ throw new Error(
1127
+ `Ambiguous ${source} ${roleTag} '${name}' for target:${surface}: ${idOf(row)} duplicates another eligible component. Resolve the duplicate before installing.`
1128
+ );
1129
+ }
1087
1130
  out.set(name, { name, body: bodyOf(row), source });
1088
1131
  }
1089
1132
  return out;
@@ -1106,25 +1149,41 @@ function resolveReferences(systemRows, personalRows, surface) {
1106
1149
  }
1107
1150
 
1108
1151
  // src/setup/skill-resolution-io.ts
1109
- var AGENT_TARGET = { "claude-code": "claude-agent" };
1152
+ var AGENT_TARGET = {
1153
+ "claude-code": "claude-agent",
1154
+ "gpt-codex": "gpt-codex-agent"
1155
+ };
1110
1156
  function agentTargetFor(surface) {
1111
1157
  return AGENT_TARGET[surface] ?? `${surface}-agent`;
1112
1158
  }
1113
1159
  async function fetchFeedRows(cfg, workspaceId) {
1114
- try {
1115
- const client = await makeClient(cfg);
1116
- const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1160
+ const client = await makeClient(cfg);
1161
+ const rows = [];
1162
+ let cursor;
1163
+ do {
1164
+ const { data, error, response } = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1117
1165
  params: {
1118
1166
  path: { workspaceId },
1119
1167
  // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
1120
1168
  // includeText: the feed omits bodies by default, we need them for SKILL.md.
1121
- query: { limit: 200, cascadeWorkspaces: true, includeText: true }
1169
+ query: {
1170
+ limit: 200,
1171
+ cascadeWorkspaces: true,
1172
+ includeText: true,
1173
+ ...cursor ? { cursor } : {}
1174
+ }
1122
1175
  }
1123
- }).then((r) => r.data).catch(() => void 0);
1124
- return feed?.results ?? feed?.Results ?? [];
1125
- } catch {
1126
- return [];
1127
- }
1176
+ });
1177
+ if (error || !response.ok || !data) {
1178
+ throw new Error(
1179
+ `Could not read operator-skill catalogue from ${workspaceId}: HTTP ${response.status}.`
1180
+ );
1181
+ }
1182
+ const feed = data;
1183
+ rows.push(...feed.results ?? feed.Results ?? []);
1184
+ cursor = feed.nextCursor ?? feed.NextCursor ?? void 0;
1185
+ } while (cursor);
1186
+ return rows;
1128
1187
  }
1129
1188
  async function fetchTemplateRows(cfg, personalWorkspaceId) {
1130
1189
  const [systemRows, personalRows] = await Promise.all([
@@ -1144,7 +1203,10 @@ function resolveReferenceSet(rows, surface) {
1144
1203
  }
1145
1204
 
1146
1205
  // src/setup/materialise.ts
1147
- var CLIENT_SURFACE = "claude-code";
1206
+ var CLIENT_SURFACE = {
1207
+ claude: "claude-code",
1208
+ codex: "gpt-codex"
1209
+ };
1148
1210
  function writeSkills(dir, skills, surface) {
1149
1211
  const written = [];
1150
1212
  for (const s of skills) {
@@ -1155,12 +1217,58 @@ function writeSkills(dir, skills, surface) {
1155
1217
  if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
1156
1218
  return written;
1157
1219
  }
1220
+ function splitAgentFrontmatter(body) {
1221
+ body = body.replaceAll("\r\n", "\n");
1222
+ if (!body.startsWith("---\n")) return { instructions: body };
1223
+ const end = body.indexOf("\n---\n", 4);
1224
+ if (end < 0) return { instructions: body };
1225
+ const frontmatter = body.slice(4, end);
1226
+ const field = (key) => {
1227
+ const line = frontmatter.split("\n").find((candidate) => candidate.startsWith(`${key}:`));
1228
+ return line?.slice(key.length + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
1229
+ };
1230
+ return { name: field("name"), description: field("description"), instructions: body.slice(end + 5).trimStart() };
1231
+ }
1232
+ function validateCodexSkills(skills) {
1233
+ for (const skill of skills) {
1234
+ const parsed = splitAgentFrontmatter(skill.body);
1235
+ if (parsed.name !== skill.name || !parsed.description) {
1236
+ throw new Error(
1237
+ `Codex skill '${skill.name}' must begin with YAML frontmatter containing matching name and a description.`
1238
+ );
1239
+ }
1240
+ }
1241
+ }
1242
+ var WORKER_NAMES = ["substrate-drafter", "substrate-miner", "substrate-verifier"];
1243
+ function validateCodexWorkerDependencies(skills, agents) {
1244
+ const available = new Set(agents.map((agent) => agent.name));
1245
+ const missing = WORKER_NAMES.filter(
1246
+ (worker) => skills.some((skill) => skill.body.includes(worker)) && !available.has(worker)
1247
+ );
1248
+ if (missing.length) {
1249
+ throw new Error(`Codex skills reference missing agent template(s): ${missing.join(", ")}.`);
1250
+ }
1251
+ }
1252
+ function codexAgentToml(agent) {
1253
+ const parsed = splitAgentFrontmatter(agent.body);
1254
+ if (!parsed.description) {
1255
+ throw new Error(`Codex agent '${agent.name}' requires a description in its leading YAML frontmatter.`);
1256
+ }
1257
+ return [
1258
+ `name = ${JSON.stringify(parsed.name || agent.name)}`,
1259
+ `description = ${JSON.stringify(parsed.description)}`,
1260
+ `developer_instructions = ${JSON.stringify(parsed.instructions.trimEnd())}`,
1261
+ ""
1262
+ ].join("\n");
1263
+ }
1158
1264
  function writeAgents(dir, agents, surface) {
1159
1265
  if (agents.length) mkdirSync3(dir, { recursive: true });
1160
1266
  const written = [];
1161
1267
  for (const a of agents) {
1162
- const file = `${a.name}.md`;
1163
- writeFileSync3(join4(dir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
1268
+ const codex = surface === CLIENT_SURFACE.codex;
1269
+ const file = `${a.name}.${codex ? "toml" : "md"}`;
1270
+ const body = codex ? codexAgentToml(a) : a.body.endsWith("\n") ? a.body : a.body + "\n";
1271
+ writeFileSync3(join4(dir, file), body);
1164
1272
  written.push(file);
1165
1273
  }
1166
1274
  if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -1177,11 +1285,71 @@ function writeReferencesIntoSkillDirs(dir, skills, refs) {
1177
1285
  }
1178
1286
  return refs.map((r) => r.name);
1179
1287
  }
1180
- var SKILL_SPEC = { kind: "skill", dir: skillsDir, resolve: resolveSkillSet, write: writeSkills };
1181
- var AGENT_SPEC = { kind: "agent", dir: agentsDir, resolve: resolveAgentSet, write: writeAgents };
1288
+ var SKILL_SPEC = {
1289
+ kind: "skill",
1290
+ dir: skillsDir,
1291
+ resolve: resolveSkillSet,
1292
+ write: writeSkills,
1293
+ supportsCodex: true
1294
+ };
1295
+ var AGENT_SPEC = {
1296
+ kind: "agent",
1297
+ dir: agentsDir,
1298
+ resolve: resolveAgentSet,
1299
+ write: writeAgents,
1300
+ supportsCodex: true
1301
+ };
1182
1302
  function scopeOf(opts) {
1183
1303
  return opts.local ? "project" : resolveScope(opts.scope);
1184
1304
  }
1305
+ function parseClient(value) {
1306
+ if (value == null) return void 0;
1307
+ if (value === "claude" || value === "codex" || value === "all") return value;
1308
+ throw new Error(`--client must be 'claude', 'codex', or 'all' (got '${value}')`);
1309
+ }
1310
+ function clientSelection(spec, g, raw) {
1311
+ if (!spec.supportsCodex) return "claude";
1312
+ const explicit = parseClient(raw);
1313
+ if (explicit) return explicit;
1314
+ const claudeConfigured = Boolean(g.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
1315
+ const codexConfigured = Boolean(g.codexHome || process.env.CODEX_HOME);
1316
+ if (claudeConfigured || codexConfigured) {
1317
+ if (claudeConfigured && codexConfigured) return "all";
1318
+ return codexConfigured ? "codex" : "claude";
1319
+ }
1320
+ const claudeDetected = resolveClaudeTargets({})[0]?.dir;
1321
+ const codexDetected = resolveCodexHomes({})[0];
1322
+ const hasClaude = Boolean(claudeDetected && existsSync3(claudeDetected));
1323
+ const hasCodex = Boolean(codexDetected && existsSync3(codexDetected));
1324
+ if (hasClaude && hasCodex) return "all";
1325
+ if (hasCodex) return "codex";
1326
+ return "claude";
1327
+ }
1328
+ function targetsFor(spec, g, scope, selection) {
1329
+ const clients = selection === "all" ? ["claude", "codex"] : [selection];
1330
+ if (scope === "project" && clients.includes("codex")) {
1331
+ throw new Error("Codex skills have no supported project scope; use --scope global or --client claude.");
1332
+ }
1333
+ const targets = [];
1334
+ for (const client of clients) {
1335
+ if (client === "claude") {
1336
+ targets.push(...resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() }).map((target) => ({
1337
+ client,
1338
+ surface: CLIENT_SURFACE[client],
1339
+ dir: spec.dir(target.dir),
1340
+ label: target.label
1341
+ })));
1342
+ continue;
1343
+ }
1344
+ targets.push(...resolveCodexHomes({ override: g.codexHome, scope }).map((home) => ({
1345
+ client,
1346
+ surface: CLIENT_SURFACE[client],
1347
+ dir: spec.dir(home),
1348
+ label: home
1349
+ })));
1350
+ }
1351
+ return targets;
1352
+ }
1185
1353
  async function runInstall(spec, cmd, opts) {
1186
1354
  const g = cmd.optsWithGlobals();
1187
1355
  let scope;
@@ -1193,31 +1361,52 @@ async function runInstall(spec, cmd, opts) {
1193
1361
  const cfg = resolveConfig(g);
1194
1362
  const dryRun = Boolean(opts.dryRun);
1195
1363
  const json = Boolean(g.json || opts.json);
1196
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1364
+ let selection;
1365
+ let targets;
1366
+ try {
1367
+ selection = clientSelection(spec, g, opts.client);
1368
+ targets = targetsFor(spec, g, scope, selection);
1369
+ } catch (err2) {
1370
+ return fail(err2.message);
1371
+ }
1197
1372
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
1198
1373
  const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
1199
- const items = spec.resolve(rows, CLIENT_SURFACE);
1200
- const refs = spec.kind === "skill" ? resolveReferenceSet(rows, CLIENT_SURFACE) : [];
1201
1374
  const results = targets.map((t) => {
1202
- const dir = spec.dir(t.dir);
1203
- const written = dryRun ? items.map((i) => i.name) : spec.write(dir, items, CLIENT_SURFACE);
1204
- const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(dir, items, refs);
1205
- return { dir, label: t.label, written, refsWritten };
1375
+ const items = spec.resolve(rows, t.surface);
1376
+ if (t.client === "codex" && spec.kind === "skill") {
1377
+ validateCodexSkills(items);
1378
+ validateCodexWorkerDependencies(items, resolveAgentSet(rows, t.surface));
1379
+ }
1380
+ const refs = spec.kind === "skill" ? resolveReferenceSet(rows, t.surface) : [];
1381
+ const written = dryRun ? items.map((i) => i.name) : spec.write(t.dir, items, t.surface);
1382
+ const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(t.dir, items, refs);
1383
+ return {
1384
+ client: t.client,
1385
+ surface: t.surface,
1386
+ dir: t.dir,
1387
+ label: t.label,
1388
+ available: items.length,
1389
+ references: refs.length,
1390
+ items: items.map(({ name, source }) => ({ name, source })),
1391
+ referenceItems: refs.map(({ name, source }) => ({ name, source })),
1392
+ written,
1393
+ refsWritten
1394
+ };
1206
1395
  });
1207
- if (json) return emit({ kind: spec.kind, dryRun, available: items.length, references: refs.length, targets: results }, true);
1208
- if (items.length === 0) {
1396
+ if (json) return emit({ kind: spec.kind, client: selection, dryRun, targets: results }, true);
1397
+ if (results.every((r) => r.available === 0)) {
1209
1398
  console.log(style.dim(`No ${spec.kind}s available to install \u2014 is the bundle installed for your account?`));
1210
1399
  return;
1211
1400
  }
1212
1401
  for (const r of results) {
1213
1402
  console.log(
1214
- `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) ${style.dim("\u2192")} ${r.dir}`
1403
+ `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) for ${r.client} ${style.dim("\u2192")} ${r.dir}`
1215
1404
  );
1216
1405
  if (r.refsWritten.length)
1217
1406
  console.log(
1218
1407
  `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.refsWritten.length} reference(s) into each skill ${style.dim("\u2192")} ${r.dir}/<skill>/references`
1219
1408
  );
1220
- if (dryRun) for (const i of items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1409
+ if (dryRun) for (const i of r.items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1221
1410
  }
1222
1411
  }
1223
1412
  function runList(spec, cmd, opts) {
@@ -1229,16 +1418,22 @@ function runList(spec, cmd, opts) {
1229
1418
  return fail(err2.message);
1230
1419
  }
1231
1420
  const json = Boolean(g.json || opts.json);
1232
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1421
+ let selection;
1422
+ let targets;
1423
+ try {
1424
+ selection = clientSelection(spec, g, opts.client);
1425
+ targets = targetsFor(spec, g, scope, selection);
1426
+ } catch (err2) {
1427
+ return fail(err2.message);
1428
+ }
1233
1429
  const out = targets.map((t) => {
1234
- const dir = spec.dir(t.dir);
1235
- const lock = readSkillsLock(dir);
1430
+ const lock = readSkillsLock(t.dir);
1236
1431
  const entries = Object.entries(lock).flatMap(
1237
- ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(dir, name)) }))
1432
+ ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(t.dir, name)) }))
1238
1433
  );
1239
- return { dir, label: t.label, entries };
1434
+ return { client: t.client, surface: t.surface, dir: t.dir, label: t.label, entries };
1240
1435
  });
1241
- if (json) return emit({ kind: spec.kind, targets: out }, true);
1436
+ if (json) return emit({ kind: spec.kind, client: selection, targets: out }, true);
1242
1437
  let any = false;
1243
1438
  for (const t of out) {
1244
1439
  if (t.entries.length === 0) continue;
@@ -1261,33 +1456,39 @@ function runClean(spec, cmd, opts, slugArg) {
1261
1456
  return fail(err2.message);
1262
1457
  }
1263
1458
  const json = Boolean(g.json || opts.json);
1264
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1459
+ let selection;
1460
+ let targets;
1461
+ try {
1462
+ selection = clientSelection(spec, g, opts.client);
1463
+ targets = targetsFor(spec, g, scope, selection);
1464
+ } catch (err2) {
1465
+ return fail(err2.message);
1466
+ }
1265
1467
  const cleaned = [];
1266
1468
  const missing = [];
1267
1469
  for (const t of targets) {
1268
- const dir = spec.dir(t.dir);
1269
- const lock = readSkillsLock(dir);
1470
+ const lock = readSkillsLock(t.dir);
1270
1471
  const entry = lock[slug];
1271
1472
  if (!entry) {
1272
- missing.push(join4(dir, SKILLS_LOCK));
1473
+ missing.push(join4(t.dir, SKILLS_LOCK));
1273
1474
  continue;
1274
1475
  }
1275
1476
  const removed = [];
1276
1477
  for (const name of entry.skills) {
1277
- const p = join4(dir, name);
1478
+ const p = join4(t.dir, name);
1278
1479
  if (existsSync3(p)) {
1279
1480
  rmSync2(p, { recursive: true, force: true });
1280
1481
  removed.push(name);
1281
1482
  }
1282
1483
  }
1283
1484
  delete lock[slug];
1284
- writeSkillsLock(dir, lock);
1285
- cleaned.push({ dir, removed });
1485
+ writeSkillsLock(t.dir, lock);
1486
+ cleaned.push({ client: t.client, surface: t.surface, dir: t.dir, removed });
1286
1487
  }
1287
1488
  if (cleaned.length === 0) {
1288
1489
  return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
1289
1490
  }
1290
- if (json) return emit({ kind: spec.kind, slug, cleaned, missing }, true);
1491
+ if (json) return emit({ kind: spec.kind, client: selection, slug, cleaned, missing }, true);
1291
1492
  for (const c of cleaned) {
1292
1493
  console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
1293
1494
  }
@@ -1301,22 +1502,24 @@ function registerAgents(program2) {
1301
1502
  `
1302
1503
  Examples:
1303
1504
  $ sechroom agents install materialise your installed agents to ~/.claude/agents
1505
+ $ sechroom agents install --client codex materialise native TOML agents to ~/.codex/agents
1304
1506
  $ sechroom agents install --scope project write them to ./.claude/agents instead
1305
1507
  $ sechroom agents install --claude-config-dir ~/.claude-work target another instance
1306
1508
  $ sechroom agents list what's materialised on disk
1307
1509
  $ sechroom agents clean remove the materialised agent files
1308
1510
 
1309
- Agents are resolved from the agent target (target:claude-agent), the dispatchable
1310
- workers your loop skills call (e.g. find-prior-art \u2192 substrate-miner).`
1511
+ Agents are resolved from the client's agent target (target:claude-agent or
1512
+ target:gpt-codex-agent), the dispatchable workers your loop skills call
1513
+ (e.g. find-prior-art \u2192 substrate-miner).`
1311
1514
  );
1312
- agents.command("install").description("Materialise your installed subagents to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--dry-run", "print what would be written; write nothing").option("--json", "machine output").action((opts, cmd) => runInstall(AGENT_SPEC, cmd, opts));
1313
- agents.command("list").description("List the subagents materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((opts, cmd) => runList(AGENT_SPEC, cmd, opts));
1314
- agents.command("clean [slug]").description(`Remove subagent files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((slugArg, opts, cmd) => runClean(AGENT_SPEC, cmd, opts, slugArg));
1515
+ agents.command("install").description("Materialise your installed subagents to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--dry-run", "print what would be written; write nothing").option("--client <client>", "claude, codex, or all").option("--json", "machine output").action((opts, cmd) => runInstall(AGENT_SPEC, cmd, opts));
1516
+ agents.command("list").description("List the subagents materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").option("--client <client>", "claude, codex, or all").action((opts, cmd) => runList(AGENT_SPEC, cmd, opts));
1517
+ agents.command("clean [slug]").description(`Remove subagent files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").option("--client <client>", "claude, codex, or all").action((slugArg, opts, cmd) => runClean(AGENT_SPEC, cmd, opts, slugArg));
1315
1518
  }
1316
1519
 
1317
1520
  // src/commands/channel.ts
1318
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
1319
- import { dirname as dirname4, join as join7 } from "path";
1521
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
1522
+ import { dirname as dirname6, join as join9 } from "path";
1320
1523
  import {
1321
1524
  HttpTransportType,
1322
1525
  HubConnectionBuilder
@@ -1324,52 +1527,218 @@ import {
1324
1527
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1325
1528
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1326
1529
 
1530
+ // src/commands/executor.ts
1531
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
1532
+ import { dirname as dirname5, join as join8 } from "path";
1533
+
1534
+ // src/sem.ts
1535
+ import { dirname as dirname2, join as join5 } from "path";
1536
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync3, statSync, writeFileSync as writeFileSync4 } from "fs";
1537
+ var SEM_FILE = join5(".sechroom", "lane.json");
1538
+ var STATE_DIR_NAME2 = ".sechroom";
1539
+ function localSemPath(cwd = process.cwd()) {
1540
+ return join5(cwd, SEM_FILE);
1541
+ }
1542
+ function resolveSemPathForRead(start = process.cwd()) {
1543
+ let dir = start;
1544
+ while (true) {
1545
+ const candidate = join5(dir, SEM_FILE);
1546
+ if (existsSync4(candidate)) return candidate;
1547
+ const parent = dirname2(dir);
1548
+ if (parent === dir) return void 0;
1549
+ dir = parent;
1550
+ }
1551
+ }
1552
+ function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
1553
+ try {
1554
+ let dir = start;
1555
+ let gitPath;
1556
+ for (; ; ) {
1557
+ const candidate = join5(dir, ".git");
1558
+ if (existsSync4(candidate)) {
1559
+ gitPath = candidate;
1560
+ break;
1561
+ }
1562
+ const parent = dirname2(dir);
1563
+ if (parent === dir) break;
1564
+ dir = parent;
1565
+ }
1566
+ if (!gitPath || statSync(gitPath).isDirectory()) return lane;
1567
+ const gitFile = readFileSync3(gitPath, "utf8");
1568
+ const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
1569
+ if (!common) return lane;
1570
+ const worktreesDir = join5(common[1], "worktrees");
1571
+ const siblings = readdirSync(worktreesDir).filter((n) => {
1572
+ try {
1573
+ return statSync(join5(worktreesDir, n)).isDirectory();
1574
+ } catch {
1575
+ return false;
1576
+ }
1577
+ });
1578
+ return laneWithWorktreeSuffix(lane, gitFile, siblings);
1579
+ } catch {
1580
+ return lane;
1581
+ }
1582
+ }
1583
+ function laneWithWorktreeSuffix(lane, gitFile, siblings) {
1584
+ const m = gitFile.trim().match(/\/worktrees\/([^/\s]+)\s*$/);
1585
+ if (!m) return lane;
1586
+ const idx = [...siblings].sort().indexOf(m[1]);
1587
+ return idx < 0 ? lane : `${lane}-${idx + 2}`;
1588
+ }
1589
+ function serializeSem(values) {
1590
+ return JSON.stringify(values, null, 2) + "\n";
1591
+ }
1592
+ function readSem(path) {
1593
+ const p = path ?? resolveSemPathForRead();
1594
+ if (!p || !existsSync4(p)) return void 0;
1595
+ return { path: p, values: parseLaneJson(readFileSync3(p, "utf8")) };
1596
+ }
1597
+ function readLocalSemValues(cwd = process.cwd()) {
1598
+ const next = join5(cwd, SEM_FILE);
1599
+ if (existsSync4(next)) return readSem(next)?.values ?? {};
1600
+ return {};
1601
+ }
1602
+ function parseLaneJson(text2) {
1603
+ try {
1604
+ const parsed = JSON.parse(text2);
1605
+ const out = {};
1606
+ for (const [k, v] of Object.entries(parsed)) {
1607
+ if (typeof v === "string") out[k] = v;
1608
+ }
1609
+ return out;
1610
+ } catch {
1611
+ return {};
1612
+ }
1613
+ }
1614
+ var STATE_DIR_IGNORE = `${STATE_DIR_NAME2}/`;
1615
+ function writeSem(values, path = localSemPath()) {
1616
+ mkdirSync4(dirname2(path), { recursive: true });
1617
+ writeFileSync4(path, serializeSem(values));
1618
+ ensureSemIgnored(path);
1619
+ ensureContinuityScaffold(path);
1620
+ return path;
1621
+ }
1622
+ function ensureStateDirIgnored(cwd = process.cwd()) {
1623
+ ensureSemIgnored(localSemPath(cwd));
1624
+ }
1625
+ var CONTINUITY_FILE_NAME = "continuity.json";
1626
+ var CONTINUITY_SCAFFOLD = JSON.stringify(
1627
+ {
1628
+ _readme: "Agent-maintained continuity intent. Keep these current during the session; `sechroom checkpoint` and the PreCompact hook snapshot from here. The five required fields (objective, state, lastAction, nextAction, resumeInstruction) must all be non-empty for a snapshot to be created.",
1629
+ objective: "",
1630
+ state: "",
1631
+ lastAction: "",
1632
+ nextAction: "",
1633
+ resumeInstruction: "",
1634
+ constraints: [],
1635
+ questions: [],
1636
+ artifacts: [],
1637
+ confidence: null
1638
+ },
1639
+ null,
1640
+ 2
1641
+ ) + "\n";
1642
+ function ensureContinuityScaffold(semPath) {
1643
+ try {
1644
+ const target = join5(dirname2(semPath), CONTINUITY_FILE_NAME);
1645
+ if (existsSync4(target)) return;
1646
+ writeFileSync4(target, CONTINUITY_SCAFFOLD);
1647
+ } catch {
1648
+ }
1649
+ }
1650
+ function ignoresSem(content) {
1651
+ return content.split("\n").some((line) => {
1652
+ const t = line.trim();
1653
+ return t === STATE_DIR_NAME2 || t === STATE_DIR_IGNORE || t === `/${STATE_DIR_NAME2}` || t === `/${STATE_DIR_IGNORE}` || t === `**/${STATE_DIR_NAME2}` || t === `**/${STATE_DIR_IGNORE}`;
1654
+ });
1655
+ }
1656
+ function inGitRepo(startDir) {
1657
+ let dir = startDir;
1658
+ for (; ; ) {
1659
+ if (existsSync4(join5(dir, ".git"))) return true;
1660
+ const parent = dirname2(dir);
1661
+ if (parent === dir) return false;
1662
+ dir = parent;
1663
+ }
1664
+ }
1665
+ function resolveGitignoreTarget(startDir) {
1666
+ let dir = startDir;
1667
+ for (; ; ) {
1668
+ const gi = join5(dir, ".gitignore");
1669
+ if (existsSync4(gi)) return { path: gi, exists: true };
1670
+ const parent = dirname2(dir);
1671
+ if (existsSync4(join5(dir, ".git")) || parent === dir) {
1672
+ return { path: join5(startDir, ".gitignore"), exists: false };
1673
+ }
1674
+ dir = parent;
1675
+ }
1676
+ }
1677
+ function ensureSemIgnored(semPath) {
1678
+ try {
1679
+ const checkoutDir = dirname2(dirname2(semPath));
1680
+ if (!inGitRepo(checkoutDir)) return;
1681
+ const target = resolveGitignoreTarget(checkoutDir);
1682
+ if (target.exists) {
1683
+ const content = readFileSync3(target.path, "utf8");
1684
+ if (ignoresSem(content)) return;
1685
+ const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
1686
+ appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
1687
+ `);
1688
+ } else {
1689
+ writeFileSync4(target.path, `${STATE_DIR_IGNORE}
1690
+ `);
1691
+ }
1692
+ } catch {
1693
+ }
1694
+ }
1695
+
1327
1696
  // src/commands/hook-install.ts
1328
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
1329
- import { delimiter, dirname as dirname3, join as join6 } from "path";
1697
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
1698
+ import { delimiter, dirname as dirname4, join as join7 } from "path";
1330
1699
 
1331
1700
  // src/setup/clients.ts
1332
- import { existsSync as existsSync4 } from "fs";
1701
+ import { existsSync as existsSync5 } from "fs";
1333
1702
  import { homedir as homedir3 } from "os";
1334
- import { dirname as dirname2, join as join5 } from "path";
1703
+ import { dirname as dirname3, join as join6 } from "path";
1335
1704
  function claudeDesktopConfigPath(home) {
1336
1705
  switch (process.platform) {
1337
1706
  case "darwin":
1338
- return join5(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1707
+ return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1339
1708
  case "win32":
1340
- return join5(process.env.APPDATA ?? join5(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1709
+ return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1341
1710
  default:
1342
- return join5(home, ".config", "Claude", "claude_desktop_config.json");
1711
+ return join6(home, ".config", "Claude", "claude_desktop_config.json");
1343
1712
  }
1344
1713
  }
1345
1714
  function clientTargets(cwd, opts = {}) {
1346
1715
  const home = homedir3();
1347
- const claudeDir = opts.claudeDir ?? join5(home, ".claude");
1348
- const codexHome = opts.codexHome ?? join5(home, ".codex");
1716
+ const claudeDir = opts.claudeDir ?? join6(home, ".claude");
1717
+ const codexHome = opts.codexHome ?? join6(home, ".codex");
1349
1718
  return {
1350
1719
  "claude-code": {
1351
1720
  key: "claude-code",
1352
1721
  label: "Claude Code",
1353
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join5(cwd, ".mcp.json"), format: "json" },
1354
- instruction: { surfaceKey: "claude-code", path: join5(cwd, "CLAUDE.md") }
1722
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
1723
+ instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
1355
1724
  },
1356
1725
  "claude-desktop": {
1357
1726
  key: "claude-desktop",
1358
1727
  label: "Claude Desktop",
1359
1728
  mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
1360
- instruction: { surfaceKey: "claude-desktop", path: join5(claudeDir, "CLAUDE.md") }
1729
+ instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
1361
1730
  },
1362
1731
  codex: {
1363
1732
  key: "codex",
1364
1733
  label: "Codex CLI",
1365
- mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join5(codexHome, "config.toml"), format: "toml" },
1366
- instruction: { surfaceKey: "chatgpt", path: join5(cwd, "AGENTS.md") }
1734
+ mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
1735
+ instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
1367
1736
  },
1368
1737
  cursor: {
1369
1738
  key: "cursor",
1370
1739
  label: "Cursor",
1371
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join5(cwd, ".cursor", "mcp.json"), format: "json" },
1372
- instruction: { surfaceKey: "chatgpt", path: join5(cwd, "AGENTS.md") }
1740
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
1741
+ instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
1373
1742
  },
1374
1743
  antigravity: {
1375
1744
  key: "antigravity",
@@ -1380,8 +1749,8 @@ function clientTargets(cwd, opts = {}) {
1380
1749
  // `type` — comes from the `antigravity` server surface, so we don't
1381
1750
  // hardcode it here. Instructions go in the project `AGENTS.md`
1382
1751
  // (cross-tool, shared with Codex/Cursor).
1383
- mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join5(home, ".gemini", "config", "mcp_config.json"), format: "json" },
1384
- instruction: { surfaceKey: "antigravity", path: join5(cwd, "AGENTS.md") }
1752
+ mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
1753
+ instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
1385
1754
  }
1386
1755
  };
1387
1756
  }
@@ -1390,11 +1759,11 @@ var DEFAULT_CLIENT_KEY = "claude-code";
1390
1759
  function detectInstalledClients(cwd) {
1391
1760
  const home = homedir3();
1392
1761
  const detected = [];
1393
- if (resolveClaudeTargets({}).some((t) => existsSync4(t.dir))) detected.push("claude-code");
1394
- if (existsSync4(dirname2(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
1395
- if (resolveCodexHomes({}).some((d) => existsSync4(d))) detected.push("codex");
1396
- if (existsSync4(join5(home, ".cursor")) || existsSync4(join5(cwd, ".cursor"))) detected.push("cursor");
1397
- if (existsSync4(join5(home, ".gemini"))) detected.push("antigravity");
1762
+ if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
1763
+ if (existsSync5(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
1764
+ if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
1765
+ if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
1766
+ if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
1398
1767
  return detected;
1399
1768
  }
1400
1769
 
@@ -1403,15 +1772,24 @@ var CLAUDE_HOOK_COMMANDS = {
1403
1772
  SessionStart: "sechroom hook session-start",
1404
1773
  PreCompact: "sechroom hook pre-compact",
1405
1774
  SessionEnd: "sechroom hook session-end",
1406
- // WLP telemetry tap (D-WLP-10) — per-turn executor self-report. No-op (exit 0) unless this
1407
- // checkout is bound to a decomposition+task via `sechroom telemetry bind`, so it's safe to wire
1408
- // for every Claude install. Claude-only (it parses a Claude Code transcript).
1409
- Stop: "sechroom telemetry hook"
1775
+ // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
1776
+ // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
1777
+ // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
1778
+ // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
1779
+ // given Claude Code version doesn't know is inert (never fires). Claude-only.
1780
+ Stop: "sechroom telemetry hook",
1781
+ SubagentStop: "sechroom telemetry hook",
1782
+ Notification: "sechroom telemetry hook",
1783
+ PermissionDenied: "sechroom telemetry hook"
1410
1784
  };
1411
1785
  var CODEX_HOOK_COMMANDS = {
1412
1786
  SessionStart: "sechroom hook session-start",
1787
+ PreCompact: "sechroom hook pre-compact",
1413
1788
  Stop: "sechroom hook session-end --debounce-minutes 10"
1414
1789
  };
1790
+ function hookCommandsForSurface(surface) {
1791
+ return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
1792
+ }
1415
1793
  function hasHookCommand(config2, event, command) {
1416
1794
  const groups = config2.hooks?.[event] ?? [];
1417
1795
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
@@ -1428,24 +1806,30 @@ function mergeHooks(config2, commands) {
1428
1806
  return added;
1429
1807
  }
1430
1808
  function readJsonConfig2(path) {
1431
- if (!existsSync5(path)) return {};
1432
- const raw = readFileSync3(path, "utf8");
1809
+ if (!existsSync6(path)) return {};
1810
+ const raw = readFileSync4(path, "utf8");
1433
1811
  if (!raw.trim()) return {};
1434
1812
  return JSON.parse(raw);
1435
1813
  }
1436
1814
  function installHooksJson(path, commands, dryRun) {
1437
- const existed = existsSync5(path) && readFileSync3(path, "utf8").trim().length > 0;
1815
+ const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
1438
1816
  const config2 = readJsonConfig2(path);
1439
1817
  const added = mergeHooks(config2, commands);
1440
1818
  if (added === 0 && existed) return { path, status: "current" };
1441
1819
  if (!dryRun) {
1442
- mkdirSync4(dirname3(path), { recursive: true });
1443
- writeFileSync4(path, JSON.stringify(config2, null, 2) + "\n");
1820
+ mkdirSync5(dirname4(path), { recursive: true });
1821
+ writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
1444
1822
  }
1445
1823
  return { path, status: existed ? "merged" : "created" };
1446
1824
  }
1447
1825
  function installClaudeCommands(claudeDir, commands, dryRun) {
1448
- return installHooksJson(join6(claudeDir, "settings.json"), commands, dryRun);
1826
+ return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
1827
+ }
1828
+ function installCodexCommands(codexHome, commands, dryRun) {
1829
+ return [
1830
+ installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
1831
+ installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
1832
+ ];
1449
1833
  }
1450
1834
  function ensureCodexFeaturesHooks(content) {
1451
1835
  const lines = content.split("\n");
@@ -1468,13 +1852,13 @@ function ensureCodexFeaturesHooks(content) {
1468
1852
  return { next: lines.join("\n"), changed: true };
1469
1853
  }
1470
1854
  function installCodexFeatureFlag(path, dryRun) {
1471
- const existed = existsSync5(path);
1472
- const content = existed ? readFileSync3(path, "utf8") : "";
1855
+ const existed = existsSync6(path);
1856
+ const content = existed ? readFileSync4(path, "utf8") : "";
1473
1857
  const { next, changed } = ensureCodexFeaturesHooks(content);
1474
1858
  if (!changed) return { path, status: "current" };
1475
1859
  if (!dryRun) {
1476
- mkdirSync4(dirname3(path), { recursive: true });
1477
- writeFileSync4(path, next);
1860
+ mkdirSync5(dirname4(path), { recursive: true });
1861
+ writeFileSync5(path, next);
1478
1862
  }
1479
1863
  return { path, status: existed ? "merged" : "created" };
1480
1864
  }
@@ -1499,11 +1883,11 @@ function installHookSurfaces(surfaces, opts) {
1499
1883
  const out = [];
1500
1884
  for (const surface of surfaces) {
1501
1885
  if (surface === "claude") {
1502
- const path = join6(opts.claudeDir, "settings.json");
1886
+ const path = join7(opts.claudeDir, "settings.json");
1503
1887
  out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
1504
1888
  } else {
1505
- const hooksJson = installHooksJson(join6(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
1506
- const featureFlag = installCodexFeatureFlag(join6(opts.codexHome, "config.toml"), opts.dryRun);
1889
+ const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
1890
+ const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
1507
1891
  out.push({ surface, results: [hooksJson, featureFlag] });
1508
1892
  }
1509
1893
  }
@@ -1523,7 +1907,7 @@ function isSechroomOnPath() {
1523
1907
  for (const dir of pathEnv.split(delimiter)) {
1524
1908
  if (!dir) continue;
1525
1909
  for (const name of names) {
1526
- if (existsSync5(join6(dir, name))) return true;
1910
+ if (existsSync6(join7(dir, name))) return true;
1527
1911
  }
1528
1912
  }
1529
1913
  return false;
@@ -1536,6 +1920,446 @@ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
1536
1920
  return true;
1537
1921
  }
1538
1922
 
1923
+ // src/commands/executor.ts
1924
+ function executorSubscriptionInput(name) {
1925
+ return {
1926
+ name,
1927
+ enabled: true,
1928
+ filter: { tags: ["kind:task"], workspaceScope: [] }
1929
+ };
1930
+ }
1931
+ function executorRegistrationInput(state, deliverySubscriptionId) {
1932
+ return {
1933
+ relayId: state.relayId,
1934
+ instanceKey: state.instanceKey,
1935
+ laneId: state.laneId ?? state.instanceKey,
1936
+ runtimeKind: parseRuntimeKind(state.runtime),
1937
+ activationMode: "Attached",
1938
+ deliverySubscriptionId,
1939
+ connectorId: state.connectorId,
1940
+ claimedCapabilityKeys: state.capabilityKeys,
1941
+ toolSetRef: null,
1942
+ ttlSeconds: state.ttlSeconds
1943
+ };
1944
+ }
1945
+ var EXECUTOR_STATE = "executor.json";
1946
+ var EXECUTOR_PULSE_COMMAND = "sechroom executor hook-pulse";
1947
+ var EXECUTOR_STOP_COMMAND = "sechroom executor hook-stop";
1948
+ var CLAUDE_EXECUTOR_HOOKS = {
1949
+ SessionStart: EXECUTOR_PULSE_COMMAND,
1950
+ UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
1951
+ PreToolUse: EXECUTOR_PULSE_COMMAND,
1952
+ Stop: EXECUTOR_PULSE_COMMAND,
1953
+ SessionEnd: EXECUTOR_STOP_COMMAND
1954
+ };
1955
+ var CODEX_EXECUTOR_HOOKS = {
1956
+ SessionStart: EXECUTOR_PULSE_COMMAND,
1957
+ UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
1958
+ PreToolUse: EXECUTOR_PULSE_COMMAND,
1959
+ Stop: EXECUTOR_PULSE_COMMAND
1960
+ };
1961
+ function registerExecutor(program2) {
1962
+ const executor = program2.command("executor").description(
1963
+ "Register and operate a local Claude Code/Codex executor advertisement"
1964
+ );
1965
+ executor.command("install").description(
1966
+ "Configure this checkout's harness to advertise itself as a WLP executor"
1967
+ ).option("--connector <id>", "Approved local-session ConnectorDefinition id").option(
1968
+ "--instance-key <key>",
1969
+ "Stable executor identity (defaults to .sechroom/lane.json code-lane)"
1970
+ ).option(
1971
+ "--lane-id <lane>",
1972
+ "Canonical affinity lane (defaults to .sechroom/lane.json code-lane)"
1973
+ ).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
1974
+ "--capability <key...>",
1975
+ "Capability operation keys claimed by this instance"
1976
+ ).option(
1977
+ "--relay <id>",
1978
+ "Relay identity shared by sibling instances",
1979
+ "sechroom-cli-local"
1980
+ ).option(
1981
+ "--subscription-name <name>",
1982
+ "SignalR delivery binding name",
1983
+ "executor-dispatch"
1984
+ ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 600).option(
1985
+ "--refresh-after <seconds>",
1986
+ "Minimum age before a hook refreshes",
1987
+ parseInteger,
1988
+ 40
1989
+ ).option(
1990
+ "-y, --yes",
1991
+ "Non-interactive: accept detected surface and lane defaults",
1992
+ false
1993
+ ).option("--dry-run", "Show hook files without writing", false).action(async (opts, cmd) => {
1994
+ const globals = cmd.optsWithGlobals();
1995
+ const lane = readSem()?.values["code-lane"];
1996
+ const detected = detectHookSurfaces(process.cwd());
1997
+ let surface = opts.surface;
1998
+ let instanceKey = opts.instanceKey;
1999
+ let runtime = opts.runtime;
2000
+ let laneId = opts.laneId;
2001
+ let connector = opts.connector;
2002
+ let capabilities = opts.capability;
2003
+ const surfaceDefault = detected.length === 1 ? detected[0] : lane?.includes("codex") ? "codex" : "claude";
2004
+ if (!opts.yes && canPrompt()) {
2005
+ surface = await promptText(
2006
+ "Harness surface (claude or codex)?",
2007
+ surface ?? surfaceDefault
2008
+ );
2009
+ instanceKey = await promptText(
2010
+ "Executor instance key?",
2011
+ instanceKey ?? lane ?? ""
2012
+ );
2013
+ laneId = await promptText(
2014
+ "Executor affinity lane?",
2015
+ laneId ?? lane ?? ""
2016
+ );
2017
+ runtime = await promptText(
2018
+ "Runtime (claude-code or codex)?",
2019
+ runtime ?? (surface === "codex" ? "codex" : "claude-code")
2020
+ );
2021
+ connector = await promptText(
2022
+ "Approved local-session connector id?",
2023
+ connector ?? ""
2024
+ );
2025
+ const capabilityText = await promptText(
2026
+ "Capability keys (comma-separated; blank for none)?",
2027
+ capabilities?.join(",") ?? ""
2028
+ );
2029
+ capabilities = capabilityText.split(",").map((x) => x.trim()).filter(Boolean);
2030
+ }
2031
+ surface ??= surfaceDefault;
2032
+ instanceKey ??= lane;
2033
+ laneId ??= lane;
2034
+ runtime ??= surface === "codex" ? "codex" : "claude-code";
2035
+ if (!connector)
2036
+ fail(
2037
+ "executor install requires --connector (or an interactive connector id)"
2038
+ );
2039
+ if (!instanceKey)
2040
+ fail(
2041
+ "no instance key resolved; pass --instance-key or pin .sechroom/lane.json code-lane"
2042
+ );
2043
+ if (!opts.yes && !canPrompt())
2044
+ fail("non-interactive executor install requires --yes");
2045
+ parseRuntimeKind(runtime);
2046
+ if (!["claude", "codex"].includes(surface))
2047
+ fail("surface must be claude or codex");
2048
+ if (opts.refreshAfter >= opts.ttl)
2049
+ fail("refresh-after must be shorter than the TTL");
2050
+ const sem = readSem();
2051
+ const checkout = sem ? dirname5(dirname5(sem.path)) : process.cwd();
2052
+ const statePath = join8(checkout, ".sechroom", EXECUTOR_STATE);
2053
+ const state = {
2054
+ schemaVersion: 1,
2055
+ instanceKey,
2056
+ laneId,
2057
+ runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
2058
+ connectorId: connector,
2059
+ capabilityKeys: capabilities ?? [],
2060
+ relayId: opts.relay,
2061
+ subscriptionName: opts.subscriptionName,
2062
+ ttlSeconds: opts.ttl,
2063
+ refreshAfterSeconds: opts.refreshAfter
2064
+ };
2065
+ if (!opts.dryRun) {
2066
+ mkdirSync6(dirname5(statePath), { recursive: true });
2067
+ writeFileSync6(statePath, JSON.stringify(state, null, 2) + "\n");
2068
+ ensureStateDirIgnored(checkout);
2069
+ }
2070
+ const configuredClaudeDirs = globals.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR ? resolveClaudeTargets({ override: globals.claudeConfigDir }).map(
2071
+ (target) => target.dir
2072
+ ) : [join8(checkout, ".claude")];
2073
+ const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join8(checkout, ".codex")];
2074
+ const hookTargets = surface === "claude" ? configuredClaudeDirs : configuredCodexHomes;
2075
+ for (const target of hookTargets) {
2076
+ const results = surface === "claude" ? [
2077
+ installClaudeCommands(
2078
+ target,
2079
+ CLAUDE_EXECUTOR_HOOKS,
2080
+ opts.dryRun
2081
+ )
2082
+ ] : installCodexCommands(target, CODEX_EXECUTOR_HOOKS, opts.dryRun);
2083
+ for (const result of results)
2084
+ process.stderr.write(describe(result, opts.dryRun) + "\n");
2085
+ }
2086
+ warnIfSechroomNotOnPath();
2087
+ process.stderr.write(
2088
+ style.green("executor harness configured") + style.dim(` \u2014 ${instanceKey}
2089
+ `)
2090
+ );
2091
+ });
2092
+ executor.command("hook-pulse").description("Hook adapter: register or refresh this checkout's executor").action(async (_opts, cmd) => {
2093
+ await drainStdin();
2094
+ const located = readExecutorState();
2095
+ if (!located) return;
2096
+ const { state, path } = located;
2097
+ const age = state.lastRefreshAt ? Date.now() - Date.parse(state.lastRefreshAt) : Number.POSITIVE_INFINITY;
2098
+ if (state.instanceId && age < state.refreshAfterSeconds * 1e3) return;
2099
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2100
+ try {
2101
+ await ensureExecutorInstance(cfg, { state, path });
2102
+ } catch {
2103
+ }
2104
+ });
2105
+ executor.command("hook-stop").description("Hook adapter: deregister this checkout's executor").action(async (_opts, cmd) => {
2106
+ await drainStdin();
2107
+ const located = readExecutorState();
2108
+ if (!located?.state.instanceId) return;
2109
+ try {
2110
+ await api(
2111
+ resolveConfig(cmd.optsWithGlobals()),
2112
+ `/me/executor-instances/${encodeURIComponent(located.state.instanceId)}`,
2113
+ { method: "DELETE", body: JSON.stringify({}) }
2114
+ );
2115
+ delete located.state.instanceId;
2116
+ delete located.state.lastRefreshAt;
2117
+ writeFileSync6(
2118
+ located.path,
2119
+ JSON.stringify(located.state, null, 2) + "\n"
2120
+ );
2121
+ } catch {
2122
+ }
2123
+ });
2124
+ executor.command("submit-connector").description(
2125
+ "Submit a local-session connector definition for governed approval"
2126
+ ).requiredOption("--slug <slug>", "Unique connector definition slug").requiredOption("--display-name <name>", "Human-readable connector name").requiredOption("--transport <kind>", "push | pull").option("--profile <profile...>", "Advertised runtime profiles", [
2127
+ "base",
2128
+ "dotnet-10"
2129
+ ]).action(async (opts, cmd) => {
2130
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2131
+ const data = await api(cfg, "/connectors/definitions", {
2132
+ method: "POST",
2133
+ body: JSON.stringify({
2134
+ slug: opts.slug,
2135
+ displayName: opts.displayName,
2136
+ runtimeProfiles: opts.profile,
2137
+ connectorKind: "ExecutionRuntime",
2138
+ providerKind: "local-session",
2139
+ dispatchTransport: parseTransport(opts.transport)
2140
+ })
2141
+ });
2142
+ emit(data, Boolean(cmd.optsWithGlobals().json));
2143
+ if (!cmd.optsWithGlobals().json) {
2144
+ process.stderr.write(
2145
+ style.dim(
2146
+ "approve this connector definition before registering executors\n"
2147
+ )
2148
+ );
2149
+ }
2150
+ });
2151
+ executor.command("register").description(
2152
+ "Create/reuse a SignalR binding and register this local executor instance"
2153
+ ).requiredOption(
2154
+ "--instance-key <key>",
2155
+ "Stable key for this concrete session/lane"
2156
+ ).requiredOption(
2157
+ "--connector <id>",
2158
+ "Approved local-session ConnectorDefinition id"
2159
+ ).option("--runtime <kind>", "claude-code | codex", "claude-code").option(
2160
+ "--lane-id <lane>",
2161
+ "Canonical affinity lane (defaults to instance key)"
2162
+ ).option(
2163
+ "--relay <id>",
2164
+ "Relay identity shared by sibling instances",
2165
+ "sechroom-cli-local"
2166
+ ).option(
2167
+ "--subscription-name <name>",
2168
+ "SignalR delivery binding name",
2169
+ "executor-dispatch"
2170
+ ).option(
2171
+ "--capability <key...>",
2172
+ "Capability operation keys claimed by this instance"
2173
+ ).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
2174
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2175
+ const subscription = await api(
2176
+ cfg,
2177
+ "/me/delivery-subscriptions/signalr",
2178
+ {
2179
+ method: "POST",
2180
+ body: JSON.stringify({
2181
+ name: opts.subscriptionName,
2182
+ enabled: true,
2183
+ // Exact executor fanout ignores this generic filter; the fixed tag only
2184
+ // satisfies the legacy SignalR subscription shape.
2185
+ filter: { tags: ["kind:task"], workspaceScope: [] }
2186
+ })
2187
+ }
2188
+ );
2189
+ const data = await api(
2190
+ cfg,
2191
+ "/me/executor-instances",
2192
+ {
2193
+ method: "POST",
2194
+ body: JSON.stringify({
2195
+ relayId: opts.relay,
2196
+ instanceKey: opts.instanceKey,
2197
+ laneId: opts.laneId ?? opts.instanceKey,
2198
+ runtimeKind: parseRuntimeKind(opts.runtime),
2199
+ activationMode: "Attached",
2200
+ deliverySubscriptionId: subscription.id,
2201
+ connectorId: opts.connector,
2202
+ claimedCapabilityKeys: opts.capability ?? [],
2203
+ toolSetRef: opts.toolSetRef ?? null,
2204
+ ttlSeconds: opts.ttl
2205
+ })
2206
+ }
2207
+ );
2208
+ emit(data, Boolean(cmd.optsWithGlobals().json));
2209
+ if (!cmd.optsWithGlobals().json)
2210
+ process.stderr.write(
2211
+ style.dim(`refresh with: sechroom executor heartbeat ${data.id}
2212
+ `)
2213
+ );
2214
+ });
2215
+ executor.command("refresh <id>").description("Refresh one advertisement lease once").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (id, opts, cmd) => {
2216
+ const data = await refreshExecutorInstance(
2217
+ resolveConfig(cmd.optsWithGlobals()),
2218
+ id,
2219
+ opts.ttl
2220
+ );
2221
+ emit(data, Boolean(cmd.optsWithGlobals().json));
2222
+ });
2223
+ executor.command("heartbeat <id>").description("Keep an advertisement alive until interrupted").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).option("--interval <seconds>", "Refresh interval", parseInteger, 40).action(async (id, opts, cmd) => {
2224
+ if (opts.interval >= opts.ttl)
2225
+ fail("heartbeat interval must be shorter than the TTL");
2226
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2227
+ await refreshExecutorInstance(cfg, id, opts.ttl);
2228
+ process.stderr.write(
2229
+ style.green("executor heartbeat active") + style.dim(` \u2014 ${id}
2230
+ `)
2231
+ );
2232
+ await holdHeartbeat(async () => {
2233
+ await refreshExecutorInstance(cfg, id, opts.ttl);
2234
+ }, opts.interval * 1e3);
2235
+ });
2236
+ executor.command("offers <id>").description("List live dispatch offers addressed to this exact instance").action(async (id, _opts, cmd) => {
2237
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2238
+ const data = await api(
2239
+ cfg,
2240
+ `/me/executor-instances/${encodeURIComponent(id)}/dispatch-offers`
2241
+ );
2242
+ emit(data, Boolean(cmd.optsWithGlobals().json));
2243
+ });
2244
+ executor.command("deregister <id>").description("Stop advertising this executor instance").action(async (id, _opts, cmd) => {
2245
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2246
+ const data = await api(
2247
+ cfg,
2248
+ `/me/executor-instances/${encodeURIComponent(id)}`,
2249
+ {
2250
+ method: "DELETE",
2251
+ body: JSON.stringify({})
2252
+ }
2253
+ );
2254
+ emit(data, Boolean(cmd.optsWithGlobals().json));
2255
+ });
2256
+ }
2257
+ function parseRuntimeKind(value) {
2258
+ switch (value.trim().toLowerCase()) {
2259
+ case "claude":
2260
+ case "claude-code":
2261
+ return "ClaudeCode";
2262
+ case "codex":
2263
+ return "Codex";
2264
+ default:
2265
+ return fail("runtime must be claude-code or codex");
2266
+ }
2267
+ }
2268
+ function parseTransport(value) {
2269
+ switch (value.trim().toLowerCase()) {
2270
+ case "push":
2271
+ return "Push";
2272
+ case "pull":
2273
+ return "Pull";
2274
+ default:
2275
+ return fail("transport must be push or pull");
2276
+ }
2277
+ }
2278
+ async function refreshExecutorInstance(cfg, id, ttlSeconds) {
2279
+ return api(
2280
+ cfg,
2281
+ `/me/executor-instances/${encodeURIComponent(id)}/refresh`,
2282
+ {
2283
+ method: "POST",
2284
+ body: JSON.stringify({ ttlSeconds })
2285
+ }
2286
+ );
2287
+ }
2288
+ async function registerInstance(cfg, state) {
2289
+ const subscription = await api(
2290
+ cfg,
2291
+ "/me/delivery-subscriptions/signalr",
2292
+ {
2293
+ method: "POST",
2294
+ body: JSON.stringify(executorSubscriptionInput(state.subscriptionName))
2295
+ }
2296
+ );
2297
+ return api(cfg, "/me/executor-instances", {
2298
+ method: "POST",
2299
+ body: JSON.stringify(executorRegistrationInput(state, subscription.id))
2300
+ });
2301
+ }
2302
+ async function ensureExecutorInstance(cfg, located) {
2303
+ const { state, path } = located;
2304
+ state.laneId ??= state.instanceKey;
2305
+ const data = await registerInstance(cfg, state);
2306
+ state.instanceId = data.id;
2307
+ state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
2308
+ writeFileSync6(path, JSON.stringify(state, null, 2) + "\n");
2309
+ return data;
2310
+ }
2311
+ function readExecutorState(start = process.cwd()) {
2312
+ const semPath = resolveSemPathForRead(start);
2313
+ const sem = semPath ? readSem(semPath) : void 0;
2314
+ const path = join8(
2315
+ sem ? dirname5(sem.path) : join8(start, ".sechroom"),
2316
+ EXECUTOR_STATE
2317
+ );
2318
+ if (!existsSync7(path)) return void 0;
2319
+ return {
2320
+ state: JSON.parse(readFileSync5(path, "utf8")),
2321
+ path
2322
+ };
2323
+ }
2324
+ async function drainStdin() {
2325
+ if (process.stdin.isTTY) return;
2326
+ for await (const _chunk of process.stdin) {
2327
+ }
2328
+ }
2329
+ async function api(cfg, path, init) {
2330
+ const token = await requireToken(cfg);
2331
+ const response = await fetch(`${cfg.baseUrl}${path}`, {
2332
+ ...init,
2333
+ headers: {
2334
+ authorization: `Bearer ${token}`,
2335
+ tenant: cfg.tenant,
2336
+ "content-type": "application/json",
2337
+ "x-sechroom-surface": "cli"
2338
+ }
2339
+ });
2340
+ if (!response.ok)
2341
+ fail(
2342
+ `${init?.method ?? "GET"} ${path} failed (${response.status}): ${await response.text()}`
2343
+ );
2344
+ return response.json();
2345
+ }
2346
+ function parseInteger(value) {
2347
+ const parsed = Number.parseInt(value, 10);
2348
+ if (!Number.isFinite(parsed)) fail(`expected an integer, got '${value}'`);
2349
+ return parsed;
2350
+ }
2351
+ function holdHeartbeat(tick, intervalMs) {
2352
+ return new Promise((resolve3, reject) => {
2353
+ const timer = setInterval(() => void tick().catch(reject), intervalMs);
2354
+ const stop = () => {
2355
+ clearInterval(timer);
2356
+ resolve3();
2357
+ };
2358
+ process.once("SIGINT", stop);
2359
+ process.once("SIGTERM", stop);
2360
+ });
2361
+ }
2362
+
1539
2363
  // src/commands/channel.ts
1540
2364
  function registerChannel(program2) {
1541
2365
  const channel = program2.command("channel").description(
@@ -1543,13 +2367,17 @@ function registerChannel(program2) {
1543
2367
  );
1544
2368
  const withFilterOpts = (c) => c.option(
1545
2369
  "--name <name>",
1546
- "Subscription name (idempotent per name)",
1547
- "wlp-dispatch"
2370
+ "Deprecated: ignored; the installed executor selects its delivery subscription"
1548
2371
  ).option(
1549
2372
  "--tag <tag...>",
1550
- "Tag(s) the event must carry to match (repeatable). Default targets WLP task dispatches.",
1551
- ["kind:task"]
1552
- ).option("--workspace <wsp...>", "Restrict to these workspace id(s)");
2373
+ "Deprecated: executor eligibility comes from the installed capability advertisement"
2374
+ ).option(
2375
+ "--workspace <wsp...>",
2376
+ "Deprecated: workspace authority is resolved by the server"
2377
+ ).option(
2378
+ "--executor-instance <id>",
2379
+ "Deprecated: the instance is read from .sechroom/executor.json"
2380
+ );
1553
2381
  withFilterOpts(
1554
2382
  channel.command("connect").description(
1555
2383
  "Register a SignalR subscription and stream matched events to stdout"
@@ -1557,37 +2385,54 @@ function registerChannel(program2) {
1557
2385
  ).action(async (opts, cmd) => {
1558
2386
  const json = Boolean(cmd.optsWithGlobals().json);
1559
2387
  const cfg = resolveConfig(cmd.optsWithGlobals());
1560
- const filter = readFilter(opts);
1561
- const sub = await ensureSubscription(cfg, opts.name, filter);
1562
- const seen = /* @__PURE__ */ new Set();
1563
- const deliver = makeDeliver(
1564
- filter,
1565
- seen,
1566
- (payload) => process.stdout.write(
1567
- (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
1568
- )
2388
+ warnLegacyChannelOptions(opts);
2389
+ const located = requireExecutorState();
2390
+ const instance = await ensureExecutorInstance(cfg, located);
2391
+ const deliver = (payload) => process.stdout.write(
2392
+ (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
2393
+ );
2394
+ const drain = createClaimDrain(cfg, instance.id, deliver);
2395
+ const conn = await openConnection(
2396
+ cfg,
2397
+ () => {
2398
+ void drain().catch(
2399
+ (e) => process.stderr.write(err(`channel claim failed: ${String(e)}
2400
+ `))
2401
+ );
2402
+ },
2403
+ instance.id
2404
+ );
2405
+ await drain();
2406
+ const stopReconciliation = startOfferReconciliation(drain);
2407
+ const stopHeartbeat = startExecutorHeartbeat(
2408
+ () => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
2409
+ located.state.refreshAfterSeconds * 1e3
1569
2410
  );
1570
- const conn = await openConnection(cfg, deliver);
1571
- await reconcile(cfg, filter, deliver);
1572
2411
  if (json) {
1573
2412
  emit(
1574
2413
  {
1575
2414
  connected: true,
1576
2415
  tenant: cfg.tenant,
1577
- subscriptionId: sub.id ?? opts.name,
1578
- filter
2416
+ executorInstanceId: instance.id,
2417
+ instanceKey: located.state.instanceKey,
2418
+ laneId: located.state.laneId
1579
2419
  },
1580
2420
  true
1581
2421
  );
1582
2422
  } else {
1583
2423
  process.stderr.write(
1584
2424
  style.green("channel connected") + style.dim(
1585
- ` \u2014 tenant ${cfg.tenant}, sub ${sub.id ?? opts.name}, tags [${filter.tags.join(", ")}]
2425
+ ` \u2014 tenant ${cfg.tenant}, executor ${located.state.instanceKey}
1586
2426
  `
1587
2427
  ) + style.dim("streaming matched events to stdout; Ctrl-C to stop.\n")
1588
2428
  );
1589
2429
  }
1590
- await holdOpen(conn);
2430
+ try {
2431
+ await holdOpen(conn);
2432
+ } finally {
2433
+ stopReconciliation();
2434
+ stopHeartbeat();
2435
+ }
1591
2436
  });
1592
2437
  withFilterOpts(
1593
2438
  channel.command("mcp").description(
@@ -1595,18 +2440,18 @@ function registerChannel(program2) {
1595
2440
  )
1596
2441
  ).action(async (opts, cmd) => {
1597
2442
  const cfg = resolveConfig(cmd.optsWithGlobals());
1598
- const filter = readFilter(opts);
2443
+ warnLegacyChannelOptions(opts);
2444
+ const located = requireExecutorState();
2445
+ const instance = await ensureExecutorInstance(cfg, located);
1599
2446
  const mcp = new Server(
1600
2447
  { name: "sechroom", version: "0.1.0" },
1601
2448
  {
1602
2449
  capabilities: { experimental: { "claude/channel": {} } },
1603
- instructions: 'Matched Sechroom substrate events arrive as <channel source="sechroom"> tags. A WLP dispatch (kind:task \u2192 status:in-progress) means a task is runnable now: load the memory id from the event, do the work, then write a closeout memory tagged wlp-decomposition:{id} + wlp-task:{taskId} + verdict:{pass|soft-fail|plan-invalid|blocked}.'
2450
+ instructions: 'Matched Sechroom substrate events arrive as <channel source="sechroom"> tags. A WLP dispatch delivered here has already been atomically claimed for this executor. Load the memory id from the event and retain the lease and claim token for holder-bound completion.'
1604
2451
  }
1605
2452
  );
1606
2453
  await mcp.connect(new StdioServerTransport());
1607
- await ensureSubscription(cfg, opts.name, filter);
1608
- const seen = /* @__PURE__ */ new Set();
1609
- const deliver = makeDeliver(filter, seen, (payload) => {
2454
+ const deliver = (payload) => {
1610
2455
  const { content, meta } = summarizeEvent(payload);
1611
2456
  void mcp.notification({
1612
2457
  method: "notifications/claude/channel",
@@ -1615,37 +2460,53 @@ function registerChannel(program2) {
1615
2460
  (e) => process.stderr.write(err(`channel push failed: ${String(e)}
1616
2461
  `))
1617
2462
  );
1618
- });
1619
- const conn = await openConnection(cfg, deliver);
1620
- await reconcile(cfg, filter, deliver);
2463
+ };
2464
+ const drain = createClaimDrain(cfg, instance.id, deliver);
2465
+ const conn = await openConnection(
2466
+ cfg,
2467
+ () => {
2468
+ void drain().catch(
2469
+ (e) => process.stderr.write(err(`channel claim failed: ${String(e)}
2470
+ `))
2471
+ );
2472
+ },
2473
+ instance.id
2474
+ );
2475
+ await drain();
2476
+ const stopReconciliation = startOfferReconciliation(drain);
2477
+ const stopHeartbeat = startExecutorHeartbeat(
2478
+ () => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
2479
+ located.state.refreshAfterSeconds * 1e3
2480
+ );
1621
2481
  process.stderr.write(
1622
2482
  style.dim(
1623
- `sechroom channel (mcp) \u2014 tenant ${cfg.tenant}, tags [${filter.tags.join(", ")}]
2483
+ `sechroom channel (mcp) \u2014 tenant ${cfg.tenant}, executor ${located.state.instanceKey}
1624
2484
  `
1625
2485
  )
1626
2486
  );
1627
- await holdOpen(conn);
2487
+ try {
2488
+ await holdOpen(conn);
2489
+ } finally {
2490
+ stopReconciliation();
2491
+ stopHeartbeat();
2492
+ }
1628
2493
  });
1629
2494
  channel.command("install").description(
1630
2495
  "Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
1631
2496
  ).option(
1632
2497
  "--workspace <wsp...>",
1633
- "Restrict dispatches to these workspace id(s)"
2498
+ "Deprecated: accepted only to migrate an existing managed entry"
1634
2499
  ).option(
1635
2500
  "--tag <tag...>",
1636
- "Tag(s) a dispatch must carry to match (repeatable). Default targets WLP task dispatches.",
1637
- ["kind:task"]
2501
+ "Deprecated: accepted only to migrate an existing managed entry"
1638
2502
  ).option(
1639
2503
  "--name <name>",
1640
2504
  "MCP server + subscription name (idempotent per name)",
1641
2505
  "sechroom-channel"
1642
2506
  ).option("--dry-run", "Print what would change; write nothing").action((opts) => {
1643
- const path = join7(process.cwd(), ".mcp.json");
2507
+ const path = join9(process.cwd(), ".mcp.json");
1644
2508
  const dryRun = Boolean(opts.dryRun);
1645
- const args = ["channel", "mcp", "--name", opts.name];
1646
- for (const w of opts.workspace ?? [])
1647
- args.push("--workspace", w);
1648
- for (const t of opts.tag ?? []) args.push("--tag", t);
2509
+ const args = ["channel", "mcp"];
1649
2510
  const entry = { command: "sechroom", args };
1650
2511
  const config2 = readMcpConfig(path);
1651
2512
  config2.mcpServers ??= {};
@@ -1653,8 +2514,8 @@ function registerChannel(program2) {
1653
2514
  const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
1654
2515
  if (status !== "current" && !dryRun) {
1655
2516
  config2.mcpServers[opts.name] = entry;
1656
- mkdirSync5(dirname4(path), { recursive: true });
1657
- writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
2517
+ mkdirSync7(dirname6(path), { recursive: true });
2518
+ writeFileSync7(path, JSON.stringify(config2, null, 2) + "\n");
1658
2519
  }
1659
2520
  const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
1660
2521
  process.stdout.write(`${style.green("channel")} ${path} (${verb})
@@ -1673,24 +2534,125 @@ Load it (Channels research preview) by launching your agent with:
1673
2534
  )
1674
2535
  );
1675
2536
  }
1676
- warnIfSechroomNotOnPath();
1677
- });
1678
- channel.addHelpText(
1679
- "after",
1680
- `
1681
- Examples:
1682
- $ sechroom channel connect stream WLP task dispatches to stdout
1683
- $ sechroom channel connect --tag kind:task --tag status:in-progress
1684
- $ sechroom channel connect --workspace wsp_X --json | jq .
1685
-
1686
- # Wire it as a Claude Code channel MCP server (research preview, v2.1.80+):
1687
- $ sechroom channel install --workspace wsp_X --tag kind:task --tag status:in-progress
1688
- # then: claude --dangerously-load-development-channels server:sechroom-channel`
1689
- );
2537
+ warnIfSechroomNotOnPath();
2538
+ if ((opts.workspace?.length ?? 0) > 0 || (opts.tag?.length ?? 0) > 0)
2539
+ process.stderr.write(
2540
+ style.dim(
2541
+ "channel: --workspace/--tag are retired; the managed entry now uses the installed executor advertisement.\n"
2542
+ )
2543
+ );
2544
+ });
2545
+ channel.addHelpText(
2546
+ "after",
2547
+ `
2548
+ Examples:
2549
+ $ sechroom executor install configure capability + lane advertisement
2550
+ $ sechroom channel connect claim WLP dispatches and stream them to stdout
2551
+
2552
+ # Wire it as a Claude Code channel MCP server (research preview, v2.1.80+):
2553
+ $ sechroom channel install migrate/install the exact-instance channel
2554
+ # then: claude --dangerously-load-development-channels server:sechroom-channel`
2555
+ );
2556
+ }
2557
+ function requireExecutorState() {
2558
+ const located = readExecutorState();
2559
+ if (!located)
2560
+ return fail(
2561
+ "channel requires an installed executor advertisement; run `sechroom executor install` first."
2562
+ );
2563
+ return located;
2564
+ }
2565
+ function warnLegacyChannelOptions(opts) {
2566
+ if (!opts.name && (opts.workspace?.length ?? 0) === 0 && (opts.tag?.length ?? 0) === 0 && !opts.executorInstance)
2567
+ return;
2568
+ process.stderr.write(
2569
+ style.dim(
2570
+ "channel: --name, --workspace, --tag, and --executor-instance are retired; delivery, eligibility, and identity come from the installed executor advertisement.\n"
2571
+ )
2572
+ );
2573
+ }
2574
+ function createClaimDrain(cfg, executorInstanceId, deliver, dependencies = {}) {
2575
+ let active2;
2576
+ const state = {};
2577
+ return () => {
2578
+ active2 ??= drainClaims(cfg, executorInstanceId, deliver, {
2579
+ ...dependencies,
2580
+ state
2581
+ }).finally(() => {
2582
+ active2 = void 0;
2583
+ });
2584
+ return active2;
2585
+ };
2586
+ }
2587
+ function startOfferReconciliation(drain, intervalMilliseconds = 5e3, dependencies = {}) {
2588
+ const schedule = dependencies.setInterval ?? setInterval;
2589
+ const cancel = dependencies.clearInterval ?? clearInterval;
2590
+ const onError = dependencies.onError ?? ((error) => process.stderr.write(err(`channel claim failed: ${String(error)}
2591
+ `)));
2592
+ const timer = schedule(() => {
2593
+ void drain().catch(onError);
2594
+ }, intervalMilliseconds);
2595
+ return () => cancel(timer);
2596
+ }
2597
+ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}) {
2598
+ const schedule = dependencies.setInterval ?? setInterval;
2599
+ const cancel = dependencies.clearInterval ?? clearInterval;
2600
+ const onError = dependencies.onError ?? ((error) => process.stderr.write(err(`channel heartbeat failed: ${String(error)}
2601
+ `)));
2602
+ const timer = schedule(() => {
2603
+ void refresh().catch(onError);
2604
+ }, intervalMilliseconds);
2605
+ return () => cancel(timer);
2606
+ }
2607
+ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
2608
+ const request = dependencies.request ?? api;
2609
+ const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)));
2610
+ const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
2611
+ const state = dependencies.state ?? {};
2612
+ for (; ; ) {
2613
+ if (state.pendingIdempotencyKey) {
2614
+ const replay = await request(
2615
+ cfg,
2616
+ `/me/executor-instances/${encodeURIComponent(executorInstanceId)}/dispatch-offers/claim-next`,
2617
+ {
2618
+ method: "POST",
2619
+ body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
2620
+ }
2621
+ );
2622
+ state.pendingIdempotencyKey = void 0;
2623
+ if (replay.outcome === "Claimed" || replay.outcome === "AlreadyHeld") {
2624
+ deliver(replay);
2625
+ continue;
2626
+ }
2627
+ return;
2628
+ }
2629
+ const offers = await request(
2630
+ cfg,
2631
+ `/me/executor-instances/${encodeURIComponent(executorInstanceId)}/dispatch-offers`
2632
+ );
2633
+ const offer = offers[0];
2634
+ if (!offer) return;
2635
+ if (offer.suggestedClaimDelayMs > 0)
2636
+ await sleep(offer.suggestedClaimDelayMs);
2637
+ state.pendingIdempotencyKey = idempotencyKey(offer);
2638
+ const claim = await request(
2639
+ cfg,
2640
+ `/me/executor-instances/${encodeURIComponent(executorInstanceId)}/dispatch-offers/claim-next`,
2641
+ {
2642
+ method: "POST",
2643
+ body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
2644
+ }
2645
+ );
2646
+ state.pendingIdempotencyKey = void 0;
2647
+ if (claim.outcome === "NoOffer") return;
2648
+ if (claim.outcome === "Claimed" || claim.outcome === "AlreadyHeld")
2649
+ deliver(claim);
2650
+ else return;
2651
+ }
1690
2652
  }
1691
2653
  function readMcpConfig(path) {
1692
- if (!existsSync6(path)) return {};
1693
- const raw = readFileSync4(path, "utf8");
2654
+ if (!existsSync8(path)) return {};
2655
+ const raw = readFileSync6(path, "utf8");
1694
2656
  if (!raw.trim()) return {};
1695
2657
  try {
1696
2658
  return JSON.parse(raw);
@@ -1700,35 +2662,9 @@ function readMcpConfig(path) {
1700
2662
  );
1701
2663
  }
1702
2664
  }
1703
- function readFilter(opts) {
1704
- const tags = opts.tag ?? [];
1705
- const workspaceScope = opts.workspace ?? [];
1706
- if (tags.length === 0 && workspaceScope.length === 0)
1707
- fail(
1708
- "A channel subscription needs at least one --tag or --workspace (an empty filter receives nothing)."
1709
- );
1710
- return { tags, workspaceScope };
1711
- }
1712
- async function ensureSubscription(cfg, name, filter) {
1713
- const token = await requireToken(cfg);
1714
- const resp = await fetch(`${cfg.baseUrl}/me/delivery-subscriptions/signalr`, {
1715
- method: "POST",
1716
- headers: {
1717
- authorization: `Bearer ${token}`,
1718
- tenant: cfg.tenant,
1719
- "content-type": "application/json",
1720
- "x-sechroom-surface": "cli"
1721
- },
1722
- body: JSON.stringify({ name, enabled: true, filter })
1723
- });
1724
- if (!resp.ok)
1725
- fail(
1726
- `Could not register the SignalR subscription (HTTP ${resp.status}): ${await resp.text()}`
1727
- );
1728
- return await resp.json();
1729
- }
1730
- async function openConnection(cfg, onEvent) {
1731
- const conn = new HubConnectionBuilder().withUrl(`${cfg.baseUrl}/notifications/${cfg.tenant}`, {
2665
+ async function openConnection(cfg, onEvent, executorInstanceId) {
2666
+ const query = executorInstanceId ? `?executorInstanceId=${encodeURIComponent(executorInstanceId)}` : "";
2667
+ const conn = new HubConnectionBuilder().withUrl(`${cfg.baseUrl}/notifications/${cfg.tenant}${query}`, {
1732
2668
  transport: HttpTransportType.LongPolling,
1733
2669
  accessTokenFactory: () => requireToken(cfg)
1734
2670
  }).withAutomaticReconnect().build();
@@ -1762,7 +2698,8 @@ function parseEvent(payload) {
1762
2698
  }
1763
2699
  }
1764
2700
  const obj = data ?? {};
1765
- const inner = obj.data ?? obj;
2701
+ const envelope = obj.data ?? obj;
2702
+ const inner = envelope.offer ?? envelope;
1766
2703
  const rawTags = inner.tags ?? inner.Tags;
1767
2704
  return {
1768
2705
  eventType: str(inner.eventType ?? inner.EventType ?? obj.type) || "substrate.event",
@@ -1771,113 +2708,6 @@ function parseEvent(payload) {
1771
2708
  tags: Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : void 0
1772
2709
  };
1773
2710
  }
1774
- function shouldDeliver(payload, filter) {
1775
- const { workspaceId, tags } = parseEvent(payload);
1776
- if (filter.workspaceScope.length > 0 && (!workspaceId || !filter.workspaceScope.includes(workspaceId)))
1777
- return false;
1778
- if (filter.tags.length > 0) {
1779
- if (!tags) return false;
1780
- return facetedTagMatch(tags, filter.tags);
1781
- }
1782
- return true;
1783
- }
1784
- function makeDeliver(filter, seen, forward) {
1785
- return (payload) => {
1786
- if (!shouldDeliver(payload, filter)) return;
1787
- const { memoryId } = parseEvent(payload);
1788
- if (memoryId) {
1789
- if (seen.has(memoryId)) return;
1790
- seen.add(memoryId);
1791
- }
1792
- forward(payload);
1793
- };
1794
- }
1795
- async function reconcile(cfg, filter, deliver) {
1796
- if (filter.workspaceScope.length === 0) {
1797
- process.stderr.write(
1798
- style.dim(
1799
- "channel: no --workspace to reconcile against; live feed only (a dropped dispatch won't be recovered).\n"
1800
- )
1801
- );
1802
- return;
1803
- }
1804
- if (filter.tags.length === 0) return;
1805
- let token;
1806
- try {
1807
- token = await requireToken(cfg);
1808
- } catch {
1809
- return;
1810
- }
1811
- const qs = `filterTags=${encodeURIComponent(filter.tags.join(","))}&limit=100`;
1812
- let recovered = 0;
1813
- for (const ws of filter.workspaceScope) {
1814
- try {
1815
- const resp = await fetch(
1816
- `${cfg.baseUrl}/workspaces/${encodeURIComponent(ws)}/memories/feed?${qs}`,
1817
- {
1818
- headers: {
1819
- authorization: `Bearer ${token}`,
1820
- tenant: cfg.tenant,
1821
- "x-sechroom-surface": "cli"
1822
- }
1823
- }
1824
- );
1825
- if (!resp.ok) {
1826
- process.stderr.write(
1827
- err(
1828
- `channel: reconcile query for ${ws} failed (HTTP ${resp.status})
1829
- `
1830
- )
1831
- );
1832
- continue;
1833
- }
1834
- const data = await resp.json();
1835
- for (const m of data.results ?? []) {
1836
- if (!m.id) continue;
1837
- deliver({
1838
- eventType: "reconcile",
1839
- memoryId: m.id,
1840
- workspaceId: ws,
1841
- tags: m.tags ?? []
1842
- });
1843
- recovered++;
1844
- }
1845
- } catch (e) {
1846
- process.stderr.write(
1847
- err(`channel: reconcile error for ${ws}: ${String(e)}
1848
- `)
1849
- );
1850
- }
1851
- }
1852
- if (recovered > 0)
1853
- process.stderr.write(
1854
- style.dim(
1855
- `channel: reconciled ${recovered} already-queued event(s) on connect.
1856
- `
1857
- )
1858
- );
1859
- }
1860
- function facetedTagMatch(eventTags, filterTags) {
1861
- const have = new Set(eventTags);
1862
- const groups = /* @__PURE__ */ new Map();
1863
- for (const f of filterTags) {
1864
- const ns = f.endsWith(":*") ? f.slice(0, -2) : namespaceOf(f);
1865
- const group = groups.get(ns) ?? [];
1866
- group.push(f);
1867
- groups.set(ns, group);
1868
- }
1869
- for (const [ns, group] of groups) {
1870
- const ok2 = group.some(
1871
- (f) => f.endsWith(":*") ? eventTags.some((t) => namespaceOf(t) === ns) : have.has(f)
1872
- );
1873
- if (!ok2) return false;
1874
- }
1875
- return true;
1876
- }
1877
- function namespaceOf(tag) {
1878
- const i = tag.indexOf(":");
1879
- return i >= 0 ? tag.slice(0, i) : tag;
1880
- }
1881
2711
  function summarizeEvent(payload) {
1882
2712
  const { eventType, memoryId, workspaceId } = parseEvent(payload);
1883
2713
  const content = memoryId ? `${eventType}: ${memoryId}${workspaceId ? ` (workspace ${workspaceId})` : ""}` : typeof payload === "string" ? payload : JSON.stringify(payload);
@@ -1885,6 +2715,10 @@ function summarizeEvent(payload) {
1885
2715
  if (eventType) meta.event_type = eventType;
1886
2716
  if (memoryId) meta.memory_id = memoryId;
1887
2717
  if (workspaceId) meta.workspace_id = workspaceId;
2718
+ const claim = payload ?? {};
2719
+ if (claim.outcome) meta.claim_outcome = claim.outcome;
2720
+ if (claim.lease?.id) meta.lease_id = claim.lease.id;
2721
+ if (claim.claimToken) meta.claim_token = claim.claimToken;
1888
2722
  return { content, meta };
1889
2723
  }
1890
2724
  function str(v) {
@@ -1974,177 +2808,13 @@ Examples:
1974
2808
  }
1975
2809
 
1976
2810
  // src/commands/checkpoint.ts
1977
- import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
1978
- import { dirname as dirname7, join as join10 } from "path";
2811
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9 } from "fs";
2812
+ import { dirname as dirname8, join as join11 } from "path";
1979
2813
 
1980
2814
  // src/commands/hook.ts
1981
2815
  import { createHash as createHash2 } from "crypto";
1982
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, statSync as statSync2, writeFileSync as writeFileSync7 } from "fs";
1983
- import { dirname as dirname6, join as join9 } from "path";
1984
-
1985
- // src/sem.ts
1986
- import { dirname as dirname5, join as join8 } from "path";
1987
- import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync6 } from "fs";
1988
- var SEM_FILE = join8(".sechroom", "lane.json");
1989
- var STATE_DIR_NAME2 = ".sechroom";
1990
- function localSemPath(cwd = process.cwd()) {
1991
- return join8(cwd, SEM_FILE);
1992
- }
1993
- function resolveSemPathForRead(start = process.cwd()) {
1994
- let dir = start;
1995
- while (true) {
1996
- const candidate = join8(dir, SEM_FILE);
1997
- if (existsSync7(candidate)) return candidate;
1998
- const parent = dirname5(dir);
1999
- if (parent === dir) return void 0;
2000
- dir = parent;
2001
- }
2002
- }
2003
- function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
2004
- try {
2005
- let dir = start;
2006
- let gitPath;
2007
- for (; ; ) {
2008
- const candidate = join8(dir, ".git");
2009
- if (existsSync7(candidate)) {
2010
- gitPath = candidate;
2011
- break;
2012
- }
2013
- const parent = dirname5(dir);
2014
- if (parent === dir) break;
2015
- dir = parent;
2016
- }
2017
- if (!gitPath || statSync(gitPath).isDirectory()) return lane;
2018
- const gitFile = readFileSync5(gitPath, "utf8");
2019
- const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
2020
- if (!common) return lane;
2021
- const worktreesDir = join8(common[1], "worktrees");
2022
- const siblings = readdirSync(worktreesDir).filter((n) => {
2023
- try {
2024
- return statSync(join8(worktreesDir, n)).isDirectory();
2025
- } catch {
2026
- return false;
2027
- }
2028
- });
2029
- return laneWithWorktreeSuffix(lane, gitFile, siblings);
2030
- } catch {
2031
- return lane;
2032
- }
2033
- }
2034
- function laneWithWorktreeSuffix(lane, gitFile, siblings) {
2035
- const m = gitFile.trim().match(/\/worktrees\/([^/\s]+)\s*$/);
2036
- if (!m) return lane;
2037
- const idx = [...siblings].sort().indexOf(m[1]);
2038
- return idx < 0 ? lane : `${lane}-${idx + 2}`;
2039
- }
2040
- function serializeSem(values) {
2041
- return JSON.stringify(values, null, 2) + "\n";
2042
- }
2043
- function readSem(path) {
2044
- const p = path ?? resolveSemPathForRead();
2045
- if (!p || !existsSync7(p)) return void 0;
2046
- return { path: p, values: parseLaneJson(readFileSync5(p, "utf8")) };
2047
- }
2048
- function readLocalSemValues(cwd = process.cwd()) {
2049
- const next = join8(cwd, SEM_FILE);
2050
- if (existsSync7(next)) return readSem(next)?.values ?? {};
2051
- return {};
2052
- }
2053
- function parseLaneJson(text2) {
2054
- try {
2055
- const parsed = JSON.parse(text2);
2056
- const out = {};
2057
- for (const [k, v] of Object.entries(parsed)) {
2058
- if (typeof v === "string") out[k] = v;
2059
- }
2060
- return out;
2061
- } catch {
2062
- return {};
2063
- }
2064
- }
2065
- var STATE_DIR_IGNORE = `${STATE_DIR_NAME2}/`;
2066
- function writeSem(values, path = localSemPath()) {
2067
- mkdirSync6(dirname5(path), { recursive: true });
2068
- writeFileSync6(path, serializeSem(values));
2069
- ensureSemIgnored(path);
2070
- ensureContinuityScaffold(path);
2071
- return path;
2072
- }
2073
- function ensureStateDirIgnored(cwd = process.cwd()) {
2074
- ensureSemIgnored(localSemPath(cwd));
2075
- }
2076
- var CONTINUITY_FILE_NAME = "continuity.json";
2077
- var CONTINUITY_SCAFFOLD = JSON.stringify(
2078
- {
2079
- _readme: "Agent-maintained continuity intent. Keep these current during the session; `sechroom checkpoint` and the PreCompact hook snapshot from here. The five required fields (objective, state, lastAction, nextAction, resumeInstruction) must all be non-empty for a snapshot to be created.",
2080
- objective: "",
2081
- state: "",
2082
- lastAction: "",
2083
- nextAction: "",
2084
- resumeInstruction: "",
2085
- constraints: [],
2086
- questions: [],
2087
- artifacts: [],
2088
- confidence: null
2089
- },
2090
- null,
2091
- 2
2092
- ) + "\n";
2093
- function ensureContinuityScaffold(semPath) {
2094
- try {
2095
- const target = join8(dirname5(semPath), CONTINUITY_FILE_NAME);
2096
- if (existsSync7(target)) return;
2097
- writeFileSync6(target, CONTINUITY_SCAFFOLD);
2098
- } catch {
2099
- }
2100
- }
2101
- function ignoresSem(content) {
2102
- return content.split("\n").some((line) => {
2103
- const t = line.trim();
2104
- return t === STATE_DIR_NAME2 || t === STATE_DIR_IGNORE || t === `/${STATE_DIR_NAME2}` || t === `/${STATE_DIR_IGNORE}` || t === `**/${STATE_DIR_NAME2}` || t === `**/${STATE_DIR_IGNORE}`;
2105
- });
2106
- }
2107
- function inGitRepo(startDir) {
2108
- let dir = startDir;
2109
- for (; ; ) {
2110
- if (existsSync7(join8(dir, ".git"))) return true;
2111
- const parent = dirname5(dir);
2112
- if (parent === dir) return false;
2113
- dir = parent;
2114
- }
2115
- }
2116
- function resolveGitignoreTarget(startDir) {
2117
- let dir = startDir;
2118
- for (; ; ) {
2119
- const gi = join8(dir, ".gitignore");
2120
- if (existsSync7(gi)) return { path: gi, exists: true };
2121
- const parent = dirname5(dir);
2122
- if (existsSync7(join8(dir, ".git")) || parent === dir) {
2123
- return { path: join8(startDir, ".gitignore"), exists: false };
2124
- }
2125
- dir = parent;
2126
- }
2127
- }
2128
- function ensureSemIgnored(semPath) {
2129
- try {
2130
- const checkoutDir = dirname5(dirname5(semPath));
2131
- if (!inGitRepo(checkoutDir)) return;
2132
- const target = resolveGitignoreTarget(checkoutDir);
2133
- if (target.exists) {
2134
- const content = readFileSync5(target.path, "utf8");
2135
- if (ignoresSem(content)) return;
2136
- const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
2137
- appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
2138
- `);
2139
- } else {
2140
- writeFileSync6(target.path, `${STATE_DIR_IGNORE}
2141
- `);
2142
- }
2143
- } catch {
2144
- }
2145
- }
2146
-
2147
- // src/commands/hook.ts
2816
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "fs";
2817
+ import { dirname as dirname7, join as join10 } from "path";
2148
2818
  async function readStdin() {
2149
2819
  if (process.stdin.isTTY) return "";
2150
2820
  const chunks = [];
@@ -2168,13 +2838,13 @@ function resolveLane(flagLane, cwd) {
2168
2838
  if (!base) return void 0;
2169
2839
  return applyWorktreeLaneSuffix(base, start);
2170
2840
  }
2171
- var INTENT_FILE = join9(".sechroom", "continuity.json");
2841
+ var INTENT_FILE = join10(".sechroom", "continuity.json");
2172
2842
  function resolveIntentPath(start) {
2173
2843
  let dir = start;
2174
2844
  for (; ; ) {
2175
- const candidate = join9(dir, INTENT_FILE);
2176
- if (existsSync8(candidate)) return candidate;
2177
- const parent = dirname6(dir);
2845
+ const candidate = join10(dir, INTENT_FILE);
2846
+ if (existsSync9(candidate)) return candidate;
2847
+ const parent = dirname7(dir);
2178
2848
  if (parent === dir) return void 0;
2179
2849
  dir = parent;
2180
2850
  }
@@ -2183,7 +2853,7 @@ function readIntent(start) {
2183
2853
  const path = resolveIntentPath(start);
2184
2854
  if (!path) return void 0;
2185
2855
  try {
2186
- return JSON.parse(readFileSync6(path, "utf8"));
2856
+ return JSON.parse(readFileSync7(path, "utf8"));
2187
2857
  } catch {
2188
2858
  return void 0;
2189
2859
  }
@@ -2225,14 +2895,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
2225
2895
  }
2226
2896
  function ledgerPath(start) {
2227
2897
  const intent = resolveIntentPath(start);
2228
- const dir = intent ? dirname6(intent) : join9(start, ".sechroom");
2229
- return join9(dir, ".checkpoint-state.json");
2898
+ const dir = intent ? dirname7(intent) : join10(start, ".sechroom");
2899
+ return join10(dir, ".checkpoint-state.json");
2230
2900
  }
2231
2901
  function readLedger(start) {
2232
2902
  try {
2233
2903
  const p = ledgerPath(start);
2234
- if (!existsSync8(p)) return {};
2235
- return JSON.parse(readFileSync6(p, "utf8"));
2904
+ if (!existsSync9(p)) return {};
2905
+ return JSON.parse(readFileSync7(p, "utf8"));
2236
2906
  } catch {
2237
2907
  return {};
2238
2908
  }
@@ -2279,13 +2949,13 @@ function recordPush(start, intent) {
2279
2949
  } catch {
2280
2950
  mtimeMs = void 0;
2281
2951
  }
2282
- mkdirSync7(dirname6(p), { recursive: true });
2952
+ mkdirSync8(dirname7(p), { recursive: true });
2283
2953
  const ledger = {
2284
2954
  lastEpochMs: Date.now(),
2285
2955
  lastMtimeMs: mtimeMs,
2286
2956
  lastHash: intentHash(intent)
2287
2957
  };
2288
- writeFileSync7(p, JSON.stringify(ledger) + "\n");
2958
+ writeFileSync8(p, JSON.stringify(ledger) + "\n");
2289
2959
  } catch {
2290
2960
  }
2291
2961
  }
@@ -2431,6 +3101,12 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2431
3101
  const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2432
3102
  process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2433
3103
  `);
3104
+ if (dryRun) {
3105
+ for (const [event, command] of Object.entries(hookCommandsForSurface("codex"))) {
3106
+ process.stdout.write(` ${event}: ${command}
3107
+ `);
3108
+ }
3109
+ }
2434
3110
  for (const r of surfaceResults) {
2435
3111
  results.push(r);
2436
3112
  process.stdout.write(describe(r, dryRun) + "\n");
@@ -2532,10 +3208,10 @@ Examples:
2532
3208
  const client = await makeClient(cfg);
2533
3209
  return client.POST("/continuity/snapshots", { body });
2534
3210
  });
2535
- const path = resolveIntentPath(cwd) ?? join10(cwd, INTENT_FILE);
3211
+ const path = resolveIntentPath(cwd) ?? join11(cwd, INTENT_FILE);
2536
3212
  const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
2537
- mkdirSync8(dirname7(path), { recursive: true });
2538
- writeFileSync8(path, JSON.stringify(fileBody, null, 2) + "\n");
3213
+ mkdirSync9(dirname8(path), { recursive: true });
3214
+ writeFileSync9(path, JSON.stringify(fileBody, null, 2) + "\n");
2539
3215
  recordPush(cwd, merged);
2540
3216
  if (json) {
2541
3217
  emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
@@ -2549,7 +3225,7 @@ Examples:
2549
3225
  }
2550
3226
 
2551
3227
  // src/commands/close.ts
2552
- import { readFileSync as readFileSync7 } from "fs";
3228
+ import { readFileSync as readFileSync8 } from "fs";
2553
3229
  var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
2554
3230
  function registerClose(program2) {
2555
3231
  program2.command("close").description(
@@ -2566,6 +3242,9 @@ function registerClose(program2) {
2566
3242
  ).option(
2567
3243
  "--no-status-flip",
2568
3244
  "Skip the status flip on a BARE task. (Managed runs never flip here \u2014 the run engine reflects the verdict onto the task on resume.)"
3245
+ ).option(
3246
+ "--to-version <n>",
3247
+ "Pin the Reference edge at this task version \u2014 the DISPATCHED version the executor worked against (D-continuity-2). Default: the task's current version (status flips are metadata edits that don't bump the version, so current == dispatched in the normal case; pass this when the task's content changed between dispatch and close)."
2569
3248
  ).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
2570
3249
  "after",
2571
3250
  `
@@ -2587,7 +3266,7 @@ Examples:
2587
3266
  );
2588
3267
  let bodyText;
2589
3268
  try {
2590
- bodyText = opts.file ? readFileSync7(opts.file, "utf8") : readFileSync7(0, "utf8");
3269
+ bodyText = opts.file ? readFileSync8(opts.file, "utf8") : readFileSync8(0, "utf8");
2591
3270
  } catch {
2592
3271
  fail(
2593
3272
  opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
@@ -2599,6 +3278,7 @@ Examples:
2599
3278
  );
2600
3279
  const client = await makeClient(cfg);
2601
3280
  let matchTags;
3281
+ let dispatchedVersion;
2602
3282
  if (opts.decomposition) {
2603
3283
  const run = await runApi(
2604
3284
  "Reading run contract",
@@ -2616,6 +3296,8 @@ Examples:
2616
3296
  `run ${opts.decomposition} is awaiting task ${run.awaitingTaskId}, not ${opts.task} \u2014 refusing to mis-key the resume.`
2617
3297
  );
2618
3298
  matchTags = await_.matchTags;
3299
+ if (typeof await_.dispatchedTaskVersion === "number")
3300
+ dispatchedVersion = await_.dispatchedTaskVersion;
2619
3301
  } else {
2620
3302
  matchTags = [`wlp-task:${opts.task}`];
2621
3303
  }
@@ -2642,11 +3324,34 @@ Examples:
2642
3324
  }
2643
3325
  })
2644
3326
  );
3327
+ let toVersion;
3328
+ if (opts.toVersion !== void 0) {
3329
+ toVersion = Number(opts.toVersion);
3330
+ if (!Number.isInteger(toVersion) || toVersion < 1)
3331
+ fail(
3332
+ `--to-version must be an integer >= 1 \u2014 got '${opts.toVersion}'`
3333
+ );
3334
+ } else if (dispatchedVersion !== void 0) {
3335
+ toVersion = dispatchedVersion;
3336
+ } else {
3337
+ const task = await runApi(
3338
+ "Reading task version",
3339
+ async () => client.GET("/memories/{memoryId}", {
3340
+ params: { path: { memoryId: opts.task } }
3341
+ })
3342
+ );
3343
+ const v = task?.item?.currentVersion ?? task?.currentVersion;
3344
+ if (typeof v !== "number" || v < 1)
3345
+ fail(
3346
+ `could not read task ${opts.task} version to pin the Reference edge \u2014 pass --to-version <n>`
3347
+ );
3348
+ toVersion = v;
3349
+ }
2645
3350
  const edge = await runApi(
2646
3351
  "Wiring Reference edge",
2647
3352
  async () => client.POST("/memories/{memoryId}/relationships", {
2648
3353
  params: { path: { memoryId: closeout.id } },
2649
- body: { toMemoryId: opts.task, type: "Reference" }
3354
+ body: { toMemoryId: opts.task, type: "Reference", toVersion }
2650
3355
  })
2651
3356
  );
2652
3357
  let taskStatus;
@@ -2862,6 +3567,7 @@ function registerDecomposition(program2) {
2862
3567
  Examples:
2863
3568
  $ sechroom decomposition decompose mem_XXXX
2864
3569
  $ sechroom decomposition execute sug_XXXX
3570
+ $ sechroom decomposition publish-run sug_XXXX
2865
3571
  $ sechroom decomposition accept sug_XXXX
2866
3572
  $ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
2867
3573
  );
@@ -2899,6 +3605,23 @@ Examples:
2899
3605
  cmd.optsWithGlobals().json
2900
3606
  );
2901
3607
  });
3608
+ decomposition.command("publish-run <decompositionId>").description(
3609
+ "Publish an accepted decomposition's context pack on demand (POST /decompositions/{id}/publish-run)"
3610
+ ).action(async (decompositionId, _opts, cmd) => {
3611
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3612
+ const data = await runApi("Publishing context pack", async () => {
3613
+ const client = await makeClient(cfg);
3614
+ return client.POST("/decompositions/{id}/publish-run", {
3615
+ params: { path: { id: decompositionId } },
3616
+ body: {}
3617
+ });
3618
+ });
3619
+ emitAction(
3620
+ `published ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
3621
+ data,
3622
+ cmd.optsWithGlobals().json
3623
+ );
3624
+ });
2902
3625
  decomposition.command("accept <decompositionId>").description(
2903
3626
  "Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
2904
3627
  ).action(async (decompositionId, _opts, cmd) => {
@@ -3446,8 +4169,8 @@ Examples:
3446
4169
 
3447
4170
  // src/setup/apply.ts
3448
4171
  import { createHash as createHash3 } from "crypto";
3449
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3450
- import { dirname as dirname8 } from "path";
4172
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync9, writeFileSync as writeFileSync10, existsSync as existsSync10 } from "fs";
4173
+ import { dirname as dirname9 } from "path";
3451
4174
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
3452
4175
  var MARKER_END = "<!-- @sechroom/cli:end";
3453
4176
  function normalizeBody(s) {
@@ -3500,22 +4223,22 @@ function parseManagedBlock(content, block) {
3500
4223
  return null;
3501
4224
  }
3502
4225
  function ensureDir2(path) {
3503
- mkdirSync9(dirname8(path), { recursive: true });
4226
+ mkdirSync10(dirname9(path), { recursive: true });
3504
4227
  }
3505
4228
  function readOr(path, fallback) {
3506
4229
  try {
3507
- return readFileSync8(path, "utf8");
4230
+ return readFileSync9(path, "utf8");
3508
4231
  } catch {
3509
4232
  return fallback;
3510
4233
  }
3511
4234
  }
3512
4235
  function mergeMcpJson(path, snippet, dryRun) {
3513
4236
  const incoming = JSON.parse(snippet);
3514
- const existed = existsSync9(path);
4237
+ const existed = existsSync10(path);
3515
4238
  let current = {};
3516
4239
  if (existed) {
3517
4240
  try {
3518
- current = JSON.parse(readFileSync8(path, "utf8"));
4241
+ current = JSON.parse(readFileSync9(path, "utf8"));
3519
4242
  } catch {
3520
4243
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
3521
4244
  }
@@ -3523,26 +4246,26 @@ function mergeMcpJson(path, snippet, dryRun) {
3523
4246
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
3524
4247
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
3525
4248
  ensureDir2(path);
3526
- writeFileSync9(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
4249
+ writeFileSync10(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
3527
4250
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
3528
4251
  }
3529
4252
  function mergeCodexToml(path, snippet, dryRun) {
3530
- const existed = existsSync9(path);
4253
+ const existed = existsSync10(path);
3531
4254
  let body = readOr(path, "");
3532
4255
  body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
3533
4256
  const trimmed = body.trim();
3534
4257
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
3535
4258
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
3536
4259
  ensureDir2(path);
3537
- writeFileSync9(path, next, { mode: 384 });
4260
+ writeFileSync10(path, next, { mode: 384 });
3538
4261
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
3539
4262
  }
3540
4263
  function writeInstructionBlock(path, write, dryRun) {
3541
- const existed = existsSync9(path);
4264
+ const existed = existsSync10(path);
3542
4265
  const next = computeBlockFile(readOr(path, ""), write);
3543
4266
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
3544
4267
  ensureDir2(path);
3545
- writeFileSync9(path, next);
4268
+ writeFileSync10(path, next);
3546
4269
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
3547
4270
  }
3548
4271
  function computeBlockFile(current, write) {
@@ -3583,7 +4306,7 @@ function applyBlock(path, write, mode, dryRun) {
3583
4306
  const next = computeBlockFile(current, write);
3584
4307
  if (!dryRun) {
3585
4308
  ensureDir2(proposedPath);
3586
- writeFileSync9(proposedPath, next);
4309
+ writeFileSync10(proposedPath, next);
3587
4310
  }
3588
4311
  return {
3589
4312
  kind: "instruction",
@@ -3713,8 +4436,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
3713
4436
  }
3714
4437
 
3715
4438
  // src/setup/skills-offer.ts
3716
- import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync10 } from "fs";
3717
- import { join as join11 } from "path";
4439
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync11 } from "fs";
4440
+ import { join as join12 } from "path";
3718
4441
 
3719
4442
  // src/setup/lane-pin.ts
3720
4443
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -3830,8 +4553,8 @@ Found ${summary} available to you for ${surface}.
3830
4553
  if (skills.length > 0) {
3831
4554
  const written = [];
3832
4555
  for (const s of skills) {
3833
- mkdirSync10(join11(sDir, s.name), { recursive: true });
3834
- writeFileSync10(join11(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
4556
+ mkdirSync11(join12(sDir, s.name), { recursive: true });
4557
+ writeFileSync11(join12(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
3835
4558
  written.push(s.name);
3836
4559
  }
3837
4560
  recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -3839,11 +4562,11 @@ Found ${summary} available to you for ${surface}.
3839
4562
  `);
3840
4563
  }
3841
4564
  if (agents.length > 0) {
3842
- mkdirSync10(aDir, { recursive: true });
4565
+ mkdirSync11(aDir, { recursive: true });
3843
4566
  const written = [];
3844
4567
  for (const a of agents) {
3845
4568
  const file = `${a.name}.md`;
3846
- writeFileSync10(join11(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
4569
+ writeFileSync11(join12(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
3847
4570
  written.push(file);
3848
4571
  }
3849
4572
  recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -4226,13 +4949,13 @@ Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it
4226
4949
  }
4227
4950
 
4228
4951
  // src/commands/onboard.ts
4229
- import { existsSync as existsSync11 } from "fs";
4230
- import { basename as basename2, join as join13 } from "path";
4952
+ import { existsSync as existsSync12 } from "fs";
4953
+ import { basename as basename2, join as join14 } from "path";
4231
4954
 
4232
4955
  // src/commands/fanout.ts
4233
4956
  import { spawnSync } from "child_process";
4234
- import { existsSync as existsSync10, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4235
- import { isAbsolute, join as join12, resolve } from "path";
4957
+ import { existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4958
+ import { isAbsolute, join as join13, resolve } from "path";
4236
4959
  var ICON = {
4237
4960
  refresh: "\u21BB",
4238
4961
  bind: "+",
@@ -4252,21 +4975,21 @@ function discoverChildren(root) {
4252
4975
  const out = [];
4253
4976
  for (const name of names.sort()) {
4254
4977
  if (name.startsWith(".") || name === "node_modules") continue;
4255
- const dir = join12(root, name);
4978
+ const dir = join13(root, name);
4256
4979
  try {
4257
4980
  if (!statSync3(dir).isDirectory()) continue;
4258
4981
  } catch {
4259
4982
  continue;
4260
4983
  }
4261
- if (existsSync10(join12(dir, ".git")) || committedBindingPath(dir)) out.push(name);
4984
+ if (existsSync11(join13(dir, ".git")) || committedBindingPath(dir)) out.push(name);
4262
4985
  }
4263
4986
  return out;
4264
4987
  }
4265
4988
  function readManifest(path) {
4266
- if (!existsSync10(path)) return null;
4989
+ if (!existsSync11(path)) return null;
4267
4990
  let parsed;
4268
4991
  try {
4269
- parsed = JSON.parse(readFileSync9(path, "utf8"));
4992
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
4270
4993
  } catch (err2) {
4271
4994
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
4272
4995
  }
@@ -4632,10 +5355,10 @@ async function chooseScope(scopeFlag, yes) {
4632
5355
  }
4633
5356
  async function planRecurseChild(entry, root, client, opts) {
4634
5357
  const dir = resolveChildDir(entry.path, root);
4635
- if (!existsSync11(dir)) {
5358
+ if (!existsSync12(dir)) {
4636
5359
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
4637
5360
  }
4638
- if (existsSync11(join13(dir, ".sechroom.json"))) {
5361
+ if (existsSync12(join14(dir, ".sechroom.json"))) {
4639
5362
  return {
4640
5363
  label: entry.path,
4641
5364
  dir,
@@ -4708,7 +5431,7 @@ This fan-out will pin the same lane in every repo:
4708
5431
  async function runRecurse(cfg, g, opts) {
4709
5432
  const { yes, dryRun, json } = opts;
4710
5433
  const root = process.cwd();
4711
- const manifestPath = join13(root, ".sechroom", "repos.json");
5434
+ const manifestPath = join14(root, ".sechroom", "repos.json");
4712
5435
  const fromManifest = readManifest(manifestPath);
4713
5436
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
4714
5437
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -5241,23 +5964,23 @@ Examples:
5241
5964
 
5242
5965
  // src/commands/reset.ts
5243
5966
  import { homedir as homedir4 } from "os";
5244
- import { join as join14 } from "path";
5245
- import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
5967
+ import { join as join15 } from "path";
5968
+ import { existsSync as existsSync13, readFileSync as readFileSync11, rmSync as rmSync3 } from "fs";
5246
5969
  var SKILLS_LOCK2 = ".sechroom-skills.json";
5247
- var localSkillsDir = () => join14(process.cwd(), ".claude", "skills");
5248
- var globalSkillsDir = () => join14(homedir4(), ".claude", "skills");
5249
- var localAgentsDir = () => join14(process.cwd(), ".claude", "agents");
5250
- var globalAgentsDir = () => join14(homedir4(), ".claude", "agents");
5970
+ var localSkillsDir = () => join15(process.cwd(), ".claude", "skills");
5971
+ var globalSkillsDir = () => join15(homedir4(), ".claude", "skills");
5972
+ var localAgentsDir = () => join15(process.cwd(), ".claude", "agents");
5973
+ var globalAgentsDir = () => join15(homedir4(), ".claude", "agents");
5251
5974
  function removeMaterialisedSkills(dir) {
5252
5975
  const removed = [];
5253
- const lockPath = join14(dir, SKILLS_LOCK2);
5254
- if (!existsSync12(lockPath)) return removed;
5976
+ const lockPath = join15(dir, SKILLS_LOCK2);
5977
+ if (!existsSync13(lockPath)) return removed;
5255
5978
  try {
5256
- const lock = JSON.parse(readFileSync10(lockPath, "utf8"));
5979
+ const lock = JSON.parse(readFileSync11(lockPath, "utf8"));
5257
5980
  for (const entry of Object.values(lock)) {
5258
5981
  for (const name of entry.skills ?? []) {
5259
- const p = join14(dir, name);
5260
- if (existsSync12(p)) {
5982
+ const p = join15(dir, name);
5983
+ if (existsSync13(p)) {
5261
5984
  rmSync3(p, { recursive: true, force: true });
5262
5985
  removed.push(p);
5263
5986
  }
@@ -5302,18 +6025,18 @@ function registerReset(program2) {
5302
6025
  }
5303
6026
  }
5304
6027
  const removed = [];
5305
- const stateDir = join14(process.cwd(), ".sechroom");
5306
- if (existsSync12(stateDir)) {
6028
+ const stateDir = join15(process.cwd(), ".sechroom");
6029
+ if (existsSync13(stateDir)) {
5307
6030
  rmSync3(stateDir, { recursive: true, force: true });
5308
6031
  removed.push(stateDir);
5309
6032
  }
5310
- const legacyCfg = join14(process.cwd(), ".sechroom.json");
5311
- if (existsSync12(legacyCfg)) {
6033
+ const legacyCfg = join15(process.cwd(), ".sechroom.json");
6034
+ if (existsSync13(legacyCfg)) {
5312
6035
  rmSync3(legacyCfg, { force: true });
5313
6036
  removed.push(legacyCfg);
5314
6037
  }
5315
- const legacySem = join14(process.cwd(), ".sem");
5316
- if (existsSync12(legacySem)) {
6038
+ const legacySem = join15(process.cwd(), ".sem");
6039
+ if (existsSync13(legacySem)) {
5317
6040
  rmSync3(legacySem, { force: true });
5318
6041
  removed.push(legacySem);
5319
6042
  }
@@ -5339,8 +6062,8 @@ function registerReset(program2) {
5339
6062
  }
5340
6063
 
5341
6064
  // src/commands/skills.ts
5342
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, statSync as statSync4, writeFileSync as writeFileSync11 } from "fs";
5343
- import { join as join15 } from "path";
6065
+ import { existsSync as existsSync14, mkdirSync as mkdirSync12, statSync as statSync4, writeFileSync as writeFileSync12 } from "fs";
6066
+ import { join as join16 } from "path";
5344
6067
  function filenameFromDisposition(header) {
5345
6068
  if (!header) return void 0;
5346
6069
  const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
@@ -5348,11 +6071,11 @@ function filenameFromDisposition(header) {
5348
6071
  }
5349
6072
  function resolveOutputPath(output, serverFilename) {
5350
6073
  const filename = serverFilename || "skills.zip";
5351
- if (!output) return join15(process.cwd(), filename);
5352
- const looksLikeDir = output.endsWith("/") || existsSync13(output) && statSync4(output).isDirectory();
6074
+ if (!output) return join16(process.cwd(), filename);
6075
+ const looksLikeDir = output.endsWith("/") || existsSync14(output) && statSync4(output).isDirectory();
5353
6076
  if (looksLikeDir) {
5354
- mkdirSync11(output, { recursive: true });
5355
- return join15(output, filename);
6077
+ mkdirSync12(output, { recursive: true });
6078
+ return join16(output, filename);
5356
6079
  }
5357
6080
  return output;
5358
6081
  }
@@ -5383,7 +6106,7 @@ async function downloadZip(label, call, output) {
5383
6106
  const buf = Buffer.from(res.data);
5384
6107
  const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
5385
6108
  const path = resolveOutputPath(output, filename);
5386
- writeFileSync11(path, buf);
6109
+ writeFileSync12(path, buf);
5387
6110
  return { path, bytes: buf.length, filename };
5388
6111
  }
5389
6112
  function registerSkills(program2) {
@@ -5392,7 +6115,9 @@ function registerSkills(program2) {
5392
6115
  "after",
5393
6116
  `
5394
6117
  Examples:
5395
- $ sechroom skills install materialise your installed skills to ~/.claude/skills
6118
+ $ sechroom skills install --client claude materialise target:claude-code skills to ~/.claude/skills
6119
+ $ sechroom skills install --client codex materialise target:gpt-codex skills to ~/.codex/skills
6120
+ $ sechroom skills install --client all keep both global clients in sync
5396
6121
  $ sechroom skills install --scope project write them to ./.claude/skills instead
5397
6122
  $ sechroom skills list what's materialised on disk
5398
6123
  $ sechroom skills clean remove the materialised skill files
@@ -5401,11 +6126,15 @@ Examples:
5401
6126
  $ sechroom skills package --from-source --workspace wsp_abc -o ./dist zip a draft from source
5402
6127
  $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
5403
6128
 
6129
+ When --client is omitted, configured client flags/environment variables win;
6130
+ otherwise existing default client homes are detected. Both configured/detected
6131
+ clients select all; an unconfigured machine preserves the legacy Claude default.
6132
+
5404
6133
  `
5405
6134
  );
5406
- skills.command("install").description("Materialise your installed skills to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--dry-run", "print what would be written; write nothing").option("--json", "machine output").action((opts, cmd) => runInstall(SKILL_SPEC, cmd, opts));
5407
- skills.command("list").description("List the skills materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((opts, cmd) => runList(SKILL_SPEC, cmd, opts));
5408
- skills.command("clean [slug]").description(`Remove skill files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--json", "machine output").action((slugArg, opts, cmd) => runClean(SKILL_SPEC, cmd, opts, slugArg));
6135
+ skills.command("install").description("Materialise your installed skills to disk (the already-installed bundle \u2014 no server install)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--dry-run", "print what would be written; write nothing").option("--json", "machine output").action((opts, cmd) => runInstall(SKILL_SPEC, cmd, opts));
6136
+ skills.command("list").description("List the skills materialised on disk (per resolved config dir)").option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--json", "machine output").action((opts, cmd) => runList(SKILL_SPEC, cmd, opts));
6137
+ skills.command("clean [slug]").description(`Remove skill files materialised to disk (default ${DEFAULT_SKILLS_SLUG})`).option("--scope <scope>", "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global").option("--local", "alias for --scope project").option("--client <client>", "claude, codex, or all (default: configured/detected clients)").option("--json", "machine output").action((slugArg, opts, cmd) => runClean(SKILL_SPEC, cmd, opts, slugArg));
5409
6138
  skills.command("preview").description("Render a workspace's draft bundle from source (no publish/install) and report the components").requiredOption("--workspace <id>", "workspace holding the draft bundle sources (wsp_\u2026)").option("--slug <slug>", "override the derived bundle slug").option("--title <title>", "override the derived bundle title").option("--version <version>", "override the derived bundle version").option("--default-install-parent <path>", "override the derived default install parent").option("--json", "machine output (the full RenderBundlePreviewResponse)").action(async (opts, cmd) => {
5410
6139
  const json = Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json);
5411
6140
  const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
@@ -5551,12 +6280,12 @@ Examples:
5551
6280
  }
5552
6281
 
5553
6282
  // src/commands/sweep.ts
5554
- import { existsSync as existsSync14 } from "fs";
5555
- import { dirname as dirname9, join as join16, resolve as resolve2 } from "path";
5556
- var DEFAULT_MANIFEST = join16(".sechroom", "repos.json");
6283
+ import { existsSync as existsSync15 } from "fs";
6284
+ import { dirname as dirname10, join as join17, resolve as resolve2 } from "path";
6285
+ var DEFAULT_MANIFEST = join17(".sechroom", "repos.json");
5557
6286
  function planEntry(entry, root) {
5558
6287
  const dir = resolveChildDir(entry.path, root);
5559
- if (!existsSync14(dir)) {
6288
+ if (!existsSync15(dir)) {
5560
6289
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
5561
6290
  }
5562
6291
  if (committedBindingPath(dir)) {
@@ -5632,7 +6361,7 @@ Examples:
5632
6361
  `);
5633
6362
  return;
5634
6363
  }
5635
- const root = dirname9(dirname9(manifestPath));
6364
+ const root = dirname10(dirname10(manifestPath));
5636
6365
  const plans = repos.map((entry) => planEntry(entry, root));
5637
6366
  if (!json) {
5638
6367
  process.stderr.write(
@@ -5651,13 +6380,13 @@ Examples:
5651
6380
 
5652
6381
  // src/commands/telemetry.ts
5653
6382
  import {
5654
- existsSync as existsSync15,
5655
- mkdirSync as mkdirSync12,
5656
- readFileSync as readFileSync11,
6383
+ existsSync as existsSync16,
6384
+ mkdirSync as mkdirSync13,
6385
+ readFileSync as readFileSync12,
5657
6386
  rmSync as rmSync4,
5658
- writeFileSync as writeFileSync12
6387
+ writeFileSync as writeFileSync13
5659
6388
  } from "fs";
5660
- import { dirname as dirname10, join as join17 } from "path";
6389
+ import { dirname as dirname11, join as join18 } from "path";
5661
6390
  function registerTelemetry(program2) {
5662
6391
  const telemetry = program2.command("telemetry").description(
5663
6392
  "Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
@@ -5727,14 +6456,14 @@ function registerTelemetry(program2) {
5727
6456
  "Decomposition id this session executes"
5728
6457
  ).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
5729
6458
  const json = Boolean(cmd.optsWithGlobals().json);
5730
- const dir = join17(process.cwd(), ".sechroom");
5731
- mkdirSync12(dir, { recursive: true });
5732
- const path = join17(dir, BINDING_FILE);
6459
+ const dir = join18(process.cwd(), ".sechroom");
6460
+ mkdirSync13(dir, { recursive: true });
6461
+ const path = join18(dir, BINDING_FILE);
5733
6462
  const binding = {
5734
6463
  decompositionId: opts.decomposition,
5735
6464
  taskId: opts.task
5736
6465
  };
5737
- writeFileSync12(path, JSON.stringify(binding, null, 2) + "\n");
6466
+ writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
5738
6467
  ensureStateDirIgnored(process.cwd());
5739
6468
  if (json) {
5740
6469
  emit({ bound: true, ...binding, path }, true);
@@ -5749,8 +6478,8 @@ function registerTelemetry(program2) {
5749
6478
  });
5750
6479
  telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
5751
6480
  const json = Boolean(cmd.optsWithGlobals().json);
5752
- const path = join17(process.cwd(), ".sechroom", BINDING_FILE);
5753
- const existed = existsSync15(path);
6481
+ const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
6482
+ const existed = existsSync16(path);
5754
6483
  if (existed) rmSync4(path);
5755
6484
  if (json) emit({ unbound: existed, path }, true);
5756
6485
  else
@@ -5759,7 +6488,7 @@ function registerTelemetry(program2) {
5759
6488
  );
5760
6489
  });
5761
6490
  telemetry.command("hook").description(
5762
- "Per-turn telemetry self-report for a Claude Code Stop hook (reads stdin; no-op unless bound). Fail-soft."
6491
+ "Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
5763
6492
  ).action(async (_opts, cmd) => {
5764
6493
  try {
5765
6494
  const raw = await readStdin2();
@@ -5768,21 +6497,10 @@ function registerTelemetry(program2) {
5768
6497
  const binding = findBinding(cwd);
5769
6498
  if (!binding) return process.exit(0);
5770
6499
  const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
5771
- if (!usage) return process.exit(0);
6500
+ const events = buildHookEvents(input, usage, binding.taskId);
6501
+ if (events.length === 0) return process.exit(0);
5772
6502
  const cfg = resolveConfig(cmd.optsWithGlobals());
5773
- await postTelemetry(cfg, binding.decompositionId, [
5774
- {
5775
- taskId: binding.taskId,
5776
- kind: "Parsed",
5777
- tokensIn: usage.tokensIn,
5778
- tokensOut: usage.tokensOut,
5779
- contextUsed: usage.contextUsed,
5780
- contextWindow: usage.contextWindow,
5781
- text: null,
5782
- approvalState: null,
5783
- verdict: null
5784
- }
5785
- ]);
6503
+ await postTelemetry(cfg, binding.decompositionId, events);
5786
6504
  return process.exit(0);
5787
6505
  } catch {
5788
6506
  return process.exit(0);
@@ -5810,7 +6528,12 @@ function registerTelemetry(program2) {
5810
6528
  scope,
5811
6529
  cwd
5812
6530
  });
5813
- const commands = { Stop: "sechroom telemetry hook" };
6531
+ const commands = {
6532
+ Stop: "sechroom telemetry hook",
6533
+ SubagentStop: "sechroom telemetry hook",
6534
+ Notification: "sechroom telemetry hook",
6535
+ PermissionDenied: "sechroom telemetry hook"
6536
+ };
5814
6537
  try {
5815
6538
  const multi = targets.length > 1;
5816
6539
  const results = targets.map((t) => {
@@ -5864,11 +6587,11 @@ async function postTelemetry(cfg, decompositionId, events) {
5864
6587
  function findBinding(start) {
5865
6588
  let dir = start;
5866
6589
  for (; ; ) {
5867
- const path = join17(dir, ".sechroom", BINDING_FILE);
5868
- if (existsSync15(path)) {
6590
+ const path = join18(dir, ".sechroom", BINDING_FILE);
6591
+ if (existsSync16(path)) {
5869
6592
  try {
5870
6593
  const b = JSON.parse(
5871
- readFileSync11(path, "utf8")
6594
+ readFileSync12(path, "utf8")
5872
6595
  );
5873
6596
  if (b.decompositionId && b.taskId)
5874
6597
  return { decompositionId: b.decompositionId, taskId: b.taskId };
@@ -5876,18 +6599,18 @@ function findBinding(start) {
5876
6599
  }
5877
6600
  return null;
5878
6601
  }
5879
- const parent = dirname10(dir);
6602
+ const parent = dirname11(dir);
5880
6603
  if (parent === dir) return null;
5881
6604
  dir = parent;
5882
6605
  }
5883
6606
  }
5884
6607
  function parseTranscript(path) {
5885
- if (!existsSync15(path)) return null;
6608
+ if (!existsSync16(path)) return null;
5886
6609
  let tokensIn = 0;
5887
6610
  let tokensOut = 0;
5888
6611
  let contextUsed = 0;
5889
6612
  let model = "";
5890
- for (const line of readFileSync11(path, "utf8").split("\n")) {
6613
+ for (const line of readFileSync12(path, "utf8").split("\n")) {
5891
6614
  if (!line.trim()) continue;
5892
6615
  let obj;
5893
6616
  try {
@@ -5911,6 +6634,55 @@ function windowFor(model, contextUsed = 0) {
5911
6634
  if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
5912
6635
  return contextUsed > 2e5 ? 1e6 : 2e5;
5913
6636
  }
6637
+ function buildHookEvents(input, usage, taskId) {
6638
+ const events = [];
6639
+ const base = (kind, over) => ({
6640
+ taskId,
6641
+ kind,
6642
+ tokensIn: null,
6643
+ tokensOut: null,
6644
+ contextUsed: null,
6645
+ contextWindow: null,
6646
+ text: null,
6647
+ approvalState: null,
6648
+ verdict: null,
6649
+ ...over
6650
+ });
6651
+ if (usage) {
6652
+ events.push(
6653
+ base("Parsed", {
6654
+ tokensIn: usage.tokensIn,
6655
+ tokensOut: usage.tokensOut,
6656
+ contextUsed: usage.contextUsed,
6657
+ contextWindow: usage.contextWindow
6658
+ })
6659
+ );
6660
+ }
6661
+ switch (input.hook_event_name) {
6662
+ case "PermissionDenied":
6663
+ events.push(
6664
+ base("Approval", {
6665
+ approvalState: "denied",
6666
+ text: input.tool_name ?? input.message ?? null
6667
+ })
6668
+ );
6669
+ break;
6670
+ case "Notification":
6671
+ if (isPermissionNotification(input))
6672
+ events.push(base("Approval", { text: input.message ?? null }));
6673
+ break;
6674
+ case "Stop":
6675
+ case "SubagentStop":
6676
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6677
+ break;
6678
+ }
6679
+ return events;
6680
+ }
6681
+ function isPermissionNotification(input) {
6682
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6683
+ if (t) return t.includes("permission");
6684
+ return (input.message ?? "").toLowerCase().includes("permission");
6685
+ }
5914
6686
  async function readStdin2() {
5915
6687
  if (process.stdin.isTTY) return "";
5916
6688
  const chunks = [];
@@ -6145,7 +6917,7 @@ Examples:
6145
6917
  function resolveVersion() {
6146
6918
  try {
6147
6919
  const pkg = JSON.parse(
6148
- readFileSync12(new URL("../package.json", import.meta.url), "utf8")
6920
+ readFileSync13(new URL("../package.json", import.meta.url), "utf8")
6149
6921
  );
6150
6922
  return pkg.version ?? "0.0.0";
6151
6923
  } catch {
@@ -6304,6 +7076,7 @@ registerRelationships(program);
6304
7076
  registerWorkspace(program);
6305
7077
  registerProject(program);
6306
7078
  registerDecomposition(program);
7079
+ registerExecutor(program);
6307
7080
  registerClose(program);
6308
7081
  registerFiling(program);
6309
7082
  registerContinuity(program);