@sechroom/cli 2026.7.9 → 2026.7.11

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 +287 -22
  2. package/package.json +1 -1
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
@@ -633,7 +633,22 @@ async function runApi(label, fn) {
633
633
  return res.data;
634
634
  }
635
635
  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);
636
+ let msg;
637
+ if (typeof error === "object" && error !== null && "title" in error) {
638
+ const problem = error;
639
+ msg = String(problem.title);
640
+ if (problem.errors && typeof problem.errors === "object") {
641
+ const detail = Object.entries(problem.errors).flatMap(
642
+ ([field, msgs]) => (Array.isArray(msgs) ? msgs : [msgs]).map((m) => ` ${field}: ${String(m)}`)
643
+ );
644
+ if (detail.length > 0) msg += `
645
+ ${detail.join("\n")}`;
646
+ }
647
+ } else if (typeof error === "object" && error !== null) {
648
+ msg = JSON.stringify(error);
649
+ } else {
650
+ msg = String(error);
651
+ }
637
652
  process.stderr.write(`error: ${msg}
638
653
  `);
639
654
  process.exit(1);
@@ -2533,6 +2548,149 @@ Examples:
2533
2548
  });
2534
2549
  }
2535
2550
 
2551
+ // src/commands/close.ts
2552
+ import { readFileSync as readFileSync7 } from "fs";
2553
+ var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
2554
+ function registerClose(program2) {
2555
+ program2.command("close").description(
2556
+ "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)."
2557
+ ).requiredOption("--task <id>", "The WorkTask memory id being closed").requiredOption("--verdict <verdict>", `Verdict: ${VERDICTS.join(" | ")}`).requiredOption(
2558
+ "--workspace <wsp>",
2559
+ "Workspace that owns the closeout memo"
2560
+ ).requiredOption("--title <title>", "Closeout title").option(
2561
+ "--file <path>",
2562
+ "Read the closeout body from a file (default: stdin)"
2563
+ ).option(
2564
+ "--decomposition <id>",
2565
+ "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."
2566
+ ).option(
2567
+ "--no-status-flip",
2568
+ "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.)"
2569
+ ).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
2570
+ "after",
2571
+ `
2572
+ Examples:
2573
+ # Managed run \u2014 reads matchTags from run state, stamps them verbatim, un-parks the run:
2574
+ $ echo "Shipped X. Tests green." | sechroom close --task mem_XXXX --decomposition sug_YYYY \\
2575
+ --verdict pass --workspace wsp_ZZZZ --title "w4_build closeout"
2576
+
2577
+ # Bare task:
2578
+ $ sechroom close --task mem_XXXX --verdict pass --workspace wsp_ZZZZ \\
2579
+ --title "done" --file ./closeout.md`
2580
+ ).action(async (opts, cmd) => {
2581
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2582
+ const json = cmd.optsWithGlobals().json;
2583
+ const verdict = String(opts.verdict);
2584
+ if (!VERDICTS.includes(verdict))
2585
+ fail(
2586
+ `--verdict must be one of ${VERDICTS.join(" | ")} \u2014 got '${opts.verdict}'`
2587
+ );
2588
+ let bodyText;
2589
+ try {
2590
+ bodyText = opts.file ? readFileSync7(opts.file, "utf8") : readFileSync7(0, "utf8");
2591
+ } catch {
2592
+ fail(
2593
+ opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
2594
+ );
2595
+ }
2596
+ if (!bodyText.trim())
2597
+ fail(
2598
+ "closeout body is empty \u2014 pass --file <path> or pipe the body on stdin"
2599
+ );
2600
+ const client = await makeClient(cfg);
2601
+ let matchTags;
2602
+ if (opts.decomposition) {
2603
+ const run = await runApi(
2604
+ "Reading run contract",
2605
+ async () => client.GET("/decompositions/{id}/run", {
2606
+ params: { path: { id: opts.decomposition } }
2607
+ })
2608
+ );
2609
+ const await_ = run.awaitingCompletion;
2610
+ if (!await_ || !Array.isArray(await_.matchTags) || await_.matchTags.length === 0)
2611
+ fail(
2612
+ `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.`
2613
+ );
2614
+ if (run.awaitingTaskId && run.awaitingTaskId !== opts.task)
2615
+ fail(
2616
+ `run ${opts.decomposition} is awaiting task ${run.awaitingTaskId}, not ${opts.task} \u2014 refusing to mis-key the resume.`
2617
+ );
2618
+ matchTags = await_.matchTags;
2619
+ } else {
2620
+ matchTags = [`wlp-task:${opts.task}`];
2621
+ }
2622
+ const tags = [
2623
+ .../* @__PURE__ */ new Set([
2624
+ ...matchTags,
2625
+ `wlp-task:${opts.task}`,
2626
+ `verdict:${verdict}`
2627
+ ])
2628
+ ];
2629
+ const closeout = await runApi(
2630
+ "Creating closeout",
2631
+ async () => client.POST("/memories", {
2632
+ body: {
2633
+ text: bodyText,
2634
+ type: "reference",
2635
+ content: "{}",
2636
+ confidence: 1,
2637
+ source: opts.source,
2638
+ archetype: "Document",
2639
+ title: opts.title,
2640
+ tags,
2641
+ owner: { type: "Workspace", id: opts.workspace }
2642
+ }
2643
+ })
2644
+ );
2645
+ const edge = await runApi(
2646
+ "Wiring Reference edge",
2647
+ async () => client.POST("/memories/{memoryId}/relationships", {
2648
+ params: { path: { memoryId: closeout.id } },
2649
+ body: { toMemoryId: opts.task, type: "Reference" }
2650
+ })
2651
+ );
2652
+ let taskStatus;
2653
+ if (opts.statusFlip !== false && !opts.decomposition) {
2654
+ const current = await runApi(
2655
+ "Reading task tags",
2656
+ async () => client.GET("/memories/{memoryId}", {
2657
+ params: { path: { memoryId: opts.task } }
2658
+ })
2659
+ );
2660
+ const currentTags = current?.item?.tags ?? current?.tags ?? [];
2661
+ const target = verdict === "blocked" ? "status:blocked" : "status:done";
2662
+ const nextTags = [
2663
+ ...new Set(currentTags.filter((t) => !t.startsWith("status:")))
2664
+ ].concat(target);
2665
+ await runApi(
2666
+ "Flipping task status",
2667
+ async () => client.PATCH("/memories/{memoryId}/metadata", {
2668
+ params: { path: { memoryId: opts.task } },
2669
+ body: {
2670
+ memoryId: opts.task,
2671
+ source: opts.source,
2672
+ tags: nextTags
2673
+ }
2674
+ })
2675
+ );
2676
+ taskStatus = target.slice("status:".length);
2677
+ }
2678
+ const view = resolveViewUrl(cfg.baseUrl, closeout.url);
2679
+ const result = {
2680
+ closeoutId: closeout.id,
2681
+ edgeId: edge.id,
2682
+ taskStatus: taskStatus ?? null,
2683
+ verdict,
2684
+ tags
2685
+ };
2686
+ emitAction(
2687
+ `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}` : ""}`,
2688
+ result,
2689
+ json
2690
+ );
2691
+ });
2692
+ }
2693
+
2536
2694
  // src/commands/continuity.ts
2537
2695
  function registerContinuity(program2) {
2538
2696
  const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
@@ -2693,6 +2851,90 @@ Examples:
2693
2851
  });
2694
2852
  }
2695
2853
 
2854
+ // src/commands/decomposition.ts
2855
+ function registerDecomposition(program2) {
2856
+ const decomposition = program2.command("decomposition").description(
2857
+ "Drive a WLP decomposition: decompose a brief, then execute / accept / reject"
2858
+ );
2859
+ decomposition.addHelpText(
2860
+ "after",
2861
+ `
2862
+ Examples:
2863
+ $ sechroom decomposition decompose mem_XXXX
2864
+ $ sechroom decomposition execute sug_XXXX
2865
+ $ sechroom decomposition accept sug_XXXX
2866
+ $ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
2867
+ );
2868
+ decomposition.command("decompose <briefId>").description(
2869
+ "Decompose a work brief into a candidate Task graph (POST /work-briefs/{id}/decompose)"
2870
+ ).action(async (briefId, _opts, cmd) => {
2871
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2872
+ const data = await runApi("Queueing decomposition", async () => {
2873
+ const client = await makeClient(cfg);
2874
+ return client.POST("/work-briefs/{id}/decompose", {
2875
+ params: { path: { id: briefId } },
2876
+ body: { id: briefId }
2877
+ });
2878
+ });
2879
+ emitAction(
2880
+ `queued decomposition of ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
2881
+ data,
2882
+ cmd.optsWithGlobals().json
2883
+ );
2884
+ });
2885
+ decomposition.command("execute <decompositionId>").description(
2886
+ "Execute a decomposition's Task graph (POST /decompositions/{id}/execute)"
2887
+ ).action(async (decompositionId, _opts, cmd) => {
2888
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2889
+ const data = await runApi("Executing decomposition", async () => {
2890
+ const client = await makeClient(cfg);
2891
+ return client.POST("/decompositions/{id}/execute", {
2892
+ params: { path: { id: decompositionId } },
2893
+ body: {}
2894
+ });
2895
+ });
2896
+ emitAction(
2897
+ `executed ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
2898
+ data,
2899
+ cmd.optsWithGlobals().json
2900
+ );
2901
+ });
2902
+ decomposition.command("accept <decompositionId>").description(
2903
+ "Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
2904
+ ).action(async (decompositionId, _opts, cmd) => {
2905
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2906
+ const data = await runApi("Accepting decomposition", async () => {
2907
+ const client = await makeClient(cfg);
2908
+ return client.POST("/decompositions/{id}/accept", {
2909
+ params: { path: { id: decompositionId } },
2910
+ body: {}
2911
+ });
2912
+ });
2913
+ emitAction(
2914
+ `accepted decomposition ${style.bold(decompositionId)}`,
2915
+ data,
2916
+ cmd.optsWithGlobals().json
2917
+ );
2918
+ });
2919
+ decomposition.command("reject <decompositionId>").description(
2920
+ "Reject a Pending decomposition \u2014 archive its Tasks, bounce the brief (POST /decompositions/{id}/reject)"
2921
+ ).option("--reason <reason>", "Optional free-text rejection reason").action(async (decompositionId, opts, cmd) => {
2922
+ const cfg = resolveConfig(cmd.optsWithGlobals());
2923
+ const data = await runApi("Rejecting decomposition", async () => {
2924
+ const client = await makeClient(cfg);
2925
+ return client.POST("/decompositions/{id}/reject", {
2926
+ params: { path: { id: decompositionId } },
2927
+ body: { reasonText: opts.reason ?? null }
2928
+ });
2929
+ });
2930
+ emitAction(
2931
+ `rejected decomposition ${style.bold(decompositionId)}`,
2932
+ data,
2933
+ cmd.optsWithGlobals().json
2934
+ );
2935
+ });
2936
+ }
2937
+
2696
2938
  // src/commands/filing.ts
