@sechroom/cli 2026.7.28 → 2026.7.30

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 +2480 -760
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -428,6 +428,30 @@ async function requireToken(cfg) {
428
428
  }
429
429
  return cached.accessToken;
430
430
  }
431
+ async function forceRefreshToken(cfg) {
432
+ if (process.env.SECHROOM_TOKEN)
433
+ throw new Error(
434
+ "SECHROOM_TOKEN was rejected (401) and cannot be refreshed \u2014 mint a fresh bearer and re-export SECHROOM_TOKEN."
435
+ );
436
+ const cached = readAccount(cfg.account);
437
+ if (!cached?.refreshToken)
438
+ throw new Error(
439
+ `Session for account "${cfg.account}" was rejected (401) and has no refresh token. Run \`sechroom login --account ${cfg.account}\` again.`
440
+ );
441
+ const meta = await discover(cfg.baseUrl);
442
+ const res = await fetch(meta.token_endpoint, {
443
+ method: "POST",
444
+ headers: { "content-type": "application/x-www-form-urlencoded" },
445
+ body: new URLSearchParams({
446
+ grant_type: "refresh_token",
447
+ refresh_token: cached.refreshToken
448
+ })
449
+ });
450
+ if (!res.ok)
451
+ throw new Error(`Token refresh failed (${res.status}). Run \`sechroom login --account ${cfg.account}\` again.`);
452
+ persistTokenResponse(cfg.account, cfg.baseUrl, await res.json());
453
+ return readAccount(cfg.account).accessToken;
454
+ }
431
455
 
432
456
  // src/client.ts
433
457
  import createClient from "openapi-fetch";
@@ -903,9 +927,9 @@ function writeSkillsLock(dir, lock) {
903
927
  mkdirSync2(dir, { recursive: true });
904
928
  writeFileSync2(join2(dir, SKILLS_LOCK), JSON.stringify(lock, null, 2) + "\n");
905
929
  }
