@sechroom/cli 2026.6.34-rc.083343cd → 2026.6.34

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 +1169 -352
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1773,14 +1773,18 @@ Examples:
1773
1773
  });
1774
1774
  }
1775
1775
 
1776
+ // src/commands/checkpoint.ts
1777
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
1778
+ import { dirname as dirname5, join as join6 } from "path";
1779
+
1776
1780
  // src/commands/hook.ts
1777
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1778
- import { homedir as homedir3 } from "os";
1779
- import { delimiter, dirname as dirname4, join as join4 } from "path";
1781
+ import { createHash as createHash2 } from "crypto";
1782
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
1783
+ import { delimiter, dirname as dirname4, join as join5 } from "path";
1780
1784
 
1781
1785
  // src/sem.ts
1782
1786
  import { basename as basename2, dirname as dirname2, join as join2 } from "path";
1783
- import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1787
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
1784
1788
  var SEM_FILE = join2(".sechroom", "lane.json");
1785
1789
  var LEGACY_SEM_FILE = ".sem";
1786
1790
  var STATE_DIR_NAME2 = ".sechroom";
@@ -1799,6 +1803,43 @@ function resolveSemPathForRead(start = process.cwd()) {
1799
1803
  dir = parent;
1800
1804
  }
1801
1805
  }
