@sechroom/cli 2026.9.1 → 2026.9.2

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 +309 -136
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1234,7 +1234,7 @@ ${mem.text.trim()}`
1234
1234
  if (parts.length === 0) return null;
1235
1235
  return { body: parts.join("\n\n"), refs };
1236
1236
  }
1237
- async function createOverride(cfg, template, personalWorkspaceId) {
1237
+ async function createOverride(cfg, template, personalWorkspaceId, source) {
1238
1238
  const client = await makeClient(cfg);
1239
1239
  const overrideTags = template.templateTags.filter(
1240
1240
  (t) => t !== "sechroom:role:template" && !t.startsWith("sechroom:bundle:") && !t.startsWith("sechroom:template-ref:")
@@ -1249,7 +1249,7 @@ async function createOverride(cfg, template, personalWorkspaceId) {
1249
1249
  type: "reference",
1250
1250
  content: "{}",
1251
1251
  confidence: 1,
1252
- source: "cli-agent-instructions-customize",
1252
+ source,
1253
1253
  archetype: "Document",
1254
1254
  title: template.title ?? null,
1255
1255
  tags: overrideTags,
@@ -7031,9 +7031,11 @@ function registerChannel(program2) {
7031
7031
  (typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
7032
7032
  )
7033
7033
  );
7034
+ const authExit = createAuthExpiredExit();
7034
7035
  const leaseHeartbeats = createChannelLeaseHeartbeatManager(
7035
7036
  cfg,
7036
- located.state.taskLeaseTtlSeconds ?? 120
7037
+ located.state.taskLeaseTtlSeconds ?? 120,
7038
+ { onAuthExpired: authExit.onAuthExpired }
7037
7039
  );
7038
7040
  const drain = createClaimDrain(cfg, instance.id, deliver, {
7039
7041
  onClaimed: leaseHeartbeats.onClaimed
@@ -7072,13 +7074,11 @@ function registerChannel(program2) {
7072
7074
  ) + style.dim("streaming matched events to stdout; Ctrl-C to stop.\n")
7073
7075
  );
7074
7076
  }
7075
- try {
7076
- await holdOpen(conn);
7077
- } finally {
7078
- stopReconciliation();
7079
- stopHeartbeat();
7080
- leaseHeartbeats.stop();
7081
- }
7077
+ await holdChannelOpen(conn, authExit.released, [
7078
+ stopReconciliation,
7079
+ stopHeartbeat,
7080
+ leaseHeartbeats.stop
7081
+ ]);
7082
7082
  });
7083
7083
  channel.command("mcp").description(
7084
7084
  "Run as a Claude Code channel (local-stdio MCP server) \u2014 claim and push dispatched tasks into the session"
@@ -7101,9 +7101,11 @@ function registerChannel(program2) {
7101
7101
  params: { content, meta }
7102
7102
  });
7103
7103
  });
7104
+ const authExit = createAuthExpiredExit();
7104
7105
  const leaseHeartbeats = createChannelLeaseHeartbeatManager(
7105
7106
  cfg,
7106
- located.state.taskLeaseTtlSeconds ?? 120
7107
+ located.state.taskLeaseTtlSeconds ?? 120,
7108
+ { onAuthExpired: authExit.onAuthExpired }
7107
7109
  );
7108
7110
  const drain = createClaimDrain(cfg, instance.id, deliver, {
7109
7111
  onClaimed: leaseHeartbeats.onClaimed
@@ -7129,13 +7131,16 @@ function registerChannel(program2) {
7129
7131
  `
7130
7132
  )
7131
7133
  );
7132
- try {
7133
- await holdOpen(conn);
7134
- } finally {
7135
- stopReconciliation();
7136
- stopHeartbeat();
7137
- leaseHeartbeats.stop();
7138
- }
7134
+ await holdChannelOpen(conn, authExit.released, [
7135
+ stopReconciliation,
7136
+ stopHeartbeat,
7137
+ leaseHeartbeats.stop,
7138
+ // The stdio transport keeps a stdin `data` listener, which holds the event
7139
+ // loop open for as long as the parent holds the pipe. Without this close, a
7140
+ // `process.exitCode` set on the auth path never becomes an exit and the
7141
+ // parent sees a connected-but-inert channel instead of a dead one.
7142
+ () => mcp.close()
7143
+ ]);
7139
7144
  });