906
- function recordMaterialisedSkills(dir, slug, skills, meta = {}) {
930
+ function recordMaterialisedSkills(dir, slug2, skills, meta = {}) {
907
931
  const lock = readSkillsLock(dir);
908
- lock[slug] = { surface: meta.surface, skills: [...skills].sort() };
932
+ lock[slug2] = { surface: meta.surface, skills: [...skills].sort() };
909
933
  writeSkillsLock(dir, lock);
910
934
  }
911
935
 
@@ -1429,7 +1453,7 @@ function runList(spec, cmd, opts) {
1429
1453
  const out = targets.map((t) => {
1430
1454
  const lock = readSkillsLock(t.dir);
1431
1455
  const entries = Object.entries(lock).flatMap(
1432
- ([slug, e]) => (e.skills ?? []).map((name) => ({ slug, name, present: existsSync3(join4(t.dir, name)) }))
1456
+ ([slug2, e]) => (e.skills ?? []).map((name) => ({ slug: slug2, name, present: existsSync3(join4(t.dir, name)) }))
1433
1457
  );
1434
1458
  return { client: t.client, surface: t.surface, dir: t.dir, label: t.label, entries };
1435
1459
  });
@@ -1448,7 +1472,7 @@ function runList(spec, cmd, opts) {
1448
1472
  }
1449
1473
  function runClean(spec, cmd, opts, slugArg) {
1450
1474
  const g = cmd.optsWithGlobals();
1451
- const slug = slugArg || DEFAULT_SKILLS_SLUG;
1475
+ const slug2 = slugArg || DEFAULT_SKILLS_SLUG;
1452
1476
  let scope;
1453
1477
  try {
1454
1478
  scope = scopeOf(opts);
@@ -1468,7 +1492,7 @@ function runClean(spec, cmd, opts, slugArg) {
1468
1492
  const missing = [];
1469
1493
  for (const t of targets) {
1470
1494
  const lock = readSkillsLock(t.dir);
1471
- const entry = lock[slug];
1495
+ const entry = lock[slug2];
1472
1496
  if (!entry) {
1473
1497
  missing.push(join4(t.dir, SKILLS_LOCK));
1474
1498
  continue;
@@ -1481,16 +1505,16 @@ function runClean(spec, cmd, opts, slugArg) {
1481
1505
  removed.push(name);
1482
1506
  }
1483
1507
  }
1484
- delete lock[slug];
1508
+ delete lock[slug2];
1485
1509
  writeSkillsLock(t.dir, lock);
1486
1510
  cleaned.push({ client: t.client, surface: t.surface, dir: t.dir, removed });
1487
1511
  }
1488
1512
  if (cleaned.length === 0) {
1489
- return fail(`No materialised ${spec.kind}s recorded for '${slug}' in ${missing.join(", ")}.`);
1513
+ return fail(`No materialised ${spec.kind}s recorded for '${slug2}' in ${missing.join(", ")}.`);
1490
1514
  }
1491
- if (json) return emit({ kind: spec.kind, client: selection, slug, cleaned, missing }, true);
1515
+ if (json) return emit({ kind: spec.kind, client: selection, slug: slug2, cleaned, missing }, true);
1492
1516
  for (const c of cleaned) {
1493
- console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug} from ${c.dir}`));
1517
+ console.log(style.green(`Removed ${c.removed.length} ${spec.kind}(s) for ${slug2} from ${c.dir}`));
1494
1518
  }
1495
1519
  }
1496
1520
 
@@ -1518,8 +1542,8 @@ target:gpt-codex-agent), the dispatchable workers your loop skills call
1518
1542
  }
1519
1543
 
1520
1544
  // src/commands/channel.ts
1521
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
1522
- import { dirname as dirname6, join as join9 } from "path";
1545
+ import { existsSync as existsSync9, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
1546
+ import { dirname as dirname8, join as join11 } from "path";
1523
1547
  import {
1524
1548
  HttpTransportType,
1525
1549
  HubConnectionBuilder
@@ -1528,8 +1552,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1528
1552
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1529
1553
 
1530
1554
  // src/commands/executor.ts
1531
- import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
1532
- import { dirname as dirname5, join as join8 } from "path";
1555
+ import { existsSync as existsSync8, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
1556
+ import { dirname as dirname7, join as join10 } from "path";
1533
1557
 
1534
1558
  // src/sem.ts
1535
1559
  import { dirname as dirname2, join as join5 } from "path";
@@ -1693,239 +1717,2072 @@ function ensureSemIgnored(semPath) {
1693
1717
  }
1694
1718
  }
1695
1719
 
1696
- // src/commands/hook-install.ts
1697
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
1698
- import { delimiter, dirname as dirname4, join as join7 } from "path";
1720
+ // src/commands/executor-run.ts
1721
+ import { join as join9, resolve as resolve2 } from "path";
1699
1722
 
1700
- // src/setup/clients.ts
1701
- import { existsSync as existsSync5 } from "fs";
1702
- import { homedir as homedir3 } from "os";
1703
- import { dirname as dirname3, join as join6 } from "path";
1704
- function claudeDesktopConfigPath(home) {
1705
- switch (process.platform) {
1706
- case "darwin":
1707
- return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1708
- case "win32":
1709
- return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1710
- default:
1711
- return join6(home, ".config", "Claude", "claude_desktop_config.json");
1723
+ // src/executor-run/claim.ts
1724
+ var SUCCESS = /* @__PURE__ */ new Set(["Claimed", "AlreadyHeld"]);
1725
+ async function claimNextTask(deps) {
1726
+ const { request, executorInstanceId } = deps;
1727
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
1728
+ const idempotencyKey = deps.idempotencyKey ?? ((offer) => `executor-run:${offer.generationId}`);
1729
+ const tokenVersion = deps.tokenVersion ?? 1;
1730
+ const log = deps.log ?? (() => {
1731
+ });
1732
+ const base = `/me/executor-instances/${encodeURIComponent(executorInstanceId)}`;
1733
+ const offers = await request(`${base}/dispatch-offers`);
1734
+ if (offers.length === 0) return null;
1735
+ const head = offers[0];
1736
+ if (head.suggestedClaimDelayMs > 0) await sleep(head.suggestedClaimDelayMs);
1737
+ const next = await request(
1738
+ `${base}/dispatch-offers/claim-next`,
1739
+ {
1740
+ method: "POST",
1741
+ body: JSON.stringify({ idempotencyKey: idempotencyKey(head) })
1742
+ }
1743
+ );
1744
+ if (SUCCESS.has(next.outcome)) return toClaimed(next, tokenVersion, next.offer ?? head);
1745
+ if (next.outcome === "NoOffer") return null;
1746
+ log(
1747
+ `claim-next returned ${next.outcome}; falling through to direct per-generation claim`
1748
+ );
1749
+ for (const offer of offers) {
1750
+ const direct = await request(`/me/executor-task-claims`, {
1751
+ method: "POST",
1752
+ body: JSON.stringify({
1753
+ generationId: offer.generationId,
1754
+ executorInstanceId
1755
+ })
1756
+ });
1757
+ if (SUCCESS.has(direct.outcome)) return toClaimed(direct, tokenVersion, offer);
1712
1758
  }
1759
+ return null;
1713
1760
  }
1714
- function clientTargets(cwd, opts = {}) {
1715
- const home = homedir3();
1716
- const claudeDir = opts.claudeDir ?? join6(home, ".claude");
1717
- const codexHome = opts.codexHome ?? join6(home, ".codex");
1761
+ function toClaimed(result, tokenVersion, offer) {
1762
+ const lease = result.lease;
1763
+ const claimToken = result.claimToken;
1764
+ if (!lease?.id || !claimToken) return null;
1718
1765
  return {
1719
- "claude-code": {
1720
- key: "claude-code",
1721
- label: "Claude Code",
1722
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
1723
- instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
1724
- },
1725
- "claude-desktop": {
1726
- key: "claude-desktop",
1727
- label: "Claude Desktop",
1728
- mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
1729
- instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
1730
- },
1731
- codex: {
1732
- key: "codex",
1733
- label: "Codex CLI",
1734
- mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
1735
- instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
1736
- },
1737
- cursor: {
1738
- key: "cursor",
1739
- label: "Cursor",
1740
- mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
1741
- instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
1742
- },
1743
- antigravity: {
1744
- key: "antigravity",
1745
- label: "Google Antigravity",
1746
- // FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
1747
- // `~/.gemini/config/mcp_config.json` (not cwd; not affected by
1748
- // CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
1749
- // `type` — comes from the `antigravity` server surface, so we don't
1750
- // hardcode it here. Instructions go in the project `AGENTS.md`
1751
- // (cross-tool, shared with Codex/Cursor).
1752
- mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
1753
- instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
1754
- }
1766
+ outcome: result.outcome,
1767
+ leaseId: lease.id,
1768
+ claimToken,
1769
+ tokenVersion,
1770
+ memoryId: lease.memoryId,
1771
+ workspaceId: lease.workspaceId,
1772
+ generationId: lease.generationId,
1773
+ decompositionId: decompositionIdFrom(offer?.tags ?? [])
1755
1774
  };
1756
1775
  }
1757
- var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
1758
- var DEFAULT_CLIENT_KEY = "claude-code";
1759
- function detectInstalledClients(cwd) {
1760
- const home = homedir3();
1761
- const detected = [];
1762
- if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
1763
- if (existsSync5(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
1764
- if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
1765
- if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
1766
- if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
1767
- return detected;
1776
+ function decompositionIdFrom(tags) {
1777
+ const tag = tags.find((value) => value.startsWith("wlp-decomposition:"));
1778
+ return tag?.slice("wlp-decomposition:".length) || void 0;
1768
1779
  }
1769
1780
 
1770
- // src/commands/hook-install.ts
1771
- var CLAUDE_HOOK_COMMANDS = {
1772
- SessionStart: "sechroom hook session-start",
1773
- PreCompact: "sechroom hook pre-compact",
1774
- SessionEnd: "sechroom hook session-end",
1775
- // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
1776
- // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
1777
- // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
1778
- // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
1779
- // given Claude Code version doesn't know is inert (never fires). Claude-only.
1780
- Stop: "sechroom telemetry hook",
1781
- SubagentStop: "sechroom telemetry hook",
1782
- Notification: "sechroom telemetry hook",
1783
- PermissionDenied: "sechroom telemetry hook"
1784
- };
1785
- var CODEX_HOOK_COMMANDS = {
1786
- SessionStart: "sechroom hook session-start",
1787
- PreCompact: "sechroom hook pre-compact",
1788
- Stop: "sechroom hook session-end --debounce-minutes 10"
1789
- };
1790
- function hookCommandsForSurface(surface) {
1791
- return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
1792
- }
1793
- function hasHookCommand(config2, event, command) {
1794
- const groups = config2.hooks?.[event] ?? [];
1795
- return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
1781
+ // src/executor-run/codex.ts
1782
+ import {
1783
+ execFile,
1784
+ spawn
1785
+ } from "child_process";
1786
+ import { createInterface } from "readline";
1787
+ import { promisify } from "util";
1788
+
1789
+ // src/executor-run/usage.ts
1790
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
1791
+ import { dirname as dirname3 } from "path";
1792
+ function rateLimitMethod(method) {
1793
+ return /rate_?limits/i.test(method);
1794
+ }
1795
+ function parseRateLimitPayload(params) {
1796
+ const wrapper = object(params.rateLimits) ?? object(params.rate_limits) ?? params;
1797
+ const primary = parseWindow(wrapper.primary);
1798
+ const secondary = parseWindow(wrapper.secondary);
1799
+ if (!primary && !secondary) return void 0;
1800
+ return { primary, secondary };
1801
+ }
1802
+ function parseWindow(value) {
1803
+ const obj = object(value);
1804
+ if (!obj) return void 0;
1805
+ const usedPercent = numberOf(obj, "used_percent", "usedPercent");
1806
+ if (usedPercent === void 0) return void 0;
1807
+ return {
1808
+ usedPercent,
1809
+ windowMinutes: numberOf(obj, "window_minutes", "windowMinutes"),
1810
+ resetsInSeconds: numberOf(obj, "resets_in_seconds", "resetsInSeconds")
1811
+ };
1796
1812
  }
1797
- function mergeHooks(config2, commands) {
1798
- config2.hooks ??= {};
1799
- let added = 0;
1800
- for (const [event, command] of Object.entries(commands)) {
1801
- if (hasHookCommand(config2, event, command)) continue;
1802
- const groups = config2.hooks[event] ??= [];
1803
- groups.push({ hooks: [{ type: "command", command }] });
1804
- added += 1;
1805
- }
1806
- return added;
1813
+ function object(value) {
1814
+ return value && typeof value === "object" ? value : void 0;
1807
1815
  }
1808
- function readJsonConfig2(path) {
1809
- if (!existsSync6(path)) return {};
1810
- const raw = readFileSync4(path, "utf8");
1811
- if (!raw.trim()) return {};
1812
- return JSON.parse(raw);
1816
+ function numberOf(obj, ...keys) {
1817
+ for (const key of keys) if (typeof obj[key] === "number") return obj[key];
1818
+ return void 0;
1813
1819
  }
1814
- function installHooksJson(path, commands, dryRun) {
1815
- const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
1816
- const config2 = readJsonConfig2(path);
1817
- const added = mergeHooks(config2, commands);
1818
- if (added === 0 && existed) return { path, status: "current" };
1819
- if (!dryRun) {
1820
- mkdirSync5(dirname4(path), { recursive: true });
1821
- writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
1820
+ function rateLimitRemaining(state, nowMs) {
1821
+ const elapsedSeconds = Math.max(0, (nowMs - state.capturedAtMs) / 1e3);
1822
+ let binding;
1823
+ for (const window of ["primary", "secondary"]) {
1824
+ const captured = state[window];
1825
+ if (!captured) continue;
1826
+ if (captured.resetsInSeconds !== void 0 && captured.resetsInSeconds <= elapsedSeconds)
1827
+ continue;
1828
+ const remainingPercent = Math.max(0, 100 - captured.usedPercent);
1829
+ if (!binding || remainingPercent < binding.remainingPercent)
1830
+ binding = {
1831
+ remainingPercent,
1832
+ window,
1833
+ resetsInSeconds: captured.resetsInSeconds === void 0 ? void 0 : Math.round(captured.resetsInSeconds - elapsedSeconds)
1834
+ };
1822
1835
  }
1823
- return { path, status: existed ? "merged" : "created" };
1824
- }
1825
- function installClaudeCommands(claudeDir, commands, dryRun) {
1826
- return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
1836
+ return binding;
1827
1837
  }
1828
- function installCodexCommands(codexHome, commands, dryRun) {
1829
- return [
1830
- installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
1831
- installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
1832
- ];
1838
+ function admissionDecision(state, reservePercent, nowMs) {
1839
+ if (!state) return { ok: true };
1840
+ const remaining = rateLimitRemaining(state, nowMs);
1841
+ if (!remaining) return { ok: true };
1842
+ if (remaining.remainingPercent > reservePercent) return { ok: true, remaining };
1843
+ const resets = remaining.resetsInSeconds === void 0 ? "" : `; resets in ~${remaining.resetsInSeconds}s`;
1844
+ return {
1845
+ ok: false,
1846
+ remaining,
1847
+ reason: `rate-limit remaining ${remaining.remainingPercent.toFixed(1)}% (${remaining.window} window) is at or below the ${reservePercent}% reserve${resets}`
1848
+ };
1833
1849
  }
1834
- function ensureCodexFeaturesHooks(content) {
1835
- const lines = content.split("\n");
1836
- const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
1837
- if (headerIdx === -1) {
1838
- const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
1839
- return { next: base + "\n[features]\nhooks = true\n", changed: true };
1850
+ function formatTokenMeter(used, window) {
1851
+ const pct = window > 0 ? (used / window * 100).toFixed(1) : "?";
1852
+ return `${used.toLocaleString("en-US")} / ${window.toLocaleString("en-US")} (${pct}%)`;
1853
+ }
1854
+ var UsageTracker = class {
1855
+ constructor(options) {
1856
+ this.options = options;
1857
+ }
1858
+ options;
1859
+ totals = { tokensIn: 0, tokensOut: 0 };
1860
+ perTask = /* @__PURE__ */ new Map();
1861
+ latest;
1862
+ limits;
1863
+ deferred = false;
1864
+ /** Fold one `thread/tokenUsage/updated` parse into the instance running totals.
1865
+ * Payload counters are cumulative within a turn but reset across turns/resumes,
1866
+ * so totals accumulate deltas; a counter that shrank is a fresh turn's counter
1867
+ * and contributes its full value. */
1868
+ recordUsage(taskId, usage) {
1869
+ const previous = this.perTask.get(taskId);
1870
+ this.totals.tokensIn += delta(usage.tokensIn, previous?.tokensIn);
1871
+ this.totals.tokensOut += delta(usage.tokensOut, previous?.tokensOut);
1872
+ this.perTask.set(taskId, { tokensIn: usage.tokensIn, tokensOut: usage.tokensOut });
1873
+ this.latest = usage;
1874
+ this.append({
1875
+ type: "usage",
1876
+ taskId,
1877
+ modelId: usage.modelId,
1878
+ tokensIn: usage.tokensIn,
1879
+ tokensOut: usage.tokensOut,
1880
+ contextUsed: usage.contextUsed,
1881
+ contextWindow: usage.contextWindow,
1882
+ instanceTokensIn: this.totals.tokensIn,
1883
+ instanceTokensOut: this.totals.tokensOut
1884
+ });
1885
+ this.options.log(this.statusLine());
1886
+ }
1887
+ /** Fold a rate-limit payload (dedicated notification or embedded in a usage
1888
+ * payload) into the admission state. */
1889
+ recordRateLimits(payload) {
1890
+ this.limits = { ...payload, capturedAtMs: this.now() };
1891
+ const remaining = rateLimitRemaining(this.limits, this.now());
1892
+ this.append({
1893
+ type: "rate-limits",
1894
+ primary: payload.primary ?? null,
1895
+ secondary: payload.secondary ?? null,
1896
+ remainingPercent: remaining?.remainingPercent ?? null
1897
+ });
1898
+ this.options.log(
1899
+ `rate-limit update: ${remaining ? `remaining ${remaining.remainingPercent.toFixed(1)}% (${remaining.window} window)` : "all windows recovered"}`
1900
+ );
1840
1901
  }
1841
- for (let i = headerIdx + 1; i < lines.length; i += 1) {
1842
- const trimmed = lines[i].trim();
1843
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
1844
- const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
1845
- if (!m) continue;
1846
- const value = m[4].replace(/\s*#.*$/, "").trim();
1847
- if (value === "true") return { next: content, changed: false };
1848
- lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
1849
- return { next: lines.join("\n"), changed: true };
1902
+ /** The claim-time admission verdict. Transitions (admit→defer, defer→admit)
1903
+ * land in the JSONL record; the loud per-cycle logging is the driver loop's. */
1904
+ admission() {
1905
+ const decision = admissionDecision(
1906
+ this.limits,
1907
+ this.options.reservePercent,
1908
+ this.now()
1909
+ );
1910
+ if (decision.ok !== !this.deferred) {
1911
+ this.deferred = !decision.ok;
1912
+ this.append({
1913
+ type: "admission",
1914
+ ok: decision.ok,
1915
+ reservePercent: this.options.reservePercent,
1916
+ reason: decision.ok ? null : decision.reason
1917
+ });
1918
+ }
1919
+ return decision.ok ? { ok: true } : { ok: false, reason: decision.reason };
1850
1920
  }
1851
- lines.splice(headerIdx + 1, 0, "hooks = true");
1852
- return { next: lines.join("\n"), changed: true };
1853
- }
1854
- function installCodexFeatureFlag(path, dryRun) {
1855
- const existed = existsSync6(path);
1856
- const content = existed ? readFileSync4(path, "utf8") : "";
1857
- const { next, changed } = ensureCodexFeaturesHooks(content);
1858
- if (!changed) return { path, status: "current" };
1859
- if (!dryRun) {
1860
- mkdirSync5(dirname4(path), { recursive: true });
1861
- writeFileSync5(path, next);
1921
+ /** Per-instance running totals + turn meter + rate-limit remaining, for the
1922
+ * driver status output. */
1923
+ statusLine() {
1924
+ const parts = [
1925
+ `instance total in ${this.totals.tokensIn.toLocaleString("en-US")} out ${this.totals.tokensOut.toLocaleString("en-US")} across ${this.perTask.size} task(s)`
1926
+ ];
1927
+ if (this.latest)
1928
+ parts.push(`turn ${formatTokenMeter(this.latest.contextUsed, this.latest.contextWindow)}`);
1929
+ const remaining = this.limits ? rateLimitRemaining(this.limits, this.now()) : void 0;
1930
+ parts.push(
1931
+ remaining ? `rate-limit remaining ${remaining.remainingPercent.toFixed(1)}%` : "rate-limit remaining unknown"
1932
+ );
1933
+ return `usage[${this.options.instanceKey}]: ${parts.join("; ")}`;
1862
1934
  }
1863
- return { path, status: existed ? "merged" : "created" };
1864
- }
1865
- function resolveSurfaces(surface, cwd) {
1866
- if (surface === "claude") return ["claude"];
1867
- if (surface === "codex") return ["codex"];
1868
- if (surface === "both") return ["claude", "codex"];
1869
- if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
1870
- const surfaces = detectHookSurfaces(cwd);
1871
- return surfaces.length > 0 ? surfaces : ["claude", "codex"];
1872
- }
1873
- function describe(result, dryRun) {
1874
- if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
1875
- const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
1876
- return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
1877
- }
1878
- var HOOK_SURFACE_LABEL = {
1879
- claude: "Claude Code",
1880
- codex: "Codex"
1881
- };
1882
- function installHookSurfaces(surfaces, opts) {
1883
- const out = [];
1884
- for (const surface of surfaces) {
1885
- if (surface === "claude") {
1886
- const path = join7(opts.claudeDir, "settings.json");
1887
- out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
1888
- } else {
1889
- const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
1890
- const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
1891
- out.push({ surface, results: [hooksJson, featureFlag] });
1892
- }
1935
+ append(record) {
1936
+ this.options.appendRecord?.({
1937
+ ts: new Date(this.now()).toISOString(),
1938
+ instanceKey: this.options.instanceKey,
1939
+ ...record
1940
+ });
1893
1941
  }
1894
- return out;
1895
- }
1896
- function detectHookSurfaces(cwd) {
1897
- const detected = detectInstalledClients(cwd);
1898
- const surfaces = [];
1899
- if (detected.includes("claude-code")) surfaces.push("claude");
1900
- if (detected.includes("codex")) surfaces.push("codex");
1901
- return surfaces;
1902
- }
1903
- function isSechroomOnPath() {
1904
- const pathEnv = process.env.PATH ?? "";
1905
- if (!pathEnv) return false;
1906
- const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
1907
- for (const dir of pathEnv.split(delimiter)) {
1908
- if (!dir) continue;
1909
- for (const name of names) {
1910
- if (existsSync6(join7(dir, name))) return true;
1911
- }
1942
+ now() {
1943
+ return this.options.now?.() ?? Date.now();
1912
1944
  }
1913
- return false;
1914
- }
1915
- function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
1916
- if (isSechroomOnPath()) return false;
1917
- write(
1918
- "\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
1919
- );
1920
- return true;
1921
- }
1922
-
1923
- // src/commands/executor.ts
1924
- function executorSubscriptionInput(name) {
1925
- return {
1926
- name,
1927
- enabled: true,
1928
- filter: { tags: ["kind:task"], workspaceScope: [] }
1945
+ };
1946
+ function delta(current, previous) {
1947
+ if (previous === void 0) return current;
1948
+ const d = current - previous;
1949
+ return d < 0 ? current : d;
1950
+ }
1951
+ function createUsageLogAppender(path, log) {
1952
+ let warned = false;
1953
+ let dirReady = false;
1954
+ return (record) => {
1955
+ try {
1956
+ if (!dirReady) {
1957
+ mkdirSync5(dirname3(path), { recursive: true });
1958
+ dirReady = true;
1959
+ }
1960
+ appendFileSync2(path, `${JSON.stringify(record)}
1961
+ `);
1962
+ } catch (error) {
1963
+ if (warned) return;
1964
+ warned = true;
1965
+ log(`usage log append failed (${String(error)}) \u2014 continuing without ${path}`);
1966
+ }
1967
+ };
1968
+ }
1969
+
1970
+ // src/executor-run/codex.ts
1971
+ var execFileAsync = promisify(execFile);
1972
+ function shouldResumeTimedOutTurn(input) {
1973
+ return input.outcome === "timeout" && !input.hasPacket && Boolean(input.threadId) && (input.contextUsed > 0 || input.detailEventCount > 0 || input.lastAgentMessage.length > 0);
1974
+ }
1975
+ function parseCodexUsage(params, previous) {
1976
+ const info = object2(params.tokenUsageInfo) ?? params;
1977
+ const last = object2(info.last) ?? object2(info.lastTokenUsage) ?? object2(info.last_turn);
1978
+ const total = object2(info.total) ?? object2(info.totalTokenUsage) ?? info;
1979
+ const resumed = Boolean(params.resumed ?? info.resumed ?? last);
1980
+ const spend = resumed && last ? last : total;
1981
+ const tokensIn = numberOf2(spend, "input_tokens", "inputTokens", "input") ?? previous?.tokensIn ?? 0;
1982
+ const tokensOut = numberOf2(spend, "output_tokens", "outputTokens", "output") ?? previous?.tokensOut ?? 0;
1983
+ const contextUsed = numberOf2(last ?? spend, "total_tokens", "totalTokens", "total") ?? tokensIn + tokensOut;
1984
+ const contextWindow = numberOf2(params, "modelContextWindow", "contextWindow") ?? numberOf2(info, "modelContextWindow", "contextWindow") ?? previous?.contextWindow ?? 0;
1985
+ const modelId = stringOf(params, "model", "modelId") ?? stringOf(info, "model", "modelId") ?? previous?.modelId ?? null;
1986
+ return { tokensIn, tokensOut, contextUsed, contextWindow, modelId };
1987
+ }
1988
+ function object2(value) {
1989
+ return value && typeof value === "object" ? value : void 0;
1990
+ }
1991
+ function numberOf2(obj, ...keys) {
1992
+ for (const key of keys) if (typeof obj[key] === "number") return obj[key];
1993
+ return void 0;
1994
+ }
1995
+ function stringOf(obj, ...keys) {
1996
+ for (const key of keys) if (typeof obj[key] === "string") return obj[key];
1997
+ return void 0;
1998
+ }
1999
+ function renderUsage(usage) {
2000
+ return `tokens ${formatTokenMeter(usage.contextUsed, usage.contextWindow)}, thread total ${(usage.tokensIn + usage.tokensOut).toLocaleString("en-US")}`;
2001
+ }
2002
+ function mapThreadItem(msg) {
2003
+ const method = msg.method ?? "";
2004
+ if (!method.includes("item")) return void 0;
2005
+ const type = String(msg.params?.type ?? msg.params?.itemType ?? method);
2006
+ const text2 = JSON.stringify({ method, type, id: msg.params?.id ?? null });
2007
+ if (/approval/i.test(type)) return { kind: "Approval", text: text2 };
2008
+ if (/commandExecution|fileChange|contextCompaction|agentMessage|userMessage/i.test(type))
2009
+ return { kind: /agentMessage|userMessage/i.test(type) ? "Raw" : "Parsed", text: text2 };
2010
+ return void 0;
2011
+ }
2012
+ function verdictForTelemetry(status) {
2013
+ if (status === "completed") return "pass";
2014
+ if (status === "needs_approval" || status === "cancelled" || status === "canceled") return "blocked";
2015
+ return "soft-fail";
2016
+ }
2017
+ var CodexAppServer = class {
2018
+ constructor(options) {
2019
+ this.options = options;
2020
+ }
2021
+ options;
2022
+ child;
2023
+ nextId = 1;
2024
+ pending = /* @__PURE__ */ new Map();
2025
+ onServerRequest;
2026
+ onNotification;
2027
+ exited;
2028
+ cliVersion;
2029
+ /** Spawn the child + initialize. Idempotent per instance. */
2030
+ async start() {
2031
+ if (this.child) return;
2032
+ this.cliVersion ??= await pinCodexVersion(this.options.codexBin, this.options.log);
2033
+ const child = spawn(this.options.codexBin, ["app-server", "--stdio"], {
2034
+ cwd: this.options.cwd,
2035
+ stdio: ["pipe", "pipe", "pipe"]
2036
+ });
2037
+ this.child = child;
2038
+ child.on("exit", (code) => {
2039
+ this.exited = { code };
2040
+ const failure = new Error(`codex app-server exited (${code ?? "signal"})`);
2041
+ for (const waiter of this.pending.values()) waiter.reject(failure);
2042
+ this.pending.clear();
2043
+ });
2044
+ child.stderr.on(
2045
+ "data",
2046
+ (chunk) => this.options.log(`codex! ${chunk.toString().trimEnd()}`)
2047
+ );
2048
+ createInterface({ input: child.stdout }).on(
2049
+ "line",
2050
+ (line) => this.onLine(line)
2051
+ );
2052
+ await this.request("initialize", {
2053
+ clientInfo: { name: "sechroom-executor-run", version: "0" },
2054
+ capabilities: { experimentalApi: true }
2055
+ });
2056
+ }
2057
+ get alive() {
2058
+ return this.child !== void 0 && this.exited === void 0;
2059
+ }
2060
+ /**
2061
+ * Run one task as one thread+turn; resolves when the server reports
2062
+ * `turn/completed` (a server→client REQUEST, per the Looper-verified protocol),
2063
+ * the child dies, or the timeout lapses.
2064
+ */
2065
+ async runTask(prompt, telemetry) {
2066
+ let packet;
2067
+ let lastAgentMessage = "";
2068
+ let threadId = "";
2069
+ let turnId = "";
2070
+ let usage;
2071
+ const detailEvents = [];
2072
+ const modelId = this.options.model ?? null;
2073
+ const event = (kind, text2 = null) => ({
2074
+ taskId: telemetry?.taskId ?? "",
2075
+ kind,
2076
+ tokensIn: null,
2077
+ tokensOut: null,
2078
+ contextUsed: null,
2079
+ contextWindow: null,
2080
+ text: text2,
2081
+ approvalState: null,
2082
+ verdict: null,
2083
+ modelId,
2084
+ executorInstanceId: this.options.executorInstanceId,
2085
+ leaseId: telemetry?.leaseId ?? null,
2086
+ turnId: turnId || null
2087
+ });
2088
+ this.onNotification = (msg) => {
2089
+ if (msg.method === "thread/tokenUsage/updated") {
2090
+ usage = parseCodexUsage(msg.params ?? {}, usage);
2091
+ this.options.log(renderUsage(usage));
2092
+ this.options.onUsage?.(telemetry?.taskId ?? "", usage);
2093
+ this.tapRateLimits(msg.params ?? {});
2094
+ return;
2095
+ }
2096
+ const mapped = mapThreadItem(msg);
2097
+ if (mapped) detailEvents.push(event(mapped.kind, mapped.text));
2098
+ };
2099
+ const terminal = new Promise((resolve5) => {
2100
+ this.onServerRequest = (msg) => {
2101
+ const method = msg.method ?? "";
2102
+ if (terminalMethod(method)) {
2103
+ this.respond(msg.id, {});
2104
+ resolve5("completed");
2105
+ return;
2106
+ }
2107
+ if (method === "item/tool/call") {
2108
+ const name = String(msg.params?.name ?? msg.params?.tool ?? "");
2109
+ const args = msg.params?.arguments ?? msg.params?.input ?? {};
2110
+ if (name === "sechroom_closeout") {
2111
+ packet = {
2112
+ terminal_status: String(args.terminal_status ?? "completed"),
2113
+ summary: String(args.summary ?? ""),
2114
+ evidence: Array.isArray(args.evidence) ? args.evidence.map(String) : void 0
2115
+ };
2116
+ this.respond(msg.id, toolText("closeout accepted"));
2117
+ return;
2118
+ }
2119
+ if (name === "sechroom_lifecycle_signal") {
2120
+ detailEvents.push(event("Parsed", `phase:${String(args.phase ?? "?")}:${String(args.status ?? "?")}`));
2121
+ this.options.log(
2122
+ `lifecycle ${String(args.phase ?? "?")}:${String(args.status ?? "?")} \u2014 ${String(args.summary ?? "")}`
2123
+ );
2124
+ this.respond(msg.id, toolText("ok"));
2125
+ return;
2126
+ }
2127
+ this.options.log(`unknown dynamic tool '${name}' \u2014 acknowledged empty`);
2128
+ this.respond(msg.id, toolText("unsupported tool"));
2129
+ return;
2130
+ }
2131
+ if (method.endsWith("requestApproval") || method.includes("elicitation")) {
2132
+ detailEvents.push({ ...event("Approval", method), approvalState: "denied" });
2133
+ this.options.log(
2134
+ `approval requested (${method}) under approvalPolicy=never \u2014 DENIED (unattended run)`
2135
+ );
2136
+ this.respond(msg.id, { decision: "denied" });
2137
+ return;
2138
+ }
2139
+ this.respond(msg.id, {});
2140
+ };
2141
+ });
2142
+ try {
2143
+ const started = await this.request("thread/start", {
2144
+ cwd: this.options.cwd,
2145
+ approvalPolicy: "never",
2146
+ sandbox: this.options.sandbox,
2147
+ ephemeral: false,
2148
+ developerInstructions: prompt,
2149
+ dynamicTools: dynamicToolDefinitions()
2150
+ });
2151
+ threadId = String(
2152
+ started.threadId ?? started.thread?.id ?? ""
2153
+ );
2154
+ const turn = await this.request("turn/start", {
2155
+ threadId,
2156
+ ...this.options.model ? { model: this.options.model } : {},
2157
+ input: [{ type: "text", text: "Begin the task now." }]
2158
+ });
2159
+ turnId = String(
2160
+ turn.turnId ?? turn.turn?.id ?? ""
2161
+ );
2162
+ } catch (e) {
2163
+ return { status: "crashed", reason: String(e) };
2164
+ }
2165
+ let outcome = await Promise.race([
2166
+ terminal,
2167
+ this.exitAsResult(),
2168
+ timeout(this.options.turnTimeoutMs)
2169
+ ]);
2170
+ if (shouldResumeTimedOutTurn({
2171
+ outcome,
2172
+ hasPacket: Boolean(packet),
2173
+ threadId,
2174
+ contextUsed: usage?.contextUsed ?? 0,
2175
+ detailEventCount: detailEvents.length,
2176
+ lastAgentMessage
2177
+ })) {
2178
+ this.options.log(
2179
+ `fresh turn timed out after ${this.options.turnTimeoutMs}ms with substantial work \u2014 resuming thread ${threadId}`
2180
+ );
2181
+ try {
2182
+ await this.request("turn/interrupt", { threadId, turnId });
2183
+ await this.request("thread/resume", { threadId });
2184
+ const resumed = await this.request("turn/start", {
2185
+ threadId,
2186
+ ...this.options.model ? { model: this.options.model } : {},
2187
+ input: [
2188
+ {
2189
+ type: "text",
2190
+ text: "Resume the interrupted work from this saved thread. Finish the task and call sechroom_closeout."
2191
+ }
2192
+ ]
2193
+ });
2194
+ turnId = String(
2195
+ resumed.turnId ?? resumed.turn?.id ?? ""
2196
+ );
2197
+ outcome = await Promise.race([
2198
+ terminal,
2199
+ this.exitAsResult(),
2200
+ timeout(this.options.resumeTurnTimeoutMs)
2201
+ ]);
2202
+ } catch (error) {
2203
+ return { status: "crashed", reason: `thread resume failed: ${String(error)}` };
2204
+ }
2205
+ }
2206
+ if (outcome === "timeout")
2207
+ return packet ? { status: "completed", packet, lastAgentMessage } : { status: "timeout" };
2208
+ if (outcome !== "completed")
2209
+ return { status: "crashed", reason: String(outcome) };
2210
+ if (!packet && threadId) {
2211
+ try {
2212
+ await this.request("turn/start", {
2213
+ threadId,
2214
+ input: [
2215
+ {
2216
+ type: "text",
2217
+ text: "The turn ended without a sechroom_closeout call. Call sechroom_closeout NOW with terminal_status, summary, and evidence."
2218
+ }
2219
+ ]
2220
+ });
2221
+ await Promise.race([terminal, this.exitAsResult(), timeout(6e4)]);
2222
+ } catch {
2223
+ }
2224
+ }
2225
+ await this.emitTurnTelemetry(telemetry, event, detailEvents, usage, packet).catch(
2226
+ (error) => this.options.log(`telemetry emit failed (non-fatal): ${String(error)}`)
2227
+ );
2228
+ return { status: "completed", packet, lastAgentMessage };
2229
+ }
2230
+ async emitTurnTelemetry(context, makeEvent, details, usage, packet) {
2231
+ if (!context?.decompositionId) {
2232
+ this.options.log("telemetry absent: claimed task carried no wlp-decomposition tag");
2233
+ return;
2234
+ }
2235
+ const verdict = verdictForTelemetry(packet?.terminal_status);
2236
+ const terminal = {
2237
+ ...makeEvent("Terminal", packet?.summary ?? null),
2238
+ tokensIn: usage?.tokensIn ?? null,
2239
+ tokensOut: usage?.tokensOut ?? null,
2240
+ contextUsed: usage?.contextUsed ?? null,
2241
+ contextWindow: usage?.contextWindow ?? null,
2242
+ modelId: usage?.modelId ?? this.options.model ?? null,
2243
+ verdict
2244
+ };
2245
+ await this.options.emitTelemetry(
2246
+ context.decompositionId,
2247
+ verdict === "pass" ? [terminal] : [...details, terminal]
2248
+ );
2249
+ }
2250
+ /** turn/interrupt — the graceful-shutdown drain boundary. Best-effort. */
2251
+ async interrupt() {
2252
+ try {
2253
+ await this.request("turn/interrupt", {});
2254
+ } catch {
2255
+ }
2256
+ }
2257
+ stop() {
2258
+ this.child?.kill("SIGTERM");
2259
+ this.child = void 0;
2260
+ }
2261
+ request(method, params) {
2262
+ const child = this.child;
2263
+ if (!child || this.exited)
2264
+ return Promise.reject(new Error("codex app-server is not running"));
2265
+ const id = this.nextId++;
2266
+ return new Promise((resolve5, reject) => {
2267
+ this.pending.set(id, { resolve: resolve5, reject });
2268
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
2269
+ });
2270
+ }
2271
+ respond(id, result) {
2272
+ if (id === void 0 || !this.child) return;
2273
+ this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
2274
+ }
2275
+ onLine(line) {
2276
+ if (!line.trim()) return;
2277
+ let msg;
2278
+ try {
2279
+ msg = JSON.parse(line);
2280
+ } catch {
2281
+ this.options.log(`codex? ${line}`);
2282
+ return;
2283
+ }
2284
+ if (msg.id !== void 0 && (msg.result !== void 0 || msg.error)) {
2285
+ const waiter = this.pending.get(msg.id);
2286
+ if (waiter) {
2287
+ this.pending.delete(msg.id);
2288
+ if (msg.error)
2289
+ waiter.reject(new Error(msg.error.message ?? "app-server error"));
2290
+ else waiter.resolve(msg.result);
2291
+ return;
2292
+ }
2293
+ }
2294
+ if (msg.method && msg.id !== void 0) {
2295
+ this.onServerRequest?.(msg);
2296
+ return;
2297
+ }
2298
+ if (msg.method && rateLimitMethod(msg.method)) this.tapRateLimits(msg.params ?? {});
2299
+ if (msg.method) this.onNotification?.(msg);
2300
+ if (msg.method && terminalMethod(msg.method)) {
2301
+ this.onServerRequest?.(msg);
2302
+ return;
2303
+ }
2304
+ }
2305
+ tapRateLimits(params) {
2306
+ const parsed = parseRateLimitPayload(params);
2307
+ if (parsed) this.options.onRateLimits?.(parsed);
2308
+ }
2309
+ exitAsResult() {
2310
+ return new Promise((resolve5) => {
2311
+ this.child?.once(
2312
+ "exit",
2313
+ (code) => resolve5(`app-server exited (${code ?? "signal"})`)
2314
+ );
2315
+ });
2316
+ }
2317
+ };
2318
+ async function pinCodexVersion(bin, log) {
2319
+ try {
2320
+ const { stdout } = await execFileAsync(bin, ["--version"]);
2321
+ const version = stdout.trim();
2322
+ log(`codex version: ${version}`);
2323
+ return version;
2324
+ } catch (error) {
2325
+ log(
2326
+ `warning: could not pin codex version: ${error instanceof Error ? error.message : String(error)}`
2327
+ );
2328
+ return void 0;
2329
+ }
2330
+ }
2331
+ function toolText(text2) {
2332
+ return { success: true, contentItems: [{ type: "inputText", text: text2 }] };
2333
+ }
2334
+ function terminalMethod(method) {
2335
+ return method === "turn/completed" || method.endsWith("/turn/completed");
2336
+ }
2337
+ function timeout(ms) {
2338
+ return new Promise((resolve5) => setTimeout(() => resolve5("timeout"), ms).unref?.());
2339
+ }
2340
+ function dynamicToolDefinitions() {
2341
+ return [
2342
+ {
2343
+ name: "sechroom_closeout",
2344
+ description: "Submit the task's terminal closeout. REQUIRED before ending the turn: call with the honest terminal_status, a summary of what was done, and evidence (files changed, tests run, checks).",
2345
+ inputSchema: {
2346
+ type: "object",
2347
+ properties: {
2348
+ terminal_status: {
2349
+ type: "string",
2350
+ enum: ["completed", "needs_approval", "error", "cancelled"]
2351
+ },
2352
+ summary: { type: "string" },
2353
+ evidence: { type: "array", items: { type: "string" } }
2354
+ },
2355
+ required: ["terminal_status", "summary"]
2356
+ }
2357
+ },
2358
+ {
2359
+ name: "sechroom_lifecycle_signal",
2360
+ description: "Report phase progress: call with phase (start|work|verify|closeout) and status (started|completed|skipped|blocked) at each phase boundary.",
2361
+ inputSchema: {
2362
+ type: "object",
2363
+ properties: {
2364
+ phase: { type: "string" },
2365
+ status: { type: "string" },
2366
+ summary: { type: "string" }
2367
+ },
2368
+ required: ["phase", "status", "summary"]
2369
+ }
2370
+ }
2371
+ ];
2372
+ }
2373
+
2374
+ // src/executor-run/delivery.ts
2375
+ import { execFile as execFile2 } from "child_process";
2376
+ function createGitRunner(rootDir) {
2377
+ return (bin, args) => new Promise((resolve5) => {
2378
+ execFile2(
2379
+ bin,
2380
+ bin === "git" ? ["-C", rootDir, ...args] : args,
2381
+ { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
2382
+ (error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
2383
+ );
2384
+ });
2385
+ }
2386
+ function porcelainPaths(stdout) {
2387
+ return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
2388
+ const path = line.slice(3);
2389
+ const arrow = path.indexOf(" -> ");
2390
+ return arrow >= 0 ? path.slice(arrow + 4) : path;
2391
+ });
2392
+ }
2393
+ async function snapshotRoot(git) {
2394
+ const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
2395
+ const status = await git("git", ["status", "--porcelain"]);
2396
+ return {
2397
+ baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
2398
+ dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
2399
+ };
2400
+ }
2401
+ async function checkRootReady(git, allowDirty) {
2402
+ const snapshot = await snapshotRoot(git);
2403
+ if (snapshot.dirtyPaths.length === 0 || allowDirty)
2404
+ return { ok: true, snapshot };
2405
+ return {
2406
+ ok: false,
2407
+ snapshot,
2408
+ reason: `root has ${snapshot.dirtyPaths.length} uncommitted path(s) (e.g. ${snapshot.dirtyPaths[0]}) \u2014 refusing to claim; commit/clean it or pass --allow-dirty-root`
2409
+ };
2410
+ }
2411
+ async function deliverTurn(git, options) {
2412
+ const status = await git("git", ["status", "--porcelain"]);
2413
+ if (!status.ok)
2414
+ return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
2415
+ const preDirty = new Set(options.snapshot.dirtyPaths);
2416
+ const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
2417
+ if (turnPaths.length === 0)
2418
+ return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
2419
+ const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
2420
+ const created = await git("git", ["checkout", "-b", branch]);
2421
+ if (!created.ok)
2422
+ return {
2423
+ delivered: false,
2424
+ note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
2425
+ };
2426
+ const notes = [];
2427
+ try {
2428
+ const added = await git("git", ["add", "--", ...turnPaths]);
2429
+ if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
2430
+ const committed = await git("git", [
2431
+ "commit",
2432
+ "-m",
2433
+ commitMessage(options)
2434
+ ]);
2435
+ if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
2436
+ const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
2437
+ const pushed = await git("git", ["push", "-u", "origin", branch]);
2438
+ if (!pushed.ok)
2439
+ notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
2440
+ let prUrl;
2441
+ if (options.raisePr && pushed.ok) {
2442
+ const pr = await git("gh", [
2443
+ "pr",
2444
+ "create",
2445
+ "--head",
2446
+ branch,
2447
+ "--title",
2448
+ `task(${options.taskId}): ${options.title}`,
2449
+ "--body",
2450
+ prBody(options, sha)
2451
+ ]);
2452
+ if (pr.ok) prUrl = firstLine(pr.stdout);
2453
+ else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
2454
+ }
2455
+ notes.unshift(
2456
+ `delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
2457
+ );
2458
+ return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
2459
+ } finally {
2460
+ const back = await git("git", ["checkout", options.snapshot.baseBranch]);
2461
+ if (!back.ok)
2462
+ options.log(
2463
+ `delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
2464
+ );
2465
+ }
2466
+ function failBack(reason) {
2467
+ return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
2468
+ }
2469
+ }
2470
+ async function freeBranchName(git, base) {
2471
+ for (let i = 0; ; i++) {
2472
+ const candidate = i === 0 ? base : `${base}-${i + 1}`;
2473
+ const exists = await git("git", [
2474
+ "rev-parse",
2475
+ "--verify",
2476
+ "--quiet",
2477
+ `refs/heads/${candidate}`
2478
+ ]);
2479
+ if (!exists.ok) return candidate;
2480
+ }
2481
+ }
2482
+ function slug(taskId) {
2483
+ return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2484
+ }
2485
+ function commitMessage(options) {
2486
+ return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
2487
+
2488
+ Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
2489
+
2490
+ Delivered-By: sechroom executor run (${options.deliveredBy})`;
2491
+ }
2492
+ function prBody(options, sha) {
2493
+ return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
2494
+
2495
+ Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
2496
+ }
2497
+ function firstLine(text2) {
2498
+ return text2.trim().split("\n")[0] ?? "";
2499
+ }
2500
+
2501
+ // src/executor-run/request.ts
2502
+ var AuthExpiredError = class extends Error {
2503
+ constructor(message) {
2504
+ super(message);
2505
+ this.name = "AuthExpiredError";
2506
+ }
2507
+ };
2508
+ var HttpError = class extends Error {
2509
+ constructor(status, method, path, body) {
2510
+ super(`${method} ${path} failed (${status}): ${body}`);
2511
+ this.status = status;
2512
+ this.method = method;
2513
+ this.path = path;
2514
+ this.body = body;
2515
+ this.name = "HttpError";
2516
+ }
2517
+ status;
2518
+ method;
2519
+ path;
2520
+ body;
2521
+ };
2522
+ function createAuthedRequest(cfg, deps = {}) {
2523
+ const getToken = deps.getToken ?? requireToken;
2524
+ const refresh = deps.refreshToken ?? forceRefreshToken;
2525
+ const doFetch = deps.fetch ?? fetch;
2526
+ const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
2527
+ ...init,
2528
+ headers: {
2529
+ authorization: `Bearer ${token}`,
2530
+ tenant: cfg.tenant,
2531
+ "content-type": "application/json",
2532
+ "x-sechroom-surface": "cli",
2533
+ ...init?.headers
2534
+ }
2535
+ });
2536
+ return async (path, init) => {
2537
+ const method = init?.method ?? "GET";
2538
+ let token;
2539
+ try {
2540
+ token = await getToken(cfg);
2541
+ } catch (error) {
2542
+ throw new AuthExpiredError(
2543
+ error instanceof Error ? error.message : String(error)
2544
+ );
2545
+ }
2546
+ let response = await call(path, init, token);
2547
+ if (response.status === 401) {
2548
+ let fresh;
2549
+ try {
2550
+ fresh = await refresh(cfg);
2551
+ } catch (error) {
2552
+ throw new AuthExpiredError(
2553
+ error instanceof Error ? error.message : String(error)
2554
+ );
2555
+ }
2556
+ response = await call(path, init, fresh);
2557
+ if (response.status === 401)
2558
+ throw new AuthExpiredError(
2559
+ `${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
2560
+ );
2561
+ }
2562
+ if (!response.ok)
2563
+ throw new HttpError(
2564
+ response.status,
2565
+ method,
2566
+ path,
2567
+ await safeText(response)
2568
+ );
2569
+ return await response.json();
2570
+ };
2571
+ }
2572
+ async function safeText(response) {
2573
+ try {
2574
+ return await response.text();
2575
+ } catch {
2576
+ return "";
2577
+ }
2578
+ }
2579
+
2580
+ // src/executor-run/driver.ts
2581
+ function verdictFor(terminalStatus) {
2582
+ switch (terminalStatus) {
2583
+ case "completed":
2584
+ return "pass";
2585
+ case "needs_approval":
2586
+ case "cancelled":
2587
+ case "canceled":
2588
+ return "blocked";
2589
+ case "error":
2590
+ default:
2591
+ return "soft-fail";
2592
+ }
2593
+ }
2594
+ async function runDriverLoop(ports, options) {
2595
+ const summary = { processed: 0, completed: 0, abandoned: 0 };
2596
+ let admissionDeferred = false;
2597
+ while (!options.stopping()) {
2598
+ if (ports.checkAdmission) {
2599
+ const admission = await ports.checkAdmission();
2600
+ if (!admission.ok) {
2601
+ admissionDeferred = true;
2602
+ ports.log(
2603
+ `ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
2604
+ );
2605
+ await ports.waitForWake(options.pollMs);
2606
+ continue;
2607
+ }
2608
+ if (admissionDeferred) {
2609
+ admissionDeferred = false;
2610
+ ports.log("admission recovered \u2014 resuming claims");
2611
+ }
2612
+ }
2613
+ if (ports.checkRootReady) {
2614
+ const ready = await ports.checkRootReady();
2615
+ if (!ready.ok) {
2616
+ ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
2617
+ await ports.waitForWake(options.pollMs);
2618
+ continue;
2619
+ }
2620
+ }
2621
+ const claim = await ports.claimNext();
2622
+ if (!claim) {
2623
+ await ports.waitForWake(options.pollMs);
2624
+ continue;
2625
+ }
2626
+ summary.processed++;
2627
+ ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
2628
+ const task = await ports.loadTask(claim.memoryId);
2629
+ const stopHeartbeat = ports.startLeaseHeartbeat(claim);
2630
+ let result;
2631
+ try {
2632
+ result = await ports.runTurn(task, claim);
2633
+ } catch (e) {
2634
+ if (e instanceof AuthExpiredError) throw e;
2635
+ result = { status: "crashed", reason: String(e) };
2636
+ } finally {
2637
+ stopHeartbeat();
2638
+ }
2639
+ if (result.status === "crashed" || result.status === "timeout") {
2640
+ summary.abandoned++;
2641
+ ports.log(
2642
+ `ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
2643
+ );
2644
+ } else {
2645
+ const verdict = verdictFor(result.packet?.terminal_status);
2646
+ let text2 = closeoutText(task, result);
2647
+ if (ports.deliver) {
2648
+ try {
2649
+ const delivery = await ports.deliver(claim, task, verdict);
2650
+ ports.log(`delivery: ${delivery.note}`);
2651
+ text2 += `
2652
+
2653
+ Delivery: ${delivery.note}`;
2654
+ } catch (e) {
2655
+ if (e instanceof AuthExpiredError) throw e;
2656
+ ports.log(`delivery threw (continuing to completion): ${String(e)}`);
2657
+ text2 += `
2658
+
2659
+ Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
2660
+ }
2661
+ }
2662
+ try {
2663
+ const done = await ports.completeLease(
2664
+ claim,
2665
+ verdict,
2666
+ text2,
2667
+ `${task.title} \u2014 driven closeout`
2668
+ );
2669
+ summary.completed++;
2670
+ ports.log(
2671
+ `completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
2672
+ );
2673
+ } catch (e) {
2674
+ if (e instanceof AuthExpiredError) throw e;
2675
+ summary.abandoned++;
2676
+ ports.log(
2677
+ `COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
2678
+ );
2679
+ }
2680
+ }
2681
+ if (options.once) break;
2682
+ }
2683
+ return summary;
2684
+ }
2685
+ function closeoutText(task, result) {
2686
+ if (!result.packet)
2687
+ return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
2688
+
2689
+ ${result.lastAgentMessage || "(none)"}`;
2690
+ const evidence = result.packet.evidence?.length ? `
2691
+
2692
+ Evidence:
2693
+ ${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
2694
+ return `${result.packet.summary}${evidence}
2695
+
2696
+ (terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
2697
+ }
2698
+ function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
2699
+ const schedule = timers.setInterval ?? setInterval;
2700
+ const cancel = timers.clearInterval ?? clearInterval;
2701
+ const timer = schedule(() => {
2702
+ void beat().catch(
2703
+ (e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
2704
+ );
2705
+ }, intervalMs);
2706
+ timer.unref?.();
2707
+ return () => cancel(timer);
2708
+ }
2709
+
2710
+ // src/executor-run/fleet.ts
2711
+ import { spawn as spawn2 } from "child_process";
2712
+ import { readFile } from "fs/promises";
2713
+ import { resolve } from "path";
2714
+ async function readFleetConfig(path) {
2715
+ const parsed = JSON.parse(
2716
+ await readFile(resolve(path), "utf8")
2717
+ );
2718
+ if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
2719
+ throw new Error("fleet config must contain a non-empty 'instances' array");
2720
+ const keys = /* @__PURE__ */ new Set();
2721
+ for (const entry of parsed.instances) {
2722
+ if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
2723
+ throw new Error(
2724
+ "each fleet instance requires string 'root' and 'instanceKey'"
2725
+ );
2726
+ if (keys.has(entry.instanceKey))
2727
+ throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
2728
+ keys.add(entry.instanceKey);
2729
+ }
2730
+ if (parsed.node !== void 0) {
2731
+ const node = parsed.node;
2732
+ if (!node || typeof node.instanceKey !== "string" || typeof node.connector !== "string")
2733
+ throw new Error(
2734
+ "fleet 'node' requires string 'instanceKey' and 'connector'"
2735
+ );
2736
+ }
2737
+ return parsed;
2738
+ }
2739
+ function entryArgs(entry, parentId) {
2740
+ const args = [
2741
+ "executor",
2742
+ "run",
2743
+ "--root",
2744
+ resolve(entry.root),
2745
+ "--instance-key",
2746
+ entry.instanceKey
2747
+ ];
2748
+ const value = (flag, v) => {
2749
+ if (v !== void 0) args.push(flag, String(v));
2750
+ };
2751
+ value("--parent-id", parentId);
2752
+ value("--lane", entry.lane);
2753
+ value("--model", entry.model);
2754
+ value("--connector", entry.connector);
2755
+ value("--ttl", entry.ttl);
2756
+ value("--poll-interval", entry.pollInterval);
2757
+ value("--heartbeat-interval", entry.heartbeatInterval);
2758
+ value("--turn-timeout", entry.turnTimeout);
2759
+ value("--resume-turn-timeout", entry.resumeTurnTimeout);
2760
+ value("--drain-timeout", entry.drainTimeout);
2761
+ value("--codex-bin", entry.codexBin);
2762
+ value("--sandbox", entry.sandbox);
2763
+ value("--usage-reserve", entry.usageReserve);
2764
+ return args;
2765
+ }
2766
+ function superviseFleet(config2, options = {}) {
2767
+ const log = options.log ?? ((line) => process.stderr.write(`${line}
2768
+ `));
2769
+ const states = /* @__PURE__ */ new Map();
2770
+ const children = /* @__PURE__ */ new Map();
2771
+ let stopping = false;
2772
+ let resolveDone;
2773
+ const done = new Promise((resolvePromise) => {
2774
+ resolveDone = resolvePromise;
2775
+ });
2776
+ const status = () => log(
2777
+ `[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`
2778
+ );
2779
+ const spawnEntry = options.spawnEntry ?? ((entry, args) => {
2780
+ const script = process.argv[1];
2781
+ if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
2782
+ return spawn2(process.execPath, [script, ...args], {
2783
+ cwd: resolve(entry.root),
2784
+ stdio: ["ignore", "pipe", "pipe"],
2785
+ env: process.env
2786
+ });
2787
+ });
2788
+ for (const entry of config2.instances) {
2789
+ const child = spawnEntry(entry, entryArgs(entry, options.parentId));
2790
+ children.set(entry.instanceKey, child);
2791
+ states.set(entry.instanceKey, "live");
2792
+ const prefix = (text2) => {
2793
+ for (const line of text2.replace(/\n$/, "").split("\n"))
2794
+ log(`[${entry.instanceKey}] ${line}`);
2795
+ };
2796
+ const concrete = child;
2797
+ concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
2798
+ concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
2799
+ child.on("exit", (code, signal) => {
2800
+ states.set(entry.instanceKey, "exited");
2801
+ log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
2802
+ status();
2803
+ if ([...states.values()].every((state) => state === "exited"))
2804
+ resolveDone();
2805
+ });
2806
+ }
2807
+ status();
2808
+ return {
2809
+ done,
2810
+ shutdown(signal = "SIGINT") {
2811
+ if (stopping) return done;
2812
+ stopping = true;
2813
+ for (const [key, child] of children) {
2814
+ if (states.get(key) !== "exited") {
2815
+ states.set(key, "stopping");
2816
+ child.kill(signal);
2817
+ }
2818
+ }
2819
+ status();
2820
+ return done;
2821
+ },
2822
+ states
2823
+ };
2824
+ }
2825
+
2826
+ // src/commands/telemetry.ts
2827
+ import {
2828
+ existsSync as existsSync7,
2829
+ mkdirSync as mkdirSync7,
2830
+ readFileSync as readFileSync5,
2831
+ rmSync as rmSync3,
2832
+ writeFileSync as writeFileSync6
2833
+ } from "fs";
2834
+ import { dirname as dirname6, join as join8 } from "path";
2835
+
2836
+ // src/commands/hook-install.ts
2837
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
2838
+ import { delimiter, dirname as dirname5, join as join7 } from "path";
2839
+
2840
+ // src/setup/clients.ts
2841
+ import { existsSync as existsSync5 } from "fs";
2842
+ import { homedir as homedir3 } from "os";
2843
+ import { dirname as dirname4, join as join6 } from "path";
2844
+ function claudeDesktopConfigPath(home) {
2845
+ switch (process.platform) {
2846
+ case "darwin":
2847
+ return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
2848
+ case "win32":
2849
+ return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
2850
+ default:
2851
+ return join6(home, ".config", "Claude", "claude_desktop_config.json");
2852
+ }
2853
+ }
2854
+ function clientTargets(cwd, opts = {}) {
2855
+ const home = homedir3();
2856
+ const claudeDir = opts.claudeDir ?? join6(home, ".claude");
2857
+ const codexHome = opts.codexHome ?? join6(home, ".codex");
2858
+ return {
2859
+ "claude-code": {
2860
+ key: "claude-code",
2861
+ label: "Claude Code",
2862
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
2863
+ instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
2864
+ },
2865
+ "claude-desktop": {
2866
+ key: "claude-desktop",
2867
+ label: "Claude Desktop",
2868
+ mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
2869
+ instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
2870
+ },
2871
+ codex: {
2872
+ key: "codex",
2873
+ label: "Codex CLI",
2874
+ mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
2875
+ instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
2876
+ },
2877
+ cursor: {
2878
+ key: "cursor",
2879
+ label: "Cursor",
2880
+ mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
2881
+ instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
2882
+ },
2883
+ antigravity: {
2884
+ key: "antigravity",
2885
+ label: "Google Antigravity",
2886
+ // FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
2887
+ // `~/.gemini/config/mcp_config.json` (not cwd; not affected by
2888
+ // CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
2889
+ // `type` — comes from the `antigravity` server surface, so we don't
2890
+ // hardcode it here. Instructions go in the project `AGENTS.md`
2891
+ // (cross-tool, shared with Codex/Cursor).
2892
+ mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
2893
+ instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
2894
+ }
2895
+ };
2896
+ }
2897
+ var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
2898
+ var DEFAULT_CLIENT_KEY = "claude-code";
2899
+ function detectInstalledClients(cwd) {
2900
+ const home = homedir3();
2901
+ const detected = [];
2902
+ if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
2903
+ if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
2904
+ if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
2905
+ if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
2906
+ if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
2907
+ return detected;
2908
+ }
2909
+
2910
+ // src/commands/hook-install.ts
2911
+ var CLAUDE_HOOK_COMMANDS = {
2912
+ SessionStart: "sechroom hook session-start",
2913
+ PreCompact: "sechroom hook pre-compact",
2914
+ SessionEnd: "sechroom hook session-end",
2915
+ // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
2916
+ // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop → parsed (token/context) +
2917
+ // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
2918
+ // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
2919
+ // given Claude Code version doesn't know is inert (never fires). Claude-only.
2920
+ Stop: "sechroom telemetry hook",
2921
+ SubagentStop: "sechroom telemetry hook",
2922
+ Notification: "sechroom telemetry hook",
2923
+ PermissionDenied: "sechroom telemetry hook"
2924
+ };
2925
+ var CODEX_HOOK_COMMANDS = {
2926
+ SessionStart: "sechroom hook session-start",
2927
+ PreCompact: "sechroom hook pre-compact",
2928
+ Stop: "sechroom hook session-end --debounce-minutes 10"
2929
+ };
2930
+ function hookCommandsForSurface(surface) {
2931
+ return surface === "claude" ? CLAUDE_HOOK_COMMANDS : CODEX_HOOK_COMMANDS;
2932
+ }
2933
+ function hasHookCommand(config2, event, command) {
2934
+ const groups = config2.hooks?.[event] ?? [];
2935
+ return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
2936
+ }
2937
+ function mergeHooks(config2, commands) {
2938
+ config2.hooks ??= {};
2939
+ let added = 0;
2940
+ for (const [event, command] of Object.entries(commands)) {
2941
+ if (hasHookCommand(config2, event, command)) continue;
2942
+ const groups = config2.hooks[event] ??= [];
2943
+ groups.push({ hooks: [{ type: "command", command }] });
2944
+ added += 1;
2945
+ }
2946
+ return added;
2947
+ }
2948
+ function readJsonConfig2(path) {
2949
+ if (!existsSync6(path)) return {};
2950
+ const raw = readFileSync4(path, "utf8");
2951
+ if (!raw.trim()) return {};
2952
+ return JSON.parse(raw);
2953
+ }
2954
+ function installHooksJson(path, commands, dryRun) {
2955
+ const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
2956
+ const config2 = readJsonConfig2(path);
2957
+ const added = mergeHooks(config2, commands);
2958
+ if (added === 0 && existed) return { path, status: "current" };
2959
+ if (!dryRun) {
2960
+ mkdirSync6(dirname5(path), { recursive: true });
2961
+ writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
2962
+ }
2963
+ return { path, status: existed ? "merged" : "created" };
2964
+ }
2965
+ function installClaudeCommands(claudeDir, commands, dryRun) {
2966
+ return installHooksJson(join7(claudeDir, "settings.json"), commands, dryRun);
2967
+ }
2968
+ function installCodexCommands(codexHome, commands, dryRun) {
2969
+ return [
2970
+ installHooksJson(join7(codexHome, "hooks.json"), commands, dryRun),
2971
+ installCodexFeatureFlag(join7(codexHome, "config.toml"), dryRun)
2972
+ ];
2973
+ }
2974
+ function ensureCodexFeaturesHooks(content) {
2975
+ const lines = content.split("\n");
2976
+ const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
2977
+ if (headerIdx === -1) {
2978
+ const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
2979
+ return { next: base + "\n[features]\nhooks = true\n", changed: true };
2980
+ }
2981
+ for (let i = headerIdx + 1; i < lines.length; i += 1) {
2982
+ const trimmed = lines[i].trim();
2983
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
2984
+ const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
2985
+ if (!m) continue;
2986
+ const value = m[4].replace(/\s*#.*$/, "").trim();
2987
+ if (value === "true") return { next: content, changed: false };
2988
+ lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
2989
+ return { next: lines.join("\n"), changed: true };
2990
+ }
2991
+ lines.splice(headerIdx + 1, 0, "hooks = true");
2992
+ return { next: lines.join("\n"), changed: true };
2993
+ }
2994
+ function installCodexFeatureFlag(path, dryRun) {
2995
+ const existed = existsSync6(path);
2996
+ const content = existed ? readFileSync4(path, "utf8") : "";
2997
+ const { next, changed } = ensureCodexFeaturesHooks(content);
2998
+ if (!changed) return { path, status: "current" };
2999
+ if (!dryRun) {
3000
+ mkdirSync6(dirname5(path), { recursive: true });
3001
+ writeFileSync5(path, next);
3002
+ }
3003
+ return { path, status: existed ? "merged" : "created" };
3004
+ }
3005
+ function resolveSurfaces(surface, cwd) {
3006
+ if (surface === "claude") return ["claude"];
3007
+ if (surface === "codex") return ["codex"];
3008
+ if (surface === "both") return ["claude", "codex"];
3009
+ if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
3010
+ const surfaces = detectHookSurfaces(cwd);
3011
+ return surfaces.length > 0 ? surfaces : ["claude", "codex"];
3012
+ }
3013
+ function describe(result, dryRun) {
3014
+ if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
3015
+ const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
3016
+ return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
3017
+ }
3018
+ var HOOK_SURFACE_LABEL = {
3019
+ claude: "Claude Code",
3020
+ codex: "Codex"
3021
+ };
3022
+ function installHookSurfaces(surfaces, opts) {
3023
+ const out = [];
3024
+ for (const surface of surfaces) {
3025
+ if (surface === "claude") {
3026
+ const path = join7(opts.claudeDir, "settings.json");
3027
+ out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
3028
+ } else {
3029
+ const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
3030
+ const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
3031
+ out.push({ surface, results: [hooksJson, featureFlag] });
3032
+ }
3033
+ }
3034
+ return out;
3035
+ }
3036
+ function detectHookSurfaces(cwd) {
3037
+ const detected = detectInstalledClients(cwd);
3038
+ const surfaces = [];
3039
+ if (detected.includes("claude-code")) surfaces.push("claude");
3040
+ if (detected.includes("codex")) surfaces.push("codex");
3041
+ return surfaces;
3042
+ }
3043
+ function isSechroomOnPath() {
3044
+ const pathEnv = process.env.PATH ?? "";
3045
+ if (!pathEnv) return false;
3046
+ const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
3047
+ for (const dir of pathEnv.split(delimiter)) {
3048
+ if (!dir) continue;
3049
+ for (const name of names) {
3050
+ if (existsSync6(join7(dir, name))) return true;
3051
+ }
3052
+ }
3053
+ return false;
3054
+ }
3055
+ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
3056
+ if (isSechroomOnPath()) return false;
3057
+ write(
3058
+ "\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
3059
+ );
3060
+ return true;
3061
+ }
3062
+
3063
+ // src/commands/telemetry.ts
3064
+ function registerTelemetry(program2) {
3065
+ const telemetry = program2.command("telemetry").description(
3066
+ "Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
3067
+ );
3068
+ telemetry.command("emit").description(
3069
+ "POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
3070
+ ).requiredOption(
3071
+ "--decomposition <id>",
3072
+ "Decomposition id whose run this event belongs to"
3073
+ ).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
3074
+ "--kind <kind>",
3075
+ "Event kind: raw | parsed | approval | terminal"
3076
+ ).option(
3077
+ "--tokens-in <n>",
3078
+ "Cumulative input tokens (spend meter)",
3079
+ parseIntOpt
3080
+ ).option(
3081
+ "--tokens-out <n>",
3082
+ "Cumulative output tokens (spend meter)",
3083
+ parseIntOpt
3084
+ ).option(
3085
+ "--context-used <n>",
3086
+ "Context tokens currently used (occupancy meter)",
3087
+ parseIntOpt
3088
+ ).option(
3089
+ "--context-window <n>",
3090
+ "Context window size (occupancy meter)",
3091
+ parseIntOpt
3092
+ ).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
3093
+ "--verdict <v>",
3094
+ "Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
3095
+ ).action(async (opts, cmd) => {
3096
+ const json = Boolean(cmd.optsWithGlobals().json);
3097
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3098
+ const event = {
3099
+ taskId: opts.task,
3100
+ kind: normalizeKind(opts.kind),
3101
+ tokensIn: opts.tokensIn ?? null,
3102
+ tokensOut: opts.tokensOut ?? null,
3103
+ contextUsed: opts.contextUsed ?? null,
3104
+ contextWindow: opts.contextWindow ?? null,
3105
+ text: opts.text ?? null,
3106
+ approvalState: opts.approval ?? null,
3107
+ verdict: opts.verdict ?? null
3108
+ };
3109
+ let body;
3110
+ try {
3111
+ body = await postTelemetry(cfg, opts.decomposition, [event]);
3112
+ } catch (e) {
3113
+ return fail(`Telemetry emit failed: ${e.message}`);
3114
+ }
3115
+ if (json) {
3116
+ emit(body, true);
3117
+ } else {
3118
+ process.stderr.write(
3119
+ style.green("telemetry emitted") + style.dim(
3120
+ ` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
3121
+ `
3122
+ )
3123
+ );
3124
+ }
3125
+ });
3126
+ telemetry.command("show <decompositionId>").description(
3127
+ "Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
3128
+ ).action(async (decompositionId, _opts, cmd) => {
3129
+ const globals = cmd.optsWithGlobals();
3130
+ const cfg = resolveConfig(globals);
3131
+ const data = await runApi("Reading run telemetry", async () => {
3132
+ const client = await makeClient(cfg);
3133
+ return client.GET("/decompositions/{id}/run/telemetry", {
3134
+ params: { path: { id: decompositionId } }
3135
+ });
3136
+ });
3137
+ emitAction(
3138
+ data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
3139
+ data,
3140
+ globals.json
3141
+ );
3142
+ });
3143
+ telemetry.command("bind").description(
3144
+ "Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
3145
+ ).requiredOption(
3146
+ "--decomposition <id>",
3147
+ "Decomposition id this session executes"
3148
+ ).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
3149
+ const json = Boolean(cmd.optsWithGlobals().json);
3150
+ const dir = join8(process.cwd(), ".sechroom");
3151
+ mkdirSync7(dir, { recursive: true });
3152
+ const path = join8(dir, BINDING_FILE);
3153
+ const binding = {
3154
+ decompositionId: opts.decomposition,
3155
+ taskId: opts.task
3156
+ };
3157
+ writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
3158
+ ensureStateDirIgnored(process.cwd());
3159
+ if (json) {
3160
+ emit({ bound: true, ...binding, path }, true);
3161
+ } else {
3162
+ process.stdout.write(
3163
+ style.green("telemetry bound") + style.dim(
3164
+ ` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
3165
+ `
3166
+ )
3167
+ );
3168
+ }
3169
+ });
3170
+ telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
3171
+ const json = Boolean(cmd.optsWithGlobals().json);
3172
+ const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
3173
+ const existed = existsSync7(path);
3174
+ if (existed) rmSync3(path);
3175
+ if (json) emit({ unbound: existed, path }, true);
3176
+ else
3177
+ process.stdout.write(
3178
+ existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
3179
+ );
3180
+ });
3181
+ telemetry.command("hook").description(
3182
+ "Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
3183
+ ).action(async (_opts, cmd) => {
3184
+ try {
3185
+ const raw = await readStdin();
3186
+ const input = parseHookInput(raw);
3187
+ const cwd = input.cwd ?? process.cwd();
3188
+ const binding = findBinding(cwd);
3189
+ if (!binding) return process.exit(0);
3190
+ const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
3191
+ const events = buildHookEvents(input, usage, binding.taskId);
3192
+ if (events.length === 0) return process.exit(0);
3193
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3194
+ await postTelemetry(cfg, binding.decompositionId, events);
3195
+ return process.exit(0);
3196
+ } catch {
3197
+ return process.exit(0);
3198
+ }
3199
+ });
3200
+ telemetry.command("install").description(
3201
+ "Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
3202
+ ).option(
3203
+ "--scope <scope>",
3204
+ "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
3205
+ ).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
3206
+ const g = cmd.optsWithGlobals();
3207
+ const dryRun = Boolean(opts.dryRun);
3208
+ const cwd = process.cwd();
3209
+ let scope;
3210
+ try {
3211
+ scope = opts.local ? "project" : resolveScope(opts.scope);
3212
+ } catch (err2) {
3213
+ process.stderr.write(`${err2.message}
3214
+ `);
3215
+ return process.exit(2);
3216
+ }
3217
+ const targets = resolveClaudeTargets({
3218
+ override: g.claudeConfigDir,
3219
+ scope,
3220
+ cwd
3221
+ });
3222
+ const commands = {
3223
+ Stop: "sechroom telemetry hook",
3224
+ SubagentStop: "sechroom telemetry hook",
3225
+ Notification: "sechroom telemetry hook",
3226
+ PermissionDenied: "sechroom telemetry hook"
3227
+ };
3228
+ try {
3229
+ const multi = targets.length > 1;
3230
+ const results = targets.map((t) => {
3231
+ const r = installClaudeCommands(t.dir, commands, dryRun);
3232
+ process.stdout.write(
3233
+ `${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
3234
+ `
3235
+ );
3236
+ process.stdout.write(describe(r, dryRun) + "\n");
3237
+ return r;
3238
+ });
3239
+ if (dryRun) {
3240
+ process.stdout.write("\n(dry run \u2014 no files were written.)\n");
3241
+ } else if (results.every((r) => r.status === "current")) {
3242
+ process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
3243
+ } else {
3244
+ process.stdout.write(
3245
+ "\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
3246
+ );
3247
+ }
3248
+ } catch (err2) {
3249
+ process.stderr.write(
3250
+ `telemetry install failed: ${err2.message}
3251
+ `
3252
+ );
3253
+ return process.exit(1);
3254
+ }
3255
+ warnIfSechroomNotOnPath();
3256
+ return process.exit(0);
3257
+ });
3258
+ }
3259
+ var BINDING_FILE = "telemetry.json";
3260
+ async function postTelemetry(cfg, decompositionId, events) {
3261
+ const token = await requireToken(cfg);
3262
+ const resp = await fetch(
3263
+ `${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
3264
+ {
3265
+ method: "POST",
3266
+ headers: {
3267
+ authorization: `Bearer ${token}`,
3268
+ tenant: cfg.tenant,
3269
+ "content-type": "application/json",
3270
+ "x-sechroom-surface": "cli"
3271
+ },
3272
+ body: JSON.stringify({ events })
3273
+ }
3274
+ );
3275
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
3276
+ return await resp.json();
3277
+ }
3278
+ function findBinding(start) {
3279
+ let dir = start;
3280
+ for (; ; ) {
3281
+ const path = join8(dir, ".sechroom", BINDING_FILE);
3282
+ if (existsSync7(path)) {
3283
+ try {
3284
+ const b = JSON.parse(
3285
+ readFileSync5(path, "utf8")
3286
+ );
3287
+ if (b.decompositionId && b.taskId)
3288
+ return { decompositionId: b.decompositionId, taskId: b.taskId };
3289
+ } catch {
3290
+ }
3291
+ return null;
3292
+ }
3293
+ const parent = dirname6(dir);
3294
+ if (parent === dir) return null;
3295
+ dir = parent;
3296
+ }
3297
+ }
3298
+ function parseTranscript(path) {
3299
+ if (!existsSync7(path)) return null;
3300
+ let tokensIn = 0;
3301
+ let tokensOut = 0;
3302
+ let contextUsed = 0;
3303
+ let model = "";
3304
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
3305
+ if (!line.trim()) continue;
3306
+ let obj;
3307
+ try {
3308
+ obj = JSON.parse(line);
3309
+ } catch {
3310
+ continue;
3311
+ }
3312
+ const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
3313
+ if (!usage) continue;
3314
+ const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
3315
+ tokensIn += input;
3316
+ tokensOut += usage.output_tokens ?? 0;
3317
+ contextUsed = input;
3318
+ if (obj.message?.model) model = obj.message.model;
3319
+ }
3320
+ if (tokensIn === 0 && tokensOut === 0) return null;
3321
+ return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
3322
+ }
3323
+ function windowFor(model, contextUsed = 0) {
3324
+ const m = model.toLowerCase();
3325
+ if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
3326
+ return contextUsed > 2e5 ? 1e6 : 2e5;
3327
+ }
3328
+ function buildHookEvents(input, usage, taskId) {
3329
+ const events = [];
3330
+ const base = (kind, over) => ({
3331
+ taskId,
3332
+ kind,
3333
+ tokensIn: null,
3334
+ tokensOut: null,
3335
+ contextUsed: null,
3336
+ contextWindow: null,
3337
+ text: null,
3338
+ approvalState: null,
3339
+ verdict: null,
3340
+ modelId: null,
3341
+ ...over
3342
+ });
3343
+ if (usage) {
3344
+ events.push(
3345
+ base("Parsed", {
3346
+ tokensIn: usage.tokensIn,
3347
+ tokensOut: usage.tokensOut,
3348
+ contextUsed: usage.contextUsed,
3349
+ contextWindow: usage.contextWindow,
3350
+ modelId: usage.modelId
3351
+ })
3352
+ );
3353
+ }
3354
+ switch (input.hook_event_name) {
3355
+ case "PermissionDenied":
3356
+ events.push(
3357
+ base("Approval", {
3358
+ approvalState: "denied",
3359
+ text: input.tool_name ?? input.message ?? null
3360
+ })
3361
+ );
3362
+ break;
3363
+ case "Notification":
3364
+ if (isPermissionNotification(input))
3365
+ events.push(base("Approval", { text: input.message ?? null }));
3366
+ break;
3367
+ case "Stop":
3368
+ case "SubagentStop":
3369
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
3370
+ break;
3371
+ }
3372
+ return events;
3373
+ }
3374
+ function isPermissionNotification(input) {
3375
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
3376
+ if (t) return t.includes("permission");
3377
+ return (input.message ?? "").toLowerCase().includes("permission");
3378
+ }
3379
+ async function readStdin() {
3380
+ if (process.stdin.isTTY) return "";
3381
+ const chunks = [];
3382
+ for await (const chunk of process.stdin) chunks.push(chunk);
3383
+ return Buffer.concat(chunks).toString("utf8");
3384
+ }
3385
+ function parseHookInput(raw) {
3386
+ if (!raw.trim()) return {};
3387
+ try {
3388
+ return JSON.parse(raw);
3389
+ } catch {
3390
+ return {};
3391
+ }
3392
+ }
3393
+ var KINDS = {
3394
+ raw: "Raw",
3395
+ parsed: "Parsed",
3396
+ approval: "Approval",
3397
+ terminal: "Terminal"
3398
+ };
3399
+ function normalizeKind(k) {
3400
+ const v = KINDS[k.toLowerCase()];
3401
+ if (!v)
3402
+ fail(
3403
+ `Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
3404
+ );
3405
+ return v;
3406
+ }
3407
+ function parseIntOpt(v) {
3408
+ const n = Number.parseInt(v, 10);
3409
+ if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
3410
+ return n;
3411
+ }
3412
+
3413
+ // src/commands/executor-run.ts
3414
+ function registerExecutorRunCommand(executor) {
3415
+ executor.command("fleet").description(
3416
+ "Run multiple isolated driven codex executors from one config file"
3417
+ ).requiredOption("--config <file>", "JSON fleet config").action(async (opts, cmd) => {
3418
+ const config2 = await readFleetConfig(String(opts.config));
3419
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3420
+ const log = (line) => process.stderr.write(style.dim(`[fleet] ${line}
3421
+ `));
3422
+ let nodeId;
3423
+ let stopNodeHeartbeat;
3424
+ if (config2.node) {
3425
+ const node = await registerFleetNode(cfg, config2.node);
3426
+ nodeId = node.id;
3427
+ const nodeTtl = config2.node.ttl ?? 120;
3428
+ log(`node registered \u2014 ${node.id} (${config2.node.instanceKey})`);
3429
+ stopNodeHeartbeat = startExecutorHeartbeat(
3430
+ () => refreshExecutorInstance(cfg, node.id, nodeTtl),
3431
+ Math.max(10, Math.floor(nodeTtl * 0.66)) * 1e3,
3432
+ { onError: (e) => log(`node refresh failed: ${String(e)}`) }
3433
+ );
3434
+ }
3435
+ const fleet = superviseFleet(config2, { parentId: nodeId });
3436
+ const stop = () => void fleet.shutdown("SIGINT");
3437
+ process.once("SIGINT", stop);
3438
+ process.once("SIGTERM", stop);
3439
+ try {
3440
+ await fleet.done;
3441
+ } finally {
3442
+ process.off("SIGINT", stop);
3443
+ process.off("SIGTERM", stop);
3444
+ stopNodeHeartbeat?.();
3445
+ if (nodeId) {
3446
+ try {
3447
+ await deregisterInstance(cfg, nodeId);
3448
+ log(`node deregistered \u2014 ${nodeId}`);
3449
+ } catch (e) {
3450
+ log(
3451
+ `node deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
3452
+ );
3453
+ }
3454
+ }
3455
+ }
3456
+ });
3457
+ executor.command("run").description(
3458
+ "Run a driven codex executor: claim, execute, heartbeat, and complete dispatched tasks unattended"
3459
+ ).option("--runtime <kind>", "Runtime to drive (only codex today)", "codex").option("--model <model>", "codex model override").option("--sandbox <mode>", "codex sandbox mode", "workspace-write").option(
3460
+ "--root <dir>",
3461
+ "Working directory for the spawned codex turns (e.g. ../sechroom_4)",
3462
+ process.cwd()
3463
+ ).option(
3464
+ "--codex-bin <bin>",
3465
+ "codex binary (or CODEX_BIN)",
3466
+ process.env.CODEX_BIN ?? "codex"
3467
+ ).option(
3468
+ "--instance-key <key>",
3469
+ "Registration identity (defaults to installed executor.json)"
3470
+ ).option(
3471
+ "--lane <lane>",
3472
+ "Affinity lane + completion source (defaults to instance key)"
3473
+ ).option(
3474
+ "--connector <id>",
3475
+ "Approved connector id (defaults to installed executor.json)"
3476
+ ).option(
3477
+ "--parent-id <id>",
3478
+ "Fleet node this child enrolls under (D-WLP-55 containment; set by the fleet supervisor)"
3479
+ ).option("--ttl <seconds>", "Advertisement TTL (30-600)").option("--once", "Process a single task, then exit", false).option(
3480
+ "--no-deliver",
3481
+ "Skip driver-side delivery (branch/commit/push of the turn's changes)"
3482
+ ).option(
3483
+ "--allow-dirty-root",
3484
+ "Claim even when the root has uncommitted changes (they are fenced out of the delivery commit)",
3485
+ false
3486
+ ).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option("--heartbeat-interval <seconds>", "Lease heartbeat cadence", "30").option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
3487
+ "--drain-timeout <seconds>",
3488
+ "On shutdown, seconds to let an in-flight turn finish before interrupting",
3489
+ "30"
3490
+ ).option(
3491
+ "--usage-reserve <percent>",
3492
+ "Rate-limit reserve: defer claiming new tasks while remaining is at or below this percent",
3493
+ "2"
3494
+ ).action(async (opts, cmd) => {
3495
+ if (String(opts.runtime).toLowerCase() !== "codex")
3496
+ fail(
3497
+ "executor run drives runtime codex only (claude-code stays attached)"
3498
+ );
3499
+ const located = readExecutorState();
3500
+ if (!located)
3501
+ fail(
3502
+ "executor run requires an installed executor advertisement; run `sechroom executor install` first."
3503
+ );
3504
+ if (located.state.runtime !== "codex")
3505
+ fail(
3506
+ `this checkout's executor advertisement is runtime '${located.state.runtime}' \u2014 reinstall with --runtime codex.`
3507
+ );
3508
+ located.state = {
3509
+ ...located.state,
3510
+ instanceKey: opts.instanceKey ? String(opts.instanceKey) : located.state.instanceKey,
3511
+ laneId: opts.lane ? String(opts.lane) : located.state.laneId,
3512
+ connectorId: opts.connector ? String(opts.connector) : located.state.connectorId,
3513
+ parentId: opts.parentId ? String(opts.parentId) : located.state.parentId,
3514
+ ttlSeconds: opts.ttl ? Number.parseInt(String(opts.ttl), 10) : located.state.ttlSeconds
3515
+ };
3516
+ const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
3517
+ if (heartbeatMs >= 12e4)
3518
+ fail("--heartbeat-interval must be shorter than the 120s lease TTL");
3519
+ const usageReserve = Number.parseFloat(String(opts.usageReserve));
3520
+ if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
3521
+ fail("--usage-reserve must be a percent in [0, 100)");
3522
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3523
+ const instance = await ensureExecutorInstance(cfg, located);
3524
+ const log = (line) => process.stderr.write(style.dim(`[run] ${line}
3525
+ `));
3526
+ const request = createAuthedRequest(cfg);
3527
+ const rootDir = resolve2(String(opts.root));
3528
+ const usageLogPath = join9(
3529
+ rootDir,
3530
+ ".sechroom",
3531
+ `executor-usage-${located.state.instanceKey.replace(/[^\w.-]/g, "-")}.jsonl`
3532
+ );
3533
+ const usageTracker = new UsageTracker({
3534
+ instanceKey: located.state.instanceKey,
3535
+ reservePercent: usageReserve,
3536
+ log,
3537
+ appendRecord: createUsageLogAppender(usageLogPath, log)
3538
+ });
3539
+ const appServer = new CodexAppServer({
3540
+ codexBin: String(opts.codexBin),
3541
+ cwd: rootDir,
3542
+ model: opts.model ? String(opts.model) : void 0,
3543
+ sandbox: String(opts.sandbox),
3544
+ turnTimeoutMs: Number.parseInt(String(opts.turnTimeout), 10) * 1e3,
3545
+ resumeTurnTimeoutMs: Number.parseInt(String(opts.resumeTurnTimeout), 10) * 1e3,
3546
+ log,
3547
+ executorInstanceId: instance.id,
3548
+ emitTelemetry: (decompositionId, events) => postTelemetry(cfg, decompositionId, events).then(() => void 0),
3549
+ onUsage: (taskId, usage) => usageTracker.recordUsage(taskId, usage),
3550
+ onRateLimits: (limits) => usageTracker.recordRateLimits(limits)
3551
+ });
3552
+ await appServer.start();
3553
+ log(`codex app-server up (${String(opts.codexBin)})`);
3554
+ let stopping = false;
3555
+ let turnInFlight = false;
3556
+ const requestStop = () => {
3557
+ if (stopping) return;
3558
+ stopping = true;
3559
+ wake();
3560
+ log("shutdown requested \u2014 finishing up");
3561
+ if (turnInFlight) {
3562
+ const drainMs = Number.parseInt(String(opts.drainTimeout), 10) * 1e3;
3563
+ setTimeout(() => void appServer.interrupt(), drainMs).unref?.();
3564
+ }
3565
+ };
3566
+ process.once("SIGINT", requestStop);
3567
+ process.once("SIGTERM", requestStop);
3568
+ let wake = () => {
3569
+ };
3570
+ const wakeSignal = () => new Promise((resolve5) => {
3571
+ wake = resolve5;
3572
+ });
3573
+ let connStop = async () => {
3574
+ };
3575
+ try {
3576
+ const conn = await openConnection(cfg, () => wake(), instance.id);
3577
+ connStop = () => conn.stop();
3578
+ } catch (e) {
3579
+ log(`SignalR wake leg unavailable (${String(e)}) \u2014 poll-only`);
3580
+ }
3581
+ const stopAdvertisementHeartbeat = startExecutorHeartbeat(
3582
+ () => request(
3583
+ `/me/executor-instances/${encodeURIComponent(instance.id)}/refresh`,
3584
+ {
3585
+ method: "POST",
3586
+ body: JSON.stringify({ ttlSeconds: located.state.ttlSeconds })
3587
+ }
3588
+ ),
3589
+ located.state.refreshAfterSeconds * 1e3,
3590
+ { onError: (e) => log(`advertisement refresh failed: ${String(e)}`) }
3591
+ );
3592
+ const gitRunner = createGitRunner(rootDir);
3593
+ let rootSnapshot;
3594
+ const deliveryPorts = opts.deliver === false ? {} : {
3595
+ checkRootReady: async () => {
3596
+ const ready = await checkRootReady(
3597
+ gitRunner,
3598
+ Boolean(opts.allowDirtyRoot)
3599
+ );
3600
+ rootSnapshot = ready.snapshot;
3601
+ return { ok: ready.ok, reason: ready.reason };
3602
+ },
3603
+ deliver: (claim, task, verdict) => deliverTurn(gitRunner, {
3604
+ taskId: claim.memoryId,
3605
+ title: task.title,
3606
+ verdict,
3607
+ snapshot: rootSnapshot ?? {
3608
+ baseBranch: "HEAD",
3609
+ dirtyPaths: []
3610
+ },
3611
+ deliveredBy: located.state.laneId ?? located.state.instanceKey,
3612
+ raisePr: opts.pr !== false,
3613
+ log
3614
+ })
3615
+ };
3616
+ const ports = {
3617
+ ...deliveryPorts,
3618
+ checkAdmission: async () => usageTracker.admission(),
3619
+ claimNext: () => claimNext(request, instance.id, log),
3620
+ loadTask: (memoryId) => loadTask(request, memoryId, log),
3621
+ startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
3622
+ () => request(
3623
+ `/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
3624
+ {
3625
+ method: "POST",
3626
+ body: JSON.stringify({
3627
+ claimToken: claim.claimToken,
3628
+ tokenVersion: claim.tokenVersion
3629
+ })
3630
+ }
3631
+ ),
3632
+ log,
3633
+ heartbeatMs
3634
+ ),
3635
+ runTurn: async (task, claim) => {
3636
+ if (!appServer.alive) {
3637
+ log("codex app-server died between tasks \u2014 respawning");
3638
+ await appServer.start();
3639
+ }
3640
+ turnInFlight = true;
3641
+ try {
3642
+ return await appServer.runTask(taskPrompt(task), {
3643
+ taskId: claim.memoryId,
3644
+ leaseId: claim.leaseId,
3645
+ decompositionId: claim.decompositionId
3646
+ });
3647
+ } finally {
3648
+ turnInFlight = false;
3649
+ }
3650
+ },
3651
+ completeLease: (claim, verdict, text2, title) => request(
3652
+ `/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/complete`,
3653
+ {
3654
+ method: "POST",
3655
+ body: JSON.stringify({
3656
+ executorInstanceId: instance.id,
3657
+ claimToken: claim.claimToken,
3658
+ tokenVersion: claim.tokenVersion,
3659
+ verdict,
3660
+ text: text2,
3661
+ source: located.state.laneId ?? located.state.instanceKey,
3662
+ title
3663
+ })
3664
+ }
3665
+ ),
3666
+ log,
3667
+ waitForWake: (ms) => Promise.race([
3668
+ new Promise((resolve5) => {
3669
+ setTimeout(resolve5, ms).unref?.();
3670
+ }),
3671
+ wakeSignal()
3672
+ ])
3673
+ };
3674
+ log(
3675
+ `driven executor live \u2014 instance ${located.state.instanceKey}, lane ${located.state.laneId ?? located.state.instanceKey}${opts.once ? ", single-task mode" : ""}`
3676
+ );
3677
+ log(`usage log \u2192 ${usageLogPath}; admission reserve ${usageReserve}%`);
3678
+ try {
3679
+ const summary = await runDriverLoop(ports, {
3680
+ once: Boolean(opts.once),
3681
+ pollMs: Number.parseInt(String(opts.pollInterval), 10) * 1e3,
3682
+ stopping: () => stopping,
3683
+ source: located.state.laneId ?? located.state.instanceKey
3684
+ });
3685
+ log(
3686
+ `done \u2014 processed ${summary.processed}, completed ${summary.completed}, abandoned ${summary.abandoned}`
3687
+ );
3688
+ } catch (error) {
3689
+ if (error instanceof AuthExpiredError) {
3690
+ process.stderr.write(
3691
+ style.dim(`[run] auth expired: ${error.message}
3692
+ `)
3693
+ );
3694
+ process.exitCode = 1;
3695
+ } else {
3696
+ throw error;
3697
+ }
3698
+ } finally {
3699
+ stopAdvertisementHeartbeat();
3700
+ await connStop().catch(() => {
3701
+ });
3702
+ try {
3703
+ await request(
3704
+ `/me/executor-instances/${encodeURIComponent(instance.id)}`,
3705
+ { method: "DELETE", body: JSON.stringify({}) }
3706
+ );
3707
+ log("deregistered");
3708
+ } catch (e) {
3709
+ log(
3710
+ `deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`
3711
+ );
3712
+ }
3713
+ appServer.stop();
3714
+ }
3715
+ });
3716
+ }
3717
+ async function claimNext(request, instanceId, log) {
3718
+ const claimed = await claimNextTask({
3719
+ request,
3720
+ executorInstanceId: instanceId,
3721
+ log
3722
+ });
3723
+ if (!claimed) return void 0;
3724
+ return {
3725
+ memoryId: claimed.memoryId,
3726
+ leaseId: claimed.leaseId,
3727
+ claimToken: claimed.claimToken,
3728
+ tokenVersion: claimed.tokenVersion,
3729
+ decompositionId: claimed.decompositionId
3730
+ };
3731
+ }
3732
+ async function loadTask(request, memoryId, log) {
3733
+ const card = await request(
3734
+ `/tasks/${encodeURIComponent(memoryId)}/card`
3735
+ );
3736
+ let packText = "";
3737
+ const pointer = card.contextPack;
3738
+ if (pointer?.slug && pointer.version) {
3739
+ try {
3740
+ const pkg = await request(
3741
+ `/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
3742
+ );
3743
+ packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
3744
+ ${c.body}`).join("\n\n");
3745
+ } catch (error) {
3746
+ log(
3747
+ `warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
3748
+ );
3749
+ }
3750
+ }
3751
+ return {
3752
+ title: card.title ?? memoryId,
3753
+ text: assemblePrompt(card, packText)
3754
+ };
3755
+ }
3756
+ function assemblePrompt(card, packText) {
3757
+ const sections = [
3758
+ `# Task: ${card.title}`,
3759
+ `## Objective
3760
+ ${card.task.objective}`,
3761
+ `## Acceptance
3762
+ ${card.task.acceptance}`,
3763
+ `## Boundaries
3764
+ ${card.task.boundaries}`,
3765
+ `## Closeout
3766
+ ${card.task.closeout}`
3767
+ ];
3768
+ if (packText) sections.push(`## Context pack
3769
+ ${packText}`);
3770
+ return sections.join("\n\n");
3771
+ }
3772
+ function taskPrompt(task) {
3773
+ return `You are a driven executor working ONE dispatched Work Layer task.
3774
+
3775
+ ${task.text}
3776
+
3777
+ Call sechroom_lifecycle_signal at each phase boundary (start/work/verify/closeout). When the work is done \u2014 or honestly cannot be \u2014 end by calling sechroom_closeout with the true terminal_status, a summary, and evidence. Never end the turn without calling sechroom_closeout.`;
3778
+ }
3779
+
3780
+ // src/commands/executor.ts
3781
+ function executorSubscriptionInput(name) {
3782
+ return {
3783
+ name,
3784
+ enabled: true,
3785
+ filter: { tags: ["kind:task"], workspaceScope: [] }
1929
3786
  };
1930
3787
  }