1806
+ function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
1807
+ try {
1808
+ let dir = start;
1809
+ let gitPath;
1810
+ for (; ; ) {
1811
+ const candidate = join2(dir, ".git");
1812
+ if (existsSync2(candidate)) {
1813
+ gitPath = candidate;
1814
+ break;
1815
+ }
1816
+ const parent = dirname2(dir);
1817
+ if (parent === dir) break;
1818
+ dir = parent;
1819
+ }
1820
+ if (!gitPath || statSync(gitPath).isDirectory()) return lane;
1821
+ const gitFile = readFileSync2(gitPath, "utf8");
1822
+ const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
1823
+ if (!common) return lane;
1824
+ const worktreesDir = join2(common[1], "worktrees");
1825
+ const siblings = readdirSync(worktreesDir).filter((n) => {
1826
+ try {
1827
+ return statSync(join2(worktreesDir, n)).isDirectory();
1828
+ } catch {
1829
+ return false;
1830
+ }
1831
+ });
1832
+ return laneWithWorktreeSuffix(lane, gitFile, siblings);
1833
+ } catch {
1834
+ return lane;
1835
+ }
1836
+ }
1837
+ function laneWithWorktreeSuffix(lane, gitFile, siblings) {
1838
+ const m = gitFile.trim().match(/\/worktrees\/([^/\s]+)\s*$/);
1839
+ if (!m) return lane;
1840
+ const idx = [...siblings].sort().indexOf(m[1]);
1841
+ return idx < 0 ? lane : `${lane}-${idx + 2}`;
1842
+ }
1802
1843
  function parseSem(text) {
1803
1844
  const out = {};
1804
1845
  for (const raw of text.split("\n")) {
@@ -1896,8 +1937,55 @@ function ensureSemIgnored(semPath) {
1896
1937
 
1897
1938
  // src/setup/clients.ts
1898
1939
  import { existsSync as existsSync3 } from "fs";
1940
+ import { homedir as homedir3 } from "os";
1941
+ import { dirname as dirname3, join as join4 } from "path";
1942
+
1943
+ // src/setup/config-dirs.ts
1899
1944
  import { homedir as homedir2 } from "os";
1900
- import { dirname as dirname3, join as join3 } from "path";
1945
+ import { join as join3 } from "path";
1946
+ function expandTilde(p) {
1947
+ if (p === "~") return homedir2();
1948
+ if (p.startsWith("~/")) return join3(homedir2(), p.slice(2));
1949
+ return p;
1950
+ }
1951
+ function splitDirs(raw) {
1952
+ if (!raw) return [];
1953
+ return raw.split(",").map((s) => expandTilde(s.trim())).filter(Boolean);
1954
+ }
1955
+ function resolveScope(flag) {
1956
+ if (flag == null) return "global";
1957
+ if (flag === "global" || flag === "project") return flag;
1958
+ throw new Error(`--scope must be 'global' or 'project' (got '${flag}')`);
1959
+ }
1960
+ function labelFor(dir) {
1961
+ const h = homedir2();
1962
+ if (dir === h) return "~";
1963
+ return dir.startsWith(h + "/") ? "~" + dir.slice(h.length) : dir;
1964
+ }
1965
+ function defaultClaudeDir() {
1966
+ return join3(homedir2(), ".claude");
1967
+ }
1968
+ function defaultCodexHome() {
1969
+ return join3(homedir2(), ".codex");
1970
+ }
1971
+ function resolveClaudeTargets(opts) {
1972
+ const scope = opts.scope ?? "global";
1973
+ const cwd = opts.cwd ?? process.cwd();
1974
+ if (scope === "project") {
1975
+ return [{ dir: join3(cwd, ".claude"), scope, label: "<project>" }];
1976
+ }
1977
+ const fromFlag = splitDirs(opts.override);
1978
+ const fromEnv = splitDirs(process.env.CLAUDE_CONFIG_DIR);
1979
+ const dirs = fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultClaudeDir()];
1980
+ return dirs.map((dir) => ({ dir, scope, label: labelFor(dir) }));
1981
+ }
1982
+ function resolveCodexHomes(opts) {
1983
+ const scope = opts.scope ?? "global";
1984
+ if (scope === "project") return [];
1985
+ const fromFlag = splitDirs(opts.override);
1986
+ const fromEnv = splitDirs(process.env.CODEX_HOME);
1987
+ return fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultCodexHome()];
1988
+ }
1901
1989
 
1902
1990
  // src/setup/operator-surface.ts
1903
1991
  var SectionType = {
@@ -1910,15 +1998,25 @@ var SectionType = {
1910
1998
  * carried a workspaceId and that workspace has agent-setup-bundle memories. */
1911
1999
  WorkspaceConventions: "workspace-conventions"
1912
2000
  };
1913
- async function fetchSetup(cfg) {
2001
+ async function fetchSetup(cfg, namespaceSlug) {
1914
2002
  const client = await makeClient(cfg);
2003
+ const query = {};
2004
+ if (cfg.workspaceId) query.workspaceId = cfg.workspaceId;
2005
+ if (namespaceSlug) query.namespaceSlug = namespaceSlug;
2006
+ const hasQuery = query.workspaceId !== void 0 || query.namespaceSlug !== void 0;
1915
2007
  const { data, error } = await client.GET(
1916
2008
  "/operator-surface/setup",
1917
- cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
2009
+ hasQuery ? { params: { query } } : {}
1918
2010
  );
1919
2011
  if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
1920
2012
  return data;
1921
2013
  }
2014
+ async function listNamespaces(cfg) {
2015
+ const client = await makeClient(cfg);
2016
+ const { data } = await client.GET("/mcp-aggregator/namespaces", {});
2017
+ const rows = data ?? [];
2018
+ return rows.filter((r) => typeof r.slug === "string").map((r) => ({ slug: r.slug, displayName: r.displayName ?? r.slug }));
2019
+ }
1922
2020
  function findSurface(setup, surfaceKey) {
1923
2021
  return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
1924
2022
  }
@@ -1989,12 +2087,14 @@ async function resolveWorkspaceConventions(cfg, section) {
1989
2087
  if (parseTagArtifactId(artifact.id)) continue;
1990
2088
  const mem = await fetchMemoryFields(cfg, artifact.id);
1991
2089
  if (typeof mem?.text === "string" && mem.text.trim().length > 0) {
1992
- parts.push(mem.text.trim());
1993
- refs.push(`${artifact.id}@v${mem.version ?? 1}`);
2090
+ const ref = `${artifact.id}@v${mem.version ?? 1}`;
2091
+ parts.push(`<!-- @sechroom/cli:section source=${ref} -->
2092
+ ${mem.text.trim()}`);
2093
+ refs.push(ref);
1994
2094
  }
1995
2095
  }
1996
2096
  if (parts.length === 0) return null;
1997
- return { body: parts.join("\n\n---\n\n"), refs };
2097
+ return { body: parts.join("\n\n"), refs };
1998
2098
  }
1999
2099
  async function createOverride(cfg, template, personalWorkspaceId) {
2000
2100
  const client = await makeClient(cfg);
@@ -2022,51 +2122,53 @@ async function createOverride(cfg, template, personalWorkspaceId) {
2022
2122
  function claudeDesktopConfigPath(home) {
2023
2123
  switch (process.platform) {
2024
2124
  case "darwin":
2025
- return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
2125
+ return join4(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
2026
2126
  case "win32":
2027
- return join3(process.env.APPDATA ?? join3(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
2127
+ return join4(process.env.APPDATA ?? join4(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
2028
2128
  default:
2029
- return join3(home, ".config", "Claude", "claude_desktop_config.json");
2129
+ return join4(home, ".config", "Claude", "claude_desktop_config.json");
2030
2130
  }
2031
2131
  }
2032
- function clientTargets(cwd) {
2033
- const home = homedir2();
2132
+ function clientTargets(cwd, opts = {}) {
2133
+ const home = homedir3();
2134
+ const claudeDir = opts.claudeDir ?? join4(home, ".claude");
2135
+ const codexHome = opts.codexHome ?? join4(home, ".codex");
2034
2136
  return {
2035
2137
  "claude-code": {
2036
2138
  key: "claude-code",
2037
2139
  label: "Claude Code",
2038
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".mcp.json"), format: "json" },
2039
- instruction: { surfaceKey: "claude-code", path: join3(cwd, "CLAUDE.md") }
2140
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".mcp.json"), format: "json" },
2141
+ instruction: { surfaceKey: "claude-code", path: join4(cwd, "CLAUDE.md") }
2040
2142
  },
2041
2143
  "claude-desktop": {
2042
2144
  key: "claude-desktop",
2043
2145
  label: "Claude Desktop",
2044
2146
  mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
2045
- instruction: { surfaceKey: "claude-desktop", path: join3(home, ".claude", "CLAUDE.md") }
2147
+ instruction: { surfaceKey: "claude-desktop", path: join4(claudeDir, "CLAUDE.md") }
2046
2148
  },
2047
2149
  codex: {
2048
2150
  key: "codex",
2049
2151
  label: "Codex CLI",
2050
- mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join3(home, ".codex", "config.toml"), format: "toml" },
2051
- instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
2152
+ mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join4(codexHome, "config.toml"), format: "toml" },
2153
+ instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
2052
2154
  },
2053
2155
  cursor: {
2054
2156
  key: "cursor",
2055
2157
  label: "Cursor",
2056
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join3(cwd, ".cursor", "mcp.json"), format: "json" },
2057
- instruction: { surfaceKey: "chatgpt", path: join3(cwd, "AGENTS.md") }
2158
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".cursor", "mcp.json"), format: "json" },
2159
+ instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
2058
2160
  }
2059
2161
  };
2060
2162
  }
2061
2163
  var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
2062
2164
  var DEFAULT_CLIENT_KEY = "claude-code";
2063
2165
  function detectInstalledClients(cwd) {
2064
- const home = homedir2();
2166
+ const home = homedir3();
2065
2167
  const detected = [];
2066
- if (existsSync3(join3(home, ".claude"))) detected.push("claude-code");
2168
+ if (resolveClaudeTargets({}).some((t) => existsSync3(t.dir))) detected.push("claude-code");
2067
2169
  if (existsSync3(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
2068
- if (existsSync3(join3(home, ".codex"))) detected.push("codex");
2069
- if (existsSync3(join3(home, ".cursor")) || existsSync3(join3(cwd, ".cursor"))) detected.push("cursor");
2170
+ if (resolveCodexHomes({}).some((d) => existsSync3(d))) detected.push("codex");
2171
+ if (existsSync3(join4(home, ".cursor")) || existsSync3(join4(cwd, ".cursor"))) detected.push("cursor");
2070
2172
  return detected;
2071
2173
  }
2072
2174
 
@@ -2090,14 +2192,15 @@ function resolveLane(flagLane, cwd) {
2090
2192
  const env = process.env.SECHROOM_LANE;
2091
2193
  if (env) return env;
2092
2194
  const start = cwd ?? process.cwd();
2093
- const sem = readSem(resolveSemPathForRead(start));
2094
- return sem?.values["code-lane"];
2195
+ const base = readSem(resolveSemPathForRead(start))?.values["code-lane"];
2196
+ if (!base) return void 0;
2197
+ return applyWorktreeLaneSuffix(base, start);
2095
2198
  }
2096
- var INTENT_FILE = join4(".sechroom", "continuity.json");
2199
+ var INTENT_FILE = join5(".sechroom", "continuity.json");
2097
2200
  function resolveIntentPath(start) {
2098
2201
  let dir = start;
2099
2202
  for (; ; ) {
2100
- const candidate = join4(dir, INTENT_FILE);
2203
+ const candidate = join5(dir, INTENT_FILE);
2101
2204
  if (existsSync4(candidate)) return candidate;
2102
2205
  const parent = dirname4(dir);
2103
2206
  if (parent === dir) return void 0;
@@ -2118,6 +2221,102 @@ function hasRequiredIntent(i) {
2118
2221
  i.objective?.trim() && i.state?.trim() && i.lastAction?.trim() && i.nextAction?.trim() && i.resumeInstruction?.trim()
2119
2222
  );
2120
2223
  }
2224
+ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScope, opts) {
2225
+ const lane = resolveLane(laneFlag, cwd);
2226
+ if (!lane) return false;
2227
+ const intent = readIntent(cwd);
2228
+ if (!intent || !hasRequiredIntent(intent)) return false;
2229
+ if (opts?.skipIfUnchanged && unchangedSinceLastPush(cwd, intent)) return false;
2230
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2231
+ const client = await makeClient(cfg);
2232
+ await client.POST("/continuity/snapshots", {
2233
+ body: {
2234
+ laneId: lane,
2235
+ scope: scopeFlag ?? intent.scope ?? defaultScope,
2236
+ currentObjective: intent.objective,
2237
+ currentState: intent.state,
2238
+ lastMeaningfulAction: intent.lastAction,
2239
+ nextIntendedAction: intent.nextAction,
2240
+ resumeInstruction: intent.resumeInstruction,
2241
+ activeConstraints: intent.constraints ?? null,
2242
+ openQuestions: intent.questions ?? null,
2243
+ surfaceMarkers: intent.surfaceMarkers ?? null,
2244
+ relevantArtifactIds: intent.artifacts ?? null,
2245
+ confidence: intent.confidence ?? null,
2246
+ // Frequent triggers (compaction, session-end) land within the FR-051 4h
2247
+ // window; Acknowledge lets the checkpoint persist on the lane.
2248
+ concurrentSessionPolicy: "Acknowledge"
2249
+ }
2250
+ });
2251
+ recordPush(cwd, intent);
2252
+ return true;
2253
+ }
2254
+ function ledgerPath(start) {
2255
+ const intent = resolveIntentPath(start);
2256
+ const dir = intent ? dirname4(intent) : join5(start, ".sechroom");
2257
+ return join5(dir, ".checkpoint-state.json");
2258
+ }
2259
+ function readLedger(start) {
2260
+ try {
2261
+ const p = ledgerPath(start);
2262
+ if (!existsSync4(p)) return {};
2263
+ return JSON.parse(readFileSync3(p, "utf8"));
2264
+ } catch {
2265
+ return {};
2266
+ }
2267
+ }
2268
+ function intentHash(i) {
2269
+ const canonical = JSON.stringify({
2270
+ objective: i.objective ?? "",
2271
+ state: i.state ?? "",
2272
+ lastAction: i.lastAction ?? "",
2273
+ nextAction: i.nextAction ?? "",
2274
+ resumeInstruction: i.resumeInstruction ?? "",
2275
+ scope: i.scope ?? "",
2276
+ constraints: i.constraints ?? [],
2277
+ questions: i.questions ?? [],
2278
+ surfaceMarkers: i.surfaceMarkers ?? [],
2279
+ artifacts: i.artifacts ?? [],
2280
+ confidence: i.confidence ?? null
2281
+ });
2282
+ return createHash2("sha256").update(canonical, "utf8").digest("hex");
2283
+ }
2284
+ function recentlyCheckpointed(start, minutes) {
2285
+ const { lastEpochMs } = readLedger(start);
2286
+ return typeof lastEpochMs === "number" && Date.now() - lastEpochMs < minutes * 6e4;
2287
+ }
2288
+ function unchangedSinceLastPush(start, intent) {
2289
+ const ledger = readLedger(start);
2290
+ if (ledger.lastHash == null) return false;
2291
+ const path = resolveIntentPath(start);
2292
+ if (path && ledger.lastMtimeMs != null) {
2293
+ try {
2294
+ if (statSync2(path).mtimeMs <= ledger.lastMtimeMs) return true;
2295
+ } catch {
2296
+ }
2297
+ }
2298
+ return intentHash(intent) === ledger.lastHash;
2299
+ }
2300
+ function recordPush(start, intent) {
2301
+ try {
2302
+ const p = ledgerPath(start);
2303
+ const path = resolveIntentPath(start);
2304
+ let mtimeMs;
2305
+ try {
2306
+ if (path) mtimeMs = statSync2(path).mtimeMs;
2307
+ } catch {
2308
+ mtimeMs = void 0;
2309
+ }
2310
+ mkdirSync3(dirname4(p), { recursive: true });
2311
+ const ledger = {
2312
+ lastEpochMs: Date.now(),
2313
+ lastMtimeMs: mtimeMs,
2314
+ lastHash: intentHash(intent)
2315
+ };
2316
+ writeFileSync3(p, JSON.stringify(ledger) + "\n");
2317
+ } catch {
2318
+ }
2319
+ }
2121
2320
  function formatContext(bundle, lane) {
2122
2321
  const s = bundle?.latestSnapshot;
2123
2322
  if (!s) return null;
@@ -2154,20 +2353,23 @@ function emitSessionStart(additionalContext) {
2154
2353
  }) + "\n"
2155
2354
  );
2156
2355
  }
2157
- var HOOK_COMMANDS = {
2356
+ var CLAUDE_HOOK_COMMANDS = {
2158
2357
  SessionStart: "sechroom hook session-start",
2159
- PreCompact: "sechroom hook pre-compact"
2358
+ PreCompact: "sechroom hook pre-compact",
2359
+ SessionEnd: "sechroom hook session-end"
2360
+ };
2361
+ var CODEX_HOOK_COMMANDS = {
2362
+ SessionStart: "sechroom hook session-start",
2363
+ Stop: "sechroom hook session-end --debounce-minutes 10"
2160
2364
  };
2161
- var HOOK_EVENTS = ["SessionStart", "PreCompact"];
2162
2365
  function hasHookCommand(config2, event, command) {
2163
2366
  const groups = config2.hooks?.[event] ?? [];
2164
2367
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
2165
2368
  }
2166
- function mergeHooks(config2) {
2369
+ function mergeHooks(config2, commands) {
2167
2370
  config2.hooks ??= {};
2168
2371
  let added = 0;
2169
- for (const event of HOOK_EVENTS) {
2170
- const command = HOOK_COMMANDS[event];
2372
+ for (const [event, command] of Object.entries(commands)) {
2171
2373
  if (hasHookCommand(config2, event, command)) continue;
2172
2374
  const groups = config2.hooks[event] ??= [];
2173
2375
  groups.push({ hooks: [{ type: "command", command }] });
@@ -2181,10 +2383,10 @@ function readJsonConfig2(path) {
2181
2383
  if (!raw.trim()) return {};
2182
2384
  return JSON.parse(raw);
2183
2385
  }
2184
- function installHooksJson(path, dryRun) {
2386
+ function installHooksJson(path, commands, dryRun) {
2185
2387
  const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
2186
2388
  const config2 = readJsonConfig2(path);
2187
- const added = mergeHooks(config2);
2389
+ const added = mergeHooks(config2, commands);
2188
2390
  if (added === 0 && existed) return { path, status: "current" };
2189
2391
  if (!dryRun) {
2190
2392
  mkdirSync3(dirname4(path), { recursive: true });
@@ -2244,11 +2446,11 @@ function installHookSurfaces(surfaces, opts) {
2244
2446
  const out = [];
2245
2447
  for (const surface of surfaces) {
2246
2448
  if (surface === "claude") {
2247
- const path = opts.local ? join4(opts.cwd, ".claude", "settings.json") : join4(opts.home, ".claude", "settings.json");
2248
- out.push({ surface, results: [installHooksJson(path, opts.dryRun)] });
2449
+ const path = join5(opts.claudeDir, "settings.json");
2450
+ out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
2249
2451
  } else {
2250
- const hooksJson = installHooksJson(join4(opts.home, ".codex", "hooks.json"), opts.dryRun);
2251
- const featureFlag = installCodexFeatureFlag(join4(opts.home, ".codex", "config.toml"), opts.dryRun);
2452
+ const hooksJson = installHooksJson(join5(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
2453
+ const featureFlag = installCodexFeatureFlag(join5(opts.codexHome, "config.toml"), opts.dryRun);
2252
2454
  out.push({ surface, results: [hooksJson, featureFlag] });
2253
2455
  }
2254
2456
  }
@@ -2268,7 +2470,7 @@ function isSechroomOnPath() {
2268
2470
  for (const dir of pathEnv.split(delimiter)) {
2269
2471
  if (!dir) continue;
2270
2472
  for (const name of names) {
2271
- if (existsSync4(join4(dir, name))) return true;
2473
+ if (existsSync4(join5(dir, name))) return true;
2272
2474
  }
2273
2475
  }
2274
2476
  return false;
@@ -2327,52 +2529,62 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2327
2529
  const raw = await readStdin();
2328
2530
  const input = parseHookInput(raw);
2329
2531
  const cwd = input.cwd ?? process.cwd();
2330
- const lane = resolveLane(opts.lane, input.cwd);
2331
- if (!lane) return process.exit(0);
2332
- const intent = readIntent(cwd);
2333
- if (!intent || !hasRequiredIntent(intent)) return process.exit(0);
2334
- const cfg = resolveConfig(cmd.optsWithGlobals());
2335
- const client = await makeClient(cfg);
2336
- await client.POST("/continuity/snapshots", {
2337
- body: {
2338
- laneId: lane,
2339
- scope: opts.scope ?? intent.scope ?? "compaction",
2340
- currentObjective: intent.objective,
2341
- currentState: intent.state,
2342
- lastMeaningfulAction: intent.lastAction,
2343
- nextIntendedAction: intent.nextAction,
2344
- resumeInstruction: intent.resumeInstruction,
2345
- activeConstraints: intent.constraints ?? null,
2346
- openQuestions: intent.questions ?? null,
2347
- surfaceMarkers: intent.surfaceMarkers ?? null,
2348
- relevantArtifactIds: intent.artifacts ?? null,
2349
- confidence: intent.confidence ?? null,
2350
- // Compaction is infrequent, so the FR-051 clobber guard doesn't bite;
2351
- // Acknowledge lets a within-window checkpoint land on the lane.
2352
- concurrentSessionPolicy: "Acknowledge"
2353
- }
2354
- });
2532
+ await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
2355
2533
  return process.exit(0);
2356
2534
  } catch {
2357
2535
  return process.exit(0);
2358
2536
  }
2359
2537
  });
2360
- hook.command("install").description("Wire the session-start + pre-compact hooks into Claude Code and/or Codex config").option("--surface <surface>", "Target surface: claude | codex | both (default: auto-detect installed surfaces)").option("--local", "Claude Code only: write <cwd>/.claude/settings.json instead of ~/.claude/settings.json").option("--dry-run", "Print what would change; write nothing").action((opts) => {
2538
+ hook.command("session-end").description("Save a continuity snapshot from the intent file on a SessionEnd (Claude) / Stop (Codex) hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sem code-lane)").option("--scope <scope>", "Snapshot scope (else the intent file's `scope`, else 'session-end')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").option(
2539
+ "--debounce-minutes <n>",
2540
+ "skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
2541
+ ).action(async (opts, cmd) => {
2542
+ try {
2543
+ const raw = await readStdin();
2544
+ const input = parseHookInput(raw);
2545
+ const cwd = input.cwd ?? process.cwd();
2546
+ const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
2547
+ if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
2548
+ await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "session-end", { skipIfUnchanged: true });
2549
+ return process.exit(0);
2550
+ } catch {
2551
+ return process.exit(0);
2552
+ }
2553
+ });
2554
+ hook.command("install").description("Wire the session-start + pre-compact hooks into Claude Code and/or Codex config").option("--surface <surface>", "Target surface: claude | codex | both (default: auto-detect installed surfaces)").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 change; write nothing").action((opts, cmd) => {
2555
+ const g = cmd.optsWithGlobals();
2361
2556
  const dryRun = Boolean(opts.dryRun);
2362
2557
  const cwd = process.cwd();
2558
+ let scope;
2363
2559
  let surfaces;
2364
2560
  try {
2561
+ scope = opts.local ? "project" : resolveScope(opts.scope);
2365
2562
  surfaces = resolveSurfaces(opts.surface, cwd);
2366
2563
  } catch (err2) {
2367
2564
  process.stderr.write(`${err2.message}
2368
2565
  `);
2369
2566
  return process.exit(2);
2370
2567
  }
2568
+ const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd }) : [];
2569
+ const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: g.codexHome, scope }) : [];
2371
2570
  const results = [];
2372
2571
  try {
2373
- const installed = installHookSurfaces(surfaces, { dryRun, local: opts.local, cwd, home: homedir3() });
2374
- for (const { surface, results: surfaceResults } of installed) {
2375
- process.stdout.write(`${HOOK_SURFACE_LABEL[surface]}:
2572
+ const multiClaude = claudeTargets.length > 1;
2573
+ for (const t of claudeTargets) {
2574
+ const surfaceResults = installHookSurfaces(["claude"], { dryRun, claudeDir: t.dir, codexHome: "" })[0].results;
2575
+ process.stdout.write(`${HOOK_SURFACE_LABEL.claude}${multiClaude ? ` (${t.label})` : ""}:
2576
+ `);
2577
+ for (const r of surfaceResults) {
2578
+ results.push(r);
2579
+ process.stdout.write(describe(r, dryRun) + "\n");
2580
+ }
2581
+ }
2582
+ if (surfaces.includes("codex") && codexHomes.length === 0) {
2583
+ process.stdout.write("Codex has no project scope \u2014 skipped (use --scope global for Codex).\n");
2584
+ }
2585
+ for (const codexHome of codexHomes) {
2586
+ const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2587
+ process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2376
2588
  `);
2377
2589
  for (const r of surfaceResults) {
2378
2590
  results.push(r);
@@ -2396,6 +2608,101 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2396
2608
  });
2397
2609
  }
2398
2610
 
2611
+ // src/commands/checkpoint.ts
2612
+ function registerCheckpoint(program2) {
2613
+ program2.command("checkpoint").description(
2614
+ "Checkpoint working state: create a continuity snapshot (server-validated) AND sync ./.sechroom/continuity.json in one step"
2615
+ ).option("--lane <laneId>", "Lane id (else SECHROOM_LANE, else ./.sem code-lane)").option("--scope <scope>", "Snapshot scope (else the file's scope, else 'session')").option("--objective <text>", "Current objective").option("--state <text>", "Current state").option("--last-action <text>", "Last meaningful action").option("--next-action <text>", "Next intended action").option("--resume-instruction <text>", "Resume instruction").option("--constraint <text...>", "Active constraints (repeatable)").option("--question <text...>", "Open questions (repeatable)").option("--surface-marker <text...>", "Surface markers (repeatable)").option("--artifact <id...>", "Relevant artifact ids (repeatable)").option("--confidence <n>", "Confidence 0..1").option("--dry-run", "validate + print the snapshot payload without creating it or writing the file", false).addHelpText(
2616
+ "after",
2617
+ `
2618
+ File-first: reads ./.sechroom/continuity.json (kept current as you work) as the base; any flag
2619
+ overrides that field. The snapshot is created FIRST (server-validated), then the local file is
2620
+ written/normalized with the returned snapshotId. Lane: --lane > SECHROOM_LANE > ./.sem code-lane.
2621
+
2622
+ Examples:
2623
+ $ sechroom checkpoint snapshot from ./.sechroom/continuity.json, then sync it
2624
+ $ sechroom checkpoint --next-action "..." override one field, keep the rest from the file
2625
+ $ sechroom checkpoint --lane claude-code-chris --objective "..." --state "..." \\
2626
+ --last-action "..." --next-action "..." --resume-instruction "..."`
2627
+ ).action(async (opts, cmd) => {
2628
+ const cwd = process.cwd();
2629
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2630
+ const json = Boolean(cmd.optsWithGlobals().json);
2631
+ const base = readIntent(cwd) ?? {};
2632
+ const merged = {
2633
+ objective: opts.objective ?? base.objective,
2634
+ state: opts.state ?? base.state,
2635
+ lastAction: opts.lastAction ?? base.lastAction,
2636
+ nextAction: opts.nextAction ?? base.nextAction,
2637
+ resumeInstruction: opts.resumeInstruction ?? base.resumeInstruction,
2638
+ scope: opts.scope ?? base.scope,
2639
+ constraints: opts.constraint ?? base.constraints,
2640
+ questions: opts.question ?? base.questions,
2641
+ surfaceMarkers: opts.surfaceMarker ?? base.surfaceMarkers,
2642
+ artifacts: opts.artifact ?? base.artifacts,
2643
+ confidence: opts.confidence != null ? Number(opts.confidence) : base.confidence
2644
+ };
2645
+ const lane = resolveLane(opts.lane, cwd);
2646
+ if (!lane) {
2647
+ fail(
2648
+ "no lane resolved \u2014 pass --lane, set SECHROOM_LANE, or pin one in ./.sem (code-lane). See `sechroom lane`."
2649
+ );
2650
+ }
2651
+ const required = [
2652
+ ["objective", "--objective"],
2653
+ ["state", "--state"],
2654
+ ["lastAction", "--last-action"],
2655
+ ["nextAction", "--next-action"],
2656
+ ["resumeInstruction", "--resume-instruction"]
2657
+ ];
2658
+ const missing = required.filter(([k]) => !String(merged[k] ?? "").trim()).map(([, flag]) => flag);
2659
+ if (missing.length > 0) {
2660
+ fail(
2661
+ `missing required field(s): ${missing.join(", ")} \u2014 supply via flag or in ./.sechroom/continuity.json`
2662
+ );
2663
+ }
2664
+ const scope = merged.scope ?? "session";
2665
+ const body = {
2666
+ laneId: lane,
2667
+ scope,
2668
+ currentObjective: merged.objective,
2669
+ currentState: merged.state,
2670
+ lastMeaningfulAction: merged.lastAction,
2671
+ nextIntendedAction: merged.nextAction,
2672
+ resumeInstruction: merged.resumeInstruction,
2673
+ activeConstraints: merged.constraints ?? null,
2674
+ openQuestions: merged.questions ?? null,
2675
+ surfaceMarkers: merged.surfaceMarkers ?? null,
2676
+ relevantArtifactIds: merged.artifacts ?? null,
2677
+ confidence: merged.confidence ?? null,
2678
+ // Explicit checkpoints are often within the FR-051 4h window; Acknowledge
2679
+ // lets one land on the lane (matches `hook pre-compact`).
2680
+ concurrentSessionPolicy: "Acknowledge"
2681
+ };
2682
+ if (opts.dryRun) {
2683
+ emit({ dryRun: true, lane, scope, wouldCreate: body }, json);
2684
+ return;
2685
+ }
2686
+ const data = await runApi("Creating snapshot", async () => {
2687
+ const client = await makeClient(cfg);
2688
+ return client.POST("/continuity/snapshots", { body });
2689
+ });
2690
+ const path = resolveIntentPath(cwd) ?? join6(cwd, INTENT_FILE);
2691
+ const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
2692
+ mkdirSync4(dirname5(path), { recursive: true });
2693
+ writeFileSync4(path, JSON.stringify(fileBody, null, 2) + "\n");
2694
+ recordPush(cwd, merged);
2695
+ if (json) {
2696
+ emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
2697
+ return;
2698
+ }
2699
+ process.stdout.write(
2700
+ `${style.bold("\u2713")} checkpoint ${style.bold(data.snapshotId)} ${style.dim(`(lane ${lane}, scope ${scope})`)} \u2014 synced ${path}
2701
+ `
2702
+ );
2703
+ });
2704
+ }
2705
+
2399
2706
  // src/commands/account.ts
2400
2707
  function registerId(program2) {
2401
2708
  const id = program2.command("id").description("Allocate human-authored id sequences (FR-*, D-*)");
@@ -2612,16 +2919,16 @@ Examples:
2612
2919
  }
2613
2920
 
2614
2921
  // src/setup/apply.ts
2615
- import { createHash as createHash2 } from "crypto";
2616
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
2617
- import { dirname as dirname5 } from "path";
2922
+ import { createHash as createHash3 } from "crypto";
2923
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
2924
+ import { dirname as dirname6 } from "path";
2618
2925
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
2619
2926
  var MARKER_END = "<!-- @sechroom/cli:end";
2620
2927
  function normalizeBody(s) {
2621
2928
  return s.replace(/\r\n/g, "\n").trim();
2622
2929
  }
2623
2930
  function bodySha256(body) {
2624
- return createHash2("sha256").update(normalizeBody(body), "utf8").digest("hex");
2931
+ return createHash3("sha256").update(normalizeBody(body), "utf8").digest("hex");
2625
2932
  }
2626
2933
  function renderBlock(write) {
2627
2934
  const body = normalizeBody(write.body);
@@ -2667,7 +2974,7 @@ function parseManagedBlock(content, block) {
2667
2974
  return null;
2668
2975
  }
2669
2976
  function ensureDir2(path) {
2670
- mkdirSync4(dirname5(path), { recursive: true });
2977
+ mkdirSync5(dirname6(path), { recursive: true });
2671
2978
  }
2672
2979
  function readOr(path, fallback) {
2673
2980
  try {
@@ -2690,7 +2997,7 @@ function mergeMcpJson(path, snippet, dryRun) {
2690
2997
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
2691
2998
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
2692
2999
  ensureDir2(path);
2693
- writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
3000
+ writeFileSync5(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
2694
3001
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
2695
3002
  }
2696
3003
  function mergeCodexToml(path, snippet, dryRun) {
@@ -2701,7 +3008,7 @@ function mergeCodexToml(path, snippet, dryRun) {
2701
3008
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
2702
3009
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
2703
3010
  ensureDir2(path);
2704
- writeFileSync4(path, next, { mode: 384 });
3011
+ writeFileSync5(path, next, { mode: 384 });
2705
3012
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
2706
3013
  }
2707
3014
  function writeInstructionBlock(path, write, dryRun) {
@@ -2709,7 +3016,7 @@ function writeInstructionBlock(path, write, dryRun) {
2709
3016
  const next = computeBlockFile(readOr(path, ""), write);
2710
3017
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
2711
3018
  ensureDir2(path);
2712
- writeFileSync4(path, next);
3019
+ writeFileSync5(path, next);
2713
3020
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
2714
3021
  }
2715
3022
  function computeBlockFile(current, write) {
@@ -2750,7 +3057,7 @@ function applyBlock(path, write, mode, dryRun) {
2750
3057
  const next = computeBlockFile(current, write);
2751
3058
  if (!dryRun) {
2752
3059
  ensureDir2(proposedPath);
2753
- writeFileSync4(proposedPath, next);
3060
+ writeFileSync5(proposedPath, next);
2754
3061
  }
2755
3062
  return {
2756
3063
  kind: "instruction",
@@ -2821,12 +3128,14 @@ async function applyClient(cfg, setup, target, opts) {
2821
3128
  }
2822
3129
 
2823
3130
  // src/setup/hooks-offer.ts
2824
- import { homedir as homedir4 } from "os";
2825
3131
  async function maybeOfferHooks(opts) {
2826
3132
  if (opts.dryRun) return;
2827
3133
  const cwd = opts.cwd ?? process.cwd();
3134
+ const scope = opts.scope ?? "global";
2828
3135
  const surfaces = detectHookSurfaces(cwd);
2829
3136
  if (surfaces.length === 0) return;
3137
+ const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: opts.claudeConfigDir, scope, cwd }) : [];
3138
+ const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: opts.codexHome, scope }) : [];
2830
3139
  const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
2831
3140
  process.stderr.write(
2832
3141
  `
@@ -2837,15 +3146,28 @@ auto-resumes where you left off and checkpoints working state before compacting.
2837
3146
  const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
2838
3147
  if (!install) return;
2839
3148
  try {
2840
- const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir4() });
2841
3149
  let changed = false;
2842
- for (const { surface, results } of installed) {
3150
+ const emit2 = (surface, results, label) => {
2843
3151
  for (const r of results) {
2844
3152
  if (r.status !== "current") changed = true;
2845
3153
  const verb = r.status === "current" ? "already configured" : r.status === "created" ? "created" : "updated";
2846
- process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}: ${r.path} (${verb})
3154
+ const tag = label ? ` ${style.dim(`(${label})`)}` : "";
3155
+ process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}${tag}: ${r.path} (${verb})
2847
3156
  `);
2848
3157
  }
3158
+ };
3159
+ const multiClaude = claudeTargets.length > 1;
3160
+ for (const t of claudeTargets) {
3161
+ const results = installHookSurfaces(["claude"], { dryRun: false, claudeDir: t.dir, codexHome: "" })[0].results;
3162
+ emit2("claude", results, multiClaude ? t.label : void 0);
3163
+ }
3164
+ if (surfaces.includes("codex") && codexHomes.length === 0) {
3165
+ process.stderr.write(`${style.dim("Codex has no project scope \u2014 skipped (use --scope global for Codex).")}
3166
+ `);
3167
+ }
3168
+ for (const codexHome of codexHomes) {
3169
+ const results = installHookSurfaces(["codex"], { dryRun: false, claudeDir: "", codexHome })[0].results;
3170
+ emit2("codex", results);
2849
3171
  }
2850
3172
  if (changed) {
2851
3173
  process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
@@ -2859,9 +3181,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
2859
3181
  }
2860
3182
 
2861
3183
  // src/setup/skills-offer.ts
2862
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2863
- import { homedir as homedir5 } from "os";
2864
- import { join as join5 } from "path";
3184
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "fs";
3185
+ import { join as join8 } from "path";
2865
3186
 
2866
3187
  // src/setup/lane-pin.ts
2867
3188
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -2943,57 +3264,173 @@ I can pin this checkout's lane so operator skills + the continuity hook resolve
2943
3264
  writePin(code || void 0, design || void 0);
2944
3265
  }
2945
3266
 
2946
- // src/setup/skills-offer.ts
2947
- var ROLE_TAG = "sechroom:role:skill-template";
3267
+ // src/setup/skill-resolution.ts
3268
+ var SYSTEM_WORKSPACE_ID = "wsp_system";
3269
+ var SKILL_ROLE_TAG = "sechroom:role:skill-template";
3270
+ var SKILL_NAME_PREFIX = "skill:";
3271
+ var AGENT_ROLE_TAG = "sechroom:role:agent-template";
3272
+ var AGENT_NAME_PREFIX = "agent:";
3273
+ function tagsOf(row) {
3274
+ const m = row?.item ?? row;
3275
+ return m?.tags ?? m?.Tags ?? [];
3276
+ }
2948
3277
  function tagValue(tags, prefix) {
2949
3278
  return tags.find((t) => t.startsWith(prefix))?.slice(prefix.length);
2950
3279
  }
2951
- async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
2952
- if (!personalWorkspaceId || opts.dryRun) return;
2953
- const surface = opts.surface ?? "claude-code";
2954
- let rows = [];
3280
+ function bodyOf(row) {
3281
+ const m = row?.item ?? row;
3282
+ return m?.text ?? m?.Text ?? "";
3283
+ }
3284
+ function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
3285
+ const out = /* @__PURE__ */ new Map();
3286
+ for (const row of rows ?? []) {
3287
+ const tags = tagsOf(row);
3288
+ if (!tags.includes(roleTag)) continue;
3289
+ if (tagValue(tags, "target:") !== surface) continue;
3290
+ const name = tagValue(tags, namePrefix);
3291
+ if (!name) continue;
3292
+ out.set(name, { name, body: bodyOf(row), source });
3293
+ }
3294
+ return out;
3295
+ }
3296
+ function resolveByRole(systemRows, personalRows, surface, roleTag, namePrefix) {
3297
+ const merged = entriesFromRows(systemRows, surface, "system", roleTag, namePrefix);
3298
+ for (const [name, item] of entriesFromRows(personalRows, surface, "personal", roleTag, namePrefix)) {
3299
+ merged.set(name, item);
3300
+ }
3301
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
3302
+ }
3303
+ function resolveSkills(systemRows, personalRows, surface) {
3304
+ return resolveByRole(systemRows, personalRows, surface, SKILL_ROLE_TAG, SKILL_NAME_PREFIX);
3305
+ }
3306
+ function resolveAgents(systemRows, personalRows, surface) {
3307
+ return resolveByRole(systemRows, personalRows, surface, AGENT_ROLE_TAG, AGENT_NAME_PREFIX);
3308
+ }
3309
+
3310
+ // src/setup/skill-resolution-io.ts
3311
+ var AGENT_TARGET = { "claude-code": "claude-agent" };
3312
+ function agentTargetFor(surface) {
3313
+ return AGENT_TARGET[surface] ?? `${surface}-agent`;
3314
+ }
3315
+ async function fetchFeedRows(cfg, workspaceId) {
2955
3316
  try {
2956
3317
  const client = await makeClient(cfg);
2957
3318
  const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
2958
3319
  params: {
2959
- path: { workspaceId: personalWorkspaceId },
3320
+ path: { workspaceId },
3321
+ // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
3322
+ // includeText: the feed omits bodies by default, we need them for SKILL.md.
2960
3323
  query: { limit: 200, cascadeWorkspaces: true, includeText: true }
2961
3324
  }
2962
3325
  }).then((r) => r.data).catch(() => void 0);
2963
- rows = feed?.results ?? feed?.Results ?? [];
3326
+ return feed?.results ?? feed?.Results ?? [];
2964
3327
  } catch {
3328
+ return [];
3329
+ }
3330
+ }
3331
+ async function fetchTemplateRows(cfg, personalWorkspaceId) {
3332
+ const [systemRows, personalRows] = await Promise.all([
3333
+ fetchFeedRows(cfg, SYSTEM_WORKSPACE_ID),
3334
+ personalWorkspaceId ? fetchFeedRows(cfg, personalWorkspaceId) : Promise.resolve([])
3335
+ ]);
3336
+ return { systemRows, personalRows };
3337
+ }
3338
+ function resolveSkillSet(rows, surface) {
3339
+ return resolveSkills(rows.systemRows, rows.personalRows, surface);
3340
+ }
3341
+ function resolveAgentSet(rows, surface) {
3342
+ return resolveAgents(rows.systemRows, rows.personalRows, agentTargetFor(surface));
3343
+ }
3344
+
3345
+ // src/setup/skills-lock.ts
3346
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
3347
+ import { join as join7 } from "path";
3348
+ var SKILLS_LOCK = ".sechroom-skills.json";
3349
+ var DEFAULT_SKILLS_SLUG = "operator-skills";
3350
+ function skillsDir(configDir) {
3351
+ return join7(configDir, "skills");
3352
+ }
3353
+ function agentsDir(configDir) {
3354
+ return join7(configDir, "agents");
3355
+ }
3356
+ function readSkillsLock(dir) {
3357
+ const lockPath = join7(dir, SKILLS_LOCK);
3358
+ if (!existsSync6(lockPath)) return {};
3359
+ try {
3360
+ return JSON.parse(readFileSync5(lockPath, "utf8"));
3361
+ } catch {
3362
+ return {};
3363
+ }
3364
+ }
3365
+ function writeSkillsLock(dir, lock) {
3366
+ mkdirSync6(dir, { recursive: true });
3367
+ writeFileSync6(join7(dir, SKILLS_LOCK), JSON.stringify(lock, null, 2) + "\n");
3368
+ }
3369
+ function recordMaterialisedSkills(dir, slug, skills, meta = {}) {
3370
+ const lock = readSkillsLock(dir);
3371
+ lock[slug] = { surface: meta.surface, skills: [...skills].sort() };
3372
+ writeSkillsLock(dir, lock);
3373
+ }
3374
+
3375
+ // src/setup/skills-offer.ts
3376
+ async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
3377
+ const surface = opts.surface ?? "claude-code";
3378
+ const configDir = opts.configDir ?? resolveClaudeTargets({})[0].dir;
3379
+ const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
3380
+ const skills = resolveSkillSet(rows, surface);
3381
+ const agents = resolveAgentSet(rows, surface);
3382
+ if (skills.length === 0 && agents.length === 0) return;
3383
+ const sDir = skillsDir(configDir);
3384
+ const aDir = agentsDir(configDir);
3385
+ if (opts.dryRun) {
3386
+ const lines = (label, items) => items.length === 0 ? "" : `
3387
+ Would materialise ${style.bold(String(items.length))} ${label} for ${surface}:
3388
+ ` + items.map((s) => ` ${s.name} ${style.dim(`[${s.source}]`)}`).join("\n") + "\n";
3389
+ process.stderr.write(lines("operator skill(s)", skills) + lines("agent(s)", agents));
2965
3390
  return;
2966
3391
  }
2967
- const skills = rows.map((r) => r.item ?? r).filter((m) => {
2968
- const tags = m.tags ?? m.Tags ?? [];
2969
- return tags.includes(ROLE_TAG) && tagValue(tags, "target:") === surface;
2970
- });
2971
- if (skills.length === 0) return;
2972
- const byName = /* @__PURE__ */ new Map();
2973
- for (const m of skills) {
2974
- const name = tagValue(m.tags ?? m.Tags ?? [], "skill:");
2975
- if (name) byName.set(name, m);
2976
- }
2977
- const names = [...byName.keys()].sort();
2978
- if (names.length === 0) return;
2979
- process.stderr.write(
2980
- `
2981
- Found ${style.bold(String(names.length))} operator skill(s) installed in your workspace: ${names.join(", ")}.
2982
- `
2983
- );
2984
- const dir = join5(homedir5(), ".claude", "skills");
2985
- const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dir}/ so ${surface} can use them?`) : false;
3392
+ const summary = [
3393
+ skills.length > 0 ? `${style.bold(String(skills.length))} skill(s)` : "",
3394
+ agents.length > 0 ? `${style.bold(String(agents.length))} agent(s)` : ""
3395
+ ].filter(Boolean).join(" + ");
3396
+ process.stderr.write(`
3397
+ Found ${summary} available to you for ${surface}.
3398
+ `);
3399
+ if (skills.length > 0) process.stderr.write(` skills: ${skills.map((s) => s.name).join(", ")}
3400
+ `);
3401
+ if (agents.length > 0) process.stderr.write(` agents: ${agents.map((a) => a.name).join(", ")}
3402
+ `);
3403
+ const dest = [skills.length > 0 ? `${sDir}/` : "", agents.length > 0 ? `${aDir}/` : ""].filter(Boolean).join(" + ");
3404
+ const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dest} so ${surface} can use them?`) : false;
2986
3405
  if (!materialise) return;
2987
- const written = [];
2988
- for (const [name, m] of byName) {
2989
- const body = m.text ?? m.Text ?? "";
2990
- mkdirSync5(join5(dir, name), { recursive: true });
2991
- writeFileSync5(join5(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
2992
- written.push(name);
3406
+ if (skills.length > 0) {
3407
+ const written = [];
3408
+ for (const s of skills) {
3409
+ mkdirSync7(join8(sDir, s.name), { recursive: true });
3410
+ writeFileSync7(join8(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
3411
+ written.push(s.name);
3412
+ }
3413
+ recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
3414
+ process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${sDir}
3415
+ `);
2993
3416
  }
2994
- process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${dir}
3417
+ if (agents.length > 0) {
3418
+ mkdirSync7(aDir, { recursive: true });
3419
+ const written = [];
3420
+ for (const a of agents) {
3421
+ const file = `${a.name}.md`;
3422
+ writeFileSync7(join8(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
3423
+ written.push(file);
3424
+ }
3425
+ recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
3426
+ process.stderr.write(`${style.green("\u2713")} wrote ${written.length} agent(s) to ${aDir}
2995
3427
  `);
2996
- await ensureLanePin(cfg, { yes: opts.yes, dryRun: opts.dryRun, clients: [surface] });
3428
+ }
3429
+ await ensureLanePin(cfg, {
3430
+ yes: opts.yes,
3431
+ dryRun: opts.dryRun,
3432
+ clients: [surface]
3433
+ });
2997
3434
  }
2998
3435
 
2999
3436
  // src/commands/setup.ts
@@ -3031,14 +3468,14 @@ version, the shared template stays clean, and you can discard back anytime.
3031
3468
  }
3032
3469
  function resolveClientKeys(raw) {
3033
3470
  const targets = clientTargets(process.cwd());
3034
- if (raw === "all") return [...ALL_CLIENT_KEYS];
3035
- const keys = raw.split(",").map((k) => k.trim()).filter(Boolean);
3036
- for (const k of keys) {
3471
+ const tokens = (Array.isArray(raw) ? raw : [raw]).flatMap((t) => t.split(",")).map((k) => k.trim()).filter(Boolean);
3472
+ if (tokens.includes("all")) return [...ALL_CLIENT_KEYS];
3473
+ for (const k of tokens) {
3037
3474
  if (!targets[k]) {
3038
3475
  fail(`unknown client '${k}'. Known: ${ALL_CLIENT_KEYS.join(", ")}, or 'all'.`);
3039
3476
  }
3040
3477
  }
3041
- return keys;
3478
+ return [...new Set(tokens)];
3042
3479
  }
3043
3480
  function printActions(client, actions) {
3044
3481
  process.stdout.write(`
@@ -3050,24 +3487,96 @@ ${client.label} (${client.key}):
3050
3487
  `);
3051
3488
  }
3052
3489
  }
3490
+ function resolveEvalMode(opts) {
3491
+ return opts.check ? "check" : opts.force ? "force" : "apply";
3492
+ }
3493
+ function summarizeEval(result, mode, json, dryRun) {
3494
+ const counts = { current: 0, stale: 0, drift: 0, absent: 0 };
3495
+ for (const { actions } of result) for (const a of actions) if (a.eval) counts[a.eval]++;
3496
+ const wouldChange = counts.stale + counts.drift + counts.absent;
3497
+ if (mode === "check") {
3498
+ if (!json) {
3499
+ if (wouldChange === 0) {
3500
+ process.stdout.write("\u2713 all instruction blocks are up to date.\n");
3501
+ } else {
3502
+ const bits = [];
3503
+ if (counts.stale) bits.push(`${counts.stale} out of date`);
3504
+ if (counts.drift) bits.push(`${counts.drift} with local edits`);
3505
+ if (counts.absent) bits.push(`${counts.absent} not yet written`);
3506
+ process.stderr.write(
3507
+ `\u26A0 ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Re-run with ${style.cyan("--refresh")}.
3508
+ `
3509
+ );
3510
+ }
3511
+ }
3512
+ process.exit(wouldChange === 0 ? 0 : 1);
3513
+ }
3514
+ if (json) return;
3515
+ if (!dryRun && counts.stale) {
3516
+ process.stderr.write(`\u21BB refreshed ${counts.stale} section(s) the server had moved
3517
+ `);
3518
+ }
3519
+ if (!dryRun && counts.drift) {
3520
+ process.stderr.write(
3521
+ mode === "force" ? `\u26A0 overwrote ${counts.drift} section(s) that had local edits (--force)
3522
+ ` : `\u26A0 ${counts.drift} section(s) have local edits \u2014 wrote a .proposed file alongside (original untouched). Review + merge, or re-run with ${style.cyan("--force")}.
3523
+ `
3524
+ );
3525
+ }
3526
+ }
3527
+ var GLOBAL_NAMESPACE = "__global__";
3528
+ async function resolveNamespaceChoice(cfg, flag) {
3529
+ if (flag) return flag;
3530
+ if (!canPrompt()) return null;
3531
+ const namespaces = await listNamespaces(cfg);
3532
+ if (namespaces.length === 0) return null;
3533
+ const picked = await promptSelect(
3534
+ "Which namespace should this connection use?",
3535
+ [
3536
+ { label: "Global (whole tenant)", value: GLOBAL_NAMESPACE },
3537
+ ...namespaces.map((n) => ({
3538
+ label: n.displayName,
3539
+ value: n.slug,
3540
+ hint: n.slug
3541
+ }))
3542
+ ],
3543
+ GLOBAL_NAMESPACE
3544
+ );
3545
+ return picked === GLOBAL_NAMESPACE ? null : picked;
3546
+ }
3053
3547
  function registerInit(program2) {
3054
- program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").addHelpText(
3548
+ program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText(
3055
3549
  "after",
3056
3550
  `
3057
3551
  Examples:
3058
3552
  $ sechroom init Claude Code (default): ./.mcp.json + ./CLAUDE.md
3059
3553
  $ sechroom init --client all claude-code, claude-desktop, codex, cursor
3060
- $ sechroom init --client codex,cursor
3554
+ $ sechroom init --client codex cursor space-separated (comma also works)
3061
3555
  $ sechroom init --mcp-only just the MCP config (skip agent files)
3062
3556
  $ sechroom init --dry-run --json preview the writes, change nothing`
3063
3557
  ).action(async (opts, cmd) => {
3064
3558
  const cfg = resolveConfig(cmd.optsWithGlobals());
3065
- const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3066
- const targets = clientTargets(process.cwd());
3559
+ const mode = resolveEvalMode(opts);
3560
+ const check = mode === "check";
3561
+ const namespaceSlug = await resolveNamespaceChoice(cfg, opts.namespace);
3562
+ const setup = await withSpinner(
3563
+ "Fetching setup descriptors",
3564
+ () => fetchSetup(cfg, namespaceSlug ?? void 0)
3565
+ );
3566
+ const g = cmd.optsWithGlobals();
3567
+ let scope;
3568
+ try {
3569
+ scope = resolveScope(opts.scope);
3570
+ } catch (err2) {
3571
+ return fail(err2.message);
3572
+ }
3573
+ const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
3574
+ const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
3575
+ const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
3067
3576
  const keys = resolveClientKeys(opts.client);
3068
- const json = cmd.optsWithGlobals().json;
3577
+ const json = g.json;
3069
3578
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3070
- if (!opts.dryRun && !opts.mcpOnly) {
3579
+ if (!opts.dryRun && !opts.mcpOnly && !check) {
3071
3580
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
3072
3581
  }
3073
3582
  const result = [];
@@ -3077,16 +3586,20 @@ Examples:
3077
3586
  dryRun: Boolean(opts.dryRun),
3078
3587
  mcp: !opts.agentFilesOnly,
3079
3588
  agentFiles: !opts.mcpOnly,
3080
- personalWorkspaceId
3589
+ personalWorkspaceId,
3590
+ mode
3081
3591
  });
3082
3592
  result.push({ client: key, actions });
3083
- if (!json) printActions(target, actions);
3593
+ if (!json && !check) printActions(target, actions);
3084
3594
  }
3595
+ summarizeEval(result, mode, Boolean(json), Boolean(opts.dryRun));
3085
3596
  if (!json && !opts.dryRun && !opts.mcpOnly) {
3086
- await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
3597
+ for (const t of claudeTargets) {
3598
+ await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code", configDir: t.dir });
3599
+ }
3087
3600
  }
3088
3601
  if (!json && !opts.dryRun && !opts.mcpOnly) {
3089
- await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
3602
+ await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3090
3603
  }
3091
3604
  if (json) {
3092
3605
  emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
@@ -3107,23 +3620,99 @@ Next \u2014 verify: ${verify.description}
3107
3620
  }
3108
3621
  function registerSetup(program2) {
3109
3622
  const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
3110
- setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
3111
- await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false });
3623
+ setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
3624
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false, namespace: opts.namespace });
3112
3625
  });
3113
- setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all").action(async (clients, opts, cmd) => {
3114
- await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy });
3626
+ setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
3627
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy, mode: resolveEvalMode(opts) });
3628
+ });
3629
+ setup.command("new-convention <title...>").description("Scaffold a workspace-conventions section: author a correctly-tagged memo (header as first body line) + regen the agent files").option("--kind <kind>", "reference | standard (orders the section; reference first)", "reference").option("--workspace <id>", "workspace to author in (default: the bound workspace)").option("--body <markdown>", "section body (default: a TODO scaffold to edit later)").option("--no-regen", "skip the agent-files regen after authoring").option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
3630
+ "after",
3631
+ `
3632
+ The memo carries the two conventions a workspace-conventions section needs (FR-sechroom-236):
3633
+ the \`agent-setup-bundle\` tag + the \`# Header\` as the FIRST body line. It's authored in the
3634
+ BOUND workspace (so the regen, which sources conventions from there, picks it up). Edit it later
3635
+ in the app or via \`sechroom memory edit-text\`.
3636
+
3637
+ Examples:
3638
+ $ sechroom setup new-convention "Deploy runbook"
3639
+ $ sechroom setup new-convention "Backend testing" --kind standard --body "- run dotnet test ..."
3640
+ $ sechroom setup new-convention "Draft section" --no-regen author only, regen later`
3641
+ ).action(async (titleParts, opts, cmd) => {
3642
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3643
+ const json = Boolean(cmd.optsWithGlobals().json);
3644
+ const title = titleParts.join(" ").trim();
3645
+ if (!title) fail('a section title is required, e.g. `sechroom setup new-convention "Deploy runbook"`.');
3646
+ const workspaceId = opts.workspace ?? cfg.workspaceId;
3647
+ if (!workspaceId)
3648
+ fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
3649
+ const kind = String(opts.kind).toLowerCase() === "standard" ? "standard" : "reference";
3650
+ const body = typeof opts.body === "string" && opts.body.trim().length > 0 ? opts.body.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
3651
+ const text = `# ${title}
3652
+
3653
+ ${body}
3654
+ `;
3655
+ const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
3656
+ if (opts.dryRun) {
3657
+ emit({ dryRun: true, workspaceId, title, kind, tags, text }, json);
3658
+ return;
3659
+ }
3660
+ const data = await runApi("Authoring convention memo", async () => {
3661
+ const client = await makeClient(cfg);
3662
+ return client.POST("/memories", {
3663
+ body: {
3664
+ text,
3665
+ type: kind,
3666
+ content: "{}",
3667
+ confidence: 1,
3668
+ source: "cli-new-convention",
3669
+ archetype: "Document",
3670
+ title,
3671
+ tags,
3672
+ owner: { type: "Workspace", id: workspaceId }
3673
+ }
3674
+ });
3675
+ });
3676
+ if (!json) {
3677
+ const view = resolveViewUrl(cfg.baseUrl, data.url);
3678
+ process.stdout.write(
3679
+ `\u2713 authored convention ${style.bold(data.id)} ${style.dim(`"${title}"`)}${view ? ` ${style.dim("\u2192")} ${view}` : ""}
3680
+ `
3681
+ );
3682
+ }
3683
+ if (opts.regen === false) {
3684
+ if (json) emit({ id: data.id, workspaceId, regen: false }, true);
3685
+ else process.stdout.write("Skipped regen (--no-regen). Run `sechroom setup agent-files all` to apply.\n");
3686
+ return;
3687
+ }
3688
+ await runClients(["claude-code", "codex"], cmd, {
3689
+ dryRun: false,
3690
+ mcp: false,
3691
+ agentFiles: true,
3692
+ copy: false,
3693
+ mode: "apply"
3694
+ });
3115
3695
  });
3116
3696
  }
3117
3697
  async function runClients(clients, cmd, opts) {
3118
- const cfg = resolveConfig(cmd.optsWithGlobals());
3119
- const targets = clientTargets(process.cwd());
3698
+ const g = cmd.optsWithGlobals();
3699
+ const cfg = resolveConfig(g);
3700
+ const mode = opts.mode ?? "apply";
3701
+ const check = mode === "check";
3702
+ const claudeDir = resolveClaudeTargets({ override: g.claudeConfigDir })[0]?.dir;
3703
+ const codexHome = resolveCodexHomes({ override: g.codexHome })[0];
3704
+ const targets = clientTargets(process.cwd(), { claudeDir, codexHome });
3120
3705
  const keys = resolveClientKeys(clients.join(","));
3121
- const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3706
+ const namespaceSlug = opts.mcp ? await resolveNamespaceChoice(cfg, opts.namespace) : null;
3707
+ const setupData = await withSpinner(
3708
+ "Fetching setup descriptors",
3709
+ () => fetchSetup(cfg, namespaceSlug ?? void 0)
3710
+ );
3122
3711
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3123
- if (opts.agentFiles && !opts.dryRun) {
3712
+ if (opts.agentFiles && !opts.dryRun && !check) {
3124
3713
  await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
3125
3714
  }
3126
- const json = cmd.optsWithGlobals().json;
3715
+ const json = g.json;
3127
3716
  const result = [];
3128
3717
  for (const key of keys) {
3129
3718
  const target = targets[key];
@@ -3131,11 +3720,13 @@ async function runClients(clients, cmd, opts) {
3131
3720
  dryRun: opts.dryRun,
3132
3721
  mcp: opts.mcp,
3133
3722
  agentFiles: opts.agentFiles,
3134
- personalWorkspaceId
3723
+ personalWorkspaceId,
3724
+ mode
3135
3725
  });
3136
3726
  result.push({ client: key, actions });
3137
- if (!json) printActions(target, actions);
3727
+ if (!json && !check) printActions(target, actions);
3138
3728
  }
3729
+ summarizeEval(result, mode, Boolean(json), opts.dryRun);
3139
3730
  if (json) {
3140
3731
  emit({ dryRun: opts.dryRun, clients: result }, true);
3141
3732
  return;
@@ -3143,14 +3734,81 @@ async function runClients(clients, cmd, opts) {
3143
3734
  process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
3144
3735
  }
3145
3736
 
3737
+ // src/commands/namespace.ts
3738
+ function registerNamespace(program2) {
3739
+ const namespace = program2.command("namespace").description("Browse, inspect, and wire up MCP namespaces");
3740
+ namespace.addHelpText(
3741
+ "after",
3742
+ `
3743
+ Examples:
3744
+ $ sechroom namespace list
3745
+ $ sechroom namespace show eng
3746
+ $ sechroom namespace use eng wire Claude Code to the 'eng' namespace
3747
+ $ sechroom namespace use eng --client all`
3748
+ );
3749
+ namespace.command("list").description("List the namespaces you can reach (GET /mcp-aggregator/namespaces)").action(async (_opts, cmd) => {
3750
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3751
+ const data = await runApi("Listing namespaces", async () => {
3752
+ const client = await makeClient(cfg);
3753
+ return client.GET("/mcp-aggregator/namespaces", {});
3754
+ });
3755
+ emit(data, cmd.optsWithGlobals().json);
3756
+ });
3757
+ namespace.command("show <slug>").description("Show a namespace's details (GET /mcp-aggregator/namespaces/{slug}). For the tool list it exposes, point an OpenAPI client at /t/{tenant}/namespaces/{slug}/api/openapi.json.").action(async (slug, _opts, cmd) => {
3758
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3759
+ const data = await runApi("Fetching namespace", async () => {
3760
+ const client = await makeClient(cfg);
3761
+ return client.GET("/mcp-aggregator/namespaces/{slug}", {
3762
+ params: { path: { slug } }
3763
+ });
3764
+ });
3765
+ emit(data, cmd.optsWithGlobals().json);
3766
+ });
3767
+ namespace.command("use <slug>").description("Wire an AI client's MCP config to this namespace's URL").option(
3768
+ "--client <list>",
3769
+ `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
3770
+ DEFAULT_CLIENT_KEY
3771
+ ).option("--dry-run", "print what would be written without writing", false).action(async (slug, opts, cmd) => {
3772
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3773
+ const setup = await withSpinner(
3774
+ "Fetching setup descriptors",
3775
+ () => fetchSetup(cfg, slug)
3776
+ );
3777
+ const targets = clientTargets(process.cwd());
3778
+ const keys = resolveClientKeys(opts.client);
3779
+ const json = cmd.optsWithGlobals().json;
3780
+ const result = [];
3781
+ for (const key of keys) {
3782
+ const target = targets[key];
3783
+ const actions = await applyClient(cfg, setup, target, {
3784
+ dryRun: Boolean(opts.dryRun),
3785
+ mcp: true,
3786
+ agentFiles: false,
3787
+ personalWorkspaceId: null
3788
+ });
3789
+ result.push({ client: key, actions });
3790
+ if (!json) printActions(target, actions);
3791
+ }
3792
+ if (json) {
3793
+ emit({ namespace: slug, dryRun: Boolean(opts.dryRun), clients: result }, true);
3794
+ return;
3795
+ }
3796
+ process.stdout.write(
3797
+ opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
3798
+ Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it up.
3799
+ `
3800
+ );
3801
+ });
3802
+ }
3803
+
3146
3804
  // src/commands/onboard.ts
3147
- import { existsSync as existsSync7 } from "fs";
3148
- import { join as join7 } from "path";
3805
+ import { existsSync as existsSync8 } from "fs";
3806
+ import { basename as basename3, join as join10 } from "path";
3149
3807
 
3150
3808
  // src/commands/fanout.ts
3151
3809
  import { spawnSync } from "child_process";
3152
- import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync } from "fs";
3153
- import { isAbsolute, join as join6, resolve } from "path";
3810
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
3811
+ import { isAbsolute, join as join9, resolve } from "path";
3154
3812
  var ICON = {
3155
3813
  refresh: "\u21BB",
3156
3814
  bind: "+",
@@ -3163,28 +3821,28 @@ function resolveChildDir(path, root) {
3163
3821
  function discoverChildren(root) {
3164
3822
  let names;
3165
3823
  try {
3166
- names = readdirSync(root);
3824
+ names = readdirSync2(root);
3167
3825
  } catch {
3168
3826
  return [];
3169
3827
  }
3170
3828
  const out = [];
3171
3829
  for (const name of names.sort()) {
3172
3830
  if (name.startsWith(".") || name === "node_modules") continue;
3173
- const dir = join6(root, name);
3831
+ const dir = join9(root, name);
3174
3832
  try {
3175
- if (!statSync(dir).isDirectory()) continue;
3833
+ if (!statSync3(dir).isDirectory()) continue;
3176
3834
  } catch {
3177
3835
  continue;
3178
3836
  }
3179
- if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
3837
+ if (existsSync7(join9(dir, ".git")) || committedBindingPath(dir)) out.push(name);
3180
3838
  }
3181
3839
  return out;
3182
3840
  }
3183
3841
  function readManifest(path) {
3184
- if (!existsSync6(path)) return null;
3842
+ if (!existsSync7(path)) return null;
3185
3843
  let parsed;
3186
3844
  try {
3187
- parsed = JSON.parse(readFileSync5(path, "utf8"));
3845
+ parsed = JSON.parse(readFileSync6(path, "utf8"));
3188
3846
  } catch (err2) {
3189
3847
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3190
3848
  }
@@ -3322,7 +3980,39 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
3322
3980
  );
3323
3981
  }
3324
3982
  }
3325
- async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
3983
+ async function fetchPersonalWorkspaceId(client) {
3984
+ try {
3985
+ const { data } = await client.GET("/me/personal-workspace", {});
3986
+ return data?.workspaceId ?? null;
3987
+ } catch {
3988
+ return null;
3989
+ }
3990
+ }
3991
+ function nameTokens(s) {
3992
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
3993
+ }
3994
+ function personalSubtreeIds(personalId, all) {
3995
+ const childrenOf = /* @__PURE__ */ new Map();
3996
+ for (const w of all) {
3997
+ if (!w.parentId) continue;
3998
+ (childrenOf.get(w.parentId) ?? childrenOf.set(w.parentId, []).get(w.parentId)).push(w);
3999
+ }
4000
+ const ids = /* @__PURE__ */ new Set([personalId]);
4001
+ const queue = [personalId];
4002
+ while (queue.length > 0) {
4003
+ const id = queue.shift();
4004
+ for (const child of childrenOf.get(id) ?? []) {
4005
+ if (!ids.has(child.id)) {
4006
+ ids.add(child.id);
4007
+ queue.push(child.id);
4008
+ }
4009
+ }
4010
+ }
4011
+ return ids;
4012
+ }
4013
+ async function pickWorkspace(client, opts = {}) {
4014
+ const promptLabel = opts.promptLabel ?? "Bind this directory to a workspace:";
4015
+ const dirName = opts.dirName ?? basename3(process.cwd());
3326
4016
  const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
3327
4017
  if (all.length === 0) {
3328
4018
  process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
@@ -3330,22 +4020,34 @@ async function pickWorkspace(client, promptLabel = "Bind this directory to a wor
3330
4020
  return void 0;
3331
4021
  }
3332
4022
  const byId = new Map(all.map((w) => [w.id, w]));
3333
- let pool = all;
3334
- if (all.length > 12) {
3335
- const q = (await promptText(`Filter ${all.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
4023
+ const personalId = await fetchPersonalWorkspaceId(client);
4024
+ const excluded = personalId ? personalSubtreeIds(personalId, all) : /* @__PURE__ */ new Set();
4025
+ let candidates = all.filter((w) => !excluded.has(w.id));
4026
+ if (candidates.length === 0) candidates = all;
4027
+ const dirToks = new Set(nameTokens(dirName));
4028
+ const isMatch = (w) => nameTokens(w.name).some((t) => dirToks.has(t));
4029
+ const suggestions = candidates.filter(isMatch);
4030
+ let pool = candidates;
4031
+ if (candidates.length > 12 && suggestions.length === 0) {
4032
+ const q = (await promptText(`Filter ${candidates.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
3336
4033
  if (q) {
3337
- const hits = all.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
4034
+ const hits = candidates.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
3338
4035
  if (hits.length > 0) pool = hits;
3339
4036
  else process.stderr.write(`no match for "${q}" \u2014 listing all
3340
4037
  `);
3341
4038
  }
3342
4039
  }
3343
4040
  const SKIP = "__skip__";
4041
+ const byPath = (a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId));
4042
+ const matched = pool.filter(isMatch).sort(byPath);
4043
+ const rest = pool.filter((w) => !isMatch(w)).sort(byPath);
3344
4044
  const choices = [
3345
- ...pool.slice().sort((a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId))).map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
4045
+ ...matched.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: style.dim(`matches "${dirName}"`) })),
4046
+ ...rest.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
3346
4047
  { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
3347
4048
  ];
3348
- const chosen = await promptSelect(promptLabel, choices, SKIP);
4049
+ const defaultValue = matched.length === 1 ? matched[0].id : SKIP;
4050
+ const chosen = await promptSelect(promptLabel, choices, defaultValue);
3349
4051
  if (chosen === SKIP) return void 0;
3350
4052
  const picked = byId.get(chosen);
3351
4053
  const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
@@ -3372,7 +4074,7 @@ async function resolveWorkspaceBinding(client, existing, opts) {
3372
4074
  }
3373
4075
  if (existing) return existing;
3374
4076
  if (!canPrompt() || opts.yes) return void 0;
3375
- return pickWorkspace(client);
4077
+ return pickWorkspace(client, { dirName: basename3(process.cwd()) });
3376
4078
  }
3377
4079
  async function ensureTenant(baseUrl, g, opts) {
3378
4080
  const persisted = readPersisted();
@@ -3488,7 +4190,7 @@ async function ensureTimezone(cfg, opts) {
3488
4190
  return { timezone: tz, action: "set" };
3489
4191
  }
3490
4192
  async function chooseClients(clientFlag, yes, cwd) {
3491
- if (clientFlag) return resolveClientKeys(clientFlag);
4193
+ if (clientFlag && clientFlag.length > 0) return resolveClientKeys(clientFlag);
3492
4194
  const detected = detectInstalledClients(cwd);
3493
4195
  const preselected = detected.length > 0 ? detected : [DEFAULT_CLIENT_KEY];
3494
4196
  if (!canPrompt() || yes) return preselected;
@@ -3503,12 +4205,24 @@ async function chooseClients(clientFlag, yes, cwd) {
3503
4205
  );
3504
4206
  return picks.length > 0 ? picks : preselected;
3505
4207
  }
4208
+ async function chooseScope(scopeFlag, yes) {
4209
+ if (scopeFlag != null) return resolveScope(scopeFlag);
4210
+ if (!canPrompt() || yes) return "global";
4211
+ return promptSelect(
4212
+ "Install skills, agents, and hooks globally or just for this project?",
4213
+ [
4214
+ { label: "Globally", value: "global", hint: "~/.claude (or CLAUDE_CONFIG_DIR) \u2014 all projects" },
4215
+ { label: "This project", value: "project", hint: "<repo>/.claude" }
4216
+ ],
4217
+ "global"
4218
+ );
4219
+ }
3506
4220
  async function planRecurseChild(entry, root, client, opts) {
3507
4221
  const dir = resolveChildDir(entry.path, root);
3508
- if (!existsSync7(dir)) {
4222
+ if (!existsSync8(dir)) {
3509
4223
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3510
4224
  }
3511
- if (existsSync7(join7(dir, ".sechroom.json"))) {
4225
+ if (existsSync8(join10(dir, ".sechroom.json"))) {
3512
4226
  return {
3513
4227
  label: entry.path,
3514
4228
  dir,
@@ -3535,7 +4249,10 @@ async function planRecurseChild(entry, root, client, opts) {
3535
4249
  process.stderr.write(`
3536
4250
  ${style.bold(entry.path)} ${style.dim("is not bound yet.")}
3537
4251
  `);
3538
- const ws = await pickWorkspace(client, `Bind ${style.cyan(entry.path)} to a workspace:`);
4252
+ const ws = await pickWorkspace(client, {
4253
+ promptLabel: `Bind ${style.cyan(entry.path)} to a workspace:`,
4254
+ dirName: basename3(entry.path)
4255
+ });
3539
4256
  if (!ws) {
3540
4257
  return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
3541
4258
  }
@@ -3578,7 +4295,7 @@ This fan-out will pin the same lane in every repo:
3578
4295
  async function runRecurse(cfg, g, opts) {
3579
4296
  const { yes, dryRun, json } = opts;
3580
4297
  const root = process.cwd();
3581
- const manifestPath = join7(root, ".sechroom", "repos.json");
4298
+ const manifestPath = join10(root, ".sechroom", "repos.json");
3582
4299
  const fromManifest = readManifest(manifestPath);
3583
4300
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
3584
4301
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -3606,7 +4323,7 @@ async function runRecurse(cfg, g, opts) {
3606
4323
  summarizeFanout(results, { dryRun });
3607
4324
  }
3608
4325
  function registerOnboard(program2) {
3609
- program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list>", `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
4326
+ program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
3610
4327
  "after",
3611
4328
  `
3612
4329
  Examples:
@@ -3649,6 +4366,13 @@ Examples:
3649
4366
  process.stderr.write(line);
3650
4367
  }
3651
4368
  const wire = await chooseWire(opts, yes);
4369
+ const scope = await chooseScope(opts.scope, yes);
4370
+ const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4371
+ const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
4372
+ if (scope === "project" && g.claudeConfigDir && !json) {
4373
+ process.stderr.write(`${style.dim("(--claude-config-dir is ignored for --scope project \u2014 project files are repo-relative)")}
4374
+ `);
4375
+ }
3652
4376
  if (wire === "cli-only") {
3653
4377
  if (json) {
3654
4378
  emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
@@ -3656,7 +4380,7 @@ Examples:
3656
4380
  }
3657
4381
  if (!dryRun) {
3658
4382
  await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
3659
- await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
4383
+ await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3660
4384
  }
3661
4385
  process.stdout.write(
3662
4386
  `
@@ -3669,7 +4393,7 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
3669
4393
  }
3670
4394
  const keys = await chooseClients(opts.client, yes, process.cwd());
3671
4395
  const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3672
- const targets = clientTargets(process.cwd());
4396
+ const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
3673
4397
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3674
4398
  if (!dryRun && !check) {
3675
4399
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
@@ -3713,10 +4437,12 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
3713
4437
  await ensureLanePin(cfg, { yes, dryRun, clients: keys });
3714
4438
  }
3715
4439
  if (!json && !dryRun) {
3716
- await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
4440
+ for (const t of claudeTargets) {
4441
+ await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code", configDir: t.dir });
4442
+ }
3717
4443
  }
3718
4444
  if (!json && !dryRun) {
3719
- await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
4445
+ await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3720
4446
  }
3721
4447
  if (json) {
3722
4448
  emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
@@ -3762,14 +4488,21 @@ async function chooseWire(opts, yes) {
3762
4488
  return opts.mcp === false ? "agent-only" : "full";
3763
4489
  }
3764
4490
  var FALLBACK_AGENT_PROMPT = "Resume my sechroom continuity, summarise what I was last working on, then suggest the next step.";
4491
+ function printNextStepBlock(heading, lines) {
4492
+ const rule = style.dim("\u2500".repeat(52));
4493
+ process.stdout.write(
4494
+ `
4495
+ ${rule}
4496
+ ${style.bold(heading)}
4497
+
4498
+ ` + lines.map((l) => ` ${l}`).join("\n") + `
4499
+ ${rule}
4500
+ `
4501
+ );
4502
+ }
3765
4503
  async function printStarterPrompt(mode, cfg) {
3766
4504
  if (mode === "cli") {
3767
- process.stdout.write(
3768
- `
3769
- ${style.bold("Next:")} pick up where you left off \u2014
3770
- ${style.cyan("sechroom continuity resume-me")}
3771
- `
3772
- );
4505
+ printNextStepBlock("Next \u2014 pick up where you left off:", [style.cyan("sechroom continuity resume-me")]);
3773
4506
  return;
3774
4507
  }
3775
4508
  let primary = FALLBACK_AGENT_PROMPT;
@@ -3781,21 +4514,16 @@ ${style.bold("Next:")} pick up where you left off \u2014
3781
4514
  } catch {
3782
4515
  }
3783
4516
  }
3784
- process.stdout.write(
3785
- `
3786
- ${style.bold("Next:")} paste this into your AI agent to get going \u2014
3787
- ${style.cyan(`"${primary}"`)}
3788
- `
3789
- );
4517
+ printNextStepBlock("Next \u2014 paste this into your AI agent to get going:", [style.cyan(`"${primary}"`)]);
3790
4518
  }
3791
4519
 
3792
4520
  // src/commands/sweep.ts
3793
- import { existsSync as existsSync8 } from "fs";
3794
- import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
3795
- var DEFAULT_MANIFEST = join8(".sechroom", "repos.json");
4521
+ import { existsSync as existsSync9 } from "fs";
4522
+ import { dirname as dirname7, join as join11, resolve as resolve2 } from "path";
4523
+ var DEFAULT_MANIFEST = join11(".sechroom", "repos.json");
3796
4524
  function planEntry(entry, root) {
3797
4525
  const dir = resolveChildDir(entry.path, root);
3798
- if (!existsSync8(dir)) {
4526
+ if (!existsSync9(dir)) {
3799
4527
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3800
4528
  }
3801
4529
  if (committedBindingPath(dir)) {
@@ -3871,7 +4599,7 @@ Examples:
3871
4599
  `);
3872
4600
  return;
3873
4601
  }
3874
- const root = dirname6(dirname6(manifestPath));
4602
+ const root = dirname7(dirname7(manifestPath));
3875
4603
  const plans = repos.map((entry) => planEntry(entry, root));
3876
4604
  if (!json) {
3877
4605
  process.stderr.write(
@@ -3888,162 +4616,222 @@ Examples:
3888
4616
  });
3889
4617
  }
3890
4618
 
3891
- // src/commands/skills.ts
3892
- import { homedir as homedir6 } from "os";
3893
- import { join as join9 } from "path";
3894
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, rmSync as rmSync2, existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3895
- var DEFAULT_SLUG = "operator-skills";
3896
- var ROLE_TAGS = ["sechroom:role:skill-template", "role:skill-template"];
3897
- var LOCK = ".sechroom-skills.json";
3898
- function skillsDir(global) {
3899
- return global ? join9(homedir6(), ".claude", "skills") : join9(process.cwd(), ".claude", "skills");
3900
- }
3901
- function tagValue2(tags, prefix) {
3902
- return (tags ?? []).find((t) => t.startsWith(prefix))?.slice(prefix.length);
3903
- }
3904
- function hasAny(tags, candidates) {
3905
- return (tags ?? []).some((t) => candidates.includes(t));
3906
- }
3907
- function registerSkills(program2) {
3908
- const skills = program2.command("skills").description("Install + manage operator skills from a bundle");
3909
- skills.addHelpText(
4619
+ // src/commands/lane.ts
4620
+ var LANE_KEYS = ["code-lane", "design-lane"];
4621
+ function showLane(json) {
4622
+ const found = readSem();
4623
+ if (!found) {
4624
+ if (json) return emit({ path: null, values: {} }, true);
4625
+ return console.log(
4626
+ style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom lane set'.`)
4627
+ );
4628
+ }
4629
+ const resolved = { ...found.values };
4630
+ let suffixed = false;
4631
+ for (const k of LANE_KEYS) {
4632
+ const v = found.values[k];
4633
+ if (!v) continue;
4634
+ resolved[k] = applyWorktreeLaneSuffix(v);
4635
+ if (resolved[k] !== v) suffixed = true;
4636
+ }
4637
+ if (json) return emit({ path: found.path, values: resolved, worktreeSuffixApplied: suffixed }, true);
4638
+ console.log(style.dim(`from ${found.path}`));
4639
+ Object.entries(resolved).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
4640
+ if (suffixed) console.log(style.dim(" (worktree -N suffix applied \u2014 non-primary git worktree)"));
4641
+ }
4642
+ function setLane(opts) {
4643
+ if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
4644
+ const target = localSemPath();
4645
+ const values = readLocalSemValues();
4646
+ if (opts.codeLane) values["code-lane"] = opts.codeLane;
4647
+ if (opts.designLane) values["design-lane"] = opts.designLane;
4648
+ writeSem(values, target);
4649
+ if (opts.json) return emit({ path: target, values }, true);
4650
+ console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
4651
+ Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
4652
+ }
4653
+ function registerLane(program2) {
4654
+ const lane = program2.command("lane").description("Show this checkout's continuity lane pin (worktree-aware -N suffix applied)").option("--json", "machine output").action((opts, cmd) => showLane(Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)));
4655
+ lane.command("set").description("Write this checkout's lane pin to ./.sechroom/lane.json").option("--code-lane <id>", "code-surface lane id (e.g. claude-code-chris)").option("--design-lane <id>", "design / substrate-authoring lane id (e.g. claude-design-chris)").option("--json", "machine output").action(
4656
+ (opts, cmd) => setLane({
4657
+ codeLane: opts.codeLane,
4658
+ designLane: opts.designLane,
4659
+ json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
4660
+ })
4661
+ );
4662
+ lane.addHelpText(
3910
4663
  "after",
3911
4664
  `
3912
4665
  Examples:
3913
- $ sechroom skills install --code-lane claude-code-chris --design-lane claude-design-chris
3914
- $ sechroom skills install operator-skills --surface claude-code --local
3915
- $ sechroom skills list
3916
- $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
3917
- $ sechroom skills lane
3918
- $ sechroom skills clean`
4666
+ $ sechroom lane show the resolved lane(s)
4667
+ $ sechroom lane set --code-lane claude-code-chris --design-lane claude-design-chris
4668
+
4669
+ In a non-primary git worktree the ratified concurrent-session -N suffix is auto-applied (SBC-1094).
4670
+ (Aliases: 'sechroom skills lane' / 'skills set-lane' \u2014 kept for back-compat.)`
3919
4671
  );
3920
- skills.command("install [slug]").description(`Install a skills bundle (default ${DEFAULT_SLUG}) into your personal workspace + write SKILL.md files`).option("--version <v>", "bundle version (default: latest published in the catalogue)").option("--instance <name>", "install as a named, separate instance (install the same bundle more than once)").option("--code-lane <id>", "identity.code-lane binding (e.g. claude-code-chris)").option("--design-lane <id>", "identity.design-lane binding (e.g. claude-design-chris)").option("--surface <s>", "skill target surface to materialise", "claude-code").option("--local", "write to ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts, cmd) => {
3921
- const slug = slugArg || DEFAULT_SLUG;
3922
- const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3923
- const pw = await runApi("resolving personal workspace", () => client.GET("/me/personal-workspace", {}));
3924
- const personalWsId = pw?.id || pw?.workspaceId || pw?.personalWorkspaceId || pw?.item?.id;
3925
- if (!personalWsId) fail("Could not resolve your personal workspace.");
3926
- let version = opts.version;
3927
- if (!version) {
3928
- const cat = await runApi("reading the bundle catalogue", () => client.GET("/me/bundles", {}));
3929
- const item = (cat?.bundles ?? cat?.Bundles ?? []).find((b) => (b.slug ?? b.Slug) === slug);
3930
- if (!item) fail(`Bundle '${slug}' is not in your self-serve catalogue (must be UserInstallable + Published).`);
3931
- version = item.latestVersion ?? item.LatestVersion;
3932
- if (!version) fail(`Bundle '${slug}' has no installable (Published) version.`);
3933
- }
3934
- const installOptions = {};
3935
- if (opts.codeLane) installOptions["identity.code-lane"] = opts.codeLane;
3936
- if (opts.designLane) installOptions["identity.design-lane"] = opts.designLane;
3937
- const res = await runApi(
3938
- `installing ${slug}@${version}${opts.instance ? ` (${opts.instance})` : ""}`,
3939
- () => client.POST("/me/bundles/{slug}/versions/{version}/install", {
3940
- params: { path: { slug, version } },
3941
- // instance: null/absent = the default instance (reinstall updates in
3942
- // place); a name installs a separate instance.
3943
- body: { installOptions, instance: opts.instance ?? null }
3944
- })
4672
+ }
4673
+
4674
+ // src/setup/materialise.ts
4675
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, rmSync as rmSync2, writeFileSync as writeFileSync8 } from "fs";
4676
+ import { join as join12 } from "path";
4677
+ var CLIENT_SURFACE = "claude-code";
4678
+ function writeSkills(dir, skills, surface) {
4679
+ const written = [];
4680
+ for (const s of skills) {
4681
+ mkdirSync8(join12(dir, s.name), { recursive: true });
4682
+ writeFileSync8(join12(dir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
4683
+ written.push(s.name);
4684
+ }
4685
+ if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
4686
+ return written;
4687
+ }
4688
+ function writeAgents(dir, agents, surface) {
4689
+ if (agents.length) mkdirSync8(dir, { recursive: true });
4690
+ const written = [];
4691
+ for (const a of agents) {
4692
+ const file = `${a.name}.md`;
4693
+ writeFileSync8(join12(dir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
4694
+ written.push(file);
4695
+ }
4696
+ if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
4697
+ return written;
4698
+ }
4699
+ var SKILL_SPEC = { kind: "skill", dir: skillsDir, resolve: resolveSkillSet, write: writeSkills };
4700
+ var AGENT_SPEC = { kind: "agent", dir: agentsDir, resolve: resolveAgentSet, write: writeAgents };
4701
+ function scopeOf(opts) {
4702
+ return opts.local ? "project" : resolveScope(opts.scope);
4703
+ }
4704
+ async function runInstall(spec, cmd, opts) {
4705
+ const g = cmd.optsWithGlobals();
4706
+ let scope;
4707
+ try {
4708
+ scope = scopeOf(opts);
4709
+ } catch (err2) {
4710
+ return fail(err2.message);
4711
+ }
4712
+ const cfg = resolveConfig(g);
4713
+ const dryRun = Boolean(opts.dryRun);
4714
+ const json = Boolean(g.json || opts.json);
4715
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4716
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
4717
+ const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
4718
+ const items = spec.resolve(rows, CLIENT_SURFACE);
4719
+ const results = targets.map((t) => {
4720
+ const dir = spec.dir(t.dir);
4721
+ const written = dryRun ? items.map((i) => i.name) : spec.write(dir, items, CLIENT_SURFACE);
4722
+ return { dir, label: t.label, written };
4723
+ });
4724
+ if (json) return emit({ kind: spec.kind, dryRun, available: items.length, targets: results }, true);
4725
+ if (items.length === 0) {
4726
+ console.log(style.dim(`No ${spec.kind}s available to install \u2014 is the bundle installed for your account?`));
4727
+ return;
4728
+ }
4729
+ for (const r of results) {
4730
+ console.log(
4731
+ `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) ${style.dim("\u2192")} ${r.dir}`
3945
4732
  );
3946
- const status = String(res?.status ?? res?.Status ?? "");
3947
- if (status && status.toLowerCase() !== "completed") {
3948
- fail(`Install did not complete (status=${status}; ${res?.failureReason ?? res?.FailureReason ?? ""}).`);
3949
- }
3950
- const feed = await runApi(
3951
- "materialising skill files",
3952
- () => client.GET("/workspaces/{workspaceId}/memories/feed", {
3953
- // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace of
3954
- // the personal workspace, so we recurse from the personal-ws root.
3955
- // includeText: the feed omits bodies by default; we need them for SKILL.md.
3956
- params: {
3957
- path: { workspaceId: personalWsId },
3958
- query: { limit: 200, cascadeWorkspaces: true, includeText: true }
3959
- }
3960
- })
4733
+ if (dryRun) for (const i of items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
4734
+ }
4735
+ }
4736
+ function runList(spec, cmd, opts) {
4737
+ const g = cmd.optsWithGlobals();
4738
+ let scope;
4739
+ try {
4740
+ scope = scopeOf(opts);
4741
+ } catch (err2) {
4742
+ return fail(err2.message);
4743
+ }
4744
+ const json = Boolean(g.json || opts.json);
4745
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4746
+ const out = targets.map((t) => {
4747
+ const dir = spec.dir(t.dir);
4748
+ const lock = readSkillsLock(dir);
4749
+ const entries = Object.entries(lock).flatMap(
4750
+ ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync10(join12(dir, name)) }))
3961
4751
  );
3962
- const rows = feed?.results ?? feed?.Results ?? [];
3963
- const dir = skillsDir(!opts.local);
3964
- const wantInstance = opts.instance || "default";
3965
- const written = [];
3966
- const bundleTagPrefix = `sechroom:bundle:${slug}@`;
3967
- for (const r of rows) {
3968
- const m = r.item ?? r;
3969
- const tags = m.tags ?? m.Tags ?? [];
3970
- if (!hasAny(tags, ROLE_TAGS)) continue;
3971
- if (tagValue2(tags, "target:") !== opts.surface) continue;
3972
- if (!tags.some((t) => t.startsWith(bundleTagPrefix))) continue;
3973
- if ((tagValue2(tags, "sechroom:skill-instance:") ?? "default") !== wantInstance) continue;
3974
- const name = tagValue2(tags, "skill:");
3975
- if (!name) continue;
3976
- const body = m.text ?? m.Text ?? "";
3977
- mkdirSync6(join9(dir, name), { recursive: true });
3978
- writeFileSync6(join9(dir, name, "SKILL.md"), body.endsWith("\n") ? body : body + "\n");
3979
- written.push(name);
3980
- }
3981
- mkdirSync6(dir, { recursive: true });
3982
- const lockPath = join9(dir, LOCK);
3983
- const lock = existsSync9(lockPath) ? JSON.parse(readFileSync6(lockPath, "utf8")) : {};
3984
- lock[slug] = { surface: opts.surface, version, instance: wantInstance, skills: written.sort() };
3985
- writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
3986
- if (opts.json) return emit({ slug, version, instance: wantInstance, surface: opts.surface, dir, installed: written }, true);
3987
- const instanceNote = opts.instance ? ` (${opts.instance})` : "";
3988
- console.log(style.green(`Installed ${slug}@${version}${instanceNote} \u2014 ${written.length} skill(s) \u2192 ${dir}`));
3989
- written.forEach((n) => console.log(" " + style.dim("\u2022") + " " + n));
3990
- if (written.length === 0) console.log(style.dim(` (no '${opts.surface}' skill bodies found; check --surface)`));
4752
+ return { dir, label: t.label, entries };
3991
4753
  });
3992
- skills.command("list").description("List your installed bundles (GET /me/bundle-installs)").option("--json", "machine output").action(async (opts, cmd) => {
3993
- const client = await makeClient(resolveConfig(cmd.optsWithGlobals()));
3994
- const data = await runApi("reading your installs", () => client.GET("/me/bundle-installs", {}));
3995
- if (opts.json) return emit(data, true);
3996
- const installs = data?.installs ?? data?.Installs ?? [];
3997
- if (installs.length === 0) return console.log(style.dim("No bundles installed."));
3998
- installs.forEach((i) => {
3999
- const inst = i.instance ?? i.Instance ?? "";
4000
- const tag = inst ? style.dim(` [${inst}]`) : "";
4001
- console.log(` ${i.bundleSlug ?? i.BundleSlug}@${i.bundleVersion ?? i.BundleVersion ?? "?"}${tag}`);
4002
- });
4003
- });
4004
- skills.command("clean [slug]").description(`Remove materialised skill files written by install (default ${DEFAULT_SLUG})`).option("--local", "clean ./.claude/skills instead of ~/.claude/skills").option("--json", "machine output").action(async (slugArg, opts) => {
4005
- const slug = slugArg || DEFAULT_SLUG;
4006
- const dir = skillsDir(!opts.local);
4007
- const lockPath = join9(dir, LOCK);
4008
- if (!existsSync9(lockPath)) fail(`No skills lockfile at ${lockPath}; nothing to clean.`);
4009
- const lock = JSON.parse(readFileSync6(lockPath, "utf8"));
4754
+ if (json) return emit({ kind: spec.kind, targets: out }, true);
4755
+ let any = false;
4756
+ for (const t of out) {
4757
+ if (t.entries.length === 0) continue;
4758
+ any = true;
4759
+ console.log(style.bold(t.dir) + ":");
4760
+ for (const e of t.entries) {
4761
+ const flag = e.present ? "" : style.dim("(missing) ");
4762
+ console.log(` ${flag}${e.name} ${style.dim(`[${e.slug}]`)}`);
4763
+ }
4764
+ }
4765
+ if (!any) console.log(style.dim(`No ${spec.kind}s materialised. Run \`sechroom ${spec.kind}s install\`.`));
4766
+ }
4767
+ function runClean(spec, cmd, opts, slugArg) {
4768
+ const g = cmd.optsWithGlobals();
4769
+ const slug = slugArg || DEFAULT_SKILLS_SLUG;
4770
+ let scope;
4771
+ try {
4772
+ scope = scopeOf(opts);
4773
+ } catch (err2) {
4774
+ return fail(err2.message);
4775
+ }
4776
+ const json = Boolean(g.json || opts.json);
4777
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4778
+ const cleaned = [];
4779
+ const missing = [];
4780
+ for (const t of targets) {
4781
+ const dir = spec.dir(t.dir);
4782
+ const lock = readSkillsLock(dir);
4010
4783
  const entry = lock[slug];
4011
- if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
4784
+ if (!entry) {
4785
+ missing.push(join12(dir, SKILLS_LOCK));
4786
+ continue;
4787
+ }
4012
4788
  const removed = [];
4013
4789
  for (const name of entry.skills) {
4014
- const skillPath = join9(dir, name);
4015
- if (existsSync9(skillPath)) {
4016
- rmSync2(skillPath, { recursive: true, force: true });
4790
+ const p = join12(dir, name);
4791
+ if (existsSync10(p)) {
4792
+ rmSync2(p, { recursive: true, force: true });
4017
4793
  removed.push(name);
4018
4794
  }
4019
4795
  }
4020
4796
  delete lock[slug];
4021
- writeFileSync6(lockPath, JSON.stringify(lock, null, 2) + "\n");
4022
- if (opts.json) return emit({ slug, removed, dir }, true);
4023
- console.log(style.green(`Removed ${removed.length} skill(s) for ${slug} from ${dir}`));
4024
- });
4025
- skills.command("set-lane").description("Write this checkout's lane pin to a local ./.sechroom/lane.json file (read at runtime by skills)").option("--code-lane <id>", "code-surface lane id (e.g. claude-code-chris)").option("--design-lane <id>", "design / substrate-authoring lane id (e.g. claude-design-chris)").option("--json", "machine output").action((opts, cmd) => {
4026
- if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
4027
- const target = localSemPath();
4028
- const values = readLocalSemValues();
4029
- if (opts.codeLane) values["code-lane"] = opts.codeLane;
4030
- if (opts.designLane) values["design-lane"] = opts.designLane;
4031
- writeSem(values, target);
4032
- if (cmd.optsWithGlobals().json) return emit({ path: target, values }, true);
4033
- console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
4034
- Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
4035
- });
4036
- skills.command("lane").description("Show the lane pin resolved from ./.sechroom/lane.json (nearest in this checkout; legacy ./.sem honoured)").option("--json", "machine output").action((opts, cmd) => {
4037
- const json = cmd.optsWithGlobals().json;
4038
- const found = readSem();
4039
- if (!found) {
4040
- if (json) return emit({ path: null, values: {} }, true);
4041
- return console.log(style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom skills set-lane'.`));
4042
- }
4043
- if (json) return emit(found, true);
4044
- console.log(style.dim(`from ${found.path}`));
4045
- Object.entries(found.values).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
4046
- });
4797
+ writeSkillsLock(dir, lock);
4798
+ cleaned.push({ dir, removed });
4799
+ }
4800
+ if (cleaned.length === 0) {
4801
+ return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
4802
+ }
4803
+ if (json) return emit({ kind: spec.kind, slug, cleaned, missing }, true);
4804
+ for (const c of cleaned) {
4805
+ console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
4806
+ }
4807
+ }
4808
+
4809
+ // src/commands/skills.ts
4810
+ function registerSkills(program2) {
4811
+ const skills = program2.command("skills").description("Manage operator skills (install to disk, list, clean)");
4812
+ skills.addHelpText(
4813
+ "after",
4814
+ `
4815
+ Examples:
4816
+ $ sechroom skills install materialise your installed skills to ~/.claude/skills
4817
+ $ sechroom skills install --scope project write them to ./.claude/skills instead
4818
+ $ sechroom skills list what's materialised on disk
4819
+ $ sechroom skills clean remove the materialised skill files
4820
+ $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
4821
+
4822
+ `
4823
+ );
4824
+ 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));
4825
+ 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));
4826
+ 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));
4827
+ skills.command("set-lane").description("Alias of `sechroom lane set` (kept for back-compat) \u2014 write this checkout's lane pin").option("--code-lane <id>", "code-surface lane id (e.g. claude-code-chris)").option("--design-lane <id>", "design / substrate-authoring lane id (e.g. claude-design-chris)").option("--json", "machine output").action(
4828
+ (opts, cmd) => setLane({
4829
+ codeLane: opts.codeLane,
4830
+ designLane: opts.designLane,
4831
+ json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
4832
+ })
4833
+ );
4834
+ skills.command("lane").description("Alias of `sechroom lane` (kept for back-compat) \u2014 show this checkout's lane pin").option("--json", "machine output").action((opts, cmd) => showLane(Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)));
4047
4835
  skills.command("set-workflow").description("Set your per-operator workflow defaults (server-side; follows you across tenants)").option("--default-code-lane <id>", "personal default code lane (e.g. claude-code-chris)").option("--default-design-lane <id>", "personal default design lane (e.g. claude-design-chris)").option("--handover-recipient <id>", "your daily-handover counterparty (e.g. andy)").option("--json", "machine output").action(async (opts, cmd) => {
4048
4836
  if (!opts.defaultCodeLane && !opts.defaultDesignLane && !opts.handoverRecipient)
4049
4837
  fail("Provide at least one of --default-code-lane / --default-design-lane / --handover-recipient.");
@@ -4106,23 +4894,46 @@ Examples:
4106
4894
  });
4107
4895
  }
4108
4896
 
4897
+ // src/commands/agents.ts
4898
+ function registerAgents(program2) {
4899
+ const agents = program2.command("agents").description("Manage operator subagents (install to disk, list, clean)");
4900
+ agents.addHelpText(
4901
+ "after",
4902
+ `
4903
+ Examples:
4904
+ $ sechroom agents install materialise your installed agents to ~/.claude/agents
4905
+ $ sechroom agents install --scope project write them to ./.claude/agents instead
4906
+ $ sechroom agents install --claude-config-dir ~/.claude-abcs target another instance
4907
+ $ sechroom agents list what's materialised on disk
4908
+ $ sechroom agents clean remove the materialised agent files
4909
+
4910
+ Agents are resolved from the agent target (target:claude-agent), the dispatchable
4911
+ workers your loop skills call (e.g. find-prior-art \u2192 substrate-miner).`
4912
+ );
4913
+ 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));
4914
+ 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));
4915
+ 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));
4916
+ }
4917
+
4109
4918
  // src/commands/reset.ts
