@sechroom/cli 2026.7.32 → 2026.8.1

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 (3) hide show
  1. package/README.md +26 -262
  2. package/dist/index.js +1753 -696
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync18 } from "fs";
4
+ import { readFileSync as readFileSync19 } from "fs";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/auth.ts
@@ -1573,7 +1573,367 @@ import {
1573
1573
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1574
1574
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1575
1575
 
1576
+ // src/executor-run/request.ts
1577
+ var AuthExpiredError = class extends Error {
1578
+ constructor(message) {
1579
+ super(message);
1580
+ this.name = "AuthExpiredError";
1581
+ }
1582
+ };
1583
+ var HttpError = class extends Error {
1584
+ constructor(status, method, path, body) {
1585
+ super(`${method} ${path} failed (${status}): ${body}`);
1586
+ this.status = status;
1587
+ this.method = method;
1588
+ this.path = path;
1589
+ this.body = body;
1590
+ this.name = "HttpError";
1591
+ }
1592
+ status;
1593
+ method;
1594
+ path;
1595
+ body;
1596
+ };
1597
+ function createAuthedRequest(cfg, deps = {}) {
1598
+ const getToken = deps.getToken ?? requireToken;
1599
+ const refresh = deps.refreshToken ?? forceRefreshToken;
1600
+ const doFetch = deps.fetch ?? fetch;
1601
+ const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
1602
+ ...init,
1603
+ headers: {
1604
+ authorization: `Bearer ${token}`,
1605
+ tenant: cfg.tenant,
1606
+ "content-type": "application/json",
1607
+ "x-sechroom-surface": "cli",
1608
+ ...init?.headers
1609
+ }
1610
+ });
1611
+ return async (path, init) => {
1612
+ const method = init?.method ?? "GET";
1613
+ let token;
1614
+ try {
1615
+ token = await getToken(cfg);
1616
+ } catch (error) {
1617
+ throw new AuthExpiredError(
1618
+ error instanceof Error ? error.message : String(error)
1619
+ );
1620
+ }
1621
+ let response = await call(path, init, token);
1622
+ if (response.status === 401) {
1623
+ let fresh;
1624
+ try {
1625
+ fresh = await refresh(cfg);
1626
+ } catch (error) {
1627
+ throw new AuthExpiredError(
1628
+ error instanceof Error ? error.message : String(error)
1629
+ );
1630
+ }
1631
+ response = await call(path, init, fresh);
1632
+ if (response.status === 401)
1633
+ throw new AuthExpiredError(
1634
+ `${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
1635
+ );
1636
+ }
1637
+ if (!response.ok)
1638
+ throw new HttpError(
1639
+ response.status,
1640
+ method,
1641
+ path,
1642
+ await safeText(response)
1643
+ );
1644
+ return await response.json();
1645
+ };
1646
+ }
1647
+ async function safeText(response) {
1648
+ try {
1649
+ return await response.text();
1650
+ } catch {
1651
+ return "";
1652
+ }
1653
+ }
1654
+
1655
+ // src/executor-run/driver.ts
1656
+ function verdictFor(terminalStatus) {
1657
+ switch (terminalStatus) {
1658
+ case "completed":
1659
+ return "pass";
1660
+ case "needs_approval":
1661
+ case "cancelled":
1662
+ case "canceled":
1663
+ return "blocked";
1664
+ case "error":
1665
+ default:
1666
+ return "soft-fail";
1667
+ }
1668
+ }
1669
+ var DEFAULT_TRANSIENT_BACKOFF = {
1670
+ baseMs: 2e3,
1671
+ maxMs: 6e4,
1672
+ maxConsecutive: 20
1673
+ };
1674
+ var BackoffCeilingExhaustedError = class extends Error {
1675
+ constructor(attempts, lastError) {
1676
+ super(
1677
+ `driven executor exiting: ${attempts} consecutive API interruptions exhausted the backoff ceiling \u2014 last error: ${String(lastError)}`
1678
+ );
1679
+ this.attempts = attempts;
1680
+ this.lastError = lastError;
1681
+ this.name = "BackoffCeilingExhaustedError";
1682
+ }
1683
+ attempts;
1684
+ lastError;
1685
+ };
1686
+ async function runDriverLoop(ports, options) {
1687
+ const summary = {
1688
+ processed: 0,
1689
+ completed: 0,
1690
+ abandoned: 0
1691
+ };
1692
+ const backoff = { ...DEFAULT_TRANSIENT_BACKOFF, ...options.transientBackoff };
1693
+ let admissionDeferred = false;
1694
+ let consecutiveTransient = 0;
1695
+ const materializationFailures = /* @__PURE__ */ new Map();
1696
+ while (!options.stopping()) {
1697
+ try {
1698
+ if (ports.checkAdmission) {
1699
+ const admission = await ports.checkAdmission();
1700
+ if (!admission.ok) {
1701
+ admissionDeferred = true;
1702
+ ports.log(
1703
+ `ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
1704
+ );
1705
+ await ports.waitForWake(options.pollMs);
1706
+ continue;
1707
+ }
1708
+ if (admissionDeferred) {
1709
+ admissionDeferred = false;
1710
+ ports.log("admission recovered \u2014 resuming claims");
1711
+ }
1712
+ }
1713
+ if (ports.checkRootReady) {
1714
+ const ready = await ports.checkRootReady();
1715
+ if (!ready.ok) {
1716
+ ports.log(
1717
+ `root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`
1718
+ );
1719
+ await ports.waitForWake(options.pollMs);
1720
+ continue;
1721
+ }
1722
+ }
1723
+ const claim = await ports.claimNext();
1724
+ if (consecutiveTransient > 0) {
1725
+ ports.log(
1726
+ `API reachable again after ${consecutiveTransient} interruption(s) \u2014 resuming`
1727
+ );
1728
+ consecutiveTransient = 0;
1729
+ }
1730
+ if (!claim) {
1731
+ await ports.waitForWake(options.pollMs);
1732
+ continue;
1733
+ }
1734
+ summary.processed++;
1735
+ ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
1736
+ let task;
1737
+ try {
1738
+ task = await ports.loadTask(claim.memoryId);
1739
+ materializationFailures.delete(claim.memoryId);
1740
+ } catch (error) {
1741
+ if (error instanceof AuthExpiredError) throw error;
1742
+ const failures = (materializationFailures.get(claim.memoryId) ?? 0) + 1;
1743
+ materializationFailures.set(claim.memoryId, failures);
1744
+ if (failures > backoff.maxConsecutive) {
1745
+ ports.log(
1746
+ `FATAL: task ${claim.memoryId} failed materialization ${backoff.maxConsecutive} consecutive times \u2014 deregistering and exiting (loud). Its lease was never heartbeaten and expires to re-offer within \u2264120s; the task is not lost. Last error: ${String(error)}`
1747
+ );
1748
+ throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, error);
1749
+ }
1750
+ throw error;
1751
+ }
1752
+ const stopHeartbeat = ports.startLeaseHeartbeat(claim);
1753
+ let result;
1754
+ try {
1755
+ result = await ports.runTurn(task, claim);
1756
+ } catch (e) {
1757
+ if (e instanceof AuthExpiredError) throw e;
1758
+ result = { status: "crashed", reason: String(e) };
1759
+ } finally {
1760
+ stopHeartbeat();
1761
+ }
1762
+ if (result.status === "crashed" || result.status === "timeout") {
1763
+ summary.abandoned++;
1764
+ ports.log(
1765
+ `ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
1766
+ );
1767
+ } else {
1768
+ const verdict = verdictFor(result.packet?.terminal_status);
1769
+ let text2 = closeoutText(task, result);
1770
+ if (ports.deliver) {
1771
+ try {
1772
+ const delivery = await ports.deliver(
1773
+ claim,
1774
+ task,
1775
+ verdict,
1776
+ result.status === "completed" ? result.fileChangeCount ?? 0 : 0
1777
+ );
1778
+ ports.log(`delivery: ${delivery.note}`);
1779
+ text2 += `
1780
+
1781
+ Delivery: ${delivery.note}`;
1782
+ } catch (e) {
1783
+ if (e instanceof AuthExpiredError) throw e;
1784
+ ports.log(
1785
+ `delivery threw (continuing to completion): ${String(e)}`
1786
+ );
1787
+ text2 += `
1788
+
1789
+ Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
1790
+ }
1791
+ }
1792
+ try {
1793
+ const done = await ports.completeLease(
1794
+ claim,
1795
+ verdict,
1796
+ text2,
1797
+ `${task.title} \u2014 driven closeout`
1798
+ );
1799
+ summary.completed++;
1800
+ ports.log(
1801
+ `completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
1802
+ );
1803
+ } catch (e) {
1804
+ if (e instanceof AuthExpiredError) throw e;
1805
+ summary.abandoned++;
1806
+ ports.log(
1807
+ `COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
1808
+ );
1809
+ }
1810
+ }
1811
+ } catch (e) {
1812
+ if (e instanceof BackoffCeilingExhaustedError) throw e;
1813
+ if (e instanceof AuthExpiredError) throw e;
1814
+ consecutiveTransient++;
1815
+ if (consecutiveTransient > backoff.maxConsecutive) {
1816
+ ports.log(
1817
+ `FATAL: ${backoff.maxConsecutive} consecutive API interruptions exhausted the backoff ceiling \u2014 deregistering and exiting (loud). Last error: ${String(e)}`
1818
+ );
1819
+ throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, e);
1820
+ }
1821
+ const delay = Math.min(
1822
+ backoff.baseMs * 2 ** (consecutiveTransient - 1),
1823
+ backoff.maxMs
1824
+ );
1825
+ ports.log(
1826
+ `API interruption (${consecutiveTransient}/${backoff.maxConsecutive}) \u2014 backing off ${delay}ms and continuing: ${String(e)}`
1827
+ );
1828
+ await ports.waitForWake(delay);
1829
+ continue;
1830
+ }
1831
+ if (options.once) break;
1832
+ }
1833
+ return summary;
1834
+ }
1835
+ function closeoutText(task, result) {
1836
+ if (!result.packet)
1837
+ return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
1838
+
1839
+ ${result.lastAgentMessage || "(none)"}`;
1840
+ const evidence = result.packet.evidence?.length ? `
1841
+
1842
+ Evidence:
1843
+ ${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
1844
+ return `${result.packet.summary}${evidence}
1845
+
1846
+ (terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
1847
+ }
1848
+ function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
1849
+ const schedule = timers.setInterval ?? setInterval;
1850
+ const cancel = timers.clearInterval ?? clearInterval;
1851
+ const timer = schedule(() => {
1852
+ void beat().catch(
1853
+ (e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
1854
+ );
1855
+ }, intervalMs);
1856
+ timer.unref?.();
1857
+ return () => cancel(timer);
1858
+ }
1859
+
1860
+ // src/executor-run/task-context.ts
1861
+ async function materializeClaimedTask(request, memoryId) {
1862
+ const card = await request(
1863
+ `/tasks/${encodeURIComponent(memoryId)}/card`
1864
+ );
1865
+ const pointer = card.contextPack;
1866
+ let components = [];
1867
+ if (pointer && (!pointer.slug || !pointer.version))
1868
+ throw new Error(
1869
+ `task ${card.taskId} carried an incomplete context-pack pointer; refusing delivery`
1870
+ );
1871
+ if (pointer?.slug && pointer.version) {
1872
+ try {
1873
+ const pkg = await request(
1874
+ `/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
1875
+ );
1876
+ components = validatePackage(pkg, pointer.slug, pointer.version);
1877
+ } catch (error) {
1878
+ throw new Error(
1879
+ `task ${card.taskId} context pack ${pointer.slug}@${pointer.version} could not be materialized before delivery: ${String(error)}`,
1880
+ { cause: error }
1881
+ );
1882
+ }
1883
+ }
1884
+ return {
1885
+ title: card.title ?? memoryId,
1886
+ text: assemblePrompt(card, components)
1887
+ };
1888
+ }
1889
+ function validatePackage(value, expectedSlug, expectedVersion) {
1890
+ if (value === null || typeof value !== "object")
1891
+ throw new Error("package response was not an object");
1892
+ const pkg = value;
1893
+ if (pkg.slug !== expectedSlug || pkg.version !== expectedVersion)
1894
+ throw new Error(
1895
+ `package identity mismatch (expected ${expectedSlug}@${expectedVersion}, received ${String(pkg.slug)}@${String(pkg.version)})`
1896
+ );
1897
+ if (!Array.isArray(pkg.components) || pkg.components.length === 0)
1898
+ throw new Error("package carried no components");
1899
+ for (const [index, component] of pkg.components.entries()) {
1900
+ if (component === null || typeof component !== "object" || typeof component.slug !== "string" || typeof component.sourceId !== "string" || !Number.isInteger(component.sourceVersion) || component.sourceVersion < 1 || typeof component.body !== "string") {
1901
+ throw new Error(
1902
+ `package component ${index + 1} lacked pinned source provenance or body`
1903
+ );
1904
+ }
1905
+ }
1906
+ return pkg.components;
1907
+ }
1908
+ function assemblePrompt(card, components) {
1909
+ const sections = [
1910
+ `# Task: ${card.title}`,
1911
+ `## Objective
1912
+ ${card.task.objective}`,
1913
+ `## Acceptance
1914
+ ${card.task.acceptance}`,
1915
+ `## Boundaries
1916
+ ${card.task.boundaries}`,
1917
+ `## Closeout
1918
+ ${card.task.closeout}`
1919
+ ];
1920
+ if (components.length > 0)
1921
+ sections.push(
1922
+ `## Task context
1923
+ ${components.map(renderComponent).join("\n\n")}`
1924
+ );
1925
+ return sections.join("\n\n");
1926
+ }
1927
+ function renderComponent(component) {
1928
+ return [
1929
+ `<!-- sechroom-task-context component=${JSON.stringify(component.slug)} id=${JSON.stringify(component.sourceId)} sourceVersion=${component.sourceVersion} -->`,
1930
+ `### ${component.title?.trim() || component.sourceId}`,
1931
+ component.body
1932
+ ].join("\n");
1933
+ }
1934
+
1576
1935
  // src/commands/executor.ts
1936
+ import { execFileSync } from "child_process";
1577
1937
  import { existsSync as existsSync8, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
1578
1938
  import { dirname as dirname8, join as join11 } from "path";
1579
1939
 
@@ -1732,95 +2092,16 @@ function ensureSemIgnored(semPath) {
1732
2092
  appendFileSync(target.path, `${sep2}${STATE_DIR_IGNORE}
1733
2093
  `);
1734
2094
  } else {
1735
- writeFileSync4(target.path, `${STATE_DIR_IGNORE}
1736
- `);
1737
- }
1738
- } catch {
1739
- }
1740
- }
1741
-
1742
- // src/commands/executor-run.ts
1743
- import { join as join10, resolve as resolve3 } from "path";
1744
-
1745
- // src/executor-run/request.ts
1746
- var AuthExpiredError = class extends Error {
1747
- constructor(message) {
1748
- super(message);
1749
- this.name = "AuthExpiredError";
1750
- }
1751
- };
1752
- var HttpError = class extends Error {
1753
- constructor(status, method, path, body) {
1754
- super(`${method} ${path} failed (${status}): ${body}`);
1755
- this.status = status;
1756
- this.method = method;
1757
- this.path = path;
1758
- this.body = body;
1759
- this.name = "HttpError";
1760
- }
1761
- status;
1762
- method;
1763
- path;
1764
- body;
1765
- };
1766
- function createAuthedRequest(cfg, deps = {}) {
1767
- const getToken = deps.getToken ?? requireToken;
1768
- const refresh = deps.refreshToken ?? forceRefreshToken;
1769
- const doFetch = deps.fetch ?? fetch;
1770
- const call = async (path, init, token) => doFetch(`${cfg.baseUrl}${path}`, {
1771
- ...init,
1772
- headers: {
1773
- authorization: `Bearer ${token}`,
1774
- tenant: cfg.tenant,
1775
- "content-type": "application/json",
1776
- "x-sechroom-surface": "cli",
1777
- ...init?.headers
1778
- }
1779
- });
1780
- return async (path, init) => {
1781
- const method = init?.method ?? "GET";
1782
- let token;
1783
- try {
1784
- token = await getToken(cfg);
1785
- } catch (error) {
1786
- throw new AuthExpiredError(
1787
- error instanceof Error ? error.message : String(error)
1788
- );
1789
- }
1790
- let response = await call(path, init, token);
1791
- if (response.status === 401) {
1792
- let fresh;
1793
- try {
1794
- fresh = await refresh(cfg);
1795
- } catch (error) {
1796
- throw new AuthExpiredError(
1797
- error instanceof Error ? error.message : String(error)
1798
- );
1799
- }
1800
- response = await call(path, init, fresh);
1801
- if (response.status === 401)
1802
- throw new AuthExpiredError(
1803
- `${method} ${path} still 401 after token refresh \u2014 re-authenticate (\`sechroom login\`).`
1804
- );
1805
- }
1806
- if (!response.ok)
1807
- throw new HttpError(
1808
- response.status,
1809
- method,
1810
- path,
1811
- await safeText(response)
1812
- );
1813
- return await response.json();
1814
- };
1815
- }
1816
- async function safeText(response) {
1817
- try {
1818
- return await response.text();
2095
+ writeFileSync4(target.path, `${STATE_DIR_IGNORE}
2096
+ `);
2097
+ }
1819
2098
  } catch {
1820
- return "";
1821
2099
  }
1822
2100
  }
1823
2101
 
2102
+ // src/commands/executor-run.ts
2103
+ import { join as join10, resolve as resolve3 } from "path";
2104
+
1824
2105
  // src/executor-run/usage.ts
1825
2106
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
1826
2107
  import { dirname as dirname3 } from "path";
@@ -2141,7 +2422,7 @@ var SUCCESS = /* @__PURE__ */ new Set(["Claimed", "AlreadyHeld"]);
2141
2422
  var CONTENTION_STATUSES = /* @__PURE__ */ new Set([409, 410]);