1931
3788
  function executorRegistrationInput(state, deliverySubscriptionId) {
@@ -1938,7 +3795,10 @@ function executorRegistrationInput(state, deliverySubscriptionId) {
1938
3795
  deliverySubscriptionId,
1939
3796
  connectorId: state.connectorId,
1940
3797
  claimedCapabilityKeys: state.capabilityKeys,
3798
+ claimPolicy: parseClaimPolicy(state.claimPolicy),
3799
+ claimTags: state.claimTags ?? [],
1941
3800
  toolSetRef: null,
3801
+ parentId: state.parentId ?? null,
1942
3802
  ttlSeconds: state.ttlSeconds
1943
3803
  };
1944
3804
  }
@@ -1973,6 +3833,13 @@ function registerExecutor(program2) {
1973
3833
  ).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
1974
3834
  "--capability <key...>",
1975
3835
  "Capability operation keys claimed by this instance"
3836
+ ).option(
3837
+ "--claim-policy <policy>",
3838
+ "open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
3839
+ "open"
3840
+ ).option(
3841
+ "--claim-tag <tag...>",
3842
+ "Task tag this instance accepts under --claim-policy restricted"
1976
3843
  ).option(
1977
3844
  "--relay <id>",
1978
3845
  "Relay identity shared by sibling instances",
@@ -2043,13 +3910,14 @@ function registerExecutor(program2) {
2043
3910
  if (!opts.yes && !canPrompt())
2044
3911
  fail("non-interactive executor install requires --yes");
2045
3912
  parseRuntimeKind(runtime);
3913
+ parseClaimPolicy(opts.claimPolicy);
2046
3914
  if (!["claude", "codex"].includes(surface))
2047
3915
  fail("surface must be claude or codex");
2048
3916
  if (opts.refreshAfter >= opts.ttl)
2049
3917
  fail("refresh-after must be shorter than the TTL");
2050
3918
  const sem = readSem();
2051
- const checkout = sem ? dirname5(dirname5(sem.path)) : process.cwd();
2052
- const statePath = join8(checkout, ".sechroom", EXECUTOR_STATE);
3919
+ const checkout = sem ? dirname7(dirname7(sem.path)) : process.cwd();
3920
+ const statePath = join10(checkout, ".sechroom", EXECUTOR_STATE);
2053
3921
  const state = {
2054
3922
  schemaVersion: 1,
2055
3923
  instanceKey,
@@ -2057,20 +3925,22 @@ function registerExecutor(program2) {
2057
3925
  runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
2058
3926
  connectorId: connector,
2059
3927
  capabilityKeys: capabilities ?? [],
3928
+ claimPolicy: (opts.claimPolicy ?? "open").toLowerCase() === "restricted" ? "restricted" : "open",
3929
+ claimTags: opts.claimTag ?? [],
2060
3930
  relayId: opts.relay,
2061
3931
  subscriptionName: opts.subscriptionName,
2062
3932
  ttlSeconds: opts.ttl,
2063
3933
  refreshAfterSeconds: opts.refreshAfter
2064
3934
  };
2065
3935
  if (!opts.dryRun) {
2066
- mkdirSync6(dirname5(statePath), { recursive: true });
2067
- writeFileSync6(statePath, JSON.stringify(state, null, 2) + "\n");
3936
+ mkdirSync8(dirname7(statePath), { recursive: true });
3937
+ writeFileSync7(statePath, JSON.stringify(state, null, 2) + "\n");
2068
3938
  ensureStateDirIgnored(checkout);
2069
3939
  }
2070
3940
  const configuredClaudeDirs = globals.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR ? resolveClaudeTargets({ override: globals.claudeConfigDir }).map(
2071
3941
  (target) => target.dir
2072
- ) : [join8(checkout, ".claude")];
2073
- const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join8(checkout, ".codex")];
3942
+ ) : [join10(checkout, ".claude")];
3943
+ const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join10(checkout, ".codex")];
2074
3944
  const hookTargets = surface === "claude" ? configuredClaudeDirs : configuredCodexHomes;