7140
7145
  channel.command("install").description(
7141
7146
  "Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
@@ -7239,6 +7244,7 @@ function startExecutorHeartbeat(refresh, intervalMilliseconds, dependencies = {}
7239
7244
  }
7240
7245
  var leaseHeartbeatApi = (cfg, path, init, deps = {}) => createAuthedRequest(cfg, deps)(path, init);
7241
7246
  function classifyLeaseHeartbeatFailure(error) {
7247
+ if (error instanceof AuthExpiredError) return "auth";
7242
7248
  if (!(error instanceof HttpError)) return "retry";
7243
7249
  if (error.status === 408 || error.status === 429) return "retry";
7244
7250
  if (error.status < 400 || error.status >= 500) return "retry";
@@ -7246,6 +7252,10 @@ function classifyLeaseHeartbeatFailure(error) {
7246
7252
  return "released";
7247
7253
  return "terminal";
7248
7254
  }
7255
+ function authExpiredLine(error) {
7256
+ const detail = error instanceof Error ? error.message : String(error);
7257
+ return `auth expired \u2014 run \`sechroom login\` to re-authenticate: ${detail}`;
7258
+ }
7249
7259
  function leaseHeartbeatStoppedLine(leaseId, error) {
7250
7260
  if (!(error instanceof HttpError))
7251
7261
  return `lease heartbeat stopped for ${leaseId}: ${String(error)}`;
@@ -7288,6 +7298,10 @@ function startChannelTaskLeaseHeartbeat(cfg, claim, intervalMilliseconds = 3e4,
7288
7298
  if (disposition === "retry") throw error;
7289
7299
  stop?.();
7290
7300
  dependencies.onLeaseTerminal?.(leaseId, error);
7301
+ if (disposition === "auth") {
7302
+ dependencies.onAuthExpired?.(error);
7303
+ return void 0;
7304
+ }
7291
7305
  if (disposition === "terminal")
7292
7306
  onError(leaseHeartbeatStoppedLine(leaseId, error));
7293
7307
  return void 0;
@@ -7301,6 +7315,7 @@ function startChannelTaskLeaseHeartbeat(cfg, claim, intervalMilliseconds = 3e4,
7301
7315
  }
7302
7316
  function createChannelLeaseHeartbeatManager(cfg, taskLeaseTtlSeconds, dependencies = {}) {
7303
7317
  const stops = /* @__PURE__ */ new Map();
7318
+ let authExpired = false;
7304
7319
  const intervalMilliseconds = Math.max(
7305
7320
  1e3,
7306
7321
  Math.min(3e4, Math.floor(taskLeaseTtlSeconds * 1e3 / 4))
@@ -7320,6 +7335,16 @@ function createChannelLeaseHeartbeatManager(cfg, taskLeaseTtlSeconds, dependenci
7320
7335
  // map does not accumulate stopped leases for the life of the channel.
7321
7336
  onLeaseTerminal: (id) => {
7322
7337
  stops.delete(id);
7338
+ },
7339
+ // A dead credential fails every lease at once, so N held leases would
7340
+ // otherwise raise this N times in the same tick. Latch it: stop every beat
7341
+ // (not just the one that noticed), then escalate exactly once.
7342
+ onAuthExpired: (error) => {
7343
+ if (authExpired) return;
7344
+ authExpired = true;
7345
+ for (const cancel of stops.values()) cancel();
7346
+ stops.clear();
7347
+ dependencies.onAuthExpired?.(error);
7323
7348
  }
7324
7349
  }
7325
7350
  );
@@ -7414,15 +7439,50 @@ async function openConnection(cfg, onEvent, executorInstanceId) {
7414
7439
  await conn.start();
7415
7440
  return conn;
7416
7441
  }
7417
- function holdOpen(conn) {
7442
+ function holdOpen(conn, until) {
7418
7443
  return new Promise((resolve9) => {
7419
7444
  const stop = () => {
7420
7445
  void conn.stop().finally(resolve9);
7421
7446
  };
7422
7447
  process.on("SIGINT", stop);
7423
7448
  process.on("SIGTERM", stop);
7449
+ void until?.then(stop);
7424
7450
  });
7425
7451
  }
7452
+ async function holdChannelOpen(conn, released, teardown) {
7453
+ try {
7454
+ await holdOpen(conn, released);
7455
+ } finally {
7456
+ let firstError;
7457
+ let failed = false;
7458
+ for (const step of teardown) {
7459
+ try {
7460
+ await step();
7461
+ } catch (error) {
7462
+ if (!failed) {
7463
+ failed = true;
7464
+ firstError = error;
7465
+ }
7466
+ }
7467
+ }
7468
+ if (failed) throw firstError;
7469
+ }
7470
+ }
7471
+ function createAuthExpiredExit() {
7472
+ let release;
7473
+ const released = new Promise((resolve9) => {
7474
+ release = resolve9;
7475
+ });
7476
+ return {
7477
+ onAuthExpired: (error) => {
7478
+ process.stderr.write(err(`${authExpiredLine(error)}
7479
+ `));
7480
+ process.exitCode = 1;
7481
+ release();
7482
+ },
7483
+ released
7484
+ };
7485
+ }
7426
7486
  function parseEvent(payload) {
7427
7487
  let data = payload;
7428
7488
  if (typeof payload === "string") {
@@ -7477,92 +7537,6 @@ function str(v) {
7477
7537
  return typeof v === "string" ? v : v == null ? "" : String(v);
7478
7538
  }
7479
7539
 
7480
- // src/commands/chat.ts
7481
- function registerChat(program2) {
7482
- const chat = program2.command("chat").description("Send and read Slack / Discord channel messages").option("--surface <surface>", "slack | discord", "slack");
7483
- chat.addHelpText(
7484
- "after",
7485
- `
7486
- Examples:
7487
- $ sechroom chat send C0123456789 "deploy is green" --surface slack
7488
- $ sechroom chat send 987654321098765432 "deploy is green" --surface discord --guild 123456789012345678
7489
- $ sechroom chat send C0123456789 "lgtm" --surface slack --as user --parent 1718049600.123456
7490
- $ sechroom chat messages --surface slack
7491
- $ sechroom chat replies 1718049600.123456 --surface slack
7492
- $ sechroom chat stop-tracking 1718049600.123456 --surface slack`
7493
- );
7494
- chat.command("send <channelId> <text>").description("Send a message to a channel (POST /chat/channel-messages/{surface})").option("--guild <guildId>", "Discord guild snowflake \u2014 required for --surface discord").option("--memory <memoryId>", "Attach a sechroom memory id").option("--no-track", "Don't capture replies to this message").option("--parent <parentMessage>", "Thread under a parent (Slack thread_ts / Discord message id)").option("--source <source>", "Source / lane stamp (renders an attribution footer)", "cli").option("--as <as>", "Slack only: 'bot' (default) or 'user' (your linked Slack identity)", "bot").action(async (channelId, text2, opts, cmd) => {
7495
- const { surface, ...globals } = cmd.optsWithGlobals();
7496
- const json = Boolean(cmd.optsWithGlobals().json);
7497
- const cfg = resolveConfig(globals);
7498
- const data = await runApi("Sending message", async () => {
7499
- const client = await makeClient(cfg);
7500
- return client.POST("/chat/channel-messages/{surface}", {
7501
- params: { path: { surface: String(surface) } },
7502
- body: {
7503
- channelId,
7504
- text: text2,
7505
- guildId: opts.guild ?? null,
7506
- attachedMemoryId: opts.memory ?? null,
7507
- trackReplies: opts.track,
7508
- parentMessage: opts.parent ?? null,
7509
- source: opts.source,
7510
- as: opts.as
7511
- }
7512
- });
7513
- });
7514
- if (!data.ok) {
7515
- if (json) {
7516
- emit(data, true);
7517
- } else {
7518
- process.stderr.write(
7519
- `${err("\u2717")} send failed: ${data.upstreamError ?? "error"}${data.errorDescription ? ` \u2014 ${data.errorDescription}` : ""}
7520
- `
7521
- );
7522
- }
7523
- process.exit(1);
7524
- }
7525
- const idPart = data.persistedId ? ` ${style.dim(`(${data.persistedId})`)}` : "";
7526
- emitAction(`sent to ${surface} ${style.bold(channelId)}${idPart}`, data, json);
7527
- });
7528
- chat.command("messages").description("List recent channel messages (GET /chat/channel-messages/{surface})").action(async (_opts, cmd) => {
7529
- const { surface, ...globals } = cmd.optsWithGlobals();
7530
- const cfg = resolveConfig(globals);
7531
- const data = await runApi("Fetching messages", async () => {
7532
- const client = await makeClient(cfg);
7533
- return client.GET("/chat/channel-messages/{surface}", {
7534
- params: { path: { surface: String(surface) } }
7535
- });
7536
- });
7537
- emit(data, cmd.optsWithGlobals().json);
7538
- });
7539
- chat.command("replies <messageId>").description("List thread replies for a message (GET /chat/channel-messages/by-id/{id}/replies)").action(async (messageId, _opts, cmd) => {
7540
- const cfg = resolveConfig(cmd.optsWithGlobals());
7541
- const data = await runApi("Fetching replies", async () => {
7542
- const client = await makeClient(cfg);
7543
- return client.GET("/chat/channel-messages/by-id/{id}/replies", {
7544
- params: { path: { id: messageId } }
7545
- });
7546
- });
7547
- emit(data, cmd.optsWithGlobals().json);
7548
- });
7549
- chat.command("stop-tracking <messageId>").description("Stop watching a message for replies (POST .../by-id/{id}/stop-tracking-replies)").action(async (messageId, _opts, cmd) => {
7550
- const cfg = resolveConfig(cmd.optsWithGlobals());
7551
- const data = await runApi("Stopping reply tracking", async () => {
7552
- const client = await makeClient(cfg);
7553
- return client.POST("/chat/channel-messages/by-id/{id}/stop-tracking-replies", {
7554
- params: { path: { id: messageId } },
7555
- body: {}
7556
- });
7557
- });
7558
- emitAction(`stopped tracking replies on ${style.bold(messageId)}`, data, cmd.optsWithGlobals().json);
7559
- });
7560
- }
7561
-
7562
- // src/commands/checkpoint.ts
7563
- import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync14 } from "fs";
7564
- import { dirname as dirname14, join as join17 } from "path";
7565
-
7566
7540
  // src/commands/hook.ts
7567
7541
  import { createHash as createHash4 } from "crypto";
7568
7542
  import { existsSync as existsSync12, mkdirSync as mkdirSync14, readFileSync as readFileSync12, statSync as statSync4, writeFileSync as writeFileSync13 } from "fs";
@@ -8160,6 +8134,17 @@ function resolveLane(flagLane, cwd) {
8160
8134
  if (!base) return void 0;
8161
8135
  return applyWorktreeLaneSuffix(base, start);
8162
8136
  }
8137
+ function resolveSourceLane(explicit, cwd) {
8138
+ const trimmed = explicit?.trim();
8139
+ if (trimmed) return trimmed;
8140
+ const lane = resolveLane(void 0, cwd);
8141
+ if (!lane) {
8142
+ throw new Error(
8143
+ "no --source and no lane pinned for this checkout \u2014 a record's source must name the lane that wrote it, never a generic value. Pin one with `sechroom lane set --code-lane <id>` (see `sechroom lane`), set SECHROOM_LANE, or pass --source explicitly."
8144
+ );
8145
+ }
8146
+ return lane;
8147
+ }
8163
8148
  var INTENT_FILE = join16(".sechroom", "continuity.json");
8164
8149
  var LOCAL_DRY_RUN_VALIDATION_WARNING = "LOCAL-ONLY \u2014 NOT SERVER-VALIDATED";
8165
8150
  function resolveIntentPath(start) {
@@ -8196,6 +8181,56 @@ function localDryRunMissingFields(i) {
8196
8181
  ];
8197
8182
  return required.filter(([key]) => !String(i[key] ?? "").trim()).map(([, flag]) => flag);
8198
8183
  }
8184
+ var SNAPSHOT_LIMITS = {
8185
+ /** objective / state / lastAction / nextAction / resumeInstruction. */
8186
+ bodyFieldLength: 2e3,
8187
+ /** Any single entry in constraints / questions / surfaceMarkers / artifacts. */
8188
+ listItemLength: 500,
8189
+ /** Entries per list. */
8190
+ listLength: 25
8191
+ };
8192
+ function snapshotLimitViolations(i) {
8193
+ const violations = [];
8194
+ const bodyFields = [
8195
+ "objective",
8196
+ "state",
8197
+ "lastAction",
8198
+ "nextAction",
8199
+ "resumeInstruction"
8200
+ ];
8201
+ for (const key of bodyFields) {
8202
+ const value = i[key];
8203
+ if (typeof value !== "string") continue;
8204
+ if (value.length > SNAPSHOT_LIMITS.bodyFieldLength) {
8205
+ violations.push(
8206
+ `${key}: ${value.length} characters exceeds the ${SNAPSHOT_LIMITS.bodyFieldLength}-character limit`
8207
+ );
8208
+ }
8209
+ }
8210
+ const lists = [
8211
+ "constraints",
8212
+ "questions",
8213
+ "surfaceMarkers",
8214
+ "artifacts"
8215
+ ];
8216
+ for (const key of lists) {
8217
+ const items = i[key];
8218
+ if (!Array.isArray(items)) continue;
8219
+ if (items.length > SNAPSHOT_LIMITS.listLength) {
8220
+ violations.push(
8221
+ `${key}: ${items.length} entries exceeds the ${SNAPSHOT_LIMITS.listLength}-entry limit`
8222
+ );
8223
+ }
8224
+ items.forEach((item, index) => {
8225
+ if (typeof item === "string" && item.length > SNAPSHOT_LIMITS.listItemLength) {
8226
+ violations.push(
8227
+ `${key}[${index}]: ${item.length} characters exceeds the ${SNAPSHOT_LIMITS.listItemLength}-character limit`
8228
+ );
8229
+ }
8230
+ });
8231
+ }
8232
+ return violations;
8233
+ }
8199
8234
  async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScope, opts) {
8200
8235
  const lane = resolveLane(laneFlag, cwd);
8201
8236
  if (!lane) return false;
@@ -8510,7 +8545,96 @@ Fail-soft: failures exit 0 and never block; session-context refresh failures ren
8510
8545
  });
8511
8546
  }
8512
8547
 
8548
+ // src/commands/chat.ts
8549
+ function registerChat(program2) {
8550
+ const chat = program2.command("chat").description("Send and read Slack / Discord channel messages").option("--surface <surface>", "slack | discord", "slack");
8551
+ chat.addHelpText(
8552
+ "after",
8553
+ `
8554
+ Examples:
8555
+ $ sechroom chat send C0123456789 "deploy is green" --surface slack
8556
+ $ sechroom chat send 987654321098765432 "deploy is green" --surface discord --guild 123456789012345678
8557
+ $ sechroom chat send C0123456789 "lgtm" --surface slack --as user --parent 1718049600.123456
8558
+ $ sechroom chat messages --surface slack
8559
+ $ sechroom chat replies 1718049600.123456 --surface slack
8560
+ $ sechroom chat stop-tracking 1718049600.123456 --surface slack`
8561
+ );
8562
+ chat.command("send <channelId> <text>").description("Send a message to a channel (POST /chat/channel-messages/{surface})").option("--guild <guildId>", "Discord guild snowflake \u2014 required for --surface discord").option("--memory <memoryId>", "Attach a sechroom memory id").option("--no-track", "Don't capture replies to this message").option("--parent <parentMessage>", "Thread under a parent (Slack thread_ts / Discord message id)").option("--source <source>", "Source / lane stamp (renders an attribution footer; default: this checkout's pinned code-lane)").option("--as <as>", "Slack only: 'bot' (default) or 'user' (your linked Slack identity)", "bot").action(async (channelId, text2, opts, cmd) => {
8563
+ const source = resolveSourceLane(opts.source);
8564
+ const { surface, ...globals } = cmd.optsWithGlobals();
8565
+ const json = Boolean(cmd.optsWithGlobals().json);
8566
+ const cfg = resolveConfig(globals);
8567
+ const data = await runApi("Sending message", async () => {
8568
+ const client = await makeClient(cfg);
8569
+ return client.POST("/chat/channel-messages/{surface}", {
8570
+ params: { path: { surface: String(surface) } },
8571
+ body: {
8572
+ channelId,
8573
+ text: text2,
8574
+ guildId: opts.guild ?? null,
8575
+ attachedMemoryId: opts.memory ?? null,
8576
+ trackReplies: opts.track,
8577
+ parentMessage: opts.parent ?? null,
8578
+ source,
8579
+ as: opts.as
8580
+ }
8581
+ });
8582
+ });
8583
+ if (!data.ok) {
8584
+ if (json) {
8585
+ emit(data, true);
8586
+ } else {
8587
+ process.stderr.write(
8588
+ `${err("\u2717")} send failed: ${data.upstreamError ?? "error"}${data.errorDescription ? ` \u2014 ${data.errorDescription}` : ""}
8589
+ `
8590
+ );
8591
+ }
8592
+ process.exit(1);
8593
+ }
8594
+ const idPart = data.persistedId ? ` ${style.dim(`(${data.persistedId})`)}` : "";
8595
+ emitAction(`sent to ${surface} ${style.bold(channelId)}${idPart}`, data, json);
8596
+ });
8597
+ chat.command("messages").description("List recent channel messages (GET /chat/channel-messages/{surface})").action(async (_opts, cmd) => {
8598
+ const { surface, ...globals } = cmd.optsWithGlobals();
8599
+ const cfg = resolveConfig(globals);
8600
+ const data = await runApi("Fetching messages", async () => {
8601
+ const client = await makeClient(cfg);
8602
+ return client.GET("/chat/channel-messages/{surface}", {
8603
+ params: { path: { surface: String(surface) } }
8604
+ });
8605
+ });
8606
+ emit(data, cmd.optsWithGlobals().json);
8607
+ });
8608
+ chat.command("replies <messageId>").description("List thread replies for a message (GET /chat/channel-messages/by-id/{id}/replies)").action(async (messageId, _opts, cmd) => {
8609
+ const cfg = resolveConfig(cmd.optsWithGlobals());
8610
+ const data = await runApi("Fetching replies", async () => {
8611
+ const client = await makeClient(cfg);
8612
+ return client.GET("/chat/channel-messages/by-id/{id}/replies", {
8613
+ params: { path: { id: messageId } }
8614
+ });
8615
+ });
8616
+ emit(data, cmd.optsWithGlobals().json);
8617
+ });
8618
+ chat.command("stop-tracking <messageId>").description("Stop watching a message for replies (POST .../by-id/{id}/stop-tracking-replies)").action(async (messageId, _opts, cmd) => {
8619
+ const cfg = resolveConfig(cmd.optsWithGlobals());
8620
+ const data = await runApi("Stopping reply tracking", async () => {
8621
+ const client = await makeClient(cfg);
8622
+ return client.POST("/chat/channel-messages/by-id/{id}/stop-tracking-replies", {
8623
+ params: { path: { id: messageId } },
8624
+ body: {}
8625
+ });
8626
+ });
8627
+ emitAction(`stopped tracking replies on ${style.bold(messageId)}`, data, cmd.optsWithGlobals().json);
8628
+ });
8629
+ }
8630
+
8513
8631
  // src/commands/checkpoint.ts
8632
+ import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync14 } from "fs";
8633
+ import { dirname as dirname14, join as join17 } from "path";
8634
+ var CHECKPOINT_UNCHANGED_EXIT_CODE = 3;
8635
+ function checkpointUnchangedLine(snapshotId) {
8636
+ return `${style.bold("=")} unchanged ${style.dim(`(${snapshotId} already current)`)} \u2014 no new snapshot; the local file was left as-is`;
8637
+ }
8514
8638
  function registerCheckpoint(program2) {
8515
8639
  program2.command("checkpoint").description(
8516
8640
  "Checkpoint working state: create a continuity snapshot (server-validated) AND sync ./.sechroom/continuity.json in one step"
@@ -8558,6 +8682,13 @@ Examples:
8558
8682
  "no lane resolved \u2014 pass --lane, set SECHROOM_LANE, or pin one in ./.sechroom/lane.json (code-lane). See `sechroom lane`."
8559
8683
  );
8560
8684
  }
8685
+ const overLimit = snapshotLimitViolations(merged);
8686
+ if (overLimit.length > 0) {
8687
+ fail(
8688
+ `snapshot exceeds the field limits \u2014 trim and retry:
8689
+ ${overLimit.join("\n ")}`
8690
+ );
8691
+ }
8561
8692
  const scope = merged.scope ?? "session";
8562
8693
  const body = {
8563
8694
  laneId: lane,
@@ -8604,6 +8735,16 @@ Examples:
8604
8735
  const client = await makeClient(cfg);
8605
8736
  return client.POST("/continuity/snapshots", { body });
8606
8737
  });
8738
+ const previousSnapshotId = base.lastSnapshotId;
8739
+ if (previousSnapshotId && data.snapshotId === previousSnapshotId) {
8740
+ process.exitCode = CHECKPOINT_UNCHANGED_EXIT_CODE;
8741
+ if (json) {
8742
+ emit({ snapshotId: data.snapshotId, lane, scope, unchanged: true, file: null }, true);
8743
+ return;
8744
+ }
8745
+ process.stdout.write(checkpointUnchangedLine(data.snapshotId) + "\n");
8746
+ return;
8747
+ }
8607
8748
  const path = resolveIntentPath(cwd) ?? join17(cwd, INTENT_FILE);
8608
8749
  const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
8609
8750
  mkdirSync15(dirname14(path), { recursive: true });
@@ -8641,7 +8782,7 @@ function registerClose(program2) {
8641
8782
  ).option(
8642
8783
  "--to-version <n>",
8643
8784
  "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)."
8644
- ).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
8785
+ ).option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").addHelpText(
8645
8786
  "after",
8646
8787
  `
8647
8788
  Examples:
@@ -8653,6 +8794,7 @@ Examples:
8653
8794
  $ sechroom close --task mem_XXXX --verdict pass --workspace wsp_ZZZZ \\
8654
8795
  --title "done" --file ./closeout.md`
8655
8796
  ).action(async (opts, cmd) => {
8797
+ const source = resolveSourceLane(opts.source);
8656
8798
  const cfg = resolveConfig(cmd.optsWithGlobals());
8657
8799
  const json = cmd.optsWithGlobals().json;
8658
8800
  const verdict = String(opts.verdict);
@@ -8712,7 +8854,7 @@ Examples:
8712
8854
  type: "reference",
8713
8855
  content: "{}",
8714
8856
  confidence: 1,
8715
- source: opts.source,
8857
+ source,
8716
8858
  archetype: "Document",
8717
8859
  title: opts.title,
8718
8860
  tags,
@@ -8769,7 +8911,7 @@ Examples:
8769
8911
  params: { path: { memoryId: opts.task } },
8770
8912
  body: {
8771
8913
  memoryId: opts.task,
8772
- source: opts.source,
8914
+ source,
8773
8915
  tags: nextTags
8774
8916
  }
8775
8917
  })
