@sechroom/cli 2026.6.35-rc.92df1100 → 2026.6.35

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 +1213 -352
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -844,6 +844,50 @@ Examples:
844
844
  cmd.optsWithGlobals().json
845
845
  );
846
846
  });
847
+ memory.command("update <memoryId>").description("Update metadata only \u2014 title/tags/type/confidence (PATCH /memories/{memoryId}/metadata; omitted = unchanged)").option("--title <text>", "Set the title").option("--tag <tag...>", "Set the full tag list (replaces existing); repeatable").option("--add-tag <tag...>", "Add tag(s) to the existing set (repeatable)").option("--remove-tag <tag...>", "Remove tag(s) from the existing set (repeatable)").option("--type <type>", "Set the memory type (e.g. reference, note, document)").option("--confidence <n>", "Set confidence (0..1)", (v) => Number(v)).option("--memory-source <src>", "Set the memory's own Source field").option("--bump-version", "Bump the version chain (use for a content reinterpretation, e.g. a type promotion)", false).option("--source <source>", "Contributing lane stamp (attribution)", "cli").action(async (memoryId, opts, cmd) => {
848
+ const cfg = resolveConfig(cmd.optsWithGlobals());
849
+ const json = cmd.optsWithGlobals().json;
850
+ const hasTagOps = Boolean(opts.tag || opts.addTag || opts.removeTag);
851
+ const hasAny = opts.title !== void 0 || hasTagOps || opts.type !== void 0 || opts.confidence !== void 0 || opts.memorySource !== void 0 || Boolean(opts.bumpVersion);
852
+ if (!hasAny)
853
+ fail("nothing to update \u2014 pass at least one of --title / --tag / --add-tag / --remove-tag / --type / --confidence / --memory-source.");
854
+ let tags;
855
+ if (hasTagOps) {
856
+ let base;
857
+ if (opts.tag) base = opts.tag;
858
+ else {
859
+ const current = await runApi("Reading current tags", async () => {
860
+ const client = await makeClient(cfg);
861
+ return client.GET("/memories/{memoryId}", { params: { path: { memoryId } } });
862
+ });
863
+ base = current?.item?.tags ?? current?.tags ?? [];
864
+ }
865
+ const set = new Set(base);
866
+ for (const t of opts.addTag ?? []) set.add(t);
867
+ for (const t of opts.removeTag ?? []) set.delete(t);
868
+ tags = [...set];
869
+ }
870
+ const body = {
871
+ memoryId,
872
+ source: opts.source,
873
+ bumpVersion: Boolean(opts.bumpVersion)
874
+ };
875
+ if (opts.title !== void 0) body.title = opts.title;
876
+ if (tags !== void 0) body.tags = tags;
877
+ if (opts.type !== void 0) body.type = opts.type;
878
+ if (opts.confidence !== void 0) body.confidence = opts.confidence;
879
+ if (opts.memorySource !== void 0) body.memorySource = opts.memorySource;
880
+ const data = await runApi("Updating metadata", async () => {
881
+ const client = await makeClient(cfg);
882
+ return client.PATCH("/memories/{memoryId}/metadata", {
883
+ params: { path: { memoryId } },
884
+ body
885
+ });
886
+ });
887
+ const changed = data?.changed ?? [];
888
+ const summary = changed.length > 0 ? `updated ${changed.join(", ")}` : "no changes";
889
+ emitAction(`${summary} on ${style.bold(memoryId)} \u2192 v${style.bold(String(data?.version ?? "?"))}`, data, json);
890
+ });
847
891
  memory.command("archive <memoryId>").description("Archive a memory (POST /memories/{memoryId}/archive)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
848
892
  const cfg = resolveConfig(cmd.optsWithGlobals());
849
893
  const data = await runApi("Archiving memory", async () => {
@@ -1773,14 +1817,18 @@ Examples:
1773
1817
  });
1774
1818
  }
1775
1819
 
1820
+ // src/commands/checkpoint.ts
1821
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
1822
+ import { dirname as dirname5, join as join6 } from "path";
1823
+
1776
1824
  // 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";
1825
+ import { createHash as createHash2 } from "crypto";
1826
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
1827
+ import { delimiter, dirname as dirname4, join as join5 } from "path";
1780
1828
 
1781
1829
  // src/sem.ts
1782
1830
  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";
1831
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
1784
1832
  var SEM_FILE = join2(".sechroom", "lane.json");
1785
1833
  var LEGACY_SEM_FILE = ".sem";
1786
1834
  var STATE_DIR_NAME2 = ".sechroom";
@@ -1799,6 +1847,43 @@ function resolveSemPathForRead(start = process.cwd()) {
1799
1847
  dir = parent;
1800
1848
  }
1801
1849
  }