4110
- import { homedir as homedir7 } from "os";
4111
- import { join as join10 } from "path";
4112
- import { existsSync as existsSync10, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
4113
- var SKILLS_LOCK = ".sechroom-skills.json";
4114
- var localSkillsDir = () => join10(process.cwd(), ".claude", "skills");
4115
- var globalSkillsDir = () => join10(homedir7(), ".claude", "skills");
4919
+ import { homedir as homedir4 } from "os";
4920
+ import { join as join13 } from "path";
4921
+ import { existsSync as existsSync11, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
4922
+ var SKILLS_LOCK2 = ".sechroom-skills.json";
4923
+ var localSkillsDir = () => join13(process.cwd(), ".claude", "skills");
4924
+ var globalSkillsDir = () => join13(homedir4(), ".claude", "skills");
4925
+ var localAgentsDir = () => join13(process.cwd(), ".claude", "agents");
4926
+ var globalAgentsDir = () => join13(homedir4(), ".claude", "agents");
4116
4927
  function removeMaterialisedSkills(dir) {
4117
4928
  const removed = [];
4118
- const lockPath = join10(dir, SKILLS_LOCK);
4119
- if (!existsSync10(lockPath)) return removed;
4929
+ const lockPath = join13(dir, SKILLS_LOCK2);
4930
+ if (!existsSync11(lockPath)) return removed;
4120
4931
  try {
4121
4932
  const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
4122
4933
  for (const entry of Object.values(lock)) {
4123
4934
  for (const name of entry.skills ?? []) {
4124
- const p = join10(dir, name);
4125
- if (existsSync10(p)) {
4935
+ const p = join13(dir, name);
4936
+ if (existsSync11(p)) {
4126
4937
  rmSync3(p, { recursive: true, force: true });
4127
4938
  removed.push(p);
4128
4939
  }
@@ -4167,28 +4978,30 @@ function registerReset(program2) {
4167
4978
  }
4168
4979
  }
4169
4980
  const removed = [];
4170
- const stateDir = join10(process.cwd(), ".sechroom");
4171
- if (existsSync10(stateDir)) {
4981
+ const stateDir = join13(process.cwd(), ".sechroom");
4982
+ if (existsSync11(stateDir)) {
4172
4983
  rmSync3(stateDir, { recursive: true, force: true });
4173
4984
  removed.push(stateDir);
4174
4985
  }
4175
- const legacyCfg = join10(process.cwd(), ".sechroom.json");
4176
- if (existsSync10(legacyCfg)) {
4986
+ const legacyCfg = join13(process.cwd(), ".sechroom.json");
4987
+ if (existsSync11(legacyCfg)) {
4177
4988
  rmSync3(legacyCfg, { force: true });
4178
4989
  removed.push(legacyCfg);
4179
4990
  }
4180
- const legacySem = join10(process.cwd(), ".sem");
4181
- if (existsSync10(legacySem)) {
4991
+ const legacySem = join13(process.cwd(), ".sem");
4992
+ if (existsSync11(legacySem)) {
4182
4993
  rmSync3(legacySem, { force: true });
4183
4994
  removed.push(legacySem);
4184
4995
  }
4185
4996
  removed.push(...removeMaterialisedSkills(localSkillsDir()));
4997
+ removed.push(...removeMaterialisedSkills(localAgentsDir()));
4186
4998
  if (global) {
4187
4999
  const tok = clearToken();
4188
5000
  if (tok) removed.push(tok);
4189
5001
  const cfg = clearPersisted();
4190
5002
  if (cfg) removed.push(cfg);
4191
5003
  removed.push(...removeMaterialisedSkills(globalSkillsDir()));
5004
+ removed.push(...removeMaterialisedSkills(globalAgentsDir()));
4192
5005
  }
4193
5006
  if (json) return emit({ global, removed }, true);
4194
5007
  if (removed.length === 0) {
@@ -4213,7 +5026,7 @@ function resolveVersion() {
4213
5026
  }
4214
5027
  }
4215
5028
  var program = new Command();
4216
- program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--binding <name>", "Named workspace binding from .sechroom.json `workspaces` (overrides path auto-selection / SECHROOM_BINDING)").option("--json", "Emit compact JSON (for scripts and agents)", false);
5029
+ program.name("sechroom").description("Sechroom CLI \u2014 thin generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.").version(resolveVersion()).option("--base-url <url>", "API base URL (overrides config / SECHROOM_BASE_URL)").option("--tenant <tenant>", "Tenant id (required by the API; overrides config / SECHROOM_TENANT)").option("--binding <name>", "Named workspace binding from .sechroom.json `workspaces` (overrides path auto-selection / SECHROOM_BINDING)").option("--claude-config-dir <dirs>", "Claude config dir(s), comma-separated (overrides CLAUDE_CONFIG_DIR / ~/.claude)").option("--codex-home <dir>", "Codex home (overrides CODEX_HOME / ~/.codex)").option("--json", "Emit compact JSON (for scripts and agents)", false);
4217
5030
  program.addHelpText(
4218
5031
  "after",
4219
5032
  `
@@ -4317,15 +5130,19 @@ registerWorkspace(program);
4317
5130
  registerProject(program);
4318
5131
  registerFiling(program);
4319
5132
  registerContinuity(program);
5133
+ registerCheckpoint(program);
4320
5134
  registerHook(program);
4321
5135
  registerId(program);
4322
5136
  registerAccount(program);
4323
5137
  registerChat(program);
4324
5138
  registerInit(program);
4325
5139
  registerSetup(program);
5140
+ registerNamespace(program);
4326
5141
  registerOnboard(program);
4327
5142
  registerSweep(program);
4328
5143
  registerSkills(program);
5144
+ registerAgents(program);
5145
+ registerLane(program);
4329
5146
  registerReset(program);
4330
5147
  program.parseAsync().catch((err2) => {
4331
5148
  process.stderr.write(`error: ${err2 instanceof Error ? err2.message : String(err2)}