2075
3945
  for (const target of hookTargets) {
2076
3946
  const results = surface === "claude" ? [
@@ -2114,7 +3984,7 @@ function registerExecutor(program2) {
2114
3984
  );
2115
3985
  delete located.state.instanceId;
2116
3986
  delete located.state.lastRefreshAt;
2117
- writeFileSync6(
3987
+ writeFileSync7(
2118
3988
  located.path,
2119
3989
  JSON.stringify(located.state, null, 2) + "\n"
2120
3990
  );
@@ -2170,7 +4040,17 @@ function registerExecutor(program2) {
2170
4040
  ).option(
2171
4041
  "--capability <key...>",
2172
4042
  "Capability operation keys claimed by this instance"
2173
- ).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
4043
+ ).option(
4044
+ "--claim-policy <policy>",
4045
+ "open | restricted (restricted only claims preferred/own-lane/allow-listed-tag work)",
4046
+ "open"
4047
+ ).option(
4048
+ "--claim-tag <tag...>",
4049
+ "Task tag this instance accepts under --claim-policy restricted"
4050
+ ).option("--tool-set-ref <ref>", "Optional governed tool-set reference").option(
4051
+ "--parent-id <id>",
4052
+ "Fleet node this instance enrolls under (D-WLP-55 containment; sets the roster parentId)"
4053
+ ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
2174
4054
  const cfg = resolveConfig(cmd.optsWithGlobals());
2175
4055
  const subscription = await api(
2176
4056
  cfg,
@@ -2200,7 +4080,10 @@ function registerExecutor(program2) {
2200
4080
  deliverySubscriptionId: subscription.id,
2201
4081
  connectorId: opts.connector,
2202
4082
  claimedCapabilityKeys: opts.capability ?? [],
4083
+ claimPolicy: parseClaimPolicy(opts.claimPolicy),
4084
+ claimTags: opts.claimTag ?? [],
2203
4085
  toolSetRef: opts.toolSetRef ?? null,
4086
+ parentId: opts.parentId ?? null,
2204
4087
  ttlSeconds: opts.ttl
2205
4088
  })
2206
4089
  }
@@ -2253,6 +4136,7 @@ function registerExecutor(program2) {
2253
4136
  );
2254
4137
  emit(data, Boolean(cmd.optsWithGlobals().json));
2255
4138
  });