@@ -10915,7 +11057,8 @@ Examples:
10915
11057
  "--owner-type <ownerType>",
10916
11058
  "Workspace | Project | Unfiled",
10917
11059
  "Unfiled"
10918
- ).option("--owner-id <ownerId>", "Owner id (required for Workspace/Project)").option("--source <source>", "Source / lane stamp", "cli").option("--confidence <n>", "Confidence 0..1", "1.0").action(async (opts, cmd) => {
11060
+ ).option("--owner-id <ownerId>", "Owner id (required for Workspace/Project)").option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").option("--confidence <n>", "Confidence 0..1", "1.0").action(async (opts, cmd) => {
11061
+ const source = resolveSourceLane(opts.source);
10919
11062
  const cfg = resolveConfig(cmd.optsWithGlobals());
10920
11063
  const { text: text2, defaultTitle } = resolveCreateBody(opts.text, opts.file);
10921
11064
  const title = opts.title ?? defaultTitle;
@@ -10928,7 +11071,7 @@ Examples:
10928
11071
  type: opts.type,
10929
11072
  content: "{}",
10930
11073
  confidence: Number(opts.confidence),
10931
- source: opts.source,
11074
+ source,
10932
11075
  archetype: "Document",
10933
11076
  title: title ?? null,
10934
11077
  tags: opts.tag ?? null,
@@ -10957,7 +11100,8 @@ Examples:
10957
11100
  "--create-workspace",
10958
11101
  "Create the --workspace when its name matches nothing",
10959
11102
  false
10960
- ).option("--recursive", "Walk subdirectories of a given directory", false).option("--type <type>", "Memory type", "reference").option("--tag <tag...>", "Tags applied to every memory (repeatable)").option("--source <source>", "Source / lane stamp", "cli").option("--confidence <n>", "Confidence 0..1", "1.0").option("--dry-run", "Resolve and print the plan; write nothing", false).action(async (paths, opts, cmd) => {
11103
+ ).option("--recursive", "Walk subdirectories of a given directory", false).option("--type <type>", "Memory type", "reference").option("--tag <tag...>", "Tags applied to every memory (repeatable)").option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").option("--confidence <n>", "Confidence 0..1", "1.0").option("--dry-run", "Resolve and print the plan; write nothing", false).action(async (paths, opts, cmd) => {
11104
+ const source = resolveSourceLane(opts.source);
10961
11105
  const cfg = resolveConfig(cmd.optsWithGlobals());
10962
11106
  const json = Boolean(cmd.optsWithGlobals().json);
10963
11107
  const dryRun = Boolean(opts.dryRun);
@@ -11070,7 +11214,7 @@ ${plan.rows.map(
11070
11214
  workspaceId: workspace.id,
11071
11215
  type: opts.type,
11072
11216
  tags: opts.tag ?? null,
11073
- source: opts.source,
11217
+ source,
11074
11218
  confidence: Number(opts.confidence)
11075
11219
  },
11076
11220
  ports,
@@ -11192,7 +11336,8 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11192
11336
  "--replace-all",
11193
11337
  "Replace every occurrence (default: first only)",
11194
11338
  false
11195
- ).option("--regenerate-filing", "Re-run filing after the edit", false).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11339
+ ).option("--regenerate-filing", "Re-run filing after the edit", false).option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11340
+ const source = resolveSourceLane(opts.source);
11196
11341
  const cfg = resolveConfig(cmd.optsWithGlobals());