2697
2939
  function registerFiling(program2) {
2698
2940
  const filing = program2.command("filing").description("Review and act on filing suggestions");
@@ -3204,7 +3446,7 @@ Examples:
3204
3446
 
3205
3447
  // src/setup/apply.ts
3206
3448
  import { createHash as createHash3 } from "crypto";
3207
- import { mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3449
+ import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
3208
3450
  import { dirname as dirname8 } from "path";
3209
3451
  var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
3210
3452
  var MARKER_END = "<!-- @sechroom/cli:end";
@@ -3262,7 +3504,7 @@ function ensureDir2(path) {
3262
3504
  }
3263
3505
  function readOr(path, fallback) {
3264
3506
  try {
3265
- return readFileSync7(path, "utf8");
3507
+ return readFileSync8(path, "utf8");
3266
3508
  } catch {
3267
3509
  return fallback;
3268
3510
  }
@@ -3273,7 +3515,7 @@ function mergeMcpJson(path, snippet, dryRun) {
3273
3515
  let current = {};
3274
3516
  if (existed) {
3275
3517
  try {
3276
- current = JSON.parse(readFileSync7(path, "utf8"));
3518
+ current = JSON.parse(readFileSync8(path, "utf8"));
3277
3519
  } catch {
3278
3520
  return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
3279
3521
  }
@@ -3989,7 +4231,7 @@ import { basename as basename2, join as join13 } from "path";
3989
4231
 
3990
4232
  // src/commands/fanout.ts
3991
4233
  import { spawnSync } from "child_process";
3992
- import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
4234
+ import { existsSync as existsSync10, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
3993
4235
  import { isAbsolute, join as join12, resolve } from "path";
3994
4236
  var ICON = {
3995
4237
  refresh: "\u21BB",
@@ -4024,7 +4266,7 @@ function readManifest(path) {
4024
4266
  if (!existsSync10(path)) return null;
4025
4267
  let parsed;
4026
4268
  try {
4027
- parsed = JSON.parse(readFileSync8(path, "utf8"));
4269
+ parsed = JSON.parse(readFileSync9(path, "utf8"));
4028
4270
  } catch (err2) {
4029
4271
  throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
4030
4272
  }
@@ -4838,24 +5080,45 @@ Examples:
4838
5080
  $ sechroom relationship suggestions --status Pending --memory mem_XXXX
4839
5081
  $ sechroom relationship suggestion accept rsg_XXXX`
4840
5082
  );
4841
- 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) => {
5083
+ 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(
5084
+ "--to-version <number>",
5085
+ "Version of the target memory to pin the edge to (defaults to the target's current version)"
5086
+ ).action(async (fromMemoryId, toMemoryId, opts, cmd) => {
4842
5087
  const cfg = resolveConfig(cmd.optsWithGlobals());
4843
- const data = await runApi("Creating relationship", async () => {
4844
- const client = await makeClient(cfg);
4845
- return client.POST("/memories/{memoryId}/relationships", {
5088
+ const client = await makeClient(cfg);
5089
+ let toVersion;
5090
+ if (opts.toVersion !== void 0) {
5091
+ toVersion = Number(opts.toVersion);
5092
+ if (!Number.isInteger(toVersion) || toVersion < 1) {
5093
+ fail(`--to-version must be a positive integer (got '${opts.toVersion}').`);
5094
+ }
5095
+ } else {
5096
+ const target = await runApi(
5097
+ "Resolving target version",
5098
+ async () => client.GET("/memories/{memoryId}", { params: { path: { memoryId: toMemoryId } } })
5099
+ );
5100
+ if (typeof target.item?.currentVersion !== "number") {
5101
+ fail(`Could not resolve the current version of ${toMemoryId}; pass --to-version explicitly.`);
5102
+ }
5103
+ toVersion = target.item.currentVersion;
5104
+ }
5105
+ const data = await runApi(
5106
+ "Creating relationship",
5107
+ async () => client.POST("/memories/{memoryId}/relationships", {
4846
5108
  params: { path: { memoryId: fromMemoryId } },
4847
5109
  body: {
4848
5110
  toMemoryId,
4849
- type: opts.type
5111
+ type: opts.type,
5112
+ toVersion
4850
5113
  }
4851
- });
4852
- });
5114
+ })
5115
+ );
4853
5116
  const inversePart = data.inverseId ? ` ${style.dim(`(inverse ${data.inverseId})`)}` : "";
4854
5117
  const view = resolveViewUrl(cfg.baseUrl, data.url);
4855
5118
  const urlPart = view ? ` ${style.dim("\u2192")} ${view}` : "";
4856
5119
  emitAction(
4857
- `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId}`)}${inversePart}${urlPart}`,
4858
- data,
5120
+ `created relationship ${style.bold(data.id)} ${style.dim(`${fromMemoryId} \u2192 ${toMemoryId} @v${toVersion}`)}${inversePart}${urlPart}`,
5121
+ { ...data, toVersion },
4859
5122
  cmd.optsWithGlobals().json
4860
5123
  );
4861
5124
  });
@@ -4979,7 +5242,7 @@ Examples:
4979
5242
  // src/commands/reset.ts
4980
5243
  import { homedir as homedir4 } from "os";
4981
5244
  import { join as join14 } from "path";
4982
- import { existsSync as existsSync12, readFileSync as readFileSync9, rmSync as rmSync3 } from "fs";
5245
+ import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
4983
5246
  var SKILLS_LOCK2 = ".sechroom-skills.json";
4984
5247
  var localSkillsDir = () => join14(process.cwd(), ".claude", "skills");
4985
5248
  var globalSkillsDir = () => join14(homedir4(), ".claude", "skills");
@@ -4990,7 +5253,7 @@ function removeMaterialisedSkills(dir) {
4990
5253
  const lockPath = join14(dir, SKILLS_LOCK2);
4991
5254
  if (!existsSync12(lockPath)) return removed;
4992
5255
  try {
4993
- const lock = JSON.parse(readFileSync9(lockPath, "utf8"));
5256
+ const lock = JSON.parse(readFileSync10(lockPath, "utf8"));
4994
5257
  for (const entry of Object.values(lock)) {
4995
5258
  for (const name of entry.skills ?? []) {
4996
5259
  const p = join14(dir, name);
@@ -5390,7 +5653,7 @@ Examples:
5390
5653
  import {
5391
5654
  existsSync as existsSync15,
5392
5655
  mkdirSync as mkdirSync12,
5393
- readFileSync as readFileSync10,
5656
+ readFileSync as readFileSync11,
5394
5657
  rmSync as rmSync4,
5395
5658
  writeFileSync as writeFileSync12
5396
5659
  } from "fs";
@@ -5605,7 +5868,7 @@ function findBinding(start) {
5605
5868
  if (existsSync15(path)) {
5606
5869
  try {
5607
5870
  const b = JSON.parse(
5608
- readFileSync10(path, "utf8")
5871
+ readFileSync11(path, "utf8")
5609
5872
  );
5610
5873
  if (b.decompositionId && b.taskId)
5611
5874
  return { decompositionId: b.decompositionId, taskId: b.taskId };
@@ -5624,7 +5887,7 @@ function parseTranscript(path) {
5624
5887
  let tokensOut = 0;
5625
5888
  let contextUsed = 0;
5626
5889
  let model = "";
5627
- for (const line of readFileSync10(path, "utf8").split("\n")) {
5890
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
5628
5891
  if (!line.trim()) continue;
5629
5892
  let obj;
5630
5893
  try {
@@ -5882,7 +6145,7 @@ Examples:
5882
6145
  function resolveVersion() {
5883
6146
  try {
5884
6147
  const pkg = JSON.parse(
5885
- readFileSync11(new URL("../package.json", import.meta.url), "utf8")
6148
+ readFileSync12(new URL("../package.json", import.meta.url), "utf8")
5886
6149
  );
5887
6150
  return pkg.version ?? "0.0.0";
5888
6151
  } catch {
@@ -6040,6 +6303,8 @@ registerLookup(program);
6040
6303
  registerRelationships(program);
6041
6304
  registerWorkspace(program);
6042
6305
  registerProject(program);
6306
+ registerDecomposition(program);
6307
+ registerClose(program);
6043
6308
  registerFiling(program);
6044
6309
  registerContinuity(program);
6045
6310
  registerCheckpoint(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.7.9",
3
+ "version": "2026.7.11",
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",