@sechroom/cli 2026.7.27 → 2026.7.29

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 +2231 -612
  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,14 +1717,770 @@ function ensureSemIgnored(semPath) {
1693
1717
  }
1694
1718
  }
1695
1719
 
1720
+ // src/commands/executor-run.ts
1721
+ import { join as join9, resolve as resolve2 } from "path";
1722
+
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);
1758
+ }
1759
+ return null;
1760
+ }
1761
+ function toClaimed(result, tokenVersion, offer) {
1762
+ const lease = result.lease;
1763
+ const claimToken = result.claimToken;
1764
+ if (!lease?.id || !claimToken) return null;
1765
+ return {
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 ?? [])
1774
+ };
1775
+ }
1776
+ function decompositionIdFrom(tags) {
1777
+ const tag = tags.find((value) => value.startsWith("wlp-decomposition:"));
1778
+ return tag?.slice("wlp-decomposition:".length) || void 0;
1779
+ }
1780
+
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
+ };
1812
+ }
1813
+ function object(value) {
1814
+ return value && typeof value === "object" ? value : void 0;
1815
+ }
1816
+ function numberOf(obj, ...keys) {
1817
+ for (const key of keys) if (typeof obj[key] === "number") return obj[key];
1818
+ return void 0;
1819
+ }
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
+ };
1835
+ }
1836
+ return binding;
1837
+ }
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
+ };
1849
+ }
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
+ );
1901
+ }
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 };
1920
+ }
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("; ")}`;
1934
+ }
1935
+ append(record) {
1936
+ this.options.appendRecord?.({
1937
+ ts: new Date(this.now()).toISOString(),
1938
+ instanceKey: this.options.instanceKey,
1939
+ ...record
1940
+ });
1941
+ }
1942
+ now() {
1943
+ return this.options.now?.() ?? Date.now();
1944
+ }
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/fleet.ts
2375
+ import { readFile } from "fs/promises";
2376
+ import { resolve } from "path";
2377
+ import { spawn as spawn2 } from "child_process";
2378
+ async function readFleetConfig(path) {
2379
+ const parsed = JSON.parse(await readFile(resolve(path), "utf8"));
2380
+ if (!Array.isArray(parsed.instances) || parsed.instances.length === 0)
2381
+ throw new Error("fleet config must contain a non-empty 'instances' array");
2382
+ const keys = /* @__PURE__ */ new Set();
2383
+ for (const entry of parsed.instances) {
2384
+ if (!entry || typeof entry.root !== "string" || typeof entry.instanceKey !== "string")
2385
+ throw new Error("each fleet instance requires string 'root' and 'instanceKey'");
2386
+ if (keys.has(entry.instanceKey)) throw new Error(`duplicate fleet instanceKey '${entry.instanceKey}'`);
2387
+ keys.add(entry.instanceKey);
2388
+ }
2389
+ return parsed;
2390
+ }
2391
+ function entryArgs(entry) {
2392
+ const args = ["executor", "run", "--root", resolve(entry.root), "--instance-key", entry.instanceKey];
2393
+ const value = (flag, v) => {
2394
+ if (v !== void 0) args.push(flag, String(v));
2395
+ };
2396
+ value("--lane", entry.lane);
2397
+ value("--model", entry.model);
2398
+ value("--connector", entry.connector);
2399
+ value("--ttl", entry.ttl);
2400
+ value("--poll-interval", entry.pollInterval);
2401
+ value("--heartbeat-interval", entry.heartbeatInterval);
2402
+ value("--turn-timeout", entry.turnTimeout);
2403
+ value("--resume-turn-timeout", entry.resumeTurnTimeout);
2404
+ value("--drain-timeout", entry.drainTimeout);
2405
+ value("--codex-bin", entry.codexBin);
2406
+ value("--sandbox", entry.sandbox);
2407
+ value("--usage-reserve", entry.usageReserve);
2408
+ return args;
2409
+ }
2410
+ function superviseFleet(config2, options = {}) {
2411
+ const log = options.log ?? ((line) => process.stderr.write(`${line}
2412
+ `));
2413
+ const states = /* @__PURE__ */ new Map();
2414
+ const children = /* @__PURE__ */ new Map();
2415
+ let stopping = false;
2416
+ let resolveDone;
2417
+ const done = new Promise((resolvePromise) => {
2418
+ resolveDone = resolvePromise;
2419
+ });
2420
+ const status = () => log(`[fleet] ${[...states].map(([key, state]) => `${key}=${state}`).join(" ")}`);
2421
+ const spawnEntry = options.spawnEntry ?? ((entry, args) => {
2422
+ const script = process.argv[1];
2423
+ if (!script) throw new Error("cannot locate the sechroom CLI entrypoint");
2424
+ return spawn2(process.execPath, [script, ...args], {
2425
+ cwd: resolve(entry.root),
2426
+ stdio: ["ignore", "pipe", "pipe"],
2427
+ env: process.env
2428
+ });
2429
+ });
2430
+ for (const entry of config2.instances) {
2431
+ const child = spawnEntry(entry, entryArgs(entry));
2432
+ children.set(entry.instanceKey, child);
2433
+ states.set(entry.instanceKey, "live");
2434
+ const prefix = (text2) => {
2435
+ for (const line of text2.replace(/\n$/, "").split("\n")) log(`[${entry.instanceKey}] ${line}`);
2436
+ };
2437
+ const concrete = child;
2438
+ concrete.stdout?.on("data", (chunk) => prefix(String(chunk)));
2439
+ concrete.stderr?.on("data", (chunk) => prefix(String(chunk)));
2440
+ child.on("exit", (code, signal) => {
2441
+ states.set(entry.instanceKey, "exited");
2442
+ log(`[${entry.instanceKey}] exited (${signal ?? code ?? "unknown"})`);
2443
+ status();
2444
+ if ([...states.values()].every((state) => state === "exited")) resolveDone();
2445
+ });
2446
+ }
2447
+ status();
2448
+ return {
2449
+ done,
2450
+ shutdown(signal = "SIGINT") {
2451
+ if (stopping) return done;
2452
+ stopping = true;
2453
+ for (const [key, child] of children) {
2454
+ if (states.get(key) !== "exited") {
2455
+ states.set(key, "stopping");
2456
+ child.kill(signal);
2457
+ }
2458
+ }
2459
+ status();
2460
+ return done;
2461
+ },
2462
+ states
2463
+ };
2464
+ }
2465
+
2466
+ // src/commands/telemetry.ts
2467
+ import {
2468
+ existsSync as existsSync7,
2469
+ mkdirSync as mkdirSync7,
2470
+ readFileSync as readFileSync5,
2471
+ rmSync as rmSync3,
2472
+ writeFileSync as writeFileSync6
2473
+ } from "fs";
2474
+ import { dirname as dirname6, join as join8 } from "path";
2475
+
1696
2476
  // 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";
2477
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
2478
+ import { delimiter, dirname as dirname5, join as join7 } from "path";
1699
2479
 
1700
2480
  // src/setup/clients.ts
1701
2481
  import { existsSync as existsSync5 } from "fs";
1702
2482
  import { homedir as homedir3 } from "os";
1703
- import { dirname as dirname3, join as join6 } from "path";
2483
+ import { dirname as dirname4, join as join6 } from "path";
1704
2484
  function claudeDesktopConfigPath(home) {
1705
2485
  switch (process.platform) {
1706
2486
  case "darwin":
@@ -1760,7 +2540,7 @@ function detectInstalledClients(cwd) {
1760
2540
  const home = homedir3();
1761
2541
  const detected = [];
1762
2542
  if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
1763
- if (existsSync5(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
2543
+ if (existsSync5(dirname4(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
1764
2544
  if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
1765
2545
  if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
1766
2546
  if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
@@ -1817,7 +2597,7 @@ function installHooksJson(path, commands, dryRun) {
1817
2597
  const added = mergeHooks(config2, commands);
1818
2598
  if (added === 0 && existed) return { path, status: "current" };
1819
2599
  if (!dryRun) {
1820
- mkdirSync5(dirname4(path), { recursive: true });
2600
+ mkdirSync6(dirname5(path), { recursive: true });
1821
2601
  writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
1822
2602
  }
1823
2603
  return { path, status: existed ? "merged" : "created" };
@@ -1857,7 +2637,7 @@ function installCodexFeatureFlag(path, dryRun) {
1857
2637
  const { next, changed } = ensureCodexFeaturesHooks(content);
1858
2638
  if (!changed) return { path, status: "current" };
1859
2639
  if (!dryRun) {
1860
- mkdirSync5(dirname4(path), { recursive: true });
2640
+ mkdirSync6(dirname5(path), { recursive: true });
1861
2641
  writeFileSync5(path, next);
1862
2642
  }
1863
2643
  return { path, status: existed ? "merged" : "created" };
@@ -1920,65 +2700,1077 @@ function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
1920
2700
  return true;
1921
2701
  }
1922
2702
 
1923
- // src/commands/executor.ts
1924
- function executorSubscriptionInput(name) {
1925
- return {
1926
- name,
1927
- enabled: true,
1928
- filter: { tags: ["kind:task"], workspaceScope: [] }
1929
- };
1930
- }
1931
- function executorRegistrationInput(state, deliverySubscriptionId) {
1932
- return {
1933
- relayId: state.relayId,
1934
- instanceKey: state.instanceKey,
1935
- laneId: state.laneId ?? state.instanceKey,
1936
- runtimeKind: parseRuntimeKind(state.runtime),
1937
- activationMode: "Attached",
1938
- deliverySubscriptionId,
1939
- connectorId: state.connectorId,
1940
- claimedCapabilityKeys: state.capabilityKeys,
1941
- toolSetRef: null,
1942
- ttlSeconds: state.ttlSeconds
1943
- };
1944
- }
1945
- var EXECUTOR_STATE = "executor.json";
1946
- var EXECUTOR_PULSE_COMMAND = "sechroom executor hook-pulse";
1947
- var EXECUTOR_STOP_COMMAND = "sechroom executor hook-stop";
1948
- var CLAUDE_EXECUTOR_HOOKS = {
1949
- SessionStart: EXECUTOR_PULSE_COMMAND,
1950
- UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
1951
- PreToolUse: EXECUTOR_PULSE_COMMAND,
1952
- Stop: EXECUTOR_PULSE_COMMAND,
1953
- SessionEnd: EXECUTOR_STOP_COMMAND
1954
- };
1955
- var CODEX_EXECUTOR_HOOKS = {
1956
- SessionStart: EXECUTOR_PULSE_COMMAND,
1957
- UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
1958
- PreToolUse: EXECUTOR_PULSE_COMMAND,
1959
- Stop: EXECUTOR_PULSE_COMMAND
1960
- };
1961
- function registerExecutor(program2) {
1962
- const executor = program2.command("executor").description(
1963
- "Register and operate a local Claude Code/Codex executor advertisement"
2703
+ // src/commands/telemetry.ts
2704
+ function registerTelemetry(program2) {
2705
+ const telemetry = program2.command("telemetry").description(
2706
+ "Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
1964
2707
  );
1965
- executor.command("install").description(
1966
- "Configure this checkout's harness to advertise itself as a WLP executor"
1967
- ).option("--connector <id>", "Approved local-session ConnectorDefinition id").option(
1968
- "--instance-key <key>",
1969
- "Stable executor identity (defaults to .sechroom/lane.json code-lane)"
1970
- ).option(
1971
- "--lane-id <lane>",
1972
- "Canonical affinity lane (defaults to .sechroom/lane.json code-lane)"
1973
- ).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
1974
- "--capability <key...>",
1975
- "Capability operation keys claimed by this instance"
2708
+ telemetry.command("emit").description(
2709
+ "POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
2710
+ ).requiredOption(
2711
+ "--decomposition <id>",
2712
+ "Decomposition id whose run this event belongs to"
2713
+ ).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
2714
+ "--kind <kind>",
2715
+ "Event kind: raw | parsed | approval | terminal"
1976
2716
  ).option(
1977
- "--relay <id>",
1978
- "Relay identity shared by sibling instances",
1979
- "sechroom-cli-local"
2717
+ "--tokens-in <n>",
2718
+ "Cumulative input tokens (spend meter)",
2719
+ parseIntOpt
1980
2720
  ).option(
1981
- "--subscription-name <name>",
2721
+ "--tokens-out <n>",
2722
+ "Cumulative output tokens (spend meter)",
2723
+ parseIntOpt
2724
+ ).option(
2725
+ "--context-used <n>",
2726
+ "Context tokens currently used (occupancy meter)",
2727
+ parseIntOpt
2728
+ ).option(
2729
+ "--context-window <n>",
2730
+ "Context window size (occupancy meter)",
2731
+ parseIntOpt
2732
+ ).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
2733
+ "--verdict <v>",
2734
+ "Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
2735
+ ).action(async (opts, cmd) => {
2736
+ const json = Boolean(cmd.optsWithGlobals().json);
2737
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2738
+ const event = {
2739
+ taskId: opts.task,
2740
+ kind: normalizeKind(opts.kind),
2741
+ tokensIn: opts.tokensIn ?? null,
2742
+ tokensOut: opts.tokensOut ?? null,
2743
+ contextUsed: opts.contextUsed ?? null,
2744
+ contextWindow: opts.contextWindow ?? null,
2745
+ text: opts.text ?? null,
2746
+ approvalState: opts.approval ?? null,
2747
+ verdict: opts.verdict ?? null
2748
+ };
2749
+ let body;
2750
+ try {
2751
+ body = await postTelemetry(cfg, opts.decomposition, [event]);
2752
+ } catch (e) {
2753
+ return fail(`Telemetry emit failed: ${e.message}`);
2754
+ }
2755
+ if (json) {
2756
+ emit(body, true);
2757
+ } else {
2758
+ process.stderr.write(
2759
+ style.green("telemetry emitted") + style.dim(
2760
+ ` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
2761
+ `
2762
+ )
2763
+ );
2764
+ }
2765
+ });
2766
+ telemetry.command("show <decompositionId>").description(
2767
+ "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.)"
2768
+ ).action(async (decompositionId, _opts, cmd) => {
2769
+ const globals = cmd.optsWithGlobals();
2770
+ const cfg = resolveConfig(globals);
2771
+ const data = await runApi("Reading run telemetry", async () => {
2772
+ const client = await makeClient(cfg);
2773
+ return client.GET("/decompositions/{id}/run/telemetry", {
2774
+ params: { path: { id: decompositionId } }
2775
+ });
2776
+ });
2777
+ emitAction(
2778
+ data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
2779
+ data,
2780
+ globals.json
2781
+ );
2782
+ });
2783
+ telemetry.command("bind").description(
2784
+ "Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
2785
+ ).requiredOption(
2786
+ "--decomposition <id>",
2787
+ "Decomposition id this session executes"
2788
+ ).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
2789
+ const json = Boolean(cmd.optsWithGlobals().json);
2790
+ const dir = join8(process.cwd(), ".sechroom");
2791
+ mkdirSync7(dir, { recursive: true });
2792
+ const path = join8(dir, BINDING_FILE);
2793
+ const binding = {
2794
+ decompositionId: opts.decomposition,
2795
+ taskId: opts.task
2796
+ };
2797
+ writeFileSync6(path, JSON.stringify(binding, null, 2) + "\n");
2798
+ ensureStateDirIgnored(process.cwd());
2799
+ if (json) {
2800
+ emit({ bound: true, ...binding, path }, true);
2801
+ } else {
2802
+ process.stdout.write(
2803
+ style.green("telemetry bound") + style.dim(
2804
+ ` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
2805
+ `
2806
+ )
2807
+ );
2808
+ }
2809
+ });
2810
+ telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
2811
+ const json = Boolean(cmd.optsWithGlobals().json);
2812
+ const path = join8(process.cwd(), ".sechroom", BINDING_FILE);
2813
+ const existed = existsSync7(path);
2814
+ if (existed) rmSync3(path);
2815
+ if (json) emit({ unbound: existed, path }, true);
2816
+ else
2817
+ process.stdout.write(
2818
+ existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
2819
+ );
2820
+ });
2821
+ telemetry.command("hook").description(
2822
+ "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."
2823
+ ).action(async (_opts, cmd) => {
2824
+ try {
2825
+ const raw = await readStdin();
2826
+ const input = parseHookInput(raw);
2827
+ const cwd = input.cwd ?? process.cwd();
2828
+ const binding = findBinding(cwd);
2829
+ if (!binding) return process.exit(0);
2830
+ const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
2831
+ const events = buildHookEvents(input, usage, binding.taskId);
2832
+ if (events.length === 0) return process.exit(0);
2833
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2834
+ await postTelemetry(cfg, binding.decompositionId, events);
2835
+ return process.exit(0);
2836
+ } catch {
2837
+ return process.exit(0);
2838
+ }
2839
+ });
2840
+ telemetry.command("install").description(
2841
+ "Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
2842
+ ).option(
2843
+ "--scope <scope>",
2844
+ "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
2845
+ ).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
2846
+ const g = cmd.optsWithGlobals();
2847
+ const dryRun = Boolean(opts.dryRun);
2848
+ const cwd = process.cwd();
2849
+ let scope;
2850
+ try {
2851
+ scope = opts.local ? "project" : resolveScope(opts.scope);
2852
+ } catch (err2) {
2853
+ process.stderr.write(`${err2.message}
2854
+ `);
2855
+ return process.exit(2);
2856
+ }
2857
+ const targets = resolveClaudeTargets({
2858
+ override: g.claudeConfigDir,
2859
+ scope,
2860
+ cwd
2861
+ });
2862
+ const commands = {
2863
+ Stop: "sechroom telemetry hook",
2864
+ SubagentStop: "sechroom telemetry hook",
2865
+ Notification: "sechroom telemetry hook",
2866
+ PermissionDenied: "sechroom telemetry hook"
2867
+ };
2868
+ try {
2869
+ const multi = targets.length > 1;
2870
+ const results = targets.map((t) => {
2871
+ const r = installClaudeCommands(t.dir, commands, dryRun);
2872
+ process.stdout.write(
2873
+ `${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
2874
+ `
2875
+ );
2876
+ process.stdout.write(describe(r, dryRun) + "\n");
2877
+ return r;
2878
+ });
2879
+ if (dryRun) {
2880
+ process.stdout.write("\n(dry run \u2014 no files were written.)\n");
2881
+ } else if (results.every((r) => r.status === "current")) {
2882
+ process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
2883
+ } else {
2884
+ process.stdout.write(
2885
+ "\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
2886
+ );
2887
+ }
2888
+ } catch (err2) {
2889
+ process.stderr.write(
2890
+ `telemetry install failed: ${err2.message}
2891
+ `
2892
+ );
2893
+ return process.exit(1);
2894
+ }
2895
+ warnIfSechroomNotOnPath();
2896
+ return process.exit(0);
2897
+ });
2898
+ }
2899
+ var BINDING_FILE = "telemetry.json";
2900
+ async function postTelemetry(cfg, decompositionId, events) {
2901
+ const token = await requireToken(cfg);
2902
+ const resp = await fetch(
2903
+ `${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
2904
+ {
2905
+ method: "POST",
2906
+ headers: {
2907
+ authorization: `Bearer ${token}`,
2908
+ tenant: cfg.tenant,
2909
+ "content-type": "application/json",
2910
+ "x-sechroom-surface": "cli"
2911
+ },
2912
+ body: JSON.stringify({ events })
2913
+ }
2914
+ );
2915
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
2916
+ return await resp.json();
2917
+ }
2918
+ function findBinding(start) {
2919
+ let dir = start;
2920
+ for (; ; ) {
2921
+ const path = join8(dir, ".sechroom", BINDING_FILE);
2922
+ if (existsSync7(path)) {
2923
+ try {
2924
+ const b = JSON.parse(
2925
+ readFileSync5(path, "utf8")
2926
+ );
2927
+ if (b.decompositionId && b.taskId)
2928
+ return { decompositionId: b.decompositionId, taskId: b.taskId };
2929
+ } catch {
2930
+ }
2931
+ return null;
2932
+ }
2933
+ const parent = dirname6(dir);
2934
+ if (parent === dir) return null;
2935
+ dir = parent;
2936
+ }
2937
+ }
2938
+ function parseTranscript(path) {
2939
+ if (!existsSync7(path)) return null;
2940
+ let tokensIn = 0;
2941
+ let tokensOut = 0;
2942
+ let contextUsed = 0;
2943
+ let model = "";
2944
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
2945
+ if (!line.trim()) continue;
2946
+ let obj;
2947
+ try {
2948
+ obj = JSON.parse(line);
2949
+ } catch {
2950
+ continue;
2951
+ }
2952
+ const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
2953
+ if (!usage) continue;
2954
+ const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
2955
+ tokensIn += input;
2956
+ tokensOut += usage.output_tokens ?? 0;
2957
+ contextUsed = input;
2958
+ if (obj.message?.model) model = obj.message.model;
2959
+ }
2960
+ if (tokensIn === 0 && tokensOut === 0) return null;
2961
+ return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed), modelId: model || null };
2962
+ }
2963
+ function windowFor(model, contextUsed = 0) {
2964
+ const m = model.toLowerCase();
2965
+ if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
2966
+ return contextUsed > 2e5 ? 1e6 : 2e5;
2967
+ }
2968
+ function buildHookEvents(input, usage, taskId) {
2969
+ const events = [];
2970
+ const base = (kind, over) => ({
2971
+ taskId,
2972
+ kind,
2973
+ tokensIn: null,
2974
+ tokensOut: null,
2975
+ contextUsed: null,
2976
+ contextWindow: null,
2977
+ text: null,
2978
+ approvalState: null,
2979
+ verdict: null,
2980
+ modelId: null,
2981
+ ...over
2982
+ });
2983
+ if (usage) {
2984
+ events.push(
2985
+ base("Parsed", {
2986
+ tokensIn: usage.tokensIn,
2987
+ tokensOut: usage.tokensOut,
2988
+ contextUsed: usage.contextUsed,
2989
+ contextWindow: usage.contextWindow,
2990
+ modelId: usage.modelId
2991
+ })
2992
+ );
2993
+ }
2994
+ switch (input.hook_event_name) {
2995
+ case "PermissionDenied":
2996
+ events.push(
2997
+ base("Approval", {
2998
+ approvalState: "denied",
2999
+ text: input.tool_name ?? input.message ?? null
3000
+ })
3001
+ );
3002
+ break;
3003
+ case "Notification":
3004
+ if (isPermissionNotification(input))
3005
+ events.push(base("Approval", { text: input.message ?? null }));
3006
+ break;
3007
+ case "Stop":
3008
+ case "SubagentStop":
3009
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
3010
+ break;
3011
+ }
3012
+ return events;
3013
+ }
3014
+ function isPermissionNotification(input) {
3015
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
3016
+ if (t) return t.includes("permission");
3017
+ return (input.message ?? "").toLowerCase().includes("permission");
3018
+ }
3019
+ async function readStdin() {
3020
+ if (process.stdin.isTTY) return "";
3021
+ const chunks = [];
3022
+ for await (const chunk of process.stdin) chunks.push(chunk);
3023
+ return Buffer.concat(chunks).toString("utf8");
3024
+ }
3025
+ function parseHookInput(raw) {
3026
+ if (!raw.trim()) return {};
3027
+ try {
3028
+ return JSON.parse(raw);
3029
+ } catch {
3030
+ return {};
3031
+ }
3032
+ }
3033
+ var KINDS = {
3034
+ raw: "Raw",
3035
+ parsed: "Parsed",
3036
+ approval: "Approval",
3037
+ terminal: "Terminal"
3038
+ };
3039
+ function normalizeKind(k) {
3040
+ const v = KINDS[k.toLowerCase()];
3041
+ if (!v)
3042
+ fail(
3043
+ `Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
3044
+ );
3045
+ return v;
3046
+ }
3047
+ function parseIntOpt(v) {
3048
+ const n = Number.parseInt(v, 10);
3049
+ if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
3050
+ return n;
3051
+ }
3052
+
3053
+ // src/executor-run/delivery.ts
3054
+ import { execFile as execFile2 } from "child_process";
3055
+ function createGitRunner(rootDir) {
3056
+ return (bin, args) => new Promise((resolve5) => {
3057
+ execFile2(
3058
+ bin,
3059
+ bin === "git" ? ["-C", rootDir, ...args] : args,
3060
+ { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
3061
+ (error, stdout, stderr) => resolve5({ ok: !error, stdout: String(stdout), stderr: String(stderr) })
3062
+ );
3063
+ });
3064
+ }
3065
+ function porcelainPaths(stdout) {
3066
+ return stdout.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 3).map((line) => {
3067
+ const path = line.slice(3);
3068
+ const arrow = path.indexOf(" -> ");
3069
+ return arrow >= 0 ? path.slice(arrow + 4) : path;
3070
+ });
3071
+ }
3072
+ async function snapshotRoot(git) {
3073
+ const branch = await git("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
3074
+ const status = await git("git", ["status", "--porcelain"]);
3075
+ return {
3076
+ baseBranch: branch.ok ? branch.stdout.trim() : "HEAD",
3077
+ dirtyPaths: status.ok ? porcelainPaths(status.stdout) : []
3078
+ };
3079
+ }
3080
+ async function checkRootReady(git, allowDirty) {
3081
+ const snapshot = await snapshotRoot(git);
3082
+ if (snapshot.dirtyPaths.length === 0 || allowDirty)
3083
+ return { ok: true, snapshot };
3084
+ return {
3085
+ ok: false,
3086
+ snapshot,
3087
+ 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`
3088
+ };
3089
+ }
3090
+ async function deliverTurn(git, options) {
3091
+ const status = await git("git", ["status", "--porcelain"]);
3092
+ if (!status.ok)
3093
+ return { delivered: false, note: `delivery skipped \u2014 git status failed: ${status.stderr.trim()}` };
3094
+ const preDirty = new Set(options.snapshot.dirtyPaths);
3095
+ const turnPaths = porcelainPaths(status.stdout).filter((p) => !preDirty.has(p));
3096
+ if (turnPaths.length === 0)
3097
+ return { delivered: false, note: "no file changes produced by the turn \u2014 nothing to deliver" };
3098
+ const branch = await freeBranchName(git, `task/${slug(options.taskId)}`);
3099
+ const created = await git("git", ["checkout", "-b", branch]);
3100
+ if (!created.ok)
3101
+ return {
3102
+ delivered: false,
3103
+ note: `delivery FAILED \u2014 could not create branch ${branch}: ${created.stderr.trim()} (changes remain uncommitted in the root)`
3104
+ };
3105
+ const notes = [];
3106
+ try {
3107
+ const added = await git("git", ["add", "--", ...turnPaths]);
3108
+ if (!added.ok) return failBack(`git add failed: ${added.stderr.trim()}`);
3109
+ const committed = await git("git", [
3110
+ "commit",
3111
+ "-m",
3112
+ commitMessage(options)
3113
+ ]);
3114
+ if (!committed.ok) return failBack(`git commit failed: ${committed.stderr.trim()}`);
3115
+ const sha = (await git("git", ["rev-parse", "--short", "HEAD"])).stdout.trim();
3116
+ const pushed = await git("git", ["push", "-u", "origin", branch]);
3117
+ if (!pushed.ok)
3118
+ notes.push(`push failed (${firstLine(pushed.stderr)}) \u2014 branch is local-only`);
3119
+ let prUrl;
3120
+ if (options.raisePr && pushed.ok) {
3121
+ const pr = await git("gh", [
3122
+ "pr",
3123
+ "create",
3124
+ "--head",
3125
+ branch,
3126
+ "--title",
3127
+ `task(${options.taskId}): ${options.title}`,
3128
+ "--body",
3129
+ prBody(options, sha)
3130
+ ]);
3131
+ if (pr.ok) prUrl = firstLine(pr.stdout);
3132
+ else notes.push(`PR raise failed (${firstLine(pr.stderr)}) \u2014 raise manually from ${branch}`);
3133
+ }
3134
+ notes.unshift(
3135
+ `delivered ${turnPaths.length} path(s) to ${branch} @ ${sha}${prUrl ? ` \u2014 PR ${prUrl}` : ""}`
3136
+ );
3137
+ return { delivered: true, branch, sha, prUrl, note: notes.join("; ") };
3138
+ } finally {
3139
+ const back = await git("git", ["checkout", options.snapshot.baseBranch]);
3140
+ if (!back.ok)
3141
+ options.log(
3142
+ `delivery: could not return root to ${options.snapshot.baseBranch}: ${back.stderr.trim()}`
3143
+ );
3144
+ }
3145
+ function failBack(reason) {
3146
+ return { delivered: false, branch, note: `delivery FAILED \u2014 ${reason}` };
3147
+ }
3148
+ }
3149
+ async function freeBranchName(git, base) {
3150
+ for (let i = 0; ; i++) {
3151
+ const candidate = i === 0 ? base : `${base}-${i + 1}`;
3152
+ const exists = await git("git", [
3153
+ "rev-parse",
3154
+ "--verify",
3155
+ "--quiet",
3156
+ `refs/heads/${candidate}`
3157
+ ]);
3158
+ if (!exists.ok) return candidate;
3159
+ }
3160
+ }
3161
+ function slug(taskId) {
3162
+ return taskId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3163
+ }
3164
+ function commitMessage(options) {
3165
+ return `task(${options.taskId}): ${options.title} [verdict:${options.verdict}]
3166
+
3167
+ Driven-executor delivery (FR-sechroom-496): work produced by the sandboxed codex turn, landed by the driver.
3168
+
3169
+ Delivered-By: sechroom executor run (${options.deliveredBy})`;
3170
+ }
3171
+ function prBody(options, sha) {
3172
+ return `Driven-executor delivery for WLP task \`${options.taskId}\` (verdict: ${options.verdict}, commit ${sha}).
3173
+
3174
+ Work produced by a sandboxed codex turn and landed by the unsandboxed driver (FR-sechroom-496). Review against the task's acceptance before merge.`;
3175
+ }
3176
+ function firstLine(text2) {
3177
+ return text2.trim().split("\n")[0] ?? "";
3178
+ }
3179
+
3180
+ // src/executor-run/request.ts
3181
+ var AuthExpiredError = class extends Error {
3182
+ constructor(message) {
3183
+ super(message);
3184
+ this.name = "AuthExpiredError";
3185
+ }
3186
+ };
3187
+ var HttpError = class extends Error {
3188
+ constructor(status, method, path, body) {
3189
+ super(`${method} ${path} failed (${status}): ${body}`);
3190
+ this.status = status;
3191
+ this.method = method;
3192
+ this.path = path;
3193
+ this.body = body;
3194
+ this.name = "HttpError";
3195
+ }
3196
+ status;
3197
+ method;
3198
+ path;
3199
+ body;
3200
+ };
3201
+ function createAuthedRequest(cfg, deps = {}) {
3202
+ const getToken = deps.getToken ?? requireToken;
3203
+ const refresh = deps.refreshToken ?? forceRefreshToken;
3204
+ const doFetch = deps.fetch ?? fetch;
3205
+ const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
3206
+ ...init,
3207
+ headers: {
3208
+ authorization: `Bearer ${token}`,
3209
+ tenant: cfg.tenant,
3210
+ "content-type": "application/json",
3211
+ "x-sechroom-surface": "cli",
3212
+ ...init?.headers
3213
+ }
3214
+ });
3215
+ return async (path, init) => {
3216
+ const method = init?.method ?? "GET";
3217
+ let token;
3218
+ try {
3219
+ token = await getToken(cfg);
3220
+ } catch (error) {
3221
+ throw new AuthExpiredError(
3222
+ error instanceof Error ? error.message : String(error)
3223
+ );
3224
+ }
3225
+ let response = await call(path, init, token);
3226
+ if (response.status === 401) {
3227
+ let fresh;
3228
+ try {
3229
+ fresh = await refresh(cfg);
3230
+ } catch (error) {
3231
+ throw new AuthExpiredError(
3232
+ error instanceof Error ? error.message : String(error)
3233
+ );
3234
+ }
3235
+ response = await call(path, init, fresh);
3236
+ if (response.status === 401)
3237
+ throw new AuthExpiredError(
3238
+ `${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
3239
+ );
3240
+ }
3241
+ if (!response.ok)
3242
+ throw new HttpError(
3243
+ response.status,
3244
+ method,
3245
+ path,
3246
+ await safeText(response)
3247
+ );
3248
+ return await response.json();
3249
+ };
3250
+ }
3251
+ async function safeText(response) {
3252
+ try {
3253
+ return await response.text();
3254
+ } catch {
3255
+ return "";
3256
+ }
3257
+ }
3258
+
3259
+ // src/executor-run/driver.ts
3260
+ function verdictFor(terminalStatus) {
3261
+ switch (terminalStatus) {
3262
+ case "completed":
3263
+ return "pass";
3264
+ case "needs_approval":
3265
+ case "cancelled":
3266
+ case "canceled":
3267
+ return "blocked";
3268
+ case "error":
3269
+ default:
3270
+ return "soft-fail";
3271
+ }
3272
+ }
3273
+ async function runDriverLoop(ports, options) {
3274
+ const summary = { processed: 0, completed: 0, abandoned: 0 };
3275
+ let admissionDeferred = false;
3276
+ while (!options.stopping()) {
3277
+ if (ports.checkAdmission) {
3278
+ const admission = await ports.checkAdmission();
3279
+ if (!admission.ok) {
3280
+ admissionDeferred = true;
3281
+ ports.log(
3282
+ `ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
3283
+ );
3284
+ await ports.waitForWake(options.pollMs);
3285
+ continue;
3286
+ }
3287
+ if (admissionDeferred) {
3288
+ admissionDeferred = false;
3289
+ ports.log("admission recovered \u2014 resuming claims");
3290
+ }
3291
+ }
3292
+ if (ports.checkRootReady) {
3293
+ const ready = await ports.checkRootReady();
3294
+ if (!ready.ok) {
3295
+ ports.log(`root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`);
3296
+ await ports.waitForWake(options.pollMs);
3297
+ continue;
3298
+ }
3299
+ }
3300
+ const claim = await ports.claimNext();
3301
+ if (!claim) {
3302
+ await ports.waitForWake(options.pollMs);
3303
+ continue;
3304
+ }
3305
+ summary.processed++;
3306
+ ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
3307
+ const task = await ports.loadTask(claim.memoryId);
3308
+ const stopHeartbeat = ports.startLeaseHeartbeat(claim);
3309
+ let result;
3310
+ try {
3311
+ result = await ports.runTurn(task, claim);
3312
+ } catch (e) {
3313
+ if (e instanceof AuthExpiredError) throw e;
3314
+ result = { status: "crashed", reason: String(e) };
3315
+ } finally {
3316
+ stopHeartbeat();
3317
+ }
3318
+ if (result.status === "crashed" || result.status === "timeout") {
3319
+ summary.abandoned++;
3320
+ ports.log(
3321
+ `ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
3322
+ );
3323
+ } else {
3324
+ const verdict = verdictFor(result.packet?.terminal_status);
3325
+ let text2 = closeoutText(task, result);
3326
+ if (ports.deliver) {
3327
+ try {
3328
+ const delivery = await ports.deliver(claim, task, verdict);
3329
+ ports.log(`delivery: ${delivery.note}`);
3330
+ text2 += `
3331
+
3332
+ Delivery: ${delivery.note}`;
3333
+ } catch (e) {
3334
+ if (e instanceof AuthExpiredError) throw e;
3335
+ ports.log(`delivery threw (continuing to completion): ${String(e)}`);
3336
+ text2 += `
3337
+
3338
+ Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
3339
+ }
3340
+ }
3341
+ try {
3342
+ const done = await ports.completeLease(
3343
+ claim,
3344
+ verdict,
3345
+ text2,
3346
+ `${task.title} \u2014 driven closeout`
3347
+ );
3348
+ summary.completed++;
3349
+ ports.log(
3350
+ `completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
3351
+ );
3352
+ } catch (e) {
3353
+ if (e instanceof AuthExpiredError) throw e;
3354
+ summary.abandoned++;
3355
+ ports.log(
3356
+ `COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
3357
+ );
3358
+ }
3359
+ }
3360
+ if (options.once) break;
3361
+ }
3362
+ return summary;
3363
+ }
3364
+ function closeoutText(task, result) {
3365
+ if (!result.packet)
3366
+ return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
3367
+
3368
+ ${result.lastAgentMessage || "(none)"}`;
3369
+ const evidence = result.packet.evidence?.length ? `
3370
+
3371
+ Evidence:
3372
+ ${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
3373
+ return `${result.packet.summary}${evidence}
3374
+
3375
+ (terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
3376
+ }
3377
+ function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
3378
+ const schedule = timers.setInterval ?? setInterval;
3379
+ const cancel = timers.clearInterval ?? clearInterval;
3380
+ const timer = schedule(() => {
3381
+ void beat().catch(
3382
+ (e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
3383
+ );
3384
+ }, intervalMs);
3385
+ timer.unref?.();
3386
+ return () => cancel(timer);
3387
+ }
3388
+
3389
+ // src/commands/executor-run.ts
3390
+ function registerExecutorRunCommand(executor) {
3391
+ executor.command("fleet").description("Run multiple isolated driven codex executors from one config file").requiredOption("--config <file>", "JSON fleet config").action(async (opts) => {
3392
+ const fleet = superviseFleet(await readFleetConfig(String(opts.config)));
3393
+ const stop = () => void fleet.shutdown("SIGINT");
3394
+ process.once("SIGINT", stop);
3395
+ process.once("SIGTERM", stop);
3396
+ try {
3397
+ await fleet.done;
3398
+ } finally {
3399
+ process.off("SIGINT", stop);
3400
+ process.off("SIGTERM", stop);
3401
+ }
3402
+ });
3403
+ executor.command("run").description(
3404
+ "Run a driven codex executor: claim, execute, heartbeat, and complete dispatched tasks unattended"
3405
+ ).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(
3406
+ "--root <dir>",
3407
+ "Working directory for the spawned codex turns (e.g. ../sechroom_4)",
3408
+ process.cwd()
3409
+ ).option(
3410
+ "--codex-bin <bin>",
3411
+ "codex binary (or CODEX_BIN)",
3412
+ process.env.CODEX_BIN ?? "codex"
3413
+ ).option(
3414
+ "--instance-key <key>",
3415
+ "Registration identity (defaults to installed executor.json)"
3416
+ ).option(
3417
+ "--lane <lane>",
3418
+ "Affinity lane + completion source (defaults to instance key)"
3419
+ ).option(
3420
+ "--connector <id>",
3421
+ "Approved connector id (defaults to installed executor.json)"
3422
+ ).option("--ttl <seconds>", "Advertisement TTL (30-600)").option("--once", "Process a single task, then exit", false).option(
3423
+ "--no-deliver",
3424
+ "Skip driver-side delivery (branch/commit/push of the turn's changes)"
3425
+ ).option(
3426
+ "--allow-dirty-root",
3427
+ "Claim even when the root has uncommitted changes (they are fenced out of the delivery commit)",
3428
+ false
3429
+ ).option("--no-pr", "Deliver without raising a PR (branch + push only)").option("--poll-interval <seconds>", "Offer reconciliation interval", "5").option(
3430
+ "--heartbeat-interval <seconds>",
3431
+ "Lease heartbeat cadence",
3432
+ "30"
3433
+ ).option("--turn-timeout <seconds>", "Fresh turn timeout", "300").option("--resume-turn-timeout <seconds>", "Resumed turn timeout", "1200").option(
3434
+ "--drain-timeout <seconds>",
3435
+ "On shutdown, seconds to let an in-flight turn finish before interrupting",
3436
+ "30"
3437
+ ).option(
3438
+ "--usage-reserve <percent>",
3439
+ "Rate-limit reserve: defer claiming new tasks while remaining is at or below this percent",
3440
+ "2"
3441
+ ).action(async (opts, cmd) => {
3442
+ if (String(opts.runtime).toLowerCase() !== "codex")
3443
+ fail("executor run drives runtime codex only (claude-code stays attached)");
3444
+ const located = readExecutorState();
3445
+ if (!located)
3446
+ fail(
3447
+ "executor run requires an installed executor advertisement; run `sechroom executor install` first."
3448
+ );
3449
+ if (located.state.runtime !== "codex")
3450
+ fail(
3451
+ `this checkout's executor advertisement is runtime '${located.state.runtime}' \u2014 reinstall with --runtime codex.`
3452
+ );
3453
+ located.state = {
3454
+ ...located.state,
3455
+ instanceKey: opts.instanceKey ? String(opts.instanceKey) : located.state.instanceKey,
3456
+ laneId: opts.lane ? String(opts.lane) : located.state.laneId,
3457
+ connectorId: opts.connector ? String(opts.connector) : located.state.connectorId,
3458
+ ttlSeconds: opts.ttl ? Number.parseInt(String(opts.ttl), 10) : located.state.ttlSeconds
3459
+ };
3460
+ const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
3461
+ if (heartbeatMs >= 12e4)
3462
+ fail("--heartbeat-interval must be shorter than the 120s lease TTL");
3463
+ const usageReserve = Number.parseFloat(String(opts.usageReserve));
3464
+ if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
3465
+ fail("--usage-reserve must be a percent in [0, 100)");
3466
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3467
+ const instance = await ensureExecutorInstance(cfg, located);
3468
+ const log = (line) => process.stderr.write(style.dim(`[run] ${line}
3469
+ `));
3470
+ const request = createAuthedRequest(cfg);
3471
+ const rootDir = resolve2(String(opts.root));
3472
+ const usageLogPath = join9(
3473
+ rootDir,
3474
+ ".sechroom",
3475
+ `executor-usage-${located.state.instanceKey.replace(/[^\w.-]/g, "-")}.jsonl`
3476
+ );
3477
+ const usageTracker = new UsageTracker({
3478
+ instanceKey: located.state.instanceKey,
3479
+ reservePercent: usageReserve,
3480
+ log,
3481
+ appendRecord: createUsageLogAppender(usageLogPath, log)
3482
+ });
3483
+ const appServer = new CodexAppServer({
3484
+ codexBin: String(opts.codexBin),
3485
+ cwd: rootDir,
3486
+ model: opts.model ? String(opts.model) : void 0,
3487
+ sandbox: String(opts.sandbox),
3488
+ turnTimeoutMs: Number.parseInt(String(opts.turnTimeout), 10) * 1e3,
3489
+ resumeTurnTimeoutMs: Number.parseInt(String(opts.resumeTurnTimeout), 10) * 1e3,
3490
+ log,
3491
+ executorInstanceId: instance.id,
3492
+ emitTelemetry: (decompositionId, events) => postTelemetry(cfg, decompositionId, events).then(() => void 0),
3493
+ onUsage: (taskId, usage) => usageTracker.recordUsage(taskId, usage),
3494
+ onRateLimits: (limits) => usageTracker.recordRateLimits(limits)
3495
+ });
3496
+ await appServer.start();
3497
+ log(`codex app-server up (${String(opts.codexBin)})`);
3498
+ let stopping = false;
3499
+ let turnInFlight = false;
3500
+ const requestStop = () => {
3501
+ if (stopping) return;
3502
+ stopping = true;
3503
+ wake();
3504
+ log("shutdown requested \u2014 finishing up");
3505
+ if (turnInFlight) {
3506
+ const drainMs = Number.parseInt(String(opts.drainTimeout), 10) * 1e3;
3507
+ setTimeout(() => void appServer.interrupt(), drainMs).unref?.();
3508
+ }
3509
+ };
3510
+ process.once("SIGINT", requestStop);
3511
+ process.once("SIGTERM", requestStop);
3512
+ let wake = () => {
3513
+ };
3514
+ const wakeSignal = () => new Promise((resolve5) => {
3515
+ wake = resolve5;
3516
+ });
3517
+ let connStop = async () => {
3518
+ };
3519
+ try {
3520
+ const conn = await openConnection(cfg, () => wake(), instance.id);
3521
+ connStop = () => conn.stop();
3522
+ } catch (e) {
3523
+ log(`SignalR wake leg unavailable (${String(e)}) \u2014 poll-only`);
3524
+ }
3525
+ const stopAdvertisementHeartbeat = startExecutorHeartbeat(
3526
+ () => request(
3527
+ `/me/executor-instances/${encodeURIComponent(instance.id)}/refresh`,
3528
+ {
3529
+ method: "POST",
3530
+ body: JSON.stringify({ ttlSeconds: located.state.ttlSeconds })
3531
+ }
3532
+ ),
3533
+ located.state.refreshAfterSeconds * 1e3,
3534
+ { onError: (e) => log(`advertisement refresh failed: ${String(e)}`) }
3535
+ );
3536
+ const gitRunner = createGitRunner(rootDir);
3537
+ let rootSnapshot;
3538
+ const deliveryPorts = opts.deliver === false ? {} : {
3539
+ checkRootReady: async () => {
3540
+ const ready = await checkRootReady(
3541
+ gitRunner,
3542
+ Boolean(opts.allowDirtyRoot)
3543
+ );
3544
+ rootSnapshot = ready.snapshot;
3545
+ return { ok: ready.ok, reason: ready.reason };
3546
+ },
3547
+ deliver: (claim, task, verdict) => deliverTurn(gitRunner, {
3548
+ taskId: claim.memoryId,
3549
+ title: task.title,
3550
+ verdict,
3551
+ snapshot: rootSnapshot ?? {
3552
+ baseBranch: "HEAD",
3553
+ dirtyPaths: []
3554
+ },
3555
+ deliveredBy: located.state.laneId ?? located.state.instanceKey,
3556
+ raisePr: opts.pr !== false,
3557
+ log
3558
+ })
3559
+ };
3560
+ const ports = {
3561
+ ...deliveryPorts,
3562
+ checkAdmission: async () => usageTracker.admission(),
3563
+ claimNext: () => claimNext(request, instance.id, log),
3564
+ loadTask: (memoryId) => loadTask(request, memoryId, log),
3565
+ startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
3566
+ () => request(
3567
+ `/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
3568
+ {
3569
+ method: "POST",
3570
+ body: JSON.stringify({
3571
+ claimToken: claim.claimToken,
3572
+ tokenVersion: claim.tokenVersion
3573
+ })
3574
+ }
3575
+ ),
3576
+ log,
3577
+ heartbeatMs
3578
+ ),
3579
+ runTurn: async (task, claim) => {
3580
+ if (!appServer.alive) {
3581
+ log("codex app-server died between tasks \u2014 respawning");
3582
+ await appServer.start();
3583
+ }
3584
+ turnInFlight = true;
3585
+ try {
3586
+ return await appServer.runTask(taskPrompt(task), {
3587
+ taskId: claim.memoryId,
3588
+ leaseId: claim.leaseId,
3589
+ decompositionId: claim.decompositionId
3590
+ });
3591
+ } finally {
3592
+ turnInFlight = false;
3593
+ }
3594
+ },
3595
+ completeLease: (claim, verdict, text2, title) => request(
3596
+ `/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/complete`,
3597
+ {
3598
+ method: "POST",
3599
+ body: JSON.stringify({
3600
+ executorInstanceId: instance.id,
3601
+ claimToken: claim.claimToken,
3602
+ tokenVersion: claim.tokenVersion,
3603
+ verdict,
3604
+ text: text2,
3605
+ source: located.state.laneId ?? located.state.instanceKey,
3606
+ title
3607
+ })
3608
+ }
3609
+ ),
3610
+ log,
3611
+ waitForWake: (ms) => Promise.race([
3612
+ new Promise((resolve5) => {
3613
+ setTimeout(resolve5, ms).unref?.();
3614
+ }),
3615
+ wakeSignal()
3616
+ ])
3617
+ };
3618
+ log(
3619
+ `driven executor live \u2014 instance ${located.state.instanceKey}, lane ${located.state.laneId ?? located.state.instanceKey}${opts.once ? ", single-task mode" : ""}`
3620
+ );
3621
+ log(`usage log \u2192 ${usageLogPath}; admission reserve ${usageReserve}%`);
3622
+ try {
3623
+ const summary = await runDriverLoop(ports, {
3624
+ once: Boolean(opts.once),
3625
+ pollMs: Number.parseInt(String(opts.pollInterval), 10) * 1e3,
3626
+ stopping: () => stopping,
3627
+ source: located.state.laneId ?? located.state.instanceKey
3628
+ });
3629
+ log(
3630
+ `done \u2014 processed ${summary.processed}, completed ${summary.completed}, abandoned ${summary.abandoned}`
3631
+ );
3632
+ } catch (error) {
3633
+ if (error instanceof AuthExpiredError) {
3634
+ process.stderr.write(
3635
+ style.dim(`[run] auth expired: ${error.message}
3636
+ `)
3637
+ );
3638
+ process.exitCode = 1;
3639
+ } else {
3640
+ throw error;
3641
+ }
3642
+ } finally {
3643
+ stopAdvertisementHeartbeat();
3644
+ await connStop().catch(() => {
3645
+ });
3646
+ try {
3647
+ await request(
3648
+ `/me/executor-instances/${encodeURIComponent(instance.id)}`,
3649
+ { method: "DELETE", body: JSON.stringify({}) }
3650
+ );
3651
+ log("deregistered");
3652
+ } catch (e) {
3653
+ log(`deregister failed (${String(e)}) \u2014 advertisement will expire by TTL`);
3654
+ }
3655
+ appServer.stop();
3656
+ }
3657
+ });
3658
+ }
3659
+ async function claimNext(request, instanceId, log) {
3660
+ const claimed = await claimNextTask({ request, executorInstanceId: instanceId, log });
3661
+ if (!claimed) return void 0;
3662
+ return {
3663
+ memoryId: claimed.memoryId,
3664
+ leaseId: claimed.leaseId,
3665
+ claimToken: claimed.claimToken,
3666
+ tokenVersion: claimed.tokenVersion,
3667
+ decompositionId: claimed.decompositionId
3668
+ };
3669
+ }
3670
+ async function loadTask(request, memoryId, log) {
3671
+ const card = await request(
3672
+ `/tasks/${encodeURIComponent(memoryId)}/card`
3673
+ );
3674
+ let packText = "";
3675
+ const pointer = card.contextPack;
3676
+ if (pointer?.slug && pointer.version) {
3677
+ try {
3678
+ const pkg = await request(
3679
+ `/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
3680
+ );
3681
+ packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
3682
+ ${c.body}`).join("\n\n");
3683
+ } catch (error) {
3684
+ log(
3685
+ `warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
3686
+ );
3687
+ }
3688
+ }
3689
+ return { title: card.title ?? memoryId, text: assemblePrompt(card, packText) };
3690
+ }
3691
+ function assemblePrompt(card, packText) {
3692
+ const sections = [
3693
+ `# Task: ${card.title}`,
3694
+ `## Objective
3695
+ ${card.task.objective}`,
3696
+ `## Acceptance
3697
+ ${card.task.acceptance}`,
3698
+ `## Boundaries
3699
+ ${card.task.boundaries}`,
3700
+ `## Closeout
3701
+ ${card.task.closeout}`
3702
+ ];
3703
+ if (packText) sections.push(`## Context pack
3704
+ ${packText}`);
3705
+ return sections.join("\n\n");
3706
+ }
3707
+ function taskPrompt(task) {
3708
+ return `You are a driven executor working ONE dispatched Work Layer task.
3709
+
3710
+ ${task.text}
3711
+
3712
+ 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.`;
3713
+ }
3714
+
3715
+ // src/commands/executor.ts
3716
+ function executorSubscriptionInput(name) {
3717
+ return {
3718
+ name,
3719
+ enabled: true,
3720
+ filter: { tags: ["kind:task"], workspaceScope: [] }
3721
+ };
3722
+ }
3723
+ function executorRegistrationInput(state, deliverySubscriptionId) {
3724
+ return {
3725
+ relayId: state.relayId,
3726
+ instanceKey: state.instanceKey,
3727
+ laneId: state.laneId ?? state.instanceKey,
3728
+ runtimeKind: parseRuntimeKind(state.runtime),
3729
+ activationMode: "Attached",
3730
+ deliverySubscriptionId,
3731
+ connectorId: state.connectorId,
3732
+ claimedCapabilityKeys: state.capabilityKeys,
3733
+ toolSetRef: null,
3734
+ ttlSeconds: state.ttlSeconds
3735
+ };
3736
+ }
3737
+ var EXECUTOR_STATE = "executor.json";
3738
+ var EXECUTOR_PULSE_COMMAND = "sechroom executor hook-pulse";
3739
+ var EXECUTOR_STOP_COMMAND = "sechroom executor hook-stop";
3740
+ var CLAUDE_EXECUTOR_HOOKS = {
3741
+ SessionStart: EXECUTOR_PULSE_COMMAND,
3742
+ UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
3743
+ PreToolUse: EXECUTOR_PULSE_COMMAND,
3744
+ Stop: EXECUTOR_PULSE_COMMAND,
3745
+ SessionEnd: EXECUTOR_STOP_COMMAND
3746
+ };
3747
+ var CODEX_EXECUTOR_HOOKS = {
3748
+ SessionStart: EXECUTOR_PULSE_COMMAND,
3749
+ UserPromptSubmit: EXECUTOR_PULSE_COMMAND,
3750
+ PreToolUse: EXECUTOR_PULSE_COMMAND,
3751
+ Stop: EXECUTOR_PULSE_COMMAND
3752
+ };
3753
+ function registerExecutor(program2) {
3754
+ const executor = program2.command("executor").description(
3755
+ "Register and operate a local Claude Code/Codex executor advertisement"
3756
+ );
3757
+ executor.command("install").description(
3758
+ "Configure this checkout's harness to advertise itself as a WLP executor"
3759
+ ).option("--connector <id>", "Approved local-session ConnectorDefinition id").option(
3760
+ "--instance-key <key>",
3761
+ "Stable executor identity (defaults to .sechroom/lane.json code-lane)"
3762
+ ).option(
3763
+ "--lane-id <lane>",
3764
+ "Canonical affinity lane (defaults to .sechroom/lane.json code-lane)"
3765
+ ).option("--runtime <kind>", "claude-code | codex").option("--surface <surface>", "claude | codex").option(
3766
+ "--capability <key...>",
3767
+ "Capability operation keys claimed by this instance"
3768
+ ).option(
3769
+ "--relay <id>",
3770
+ "Relay identity shared by sibling instances",
3771
+ "sechroom-cli-local"
3772
+ ).option(
3773
+ "--subscription-name <name>",
1982
3774
  "SignalR delivery binding name",
1983
3775
  "executor-dispatch"
1984
3776
  ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 600).option(
@@ -2048,8 +3840,8 @@ function registerExecutor(program2) {
2048
3840
  if (opts.refreshAfter >= opts.ttl)
2049
3841
  fail("refresh-after must be shorter than the TTL");
2050
3842
  const sem = readSem();
2051
- const checkout = sem ? dirname5(dirname5(sem.path)) : process.cwd();
2052
- const statePath = join8(checkout, ".sechroom", EXECUTOR_STATE);
3843
+ const checkout = sem ? dirname7(dirname7(sem.path)) : process.cwd();
3844
+ const statePath = join10(checkout, ".sechroom", EXECUTOR_STATE);
2053
3845
  const state = {
2054
3846
  schemaVersion: 1,
2055
3847
  instanceKey,
@@ -2063,14 +3855,14 @@ function registerExecutor(program2) {
2063
3855
  refreshAfterSeconds: opts.refreshAfter
2064
3856
  };
2065
3857
  if (!opts.dryRun) {
2066
- mkdirSync6(dirname5(statePath), { recursive: true });
2067
- writeFileSync6(statePath, JSON.stringify(state, null, 2) + "\n");
3858
+ mkdirSync8(dirname7(statePath), { recursive: true });
3859
+ writeFileSync7(statePath, JSON.stringify(state, null, 2) + "\n");
2068
3860
  ensureStateDirIgnored(checkout);
2069
3861
  }
2070
3862
  const configuredClaudeDirs = globals.claudeConfigDir || process.env.CLAUDE_CONFIG_DIR ? resolveClaudeTargets({ override: globals.claudeConfigDir }).map(
2071
3863
  (target) => target.dir
2072
- ) : [join8(checkout, ".claude")];
2073
- const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join8(checkout, ".codex")];
3864
+ ) : [join10(checkout, ".claude")];
3865
+ const configuredCodexHomes = globals.codexHome || process.env.CODEX_HOME ? resolveCodexHomes({ override: globals.codexHome }) : [join10(checkout, ".codex")];
2074
3866
  const hookTargets = surface === "claude" ? configuredClaudeDirs : configuredCodexHomes;
2075
3867
  for (const target of hookTargets) {
2076
3868
  const results = surface === "claude" ? [
@@ -2114,7 +3906,7 @@ function registerExecutor(program2) {
2114
3906
  );
2115
3907
  delete located.state.instanceId;
2116
3908
  delete located.state.lastRefreshAt;
2117
- writeFileSync6(
3909
+ writeFileSync7(
2118
3910
  located.path,
2119
3911
  JSON.stringify(located.state, null, 2) + "\n"
2120
3912
  );
@@ -2253,6 +4045,7 @@ function registerExecutor(program2) {
2253
4045
  );
2254
4046
  emit(data, Boolean(cmd.optsWithGlobals().json));
2255
4047
  });
4048
+ registerExecutorRunCommand(executor);
2256
4049
  }
2257
4050
  function parseRuntimeKind(value) {
2258
4051
  switch (value.trim().toLowerCase()) {
@@ -2305,19 +4098,19 @@ async function ensureExecutorInstance(cfg, located) {
2305
4098
  const data = await registerInstance(cfg, state);
2306
4099
  state.instanceId = data.id;
2307
4100
  state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
2308
- writeFileSync6(path, JSON.stringify(state, null, 2) + "\n");
4101
+ writeFileSync7(path, JSON.stringify(state, null, 2) + "\n");
2309
4102
  return data;
2310
4103
  }
2311
4104
  function readExecutorState(start = process.cwd()) {
2312
4105
  const semPath = resolveSemPathForRead(start);
2313
4106
  const sem = semPath ? readSem(semPath) : void 0;
2314
- const path = join8(
2315
- sem ? dirname5(sem.path) : join8(start, ".sechroom"),
4107
+ const path = join10(
4108
+ sem ? dirname7(sem.path) : join10(start, ".sechroom"),
2316
4109
  EXECUTOR_STATE
2317
4110
  );
2318
- if (!existsSync7(path)) return void 0;
4111
+ if (!existsSync8(path)) return void 0;
2319
4112
  return {
2320
- state: JSON.parse(readFileSync5(path, "utf8")),
4113
+ state: JSON.parse(readFileSync6(path, "utf8")),
2321
4114
  path
2322
4115
  };
2323
4116
  }
@@ -2349,11 +4142,11 @@ function parseInteger(value) {
2349
4142
  return parsed;
2350
4143
  }
2351
4144
  function holdHeartbeat(tick, intervalMs) {
2352
- return new Promise((resolve3, reject) => {
4145
+ return new Promise((resolve5, reject) => {
2353
4146
  const timer = setInterval(() => void tick().catch(reject), intervalMs);
2354
4147
  const stop = () => {
2355
4148
  clearInterval(timer);
2356
- resolve3();
4149
+ resolve5();
2357
4150
  };
2358
4151
  process.once("SIGINT", stop);
2359
4152
  process.once("SIGTERM", stop);
@@ -2504,7 +4297,7 @@ function registerChannel(program2) {
2504
4297
  "MCP server + subscription name (idempotent per name)",
2505
4298
  "sechroom-channel"
2506
4299
  ).option("--dry-run", "Print what would change; write nothing").action((opts) => {
2507
- const path = join9(process.cwd(), ".mcp.json");
4300
+ const path = join11(process.cwd(), ".mcp.json");
2508
4301
  const dryRun = Boolean(opts.dryRun);
2509
4302
  const args = ["channel", "mcp"];
2510
4303
  const entry = { command: "sechroom", args };
@@ -2514,8 +4307,8 @@ function registerChannel(program2) {
2514
4307
  const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
2515
4308
  if (status !== "current" && !dryRun) {
2516
4309
  config2.mcpServers[opts.name] = entry;
2517
- mkdirSync7(dirname6(path), { recursive: true });
2518
- writeFileSync7(path, JSON.stringify(config2, null, 2) + "\n");
4310
+ mkdirSync9(dirname8(path), { recursive: true });
4311
+ writeFileSync8(path, JSON.stringify(config2, null, 2) + "\n");
2519
4312
  }
2520
4313
  const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
2521
4314
  process.stdout.write(`${style.green("channel")} ${path} (${verb})
@@ -2606,7 +4399,7 @@ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}
2606
4399
  }
2607
4400
  async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
2608
4401
  const request = dependencies.request ?? api;
2609
- const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve3) => setTimeout(resolve3, milliseconds)));
4402
+ const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve5) => setTimeout(resolve5, milliseconds)));
2610
4403
  const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
2611
4404
  const state = dependencies.state ?? {};
2612
4405
  for (; ; ) {
@@ -2651,8 +4444,8 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
2651
4444
  }
2652
4445
  }
2653
4446
  function readMcpConfig(path) {
2654
- if (!existsSync8(path)) return {};
2655
- const raw = readFileSync6(path, "utf8");
4447
+ if (!existsSync9(path)) return {};
4448
+ const raw = readFileSync7(path, "utf8");
2656
4449
  if (!raw.trim()) return {};
2657
4450
  try {
2658
4451
  return JSON.parse(raw);
@@ -2680,9 +4473,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
2680
4473
  return conn;
2681
4474
  }
2682
4475
  function holdOpen(conn) {
2683
- return new Promise((resolve3) => {
4476
+ return new Promise((resolve5) => {
2684
4477
  const stop = () => {
2685
- void conn.stop().finally(resolve3);
4478
+ void conn.stop().finally(resolve5);
2686
4479
  };
2687
4480
  process.on("SIGINT", stop);
2688
4481
  process.on("SIGTERM", stop);
@@ -2808,20 +4601,20 @@ Examples:
2808
4601
  }
2809
4602
 
2810
4603
  // src/commands/checkpoint.ts
2811
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync9 } from "fs";
2812
- import { dirname as dirname8, join as join11 } from "path";
4604
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync10 } from "fs";
4605
+ import { dirname as dirname10, join as join13 } from "path";
2813
4606
 
2814
4607
  // src/commands/hook.ts
2815
4608
  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() {
4609
+ import { existsSync as existsSync10, mkdirSync as mkdirSync10, readFileSync as readFileSync8, statSync as statSync2, writeFileSync as writeFileSync9 } from "fs";
4610
+ import { dirname as dirname9, join as join12 } from "path";
4611
+ async function readStdin2() {
2819
4612
  if (process.stdin.isTTY) return "";
2820
4613
  const chunks = [];
2821
4614
  for await (const chunk of process.stdin) chunks.push(chunk);
2822
4615
  return Buffer.concat(chunks).toString("utf8");
2823
4616
  }
2824
- function parseHookInput(raw) {
4617
+ function parseHookInput2(raw) {
2825
4618
  if (!raw.trim()) return {};
2826
4619
  try {
2827
4620
  return JSON.parse(raw);
@@ -2838,13 +4631,13 @@ function resolveLane(flagLane, cwd) {
2838
4631
  if (!base) return void 0;
2839
4632
  return applyWorktreeLaneSuffix(base, start);
2840
4633
  }
2841
- var INTENT_FILE = join10(".sechroom", "continuity.json");
4634
+ var INTENT_FILE = join12(".sechroom", "continuity.json");
2842
4635
  function resolveIntentPath(start) {
2843
4636
  let dir = start;
2844
4637
  for (; ; ) {
2845
- const candidate = join10(dir, INTENT_FILE);
2846
- if (existsSync9(candidate)) return candidate;
2847
- const parent = dirname7(dir);
4638
+ const candidate = join12(dir, INTENT_FILE);
4639
+ if (existsSync10(candidate)) return candidate;
4640
+ const parent = dirname9(dir);
2848
4641
  if (parent === dir) return void 0;
2849
4642
  dir = parent;
2850
4643
  }
@@ -2853,7 +4646,7 @@ function readIntent(start) {
2853
4646
  const path = resolveIntentPath(start);
2854
4647
  if (!path) return void 0;
2855
4648
  try {
2856
- return JSON.parse(readFileSync7(path, "utf8"));
4649
+ return JSON.parse(readFileSync8(path, "utf8"));
2857
4650
  } catch {
2858
4651
  return void 0;
2859
4652
  }
@@ -2895,14 +4688,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
2895
4688
  }
2896
4689
  function ledgerPath(start) {
2897
4690
  const intent = resolveIntentPath(start);
2898
- const dir = intent ? dirname7(intent) : join10(start, ".sechroom");
2899
- return join10(dir, ".checkpoint-state.json");
4691
+ const dir = intent ? dirname9(intent) : join12(start, ".sechroom");
4692
+ return join12(dir, ".checkpoint-state.json");
2900
4693
  }
2901
4694
  function readLedger(start) {
2902
4695
  try {
2903
4696
  const p = ledgerPath(start);
2904
- if (!existsSync9(p)) return {};
2905
- return JSON.parse(readFileSync7(p, "utf8"));
4697
+ if (!existsSync10(p)) return {};
4698
+ return JSON.parse(readFileSync8(p, "utf8"));
2906
4699
  } catch {
2907
4700
  return {};
2908
4701
  }
@@ -2949,13 +4742,13 @@ function recordPush(start, intent) {
2949
4742
  } catch {
2950
4743
  mtimeMs = void 0;
2951
4744
  }
2952
- mkdirSync8(dirname7(p), { recursive: true });
4745
+ mkdirSync10(dirname9(p), { recursive: true });
2953
4746
  const ledger = {
2954
4747
  lastEpochMs: Date.now(),
2955
4748
  lastMtimeMs: mtimeMs,
2956
4749
  lastHash: intentHash(intent)
2957
4750
  };
2958
- writeFileSync8(p, JSON.stringify(ledger) + "\n");
4751
+ writeFileSync9(p, JSON.stringify(ledger) + "\n");
2959
4752
  } catch {
2960
4753
  }
2961
4754
  }
@@ -3015,8 +4808,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3015
4808
  );
3016
4809
  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
4810
  try {
3018
- const raw = await readStdin();
3019
- const input = parseHookInput(raw);
4811
+ const raw = await readStdin2();
4812
+ const input = parseHookInput2(raw);
3020
4813
  const lane = resolveLane(opts.lane, input.cwd);
3021
4814
  if (!lane) return process.exit(0);
3022
4815
  const semPath = resolveSemPathForRead(input.cwd ?? process.cwd());
@@ -3041,8 +4834,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3041
4834
  });
3042
4835
  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
4836
  try {
3044
- const raw = await readStdin();
3045
- const input = parseHookInput(raw);
4837
+ const raw = await readStdin2();
4838
+ const input = parseHookInput2(raw);
3046
4839
  const cwd = input.cwd ?? process.cwd();
3047
4840
  await saveSnapshotFromIntent(cmd, cwd, opts.lane, opts.scope, "compaction", { skipIfUnchanged: true });
3048
4841
  return process.exit(0);
@@ -3055,8 +4848,8 @@ Fail-soft: no lane / no auth / no-or-partial intent file / API error -> exit 0,
3055
4848
  "skip if a hook checkpoint ran within this many minutes \u2014 for high-frequency triggers like Codex Stop (Claude SessionEnd passes none)"
3056
4849
  ).action(async (opts, cmd) => {
3057
4850
  try {
3058
- const raw = await readStdin();
3059
- const input = parseHookInput(raw);
4851
+ const raw = await readStdin2();
4852
+ const input = parseHookInput2(raw);
3060
4853
  const cwd = input.cwd ?? process.cwd();
3061
4854
  const debounce = opts.debounceMinutes != null ? Number(opts.debounceMinutes) : 0;
3062
4855
  if (debounce > 0 && recentlyCheckpointed(cwd, debounce)) return process.exit(0);
@@ -3208,10 +5001,10 @@ Examples:
3208
5001
  const client = await makeClient(cfg);
3209
5002
  return client.POST("/continuity/snapshots", { body });
3210
5003
  });
3211
- const path = resolveIntentPath(cwd) ?? join11(cwd, INTENT_FILE);
5004
+ const path = resolveIntentPath(cwd) ?? join13(cwd, INTENT_FILE);
3212
5005
  const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
3213
- mkdirSync9(dirname8(path), { recursive: true });
3214
- writeFileSync9(path, JSON.stringify(fileBody, null, 2) + "\n");
5006
+ mkdirSync11(dirname10(path), { recursive: true });
5007
+ writeFileSync10(path, JSON.stringify(fileBody, null, 2) + "\n");
3215
5008
  recordPush(cwd, merged);
3216
5009
  if (json) {
3217
5010
  emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
@@ -3225,7 +5018,7 @@ Examples:
3225
5018
  }
3226
5019
 
3227
5020
  // src/commands/close.ts
3228
- import { readFileSync as readFileSync8 } from "fs";
5021
+ import { readFileSync as readFileSync9 } from "fs";
3229
5022
  var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
3230
5023
  function registerClose(program2) {
3231
5024
  program2.command("close").description(
@@ -3266,7 +5059,7 @@ Examples:
3266
5059
  );
3267
5060
  let bodyText;
3268
5061
  try {
3269
- bodyText = opts.file ? readFileSync8(opts.file, "utf8") : readFileSync8(0, "utf8");
5062
+ bodyText = opts.file ? readFileSync9(opts.file, "utf8") : readFileSync9(0, "utf8");
3270
5063
  } catch {
3271
5064
  fail(
3272
5065
  opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
@@ -3557,7 +5350,7 @@ Examples:
3557
5350
  }
3558
5351
 
3559
5352
  // src/commands/work-plan.ts
3560
- import { readFile } from "fs/promises";
5353
+ import { readFile as readFile2 } from "fs/promises";
3561
5354
  function registerWorkPlan(program2) {
3562
5355
  const workPlan = program2.command("work-plan").description(
3563
5356
  "Drive a work plan: create one from a brief, then execute / accept / reject"
@@ -3568,9 +5361,13 @@ function registerWorkPlan(program2) {
3568
5361
  Examples:
3569
5362
  $ sechroom work-plan create mem_XXXX
3570
5363
  $ sechroom work-plan create-from-tasks mem_XXXX --file plan.json
5364
+ $ sechroom work-plan append wlp_XXXX --file append.json
3571
5365
  $ sechroom work-plan get-plan wlp_XXXX
3572
5366
  $ sechroom work-plan execute wlp_XXXX
3573
5367
  $ sechroom work-plan publish-context-pack wlp_XXXX
5368
+ $ sechroom work-plan resume wlp_XXXX
5369
+ $ sechroom work-plan return-for-revision wlp_XXXX --notes "Split the migration task"
5370
+ $ sechroom work-plan list --status Accepted --brief mem_XXXX
3574
5371
  $ sechroom work-plan accept wlp_XXXX
3575
5372
  $ sechroom work-plan reject wlp_XXXX --reason "wrong shape"`
3576
5373
  );
@@ -3580,38 +5377,57 @@ Examples:
3580
5377
  const cfg = resolveConfig(cmd.optsWithGlobals());
3581
5378
  const data = await runApi("Creating work plan", async () => {
3582
5379
  const client = await makeClient(cfg);
3583
- return client.POST("/work-briefs/{id}/decompose", {
5380
+ return client.POST("/work-briefs/{id}/decompose", {
5381
+ params: { path: { id: briefId } },
5382
+ body: { id: briefId }
5383
+ });
5384
+ });
5385
+ emitAction(
5386
+ `created work plan from ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
5387
+ data,
5388
+ cmd.optsWithGlobals().json
5389
+ );
5390
+ });
5391
+ workPlan.command("create-from-tasks <briefId>").description(
5392
+ "Create a work plan from a hand-authored JSON task list; tasks may carry Unix-millisecond wakeAtMs"
5393
+ ).requiredOption(
5394
+ "--file <path>",
5395
+ "JSON file containing { project, tasks, source? }; use - for stdin"
5396
+ ).action(async (briefId, opts, cmd) => {
5397
+ const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
5398
+ const body = parsePlanInput(raw, opts.file);
5399
+ const cfg = resolveConfig(cmd.optsWithGlobals());
5400
+ const data = await runApi("Creating work plan from tasks", async () => {
5401
+ const client = await makeClient(cfg);
5402
+ return client.POST("/work-briefs/{id}/decompose-from-plan", {
3584
5403
  params: { path: { id: briefId } },
3585
- body: { id: briefId }
5404
+ body
3586
5405
  });
3587
5406
  });
3588
5407
  emitAction(
3589
- `created work plan from ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
5408
+ `created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
3590
5409
  data,
3591
5410
  cmd.optsWithGlobals().json
3592
5411
  );
3593
5412
  });
3594
- workPlan.command("create-from-tasks <briefId>").description(
3595
- "Create a work plan from a hand-authored JSON task list; tasks may carry Unix-millisecond wakeAtMs"
5413
+ workPlan.command("append <decompositionId>").description(
5414
+ "Append hand-authored task(s) to a running work plan; they land proposed until ratified (POST /decompositions/{id}/append-tasks)"
3596
5415
  ).requiredOption(
3597
5416
  "--file <path>",
3598
- "JSON file containing { project, tasks, source? }; use - for stdin"
3599
- ).action(async (briefId, opts, cmd) => {
3600
- const raw = opts.file === "-" ? await readStdin2() : await readFile(opts.file, "utf8");
3601
- const body = parsePlanInput(raw, opts.file);
5417
+ "JSON file containing { tasks, gates? } (the AppendTasksInput shape); use - for stdin"
5418
+ ).action(async (decompositionId, opts, cmd) => {
5419
+ const raw = opts.file === "-" ? await readStdin3() : await readFile2(opts.file, "utf8");
5420
+ const body = parseAppendInput(raw, opts.file);
3602
5421
  const cfg = resolveConfig(cmd.optsWithGlobals());
3603
- const data = await runApi(
3604
- "Creating work plan from tasks",
3605
- async () => {
3606
- const client = await makeClient(cfg);
3607
- return client.POST("/work-briefs/{id}/decompose-from-plan", {
3608
- params: { path: { id: briefId } },
3609
- body
3610
- });
3611
- }
3612
- );
5422
+ const data = await runApi("Appending tasks to work plan", async () => {
5423
+ const client = await makeClient(cfg);
5424
+ return client.POST("/decompositions/{id}/append-tasks", {
5425
+ params: { path: { id: decompositionId } },
5426
+ body
5427
+ });
5428
+ });
3613
5429
  emitAction(
3614
- `created ${style.bold(data.suggestionId)} from ${data.taskCount} hand-authored task(s)`,
5430
+ `appended ${style.bold(String(data.appendedTaskIds.length))} task(s) to ${style.bold(decompositionId)}`,
3615
5431
  data,
3616
5432
  cmd.optsWithGlobals().json
3617
5433
  );
@@ -3666,6 +5482,70 @@ Examples:
3666
5482
  cmd.optsWithGlobals().json
3667
5483
  );
3668
5484
  });
5485
+ workPlan.command("resume <decompositionId>").description(
5486
+ "Resume a failed work-plan decomposition (POST /decompositions/{id}/resume)"
5487
+ ).action(async (decompositionId, _opts, cmd) => {
5488
+ const globals = cmd.optsWithGlobals();
5489
+ const cfg = resolveConfig(globals);
5490
+ const data = await runApi("Resuming work plan", async () => {
5491
+ const client = await makeClient(cfg);
5492
+ return client.POST("/decompositions/{id}/resume", {
5493
+ params: { path: { id: decompositionId } },
5494
+ body: {}
5495
+ });
5496
+ });
5497
+ emitAction(
5498
+ `resumed work plan ${style.bold(decompositionId)} \u2192 ${data.status}`,
5499
+ data,
5500
+ globals.json
5501
+ );
5502
+ });
5503
+ workPlan.command("return-for-revision <decompositionId>").description(
5504
+ "Return a candidate work plan for revision (POST /decompositions/{id}/return-for-revision)"
5505
+ ).requiredOption(
5506
+ "--notes <text>",
5507
+ "Revision notes for the next decomposition attempt"
5508
+ ).action(async (decompositionId, opts, cmd) => {
5509
+ const globals = cmd.optsWithGlobals();
5510
+ const cfg = resolveConfig(globals);
5511
+ const data = await runApi(
5512
+ "Returning work plan for revision",
5513
+ async () => {
5514
+ const client = await makeClient(cfg);
5515
+ return client.POST("/decompositions/{id}/return-for-revision", {
5516
+ params: { path: { id: decompositionId } },
5517
+ body: { notes: opts.notes }
5518
+ });
5519
+ }
5520
+ );
5521
+ emitAction(
5522
+ `returned ${style.bold(decompositionId)} \u2192 ${style.bold(data.newSuggestionId)}`,
5523
+ data,
5524
+ globals.json
5525
+ );
5526
+ });
5527
+ 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) => {
5528
+ const globals = cmd.optsWithGlobals();
5529
+ const cfg = resolveConfig(globals);
5530
+ const data = await runApi("Listing work plans", async () => {
5531
+ const client = await makeClient(cfg);
5532
+ return client.GET("/decompositions", {
5533
+ params: {
5534
+ query: {
5535
+ status: opts.status,
5536
+ briefId: opts.brief,
5537
+ page: opts.page,
5538
+ pageSize: opts.pageSize
5539
+ }
5540
+ }
5541
+ });
5542
+ });
5543
+ emitAction(
5544
+ `listed ${style.bold(String(data.items.length))} of ${data.count} work plan(s)`,
5545
+ data,
5546
+ globals.json
5547
+ );
5548
+ });
3669
5549
  workPlan.command("accept <decompositionId>").description(
3670
5550
  "Accept a Pending work plan \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
3671
5551
  ).action(async (decompositionId, _opts, cmd) => {
@@ -3701,6 +5581,19 @@ Examples:
3701
5581
  );
3702
5582
  });
3703
5583
  }
5584
+ function parseAppendInput(raw, sourceName = "append input") {
5585
+ let value;
5586
+ try {
5587
+ value = JSON.parse(raw);
5588
+ } catch (error) {
5589
+ throw new Error(
5590
+ `${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
5591
+ );
5592
+ }
5593
+ if (!value || typeof value !== "object" || !Array.isArray(value.tasks))
5594
+ throw new Error(`${sourceName} must contain an object with a tasks array`);
5595
+ return value;
5596
+ }
3704
5597
  function parsePlanInput(raw, sourceName = "plan input") {
3705
5598
  let value;
3706
5599
  try {
@@ -3714,7 +5607,7 @@ function parsePlanInput(raw, sourceName = "plan input") {
3714
5607
  throw new Error(`${sourceName} must contain an object with a tasks array`);
3715
5608
  return value;
3716
5609
  }
3717
- async function readStdin2() {
5610
+ async function readStdin3() {
3718
5611
  const chunks = [];
3719
5612
  for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
3720
5613
  return Buffer.concat(chunks).toString("utf8");
@@ -4231,8 +6124,8 @@ Examples:
4231
6124
 
4232
6125
  // src/setup/apply.ts
4233
6126
  import { createHash as createHash3 } from "crypto";
4234
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync9, writeFileSync as writeFileSync10, existsSync as existsSync10 } from "fs";
4235
- import { dirname as dirname9 } from "path";
6127
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync11, existsSync as existsSync11 } from "fs";
6128
+ import { dirname as dirname11 } from "path";
4236
6129
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
4237
6130
  var MARKER_END = "<!-- @sechroom/cli:end";
4238
6131
  function normalizeBody(s) {
@@ -4285,22 +6178,22 @@ function parseManagedBlock(content, block) {
4285
6178
  return null;
4286
6179
  }
4287
6180
  function ensureDir2(path) {
4288
- mkdirSync10(dirname9(path), { recursive: true });
6181
+ mkdirSync12(dirname11(path), { recursive: true });
4289
6182
  }
4290
6183
  function readOr(path, fallback) {
4291
6184
  try {
4292
- return readFileSync9(path, "utf8");
6185
+ return readFileSync10(path, "utf8");
4293
6186
  } catch {
4294
6187
  return fallback;
4295
6188
  }
4296
6189
  }
4297
6190
  function mergeMcpJson(path, snippet, dryRun) {
4298
6191
  const incoming = JSON.parse(snippet);
4299
- const existed = existsSync10(path);
6192
+ const existed = existsSync11(path);
4300
6193
  let current = {};
4301
6194
  if (existed) {
4302
6195
  try {
4303
- current = JSON.parse(readFileSync9(path, "utf8"));
6196
+ current = JSON.parse(readFileSync10(path, "utf8"));
4304
6197
  } catch {
4305
6198
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
4306
6199
  }
@@ -4308,26 +6201,26 @@ function mergeMcpJson(path, snippet, dryRun) {
4308
6201
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
4309
6202
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
4310
6203
  ensureDir2(path);
4311
- writeFileSync10(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
6204
+ writeFileSync11(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
4312
6205
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
4313
6206
  }
4314
6207
  function mergeCodexToml(path, snippet, dryRun) {
4315
- const existed = existsSync10(path);
6208
+ const existed = existsSync11(path);
4316
6209
  let body = readOr(path, "");
4317
6210
  body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
4318
6211
  const trimmed = body.trim();
4319
6212
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
4320
6213
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
4321
6214
  ensureDir2(path);
4322
- writeFileSync10(path, next, { mode: 384 });
6215
+ writeFileSync11(path, next, { mode: 384 });
4323
6216
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
4324
6217
  }
4325
6218
  function writeInstructionBlock(path, write, dryRun) {
4326
- const existed = existsSync10(path);
6219
+ const existed = existsSync11(path);
4327
6220
  const next = computeBlockFile(readOr(path, ""), write);
4328
6221
  if (dryRun) return { kind: "instruction", path, status: "dry-run" };
4329
6222
  ensureDir2(path);
4330
- writeFileSync10(path, next);
6223
+ writeFileSync11(path, next);
4331
6224
  return { kind: "instruction", path, status: existed ? "merged" : "created" };
4332
6225
  }
4333
6226
  function computeBlockFile(current, write) {
@@ -4368,7 +6261,7 @@ function applyBlock(path, write, mode, dryRun) {
4368
6261
  const next = computeBlockFile(current, write);
4369
6262
  if (!dryRun) {
4370
6263
  ensureDir2(proposedPath);
4371
- writeFileSync10(proposedPath, next);
6264
+ writeFileSync11(proposedPath, next);
4372
6265
  }
4373
6266
  return {
4374
6267
  kind: "instruction",
@@ -4498,8 +6391,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
4498
6391
  }
4499
6392
 
4500
6393
  // src/setup/skills-offer.ts
4501
- import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync11 } from "fs";
4502
- import { join as join12 } from "path";
6394
+ import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync12 } from "fs";
6395
+ import { join as join14 } from "path";
4503
6396
 
4504
6397
  // src/setup/lane-pin.ts
4505
6398
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -4615,8 +6508,8 @@ Found ${summary} available to you for ${surface}.
4615
6508
  if (skills.length > 0) {
4616
6509
  const written = [];
4617
6510
  for (const s of skills) {
4618
- mkdirSync11(join12(sDir, s.name), { recursive: true });
4619
- writeFileSync11(join12(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
6511
+ mkdirSync13(join14(sDir, s.name), { recursive: true });
6512
+ writeFileSync12(join14(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
4620
6513
  written.push(s.name);
4621
6514
  }
4622
6515
  recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -4624,11 +6517,11 @@ Found ${summary} available to you for ${surface}.
4624
6517
  `);
4625
6518
  }
4626
6519
  if (agents.length > 0) {
4627
- mkdirSync11(aDir, { recursive: true });
6520
+ mkdirSync13(aDir, { recursive: true });
4628
6521
  const written = [];
4629
6522
  for (const a of agents) {
4630
6523
  const file = `${a.name}.md`;
4631
- writeFileSync11(join12(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
6524
+ writeFileSync12(join14(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
4632
6525
  written.push(file);
4633
6526
  }
4634
6527
  recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -4963,12 +6856,12 @@ Examples:
4963
6856
  });
4964
6857
  emit(data, cmd.optsWithGlobals().json);
4965
6858
  });
4966
- 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) => {
6859
+ 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) => {
4967
6860
  const cfg = resolveConfig(cmd.optsWithGlobals());
4968
6861
  const data = await runApi("Fetching namespace", async () => {
4969
6862
  const client = await makeClient(cfg);
4970
6863
  return client.GET("/mcp-aggregator/namespaces/{slug}", {
4971
- params: { path: { slug } }
6864
+ params: { path: { slug: slug2 } }
4972
6865
  });
4973
6866
  });
4974
6867
  emit(data, cmd.optsWithGlobals().json);
@@ -4977,11 +6870,11 @@ Examples:
4977
6870
  "--client <list>",
4978
6871
  `comma-separated clients (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`,
4979
6872
  DEFAULT_CLIENT_KEY
4980
- ).option("--dry-run", "print what would be written without writing", false).action(async (slug, opts, cmd) => {
6873
+ ).option("--dry-run", "print what would be written without writing", false).action(async (slug2, opts, cmd) => {
4981
6874
  const cfg = resolveConfig(cmd.optsWithGlobals());
4982
6875
  const setup = await withSpinner(
4983
6876
  "Fetching setup descriptors",
4984
- () => fetchSetup(cfg, slug)
6877
+ () => fetchSetup(cfg, slug2)
4985
6878
  );
4986
6879
  const targets = clientTargets(process.cwd());
4987
6880
  const keys = resolveClientKeys(opts.client);
@@ -4999,25 +6892,25 @@ Examples:
4999
6892
  if (!json) printActions(target, actions);
5000
6893
  }
5001
6894
  if (json) {
5002
- emit({ namespace: slug, dryRun: Boolean(opts.dryRun), clients: result }, true);
6895
+ emit({ namespace: slug2, dryRun: Boolean(opts.dryRun), clients: result }, true);
5003
6896
  return;
5004
6897
  }
5005
6898
  process.stdout.write(
5006
6899
  opts.dryRun ? "\n(dry run \u2014 nothing written)\n" : `
5007
- Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it up.
6900
+ Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it up.
5008
6901
  `
5009
6902
  );
5010
6903
  });
5011
6904
  }
5012
6905
 
5013
6906
  // src/commands/onboard.ts
5014
- import { existsSync as existsSync12 } from "fs";
5015
- import { basename as basename2, join as join14 } from "path";
6907
+ import { existsSync as existsSync13 } from "fs";
6908
+ import { basename as basename2, join as join16 } from "path";
5016
6909
 
5017
6910
  // src/commands/fanout.ts
5018
6911
  import { spawnSync } from "child_process";
5019
- import { existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
5020
- import { isAbsolute, join as join13, resolve } from "path";
6912
+ import { existsSync as existsSync12, readFileSync as readFileSync11, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
6913
+ import { isAbsolute, join as join15, resolve as resolve3 } from "path";
5021
6914
  var ICON = {
5022
6915
  refresh: "\u21BB",
5023
6916
  bind: "+",
@@ -5025,7 +6918,7 @@ var ICON = {
5025
6918
  "skip-unbound": "\u26A0"
5026
6919
  };
5027
6920
  function resolveChildDir(path, root) {
5028
- return isAbsolute(path) ? path : resolve(root, path);
6921
+ return isAbsolute(path) ? path : resolve3(root, path);
5029
6922
  }
5030
6923
  function discoverChildren(root) {
5031
6924
  let names;
@@ -5037,21 +6930,21 @@ function discoverChildren(root) {
5037
6930
  const out = [];
5038
6931
  for (const name of names.sort()) {
5039
6932
  if (name.startsWith(".") || name === "node_modules") continue;
5040
- const dir = join13(root, name);
6933
+ const dir = join15(root, name);
5041
6934
  try {
5042
6935
  if (!statSync3(dir).isDirectory()) continue;
5043
6936
  } catch {
5044
6937
  continue;
5045
6938
  }
5046
- if (existsSync11(join13(dir, ".git")) || committedBindingPath(dir)) out.push(name);
6939
+ if (existsSync12(join15(dir, ".git")) || committedBindingPath(dir)) out.push(name);
5047
6940
  }
5048
6941
  return out;
5049
6942
  }
5050
6943
  function readManifest(path) {
5051
- if (!existsSync11(path)) return null;
6944
+ if (!existsSync12(path)) return null;
5052
6945
  let parsed;
5053
6946
  try {
5054
- parsed = JSON.parse(readFileSync10(path, "utf8"));
6947
+ parsed = JSON.parse(readFileSync11(path, "utf8"));
5055
6948
  } catch (err2) {
5056
6949
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
5057
6950
  }
@@ -5417,10 +7310,10 @@ async function chooseScope(scopeFlag, yes) {
5417
7310
  }
5418
7311
  async function planRecurseChild(entry, root, client, opts) {
5419
7312
  const dir = resolveChildDir(entry.path, root);
5420
- if (!existsSync12(dir)) {
7313
+ if (!existsSync13(dir)) {
5421
7314
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
5422
7315
  }
5423
- if (existsSync12(join14(dir, ".sechroom.json"))) {
7316
+ if (existsSync13(join16(dir, ".sechroom.json"))) {
5424
7317
  return {
5425
7318
  label: entry.path,
5426
7319
  dir,
@@ -5493,7 +7386,7 @@ This fan-out will pin the same lane in every repo:
5493
7386
  async function runRecurse(cfg, g, opts) {
5494
7387
  const { yes, dryRun, json } = opts;
5495
7388
  const root = process.cwd();
5496
- const manifestPath = join14(root, ".sechroom", "repos.json");
7389
+ const manifestPath = join16(root, ".sechroom", "repos.json");
5497
7390
  const fromManifest = readManifest(manifestPath);
5498
7391
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
5499
7392
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -6026,31 +7919,31 @@ Examples:
6026
7919
 
6027
7920
  // src/commands/reset.ts
6028
7921
  import { homedir as homedir4 } from "os";
6029
- import { join as join15 } from "path";
6030
- import { existsSync as existsSync13, readFileSync as readFileSync11, rmSync as rmSync3 } from "fs";
7922
+ import { join as join17 } from "path";
7923
+ import { existsSync as existsSync14, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
6031
7924
  var SKILLS_LOCK2 = ".sechroom-skills.json";
6032
- var localSkillsDir = () => join15(process.cwd(), ".claude", "skills");
6033
- var globalSkillsDir = () => join15(homedir4(), ".claude", "skills");
6034
- var localAgentsDir = () => join15(process.cwd(), ".claude", "agents");
6035
- var globalAgentsDir = () => join15(homedir4(), ".claude", "agents");
7925
+ var localSkillsDir = () => join17(process.cwd(), ".claude", "skills");
7926
+ var globalSkillsDir = () => join17(homedir4(), ".claude", "skills");
7927
+ var localAgentsDir = () => join17(process.cwd(), ".claude", "agents");
7928
+ var globalAgentsDir = () => join17(homedir4(), ".claude", "agents");
6036
7929
  function removeMaterialisedSkills(dir) {
6037
7930
  const removed = [];
6038
- const lockPath = join15(dir, SKILLS_LOCK2);
6039
- if (!existsSync13(lockPath)) return removed;
7931
+ const lockPath = join17(dir, SKILLS_LOCK2);
7932
+ if (!existsSync14(lockPath)) return removed;
6040
7933
  try {
6041
- const lock = JSON.parse(readFileSync11(lockPath, "utf8"));
7934
+ const lock = JSON.parse(readFileSync12(lockPath, "utf8"));
6042
7935
  for (const entry of Object.values(lock)) {
6043
7936
  for (const name of entry.skills ?? []) {
6044
- const p = join15(dir, name);
6045
- if (existsSync13(p)) {
6046
- rmSync3(p, { recursive: true, force: true });
7937
+ const p = join17(dir, name);
7938
+ if (existsSync14(p)) {
7939
+ rmSync4(p, { recursive: true, force: true });
6047
7940
  removed.push(p);
6048
7941
  }
6049
7942
  }
6050
7943
  }
6051
7944
  } catch {
6052
7945
  }
6053
- rmSync3(lockPath, { force: true });
7946
+ rmSync4(lockPath, { force: true });
6054
7947
  removed.push(lockPath);
6055
7948
  return removed;
6056
7949
  }
@@ -6087,19 +7980,19 @@ function registerReset(program2) {
6087
7980
  }
6088
7981
  }
6089
7982
  const removed = [];
6090
- const stateDir = join15(process.cwd(), ".sechroom");
6091
- if (existsSync13(stateDir)) {
6092
- rmSync3(stateDir, { recursive: true, force: true });
7983
+ const stateDir = join17(process.cwd(), ".sechroom");
7984
+ if (existsSync14(stateDir)) {
7985
+ rmSync4(stateDir, { recursive: true, force: true });
6093
7986
  removed.push(stateDir);
6094
7987
  }
6095
- const legacyCfg = join15(process.cwd(), ".sechroom.json");
6096
- if (existsSync13(legacyCfg)) {
6097
- rmSync3(legacyCfg, { force: true });
7988
+ const legacyCfg = join17(process.cwd(), ".sechroom.json");
7989
+ if (existsSync14(legacyCfg)) {
7990
+ rmSync4(legacyCfg, { force: true });
6098
7991
  removed.push(legacyCfg);
6099
7992
  }
6100
- const legacySem = join15(process.cwd(), ".sem");
6101
- if (existsSync13(legacySem)) {
6102
- rmSync3(legacySem, { force: true });
7993
+ const legacySem = join17(process.cwd(), ".sem");
7994
+ if (existsSync14(legacySem)) {
7995
+ rmSync4(legacySem, { force: true });
6103
7996
  removed.push(legacySem);
6104
7997
  }
6105
7998
  removed.push(...removeMaterialisedSkills(localSkillsDir()));
@@ -6124,8 +8017,8 @@ function registerReset(program2) {
6124
8017
  }
6125
8018
 
6126
8019
  // src/commands/skills.ts
6127
- import { existsSync as existsSync14, mkdirSync as mkdirSync12, statSync as statSync4, writeFileSync as writeFileSync12 } from "fs";
6128
- import { join as join16 } from "path";
8020
+ import { existsSync as existsSync15, mkdirSync as mkdirSync14, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
8021
+ import { join as join18 } from "path";
6129
8022
  function filenameFromDisposition(header) {
6130
8023
  if (!header) return void 0;
6131
8024
  const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
@@ -6133,11 +8026,11 @@ function filenameFromDisposition(header) {
6133
8026
  }
6134
8027
  function resolveOutputPath(output, serverFilename) {
6135
8028
  const filename = serverFilename || "skills.zip";
6136
- if (!output) return join16(process.cwd(), filename);
6137
- const looksLikeDir = output.endsWith("/") || existsSync14(output) && statSync4(output).isDirectory();
8029
+ if (!output) return join18(process.cwd(), filename);
8030
+ const looksLikeDir = output.endsWith("/") || existsSync15(output) && statSync4(output).isDirectory();
6138
8031
  if (looksLikeDir) {
6139
- mkdirSync12(output, { recursive: true });
6140
- return join16(output, filename);
8032
+ mkdirSync14(output, { recursive: true });
8033
+ return join18(output, filename);
6141
8034
  }
6142
8035
  return output;
6143
8036
  }
@@ -6168,7 +8061,7 @@ async function downloadZip(label, call, output) {
6168
8061
  const buf = Buffer.from(res.data);
6169
8062
  const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
6170
8063
  const path = resolveOutputPath(output, filename);
6171
- writeFileSync12(path, buf);
8064
+ writeFileSync13(path, buf);
6172
8065
  return { path, bytes: buf.length, filename };
6173
8066
  }
6174
8067
  function registerSkills(program2) {
@@ -6342,12 +8235,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
6342
8235
  }
6343
8236
 
6344
8237
  // src/commands/sweep.ts
6345
- import { existsSync as existsSync15 } from "fs";
6346
- import { dirname as dirname10, join as join17, resolve as resolve2 } from "path";
6347
- var DEFAULT_MANIFEST = join17(".sechroom", "repos.json");
8238
+ import { existsSync as existsSync16 } from "fs";
8239
+ import { dirname as dirname12, join as join19, resolve as resolve4 } from "path";
8240
+ var DEFAULT_MANIFEST = join19(".sechroom", "repos.json");
6348
8241
  function planEntry(entry, root) {
6349
8242
  const dir = resolveChildDir(entry.path, root);
6350
- if (!existsSync15(dir)) {
8243
+ if (!existsSync16(dir)) {
6351
8244
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
6352
8245
  }
6353
8246
  if (committedBindingPath(dir)) {
@@ -6400,400 +8293,44 @@ Per repo (paths resolve relative to the manifest's root):
6400
8293
  ${ICON["skip-unbound"]} no workspace unbound + no workspaceId in manifest \u2192 skipped (add one, or onboard manually)
6401
8294
 
6402
8295
  Examples:
6403
- $ sechroom sweep --dry-run preview every repo's disposition, run nothing
6404
- $ sechroom sweep onboard the whole tree from the root
6405
- $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
6406
- ).action((opts, cmd) => {
6407
- const g = cmd.optsWithGlobals();
6408
- const json = Boolean(g.json);
6409
- const dryRun = Boolean(opts.dryRun);
6410
- const manifestPath = resolve2(opts.manifest);
6411
- let repos;
6412
- try {
6413
- repos = readManifest(manifestPath);
6414
- } catch (err2) {
6415
- fail(err2 instanceof Error ? err2.message : String(err2));
6416
- }
6417
- if (repos === null) {
6418
- fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
6419
- }
6420
- if (repos.length === 0) {
6421
- if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
6422
- else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
6423
- `);
6424
- return;
6425
- }
6426
- const root = dirname10(dirname10(manifestPath));
6427
- const plans = repos.map((entry) => planEntry(entry, root));
6428
- if (!json) {
6429
- process.stderr.write(
6430
- `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
6431
- `
6432
- );
6433
- }
6434
- const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
6435
- if (json) {
6436
- process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
6437
- return;
6438
- }
6439
- summarizeFanout(results, { dryRun });
6440
- });
6441
- }
6442
-
6443
- // src/commands/telemetry.ts
6444
- import {
6445
- existsSync as existsSync16,
6446
- mkdirSync as mkdirSync13,
6447
- readFileSync as readFileSync12,
6448
- rmSync as rmSync4,
6449
- writeFileSync as writeFileSync13
6450
- } from "fs";
6451
- import { dirname as dirname11, join as join18 } from "path";
6452
- function registerTelemetry(program2) {
6453
- const telemetry = program2.command("telemetry").description(
6454
- "Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
6455
- );
6456
- telemetry.command("emit").description(
6457
- "POST one progress event to /decompositions/{id}/run/telemetry (the 5a ingest)"
6458
- ).requiredOption(
6459
- "--decomposition <id>",
6460
- "Decomposition id whose run this event belongs to"
6461
- ).requiredOption("--task <id>", "Task id this event belongs to").requiredOption(
6462
- "--kind <kind>",
6463
- "Event kind: raw | parsed | approval | terminal"
6464
- ).option(
6465
- "--tokens-in <n>",
6466
- "Cumulative input tokens (spend meter)",
6467
- parseIntOpt
6468
- ).option(
6469
- "--tokens-out <n>",
6470
- "Cumulative output tokens (spend meter)",
6471
- parseIntOpt
6472
- ).option(
6473
- "--context-used <n>",
6474
- "Context tokens currently used (occupancy meter)",
6475
- parseIntOpt
6476
- ).option(
6477
- "--context-window <n>",
6478
- "Context window size (occupancy meter)",
6479
- parseIntOpt
6480
- ).option("--text <s>", "Raw/parsed payload text").option("--approval <state>", "Approval gate state (approval events)").option(
6481
- "--verdict <v>",
6482
- "Typed verdict (terminal events): pass | soft-fail | plan-invalid | blocked"
6483
- ).action(async (opts, cmd) => {
6484
- const json = Boolean(cmd.optsWithGlobals().json);
6485
- const cfg = resolveConfig(cmd.optsWithGlobals());
6486
- const event = {
6487
- taskId: opts.task,
6488
- kind: normalizeKind(opts.kind),
6489
- tokensIn: opts.tokensIn ?? null,
6490
- tokensOut: opts.tokensOut ?? null,
6491
- contextUsed: opts.contextUsed ?? null,
6492
- contextWindow: opts.contextWindow ?? null,
6493
- text: opts.text ?? null,
6494
- approvalState: opts.approval ?? null,
6495
- verdict: opts.verdict ?? null
6496
- };
6497
- let body;
6498
- try {
6499
- body = await postTelemetry(cfg, opts.decomposition, [event]);
6500
- } catch (e) {
6501
- return fail(`Telemetry emit failed: ${e.message}`);
6502
- }
6503
- if (json) {
6504
- emit(body, true);
6505
- } else {
6506
- process.stderr.write(
6507
- style.green("telemetry emitted") + style.dim(
6508
- ` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
6509
- `
6510
- )
6511
- );
6512
- }
6513
- });
6514
- telemetry.command("show <decompositionId>").description(
6515
- "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.)"
6516
- ).action(async (decompositionId, _opts, cmd) => {
6517
- const globals = cmd.optsWithGlobals();
6518
- const cfg = resolveConfig(globals);
6519
- const data = await runApi("Reading run telemetry", async () => {
6520
- const client = await makeClient(cfg);
6521
- return client.GET("/decompositions/{id}/run/telemetry", {
6522
- params: { path: { id: decompositionId } }
6523
- });
6524
- });
6525
- emitAction(
6526
- data.hasTelemetry ? `read telemetry for ${style.bold(decompositionId)}` : `no telemetry yet for ${style.bold(decompositionId)} (run not started or not yet reporting)`,
6527
- data,
6528
- globals.json
6529
- );
6530
- });
6531
- telemetry.command("bind").description(
6532
- "Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
6533
- ).requiredOption(
6534
- "--decomposition <id>",
6535
- "Decomposition id this session executes"
6536
- ).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
6537
- const json = Boolean(cmd.optsWithGlobals().json);
6538
- const dir = join18(process.cwd(), ".sechroom");
6539
- mkdirSync13(dir, { recursive: true });
6540
- const path = join18(dir, BINDING_FILE);
6541
- const binding = {
6542
- decompositionId: opts.decomposition,
6543
- taskId: opts.task
6544
- };
6545
- writeFileSync13(path, JSON.stringify(binding, null, 2) + "\n");
6546
- ensureStateDirIgnored(process.cwd());
6547
- if (json) {
6548
- emit({ bound: true, ...binding, path }, true);
6549
- } else {
6550
- process.stdout.write(
6551
- style.green("telemetry bound") + style.dim(
6552
- ` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
6553
- `
6554
- )
6555
- );
6556
- }
6557
- });
6558
- telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
6559
- const json = Boolean(cmd.optsWithGlobals().json);
6560
- const path = join18(process.cwd(), ".sechroom", BINDING_FILE);
6561
- const existed = existsSync16(path);
6562
- if (existed) rmSync4(path);
6563
- if (json) emit({ unbound: existed, path }, true);
6564
- else
6565
- process.stdout.write(
6566
- existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
6567
- );
6568
- });
6569
- telemetry.command("hook").description(
6570
- "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."
6571
- ).action(async (_opts, cmd) => {
6572
- try {
6573
- const raw = await readStdin3();
6574
- const input = parseHookInput2(raw);
6575
- const cwd = input.cwd ?? process.cwd();
6576
- const binding = findBinding(cwd);
6577
- if (!binding) return process.exit(0);
6578
- const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
6579
- const events = buildHookEvents(input, usage, binding.taskId);
6580
- if (events.length === 0) return process.exit(0);
6581
- const cfg = resolveConfig(cmd.optsWithGlobals());
6582
- await postTelemetry(cfg, binding.decompositionId, events);
6583
- return process.exit(0);
6584
- } catch {
6585
- return process.exit(0);
6586
- }
6587
- });
6588
- telemetry.command("install").description(
6589
- "Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
6590
- ).option(
6591
- "--scope <scope>",
6592
- "global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
6593
- ).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
8296
+ $ sechroom sweep --dry-run preview every repo's disposition, run nothing
8297
+ $ sechroom sweep onboard the whole tree from the root
8298
+ $ sechroom --tenant ocd sweep force a tenant for every child (else each resolves its own)`
8299
+ ).action((opts, cmd) => {
6594
8300
  const g = cmd.optsWithGlobals();
8301
+ const json = Boolean(g.json);
6595
8302
  const dryRun = Boolean(opts.dryRun);
6596
- const cwd = process.cwd();
6597
- let scope;
8303
+ const manifestPath = resolve4(opts.manifest);
8304
+ let repos;
6598
8305
  try {
6599
- scope = opts.local ? "project" : resolveScope(opts.scope);
8306
+ repos = readManifest(manifestPath);
6600
8307
  } catch (err2) {
6601
- process.stderr.write(`${err2.message}
8308
+ fail(err2 instanceof Error ? err2.message : String(err2));
8309
+ }
8310
+ if (repos === null) {
8311
+ fail(`no manifest at ${manifestPath} \u2014 create ./.sechroom/repos.json, or use \`sechroom onboard --recurse\` to auto-discover (see \`sechroom sweep --help\`).`);
8312
+ }
8313
+ if (repos.length === 0) {
8314
+ if (json) process.stdout.write(JSON.stringify({ manifest: manifestPath, repos: [] }) + "\n");
8315
+ else process.stderr.write(`${warn("\u26A0")} ${manifestPath} lists no repos \u2014 nothing to do.
6602
8316
  `);
6603
- return process.exit(2);
8317
+ return;
6604
8318
  }
6605
- const targets = resolveClaudeTargets({
6606
- override: g.claudeConfigDir,
6607
- scope,
6608
- cwd
6609
- });
6610
- const commands = {
6611
- Stop: "sechroom telemetry hook",
6612
- SubagentStop: "sechroom telemetry hook",
6613
- Notification: "sechroom telemetry hook",
6614
- PermissionDenied: "sechroom telemetry hook"
6615
- };
6616
- try {
6617
- const multi = targets.length > 1;
6618
- const results = targets.map((t) => {
6619
- const r = installClaudeCommands(t.dir, commands, dryRun);
6620
- process.stdout.write(
6621
- `${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
6622
- `
6623
- );
6624
- process.stdout.write(describe(r, dryRun) + "\n");
6625
- return r;
6626
- });
6627
- if (dryRun) {
6628
- process.stdout.write("\n(dry run \u2014 no files were written.)\n");
6629
- } else if (results.every((r) => r.status === "current")) {
6630
- process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
6631
- } else {
6632
- process.stdout.write(
6633
- "\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
6634
- );
6635
- }
6636
- } catch (err2) {
8319
+ const root = dirname12(dirname12(manifestPath));
8320
+ const plans = repos.map((entry) => planEntry(entry, root));
8321
+ if (!json) {
6637
8322
  process.stderr.write(
6638
- `telemetry install failed: ${err2.message}
8323
+ `${style.bold("sweep")} ${style.dim(`(${plans.length} repo${plans.length === 1 ? "" : "s"} from ${manifestPath})`)}
6639
8324
  `
6640
8325
  );
6641
- return process.exit(1);
6642
- }
6643
- warnIfSechroomNotOnPath();
6644
- return process.exit(0);
6645
- });
6646
- }
6647
- var BINDING_FILE = "telemetry.json";
6648
- async function postTelemetry(cfg, decompositionId, events) {
6649
- const token = await requireToken(cfg);
6650
- const resp = await fetch(
6651
- `${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
6652
- {
6653
- method: "POST",
6654
- headers: {
6655
- authorization: `Bearer ${token}`,
6656
- tenant: cfg.tenant,
6657
- "content-type": "application/json",
6658
- "x-sechroom-surface": "cli"
6659
- },
6660
- body: JSON.stringify({ events })
6661
- }
6662
- );
6663
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
6664
- return await resp.json();
6665
- }
6666
- function findBinding(start) {
6667
- let dir = start;
6668
- for (; ; ) {
6669
- const path = join18(dir, ".sechroom", BINDING_FILE);
6670
- if (existsSync16(path)) {
6671
- try {
6672
- const b = JSON.parse(
6673
- readFileSync12(path, "utf8")
6674
- );
6675
- if (b.decompositionId && b.taskId)
6676
- return { decompositionId: b.decompositionId, taskId: b.taskId };
6677
- } catch {
6678
- }
6679
- return null;
6680
8326
  }
6681
- const parent = dirname11(dir);
6682
- if (parent === dir) return null;
6683
- dir = parent;
6684
- }
6685
- }
6686
- function parseTranscript(path) {
6687
- if (!existsSync16(path)) return null;
6688
- let tokensIn = 0;
6689
- let tokensOut = 0;
6690
- let contextUsed = 0;
6691
- let model = "";
6692
- for (const line of readFileSync12(path, "utf8").split("\n")) {
6693
- if (!line.trim()) continue;
6694
- let obj;
6695
- try {
6696
- obj = JSON.parse(line);
6697
- } catch {
6698
- continue;
8327
+ const results = runChildren(plans, { globals: passthroughGlobals(g), dryRun, json });
8328
+ if (json) {
8329
+ process.stdout.write(JSON.stringify({ manifest: manifestPath, dryRun, repos: results }) + "\n");
8330
+ return;
6699
8331
  }
6700
- const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
6701
- if (!usage) continue;
6702
- const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
6703
- tokensIn += input;
6704
- tokensOut += usage.output_tokens ?? 0;
6705
- contextUsed = input;
6706
- if (obj.message?.model) model = obj.message.model;
6707
- }
6708
- if (tokensIn === 0 && tokensOut === 0) return null;
6709
- return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
6710
- }
6711
- function windowFor(model, contextUsed = 0) {
6712
- const m = model.toLowerCase();
6713
- if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
6714
- return contextUsed > 2e5 ? 1e6 : 2e5;
6715
- }
6716
- function buildHookEvents(input, usage, taskId) {
6717
- const events = [];
6718
- const base = (kind, over) => ({
6719
- taskId,
6720
- kind,
6721
- tokensIn: null,
6722
- tokensOut: null,
6723
- contextUsed: null,
6724
- contextWindow: null,
6725
- text: null,
6726
- approvalState: null,
6727
- verdict: null,
6728
- ...over
8332
+ summarizeFanout(results, { dryRun });
6729
8333
  });
6730
- if (usage) {
6731
- events.push(
6732
- base("Parsed", {
6733
- tokensIn: usage.tokensIn,
6734
- tokensOut: usage.tokensOut,
6735
- contextUsed: usage.contextUsed,
6736
- contextWindow: usage.contextWindow
6737
- })
6738
- );
6739
- }
6740
- switch (input.hook_event_name) {
6741
- case "PermissionDenied":
6742
- events.push(
6743
- base("Approval", {
6744
- approvalState: "denied",
6745
- text: input.tool_name ?? input.message ?? null
6746
- })
6747
- );
6748
- break;
6749
- case "Notification":
6750
- if (isPermissionNotification(input))
6751
- events.push(base("Approval", { text: input.message ?? null }));
6752
- break;
6753
- case "Stop":
6754
- case "SubagentStop":
6755
- events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6756
- break;
6757
- }
6758
- return events;
6759
- }
6760
- function isPermissionNotification(input) {
6761
- const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6762
- if (t) return t.includes("permission");
6763
- return (input.message ?? "").toLowerCase().includes("permission");
6764
- }
6765
- async function readStdin3() {
6766
- if (process.stdin.isTTY) return "";
6767
- const chunks = [];
6768
- for await (const chunk of process.stdin) chunks.push(chunk);
6769
- return Buffer.concat(chunks).toString("utf8");
6770
- }
6771
- function parseHookInput2(raw) {
6772
- if (!raw.trim()) return {};
6773
- try {
6774
- return JSON.parse(raw);
6775
- } catch {
6776
- return {};
6777
- }
6778
- }
6779
- var KINDS = {
6780
- raw: "Raw",
6781
- parsed: "Parsed",
6782
- approval: "Approval",
6783
- terminal: "Terminal"
6784
- };
6785
- function normalizeKind(k) {
6786
- const v = KINDS[k.toLowerCase()];
6787
- if (!v)
6788
- fail(
6789
- `Unknown --kind '${k}'. Expected one of: raw, parsed, approval, terminal.`
6790
- );
6791
- return v;
6792
- }
6793
- function parseIntOpt(v) {
6794
- const n = Number.parseInt(v, 10);
6795
- if (Number.isNaN(n)) fail(`Expected an integer, got '${v}'.`);
6796
- return n;
6797
8334
  }
6798
8335
 
6799
8336
  // src/commands/worklog.ts
@@ -7001,11 +8538,43 @@ function registerWorkBrief(program2) {
7001
8538
  Examples:
7002
8539
  $ sechroom work-brief pause mem_XXXX --reason-code operator-hold --source claude-code-chris
7003
8540
  $ sechroom work-brief resume mem_XXXX --reason-code operator-resume --source claude-code-chris --reason "Ready to continue"
7004
- $ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"`
8541
+ $ sechroom work-brief cancel mem_XXXX --reason-code operator-stopped --source claude-code-chris --reason "Work no longer required"
8542
+ $ sechroom work-brief park mem_XXXX --source claude-code-chris
8543
+ $ sechroom work-brief unpark mem_XXXX --source claude-code-chris
8544
+ $ sechroom work-brief supersede mem_XXXX --source claude-code-chris`
7005
8545
  );
7006
8546
  registerLifecycleAction(workBrief, "pause");
7007
8547
  registerLifecycleAction(workBrief, "resume");
7008
8548
  registerLifecycleAction(workBrief, "cancel");
8549
+ registerStatusAction(workBrief, "park", "status:parked");
8550
+ registerStatusAction(workBrief, "unpark", "status:ready_for_decomposition");
8551
+ registerStatusAction(workBrief, "supersede", "status:superseded");
8552
+ }
8553
+ function registerStatusAction(workBrief, action, to) {
8554
+ workBrief.command(`${action} <briefId>`).description(
8555
+ `${capitalize(action)} a work brief via its governed status transition`
8556
+ ).requiredOption(
8557
+ "--source <source>",
8558
+ "Calling surface or lane recorded on the contribution"
8559
+ ).action(async (briefId, opts, cmd) => {
8560
+ const globals = cmd.optsWithGlobals();
8561
+ const cfg = resolveConfig(globals);
8562
+ const data = await runApi(
8563
+ `${capitalize(action)}ing work brief`,
8564
+ async () => {
8565
+ const client = await makeClient(cfg);
8566
+ return client.POST("/work-briefs/{id}/status", {
8567
+ params: { path: { id: briefId } },
8568
+ body: { id: briefId, to, source: opts.source }
8569
+ });
8570
+ }
8571
+ );
8572
+ emitAction(
8573
+ `${action} work brief ${style.bold(briefId)} \u2192 ${data.to}`,
8574
+ data,
8575
+ globals.json
8576
+ );
8577
+ });
7009
8578
  }
7010
8579
  function registerLifecycleAction(workBrief, action) {
7011
8580
  const presentParticiple = action === "pause" ? "Pausing" : action === "resume" ? "Resuming" : "Cancelling";
@@ -7043,17 +8612,24 @@ function capitalize(value) {
7043
8612
  }
7044
8613
 
7045
8614
  // src/commands/work-task.ts
8615
+ import { readFile as readFile3 } from "fs/promises";
7046
8616
  function registerWorkTask(program2) {
7047
- const workTask = program2.command("work-task").description("Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)");
8617
+ const workTask = program2.command("work-task").description(
8618
+ "Read work tasks and close them out (list \xB7 card \xB7 mark-no-residue)"
8619
+ );
7048
8620
  workTask.addHelpText(
7049
8621
  "after",
7050
8622
  `
7051
8623
  Examples:
7052
8624
  $ sechroom work-task list --status in-progress --lane claude-code-chris
7053
8625
  $ sechroom work-task card mem_XXXX
7054
- $ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX`
8626
+ $ sechroom work-task mark-no-residue mem_XXXX --decomposition wlp_XXXX
8627
+ $ sechroom work-task residue-produce mem_XXXX --file residue.json`
7055
8628
  );
7056
- 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(
8629
+ workTask.command("list").description("List work tasks, newest-first (GET /work-tasks)").option("--shape <shape>", "Filter: bare | managed").option(
8630
+ "--lane <lane>",
8631
+ "Filter by dispatch-lane value, e.g. claude-code-chris"
8632
+ ).option("--status <status>", "Filter by status value, e.g. in-progress").option("--page <n>", "1-based page number", (v) => Number.parseInt(v, 10)).option(
7057
8633
  "--page-size <n>",
7058
8634
  "Page size (default 50, capped 200)",
7059
8635
  (v) => Number.parseInt(v, 10)
@@ -7080,7 +8656,9 @@ Examples:
7080
8656
  globals.json
7081
8657
  );
7082
8658
  });
7083
- 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) => {
8659
+ workTask.command("card <taskId>").description(
8660
+ "Read one task's runnable card \u2014 its full working instructions (GET /tasks/{id}/card)"
8661
+ ).action(async (taskId, _opts, cmd) => {
7084
8662
  const globals = cmd.optsWithGlobals();
7085
8663
  const cfg = resolveConfig(globals);
7086
8664
  const data = await runApi("Reading task card", async () => {
@@ -7091,26 +8669,67 @@ Examples:
7091
8669
  });
7092
8670
  emitAction(`read card ${style.bold(taskId)}`, data, globals.json);
7093
8671
  });
7094
- workTask.command("mark-no-residue <taskId>").description(
7095
- "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)"
8672
+ workTask.command("residue-produce <taskId>").description(
8673
+ "Produce typed residue for a work task (POST /work-tasks/produce-residue)"
7096
8674
  ).requiredOption(
7097
- "--decomposition <id>",
7098
- "The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
8675
+ "--file <path>",
8676
+ "JSON file containing a residues array; use - for stdin"
7099
8677
  ).action(async (taskId, opts, cmd) => {
8678
+ const raw = opts.file === "-" ? await readStdin4() : await readFile3(opts.file, "utf8");
8679
+ const body = parseResidueInput(raw, opts.file, taskId);
7100
8680
  const globals = cmd.optsWithGlobals();
7101
8681
  const cfg = resolveConfig(globals);
7102
- const data = await runApi("Marking task no-residue", async () => {
8682
+ const data = await runApi("Producing task residue", async () => {
7103
8683
  const client = await makeClient(cfg);
7104
- return client.POST("/work-tasks/mark-no-residue", {
7105
- body: { taskId, decompositionId: opts.decomposition }
7106
- });
8684
+ return client.POST("/work-tasks/produce-residue", { body });
7107
8685
  });
7108
8686
  emitAction(
7109
- `marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
8687
+ `produced ${style.bold(String(data.produced.length))} residue item(s) for ${style.bold(taskId)}`,
7110
8688
  data,
7111
8689
  globals.json
7112
8690
  );
7113
8691
  });
8692
+ workTask.command("mark-no-residue <taskId>").description(
8693
+ "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)"
8694
+ ).requiredOption(
8695
+ "--decomposition <id>",
8696
+ "The work plan id (wlp_\u2026) the task belongs to \u2014 the marker is keyed (decompositionId, taskId)"
8697
+ ).action(
8698
+ async (taskId, opts, cmd) => {
8699
+ const globals = cmd.optsWithGlobals();
8700
+ const cfg = resolveConfig(globals);
8701
+ const data = await runApi("Marking task no-residue", async () => {
8702
+ const client = await makeClient(cfg);
8703
+ return client.POST("/work-tasks/mark-no-residue", {
8704
+ body: { taskId, decompositionId: opts.decomposition }
8705
+ });
8706
+ });
8707
+ emitAction(
8708
+ `marked ${style.bold(taskId)} no-residue (minted: ${data.minted})`,
8709
+ data,
8710
+ globals.json
8711
+ );
8712
+ }
8713
+ );
8714
+ }
8715
+ function parseResidueInput(raw, sourceName, taskId) {
8716
+ let value;
8717
+ try {
8718
+ value = JSON.parse(raw);
8719
+ } catch (error) {
8720
+ throw new Error(
8721
+ `${sourceName} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
8722
+ );
8723
+ }
8724
+ const residues = Array.isArray(value) ? value : value && typeof value === "object" ? value.residues : void 0;
8725
+ if (!Array.isArray(residues) || residues.length === 0)
8726
+ throw new Error(`${sourceName} must contain a non-empty residues array`);
8727
+ return { taskId, residues };
8728
+ }
8729
+ async function readStdin4() {
8730
+ const chunks = [];
8731
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
8732
+ return Buffer.concat(chunks).toString("utf8");
7114
8733
  }
7115
8734
 
7116
8735
  // src/index.ts