1850
+ function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
1851
+ try {
1852
+ let dir = start;
1853
+ let gitPath;
1854
+ for (; ; ) {
1855
+ const candidate = join2(dir, ".git");
1856
+ if (existsSync2(candidate)) {
1857
+ gitPath = candidate;
1858
+ break;
1859
+ }
1860
+ const parent = dirname2(dir);
1861
+ if (parent === dir) break;
1862
+ dir = parent;
1863
+ }
1864
+ if (!gitPath || statSync(gitPath).isDirectory()) return lane;
1865
+ const gitFile = readFileSync2(gitPath, "utf8");
1866
+ const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
1867
+ if (!common) return lane;
1868
+ const worktreesDir = join2(common[1], "worktrees");
1869
+ const siblings = readdirSync(worktreesDir).filter((n) => {
1870
+ try {
1871
+ return statSync(join2(worktreesDir, n)).isDirectory();
1872
+ } catch {
1873
+ return false;
1874
+ }
1875
+ });
1876
+ return laneWithWorktreeSuffix(lane, gitFile, siblings);
1877
+ } catch {
1878
+ return lane;
1879
+ }
1880
+ }
1881
+ function laneWithWorktreeSuffix(lane, gitFile, siblings) {
1882
+ const m = gitFile.trim().match(/\/worktrees\/([^/\s]+)\s*$/);
1883
+ if (!m) return lane;
1884
+ const idx = [...siblings].sort().indexOf(m[1]);
1885
+ return idx < 0 ? lane : `${lane}-${idx + 2}`;
1886
+ }
1802
1887
  function parseSem(text) {
1803
1888
  const out = {};
1804
1889
  for (const raw of text.split("\n")) {
@@ -1896,8 +1981,55 @@ function ensureSemIgnored(semPath) {
1896
1981
 
1897
1982
  // src/setup/clients.ts
1898
1983
  import { existsSync as existsSync3 } from "fs";
1984
+ import { homedir as homedir3 } from "os";
1985
+ import { dirname as dirname3, join as join4 } from "path";
1986
+
1987
+ // src/setup/config-dirs.ts
1899
1988
  import { homedir as homedir2 } from "os";
1900
- import { dirname as dirname3, join as join3 } from "path";
1989
+ import { join as join3 } from "path";
1990
+ function expandTilde(p) {
1991
+ if (p === "~") return homedir2();
1992
+ if (p.startsWith("~/")) return join3(homedir2(), p.slice(2));
1993
+ return p;
1994
+ }
1995
+ function splitDirs(raw) {
1996
+ if (!raw) return [];
1997
+ return raw.split(",").map((s) => expandTilde(s.trim())).filter(Boolean);
1998
+ }
1999
+ function resolveScope(flag) {
2000
+ if (flag == null) return "global";
2001
+ if (flag === "global" || flag === "project") return flag;
2002
+ throw new Error(`--scope must be 'global' or 'project' (got '${flag}')`);
2003
+ }
2004
+ function labelFor(dir) {
2005
+ const h = homedir2();
2006
+ if (dir === h) return "~";
2007
+ return dir.startsWith(h + "/") ? "~" + dir.slice(h.length) : dir;
2008
+ }
2009
+ function defaultClaudeDir() {
2010
+ return join3(homedir2(), ".claude");
2011
+ }
2012
+ function defaultCodexHome() {
2013
+ return join3(homedir2(), ".codex");
2014
+ }
2015
+ function resolveClaudeTargets(opts) {
2016
+ const scope = opts.scope ?? "global";
2017
+ const cwd = opts.cwd ?? process.cwd();
2018
+ if (scope === "project") {
2019
+ return [{ dir: join3(cwd, ".claude"), scope, label: "<project>" }];
2020
+ }
2021
+ const fromFlag = splitDirs(opts.override);
2022
+ const fromEnv = splitDirs(process.env.CLAUDE_CONFIG_DIR);
2023
+ const dirs = fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultClaudeDir()];
2024
+ return dirs.map((dir) => ({ dir, scope, label: labelFor(dir) }));
2025
+ }
2026
+ function resolveCodexHomes(opts) {
2027
+ const scope = opts.scope ?? "global";
2028
+ if (scope === "project") return [];
2029
+ const fromFlag = splitDirs(opts.override);
2030
+ const fromEnv = splitDirs(process.env.CODEX_HOME);
2031
+ return fromFlag.length ? fromFlag : fromEnv.length ? fromEnv : [defaultCodexHome()];
2032
+ }
1901
2033
 
1902
2034
  // src/setup/operator-surface.ts
1903
2035
  var SectionType = {
@@ -1910,15 +2042,25 @@ var SectionType = {
1910
2042
  * carried a workspaceId and that workspace has agent-setup-bundle memories. */
1911
2043
  WorkspaceConventions: "workspace-conventions"
1912
2044
  };
1913
- async function fetchSetup(cfg) {
2045
+ async function fetchSetup(cfg, namespaceSlug) {
1914
2046
  const client = await makeClient(cfg);
2047
+ const query = {};
2048
+ if (cfg.workspaceId) query.workspaceId = cfg.workspaceId;
2049
+ if (namespaceSlug) query.namespaceSlug = namespaceSlug;
2050
+ const hasQuery = query.workspaceId !== void 0 || query.namespaceSlug !== void 0;
1915
2051
  const { data, error } = await client.GET(
1916
2052
  "/operator-surface/setup",
1917
- cfg.workspaceId ? { params: { query: { workspaceId: cfg.workspaceId } } } : {}
2053
+ hasQuery ? { params: { query } } : {}
1918
2054
  );
1919
2055
  if (error) throw new Error(`GET /operator-surface/setup failed: ${JSON.stringify(error)}`);
1920
2056
  return data;
1921
2057
  }
2058
+ async function listNamespaces(cfg) {
2059
+ const client = await makeClient(cfg);
2060
+ const { data } = await client.GET("/mcp-aggregator/namespaces", {});
2061
+ const rows = data ?? [];
2062
+ return rows.filter((r) => typeof r.slug === "string").map((r) => ({ slug: r.slug, displayName: r.displayName ?? r.slug }));
2063
+ }
1922
2064
  function findSurface(setup, surfaceKey) {
1923
2065
  return setup.surfaces.find((s) => s.surfaceKey === surfaceKey);
1924
2066
  }
@@ -1989,12 +2131,14 @@ async function resolveWorkspaceConventions(cfg, section) {
1989
2131
  if (parseTagArtifactId(artifact.id)) continue;
1990
2132
  const mem = await fetchMemoryFields(cfg, artifact.id);
1991
2133
  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}`);
2134
+ const ref = `${artifact.id}@v${mem.version ?? 1}`;
2135
+ parts.push(`<!-- @sechroom/cli:section source=${ref} -->
2136
+ ${mem.text.trim()}`);
2137
+ refs.push(ref);
1994
2138
  }
1995
2139
  }
1996
2140
  if (parts.length === 0) return null;
1997
- return { body: parts.join("\n\n---\n\n"), refs };
2141
+ return { body: parts.join("\n\n"), refs };
1998
2142
  }
1999
2143
  async function createOverride(cfg, template, personalWorkspaceId) {
2000
2144
  const client = await makeClient(cfg);
@@ -2022,51 +2166,53 @@ async function createOverride(cfg, template, personalWorkspaceId) {
2022
2166
  function claudeDesktopConfigPath(home) {
2023
2167
  switch (process.platform) {
2024
2168
  case "darwin":
2025
- return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
2169
+ return join4(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
2026
2170
  case "win32":
2027
- return join3(process.env.APPDATA ?? join3(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
2171
+ return join4(process.env.APPDATA ?? join4(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
2028
2172
  default:
2029
- return join3(home, ".config", "Claude", "claude_desktop_config.json");
2173
+ return join4(home, ".config", "Claude", "claude_desktop_config.json");
2030
2174
  }
2031
2175
  }
2032
- function clientTargets(cwd) {
2033
- const home = homedir2();
2176
+ function clientTargets(cwd, opts = {}) {
2177
+ const home = homedir3();
2178
+ const claudeDir = opts.claudeDir ?? join4(home, ".claude");
2179
+ const codexHome = opts.codexHome ?? join4(home, ".codex");
2034
2180
  return {
2035
2181
  "claude-code": {
2036
2182
  key: "claude-code",
2037
2183
  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") }
2184
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".mcp.json"), format: "json" },
2185
+ instruction: { surfaceKey: "claude-code", path: join4(cwd, "CLAUDE.md") }
2040
2186
  },
2041
2187
  "claude-desktop": {
2042
2188
  key: "claude-desktop",
2043
2189
  label: "Claude Desktop",
2044
2190
  mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
2045
- instruction: { surfaceKey: "claude-desktop", path: join3(home, ".claude", "CLAUDE.md") }
2191
+ instruction: { surfaceKey: "claude-desktop", path: join4(claudeDir, "CLAUDE.md") }
2046
2192
  },
2047
2193
  codex: {
2048
2194
  key: "codex",
2049
2195
  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") }
2196
+ mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join4(codexHome, "config.toml"), format: "toml" },
2197
+ instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
2052
2198
  },
2053
2199
  cursor: {
2054
2200
  key: "cursor",
2055
2201
  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") }
2202
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join4(cwd, ".cursor", "mcp.json"), format: "json" },
2203
+ instruction: { surfaceKey: "chatgpt", path: join4(cwd, "AGENTS.md") }
2058
2204
  }
2059
2205
  };
2060
2206
  }
2061
2207
  var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor"];
2062
2208
  var DEFAULT_CLIENT_KEY = "claude-code";
2063
2209
  function detectInstalledClients(cwd) {
2064
- const home = homedir2();
2210
+ const home = homedir3();
2065
2211
  const detected = [];
2066
- if (existsSync3(join3(home, ".claude"))) detected.push("claude-code");
2212
+ if (resolveClaudeTargets({}).some((t) => existsSync3(t.dir))) detected.push("claude-code");
2067
2213
  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");
2214
+ if (resolveCodexHomes({}).some((d) => existsSync3(d))) detected.push("codex");
2215
+ if (existsSync3(join4(home, ".cursor")) || existsSync3(join4(cwd, ".cursor"))) detected.push("cursor");
2070
2216
  return detected;
2071
2217
  }
2072
2218
 
@@ -2090,14 +2236,15 @@ function resolveLane(flagLane, cwd) {
2090
2236
  const env = process.env.SECHROOM_LANE;
2091
2237
  if (env) return env;
2092
2238
  const start = cwd ?? process.cwd();
2093
- const sem = readSem(resolveSemPathForRead(start));
2094
- return sem?.values["code-lane"];
2239
+ const base = readSem(resolveSemPathForRead(start))?.values["code-lane"];
2240
+ if (!base) return void 0;
2241
+ return applyWorktreeLaneSuffix(base, start);
2095
2242
  }
2096
- var INTENT_FILE = join4(".sechroom", "continuity.json");
2243
+ var INTENT_FILE = join5(".sechroom", "continuity.json");
2097
2244
  function resolveIntentPath(start) {
2098
2245
  let dir = start;
2099
2246
  for (; ; ) {
2100
- const candidate = join4(dir, INTENT_FILE);
2247
+ const candidate = join5(dir, INTENT_FILE);
2101
2248
  if (existsSync4(candidate)) return candidate;
2102
2249
  const parent = dirname4(dir);
2103
2250
  if (parent === dir) return void 0;
@@ -2118,6 +2265,102 @@ function hasRequiredIntent(i) {
2118
2265
  i.objective?.trim() && i.state?.trim() && i.lastAction?.trim() && i.nextAction?.trim() && i.resumeInstruction?.trim()
2119
2266
  );
2120
2267
  }
2268
+ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScope, opts) {
2269
+ const lane = resolveLane(laneFlag, cwd);
2270
+ if (!lane) return false;
2271
+ const intent = readIntent(cwd);
2272
+ if (!intent || !hasRequiredIntent(intent)) return false;
2273
+ if (opts?.skipIfUnchanged && unchangedSinceLastPush(cwd, intent)) return false;
2274
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2275
+ const client = await makeClient(cfg);
2276
+ await client.POST("/continuity/snapshots", {
2277
+ body: {
2278
+ laneId: lane,
2279
+ scope: scopeFlag ?? intent.scope ?? defaultScope,
2280
+ currentObjective: intent.objective,
2281
+ currentState: intent.state,
2282
+ lastMeaningfulAction: intent.lastAction,
2283
+ nextIntendedAction: intent.nextAction,
2284
+ resumeInstruction: intent.resumeInstruction,
2285
+ activeConstraints: intent.constraints ?? null,
2286
+ openQuestions: intent.questions ?? null,
2287
+ surfaceMarkers: intent.surfaceMarkers ?? null,
2288
+ relevantArtifactIds: intent.artifacts ?? null,
2289
+ confidence: intent.confidence ?? null,
2290
+ // Frequent triggers (compaction, session-end) land within the FR-051 4h
2291
+ // window; Acknowledge lets the checkpoint persist on the lane.
2292
+ concurrentSessionPolicy: "Acknowledge"
2293
+ }
2294
+ });
2295
+ recordPush(cwd, intent);
2296
+ return true;
2297
+ }
2298
+ function ledgerPath(start) {
2299
+ const intent = resolveIntentPath(start);
2300
+ const dir = intent ? dirname4(intent) : join5(start, ".sechroom");
2301
+ return join5(dir, ".checkpoint-state.json");
2302
+ }
2303
+ function readLedger(start) {
2304
+ try {
2305
+ const p = ledgerPath(start);
2306
+ if (!existsSync4(p)) return {};
2307
+ return JSON.parse(readFileSync3(p, "utf8"));
2308
+ } catch {
2309
+ return {};
2310
+ }
2311
+ }
2312
+ function intentHash(i) {
2313
+ const canonical = JSON.stringify({
2314
+ objective: i.objective ?? "",
2315
+ state: i.state ?? "",
2316
+ lastAction: i.lastAction ?? "",
2317
+ nextAction: i.nextAction ?? "",
2318
+ resumeInstruction: i.resumeInstruction ?? "",
2319
+ scope: i.scope ?? "",
2320
+ constraints: i.constraints ?? [],
2321
+ questions: i.questions ?? [],
2322
+ surfaceMarkers: i.surfaceMarkers ?? [],
2323
+ artifacts: i.artifacts ?? [],
2324
+ confidence: i.confidence ?? null
2325
+ });
2326
+ return createHash2("sha256").update(canonical, "utf8").digest("hex");
2327
+ }
2328
+ function recentlyCheckpointed(start, minutes) {
2329
+ const { lastEpochMs } = readLedger(start);
2330
+ return typeof lastEpochMs === "number" && Date.now() - lastEpochMs < minutes * 6e4;
2331
+ }
2332
+ function unchangedSinceLastPush(start, intent) {
2333
+ const ledger = readLedger(start);
2334
+ if (ledger.lastHash == null) return false;
2335
+ const path = resolveIntentPath(start);
2336
+ if (path && ledger.lastMtimeMs != null) {
2337
+ try {
2338
+ if (statSync2(path).mtimeMs <= ledger.lastMtimeMs) return true;
2339
+ } catch {
2340
+ }
2341
+ }
2342
+ return intentHash(intent) === ledger.lastHash;
2343
+ }
2344
+ function recordPush(start, intent) {
2345
+ try {
2346
+ const p = ledgerPath(start);
2347
+ const path = resolveIntentPath(start);
2348
+ let mtimeMs;
2349
+ try {
2350
+ if (path) mtimeMs = statSync2(path).mtimeMs;
2351
+ } catch {
2352
+ mtimeMs = void 0;
2353
+ }
2354
+ mkdirSync3(dirname4(p), { recursive: true });
2355
+ const ledger = {
2356
+ lastEpochMs: Date.now(),
2357
+ lastMtimeMs: mtimeMs,
2358
+ lastHash: intentHash(intent)
2359
+ };
2360
+ writeFileSync3(p, JSON.stringify(ledger) + "\n");
2361
+ } catch {
2362
+ }
2363
+ }
2121
2364
  function formatContext(bundle, lane) {
2122
2365
  const s = bundle?.latestSnapshot;
2123
2366
  if (!s) return null;
@@ -2154,20 +2397,23 @@ function emitSessionStart(additionalContext) {
2154
2397
  }) + "\n"
2155
2398
  );
2156
2399
  }
2157
- var HOOK_COMMANDS = {
2400
+ var CLAUDE_HOOK_COMMANDS = {
2401
+ SessionStart: "sechroom hook session-start",
2402
+ PreCompact: "sechroom hook pre-compact",
2403
+ SessionEnd: "sechroom hook session-end"
2404
+ };
2405
+ var CODEX_HOOK_COMMANDS = {
2158
2406
  SessionStart: "sechroom hook session-start",
2159
- PreCompact: "sechroom hook pre-compact"
2407
+ Stop: "sechroom hook session-end --debounce-minutes 10"
2160
2408
  };
2161
- var HOOK_EVENTS = ["SessionStart", "PreCompact"];
2162
2409
  function hasHookCommand(config2, event, command) {
2163
2410
  const groups = config2.hooks?.[event] ?? [];
2164
2411
  return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
2165
2412
  }
2166
- function mergeHooks(config2) {
2413
+ function mergeHooks(config2, commands) {
2167
2414
  config2.hooks ??= {};
2168
2415
  let added = 0;
2169
- for (const event of HOOK_EVENTS) {
2170
- const command = HOOK_COMMANDS[event];
2416
+ for (const [event, command] of Object.entries(commands)) {
2171
2417
  if (hasHookCommand(config2, event, command)) continue;
2172
2418
  const groups = config2.hooks[event] ??= [];
2173
2419
  groups.push({ hooks: [{ type: "command", command }] });
@@ -2181,10 +2427,10 @@ function readJsonConfig2(path) {
2181
2427
  if (!raw.trim()) return {};
2182
2428
  return JSON.parse(raw);
2183
2429
  }
2184
- function installHooksJson(path, dryRun) {
2430
+ function installHooksJson(path, commands, dryRun) {
2185
2431
  const existed = existsSync4(path) && readFileSync3(path, "utf8").trim().length > 0;
2186
2432
  const config2 = readJsonConfig2(path);
2187
- const added = mergeHooks(config2);
2433
+ const added = mergeHooks(config2, commands);
2188
2434
  if (added === 0 && existed) return { path, status: "current" };
2189
2435
  if (!dryRun) {
2190
2436
  mkdirSync3(dirname4(path), { recursive: true });
@@ -2244,11 +2490,11 @@ function installHookSurfaces(surfaces, opts) {
2244
2490
  const out = [];
2245
2491
  for (const surface of surfaces) {
2246
2492
  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)] });
2493
+ const path = join5(opts.claudeDir, "settings.json");
2494
+ out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
2249
2495
  } 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);
2496
+ const hooksJson = installHooksJson(join5(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
2497
+ const featureFlag = installCodexFeatureFlag(join5(opts.codexHome, "config.toml"), opts.dryRun);
2252
2498
  out.push({ surface, results: [hooksJson, featureFlag] });
2253
2499
  }
2254
2500
  }
@@ -2268,7 +2514,7 @@ function isSechroomOnPath() {
2268
2514
  for (const dir of pathEnv.split(delimiter)) {
2269
2515
  if (!dir) continue;
2270
2516
  for (const name of names) {
2271
- if (existsSync4(join4(dir, name))) return true;
2517
+ if (existsSync4(join5(dir, name))) return true;
2272
2518
  }
2273
2519
  }
2274
2520
  return false;
@@ -2327,52 +2573,62 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2327
2573
  const raw = await readStdin();
2328
2574
  const input = parseHookInput(raw);
2329
2575
  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
- });
2576
+ await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
2577
+ return process.exit(0);
2578
+ } catch {
2579
+ return process.exit(0);
2580
+ }
2581
+ });
2582
+ 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(
2583
+ "--debounce-minutes <n>",
2584
+ "skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
2585
+ ).action(async (opts, cmd) => {
2586
+ try {
2587
+ const raw = await readStdin();
2588
+ const input = parseHookInput(raw);
2589
+ const cwd = input.cwd ?? process.cwd();
2590
+ const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
2591
+ if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
2592
+ await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "session-end", { skipIfUnchanged: true });
2355
2593
  return process.exit(0);
2356
2594
  } catch {
2357
2595
  return process.exit(0);
2358
2596
  }
2359
2597
  });
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) => {
2598
+ 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) => {
2599
+ const g = cmd.optsWithGlobals();
2361
2600
  const dryRun = Boolean(opts.dryRun);
2362
2601
  const cwd = process.cwd();
2602
+ let scope;
2363
2603
  let surfaces;
2364
2604
  try {
2605
+ scope = opts.local ? "project" : resolveScope(opts.scope);
2365
2606
  surfaces = resolveSurfaces(opts.surface, cwd);
2366
2607
  } catch (err2) {
2367
2608
  process.stderr.write(`${err2.message}
2368
2609
  `);
2369
2610
  return process.exit(2);
2370
2611
  }
2612
+ const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd }) : [];
2613
+ const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: g.codexHome, scope }) : [];
2371
2614
  const results = [];
2372
2615
  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]}:
2616
+ const multiClaude = claudeTargets.length > 1;
2617
+ for (const t of claudeTargets) {
2618
+ const surfaceResults = installHookSurfaces(["claude"], { dryRun, claudeDir: t.dir, codexHome: "" })[0].results;
2619
+ process.stdout.write(`${HOOK_SURFACE_LABEL.claude}${multiClaude ? ` (${t.label})` : ""}:
2620
+ `);
2621
+ for (const r of surfaceResults) {
2622
+ results.push(r);
2623
+ process.stdout.write(describe(r, dryRun) + "\n");
2624
+ }
2625
+ }
2626
+ if (surfaces.includes("codex") && codexHomes.length === 0) {
2627
+ process.stdout.write("Codex has no project scope \u2014 skipped (use --scope global for Codex).\n");
2628
+ }
2629
+ for (const codexHome of codexHomes) {
2630
+ const surfaceResults = installHookSurfaces(["codex"], { dryRun, claudeDir: "", codexHome })[0].results;
2631
+ process.stdout.write(`${HOOK_SURFACE_LABEL.codex}:
2376
2632
  `);
2377
2633
  for (const r of surfaceResults) {
2378
2634
  results.push(r);
@@ -2396,6 +2652,101 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
2396
2652
  });
2397
2653
  }
2398
2654
 
2655
+ // src/commands/checkpoint.ts
2656
+ function registerCheckpoint(program2) {
2657
+ program2.command("checkpoint").description(
2658
+ "Checkpoint working state: create a continuity snapshot (server-validated) AND sync ./.sechroom/continuity.json in one step"
2659
+ ).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(
2660
+ "after",
2661
+ `
2662
+ File-first: reads ./.sechroom/continuity.json (kept current as you work) as the base; any flag
2663
+ overrides that field. The snapshot is created FIRST (server-validated), then the local file is
2664
+ written/normalized with the returned snapshotId. Lane: --lane > SECHROOM_LANE > ./.sem code-lane.
2665
+
2666
+ Examples:
2667
+ $ sechroom checkpoint snapshot from ./.sechroom/continuity.json, then sync it
2668
+ $ sechroom checkpoint --next-action "..." override one field, keep the rest from the file
2669
+ $ sechroom checkpoint --lane claude-code-chris --objective "..." --state "..." \\
2670
+ --last-action "..." --next-action "..." --resume-instruction "..."`
2671
+ ).action(async (opts, cmd) => {
2672
+ const cwd = process.cwd();
2673
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2674
+ const json = Boolean(cmd.optsWithGlobals().json);
2675
+ const base = readIntent(cwd) ?? {};
2676
+ const merged = {
2677
+ objective: opts.objective ?? base.objective,
2678
+ state: opts.state ?? base.state,
2679
+ lastAction: opts.lastAction ?? base.lastAction,
2680
+ nextAction: opts.nextAction ?? base.nextAction,
2681
+ resumeInstruction: opts.resumeInstruction ?? base.resumeInstruction,
2682
+ scope: opts.scope ?? base.scope,
2683
+ constraints: opts.constraint ?? base.constraints,
2684
+ questions: opts.question ?? base.questions,
2685
+ surfaceMarkers: opts.surfaceMarker ?? base.surfaceMarkers,
2686
+ artifacts: opts.artifact ?? base.artifacts,
2687
+ confidence: opts.confidence != null ? Number(opts.confidence) : base.confidence
2688
+ };
2689
+ const lane = resolveLane(opts.lane, cwd);
2690
+ if (!lane) {
2691
+ fail(
2692
+ "no lane resolved \u2014 pass --lane, set SECHROOM_LANE, or pin one in ./.sem (code-lane). See `sechroom lane`."
2693
+ );
2694
+ }
2695
+ const required = [
2696
+ ["objective", "--objective"],
2697
+ ["state", "--state"],
2698
+ ["lastAction", "--last-action"],
2699
+ ["nextAction", "--next-action"],
2700
+ ["resumeInstruction", "--resume-instruction"]
2701
+ ];
2702
+ const missing = required.filter(([k]) => !String(merged[k] ?? "").trim()).map(([, flag]) => flag);
2703
+ if (missing.length > 0) {
2704
+ fail(
2705
+ `missing required field(s): ${missing.join(", ")} \u2014 supply via flag or in ./.sechroom/continuity.json`
2706
+ );
2707
+ }
2708
+ const scope = merged.scope ?? "session";
2709
+ const body = {
2710
+ laneId: lane,
2711
+ scope,
2712
+ currentObjective: merged.objective,
2713
+ currentState: merged.state,
2714
+ lastMeaningfulAction: merged.lastAction,
2715
+ nextIntendedAction: merged.nextAction,
2716
+ resumeInstruction: merged.resumeInstruction,
2717
+ activeConstraints: merged.constraints ?? null,
2718
+ openQuestions: merged.questions ?? null,
2719
+ surfaceMarkers: merged.surfaceMarkers ?? null,
2720
+ relevantArtifactIds: merged.artifacts ?? null,
2721
+ confidence: merged.confidence ?? null,
2722
+ // Explicit checkpoints are often within the FR-051 4h window; Acknowledge
2723
+ // lets one land on the lane (matches `hook pre-compact`).
2724
+ concurrentSessionPolicy: "Acknowledge"
2725
+ };
2726
+ if (opts.dryRun) {
2727
+ emit({ dryRun: true, lane, scope, wouldCreate: body }, json);
2728
+ return;
2729
+ }
2730
+ const data = await runApi("Creating snapshot", async () => {
2731
+ const client = await makeClient(cfg);
2732
+ return client.POST("/continuity/snapshots", { body });
2733
+ });
2734
+ const path = resolveIntentPath(cwd) ?? join6(cwd, INTENT_FILE);
2735
+ const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
2736
+ mkdirSync4(dirname5(path), { recursive: true });
2737
+ writeFileSync4(path, JSON.stringify(fileBody, null, 2) + "\n");
2738
+ recordPush(cwd, merged);
2739
+ if (json) {
2740
+ emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
2741
+ return;
2742
+ }
2743
+ process.stdout.write(
2744
+ `${style.bold("\u2713")} checkpoint ${style.bold(data.snapshotId)} ${style.dim(`(lane ${lane}, scope ${scope})`)} \u2014 synced ${path}
2745
+ `
2746
+ );
2747
+ });
2748
+ }
2749
+
2399
2750
  // src/commands/account.ts
2400
2751
  function registerId(program2) {
2401
2752
  const id = program2.command("id").description("Allocate human-authored id sequences (FR-*, D-*)");
@@ -2612,16 +2963,16 @@ Examples:
2612
2963
  }
2613
2964
 
2614
2965
  // 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";
2966
+ import { createHash as createHash3 } from "crypto";
2967
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
2968
+ import { dirname as dirname6 } from "path";
2618
2969
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
2619
2970
  var MARKER_END = "<!-- @sechroom/cli:end";
2620
2971
  function normalizeBody(s) {
2621
2972
  return s.replace(/\r\n/g, "\n").trim();
2622
2973
  }
2623
2974
  function bodySha256(body) {
2624
- return createHash2("sha256").update(normalizeBody(body), "utf8").digest("hex");
2975
+ return createHash3("sha256").update(normalizeBody(body), "utf8").digest("hex");
2625
2976
  }
2626
2977
  function renderBlock(write) {
2627
2978
  const body = normalizeBody(write.body);
@@ -2667,7 +3018,7 @@ function parseManagedBlock(content, block) {
2667
3018
  return null;
2668
3019
  }
2669
3020
  function ensureDir2(path) {
2670
- mkdirSync4(dirname5(path), { recursive: true });
3021
+ mkdirSync5(dirname6(path), { recursive: true });
2671
3022
  }
2672
3023
  function readOr(path, fallback) {
2673
3024
  try {
@@ -2690,7 +3041,7 @@ function mergeMcpJson(path, snippet, dryRun) {
2690
3041
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
2691
3042
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
2692
3043
  ensureDir2(path);
2693
- writeFileSync4(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
3044
+ writeFileSync5(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
2694
3045
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
2695
3046
  }
2696
3047
  function mergeCodexToml(path, snippet, dryRun) {
@@ -2701,7 +3052,7 @@ function mergeCodexToml(path, snippet, dryRun) {
2701
3052
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
2702
3053
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
2703
3054
  ensureDir2(path);
2704
- writeFileSync4(path, next, { mode: 384 });
3055
+ writeFileSync5(path, next, { mode: 384 });
2705
3056
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
2706
3057
  }
2707
3058
  function writeInstructionBlock(path, write, dryRun) {
@@ -2709,7 +3060,7 @@ function writeInstructionBlock(path, write, dryRun) {
2709
3060
  const next = computeBlockFile(readOr(path, ""), write);
2710
3061
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
2711
3062
  ensureDir2(path);
2712
- writeFileSync4(path, next);
3063
+ writeFileSync5(path, next);
2713
3064
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
2714
3065
  }
2715
3066
  function computeBlockFile(current, write) {
@@ -2750,7 +3101,7 @@ function applyBlock(path, write, mode, dryRun) {
2750
3101
  const next = computeBlockFile(current, write);
2751
3102
  if (!dryRun) {
2752
3103
  ensureDir2(proposedPath);
2753
- writeFileSync4(proposedPath, next);
3104
+ writeFileSync5(proposedPath, next);
2754
3105
  }
2755
3106
  return {
2756
3107
  kind: "instruction",
@@ -2821,12 +3172,14 @@ async function applyClient(cfg, setup, target, opts) {
2821
3172
  }
2822
3173
 
2823
3174
  // src/setup/hooks-offer.ts
2824
- import { homedir as homedir4 } from "os";
2825
3175
  async function maybeOfferHooks(opts) {
2826
3176
  if (opts.dryRun) return;
2827
3177
  const cwd = opts.cwd ?? process.cwd();
3178
+ const scope = opts.scope ?? "global";
2828
3179
  const surfaces = detectHookSurfaces(cwd);
2829
3180
  if (surfaces.length === 0) return;
3181
+ const claudeTargets = surfaces.includes("claude") ? resolveClaudeTargets({ override: opts.claudeConfigDir, scope, cwd }) : [];
3182
+ const codexHomes = surfaces.includes("codex") ? resolveCodexHomes({ override: opts.codexHome, scope }) : [];
2830
3183
  const names = surfaces.map((s) => HOOK_SURFACE_LABEL[s]).join(" + ");
2831
3184
  process.stderr.write(
2832
3185
  `
@@ -2837,15 +3190,28 @@ auto-resumes where you left off and checkpoints working state before compacting.
2837
3190
  const install = opts.yes ? true : canPrompt() ? await promptYesNo(`Install the continuity hooks for ${names}?`) : false;
2838
3191
  if (!install) return;
2839
3192
  try {
2840
- const installed = installHookSurfaces(surfaces, { dryRun: false, cwd, home: homedir4() });
2841
3193
  let changed = false;
2842
- for (const { surface, results } of installed) {
3194
+ const emit2 = (surface, results, label) => {
2843
3195
  for (const r of results) {
2844
3196
  if (r.status !== "current") changed = true;
2845
3197
  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})
3198
+ const tag = label ? ` ${style.dim(`(${label})`)}` : "";
3199
+ process.stderr.write(`${style.green("\u2713")} ${HOOK_SURFACE_LABEL[surface]}${tag}: ${r.path} (${verb})
2847
3200
  `);
2848
3201
  }
3202
+ };
3203
+ const multiClaude = claudeTargets.length > 1;
3204
+ for (const t of claudeTargets) {
3205
+ const results = installHookSurfaces(["claude"], { dryRun: false, claudeDir: t.dir, codexHome: "" })[0].results;
3206
+ emit2("claude", results, multiClaude ? t.label : void 0);
3207
+ }
3208
+ if (surfaces.includes("codex") && codexHomes.length === 0) {
3209
+ process.stderr.write(`${style.dim("Codex has no project scope \u2014 skipped (use --scope global for Codex).")}
3210
+ `);
3211
+ }
3212
+ for (const codexHome of codexHomes) {
3213
+ const results = installHookSurfaces(["codex"], { dryRun: false, claudeDir: "", codexHome })[0].results;
3214
+ emit2("codex", results);
2849
3215
  }
2850
3216
  if (changed) {
2851
3217
  process.stderr.write(`${style.dim("Restart (or reload) your agent for the hooks to take effect.")}
@@ -2859,9 +3225,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
2859
3225
  }
2860
3226
 
2861
3227
  // 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";
3228
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "fs";
3229
+ import { join as join8 } from "path";
2865
3230
 
2866
3231
  // src/setup/lane-pin.ts
2867
3232
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -2943,57 +3308,173 @@ I can pin this checkout's lane so operator skills + the continuity hook resolve
2943
3308
  writePin(code || void 0, design || void 0);
2944
3309
  }
2945
3310
 
2946
- // src/setup/skills-offer.ts
2947
- var ROLE_TAG = "sechroom:role:skill-template";
3311
+ // src/setup/skill-resolution.ts
3312
+ var SYSTEM_WORKSPACE_ID = "wsp_system";
3313
+ var SKILL_ROLE_TAG = "sechroom:role:skill-template";
3314
+ var SKILL_NAME_PREFIX = "skill:";
3315
+ var AGENT_ROLE_TAG = "sechroom:role:agent-template";
3316
+ var AGENT_NAME_PREFIX = "agent:";
3317
+ function tagsOf(row) {
3318
+ const m = row?.item ?? row;
3319
+ return m?.tags ?? m?.Tags ?? [];
3320
+ }
2948
3321
  function tagValue(tags, prefix) {
2949
3322
  return tags.find((t) => t.startsWith(prefix))?.slice(prefix.length);
2950
3323
  }
2951
- async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
2952
- if (!personalWorkspaceId || opts.dryRun) return;
2953
- const surface = opts.surface ?? "claude-code";
2954
- let rows = [];
3324
+ function bodyOf(row) {
3325
+ const m = row?.item ?? row;
3326
+ return m?.text ?? m?.Text ?? "";
3327
+ }
3328
+ function entriesFromRows(rows, surface, source, roleTag, namePrefix) {
3329
+ const out = /* @__PURE__ */ new Map();
3330
+ for (const row of rows ?? []) {
3331
+ const tags = tagsOf(row);
3332
+ if (!tags.includes(roleTag)) continue;
3333
+ if (tagValue(tags, "target:") !== surface) continue;
3334
+ const name = tagValue(tags, namePrefix);
3335
+ if (!name) continue;
3336
+ out.set(name, { name, body: bodyOf(row), source });
3337
+ }
3338
+ return out;
3339
+ }
3340
+ function resolveByRole(systemRows, personalRows, surface, roleTag, namePrefix) {
3341
+ const merged = entriesFromRows(systemRows, surface, "system", roleTag, namePrefix);
3342
+ for (const [name, item] of entriesFromRows(personalRows, surface, "personal", roleTag, namePrefix)) {
3343
+ merged.set(name, item);
3344
+ }
3345
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
3346
+ }
3347
+ function resolveSkills(systemRows, personalRows, surface) {
3348
+ return resolveByRole(systemRows, personalRows, surface, SKILL_ROLE_TAG, SKILL_NAME_PREFIX);
3349
+ }
3350
+ function resolveAgents(systemRows, personalRows, surface) {
3351
+ return resolveByRole(systemRows, personalRows, surface, AGENT_ROLE_TAG, AGENT_NAME_PREFIX);
3352
+ }
3353
+
3354
+ // src/setup/skill-resolution-io.ts
3355
+ var AGENT_TARGET = { "claude-code": "claude-agent" };
3356
+ function agentTargetFor(surface) {
3357
+ return AGENT_TARGET[surface] ?? `${surface}-agent`;
3358
+ }
3359
+ async function fetchFeedRows(cfg, workspaceId) {
2955
3360
  try {
2956
3361
  const client = await makeClient(cfg);
2957
3362
  const feed = await client.GET("/workspaces/{workspaceId}/memories/feed", {
2958
3363
  params: {
2959
- path: { workspaceId: personalWorkspaceId },
3364
+ path: { workspaceId },
3365
+ // cascadeWorkspaces: skills land in an "Operator Skills" SUB-workspace;
3366
+ // includeText: the feed omits bodies by default, we need them for SKILL.md.
2960
3367
  query: { limit: 200, cascadeWorkspaces: true, includeText: true }
2961
3368
  }
2962
3369
  }).then((r) => r.data).catch(() => void 0);
2963
- rows = feed?.results ?? feed?.Results ?? [];
3370
+ return feed?.results ?? feed?.Results ?? [];
3371
+ } catch {
3372
+ return [];
3373
+ }
3374
+ }
3375
+ async function fetchTemplateRows(cfg, personalWorkspaceId) {
3376
+ const [systemRows, personalRows] = await Promise.all([
3377
+ fetchFeedRows(cfg, SYSTEM_WORKSPACE_ID),
3378
+ personalWorkspaceId ? fetchFeedRows(cfg, personalWorkspaceId) : Promise.resolve([])
3379
+ ]);
3380
+ return { systemRows, personalRows };
3381
+ }
3382
+ function resolveSkillSet(rows, surface) {
3383
+ return resolveSkills(rows.systemRows, rows.personalRows, surface);
3384
+ }
3385
+ function resolveAgentSet(rows, surface) {
3386
+ return resolveAgents(rows.systemRows, rows.personalRows, agentTargetFor(surface));
3387
+ }
3388
+
3389
+ // src/setup/skills-lock.ts
3390
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
3391
+ import { join as join7 } from "path";
3392
+ var SKILLS_LOCK = ".sechroom-skills.json";
3393
+ var DEFAULT_SKILLS_SLUG = "operator-skills";
3394
+ function skillsDir(configDir) {
3395
+ return join7(configDir, "skills");
3396
+ }
3397
+ function agentsDir(configDir) {
3398
+ return join7(configDir, "agents");
3399
+ }
3400
+ function readSkillsLock(dir) {
3401
+ const lockPath = join7(dir, SKILLS_LOCK);
3402
+ if (!existsSync6(lockPath)) return {};
3403
+ try {
3404
+ return JSON.parse(readFileSync5(lockPath, "utf8"));
2964
3405
  } catch {
3406
+ return {};
3407
+ }
3408
+ }
3409
+ function writeSkillsLock(dir, lock) {
3410
+ mkdirSync6(dir, { recursive: true });
3411
+ writeFileSync6(join7(dir, SKILLS_LOCK), JSON.stringify(lock, null, 2) + "\n");
3412
+ }
3413
+ function recordMaterialisedSkills(dir, slug, skills, meta = {}) {
3414
+ const lock = readSkillsLock(dir);
3415
+ lock[slug] = { surface: meta.surface, skills: [...skills].sort() };
3416
+ writeSkillsLock(dir, lock);
3417
+ }
3418
+
3419
+ // src/setup/skills-offer.ts
3420
+ async function maybeOfferSkills(cfg, personalWorkspaceId, opts) {
3421
+ const surface = opts.surface ?? "claude-code";
3422
+ const configDir = opts.configDir ?? resolveClaudeTargets({})[0].dir;
3423
+ const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
3424
+ const skills = resolveSkillSet(rows, surface);
3425
+ const agents = resolveAgentSet(rows, surface);
3426
+ if (skills.length === 0 && agents.length === 0) return;
3427
+ const sDir = skillsDir(configDir);
3428
+ const aDir = agentsDir(configDir);
3429
+ if (opts.dryRun) {
3430
+ const lines = (label, items) => items.length === 0 ? "" : `
3431
+ Would materialise ${style.bold(String(items.length))} ${label} for ${surface}:
3432
+ ` + items.map((s) => ` ${s.name} ${style.dim(`[${s.source}]`)}`).join("\n") + "\n";
3433
+ process.stderr.write(lines("operator skill(s)", skills) + lines("agent(s)", agents));
2965
3434
  return;
2966
3435
  }
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;
3436
+ const summary = [
3437
+ skills.length > 0 ? `${style.bold(String(skills.length))} skill(s)` : "",
3438
+ agents.length > 0 ? `${style.bold(String(agents.length))} agent(s)` : ""
3439
+ ].filter(Boolean).join(" + ");
3440
+ process.stderr.write(`
3441
+ Found ${summary} available to you for ${surface}.
3442
+ `);
3443
+ if (skills.length > 0) process.stderr.write(` skills: ${skills.map((s) => s.name).join(", ")}
3444
+ `);
3445
+ if (agents.length > 0) process.stderr.write(` agents: ${agents.map((a) => a.name).join(", ")}
3446
+ `);
3447
+ const dest = [skills.length > 0 ? `${sDir}/` : "", agents.length > 0 ? `${aDir}/` : ""].filter(Boolean).join(" + ");
3448
+ const materialise = opts.yes ? true : canPrompt() ? await promptYesNo(`Write them to ${dest} so ${surface} can use them?`) : false;
2986
3449
  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);
3450
+ if (skills.length > 0) {
3451
+ const written = [];
3452
+ for (const s of skills) {
3453
+ mkdirSync7(join8(sDir, s.name), { recursive: true });
3454
+ writeFileSync7(join8(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
3455
+ written.push(s.name);
3456
+ }
3457
+ recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
3458
+ process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${sDir}
3459
+ `);
2993
3460
  }
2994
- process.stderr.write(`${style.green("\u2713")} wrote ${written.length} skill(s) to ${dir}
3461
+ if (agents.length > 0) {
3462
+ mkdirSync7(aDir, { recursive: true });
3463
+ const written = [];
3464
+ for (const a of agents) {
3465
+ const file = `${a.name}.md`;
3466
+ writeFileSync7(join8(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
3467
+ written.push(file);
3468
+ }
3469
+ recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
3470
+ process.stderr.write(`${style.green("\u2713")} wrote ${written.length} agent(s) to ${aDir}
2995
3471
  `);
2996
- await ensureLanePin(cfg, { yes: opts.yes, dryRun: opts.dryRun, clients: [surface] });
3472
+ }
3473
+ await ensureLanePin(cfg, {
3474
+ yes: opts.yes,
3475
+ dryRun: opts.dryRun,
3476
+ clients: [surface]
3477
+ });
2997
3478
  }
2998
3479
 
2999
3480
  // src/commands/setup.ts
@@ -3031,14 +3512,14 @@ version, the shared template stays clean, and you can discard back anytime.
3031
3512
  }
3032
3513
  function resolveClientKeys(raw) {
3033
3514
  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) {
3515
+ const tokens = (Array.isArray(raw) ? raw : [raw]).flatMap((t) => t.split(",")).map((k) => k.trim()).filter(Boolean);
3516
+ if (tokens.includes("all")) return [...ALL_CLIENT_KEYS];
3517
+ for (const k of tokens) {
3037
3518
  if (!targets[k]) {
3038
3519
  fail(`unknown client '${k}'. Known: ${ALL_CLIENT_KEYS.join(", ")}, or 'all'.`);
3039
3520
  }
3040
3521
  }
3041
- return keys;
3522
+ return [...new Set(tokens)];
3042
3523
  }
3043
3524
  function printActions(client, actions) {
3044
3525
  process.stdout.write(`
@@ -3050,24 +3531,96 @@ ${client.label} (${client.key}):
3050
3531
  `);
3051
3532
  }
3052
3533
  }
3534
+ function resolveEvalMode(opts) {
3535
+ return opts.check ? "check" : opts.force ? "force" : "apply";
3536
+ }
3537
+ function summarizeEval(result, mode, json, dryRun) {
3538
+ const counts = { current: 0, stale: 0, drift: 0, absent: 0 };
3539
+ for (const { actions } of result) for (const a of actions) if (a.eval) counts[a.eval]++;
3540
+ const wouldChange = counts.stale + counts.drift + counts.absent;
3541
+ if (mode === "check") {
3542
+ if (!json) {
3543
+ if (wouldChange === 0) {
3544
+ process.stdout.write("\u2713 all instruction blocks are up to date.\n");
3545
+ } else {
3546
+ const bits = [];
3547
+ if (counts.stale) bits.push(`${counts.stale} out of date`);
3548
+ if (counts.drift) bits.push(`${counts.drift} with local edits`);
3549
+ if (counts.absent) bits.push(`${counts.absent} not yet written`);
3550
+ process.stderr.write(
3551
+ `\u26A0 ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Re-run with ${style.cyan("--refresh")}.
3552
+ `
3553
+ );
3554
+ }
3555
+ }
3556
+ process.exit(wouldChange === 0 ? 0 : 1);
3557
+ }
3558
+ if (json) return;
3559
+ if (!dryRun && counts.stale) {
3560
+ process.stderr.write(`\u21BB refreshed ${counts.stale} section(s) the server had moved
3561
+ `);
3562
+ }
3563
+ if (!dryRun && counts.drift) {
3564
+ process.stderr.write(
3565
+ mode === "force" ? `\u26A0 overwrote ${counts.drift} section(s) that had local edits (--force)
3566
+ ` : `\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")}.
3567
+ `
3568
+ );
3569
+ }
3570
+ }
3571
+ var GLOBAL_NAMESPACE = "__global__";
3572
+ async function resolveNamespaceChoice(cfg, flag) {
3573
+ if (flag) return flag;
3574
+ if (!canPrompt()) return null;
3575
+ const namespaces = await listNamespaces(cfg);
3576
+ if (namespaces.length === 0) return null;
3577
+ const picked = await promptSelect(
3578
+ "Which namespace should this connection use?",
3579
+ [
3580
+ { label: "Global (whole tenant)", value: GLOBAL_NAMESPACE },
3581
+ ...namespaces.map((n) => ({
3582
+ label: n.displayName,
3583
+ value: n.slug,
3584
+ hint: n.slug
3585
+ }))
3586
+ ],
3587
+ GLOBAL_NAMESPACE
3588
+ );
3589
+ return picked === GLOBAL_NAMESPACE ? null : picked;
3590
+ }
3053
3591
  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(
3592
+ 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
3593
  "after",
3056
3594
  `
3057
3595
  Examples:
3058
3596
  $ sechroom init Claude Code (default): ./.mcp.json + ./CLAUDE.md
3059
3597
  $ sechroom init --client all claude-code, claude-desktop, codex, cursor
3060
- $ sechroom init --client codex,cursor
3598
+ $ sechroom init --client codex cursor space-separated (comma also works)
3061
3599
  $ sechroom init --mcp-only just the MCP config (skip agent files)
3062
3600
  $ sechroom init --dry-run --json preview the writes, change nothing`
3063
3601
  ).action(async (opts, cmd) => {
3064
3602
  const cfg = resolveConfig(cmd.optsWithGlobals());
3065
- const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3066
- const targets = clientTargets(process.cwd());
3603
+ const mode = resolveEvalMode(opts);
3604
+ const check = mode === "check";
3605
+ const namespaceSlug = await resolveNamespaceChoice(cfg, opts.namespace);
3606
+ const setup = await withSpinner(
3607
+ "Fetching setup descriptors",
3608
+ () => fetchSetup(cfg, namespaceSlug ?? void 0)
3609
+ );
3610
+ const g = cmd.optsWithGlobals();
3611
+ let scope;
3612
+ try {
3613
+ scope = resolveScope(opts.scope);
3614
+ } catch (err2) {
3615
+ return fail(err2.message);
3616
+ }
3617
+ const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
3618
+ const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
3619
+ const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
3067
3620
  const keys = resolveClientKeys(opts.client);
3068
- const json = cmd.optsWithGlobals().json;
3621
+ const json = g.json;
3069
3622
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3070
- if (!opts.dryRun && !opts.mcpOnly) {
3623
+ if (!opts.dryRun && !opts.mcpOnly && !check) {
3071
3624
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
3072
3625
  }
3073
3626
  const result = [];
@@ -3077,16 +3630,20 @@ Examples:
3077
3630
  dryRun: Boolean(opts.dryRun),
3078
3631
  mcp: !opts.agentFilesOnly,
3079
3632
  agentFiles: !opts.mcpOnly,
3080
- personalWorkspaceId
3633
+ personalWorkspaceId,
3634
+ mode
3081
3635
  });
3082
3636
  result.push({ client: key, actions });
3083
- if (!json) printActions(target, actions);
3637
+ if (!json && !check) printActions(target, actions);
3084
3638
  }
3639
+ summarizeEval(result, mode, Boolean(json), Boolean(opts.dryRun));
3085
3640
  if (!json && !opts.dryRun && !opts.mcpOnly) {
3086
- await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code" });
3641
+ for (const t of claudeTargets) {
3642
+ await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code", configDir: t.dir });
3643
+ }
3087
3644
  }
3088
3645
  if (!json && !opts.dryRun && !opts.mcpOnly) {
3089
- await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd() });
3646
+ await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3090
3647
  }
3091
3648
  if (json) {
3092
3649
  emit({ dryRun: Boolean(opts.dryRun), clients: result }, true);
@@ -3107,23 +3664,99 @@ Next \u2014 verify: ${verify.description}
3107
3664
  }
3108
3665
  function registerSetup(program2) {
3109
3666
  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 });
3667
+ 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) => {
3668
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false, namespace: opts.namespace });
3112
3669
  });
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 });
3670
+ 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) => {
3671
+ await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy, mode: resolveEvalMode(opts) });
3672
+ });
3673
+ 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(
3674
+ "after",
3675
+ `
3676
+ The memo carries the two conventions a workspace-conventions section needs (FR-sechroom-236):
3677
+ the \`agent-setup-bundle\` tag + the \`# Header\` as the FIRST body line. It's authored in the
3678
+ BOUND workspace (so the regen, which sources conventions from there, picks it up). Edit it later
3679
+ in the app or via \`sechroom memory edit-text\`.
3680
+
3681
+ Examples:
3682
+ $ sechroom setup new-convention "Deploy runbook"
3683
+ $ sechroom setup new-convention "Backend testing" --kind standard --body "- run dotnet test ..."
3684
+ $ sechroom setup new-convention "Draft section" --no-regen author only, regen later`
3685
+ ).action(async (titleParts, opts, cmd) => {
3686
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3687
+ const json = Boolean(cmd.optsWithGlobals().json);
3688
+ const title = titleParts.join(" ").trim();
3689
+ if (!title) fail('a section title is required, e.g. `sechroom setup new-convention "Deploy runbook"`.');
3690
+ const workspaceId = opts.workspace ?? cfg.workspaceId;
3691
+ if (!workspaceId)
3692
+ fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
3693
+ const kind = String(opts.kind).toLowerCase() === "standard" ? "standard" : "reference";
3694
+ 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._";
3695
+ const text = `# ${title}
3696
+
3697
+ ${body}
3698
+ `;
3699
+ const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
3700
+ if (opts.dryRun) {
3701
+ emit({ dryRun: true, workspaceId, title, kind, tags, text }, json);
3702
+ return;
3703
+ }
3704
+ const data = await runApi("Authoring convention memo", async () => {
3705
+ const client = await makeClient(cfg);
3706
+ return client.POST("/memories", {
3707
+ body: {
3708
+ text,
3709
+ type: kind,
3710
+ content: "{}",
3711
+ confidence: 1,
3712
+ source: "cli-new-convention",
3713
+ archetype: "Document",
3714
+ title,
3715
+ tags,
3716
+ owner: { type: "Workspace", id: workspaceId }
3717
+ }
3718
+ });
3719
+ });
3720
+ if (!json) {
3721
+ const view = resolveViewUrl(cfg.baseUrl, data.url);
3722
+ process.stdout.write(
3723
+ `\u2713 authored convention ${style.bold(data.id)} ${style.dim(`"${title}"`)}${view ? ` ${style.dim("\u2192")} ${view}` : ""}
3724
+ `
3725
+ );
3726
+ }
3727
+ if (opts.regen === false) {
3728
+ if (json) emit({ id: data.id, workspaceId, regen: false }, true);
3729
+ else process.stdout.write("Skipped regen (--no-regen). Run `sechroom setup agent-files all` to apply.\n");
3730
+ return;
3731
+ }
3732
+ await runClients(["claude-code", "codex"], cmd, {
3733
+ dryRun: false,
3734
+ mcp: false,
3735
+ agentFiles: true,
3736
+ copy: false,
3737
+ mode: "apply"
3738
+ });
3115
3739
  });
3116
3740
  }
3117
3741
  async function runClients(clients, cmd, opts) {
3118
- const cfg = resolveConfig(cmd.optsWithGlobals());
3119
- const targets = clientTargets(process.cwd());
3742
+ const g = cmd.optsWithGlobals();
3743
+ const cfg = resolveConfig(g);
3744
+ const mode = opts.mode ?? "apply";
3745
+ const check = mode === "check";
3746
+ const claudeDir = resolveClaudeTargets({ override: g.claudeConfigDir })[0]?.dir;
3747
+ const codexHome = resolveCodexHomes({ override: g.codexHome })[0];
3748
+ const targets = clientTargets(process.cwd(), { claudeDir, codexHome });
3120
3749
  const keys = resolveClientKeys(clients.join(","));
3121
- const setupData = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3750
+ const namespaceSlug = opts.mcp ? await resolveNamespaceChoice(cfg, opts.namespace) : null;
3751
+ const setupData = await withSpinner(
3752
+ "Fetching setup descriptors",
3753
+ () => fetchSetup(cfg, namespaceSlug ?? void 0)
3754
+ );
3122
3755
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3123
- if (opts.agentFiles && !opts.dryRun) {
3756
+ if (opts.agentFiles && !opts.dryRun && !check) {
3124
3757
  await maybeOfferCopies(cfg, setupData, targets, keys, personalWorkspaceId, copyChoice(opts));
3125
3758
  }
3126
- const json = cmd.optsWithGlobals().json;
3759
+ const json = g.json;
3127
3760
  const result = [];
3128
3761
  for (const key of keys) {
3129
3762
  const target = targets[key];
@@ -3131,11 +3764,13 @@ async function runClients(clients, cmd, opts) {
3131
3764
  dryRun: opts.dryRun,
3132
3765
  mcp: opts.mcp,
3133
3766
  agentFiles: opts.agentFiles,
3134
- personalWorkspaceId
3767
+ personalWorkspaceId,
3768
+ mode
3135
3769
  });
3136
3770
  result.push({ client: key, actions });
3137
- if (!json) printActions(target, actions);
3771
+ if (!json && !check) printActions(target, actions);
3138
3772
  }
3773
+ summarizeEval(result, mode, Boolean(json), opts.dryRun);
3139
3774
  if (json) {
3140
3775
  emit({ dryRun: opts.dryRun, clients: result }, true);
3141
3776
  return;
@@ -3143,14 +3778,81 @@ async function runClients(clients, cmd, opts) {
3143
3778
  process.stdout.write(opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : "\nDone.\n");
3144
3779
  }
3145
3780
 
3781
+ // src/commands/namespace.ts
3782
+ function registerNamespace(program2) {
3783
+ const namespace = program2.command("namespace").description("Browse, inspect, and wire up MCP namespaces");
3784
+ namespace.addHelpText(
3785
+ "after",
3786
+ `
3787
+ Examples:
3788
+ $ sechroom namespace list
3789
+ $ sechroom namespace show eng
3790
+ $ sechroom namespace use eng wire Claude Code to the 'eng' namespace
3791
+ $ sechroom namespace use eng --client all`
3792
+ );
3793
+ namespace.command("list").description("List the namespaces you can reach (GET /mcp-aggregator/namespaces)").action(async (_opts, cmd) => {
3794
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3795
+ const data = await runApi("Listing namespaces", async () => {
3796
+ const client = await makeClient(cfg);
3797
+ return client.GET("/mcp-aggregator/namespaces", {});
3798
+ });
3799
+ emit(data, cmd.optsWithGlobals().json);
3800
+ });
3801
+ 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) => {
3802
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3803
+ const data = await runApi("Fetching namespace", async () => {
3804
+ const client = await makeClient(cfg);
3805
+ return client.GET("/mcp-aggregator/namespaces/{slug}", {
3806
+ params: { path: { slug } }
3807
+ });
3808
+ });
3809
+ emit(data, cmd.optsWithGlobals().json);
3810
+ });
3811
+ namespace.command("use <slug>").description("Wire an AI client's MCP config to this namespace's URL").option(
3812
+ "--client <list>",
3813
+ `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
3814
+ DEFAULT_CLIENT_KEY
3815
+ ).option("--dry-run", "print what would be written without writing", false).action(async (slug, opts, cmd) => {
3816
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3817
+ const setup = await withSpinner(
3818
+ "Fetching setup descriptors",
3819
+ () => fetchSetup(cfg, slug)
3820
+ );
3821
+ const targets = clientTargets(process.cwd());
3822
+ const keys = resolveClientKeys(opts.client);
3823
+ const json = cmd.optsWithGlobals().json;
3824
+ const result = [];
3825
+ for (const key of keys) {
3826
+ const target = targets[key];
3827
+ const actions = await applyClient(cfg, setup, target, {
3828
+ dryRun: Boolean(opts.dryRun),
3829
+ mcp: true,
3830
+ agentFiles: false,
3831
+ personalWorkspaceId: null
3832
+ });
3833
+ result.push({ client: key, actions });
3834
+ if (!json) printActions(target, actions);
3835
+ }
3836
+ if (json) {
3837
+ emit({ namespace: slug, dryRun: Boolean(opts.dryRun), clients: result }, true);
3838
+ return;
3839
+ }
3840
+ process.stdout.write(
3841
+ opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
3842
+ Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it up.
3843
+ `
3844
+ );
3845
+ });
3846
+ }
3847
+
3146
3848
  // src/commands/onboard.ts
3147
- import { existsSync as existsSync7 } from "fs";
3148
- import { join as join7 } from "path";
3849
+ import { existsSync as existsSync8 } from "fs";
3850
+ import { basename as basename3, join as join10 } from "path";
3149
3851
 
3150
3852
  // src/commands/fanout.ts
3151
3853
  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";
3854
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
3855
+ import { isAbsolute, join as join9, resolve } from "path";
3154
3856
  var ICON = {
3155
3857
  refresh: "\u21BB",
3156
3858
  bind: "+",
@@ -3163,28 +3865,28 @@ function resolveChildDir(path, root) {
3163
3865
  function discoverChildren(root) {
3164
3866
  let names;
3165
3867
  try {
3166
- names = readdirSync(root);
3868
+ names = readdirSync2(root);
3167
3869
  } catch {
3168
3870
  return [];
3169
3871
  }
3170
3872
  const out = [];
3171
3873
  for (const name of names.sort()) {
3172
3874
  if (name.startsWith(".") || name === "node_modules") continue;
3173
- const dir = join6(root, name);
3875
+ const dir = join9(root, name);
3174
3876
  try {
3175
- if (!statSync(dir).isDirectory()) continue;
3877
+ if (!statSync3(dir).isDirectory()) continue;
3176
3878
  } catch {
3177
3879
  continue;
3178
3880
  }
3179
- if (existsSync6(join6(dir, ".git")) || committedBindingPath(dir)) out.push(name);
3881
+ if (existsSync7(join9(dir, ".git")) || committedBindingPath(dir)) out.push(name);
3180
3882
  }
3181
3883
  return out;
3182
3884
  }
3183
3885
  function readManifest(path) {
3184
- if (!existsSync6(path)) return null;
3886
+ if (!existsSync7(path)) return null;
3185
3887
  let parsed;
3186
3888
  try {
3187
- parsed = JSON.parse(readFileSync5(path, "utf8"));
3889
+ parsed = JSON.parse(readFileSync6(path, "utf8"));
3188
3890
  } catch (err2) {
3189
3891
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3190
3892
  }
@@ -3322,7 +4024,39 @@ async function warnIfProjectStray(client, projectId, workspaceId, json) {
3322
4024
  );
3323
4025
  }
3324
4026
  }
3325
- async function pickWorkspace(client, promptLabel = "Bind this directory to a workspace:") {
4027
+ async function fetchPersonalWorkspaceId(client) {
4028
+ try {
4029
+ const { data } = await client.GET("/me/personal-workspace", {});
4030
+ return data?.workspaceId ?? null;
4031
+ } catch {
4032
+ return null;
4033
+ }
4034
+ }
4035
+ function nameTokens(s) {
4036
+ return s.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2);
4037
+ }
4038
+ function personalSubtreeIds(personalId, all) {
4039
+ const childrenOf = /* @__PURE__ */ new Map();
4040
+ for (const w of all) {
4041
+ if (!w.parentId) continue;
4042
+ (childrenOf.get(w.parentId) ?? childrenOf.set(w.parentId, []).get(w.parentId)).push(w);
4043
+ }
4044
+ const ids = /* @__PURE__ */ new Set([personalId]);
4045
+ const queue = [personalId];
4046
+ while (queue.length > 0) {
4047
+ const id = queue.shift();
4048
+ for (const child of childrenOf.get(id) ?? []) {
4049
+ if (!ids.has(child.id)) {
4050
+ ids.add(child.id);
4051
+ queue.push(child.id);
4052
+ }
4053
+ }
4054
+ }
4055
+ return ids;
4056
+ }
4057
+ async function pickWorkspace(client, opts = {}) {
4058
+ const promptLabel = opts.promptLabel ?? "Bind this directory to a workspace:";
4059
+ const dirName = opts.dirName ?? basename3(process.cwd());
3326
4060
  const all = await withSpinner("Listing your workspaces", () => fetchWorkspaces(client));
3327
4061
  if (all.length === 0) {
3328
4062
  process.stderr.write(`no workspaces found \u2014 skipping workspace binding (you can set it later with \`sechroom config set --local workspaceId <id>\`)
@@ -3330,22 +4064,34 @@ async function pickWorkspace(client, promptLabel = "Bind this directory to a wor
3330
4064
  return void 0;
3331
4065
  }
3332
4066
  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();
4067
+ const personalId = await fetchPersonalWorkspaceId(client);
4068
+ const excluded = personalId ? personalSubtreeIds(personalId, all) : /* @__PURE__ */ new Set();
4069
+ let candidates = all.filter((w) => !excluded.has(w.id));
4070
+ if (candidates.length === 0) candidates = all;
4071
+ const dirToks = new Set(nameTokens(dirName));
4072
+ const isMatch = (w) => nameTokens(w.name).some((t) => dirToks.has(t));
4073
+ const suggestions = candidates.filter(isMatch);
4074
+ let pool = candidates;
4075
+ if (candidates.length > 12 && suggestions.length === 0) {
4076
+ const q = (await promptText(`Filter ${candidates.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
3336
4077
  if (q) {
3337
- const hits = all.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
4078
+ const hits = candidates.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
3338
4079
  if (hits.length > 0) pool = hits;
3339
4080
  else process.stderr.write(`no match for "${q}" \u2014 listing all
3340
4081
  `);
3341
4082
  }
3342
4083
  }
3343
4084
  const SKIP = "__skip__";
4085
+ const byPath = (a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId));
4086
+ const matched = pool.filter(isMatch).sort(byPath);
4087
+ const rest = pool.filter((w) => !isMatch(w)).sort(byPath);
3344
4088
  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 })),
4089
+ ...matched.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: style.dim(`matches "${dirName}"`) })),
4090
+ ...rest.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
3346
4091
  { label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
3347
4092
  ];