11197
11342
  const data = await runApi("Editing memory text", async () => {
11198
11343
  const client = await makeClient(cfg);
@@ -11204,7 +11349,7 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11204
11349
  newText: opts.new,
11205
11350
  replaceAll: Boolean(opts.replaceAll),
11206
11351
  regenerateFiling: Boolean(opts.regenerateFiling),
11207
- source: opts.source
11352
+ source
11208
11353
  }
11209
11354
  });
11210
11355
  });
@@ -11216,7 +11361,8 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11216
11361
  });
11217
11362
  memory.command("edit-text-batch <memoryId>").description(
11218
11363
  "Apply many find/replace edits (POST /memories/{memoryId}/edit-text-batch)"
11219
- ).requiredOption("--edit <old=>new...>", "Edit as 'old=>new' (repeatable)").option("--replace-all", "Apply replaceAll to every edit", false).option("--regenerate-filing", "Re-run filing after the edits", false).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11364
+ ).requiredOption("--edit <old=>new...>", "Edit as 'old=>new' (repeatable)").option("--replace-all", "Apply replaceAll to every edit", false).option("--regenerate-filing", "Re-run filing after the edits", false).option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11365
+ const source = resolveSourceLane(opts.source);
11220
11366
  const cfg = resolveConfig(cmd.optsWithGlobals());