4139
+ registerExecutorRunCommand(executor);
2256
4140
  }
2257
4141
  function parseRuntimeKind(value) {
2258
4142
  switch (value.trim().toLowerCase()) {
@@ -2265,6 +4149,17 @@ function parseRuntimeKind(value) {
2265
4149
  return fail("runtime must be claude-code or codex");
2266
4150
  }
2267
4151
  }
4152
+ function parseClaimPolicy(value) {
4153
+ switch ((value ?? "open").trim().toLowerCase()) {
4154
+ case "":
4155
+ case "open":
4156
+ return "Open";
4157
+ case "restricted":
4158
+ return "Restricted";
4159
+ default:
4160
+ return fail("claim-policy must be open or restricted");
4161
+ }
4162
+ }
2268
4163
  function parseTransport(value) {
2269
4164
  switch (value.trim().toLowerCase()) {
2270
4165
  case "push":
@@ -2285,18 +4180,53 @@ async function refreshExecutorInstance(cfg, id, ttlSeconds) {
2285
4180
  }
2286
4181
  );
2287
4182
  }
2288
- async function registerInstance(cfg, state) {
4183
+ async function registerInstance(cfg, state) {
4184
+ const subscription = await api(
4185
+ cfg,
4186
+ "/me/delivery-subscriptions/signalr",
4187
+ {
4188
+ method: "POST",
4189
+ body: JSON.stringify(executorSubscriptionInput(state.subscriptionName))
4190
+ }
4191
+ );
4192
+ return api(cfg, "/me/executor-instances", {
4193
+ method: "POST",
4194
+ body: JSON.stringify(executorRegistrationInput(state, subscription.id))
4195
+ });
4196
+ }
4197
+ async function registerFleetNode(cfg, node) {
4198
+ const subscriptionName = node.subscriptionName ?? "executor-dispatch";
2289
4199
  const subscription = await api(
2290
4200
  cfg,
2291
4201
  "/me/delivery-subscriptions/signalr",
2292
4202
  {
2293
4203
  method: "POST",
2294
- body: JSON.stringify(executorSubscriptionInput(state.subscriptionName))
4204
+ body: JSON.stringify(executorSubscriptionInput(subscriptionName))
2295
4205
  }
2296
4206
  );
2297
4207
  return api(cfg, "/me/executor-instances", {
2298
4208
  method: "POST",
2299
- body: JSON.stringify(executorRegistrationInput(state, subscription.id))
4209
+ body: JSON.stringify({
4210
+ relayId: node.relay ?? "sechroom-cli-fleet",
4211
+ instanceKey: node.instanceKey,
4212
+ laneId: node.lane ?? node.instanceKey,
4213
+ runtimeKind: "Node",
4214
+ activationMode: "Attached",
4215
+ deliverySubscriptionId: subscription.id,
4216
+ connectorId: node.connector,
4217
+ claimedCapabilityKeys: [],
4218
+ claimPolicy: "Open",
4219
+ claimTags: [],
4220
+ toolSetRef: null,
4221
+ parentId: null,
4222
+ ttlSeconds: node.ttl ?? 120
4223
+ })
4224
+ });
4225
+ }
4226
+ async function deregisterInstance(cfg, id) {
4227
+ await api(cfg, `/me/executor-instances/${encodeURIComponent(id)}`, {
4228
+ method: "DELETE",
4229
+ body: JSON.stringify({})
2300
4230
  });
2301
4231
  }
2302
4232
  async function ensureExecutorInstance(cfg, located) {
@@ -2305,19 +4235,19 @@ async function ensureExecutorInstance(cfg, located) {
2305
4235
  const data = await registerInstance(cfg, state);
2306
4236
  state.instanceId = data.id;
2307
4237
  state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
2308
- writeFileSync6(path, JSON.stringify(state, null, 2) + "\n");
4238
+ writeFileSync7(path, JSON.stringify(state, null, 2) + "\n");
2309
4239
  return data;
2310
4240
  }
2311
4241
  function readExecutorState(start = process.cwd()) {
2312
4242
  const semPath = resolveSemPathForRead(start);
2313
4243
  const sem = semPath ? readSem(semPath) : void 0;
2314
- const path = join8(
2315
- sem ? dirname5(sem.path) : join8(start, ".sechroom"),
4244
+ const path = join10(
4245
+ sem ? dirname7(sem.path) : join10(start, ".sechroom"),
2316
4246
  EXECUTOR_STATE
2317
4247
  );
2318
- if (!existsSync7(path)) return void 0;
4248
+ if (!existsSync8(path)) return void 0;
2319
4249
  return {
2320
- state: JSON.parse(readFileSync5(path, "utf8")),
4250
+ state: JSON.parse(readFileSync6(path, "utf8")),
2321
4251
  path
2322
4252
  };
2323
4253
  }
@@ -2349,11 +4279,11 @@ function parseInteger(value) {
2349
4279
  return parsed;
2350
4280
  }
2351
4281
  function holdHeartbeat(tick, intervalMs) {
2352
- return new Promise((resolve3, reject) => {
4282
+ return new Promise((resolve5, reject) => {
2353
4283
  const timer = setInterval(() => void tick().catch(reject), intervalMs);
2354
4284
  const stop = () => {
2355
4285
  clearInterval(timer);
2356
- resolve3();
4286
+ resolve5();
2357
4287
  };
2358
4288
  process.once("SIGINT", stop);
2359
4289
  process.once("SIGTERM", stop);
@@ -2504,7 +4434,7 @@ function registerChannel(program2) {
2504
4434
  "MCP server + subscription name (idempotent per name)",
2505
4435
  "sechroom-channel"
2506
4436
  ).option("--dry-run", "Print what would change; write nothing").action((opts) => {
2507
- const path = join9(process.cwd(), ".mcp.json");
4437
+ const path = join11(process.cwd(), ".mcp.json");
2508
4438
  const dryRun = Boolean(opts.dryRun);
2509
4439
  const args = ["channel", "mcp"];
2510
4440
  const entry = { command: "sechroom", args };
@@ -2514,8 +4444,8 @@ function registerChannel(program2) {
2514
4444
  const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
2515
4445
  if (status !== "current" && !dryRun) {
2516
4446
  config2.mcpServers[opts.name] = entry;
2517
- mkdirSync7(dirname6(path), { recursive: true });
2518
- writeFileSync7(path, JSON.stringify(config2, null, 2) + "\n");
4447
+ mkdirSync9(dirname8(path), { recursive: true });
4448
+ writeFileSync8(path, JSON.stringify(config2, null, 2) + "\n");
2519
4449
  }
2520
4450
  const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
2521
4451
  process.stdout.write(`${style.green("channel")} ${path} (${verb})
@@ -2606,7 +4536,7 @@ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}
2606
4536
  }
2607
4537
  async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
2608
4538
  const request = dependencies.request ?? api;
2609
- const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)));
4539
+ const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve5) => setTimeout(resolve5, milliseconds)));
2610
4540
  const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
2611
4541
  const state = dependencies.state ?? {};
2612
4542
  for (; ; ) {
@@ -2651,8 +4581,8 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
2651
4581
  }
2652
4582
  }
2653
4583
  function readMcpConfig(path) {
2654
- if (!existsSync8(path)) return {};
2655
- const raw = readFileSync6(path, "utf8");
4584
+ if (!existsSync9(path)) return {};
4585
+ const raw = readFileSync7(path, "utf8");
2656
4586
  if (!raw.trim()) return {};
2657
4587
  try {
2658
4588
  return JSON.parse(raw);
@@ -2680,9 +4610,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
2680
4610
  return conn;
2681
4611
  }
2682
4612
  function holdOpen(conn) {
2683
- return new Promise((resolve3) => {
4613
+ return new Promise((resolve5) => {
2684
4614
  const stop = () => {
2685
- void conn.stop().finally(resolve3);
4615
+ void conn.stop().finally(resolve5);
2686
4616
  };
2687
4617
  process.on("SIGINT", stop);
2688
4618
  process.on("SIGTERM", stop);
@@ -2808,20 +4738,20 @@ Examples:
2808
4738
  }
2809
4739
 
2810
4740
  // src/commands/checkpoint.ts
2811
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9 } from "fs";
2812
- import { dirname as dirname8, join as join11 } from "path";
4741
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
4742
+ import { dirname as dirname10, join as join13 } from "path";
2813
4743
 
2814
4744
  // src/commands/hook.ts
2815
4745
  import { createHash as createHash2 } from "crypto";
2816
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "fs";
2817
- import { dirname as dirname7, join as join10 } from "path";
2818
- async function readStdin() {
4746
+ import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, statSync as statSync2, writeFileSync as writeFileSync9 } from "fs";
4747
+ import { dirname as dirname9, join as join12 } from "path";
4748
+ async function readStdin2() {
2819
4749
  if (process.stdin.isTTY) return "";
2820
4750
  const chunks = [];
2821
4751
  for await (const chunk of process.stdin) chunks.push(chunk);
2822
4752
  return Buffer.concat(chunks).toString("utf8");
2823
4753
  }
2824
- function parseHookInput(raw) {
4754
+ function parseHookInput2(raw) {
2825
4755
  if (!raw.trim()) return {};
2826
4756
  try {
2827
4757
  return JSON.parse(raw);
@@ -2838,13 +4768,13 @@ function resolveLane(flagLane, cwd) {
2838
4768
  if (!base) return void 0;
2839
4769
  return applyWorktreeLaneSuffix(base, start);
2840
4770
  }
2841
- var INTENT_FILE = join10(".sechroom", "continuity.json");
4771
+ var INTENT_FILE = join12(".sechroom", "continuity.json");
2842
4772
  function resolveIntentPath(start) {
2843
4773
  let dir = start;
2844
4774
  for (; ; ) {
2845
- const candidate = join10(dir, INTENT_FILE);
2846
- if (existsSync9(candidate)) return candidate;
2847
- const parent = dirname7(dir);
4775
+ const candidate = join12(dir, INTENT_FILE);
4776
+ if (existsSync10(candidate)) return candidate;
4777
+ const parent = dirname9(dir);
2848
4778
  if (parent === dir) return void 0;
2849
4779
  dir = parent;
2850
4780
  }
@@ -2853,7 +4783,7 @@ function readIntent(start) {
2853
4783
  const path = resolveIntentPath(start);
2854
4784
  if (!path) return void 0;
2855
4785
  try {
2856
- return JSON.parse(readFileSync7(path, "utf8"));
4786
+ return JSON.parse(readFileSync8(path, "utf8"));
2857
4787
  } catch {
2858
4788
  return void 0;
2859
4789
  }
@@ -2895,14 +4825,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
2895
4825
  }
2896
4826
  function ledgerPath(start) {
2897
4827
  const intent = resolveIntentPath(start);
2898
- const dir = intent ? dirname7(intent) : join10(start, ".sechroom");
2899
- return join10(dir, ".checkpoint-state.json");
4828
+ const dir = intent ? dirname9(intent) : join12(start, ".sechroom");
4829
+ return join12(dir, ".checkpoint-state.json");
2900
4830
  }
2901
4831
  function readLedger(start) {
2902
4832
  try {
2903
4833
  const p = ledgerPath(start);
2904
- if (!existsSync9(p)) return {};
2905
- return JSON.parse(readFileSync7(p, "utf8"));
4834
+ if (!existsSync10(p)) return {};
4835
+ return JSON.parse(readFileSync8(p, "utf8"));
2906
4836
  } catch {
2907
4837
  return {};
2908
4838
  }
@@ -2949,13 +4879,13 @@ function recordPush(start, intent) {
2949
4879
  } catch {
2950
4880
  mtimeMs = void 0;
2951
4881
  }
2952
- mkdirSync8(dirname7(p), { recursive: true });
4882
+ mkdirSync10(dirname9(p), { recursive: true });
2953
4883
  const ledger = {
2954
4884
  lastEpochMs: Date.now(),
2955
4885
  lastMtimeMs: mtimeMs,
2956
4886
  lastHash: intentHash(intent)
2957
4887
  };
2958
- writeFileSync8(p, JSON.stringify(ledger) + "\n");
4888
+ writeFileSync9(p, JSON.stringify(ledger) + "\n");
2959
4889
  } catch {
2960
4890
  }
2961
4891
  }
@@ -3015,8 +4945,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3015
4945
  );
3016
4946
  hook.command("session-start").description("Resume the checkout's lane and emit continuity context for a SessionStart hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sechroom/lane.json code-lane)").option("--surface <surface>", "Target surface: claude | codex (output is identical for session-start)", "claude").option("--max-artifacts <n>", "Cap artifacts in the resume bundle").action(async (opts, cmd) => {
3017
4947
  try {
3018
- const raw = await readStdin();
3019
- const input = parseHookInput(raw);
4948
+ const raw = await readStdin2();
4949
+ const input = parseHookInput2(raw);
3020
4950
  const lane = resolveLane(opts.lane, input.cwd);
3021
4951
  if (!lane) return process.exit(0);
3022
4952
  const semPath = resolveSemPathForRead(input.cwd ?? process.cwd());
@@ -3041,8 +4971,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3041
4971
  });
3042
4972
  hook.command("pre-compact").description("Save a continuity snapshot from the agent-maintained intent file on a PreCompact hook").option("--lane <laneId>", "Override the resolved lane (else SECHROOM_LANE, else ./.sechroom/lane.json code-lane)").option("--scope <scope>", "Snapshot scope (else the intent file's `scope`, else 'compaction')").option("--surface <surface>", "Target surface: claude | codex (lifecycle-only on both)", "claude").action(async (opts, cmd) => {
3043
4973
  try {
3044
- const raw = await readStdin();
3045
- const input = parseHookInput(raw);
4974
+ const raw = await readStdin2();
4975
+ const input = parseHookInput2(raw);
3046
4976
  const cwd = input.cwd ?? process.cwd();
3047
4977
  await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
3048
4978
  return process.exit(0);
@@ -3055,8 +4985,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3055
4985
  "skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
3056
4986
  ).action(async (opts, cmd) => {
3057
4987
  try {
3058
- const raw = await readStdin();
3059
- const input = parseHookInput(raw);
4988
+ const raw = await readStdin2();
4989
+ const input = parseHookInput2(raw);
3060
4990
  const cwd = input.cwd ?? process.cwd();
3061
4991
  const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
3062
4992
  if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
@@ -3208,10 +5138,10 @@ Examples:
3208
5138
  const client = await makeClient(cfg);
3209
5139
  return client.POST("/continuity/snapshots", { body });
3210
5140
  });
3211
- const path = resolveIntentPath(cwd) ?? join11(cwd, INTENT_FILE);
5141
+ const path = resolveIntentPath(cwd) ?? join13(cwd, INTENT_FILE);
3212
5142
  const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
3213
- mkdirSync9(dirname8(path), { recursive: true });
3214
- writeFileSync9(path, JSON.stringify(fileBody, null, 2) + "\n");
5143
+ mkdirSync11(dirname10(path), { recursive: true });
5144
+ writeFileSync10(path, JSON.stringify(fileBody, null, 2) + "\n");
3215
5145
  recordPush(cwd, merged);
3216
5146
  if (json) {
3217
5147
  emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
@@ -3225,7 +5155,7 @@ Examples:
3225
5155
  }
3226
5156
 
3227
5157
  // src/commands/close.ts
3228
- import { readFileSync as readFileSync8 } from "fs";
5158
+ import { readFileSync as readFileSync9 } from "fs";
3229
5159
  var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
3230
5160
  function registerClose(program2) {
3231
5161
  program2.command("close").description(
@@ -3266,7 +5196,7 @@ Examples:
3266
5196
  );
3267
5197
  let bodyText;
3268
5198
  try {
3269
- bodyText = opts.file ? readFileSync8(opts.file, "utf8") : readFileSync8(0, "utf8");
5199
+ bodyText = opts.file ? readFileSync9(opts.file, "utf8") : readFileSync9(0, "utf8");
3270
5200
  } catch {
3271
5201
  fail(
3272
5202
  opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
@@ -3557,7 +5487,7 @@ Examples:
3557
5487
  }
3558
5488
 
3559
5489
  // src/commands/work-plan.ts
3560
- import { readFile } from "fs/promises";
5490
+ import { readFile as readFile2 } from "fs/promises";
3561
5491
  function registerWorkPlan(program2) {
3562
5492
  const workPlan = program2.command("work-plan").description(
3563
5493
  "Drive a work plan: create one from a brief, then execute / accept / reject"
@@ -3572,6 +5502,9 @@ Examples:
3572
5502
  $ sechroom work-plan get-plan wlp_XXXX
3573
5503
  $ sechroom work-plan execute wlp_XXXX
3574
5504
  $ sechroom work-plan publish-context-pack wlp_XXXX
5505
+ $ sechroom work-plan resume wlp_XXXX
5506
+ $ sechroom work-plan return-for-revision wlp_XXXX --notes "Split the migration task"
5507
+ $ sechroom work-plan list --status Accepted --brief mem_XXXX
3575
5508
  $ sechroom work-plan accept wlp_XXXX
3576
5509
  $ sechroom work-plan reject wlp_XXXX --reason "wrong shape"`
3577
5510
  );
@@ -3598,19 +5531,16 @@ Examples:
3598
5531
  "--file <path>",
3599
5532
  "JSON file containing { project, tasks, source? }; use - for stdin"
3600
5533
  ).action(async (briefId, opts, cmd) => {
3601
- const raw = opts.file === "-" ? await readStdin2() : await readFile(opts.file, "utf8");
5534
+ const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
3602
5535
  const body = parsePlanInput(raw, opts.file);
3603
5536
  const cfg = resolveConfig(cmd.optsWithGlobals());
3604
- const data = await runApi(
3605
- "Creating work plan from tasks",
3606
- async () => {
3607
- const client = await makeClient(cfg);
3608
- return client.POST("/work-briefs/{id}/decompose-from-plan", {
3609
- params: { path: { id: briefId } },
3610
- body
3611
- });
3612
- }
3613
- );
5537
+ const data = await runApi("Creating work plan from tasks", async () => {
5538
+ const client = await makeClient(cfg);
5539
+ return client.POST("/work-briefs/{id}/decompose-from-plan", {
5540
+ params: { path: { id: briefId } },
5541
+ body
5542
+ });
5543
+ });
3614
5544
  emitAction(
3615
5545
  `created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
3616
5546
  data,
@@ -3623,7 +5553,7 @@ Examples:
3623
5553
  "--file <path>",
3624
5554
  "JSON file containing { tasks, gates? } (the AppendTasksInput shape); use - for stdin"
3625
5555
  ).action(async (decompositionId, opts, cmd) => {
3626
- const raw = opts.file === "-" ? await readStdin2() : await readFile(opts.file, "utf8");
5556
+ const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
3627
5557
  const body = parseAppendInput(raw, opts.file);
3628
5558
  const cfg = resolveConfig(cmd.optsWithGlobals());
3629
5559
  const data = await runApi("Appending tasks to work plan", async () => {
@@ -3689,6 +5619,70 @@ Examples:
3689
5619
  cmd.optsWithGlobals().json
3690
5620
  );
3691
5621
  });
5622
+ workPlan.command("resume <decompositionId>").description(
5623
+ "Resume a failed work-plan decomposition (POST /decompositions/{id}/resume)"
5624
+ ).action(async (decompositionId, _opts, cmd) => {
5625
+ const globals = cmd.optsWithGlobals();
5626
+ const cfg = resolveConfig(globals);
5627
+ const data = await runApi("Resuming work plan", async () => {
5628
+ const client = await makeClient(cfg);
5629
+ return client.POST("/decompositions/{id}/resume", {
5630
+ params: { path: { id: decompositionId } },
5631
+ body: {}
5632
+ });
5633
+ });
5634
+ emitAction(
5635
+ `resumed work plan ${style.bold(decompositionId)} \u2192 ${data.status}`,
5636
+ data,
5637
+ globals.json
5638
+ );
5639
+ });
5640
+ workPlan.command("return-for-revision <decompositionId>").description(
5641
+ "Return a candidate work plan for revision (POST /decompositions/{id}/return-for-revision)"
5642
+ ).requiredOption(
5643
+ "--notes <text>",
5644
+ "Revision notes for the next decomposition attempt"
5645
+ ).action(async (decompositionId, opts, cmd) => {
5646
+ const globals = cmd.optsWithGlobals();
5647
+ const cfg = resolveConfig(globals);
5648
+ const data = await runApi(
5649
+ "Returning work plan for revision",
5650
+ async () => {
5651
+ const client = await makeClient(cfg);
5652
+ return client.POST("/decompositions/{id}/return-for-revision", {
5653
+ params: { path: { id: decompositionId } },
5654
+ body: { notes: opts.notes }
5655
+ });
5656
+ }
5657
+ );
5658
+ emitAction(
5659
+ `returned ${style.bold(decompositionId)} \u2192 ${style.bold(data.newSuggestionId)}`,
5660
+ data,
5661
+ globals.json
5662
+ );
5663
+ });
5664
+ workPlan.command("list").description("List work plans, newest-first (GET /decompositions)").option("--status <status>", "Filter by decomposition status").option("--brief <briefId>", "Filter by work-brief memory id").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option("--page-size <n>", "Page size", (v) => Number.parseInt(v, 10)).action(async (opts, cmd) => {
5665
+ const globals = cmd.optsWithGlobals();
5666
+ const cfg = resolveConfig(globals);
5667
+ const data = await runApi("Listing work plans", async () => {
5668
+ const client = await makeClient(cfg);
5669
+ return client.GET("/decompositions", {
5670
+ params: {
5671
+ query: {
5672
+ status: opts.status,
5673
+ briefId: opts.brief,
5674
+ page: opts.page,
5675
+ pageSize: opts.pageSize
5676
+ }
5677
+ }
5678
+ });
5679
+ });
5680
+ emitAction(
5681
+ `listed ${style.bold(String(data.items.length))} of ${data.count} work plan(s)`,
5682
+ data,
5683
+ globals.json
5684
+ );
5685
+ });
3692
5686
  workPlan.command("accept <decompositionId>").description(
3693
5687
  "Accept a Pending work plan \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
3694
5688
  ).action(async (decompositionId, _opts, cmd) => {
@@ -3750,7 +5744,7 @@ function parsePlanInput(raw, sourceName = "plan input") {
3750
5744
  throw new Error(`${sourceName} must contain an object with a tasks array`);
3751
5745
  return value;
3752
5746
  }
3753
- async function readStdin2() {
5747
+ async function readStdin3() {
3754
5748
  const chunks = [];
3755
5749
  for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
3756
5750
  return Buffer.concat(chunks).toString("utf8");
@@ -4267,8 +6261,8 @@ Examples:
4267
6261
 
4268
6262
  // src/setup/apply.ts
4269
6263
  import { createHash as createHash3 } from "crypto";
4270
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync9, writeFileSync as writeFileSync10, existsSync as existsSync10 } from "fs";
4271
- import { dirname as dirname9 } from "path";
6264
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync11, existsSync as existsSync11 } from "fs";
6265
+ import { dirname as dirname11 } from "path";
4272
6266
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
4273
6267
  var MARKER_END = "<!-- @sechroom/cli:end";
4274
6268
  function normalizeBody(s) {
@@ -4321,22 +6315,22 @@ function parseManagedBlock(content, block) {
4321
6315
  return null;
4322
6316
  }
4323
6317
  function ensureDir2(path) {
4324
- mkdirSync10(dirname9(path), { recursive: true });
6318
+ mkdirSync12(dirname11(path), { recursive: true });
4325
6319
  }
4326
6320
  function readOr(path, fallback) {
4327
6321
  try {
4328
- return readFileSync9(path, "utf8");
6322
+ return readFileSync10(path, "utf8");
4329
6323
  } catch {
4330
6324
  return fallback;
4331
6325
  }
4332
6326
  }
4333
6327
  function mergeMcpJson(path, snippet, dryRun) {
4334
6328
  const incoming = JSON.parse(snippet);
4335
- const existed = existsSync10(path);
6329
+ const existed = existsSync11(path);
4336
6330
  let current = {};
4337
6331
  if (existed) {
4338
6332
  try {
4339
- current = JSON.parse(readFileSync9(path, "utf8"));
6333
+ current = JSON.parse(readFileSync10(path, "utf8"));
4340
6334
  } catch {
4341
6335
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
4342
6336
  }
@@ -4344,26 +6338,26 @@ function mergeMcpJson(path, snippet, dryRun) {
4344
6338
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
4345
6339
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
4346
6340
  ensureDir2(path);
4347
- writeFileSync10(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
6341
+ writeFileSync11(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
4348
6342
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
4349
6343
  }
4350
6344
  function mergeCodexToml(path, snippet, dryRun) {
4351
- const existed = existsSync10(path);
6345
+ const existed = existsSync11(path);
4352
6346
  let body = readOr(path, "");
4353
6347
  body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
4354
6348
  const trimmed = body.trim();
4355
6349
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
4356
6350
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
4357
6351
  ensureDir2(path);
4358
- writeFileSync10(path, next, { mode: 384 });
6352
+ writeFileSync11(path, next, { mode: 384 });
4359
6353
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
4360
6354
  }
4361
6355
  function writeInstructionBlock(path, write, dryRun) {
4362
- const existed = existsSync10(path);
6356
+ const existed = existsSync11(path);
4363
6357
  const next = computeBlockFile(readOr(path, ""), write);
4364
6358
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
4365
6359
  ensureDir2(path);
4366
- writeFileSync10(path, next);
6360
+ writeFileSync11(path, next);
4367
6361
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
4368
6362
  }
4369
6363
  function computeBlockFile(current, write) {
@@ -4404,7 +6398,7 @@ function applyBlock(path, write, mode, dryRun) {
4404
6398
  const next = computeBlockFile(current, write);
4405
6399
  if (!dryRun) {
4406
6400
  ensureDir2(proposedPath);
4407
- writeFileSync10(proposedPath, next);
6401
+ writeFileSync11(proposedPath, next);
4408
6402
  }
4409
6403
  return {
4410
6404
  kind: "instruction",
@@ -4534,8 +6528,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
4534
6528
  }
4535
6529
 
4536
6530
  // src/setup/skills-offer.ts
4537
- import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync11 } from "fs";
4538
- import { join as join12 } from "path";
6531
+ import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync12 } from "fs";
6532
+ import { join as join14 } from "path";
4539
6533
 
4540
6534
  // src/setup/lane-pin.ts
4541
6535
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -4651,8 +6645,8 @@ Found ${summary} available to you for ${surface}.
4651
6645
  if (skills.length > 0) {
4652
6646
  const written = [];
4653
6647
  for (const s of skills) {
4654
- mkdirSync11(join12(sDir, s.name), { recursive: true });
4655
- writeFileSync11(join12(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
6648
+ mkdirSync13(join14(sDir, s.name), { recursive: true });
6649
+ writeFileSync12(join14(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
4656
6650
  written.push(s.name);
4657
6651
  }
4658
6652
  recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -4660,11 +6654,11 @@ Found ${summary} available to you for ${surface}.
4660
6654
  `);
4661
6655
  }
4662
6656
  if (agents.length > 0) {
4663
- mkdirSync11(aDir, { recursive: true });
6657
+ mkdirSync13(aDir, { recursive: true });
4664
6658
  const written = [];
4665
6659
  for (const a of agents) {
4666
6660
  const file = `${a.name}.md`;
4667
- writeFileSync11(join12(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
6661
+ writeFileSync12(join14(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
4668
6662
  written.push(file);
4669
6663
  }
4670
6664
  recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -4999,12 +6993,12 @@ Examples:
4999
6993
  });
5000
6994
  emit(data, cmd.optsWithGlobals().json);
5001
6995
  });
5002
- 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) => {
6996
+ 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 (slug2, _opts, cmd) => {
5003
6997
  const cfg = resolveConfig(cmd.optsWithGlobals());
5004
6998
  const data = await runApi("Fetching namespace", async () => {
5005
6999
  const client = await makeClient(cfg);
5006
7000
  return client.GET("/mcp-aggregator/namespaces/{slug}", {
5007
- params: { path: { slug } }
7001
+ params: { path: { slug: slug2 } }
5008
7002
  });
5009
7003
  });
5010
7004
  emit(data, cmd.optsWithGlobals().json);
@@ -5013,11 +7007,11 @@ Examples:
5013
7007
  "--client <list>",
5014
7008
  `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
5015
7009
  DEFAULT_CLIENT_KEY
5016
- ).option("--dry-run", "print what would be written without writing", false).action(async (slug, opts, cmd) => {
7010
+ ).option("--dry-run", "print what would be written without writing", false).action(async (slug2, opts, cmd) => {
5017
7011
  const cfg = resolveConfig(cmd.optsWithGlobals());
5018
7012
  const setup = await withSpinner(
5019
7013
  "Fetching setup descriptors",
5020
- () => fetchSetup(cfg, slug)
7014
+ () => fetchSetup(cfg, slug2)
5021
7015
  );
5022
7016
  const targets = clientTargets(process.cwd());
5023
7017
  const keys = resolveClientKeys(opts.client);
@@ -5035,25 +7029,25 @@ Examples:
5035
7029
  if (!json) printActions(target, actions);
5036
7030
  }
5037
7031
  if (json) {
5038
- emit({ namespace: slug, dryRun: Boolean(opts.dryRun), clients: result }, true);
7032
+ emit({ namespace: slug2, dryRun: Boolean(opts.dryRun), clients: result }, true);
5039
7033
  return;
5040
7034
  }
5041
7035
  process.stdout.write(
5042
7036
  opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
5043
- Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it up.
7037
+ Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it up.
5044
7038
  `
5045
7039
  );
5046
7040
  });
5047
7041
  }
5048
7042
 
5049
7043
  // src/commands/onboard.ts
5050
- import { existsSync as existsSync12 } from "fs";
5051
- import { basename as basename2, join as join14 } from "path";
7044
+ import { existsSync as existsSync13 } from "fs";
7045
+ import { basename as basename2, join as join16 } from "path";
5052
7046
 
5053
7047
  // src/commands/fanout.ts
5054
7048
  import { spawnSync } from "child_process";
5055
- import { existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
5056
- import { isAbsolute, join as join13, resolve } from "path";
7049
+ import { existsSync as existsSync12, readFileSync as readFileSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
7050
+ import { isAbsolute, join as join15, resolve as resolve3 } from "path";
5057
7051
  var ICON = {
5058
7052
  refresh: "\u21BB",
5059
7053
  bind: "+",
@@ -5061,7 +7055,7 @@ var ICON = {
5061
7055
  "skip-unbound": "\u26A0"
5062
7056
  };
5063
7057
  function resolveChildDir(path, root) {
5064
- return isAbsolute(path) ? path : resolve(root, path);
7058
+ return isAbsolute(path) ? path : resolve3(root, path);
5065
7059
  }
5066
7060
  function discoverChildren(root) {
5067
7061
  let names;
@@ -5073,21 +7067,21 @@ function discoverChildren(root) {
5073
7067
  const out = [];
5074
7068
  for (const name of names.sort()) {
5075
7069
  if (name.startsWith(".") || name === "node_modules") continue;
5076
- const dir = join13(root, name);
7070
+ const dir = join15(root, name);
5077
7071
  try {
5078
7072
  if (!statSync3(dir).isDirectory()) continue;
5079
7073
  } catch {
5080
7074
  continue;
5081
7075
  }
5082
- if (existsSync11(join13(dir, ".git")) || committedBindingPath(dir)) out.push(name);
7076
+ if (existsSync12(join15(dir, ".git")) || committedBindingPath(dir)) out.push(name);
5083
7077
  }
5084
7078
  return out;
5085
7079
  }
5086
7080
  function readManifest(path) {
5087
- if (!existsSync11(path)) return null;
7081
+ if (!existsSync12(path)) return null;
5088
7082
  let parsed;
5089
7083
  try {
5090
- parsed = JSON.parse(readFileSync10(path, "utf8"));
7084
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
5091
7085
  } catch (err2) {
5092
7086
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
5093
7087
  }
@@ -5453,10 +7447,10 @@ async function chooseScope(scopeFlag, yes) {
5453
7447
  }
5454
7448
  async function planRecurseChild(entry, root, client, opts) {
5455
7449
  const dir = resolveChildDir(entry.path, root);
5456
- if (!existsSync12(dir)) {
7450
+ if (!existsSync13(dir)) {
5457
7451
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
5458
7452
  }
5459
- if (existsSync12(join14(dir, ".sechroom.json"))) {
7453
+ if (existsSync13(join16(dir, ".sechroom.json"))) {
5460
7454
  return {
5461
7455
  label: entry.path,
5462
7456
  dir,
@@ -5529,7 +7523,7 @@ This fan-out will pin the same lane in every repo:
5529
7523
  async function runRecurse(cfg, g, opts) {
5530
7524
  const { yes, dryRun, json } = opts;
5531
7525
  const root = process.cwd();
5532
- const manifestPath = join14(root, ".sechroom", "repos.json");
7526
+ const manifestPath = join16(root, ".sechroom", "repos.json");
5533
7527
  const fromManifest = readManifest(manifestPath);
5534
7528
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
5535
7529
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -6062,31 +8056,31 @@ Examples:
6062
8056
 
6063
8057
  // src/commands/reset.ts
6064
8058
  import { homedir as homedir4 } from "os";
6065
- import { join as join15 } from "path";
6066
- import { existsSync as existsSync13, readFileSync as readFileSync11, rmSync as rmSync3 } from "fs";
8059
+ import { join as join17 } from "path";
8060
+ import { existsSync as existsSync14, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
6067
8061
  var SKILLS_LOCK2 = ".sechroom-skills.json";
6068
- var localSkillsDir = () => join15(process.cwd(), ".claude", "skills");
6069
- var globalSkillsDir = () => join15(homedir4(), ".claude", "skills");
6070
- var localAgentsDir = () => join15(process.cwd(), ".claude", "agents");
6071
- var globalAgentsDir = () => join15(homedir4(), ".claude", "agents");
8062
+ var localSkillsDir = () => join17(process.cwd(), ".claude", "skills");
8063
+ var globalSkillsDir = () => join17(homedir4(), ".claude", "skills");
8064
+ var localAgentsDir = () => join17(process.cwd(), ".claude", "agents");
8065
+ var globalAgentsDir = () => join17(homedir4(), ".claude", "agents");
6072
8066
  function removeMaterialisedSkills(dir) {
6073
8067
  const removed = [];
6074
- const lockPath = join15(dir, SKILLS_LOCK2);
6075
- if (!existsSync13(lockPath)) return removed;
8068
+ const lockPath = join17(dir, SKILLS_LOCK2);
8069
+ if (!existsSync14(lockPath)) return removed;
6076
8070
  try {
6077
- const lock = JSON.parse(readFileSync11(lockPath, "utf8"));
8071
+ const lock = JSON.parse(readFileSync12(lockPath, "utf8"));
6078
8072
  for (const entry of Object.values(lock)) {
6079
8073
  for (const name of entry.skills ?? []) {
6080
- const p = join15(dir, name);
6081
- if (existsSync13(p)) {
6082
- rmSync3(p, { recursive: true, force: true });
8074
+ const p = join17(dir, name);
8075
+ if (existsSync14(p)) {
8076
+ rmSync4(p, { recursive: true, force: true });
6083
8077
  removed.push(p);
6084
8078
  }
6085
8079
  }
6086
8080
  }
6087
8081
  } catch {
6088
8082
  }
6089
- rmSync3(lockPath, { force: true });
8083
+ rmSync4(lockPath, { force: true });
6090
8084
  removed.push(lockPath);
6091
8085
  return removed;
6092
8086
  }
@@ -6123,19 +8117,19 @@ function registerReset(program2) {
6123
8117
  }
6124
8118
  }
6125
8119
  const removed = [];
6126
- const stateDir = join15(process.cwd(), ".sechroom");
6127
- if (existsSync13(stateDir)) {
6128
- rmSync3(stateDir, { recursive: true, force: true });
8120
+ const stateDir = join17(process.cwd(), ".sechroom");
8121
+ if (existsSync14(stateDir)) {
8122
+ rmSync4(stateDir, { recursive: true, force: true });
6129
8123
  removed.push(stateDir);
6130
8124
  }
6131
- const legacyCfg = join15(process.cwd(), ".sechroom.json");
6132
- if (existsSync13(legacyCfg)) {
6133
- rmSync3(legacyCfg, { force: true });
8125
+ const legacyCfg = join17(process.cwd(), ".sechroom.json");
8126
+ if (existsSync14(legacyCfg)) {
8127
+ rmSync4(legacyCfg, { force: true });
6134
8128
  removed.push(legacyCfg);
6135
8129
  }
6136
- const legacySem = join15(process.cwd(), ".sem");
6137
- if (existsSync13(legacySem)) {
6138
- rmSync3(legacySem, { force: true });
8130
+ const legacySem = join17(process.cwd(), ".sem");
8131
+ if (existsSync14(legacySem)) {
8132
+ rmSync4(legacySem, { force: true });
6139
8133
  removed.push(legacySem);
6140
8134
  }
6141
8135
  removed.push(...removeMaterialisedSkills(localSkillsDir()));
@@ -6160,8 +8154,8 @@ function registerReset(program2) {
6160
8154
  }
6161
8155
 
6162
8156
  // src/commands/skills.ts
6163
- import { existsSync as existsSync14, mkdirSync as mkdirSync12, statSync as statSync4, writeFileSync as writeFileSync12 } from "fs";
6164
- import { join as join16 } from "path";
8157
+ import { existsSync as existsSync15, mkdirSync as mkdirSync14, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
8158
+ import { join as join18 } from "path";
6165
8159
  function filenameFromDisposition(header) {
6166
8160
  if (!header) return void 0;
6167
8161
  const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
@@ -6169,11 +8163,11 @@ function filenameFromDisposition(header) {
6169
8163
  }
6170
8164
  function resolveOutputPath(output, serverFilename) {
6171
8165
  const filename = serverFilename || "skills.zip";
6172
- if (!output) return join16(process.cwd(), filename);
6173
- const looksLikeDir = output.endsWith("/") || existsSync14(output) && statSync4(output).isDirectory();
8166
+ if (!output) return join18(process.cwd(), filename);
8167
+ const looksLikeDir = output.endsWith("/") || existsSync15(output) && statSync4(output).isDirectory();
6174
8168
  if (looksLikeDir) {
6175
- mkdirSync12(output, { recursive: true });
6176
- return join16(output, filename);
8169
+ mkdirSync14(output, { recursive: true });
8170
+ return join18(output, filename);
6177
8171
  }
6178
8172
  return output;
6179
8173
  }
@@ -6204,7 +8198,7 @@ async function downloadZip(label, call, output) {
6204
8198
  const buf = Buffer.from(res.data);
6205
8199
  const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
6206
8200
  const path = resolveOutputPath(output, filename);
6207
- writeFileSync12(path, buf);
8201
+ writeFileSync13(path, buf);
6208
8202
  return { path, bytes: buf.length, filename };
6209
8203
  }
6210
8204
  function registerSkills(program2) {
@@ -6378,12 +8372,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
6378
8372
  }
6379
8373
 
6380
8374
  // src/commands/sweep.ts
6381
- import { existsSync as existsSync15 } from "fs";
6382
- import { dirname as dirname10, join as join17, resolve as resolve2 } from "path";
6383
- var DEFAULT_MANIFEST = join17(".sechroom", "repos.json");
8375
+ import { existsSync as existsSync16 } from "fs";
8376
+ import { dirname as dirname12, join as join19, resolve as resolve4 } from "path";
8377
+ var DEFAULT_MANIFEST = join19(".sechroom", "repos.json");
6384
8378
  function planEntry(entry, root) {
6385
8379
  const dir = resolveChildDir(entry.path, root);
6386
- if (!existsSync15(dir)) {
8380
+ if (!existsSync16(dir)) {
6387
8381
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
6388
8382
  }
6389
8383
  if (committedBindingPath(dir)) {
@@ -6436,400 +8430,44 @@ Per repo (paths resolve relative to the manifest's root):
6436
8430
  ${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
6437
8431
 
6438
8432
  Examples:
6439
- $ sechroom sweep --dry-run preview every repo's disposition, run nothing
6440
- $ sechroom sweep onboard the whole tree from the root
6441
- $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
6442
- ).action((opts, cmd) => {
6443
- const g = cmd.optsWithGlobals();
6444
- const json = Boolean(g.json);
6445
- const dryRun = Boolean(opts.dryRun);
6446
- const manifestPath = resolve2(opts.manifest);
6447
- let repos;
6448
- try {
6449
- repos = readManifest(manifestPath);
6450
- } catch (err2) {
6451
- fail(err2 instanceof Error ? err2.message : String(err2));
6452
- }
6453
- if (repos === null) {
6454
- fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
6455
- }
6456
- if (repos.length === 0) {
6457
- if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
6458
- else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
6459
- `);
6460
- return;
6461
- }
6462
- const root = dirname10(dirname10(manifestPath));
6463
- const plans = repos.map((entry) => planEntry(entry, root));
6464
- if (!json) {
6465
- process.stderr.write(
6466
- `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
6467
- `
6468
- );
6469
- }
6470
- const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
6471
- if (json) {
6472
- process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
6473
- return;
6474
- }
6475
- summarizeFanout(results, { dryRun });
6476
- });
6477
- }
6478
-
6479
- // src/commands/telemetry.ts
6480
- import {
6481
- existsSync as existsSync16,
6482
- mkdirSync as mkdirSync13,
6483
- readFileSync as readFileSync12,
6484
- rmSync as rmSync4,
6485
- writeFileSync as writeFileSync13
6486
- } from "fs";
6487
- import { dirname as dirname11, join as join18 } from "path";
6488
- function registerTelemetry(program2) {
6489
- const telemetry = program2.command("telemetry").description(
6490
- "Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
6491
- );
6492
- telemetry.command("emit").description(
6493
- "POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
6494
- ).requiredOption(
6495
- "--decomposition <id>",
6496
- "Decomposition id whose run this event belongs to"
6497
- ).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
6498
- "--kind <kind>",
6499
- "Event kind: raw | parsed | approval | terminal"
6500
- ).option(
6501
- "--tokens-in <n>",
6502
- "Cumulative input tokens (spend meter)",
6503
- parseIntOpt
6504
- ).option(
6505
- "--tokens-out <n>",
6506
- "Cumulative output tokens (spend meter)",
6507
- parseIntOpt
6508
- ).option(
6509
- "--context-used <n>",
6510
- "Context tokens currently used (occupancy meter)",
6511
- parseIntOpt
6512
- ).option(
6513
- "--context-window <n>",
6514
- "Context window size (occupancy meter)",
6515
- parseIntOpt
6516
- ).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
6517
- "--verdict <v>",
6518
- "Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
6519
- ).action(async (opts, cmd) => {
6520
- const json = Boolean(cmd.optsWithGlobals().json);
6521
- const cfg = resolveConfig(cmd.optsWithGlobals());
6522
- const event = {
6523
- taskId: opts.task,
6524
- kind: normalizeKind(opts.kind),
6525
- tokensIn: opts.tokensIn ?? null,
6526
- tokensOut: opts.tokensOut ?? null,
6527
- contextUsed: opts.contextUsed ?? null,
6528
- contextWindow: opts.contextWindow ?? null,
6529
- text: opts.text ?? null,
6530
- approvalState: opts.approval ?? null,
6531
- verdict: opts.verdict ?? null
6532
- };
6533
- let body;
6534
- try {
6535
- body = await postTelemetry(cfg, opts.decomposition, [event]);
6536
- } catch (e) {
6537
- return fail(`Telemetry emit failed: ${e.message}`);
6538
- }
6539
- if (json) {
6540
- emit(body, true);
6541
- } else {
6542
- process.stderr.write(
6543
- style.green("telemetry emitted") + style.dim(
6544
- ` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
6545
- `
6546
- )
6547
- );
6548
- }
6549
- });
6550
- telemetry.command("show <decompositionId>").description(
6551
- "Read a run's telemetry \u2014 per-task meters + the raw/parsed/approval/terminal timeline (GET /decompositions/{id}/run/telemetry). Returns hasTelemetry:false, not an error, before any event is ingested \u2014 so an unstarted run and a stalled one read differently. The read side of this group; `emit` is the source side. (FR-sechroom-442 slice 1 step 4; mirrors the work_plan_run_telemetry MCP tool.)"
6552
- ).action(async (decompositionId, _opts, cmd) => {
6553
- const globals = cmd.optsWithGlobals();
6554
- const cfg = resolveConfig(globals);
6555
- const data = await runApi("Reading run telemetry", async () => {
6556
- const client = await makeClient(cfg);
6557
- return client.GET("/decompositions/{id}/run/telemetry", {
6558
- params: { path: { id: decompositionId } }
6559
- });
6560
- });
6561
- emitAction(
6562
- data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
6563
- data,
6564
- globals.json
6565
- );
6566
- });
6567
- telemetry.command("bind").description(
6568
- "Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
6569
- ).requiredOption(
6570
- "--decomposition <id>",
6571
- "Decomposition id this session executes"
6572
- ).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
6573
- const json = Boolean(cmd.optsWithGlobals().json);
6574
- const dir = join18(process.cwd(), ".sechroom");
6575
- mkdirSync13(dir, { recursive: true });
6576
- const path = join18(dir, BINDING_FILE);
6577
- const binding = {
6578
- decompositionId: opts.decomposition,
6579
- taskId: opts.task
6580
- };
6581
- writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
6582
- ensureStateDirIgnored(process.cwd());
6583
- if (json) {
6584
- emit({ bound: true, ...binding, path }, true);
6585
- } else {
6586
- process.stdout.write(
6587
- style.green("telemetry bound") + style.dim(
6588
- ` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
6589
- `
6590
- )
6591
- );
6592
- }
6593
- });
6594
- telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
6595
- const json = Boolean(cmd.optsWithGlobals().json);
6596
- const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
6597
- const existed = existsSync16(path);
6598
- if (existed) rmSync4(path);
6599
- if (json) emit({ unbound: existed, path }, true);
6600
- else
6601
- process.stdout.write(
6602
- existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
6603
- );
6604
- });
6605
- telemetry.command("hook").description(
6606
- "Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
6607
- ).action(async (_opts, cmd) => {
6608
- try {
6609
- const raw = await readStdin3();
6610
- const input = parseHookInput2(raw);
6611
- const cwd = input.cwd ?? process.cwd();
6612
- const binding = findBinding(cwd);
6613
- if (!binding) return process.exit(0);
6614
- const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
6615
- const events = buildHookEvents(input, usage, binding.taskId);
6616
- if (events.length === 0) return process.exit(0);
6617
- const cfg = resolveConfig(cmd.optsWithGlobals());
6618
- await postTelemetry(cfg, binding.decompositionId, events);
6619
- return process.exit(0);
6620
- } catch {
6621
- return process.exit(0);
6622
- }
6623
- });
6624
- telemetry.command("install").description(
6625
- "Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
6626
- ).option(
6627
- "--scope <scope>",
6628
- "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
6629
- ).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
8433
+ $ sechroom sweep --dry-run preview every repo's disposition, run nothing
8434
+ $ sechroom sweep onboard the whole tree from the root
8435
+ $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
8436
+ ).action((opts, cmd) => {
6630
8437
  const g = cmd.optsWithGlobals();
8438
+ const json = Boolean(g.json);
6631
8439
  const dryRun = Boolean(opts.dryRun);
6632
- const cwd = process.cwd();
6633
- let scope;
8440
+ const manifestPath = resolve4(opts.manifest);
8441
+ let repos;
6634
8442
  try {
6635
- scope = opts.local ? "project" : resolveScope(opts.scope);
8443
+ repos = readManifest(manifestPath);
6636
8444
  } catch (err2) {
6637
- process.stderr.write(`${err2.message}
8445
+ fail(err2 instanceof Error ? err2.message : String(err2));
8446
+ }
8447
+ if (repos === null) {
8448
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
8449
+ }
8450
+ if (repos.length === 0) {
8451
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
8452
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
6638
8453
  `);
6639
- return process.exit(2);
8454
+ return;
6640
8455
  }
6641
- const targets = resolveClaudeTargets({
6642
- override: g.claudeConfigDir,
6643
- scope,
6644
- cwd
6645
- });
6646
- const commands = {
6647
- Stop: "sechroom telemetry hook",
6648
- SubagentStop: "sechroom telemetry hook",
6649
- Notification: "sechroom telemetry hook",
6650
- PermissionDenied: "sechroom telemetry hook"
6651
- };
6652
- try {
6653
- const multi = targets.length > 1;
6654
- const results = targets.map((t) => {
6655
- const r = installClaudeCommands(t.dir, commands, dryRun);
6656
- process.stdout.write(
6657
- `${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
6658
- `
6659
- );
6660
- process.stdout.write(describe(r, dryRun) + "\n");
6661
- return r;
6662
- });
6663
- if (dryRun) {
6664
- process.stdout.write("\n(dry run \u2014 no files were written.)\n");
6665
- } else if (results.every((r) => r.status === "current")) {
6666
- process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
6667
- } else {
6668
- process.stdout.write(
6669
- "\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
6670
- );
6671
- }
6672
- } catch (err2) {
8456
+ const root = dirname12(dirname12(manifestPath));
8457
+ const plans = repos.map((entry) => planEntry(entry, root));
8458
+ if (!json) {
6673
8459
  process.stderr.write(
6674
- `telemetry install failed: ${err2.message}
8460
+ `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
6675
8461
  `
6676
8462
  );
6677
- return process.exit(1);
6678
- }
6679
- warnIfSechroomNotOnPath();
6680
- return process.exit(0);
6681
- });
6682
- }
6683
- var BINDING_FILE = "telemetry.json";
6684
- async function postTelemetry(cfg, decompositionId, events) {
6685
- const token = await requireToken(cfg);
6686
- const resp = await fetch(
6687
- `${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
6688
- {
6689
- method: "POST",
6690
- headers: {
6691
- authorization: `Bearer ${token}`,
6692
- tenant: cfg.tenant,
6693
- "content-type": "application/json",
6694
- "x-sechroom-surface": "cli"
6695
- },
6696
- body: JSON.stringify({ events })
6697
- }
6698
- );
6699
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
6700
- return await resp.json();
6701
- }
6702
- function findBinding(start) {
6703
- let dir = start;
6704
- for (; ; ) {
6705
- const path = join18(dir, ".sechroom", BINDING_FILE);
6706
- if (existsSync16(path)) {
6707
- try {
6708
- const b = JSON.parse(
6709
- readFileSync12(path, "utf8")
6710
- );
6711
- if (b.decompositionId && b.taskId)
6712
- return { decompositionId: b.decompositionId, taskId: b.taskId };
6713
- } catch {
6714
- }
6715
- return null;
6716
8463
  }
6717
- const parent = dirname11(dir);
6718
- if (parent === dir) return null;
6719
- dir = parent;
6720
- }
6721
- }
6722
- function parseTranscript(path) {
6723
- if (!existsSync16(path)) return null;
6724
- let tokensIn = 0;
6725
- let tokensOut = 0;
6726
- let contextUsed = 0;
6727
- let model = "";
6728
- for (const line of readFileSync12(path, "utf8").split("\n")) {
6729
- if (!line.trim()) continue;
6730
- let obj;
6731
- try {
6732
- obj = JSON.parse(line);
6733
- } catch {
6734
- continue;
8464
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
8465
+ if (json) {
8466
+ process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
8467
+ return;
6735
8468
  }
6736
- const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
6737
- if (!usage) continue;
6738
- const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
6739
- tokensIn += input;
6740
- tokensOut += usage.output_tokens ?? 0;
6741
- contextUsed = input;
6742
- if (obj.message?.model) model = obj.message.model;
6743
- }
6744
- if (tokensIn === 0 && tokensOut === 0) return null;
6745
- return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
6746
- }
6747
- function windowFor(model, contextUsed = 0) {
6748
- const m = model.toLowerCase();
6749
- if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
6750
- return contextUsed > 2e5 ? 1e6 : 2e5;
6751
- }
6752
- function buildHookEvents(input, usage, taskId) {
6753
- const events = [];
6754
- const base = (kind, over) => ({
6755
- taskId,
6756
- kind,
6757
- tokensIn: null,
6758
- tokensOut: null,
6759
- contextUsed: null,
6760
- contextWindow: null,
6761
- text: null,
6762
- approvalState: null,
6763
- verdict: null,
6764
- ...over
8469
+ summarizeFanout(results, { dryRun });
6765
8470
  });
6766
- if (usage) {
6767
- events.push(
6768
- base("Parsed", {
6769
- tokensIn: usage.tokensIn,
6770
- tokensOut: usage.tokensOut,
6771
- contextUsed: usage.contextUsed,
6772
- contextWindow: usage.contextWindow
6773
- })
6774
- );
6775
- }
6776
- switch (input.hook_event_name) {
6777
- case "PermissionDenied":
6778
- events.push(
6779
- base("Approval", {
6780
- approvalState: "denied",
6781
- text: input.tool_name ?? input.message ?? null
6782
- })
6783
- );
6784
- break;
6785
- case "Notification":
6786
- if (isPermissionNotification(input))
6787
- events.push(base("Approval", { text: input.message ?? null }));
6788
- break;
6789
- case "Stop":
6790
- case "SubagentStop":
6791
- events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6792
- break;
6793
- }
6794
- return events;
6795
- }
6796
- function isPermissionNotification(input) {
6797
- const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6798
- if (t) return t.includes("permission");
6799
- return (input.message ?? "").toLowerCase().includes("permission");
6800
- }
6801
- async function readStdin3() {
6802
- if (process.stdin.isTTY) return "";
6803
- const chunks = [];
6804
- for await (const chunk of process.stdin) chunks.push(chunk);
6805
- return Buffer.concat(chunks).toString("utf8");
6806
- }
6807
- function parseHookInput2(raw) {
6808
- if (!raw.trim()) return {};
6809
- try {
6810
- return JSON.parse(raw);
6811
- } catch {
6812
- return {};
6813
- }
6814
- }
6815
- var KINDS = {
6816
- raw: "Raw",
6817
- parsed: "Parsed",
6818
- approval: "Approval",
6819
- terminal: "Terminal"
6820
- };
6821
- function normalizeKind(k) {
6822
- const v = KINDS[k.toLowerCase()];
6823
- if (!v)
6824
- fail(
6825
- `Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
6826
- );
6827
- return v;
6828
- }
6829
- function parseIntOpt(v) {
6830
- const n = Number.parseInt(v, 10);
6831
- if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
6832
- return n;
6833
8471
  }
6834
8472
 
6835
8473
  // src/commands/worklog.ts
@@ -7037,11 +8675,43 @@ function registerWorkBrief(program2) {
7037
8675
  Examples:
7038
8676
  $ sechroom work-brief pause mem_XXXX --reason-code operator-hold --source claude-code-chris
7039
8677
  $ sechroom work-brief resume mem_XXXX --reason-code operator-resume --source claude-code-chris --reason "Ready to continue"
7040
- $ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"`
8678
+ $ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
8679
+ $ sechroom work-brief park mem_XXXX --source claude-code-chris
8680
+ $ sechroom work-brief unpark mem_XXXX --source claude-code-chris
8681
+ $ sechroom work-brief supersede mem_XXXX --source claude-code-chris`
7041
8682
  );
7042
8683
  registerLifecycleAction(workBrief, "pause");
7043
8684
  registerLifecycleAction(workBrief, "resume");
7044
8685
  registerLifecycleAction(workBrief, "cancel");
8686
+ registerStatusAction(workBrief, "park", "status:parked");
8687
+ registerStatusAction(workBrief, "unpark", "status:ready_for_decomposition");
8688
+ registerStatusAction(workBrief, "supersede", "status:superseded");
8689
+ }
8690
+ function registerStatusAction(workBrief, action, to) {
8691
+ workBrief.command(`${action} <briefId>`).description(
8692
+ `${capitalize(action)} a work brief via its governed status transition`
8693
+ ).requiredOption(
8694
+ "--source <source>",
8695
+ "Calling surface or lane recorded on the contribution"
8696
+ ).action(async (briefId, opts, cmd) => {
8697
+ const globals = cmd.optsWithGlobals();
8698
+ const cfg = resolveConfig(globals);
8699
+ const data = await runApi(
8700
+ `${capitalize(action)}ing work brief`,
8701
+ async () => {
8702
+ const client = await makeClient(cfg);
8703
+ return client.POST("/work-briefs/{id}/status", {
8704
+ params: { path: { id: briefId } },
8705
+ body: { id: briefId, to, source: opts.source }
8706
+ });
8707
+ }
8708
+ );
8709
+ emitAction(
8710
+ `${action} work brief ${style.bold(briefId)} \u2192 ${data.to}`,
8711
+ data,
8712
+ globals.json
8713
+ );
8714
+ });
7045
8715
  }
7046
8716
  function registerLifecycleAction(workBrief, action) {
7047
8717
  const presentParticiple = action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling";
@@ -7079,17 +8749,24 @@ function capitalize(value) {
7079
8749
  }
7080
8750
 
7081
8751
  // src/commands/work-task.ts
8752
+ import { readFile as readFile3 } from "fs/promises";
7082
8753
  function registerWorkTask(program2) {
7083
- const workTask = program2.command("work-task").description("Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)");
8754
+ const workTask = program2.command("work-task").description(
8755
+ "Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)"
8756
+ );
7084
8757
  workTask.addHelpText(
7085
8758
  "after",
7086
8759
  `
7087
8760
  Examples:
7088
8761
  $ sechroom work-task list --status in-progress --lane claude-code-chris
7089
8762
  $ sechroom work-task card mem_XXXX
7090
- $ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX`
8763
+ $ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
8764
+ $ sechroom work-task residue-produce mem_XXXX --file residue.json`
7091
8765
  );
7092
- workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option("--lane <lane>", "Filter by dispatch-lane value, e.g. claude-code-chris").option("--status <status>", "Filter by status value, e.g. in-progress").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option(
8766
+ workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
8767
+ "--lane <lane>",
8768
+ "Filter by dispatch-lane value, e.g. claude-code-chris"
8769
+ ).option("--status <status>", "Filter by status value, e.g. in-progress").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option(
7093
8770
  "--page-size <n>",
7094
8771
  "Page size (default 50, capped 200)",
7095
8772
  (v) => Number.parseInt(v, 10)
@@ -7116,7 +8793,9 @@ Examples:
7116
8793
  globals.json
7117
8794
  );
7118
8795
  });
7119
- workTask.command("card <taskId>").description("Read one task's runnable card \u2014 its full working instructions (GET /tasks/{id}/card)").action(async (taskId, _opts, cmd) => {
8796
+ workTask.command("card <taskId>").description(
8797
+ "Read one task's runnable card \u2014 its full working instructions (GET /tasks/{id}/card)"
8798
+ ).action(async (taskId, _opts, cmd) => {
7120
8799
  const globals = cmd.optsWithGlobals();
7121
8800
  const cfg = resolveConfig(globals);
7122
8801
  const data = await runApi("Reading task card", async () => {
@@ -7127,26 +8806,67 @@ Examples:
7127
8806
  });
7128
8807
  emitAction(`read card ${style.bold(taskId)}`, data, globals.json);
7129
8808
  });
7130
- workTask.command("mark-no-residue <taskId>").description(
7131
- "Mark a task as having produced no residue \u2014 the explicit no-residue marker that lets a lane finish a run it drove end-to-end (POST /work-tasks/mark-no-residue)"
8809
+ workTask.command("residue-produce <taskId>").description(
8810
+ "Produce typed residue for a work task (POST /work-tasks/produce-residue)"
7132
8811
  ).requiredOption(
7133
- "--decomposition <id>",
7134
- "The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
8812
+ "--file <path>",
8813
+ "JSON file containing a residues array; use - for stdin"
7135
8814
  ).action(async (taskId, opts, cmd) => {
8815
+ const raw = opts.file === "-" ? await readStdin4() : await readFile3(opts.file, "utf8");
8816
+ const body = parseResidueInput(raw, opts.file, taskId);
7136
8817
  const globals = cmd.optsWithGlobals();
7137
8818
  const cfg = resolveConfig(globals);
7138
- const data = await runApi("Marking task no-residue", async () => {
8819
+ const data = await runApi("Producing task residue", async () => {
7139
8820
  const client = await makeClient(cfg);
7140
- return client.POST("/work-tasks/mark-no-residue", {
7141
- body: { taskId, decompositionId: opts.decomposition }
7142
- });
8821
+ return client.POST("/work-tasks/produce-residue", { body });
7143
8822
  });
7144
8823
  emitAction(
7145
- `marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
8824
+ `produced ${style.bold(String(data.produced.length))} residue item(s) for ${style.bold(taskId)}`,
7146
8825
  data,
7147
8826
  globals.json
7148
8827
  );
7149
8828
  });
8829
+ workTask.command("mark-no-residue <taskId>").description(
8830
+ "Mark a task as having produced no residue \u2014 the explicit no-residue marker that lets a lane finish a run it drove end-to-end (POST /work-tasks/mark-no-residue)"
8831
+ ).requiredOption(
8832
+ "--decomposition <id>",
8833
+ "The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
8834
+ ).action(
8835
+ async (taskId, opts, cmd) => {
8836
+ const globals = cmd.optsWithGlobals();
8837
+ const cfg = resolveConfig(globals);
8838
+ const data = await runApi("Marking task no-residue", async () => {
8839
+ const client = await makeClient(cfg);
8840
+ return client.POST("/work-tasks/mark-no-residue", {
8841
+ body: { taskId, decompositionId: opts.decomposition }
8842
+ });
8843
+ });
8844
+ emitAction(
8845
+ `marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
8846
+ data,
8847
+ globals.json
8848
+ );
8849
+ }
8850
+ );
8851
+ }
8852
+ function parseResidueInput(raw, sourceName, taskId) {
8853
+ let value;
8854
+ try {
8855
+ value = JSON.parse(raw);
8856
+ } catch (error) {
8857
+ throw new Error(
8858
+ `${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
8859
+ );
8860
+ }
8861
+ const residues = Array.isArray(value) ? value : value && typeof value === "object" ? value.residues : void 0;
8862
+ if (!Array.isArray(residues) || residues.length === 0)
8863
+ throw new Error(`${sourceName} must contain a non-empty residues array`);
8864
+ return { taskId, residues };
8865
+ }
8866
+ async function readStdin4() {
8867
+ const chunks = [];
8868
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
8869
+ return Buffer.concat(chunks).toString("utf8");
7150
8870
  }
7151
8871
 
7152
8872
  // src/index.ts