3348
- const chosen = await promptSelect(promptLabel, choices, SKIP);
4093
+ const defaultValue = matched.length === 1 ? matched[0].id : SKIP;
4094
+ const chosen = await promptSelect(promptLabel, choices, defaultValue);
3349
4095
  if (chosen === SKIP) return void 0;
3350
4096
  const picked = byId.get(chosen);
3351
4097
  const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
@@ -3372,7 +4118,7 @@ async function resolveWorkspaceBinding(client, existing, opts) {
3372
4118
  }
3373
4119
  if (existing) return existing;
3374
4120
  if (!canPrompt() || opts.yes) return void 0;
3375
- return pickWorkspace(client);
4121
+ return pickWorkspace(client, { dirName: basename3(process.cwd()) });
3376
4122
  }
3377
4123
  async function ensureTenant(baseUrl, g, opts) {
3378
4124
  const persisted = readPersisted();
@@ -3488,7 +4234,7 @@ async function ensureTimezone(cfg, opts) {
3488
4234
  return { timezone: tz, action: "set" };
3489
4235
  }
3490
4236
  async function chooseClients(clientFlag, yes, cwd) {
3491
- if (clientFlag) return resolveClientKeys(clientFlag);
4237
+ if (clientFlag && clientFlag.length > 0) return resolveClientKeys(clientFlag);
3492
4238
  const detected = detectInstalledClients(cwd);
3493
4239
  const preselected = detected.length > 0 ? detected : [DEFAULT_CLIENT_KEY];
3494
4240
  if (!canPrompt() || yes) return preselected;
@@ -3503,12 +4249,24 @@ async function chooseClients(clientFlag, yes, cwd) {
3503
4249
  );
3504
4250
  return picks.length > 0 ? picks : preselected;
3505
4251
  }
4252
+ async function chooseScope(scopeFlag, yes) {
4253
+ if (scopeFlag != null) return resolveScope(scopeFlag);
4254
+ if (!canPrompt() || yes) return "global";
4255
+ return promptSelect(
4256
+ "Install skills, agents, and hooks globally or just for this project?",
4257
+ [
4258
+ { label: "Globally", value: "global", hint: "~/.claude (or CLAUDE_CONFIG_DIR) \u2014 all projects" },
4259
+ { label: "This project", value: "project", hint: "<repo>/.claude" }
4260
+ ],
4261
+ "global"
4262
+ );
4263
+ }
3506
4264
  async function planRecurseChild(entry, root, client, opts) {
3507
4265
  const dir = resolveChildDir(entry.path, root);
3508
- if (!existsSync7(dir)) {
4266
+ if (!existsSync8(dir)) {
3509
4267
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3510
4268
  }
3511
- if (existsSync7(join7(dir, ".sechroom.json"))) {
4269
+ if (existsSync8(join10(dir, ".sechroom.json"))) {
3512
4270
  return {
3513
4271
  label: entry.path,
3514
4272
  dir,
@@ -3535,7 +4293,10 @@ async function planRecurseChild(entry, root, client, opts) {
3535
4293
  process.stderr.write(`
3536
4294
  ${style.bold(entry.path)} ${style.dim("is not bound yet.")}
3537
4295
  `);
3538
- const ws = await pickWorkspace(client, `Bind ${style.cyan(entry.path)} to a workspace:`);
4296
+ const ws = await pickWorkspace(client, {
4297
+ promptLabel: `Bind ${style.cyan(entry.path)} to a workspace:`,
4298
+ dirName: basename3(entry.path)
4299
+ });
3539
4300
  if (!ws) {
3540
4301
  return { label: entry.path, dir, disposition: "skip-unbound", argv: [], reason: "unbound \u2014 no workspace chosen (skipped)" };
3541
4302
  }
@@ -3578,7 +4339,7 @@ This fan-out will pin the same lane in every repo:
3578
4339
  async function runRecurse(cfg, g, opts) {
3579
4340
  const { yes, dryRun, json } = opts;
3580
4341
  const root = process.cwd();
3581
- const manifestPath = join7(root, ".sechroom", "repos.json");
4342
+ const manifestPath = join10(root, ".sechroom", "repos.json");
3582
4343
  const fromManifest = readManifest(manifestPath);
3583
4344
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
3584
4345
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -3606,7 +4367,7 @@ async function runRecurse(cfg, g, opts) {
3606
4367
  summarizeFanout(results, { dryRun });
3607
4368
  }
3608
4369
  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(
4370
+ 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
4371
  "after",
3611
4372
  `
3612
4373
  Examples:
@@ -3649,6 +4410,13 @@ Examples:
3649
4410
  process.stderr.write(line);
3650
4411
  }
3651
4412
  const wire = await chooseWire(opts, yes);
4413
+ const scope = await chooseScope(opts.scope, yes);
4414
+ const claudeTargets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4415
+ const codexHomes = resolveCodexHomes({ override: g.codexHome, scope });
4416
+ if (scope === "project" && g.claudeConfigDir && !json) {
4417
+ process.stderr.write(`${style.dim("(--claude-config-dir is ignored for --scope project \u2014 project files are repo-relative)")}
4418
+ `);
4419
+ }
3652
4420
  if (wire === "cli-only") {
3653
4421
  if (json) {
3654
4422
  emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, clients: [] }, true);
@@ -3656,7 +4424,7 @@ Examples:
3656
4424
  }
3657
4425
  if (!dryRun) {
3658
4426
  await ensureLanePin(cfg, { yes, dryRun, clients: detectInstalledClients(process.cwd()) });
3659
- await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
4427
+ await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3660
4428
  }
3661
4429
  process.stdout.write(
3662
4430
  `
@@ -3669,7 +4437,7 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
3669
4437
  }
3670
4438
  const keys = await chooseClients(opts.client, yes, process.cwd());
3671
4439
  const setup = await withSpinner("Fetching setup descriptors", () => fetchSetup(cfg));
3672
- const targets = clientTargets(process.cwd());
4440
+ const targets = clientTargets(process.cwd(), { claudeDir: claudeTargets[0]?.dir, codexHome: codexHomes[0] });
3673
4441
  const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
3674
4442
  if (!dryRun && !check) {
3675
4443
  await maybeOfferCopies(cfg, setup, targets, keys, personalWorkspaceId, copyChoice(opts));
@@ -3713,10 +4481,12 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
3713
4481
  await ensureLanePin(cfg, { yes, dryRun, clients: keys });
3714
4482
  }
3715
4483
  if (!json && !dryRun) {
3716
- await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code" });
4484
+ for (const t of claudeTargets) {
4485
+ await maybeOfferSkills(cfg, personalWorkspaceId, { yes, dryRun, surface: "claude-code", configDir: t.dir });
4486
+ }
3717
4487
  }
3718
4488
  if (!json && !dryRun) {
3719
- await maybeOfferHooks({ yes, dryRun, cwd: process.cwd() });
4489
+ await maybeOfferHooks({ yes, dryRun, cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
3720
4490
  }
3721
4491
  if (json) {
3722
4492
  emit({ dryRun, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, timezone: tz, wire, eval: evalCounts, clients: result }, true);
@@ -3762,14 +4532,21 @@ async function chooseWire(opts, yes) {
3762
4532
  return opts.mcp === false ? "agent-only" : "full";
3763
4533
  }
3764
4534
  var FALLBACK_AGENT_PROMPT = "Resume my sechroom continuity, summarise what I was last working on, then suggest the next step.";
4535
+ function printNextStepBlock(heading, lines) {
4536
+ const rule = style.dim("\u2500".repeat(52));
4537
+ process.stdout.write(
4538
+ `
4539
+ ${rule}
4540
+ ${style.bold(heading)}
4541
+
4542
+ ` + lines.map((l) => ` ${l}`).join("\n") + `
4543
+ ${rule}
4544
+ `
4545
+ );
4546
+ }
3765
4547
  async function printStarterPrompt(mode, cfg) {
3766
4548
  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
- );
4549
+ printNextStepBlock("Next \u2014 pick up where you left off:", [style.cyan("sechroom continuity resume-me")]);
3773
4550
  return;
3774
4551
  }
3775
4552
  let primary = FALLBACK_AGENT_PROMPT;
@@ -3781,21 +4558,16 @@ ${style.bold("Next:")} pick up where you left off \u2014
3781
4558
  } catch {
3782
4559
  }
3783
4560
  }
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
- );
4561
+ printNextStepBlock("Next \u2014 paste this into your AI agent to get going:", [style.cyan(`"${primary}"`)]);
3790
4562
  }
3791
4563
 
3792
4564
  // 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");
4565
+ import { existsSync as existsSync9 } from "fs";
4566
+ import { dirname as dirname7, join as join11, resolve as resolve2 } from "path";
4567
+ var DEFAULT_MANIFEST = join11(".sechroom", "repos.json");
3796
4568
  function planEntry(entry, root) {
3797
4569
  const dir = resolveChildDir(entry.path, root);
3798
- if (!existsSync8(dir)) {
4570
+ if (!existsSync9(dir)) {
3799
4571
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
3800
4572
  }
3801
4573
  if (committedBindingPath(dir)) {
@@ -3871,7 +4643,7 @@ Examples:
3871
4643
  `);
3872
4644
  return;
3873
4645
  }
3874
- const root = dirname6(dirname6(manifestPath));
4646
+ const root = dirname7(dirname7(manifestPath));
3875
4647
  const plans = repos.map((entry) => planEntry(entry, root));
3876
4648
  if (!json) {
3877
4649
  process.stderr.write(
@@ -3888,162 +4660,222 @@ Examples:
3888
4660
  });
3889
4661
  }
3890
4662
 
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(
4663
+ // src/commands/lane.ts
4664
+ var LANE_KEYS = ["code-lane", "design-lane"];
4665
+ function showLane(json) {
4666
+ const found = readSem();
4667
+ if (!found) {
4668
+ if (json) return emit({ path: null, values: {} }, true);
4669
+ return console.log(
4670
+ style.dim(`No ./.sechroom/lane.json pin in this checkout. Run 'sechroom lane set'.`)
4671
+ );
4672
+ }
4673
+ const resolved = { ...found.values };
4674
+ let suffixed = false;
4675
+ for (const k of LANE_KEYS) {
4676
+ const v = found.values[k];
4677
+ if (!v) continue;
4678
+ resolved[k] = applyWorktreeLaneSuffix(v);
4679
+ if (resolved[k] !== v) suffixed = true;
4680
+ }
4681
+ if (json) return emit({ path: found.path, values: resolved, worktreeSuffixApplied: suffixed }, true);
4682
+ console.log(style.dim(`from ${found.path}`));
4683
+ Object.entries(resolved).forEach(([k, v]) => console.log(" " + style.bold(k) + " = " + v));
4684
+ if (suffixed) console.log(style.dim(" (worktree -N suffix applied \u2014 non-primary git worktree)"));
4685
+ }
4686
+ function setLane(opts) {
4687
+ if (!opts.codeLane && !opts.designLane) fail("Provide --code-lane and/or --design-lane.");
4688
+ const target = localSemPath();
4689
+ const values = readLocalSemValues();
4690
+ if (opts.codeLane) values["code-lane"] = opts.codeLane;
4691
+ if (opts.designLane) values["design-lane"] = opts.designLane;
4692
+ writeSem(values, target);
4693
+ if (opts.json) return emit({ path: target, values }, true);
4694
+ console.log(style.green(`Wrote lane pin \u2192 ${target} ${style.dim("(git-ignored)")}`));
4695
+ Object.entries(values).forEach(([k, v]) => console.log(" " + style.dim(k) + " = " + v));
4696
+ }
4697
+ function registerLane(program2) {
4698
+ 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)));
4699
+ 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(
4700
+ (opts, cmd) => setLane({
4701
+ codeLane: opts.codeLane,
4702
+ designLane: opts.designLane,
4703
+ json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
4704
+ })
4705
+ );
4706
+ lane.addHelpText(
3910
4707
  "after",
3911
4708
  `
3912
4709
  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`
4710
+ $ sechroom lane show the resolved lane(s)
4711
+ $ sechroom lane set --code-lane claude-code-chris --design-lane claude-design-chris
4712
+
4713
+ In a non-primary git worktree the ratified concurrent-session -N suffix is auto-applied (SBC-1094).
4714
+ (Aliases: 'sechroom skills lane' / 'skills set-lane' \u2014 kept for back-compat.)`
3919
4715
  );
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
- })
4716
+ }
4717
+
4718
+ // src/setup/materialise.ts
4719
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, rmSync as rmSync2, writeFileSync as writeFileSync8 } from "fs";
4720
+ import { join as join12 } from "path";
4721
+ var CLIENT_SURFACE = "claude-code";
4722
+ function writeSkills(dir, skills, surface) {
4723
+ const written = [];
4724
+ for (const s of skills) {
4725
+ mkdirSync8(join12(dir, s.name), { recursive: true });
4726
+ writeFileSync8(join12(dir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
4727
+ written.push(s.name);
4728
+ }
4729
+ if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
4730
+ return written;
4731
+ }
4732
+ function writeAgents(dir, agents, surface) {
4733
+ if (agents.length) mkdirSync8(dir, { recursive: true });
4734
+ const written = [];
4735
+ for (const a of agents) {
4736
+ const file = `${a.name}.md`;
4737
+ writeFileSync8(join12(dir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
4738
+ written.push(file);
4739
+ }
4740
+ if (written.length) recordMaterialisedSkills(dir, DEFAULT_SKILLS_SLUG, written, { surface });
4741
+ return written;
4742
+ }
4743
+ var SKILL_SPEC = { kind: "skill", dir: skillsDir, resolve: resolveSkillSet, write: writeSkills };
4744
+ var AGENT_SPEC = { kind: "agent", dir: agentsDir, resolve: resolveAgentSet, write: writeAgents };
4745
+ function scopeOf(opts) {
4746
+ return opts.local ? "project" : resolveScope(opts.scope);
4747
+ }
4748
+ async function runInstall(spec, cmd, opts) {
4749
+ const g = cmd.optsWithGlobals();
4750
+ let scope;
4751
+ try {
4752
+ scope = scopeOf(opts);
4753
+ } catch (err2) {
4754
+ return fail(err2.message);
4755
+ }
4756
+ const cfg = resolveConfig(g);
4757
+ const dryRun = Boolean(opts.dryRun);
4758
+ const json = Boolean(g.json || opts.json);
4759
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4760
+ const personalWorkspaceId = await getPersonalWorkspaceId(cfg);
4761
+ const rows = await fetchTemplateRows(cfg, personalWorkspaceId);
4762
+ const items = spec.resolve(rows, CLIENT_SURFACE);
4763
+ const results = targets.map((t) => {
4764
+ const dir = spec.dir(t.dir);
4765
+ const written = dryRun ? items.map((i) => i.name) : spec.write(dir, items, CLIENT_SURFACE);
4766
+ return { dir, label: t.label, written };
4767
+ });
4768
+ if (json) return emit({ kind: spec.kind, dryRun, available: items.length, targets: results }, true);
4769
+ if (items.length === 0) {
4770
+ console.log(style.dim(`No ${spec.kind}s available to install \u2014 is the bundle installed for your account?`));
4771
+ return;
4772
+ }
4773
+ for (const r of results) {
4774
+ console.log(
4775
+ `${dryRun ? "" : style.green("\u2713 ")}${dryRun ? "would write" : "wrote"} ${r.written.length} ${spec.kind}(s) ${style.dim("\u2192")} ${r.dir}`
3945
4776
  );
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
- })
4777
+ if (dryRun) for (const i of items) console.log(` ${i.name} ${style.dim(`[${i.source}]`)}`);
4778
+ }
4779
+ }
4780
+ function runList(spec, cmd, opts) {
4781
+ const g = cmd.optsWithGlobals();
4782
+ let scope;
4783
+ try {
4784
+ scope = scopeOf(opts);
4785
+ } catch (err2) {
4786
+ return fail(err2.message);
4787
+ }
4788
+ const json = Boolean(g.json || opts.json);
4789
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4790
+ const out = targets.map((t) => {
4791
+ const dir = spec.dir(t.dir);
4792
+ const lock = readSkillsLock(dir);
4793
+ const entries = Object.entries(lock).flatMap(
4794
+ ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync10(join12(dir, name)) }))
3961
4795
  );
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)`));
3991
- });
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
- });
4796
+ return { dir, label: t.label, entries };
4003
4797
  });
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"));
4798
+ if (json) return emit({ kind: spec.kind, targets: out }, true);
4799
+ let any = false;
4800
+ for (const t of out) {
4801
+ if (t.entries.length === 0) continue;
4802
+ any = true;
4803
+ console.log(style.bold(t.dir) + ":");
4804
+ for (const e of t.entries) {
4805
+ const flag = e.present ? "" : style.dim("(missing) ");
4806
+ console.log(` ${flag}${e.name} ${style.dim(`[${e.slug}]`)}`);
4807
+ }
4808
+ }
4809
+ if (!any) console.log(style.dim(`No ${spec.kind}s materialised. Run \`sechroom ${spec.kind}s install\`.`));
4810
+ }
4811
+ function runClean(spec, cmd, opts, slugArg) {
4812
+ const g = cmd.optsWithGlobals();
4813
+ const slug = slugArg || DEFAULT_SKILLS_SLUG;
4814
+ let scope;
4815
+ try {
4816
+ scope = scopeOf(opts);
4817
+ } catch (err2) {
4818
+ return fail(err2.message);
4819
+ }
4820
+ const json = Boolean(g.json || opts.json);
4821
+ const targets = resolveClaudeTargets({ override: g.claudeConfigDir, scope, cwd: process.cwd() });
4822
+ const cleaned = [];
4823
+ const missing = [];
4824
+ for (const t of targets) {
4825
+ const dir = spec.dir(t.dir);
4826
+ const lock = readSkillsLock(dir);
4010
4827
  const entry = lock[slug];
4011
- if (!entry) fail(`No installed record for '${slug}' in ${lockPath}.`);
4828
+ if (!entry) {
4829
+ missing.push(join12(dir, SKILLS_LOCK));
4830
+ continue;
4831
+ }
4012
4832
  const removed = [];
4013
4833
  for (const name of entry.skills) {
4014
- const skillPath = join9(dir, name);
4015
- if (existsSync9(skillPath)) {
4016
- rmSync2(skillPath, { recursive: true, force: true });
4834
+ const p = join12(dir, name);
4835
+ if (existsSync10(p)) {
4836
+ rmSync2(p, { recursive: true, force: true });
4017
4837
  removed.push(name);
4018
4838
  }
4019
4839
  }
4020
4840
  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
- });
4841
+ writeSkillsLock(dir, lock);
4842
+ cleaned.push({ dir, removed });
4843
+ }
4844
+ if (cleaned.length === 0) {
4845
+ return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
4846
+ }
4847
+ if (json) return emit({ kind: spec.kind, slug, cleaned, missing }, true);
4848
+ for (const c of cleaned) {
4849
+ console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
4850
+ }
4851
+ }
4852
+
4853
+ // src/commands/skills.ts
4854
+ function registerSkills(program2) {
4855
+ const skills = program2.command("skills").description("Manage operator skills (install to disk, list, clean)");
4856
+ skills.addHelpText(
4857
+ "after",
4858
+ `
4859
+ Examples:
4860
+ $ sechroom skills install materialise your installed skills to ~/.claude/skills
4861
+ $ sechroom skills install --scope project write them to ./.claude/skills instead
4862
+ $ sechroom skills list what's materialised on disk
4863
+ $ sechroom skills clean remove the materialised skill files
4864
+ $ sechroom skills set-lane --code-lane claude-code-chris --design-lane claude-design-chris
4865
+
4866
+ `
4867
+ );
4868
+ 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));
4869
+ 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));
4870
+ 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));
4871
+ 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(
4872
+ (opts, cmd) => setLane({
4873
+ codeLane: opts.codeLane,
4874
+ designLane: opts.designLane,
4875
+ json: Boolean(opts.json) || Boolean(cmd.optsWithGlobals().json)
4876
+ })
4877
+ );
4878
+ 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
4879
  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
