@sechroom/cli 2026.7.6 → 2026.7.7-rc.56782977

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 (2) hide show
  1. package/dist/index.js +692 -94
  2. 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 readFileSync11 } from "fs";
4
+ import { readFileSync as readFileSync12 } 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();
@@ -633,7 +658,22 @@ async function runApi(label, fn) {
633
658
  return res.data;
634
659
  }
635
660
  function fail(error) {
636
- const msg = typeof error === "object" && error !== null && "title" in error ? String(error.title) : typeof error === "object" ? JSON.stringify(error) : String(error);
661
+ let msg;
662
+ if (typeof error === "object" && error !== null && "title" in error) {
663
+ const problem = error;
664
+ msg = String(problem.title);
665
+ if (problem.errors && typeof problem.errors === "object") {
666
+ const detail = Object.entries(problem.errors).flatMap(
667
+ ([field, msgs]) => (Array.isArray(msgs) ? msgs : [msgs]).map((m) => ` ${field}: ${String(m)}`)
668
+ );
669
+ if (detail.length > 0) msg += `
670
+ ${detail.join("\n")}`;
671
+ }
672
+ } else if (typeof error === "object" && error !== null) {
673
+ msg = JSON.stringify(error);
674
+ } else {
675
+ msg = String(error);
676
+ }
637
677
  process.stderr.write(`error: ${msg}
638
678
  `);
639
679
  process.exit(1);
@@ -1061,14 +1101,23 @@ function bodyOf(row) {
1061
1101
  const m = row?.item ?? row;
1062
1102
  return m?.text ?? m?.Text ?? "";
1063
1103
  }
1104
+ function idOf(row) {
1105
+ const m = row?.item ?? row;
1106
+ return String(m?.id ?? m?.memoryId ?? "unknown");
1107
+ }
1064
1108
  function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
1065
1109
  const out = /* @__PURE__ */ new Map();
1066
1110
  for (const row of rows ?? []) {
1067
1111
  const tags = tagsOf(row);
1068
1112
  if (!tags.includes(roleTag)) continue;
1069
- if (tagValue(tags, "target:") !== surface) continue;
1113
+ if (!tags.includes(`target:${surface}`)) continue;
1070
1114
  const name = tagValue(tags, namePrefix);
1071
1115
  if (!name) continue;
1116
+ if (out.has(name)) {
1117
+ throw new Error(
1118
+ `Ambiguous ${source} ${roleTag} '${name}' for target:${surface}: ${idOf(row)} duplicates another eligible component. Resolve the duplicate before installing.`
1119
+ );
1120
+ }
1072
1121
  out.set(name, { name, body: bodyOf(row), source });
1073
1122
  }
1074
1123
  return out;
@@ -1096,20 +1145,33 @@ function agentTargetFor(surface) {
1096
1145
  return AGENT_TARGET[surface] ?? `${surface}-agent`;
1097
1146
  }
1098
1147
  async function fetchFeedRows(cfg, workspaceId) {
1099
- try {
1100
- const client = await makeClient(cfg);
1101
- const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1148
+ const client = await makeClient(cfg);
1149
+ const rows = [];
1150
+ let cursor;
1151
+ do {
1152
+ const { data, error, response } = await client.GET("/workspaces/{workspaceId}/memories/feed", {
1102
1153
  params: {
1103
1154
  path: { workspaceId },
1104
1155
  // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
1105
1156
  // includeText: the feed omits bodies by default, we need them for SKILL.md.
1106
- query: { limit: 200, cascadeWorkspaces: true, includeText: true }
1157
+ query: {
1158
+ limit: 200,
1159
+ cascadeWorkspaces: true,
1160
+ includeText: true,
1161
+ ...cursor ? { cursor } : {}
1162
+ }
1107
1163
  }
1108
- }).then((r) => r.data).catch(() => void 0);
1109
- return feed?.results ?? feed?.Results ?? [];
1110
- } catch {
1111
- return [];
1112
- }
1164
+ });
1165
+ if (error || !response.ok || !data) {
1166
+ throw new Error(
1167
+ `Could not read operator-skill catalogue from ${workspaceId}: HTTP ${response.status}.`
1168
+ );
1169
+ }
1170
+ const feed = data;
1171
+ rows.push(...feed.results ?? feed.Results ?? []);
1172
+ cursor = feed.nextCursor ?? feed.NextCursor ?? void 0;
1173
+ } while (cursor);
1174
+ return rows;
1113
1175
  }