11221
11367
  const replaceAll = Boolean(opts.replaceAll);
11222
11368
  const edits = opts.edit.map((spec) => {
@@ -11242,7 +11388,7 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11242
11388
  memoryId,
11243
11389
  edits,
11244
11390
  regenerateFiling: Boolean(opts.regenerateFiling),
11245
- source: opts.source
11391
+ source
11246
11392
  }
11247
11393
  });
11248
11394
  });
@@ -11267,7 +11413,8 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11267
11413
  "--bump-version",
11268
11414
  "Bump the version chain (use for a content reinterpretation, e.g. a type promotion)",
11269
11415
  false
11270
- ).option("--source <source>", "Contributing lane stamp (attribution)", "cli").action(async (memoryId, opts, cmd) => {
11416
+ ).option("--source <source>", "Contributing lane stamp (attribution; default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11417
+ const source = resolveSourceLane(opts.source);
11271
11418
  const cfg = resolveConfig(cmd.optsWithGlobals());
11272
11419
  const json = cmd.optsWithGlobals().json;
11273
11420
  const hasTagOps = Boolean(opts.tag || opts.addTag || opts.removeTag);
@@ -11299,7 +11446,7 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11299
11446
  }
11300
11447
  const body = {
11301
11448
  memoryId,
11302
- source: opts.source,
11449
+ source,
11303
11450
  bumpVersion: Boolean(opts.bumpVersion)
11304
11451
  };
11305
11452
  if (opts.title !== void 0) body.title = opts.title;
@@ -11323,13 +11470,14 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11323
11470
  json
11324
11471
  );
