@sechroom/cli 2026.7.5 → 2026.7.6-rc.a2828c37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +567 -56
  2. package/package.json +4 -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 readFileSync11 } from "fs";
4
+ import { readFileSync as readFileSync12 } from "fs";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/auth.ts
@@ -439,6 +439,9 @@ var quiet = false;
439
439
  function setQuiet(q) {
440
440
  quiet = q;
441
441
  }
442
+ function isQuiet() {
443
+ return quiet;
444
+ }
442
445
  function colorOn() {
443
446
  return !quiet && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0" && Boolean(process.stdout.isTTY);
444
447
  }
@@ -615,6 +618,22 @@ function emitAction(summary, data, json) {
615
618
  process.stdout.write(`${ok("\u2713")} ${summary}
616
619
  `);
617
620
  }
621
+ var GOVERNANCE_QUEUED_PROBLEM_TYPE = "https://sechroom.dev/problems/governance-review-queued";
622
+ function isGovernanceQueued(body) {
623
+ return typeof body === "object" && body !== null && "type" in body && body.type === GOVERNANCE_QUEUED_PROBLEM_TYPE;
624
+ }
625
+ function formatQueuedForApproval(body, json) {
626
+ if (json) return JSON.stringify(body) + "\n";
627
+ const b = body ?? {};
628
+ const detail = typeof b.detail === "string" ? b.detail : void 0;
629
+ const requestId = typeof b.requestId === "string" ? b.requestId : void 0;
630
+ const message = detail ?? "This change requires operator approval before it takes effect.";
631
+ let out = `${warn("\u29D7")} Pending approval \u2014 ${message}
632
+ `;
633
+ if (requestId) out += ` ${style.dim(`request: ${requestId}`)}
634
+ `;
635
+ return out;
636
+ }
618
637
  async function runApi(label, fn) {
619
638
  const s = spinner(label);
620
639
  let res;
@@ -624,6 +643,12 @@ async function runApi(label, fn) {
624
643
  s.fail();
625
644
  fail(err2);
626
645
  }
646
+ const queuedBody = res.data ?? res.error;
647
+ if (res.response?.status === 202 && isGovernanceQueued(queuedBody)) {
648
+ s.stop();
649
+ process.stdout.write(formatQueuedForApproval(queuedBody, isQuiet()));
650
+ process.exit(0);
651
+ }
627
652
  const httpFailed = res.response !== void 0 && !res.response.ok;
628
653
  if (res.error !== void 0 && res.error !== null || httpFailed) {
629
654
  s.fail();
@@ -633,7 +658,22 @@ async function runApi(label, fn) {
633
658
  return res.data;
634
659
  }
635
660
  function fail(error) {
636
- const msg = typeof error === "object" && error !== null && "title" in error ? String(error.title) : typeof error === "object" ? JSON.stringify(error) : String(error);
661
+ let msg;
662
+ if (typeof error === "object" && error !== null && "title" in error) {
663
+ const problem = error;
664
+ msg = String(problem.title);
665
+ if (problem.errors && typeof problem.errors === "object") {
666
+ const detail = Object.entries(problem.errors).flatMap(
667
+ ([field, msgs]) => (Array.isArray(msgs) ? msgs : [msgs]).map((m) => ` ${field}: ${String(m)}`)
668
+ );
669
+ if (detail.length > 0) msg += `
670
+ ${detail.join("\n")}`;
671
+ }
672
+ } else if (typeof error === "object" && error !== null) {
673
+ msg = JSON.stringify(error);
674
+ } else {
675
+ msg = String(error);
676
+ }
637
677
  process.stderr.write(`error: ${msg}
638
678
  `);
639
679
  process.exit(1);
@@ -1388,10 +1428,15 @@ var CLAUDE_HOOK_COMMANDS = {
1388
1428
  SessionStart: "sechroom hook session-start",
1389
1429
  PreCompact: "sechroom hook pre-compact",
1390
1430
  SessionEnd: "sechroom hook session-end",
1391
- // WLP telemetry tap (D-WLP-10) — per-turn executor self-report. No-op (exit 0) unless this
1392
- // checkout is bound to a decomposition+task via `sechroom telemetry bind`, so it's safe to wire
1393
- // for every Claude install. Claude-only (it parses a Claude Code transcript).
1394
- Stop: "sechroom telemetry hook"
1431
+ // WLP telemetry tap (D-WLP-10 + FR-352 Tier 1) — per-turn executor self-report. The one
1432
+ // `telemetry hook` verb dispatches on hook_event_name: Stop/SubagentStop parsed (token/context) +
1433
+ // terminal (turn end), Notification/PermissionDenied → approval. No-op (exit 0) unless this checkout
1434
+ // is bound via `sechroom telemetry bind`, so it's safe to wire for every Claude install; an event a
1435
+ // given Claude Code version doesn't know is inert (never fires). Claude-only.
1436
+ Stop: "sechroom telemetry hook",
1437
+ SubagentStop: "sechroom telemetry hook",
1438
+ Notification: "sechroom telemetry hook",
1439
+ PermissionDenied: "sechroom telemetry hook"
1395
1440
  };
1396
1441
  var CODEX_HOOK_COMMANDS = {
1397
1442
  SessionStart: "sechroom hook session-start",
@@ -1544,11 +1589,16 @@ function registerChannel(program2) {
1544
1589
  const cfg = resolveConfig(cmd.optsWithGlobals());
1545
1590
  const filter = readFilter(opts);
1546
1591
  const sub = await ensureSubscription(cfg, opts.name, filter);
1547
- const conn = await openConnection(cfg, (payload) => {
1548
- process.stdout.write(
1592
+ const seen = /* @__PURE__ */ new Set();
1593
+ const deliver = makeDeliver(
1594
+ filter,
1595
+ seen,
1596
+ (payload) => process.stdout.write(
1549
1597
  (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
1550
- );
1551
- });
1598
+ )
1599
+ );
1600
+ const conn = await openConnection(cfg, deliver);
1601
+ await reconcile(cfg, filter, deliver);
1552
1602
  if (json) {
1553
1603
  emit(
1554
1604
  {
@@ -1585,7 +1635,8 @@ function registerChannel(program2) {
1585
1635
  );
1586
1636
  await mcp.connect(new StdioServerTransport());
1587
1637
  await ensureSubscription(cfg, opts.name, filter);
1588
- const conn = await openConnection(cfg, (payload) => {
1638
+ const seen = /* @__PURE__ */ new Set();
1639
+ const deliver = makeDeliver(filter, seen, (payload) => {
1589
1640
  const { content, meta } = summarizeEvent(payload);
1590
1641
  void mcp.notification({
1591
1642
  method: "notifications/claude/channel",
@@ -1595,6 +1646,8 @@ function registerChannel(program2) {
1595
1646
  `))
1596
1647
  );
1597
1648
  });
1649
+ const conn = await openConnection(cfg, deliver);
1650
+ await reconcile(cfg, filter, deliver);
1598
1651
  process.stderr.write(
1599
1652
  style.dim(
1600
1653
  `sechroom channel (mcp) \u2014 tenant ${cfg.tenant}, tags [${filter.tags.join(", ")}]
@@ -1605,7 +1658,10 @@ function registerChannel(program2) {
1605
1658
  });
1606
1659
  channel.command("install").description(
1607
1660
  "Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
1608
- ).option("--workspace <wsp...>", "Restrict dispatches to these workspace id(s)").option(
1661
+ ).option(
1662
+ "--workspace <wsp...>",
1663
+ "Restrict dispatches to these workspace id(s)"
1664
+ ).option(
1609
1665
  "--tag <tag...>",
1610
1666
  "Tag(s) a dispatch must carry to match (repeatable). Default targets WLP task dispatches.",
1611
1667
  ["kind:task"]
@@ -1726,20 +1782,134 @@ function holdOpen(conn) {
1726
1782
  process.on("SIGTERM", stop);
1727
1783
  });
1728
1784
  }
1729
- function summarizeEvent(payload) {
1785
+ function parseEvent(payload) {
1730
1786
  let data = payload;
1731
1787
  if (typeof payload === "string") {
1732
1788
  try {
1733
1789
  data = JSON.parse(payload);
1734
1790
  } catch {
1735
- return { content: payload, meta: {} };
1791
+ return { eventType: "", memoryId: "", workspaceId: "", tags: void 0 };
1736
1792
  }
1737
1793
  }
1738
1794
  const obj = data ?? {};
1739
1795
  const inner = obj.data ?? obj;
1740
- const eventType = str(inner.eventType ?? inner.EventType ?? obj.type) || "substrate.event";
1741
- const memoryId = str(inner.memoryId ?? inner.MemoryId);
1742
- const workspaceId = str(inner.workspaceId ?? inner.WorkspaceId);
1796
+ const rawTags = inner.tags ?? inner.Tags;
1797
+ return {
1798
+ eventType: str(inner.eventType ?? inner.EventType ?? obj.type) || "substrate.event",
1799
+ memoryId: str(inner.memoryId ?? inner.MemoryId),
1800
+ workspaceId: str(inner.workspaceId ?? inner.WorkspaceId),
1801
+ tags: Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : void 0
1802
+ };
1803
+ }
1804
+ function shouldDeliver(payload, filter) {
1805
+ const { workspaceId, tags } = parseEvent(payload);
1806
+ if (filter.workspaceScope.length > 0 && (!workspaceId || !filter.workspaceScope.includes(workspaceId)))
1807
+ return false;
1808
+ if (filter.tags.length > 0) {
1809
+ if (!tags) return false;
1810
+ return facetedTagMatch(tags, filter.tags);
1811
+ }
1812
+ return true;
1813
+ }
1814
+ function makeDeliver(filter, seen, forward) {
1815
+ return (payload) => {
1816
+ if (!shouldDeliver(payload, filter)) return;
1817
+ const { memoryId } = parseEvent(payload);
1818
+ if (memoryId) {
1819
+ if (seen.has(memoryId)) return;
1820
+ seen.add(memoryId);
1821
+ }
1822
+ forward(payload);
1823
+ };
1824
+ }
1825
+ async function reconcile(cfg, filter, deliver) {
1826
+ if (filter.workspaceScope.length === 0) {
1827
+ process.stderr.write(
1828
+ style.dim(
1829
+ "channel: no --workspace to reconcile against; live feed only (a dropped dispatch won't be recovered).\n"
1830
+ )
1831
+ );
1832
+ return;
1833
+ }
1834
+ if (filter.tags.length === 0) return;
1835
+ let token;
1836
+ try {
1837
+ token = await requireToken(cfg);
1838
+ } catch {
1839
+ return;
1840
+ }
1841
+ const qs = `filterTags=${encodeURIComponent(filter.tags.join(","))}&limit=100`;
1842
+ let recovered = 0;
1843
+ for (const ws of filter.workspaceScope) {
1844
+ try {
1845
+ const resp = await fetch(
1846
+ `${cfg.baseUrl}/workspaces/${encodeURIComponent(ws)}/memories/feed?${qs}`,
1847
+ {
1848
+ headers: {
1849
+ authorization: `Bearer ${token}`,
1850
+ tenant: cfg.tenant,
1851
+ "x-sechroom-surface": "cli"
1852
+ }
1853
+ }
1854
+ );
1855
+ if (!resp.ok) {
1856
+ process.stderr.write(
1857
+ err(
1858
+ `channel: reconcile query for ${ws} failed (HTTP ${resp.status})
1859
+ `
1860
+ )
1861
+ );
1862
+ continue;
1863
+ }
1864
+ const data = await resp.json();
1865
+ for (const m of data.results ?? []) {
1866
+ if (!m.id) continue;
1867
+ deliver({
1868
+ eventType: "reconcile",
1869
+ memoryId: m.id,
1870
+ workspaceId: ws,
1871
+ tags: m.tags ?? []
1872
+ });
1873
+ recovered++;
1874
+ }
1875
+ } catch (e) {
1876
+ process.stderr.write(
1877
+ err(`channel: reconcile error for ${ws}: ${String(e)}
1878
+ `)
1879
+ );
1880
+ }
1881
+ }
1882
+ if (recovered > 0)
1883
+ process.stderr.write(
1884
+ style.dim(
1885
+ `channel: reconciled ${recovered} already-queued event(s) on connect.
1886
+ `
1887
+ )
1888
+ );
1889
+ }
1890
+ function facetedTagMatch(eventTags, filterTags) {
1891
+ const have = new Set(eventTags);
1892
+ const groups = /* @__PURE__ */ new Map();
1893
+ for (const f of filterTags) {
1894
+ const ns = f.endsWith(":*") ? f.slice(0, -2) : namespaceOf(f);
1895
+ const group = groups.get(ns) ?? [];
1896
+ group.push(f);
1897
+ groups.set(ns, group);
1898
+ }
1899
+ for (const [ns, group] of groups) {
1900
+ const ok2 = group.some(
1901
+ (f) => f.endsWith(":*") ? eventTags.some((t) => namespaceOf(t) === ns) : have.has(f)
1902
+ );
1903
+ if (!ok2) return false;
1904
+ }
1905
+ return true;
1906
+ }
1907
+ function namespaceOf(tag) {
1908
+ const i = tag.indexOf(":");
1909
+ return i >= 0 ? tag.slice(0, i) : tag;
1910
+ }
1911
+ function summarizeEvent(payload) {
1912
+ const { eventType, memoryId, workspaceId } = parseEvent(payload);
1743
1913
  const content = memoryId ? `${eventType}: ${memoryId}${workspaceId ? ` (workspace ${workspaceId})` : ""}` : typeof payload === "string" ? payload : JSON.stringify(payload);
1744
1914
  const meta = {};
1745
1915
  if (eventType) meta.event_type = eventType;
@@ -2408,6 +2578,178 @@ Examples:
2408
2578
  });
2409
2579
  }
2410
2580
 
2581
+ // src/commands/close.ts
2582
+ import { readFileSync as readFileSync7 } from "fs";
2583
+ var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
2584
+ function registerClose(program2) {
2585
+ program2.command("close").description(
2586
+ "Close a dispatched WorkTask in one call: closeout memo (verdict + correlation) + Reference edge + source status flip. Managed runs stamp the run's matchTags verbatim (SBC-1180)."
2587
+ ).requiredOption("--task <id>", "The WorkTask memory id being closed").requiredOption("--verdict <verdict>", `Verdict: ${VERDICTS.join(" | ")}`).requiredOption(
2588
+ "--workspace <wsp>",
2589
+ "Workspace that owns the closeout memo"
2590
+ ).requiredOption("--title <title>", "Closeout title").option(
2591
+ "--file <path>",
2592
+ "Read the closeout body from a file (default: stdin)"
2593
+ ).option(
2594
+ "--decomposition <id>",
2595
+ "Managed-run selector \u2014 the sug_ decomposition the task belongs to. Locates the parked run so its matchTags/verdict contract are read from state, not assembled from args."
2596
+ ).option(
2597
+ "--no-status-flip",
2598
+ "Skip the status flip on a BARE task. (Managed runs never flip here \u2014 the run engine reflects the verdict onto the task on resume.)"
2599
+ ).option(
2600
+ "--to-version <n>",
2601
+ "Pin the Reference edge at this task version \u2014 the DISPATCHED version the executor worked against (D-continuity-2). Default: the task's current version (status flips are metadata edits that don't bump the version, so current == dispatched in the normal case; pass this when the task's content changed between dispatch and close)."
2602
+ ).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
2603
+ "after",
2604
+ `
2605
+ Examples:
2606
+ # Managed run \u2014 reads matchTags from run state, stamps them verbatim, un-parks the run:
2607
+ $ echo "Shipped X. Tests green." | sechroom close --task mem_XXXX --decomposition sug_YYYY \\
2608
+ --verdict pass --workspace wsp_ZZZZ --title "w4_build closeout"
2609
+
2610
+ # Bare task:
2611
+ $ sechroom close --task mem_XXXX --verdict pass --workspace wsp_ZZZZ \\
2612
+ --title "done" --file ./closeout.md`
2613
+ ).action(async (opts, cmd) => {
2614
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2615
+ const json = cmd.optsWithGlobals().json;
2616
+ const verdict = String(opts.verdict);
2617
+ if (!VERDICTS.includes(verdict))
2618
+ fail(
2619
+ `--verdict must be one of ${VERDICTS.join(" | ")} \u2014 got '${opts.verdict}'`
2620
+ );
2621
+ let bodyText;
2622
+ try {
2623
+ bodyText = opts.file ? readFileSync7(opts.file, "utf8") : readFileSync7(0, "utf8");
2624
+ } catch {
2625
+ fail(
2626
+ opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
2627
+ );
2628
+ }
2629
+ if (!bodyText.trim())
2630
+ fail(
2631
+ "closeout body is empty \u2014 pass --file <path> or pipe the body on stdin"
2632
+ );
2633
+ const client = await makeClient(cfg);
2634
+ let matchTags;
2635
+ let dispatchedVersion;
2636
+ if (opts.decomposition) {
2637
+ const run = await runApi(
2638
+ "Reading run contract",
2639
+ async () => client.GET("/decompositions/{id}/run", {
2640
+ params: { path: { id: opts.decomposition } }
2641
+ })
2642
+ );
2643
+ const await_ = run.awaitingCompletion;
2644
+ if (!await_ || !Array.isArray(await_.matchTags) || await_.matchTags.length === 0)
2645
+ fail(
2646
+ `decomposition ${opts.decomposition} has no parked run awaiting a completion \u2014 nothing to close (run outcome: ${run.outcome ?? "unknown"}). Idempotent no-op on an already-resumed/terminal run.`
2647
+ );
2648
+ if (run.awaitingTaskId && run.awaitingTaskId !== opts.task)
2649
+ fail(
2650
+ `run ${opts.decomposition} is awaiting task ${run.awaitingTaskId}, not ${opts.task} \u2014 refusing to mis-key the resume.`
2651
+ );
2652
+ matchTags = await_.matchTags;
2653
+ if (typeof await_.dispatchedTaskVersion === "number")
2654
+ dispatchedVersion = await_.dispatchedTaskVersion;
2655
+ } else {
2656
+ matchTags = [`wlp-task:${opts.task}`];
2657
+ }
2658
+ const tags = [
2659
+ .../* @__PURE__ */ new Set([
2660
+ ...matchTags,
2661
+ `wlp-task:${opts.task}`,
2662
+ `verdict:${verdict}`
2663
+ ])
2664
+ ];
2665
+ const closeout = await runApi(
2666
+ "Creating closeout",
2667
+ async () => client.POST("/memories", {
2668
+ body: {
2669
+ text: bodyText,
2670
+ type: "reference",
2671
+ content: "{}",
2672
+ confidence: 1,
2673
+ source: opts.source,
2674
+ archetype: "Document",
2675
+ title: opts.title,
2676
+ tags,
2677
+ owner: { type: "Workspace", id: opts.workspace }
2678
+ }
2679
+ })
2680
+ );
2681
+ let toVersion;
2682
+ if (opts.toVersion !== void 0) {
2683
+ toVersion = Number(opts.toVersion);
2684
+ if (!Number.isInteger(toVersion) || toVersion < 1)
2685
+ fail(
2686
+ `--to-version must be an integer >= 1 \u2014 got '${opts.toVersion}'`
2687
+ );
2688
+ } else if (dispatchedVersion !== void 0) {
2689
+ toVersion = dispatchedVersion;
2690
+ } else {
2691
+ const task = await runApi(
2692
+ "Reading task version",
2693
+ async () => client.GET("/memories/{memoryId}", {
2694
+ params: { path: { memoryId: opts.task } }
2695
+ })
2696
+ );
2697
+ const v = task?.item?.currentVersion ?? task?.currentVersion;
2698
+ if (typeof v !== "number" || v < 1)
2699
+ fail(
2700
+ `could not read task ${opts.task} version to pin the Reference edge \u2014 pass --to-version <n>`
2701
+ );
2702
+ toVersion = v;
2703
+ }
2704
+ const edge = await runApi(
2705
+ "Wiring Reference edge",
2706
+ async () => client.POST("/memories/{memoryId}/relationships", {
2707
+ params: { path: { memoryId: closeout.id } },
2708
+ body: { toMemoryId: opts.task, type: "Reference", toVersion }
2709
+ })
2710
+ );
2711
+ let taskStatus;
2712
+ if (opts.statusFlip !== false && !opts.decomposition) {
2713
+ const current = await runApi(
2714
+ "Reading task tags",
2715
+ async () => client.GET("/memories/{memoryId}", {
2716
+ params: { path: { memoryId: opts.task } }
2717
+ })
2718
+ );
2719
+ const currentTags = current?.item?.tags ?? current?.tags ?? [];
2720
+ const target = verdict === "blocked" ? "status:blocked" : "status:done";
2721
+ const nextTags = [
2722
+ ...new Set(currentTags.filter((t) => !t.startsWith("status:")))
2723
+ ].concat(target);
2724
+ await runApi(
2725
+ "Flipping task status",
2726
+ async () => client.PATCH("/memories/{memoryId}/metadata", {
2727
+ params: { path: { memoryId: opts.task } },
2728
+ body: {
2729
+ memoryId: opts.task,
2730
+ source: opts.source,
2731
+ tags: nextTags
2732
+ }
2733
+ })
2734
+ );
2735
+ taskStatus = target.slice("status:".length);
2736
+ }
2737
+ const view = resolveViewUrl(cfg.baseUrl, closeout.url);
2738
+ const result = {
2739
+ closeoutId: closeout.id,
2740
+ edgeId: edge.id,
2741
+ taskStatus: taskStatus ?? null,
2742
+ verdict,
2743
+ tags
2744
+ };
2745
+ emitAction(
2746
+ `closed ${style.bold(opts.task)} verdict:${style.bold(verdict)} \u2192 closeout ${style.bold(closeout.id)}${taskStatus ? ` ${style.dim(`(task ${taskStatus})`)}` : ""}${view ? ` ${style.dim("\u2192")} ${view}` : ""}`,
2747
+ result,
2748
+ json
2749
+ );
2750
+ });
2751
+ }
2752
+
2411
2753
  // src/commands/continuity.ts
2412
2754
  function registerContinuity(program2) {
2413
2755
  const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
@@ -2568,6 +2910,108 @@ Examples:
2568
2910
  });
2569
2911
  }
2570
2912
 
2913
+ // src/commands/decomposition.ts
2914
+ function registerDecomposition(program2) {
2915
+ const decomposition = program2.command("decomposition").description(
2916
+ "Drive a WLP decomposition: decompose a brief, then execute / accept / reject"
2917
+ );
2918
+ decomposition.addHelpText(
2919
+ "after",
2920
+ `
2921
+ Examples:
2922
+ $ sechroom decomposition decompose mem_XXXX
2923
+ $ sechroom decomposition execute sug_XXXX
2924
+ $ sechroom decomposition publish-run sug_XXXX
2925
+ $ sechroom decomposition accept sug_XXXX
2926
+ $ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
2927
+ );
2928
+ decomposition.command("decompose <briefId>").description(
2929
+ "Decompose a work brief into a candidate Task graph (POST /work-briefs/{id}/decompose)"
2930
+ ).action(async (briefId, _opts, cmd) => {
2931
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2932
+ const data = await runApi("Queueing decomposition", async () => {
2933
+ const client = await makeClient(cfg);
2934
+ return client.POST("/work-briefs/{id}/decompose", {
2935
+ params: { path: { id: briefId } },
2936
+ body: { id: briefId }
2937
+ });
2938
+ });
2939
+ emitAction(
2940
+ `queued decomposition of ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
2941
+ data,
2942
+ cmd.optsWithGlobals().json
2943
+ );
2944
+ });
2945
+ decomposition.command("execute <decompositionId>").description(
2946
+ "Execute a decomposition's Task graph (POST /decompositions/{id}/execute)"
2947
+ ).action(async (decompositionId, _opts, cmd) => {
2948
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2949
+ const data = await runApi("Executing decomposition", async () => {
2950
+ const client = await makeClient(cfg);
2951
+ return client.POST("/decompositions/{id}/execute", {
2952
+ params: { path: { id: decompositionId } },
2953
+ body: {}
2954
+ });
2955
+ });
2956
+ emitAction(
2957
+ `executed ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
2958
+ data,
2959
+ cmd.optsWithGlobals().json
2960
+ );
2961
+ });
2962
+ decomposition.command("publish-run <decompositionId>").description(
2963
+ "Publish an accepted decomposition's context pack on demand (POST /decompositions/{id}/publish-run)"
2964
+ ).action(async (decompositionId, _opts, cmd) => {
2965
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2966
+ const data = await runApi("Publishing context pack", async () => {
2967
+ const client = await makeClient(cfg);
2968
+ return client.POST("/decompositions/{id}/publish-run", {
2969
+ params: { path: { id: decompositionId } },
2970
+ body: {}
2971
+ });
2972
+ });
2973
+ emitAction(
2974
+ `published ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
2975
+ data,
2976
+ cmd.optsWithGlobals().json
2977
+ );
2978
+ });
2979
+ decomposition.command("accept <decompositionId>").description(
2980
+ "Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
2981
+ ).action(async (decompositionId, _opts, cmd) => {
2982
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2983
+ const data = await runApi("Accepting decomposition", async () => {
2984
+ const client = await makeClient(cfg);
2985
+ return client.POST("/decompositions/{id}/accept", {
2986
+ params: { path: { id: decompositionId } },
2987
+ body: {}
2988
+ });
2989
+ });
2990
+ emitAction(
2991
+ `accepted decomposition ${style.bold(decompositionId)}`,
2992
+ data,
2993
+ cmd.optsWithGlobals().json
2994
+ );
2995
+ });
2996
+ decomposition.command("reject <decompositionId>").description(
2997
+ "Reject a Pending decomposition \u2014 archive its Tasks, bounce the brief (POST /decompositions/{id}/reject)"
2998
+ ).option("--reason <reason>", "Optional free-text rejection reason").action(async (decompositionId, opts, cmd) => {
2999
+ const cfg = resolveConfig(cmd.optsWithGlobals());
3000
+ const data = await runApi("Rejecting decomposition", async () => {
3001
+ const client = await makeClient(cfg);
3002
+ return client.POST("/decompositions/{id}/reject", {
3003
+ params: { path: { id: decompositionId } },
3004
+ body: { reasonText: opts.reason ?? null }
3005
+ });
3006
+ });
3007
+ emitAction(
3008
+ `rejected decomposition ${style.bold(decompositionId)}`,
3009
+ data,
3010
+ cmd.optsWithGlobals().json
3011
+ );
3012
+ });
3013
+ }
3014
+
2571
3015
  // src/commands/filing.ts
2572
3016
  function registerFiling(program2) {
2573
3017
  const filing = program2.command("filing").description("Review and act on filing suggestions");
@@ -3079,7 +3523,7 @@ Examples:
3079
3523
 
3080
3524
  // src/setup/apply.ts
3081
3525
  import { createHash as createHash3 } from "crypto";
3082
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3526
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3083
3527
  import { dirname as dirname8 } from "path";
3084
3528
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
3085
3529
  var MARKER_END = "<!-- @sechroom/cli:end";
@@ -3137,7 +3581,7 @@ function ensureDir2(path) {
3137
3581
  }
3138
3582
  function readOr(path, fallback) {
3139
3583
  try {
3140
- return readFileSync7(path, "utf8");
3584
+ return readFileSync8(path, "utf8");
3141
3585
  } catch {
3142
3586
  return fallback;
3143
3587
  }
@@ -3148,7 +3592,7 @@ function mergeMcpJson(path, snippet, dryRun) {
3148
3592
  let current = {};
3149
3593
  if (existed) {
3150
3594
  try {
3151
- current = JSON.parse(readFileSync7(path, "utf8"));
3595
+ current = JSON.parse(readFileSync8(path, "utf8"));
3152
3596
  } catch {
3153
3597
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
3154
3598
  }
@@ -3864,7 +4308,7 @@ import { basename as basename2, join as join13 } from "path";
3864
4308
 
3865
4309
  // src/commands/fanout.ts
3866
4310
  import { spawnSync } from "child_process";
3867
- import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4311
+ import { existsSync as existsSync10, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
3868
4312
  import { isAbsolute, join as join12, resolve } from "path";
3869
4313
  var ICON = {
3870
4314
  refresh: "\u21BB",
@@ -3899,7 +4343,7 @@ function readManifest(path) {
3899
4343
  if (!existsSync10(path)) return null;
3900
4344
  let parsed;
3901
4345
  try {
3902
- parsed = JSON.parse(readFileSync8(path, "utf8"));
4346
+ parsed = JSON.parse(readFileSync9(path, "utf8"));
3903
4347
  } catch (err2) {
3904
4348
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
3905
4349
  }
@@ -4713,24 +5157,45 @@ Examples:
4713
5157
  $ sechroom relationship suggestions --status Pending --memory mem_XXXX
4714
5158
  $ sechroom relationship suggestion accept rsg_XXXX`
4715
5159
  );
4716
- relationship.command("create <fromMemoryId> <toMemoryId>").description("Create a relationship (POST /memories/{memoryId}/relationships)").option("--type <type>", "Relationship type (Reference, Related, Parent, Child, Follows, \u2026)", "Reference").action(async (fromMemoryId, toMemoryId, opts, cmd) => {
5160
+ relationship.command("create <fromMemoryId> <toMemoryId>").description("Create a relationship (POST /memories/{memoryId}/relationships)").option("--type <type>", "Relationship type (Reference, Related, Parent, Child, Follows, \u2026)", "Reference").option(
5161
+ "--to-version <number>",
5162
+ "Version of the target memory to pin the edge to (defaults to the target's current version)"
5163
+ ).action(async (fromMemoryId, toMemoryId, opts, cmd) => {
4717
5164
  const cfg = resolveConfig(cmd.optsWithGlobals());
4718
- const data = await runApi("Creating relationship", async () => {
4719
- const client = await makeClient(cfg);
4720
- return client.POST("/memories/{memoryId}/relationships", {
5165
+ const client = await makeClient(cfg);
5166
+ let toVersion;
5167
+ if (opts.toVersion !== void 0) {
5168
+ toVersion = Number(opts.toVersion);
5169
+ if (!Number.isInteger(toVersion) || toVersion < 1) {
5170
+ fail(`--to-version must be a positive integer (got '${opts.toVersion}').`);
5171
+ }
5172
+ } else {
5173
+ const target = await runApi(
5174
+ "Resolving target version",
5175
+ async () => client.GET("/memories/{memoryId}", { params: { path: { memoryId: toMemoryId } } })
5176
+ );
5177
+ if (typeof target.item?.currentVersion !== "number") {
5178
+ fail(`Could not resolve the current version of ${toMemoryId}; pass --to-version explicitly.`);
5179
+ }
5180
+ toVersion = target.item.currentVersion;
5181
+ }
5182
+ const data = await runApi(
5183
+ "Creating relationship",
5184
+ async () => client.POST("/memories/{memoryId}/relationships", {
4721
5185
  params: { path: { memoryId: fromMemoryId } },
4722
5186
  body: {
4723
5187
  toMemoryId,
4724
- type: opts.type
5188
+ type: opts.type,
5189
+ toVersion
4725
5190
  }
4726
- });
4727
- });
5191
+ })
5192
+ );
4728
5193
  const inversePart = data.inverseId ? ` ${style.dim(`(inverse ${data.inverseId})`)}` : "";
4729
5194
  const view = resolveViewUrl(cfg.baseUrl, data.url);
4730
5195
  const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
4731
5196
  emitAction(
4732
- `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId}`)}${inversePart}${urlPart}`,
4733
- data,
5197
+ `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId} @v${toVersion}`)}${inversePart}${urlPart}`,
5198
+ { ...data, toVersion },
4734
5199
  cmd.optsWithGlobals().json
4735
5200
  );
4736
5201
  });
@@ -4854,7 +5319,7 @@ Examples:
4854
5319
  // src/commands/reset.ts
4855
5320
  import { homedir as homedir4 } from "os";
4856
5321
  import { join as join14 } from "path";
4857
- import { existsSync as existsSync12, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs";
5322
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
4858
5323
  var SKILLS_LOCK2 = ".sechroom-skills.json";
4859
5324
  var localSkillsDir = () => join14(process.cwd(), ".claude", "skills");
4860
5325
  var globalSkillsDir = () => join14(homedir4(), ".claude", "skills");
@@ -4865,7 +5330,7 @@ function removeMaterialisedSkills(dir) {
4865
5330
  const lockPath = join14(dir, SKILLS_LOCK2);
4866
5331
  if (!existsSync12(lockPath)) return removed;
4867
5332
  try {
4868
- const lock = JSON.parse(readFileSync9(lockPath, "utf8"));
5333
+ const lock = JSON.parse(readFileSync10(lockPath, "utf8"));
4869
5334
  for (const entry of Object.values(lock)) {
4870
5335
  for (const name of entry.skills ?? []) {
4871
5336
  const p = join14(dir, name);
@@ -5265,7 +5730,7 @@ Examples:
5265
5730
  import {
5266
5731
  existsSync as existsSync15,
5267
5732
  mkdirSync as mkdirSync12,
5268
- readFileSync as readFileSync10,
5733
+ readFileSync as readFileSync11,
5269
5734
  rmSync as rmSync4,
5270
5735
  writeFileSync as writeFileSync12
5271
5736
  } from "fs";
@@ -5371,7 +5836,7 @@ function registerTelemetry(program2) {
5371
5836
  );
5372
5837
  });
5373
5838
  telemetry.command("hook").description(
5374
- "Per-turn telemetry self-report for a Claude Code Stop hook (reads stdin; no-op unless bound). Fail-soft."
5839
+ "Per-turn telemetry self-report for Claude Code hooks \u2014 Stop/SubagentStop \u2192 parsed + terminal, Notification/PermissionDenied \u2192 approval (reads stdin; no-op unless bound). Fail-soft."
5375
5840
  ).action(async (_opts, cmd) => {
5376
5841
  try {
5377
5842
  const raw = await readStdin2();
@@ -5380,21 +5845,10 @@ function registerTelemetry(program2) {
5380
5845
  const binding = findBinding(cwd);
5381
5846
  if (!binding) return process.exit(0);
5382
5847
  const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
5383
- if (!usage) return process.exit(0);
5848
+ const events = buildHookEvents(input, usage, binding.taskId);
5849
+ if (events.length === 0) return process.exit(0);
5384
5850
  const cfg = resolveConfig(cmd.optsWithGlobals());
5385
- await postTelemetry(cfg, binding.decompositionId, [
5386
- {
5387
- taskId: binding.taskId,
5388
- kind: "Parsed",
5389
- tokensIn: usage.tokensIn,
5390
- tokensOut: usage.tokensOut,
5391
- contextUsed: usage.contextUsed,
5392
- contextWindow: usage.contextWindow,
5393
- text: null,
5394
- approvalState: null,
5395
- verdict: null
5396
- }
5397
- ]);
5851
+ await postTelemetry(cfg, binding.decompositionId, events);
5398
5852
  return process.exit(0);
5399
5853
  } catch {
5400
5854
  return process.exit(0);
@@ -5422,7 +5876,12 @@ function registerTelemetry(program2) {
5422
5876
  scope,
5423
5877
  cwd
5424
5878
  });
5425
- const commands = { Stop: "sechroom telemetry hook" };
5879
+ const commands = {
5880
+ Stop: "sechroom telemetry hook",
5881
+ SubagentStop: "sechroom telemetry hook",
5882
+ Notification: "sechroom telemetry hook",
5883
+ PermissionDenied: "sechroom telemetry hook"
5884
+ };
5426
5885
  try {
5427
5886
  const multi = targets.length > 1;
5428
5887
  const results = targets.map((t) => {
@@ -5480,7 +5939,7 @@ function findBinding(start) {
5480
5939
  if (existsSync15(path)) {
5481
5940
  try {
5482
5941
  const b = JSON.parse(
5483
- readFileSync10(path, "utf8")
5942
+ readFileSync11(path, "utf8")
5484
5943
  );
5485
5944
  if (b.decompositionId && b.taskId)
5486
5945
  return { decompositionId: b.decompositionId, taskId: b.taskId };
@@ -5499,7 +5958,7 @@ function parseTranscript(path) {
5499
5958
  let tokensOut = 0;
5500
5959
  let contextUsed = 0;
5501
5960
  let model = "";
5502
- for (const line of readFileSync10(path, "utf8").split("\n")) {
5961
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
5503
5962
  if (!line.trim()) continue;
5504
5963
  let obj;
5505
5964
  try {
@@ -5516,11 +5975,61 @@ function parseTranscript(path) {
5516
5975
  if (obj.message?.model) model = obj.message.model;
5517
5976
  }
5518
5977
  if (tokensIn === 0 && tokensOut === 0) return null;
5519
- return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model) };
5978
+ return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
5520
5979
  }
5521
- function windowFor(model) {
5980
+ function windowFor(model, contextUsed = 0) {
5522
5981
  const m = model.toLowerCase();
5523
- return m.includes("[1m]") || m.includes("-1m") ? 1e6 : 2e5;
5982
+ if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
5983
+ return contextUsed > 2e5 ? 1e6 : 2e5;
5984
+ }
5985
+ function buildHookEvents(input, usage, taskId) {
5986
+ const events = [];
5987
+ const base = (kind, over) => ({
5988
+ taskId,
5989
+ kind,
5990
+ tokensIn: null,
5991
+ tokensOut: null,
5992
+ contextUsed: null,
5993
+ contextWindow: null,
5994
+ text: null,
5995
+ approvalState: null,
5996
+ verdict: null,
5997
+ ...over
5998
+ });
5999
+ if (usage) {
6000
+ events.push(
6001
+ base("Parsed", {
6002
+ tokensIn: usage.tokensIn,
6003
+ tokensOut: usage.tokensOut,
6004
+ contextUsed: usage.contextUsed,
6005
+ contextWindow: usage.contextWindow
6006
+ })
6007
+ );
6008
+ }
6009
+ switch (input.hook_event_name) {
6010
+ case "PermissionDenied":
6011
+ events.push(
6012
+ base("Approval", {
6013
+ approvalState: "denied",
6014
+ text: input.tool_name ?? input.message ?? null
6015
+ })
6016
+ );
6017
+ break;
6018
+ case "Notification":
6019
+ if (isPermissionNotification(input))
6020
+ events.push(base("Approval", { text: input.message ?? null }));
6021
+ break;
6022
+ case "Stop":
6023
+ case "SubagentStop":
6024
+ events.push(base("Terminal", { text: input.last_assistant_message ?? null }));
6025
+ break;
6026
+ }
6027
+ return events;
6028
+ }
6029
+ function isPermissionNotification(input) {
6030
+ const t = (input.notification_type ?? input.type ?? "").toLowerCase();
6031
+ if (t) return t.includes("permission");
6032
+ return (input.message ?? "").toLowerCase().includes("permission");
5524
6033
  }
5525
6034
  async function readStdin2() {
5526
6035
  if (process.stdin.isTTY) return "";
@@ -5756,7 +6265,7 @@ Examples:
5756
6265
  function resolveVersion() {
5757
6266
  try {
5758
6267
  const pkg = JSON.parse(
5759
- readFileSync11(new URL("../package.json", import.meta.url), "utf8")
6268
+ readFileSync12(new URL("../package.json", import.meta.url), "utf8")
5760
6269
  );
5761
6270
  return pkg.version ?? "0.0.0";
5762
6271
  } catch {
@@ -5914,6 +6423,8 @@ registerLookup(program);
5914
6423
  registerRelationships(program);
5915
6424
  registerWorkspace(program);
5916
6425
  registerProject(program);
6426
+ registerDecomposition(program);
6427
+ registerClose(program);
5917
6428
  registerFiling(program);
5918
6429
  registerContinuity(program);
5919
6430
  registerCheckpoint(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.7.5",
3
+ "version": "2026.7.6-rc.a2828c37",
4
4
  "description": "Sechroom CLI — a thin, generated client over the Sechroom HTTP API. An agent/human surface alongside MCP.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -36,12 +36,14 @@
36
36
  "openapi-typescript": "^7.4.0",
37
37
  "tsup": "^8.3.0",
38
38
  "tsx": "^4.19.0",
39
- "typescript": "^5.6.0"
39
+ "typescript": "^5.6.0",
40
+ "typescript-7": "npm:typescript@^7.0.2"
40
41
  },
41
42
  "scripts": {
42
43
  "gen": "openapi-typescript \"${SECHROOM_OPENAPI_URL:-https://app.sechroom.ai/api/openapi/v1.json}\" -o src/generated/api.d.ts",
43
44
  "build": "tsup src/index.ts --format esm --target node20 --clean",
44
45
  "dev": "tsx src/index.ts",
46
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
45
47
  "check-types": "tsc --noEmit"
46
48
  }
47
49
  }