1114
1176
  async function fetchTemplateRows(cfg, personalWorkspaceId) {
1115
1177
  const [systemRows, personalRows] = await Promise.all([
@@ -1129,7 +1191,10 @@ function resolveReferenceSet(rows, surface) {
1129
1191
  }
1130
1192
 
1131
1193
  // src/setup/materialise.ts
1132
- var CLIENT_SURFACE = "claude-code";
1194
+ var CLIENT_SURFACE = {
1195
+ claude: "claude-code",
1196
+ codex: "gpt-codex"
1197
+ };
1133
1198
  function writeSkills(dir, skills, surface) {
1134
1199
  const written = [];
1135
1200
  for (const s of skills) {
@@ -1162,11 +1227,71 @@ function writeReferencesIntoSkillDirs(dir, skills, refs) {
1162
1227
  }
1163
1228
  return refs.map((r) => r.name);
1164
1229
  }
1165
- var SKILL_SPEC = { kind: "skill", dir: skillsDir, resolve: resolveSkillSet, write: writeSkills };
1166
- var AGENT_SPEC = { kind: "agent", dir: agentsDir, resolve: resolveAgentSet, write: writeAgents };
1230
+ var SKILL_SPEC = {
1231
+ kind: "skill",
1232
+ dir: skillsDir,
1233
+ resolve: resolveSkillSet,
1234
+ write: writeSkills,
1235
+ supportsCodex: true
1236
+ };
1237
+ var AGENT_SPEC = {
1238
+ kind: "agent",
1239
+ dir: agentsDir,
1240
+ resolve: resolveAgentSet,
1241
+ write: writeAgents,
1242
+ supportsCodex: false
1243
+ };
1167
1244
  function scopeOf(opts) {
1168
1245
  return opts.local ? "project" : resolveScope(opts.scope);
1169
1246
  }
1247
+ function parseClient(value) {
1248
+ if (value == null) return void 0;
1249
+ if (value === "claude" || value === "codex" || value === "all") return value;
1250
+ throw new Error(`--client must be 'claude', 'codex', or 'all' (got '${value}')`);
1251
+ }
1252
+ function clientSelection(spec, g, raw) {
1253
+ if (!spec.supportsCodex) return "claude";
1254
+ const explicit = parseClient(raw);
1255
+ if (explicit) return explicit;
1256
+ const claudeConfigured = Boolean(g.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR);
1257
+ const codexConfigured = Boolean(g.codexHome || process.env.CODEX_HOME);
1258
+ if (claudeConfigured || codexConfigured) {
1259
+ if (claudeConfigured && codexConfigured) return "all";
1260
+ return codexConfigured ? "codex" : "claude";
1261
+ }
1262
+ const claudeDetected = resolveClaudeTargets({})[0]?.dir;
1263
+ const codexDetected = resolveCodexHomes({})[0];
1264
+ const hasClaude = Boolean(claudeDetected && existsSync3(claudeDetected));
1265
+ const hasCodex = Boolean(codexDetected && existsSync3(codexDetected));
1266
+ if (hasClaude && hasCodex) return "all";
1267
+ if (hasCodex) return "codex";
1268
+ return "claude";
1269
+ }
1270
+ function targetsFor(spec, g, scope, selection) {
1271
+ const clients = selection === "all" ? ["claude", "codex"] : [selection];
1272
+ if (scope === "project" && clients.includes("codex")) {
1273
+ throw new Error("Codex skills have no supported project scope; use --scope global or --client claude.");
1274
+ }
1275
+ const targets = [];
1276
+ for (const client of clients) {
1277
+ if (client === "claude") {
1278
+ targets.push(...resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() }).map((target) => ({
1279
+ client,
1280
+ surface: CLIENT_SURFACE[client],
1281
+ dir: spec.dir(target.dir),
1282
+ label: target.label
1283
+ })));
1284
+ continue;
1285
+ }
1286
+ targets.push(...resolveCodexHomes({ override: g.codexHome, scope }).map((home) => ({
1287
+ client,
1288
+ surface: CLIENT_SURFACE[client],
1289
+ dir: spec.dir(home),
1290
+ label: home
1291
+ })));
1292
+ }
1293
+ return targets;
1294
+ }
1170
1295
  async function runInstall(spec, cmd, opts) {
1171
1296
  const g = cmd.optsWithGlobals();
1172
1297
  let scope;
@@ -1178,31 +1303,48 @@ async function runInstall(spec, cmd, opts) {
1178
1303
  const cfg = resolveConfig(g);
1179
1304
  const dryRun = Boolean(opts.dryRun);
1180
1305
  const json = Boolean(g.json || opts.json);
1181
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1306
+ let selection;
1307
+ let targets;
1308
+ try {
1309
+ selection = clientSelection(spec, g, opts.client);
1310
+ targets = targetsFor(spec, g, scope, selection);
1311
+ } catch (err2) {
1312
+ return fail(err2.message);
1313
+ }
1182
1314
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
1183
1315
  const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
1184
- const items = spec.resolve(rows, CLIENT_SURFACE);
1185
- const refs = spec.kind === "skill" ? resolveReferenceSet(rows, CLIENT_SURFACE) : [];
1186
1316
  const results = targets.map((t) => {
1187
- const dir = spec.dir(t.dir);
1188
- const written = dryRun ? items.map((i) => i.name) : spec.write(dir, items, CLIENT_SURFACE);
1189
- const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(dir, items, refs);
1190
- return { dir, label: t.label, written, refsWritten };
1317
+ const items = spec.resolve(rows, t.surface);
1318
+ const refs = spec.kind === "skill" ? resolveReferenceSet(rows, t.surface) : [];
1319
+ const written = dryRun ? items.map((i) => i.name) : spec.write(t.dir, items, t.surface);
1320
+ const refsWritten = dryRun ? refs.map((r) => r.name) : writeReferencesIntoSkillDirs(t.dir, items, refs);
1321
+ return {
1322
+ client: t.client,
1323
+ surface: t.surface,
1324
+ dir: t.dir,
1325
+ label: t.label,
1326
+ available: items.length,
1327
+ references: refs.length,
1328
+ items: items.map(({ name, source }) => ({ name, source })),
1329
+ referenceItems: refs.map(({ name, source }) => ({ name, source })),
1330
+ written,
1331
+ refsWritten
1332
+ };
1191
1333
  });
1192
- if (json) return emit({ kind: spec.kind, dryRun, available: items.length, references: refs.length, targets: results }, true);
1193
- if (items.length === 0) {
1334
+ if (json) return emit({ kind: spec.kind, client: selection, dryRun, targets: results }, true);
1335
+ if (results.every((r) => r.available === 0)) {
1194
1336
  console.log(style.dim(`No ${spec.kind}s available to install \u2014 is the bundle installed for your account?`));
1195
1337
  return;
1196
1338
  }
1197
1339
  for (const r of results) {
1198
1340
  console.log(
1199
- `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) ${style.dim("\u2192")} ${r.dir}`
1341
+ `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) for ${r.client} ${style.dim("\u2192")} ${r.dir}`
1200
1342
  );
1201
1343
  if (r.refsWritten.length)
1202
1344
  console.log(
1203
1345
  `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.refsWritten.length} reference(s) into each skill ${style.dim("\u2192")} ${r.dir}/<skill>/references`
1204
1346
  );
1205
- if (dryRun) for (const i of items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1347
+ if (dryRun) for (const i of r.items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
1206
1348
  }
1207
1349
  }
1208
1350
  function runList(spec, cmd, opts) {
@@ -1214,16 +1356,22 @@ function runList(spec, cmd, opts) {
1214
1356
  return fail(err2.message);
1215
1357
  }
1216
1358
  const json = Boolean(g.json || opts.json);
1217
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1359
+ let selection;
1360
+ let targets;
1361
+ try {
1362
+ selection = clientSelection(spec, g, opts.client);
1363
+ targets = targetsFor(spec, g, scope, selection);
1364
+ } catch (err2) {
1365
+ return fail(err2.message);
1366
+ }
1218
1367
  const out = targets.map((t) => {
1219
- const dir = spec.dir(t.dir);
1220
- const lock = readSkillsLock(dir);
1368
+ const lock = readSkillsLock(t.dir);
1221
1369
  const entries = Object.entries(lock).flatMap(
1222
- ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(dir, name)) }))
1370
+ ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(t.dir, name)) }))
1223
1371
  );
1224
- return { dir, label: t.label, entries };
1372
+ return { client: t.client, surface: t.surface, dir: t.dir, label: t.label, entries };
1225
1373
  });