11325
11472
  });
11326
- memory.command("archive <memoryId>").description("Archive a memory (POST /memories/{memoryId}/archive)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11473
+ memory.command("archive <memoryId>").description("Archive a memory (POST /memories/{memoryId}/archive)").option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11474
+ const source = resolveSourceLane(opts.source);
11327
11475
  const cfg = resolveConfig(cmd.optsWithGlobals());
11328
11476
  const data = await runApi("Archiving memory", async () => {
11329
11477
  const client = await makeClient(cfg);
11330
11478
  return client.POST("/memories/{memoryId}/archive", {
11331
11479
  params: { path: { memoryId } },
11332
- body: { source: opts.source }
11480
+ body: { source }
11333
11481
  });
11334
11482
  });
11335
11483
  emitAction(
@@ -11340,13 +11488,14 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11340
11488
  });
11341
11489
  memory.command("restore <memoryId>").description(
11342
11490
  "Restore an archived memory (POST /memories/{memoryId}/restore)"
11343
- ).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11491
+ ).option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11492
+ const source = resolveSourceLane(opts.source);
11344
11493
  const cfg = resolveConfig(cmd.optsWithGlobals());
11345
11494
  const data = await runApi("Restoring memory", async () => {
11346
11495
  const client = await makeClient(cfg);
11347
11496
  return client.POST("/memories/{memoryId}/restore", {
11348
11497
  params: { path: { memoryId } },
11349
- body: { source: opts.source }
11498
+ body: { source }
11350
11499
  });
11351
11500
  });