4880
  if (!opts.defaultCodeLane && !opts.defaultDesignLane && !opts.handoverRecipient)
4049
4881
  fail("Provide at least one of --default-code-lane / --default-design-lane / --handover-recipient.");
@@ -4106,23 +4938,46 @@ Examples:
4106
4938
  });
4107
4939
  }
4108
4940
 
4941
+ // src/commands/agents.ts
4942
+ function registerAgents(program2) {
4943
+ const agents = program2.command("agents").description("Manage operator subagents (install to disk, list, clean)");
4944
+ agents.addHelpText(
4945
+ "after",
4946
+ `
4947
+ Examples:
4948
+ $ sechroom agents install materialise your installed agents to ~/.claude/agents
4949
+ $ sechroom agents install --scope project write them to ./.claude/agents instead
4950
+ $ sechroom agents install --claude-config-dir ~/.claude-abcs target another instance
4951
+ $ sechroom agents list what's materialised on disk
4952
+ $ sechroom agents clean remove the materialised agent files
4953
+
4954
+ Agents are resolved from the agent target (target:claude-agent), the dispatchable
4955
+ workers your loop skills call (e.g. find-prior-art \u2192 substrate-miner).`
4956
+ );
4957
+ 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));
4958
+ 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));
4959
+ 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));
4960
+ }
4961
+
4109
4962
  // 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");