1226
- if (json) return emit({ kind: spec.kind, targets: out }, true);
1374
+ if (json) return emit({ kind: spec.kind, client: selection, targets: out }, true);
1227
1375
  let any = false;
1228
1376
  for (const t of out) {
1229
1377
  if (t.entries.length === 0) continue;
@@ -1246,33 +1394,39 @@ function runClean(spec, cmd, opts, slugArg) {
1246
1394
  return fail(err2.message);
1247
1395
  }
1248
1396
  const json = Boolean(g.json || opts.json);
1249
- const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
1397
+ let selection;
1398
+ let targets;
1399
+ try {
1400
+ selection = clientSelection(spec, g, opts.client);
1401
+ targets = targetsFor(spec, g, scope, selection);
1402
+ } catch (err2) {
1403
+ return fail(err2.message);
1404
+ }
1250
1405
  const cleaned = [];
1251
1406
  const missing = [];
1252
1407
  for (const t of targets) {
1253
- const dir = spec.dir(t.dir);
1254
- const lock = readSkillsLock(dir);
1408
+ const lock = readSkillsLock(t.dir);
1255
1409
  const entry = lock[slug];
1256
1410
  if (!entry) {
1257
- missing.push(join4(dir, SKILLS_LOCK));
1411
+ missing.push(join4(t.dir, SKILLS_LOCK));
1258
1412
  continue;
1259
1413
  }
1260
1414
  const removed = [];
1261
1415
  for (const name of entry.skills) {
1262
- const p = join4(dir, name);
1416
+ const p = join4(t.dir, name);
1263
1417
  if (existsSync3(p)) {
1264
1418
  rmSync2(p, { recursive: true, force: true });
1265
1419
  removed.push(name);
1266
1420
  }
1267
1421
  }
1268
1422
  delete lock[slug];
1269
- writeSkillsLock(dir, lock);
1270
- cleaned.push({ dir, removed });
1423
+ writeSkillsLock(t.dir, lock);
1424
+ cleaned.push({ client: t.client, surface: t.surface, dir: t.dir, removed });
1271
1425
  }
1272
1426
  if (cleaned.length === 0) {
1273
1427
  return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
1274
1428
  }
1275
- if (json) return emit({ kind: spec.kind, slug, cleaned, missing }, true);
1429
+ if (json) return emit({ kind: spec.kind, client: selection, slug, cleaned, missing }, true);
1276
1430
  for (const c of cleaned) {
1277
1431
  console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
1278
1432
  }
@@ -1388,15 +1542,24 @@ var CLAUDE_HOOK_COMMANDS = {
1388
1542
  SessionStart: "sechroom hook session-start",
1389
1543
  PreCompact: "sechroom hook pre-compact",
1390
1544
  SessionEnd: "sechroom hook session-end",
1391
- // WLP telemetry tap (D-WLP-10) — per-turn executor self-report. No-op (exit 0) unless this
1392
- // checkout is bound to a decomposition+task via `sechroom telemetry bind`, so it's safe to wire
1393
- // for every Claude install. Claude-only (it parses a Claude Code transcript).
1394
- Stop: "sechroom telemetry hook"
1545
+ // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
1546
+ // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop parsed (token/context) +
1547
+ // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
1548
+ // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
1549
+ // given Claude Code version doesn't know is inert (never fires). Claude-only.
1550
+ Stop: "sechroom telemetry hook",
1551
+ SubagentStop: "sechroom telemetry hook",
1552
+ Notification: "sechroom telemetry hook",
1553
+ PermissionDenied: "sechroom telemetry hook"
1395
1554
  };
1396
1555
  var CODEX_HOOK_COMMANDS = {
1397
1556
  SessionStart: "sechroom hook session-start",
1557
+ PreCompact: "sechroom hook pre-compact",
1398
1558
  Stop: "sechroom hook session-end --debounce-minutes 10"
1399
1559
  };
1560
+ function hookCommandsForSurface(surface) {
1561
+ return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
1562
+ }
1400
1563
  function hasHookCommand(config2, event, command) {
1401
1564
  const groups = config2.hooks?.[event] ?? [];
1402
1565
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
@@ -1544,12 +1707,16 @@ function registerChannel(program2) {
1544
1707
  const cfg = resolveConfig(cmd.optsWithGlobals());
1545
1708
  const filter = readFilter(opts);
1546
1709
  const sub = await ensureSubscription(cfg, opts.name, filter);
1547
- const conn = await openConnection(cfg, (payload) => {
1548
- if (!shouldDeliver(payload, filter)) return;
1549
- process.stdout.write(
1710
+ const seen = /* @__PURE__ */ new Set();
1711
+ const deliver = makeDeliver(
1712
+ filter,
1713
+ seen,
1714
+ (payload) => process.stdout.write(
1550
1715
  (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
1551
- );
1552
- });
1716
+ )
1717
+ );
1718
+ const conn = await openConnection(cfg, deliver);
1719
+ await reconcile(cfg, filter, deliver);
1553
1720
  if (json) {
1554
1721
  emit(
1555
1722
  {
@@ -1586,8 +1753,8 @@ function registerChannel(program2) {
1586
1753
  );
1587
1754
  await mcp.connect(new StdioServerTransport());
1588
1755
  await ensureSubscription(cfg, opts.name, filter);
1589
- const conn = await openConnection(cfg, (payload) => {
1590
- if (!shouldDeliver(payload, filter)) return;
1756
+ const seen = /* @__PURE__ */ new Set();
1757
+ const deliver = makeDeliver(filter, seen, (payload) => {
1591
1758
  const { content, meta } = summarizeEvent(payload);
1592
1759
  void mcp.notification({
1593
1760
  method: "notifications/claude/channel",
@@ -1597,6 +1764,8 @@ function registerChannel(program2) {
1597
1764
  `))
1598
1765
  );
1599
1766
  });
1767
+ const conn = await openConnection(cfg, deliver);
1768
+ await reconcile(cfg, filter, deliver);
1600
1769
  process.stderr.write(
1601
1770
  style.dim(
1602
1771
  `sechroom channel (mcp) \u2014 tenant ${cfg.tenant}, tags [${filter.tags.join(", ")}]
@@ -1760,6 +1929,82 @@ function shouldDeliver(payload, filter) {
1760
1929
  }
1761
1930
  return true;
1762
1931
  }
1932
+ function makeDeliver(filter, seen, forward) {
1933
+ return (payload) => {
1934
+ if (!shouldDeliver(payload, filter)) return;
1935
+ const { memoryId } = parseEvent(payload);
1936
+ if (memoryId) {
1937
+ if (seen.has(memoryId)) return;
1938
+ seen.add(memoryId);
1939
+ }
1940
+ forward(payload);
1941
+ };
1942
+ }
1943
+ async function reconcile(cfg, filter, deliver) {
1944
+ if (filter.workspaceScope.length === 0) {
1945
+ process.stderr.write(
1946
+ style.dim(
1947
+ "channel: no --workspace to reconcile against; live feed only (a dropped dispatch won't be recovered).\n"
1948
+ )
1949
+ );
1950
+ return;
1951
+ }
1952
+ if (filter.tags.length === 0) return;
1953
+ let token;
1954
+ try {
1955
+ token = await requireToken(cfg);
1956
+ } catch {
1957
+ return;
1958
+ }
1959
+ const qs = `filterTags=${encodeURIComponent(filter.tags.join(","))}&limit=100`;
1960
+ let recovered = 0;
1961
+ for (const ws of filter.workspaceScope) {
1962
+ try {
1963
+ const resp = await fetch(
1964
+ `${cfg.baseUrl}/workspaces/${encodeURIComponent(ws)}/memories/feed?${qs}`,
1965
+ {
1966
+ headers: {
1967
+ authorization: `Bearer ${token}`,
1968
+ tenant: cfg.tenant,
1969
+ "x-sechroom-surface": "cli"
1970
+ }
1971
+ }
1972
+ );
1973
+ if (!resp.ok) {
1974
+ process.stderr.write(
1975
+ err(
1976
+ `channel: reconcile query for ${ws} failed (HTTP ${resp.status})
1977
+ `
1978
+ )
1979
+ );
1980
+ continue;
1981
+ }
1982
+ const data = await resp.json();
1983
+ for (const m of data.results ?? []) {
1984
+ if (!m.id) continue;
1985
+ deliver({
1986
+ eventType: "reconcile",
1987
+ memoryId: m.id,
1988
+ workspaceId: ws,
1989
+ tags: m.tags ?? []
1990
+ });
1991
+ recovered++;
1992
+ }
1993
+ } catch (e) {
1994
+ process.stderr.write(
1995
+ err(`channel: reconcile error for ${ws}: ${String(e)}
1996
+ `)
1997
+ );
1998
+ }
1999
+ }
2000
+ if (recovered > 0)
2001
+ process.stderr.write(
2002
+ style.dim(
2003
+ `channel: reconciled ${recovered} already-queued event(s) on connect.
2004
+ `
2005
+ )
2006
+ );
2007
+ }
1763
2008
  function facetedTagMatch(eventTags, filterTags) {
1764
2009
  const have = new Set(eventTags);
1765
2010
  const groups = /* @__PURE__ */ new Map();
@@ -2334,6 +2579,12 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2334
2579
  const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2335
2580
  process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2336
2581
  `);
2582
+ if (dryRun) {
2583
+ for (const [event, command] of Object.entries(hookCommandsForSurface("codex"))) {
2584
+ process.stdout.write(` ${event}: ${command}
2585
+ `);
2586
+ }
2587
+ }
2337
2588
  for (const r of surfaceResults) {
2338
2589
  results.push(r);
2339
2590
  process.stdout.write(describe(r, dryRun) + "\n");
@@ -2451,6 +2702,178 @@ Examples:
2451
2702
  });
2452
2703
  }
2453
2704
 
2705
+ // src/commands/close.ts
2706
+ import { readFileSync as readFileSync7 } from "fs";
2707
+ var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
2708
+ function registerClose(program2) {
2709
+ program2.command("close").description(
2710
+ "Close a dispatched WorkTask in one call: closeout memo (verdict + correlation) + Reference edge + source status flip. Managed runs stamp the run's matchTags verbatim (SBC-1180)."
2711
+ ).requiredOption("--task <id>", "The WorkTask memory id being closed").requiredOption("--verdict <verdict>", `Verdict: ${VERDICTS.join(" | ")}`).requiredOption(
2712
+ "--workspace <wsp>",
2713
+ "Workspace that owns the closeout memo"
2714
+ ).requiredOption("--title <title>", "Closeout title").option(
2715
+ "--file <path>",
2716
+ "Read the closeout body from a file (default: stdin)"
2717
+ ).option(
2718
+ "--decomposition <id>",
2719
+ "Managed-run selector \u2014 the sug_ decomposition the task belongs to. Locates the parked run so its matchTags/verdict contract are read from state, not assembled from args."
2720
+ ).option(
2721
+ "--no-status-flip",
2722
+ "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.)"
2723
+ ).option(
2724
+ "--to-version <n>",
2725
+ "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)."
2726
+ ).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
2727
+ "after",
2728
+ `
2729
+ Examples:
2730
+ # Managed run \u2014 reads matchTags from run state, stamps them verbatim, un-parks the run:
2731
+ $ echo "Shipped X. Tests green." | sechroom close --task mem_XXXX --decomposition sug_YYYY \\
2732
+ --verdict pass --workspace wsp_ZZZZ --title "w4_build closeout"
2733
+
2734
+ # Bare task:
2735
+ $ sechroom close --task mem_XXXX --verdict pass --workspace wsp_ZZZZ \\
2736
+ --title "done" --file ./closeout.md`
2737
+ ).action(async (opts, cmd) => {
2738
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2739
+ const json = cmd.optsWithGlobals().json;
2740
+ const verdict = String(opts.verdict);
2741
+ if (!VERDICTS.includes(verdict))
2742
+ fail(
2743
+ `--verdict must be one of ${VERDICTS.join(" | ")} \u2014 got '${opts.verdict}'`
2744
+ );
2745
+ let bodyText;
2746
+ try {
2747
+ bodyText = opts.file ? readFileSync7(opts.file, "utf8") : readFileSync7(0, "utf8");
2748
+ } catch {
2749
+ fail(
2750
+ opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
2751
+ );
2752
+ }
2753
+ if (!bodyText.trim())
2754
+ fail(
2755
+ "closeout body is empty \u2014 pass --file <path> or pipe the body on stdin"
2756
+ );
2757
+ const client = await makeClient(cfg);
2758
+ let matchTags;
2759
+ let dispatchedVersion;
2760
+ if (opts.decomposition) {
2761
+ const run = await runApi(
2762
+ "Reading run contract",
2763
+ async () => client.GET("/decompositions/{id}/run", {
2764
+ params: { path: { id: opts.decomposition } }
2765
+ })
2766
+ );
2767
+ const await_ = run.awaitingCompletion;
2768
+ if (!await_ || !Array.isArray(await_.matchTags) || await_.matchTags.length === 0)
2769
+ fail(
2770
+ `decomposition ${opts.decomposition} has no parked run awaiting a completion \u2014 nothing to close (run outcome: ${run.outcome ?? "unknown"}). Idempotent no-op on an already-resumed/terminal run.`
2771
+ );
2772
+ if (run.awaitingTaskId && run.awaitingTaskId !== opts.task)
2773
+ fail(
2774
+ `run ${opts.decomposition} is awaiting task ${run.awaitingTaskId}, not ${opts.task} \u2014 refusing to mis-key the resume.`
2775
+ );
2776
+ matchTags = await_.matchTags;
2777
+ if (typeof await_.dispatchedTaskVersion === "number")
2778
+ dispatchedVersion = await_.dispatchedTaskVersion;
2779
+ } else {
2780
+ matchTags = [`wlp-task:${opts.task}`];
2781
+ }
2782
+ const tags = [
2783
+ .../* @__PURE__ */ new Set([
2784
+ ...matchTags,
2785
+ `wlp-task:${opts.task}`,
2786
+ `verdict:${verdict}`
2787
+ ])
2788
+ ];
2789
+ const closeout = await runApi(
2790
+ "Creating closeout",
2791
+ async () => client.POST("/memories", {
2792
+ body: {
2793
+ text: bodyText,
2794
+ type: "reference",
2795
+ content: "{}",
2796
+ confidence: 1,
2797
+ source: opts.source,
2798
+ archetype: "Document",
2799
+ title: opts.title,
2800
+ tags,
2801
+ owner: { type: "Workspace", id: opts.workspace }
2802
+ }
2803
+ })
2804
+ );
2805
+ let toVersion;
2806
+ if (opts.toVersion !== void 0) {
2807
+ toVersion = Number(opts.toVersion);
2808
+ if (!Number.isInteger(toVersion) || toVersion < 1)
2809
+ fail(
2810
+ `--to-version must be an integer >= 1 \u2014 got '${opts.toVersion}'`
2811
+ );
2812
+ } else if (dispatchedVersion !== void 0) {
2813
+ toVersion = dispatchedVersion;
2814
+ } else {
2815
+ const task = await runApi(
2816
+ "Reading task version",
2817
+ async () => client.GET("/memories/{memoryId}", {
2818
+ params: { path: { memoryId: opts.task } }
2819
+ })
2820
+ );
2821
+ const v = task?.item?.currentVersion ?? task?.currentVersion;
2822
+ if (typeof v !== "number" || v < 1)
2823
+ fail(
2824
+ `could not read task ${opts.task} version to pin the Reference edge \u2014 pass --to-version <n>`
2825
+ );
2826
+ toVersion = v;
2827
+ }
2828
+ const edge = await runApi(
2829
+ "Wiring Reference edge",
2830
+ async () => client.POST("/memories/{memoryId}/relationships", {
2831
+ params: { path: { memoryId: closeout.id } },
2832
+ body: { toMemoryId: opts.task, type: "Reference", toVersion }
2833
+ })
2834
+ );
2835
+ let taskStatus;
2836
+ if (opts.statusFlip !== false && !opts.decomposition) {
2837
+ const current = await runApi(
2838
+ "Reading task tags",
2839
+ async () => client.GET("/memories/{memoryId}", {
2840
+ params: { path: { memoryId: opts.task } }
2841
+ })
2842
+ );
2843
+ const currentTags = current?.item?.tags ?? current?.tags ?? [];
2844
+ const target = verdict === "blocked" ? "status:blocked" : "status:done";
2845
+ const nextTags = [
2846
+ ...new Set(currentTags.filter((t) => !t.startsWith("status:")))
2847
+ ].concat(target);
2848
+ await runApi(
2849
+ "Flipping task status",
2850
+ async () => client.PATCH("/memories/{memoryId}/metadata", {
2851
+ params: { path: { memoryId: opts.task } },
2852
+ body: {
2853
+ memoryId: opts.task,
2854
+ source: opts.source,
2855
+ tags: nextTags
2856
+ }
2857
+ })
2858
+ );
2859
+ taskStatus = target.slice("status:".length);
2860
+ }
2861
+ const view = resolveViewUrl(cfg.baseUrl, closeout.url);
2862
+ const result = {
2863
+ closeoutId: closeout.id,
2864
+ edgeId: edge.id,
2865
+ taskStatus: taskStatus ?? null,
2866
+ verdict,
2867
+ tags
2868
+ };
2869
+ emitAction(
2870
+ `closed ${style.bold(opts.task)} verdict:${style.bold(verdict)} \u2192 closeout ${style.bold(closeout.id)}${taskStatus ? ` ${style.dim(`(task ${taskStatus})`)}` : ""}${view ? ` ${style.dim("\u2192")} ${view}` : ""}`,
2871
+ result,
2872
+ json
2873
+ );
2874
+ });
2875
+ }
2876
+
2454
2877
  // src/commands/continuity.ts
2455
2878
  function registerContinuity(program2) {
2456
2879
  const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
@@ -2611,6 +3034,108 @@ Examples:
2611
3034
  });
2612
3035
  }
2613
3036
 
3037
+ // src/commands/decomposition.ts
3038
+ function registerDecomposition(program2) {
3039
+ const decomposition = program2.command("decomposition").description(
3040
+ "Drive a WLP decomposition: decompose a brief, then execute / accept / reject"
3041
+ );
3042
+ decomposition.addHelpText(
3043
+ "after",
3044
+ `
3045
+ Examples:
3046
+ $ sechroom decomposition decompose mem_XXXX
3047
+ $ sechroom decomposition execute sug_XXXX
3048
+ $ sechroom decomposition publish-run sug_XXXX
3049
+ $ sechroom decomposition accept sug_XXXX
3050
+ $ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
3051
+ );
3052
+ decomposition.command("decompose <briefId>").description(
3053
+ "Decompose a work brief into a candidate Task graph (POST /work-briefs/{id}/decompose)"
3054
+ ).action(async (briefId, _opts, cmd) => {
3055
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3056
+ const data = await runApi("Queueing decomposition", async () => {
3057
+ const client = await makeClient(cfg);
3058
+ return client.POST("/work-briefs/{id}/decompose", {
3059
+ params: { path: { id: briefId } },
3060
+ body: { id: briefId }
3061
+ });
3062
+ });
3063
+ emitAction(
3064
+ `queued decomposition of ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
3065
+ data,
3066
+ cmd.optsWithGlobals().json
3067
+ );
3068
+ });
3069
+ decomposition.command("execute <decompositionId>").description(
3070
+ "Execute a decomposition's Task graph (POST /decompositions/{id}/execute)"
3071
+ ).action(async (decompositionId, _opts, cmd) => {
3072
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3073
+ const data = await runApi("Executing decomposition", async () => {
3074
+ const client = await makeClient(cfg);
3075
+ return client.POST("/decompositions/{id}/execute", {
3076
+ params: { path: { id: decompositionId } },
3077
+ body: {}
3078
+ });
3079
+ });
3080
+ emitAction(
3081
+ `executed ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
3082
+ data,
3083
+ cmd.optsWithGlobals().json
3084
+ );
3085
+ });
3086
+ decomposition.command("publish-run <decompositionId>").description(
3087
+ "Publish an accepted decomposition's context pack on demand (POST /decompositions/{id}/publish-run)"
3088
+ ).action(async (decompositionId, _opts, cmd) => {
3089
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3090
+ const data = await runApi("Publishing context pack", async () => {
3091
+ const client = await makeClient(cfg);
3092
+ return client.POST("/decompositions/{id}/publish-run", {
3093
+ params: { path: { id: decompositionId } },
3094
+ body: {}
3095
+ });
3096
+ });
3097
+ emitAction(
3098
+ `published ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
3099
+ data,
3100
+ cmd.optsWithGlobals().json
3101
+ );
3102
+ });
3103
+ decomposition.command("accept <decompositionId>").description(
3104
+ "Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
3105
+ ).action(async (decompositionId, _opts, cmd) => {
3106
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3107
+ const data = await runApi("Accepting decomposition", async () => {
3108
+ const client = await makeClient(cfg);
3109
+ return client.POST("/decompositions/{id}/accept", {
3110
+ params: { path: { id: decompositionId } },
3111
+ body: {}
3112
+ });
3113
+ });
3114
+ emitAction(
3115
+ `accepted decomposition ${style.bold(decompositionId)}`,
3116
+ data,
3117
+ cmd.optsWithGlobals().json
3118
+ );
3119
+ });
3120
+ decomposition.command("reject <decompositionId>").description(
3121
+ "Reject a Pending decomposition \u2014 archive its Tasks, bounce the brief (POST /decompositions/{id}/reject)"
3122
+ ).option("--reason <reason>", "Optional free-text rejection reason").action(async (decompositionId, opts, cmd) => {
3123
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3124
+ const data = await runApi("Rejecting decomposition", async () => {
3125
+ const client = await makeClient(cfg);
3126
+ return client.POST("/decompositions/{id}/reject", {
3127
+ params: { path: { id: decompositionId } },
3128
+ body: { reasonText: opts.reason ?? null }
3129
+ });
3130
+ });
3131
+ emitAction(
3132
+ `rejected decomposition ${style.bold(decompositionId)}`,
3133
+ data,
3134
+ cmd.optsWithGlobals().json
3135
+ );
3136
+ });
3137
+ }
3138
+
2614
3139
  // src/commands/filing.ts
2615
3140
  function registerFiling(program2) {
2616
3141
  const filing = program2.command("filing").description("Review and act on filing suggestions");
@@ -3122,7 +3647,7 @@ Examples:
3122
3647
 
3123
3648
  // src/setup/apply.ts
3124
3649
  import { createHash as createHash3 } from "crypto";
3125
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3650
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3126
3651
  import { dirname as dirname8 } from "path";
3127
3652
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
3128
3653
  var MARKER_END = "<!-- @sechroom/cli:end";
@@ -3180,7 +3705,7 @@ function ensureDir2(path) {
3180
3705
  }
3181
3706
  function readOr(path, fallback) {
3182
3707
  try {
3183
- return readFileSync7(path, "utf8");
3708
+ return readFileSync8(path, "utf8");
3184
3709
  } catch {
3185
3710
  return fallback;
3186
3711
  }
@@ -3191,7 +3716,7 @@ function mergeMcpJson(path, snippet, dryRun) {
3191
3716
  let current = {};
3192
3717
  if (existed) {
3193
3718
  try {
3194
- current = JSON.parse(readFileSync7(path, "utf8"));
3719
+ current = JSON.parse(readFileSync8(path, "utf8"));
3195
3720
  } catch {
3196
3721
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
3197
3722
  }
@@ -3907,7 +4432,7 @@ import { basename as basename2, join as join13 } from "path";
3907
4432
 
3908
4433
  // src/commands/fanout.ts
3909
4434
  import { spawnSync } from "child_process";
3910
- import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4435
+ import { existsSync as existsSync10, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
3911
4436
  import { isAbsolute, join as join12, resolve } from "path";
3912
4437
  var ICON = {
3913
4438
  refresh: "\u21BB",
@@ -3942,7 +4467,7 @@ function readManifest(path) {
3942
4467
  if (!existsSync10(path)) return null;
3943
4468
  let parsed;
3944
4469
  try {
3945
- parsed = JSON.parse(readFileSync8(path, "utf8"));
4470
+ parsed = JSON.parse(readFileSync9(path, "utf8"));
3946
4471
  } catch (err2) {
3947
4472
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3948
4473
  }
@@ -4756,24 +5281,45 @@ Examples:
4756
5281
  $ sechroom relationship suggestions --status Pending --memory mem_XXXX
4757
5282
  $ sechroom relationship suggestion accept rsg_XXXX`
4758
5283
  );
4759
- relationship.command("create <fromMemoryId> <toMemoryId>").description("Create a relationship (POST /memories/{memoryId}/relationships)").option("--type <type>", "Relationship type (Reference, Related, Parent, Child, Follows, \u2026)", "Reference").action(async (fromMemoryId, toMemoryId, opts, cmd) => {
5284
+ relationship.command("create <fromMemoryId> <toMemoryId>").description("Create a relationship (POST /memories/{memoryId}/relationships)").option("--type <type>", "Relationship type (Reference, Related, Parent, Child, Follows, \u2026)", "Reference").option(
5285
+ "--to-version <number>",
5286
+ "Version of the target memory to pin the edge to (defaults to the target's current version)"
5287
+ ).action(async (fromMemoryId, toMemoryId, opts, cmd) => {
4760
5288
  const cfg = resolveConfig(cmd.optsWithGlobals());
4761
- const data = await runApi("Creating relationship", async () => {
4762
- const client = await makeClient(cfg);
4763
- return client.POST("/memories/{memoryId}/relationships", {
5289
+ const client = await makeClient(cfg);
5290
+ let toVersion;
5291
+ if (opts.toVersion !== void 0) {
5292
+ toVersion = Number(opts.toVersion);
5293
+ if (!Number.isInteger(toVersion) || toVersion < 1) {
5294
+ fail(`--to-version must be a positive integer (got '${opts.toVersion}').`);
5295
+ }
5296
+ } else {
5297
+ const target = await runApi(
5298
+ "Resolving target version",
5299
+ async () => client.GET("/memories/{memoryId}", { params: { path: { memoryId: toMemoryId } } })
5300
+ );
5301
+ if (typeof target.item?.currentVersion !== "number") {
5302
+ fail(`Could not resolve the current version of ${toMemoryId}; pass --to-version explicitly.`);
5303
+ }
5304
+ toVersion = target.item.currentVersion;
5305
+ }
5306
+ const data = await runApi(
5307
+ "Creating relationship",
5308
+ async () => client.POST("/memories/{memoryId}/relationships", {
4764
5309
  params: { path: { memoryId: fromMemoryId } },
4765
5310
  body: {
4766
5311
  toMemoryId,
4767
- type: opts.type
5312
+ type: opts.type,
5313
+ toVersion
4768
5314
  }
4769
- });
4770
- });
5315
+ })
5316
+ );
4771
5317
  const inversePart = data.inverseId ? ` ${style.dim(`(inverse ${data.inverseId})`)}` : "";
4772
5318
  const view = resolveViewUrl(cfg.baseUrl, data.url);
4773
5319
  const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
4774
5320
  emitAction(
4775
- `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId}`)}${inversePart}${urlPart}`,
4776
- data,
5321
+ `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId} @v${toVersion}`)}${inversePart}${urlPart}`,
5322
+ { ...data, toVersion },
4777
5323
  cmd.optsWithGlobals().json
4778
5324
  );
4779
5325
  });
@@ -4897,7 +5443,7 @@ Examples:
4897
5443
  // src/commands/reset.ts
4898
5444
  import { homedir as homedir4 } from "os";
4899
5445
  import { join as join14 } from "path";
4900
- import { existsSync as existsSync12, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs";
5446
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
4901
5447
  var SKILLS_LOCK2 = ".sechroom-skills.json";
4902
5448
  var localSkillsDir = () => join14(process.cwd(), ".claude", "skills");
4903
5449
  var globalSkillsDir = () => join14(homedir4(), ".claude", "skills");
@@ -4908,7 +5454,7 @@ function removeMaterialisedSkills(dir) {
4908
5454
  const lockPath = join14(dir, SKILLS_LOCK2);
4909
5455
  if (!existsSync12(lockPath)) return removed;
4910
5456
  try {
4911
- const lock = JSON.parse(readFileSync9(lockPath, "utf8"));
5457
+ const lock = JSON.parse(readFileSync10(lockPath, "utf8"));
4912
5458
  for (const entry of Object.values(lock)) {
4913
5459
  for (const name of entry.skills ?? []) {
4914
5460
  const p = join14(dir, name);
@@ -5047,7 +5593,9 @@ function registerSkills(program2) {
5047
5593
  "after",
5048
5594
  `
5049
5595
  Examples:
5050
- $ sechroom skills install materialise your installed skills to ~/.claude/skills
5596
+ $ sechroom skills install --client claude materialise target:claude-code skills to ~/.claude/skills
5597
+ $ sechroom skills install --client codex materialise target:gpt-codex skills to ~/.codex/skills
5598
+ $ sechroom skills install --client all keep both global clients in sync
5051
5599
  $ sechroom skills install --scope project write them to ./.claude/skills instead
5052
5600
  $ sechroom skills list what's materialised on disk
5053
5601
  $ sechroom skills clean remove the materialised skill files
@@ -5056,11 +5604,15 @@ Examples:
5056
5604
  $ sechroom skills package --from-source --workspace wsp_abc -o ./dist zip a draft from source
5057
5605
  $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
5058
5606
 
5607
+ When --client is omitted, configured client flags/environment variables win;
5608
+ otherwise existing default client homes are detected. Both configured/detected
5609
+ clients select all; an unconfigured machine preserves the legacy Claude default.
5610
+
5059
5611
  `
5060
5612
  );
5061
- 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));
5062
- 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));
5063
- 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));
5613
+ 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));
5614
+ 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));
5615
+ 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));
5064
5616
  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) => {
5065
5617
  const json = Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json);
5066
5618
  const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
@@ -5308,7 +5860,7 @@ Examples:
5308
5860
  import {
5309
5861
  existsSync as existsSync15,
5310
5862
  mkdirSync as mkdirSync12,
5311
- readFileSync as readFileSync10,
5863
+ readFileSync as readFileSync11,
5312
5864
  rmSync as rmSync4,
5313
5865
  writeFileSync as writeFileSync12
5314
5866
  } from "fs";
@@ -5414,7 +5966,7 @@ function registerTelemetry(program2) {
5414
5966
  );
5415
5967
  });
5416
5968
  telemetry.command("hook").description(
5417
- "Per-turn telemetry self-report for a Claude Code Stop hook (reads stdin; no-op unless bound). Fail-soft."
5969
+ "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."
5418
5970
  ).action(async (_opts, cmd) => {
5419
5971
  try {
5420
5972
  const raw = await readStdin2();
@@ -5423,21 +5975,10 @@ function registerTelemetry(program2) {
5423
5975
  const binding = findBinding(cwd);
5424
5976
  if (!binding) return process.exit(0);
5425
5977
  const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
5426
- if (!usage) return process.exit(0);
5978
+ const events = buildHookEvents(input, usage, binding.taskId);
5979
+ if (events.length === 0) return process.exit(0);
5427
5980
  const cfg = resolveConfig(cmd.optsWithGlobals());
5428
- await postTelemetry(cfg, binding.decompositionId, [
5429
- {
5430
- taskId: binding.taskId,
5431
- kind: "Parsed",
5432
- tokensIn: usage.tokensIn,
5433
- tokensOut: usage.tokensOut,
5434
- contextUsed: usage.contextUsed,
5435
- contextWindow: usage.contextWindow,
5436
- text: null,
5437
- approvalState: null,
5438
- verdict: null
5439
- }
5440
- ]);
5981
+ await postTelemetry(cfg, binding.decompositionId, events);
5441
5982
  return process.exit(0);
5442
5983
  } catch {
5443
5984
  return process.exit(0);
@@ -5465,7 +6006,12 @@ function registerTelemetry(program2) {
5465
6006
  scope,
5466
6007
  cwd
5467
6008
  });
5468
- const commands = { Stop: "sechroom telemetry hook" };
6009
+ const commands = {
6010
+ Stop: "sechroom telemetry hook",
6011
+ SubagentStop: "sechroom telemetry hook",
6012
+ Notification: "sechroom telemetry hook",
6013
+ PermissionDenied: "sechroom telemetry hook"
6014
+ };
5469
6015
  try {
5470
6016
  const multi = targets.length > 1;
5471
6017
  const results = targets.map((t) => {
@@ -5523,7 +6069,7 @@ function findBinding(start) {
5523
6069
  if (existsSync15(path)) {
5524
6070
  try {
5525
6071
  const b = JSON.parse(
5526
- readFileSync10(path, "utf8")
6072
+ readFileSync11(path, "utf8")
5527
6073
  );
5528
6074
  if (b.decompositionId && b.taskId)
5529
6075
  return { decompositionId: b.decompositionId, taskId: b.taskId };
@@ -5542,7 +6088,7 @@ function parseTranscript(path) {
5542
6088
  let tokensOut = 0;
5543
6089
  let contextUsed = 0;
5544
6090
  let model = "";
5545
- for (const line of readFileSync10(path, "utf8").split("\n")) {
6091
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
5546
6092
  if (!line.trim()) continue;
5547
6093
  let obj;
5548
6094
  try {
@@ -5559,11 +6105,61 @@ function parseTranscript(path) {
5559
6105
  if (obj.message?.model) model = obj.message.model;
5560
6106
  }
5561
6107
  if (tokensIn === 0 && tokensOut === 0) return null;
5562
- return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model) };
6108
+ return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
5563
6109
  }
5564
- function windowFor(model) {
6110
+ function windowFor(model, contextUsed = 0) {
5565
6111
  const m = model.toLowerCase();
5566
- return m.includes("[1m]") || m.includes("-1m") ? 1e6 : 2e5;
6112
+ if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
6113
+ return contextUsed > 2e5 ? 1e6 : 2e5;
6114
+ }
6115
+ function buildHookEvents(input, usage, taskId) {
6116
+ const events = [];
6117
+ const base = (kind, over) => ({
6118
+ taskId,
6119
+ kind,
6120
+ tokensIn: null,
6121
+ tokensOut: null,
6122
+ contextUsed: null,
6123
+ contextWindow: null,
6124
+ text: null,
6125
+ approvalState: null,
6126
+ verdict: null,
6127
+ ...over
6128
+ });
6129
+ if (usage) {
6130
+ events.push(
6131
+ base("Parsed", {
6132
+ tokensIn: usage.tokensIn,
6133
+ tokensOut: usage.tokensOut,
6134
+ contextUsed: usage.contextUsed,
6135
+ contextWindow: usage.contextWindow
6136
+ })
6137
+ );
6138
+ }
6139
+ switch (input.hook_event_name) {
6140
+ case "PermissionDenied":
6141
+ events.push(
6142
+ base("Approval", {
6143
+ approvalState: "denied",
6144
+ text: input.tool_name ?? input.message ?? null
6145
+ })
6146
+ );
6147
+ break;
6148
+ case "Notification":
6149
+ if (isPermissionNotification(input))
6150
+ events.push(base("Approval", { text: input.message ?? null }));
6151
+ break;
6152
+ case "Stop":
6153
+ case "SubagentStop":
6154
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6155
+ break;
6156
+ }
6157
+ return events;
6158
+ }
6159
+ function isPermissionNotification(input) {
6160
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6161
+ if (t) return t.includes("permission");
6162
+ return (input.message ?? "").toLowerCase().includes("permission");
5567
6163
  }
5568
6164
  async function readStdin2() {
5569
6165
  if (process.stdin.isTTY) return "";
@@ -5799,7 +6395,7 @@ Examples:
5799
6395
  function resolveVersion() {
5800
6396
  try {
5801
6397
  const pkg = JSON.parse(
5802
- readFileSync11(new URL("../package.json", import.meta.url), "utf8")
6398
+ readFileSync12(new URL("../package.json", import.meta.url), "utf8")
5803
6399
  );
5804
6400
  return pkg.version ?? "0.0.0";
5805
6401
  } catch {
@@ -5957,6 +6553,8 @@ registerLookup(program);
5957
6553
  registerRelationships(program);
5958
6554
  registerWorkspace(program);
5959
6555
  registerProject(program);
6556
+ registerDecomposition(program);
6557
+ registerClose(program);
5960
6558
  registerFiling(program);
5961
6559
  registerContinuity(program);
5962
6560
  registerCheckpoint(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.7.6",
3
+ "version": "2026.7.7-rc.56782977",
4
4
  "description": "Sechroom CLI — a thin, generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -36,12 +36,14 @@
36
36
  "openapi-typescript": "^7.4.0",
37
37
  "tsup": "^8.3.0",
38
38
  "tsx": "^4.19.0",
39
- "typescript": "^5.6.0"
39
+ "typescript": "^5.6.0",
40
+ "typescript-7": "npm:typescript@^7.0.2"
40
41
  },
41
42
  "scripts": {
42
43
  "gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
43
44
  "build": "tsup src/index.ts --format esm --target node20 --clean",
44
45
  "dev": "tsx src/index.ts",
46
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
45
47
  "check-types": "tsc --noEmit"
46
48
  }
47
49
  }