11352
11501
  emitAction(
@@ -11360,7 +11509,8 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11360
11509
  ).requiredOption(
11361
11510
  "--owner-type <ownerType>",
11362
11511
  "Unfiled | Workspace | Project | Candidate"
11363
- ).option("--owner-id <ownerId>", "Owner id (required unless Unfiled)").option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11512
+ ).option("--owner-id <ownerId>", "Owner id (required unless Unfiled)").option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11513
+ const source = resolveSourceLane(opts.source);
11364
11514
  const cfg = resolveConfig(cmd.optsWithGlobals());
11365
11515
  const data = await runApi("Moving memory", async () => {
11366
11516
  const client = await makeClient(cfg);
@@ -11371,7 +11521,7 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11371
11521
  type: opts.ownerType,
11372
11522
  id: String(opts.ownerId ?? "")
11373
11523
  },
11374
- source: opts.source
11524
+ source
11375
11525
  }
11376
11526
  });
11377
11527
  });
@@ -11423,7 +11573,8 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11423
11573
  ).requiredOption(
11424
11574
  "--content <content>",
11425
11575
  "Reverted content JSON (the target version's content)"
11426
- ).option("--source <source>", "Source / lane stamp", "cli").action(async (memoryId, opts, cmd) => {
11576
+ ).option("--source <source>", "Source / lane stamp (default: this checkout's pinned code-lane)").action(async (memoryId, opts, cmd) => {
11577
+ const source = resolveSourceLane(opts.source);
11427
11578
  const cfg = resolveConfig(cmd.optsWithGlobals());
11428
11579
  const data = await runApi("Reverting memory", async () => {
11429
11580
  const client = await makeClient(cfg);
@@ -11433,7 +11584,7 @@ Note: CLI memory get omits relationships by default; MCP get_memory includes the
11433
11584
  fromVersion: Number(opts.fromVersion),
11434
11585
  revertedContent: opts.content,
11435
11586
  revertedText: opts.text,
11436
- source: opts.source
11587
+ source
11437
11588
  }
11438
11589
  });
11439
11590
  });
@@ -12473,7 +12624,8 @@ version, the shared template stays clean, and you can discard back anytime.
12473
12624
  make = await promptYesNo("Make a personal copy to customise?");
12474
12625
  }