4963
+ import { homedir as homedir4 } from "os";
4964
+ import { join as join13 } from "path";
4965
+ import { existsSync as existsSync11, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
4966
+ var SKILLS_LOCK2 = ".sechroom-skills.json";
4967
+ var localSkillsDir = () => join13(process.cwd(), ".claude", "skills");
4968
+ var globalSkillsDir = () => join13(homedir4(), ".claude", "skills");
4969
+ var localAgentsDir = () => join13(process.cwd(), ".claude", "agents");
4970
+ var globalAgentsDir = () => join13(homedir4(), ".claude", "agents");
4116
4971
  function removeMaterialisedSkills(dir) {
4117
4972
  const removed = [];
4118
- const lockPath = join10(dir, SKILLS_LOCK);
4119
- if (!existsSync10(lockPath)) return removed;
4973
+ const lockPath = join13(dir, SKILLS_LOCK2);
4974
+ if (!existsSync11(lockPath)) return removed;
4120
4975
  try {
4121
4976
  const lock = JSON.parse(readFileSync7(lockPath, "utf8"));
4122
4977
  for (const entry of Object.values(lock)) {
4123
4978
  for (const name of entry.skills ?? []) {
4124
- const p = join10(dir, name);
4125
- if (existsSync10(p)) {
4979
+ const p = join13(dir, name);
4980
+ if (existsSync11(p)) {
4126
4981
  rmSync3(p, { recursive: true, force: true });
4127
4982
  removed.push(p);
4128
4983
  }
@@ -4167,28 +5022,30 @@ function registerReset(program2) {
4167
5022
  }
4168
5023
  }
4169
5024
  const removed = [];
4170
- const stateDir = join10(process.cwd(), ".sechroom");
4171
- if (existsSync10(stateDir)) {
5025
+ const stateDir = join13(process.cwd(), ".sechroom");
5026
+ if (existsSync11(stateDir)) {
4172
5027
  rmSync3(stateDir, { recursive: true, force: true });
4173
5028
  removed.push(stateDir);
4174
5029
  }
4175
- const legacyCfg = join10(process.cwd(), ".sechroom.json");
4176
- if (existsSync10(legacyCfg)) {
5030
+ const legacyCfg = join13(process.cwd(), ".sechroom.json");
5031
+ if (existsSync11(legacyCfg)) {
4177
5032
  rmSync3(legacyCfg, { force: true });
4178
5033
  removed.push(legacyCfg);
4179
5034
  }
4180
- const legacySem = join10(process.cwd(), ".sem");
4181
- if (existsSync10(legacySem)) {
5035
+ const legacySem = join13(process.cwd(), ".sem");
5036
+ if (existsSync11(legacySem)) {
4182
5037
  rmSync3(legacySem, { force: true });
4183
5038
  removed.push(legacySem);
4184
5039
  }
4185
5040
  removed.push(...removeMaterialisedSkills(localSkillsDir()));
5041
+ removed.push(...removeMaterialisedSkills(localAgentsDir()));
4186
5042
  if (global) {
4187
5043
  const tok = clearToken();
4188
5044
  if (tok) removed.push(tok);
4189
5045
  const cfg = clearPersisted();
4190
5046
  if (cfg) removed.push(cfg);
4191
5047
  removed.push(...removeMaterialisedSkills(globalSkillsDir()));
5048
+ removed.push(...removeMaterialisedSkills(globalAgentsDir()));
4192
5049
  }
4193
5050
  if (json) return emit({ global, removed }, true);
4194
5051
  if (removed.length === 0) {
@@ -4213,7 +5070,7 @@ function resolveVersion() {
4213
5070
  }
4214
5071
  }
4215
5072
  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);
5073
+ 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
5074
  program.addHelpText(
4218
5075
  "after",
4219
5076
  `
@@ -4317,15 +5174,19 @@ registerWorkspace(program);
4317
5174
  registerProject(program);
4318
5175
  registerFiling(program);
4319
5176
  registerContinuity(program);
5177
+ registerCheckpoint(program);
4320
5178
  registerHook(program);
4321
5179
  registerId(program);
4322
5180
  registerAccount(program);
4323
5181
  registerChat(program);
4324
5182
  registerInit(program);
4325
5183
  registerSetup(program);
5184
+ registerNamespace(program);
4326
5185
  registerOnboard(program);
4327
5186
  registerSweep(program);
4328
5187
  registerSkills(program);
5188
+ registerAgents(program);
5189
+ registerLane(program);
4329
5190
  registerReset(program);
4330
5191
  program.parseAsync().catch((err2) => {
4331
5192
  process.stderr.write(`error: ${err2 instanceof Error ? err2.message : String(err2)}