2142
2423
  async function claimNextTask(deps) {
2143
2424
  const { request, executorInstanceId } = deps;
2144
- const sleep = deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
2425
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
2145
2426
  const idempotencyKey = deps.idempotencyKey ?? ((offer) => `executor-run:${offer.generationId}`);
2146
2427
  const tokenVersion = deps.tokenVersion ?? 1;
2147
2428
  const log = deps.log ?? (() => {
@@ -2471,12 +2752,12 @@ var CodexAppServer = class {
2471
2752
  ))
2472
2753
  fileChangeCount += 1;
2473
2754
  };
2474
- const terminal = new Promise((resolve8) => {
2755
+ const terminal = new Promise((resolve9) => {
2475
2756
  this.onServerRequest = (msg) => {
2476
2757
  const method = msg.method ?? "";
2477
2758
  if (terminalMethod(method)) {
2478
2759
  this.respond(msg.id, {});
2479
- resolve8("completed");
2760
+ resolve9("completed");
2480
2761
  return;
2481
2762
  }
2482
2763
  if (method === "item/tool/call") {
@@ -2663,14 +2944,14 @@ var CodexAppServer = class {
2663
2944
  if (!child || this.exited)
2664
2945
  return Promise.reject(new Error("codex app-server is not running"));
2665
2946
  const id = this.nextId++;
2666
- return new Promise((resolve8, reject) => {
2947
+ return new Promise((resolve9, reject) => {
2667
2948
  let timer;
2668
2949
  const clearTimer = () => {
2669
2950
  if (timer) clearTimeout(timer);
2670
2951
  };
2671
2952
  const resolvePending = (value) => {
2672
2953
  clearTimer();
2673
- resolve8(value);
2954
+ resolve9(value);
2674
2955
  };
2675
2956
  const rejectPending = (error) => {
2676
2957
  clearTimer();
@@ -2736,10 +3017,10 @@ var CodexAppServer = class {
2736
3017
  if (parsed) this.options.onRateLimits?.(parsed);
2737
3018
  }
2738
3019
  exitAsResult() {
2739
- return new Promise((resolve8) => {
3020
+ return new Promise((resolve9) => {
2740
3021
  this.child?.once(
2741
3022
  "exit",
2742
- (code) => resolve8(`app-server exited (${code ?? "signal"})`)
3023
+ (code) => resolve9(`app-server exited (${code ?? "signal"})`)
2743
3024
  );
2744
3025
  });
2745
3026
  }
@@ -2765,7 +3046,7 @@ function terminalMethod(method) {
2765
3046
  }
2766
3047
  function timeout(ms) {
2767
3048
  return new Promise(
2768
- (resolve8) => setTimeout(() => resolve8("timeout"), ms).unref?.()
3049
+ (resolve9) => setTimeout(() => resolve9("timeout"), ms).unref?.()
2769
3050
  );
2770
3051
  }
2771
3052
  function dynamicToolDefinitions() {
@@ -2805,12 +3086,12 @@ function dynamicToolDefinitions() {
2805
3086
  // src/executor-run/delivery.ts
2806
3087
  import { execFile as execFile2 } from "child_process";
2807
3088
  function createGitRunner(rootDir) {
2808
- return (bin, args) => new Promise((resolve8) => {
3089
+ return (bin, args) => new Promise((resolve9) => {
2809
3090
  execFile2(
2810
3091
  bin,
2811
3092
  bin === "git" ? ["-C", rootDir, ...args] : args,
2812
3093
  { cwd: rootDir, maxBuffer: 10 * 1024 * 1024 },
2813
- (error, stdout, stderr) => resolve8({
3094
+ (error, stdout, stderr) => resolve9({
2814
3095
  ok: !error,
2815
3096
  stdout: String(stdout),
2816
3097
  stderr: String(stderr)
@@ -2955,194 +3236,6 @@ function firstLine(text2) {
2955
3236
  return text2.trim().split("\n")[0] ?? "";
2956
3237
  }
2957
3238
 
2958
- // src/executor-run/driver.ts
2959
- function verdictFor(terminalStatus) {
2960
- switch (terminalStatus) {
2961
- case "completed":
2962
- return "pass";
2963
- case "needs_approval":
2964
- case "cancelled":
2965
- case "canceled":
2966
- return "blocked";
2967
- case "error":
2968
- default:
2969
- return "soft-fail";
2970
- }
2971
- }
2972
- var DEFAULT_TRANSIENT_BACKOFF = {
2973
- baseMs: 2e3,
2974
- maxMs: 6e4,
2975
- maxConsecutive: 20
2976
- };
2977
- var BackoffCeilingExhaustedError = class extends Error {
2978
- constructor(attempts, lastError) {
2979
- super(
2980
- `driven executor exiting: ${attempts} consecutive API interruptions exhausted the backoff ceiling \u2014 last error: ${String(lastError)}`
2981
- );
2982
- this.attempts = attempts;
2983
- this.lastError = lastError;
2984
- this.name = "BackoffCeilingExhaustedError";
2985
- }
2986
- attempts;
2987
- lastError;
2988
- };
2989
- async function runDriverLoop(ports, options) {
2990
- const summary = {
2991
- processed: 0,
2992
- completed: 0,
2993
- abandoned: 0
2994
- };
2995
- const backoff = { ...DEFAULT_TRANSIENT_BACKOFF, ...options.transientBackoff };
2996
- let admissionDeferred = false;
2997
- let consecutiveTransient = 0;
2998
- while (!options.stopping()) {
2999
- try {
3000
- if (ports.checkAdmission) {
3001
- const admission = await ports.checkAdmission();
3002
- if (!admission.ok) {
3003
- admissionDeferred = true;
3004
- ports.log(
3005
- `ADMISSION DEFERRED \u2014 not claiming: ${admission.reason ?? "usage budget exhausted"}`
3006
- );
3007
- await ports.waitForWake(options.pollMs);
3008
- continue;
3009
- }
3010
- if (admissionDeferred) {
3011
- admissionDeferred = false;
3012
- ports.log("admission recovered \u2014 resuming claims");
3013
- }
3014
- }
3015
- if (ports.checkRootReady) {
3016
- const ready = await ports.checkRootReady();
3017
- if (!ready.ok) {
3018
- ports.log(
3019
- `root not ready \u2014 not claiming: ${ready.reason ?? "unknown"}`
3020
- );
3021
- await ports.waitForWake(options.pollMs);
3022
- continue;
3023
- }
3024
- }
3025
- const claim = await ports.claimNext();
3026
- if (consecutiveTransient > 0) {
3027
- ports.log(
3028
- `API reachable again after ${consecutiveTransient} interruption(s) \u2014 resuming`
3029
- );
3030
- consecutiveTransient = 0;
3031
- }
3032
- if (!claim) {
3033
- await ports.waitForWake(options.pollMs);
3034
- continue;
3035
- }
3036
- summary.processed++;
3037
- ports.log(`claimed ${claim.memoryId} (lease ${claim.leaseId})`);
3038
- const task = await ports.loadTask(claim.memoryId);
3039
- const stopHeartbeat = ports.startLeaseHeartbeat(claim);
3040
- let result;
3041
- try {
3042
- result = await ports.runTurn(task, claim);
3043
- } catch (e) {
3044
- if (e instanceof AuthExpiredError) throw e;
3045
- result = { status: "crashed", reason: String(e) };
3046
- } finally {
3047
- stopHeartbeat();
3048
- }
3049
- if (result.status === "crashed" || result.status === "timeout") {
3050
- summary.abandoned++;
3051
- ports.log(
3052
- `ABANDONED ${claim.memoryId}: ${result.status === "timeout" ? "turn timed out" : result.reason} \u2014 lease will expire and the task re-offers (work may re-run).`
3053
- );
3054
- } else {
3055
- const verdict = verdictFor(result.packet?.terminal_status);
3056
- let text2 = closeoutText(task, result);
3057
- if (ports.deliver) {
3058
- try {
3059
- const delivery = await ports.deliver(
3060
- claim,
3061
- task,
3062
- verdict,
3063
- result.status === "completed" ? result.fileChangeCount ?? 0 : 0
3064
- );
3065
- ports.log(`delivery: ${delivery.note}`);
3066
- text2 += `
3067
-
3068
- Delivery: ${delivery.note}`;
3069
- } catch (e) {
3070
- if (e instanceof AuthExpiredError) throw e;
3071
- ports.log(
3072
- `delivery threw (continuing to completion): ${String(e)}`
3073
- );
3074
- text2 += `
3075
-
3076
- Delivery: FAILED unexpectedly (${String(e)}) \u2014 changes remain in the executor root.`;
3077
- }
3078
- }
3079
- try {
3080
- const done = await ports.completeLease(
3081
- claim,
3082
- verdict,
3083
- text2,
3084
- `${task.title} \u2014 driven closeout`
3085
- );
3086
- summary.completed++;
3087
- ports.log(
3088
- `completed ${claim.memoryId} verdict:${verdict} \u2192 ${done.completionMemoryId ?? done.outcome}`
3089
- );
3090
- } catch (e) {
3091
- if (e instanceof AuthExpiredError) throw e;
3092
- summary.abandoned++;
3093
- ports.log(
3094
- `COMPLETE REJECTED for ${claim.memoryId} (${String(e)}) \u2014 task will re-offer; investigate the heartbeat gap.`
3095
- );
3096
- }
3097
- }
3098
- } catch (e) {
3099
- if (e instanceof AuthExpiredError) throw e;
3100
- consecutiveTransient++;
3101
- if (consecutiveTransient > backoff.maxConsecutive) {
3102
- ports.log(
3103
- `FATAL: ${backoff.maxConsecutive} consecutive API interruptions exhausted the backoff ceiling \u2014 deregistering and exiting (loud). Last error: ${String(e)}`
3104
- );
3105
- throw new BackoffCeilingExhaustedError(backoff.maxConsecutive, e);
3106
- }
3107
- const delay = Math.min(
3108
- backoff.baseMs * 2 ** (consecutiveTransient - 1),
3109
- backoff.maxMs
3110
- );
3111
- ports.log(
3112
- `API interruption (${consecutiveTransient}/${backoff.maxConsecutive}) \u2014 backing off ${delay}ms and continuing: ${String(e)}`
3113
- );
3114
- await ports.waitForWake(delay);
3115
- continue;
3116
- }
3117
- if (options.once) break;
3118
- }
3119
- return summary;
3120
- }
3121
- function closeoutText(task, result) {
3122
- if (!result.packet)
3123
- return `Driven codex run ended without a sechroom_closeout packet (soft-fail). Last agent message:
3124
-
3125
- ${result.lastAgentMessage || "(none)"}`;
3126
- const evidence = result.packet.evidence?.length ? `
3127
-
3128
- Evidence:
3129
- ${result.packet.evidence.map((e) => `- ${e}`).join("\n")}` : "";
3130
- return `${result.packet.summary}${evidence}
3131
-
3132
- (terminal_status: ${result.packet.terminal_status}; driven by sechroom executor run.)`;
3133
- }
3134
- function startLeaseHeartbeat(beat, log, intervalMs = 3e4, timers = {}) {
3135
- const schedule = timers.setInterval ?? setInterval;
3136
- const cancel = timers.clearInterval ?? clearInterval;
3137
- const timer = schedule(() => {
3138
- void beat().catch(
3139
- (e) => log(`lease heartbeat failed (retrying next beat): ${String(e)}`)
3140
- );
3141
- }, intervalMs);
3142
- timer.unref?.();
3143
- return () => cancel(timer);
3144
- }
3145
-
3146
3239
  // src/executor-run/fleet.ts
3147
3240
  import { spawn as spawn2 } from "child_process";
3148
3241
  import { createHash as createHash2 } from "crypto";
@@ -4616,6 +4709,7 @@ function registerTelemetry(program2) {
4616
4709
  const cwd = input.cwd ?? process.cwd();
4617
4710
  const binding = findBinding(cwd);
4618
4711
  if (!binding) return process.exit(0);
4712
+ const cfg = resolveConfig(cmd.optsWithGlobals());
4619
4713
  const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
4620
4714
  const configRoot = resolveClaudeConfigRoot(
4621
4715
  process.env.CLAUDE_CONFIG_DIR,
@@ -4628,7 +4722,10 @@ function registerTelemetry(program2) {
4628
4722
  configRoot
4629
4723
  );
4630
4724
  if (events.length === 0) return process.exit(0);
4631
- const cfg = resolveConfig(cmd.optsWithGlobals());
4725
+ const taskId = await taskIdForHook(cfg, binding);
4726
+ if (!taskId) {
4727
+ for (const event of events) delete event.taskId;
4728
+ }
4632
4729
  await postTelemetry(cfg, binding.decompositionId, events);
4633
4730
  return process.exit(0);
4634
4731
  } catch {
@@ -4723,7 +4820,20 @@ function findBinding(start) {
4723
4820
  readFileSync6(path, "utf8")
4724
4821
  );
4725
4822
  if (b.decompositionId && b.taskId)
4726
- return { decompositionId: b.decompositionId, taskId: b.taskId };
4823
+ return {
4824
+ decompositionId: b.decompositionId,
4825
+ taskId: b.taskId,
4826
+ activeTaskCheckedAt: b.activeTaskCheckedAt,
4827
+ lifecycleWarning: b.lifecycleWarning,
4828
+ path
4829
+ };
4830
+ if (b.decompositionId && b.invalidatedTaskId && b.invalidatedReason === "terminal-task")
4831
+ return {
4832
+ decompositionId: b.decompositionId,
4833
+ invalidatedTaskId: b.invalidatedTaskId,
4834
+ invalidatedReason: b.invalidatedReason,
4835
+ path
4836
+ };
4727
4837
  } catch {
4728
4838
  }
4729
4839
  return null;
@@ -4733,6 +4843,109 @@ function findBinding(start) {
4733
4843
  dir = parent;
4734
4844
  }
4735
4845
  }
4846
+ var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set([
4847
+ "done",
4848
+ "superseded",
4849
+ "cancelled",
4850
+ "abandoned-by-policy"
4851
+ ]);
4852
+ var ACTIVE_TASK_CACHE_TTL_MS = 5 * 60 * 1e3;
4853
+ function isTerminalTaskStatus(status) {
4854
+ return TERMINAL_TASK_STATUSES.has(status);
4855
+ }
4856
+ async function taskIdForHook(cfg, binding) {
4857
+ if (!binding.taskId) return void 0;
4858
+ if (hasFreshActiveTaskCache(binding)) return binding.taskId;
4859
+ const verdict = await getTaskLifecycleVerdict(cfg, binding);
4860
+ if (verdict === "active") {
4861
+ cacheActiveBindingIfCurrent(binding);
4862
+ return binding.taskId;
4863
+ }
4864
+ if (verdict === "indeterminate") return binding.taskId;
4865
+ invalidateBindingIfCurrent(binding);
4866
+ return void 0;
4867
+ }
4868
+ function hasFreshActiveTaskCache(binding) {
4869
+ if (!binding.activeTaskCheckedAt) return false;
4870
+ const checkedAt = Date.parse(binding.activeTaskCheckedAt);
4871
+ return !Number.isNaN(checkedAt) && Date.now() - checkedAt >= 0 && Date.now() - checkedAt < ACTIVE_TASK_CACHE_TTL_MS;
4872
+ }
4873
+ async function getTaskLifecycleVerdict(cfg, binding) {
4874
+ try {
4875
+ const client = await makeClient(cfg);
4876
+ const result = await client.GET("/tasks/{id}/card", {
4877
+ params: { path: { id: binding.taskId } }
4878
+ });
4879
+ if (result.response.status === 403) {
4880
+ if (binding.lifecycleWarning !== "insufficient-permission") {
4881
+ process.stderr.write(
4882
+ "lifecycle check unavailable: insufficient permission \u2014 server door remains authoritative\n"
4883
+ );
4884
+ markLifecycleWarningIfCurrent(binding);
4885
+ }
4886
+ return "indeterminate";
4887
+ }
4888
+ if (!result.response.ok || !result.data) return "indeterminate";
4889
+ return isTerminalTaskStatus(result.data.status) ? "terminal" : "active";
4890
+ } catch {
4891
+ return "indeterminate";
4892
+ }
4893
+ }
4894
+ function markLifecycleWarningIfCurrent(binding) {
4895
+ try {
4896
+ const current = JSON.parse(
4897
+ readFileSync6(binding.path, "utf8")
4898
+ );
4899
+ if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
4900
+ return;
4901
+ const warned = {
4902
+ decompositionId: binding.decompositionId,
4903
+ taskId: binding.taskId,
4904
+ activeTaskCheckedAt: current.activeTaskCheckedAt,
4905
+ lifecycleWarning: "insufficient-permission"
4906
+ };
4907
+ writeFileSync7(binding.path, JSON.stringify(warned, null, 2) + "\n");
4908
+ } catch {
4909
+ }
4910
+ }
4911
+ function cacheActiveBindingIfCurrent(binding) {
4912
+ try {
4913
+ const current = JSON.parse(
4914
+ readFileSync6(binding.path, "utf8")
4915
+ );
4916
+ if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
4917
+ return;
4918
+ const cached = {
4919
+ decompositionId: binding.decompositionId,
4920
+ taskId: binding.taskId,
4921
+ activeTaskCheckedAt: (/* @__PURE__ */ new Date()).toISOString()
4922
+ };
4923
+ writeFileSync7(binding.path, JSON.stringify(cached, null, 2) + "\n");
4924
+ } catch {
4925
+ }
4926
+ }
4927
+ function invalidateBindingIfCurrent(binding) {
4928
+ try {
4929
+ const current = JSON.parse(
4930
+ readFileSync6(binding.path, "utf8")
4931
+ );
4932
+ if (current.decompositionId !== binding.decompositionId || current.taskId !== binding.taskId)
4933
+ return;
4934
+ writeFileSync7(
4935
+ binding.path,
4936
+ JSON.stringify(
4937
+ {
4938
+ decompositionId: binding.decompositionId,
4939
+ invalidatedTaskId: binding.taskId,
4940
+ invalidatedReason: "terminal-task"
4941
+ },
4942
+ null,
4943
+ 2
4944
+ ) + "\n"
4945
+ );
4946
+ } catch {
4947
+ }
4948
+ }
4736
4949
  function parseTranscript(path) {
4737
4950
  if (!existsSync7(path)) return null;
4738
4951
  let tokensIn = 0;
@@ -5158,11 +5371,16 @@ function registerExecutorRunCommand(executor) {
5158
5371
  // Driver-side claim exclusion: the flag (repeatable) overlays the installed
5159
5372
  // state (flag wins when present), else the persisted excludeTags carry through.
5160
5373
  // Local-only — never sent in the advertisement (server schema untouched).
5161
- excludeTags: opts.excludeTag ?? located.state.excludeTags
5374
+ excludeTags: opts.excludeTag ?? located.state.excludeTags,
5375
+ modelId: opts.model ? String(opts.model) : located.state.modelId,
5376
+ runtimeVersion: detectRuntimeVersion("codex", String(opts.codexBin)) ?? located.state.runtimeVersion
5162
5377
  };
5163
5378
  const heartbeatMs = Number.parseInt(String(opts.heartbeatInterval), 10) * 1e3;
5164
- if (heartbeatMs >= 12e4)
5165
- fail("--heartbeat-interval must be shorter than the 120s lease TTL");
5379
+ const taskLeaseTtlMs = (located.state.taskLeaseTtlSeconds ?? 120) * 1e3;
5380
+ if (heartbeatMs >= taskLeaseTtlMs)
5381
+ fail(
5382
+ `--heartbeat-interval must be shorter than the ${taskLeaseTtlMs / 1e3}s task lease TTL`
5383
+ );
5166
5384
  const usageReserve = Number.parseFloat(String(opts.usageReserve));
5167
5385
  if (!Number.isFinite(usageReserve) || usageReserve < 0 || usageReserve >= 100)
5168
5386
  fail("--usage-reserve must be a percent in [0, 100)");
@@ -5231,8 +5449,8 @@ function registerExecutorRunCommand(executor) {
5231
5449
  await appServer.start();
5232
5450
  log(`codex app-server up (${String(opts.codexBin)})`);
5233
5451
  let signalCapacityTerminal;
5234
- const capacityTerminal = new Promise((resolve8) => {
5235
- signalCapacityTerminal = resolve8;
5452
+ const capacityTerminal = new Promise((resolve9) => {
5453
+ signalCapacityTerminal = resolve9;
5236
5454
  });
5237
5455
  let stopCapacityCapture = () => {
5238
5456
  };
@@ -5252,8 +5470,8 @@ function registerExecutorRunCommand(executor) {
5252
5470
  process.once("SIGTERM", requestStop);
5253
5471
  let wake = () => {
5254
5472
  };
5255
- const wakeSignal = () => new Promise((resolve8) => {
5256
- wake = resolve8;
5473
+ const wakeSignal = () => new Promise((resolve9) => {
5474
+ wake = resolve9;
5257
5475
  });
5258
5476
  let connStop = async () => {
5259
5477
  };
@@ -5313,7 +5531,7 @@ function registerExecutorRunCommand(executor) {
5313
5531
  return admission;
5314
5532
  },
5315
5533
  claimNext: async () => await fleetInbox.waitForClaim() ?? await claimNext(request, instance.id, log, excludeTags, skipLog),
5316
- loadTask: (memoryId) => loadTask(request, memoryId, log),
5534
+ loadTask: (memoryId) => materializeClaimedTask(request, memoryId),
5317
5535
  startLeaseHeartbeat: (claim) => startLeaseHeartbeat(
5318
5536
  () => request(
5319
5537
  `/me/executor-task-leases/${encodeURIComponent(claim.leaseId)}/heartbeat`,
@@ -5372,8 +5590,8 @@ function registerExecutorRunCommand(executor) {
5372
5590
  ),
5373
5591
  log,
5374
5592
  waitForWake: (ms) => Promise.race([
5375
- new Promise((resolve8) => {
5376
- setTimeout(resolve8, ms).unref?.();
5593
+ new Promise((resolve9) => {
5594
+ setTimeout(resolve9, ms).unref?.();
5377
5595
  }),
5378
5596
  wakeSignal()
5379
5597
  ])
@@ -5521,49 +5739,9 @@ async function claimNext(request, instanceId, log, excludeTags, skipLog) {
5521
5739
  leaseId: claimed.leaseId,
5522
5740
  claimToken: claimed.claimToken,
5523
5741
  tokenVersion: claimed.tokenVersion,
5524
- decompositionId: claimed.decompositionId
5525
- };
5526
- }
5527
- async function loadTask(request, memoryId, log) {
5528
- const card = await request(
5529
- `/tasks/${encodeURIComponent(memoryId)}/card`
5530
- );
5531
- let packText = "";
5532
- const pointer = card.contextPack;
5533
- if (pointer?.slug && pointer.version) {
5534
- try {
5535
- const pkg = await request(
5536
- `/bundles/${encodeURIComponent(pointer.slug)}/versions/${encodeURIComponent(pointer.version)}/package`
5537
- );
5538
- packText = (pkg.components ?? []).map((c) => `### ${c.title ?? "context"}
5539
- ${c.body}`).join("\n\n");
5540
- } catch (error) {
5541
- log(
5542
- `warning: task ${card.taskId} context pack (${pointer.slug}@${pointer.version}) failed to resolve (${String(error)}) \u2014 running on card body only`
5543
- );
5544
- }
5545
- }
5546
- return {
5547
- title: card.title ?? memoryId,
5548
- text: assemblePrompt(card, packText)
5742
+ decompositionId: claimed.decompositionId
5549
5743
  };
5550
5744
  }
5551
- function assemblePrompt(card, packText) {
5552
- const sections = [
5553
- `# Task: ${card.title}`,
5554
- `## Objective
5555
- ${card.task.objective}`,
5556
- `## Acceptance
5557
- ${card.task.acceptance}`,
5558
- `## Boundaries
5559
- ${card.task.boundaries}`,
5560
- `## Closeout
5561
- ${card.task.closeout}`
5562
- ];
5563
- if (packText) sections.push(`## Context pack
5564
- ${packText}`);
5565
- return sections.join("\n\n");
5566
- }
5567
5745
  function taskPrompt(task) {
5568
5746
  return `You are a driven executor working ONE dispatched Work Layer task.
5569
5747
 
@@ -5590,6 +5768,10 @@ function executorRegistrationInput(state, deliverySubscriptionId, activationMode
5590
5768
  deliverySubscriptionId,
5591
5769
  connectorId: state.connectorId,
5592
5770
  claimedCapabilityKeys: state.capabilityKeys,
5771
+ taskLeaseTtlSeconds: state.taskLeaseTtlSeconds ?? 120,
5772
+ modelId: state.modelId ?? null,
5773
+ runtimeVersion: state.runtimeVersion ?? null,
5774
+ effortLabel: state.effortLabel ?? null,
5593
5775
  claimPolicy: parseClaimPolicy(state.claimPolicy),
5594
5776
  claimTags: state.claimTags ?? [],
5595
5777
  toolSetRef: null,
@@ -5647,6 +5829,11 @@ function registerExecutor(program2) {
5647
5829
  "SignalR delivery binding name",
5648
5830
  "executor-dispatch"
5649
5831
  ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 600).option(
5832
+ "--task-lease-ttl <seconds>",
5833
+ "Task lease TTL (60-86400)",
5834
+ parseInteger,
5835
+ 120
5836
+ ).option("--model-id <id>", "Driver model identity").option("--effort-label <label>", "Optional driver effort label").option(
5650
5837
  "--refresh-after <seconds>",
5651
5838
  "Minimum age before a hook refreshes",
5652
5839
  parseInteger,
@@ -5716,6 +5903,8 @@ function registerExecutor(program2) {
5716
5903
  const sem = readSem();
5717
5904
  const checkout = sem ? dirname8(dirname8(sem.path)) : process.cwd();
5718
5905
  const statePath = join11(checkout, ".sechroom", EXECUTOR_STATE);
5906
+ const previous = existsSync8(statePath) ? JSON.parse(readFileSync7(statePath, "utf8")) : void 0;
5907
+ const canUpdateExisting = previous?.instanceId && previous.instanceKey === instanceKey && previous.laneId === laneId && previous.runtime === (runtime.toLowerCase() === "codex" ? "codex" : "claude-code") && previous.relayId === opts.relay && previous.connectorId === connector;
5719
5908
  const state = {
5720
5909
  schemaVersion: 1,
5721
5910
  instanceKey,
@@ -5723,13 +5912,18 @@ function registerExecutor(program2) {
5723
5912
  runtime: runtime.toLowerCase() === "codex" ? "codex" : "claude-code",
5724
5913
  connectorId: connector,
5725
5914
  capabilityKeys: capabilities ?? [],
5915
+ taskLeaseTtlSeconds: opts.taskLeaseTtl,
5916
+ modelId: opts.modelId,
5917
+ effortLabel: opts.effortLabel,
5726
5918
  claimPolicy: (opts.claimPolicy ?? "open").toLowerCase() === "restricted" ? "restricted" : "open",
5727
5919
  claimTags: opts.claimTag ?? [],
5728
5920
  excludeTags: opts.excludeTag ?? [],
5729
5921
  relayId: opts.relay,
5730
5922
  subscriptionName: opts.subscriptionName,
5731
5923
  ttlSeconds: opts.ttl,
5732
- refreshAfterSeconds: opts.refreshAfter
5924
+ refreshAfterSeconds: opts.refreshAfter,
5925
+ instanceId: canUpdateExisting ? previous.instanceId : void 0,
5926
+ runtimeVersion: canUpdateExisting ? previous.runtimeVersion : void 0
5733
5927
  };
5734
5928
  if (!opts.dryRun) {
5735
5929
  mkdirSync9(dirname8(statePath), { recursive: true });
@@ -5767,6 +5961,7 @@ function registerExecutor(program2) {
5767
5961
  if (state.instanceId && age < state.refreshAfterSeconds * 1e3) return;
5768
5962
  const cfg = resolveConfig(cmd.optsWithGlobals());
5769
5963
  try {
5964
+ refreshRuntimeVersion(state);
5770
5965
  await ensureExecutorInstance(cfg, { state, path });
5771
5966
  } catch {
5772
5967
  }
@@ -5853,7 +6048,12 @@ function registerExecutor(program2) {
5853
6048
  "--activation-mode <mode>",
5854
6049
  "attached | detached \u2014 detached marks a fleet run as a service that outlives its shell (default attached)",
5855
6050
  "attached"
5856
- ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).action(async (opts, cmd) => {
6051
+ ).option("--ttl <seconds>", "Advertisement TTL (30-600)", parseInteger, 120).option(
6052
+ "--task-lease-ttl <seconds>",
6053
+ "Task lease TTL (60-86400)",
6054
+ parseInteger,
6055
+ 120
6056
+ ).option("--model-id <id>", "Driver model identity").option("--effort-label <label>", "Optional driver effort label").action(async (opts, cmd) => {
5857
6057
  const cfg = resolveConfig(cmd.optsWithGlobals());
5858
6058
  const subscription = await api(
5859
6059
  cfg,
@@ -5883,6 +6083,12 @@ function registerExecutor(program2) {
5883
6083
  deliverySubscriptionId: subscription.id,
5884
6084
  connectorId: opts.connector,
5885
6085
  claimedCapabilityKeys: opts.capability ?? [],
6086
+ taskLeaseTtlSeconds: opts.taskLeaseTtl,
6087
+ modelId: opts.modelId ?? null,
6088
+ runtimeVersion: detectRuntimeVersion(
6089
+ String(opts.runtime).toLowerCase() === "codex" ? "codex" : "claude-code"
6090
+ ),
6091
+ effortLabel: opts.effortLabel ?? null,
5886
6092
  claimPolicy: parseClaimPolicy(opts.claimPolicy),
5887
6093
  claimTags: opts.claimTag ?? [],
5888
6094
  toolSetRef: opts.toolSetRef ?? null,
@@ -6017,6 +6223,31 @@ function registerExecutor(program2) {
6017
6223
  );
6018
6224
  emit(data, Boolean(cmd.optsWithGlobals().json));
6019
6225
  });
6226
+ executor.command("update <id>").description(
6227
+ "Update executor capabilities, bounded lease TTL, and model identity in place"
6228
+ ).option("--capability <key...>", "Capability operation keys").option(
6229
+ "--task-lease-ttl <seconds>",
6230
+ "Task lease TTL (60-86400)",
6231
+ parseInteger
6232
+ ).option("--model-id <id>", "Driver model identity").option("--runtime-version <version>", "Driver runtime/version identity").option("--effort-label <label>", "Optional driver effort label").option(
6233
+ "--deregister-others",
6234
+ "Deregister the caller's other active advertisements",
6235
+ false
6236
+ ).action(async (id, opts, cmd) => {
6237
+ const data = await updateExecutorAdvertisement(
6238
+ resolveConfig(cmd.optsWithGlobals()),
6239
+ id,
6240
+ {
6241
+ capabilityKeys: opts.capability,
6242
+ taskLeaseTtlSeconds: opts.taskLeaseTtl,
6243
+ deregisterOthers: opts.deregisterOthers,
6244
+ modelId: opts.modelId,
6245
+ runtimeVersion: opts.runtimeVersion,
6246
+ effortLabel: opts.effortLabel
6247
+ }
6248
+ );
6249
+ emit(data, Boolean(cmd.optsWithGlobals().json));
6250
+ });
6020
6251
  executor.command("deregister <id>").description("Stop advertising this executor instance").action(async (id, _opts, cmd) => {
6021
6252
  const cfg = resolveConfig(cmd.optsWithGlobals());
6022
6253
  const data = await api(
@@ -6138,12 +6369,87 @@ async function deregisterInstance(cfg, id) {
6138
6369
  async function ensureExecutorInstance(cfg, located, activationMode = "Attached") {
6139
6370
  const { state, path } = located;
6140
6371
  state.laneId ??= state.instanceKey;
6141
- const data = await registerInstance(cfg, state, activationMode);
6372
+ state.taskLeaseTtlSeconds ??= 120;
6373
+ refreshRuntimeVersion(state);
6374
+ let data;
6375
+ if (state.instanceId) {
6376
+ const updated = await tryUpdateExecutorAdvertisement(
6377
+ cfg,
6378
+ state.instanceId,
6379
+ {
6380
+ capabilityKeys: state.capabilityKeys,
6381
+ taskLeaseTtlSeconds: state.taskLeaseTtlSeconds,
6382
+ deregisterOthers: false,
6383
+ modelId: state.modelId,
6384
+ runtimeVersion: state.runtimeVersion,
6385
+ effortLabel: state.effortLabel,
6386
+ laneId: state.laneId,
6387
+ claimPolicy: parseClaimPolicy(state.claimPolicy),
6388
+ claimTags: state.claimTags ?? [],
6389
+ parentId: state.parentId ?? null,
6390
+ repairRegistrationState: true
6391
+ }
6392
+ );
6393
+ data = updated ? await refreshExecutorInstance(cfg, state.instanceId, state.ttlSeconds) : await registerInstance(cfg, state, activationMode);
6394
+ } else {
6395
+ data = await registerInstance(cfg, state, activationMode);
6396
+ }
6142
6397
  state.instanceId = data.id;
6143
6398
  state.lastRefreshAt = (/* @__PURE__ */ new Date()).toISOString();
6144
6399
  writeFileSync8(path, JSON.stringify(state, null, 2) + "\n");
6145
6400
  return data;
6146
6401
  }
6402
+ async function updateExecutorAdvertisement(cfg, id, input) {
6403
+ return await tryUpdateExecutorAdvertisement(cfg, id, input) ?? fail(`no active executor instance '${id}' is owned by the current operator`);
6404
+ }
6405
+ async function tryUpdateExecutorAdvertisement(cfg, id, input) {
6406
+ const token = await requireToken(cfg);
6407
+ const path = `/me/executor-instances/${encodeURIComponent(id)}`;
6408
+ const response = await fetch(`${cfg.baseUrl}${path}`, {
6409
+ method: "PUT",
6410
+ headers: {
6411
+ authorization: `Bearer ${token}`,
6412
+ tenant: cfg.tenant,
6413
+ "content-type": "application/json",
6414
+ "x-sechroom-surface": "cli"
6415
+ },
6416
+ body: JSON.stringify({
6417
+ claimedCapabilityKeys: input.capabilityKeys,
6418
+ taskLeaseTtlSeconds: input.taskLeaseTtlSeconds,
6419
+ deregisterOthers: input.deregisterOthers,
6420
+ modelId: input.modelId ?? null,
6421
+ runtimeVersion: input.runtimeVersion,
6422
+ effortLabel: input.effortLabel,
6423
+ laneId: input.laneId,
6424
+ claimPolicy: input.claimPolicy,
6425
+ claimTags: input.claimTags,
6426
+ parentId: input.parentId,
6427
+ repairRegistrationState: input.repairRegistrationState
6428
+ })
6429
+ });
6430
+ if (response.ok) return response.json();
6431
+ const detail = await response.text();
6432
+ if ((response.status === 400 || response.status === 404) && detail.includes("No active executor instance"))
6433
+ return void 0;
6434
+ return fail(`PUT ${path} failed (${response.status}): ${detail}`);
6435
+ }
6436
+ function detectRuntimeVersion(runtime, binaryOverride) {
6437
+ const binary = binaryOverride ?? (runtime === "codex" ? "codex" : "claude");
6438
+ try {
6439
+ return execFileSync(binary, ["--version"], {
6440
+ encoding: "utf8",
6441
+ timeout: 2e3,
6442
+ stdio: ["ignore", "pipe", "ignore"]
6443
+ }).trim() || void 0;
6444
+ } catch {
6445
+ return void 0;
6446
+ }
6447
+ }
6448
+ function refreshRuntimeVersion(state, binaryOverride) {
6449
+ const detected = detectRuntimeVersion(state.runtime, binaryOverride);
6450
+ if (detected) state.runtimeVersion = detected;
6451
+ return state.runtimeVersion;
6452
+ }
6147
6453
  function readExecutorState(start = process.cwd()) {
6148
6454
  const semPath = resolveSemPathForRead(start);
6149
6455
  const sem = semPath ? readSem(semPath) : void 0;
@@ -6185,11 +6491,11 @@ function parseInteger(value) {
6185
6491
  return parsed;
6186
6492
  }
6187
6493
  function holdHeartbeat(tick, intervalMs) {
6188
- return new Promise((resolve8, reject) => {
6494
+ return new Promise((resolve9, reject) => {
6189
6495
  const timer = setInterval(() => void tick().catch(reject), intervalMs);
6190
6496
  const stop = () => {
6191
6497
  clearInterval(timer);
6192
- resolve8();
6498
+ resolve9();
6193
6499
  };
6194
6500
  process.once("SIGINT", stop);
6195
6501
  process.once("SIGTERM", stop);
@@ -6201,33 +6507,26 @@ function registerChannel(program2) {
6201
6507
  const channel = program2.command("channel").description(
6202
6508
  "Receive matched substrate events over the held SignalR push leg (D-WLP-9)"
6203
6509
  );
6204
- const withFilterOpts = (c) => c.option(
6205
- "--name <name>",
6206
- "Deprecated: ignored; the installed executor selects its delivery subscription"
6207
- ).option(
6208
- "--tag <tag...>",
6209
- "Deprecated: executor eligibility comes from the installed capability advertisement"
6210
- ).option(
6211
- "--workspace <wsp...>",
6212
- "Deprecated: workspace authority is resolved by the server"
6213
- ).option(
6214
- "--executor-instance <id>",
6215
- "Deprecated: the instance is read from .sechroom/executor.json"
6216
- );
6217
- withFilterOpts(
6218
- channel.command("connect").description(
6219
- "Register a SignalR subscription and stream matched events to stdout"
6220
- )
6221
- ).action(async (opts, cmd) => {
6510
+ channel.command("connect").description(
6511
+ "Register an executor advertisement, claim exact offers, and stream tasks to stdout"
6512
+ ).action(async (_opts, cmd) => {
6222
6513
  const json = Boolean(cmd.optsWithGlobals().json);
6223
6514
  const cfg = resolveConfig(cmd.optsWithGlobals());
6224
- warnLegacyChannelOptions(opts);
6225
6515
  const located = requireExecutorState();
6226
6516
  const instance = await ensureExecutorInstance(cfg, located);
6227
- const deliver = (payload) => process.stdout.write(
6228
- (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
6517
+ const deliver = createTaskClaimDelivery(
6518
+ cfg,
6519
+ (payload) => process.stdout.write(
6520
+ (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
6521
+ )
6522
+ );
6523
+ const leaseHeartbeats = createChannelLeaseHeartbeatManager(
6524
+ cfg,
6525
+ located.state.taskLeaseTtlSeconds ?? 120
6229
6526
  );
6230
- const drain = createClaimDrain(cfg, instance.id, deliver);
6527
+ const drain = createClaimDrain(cfg, instance.id, deliver, {
6528
+ onClaimed: leaseHeartbeats.onClaimed
6529
+ });
6231
6530
  const conn = await openConnection(
6232
6531
  cfg,
6233
6532
  () => {
@@ -6238,8 +6537,7 @@ function registerChannel(program2) {
6238
6537
  },
6239
6538
  instance.id
6240
6539
  );
6241
- await drain();
6242
- const stopReconciliation = startOfferReconciliation(drain);
6540
+ const stopReconciliation = startClaimReconciliation(drain);
6243
6541
  const stopHeartbeat = startExecutorHeartbeat(
6244
6542
  () => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
6245
6543
  located.state.refreshAfterSeconds * 1e3
@@ -6268,36 +6566,37 @@ function registerChannel(program2) {
6268
6566
  } finally {
6269
6567
  stopReconciliation();
6270
6568
  stopHeartbeat();
6569
+ leaseHeartbeats.stop();
6271
6570
  }
6272
6571
  });
6273
- withFilterOpts(
6274
- channel.command("mcp").description(
6275
- "Run as a Claude Code channel (local-stdio MCP server) \u2014 push matched events into the session"
6276
- )
6277
- ).action(async (opts, cmd) => {
6572
+ channel.command("mcp").description(
6573
+ "Run as a Claude Code channel (local-stdio MCP server) \u2014 claim and push dispatched tasks into the session"
6574
+ ).action(async (_opts, cmd) => {
6278
6575
  const cfg = resolveConfig(cmd.optsWithGlobals());
6279
- warnLegacyChannelOptions(opts);
6280
6576
  const located = requireExecutorState();
6281
6577
  const instance = await ensureExecutorInstance(cfg, located);
6282
6578
  const mcp = new Server(
6283
6579
  { name: "sechroom", version: "0.1.0" },
6284
6580
  {
6285
6581
  capabilities: { experimental: { "claude/channel": {} } },
6286
- instructions: 'Matched Sechroom substrate events arrive as <channel source="sechroom"> tags. A WLP dispatch delivered here has already been atomically claimed for this executor. Load the memory id from the event and retain the lease and claim token for holder-bound completion.'
6582
+ instructions: 'A WLP dispatch arrives as a <channel source="sechroom"> tag containing the complete runnable task card followed by its pinned task context. Treat it as the task-time layer on top of the session floor; do not refetch or substitute live memory bodies. Retain memory_id, lease_id, and claim_token from the metadata for holder-bound completion.'
6287
6583
  }
6288
6584
  );
6289
6585
  await mcp.connect(new StdioServerTransport());
6290
- const deliver = (payload) => {
6291
- const { content, meta } = summarizeEvent(payload);
6292
- void mcp.notification({
6586
+ const deliver = createTaskClaimDelivery(cfg, async (payload) => {
6587
+ const { content, meta } = summarizeTaskClaim(payload);
6588
+ await mcp.notification({
6293
6589
  method: "notifications/claude/channel",
6294
6590
  params: { content, meta }
6295
- }).catch(
6296
- (e) => process.stderr.write(err(`channel push failed: ${String(e)}
6297
- `))
6298
- );
6299
- };
6300
- const drain = createClaimDrain(cfg, instance.id, deliver);
6591
+ });
6592
+ });
6593
+ const leaseHeartbeats = createChannelLeaseHeartbeatManager(
6594
+ cfg,
6595
+ located.state.taskLeaseTtlSeconds ?? 120
6596
+ );
6597
+ const drain = createClaimDrain(cfg, instance.id, deliver, {
6598
+ onClaimed: leaseHeartbeats.onClaimed
6599
+ });
6301
6600
  const conn = await openConnection(
6302
6601
  cfg,
6303
6602
  () => {
@@ -6308,8 +6607,7 @@ function registerChannel(program2) {
6308
6607
  },
6309
6608
  instance.id
6310
6609
  );
6311
- await drain();
6312
- const stopReconciliation = startOfferReconciliation(drain);
6610
+ const stopReconciliation = startClaimReconciliation(drain);
6313
6611
  const stopHeartbeat = startExecutorHeartbeat(
6314
6612
  () => refreshExecutorInstance(cfg, instance.id, located.state.ttlSeconds),
6315
6613
  located.state.refreshAfterSeconds * 1e3
@@ -6325,31 +6623,23 @@ function registerChannel(program2) {
6325
6623
  } finally {
6326
6624
  stopReconciliation();
6327
6625
  stopHeartbeat();
6626
+ leaseHeartbeats.stop();
6328
6627
  }
6329
6628
  });
6330
6629
  channel.command("install").description(
6331
6630
  "Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
6332
- ).option(
6333
- "--workspace <wsp...>",
6334
- "Deprecated: accepted only to migrate an existing managed entry"
6335
- ).option(
6336
- "--tag <tag...>",
6337
- "Deprecated: accepted only to migrate an existing managed entry"
6338
- ).option(
6339
- "--name <name>",
6340
- "MCP server + subscription name (idempotent per name)",
6341
- "sechroom-channel"
6342
6631
  ).option("--dry-run", "Print what would change; write nothing").action((opts) => {
6343
6632
  const path = join12(process.cwd(), ".mcp.json");
6344
6633
  const dryRun = Boolean(opts.dryRun);
6634
+ const name = "sechroom-channel";
6345
6635
  const args = ["channel", "mcp"];
6346
6636
  const entry = { command: "sechroom", args };
6347
6637
  const config2 = readMcpConfig(path);
6348
6638
  config2.mcpServers ??= {};
6349
- const existing = config2.mcpServers[opts.name];
6639
+ const existing = config2.mcpServers[name];
6350
6640
  const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
6351
6641
  if (status !== "current" && !dryRun) {
6352
- config2.mcpServers[opts.name] = entry;
6642
+ config2.mcpServers[name] = entry;
6353
6643
  mkdirSync10(dirname9(path), { recursive: true });
6354
6644
  writeFileSync9(path, JSON.stringify(config2, null, 2) + "\n");
6355
6645
  }
@@ -6357,7 +6647,7 @@ function registerChannel(program2) {
6357
6647
  process.stdout.write(`${style.green("channel")} ${path} (${verb})
6358
6648
  `);
6359
6649
  process.stdout.write(
6360
- style.dim(` server "${opts.name}": sechroom ${args.join(" ")}
6650
+ style.dim(` server "${name}": sechroom ${args.join(" ")}
6361
6651
  `)
6362
6652
  );
6363
6653
  if (status !== "current") {
@@ -6365,18 +6655,12 @@ function registerChannel(program2) {
6365
6655
  style.dim(
6366
6656
  `
6367
6657
  Load it (Channels research preview) by launching your agent with:
6368
- claude --dangerously-load-development-channels server:${opts.name}
6658
+ claude --dangerously-load-development-channels server:${name}
6369
6659
  `
6370
6660
  )
6371
6661
  );
6372
6662
  }
6373
6663
  warnIfSechroomNotOnPath();
6374
- if ((opts.workspace?.length ?? 0) > 0 || (opts.tag?.length ?? 0) > 0)
6375
- process.stderr.write(
6376
- style.dim(
6377
- "channel: --workspace/--tag are retired; the managed entry now uses the installed executor advertisement.\n"
6378
- )
6379
- );
6380
6664
  });
6381
6665
  channel.addHelpText(
6382
6666
  "after",
@@ -6398,15 +6682,6 @@ function requireExecutorState() {
6398
6682
  );
6399
6683
  return located;
6400
6684
  }
6401
- function warnLegacyChannelOptions(opts) {
6402
- if (!opts.name && (opts.workspace?.length ?? 0) === 0 && (opts.tag?.length ?? 0) === 0 && !opts.executorInstance)
6403
- return;
6404
- process.stderr.write(
6405
- style.dim(
6406
- "channel: --name, --workspace, --tag, and --executor-instance are retired; delivery, eligibility, and identity come from the installed executor advertisement.\n"
6407
- )
6408
- );
6409
- }
6410
6685
  function createClaimDrain(cfg, executorInstanceId, deliver, dependencies = {}) {
6411
6686
  let active2;
6412
6687
  const state = {};
@@ -6430,21 +6705,88 @@ function startOfferReconciliation(drain, intervalMilliseconds = 5e3, dependencie
6430
6705
  }, intervalMilliseconds);
6431
6706
  return () => cancel(timer);
6432
6707
  }
6708
+ function startClaimReconciliation(drain, intervalMilliseconds = 5e3, dependencies = {}) {
6709
+ const onError = dependencies.onError ?? ((error) => process.stderr.write(err(`channel claim failed: ${String(error)}
6710
+ `)));
6711
+ void drain().catch(onError);
6712
+ return startOfferReconciliation(drain, intervalMilliseconds, {
6713
+ ...dependencies,
6714
+ onError
6715
+ });
6716
+ }
6433
6717
  function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}) {
6434
6718
  const schedule = dependencies.setInterval ?? setInterval;
6435
6719
  const cancel = dependencies.clearInterval ?? clearInterval;
6436
- const onError = dependencies.onError ?? ((error) => process.stderr.write(err(`channel heartbeat failed: ${String(error)}
6437
- `)));
6720
+ const onError = dependencies.onError ?? ((error) => process.stderr.write(
6721
+ err(`channel heartbeat failed: ${String(error)}
6722
+ `)
6723
+ ));
6438
6724
  const timer = schedule(() => {
6439
6725
  void refresh().catch(onError);
6440
6726
  }, intervalMilliseconds);
6441
6727
  return () => cancel(timer);
6442
6728
  }
6729
+ function startChannelTaskLeaseHeartbeat(cfg, claim, intervalMilliseconds = 3e4, dependencies = {}) {
6730
+ const leaseId = claim.lease?.id;
6731
+ const claimToken = claim.claimToken;
6732
+ if (!leaseId || !claimToken) return void 0;
6733
+ const request = dependencies.request ?? api;
6734
+ const onError = dependencies.onError ?? ((value) => process.stderr.write(
6735
+ err(`channel lease heartbeat failed: ${String(value)}
6736
+ `)
6737
+ ));
6738
+ return startLeaseHeartbeat(
6739
+ () => request(
6740
+ cfg,
6741
+ `/me/executor-task-leases/${encodeURIComponent(leaseId)}/heartbeat`,
6742
+ {
6743
+ method: "POST",
6744
+ body: JSON.stringify({
6745
+ claimToken,
6746
+ tokenVersion: claim.tokenVersion ?? 1
6747
+ })
6748
+ }
6749
+ ),
6750
+ onError,
6751
+ intervalMilliseconds,
6752
+ {
6753
+ setInterval: dependencies.setInterval,
6754
+ clearInterval: dependencies.clearInterval
6755
+ }
6756
+ );
6757
+ }
6758
+ function createChannelLeaseHeartbeatManager(cfg, taskLeaseTtlSeconds) {
6759
+ const stops = /* @__PURE__ */ new Map();
6760
+ const intervalMilliseconds = Math.max(
6761
+ 1e3,
6762
+ Math.min(3e4, Math.floor(taskLeaseTtlSeconds * 1e3 / 4))
6763
+ );
6764
+ return {
6765
+ onClaimed: (claim) => {
6766
+ const leaseId = claim.lease?.id;
6767
+ if (!leaseId || stops.has(leaseId)) return;
6768
+ const stop = startChannelTaskLeaseHeartbeat(
6769
+ cfg,
6770
+ claim,
6771
+ intervalMilliseconds
6772
+ );
6773
+ if (stop) stops.set(leaseId, stop);
6774
+ },
6775
+ stop: () => {
6776
+ for (const stop of stops.values()) stop();
6777
+ stops.clear();
6778
+ }
6779
+ };
6780
+ }
6443
6781
  async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {}) {
6444
6782
  const request = dependencies.request ?? api;
6445
- const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve8) => setTimeout(resolve8, milliseconds)));
6783
+ const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolve9) => setTimeout(resolve9, milliseconds)));
6446
6784
  const idempotencyKey = dependencies.idempotencyKey ?? ((offer) => `channel:${offer.generationId}`);
6447
6785
  const state = dependencies.state ?? {};
6786
+ const deliverClaim = async (claim) => {
6787
+ dependencies.onClaimed?.(claim);
6788
+ await deliver(claim);
6789
+ };
6448
6790
  for (; ; ) {
6449
6791
  if (state.pendingIdempotencyKey) {
6450
6792
  const replay = await request(
@@ -6455,11 +6797,12 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
6455
6797
  body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
6456
6798
  }
6457
6799
  );
6458
- state.pendingIdempotencyKey = void 0;
6459
6800
  if (replay.outcome === "Claimed" || replay.outcome === "AlreadyHeld") {
6460
- deliver(replay);
6801
+ await deliverClaim(replay);
6802
+ state.pendingIdempotencyKey = void 0;
6461
6803
  continue;
6462
6804
  }
6805
+ state.pendingIdempotencyKey = void 0;
6463
6806
  return;
6464
6807
  }
6465
6808
  const offers = await request(
@@ -6479,11 +6822,13 @@ async function drainClaims(cfg, executorInstanceId, deliver, dependencies = {})
6479
6822
  body: JSON.stringify({ idempotencyKey: state.pendingIdempotencyKey })
6480
6823
  }
6481
6824
  );
6825
+ if (claim.outcome === "Claimed" || claim.outcome === "AlreadyHeld") {
6826
+ await deliverClaim(claim);
6827
+ state.pendingIdempotencyKey = void 0;
6828
+ continue;
6829
+ }
6482
6830
  state.pendingIdempotencyKey = void 0;
6483
- if (claim.outcome === "NoOffer") return;
6484
- if (claim.outcome === "Claimed" || claim.outcome === "AlreadyHeld")
6485
- deliver(claim);
6486
- else return;
6831
+ return;
6487
6832
  }
6488
6833
  }
6489
6834
  function readMcpConfig(path) {
@@ -6516,9 +6861,9 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
6516
6861
  return conn;
6517
6862
  }
6518
6863
  function holdOpen(conn) {
6519
- return new Promise((resolve8) => {
6864
+ return new Promise((resolve9) => {
6520
6865
  const stop = () => {
6521
- void conn.stop().finally(resolve8);
6866
+ void conn.stop().finally(resolve9);
6522
6867
  };
6523
6868
  process.on("SIGINT", stop);
6524
6869
  process.on("SIGTERM", stop);
@@ -6544,18 +6889,35 @@ function parseEvent(payload) {
6544
6889
  tags: Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : void 0
6545
6890
  };
6546
6891
  }
6547
- function summarizeEvent(payload) {
6548
- const { eventType, memoryId, workspaceId } = parseEvent(payload);
6549
- const content = memoryId ? `${eventType}: ${memoryId}${workspaceId ? ` (workspace ${workspaceId})` : ""}` : typeof payload === "string" ? payload : JSON.stringify(payload);
6892
+ function createTaskClaimDelivery(cfg, deliver, dependencies = {}) {
6893
+ const request = dependencies.request ?? api;
6894
+ return async (claim) => {
6895
+ const memoryId = claim.lease?.memoryId || claim.offer?.memoryId;
6896
+ if (!memoryId)
6897
+ throw new Error(
6898
+ "claimed task response carried no memory id; refusing attached-channel delivery"
6899
+ );
6900
+ const task = await materializeClaimedTask(
6901
+ (path, init) => request(cfg, path, init),
6902
+ memoryId
6903
+ );
6904
+ await deliver({ ...claim, task });
6905
+ };
6906
+ }
6907
+ function summarizeTaskClaim(payload) {
6908
+ const { task, ...claim } = payload;
6909
+ const parsed = parseEvent(claim);
6910
+ const memoryId = parsed.memoryId || claim.lease?.memoryId || claim.offer?.memoryId || "";
6911
+ const workspaceId = parsed.workspaceId || claim.lease?.workspaceId || claim.offer?.workspaceId || "";
6550
6912
  const meta = {};
6551
- if (eventType) meta.event_type = eventType;
6913
+ if (parsed.eventType) meta.event_type = parsed.eventType;
6552
6914
  if (memoryId) meta.memory_id = memoryId;
6553
6915
  if (workspaceId) meta.workspace_id = workspaceId;
6554
- const claim = payload ?? {};
6555
6916
  if (claim.outcome) meta.claim_outcome = claim.outcome;
6556
6917
  if (claim.lease?.id) meta.lease_id = claim.lease.id;
6557
6918
  if (claim.claimToken) meta.claim_token = claim.claimToken;
6558
- return { content, meta };
6919
+ meta.context_layer = "task";
6920
+ return { content: task.text, meta };
6559
6921
  }
6560
6922
  function str(v) {
6561
6923
  return typeof v === "string" ? v : v == null ? "" : String(v);
@@ -6644,16 +7006,16 @@ Examples:
6644
7006
  }
6645
7007
 
6646
7008
  // src/commands/checkpoint.ts
6647
- import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync13 } from "fs";
6648
- import { dirname as dirname13, join as join15 } from "path";
7009
+ import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync14 } from "fs";
7010
+ import { dirname as dirname14, join as join16 } from "path";
6649
7011
 
6650
7012
  // src/commands/hook.ts
6651
- import { createHash as createHash3 } from "crypto";
6652
- import { existsSync as existsSync11, mkdirSync as mkdirSync13, readFileSync as readFileSync10, statSync as statSync2, writeFileSync as writeFileSync12 } from "fs";
6653
- import { dirname as dirname12, join as join14 } from "path";
7013
+ import { createHash as createHash4 } from "crypto";
7014
+ import { existsSync as existsSync12, mkdirSync as mkdirSync14, readFileSync as readFileSync11, statSync as statSync3, writeFileSync as writeFileSync13 } from "fs";
7015
+ import { dirname as dirname13, join as join15 } from "path";
6654
7016
 
6655
7017
  // src/commands/lane-commit-hook.ts
6656
- import { execFileSync } from "child_process";
7018
+ import { execFileSync as execFileSync2 } from "child_process";
6657
7019
  import {
6658
7020
  chmodSync,
6659
7021
  existsSync as existsSync10,
@@ -6717,90 +7079,427 @@ ${current.slice(shebang.length)}`;
6717
7079
  ${MANAGED_BLOCK}
6718
7080
  ${current}`;
6719
7081
  }
6720
- function managedBlockPattern() {
6721
- return new RegExp(
6722
- `${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
7082
+ function managedBlockPattern() {
7083
+ return new RegExp(
7084
+ `${BEGIN.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`
7085
+ );
7086
+ }
7087
+ function chainWrapper(incumbentName) {
7088
+ return `#!/bin/sh
7089
+ ${MANAGED_BLOCK}
7090
+ hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
7091
+ exec "$hook_dir/${incumbentName}" "$@"
7092
+ `;
7093
+ }
7094
+ function nextIncumbentPath(path) {
7095
+ const base = `${path}${INCUMBENT_SUFFIX}`;
7096
+ if (!existsSync10(base)) return base;
7097
+ for (let index = 2; ; index++) {
7098
+ const candidate = `${base}.${index}`;
7099
+ if (!existsSync10(candidate)) return candidate;
7100
+ }
7101
+ }
7102
+ function resolveHookPath(root, hookName) {
7103
+ const gitPath = execFileSync2(
7104
+ "git",
7105
+ ["-C", root, "rev-parse", "--git-path", `hooks/${hookName}`],
7106
+ {
7107
+ encoding: "utf8",
7108
+ stdio: ["ignore", "pipe", "ignore"]
7109
+ }
7110
+ ).trim();
7111
+ return isAbsolute(gitPath) ? gitPath : resolve4(root, gitPath);
7112
+ }
7113
+ function removeLegacyPrepareCommitMsgLeg(root) {
7114
+ const path = resolveHookPath(root, LEGACY_HOOK_NAME);
7115
+ if (!existsSync10(path)) return;
7116
+ const current = readFileSync9(path, "utf8");
7117
+ const pattern = managedBlockPattern();
7118
+ if (!pattern.test(current)) return;
7119
+ const next = current.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd();
7120
+ if (!next || next === "#!/bin/sh") {
7121
+ unlinkSync(path);
7122
+ return;
7123
+ }
7124
+ writeFileSync10(path, `${next}
7125
+ `, "utf8");
7126
+ chmodSync(path, 493);
7127
+ }
7128
+ function installLaneCommitHook(start) {
7129
+ try {
7130
+ const root = execFileSync2("git", ["-C", start, "rev-parse", "--show-toplevel"], {
7131
+ encoding: "utf8",
7132
+ stdio: ["ignore", "pipe", "ignore"]
7133
+ }).trim();
7134
+ removeLegacyPrepareCommitMsgLeg(root);
7135
+ const path = resolveHookPath(root, HOOK_NAME);
7136
+ const current = existsSync10(path) ? readFileSync9(path, "utf8") : "";
7137
+ let next;
7138
+ if (current && !isShellHook(current)) {
7139
+ const incumbentPath = nextIncumbentPath(path);
7140
+ renameSync(path, incumbentPath);
7141
+ chmodSync(incumbentPath, 493);
7142
+ next = chainWrapper(basename2(incumbentPath));
7143
+ } else {
7144
+ next = insertManagedBlock(current);
7145
+ }
7146
+ if (next !== current) {
7147
+ mkdirSync11(dirname10(path), { recursive: true });
7148
+ writeFileSync10(path, next.endsWith("\n") ? next : `${next}
7149
+ `, "utf8");
7150
+ }
7151
+ chmodSync(path, 493);
7152
+ return path;
7153
+ } catch {
7154
+ return void 0;
7155
+ }
7156
+ }
7157
+
7158
+ // src/commands/session-context.ts
7159
+ import { randomUUID as randomUUID2 } from "crypto";
7160
+ import { mkdirSync as mkdirSync13, renameSync as renameSync3, rmSync as rmSync6, writeFileSync as writeFileSync12 } from "fs";
7161
+ import { dirname as dirname12, join as join14 } from "path";
7162
+
7163
+ // src/setup/skill-composition-materialise.ts
7164
+ import { createHash as createHash3, randomUUID } from "crypto";
7165
+ import {
7166
+ existsSync as existsSync11,
7167
+ mkdirSync as mkdirSync12,
7168
+ readdirSync as readdirSync2,
7169
+ readFileSync as readFileSync10,
7170
+ renameSync as renameSync2,
7171
+ rmdirSync,
7172
+ rmSync as rmSync5,
7173
+ statSync as statSync2,
7174
+ writeFileSync as writeFileSync11
7175
+ } from "fs";
7176
+ import { dirname as dirname11, isAbsolute as isAbsolute2, join as join13, relative, resolve as resolve5 } from "path";
7177
+ var COMPILED_SKILL_LOCK = join13(
7178
+ ".sechroom",
7179
+ "compiled-skill-materialisation.json"
7180
+ );
7181
+ var GIT_EXCLUDE_BEGIN = "# sechroom compiled skills: begin";
7182
+ var GIT_EXCLUDE_END = "# sechroom compiled skills: end";
7183
+ function materialiseCompiledSkillCompositions(cwd, payload, options = {}) {
7184
+ const holds = [...payload.holds ?? []];
7185
+ if (payload.preserveExisting || holds.some((hold) => hold.skillName == null))
7186
+ return { items: [], holds };
7187
+ const destinations = resolveDestinations(cwd, options);
7188
+ const desired = /* @__PURE__ */ new Map();
7189
+ const ambiguous = /* @__PURE__ */ new Set();
7190
+ for (const skill of payload.skills) {
7191
+ if (!isSafePathSegment(skill.name)) {
7192
+ holds.push({
7193
+ code: "compiled-skill-name-invalid",
7194
+ reason: `Compiled skill '${skill.name}' is not a safe path segment.`,
7195
+ bundleSlug: skill.bundleSlug,
7196
+ skillName: skill.name
7197
+ });
7198
+ continue;
7199
+ }
7200
+ const body = withFinalNewline(skill.body);
7201
+ for (const rawTarget of skill.targets) {
7202
+ if (!isSupportedTarget(rawTarget)) {
7203
+ holds.push({
7204
+ code: "compiled-skill-target-unsupported",
7205
+ reason: `Compiled skill '${skill.name}' names unsupported target:${rawTarget}.`,
7206
+ bundleSlug: skill.bundleSlug,
7207
+ skillName: skill.name
7208
+ });
7209
+ continue;
7210
+ }
7211
+ for (const destination of destinations.filter(
7212
+ (candidate) => candidate.target === rawTarget
7213
+ )) {
7214
+ const key = entryKey(rawTarget, skill.name, destination.skillsRoot);
7215
+ if (desired.has(key) || ambiguous.has(key)) {
7216
+ holds.push({
7217
+ code: "compiled-skill-identity-ambiguous",
7218
+ reason: `More than one compiled composition resolved '${skill.name}' for target:${rawTarget}.`,
7219
+ bundleSlug: skill.bundleSlug,
7220
+ skillName: skill.name
7221
+ });
7222
+ desired.delete(key);
7223
+ ambiguous.add(key);
7224
+ continue;
7225
+ }
7226
+ desired.set(key, {
7227
+ target: rawTarget,
7228
+ skillsRoot: destination.skillsRoot,
7229
+ skill,
7230
+ body
7231
+ });
7232
+ }
7233
+ }
7234
+ }
7235
+ for (const [key, value] of desired) {
7236
+ if (holds.some(
7237
+ (hold) => hold.skillName === value.skill.name && (hold.bundleSlug == null || hold.bundleSlug === value.skill.bundleSlug)
7238
+ )) {
7239
+ desired.delete(key);
7240
+ }
7241
+ }
7242
+ const lockPath = join13(cwd, COMPILED_SKILL_LOCK);
7243
+ const previous = readLock(lockPath, destinations);
7244
+ const next = {
7245
+ version: 2,
7246
+ entries: { ...previous.entries }
7247
+ };
7248
+ const items = [];
7249
+ for (const [key, value] of desired) {
7250
+ const path = skillPath(value.skillsRoot, value.skill.name);
7251
+ const directory = dirname11(path);
7252
+ const prior = previous.entries[key];
7253
+ const existing = existsSync11(path) ? readFileSync10(path, "utf8") : void 0;
7254
+ const existingHash = existing === void 0 ? void 0 : sha256(existing);
7255
+ const owned = prior !== void 0 && prior.skillsRoot === value.skillsRoot && existing !== void 0 && (existingHash === prior.contentHash || existingHash === prior.previousContentHash);
7256
+ const recoverableMissing = prior !== void 0 && existing === void 0;
7257
+ if (existing !== void 0 && !owned) {
7258
+ delete next.entries[key];
7259
+ items.push({
7260
+ target: value.target,
7261
+ name: value.skill.name,
7262
+ path,
7263
+ status: "collision",
7264
+ reason: "existing SKILL.md is not owned by the compiled-skill materializer"
7265
+ });
7266
+ continue;
7267
+ }
7268
+ if (existing === void 0 && existsSync11(directory) && !recoverableMissing) {
7269
+ delete next.entries[key];
7270
+ items.push({
7271
+ target: value.target,
7272
+ name: value.skill.name,
7273
+ path,
7274
+ status: "collision",
7275
+ reason: "existing skill directory is not owned by the compiled-skill materializer"
7276
+ });
7277
+ continue;
7278
+ }
7279
+ const contentHash = sha256(value.body);
7280
+ const finalEntry = {
7281
+ target: value.target,
7282
+ name: value.skill.name,
7283
+ skillsRoot: value.skillsRoot,
7284
+ bundleSlug: value.skill.bundleSlug,
7285
+ bundleVersion: value.skill.bundleVersion,
7286
+ manifestHash: value.skill.manifestHash,
7287
+ contentHash
7288
+ };
7289
+ let status;
7290
+ if (existing === value.body) {
7291
+ status = "unchanged";
7292
+ next.entries[key] = finalEntry;
7293
+ writeLock(lockPath, next);
7294
+ } else {
7295
+ next.entries[key] = {
7296
+ ...finalEntry,
7297
+ previousContentHash: existingHash
7298
+ };
7299
+ writeLock(lockPath, next);
7300
+ mkdirSync12(directory, { recursive: true });
7301
+ if (existingHash !== void 0 && (!existsSync11(path) || sha256(readFileSync10(path, "utf8")) !== existingHash)) {
7302
+ delete next.entries[key];
7303
+ writeLock(lockPath, next);
7304
+ items.push({
7305
+ target: value.target,
7306
+ name: value.skill.name,
7307
+ path,
7308
+ status: "collision",
7309
+ reason: "owned SKILL.md changed during reconciliation; preserved byte-identical"
7310
+ });
7311
+ continue;
7312
+ }
7313
+ writeAtomic(path, value.body);
7314
+ status = existing === void 0 ? "written" : "updated";
7315
+ next.entries[key] = finalEntry;
7316
+ writeLock(lockPath, next);
7317
+ }
7318
+ items.push({ target: value.target, name: value.skill.name, path, status });
7319
+ }
7320
+ for (const [key, prior] of Object.entries(previous.entries)) {
7321
+ if (desired.has(key)) continue;
7322
+ const path = skillPath(prior.skillsRoot, prior.name);
7323
+ const removed = removeOwnedFile(path, [
7324
+ prior.contentHash,
7325
+ prior.previousContentHash
7326
+ ]);
7327
+ if (removed === "collision") {
7328
+ items.push({
7329
+ target: prior.target,
7330
+ name: prior.name,
7331
+ path,
7332
+ status: "collision",
7333
+ reason: "previously materialized SKILL.md was edited; preserved byte-identical"
7334
+ });
7335
+ } else if (removed === "removed") {
7336
+ removeDirectoryIfEmpty(dirname11(path));
7337
+ items.push({
7338
+ target: prior.target,
7339
+ name: prior.name,
7340
+ path,
7341
+ status: "removed"
7342
+ });
7343
+ }
7344
+ delete next.entries[key];
7345
+ writeLock(lockPath, next);
7346
+ }
7347
+ writeLock(lockPath, next);
7348
+ syncGitExcludes(cwd, next);
7349
+ return { items, holds };
7350
+ }
7351
+ function isSupportedTarget(value) {
7352
+ return value === "claude-code" || value === "gpt-codex";
7353
+ }
7354
+ function isSafePathSegment(value) {
7355
+ return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !value.includes("\0");
7356
+ }
7357
+ function resolveDestinations(cwd, options) {
7358
+ const destinations = [
7359
+ {
7360
+ target: "claude-code",
7361
+ skillsRoot: resolve5(cwd, ".claude", "skills")
7362
+ }
7363
+ ];
7364
+ const codexHomes = options.codexHomes ?? resolveCodexHomes({
7365
+ override: options.codexHomeOverride,
7366
+ scope: "global"
7367
+ });
7368
+ for (const home of codexHomes) {
7369
+ destinations.push({
7370
+ target: "gpt-codex",
7371
+ skillsRoot: resolve5(home, "skills")
7372
+ });
7373
+ }
7374
+ return destinations.filter(
7375
+ (candidate, index, all) => all.findIndex(
7376
+ (other) => other.target === candidate.target && other.skillsRoot === candidate.skillsRoot
7377
+ ) === index
6723
7378
  );
6724
7379
  }
6725
- function chainWrapper(incumbentName) {
6726
- return `#!/bin/sh
6727
- ${MANAGED_BLOCK}
6728
- hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6729
- exec "$hook_dir/${incumbentName}" "$@"
6730
- `;
7380
+ function entryKey(target, name, skillsRoot) {
7381
+ return `${target}:${sha256(skillsRoot)}:${name}`;
6731
7382
  }
6732
- function nextIncumbentPath(path) {
6733
- const base = `${path}${INCUMBENT_SUFFIX}`;
6734
- if (!existsSync10(base)) return base;
6735
- for (let index = 2; ; index++) {
6736
- const candidate = `${base}.${index}`;
6737
- if (!existsSync10(candidate)) return candidate;
6738
- }
7383
+ function skillPath(skillsRoot, name) {
7384
+ return join13(skillsRoot, name, "SKILL.md");
6739
7385
  }
6740
- function resolveHookPath(root, hookName) {
6741
- const gitPath = execFileSync(
6742
- "git",
6743
- ["-C", root, "rev-parse", "--git-path", `hooks/${hookName}`],
6744
- {
6745
- encoding: "utf8",
6746
- stdio: ["ignore", "pipe", "ignore"]
7386
+ function withFinalNewline(body) {
7387
+ return body.endsWith("\n") ? body : body + "\n";
7388
+ }
7389
+ function sha256(value) {
7390
+ return createHash3("sha256").update(value, "utf8").digest("hex");
7391
+ }
7392
+ function readLock(path, destinations) {
7393
+ const allowedRoots = new Set(
7394
+ destinations.map(
7395
+ (destination) => `${destination.target}\0${destination.skillsRoot}`
7396
+ )
7397
+ );
7398
+ try {
7399
+ const parsed = JSON.parse(readFileSync10(path, "utf8"));
7400
+ if (parsed.version === 2 && parsed.entries && typeof parsed.entries === "object") {
7401
+ const entries = {};
7402
+ for (const [key, candidate] of Object.entries(parsed.entries)) {
7403
+ if (!isLockEntry(candidate) || key !== entryKey(candidate.target, candidate.name, candidate.skillsRoot))
7404
+ continue;
7405
+ if (!allowedRoots.has(`${candidate.target}\0${candidate.skillsRoot}`))
7406
+ continue;
7407
+ entries[key] = candidate;
7408
+ }
7409
+ return { version: 2, entries };
6747
7410
  }
6748
- ).trim();
6749
- return isAbsolute(gitPath) ? gitPath : resolve4(root, gitPath);
7411
+ } catch {
7412
+ }
7413
+ return { version: 2, entries: {} };
6750
7414
  }
6751
- function removeLegacyPrepareCommitMsgLeg(root) {
6752
- const path = resolveHookPath(root, LEGACY_HOOK_NAME);
6753
- if (!existsSync10(path)) return;
6754
- const current = readFileSync9(path, "utf8");
6755
- const pattern = managedBlockPattern();
6756
- if (!pattern.test(current)) return;
6757
- const next = current.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimEnd();
6758
- if (!next || next === "#!/bin/sh") {
6759
- unlinkSync(path);
6760
- return;
7415
+ function isLockEntry(value) {
7416
+ if (value === null || typeof value !== "object") return false;
7417
+ const candidate = value;
7418
+ return isSupportedTarget(String(candidate.target)) && typeof candidate.name === "string" && isSafePathSegment(candidate.name) && typeof candidate.skillsRoot === "string" && isAbsolute2(candidate.skillsRoot) && typeof candidate.bundleSlug === "string" && typeof candidate.bundleVersion === "string" && typeof candidate.manifestHash === "string" && typeof candidate.contentHash === "string" && /^[0-9a-f]{64}$/.test(candidate.contentHash) && (candidate.previousContentHash === void 0 || typeof candidate.previousContentHash === "string" && /^[0-9a-f]{64}$/.test(candidate.previousContentHash));
7419
+ }
7420
+ function writeLock(path, lock) {
7421
+ mkdirSync12(dirname11(path), { recursive: true });
7422
+ writeAtomic(path, JSON.stringify(lock, null, 2) + "\n");
7423
+ }
7424
+ function writeAtomic(path, body) {
7425
+ const temporary = `${path}.sechroom-${process.pid}.tmp`;
7426
+ writeFileSync11(temporary, body);
7427
+ renameSync2(temporary, path);
7428
+ }
7429
+ function removeOwnedFile(path, acceptableHashes) {
7430
+ const quarantine = `${path}.sechroom-retire-${process.pid}-${randomUUID()}`;
7431
+ try {
7432
+ renameSync2(path, quarantine);
7433
+ } catch (error) {
7434
+ if (error.code === "ENOENT") return "missing";
7435
+ throw error;
6761
7436
  }
6762
- writeFileSync10(path, `${next}
6763
- `, "utf8");
6764
- chmodSync(path, 493);
7437
+ const quarantinedHash = sha256(readFileSync10(quarantine, "utf8"));
7438
+ if (acceptableHashes.includes(quarantinedHash)) {
7439
+ rmSync5(quarantine);
7440
+ return "removed";
7441
+ }
7442
+ if (!existsSync11(path)) {
7443
+ renameSync2(quarantine, path);
7444
+ } else {
7445
+ renameSync2(quarantine, `${path}.sechroom-preserved-${randomUUID()}`);
7446
+ }
7447
+ return "collision";
6765
7448
  }
6766
- function installLaneCommitHook(start) {
7449
+ function syncGitExcludes(cwd, lock) {
7450
+ const path = gitExcludePath(cwd);
7451
+ if (!path) return;
7452
+ let current = "";
6767
7453
  try {
6768
- const root = execFileSync("git", ["-C", start, "rev-parse", "--show-toplevel"], {
6769
- encoding: "utf8",
6770
- stdio: ["ignore", "pipe", "ignore"]
6771
- }).trim();
6772
- removeLegacyPrepareCommitMsgLeg(root);
6773
- const path = resolveHookPath(root, HOOK_NAME);
6774
- const current = existsSync10(path) ? readFileSync9(path, "utf8") : "";
6775
- let next;
6776
- if (current && !isShellHook(current)) {
6777
- const incumbentPath = nextIncumbentPath(path);
6778
- renameSync(path, incumbentPath);
6779
- chmodSync(incumbentPath, 493);
6780
- next = chainWrapper(basename2(incumbentPath));
6781
- } else {
6782
- next = insertManagedBlock(current);
6783
- }
6784
- if (next !== current) {
6785
- mkdirSync11(dirname10(path), { recursive: true });
6786
- writeFileSync10(path, next.endsWith("\n") ? next : `${next}
6787
- `, "utf8");
6788
- }
6789
- chmodSync(path, 493);
6790
- return path;
7454
+ current = readFileSync10(path, "utf8");
7455
+ } catch {
7456
+ }
7457
+ const withoutOwnedBlock = removeOwnedExcludeBlock(current);
7458
+ const patterns = Object.values(lock.entries).filter((entry) => entry.target === "claude-code").map((entry) => relative(cwd, skillPath(entry.skillsRoot, entry.name))).filter(
7459
+ (entryPath) => entryPath.length > 0 && !isAbsolute2(entryPath) && entryPath !== ".." && !entryPath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)
7460
+ ).map((entryPath) => `/${entryPath.replaceAll("\\", "/")}`).sort();
7461
+ const block = patterns.length === 0 ? "" : [GIT_EXCLUDE_BEGIN, ...patterns, GIT_EXCLUDE_END, ""].join("\n");
7462
+ const separator = withoutOwnedBlock.length > 0 && !withoutOwnedBlock.endsWith("\n") && block ? "\n" : "";
7463
+ const updated = withoutOwnedBlock + separator + block;
7464
+ if (updated === current) return;
7465
+ mkdirSync12(dirname11(path), { recursive: true });
7466
+ writeAtomic(path, updated);
7467
+ }
7468
+ function gitExcludePath(cwd) {
7469
+ const dotGit = join13(cwd, ".git");
7470
+ try {
7471
+ if (statSync2(dotGit).isDirectory()) return join13(dotGit, "info", "exclude");
7472
+ const pointer = readFileSync10(dotGit, "utf8").trim();
7473
+ if (!pointer.startsWith("gitdir:")) return void 0;
7474
+ const raw = pointer.slice("gitdir:".length).trim();
7475
+ const gitDir = isAbsolute2(raw) ? raw : resolve5(cwd, raw);
7476
+ return join13(gitDir, "info", "exclude");
6791
7477
  } catch {
6792
7478
  return void 0;
6793
7479
  }
6794
7480
  }
7481
+ function removeOwnedExcludeBlock(body) {
7482
+ const start = body.indexOf(GIT_EXCLUDE_BEGIN);
7483
+ if (start < 0) return body;
7484
+ const endMarker = body.indexOf(GIT_EXCLUDE_END, start);
7485
+ if (endMarker < 0) return body;
7486
+ let end = endMarker + GIT_EXCLUDE_END.length;
7487
+ if (body.slice(end, end + 2) === "\r\n") end += 2;
7488
+ else if (body[end] === "\n") end += 1;
7489
+ return body.slice(0, start) + body.slice(end);
7490
+ }
7491
+ function removeDirectoryIfEmpty(path) {
7492
+ try {
7493
+ if (readdirSync2(path).length === 0) rmdirSync(path);
7494
+ } catch {
7495
+ }
7496
+ }
6795
7497
 
6796
7498
  // src/commands/session-context.ts
6797
- import { randomUUID } from "crypto";
6798
- import { mkdirSync as mkdirSync12, renameSync as renameSync2, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "fs";
6799
- import { dirname as dirname11, join as join13 } from "path";
6800
- var DYNAMIC_AGENT_CONTEXT_FILE = join13(".sechroom", "CLAUDE.md");
7499
+ var DYNAMIC_AGENT_CONTEXT_FILE = join14(".sechroom", "CLAUDE.md");
6801
7500
  function checkoutRoot(start) {
6802
7501
  const semPath = resolveSemPathForRead(start);
6803
- return semPath ? dirname11(dirname11(semPath)) : start;
7502
+ return semPath ? dirname12(dirname12(semPath)) : start;
6804
7503
  }
6805
7504
  function renderSessionContext(result, lane) {
6806
7505
  if (result.status === "hold") {
@@ -6832,22 +7531,36 @@ function renderSessionContext(result, lane) {
6832
7531
  }
6833
7532
  return lines.join("\n");
6834
7533
  }
6835
- function writeSessionContext(start, lane, result) {
6836
- const path = join13(checkoutRoot(start), DYNAMIC_AGENT_CONTEXT_FILE);
7534
+ function writeSessionContext(start, lane, result, options = {}) {
7535
+ const root = checkoutRoot(start);
7536
+ const path = join14(root, DYNAMIC_AGENT_CONTEXT_FILE);
7537
+ const skills = materialiseCompiledSkillCompositions(
7538
+ root,
7539
+ result.status === "hold" ? {
7540
+ skills: [],
7541
+ holds: [{
7542
+ code: "session-context-hold",
7543
+ reason: result.reason?.trim() || "Session context resolution held without a reason.",
7544
+ skillName: null
7545
+ }],
7546
+ preserveExisting: true
7547
+ } : { skills: result.skills },
7548
+ options
7549
+ );
6837
7550
  const context = renderSessionContext(result, lane);
6838
- mkdirSync12(dirname11(path), { recursive: true });
6839
- const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
7551
+ mkdirSync13(dirname12(path), { recursive: true });
7552
+ const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
6840
7553
  try {
6841
- writeFileSync11(temporaryPath, context.endsWith("\n") ? context : `${context}
7554
+ writeFileSync12(temporaryPath, context.endsWith("\n") ? context : `${context}
6842
7555
  `, "utf8");
6843
- renameSync2(temporaryPath, path);
7556
+ renameSync3(temporaryPath, path);
6844
7557
  } catch (error) {
6845
- rmSync5(temporaryPath, { force: true });
7558
+ rmSync6(temporaryPath, { force: true });
6846
7559
  throw error;
6847
7560
  }
6848
- return { status: result.status, path, context };
7561
+ return { status: result.status, path, context, skills };
6849
7562
  }
6850
- async function materializeSessionContext(client, cfg, lane, start) {
7563
+ async function materializeSessionContext(client, cfg, lane, start, options = {}) {
6851
7564
  const { data, error, response } = await client.POST("/session-context/resolve", {
6852
7565
  body: {
6853
7566
  laneId: lane,
@@ -6858,7 +7571,15 @@ async function materializeSessionContext(client, cfg, lane, start) {
6858
7571
  const detail = error ? JSON.stringify(error) : `HTTP ${response?.status ?? "unknown"}`;
6859
7572
  throw new Error(`session context resolution failed: ${detail}`);
6860
7573
  }
6861
- return writeSessionContext(start, lane, data);
7574
+ assertSessionContextResult(data);
7575
+ return writeSessionContext(start, lane, data, options);
7576
+ }
7577
+ function assertSessionContextResult(value) {
7578
+ if (value === null || typeof value !== "object" || !["ready", "hold"].includes(String(value.status)) || !Array.isArray(value.members) || !Array.isArray(value.skills)) {
7579
+ throw new Error(
7580
+ "session context resolution returned an invalid payload; preserving existing compiled skills"
7581
+ );
7582
+ }
6862
7583
  }
6863
7584
 
6864
7585
  // src/commands/hook.ts
@@ -6885,13 +7606,13 @@ function resolveLane(flagLane, cwd) {
6885
7606
  if (!base) return void 0;
6886
7607
  return applyWorktreeLaneSuffix(base, start);
6887
7608
  }
6888
- var INTENT_FILE = join14(".sechroom", "continuity.json");
7609
+ var INTENT_FILE = join15(".sechroom", "continuity.json");
6889
7610
  function resolveIntentPath(start) {
6890
7611
  let dir = start;
6891
7612
  for (; ; ) {
6892
- const candidate = join14(dir, INTENT_FILE);
6893
- if (existsSync11(candidate)) return candidate;
6894
- const parent = dirname12(dir);
7613
+ const candidate = join15(dir, INTENT_FILE);
7614
+ if (existsSync12(candidate)) return candidate;
7615
+ const parent = dirname13(dir);
6895
7616
  if (parent === dir) return void 0;
6896
7617
  dir = parent;
6897
7618
  }
@@ -6900,7 +7621,7 @@ function readIntent(start) {
6900
7621
  const path = resolveIntentPath(start);
6901
7622
  if (!path) return void 0;
6902
7623
  try {
6903
- return JSON.parse(readFileSync10(path, "utf8"));
7624
+ return JSON.parse(readFileSync11(path, "utf8"));
6904
7625
  } catch {
6905
7626
  return void 0;
6906
7627
  }
@@ -6942,14 +7663,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
6942
7663
  }
6943
7664
  function ledgerPath(start) {
6944
7665
  const intent = resolveIntentPath(start);
6945
- const dir = intent ? dirname12(intent) : join14(start, ".sechroom");
6946
- return join14(dir, ".checkpoint-state.json");
7666
+ const dir = intent ? dirname13(intent) : join15(start, ".sechroom");
7667
+ return join15(dir, ".checkpoint-state.json");
6947
7668
  }
6948
7669
  function readLedger(start) {
6949
7670
  try {
6950
7671
  const p = ledgerPath(start);
6951
- if (!existsSync11(p)) return {};
6952
- return JSON.parse(readFileSync10(p, "utf8"));
7672
+ if (!existsSync12(p)) return {};
7673
+ return JSON.parse(readFileSync11(p, "utf8"));
6953
7674
  } catch {
6954
7675
  return {};
6955
7676
  }
@@ -6968,7 +7689,7 @@ function intentHash(i) {
6968
7689
  artifacts: i.artifacts ?? [],
6969
7690
  confidence: i.confidence ?? null
6970
7691
  });
6971
- return createHash3("sha256").update(canonical, "utf8").digest("hex");
7692
+ return createHash4("sha256").update(canonical, "utf8").digest("hex");
6972
7693
  }
6973
7694
  function recentlyCheckpointed(start, minutes) {
6974
7695
  const { lastEpochMs } = readLedger(start);
@@ -6980,7 +7701,7 @@ function unchangedSinceLastPush(start, intent) {
6980
7701
  const path = resolveIntentPath(start);
6981
7702
  if (path && ledger.lastMtimeMs != null) {
6982
7703
  try {
6983
- if (statSync2(path).mtimeMs <= ledger.lastMtimeMs) return true;
7704
+ if (statSync3(path).mtimeMs <= ledger.lastMtimeMs) return true;
6984
7705
  } catch {
6985
7706
  }
6986
7707
  }
@@ -6992,17 +7713,17 @@ function recordPush(start, intent) {
6992
7713
  const path = resolveIntentPath(start);
6993
7714
  let mtimeMs;
6994
7715
  try {
6995
- if (path) mtimeMs = statSync2(path).mtimeMs;
7716
+ if (path) mtimeMs = statSync3(path).mtimeMs;
6996
7717
  } catch {
6997
7718
  mtimeMs = void 0;
6998
7719
  }
6999
- mkdirSync13(dirname12(p), { recursive: true });
7720
+ mkdirSync14(dirname13(p), { recursive: true });
7000
7721
  const ledger = {
7001
7722
  lastEpochMs: Date.now(),
7002
7723
  lastMtimeMs: mtimeMs,
7003
7724
  lastHash: intentHash(intent)
7004
7725
  };
7005
- writeFileSync12(p, JSON.stringify(ledger) + "\n");
7726
+ writeFileSync13(p, JSON.stringify(ledger) + "\n");
7006
7727
  } catch {
7007
7728
  }
7008
7729
  }
@@ -7070,18 +7791,34 @@ Fail-soft: failures exit 0 and never block; session-context refresh failures ren
7070
7791
  installLaneCommitHook(cwd);
7071
7792
  const semPath = resolveSemPathForRead(cwd);
7072
7793
  if (semPath) ensureContinuityScaffold(semPath);
7073
- let dynamic = writeSessionContext(cwd, lane, {
7074
- status: "hold",
7075
- workspaceId: null,
7076
- reason: "SESSION_CONTEXT_UNAVAILABLE: the session-start hook could not refresh the pinned context (transport or authentication failure).",
7077
- members: []
7078
- });
7794
+ const globals = cmd.optsWithGlobals();
7795
+ const materialisationOptions = {
7796
+ codexHomeOverride: globals.codexHome
7797
+ };
7798
+ let dynamic = writeSessionContext(
7799
+ cwd,
7800
+ lane,
7801
+ {
7802
+ status: "hold",
7803
+ workspaceId: null,
7804
+ reason: "SESSION_CONTEXT_UNAVAILABLE: the session-start hook could not refresh the pinned context (transport or authentication failure).",
7805
+ members: [],
7806
+ skills: []
7807
+ },
7808
+ materialisationOptions
7809
+ );
7079
7810
  let continuity = null;
7080
7811
  try {
7081
- const cfg = resolveConfig(cmd.optsWithGlobals(), cwd);
7812
+ const cfg = resolveConfig(globals, cwd);
7082
7813
  const client = await makeClient(cfg);
7083
7814
  try {
7084
- dynamic = await materializeSessionContext(client, cfg, lane, cwd);
7815
+ dynamic = await materializeSessionContext(
7816
+ client,
7817
+ cfg,
7818
+ lane,
7819
+ cwd,
7820
+ materialisationOptions
7821
+ );
7085
7822
  } catch {
7086
7823
  }
7087
7824
  try {
@@ -7285,10 +8022,10 @@ Examples:
7285
8022
  const client = await makeClient(cfg);
7286
8023
  return client.POST("/continuity/snapshots", { body });
7287
8024
  });
7288
- const path = resolveIntentPath(cwd) ?? join15(cwd, INTENT_FILE);
8025
+ const path = resolveIntentPath(cwd) ?? join16(cwd, INTENT_FILE);
7289
8026
  const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
7290
- mkdirSync14(dirname13(path), { recursive: true });
7291
- writeFileSync13(path, JSON.stringify(fileBody, null, 2) + "\n");
8027
+ mkdirSync15(dirname14(path), { recursive: true });
8028
+ writeFileSync14(path, JSON.stringify(fileBody, null, 2) + "\n");
7292
8029
  recordPush(cwd, merged);
7293
8030
  if (json) {
7294
8031
  emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
@@ -7302,7 +8039,7 @@ Examples:
7302
8039
  }
7303
8040
 
7304
8041
  // src/commands/close.ts
7305
- import { readFileSync as readFileSync11 } from "fs";
8042
+ import { readFileSync as readFileSync12 } from "fs";
7306
8043
  var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
7307
8044
  function registerClose(program2) {
7308
8045
  program2.command("close").description(
@@ -7343,7 +8080,7 @@ Examples:
7343
8080
  );
7344
8081
  let bodyText;
7345
8082
  try {
7346
- bodyText = opts.file ? readFileSync11(opts.file, "utf8") : readFileSync11(0, "utf8");
8083
+ bodyText = opts.file ? readFileSync12(opts.file, "utf8") : readFileSync12(0, "utf8");
7347
8084
  } catch {
7348
8085
  fail(
7349
8086
  opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
@@ -7475,7 +8212,9 @@ Examples:
7475
8212
 
7476
8213
  // src/commands/continuity.ts
7477
8214
  function registerContinuity(program2) {
7478
- const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
8215
+ const continuity = program2.command("continuity").description(
8216
+ "Continuity snapshots: checkpoint, resume, and recover displaced work"
8217
+ );
7479
8218
  continuity.addHelpText(
7480
8219
  "after",
7481
8220
  `
@@ -7488,7 +8227,13 @@ Examples:
7488
8227
  $ sechroom continuity snapshot-get csn_XXXX --json
7489
8228
  $ sechroom continuity resume-me --max-artifacts 20
7490
8229
  $ sechroom continuity changed-since --since 2026-06-01T00:00:00Z
7491
- $ sechroom continuity grant csn_XXXX --grantee usr_XXXX`
8230
+ $ sechroom continuity grant csn_XXXX --grantee usr_XXXX
8231
+
8232
+ Displaced snapshot recovery surfaces:
8233
+ CLI discovery: sechroom continuity snapshots --lane <laneId> --json
8234
+ CLI inspection: sechroom continuity snapshot-get <snapshotId> --json
8235
+ MCP adoption: continuity_adopt_snapshot
8236
+ Canonical long form: https://github.com/OcdLimited/sechroom/blob/main/frontend/apps/cli/DEVELOPMENT.md#recover-a-displaced-continuity-snapshot`
7492
8237
  );
7493
8238
  continuity.command("snapshot-create").description("Create a continuity snapshot (POST /continuity/snapshots)").requiredOption("--lane <laneId>", "Lane id (e.g. claude-code-chris)").requiredOption("--scope <scope>", "Snapshot scope (e.g. loop-spec)").requiredOption("--objective <text>", "Current objective").requiredOption("--state <text>", "Current state").requiredOption("--last-action <text>", "Last meaningful action").requiredOption("--next-action <text>", "Next intended action").requiredOption("--resume-instruction <text>", "Resume instruction").option("--constraint <text...>", "Active constraints (repeatable)").option("--question <text...>", "Open questions (repeatable)").option("--surface-marker <text...>", "Surface markers (repeatable)").option("--artifact <id...>", "Relevant artifact ids (repeatable)").option("--confidence <n>", "Confidence 0..1").action(async (opts, cmd) => {
7494
8239
  const cfg = resolveConfig(cmd.optsWithGlobals());
@@ -7513,15 +8258,30 @@ Examples:
7513
8258
  });
7514
8259
  emitAction(`created snapshot ${style.bold(data.snapshotId)}`, data, cmd.optsWithGlobals().json);
7515
8260
  });
7516
- continuity.command("snapshot-get <id>").description("Fetch a snapshot by id (GET /continuity/snapshots/{id})").action(async (id, _opts, cmd) => {
8261
+ continuity.command("snapshot-get <id>").description(
8262
+ "Fetch one of the caller's snapshots by id; use --all for the tenant-wide admin door"
8263
+ ).option(
8264
+ "--all",
8265
+ "Use the tenant-wide Owner/Manager endpoint (GET /continuity/snapshots/{id})",
8266
+ false
8267
+ ).action(async (id, opts, cmd) => {
7517
8268
  const cfg = resolveConfig(cmd.optsWithGlobals());
7518
8269
  const data = await runApi("Fetching snapshot", async () => {
7519
8270
  const client = await makeClient(cfg);
7520
- return client.GET("/continuity/snapshots/{id}", { params: { path: { id } } });
8271
+ const result = await client.GET(snapshotGetPath(Boolean(opts.all)), {
8272
+ params: { path: { id } }
8273
+ });
8274
+ const hint = snapshotGetNotFoundHint(
8275
+ Boolean(opts.all),
8276
+ result.response.status
8277
+ );
8278
+ return hint ? { ...result, error: hint } : result;
7521
8279
  });
7522
8280
  emit(data, cmd.optsWithGlobals().json);
7523
8281
  });
7524
- continuity.command("snapshots").description("List the caller's own snapshots (GET /me/continuity/snapshots)").option("--scope <scope>", "Filter by scope").option("--lane <laneId>", "Filter by lane id").action(async (opts, cmd) => {
8282
+ continuity.command("snapshots").description(
8283
+ "List the caller's own snapshots, including displaced recovery candidates (GET /me/continuity/snapshots)"
8284
+ ).option("--scope <scope>", "Filter by scope").option("--lane <laneId>", "Filter by lane id").action(async (opts, cmd) => {
7525
8285
  const cfg = resolveConfig(cmd.optsWithGlobals());
7526
8286
  const data = await runApi("Listing snapshots", async () => {
7527
8287
  const client = await makeClient(cfg);
@@ -7632,6 +8392,12 @@ Examples:
7632
8392
  );
7633
8393
  });
7634
8394
  }
8395
+ function snapshotGetPath(includeAll) {
8396
+ return includeAll ? "/continuity/snapshots/{id}" : "/me/continuity/snapshots/{id}";
8397
+ }
8398
+ function snapshotGetNotFoundHint(includeAll, status) {
8399
+ return !includeAll && status === 404 ? "Snapshot not found among your own snapshots. Owners and Managers can retry with --all to use the tenant-wide door." : void 0;
8400
+ }
7635
8401
 
7636
8402
  // src/commands/work-plan.ts
7637
8403
  import { readFile as readFile2 } from "fs/promises";
@@ -8033,13 +8799,13 @@ Examples:
8033
8799
  }
8034
8800
 
8035
8801
  // src/commands/herdr.ts
8036
- import { readFileSync as readFileSync12 } from "fs";
8802
+ import { readFileSync as readFileSync13 } from "fs";
8037
8803
  import { basename as basename3 } from "path";
8038
8804
 
8039
8805
  // src/herdr/client.ts
8040
8806
  import { createConnection as createConnection2 } from "net";
8041
8807
  import { homedir as homedir5 } from "os";
8042
- import { join as join16 } from "path";
8808
+ import { join as join17 } from "path";
8043
8809
  var DEFAULT_HERDR_SOCKET_RELATIVE = ".config/herdr/herdr.sock";
8044
8810
  var HerdrUnreachableError = class extends Error {
8045
8811
  constructor(socketPath, reason) {
@@ -8064,7 +8830,7 @@ function resolveSocketPath(flag, env = process.env, home = homedir5()) {
8064
8830
  if (fromFlag) return fromFlag;
8065
8831
  const fromEnv = env.HERDR_SOCKET?.trim();
8066
8832
  if (fromEnv) return fromEnv;
8067
- return join16(home, DEFAULT_HERDR_SOCKET_RELATIVE);
8833
+ return join17(home, DEFAULT_HERDR_SOCKET_RELATIVE);
8068
8834
  }
8069
8835
  function expandTarget(target) {
8070
8836
  const trimmed = target.trim();
@@ -8287,19 +9053,19 @@ function herdrStream(socketPath, subscriptions, onEvent, options = {}) {
8287
9053
  });
8288
9054
  }
8289
9055
  function abortableSleep(milliseconds, signal) {
8290
- return new Promise((resolve8) => {
9056
+ return new Promise((resolve9) => {
8291
9057
  if (signal?.aborted) {
8292
- resolve8();
9058
+ resolve9();
8293
9059
  return;
8294
9060
  }
8295
9061
  let timer;
8296
9062
  const onAbort = () => {
8297
9063
  clearTimeout(timer);
8298
- resolve8();
9064
+ resolve9();
8299
9065
  };
8300
9066
  timer = setTimeout(() => {
8301
9067
  signal?.removeEventListener("abort", onAbort);
8302
- resolve8();
9068
+ resolve9();
8303
9069
  }, milliseconds);
8304
9070
  signal?.addEventListener("abort", onAbort, { once: true });
8305
9071
  });
@@ -8322,6 +9088,7 @@ var HERDR_READ_SOURCES = [
8322
9088
  ];
8323
9089
  var DEFAULT_READ_LINES = 40;
8324
9090
  var DEFAULT_READ_SOURCE = "recent";
9091
+ var DEFAULT_WAIT_STATUSES = ["idle", "done"];
8325
9092
  var TITLE_WIDTH = 48;
8326
9093
  var STREAM_HEALTHY_MS = 3e4;
8327
9094
  var WAIT_FOR_PANES_MAX_MS = 5e3;
@@ -8402,7 +9169,7 @@ function parseSource(value, fallback = DEFAULT_READ_SOURCE) {
8402
9169
  }
8403
9170
  return match;
8404
9171
  }
8405
- function resolveSendText(textArgs, useStdin, readStdin5 = () => readFileSync12(0, "utf8")) {
9172
+ function resolveSendText(textArgs, useStdin, readStdin5 = () => readFileSync13(0, "utf8")) {
8406
9173
  if (useStdin) {
8407
9174
  if (textArgs.length > 0) {
8408
9175
  throw new Error(
@@ -8550,6 +9317,176 @@ async function runSend(ports, target, text2, json) {
8550
9317
  json
8551
9318
  );
8552
9319
  }
9320
+ function parseWaitStatuses(values) {
9321
+ const statuses = (values ?? [...DEFAULT_WAIT_STATUSES]).flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()).filter((value) => value.length > 0);
9322
+ if (statuses.length === 0) {
9323
+ throw new Error("--until expects at least one status");
9324
+ }
9325
+ return new Set(statuses);
9326
+ }
9327
+ function parseWaitTimeout(value) {
9328
+ if (value === void 0) return void 0;
9329
+ const seconds = Number(value);
9330
+ if (!Number.isFinite(seconds) || seconds <= 0) {
9331
+ throw new Error(
9332
+ `--timeout expects a positive number of seconds (got '${value}')`
9333
+ );
9334
+ }
9335
+ return seconds;
9336
+ }
9337
+ var HerdrWaitTimeoutError = class extends Error {
9338
+ constructor(timeoutSeconds, targets, statuses) {
9339
+ super(
9340
+ `herdr wait timed out after ${timeoutSeconds}s waiting for ${targets.join(", ")} to reach ${statuses.join(", ")}`
9341
+ );
9342
+ this.timeoutSeconds = timeoutSeconds;
9343
+ this.targets = targets;
9344
+ this.statuses = statuses;
9345
+ this.name = "HerdrWaitTimeoutError";
9346
+ }
9347
+ timeoutSeconds;
9348
+ targets;
9349
+ statuses;
9350
+ };
9351
+ function formatWaitRecord(record) {
9352
+ return `${record.pane_id} ${record.cwd?.trim() || "?"} [${agentLabel(record, record.pane_id)}]: ${displayStatus(record.agent_status)}`;
9353
+ }
9354
+ async function runWait(ports, targets, options) {
9355
+ const targetIds = [...new Set(targets.map(expandTarget))];
9356
+ const statuses = parseWaitStatuses(options.statuses);
9357
+ const timeoutSeconds = options.timeoutSeconds;
9358
+ const write = options.write ?? ((line) => process.stdout.write(`${line}
9359
+ `));
9360
+ const warn2 = options.warn ?? ((line) => process.stderr.write(`${line}
9361
+ `));
9362
+ const now = options.now ?? Date.now;
9363
+ const controller = new AbortController();
9364
+ let timedOut = false;
9365
+ let completed = false;
9366
+ const onExternalAbort = () => controller.abort();
9367
+ if (options.signal?.aborted) return;
9368
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
9369
+ const timeout2 = timeoutSeconds === void 0 ? void 0 : setTimeout(() => {
9370
+ timedOut = true;
9371
+ controller.abort();
9372
+ }, timeoutSeconds * 1e3);
9373
+ const aborted = /* @__PURE__ */ Symbol("aborted");
9374
+ const abortPromise = new Promise((resolve9) => {
9375
+ controller.signal.addEventListener("abort", () => resolve9(aborted), {
9376
+ once: true
9377
+ });
9378
+ });
9379
+ const raceAbort = (operation) => Promise.race([operation, abortPromise]);
9380
+ const throwIfTimedOut = () => {
9381
+ if (timedOut && timeoutSeconds !== void 0) {
9382
+ throw new HerdrWaitTimeoutError(timeoutSeconds, targetIds, [...statuses]);
9383
+ }
9384
+ };
9385
+ const remaining = new Set(targetIds);
9386
+ let seed = /* @__PURE__ */ new Map();
9387
+ let attempt = 0;
9388
+ const settle = (agent, paneId, status) => {
9389
+ if (!remaining.has(paneId)) return;
9390
+ const record = {
9391
+ agent: agent?.agent ?? "?",
9392
+ ...agent,
9393
+ pane_id: paneId,
9394
+ agent_status: status
9395
+ };
9396
+ write(options.json ? JSON.stringify(record) : formatWaitRecord(record));
9397
+ remaining.delete(paneId);
9398
+ if (!options.all || remaining.size === 0) {
9399
+ completed = true;
9400
+ controller.abort();
9401
+ }
9402
+ };
9403
+ try {
9404
+ while (!controller.signal.aborted) {
9405
+ try {
9406
+ const listed = await raceAbort(
9407
+ ports.request("agent.list", {})
9408
+ );
9409
+ if (listed === aborted) break;
9410
+ const current = listed.agents ?? [];
9411
+ const fresh = indexAgents(current);
9412
+ for (const paneId of [...remaining]) {
9413
+ const currentAgent = fresh.get(paneId);
9414
+ if (currentAgent === void 0) {
9415
+ settle(seed.get(paneId), paneId, "exited");
9416
+ continue;
9417
+ }
9418
+ const status = currentAgent.agent_status?.trim().toLowerCase();
9419
+ if (status !== void 0 && statuses.has(status)) {
9420
+ settle(currentAgent, paneId, currentAgent.agent_status ?? status);
9421
+ }
9422
+ }
9423
+ seed = fresh;
9424
+ if (completed || remaining.size === 0) return;
9425
+ const subscriptions = buildWatchSubscriptions(
9426
+ current.filter((agent) => remaining.has(agent.pane_id))
9427
+ );
9428
+ let carriedTraffic = false;
9429
+ let relistNeeded = false;
9430
+ const startedAt = now();
9431
+ const cycle = new AbortController();
9432
+ const stopCycle = () => cycle.abort();
9433
+ controller.signal.addEventListener("abort", stopCycle, { once: true });
9434
+ try {
9435
+ const streamed = await raceAbort(
9436
+ ports.stream(
9437
+ subscriptions,
9438
+ (raw) => {
9439
+ carriedTraffic = true;
9440
+ const event = normalizeWatchEvent(raw);
9441
+ if (event === null || needsRelistFallback(event)) {
9442
+ relistNeeded = true;
9443
+ cycle.abort();
9444
+ return;
9445
+ }
9446
+ if (!remaining.has(event.paneId)) return;
9447
+ const known = seed.get(event.paneId);
9448
+ if (event.type === "pane.exited") {
9449
+ settle(known, event.paneId, "exited");
9450
+ return;
9451
+ }
9452
+ const status = event.to?.trim().toLowerCase();
9453
+ if (known && event.to) known.agent_status = event.to;
9454
+ if (status !== void 0 && statuses.has(status)) {
9455
+ settle(known, event.paneId, event.to ?? status);
9456
+ }
9457
+ },
9458
+ {
9459
+ signal: cycle.signal,
9460
+ onWarning: (message) => warn2(`herdr wait: ${message}`)
9461
+ }
9462
+ )
9463
+ );
9464
+ if (streamed === aborted) break;
9465
+ } finally {
9466
+ controller.signal.removeEventListener("abort", stopCycle);
9467
+ }
9468
+ if (completed) return;
9469
+ if (relistNeeded) continue;
9470
+ if (carriedTraffic || now() - startedAt >= STREAM_HEALTHY_MS) {
9471
+ attempt = 0;
9472
+ }
9473
+ } catch (error) {
9474
+ throwIfTimedOut();
9475
+ if (controller.signal.aborted) return;
9476
+ warn2(`herdr wait: ${formatFailureMessage(error)}`);
9477
+ }
9478
+ if (controller.signal.aborted) break;
9479
+ const delay = watchBackoffMs(attempt);
9480
+ attempt += 1;
9481
+ const slept = await raceAbort(ports.sleep(delay, controller.signal));
9482
+ if (slept === aborted) break;
9483
+ }
9484
+ throwIfTimedOut();
9485
+ } finally {
9486
+ if (timeout2 !== void 0) clearTimeout(timeout2);
9487
+ options.signal?.removeEventListener("abort", onExternalAbort);
9488
+ }
9489
+ }
8553
9490
  async function runWatch(ports, options) {
8554
9491
  const write = options.write ?? ((line) => process.stdout.write(`${line}
8555
9492
  `));
@@ -8688,7 +9625,7 @@ function exitCleanlyOnBrokenPipe(stream, exit = (code) => process.exit(code), re
8688
9625
  }
8689
9626
  function registerHerdr(program2) {
8690
9627
  const herdr = program2.command("herdr").description(
8691
- "Drive local Herdr agents (herdr.dev) over its Unix-socket API \u2014 list, read, send, watch"
9628
+ "Drive local Herdr agents (herdr.dev) over its Unix-socket API \u2014 list, read, send, wait, watch"
8692
9629
  );
8693
9630
  herdr.addHelpText(
8694
9631
  "after",
@@ -8701,6 +9638,10 @@ Examples:
8701
9638
  $ sechroom herdr read w6:p1 --source visible only what's on screen
8702
9639
  $ sechroom herdr send w6 "run the tests and report back"
8703
9640
  $ cat brief.md | sechroom herdr send w6:p1 --stdin long dispatch from stdin
9641
+ $ sechroom herdr wait w3 w6 return when either pane is idle or done
9642
+ $ sechroom herdr wait w3 w6 --all wait until both panes have settled
9643
+ $ sechroom herdr wait w6 --until blocked --timeout 300
9644
+ $ sechroom herdr wait w6 --json machine-readable settling pane
8704
9645
  $ sechroom herdr watch live status transitions; Ctrl-C to stop
8705
9646
  $ sechroom herdr watch --json | jq . one JSON object per event
8706
9647
 
@@ -8731,6 +9672,40 @@ AGPL \u2014 no Herdr code is vendored here.`
8731
9672
  fail(error);
8732
9673
  }
8733
9674
  });
9675
+ withSocket(
9676
+ herdr.command("wait <targets...>").description(
9677
+ "Block until any target settles (idle or done by default); --all waits for every target"
9678
+ ).option("--all", "Wait for every target instead of the first to settle").option(
9679
+ "--until <status...>",
9680
+ `Statuses that count as settled (default ${DEFAULT_WAIT_STATUSES.join(", ")})`
9681
+ ).option("--timeout <seconds>", "Stop waiting after this many seconds")
9682
+ ).action(async (targets, opts, cmd) => {
9683
+ exitCleanlyOnBrokenPipe(process.stdout);
9684
+ const controller = new AbortController();
9685
+ const onSignal = () => controller.abort();
9686
+ process.once("SIGINT", onSignal);
9687
+ process.once("SIGTERM", onSignal);
9688
+ try {
9689
+ await runWait(portsFor(opts), targets, {
9690
+ all: Boolean(opts.all),
9691
+ json: Boolean(cmd.optsWithGlobals().json),
9692
+ statuses: opts.until,
9693
+ timeoutSeconds: parseWaitTimeout(opts.timeout),
9694
+ signal: controller.signal
9695
+ });
9696
+ } catch (error) {
9697
+ if (error instanceof HerdrWaitTimeoutError) {
9698
+ process.stderr.write(`error: ${error.message}
9699
+ `);
9700
+ process.exitCode = 2;
9701
+ return;
9702
+ }
9703
+ fail(error);
9704
+ } finally {
9705
+ process.off("SIGINT", onSignal);
9706
+ process.off("SIGTERM", onSignal);
9707
+ }
9708
+ });
8734
9709
  withSocket(
8735
9710
  herdr.command("read <target>").description(
8736
9711
  `Print a pane's text (default ${DEFAULT_READ_LINES} lines, source ${DEFAULT_READ_SOURCE})`
@@ -8873,11 +9848,11 @@ Examples:
8873
9848
  }
8874
9849
 
8875
9850
  // src/commands/memory.ts
8876
- import { readFileSync as readFileSync14 } from "fs";
9851
+ import { readFileSync as readFileSync15 } from "fs";
8877
9852
 
8878
9853
  // src/commands/memory-import.ts
8879
- import { readdirSync as readdirSync2, readFileSync as readFileSync13, realpathSync, statSync as statSync3 } from "fs";
8880
- import { basename as basename4, join as join17, resolve as resolve5 } from "path";
9854
+ import { readdirSync as readdirSync3, readFileSync as readFileSync14, realpathSync, statSync as statSync4 } from "fs";
9855
+ import { basename as basename4, join as join18, resolve as resolve6 } from "path";
8881
9856
  var MARKDOWN_RE = /\.(md|markdown)$/i;
8882
9857
  function isMarkdownPath(path) {
8883
9858
  return MARKDOWN_RE.test(path);
@@ -8897,25 +9872,25 @@ function collectImportFiles(inputs, opts = {}) {
8897
9872
  try {
8898
9873
  key = realpathSync(path);
8899
9874
  } catch {
8900
- key = resolve5(path);
9875
+ key = resolve6(path);
8901
9876
  }
8902
9877
  if (seen.has(key)) return;
8903
9878
  seen.add(key);
8904
9879
  files.push(path);
8905
9880
  };
8906
9881
  const walk = (dir) => {
8907
- const entries = readdirSync2(dir, { withFileTypes: true }).sort(
9882
+ const entries = readdirSync3(dir, { withFileTypes: true }).sort(
8908
9883
  (a, b) => a.name.localeCompare(b.name)
8909
9884
  );
8910
9885
  for (const entry of entries) {
8911
9886
  if (entry.name.startsWith(".")) continue;
8912
- const child = join17(dir, entry.name);
9887
+ const child = join18(dir, entry.name);
8913
9888
  let isDirectory = entry.isDirectory();
8914
9889
  let isFile = entry.isFile();
8915
9890
  if (entry.isSymbolicLink()) {
8916
9891
  let target;
8917
9892
  try {
8918
- target = statSync3(child);
9893
+ target = statSync4(child);
8919
9894
  } catch {
8920
9895
  skipped.push({ path: child, reason: "broken symlink" });
8921
9896
  continue;
@@ -8953,7 +9928,7 @@ function collectImportFiles(inputs, opts = {}) {
8953
9928
  for (const input of inputs) {
8954
9929
  let isDirectory;
8955
9930
  try {
8956
- isDirectory = statSync3(input).isDirectory();
9931
+ isDirectory = statSync4(input).isDirectory();
8957
9932
  } catch {
8958
9933
  missing.push(input);
8959
9934
  continue;
@@ -8969,7 +9944,7 @@ function buildImportPlan(collected) {
8969
9944
  for (const path of collected.files) {
8970
9945
  let text2;
8971
9946
  try {
8972
- text2 = readFileSync13(path, "utf8");
9947
+ text2 = readFileSync14(path, "utf8");
8973
9948
  } catch (error) {
8974
9949
  throw new Error(
8975
9950
  `couldn't read ${path}: ${error instanceof Error ? error.message : String(error)}`
@@ -9070,7 +10045,7 @@ function resolveCreateBody(textOpt, fileOpt) {
9070
10045
  }
9071
10046
  if (textOpt != null) return { text: textOpt, defaultTitle: null };
9072
10047
  const fromStdin = fileOpt === "-";
9073
- const text2 = fromStdin ? readFileSync14(0, "utf8") : readFileSync14(String(fileOpt), "utf8");
10048
+ const text2 = fromStdin ? readFileSync15(0, "utf8") : readFileSync15(String(fileOpt), "utf8");
9074
10049
  if (text2.trim().length === 0) {
9075
10050
  fail(fromStdin ? "Stdin was empty." : `File is empty: ${fileOpt}`);
9076
10051
  }
@@ -9689,16 +10664,17 @@ ${plan.rows.map(
9689
10664
  }
9690
10665
 
9691
10666
  // src/setup/apply.ts
9692
- import { createHash as createHash4 } from "crypto";
9693
- import { mkdirSync as mkdirSync15, readFileSync as readFileSync15, writeFileSync as writeFileSync14, existsSync as existsSync12 } from "fs";
9694
- import { dirname as dirname14 } from "path";
10667
+ import { createHash as createHash5 } from "crypto";
10668
+ import { mkdirSync as mkdirSync16, readFileSync as readFileSync16, writeFileSync as writeFileSync15, existsSync as existsSync13 } from "fs";
10669
+ import { dirname as dirname15 } from "path";
9695
10670
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
9696
10671
  var MARKER_END = "<!-- @sechroom/cli:end";
10672
+ var BOOTSTRAP_STUB_REFUSAL = "This file carries pointers only";
9697
10673
  function normalizeBody(s) {
9698
10674
  return s.replace(/\r\n/g, "\n").trim();
9699
10675
  }
9700
10676
  function bodySha256(body) {
9701
- return createHash4("sha256").update(normalizeBody(body), "utf8").digest("hex");
10677
+ return createHash5("sha256").update(normalizeBody(body), "utf8").digest("hex");
9702
10678
  }
9703
10679
  function renderBlock(write) {
9704
10680
  const body = normalizeBody(write.body);
@@ -9744,22 +10720,22 @@ function parseManagedBlock(content, block) {
9744
10720
  return null;
9745
10721
  }
9746
10722
  function ensureDir2(path) {
9747
- mkdirSync15(dirname14(path), { recursive: true });
10723
+ mkdirSync16(dirname15(path), { recursive: true });
9748
10724
  }
9749
10725
  function readOr(path, fallback) {
9750
10726
  try {
9751
- return readFileSync15(path, "utf8");
10727
+ return readFileSync16(path, "utf8");
9752
10728
  } catch {
9753
10729
  return fallback;
9754
10730
  }
9755
10731
  }
9756
10732
  function mergeMcpJson(path, snippet, dryRun) {
9757
10733
  const incoming = JSON.parse(snippet);
9758
- const existed = existsSync12(path);
10734
+ const existed = existsSync13(path);
9759
10735
  let current = {};
9760
10736
  if (existed) {
9761
10737
  try {
9762
- current = JSON.parse(readFileSync15(path, "utf8"));
10738
+ current = JSON.parse(readFileSync16(path, "utf8"));
9763
10739
  } catch {
9764
10740
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
9765
10741
  }
@@ -9767,27 +10743,27 @@ function mergeMcpJson(path, snippet, dryRun) {
9767
10743
  current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
9768
10744
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
9769
10745
  ensureDir2(path);
9770
- writeFileSync14(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
10746
+ writeFileSync15(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
9771
10747
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
9772
10748
  }
9773
10749
  function mergeCodexToml(path, snippet, dryRun) {
9774
- const existed = existsSync12(path);
10750
+ const existed = existsSync13(path);
9775
10751
  let body = readOr(path, "");
9776
10752
  body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
9777
10753
  const trimmed = body.trim();
9778
10754
  const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
9779
10755
  if (dryRun) return { kind: "mcp", path, status: "dry-run" };
9780
10756
  ensureDir2(path);
9781
- writeFileSync14(path, next, { mode: 384 });
10757
+ writeFileSync15(path, next, { mode: 384 });
9782
10758
  return { kind: "mcp", path, status: existed ? "merged" : "created" };
9783
10759
  }
9784
10760
  function writeInstructionBlock(path, write, dryRun) {
9785
- const existed = existsSync12(path);
10761
+ const existed = existsSync13(path);
9786
10762
  const next = computeBlockFile(readOr(path, ""), write);
9787
- if (dryRun) return { kind: "instruction", path, status: "dry-run" };
10763
+ if (dryRun) return { kind: "instruction", path, status: "dry-run", block: write.block };
9788
10764
  ensureDir2(path);
9789
- writeFileSync14(path, next);
9790
- return { kind: "instruction", path, status: existed ? "merged" : "created" };
10765
+ writeFileSync15(path, next);
10766
+ return { kind: "instruction", path, status: existed ? "merged" : "created", block: write.block };
9791
10767
  }
9792
10768
  function computeBlockFile(current, write) {
9793
10769
  const rendered = renderBlock(write);
@@ -9802,7 +10778,12 @@ ${rendered}` : rendered;
9802
10778
  }
9803
10779
  function evaluateBlock(content, block, serverBody) {
9804
10780
  const onDisk = parseManagedBlock(content, block);
9805
- if (!onDisk) return "absent";
10781
+ if (!onDisk) {
10782
+ const hasManagedMarker = content.includes(MARKER_BEGIN) || content.includes(MARKER_END);
10783
+ if (!hasManagedMarker && content.includes(BOOTSTRAP_STUB_REFUSAL))
10784
+ return "stub";
10785
+ return "absent";
10786
+ }
9806
10787
  const actual = bodySha256(onDisk.body);
9807
10788
  if (onDisk.sha256 && actual !== onDisk.sha256) return "drift";
9808
10789
  return actual === bodySha256(serverBody) ? "current" : "stale";
@@ -9816,24 +10797,36 @@ function applyBlock(path, write, mode, dryRun) {
9816
10797
  path,
9817
10798
  status: state === "current" ? "current" : "skipped",
9818
10799
  eval: state,
9819
- note: state === "current" ? void 0 : `would ${state === "absent" ? "write" : "refresh"} (${state})`
10800
+ block: write.block,
10801
+ note: state === "current" ? void 0 : state === "stub" ? "dynamic chain pointer file \u2014 no static managed block to check" : `would ${state === "absent" ? "write" : "refresh"} (${state})`
10802
+ };
10803
+ }
10804
+ if (state === "stub") {
10805
+ return {
10806
+ kind: "instruction",
10807
+ path,
10808
+ status: "skipped",
10809
+ eval: "stub",
10810
+ block: write.block,
10811
+ note: "dynamic chain pointer file \u2014 managed blocks were not written"
9820
10812
  };
9821
10813
  }
9822
10814
  if (state === "current") {
9823
- return { kind: "instruction", path, status: "current", eval: "current" };
10815
+ return { kind: "instruction", path, status: "current", eval: "current", block: write.block };
9824
10816
  }
9825
10817
  if (state === "drift" && mode !== "force") {
9826
10818
  const proposedPath = `${path}.proposed`;
9827
10819
  const next = computeBlockFile(current, write);
9828
10820
  if (!dryRun) {
9829
10821
  ensureDir2(proposedPath);
9830
- writeFileSync14(proposedPath, next);
10822
+ writeFileSync15(proposedPath, next);
9831
10823
  }
9832
10824
  return {
9833
10825
  kind: "instruction",
9834
10826
  path,
9835
10827
  status: "skipped",
9836
10828
  eval: "drift",
10829
+ block: write.block,
9837
10830
  proposedPath,
9838
10831
  note: `local edits \u2014 wrote ${proposedPath} (original left untouched)`
9839
10832
  };
@@ -9957,8 +10950,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
9957
10950
  }
9958
10951
 
9959
10952
  // src/setup/skills-offer.ts
9960
- import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync15 } from "fs";
9961
- import { join as join18 } from "path";
10953
+ import { mkdirSync as mkdirSync17, writeFileSync as writeFileSync16 } from "fs";
10954
+ import { join as join19 } from "path";
9962
10955
 
9963
10956
  // src/setup/lane-pin.ts
9964
10957
  var CODE_LANE_PREFIX_BY_CLIENT = {
@@ -10074,8 +11067,8 @@ Found ${summary} available to you for ${surface}.
10074
11067
  if (skills.length > 0) {
10075
11068
  const written = [];
10076
11069
  for (const s of skills) {
10077
- mkdirSync16(join18(sDir, s.name), { recursive: true });
10078
- writeFileSync15(join18(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
11070
+ mkdirSync17(join19(sDir, s.name), { recursive: true });
11071
+ writeFileSync16(join19(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
10079
11072
  written.push(s.name);
10080
11073
  }
10081
11074
  recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -10083,11 +11076,11 @@ Found ${summary} available to you for ${surface}.
10083
11076
  `);
10084
11077
  }
10085
11078
  if (agents.length > 0) {
10086
- mkdirSync16(aDir, { recursive: true });
11079
+ mkdirSync17(aDir, { recursive: true });
10087
11080
  const written = [];
10088
11081
  for (const a of agents) {
10089
11082
  const file = `${a.name}.md`;
10090
- writeFileSync15(join18(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
11083
+ writeFileSync16(join19(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
10091
11084
  written.push(file);
10092
11085
  }
10093
11086
  recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
@@ -10102,6 +11095,21 @@ Found ${summary} available to you for ${surface}.
10102
11095
  }
10103
11096
 
10104
11097
  // src/commands/setup.ts
11098
+ var CONVENTION_REGEN_CLIENTS = ["claude-code", "codex"];
11099
+ function buildConventionDraft(title, rawKind, rawBody) {
11100
+ const kind = String(rawKind).toLowerCase() === "standard" ? "standard" : "reference";
11101
+ const body = typeof rawBody === "string" && rawBody.trim().length > 0 ? rawBody.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
11102
+ return {
11103
+ title,
11104
+ kind,
11105
+ body,
11106
+ text: `# ${title}
11107
+
11108
+ ${body}
11109
+ `,
11110
+ tags: ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"]
11111
+ };
11112
+ }
10105
11113
  function copyChoice(opts) {
10106
11114
  return opts.copy === true ? "yes" : opts.copy === false ? "no" : "ask";
10107
11115
  }
@@ -10158,27 +11166,72 @@ ${client.label} (${client.key}):
10158
11166
  function resolveEvalMode(opts) {
10159
11167
  return opts.check ? "check" : opts.force ? "force" : "apply";
10160
11168
  }
10161
- function summarizeEval(result, mode, json, dryRun) {
10162
- const counts = { current: 0, stale: 0, drift: 0, absent: 0 };
10163
- for (const { actions } of result) for (const a of actions) if (a.eval) counts[a.eval]++;
10164
- const wouldChange = counts.stale + counts.drift + counts.absent;
10165
- if (mode === "check") {
10166
- if (!json) {
10167
- if (wouldChange === 0) {
10168
- process.stdout.write("\u2713 all instruction blocks are up to date.\n");
10169
- } else {
10170
- const bits = [];
10171
- if (counts.stale) bits.push(`${counts.stale} out of date`);
10172
- if (counts.drift) bits.push(`${counts.drift} with local edits`);
10173
- if (counts.absent) bits.push(`${counts.absent} not yet written`);
10174
- process.stderr.write(
10175
- `\u26A0 ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Re-run with ${style.cyan("--refresh")}.
11169
+ function buildCheckReport(result) {
11170
+ const counts = {
11171
+ current: 0,
11172
+ stale: 0,
11173
+ drift: 0,
11174
+ absent: 0,
11175
+ stub: 0
11176
+ };
11177
+ const blocks = [];
11178
+ for (const { client, actions } of result) {
11179
+ for (const action of actions) {
11180
+ if (!action.eval) continue;
11181
+ counts[action.eval]++;
11182
+ blocks.push({
11183
+ client,
11184
+ path: action.path,
11185
+ block: action.block ?? "unknown",
11186
+ state: action.eval
11187
+ });
11188
+ }
11189
+ }
11190
+ return {
11191
+ eval: counts,
11192
+ wouldChange: counts.stale + counts.drift + counts.absent,
11193
+ blocks
11194
+ };
11195
+ }
11196
+ function reportCheckAndExit(result, json, refreshCommand, jsonContext = {}) {
11197
+ const report = buildCheckReport(result);
11198
+ if (json) {
11199
+ emit(
11200
+ {
11201
+ check: true,
11202
+ ...jsonContext,
11203
+ ...report,
11204
+ clients: result
11205
+ },
11206
+ true
11207
+ );
11208
+ } else if (report.wouldChange === 0) {
11209
+ if (report.eval.stub) {
11210
+ const files = report.eval.stub === 1 ? "file" : "files";
11211
+ process.stdout.write(
11212
+ `\u2713 ${report.eval.stub} dynamic chain pointer ${files}; no static managed blocks to check.
10176
11213
  `
10177
- );
10178
- }
11214
+ );
11215
+ } else {
11216
+ process.stdout.write("\u2713 all instruction blocks are up to date.\n");
10179
11217
  }
10180
- process.exit(wouldChange === 0 ? 0 : 1);
11218
+ } else {
11219
+ const bits = [];
11220
+ if (report.eval.stale) bits.push(`${report.eval.stale} out of date`);
11221
+ if (report.eval.drift) bits.push(`${report.eval.drift} with local edits`);
11222
+ if (report.eval.absent) bits.push(`${report.eval.absent} not yet written`);
11223
+ process.stderr.write(
11224
+ `\u26A0 ${report.wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan(refreshCommand)}.
11225
+ `
11226
+ );
11227
+ }
11228
+ process.exit(report.wouldChange === 0 ? 0 : 1);
11229
+ }
11230
+ function summarizeEval(result, mode, json, dryRun) {
11231
+ if (mode === "check") {
11232
+ reportCheckAndExit(result, json, "--refresh");
10181
11233
  }
11234
+ const { eval: counts } = buildCheckReport(result);
10182
11235
  if (json) return;
10183
11236
  if (!dryRun && counts.stale) {
10184
11237
  process.stderr.write(`\u21BB refreshed ${counts.stale} section(s) the server had moved
@@ -10213,7 +11266,7 @@ async function resolveNamespaceChoice(cfg, flag) {
10213
11266
  return picked === GLOBAL_NAMESPACE ? null : picked;
10214
11267
  }
10215
11268
  function registerInit(program2) {
10216
- program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText(
11269
+ program2.command("init").description("Wire this project for sechroom: write MCP config + agent instruction files from the server's setup descriptors").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all'`, DEFAULT_CLIENT_KEY).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default global", "global").option("--dry-run", "print what would be written without writing", false).option("--mcp-only", "only write MCP config (skip agent files)", false).option("--agent-files-only", "only write agent instruction files (skip MCP config)", false).option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").option("--refresh", "refresh out-of-date agent-file blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite agent-file managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether agent files would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).addHelpText(
10217
11270
  "after",
10218
11271
  `
10219
11272
  Examples:
@@ -10221,11 +11274,15 @@ Examples:
10221
11274
  $ sechroom init --client all claude-code, claude-desktop, codex, cursor, antigravity
10222
11275
  $ sechroom init --client codex cursor space-separated (comma also works)
10223
11276
  $ sechroom init --mcp-only just the MCP config (skip agent files)
11277
+ $ sechroom init --agent-files-only --check CI gate: nonzero exit if agent files are out of date
10224
11278
  $ sechroom init --dry-run --json preview the writes, change nothing`
10225
11279
  ).action(async (opts, cmd) => {
10226
11280
  const cfg = resolveConfig(cmd.optsWithGlobals());
10227
11281
  const mode = resolveEvalMode(opts);
10228
11282
  const check = mode === "check";
11283
+ if (check && opts.mcpOnly) {
11284
+ fail("--check inspects agent files and cannot be combined with --mcp-only.");
11285
+ }
10229
11286
  const namespaceSlug = await resolveNamespaceChoice(cfg, opts.namespace);
10230
11287
  const setup = await withSpinner(
10231
11288
  "Fetching setup descriptors",
@@ -10261,12 +11318,12 @@ Examples:
10261
11318
  if (!json && !check) printActions(target, actions);
10262
11319
  }
10263
11320
  summarizeEval(result, mode, Boolean(json), Boolean(opts.dryRun));
10264
- if (!json && !opts.dryRun && !opts.mcpOnly) {
11321
+ if (!json && !opts.dryRun && !opts.mcpOnly && !check) {
10265
11322
  for (const t of claudeTargets) {
10266
11323
  await maybeOfferSkills(cfg, personalWorkspaceId, { yes: false, dryRun: Boolean(opts.dryRun), surface: "claude-code", configDir: t.dir });
10267
11324
  }
10268
11325
  }
10269
- if (!json && !opts.dryRun && !opts.mcpOnly) {
11326
+ if (!json && !opts.dryRun && !opts.mcpOnly && !check) {
10270
11327
  await maybeOfferHooks({ yes: false, dryRun: Boolean(opts.dryRun), cwd: process.cwd(), scope, claudeConfigDir: g.claudeConfigDir, codexHome: g.codexHome });
10271
11328
  }
10272
11329
  if (json) {
@@ -10286,12 +11343,12 @@ Next \u2014 verify: ${verify.description}
10286
11343
  );
10287
11344
  });
10288
11345
  }
10289
- function registerSetup(program2) {
11346
+ function registerSetup(program2, deps = {}) {
10290
11347
  const setup = program2.command("setup").description("Granular onboarding steps (init runs these together)");
10291
11348
  setup.command("mcp <clients...>").description(`Write only the MCP config for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--namespace <slug>", "MCP namespace for the connection URL (interactive picker if omitted on a TTY; defaults to tenant-global)").addHelpText("after", "\nExamples:\n $ sechroom setup mcp codex\n $ sechroom setup mcp claude-code codex\n $ sechroom setup mcp all").action(async (clients, opts, cmd) => {
10292
11349
  await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: true, agentFiles: false, namespace: opts.namespace });
10293
11350
  });
10294
- setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
11351
+ setup.command("agent-files <clients...>").description(`Write only the agent instruction file(s) for one or more clients (${ALL_CLIENT_KEYS.join(", ")}, or 'all')`).option("--dry-run", "print what would be written without writing", false).option("--copy", "make a personal copy you can edit (default: prompt on a TTY, else skip)").option("--refresh", "refresh out-of-date blocks in place (local edits preserved to .proposed)", false).option("--force", "rewrite managed blocks, overwriting local edits inside the markers", false).option("--check", "report whether anything would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).addHelpText("after", "\nExamples:\n $ sechroom setup agent-files claude-code CLAUDE.md\n $ sechroom setup agent-files claude-code codex CLAUDE.md + AGENTS.md in one run\n $ sechroom setup agent-files all --check CI gate: nonzero exit if out of date\n $ sechroom setup agent-files claude-code --force overwrite local edits in the managed block").action(async (clients, opts, cmd) => {
10295
11352
  await runClients(clients, cmd, { dryRun: Boolean(opts.dryRun), mcp: false, agentFiles: true, copy: opts.copy, mode: resolveEvalMode(opts) });
10296
11353
  });
10297
11354
  setup.command("new-convention <title...>").description("Scaffold a workspace-conventions section: author a correctly-tagged memo (header as first body line) + regen the agent files").option("--kind <kind>", "reference | standard (orders the section; reference first)", "reference").option("--workspace <id>", "workspace to author in (default: the bound workspace)").option("--body <markdown>", "section body (default: a TODO scaffold to edit later)").option("--no-regen", "skip the agent-files regen after authoring").option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
@@ -10314,29 +11371,33 @@ Examples:
10314
11371
  const workspaceId = opts.workspace ?? cfg.workspaceId;
10315
11372
  if (!workspaceId)
10316
11373
  fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
10317
- const kind = String(opts.kind).toLowerCase() === "standard" ? "standard" : "reference";
10318
- const body = typeof opts.body === "string" && opts.body.trim().length > 0 ? opts.body.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
10319
- const text2 = `# ${title}
10320
-
10321
- ${body}
10322
- `;
10323
- const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
11374
+ const draft = buildConventionDraft(title, opts.kind, opts.body);
10324
11375
  if (opts.dryRun) {
10325
- emit({ dryRun: true, workspaceId, title, kind, tags, text: text2 }, json);
11376
+ emit(
11377
+ {
11378
+ dryRun: true,
11379
+ workspaceId,
11380
+ title: draft.title,
11381
+ kind: draft.kind,
11382
+ tags: draft.tags,
11383
+ text: draft.text
11384
+ },
11385
+ json
11386
+ );
10326
11387
  return;
10327
11388
  }
10328
- const data = await runApi("Authoring convention memo", async () => {
11389
+ const data = deps.authorConvention ? await deps.authorConvention({ cfg, draft, workspaceId }) : await runApi("Authoring convention memo", async () => {
10329
11390
  const client = await makeClient(cfg);
10330
11391
  return client.POST("/memories", {
10331
11392
  body: {
10332
- text: text2,
10333
- type: kind,
11393
+ text: draft.text,
11394
+ type: draft.kind,
10334
11395
  content: "{}",
10335
11396
  confidence: 1,
10336
11397
  source: "cli-new-convention",
10337
11398
  archetype: "Document",
10338
- title,
10339
- tags,
11399
+ title: draft.title,
11400
+ tags: draft.tags,
10340
11401
  owner: { type: "Workspace", id: workspaceId }
10341
11402
  }
10342
11403
  });
@@ -10353,7 +11414,8 @@ ${body}
10353
11414
  else process.stdout.write("Skipped regen (--no-regen). Run `sechroom setup agent-files all` to apply.\n");
10354
11415
  return;
10355
11416
  }
10356
- await runClients(["claude-code", "codex"], cmd, {
11417
+ const regenerate = deps.regenerateConvention ?? runClients;
11418
+ await regenerate([...CONVENTION_REGEN_CLIENTS], cmd, {
10357
11419
  dryRun: false,
10358
11420
  mcp: false,
10359
11421
  agentFiles: true,
@@ -10470,13 +11532,13 @@ Wired to namespace '${slug2}'. Restart your AI client (or reload MCP) to pick it
10470
11532
  }
10471
11533
 
10472
11534
  // src/commands/onboard.ts
10473
- import { existsSync as existsSync14 } from "fs";
10474
- import { basename as basename5, join as join20 } from "path";
11535
+ import { existsSync as existsSync15 } from "fs";
11536
+ import { basename as basename5, join as join21 } from "path";
10475
11537
 
10476
11538
  // src/commands/fanout.ts
10477
11539
  import { spawnSync } from "child_process";
10478
- import { existsSync as existsSync13, readFileSync as readFileSync16, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
10479
- import { isAbsolute as isAbsolute2, join as join19, resolve as resolve6 } from "path";
11540
+ import { existsSync as existsSync14, readFileSync as readFileSync17, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
11541
+ import { isAbsolute as isAbsolute3, join as join20, resolve as resolve7 } from "path";
10480
11542
  var ICON = {
10481
11543
  refresh: "\u21BB",
10482
11544
  bind: "+",
@@ -10484,33 +11546,33 @@ var ICON = {
10484
11546
  "skip-unbound": "\u26A0"
10485
11547
  };
10486
11548
  function resolveChildDir(path, root) {
10487
- return isAbsolute2(path) ? path : resolve6(root, path);
11549
+ return isAbsolute3(path) ? path : resolve7(root, path);
10488
11550
  }
10489
11551
  function discoverChildren(root) {
10490
11552
  let names;
10491
11553
  try {
10492
- names = readdirSync3(root);
11554
+ names = readdirSync4(root);
10493
11555
  } catch {
10494
11556
  return [];
10495
11557
  }
10496
11558
  const out = [];
10497
11559
  for (const name of names.sort()) {
10498
11560
  if (name.startsWith(".") || name === "node_modules") continue;
10499
- const dir = join19(root, name);
11561
+ const dir = join20(root, name);
10500
11562
  try {
10501
- if (!statSync4(dir).isDirectory()) continue;
11563
+ if (!statSync5(dir).isDirectory()) continue;
10502
11564
  } catch {
10503
11565
  continue;
10504
11566
  }
10505
- if (existsSync13(join19(dir, ".git")) || committedBindingPath(dir)) out.push(name);
11567
+ if (existsSync14(join20(dir, ".git")) || committedBindingPath(dir)) out.push(name);
10506
11568
  }
10507
11569
  return out;
10508
11570
  }
10509
11571
  function readManifest(path) {
10510
- if (!existsSync13(path)) return null;
11572
+ if (!existsSync14(path)) return null;
10511
11573
  let parsed;
10512
11574
  try {
10513
- parsed = JSON.parse(readFileSync16(path, "utf8"));
11575
+ parsed = JSON.parse(readFileSync17(path, "utf8"));
10514
11576
  } catch (err2) {
10515
11577
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
10516
11578
  }
@@ -10876,10 +11938,10 @@ async function chooseScope(scopeFlag, yes) {
10876
11938
  }
10877
11939
  async function planRecurseChild(entry, root, client, opts) {
10878
11940
  const dir = resolveChildDir(entry.path, root);
10879
- if (!existsSync14(dir)) {
11941
+ if (!existsSync15(dir)) {
10880
11942
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
10881
11943
  }
10882
- if (existsSync14(join20(dir, ".sechroom.json"))) {
11944
+ if (existsSync15(join21(dir, ".sechroom.json"))) {
10883
11945
  return {
10884
11946
  label: entry.path,
10885
11947
  dir,
@@ -10952,7 +12014,7 @@ This fan-out will pin the same lane in every repo:
10952
12014
  async function runRecurse(cfg, g, opts) {
10953
12015
  const { yes, dryRun, json } = opts;
10954
12016
  const root = process.cwd();
10955
- const manifestPath = join20(root, ".sechroom", "repos.json");
12017
+ const manifestPath = join21(root, ".sechroom", "repos.json");
10956
12018
  const fromManifest = readManifest(manifestPath);
10957
12019
  const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
10958
12020
  const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
@@ -10980,7 +12042,7 @@ async function runRecurse(cfg, g, opts) {
10980
12042
  summarizeFanout(results, { dryRun });
10981
12043
  }
10982
12044
  function registerOnboard(program2) {
10983
- program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = all current, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
12045
+ program2.command("onboard").description("Guided first-run setup: configure, sign in, set timezone, detect clients, and wire this project").option("--recurse", "orchestration-root mode: onboard every child repo under this dir (auto-discovered, or from ./.sechroom/repos.json) \u2014 refreshes bound repos, prompts a workspace per new one", false).option("--lane <id>", "set the code-lane (substrate source identity) explicitly instead of inferring it; with --recurse it's used for every child repo").option("--design-lane <id>", "set the design-lane explicitly (substrate-authoring identity); with --recurse applies to every child").option("--client <list...>", `clients to wire \u2014 space- or comma-separated (${ALL_CLIENT_KEYS.join(", ")}) or 'all' (default: auto-detected)`).option("--scope <scope>", "install skills/agents/hooks 'global' (config dir / CLAUDE_CONFIG_DIR) or 'project' (<cwd>/.claude) \u2014 default: prompt, else global").option("--local", "save the binding (tenant + base URL + workspace) to a committed .sechroom.json in this repo instead of the global config", false).option("--here", "with --local: write the binding at THIS directory even when a parent already carries one \u2014 binds a subtree (e.g. a monorepo's frontend/) to its own workspace", false).option("--workspace <id>", "bind this directory to a workspace (skips the interactive workspace pick)").option("--cli-only", "configure the CLI only \u2014 don't wire any AI client (no MCP config, no agent files)", false).option("--no-mcp", "skip the MCP server config (.mcp.json etc.); still write the agent instruction files").option("--copy", "make a personal copy of the agent instructions you can edit (default: prompt on a TTY, else skip)").option("--dry-run", "walk through without writing files or changing the profile", false).option("--refresh", "re-fetch descriptors and refresh any out-of-date managed blocks (local edits preserved to .proposed)", false).option("--force", "rewrite every managed block, overwriting local edits inside the markers (content outside untouched)", false).option("--check", "report whether anything would change and exit (0 = current/stub, 1 = stale/drift/absent); writes nothing", false).option("-y, --yes", "non-interactive: accept defaults (system timezone, detected clients, global config, full wire)", false).addHelpText(
10984
12046
  "after",
10985
12047
  `
10986
12048
  Examples:
@@ -11002,6 +12064,9 @@ Examples:
11002
12064
  const mode = opts.check ? "check" : opts.force ? "force" : "apply";
11003
12065
  const check = mode === "check";
11004
12066
  const yes = Boolean(opts.yes) || check;
12067
+ if (check && (opts.recurse || opts.cliOnly)) {
12068
+ fail("--check inspects this project's agent files and cannot be combined with --recurse or --cli-only.");
12069
+ }
11005
12070
  if (opts.lane) process.env.SECHROOM_CODE_LANE = opts.lane;
11006
12071
  if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
11007
12072
  if (opts.recurse) {
@@ -11069,27 +12134,19 @@ Try: ${style.cyan('sechroom memory search "..."')} or ${style.cyan("sechroom -
11069
12134
  result.push({ client: key, actions });
11070
12135
  if (!json && !check) printActions(target, actions);
11071
12136
  }
11072
- const evalCounts = { current: 0, stale: 0, drift: 0, absent: 0 };
11073
- for (const { actions } of result) for (const a of actions) if (a.eval) evalCounts[a.eval]++;
11074
- const wouldChange = evalCounts.stale + evalCounts.drift + evalCounts.absent;
11075
12137
  if (check) {
11076
- if (json) {
11077
- emit({ check: true, baseUrl: cfg.baseUrl, tenant: cfg.tenant, workspaceId: cfg.workspaceId ?? null, eval: evalCounts, wouldChange, clients: result }, true);
11078
- } else if (wouldChange === 0) {
11079
- process.stdout.write(`${ok("\u2713")} all instruction blocks are up to date.
11080
- `);
11081
- } else {
11082
- const bits = [];
11083
- if (evalCounts.stale) bits.push(`${evalCounts.stale} out of date`);
11084
- if (evalCounts.drift) bits.push(`${evalCounts.drift} with local edits`);
11085
- if (evalCounts.absent) bits.push(`${evalCounts.absent} not yet written`);
11086
- process.stderr.write(
11087
- `${warn("\u26A0")} ${wouldChange} instruction block(s) would change: ${bits.join(", ")}. Run ${style.cyan("sechroom onboard --refresh")}.
11088
- `
11089
- );
11090
- }
11091
- process.exit(wouldChange === 0 ? 0 : 1);
12138
+ reportCheckAndExit(
12139
+ result,
12140
+ json,
12141
+ "sechroom onboard --refresh",
12142
+ {
12143
+ baseUrl: cfg.baseUrl,
12144
+ tenant: cfg.tenant,
12145
+ workspaceId: cfg.workspaceId ?? null
12146
+ }
12147
+ );
11092
12148
  }
12149
+ const evalCounts = buildCheckReport(result).eval;
11093
12150
  if (!json && !dryRun) {
11094
12151
  await ensureLanePin(cfg, { yes, dryRun, clients: keys });
11095
12152
  }
@@ -11485,31 +12542,31 @@ Examples:
11485
12542
 
11486
12543
  // src/commands/reset.ts
11487
12544
  import { homedir as homedir6 } from "os";
11488
- import { join as join21 } from "path";
11489
- import { existsSync as existsSync15, readFileSync as readFileSync17, rmSync as rmSync6 } from "fs";
12545
+ import { join as join22 } from "path";
12546
+ import { existsSync as existsSync16, readFileSync as readFileSync18, rmSync as rmSync7 } from "fs";
11490
12547
  var SKILLS_LOCK2 = ".sechroom-skills.json";
11491
- var localSkillsDir = () => join21(process.cwd(), ".claude", "skills");
11492
- var globalSkillsDir = () => join21(homedir6(), ".claude", "skills");
11493
- var localAgentsDir = () => join21(process.cwd(), ".claude", "agents");
11494
- var globalAgentsDir = () => join21(homedir6(), ".claude", "agents");
12548
+ var localSkillsDir = () => join22(process.cwd(), ".claude", "skills");
12549
+ var globalSkillsDir = () => join22(homedir6(), ".claude", "skills");
12550
+ var localAgentsDir = () => join22(process.cwd(), ".claude", "agents");
12551
+ var globalAgentsDir = () => join22(homedir6(), ".claude", "agents");
11495
12552
  function removeMaterialisedSkills(dir) {
11496
12553
  const removed = [];
11497
- const lockPath = join21(dir, SKILLS_LOCK2);
11498
- if (!existsSync15(lockPath)) return removed;
12554
+ const lockPath = join22(dir, SKILLS_LOCK2);
12555
+ if (!existsSync16(lockPath)) return removed;
11499
12556
  try {
11500
- const lock = JSON.parse(readFileSync17(lockPath, "utf8"));
12557
+ const lock = JSON.parse(readFileSync18(lockPath, "utf8"));
11501
12558
  for (const entry of Object.values(lock)) {
11502
12559
  for (const name of entry.skills ?? []) {
11503
- const p = join21(dir, name);
11504
- if (existsSync15(p)) {
11505
- rmSync6(p, { recursive: true, force: true });
12560
+ const p = join22(dir, name);
12561
+ if (existsSync16(p)) {
12562
+ rmSync7(p, { recursive: true, force: true });
11506
12563
  removed.push(p);
11507
12564
  }
11508
12565
  }
11509
12566
  }
11510
12567
  } catch {
11511
12568
  }
11512
- rmSync6(lockPath, { force: true });
12569
+ rmSync7(lockPath, { force: true });
11513
12570
  removed.push(lockPath);
11514
12571
  return removed;
11515
12572
  }
@@ -11546,19 +12603,19 @@ function registerReset(program2) {
11546
12603
  }
11547
12604
  }
11548
12605
  const removed = [];
11549
- const stateDir = join21(process.cwd(), ".sechroom");
11550
- if (existsSync15(stateDir)) {
11551
- rmSync6(stateDir, { recursive: true, force: true });
12606
+ const stateDir = join22(process.cwd(), ".sechroom");
12607
+ if (existsSync16(stateDir)) {
12608
+ rmSync7(stateDir, { recursive: true, force: true });
11552
12609
  removed.push(stateDir);
11553
12610
  }
11554
- const legacyCfg = join21(process.cwd(), ".sechroom.json");
11555
- if (existsSync15(legacyCfg)) {
11556
- rmSync6(legacyCfg, { force: true });
12611
+ const legacyCfg = join22(process.cwd(), ".sechroom.json");
12612
+ if (existsSync16(legacyCfg)) {
12613
+ rmSync7(legacyCfg, { force: true });
11557
12614
  removed.push(legacyCfg);
11558
12615
  }
11559
- const legacySem = join21(process.cwd(), ".sem");
11560
- if (existsSync15(legacySem)) {
11561
- rmSync6(legacySem, { force: true });
12616
+ const legacySem = join22(process.cwd(), ".sem");
12617
+ if (existsSync16(legacySem)) {
12618
+ rmSync7(legacySem, { force: true });
11562
12619
  removed.push(legacySem);
11563
12620
  }
11564
12621
  removed.push(...removeMaterialisedSkills(localSkillsDir()));
@@ -11583,8 +12640,8 @@ function registerReset(program2) {
11583
12640
  }
11584
12641
 
11585
12642
  // src/commands/skills.ts
11586
- import { existsSync as existsSync16, mkdirSync as mkdirSync17, statSync as statSync5, writeFileSync as writeFileSync16 } from "fs";
11587
- import { join as join22 } from "path";
12643
+ import { existsSync as existsSync17, mkdirSync as mkdirSync18, statSync as statSync6, writeFileSync as writeFileSync17 } from "fs";
12644
+ import { join as join23 } from "path";
11588
12645
  function filenameFromDisposition(header) {
11589
12646
  if (!header) return void 0;
11590
12647
  const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
@@ -11592,11 +12649,11 @@ function filenameFromDisposition(header) {
11592
12649
  }
11593
12650
  function resolveOutputPath(output, serverFilename) {
11594
12651
  const filename = serverFilename || "skills.zip";
11595
- if (!output) return join22(process.cwd(), filename);
11596
- const looksLikeDir = output.endsWith("/") || existsSync16(output) && statSync5(output).isDirectory();
12652
+ if (!output) return join23(process.cwd(), filename);
12653
+ const looksLikeDir = output.endsWith("/") || existsSync17(output) && statSync6(output).isDirectory();
11597
12654
  if (looksLikeDir) {
11598
- mkdirSync17(output, { recursive: true });
11599
- return join22(output, filename);
12655
+ mkdirSync18(output, { recursive: true });
12656
+ return join23(output, filename);
11600
12657
  }
11601
12658
  return output;
11602
12659
  }
@@ -11627,7 +12684,7 @@ async function downloadZip(label, call, output) {
11627
12684
  const buf = Buffer.from(res.data);
11628
12685
  const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
11629
12686
  const path = resolveOutputPath(output, filename);
11630
- writeFileSync16(path, buf);
12687
+ writeFileSync17(path, buf);
11631
12688
  return { path, bytes: buf.length, filename };
11632
12689
  }
11633
12690
  function registerSkills(program2) {
@@ -11801,12 +12858,12 @@ clients select all; an unconfigured machine preserves the legacy Claude default.
11801
12858
  }
11802
12859
 
11803
12860
  // src/commands/sweep.ts
11804
- import { existsSync as existsSync17 } from "fs";
11805
- import { dirname as dirname15, join as join23, resolve as resolve7 } from "path";
11806
- var DEFAULT_MANIFEST = join23(".sechroom", "repos.json");
12861
+ import { existsSync as existsSync18 } from "fs";
12862
+ import { dirname as dirname16, join as join24, resolve as resolve8 } from "path";
12863
+ var DEFAULT_MANIFEST = join24(".sechroom", "repos.json");
11807
12864
  function planEntry(entry, root) {
11808
12865
  const dir = resolveChildDir(entry.path, root);
11809
- if (!existsSync17(dir)) {
12866
+ if (!existsSync18(dir)) {
11810
12867
  return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
11811
12868
  }
11812
12869
  if (committedBindingPath(dir)) {
@@ -11866,7 +12923,7 @@ Examples:
11866
12923
  const g = cmd.optsWithGlobals();
11867
12924
  const json = Boolean(g.json);
11868
12925
  const dryRun = Boolean(opts.dryRun);
11869
- const manifestPath = resolve7(opts.manifest);
12926
+ const manifestPath = resolve8(opts.manifest);
11870
12927
  let repos;
11871
12928
  try {
11872
12929
  repos = readManifest(manifestPath);
@@ -11882,7 +12939,7 @@ Examples:
11882
12939
  `);
11883
12940
  return;
11884
12941
  }
11885
- const root = dirname15(dirname15(manifestPath));
12942
+ const root = dirname16(dirname16(manifestPath));
11886
12943
  const plans = repos.map((entry) => planEntry(entry, root));
11887
12944
  if (!json) {
11888
12945
  process.stderr.write(
@@ -12302,7 +13359,7 @@ async function readStdin4() {
12302
13359
  function resolveVersion() {
12303
13360
  try {
12304
13361
  const pkg = JSON.parse(
12305
- readFileSync18(new URL("../package.json", import.meta.url), "utf8")
13362
+ readFileSync19(new URL("../package.json", import.meta.url), "utf8")
12306
13363
  );
12307
13364
  return pkg.version ?? "0.0.0";
12308
13365
  } catch {