12475
12626
  if (make) {
12476
- await createOverride(cfg, resolved, personalWorkspaceId);
12627
+ const source = resolveSourceLane(void 0);
12628
+ await createOverride(cfg, resolved, personalWorkspaceId, source);
12477
12629
  process.stderr.write(
12478
12630
  `\u2713 personal copy created for ${instr.surfaceKey} \u2014 edit it on the Agent setup page or via the API.
12479
12631
  `
@@ -12837,7 +12989,10 @@ function registerSetup(program2, deps = {}) {
12837
12989
  ).option(
12838
12990
  "--body <markdown>",
12839
12991
  "section body (default: a TODO scaffold to edit later)"
12840
- ).option("--no-regen", "skip the agent-files regen after authoring").option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
12992
+ ).option("--no-regen", "skip the agent-files regen after authoring").option(
12993
+ "--source <source>",
12994
+ "Source / lane stamp (default: this checkout's pinned code-lane)"
12995
+ ).option("--dry-run", "print what would be authored; write nothing", false).addHelpText(
12841
12996
  "after",
12842
12997
  `
12843
12998
  The memo carries the two conventions a workspace-conventions section needs (FR-sechroom-236):
@@ -12850,6 +13005,7 @@ Examples:
12850
13005
  $ sechroom setup new-convention "Backend testing" --kind standard --body "- run dotnet test ..."
12851
13006
  $ sechroom setup new-convention "Draft section" --no-regen author only, regen later`
12852
13007
  ).action(async (titleParts, opts, cmd) => {
13008
+ const source = resolveSourceLane(opts.source);
12853
13009
  const cfg = resolveConfig(cmd.optsWithGlobals());
12854
13010
  const json = Boolean(cmd.optsWithGlobals().json);
12855
13011
  const title = titleParts.join(" ").trim();
@@ -12885,7 +13041,7 @@ Examples:
12885
13041
  type: draft.kind,
12886
13042
  content: "{}",
12887
13043
  confidence: 1,
12888
- source: "cli-new-convention",
13044
+ source,
12889
13045
  archetype: "Document",
12890
13046
  title: draft.title,
12891
13047
  tags: draft.tags,
@@ -13463,11 +13619,20 @@ async function ensureTenant(baseUrl, g, opts) {
13463
13619
  clientId: persisted.clientId
13464
13620
  };
13465
13621
  }
13466
- async function ensureAuth(cfg, yes) {
13467
- if (process.env.SECHROOM_TOKEN) return;
13622
+ function hasUsableCredential() {
13623
+ if (process.env.SECHROOM_TOKEN) return true;
13468
13624
  const cached = readToken();
13469
- const usable = Boolean(cached?.accessToken) && (cached.expiresAt === void 0 || Date.now() < cached.expiresAt - 6e4 || Boolean(cached.refreshToken));
13470
- if (usable) return;
13625
+ return Boolean(cached?.accessToken) && (cached.expiresAt === void 0 || Date.now() < cached.expiresAt - 6e4 || Boolean(cached.refreshToken));
13626
+ }
13627
+ function requireCredentialForPreview(preview, dryRun) {
13628
+ if (!preview || hasUsableCredential()) return;
13629
+ const flag = dryRun ? "--dry-run" : "--check";
13630
+ fail(
13631
+ `sechroom onboard ${flag} needs an existing sign-in; run \`sechroom login\` first, then re-run the preview.`
13632
+ );
13633
+ }
13634
+ async function ensureAuth(cfg, yes) {
13635
+ if (hasUsableCredential()) return;
13471
13636
  if (!canPrompt() || yes) {
13472
13637
  fail(
13473
13638
  "Not signed in. Run `sechroom login` first, or set SECHROOM_TOKEN for headless use."
@@ -13806,6 +13971,7 @@ Examples:
13806
13971
  if (opts.designLane) process.env.SECHROOM_DESIGN_LANE = opts.designLane;
13807
13972
  if (opts.recurse) {
13808
13973
  const baseUrl2 = resolveBaseUrl(g);
13974
+ requireCredentialForPreview(dryRun || check, dryRun);
13809
13975
  await ensureAuth(
13810
13976
  {
13811
13977
  baseUrl: baseUrl2,
@@ -13830,6 +13996,7 @@ Examples:
13830
13996
  return;
13831
13997
  }
13832
13998
  const baseUrl = resolveBaseUrl(g);
13999
+ requireCredentialForPreview(dryRun || check, dryRun);
13833
14000
  await ensureAuth(
13834
14001
  {
13835
14002
  baseUrl,
@@ -13846,7 +14013,12 @@ Examples:
13846
14013
  local: Boolean(opts.local) || scope === "project",
13847
14014
  here: scope === "project" ? true : Boolean(opts.here),
13848
14015
  workspace: opts.workspace,
13849
- persist: !check
14016
+ // Both previews are read-only. `--dry-run` promises to "walk through without
14017
+ // writing files or changing the profile", but only `--check` was in this
14018
+ // guard, so a dry run could still create or rewrite `.sechroom.json` /
14019
+ // `~/.config/sechroom/config.json` before the operator approved anything
14020
+ // (FR-sechroom-710). Matches how `ensureTimezone` is already gated below.
14021
+ persist: !(dryRun || check)
13850
14022
  });
13851
14023
  const tz = await ensureTimezone(cfg, { yes, dryRun: dryRun || check });
13852
14024
  if (!json && tz.action !== "already-set") {
@@ -14877,14 +15049,15 @@ Examples:
14877
15049
  $ sechroom worklog append --text "shipped CLI help + onboarding scope; PR #1430"
14878
15050
  $ sechroom worklog append --text "smoke passed" --source claude-code-chris --title "CLI smoke"`
14879
15051
  );
14880
- worklog.command("append").description("Append a work-log entry (POST /operator-surface/work-log/append)").requiredOption("--text <text>", "Entry body (short bullets / pointers) \u2014 the bullet").option("--source <source>", "Lane stamp (e.g. claude-code-chris) \u2014 laneId", "cli").option("--workspace <workspaceId>", "Target work-log workspace (default: caller's daily log)").option("--title <title>", "Optional entry title").action(async (opts, cmd) => {
15052
+ worklog.command("append").description("Append a work-log entry (POST /operator-surface/work-log/append)").requiredOption("--text <text>", "Entry body (short bullets / pointers) \u2014 the bullet").option("--source <source>", "Lane stamp (e.g. claude-code-chris) \u2014 laneId (default: this checkout's pinned code-lane)").option("--workspace <workspaceId>", "Target work-log workspace (default: caller's daily log)").option("--title <title>", "Optional entry title").action(async (opts, cmd) => {
15053
+ const source = resolveSourceLane(opts.source);
14881
15054
  const cfg = resolveConfig(cmd.optsWithGlobals());
14882
15055
  const data = await runApi("Appending work-log entry", async () => {
14883
15056
  const client = await makeClient(cfg);
14884
15057
  return client.POST("/operator-surface/work-log/append", {
14885
15058
  body: {
14886
15059
  bullet: opts.text,
14887
- laneId: opts.source ?? null,
15060
+ laneId: source,
14888
15061
  workspaceId: opts.workspace ?? null,
14889
15062
  title: opts.title ?? null
14890
15063
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sechroom/cli",
3
- "version": "2026.9.1",
3
+ "version": "2026.9.2",
4
4
  "description": "Command-line interface for Sechroom — sign in, wire your AI tools, and work with your sechroom from the terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",