@sechroom/cli 2026.6.238-rc.10da7380 → 2026.7.1-rc.2c16fefa
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.
- package/dist/index.js +1091 -496
- package/package.json +4 -3
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
|
|
4
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/auth.ts
|
|
@@ -433,6 +433,7 @@ async function requireToken(cfg) {
|
|
|
433
433
|
import createClient from "openapi-fetch";
|
|
434
434
|
|
|
435
435
|
// src/ui.ts
|
|
436
|
+
import * as clack from "@clack/prompts";
|
|
436
437
|
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
437
438
|
var quiet = false;
|
|
438
439
|
function setQuiet(q) {
|
|
@@ -458,7 +459,7 @@ var err = (s) => style.red(s);
|
|
|
458
459
|
function active() {
|
|
459
460
|
return !quiet && Boolean(process.stderr.isTTY);
|
|
460
461
|
}
|
|
461
|
-
function spinner(
|
|
462
|
+
function spinner(text2) {
|
|
462
463
|
if (!active()) {
|
|
463
464
|
return { succeed() {
|
|
464
465
|
}, fail() {
|
|
@@ -468,9 +469,9 @@ function spinner(text) {
|
|
|
468
469
|
let i = 0;
|
|
469
470
|
const render = () => {
|
|
470
471
|
i = (i + 1) % FRAMES.length;
|
|
471
|
-
process.stderr.write(`\r${FRAMES[i]} ${
|
|
472
|
+
process.stderr.write(`\r${FRAMES[i]} ${text2}`);
|
|
472
473
|
};
|
|
473
|
-
process.stderr.write(`\r${FRAMES[0]} ${
|
|
474
|
+
process.stderr.write(`\r${FRAMES[0]} ${text2}`);
|
|
474
475
|
const timer = setInterval(render, 80);
|
|
475
476
|
timer.unref?.();
|
|
476
477
|
const clear = () => {
|
|
@@ -480,12 +481,12 @@ function spinner(text) {
|
|
|
480
481
|
return {
|
|
481
482
|
succeed(t) {
|
|
482
483
|
clear();
|
|
483
|
-
process.stderr.write(`${ok("\u2713")} ${t ??
|
|
484
|
+
process.stderr.write(`${ok("\u2713")} ${t ?? text2}
|
|
484
485
|
`);
|
|
485
486
|
},
|
|
486
487
|
fail(t) {
|
|
487
488
|
clear();
|
|
488
|
-
process.stderr.write(`${err("\u2717")} ${t ??
|
|
489
|
+
process.stderr.write(`${err("\u2717")} ${t ?? text2}
|
|
489
490
|
`);
|
|
490
491
|
},
|
|
491
492
|
stop: clear
|
|
@@ -496,31 +497,24 @@ function canPrompt() {
|
|
|
496
497
|
}
|
|
497
498
|
async function promptYesNo(question) {
|
|
498
499
|
if (!canPrompt()) return false;
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
return /^y(es)?$/i.test(answer.trim());
|
|
506
|
-
} finally {
|
|
507
|
-
rl.close();
|
|
508
|
-
}
|
|
500
|
+
const r = await clack.confirm({
|
|
501
|
+
message: question,
|
|
502
|
+
initialValue: false,
|
|
503
|
+
output: process.stderr
|
|
504
|
+
});
|
|
505
|
+
return clack.isCancel(r) ? false : r;
|
|
509
506
|
}
|
|
510
507
|
async function promptText(question, def) {
|
|
511
508
|
if (!canPrompt()) return def ?? "";
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
} finally {
|
|
522
|
-
rl.close();
|
|
523
|
-
}
|
|
509
|
+
const r = await clack.text({
|
|
510
|
+
message: question,
|
|
511
|
+
defaultValue: def,
|
|
512
|
+
placeholder: def,
|
|
513
|
+
output: process.stderr
|
|
514
|
+
});
|
|
515
|
+
if (clack.isCancel(r)) return def ?? "";
|
|
516
|
+
const trimmed = (r ?? "").trim();
|
|
517
|
+
return trimmed.length > 0 ? trimmed : def ?? "";
|
|
524
518
|
}
|
|
525
519
|
async function promptSelect(question, choices, def) {
|
|
526
520
|
if (choices.length === 0) throw new Error("promptSelect: no choices");
|
|
@@ -529,74 +523,50 @@ async function promptSelect(question, choices, def) {
|
|
|
529
523
|
def !== void 0 ? choices.findIndex((c) => c.value === def) : 0
|
|
530
524
|
);
|
|
531
525
|
if (!canPrompt()) return choices[defIdx].value;
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
choices.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
`);
|
|
542
|
-
});
|
|
543
|
-
const answer = await new Promise((resolve3) => {
|
|
544
|
-
rl.question(`Choose ${style.dim(`[${defIdx + 1}]`)} `, resolve3);
|
|
545
|
-
});
|
|
546
|
-
const trimmed = answer.trim();
|
|
547
|
-
if (!trimmed) return choices[defIdx].value;
|
|
548
|
-
const n = Number(trimmed);
|
|
549
|
-
if (Number.isInteger(n) && n >= 1 && n <= choices.length) return choices[n - 1].value;
|
|
550
|
-
const byLabel = choices.find(
|
|
551
|
-
(c) => c.label.toLowerCase().startsWith(trimmed.toLowerCase())
|
|
552
|
-
);
|
|
553
|
-
return byLabel ? byLabel.value : choices[defIdx].value;
|
|
554
|
-
} finally {
|
|
555
|
-
rl.close();
|
|
556
|
-
}
|
|
526
|
+
const r = await clack.select({
|
|
527
|
+
message: question,
|
|
528
|
+
// Cast: clack's Option<T> is a conditional type (primitive vs not) that TS
|
|
529
|
+
// can't verify against a generic T; the runtime shape is correct.
|
|
530
|
+
options: choices.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
531
|
+
initialValue: choices[defIdx].value,
|
|
532
|
+
output: process.stderr
|
|
533
|
+
});
|
|
534
|
+
return clack.isCancel(r) ? choices[defIdx].value : r;
|
|
557
535
|
}
|
|
558
536
|
async function promptMultiSelect(question, choices, preselected = []) {
|
|
559
537
|
if (choices.length === 0) return [];
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const hint = c.hint ? ` ${style.dim(`\u2014 ${c.hint}`)}` : "";
|
|
573
|
-
process.stderr.write(` ${box} ${style.bold(String(i + 1))}. ${c.label}${hint}
|
|
574
|
-
`);
|
|
575
|
-
});
|
|
576
|
-
const answer = await new Promise((resolve3) => {
|
|
577
|
-
rl.question(`Select ${style.dim("[Enter = \u25C9]")} `, resolve3);
|
|
578
|
-
});
|
|
579
|
-
const trimmed = answer.trim().toLowerCase();
|
|
580
|
-
if (!trimmed) return preValues();
|
|
581
|
-
if (trimmed === "all") return choices.map((c) => c.value);
|
|
582
|
-
const picks = [];
|
|
583
|
-
for (const tok of trimmed.split(",").map((t) => t.trim()).filter(Boolean)) {
|
|
584
|
-
const n = Number(tok);
|
|
585
|
-
if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
|
|
586
|
-
picks.push(choices[n - 1].value);
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
const byLabel = choices.find((c) => c.label.toLowerCase().startsWith(tok));
|
|
590
|
-
if (byLabel) picks.push(byLabel.value);
|
|
591
|
-
}
|
|
592
|
-
const uniq = [...new Set(picks)];
|
|
593
|
-
return uniq.length > 0 ? uniq : preValues();
|
|
594
|
-
} finally {
|
|
595
|
-
rl.close();
|
|
596
|
-
}
|
|
538
|
+
const preValues = choices.filter((c) => preselected.includes(c.value)).map((c) => c.value);
|
|
539
|
+
if (!canPrompt()) return preValues;
|
|
540
|
+
const r = await clack.multiselect({
|
|
541
|
+
message: question,
|
|
542
|
+
// Cast: clack's Option<T> is a conditional type (primitive vs not) that TS
|
|
543
|
+
// can't verify against a generic T; the runtime shape is correct.
|
|
544
|
+
options: choices.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
545
|
+
initialValues: preValues,
|
|
546
|
+
required: false,
|
|
547
|
+
output: process.stderr
|
|
548
|
+
});
|
|
549
|
+
return clack.isCancel(r) ? preValues : r;
|
|
597
550
|
}
|
|
598
|
-
async function
|
|
599
|
-
|
|
551
|
+
async function promptAutocomplete(question, choices, def) {
|
|
552
|
+
if (choices.length === 0) throw new Error("promptAutocomplete: no choices");
|
|
553
|
+
const defIdx = Math.max(
|
|
554
|
+
0,
|
|
555
|
+
def !== void 0 ? choices.findIndex((c) => c.value === def) : 0
|
|
556
|
+
);
|
|
557
|
+
if (!canPrompt()) return choices[defIdx].value;
|
|
558
|
+
const r = await clack.autocomplete({
|
|
559
|
+
message: question,
|
|
560
|
+
// Cast: clack's Option<T> is a conditional type (primitive vs not) that TS
|
|
561
|
+
// can't verify against a generic T; the runtime shape is correct.
|
|
562
|
+
options: choices.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
563
|
+
placeholder: "Type to filter\u2026",
|
|
564
|
+
output: process.stderr
|
|
565
|
+
});
|
|
566
|
+
return clack.isCancel(r) ? choices[defIdx].value : r;
|
|
567
|
+
}
|
|
568
|
+
async function withSpinner(text2, fn) {
|
|
569
|
+
const s = spinner(text2);
|
|
600
570
|
try {
|
|
601
571
|
const result = await fn();
|
|
602
572
|
s.succeed();
|
|
@@ -1330,12 +1300,228 @@ workers your loop skills call (e.g. find-prior-art \u2192 substrate-miner).`
|
|
|
1330
1300
|
}
|
|
1331
1301
|
|
|
1332
1302
|
// src/commands/channel.ts
|
|
1303
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
|
|
1304
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1333
1305
|
import {
|
|
1334
1306
|
HttpTransportType,
|
|
1335
1307
|
HubConnectionBuilder
|
|
1336
1308
|
} from "@microsoft/signalr";
|
|
1337
1309
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1338
1310
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1311
|
+
|
|
1312
|
+
// src/commands/hook-install.ts
|
|
1313
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1314
|
+
import { delimiter, dirname as dirname3, join as join6 } from "path";
|
|
1315
|
+
|
|
1316
|
+
// src/setup/clients.ts
|
|
1317
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1318
|
+
import { homedir as homedir3 } from "os";
|
|
1319
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
1320
|
+
function claudeDesktopConfigPath(home) {
|
|
1321
|
+
switch (process.platform) {
|
|
1322
|
+
case "darwin":
|
|
1323
|
+
return join5(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1324
|
+
case "win32":
|
|
1325
|
+
return join5(process.env.APPDATA ?? join5(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
1326
|
+
default:
|
|
1327
|
+
return join5(home, ".config", "Claude", "claude_desktop_config.json");
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function clientTargets(cwd, opts = {}) {
|
|
1331
|
+
const home = homedir3();
|
|
1332
|
+
const claudeDir = opts.claudeDir ?? join5(home, ".claude");
|
|
1333
|
+
const codexHome = opts.codexHome ?? join5(home, ".codex");
|
|
1334
|
+
return {
|
|
1335
|
+
"claude-code": {
|
|
1336
|
+
key: "claude-code",
|
|
1337
|
+
label: "Claude Code",
|
|
1338
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join5(cwd, ".mcp.json"), format: "json" },
|
|
1339
|
+
instruction: { surfaceKey: "claude-code", path: join5(cwd, "CLAUDE.md") }
|
|
1340
|
+
},
|
|
1341
|
+
"claude-desktop": {
|
|
1342
|
+
key: "claude-desktop",
|
|
1343
|
+
label: "Claude Desktop",
|
|
1344
|
+
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
1345
|
+
instruction: { surfaceKey: "claude-desktop", path: join5(claudeDir, "CLAUDE.md") }
|
|
1346
|
+
},
|
|
1347
|
+
codex: {
|
|
1348
|
+
key: "codex",
|
|
1349
|
+
label: "Codex CLI",
|
|
1350
|
+
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join5(codexHome, "config.toml"), format: "toml" },
|
|
1351
|
+
instruction: { surfaceKey: "chatgpt", path: join5(cwd, "AGENTS.md") }
|
|
1352
|
+
},
|
|
1353
|
+
cursor: {
|
|
1354
|
+
key: "cursor",
|
|
1355
|
+
label: "Cursor",
|
|
1356
|
+
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join5(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
1357
|
+
instruction: { surfaceKey: "chatgpt", path: join5(cwd, "AGENTS.md") }
|
|
1358
|
+
},
|
|
1359
|
+
antigravity: {
|
|
1360
|
+
key: "antigravity",
|
|
1361
|
+
label: "Google Antigravity",
|
|
1362
|
+
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
1363
|
+
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
1364
|
+
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
1365
|
+
// `type` — comes from the `antigravity` server surface, so we don't
|
|
1366
|
+
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
1367
|
+
// (cross-tool, shared with Codex/Cursor).
|
|
1368
|
+
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join5(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
1369
|
+
instruction: { surfaceKey: "antigravity", path: join5(cwd, "AGENTS.md") }
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
|
|
1374
|
+
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
1375
|
+
function detectInstalledClients(cwd) {
|
|
1376
|
+
const home = homedir3();
|
|
1377
|
+
const detected = [];
|
|
1378
|
+
if (resolveClaudeTargets({}).some((t) => existsSync4(t.dir))) detected.push("claude-code");
|
|
1379
|
+
if (existsSync4(dirname2(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
1380
|
+
if (resolveCodexHomes({}).some((d) => existsSync4(d))) detected.push("codex");
|
|
1381
|
+
if (existsSync4(join5(home, ".cursor")) || existsSync4(join5(cwd, ".cursor"))) detected.push("cursor");
|
|
1382
|
+
if (existsSync4(join5(home, ".gemini"))) detected.push("antigravity");
|
|
1383
|
+
return detected;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// src/commands/hook-install.ts
|
|
1387
|
+
var CLAUDE_HOOK_COMMANDS = {
|
|
1388
|
+
SessionStart: "sechroom hook session-start",
|
|
1389
|
+
PreCompact: "sechroom hook pre-compact",
|
|
1390
|
+
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"
|
|
1395
|
+
};
|
|
1396
|
+
var CODEX_HOOK_COMMANDS = {
|
|
1397
|
+
SessionStart: "sechroom hook session-start",
|
|
1398
|
+
Stop: "sechroom hook session-end --debounce-minutes 10"
|
|
1399
|
+
};
|
|
1400
|
+
function hasHookCommand(config2, event, command) {
|
|
1401
|
+
const groups = config2.hooks?.[event] ?? [];
|
|
1402
|
+
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
1403
|
+
}
|
|
1404
|
+
function mergeHooks(config2, commands) {
|
|
1405
|
+
config2.hooks ??= {};
|
|
1406
|
+
let added = 0;
|
|
1407
|
+
for (const [event, command] of Object.entries(commands)) {
|
|
1408
|
+
if (hasHookCommand(config2, event, command)) continue;
|
|
1409
|
+
const groups = config2.hooks[event] ??= [];
|
|
1410
|
+
groups.push({ hooks: [{ type: "command", command }] });
|
|
1411
|
+
added += 1;
|
|
1412
|
+
}
|
|
1413
|
+
return added;
|
|
1414
|
+
}
|
|
1415
|
+
function readJsonConfig2(path) {
|
|
1416
|
+
if (!existsSync5(path)) return {};
|
|
1417
|
+
const raw = readFileSync3(path, "utf8");
|
|
1418
|
+
if (!raw.trim()) return {};
|
|
1419
|
+
return JSON.parse(raw);
|
|
1420
|
+
}
|
|
1421
|
+
function installHooksJson(path, commands, dryRun) {
|
|
1422
|
+
const existed = existsSync5(path) && readFileSync3(path, "utf8").trim().length > 0;
|
|
1423
|
+
const config2 = readJsonConfig2(path);
|
|
1424
|
+
const added = mergeHooks(config2, commands);
|
|
1425
|
+
if (added === 0 && existed) return { path, status: "current" };
|
|
1426
|
+
if (!dryRun) {
|
|
1427
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
1428
|
+
writeFileSync4(path, JSON.stringify(config2, null, 2) + "\n");
|
|
1429
|
+
}
|
|
1430
|
+
return { path, status: existed ? "merged" : "created" };
|
|
1431
|
+
}
|
|
1432
|
+
function installClaudeCommands(claudeDir, commands, dryRun) {
|
|
1433
|
+
return installHooksJson(join6(claudeDir, "settings.json"), commands, dryRun);
|
|
1434
|
+
}
|
|
1435
|
+
function ensureCodexFeaturesHooks(content) {
|
|
1436
|
+
const lines = content.split("\n");
|
|
1437
|
+
const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
|
|
1438
|
+
if (headerIdx === -1) {
|
|
1439
|
+
const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
|
|
1440
|
+
return { next: base + "\n[features]\nhooks = true\n", changed: true };
|
|
1441
|
+
}
|
|
1442
|
+
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
|
1443
|
+
const trimmed = lines[i].trim();
|
|
1444
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
|
|
1445
|
+
const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
|
|
1446
|
+
if (!m) continue;
|
|
1447
|
+
const value = m[4].replace(/\s*#.*$/, "").trim();
|
|
1448
|
+
if (value === "true") return { next: content, changed: false };
|
|
1449
|
+
lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
|
|
1450
|
+
return { next: lines.join("\n"), changed: true };
|
|
1451
|
+
}
|
|
1452
|
+
lines.splice(headerIdx + 1, 0, "hooks = true");
|
|
1453
|
+
return { next: lines.join("\n"), changed: true };
|
|
1454
|
+
}
|
|
1455
|
+
function installCodexFeatureFlag(path, dryRun) {
|
|
1456
|
+
const existed = existsSync5(path);
|
|
1457
|
+
const content = existed ? readFileSync3(path, "utf8") : "";
|
|
1458
|
+
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
1459
|
+
if (!changed) return { path, status: "current" };
|
|
1460
|
+
if (!dryRun) {
|
|
1461
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
1462
|
+
writeFileSync4(path, next);
|
|
1463
|
+
}
|
|
1464
|
+
return { path, status: existed ? "merged" : "created" };
|
|
1465
|
+
}
|
|
1466
|
+
function resolveSurfaces(surface, cwd) {
|
|
1467
|
+
if (surface === "claude") return ["claude"];
|
|
1468
|
+
if (surface === "codex") return ["codex"];
|
|
1469
|
+
if (surface === "both") return ["claude", "codex"];
|
|
1470
|
+
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
1471
|
+
const surfaces = detectHookSurfaces(cwd);
|
|
1472
|
+
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
1473
|
+
}
|
|
1474
|
+
function describe(result, dryRun) {
|
|
1475
|
+
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
1476
|
+
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
1477
|
+
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
1478
|
+
}
|
|
1479
|
+
var HOOK_SURFACE_LABEL = {
|
|
1480
|
+
claude: "Claude Code",
|
|
1481
|
+
codex: "Codex"
|
|
1482
|
+
};
|
|
1483
|
+
function installHookSurfaces(surfaces, opts) {
|
|
1484
|
+
const out = [];
|
|
1485
|
+
for (const surface of surfaces) {
|
|
1486
|
+
if (surface === "claude") {
|
|
1487
|
+
const path = join6(opts.claudeDir, "settings.json");
|
|
1488
|
+
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
1489
|
+
} else {
|
|
1490
|
+
const hooksJson = installHooksJson(join6(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
1491
|
+
const featureFlag = installCodexFeatureFlag(join6(opts.codexHome, "config.toml"), opts.dryRun);
|
|
1492
|
+
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
function detectHookSurfaces(cwd) {
|
|
1498
|
+
const detected = detectInstalledClients(cwd);
|
|
1499
|
+
const surfaces = [];
|
|
1500
|
+
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
1501
|
+
if (detected.includes("codex")) surfaces.push("codex");
|
|
1502
|
+
return surfaces;
|
|
1503
|
+
}
|
|
1504
|
+
function isSechroomOnPath() {
|
|
1505
|
+
const pathEnv = process.env.PATH ?? "";
|
|
1506
|
+
if (!pathEnv) return false;
|
|
1507
|
+
const names = process.platform === "win32" ? ["sechroom.cmd", "sechroom.exe", "sechroom.bat", "sechroom"] : ["sechroom"];
|
|
1508
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
1509
|
+
if (!dir) continue;
|
|
1510
|
+
for (const name of names) {
|
|
1511
|
+
if (existsSync5(join6(dir, name))) return true;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
function warnIfSechroomNotOnPath(write = (s) => void process.stderr.write(s)) {
|
|
1517
|
+
if (isSechroomOnPath()) return false;
|
|
1518
|
+
write(
|
|
1519
|
+
"\n\u26A0 `sechroom` isn't on your PATH. The hooks run a bare `sechroom hook \u2026` command\n when your agent fires them, so a non-global install (npx / local) will fail at\n that point. Install globally so the command resolves:\n npm i -g @sechroom/cli\n"
|
|
1520
|
+
);
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/commands/channel.ts
|
|
1339
1525
|
function registerChannel(program2) {
|
|
1340
1526
|
const channel = program2.command("channel").description(
|
|
1341
1527
|
"Receive matched substrate events over the held SignalR push leg (D-WLP-9)"
|
|
@@ -1358,11 +1544,16 @@ function registerChannel(program2) {
|
|
|
1358
1544
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
1359
1545
|
const filter = readFilter(opts);
|
|
1360
1546
|
const sub = await ensureSubscription(cfg, opts.name, filter);
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1547
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1548
|
+
const deliver = makeDeliver(
|
|
1549
|
+
filter,
|
|
1550
|
+
seen,
|
|
1551
|
+
(payload) => process.stdout.write(
|
|
1363
1552
|
(typeof payload === "string" ? payload : JSON.stringify(payload)) + "\n"
|
|
1364
|
-
)
|
|
1365
|
-
|
|
1553
|
+
)
|
|
1554
|
+
);
|
|
1555
|
+
const conn = await openConnection(cfg, deliver);
|
|
1556
|
+
await reconcile(cfg, filter, deliver);
|
|
1366
1557
|
if (json) {
|
|
1367
1558
|
emit(
|
|
1368
1559
|
{
|
|
@@ -1399,7 +1590,8 @@ function registerChannel(program2) {
|
|
|
1399
1590
|
);
|
|
1400
1591
|
await mcp.connect(new StdioServerTransport());
|
|
1401
1592
|
await ensureSubscription(cfg, opts.name, filter);
|
|
1402
|
-
const
|
|
1593
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1594
|
+
const deliver = makeDeliver(filter, seen, (payload) => {
|
|
1403
1595
|
const { content, meta } = summarizeEvent(payload);
|
|
1404
1596
|
void mcp.notification({
|
|
1405
1597
|
method: "notifications/claude/channel",
|
|
@@ -1409,6 +1601,8 @@ function registerChannel(program2) {
|
|
|
1409
1601
|
`))
|
|
1410
1602
|
);
|
|
1411
1603
|
});
|
|
1604
|
+
const conn = await openConnection(cfg, deliver);
|
|
1605
|
+
await reconcile(cfg, filter, deliver);
|
|
1412
1606
|
process.stderr.write(
|
|
1413
1607
|
style.dim(
|
|
1414
1608
|
`sechroom channel (mcp) \u2014 tenant ${cfg.tenant}, tags [${filter.tags.join(", ")}]
|
|
@@ -1417,6 +1611,55 @@ function registerChannel(program2) {
|
|
|
1417
1611
|
);
|
|
1418
1612
|
await holdOpen(conn);
|
|
1419
1613
|
});
|
|
1614
|
+
channel.command("install").description(
|
|
1615
|
+
"Wire `sechroom channel mcp` into the project .mcp.json as a Claude Code channel MCP server (idempotent)"
|
|
1616
|
+
).option(
|
|
1617
|
+
"--workspace <wsp...>",
|
|
1618
|
+
"Restrict dispatches to these workspace id(s)"
|
|
1619
|
+
).option(
|
|
1620
|
+
"--tag <tag...>",
|
|
1621
|
+
"Tag(s) a dispatch must carry to match (repeatable). Default targets WLP task dispatches.",
|
|
1622
|
+
["kind:task"]
|
|
1623
|
+
).option(
|
|
1624
|
+
"--name <name>",
|
|
1625
|
+
"MCP server + subscription name (idempotent per name)",
|
|
1626
|
+
"sechroom-channel"
|
|
1627
|
+
).option("--dry-run", "Print what would change; write nothing").action((opts) => {
|
|
1628
|
+
const path = join7(process.cwd(), ".mcp.json");
|
|
1629
|
+
const dryRun = Boolean(opts.dryRun);
|
|
1630
|
+
const args = ["channel", "mcp", "--name", opts.name];
|
|
1631
|
+
for (const w of opts.workspace ?? [])
|
|
1632
|
+
args.push("--workspace", w);
|
|
1633
|
+
for (const t of opts.tag ?? []) args.push("--tag", t);
|
|
1634
|
+
const entry = { command: "sechroom", args };
|
|
1635
|
+
const config2 = readMcpConfig(path);
|
|
1636
|
+
config2.mcpServers ??= {};
|
|
1637
|
+
const existing = config2.mcpServers[opts.name];
|
|
1638
|
+
const status = JSON.stringify(existing) === JSON.stringify(entry) ? "current" : existing ? "updated" : "created";
|
|
1639
|
+
if (status !== "current" && !dryRun) {
|
|
1640
|
+
config2.mcpServers[opts.name] = entry;
|
|
1641
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
1642
|
+
writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
|
|
1643
|
+
}
|
|
1644
|
+
const verb = status === "current" ? "already configured" : dryRun ? `would ${status === "created" ? "create" : "update"}` : status;
|
|
1645
|
+
process.stdout.write(`${style.green("channel")} ${path} (${verb})
|
|
1646
|
+
`);
|
|
1647
|
+
process.stdout.write(
|
|
1648
|
+
style.dim(` server "${opts.name}": sechroom ${args.join(" ")}
|
|
1649
|
+
`)
|
|
1650
|
+
);
|
|
1651
|
+
if (status !== "current") {
|
|
1652
|
+
process.stdout.write(
|
|
1653
|
+
style.dim(
|
|
1654
|
+
`
|
|
1655
|
+
Load it (Channels research preview) by launching your agent with:
|
|
1656
|
+
claude --dangerously-load-development-channels server:${opts.name}
|
|
1657
|
+
`
|
|
1658
|
+
)
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
warnIfSechroomNotOnPath();
|
|
1662
|
+
});
|
|
1420
1663
|
channel.addHelpText(
|
|
1421
1664
|
"after",
|
|
1422
1665
|
`
|
|
@@ -1425,11 +1668,23 @@ Examples:
|
|
|
1425
1668
|
$ sechroom channel connect --tag kind:task --tag status:in-progress
|
|
1426
1669
|
$ sechroom channel connect --workspace wsp_X --json | jq .
|
|
1427
1670
|
|
|
1428
|
-
#
|
|
1429
|
-
|
|
1430
|
-
# then: claude --dangerously-load-development-channels server:sechroom`
|
|
1671
|
+
# Wire it as a Claude Code channel MCP server (research preview, v2.1.80+):
|
|
1672
|
+
$ sechroom channel install --workspace wsp_X --tag kind:task --tag status:in-progress
|
|
1673
|
+
# then: claude --dangerously-load-development-channels server:sechroom-channel`
|
|
1431
1674
|
);
|
|
1432
1675
|
}
|
|
1676
|
+
function readMcpConfig(path) {
|
|
1677
|
+
if (!existsSync6(path)) return {};
|
|
1678
|
+
const raw = readFileSync4(path, "utf8");
|
|
1679
|
+
if (!raw.trim()) return {};
|
|
1680
|
+
try {
|
|
1681
|
+
return JSON.parse(raw);
|
|
1682
|
+
} catch {
|
|
1683
|
+
return fail(
|
|
1684
|
+
`Could not parse ${path} as JSON \u2014 fix or remove it before installing the channel.`
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1433
1688
|
function readFilter(opts) {
|
|
1434
1689
|
const tags = opts.tag ?? [];
|
|
1435
1690
|
const workspaceScope = opts.workspace ?? [];
|
|
@@ -1482,20 +1737,134 @@ function holdOpen(conn) {
|
|
|
1482
1737
|
process.on("SIGTERM", stop);
|
|
1483
1738
|
});
|
|
1484
1739
|
}
|
|
1485
|
-
function
|
|
1740
|
+
function parseEvent(payload) {
|
|
1486
1741
|
let data = payload;
|
|
1487
1742
|
if (typeof payload === "string") {
|
|
1488
1743
|
try {
|
|
1489
1744
|
data = JSON.parse(payload);
|
|
1490
1745
|
} catch {
|
|
1491
|
-
return {
|
|
1746
|
+
return { eventType: "", memoryId: "", workspaceId: "", tags: void 0 };
|
|
1492
1747
|
}
|
|
1493
1748
|
}
|
|
1494
1749
|
const obj = data ?? {};
|
|
1495
1750
|
const inner = obj.data ?? obj;
|
|
1496
|
-
const
|
|
1497
|
-
|
|
1498
|
-
|
|
1751
|
+
const rawTags = inner.tags ?? inner.Tags;
|
|
1752
|
+
return {
|
|
1753
|
+
eventType: str(inner.eventType ?? inner.EventType ?? obj.type) || "substrate.event",
|
|
1754
|
+
memoryId: str(inner.memoryId ?? inner.MemoryId),
|
|
1755
|
+
workspaceId: str(inner.workspaceId ?? inner.WorkspaceId),
|
|
1756
|
+
tags: Array.isArray(rawTags) ? rawTags.filter((t) => typeof t === "string") : void 0
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
function shouldDeliver(payload, filter) {
|
|
1760
|
+
const { workspaceId, tags } = parseEvent(payload);
|
|
1761
|
+
if (filter.workspaceScope.length > 0 && (!workspaceId || !filter.workspaceScope.includes(workspaceId)))
|
|
1762
|
+
return false;
|
|
1763
|
+
if (filter.tags.length > 0) {
|
|
1764
|
+
if (!tags) return false;
|
|
1765
|
+
return facetedTagMatch(tags, filter.tags);
|
|
1766
|
+
}
|
|
1767
|
+
return true;
|
|
1768
|
+
}
|
|
1769
|
+
function makeDeliver(filter, seen, forward) {
|
|
1770
|
+
return (payload) => {
|
|
1771
|
+
if (!shouldDeliver(payload, filter)) return;
|
|
1772
|
+
const { memoryId } = parseEvent(payload);
|
|
1773
|
+
if (memoryId) {
|
|
1774
|
+
if (seen.has(memoryId)) return;
|
|
1775
|
+
seen.add(memoryId);
|
|
1776
|
+
}
|
|
1777
|
+
forward(payload);
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
async function reconcile(cfg, filter, deliver) {
|
|
1781
|
+
if (filter.workspaceScope.length === 0) {
|
|
1782
|
+
process.stderr.write(
|
|
1783
|
+
style.dim(
|
|
1784
|
+
"channel: no --workspace to reconcile against; live feed only (a dropped dispatch won't be recovered).\n"
|
|
1785
|
+
)
|
|
1786
|
+
);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (filter.tags.length === 0) return;
|
|
1790
|
+
let token;
|
|
1791
|
+
try {
|
|
1792
|
+
token = await requireToken(cfg);
|
|
1793
|
+
} catch {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const qs = `filterTags=${encodeURIComponent(filter.tags.join(","))}&limit=100`;
|
|
1797
|
+
let recovered = 0;
|
|
1798
|
+
for (const ws of filter.workspaceScope) {
|
|
1799
|
+
try {
|
|
1800
|
+
const resp = await fetch(
|
|
1801
|
+
`${cfg.baseUrl}/workspaces/${encodeURIComponent(ws)}/memories/feed?${qs}`,
|
|
1802
|
+
{
|
|
1803
|
+
headers: {
|
|
1804
|
+
authorization: `Bearer ${token}`,
|
|
1805
|
+
tenant: cfg.tenant,
|
|
1806
|
+
"x-sechroom-surface": "cli"
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
);
|
|
1810
|
+
if (!resp.ok) {
|
|
1811
|
+
process.stderr.write(
|
|
1812
|
+
err(
|
|
1813
|
+
`channel: reconcile query for ${ws} failed (HTTP ${resp.status})
|
|
1814
|
+
`
|
|
1815
|
+
)
|
|
1816
|
+
);
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
const data = await resp.json();
|
|
1820
|
+
for (const m of data.results ?? []) {
|
|
1821
|
+
if (!m.id) continue;
|
|
1822
|
+
deliver({
|
|
1823
|
+
eventType: "reconcile",
|
|
1824
|
+
memoryId: m.id,
|
|
1825
|
+
workspaceId: ws,
|
|
1826
|
+
tags: m.tags ?? []
|
|
1827
|
+
});
|
|
1828
|
+
recovered++;
|
|
1829
|
+
}
|
|
1830
|
+
} catch (e) {
|
|
1831
|
+
process.stderr.write(
|
|
1832
|
+
err(`channel: reconcile error for ${ws}: ${String(e)}
|
|
1833
|
+
`)
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (recovered > 0)
|
|
1838
|
+
process.stderr.write(
|
|
1839
|
+
style.dim(
|
|
1840
|
+
`channel: reconciled ${recovered} already-queued event(s) on connect.
|
|
1841
|
+
`
|
|
1842
|
+
)
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
function facetedTagMatch(eventTags, filterTags) {
|
|
1846
|
+
const have = new Set(eventTags);
|
|
1847
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1848
|
+
for (const f of filterTags) {
|
|
1849
|
+
const ns = f.endsWith(":*") ? f.slice(0, -2) : namespaceOf(f);
|
|
1850
|
+
const group = groups.get(ns) ?? [];
|
|
1851
|
+
group.push(f);
|
|
1852
|
+
groups.set(ns, group);
|
|
1853
|
+
}
|
|
1854
|
+
for (const [ns, group] of groups) {
|
|
1855
|
+
const ok2 = group.some(
|
|
1856
|
+
(f) => f.endsWith(":*") ? eventTags.some((t) => namespaceOf(t) === ns) : have.has(f)
|
|
1857
|
+
);
|
|
1858
|
+
if (!ok2) return false;
|
|
1859
|
+
}
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
function namespaceOf(tag) {
|
|
1863
|
+
const i = tag.indexOf(":");
|
|
1864
|
+
return i >= 0 ? tag.slice(0, i) : tag;
|
|
1865
|
+
}
|
|
1866
|
+
function summarizeEvent(payload) {
|
|
1867
|
+
const { eventType, memoryId, workspaceId } = parseEvent(payload);
|
|
1499
1868
|
const content = memoryId ? `${eventType}: ${memoryId}${workspaceId ? ` (workspace ${workspaceId})` : ""}` : typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
1500
1869
|
const meta = {};
|
|
1501
1870
|
if (eventType) meta.event_type = eventType;
|
|
@@ -1521,7 +1890,7 @@ Examples:
|
|
|
1521
1890
|
$ sechroom chat replies 1718049600.123456 --surface slack
|
|
1522
1891
|
$ sechroom chat stop-tracking 1718049600.123456 --surface slack`
|
|
1523
1892
|
);
|
|
1524
|
-
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,
|
|
1893
|
+
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) => {
|
|
1525
1894
|
const { surface, ...globals } = cmd.optsWithGlobals();
|
|
1526
1895
|
const json = Boolean(cmd.optsWithGlobals().json);
|
|
1527
1896
|
const cfg = resolveConfig(globals);
|
|
@@ -1531,7 +1900,7 @@ Examples:
|
|
|
1531
1900
|
params: { path: { surface: String(surface) } },
|
|
1532
1901
|
body: {
|
|
1533
1902
|
channelId,
|
|
1534
|
-
text,
|
|
1903
|
+
text: text2,
|
|
1535
1904
|
guildId: opts.guild ?? null,
|
|
1536
1905
|
attachedMemoryId: opts.memory ?? null,
|
|
1537
1906
|
trackReplies: opts.track,
|
|
@@ -1590,28 +1959,28 @@ Examples:
|
|
|
1590
1959
|
}
|
|
1591
1960
|
|
|
1592
1961
|
// src/commands/checkpoint.ts
|
|
1593
|
-
import { mkdirSync as
|
|
1594
|
-
import { dirname as
|
|
1962
|
+
import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "fs";
|
|
1963
|
+
import { dirname as dirname7, join as join10 } from "path";
|
|
1595
1964
|
|
|
1596
1965
|
// src/commands/hook.ts
|
|
1597
1966
|
import { createHash as createHash2 } from "crypto";
|
|
1598
|
-
import { existsSync as
|
|
1599
|
-
import {
|
|
1967
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync6, statSync as statSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
1968
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
1600
1969
|
|
|
1601
1970
|
// src/sem.ts
|
|
1602
|
-
import { dirname as
|
|
1603
|
-
import { appendFileSync, existsSync as
|
|
1604
|
-
var SEM_FILE =
|
|
1971
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
1972
|
+
import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readdirSync, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1973
|
+
var SEM_FILE = join8(".sechroom", "lane.json");
|
|
1605
1974
|
var STATE_DIR_NAME2 = ".sechroom";
|
|
1606
1975
|
function localSemPath(cwd = process.cwd()) {
|
|
1607
|
-
return
|
|
1976
|
+
return join8(cwd, SEM_FILE);
|
|
1608
1977
|
}
|
|
1609
1978
|
function resolveSemPathForRead(start = process.cwd()) {
|
|
1610
1979
|
let dir = start;
|
|
1611
1980
|
while (true) {
|
|
1612
|
-
const candidate =
|
|
1613
|
-
if (
|
|
1614
|
-
const parent =
|
|
1981
|
+
const candidate = join8(dir, SEM_FILE);
|
|
1982
|
+
if (existsSync7(candidate)) return candidate;
|
|
1983
|
+
const parent = dirname5(dir);
|
|
1615
1984
|
if (parent === dir) return void 0;
|
|
1616
1985
|
dir = parent;
|
|
1617
1986
|
}
|
|
@@ -1621,23 +1990,23 @@ function applyWorktreeLaneSuffix(lane, start = process.cwd()) {
|
|
|
1621
1990
|
let dir = start;
|
|
1622
1991
|
let gitPath;
|
|
1623
1992
|
for (; ; ) {
|
|
1624
|
-
const candidate =
|
|
1625
|
-
if (
|
|
1993
|
+
const candidate = join8(dir, ".git");
|
|
1994
|
+
if (existsSync7(candidate)) {
|
|
1626
1995
|
gitPath = candidate;
|
|
1627
1996
|
break;
|
|
1628
1997
|
}
|
|
1629
|
-
const parent =
|
|
1998
|
+
const parent = dirname5(dir);
|
|
1630
1999
|
if (parent === dir) break;
|
|
1631
2000
|
dir = parent;
|
|
1632
2001
|
}
|
|
1633
2002
|
if (!gitPath || statSync(gitPath).isDirectory()) return lane;
|
|
1634
|
-
const gitFile =
|
|
2003
|
+
const gitFile = readFileSync5(gitPath, "utf8");
|
|
1635
2004
|
const common = gitFile.trim().match(/^gitdir:\s*(.+)\/worktrees\/[^/\s]+\s*$/);
|
|
1636
2005
|
if (!common) return lane;
|
|
1637
|
-
const worktreesDir =
|
|
2006
|
+
const worktreesDir = join8(common[1], "worktrees");
|
|
1638
2007
|
const siblings = readdirSync(worktreesDir).filter((n) => {
|
|
1639
2008
|
try {
|
|
1640
|
-
return statSync(
|
|
2009
|
+
return statSync(join8(worktreesDir, n)).isDirectory();
|
|
1641
2010
|
} catch {
|
|
1642
2011
|
return false;
|
|
1643
2012
|
}
|
|
@@ -1658,17 +2027,17 @@ function serializeSem(values) {
|
|
|
1658
2027
|
}
|
|
1659
2028
|
function readSem(path) {
|
|
1660
2029
|
const p = path ?? resolveSemPathForRead();
|
|
1661
|
-
if (!p || !
|
|
1662
|
-
return { path: p, values: parseLaneJson(
|
|
2030
|
+
if (!p || !existsSync7(p)) return void 0;
|
|
2031
|
+
return { path: p, values: parseLaneJson(readFileSync5(p, "utf8")) };
|
|
1663
2032
|
}
|
|
1664
2033
|
function readLocalSemValues(cwd = process.cwd()) {
|
|
1665
|
-
const next =
|
|
1666
|
-
if (
|
|
2034
|
+
const next = join8(cwd, SEM_FILE);
|
|
2035
|
+
if (existsSync7(next)) return readSem(next)?.values ?? {};
|
|
1667
2036
|
return {};
|
|
1668
2037
|
}
|
|
1669
|
-
function parseLaneJson(
|
|
2038
|
+
function parseLaneJson(text2) {
|
|
1670
2039
|
try {
|
|
1671
|
-
const parsed = JSON.parse(
|
|
2040
|
+
const parsed = JSON.parse(text2);
|
|
1672
2041
|
const out = {};
|
|
1673
2042
|
for (const [k, v] of Object.entries(parsed)) {
|
|
1674
2043
|
if (typeof v === "string") out[k] = v;
|
|
@@ -1680,12 +2049,15 @@ function parseLaneJson(text) {
|
|
|
1680
2049
|
}
|
|
1681
2050
|
var STATE_DIR_IGNORE = `${STATE_DIR_NAME2}/`;
|
|
1682
2051
|
function writeSem(values, path = localSemPath()) {
|
|
1683
|
-
|
|
1684
|
-
|
|
2052
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
2053
|
+
writeFileSync6(path, serializeSem(values));
|
|
1685
2054
|
ensureSemIgnored(path);
|
|
1686
2055
|
ensureContinuityScaffold(path);
|
|
1687
2056
|
return path;
|
|
1688
2057
|
}
|
|
2058
|
+
function ensureStateDirIgnored(cwd = process.cwd()) {
|
|
2059
|
+
ensureSemIgnored(localSemPath(cwd));
|
|
2060
|
+
}
|
|
1689
2061
|
var CONTINUITY_FILE_NAME = "continuity.json";
|
|
1690
2062
|
var CONTINUITY_SCAFFOLD = JSON.stringify(
|
|
1691
2063
|
{
|
|
@@ -1705,9 +2077,9 @@ var CONTINUITY_SCAFFOLD = JSON.stringify(
|
|
|
1705
2077
|
) + "\n";
|
|
1706
2078
|
function ensureContinuityScaffold(semPath) {
|
|
1707
2079
|
try {
|
|
1708
|
-
const target =
|
|
1709
|
-
if (
|
|
1710
|
-
|
|
2080
|
+
const target = join8(dirname5(semPath), CONTINUITY_FILE_NAME);
|
|
2081
|
+
if (existsSync7(target)) return;
|
|
2082
|
+
writeFileSync6(target, CONTINUITY_SCAFFOLD);
|
|
1711
2083
|
} catch {
|
|
1712
2084
|
}
|
|
1713
2085
|
}
|
|
@@ -1720,8 +2092,8 @@ function ignoresSem(content) {
|
|
|
1720
2092
|
function inGitRepo(startDir) {
|
|
1721
2093
|
let dir = startDir;
|
|
1722
2094
|
for (; ; ) {
|
|
1723
|
-
if (
|
|
1724
|
-
const parent =
|
|
2095
|
+
if (existsSync7(join8(dir, ".git"))) return true;
|
|
2096
|
+
const parent = dirname5(dir);
|
|
1725
2097
|
if (parent === dir) return false;
|
|
1726
2098
|
dir = parent;
|
|
1727
2099
|
}
|
|
@@ -1729,104 +2101,34 @@ function inGitRepo(startDir) {
|
|
|
1729
2101
|
function resolveGitignoreTarget(startDir) {
|
|
1730
2102
|
let dir = startDir;
|
|
1731
2103
|
for (; ; ) {
|
|
1732
|
-
const gi =
|
|
1733
|
-
if (
|
|
1734
|
-
const parent =
|
|
1735
|
-
if (
|
|
1736
|
-
return { path:
|
|
2104
|
+
const gi = join8(dir, ".gitignore");
|
|
2105
|
+
if (existsSync7(gi)) return { path: gi, exists: true };
|
|
2106
|
+
const parent = dirname5(dir);
|
|
2107
|
+
if (existsSync7(join8(dir, ".git")) || parent === dir) {
|
|
2108
|
+
return { path: join8(startDir, ".gitignore"), exists: false };
|
|
1737
2109
|
}
|
|
1738
2110
|
dir = parent;
|
|
1739
2111
|
}
|
|
1740
2112
|
}
|
|
1741
2113
|
function ensureSemIgnored(semPath) {
|
|
1742
2114
|
try {
|
|
1743
|
-
const checkoutDir =
|
|
2115
|
+
const checkoutDir = dirname5(dirname5(semPath));
|
|
1744
2116
|
if (!inGitRepo(checkoutDir)) return;
|
|
1745
2117
|
const target = resolveGitignoreTarget(checkoutDir);
|
|
1746
2118
|
if (target.exists) {
|
|
1747
|
-
const content =
|
|
2119
|
+
const content = readFileSync5(target.path, "utf8");
|
|
1748
2120
|
if (ignoresSem(content)) return;
|
|
1749
2121
|
const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
|
|
1750
2122
|
appendFileSync(target.path, `${sep}${STATE_DIR_IGNORE}
|
|
1751
2123
|
`);
|
|
1752
2124
|
} else {
|
|
1753
|
-
|
|
2125
|
+
writeFileSync6(target.path, `${STATE_DIR_IGNORE}
|
|
1754
2126
|
`);
|
|
1755
2127
|
}
|
|
1756
2128
|
} catch {
|
|
1757
2129
|
}
|
|
1758
2130
|
}
|
|
1759
2131
|
|
|
1760
|
-
// src/setup/clients.ts
|
|
1761
|
-
import { existsSync as existsSync5 } from "fs";
|
|
1762
|
-
import { homedir as homedir3 } from "os";
|
|
1763
|
-
import { dirname as dirname3, join as join6 } from "path";
|
|
1764
|
-
function claudeDesktopConfigPath(home) {
|
|
1765
|
-
switch (process.platform) {
|
|
1766
|
-
case "darwin":
|
|
1767
|
-
return join6(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
1768
|
-
case "win32":
|
|
1769
|
-
return join6(process.env.APPDATA ?? join6(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
1770
|
-
default:
|
|
1771
|
-
return join6(home, ".config", "Claude", "claude_desktop_config.json");
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
function clientTargets(cwd, opts = {}) {
|
|
1775
|
-
const home = homedir3();
|
|
1776
|
-
const claudeDir = opts.claudeDir ?? join6(home, ".claude");
|
|
1777
|
-
const codexHome = opts.codexHome ?? join6(home, ".codex");
|
|
1778
|
-
return {
|
|
1779
|
-
"claude-code": {
|
|
1780
|
-
key: "claude-code",
|
|
1781
|
-
label: "Claude Code",
|
|
1782
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".mcp.json"), format: "json" },
|
|
1783
|
-
instruction: { surfaceKey: "claude-code", path: join6(cwd, "CLAUDE.md") }
|
|
1784
|
-
},
|
|
1785
|
-
"claude-desktop": {
|
|
1786
|
-
key: "claude-desktop",
|
|
1787
|
-
label: "Claude Desktop",
|
|
1788
|
-
mcp: { surfaceKey: "claude-desktop", sectionType: SectionType.McpConfig, path: claudeDesktopConfigPath(home), format: "json" },
|
|
1789
|
-
instruction: { surfaceKey: "claude-desktop", path: join6(claudeDir, "CLAUDE.md") }
|
|
1790
|
-
},
|
|
1791
|
-
codex: {
|
|
1792
|
-
key: "codex",
|
|
1793
|
-
label: "Codex CLI",
|
|
1794
|
-
mcp: { surfaceKey: "chatgpt", sectionType: SectionType.McpConfigToml, path: join6(codexHome, "config.toml"), format: "toml" },
|
|
1795
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
1796
|
-
},
|
|
1797
|
-
cursor: {
|
|
1798
|
-
key: "cursor",
|
|
1799
|
-
label: "Cursor",
|
|
1800
|
-
mcp: { surfaceKey: "claude-code", sectionType: SectionType.McpConfig, path: join6(cwd, ".cursor", "mcp.json"), format: "json" },
|
|
1801
|
-
instruction: { surfaceKey: "chatgpt", path: join6(cwd, "AGENTS.md") }
|
|
1802
|
-
},
|
|
1803
|
-
antigravity: {
|
|
1804
|
-
key: "antigravity",
|
|
1805
|
-
label: "Google Antigravity",
|
|
1806
|
-
// FR-sechroom-247 — Antigravity reads MCP from a GLOBAL, home-relative
|
|
1807
|
-
// `~/.gemini/config/mcp_config.json` (not cwd; not affected by
|
|
1808
|
-
// CLAUDE_CONFIG_DIR / CODEX_HOME). The snippet — `serverUrl`-shaped, no
|
|
1809
|
-
// `type` — comes from the `antigravity` server surface, so we don't
|
|
1810
|
-
// hardcode it here. Instructions go in the project `AGENTS.md`
|
|
1811
|
-
// (cross-tool, shared with Codex/Cursor).
|
|
1812
|
-
mcp: { surfaceKey: "antigravity", sectionType: SectionType.McpConfig, path: join6(home, ".gemini", "config", "mcp_config.json"), format: "json" },
|
|
1813
|
-
instruction: { surfaceKey: "antigravity", path: join6(cwd, "AGENTS.md") }
|
|
1814
|
-
}
|
|
1815
|
-
};
|
|
1816
|
-
}
|
|
1817
|
-
var ALL_CLIENT_KEYS = ["claude-code", "claude-desktop", "codex", "cursor", "antigravity"];
|
|
1818
|
-
var DEFAULT_CLIENT_KEY = "claude-code";
|
|
1819
|
-
function detectInstalledClients(cwd) {
|
|
1820
|
-
const home = homedir3();
|
|
1821
|
-
const detected = [];
|
|
1822
|
-
if (resolveClaudeTargets({}).some((t) => existsSync5(t.dir))) detected.push("claude-code");
|
|
1823
|
-
if (existsSync5(dirname3(claudeDesktopConfigPath(home)))) detected.push("claude-desktop");
|
|
1824
|
-
if (resolveCodexHomes({}).some((d) => existsSync5(d))) detected.push("codex");
|
|
1825
|
-
if (existsSync5(join6(home, ".cursor")) || existsSync5(join6(cwd, ".cursor"))) detected.push("cursor");
|
|
1826
|
-
if (existsSync5(join6(home, ".gemini"))) detected.push("antigravity");
|
|
1827
|
-
return detected;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
2132
|
// src/commands/hook.ts
|
|
1831
2133
|
async function readStdin() {
|
|
1832
2134
|
if (process.stdin.isTTY) return "";
|
|
@@ -1851,13 +2153,13 @@ function resolveLane(flagLane, cwd) {
|
|
|
1851
2153
|
if (!base) return void 0;
|
|
1852
2154
|
return applyWorktreeLaneSuffix(base, start);
|
|
1853
2155
|
}
|
|
1854
|
-
var INTENT_FILE =
|
|
2156
|
+
var INTENT_FILE = join9(".sechroom", "continuity.json");
|
|
1855
2157
|
function resolveIntentPath(start) {
|
|
1856
2158
|
let dir = start;
|
|
1857
2159
|
for (; ; ) {
|
|
1858
|
-
const candidate =
|
|
1859
|
-
if (
|
|
1860
|
-
const parent =
|
|
2160
|
+
const candidate = join9(dir, INTENT_FILE);
|
|
2161
|
+
if (existsSync8(candidate)) return candidate;
|
|
2162
|
+
const parent = dirname6(dir);
|
|
1861
2163
|
if (parent === dir) return void 0;
|
|
1862
2164
|
dir = parent;
|
|
1863
2165
|
}
|
|
@@ -1866,7 +2168,7 @@ function readIntent(start) {
|
|
|
1866
2168
|
const path = resolveIntentPath(start);
|
|
1867
2169
|
if (!path) return void 0;
|
|
1868
2170
|
try {
|
|
1869
|
-
return JSON.parse(
|
|
2171
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
1870
2172
|
} catch {
|
|
1871
2173
|
return void 0;
|
|
1872
2174
|
}
|
|
@@ -1908,14 +2210,14 @@ async function saveSnapshotFromIntent(cmd, cwd, laneFlag, scopeFlag, defaultScop
|
|
|
1908
2210
|
}
|
|
1909
2211
|
function ledgerPath(start) {
|
|
1910
2212
|
const intent = resolveIntentPath(start);
|
|
1911
|
-
const dir = intent ?
|
|
1912
|
-
return
|
|
2213
|
+
const dir = intent ? dirname6(intent) : join9(start, ".sechroom");
|
|
2214
|
+
return join9(dir, ".checkpoint-state.json");
|
|
1913
2215
|
}
|
|
1914
2216
|
function readLedger(start) {
|
|
1915
2217
|
try {
|
|
1916
2218
|
const p = ledgerPath(start);
|
|
1917
|
-
if (!
|
|
1918
|
-
return JSON.parse(
|
|
2219
|
+
if (!existsSync8(p)) return {};
|
|
2220
|
+
return JSON.parse(readFileSync6(p, "utf8"));
|
|
1919
2221
|
} catch {
|
|
1920
2222
|
return {};
|
|
1921
2223
|
}
|
|
@@ -1962,180 +2264,51 @@ function recordPush(start, intent) {
|
|
|
1962
2264
|
} catch {
|
|
1963
2265
|
mtimeMs = void 0;
|
|
1964
2266
|
}
|
|
1965
|
-
|
|
1966
|
-
const ledger = {
|
|
1967
|
-
lastEpochMs: Date.now(),
|
|
1968
|
-
lastMtimeMs: mtimeMs,
|
|
1969
|
-
lastHash: intentHash(intent)
|
|
1970
|
-
};
|
|
1971
|
-
|
|
1972
|
-
} catch {
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
function formatContext(bundle, lane) {
|
|
1976
|
-
const s = bundle?.latestSnapshot;
|
|
1977
|
-
if (!s) return null;
|
|
1978
|
-
const lines = [];
|
|
1979
|
-
lines.push(`[sechroom continuity \u2014 resumed lane ${lane}]`);
|
|
1980
|
-
if (s.currentObjective) lines.push(`Objective: ${s.currentObjective}`);
|
|
1981
|
-
if (s.currentState) lines.push(`State: ${s.currentState}`);
|
|
1982
|
-
if (s.lastMeaningfulAction) lines.push(`Last action: ${s.lastMeaningfulAction}`);
|
|
1983
|
-
if (s.nextIntendedAction) lines.push(`Next: ${s.nextIntendedAction}`);
|
|
1984
|
-
if (s.resumeInstruction) lines.push(`Resume: ${s.resumeInstruction}`);
|
|
1985
|
-
const constraints = s.activeConstraints ?? [];
|
|
1986
|
-
if (constraints.length) {
|
|
1987
|
-
lines.push("Active constraints:");
|
|
1988
|
-
for (const c of constraints) lines.push(` - ${c}`);
|
|
1989
|
-
}
|
|
1990
|
-
const questions = s.openQuestions ?? [];
|
|
1991
|
-
if (questions.length) {
|
|
1992
|
-
lines.push("Open questions:");
|
|
1993
|
-
for (const q of questions) lines.push(` - ${q}`);
|
|
1994
|
-
}
|
|
1995
|
-
const artifacts = s.relevantArtifactIds ?? [];
|
|
1996
|
-
if (artifacts.length) lines.push(`Relevant artifacts: ${artifacts.join(", ")}`);
|
|
1997
|
-
const marker = [s.id, s.createdAt].filter(Boolean).join(" @ ");
|
|
1998
|
-
if (marker) lines.push(`(snapshot ${marker})`);
|
|
1999
|
-
return lines.join("\n");
|
|
2000
|
-
}
|
|
2001
|
-
function emitSessionStart(additionalContext) {
|
|
2002
|
-
process.stdout.write(
|
|
2003
|
-
JSON.stringify({
|
|
2004
|
-
hookSpecificOutput: {
|
|
2005
|
-
hookEventName: "SessionStart",
|
|
2006
|
-
additionalContext
|
|
2007
|
-
}
|
|
2008
|
-
}) + "\n"
|
|
2009
|
-
);
|
|
2010
|
-
}
|
|
2011
|
-
var CLAUDE_HOOK_COMMANDS = {
|
|
2012
|
-
SessionStart: "sechroom hook session-start",
|
|
2013
|
-
PreCompact: "sechroom hook pre-compact",
|
|
2014
|
-
SessionEnd: "sechroom hook session-end"
|
|
2015
|
-
};
|
|
2016
|
-
var CODEX_HOOK_COMMANDS = {
|
|
2017
|
-
SessionStart: "sechroom hook session-start",
|
|
2018
|
-
Stop: "sechroom hook session-end --debounce-minutes 10"
|
|
2019
|
-
};
|
|
2020
|
-
function hasHookCommand(config2, event, command) {
|
|
2021
|
-
const groups = config2.hooks?.[event] ?? [];
|
|
2022
|
-
return groups.some((g) => (g.hooks ?? []).some((h) => h.type === "command" && h.command === command));
|
|
2023
|
-
}
|
|
2024
|
-
function mergeHooks(config2, commands) {
|
|
2025
|
-
config2.hooks ??= {};
|
|
2026
|
-
let added = 0;
|
|
2027
|
-
for (const [event, command] of Object.entries(commands)) {
|
|
2028
|
-
if (hasHookCommand(config2, event, command)) continue;
|
|
2029
|
-
const groups = config2.hooks[event] ??= [];
|
|
2030
|
-
groups.push({ hooks: [{ type: "command", command }] });
|
|
2031
|
-
added += 1;
|
|
2032
|
-
}
|
|
2033
|
-
return added;
|
|
2034
|
-
}
|
|
2035
|
-
function readJsonConfig2(path) {
|
|
2036
|
-
if (!existsSync6(path)) return {};
|
|
2037
|
-
const raw = readFileSync4(path, "utf8");
|
|
2038
|
-
if (!raw.trim()) return {};
|
|
2039
|
-
return JSON.parse(raw);
|
|
2040
|
-
}
|
|
2041
|
-
function installHooksJson(path, commands, dryRun) {
|
|
2042
|
-
const existed = existsSync6(path) && readFileSync4(path, "utf8").trim().length > 0;
|
|
2043
|
-
const config2 = readJsonConfig2(path);
|
|
2044
|
-
const added = mergeHooks(config2, commands);
|
|
2045
|
-
if (added === 0 && existed) return { path, status: "current" };
|
|
2046
|
-
if (!dryRun) {
|
|
2047
|
-
mkdirSync5(dirname4(path), { recursive: true });
|
|
2048
|
-
writeFileSync5(path, JSON.stringify(config2, null, 2) + "\n");
|
|
2049
|
-
}
|
|
2050
|
-
return { path, status: existed ? "merged" : "created" };
|
|
2051
|
-
}
|
|
2052
|
-
function ensureCodexFeaturesHooks(content) {
|
|
2053
|
-
const lines = content.split("\n");
|
|
2054
|
-
const headerIdx = lines.findIndex((l) => l.trim() === "[features]");
|
|
2055
|
-
if (headerIdx === -1) {
|
|
2056
|
-
const base = content.length === 0 || content.endsWith("\n") ? content : content + "\n";
|
|
2057
|
-
return { next: base + "\n[features]\nhooks = true\n", changed: true };
|
|
2058
|
-
}
|
|
2059
|
-
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
|
2060
|
-
const trimmed = lines[i].trim();
|
|
2061
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) break;
|
|
2062
|
-
const m = lines[i].match(/^(\s*)hooks(\s*)=(\s*)(.*)$/);
|
|
2063
|
-
if (!m) continue;
|
|
2064
|
-
const value = m[4].replace(/\s*#.*$/, "").trim();
|
|
2065
|
-
if (value === "true") return { next: content, changed: false };
|
|
2066
|
-
lines[i] = `${m[1]}hooks${m[2]}=${m[3]}true`;
|
|
2067
|
-
return { next: lines.join("\n"), changed: true };
|
|
2068
|
-
}
|
|
2069
|
-
lines.splice(headerIdx + 1, 0, "hooks = true");
|
|
2070
|
-
return { next: lines.join("\n"), changed: true };
|
|
2071
|
-
}
|
|
2072
|
-
function installCodexFeatureFlag(path, dryRun) {
|
|
2073
|
-
const existed = existsSync6(path);
|
|
2074
|
-
const content = existed ? readFileSync4(path, "utf8") : "";
|
|
2075
|
-
const { next, changed } = ensureCodexFeaturesHooks(content);
|
|
2076
|
-
if (!changed) return { path, status: "current" };
|
|
2077
|
-
if (!dryRun) {
|
|
2078
|
-
mkdirSync5(dirname4(path), { recursive: true });
|
|
2079
|
-
writeFileSync5(path, next);
|
|
2080
|
-
}
|
|
2081
|
-
return { path, status: existed ? "merged" : "created" };
|
|
2082
|
-
}
|
|
2083
|
-
function resolveSurfaces(surface, cwd) {
|
|
2084
|
-
if (surface === "claude") return ["claude"];
|
|
2085
|
-
if (surface === "codex") return ["codex"];
|
|
2086
|
-
if (surface === "both") return ["claude", "codex"];
|
|
2087
|
-
if (surface) throw new Error(`--surface must be one of claude | codex | both (got '${surface}')`);
|
|
2088
|
-
const surfaces = detectHookSurfaces(cwd);
|
|
2089
|
-
return surfaces.length > 0 ? surfaces : ["claude", "codex"];
|
|
2090
|
-
}
|
|
2091
|
-
function describe(result, dryRun) {
|
|
2092
|
-
if (result.status === "current") return ` \u2713 ${result.path} (already configured)`;
|
|
2093
|
-
const verb = dryRun ? "would" : result.status === "created" ? "created" : "updated";
|
|
2094
|
-
return ` \u2713 ${result.path} (${dryRun ? `${verb} ${result.status === "created" ? "create" : "update"}` : verb})`;
|
|
2095
|
-
}
|
|
2096
|
-
var HOOK_SURFACE_LABEL = {
|
|
2097
|
-
claude: "Claude Code",
|
|
2098
|
-
codex: "Codex"
|
|
2099
|
-
};
|
|
2100
|
-
function installHookSurfaces(surfaces, opts) {
|
|
2101
|
-
const out = [];
|
|
2102
|
-
for (const surface of surfaces) {
|
|
2103
|
-
if (surface === "claude") {
|
|
2104
|
-
const path = join7(opts.claudeDir, "settings.json");
|
|
2105
|
-
out.push({ surface, results: [installHooksJson(path, CLAUDE_HOOK_COMMANDS, opts.dryRun)] });
|
|
2106
|
-
} else {
|
|
2107
|
-
const hooksJson = installHooksJson(join7(opts.codexHome, "hooks.json"), CODEX_HOOK_COMMANDS, opts.dryRun);
|
|
2108
|
-
const featureFlag = installCodexFeatureFlag(join7(opts.codexHome, "config.toml"), opts.dryRun);
|
|
2109
|
-
out.push({ surface, results: [hooksJson, featureFlag] });
|
|
2110
|
-
}
|
|
2267
|
+
mkdirSync7(dirname6(p), { recursive: true });
|
|
2268
|
+
const ledger = {
|
|
2269
|
+
lastEpochMs: Date.now(),
|
|
2270
|
+
lastMtimeMs: mtimeMs,
|
|
2271
|
+
lastHash: intentHash(intent)
|
|
2272
|
+
};
|
|
2273
|
+
writeFileSync7(p, JSON.stringify(ledger) + "\n");
|
|
2274
|
+
} catch {
|
|
2111
2275
|
}
|
|
2112
|
-
return out;
|
|
2113
|
-
}
|
|
2114
|
-
function detectHookSurfaces(cwd) {
|
|
2115
|
-
const detected = detectInstalledClients(cwd);
|
|
2116
|
-
const surfaces = [];
|
|
2117
|
-
if (detected.includes("claude-code")) surfaces.push("claude");
|
|
2118
|
-
if (detected.includes("codex")) surfaces.push("codex");
|
|
2119
|
-
return surfaces;
|
|
2120
2276
|
}
|
|
2121
|
-
function
|
|
2122
|
-
const
|
|
2123
|
-
if (!
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2277
|
+
function formatContext(bundle, lane) {
|
|
2278
|
+
const s = bundle?.latestSnapshot;
|
|
2279
|
+
if (!s) return null;
|
|
2280
|
+
const lines = [];
|
|
2281
|
+
lines.push(`[sechroom continuity \u2014 resumed lane ${lane}]`);
|
|
2282
|
+
if (s.currentObjective) lines.push(`Objective: ${s.currentObjective}`);
|
|
2283
|
+
if (s.currentState) lines.push(`State: ${s.currentState}`);
|
|
2284
|
+
if (s.lastMeaningfulAction) lines.push(`Last action: ${s.lastMeaningfulAction}`);
|
|
2285
|
+
if (s.nextIntendedAction) lines.push(`Next: ${s.nextIntendedAction}`);
|
|
2286
|
+
if (s.resumeInstruction) lines.push(`Resume: ${s.resumeInstruction}`);
|
|
2287
|
+
const constraints = s.activeConstraints ?? [];
|
|
2288
|
+
if (constraints.length) {
|
|
2289
|
+
lines.push("Active constraints:");
|
|
2290
|
+
for (const c of constraints) lines.push(` - ${c}`);
|
|
2130
2291
|
}
|
|
2131
|
-
|
|
2292
|
+
const questions = s.openQuestions ?? [];
|
|
2293
|
+
if (questions.length) {
|
|
2294
|
+
lines.push("Open questions:");
|
|
2295
|
+
for (const q of questions) lines.push(` - ${q}`);
|
|
2296
|
+
}
|
|
2297
|
+
const artifacts = s.relevantArtifactIds ?? [];
|
|
2298
|
+
if (artifacts.length) lines.push(`Relevant artifacts: ${artifacts.join(", ")}`);
|
|
2299
|
+
const marker = [s.id, s.createdAt].filter(Boolean).join(" @ ");
|
|
2300
|
+
if (marker) lines.push(`(snapshot ${marker})`);
|
|
2301
|
+
return lines.join("\n");
|
|
2132
2302
|
}
|
|
2133
|
-
function
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2303
|
+
function emitSessionStart(additionalContext) {
|
|
2304
|
+
process.stdout.write(
|
|
2305
|
+
JSON.stringify({
|
|
2306
|
+
hookSpecificOutput: {
|
|
2307
|
+
hookEventName: "SessionStart",
|
|
2308
|
+
additionalContext
|
|
2309
|
+
}
|
|
2310
|
+
}) + "\n"
|
|
2137
2311
|
);
|
|
2138
|
-
return true;
|
|
2139
2312
|
}
|
|
2140
2313
|
function registerHook(program2) {
|
|
2141
2314
|
const hook = program2.command("hook").description("Agent-lifecycle hook adapter (Claude Code / Codex) \u2014 bridges hooks to continuity");
|
|
@@ -2344,10 +2517,10 @@ Examples:
|
|
|
2344
2517
|
const client = await makeClient(cfg);
|
|
2345
2518
|
return client.POST("/continuity/snapshots", { body });
|
|
2346
2519
|
});
|
|
2347
|
-
const path = resolveIntentPath(cwd) ??
|
|
2520
|
+
const path = resolveIntentPath(cwd) ?? join10(cwd, INTENT_FILE);
|
|
2348
2521
|
const fileBody = { ...merged, scope, lastSnapshotId: data.snapshotId };
|
|
2349
|
-
|
|
2350
|
-
|
|
2522
|
+
mkdirSync8(dirname7(path), { recursive: true });
|
|
2523
|
+
writeFileSync8(path, JSON.stringify(fileBody, null, 2) + "\n");
|
|
2351
2524
|
recordPush(cwd, merged);
|
|
2352
2525
|
if (json) {
|
|
2353
2526
|
emit({ snapshotId: data.snapshotId, lane, scope, file: path }, true);
|
|
@@ -2360,6 +2533,149 @@ Examples:
|
|
|
2360
2533
|
});
|
|
2361
2534
|
}
|
|
2362
2535
|
|
|
2536
|
+
// src/commands/close.ts
|
|
2537
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
2538
|
+
var VERDICTS = ["pass", "soft-fail", "plan-invalid", "blocked"];
|
|
2539
|
+
function registerClose(program2) {
|
|
2540
|
+
program2.command("close").description(
|
|
2541
|
+
"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)."
|
|
2542
|
+
).requiredOption("--task <id>", "The WorkTask memory id being closed").requiredOption("--verdict <verdict>", `Verdict: ${VERDICTS.join(" | ")}`).requiredOption(
|
|
2543
|
+
"--workspace <wsp>",
|
|
2544
|
+
"Workspace that owns the closeout memo"
|
|
2545
|
+
).requiredOption("--title <title>", "Closeout title").option(
|
|
2546
|
+
"--file <path>",
|
|
2547
|
+
"Read the closeout body from a file (default: stdin)"
|
|
2548
|
+
).option(
|
|
2549
|
+
"--decomposition <id>",
|
|
2550
|
+
"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."
|
|
2551
|
+
).option(
|
|
2552
|
+
"--no-status-flip",
|
|
2553
|
+
"Skip the source status flip (for tasks whose status is managed elsewhere)"
|
|
2554
|
+
).option("--source <source>", "Source / lane stamp", "cli").addHelpText(
|
|
2555
|
+
"after",
|
|
2556
|
+
`
|
|
2557
|
+
Examples:
|
|
2558
|
+
# Managed run \u2014 reads matchTags from run state, stamps them verbatim, un-parks the run:
|
|
2559
|
+
$ echo "Shipped X. Tests green." | sechroom close --task mem_XXXX --decomposition sug_YYYY \\
|
|
2560
|
+
--verdict pass --workspace wsp_ZZZZ --title "w4_build closeout"
|
|
2561
|
+
|
|
2562
|
+
# Bare task:
|
|
2563
|
+
$ sechroom close --task mem_XXXX --verdict pass --workspace wsp_ZZZZ \\
|
|
2564
|
+
--title "done" --file ./closeout.md`
|
|
2565
|
+
).action(async (opts, cmd) => {
|
|
2566
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2567
|
+
const json = cmd.optsWithGlobals().json;
|
|
2568
|
+
const verdict = String(opts.verdict);
|
|
2569
|
+
if (!VERDICTS.includes(verdict))
|
|
2570
|
+
fail(
|
|
2571
|
+
`--verdict must be one of ${VERDICTS.join(" | ")} \u2014 got '${opts.verdict}'`
|
|
2572
|
+
);
|
|
2573
|
+
let bodyText;
|
|
2574
|
+
try {
|
|
2575
|
+
bodyText = opts.file ? readFileSync7(opts.file, "utf8") : readFileSync7(0, "utf8");
|
|
2576
|
+
} catch {
|
|
2577
|
+
fail(
|
|
2578
|
+
opts.file ? `could not read --file ${opts.file}` : "no closeout body \u2014 pass --file <path> or pipe it on stdin"
|
|
2579
|
+
);
|
|
2580
|
+
}
|
|
2581
|
+
if (!bodyText.trim())
|
|
2582
|
+
fail(
|
|
2583
|
+
"closeout body is empty \u2014 pass --file <path> or pipe the body on stdin"
|
|
2584
|
+
);
|
|
2585
|
+
const client = await makeClient(cfg);
|
|
2586
|
+
let matchTags;
|
|
2587
|
+
if (opts.decomposition) {
|
|
2588
|
+
const run = await runApi(
|
|
2589
|
+
"Reading run contract",
|
|
2590
|
+
async () => client.GET("/decompositions/{id}/run", {
|
|
2591
|
+
params: { path: { id: opts.decomposition } }
|
|
2592
|
+
})
|
|
2593
|
+
);
|
|
2594
|
+
const await_ = run.awaitingCompletion;
|
|
2595
|
+
if (!await_ || !Array.isArray(await_.matchTags) || await_.matchTags.length === 0)
|
|
2596
|
+
fail(
|
|
2597
|
+
`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.`
|
|
2598
|
+
);
|
|
2599
|
+
if (run.awaitingTaskId && run.awaitingTaskId !== opts.task)
|
|
2600
|
+
fail(
|
|
2601
|
+
`run ${opts.decomposition} is awaiting task ${run.awaitingTaskId}, not ${opts.task} \u2014 refusing to mis-key the resume.`
|
|
2602
|
+
);
|
|
2603
|
+
matchTags = await_.matchTags;
|
|
2604
|
+
} else {
|
|
2605
|
+
matchTags = [`wlp-task:${opts.task}`];
|
|
2606
|
+
}
|
|
2607
|
+
const tags = [
|
|
2608
|
+
.../* @__PURE__ */ new Set([
|
|
2609
|
+
...matchTags,
|
|
2610
|
+
`wlp-task:${opts.task}`,
|
|
2611
|
+
`verdict:${verdict}`
|
|
2612
|
+
])
|
|
2613
|
+
];
|
|
2614
|
+
const closeout = await runApi(
|
|
2615
|
+
"Creating closeout",
|
|
2616
|
+
async () => client.POST("/memories", {
|
|
2617
|
+
body: {
|
|
2618
|
+
text: bodyText,
|
|
2619
|
+
type: "reference",
|
|
2620
|
+
content: "{}",
|
|
2621
|
+
confidence: 1,
|
|
2622
|
+
source: opts.source,
|
|
2623
|
+
archetype: "Document",
|
|
2624
|
+
title: opts.title,
|
|
2625
|
+
tags,
|
|
2626
|
+
owner: { type: "Workspace", id: opts.workspace }
|
|
2627
|
+
}
|
|
2628
|
+
})
|
|
2629
|
+
);
|
|
2630
|
+
const edge = await runApi(
|
|
2631
|
+
"Wiring Reference edge",
|
|
2632
|
+
async () => client.POST("/memories/{memoryId}/relationships", {
|
|
2633
|
+
params: { path: { memoryId: closeout.id } },
|
|
2634
|
+
body: { toMemoryId: opts.task, type: "Reference" }
|
|
2635
|
+
})
|
|
2636
|
+
);
|
|
2637
|
+
let taskStatus;
|
|
2638
|
+
if (opts.statusFlip !== false) {
|
|
2639
|
+
const current = await runApi(
|
|
2640
|
+
"Reading task tags",
|
|
2641
|
+
async () => client.GET("/memories/{memoryId}", {
|
|
2642
|
+
params: { path: { memoryId: opts.task } }
|
|
2643
|
+
})
|
|
2644
|
+
);
|
|
2645
|
+
const currentTags = current.item?.tags ?? current.tags ?? [];
|
|
2646
|
+
const target = verdict === "blocked" ? "status:blocked" : "status:done";
|
|
2647
|
+
const nextTags = [
|
|
2648
|
+
...new Set(currentTags.filter((t) => !t.startsWith("status:")))
|
|
2649
|
+
].concat(target);
|
|
2650
|
+
await runApi(
|
|
2651
|
+
"Flipping task status",
|
|
2652
|
+
async () => client.PATCH("/memories/{memoryId}/metadata", {
|
|
2653
|
+
params: { path: { memoryId: opts.task } },
|
|
2654
|
+
body: {
|
|
2655
|
+
memoryId: opts.task,
|
|
2656
|
+
source: opts.source,
|
|
2657
|
+
tags: nextTags
|
|
2658
|
+
}
|
|
2659
|
+
})
|
|
2660
|
+
);
|
|
2661
|
+
taskStatus = target.slice("status:".length);
|
|
2662
|
+
}
|
|
2663
|
+
const view = resolveViewUrl(cfg.baseUrl, closeout.url);
|
|
2664
|
+
const result = {
|
|
2665
|
+
closeoutId: closeout.id,
|
|
2666
|
+
edgeId: edge.id,
|
|
2667
|
+
taskStatus: taskStatus ?? null,
|
|
2668
|
+
verdict,
|
|
2669
|
+
tags
|
|
2670
|
+
};
|
|
2671
|
+
emitAction(
|
|
2672
|
+
`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}` : ""}`,
|
|
2673
|
+
result,
|
|
2674
|
+
json
|
|
2675
|
+
);
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2363
2679
|
// src/commands/continuity.ts
|
|
2364
2680
|
function registerContinuity(program2) {
|
|
2365
2681
|
const continuity = program2.command("continuity").description("Continuity snapshots: checkpoint and resume work");
|
|
@@ -2520,6 +2836,90 @@ Examples:
|
|
|
2520
2836
|
});
|
|
2521
2837
|
}
|
|
2522
2838
|
|
|
2839
|
+
// src/commands/decomposition.ts
|
|
2840
|
+
function registerDecomposition(program2) {
|
|
2841
|
+
const decomposition = program2.command("decomposition").description(
|
|
2842
|
+
"Drive a WLP decomposition: decompose a brief, then execute / accept / reject"
|
|
2843
|
+
);
|
|
2844
|
+
decomposition.addHelpText(
|
|
2845
|
+
"after",
|
|
2846
|
+
`
|
|
2847
|
+
Examples:
|
|
2848
|
+
$ sechroom decomposition decompose mem_XXXX
|
|
2849
|
+
$ sechroom decomposition execute sug_XXXX
|
|
2850
|
+
$ sechroom decomposition accept sug_XXXX
|
|
2851
|
+
$ sechroom decomposition reject sug_XXXX --reason "wrong shape"`
|
|
2852
|
+
);
|
|
2853
|
+
decomposition.command("decompose <briefId>").description(
|
|
2854
|
+
"Decompose a governed brief into a candidate Task graph (POST /governed-briefs/{id}/decompose)"
|
|
2855
|
+
).action(async (briefId, _opts, cmd) => {
|
|
2856
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2857
|
+
const data = await runApi("Queueing decomposition", async () => {
|
|
2858
|
+
const client = await makeClient(cfg);
|
|
2859
|
+
return client.POST("/governed-briefs/{id}/decompose", {
|
|
2860
|
+
params: { path: { id: briefId } },
|
|
2861
|
+
body: { id: briefId }
|
|
2862
|
+
});
|
|
2863
|
+
});
|
|
2864
|
+
emitAction(
|
|
2865
|
+
`queued decomposition of ${style.bold(briefId)} \u2192 ${style.bold(data.suggestionId)}`,
|
|
2866
|
+
data,
|
|
2867
|
+
cmd.optsWithGlobals().json
|
|
2868
|
+
);
|
|
2869
|
+
});
|
|
2870
|
+
decomposition.command("execute <decompositionId>").description(
|
|
2871
|
+
"Execute a decomposition's Task graph (POST /decompositions/{id}/execute)"
|
|
2872
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
2873
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2874
|
+
const data = await runApi("Executing decomposition", async () => {
|
|
2875
|
+
const client = await makeClient(cfg);
|
|
2876
|
+
return client.POST("/decompositions/{id}/execute", {
|
|
2877
|
+
params: { path: { id: decompositionId } },
|
|
2878
|
+
body: {}
|
|
2879
|
+
});
|
|
2880
|
+
});
|
|
2881
|
+
emitAction(
|
|
2882
|
+
`executed ${style.bold(decompositionId)} \u2192 run ${style.bold(data.runRecordId)} (${data.outcome})`,
|
|
2883
|
+
data,
|
|
2884
|
+
cmd.optsWithGlobals().json
|
|
2885
|
+
);
|
|
2886
|
+
});
|
|
2887
|
+
decomposition.command("accept <decompositionId>").description(
|
|
2888
|
+
"Accept a Pending decomposition \u2014 promote + ratify its Tasks (POST /decompositions/{id}/accept)"
|
|
2889
|
+
).action(async (decompositionId, _opts, cmd) => {
|
|
2890
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2891
|
+
const data = await runApi("Accepting decomposition", async () => {
|
|
2892
|
+
const client = await makeClient(cfg);
|
|
2893
|
+
return client.POST("/decompositions/{id}/accept", {
|
|
2894
|
+
params: { path: { id: decompositionId } },
|
|
2895
|
+
body: {}
|
|
2896
|
+
});
|
|
2897
|
+
});
|
|
2898
|
+
emitAction(
|
|
2899
|
+
`accepted decomposition ${style.bold(decompositionId)}`,
|
|
2900
|
+
data,
|
|
2901
|
+
cmd.optsWithGlobals().json
|
|
2902
|
+
);
|
|
2903
|
+
});
|
|
2904
|
+
decomposition.command("reject <decompositionId>").description(
|
|
2905
|
+
"Reject a Pending decomposition \u2014 archive its Tasks, bounce the brief (POST /decompositions/{id}/reject)"
|
|
2906
|
+
).option("--reason <reason>", "Optional free-text rejection reason").action(async (decompositionId, opts, cmd) => {
|
|
2907
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
2908
|
+
const data = await runApi("Rejecting decomposition", async () => {
|
|
2909
|
+
const client = await makeClient(cfg);
|
|
2910
|
+
return client.POST("/decompositions/{id}/reject", {
|
|
2911
|
+
params: { path: { id: decompositionId } },
|
|
2912
|
+
body: { reasonText: opts.reason ?? null }
|
|
2913
|
+
});
|
|
2914
|
+
});
|
|
2915
|
+
emitAction(
|
|
2916
|
+
`rejected decomposition ${style.bold(decompositionId)}`,
|
|
2917
|
+
data,
|
|
2918
|
+
cmd.optsWithGlobals().json
|
|
2919
|
+
);
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2523
2923
|
// src/commands/filing.ts
|
|
2524
2924
|
function registerFiling(program2) {
|
|
2525
2925
|
const filing = program2.command("filing").description("Review and act on filing suggestions");
|
|
@@ -3031,8 +3431,8 @@ Examples:
|
|
|
3031
3431
|
|
|
3032
3432
|
// src/setup/apply.ts
|
|
3033
3433
|
import { createHash as createHash3 } from "crypto";
|
|
3034
|
-
import { mkdirSync as
|
|
3035
|
-
import { dirname as
|
|
3434
|
+
import { mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync9, existsSync as existsSync9 } from "fs";
|
|
3435
|
+
import { dirname as dirname8 } from "path";
|
|
3036
3436
|
var MARKER_BEGIN = "<!-- @sechroom/cli:begin";
|
|
3037
3437
|
var MARKER_END = "<!-- @sechroom/cli:end";
|
|
3038
3438
|
function normalizeBody(s) {
|
|
@@ -3085,22 +3485,22 @@ function parseManagedBlock(content, block) {
|
|
|
3085
3485
|
return null;
|
|
3086
3486
|
}
|
|
3087
3487
|
function ensureDir2(path) {
|
|
3088
|
-
|
|
3488
|
+
mkdirSync9(dirname8(path), { recursive: true });
|
|
3089
3489
|
}
|
|
3090
3490
|
function readOr(path, fallback) {
|
|
3091
3491
|
try {
|
|
3092
|
-
return
|
|
3492
|
+
return readFileSync8(path, "utf8");
|
|
3093
3493
|
} catch {
|
|
3094
3494
|
return fallback;
|
|
3095
3495
|
}
|
|
3096
3496
|
}
|
|
3097
3497
|
function mergeMcpJson(path, snippet, dryRun) {
|
|
3098
3498
|
const incoming = JSON.parse(snippet);
|
|
3099
|
-
const existed =
|
|
3499
|
+
const existed = existsSync9(path);
|
|
3100
3500
|
let current = {};
|
|
3101
3501
|
if (existed) {
|
|
3102
3502
|
try {
|
|
3103
|
-
current = JSON.parse(
|
|
3503
|
+
current = JSON.parse(readFileSync8(path, "utf8"));
|
|
3104
3504
|
} catch {
|
|
3105
3505
|
return { kind: "mcp", path, status: "skipped", note: "existing file isn't valid JSON \u2014 left untouched" };
|
|
3106
3506
|
}
|
|
@@ -3108,26 +3508,26 @@ function mergeMcpJson(path, snippet, dryRun) {
|
|
|
3108
3508
|
current.mcpServers = { ...current.mcpServers ?? {}, ...incoming.mcpServers ?? {} };
|
|
3109
3509
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
3110
3510
|
ensureDir2(path);
|
|
3111
|
-
|
|
3511
|
+
writeFileSync9(path, JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
3112
3512
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
3113
3513
|
}
|
|
3114
3514
|
function mergeCodexToml(path, snippet, dryRun) {
|
|
3115
|
-
const existed =
|
|
3515
|
+
const existed = existsSync9(path);
|
|
3116
3516
|
let body = readOr(path, "");
|
|
3117
3517
|
body = body.replace(/(^|\n)\[mcp_servers\.sechroom\][^[]*/, "\n").replace(/\n{3,}/g, "\n\n");
|
|
3118
3518
|
const trimmed = body.trim();
|
|
3119
3519
|
const next = (trimmed.length > 0 ? trimmed + "\n\n" : "") + snippet.trim() + "\n";
|
|
3120
3520
|
if (dryRun) return { kind: "mcp", path, status: "dry-run" };
|
|
3121
3521
|
ensureDir2(path);
|
|
3122
|
-
|
|
3522
|
+
writeFileSync9(path, next, { mode: 384 });
|
|
3123
3523
|
return { kind: "mcp", path, status: existed ? "merged" : "created" };
|
|
3124
3524
|
}
|
|
3125
3525
|
function writeInstructionBlock(path, write, dryRun) {
|
|
3126
|
-
const existed =
|
|
3526
|
+
const existed = existsSync9(path);
|
|
3127
3527
|
const next = computeBlockFile(readOr(path, ""), write);
|
|
3128
3528
|
if (dryRun) return { kind: "instruction", path, status: "dry-run" };
|
|
3129
3529
|
ensureDir2(path);
|
|
3130
|
-
|
|
3530
|
+
writeFileSync9(path, next);
|
|
3131
3531
|
return { kind: "instruction", path, status: existed ? "merged" : "created" };
|
|
3132
3532
|
}
|
|
3133
3533
|
function computeBlockFile(current, write) {
|
|
@@ -3168,7 +3568,7 @@ function applyBlock(path, write, mode, dryRun) {
|
|
|
3168
3568
|
const next = computeBlockFile(current, write);
|
|
3169
3569
|
if (!dryRun) {
|
|
3170
3570
|
ensureDir2(proposedPath);
|
|
3171
|
-
|
|
3571
|
+
writeFileSync9(proposedPath, next);
|
|
3172
3572
|
}
|
|
3173
3573
|
return {
|
|
3174
3574
|
kind: "instruction",
|
|
@@ -3208,7 +3608,10 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
3208
3608
|
if (!section) {
|
|
3209
3609
|
actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: `no instruction-file section on surface '${target.instruction.surfaceKey}'` });
|
|
3210
3610
|
} else {
|
|
3211
|
-
const resolved = await
|
|
3611
|
+
const resolved = await withSpinner(
|
|
3612
|
+
`Resolving ${target.label} agent instructions`,
|
|
3613
|
+
() => resolveInstruction(cfg, section, opts.personalWorkspaceId)
|
|
3614
|
+
);
|
|
3212
3615
|
if (!resolved) {
|
|
3213
3616
|
actions.push({ kind: "instruction", path: target.instruction.path, status: "skipped", note: "no role template found in this tenant \u2014 install the SEM Starter bundle, then re-run `sechroom setup agent-files`" });
|
|
3214
3617
|
} else {
|
|
@@ -3223,7 +3626,10 @@ async function applyClient(cfg, setup, target, opts) {
|
|
|
3223
3626
|
}
|
|
3224
3627
|
const conventionsSection = findSection(surface, SectionType.WorkspaceConventions);
|
|
3225
3628
|
if (conventionsSection) {
|
|
3226
|
-
const conventions = await
|
|
3629
|
+
const conventions = await withSpinner(
|
|
3630
|
+
`Composing ${target.label} workspace conventions`,
|
|
3631
|
+
() => resolveWorkspaceConventions(cfg, conventionsSection)
|
|
3632
|
+
);
|
|
3227
3633
|
if (conventions) {
|
|
3228
3634
|
const action = applyBlock(
|
|
3229
3635
|
target.instruction.path,
|
|
@@ -3292,8 +3698,8 @@ auto-resumes where you left off and checkpoints working state before compacting.
|
|
|
3292
3698
|
}
|
|
3293
3699
|
|
|
3294
3700
|
// src/setup/skills-offer.ts
|
|
3295
|
-
import { mkdirSync as
|
|
3296
|
-
import { join as
|
|
3701
|
+
import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync10 } from "fs";
|
|
3702
|
+
import { join as join11 } from "path";
|
|
3297
3703
|
|
|
3298
3704
|
// src/setup/lane-pin.ts
|
|
3299
3705
|
var CODE_LANE_PREFIX_BY_CLIENT = {
|
|
@@ -3409,8 +3815,8 @@ Found ${summary} available to you for ${surface}.
|
|
|
3409
3815
|
if (skills.length > 0) {
|
|
3410
3816
|
const written = [];
|
|
3411
3817
|
for (const s of skills) {
|
|
3412
|
-
|
|
3413
|
-
|
|
3818
|
+
mkdirSync10(join11(sDir, s.name), { recursive: true });
|
|
3819
|
+
writeFileSync10(join11(sDir, s.name, "SKILL.md"), s.body.endsWith("\n") ? s.body : s.body + "\n");
|
|
3414
3820
|
written.push(s.name);
|
|
3415
3821
|
}
|
|
3416
3822
|
recordMaterialisedSkills(sDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -3418,11 +3824,11 @@ Found ${summary} available to you for ${surface}.
|
|
|
3418
3824
|
`);
|
|
3419
3825
|
}
|
|
3420
3826
|
if (agents.length > 0) {
|
|
3421
|
-
|
|
3827
|
+
mkdirSync10(aDir, { recursive: true });
|
|
3422
3828
|
const written = [];
|
|
3423
3829
|
for (const a of agents) {
|
|
3424
3830
|
const file = `${a.name}.md`;
|
|
3425
|
-
|
|
3831
|
+
writeFileSync10(join11(aDir, file), a.body.endsWith("\n") ? a.body : a.body + "\n");
|
|
3426
3832
|
written.push(file);
|
|
3427
3833
|
}
|
|
3428
3834
|
recordMaterialisedSkills(aDir, DEFAULT_SKILLS_SLUG, written, { surface });
|
|
@@ -3651,20 +4057,20 @@ Examples:
|
|
|
3651
4057
|
fail("no workspace \u2014 pass --workspace <id> or bind one (`sechroom config set --local workspaceId <id>`).");
|
|
3652
4058
|
const kind = String(opts.kind).toLowerCase() === "standard" ? "standard" : "reference";
|
|
3653
4059
|
const body = typeof opts.body === "string" && opts.body.trim().length > 0 ? opts.body.trim() : "_TODO: write this section, then edit the memo and re-run the regen._";
|
|
3654
|
-
const
|
|
4060
|
+
const text2 = `# ${title}
|
|
3655
4061
|
|
|
3656
4062
|
${body}
|
|
3657
4063
|
`;
|
|
3658
4064
|
const tags = ["agent-setup-bundle", "scope:sechroom", `kind:${kind}`, "archetype:document"];
|
|
3659
4065
|
if (opts.dryRun) {
|
|
3660
|
-
emit({ dryRun: true, workspaceId, title, kind, tags, text }, json);
|
|
4066
|
+
emit({ dryRun: true, workspaceId, title, kind, tags, text: text2 }, json);
|
|
3661
4067
|
return;
|
|
3662
4068
|
}
|
|
3663
4069
|
const data = await runApi("Authoring convention memo", async () => {
|
|
3664
4070
|
const client = await makeClient(cfg);
|
|
3665
4071
|
return client.POST("/memories", {
|
|
3666
4072
|
body: {
|
|
3667
|
-
text,
|
|
4073
|
+
text: text2,
|
|
3668
4074
|
type: kind,
|
|
3669
4075
|
content: "{}",
|
|
3670
4076
|
confidence: 1,
|
|
@@ -3805,13 +4211,13 @@ Wired to namespace '${slug}'. Restart your AI client (or reload MCP) to pick it
|
|
|
3805
4211
|
}
|
|
3806
4212
|
|
|
3807
4213
|
// src/commands/onboard.ts
|
|
3808
|
-
import { existsSync as
|
|
3809
|
-
import { basename as basename2, join as
|
|
4214
|
+
import { existsSync as existsSync11 } from "fs";
|
|
4215
|
+
import { basename as basename2, join as join13 } from "path";
|
|
3810
4216
|
|
|
3811
4217
|
// src/commands/fanout.ts
|
|
3812
4218
|
import { spawnSync } from "child_process";
|
|
3813
|
-
import { existsSync as
|
|
3814
|
-
import { isAbsolute, join as
|
|
4219
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
4220
|
+
import { isAbsolute, join as join12, resolve } from "path";
|
|
3815
4221
|
var ICON = {
|
|
3816
4222
|
refresh: "\u21BB",
|
|
3817
4223
|
bind: "+",
|
|
@@ -3831,21 +4237,21 @@ function discoverChildren(root) {
|
|
|
3831
4237
|
const out = [];
|
|
3832
4238
|
for (const name of names.sort()) {
|
|
3833
4239
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
3834
|
-
const dir =
|
|
4240
|
+
const dir = join12(root, name);
|
|
3835
4241
|
try {
|
|
3836
4242
|
if (!statSync3(dir).isDirectory()) continue;
|
|
3837
4243
|
} catch {
|
|
3838
4244
|
continue;
|
|
3839
4245
|
}
|
|
3840
|
-
if (
|
|
4246
|
+
if (existsSync10(join12(dir, ".git")) || committedBindingPath(dir)) out.push(name);
|
|
3841
4247
|
}
|
|
3842
4248
|
return out;
|
|
3843
4249
|
}
|
|
3844
4250
|
function readManifest(path) {
|
|
3845
|
-
if (!
|
|
4251
|
+
if (!existsSync10(path)) return null;
|
|
3846
4252
|
let parsed;
|
|
3847
4253
|
try {
|
|
3848
|
-
parsed = JSON.parse(
|
|
4254
|
+
parsed = JSON.parse(readFileSync9(path, "utf8"));
|
|
3849
4255
|
} catch (err2) {
|
|
3850
4256
|
throw new Error(`couldn't parse ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`);
|
|
3851
4257
|
}
|
|
@@ -4029,28 +4435,17 @@ async function pickWorkspace(client, opts = {}) {
|
|
|
4029
4435
|
if (candidates.length === 0) candidates = all;
|
|
4030
4436
|
const dirToks = new Set(nameTokens(dirName));
|
|
4031
4437
|
const isMatch = (w) => nameTokens(w.name).some((t) => dirToks.has(t));
|
|
4032
|
-
const suggestions = candidates.filter(isMatch);
|
|
4033
|
-
let pool = candidates;
|
|
4034
|
-
if (candidates.length > 12 && suggestions.length === 0) {
|
|
4035
|
-
const q = (await promptText(`Filter ${candidates.length} workspaces (substring, Enter to list all)?`, "")).trim().toLowerCase();
|
|
4036
|
-
if (q) {
|
|
4037
|
-
const hits = candidates.filter((w) => `${w.name} ${workspacePath(w, byId)}`.toLowerCase().includes(q));
|
|
4038
|
-
if (hits.length > 0) pool = hits;
|
|
4039
|
-
else process.stderr.write(`no match for "${q}" \u2014 listing all
|
|
4040
|
-
`);
|
|
4041
|
-
}
|
|
4042
|
-
}
|
|
4043
4438
|
const SKIP = "__skip__";
|
|
4044
4439
|
const byPath = (a, b) => workspacePath(a, byId).localeCompare(workspacePath(b, byId));
|
|
4045
|
-
const matched =
|
|
4046
|
-
const rest =
|
|
4440
|
+
const matched = candidates.filter(isMatch).sort(byPath);
|
|
4441
|
+
const rest = candidates.filter((w) => !isMatch(w)).sort(byPath);
|
|
4047
4442
|
const choices = [
|
|
4048
4443
|
...matched.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: style.dim(`matches "${dirName}"`) })),
|
|
4049
4444
|
...rest.map((w) => ({ label: workspacePath(w, byId), value: w.id, hint: w.id })),
|
|
4050
4445
|
{ label: style.dim("skip \u2014 don't bind a workspace"), value: SKIP, hint: void 0 }
|
|
4051
4446
|
];
|
|
4052
4447
|
const defaultValue = matched.length === 1 ? matched[0].id : SKIP;
|
|
4053
|
-
const chosen = await promptSelect(promptLabel, choices, defaultValue);
|
|
4448
|
+
const chosen = candidates.length > 12 ? await promptAutocomplete(promptLabel, choices, defaultValue) : await promptSelect(promptLabel, choices, defaultValue);
|
|
4054
4449
|
if (chosen === SKIP) return void 0;
|
|
4055
4450
|
const picked = byId.get(chosen);
|
|
4056
4451
|
const collisions = all.filter((w) => w.id !== picked.id && namesCollide(w.name, picked.name));
|
|
@@ -4222,10 +4617,10 @@ async function chooseScope(scopeFlag, yes) {
|
|
|
4222
4617
|
}
|
|
4223
4618
|
async function planRecurseChild(entry, root, client, opts) {
|
|
4224
4619
|
const dir = resolveChildDir(entry.path, root);
|
|
4225
|
-
if (!
|
|
4620
|
+
if (!existsSync11(dir)) {
|
|
4226
4621
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
4227
4622
|
}
|
|
4228
|
-
if (
|
|
4623
|
+
if (existsSync11(join13(dir, ".sechroom.json"))) {
|
|
4229
4624
|
return {
|
|
4230
4625
|
label: entry.path,
|
|
4231
4626
|
dir,
|
|
@@ -4298,7 +4693,7 @@ This fan-out will pin the same lane in every repo:
|
|
|
4298
4693
|
async function runRecurse(cfg, g, opts) {
|
|
4299
4694
|
const { yes, dryRun, json } = opts;
|
|
4300
4695
|
const root = process.cwd();
|
|
4301
|
-
const manifestPath =
|
|
4696
|
+
const manifestPath = join13(root, ".sechroom", "repos.json");
|
|
4302
4697
|
const fromManifest = readManifest(manifestPath);
|
|
4303
4698
|
const entries = fromManifest ?? discoverChildren(root).map((path) => ({ path }));
|
|
4304
4699
|
const sourceLabel = fromManifest ? `manifest ${manifestPath}` : `auto-discovered under ${root}`;
|
|
@@ -4810,23 +5205,23 @@ Examples:
|
|
|
4810
5205
|
|
|
4811
5206
|
// src/commands/reset.ts
|
|
4812
5207
|
import { homedir as homedir4 } from "os";
|
|
4813
|
-
import { join as
|
|
4814
|
-
import { existsSync as
|
|
5208
|
+
import { join as join14 } from "path";
|
|
5209
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
|
|
4815
5210
|
var SKILLS_LOCK2 = ".sechroom-skills.json";
|
|
4816
|
-
var localSkillsDir = () =>
|
|
4817
|
-
var globalSkillsDir = () =>
|
|
4818
|
-
var localAgentsDir = () =>
|
|
4819
|
-
var globalAgentsDir = () =>
|
|
5211
|
+
var localSkillsDir = () => join14(process.cwd(), ".claude", "skills");
|
|
5212
|
+
var globalSkillsDir = () => join14(homedir4(), ".claude", "skills");
|
|
5213
|
+
var localAgentsDir = () => join14(process.cwd(), ".claude", "agents");
|
|
5214
|
+
var globalAgentsDir = () => join14(homedir4(), ".claude", "agents");
|
|
4820
5215
|
function removeMaterialisedSkills(dir) {
|
|
4821
5216
|
const removed = [];
|
|
4822
|
-
const lockPath =
|
|
4823
|
-
if (!
|
|
5217
|
+
const lockPath = join14(dir, SKILLS_LOCK2);
|
|
5218
|
+
if (!existsSync12(lockPath)) return removed;
|
|
4824
5219
|
try {
|
|
4825
|
-
const lock = JSON.parse(
|
|
5220
|
+
const lock = JSON.parse(readFileSync10(lockPath, "utf8"));
|
|
4826
5221
|
for (const entry of Object.values(lock)) {
|
|
4827
5222
|
for (const name of entry.skills ?? []) {
|
|
4828
|
-
const p =
|
|
4829
|
-
if (
|
|
5223
|
+
const p = join14(dir, name);
|
|
5224
|
+
if (existsSync12(p)) {
|
|
4830
5225
|
rmSync3(p, { recursive: true, force: true });
|
|
4831
5226
|
removed.push(p);
|
|
4832
5227
|
}
|
|
@@ -4871,18 +5266,18 @@ function registerReset(program2) {
|
|
|
4871
5266
|
}
|
|
4872
5267
|
}
|
|
4873
5268
|
const removed = [];
|
|
4874
|
-
const stateDir =
|
|
4875
|
-
if (
|
|
5269
|
+
const stateDir = join14(process.cwd(), ".sechroom");
|
|
5270
|
+
if (existsSync12(stateDir)) {
|
|
4876
5271
|
rmSync3(stateDir, { recursive: true, force: true });
|
|
4877
5272
|
removed.push(stateDir);
|
|
4878
5273
|
}
|
|
4879
|
-
const legacyCfg =
|
|
4880
|
-
if (
|
|
5274
|
+
const legacyCfg = join14(process.cwd(), ".sechroom.json");
|
|
5275
|
+
if (existsSync12(legacyCfg)) {
|
|
4881
5276
|
rmSync3(legacyCfg, { force: true });
|
|
4882
5277
|
removed.push(legacyCfg);
|
|
4883
5278
|
}
|
|
4884
|
-
const legacySem =
|
|
4885
|
-
if (
|
|
5279
|
+
const legacySem = join14(process.cwd(), ".sem");
|
|
5280
|
+
if (existsSync12(legacySem)) {
|
|
4886
5281
|
rmSync3(legacySem, { force: true });
|
|
4887
5282
|
removed.push(legacySem);
|
|
4888
5283
|
}
|
|
@@ -4908,8 +5303,8 @@ function registerReset(program2) {
|
|
|
4908
5303
|
}
|
|
4909
5304
|
|
|
4910
5305
|
// src/commands/skills.ts
|
|
4911
|
-
import { existsSync as
|
|
4912
|
-
import { join as
|
|
5306
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync11, statSync as statSync4, writeFileSync as writeFileSync11 } from "fs";
|
|
5307
|
+
import { join as join15 } from "path";
|
|
4913
5308
|
function filenameFromDisposition(header) {
|
|
4914
5309
|
if (!header) return void 0;
|
|
4915
5310
|
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
|
|
@@ -4917,11 +5312,11 @@ function filenameFromDisposition(header) {
|
|
|
4917
5312
|
}
|
|
4918
5313
|
function resolveOutputPath(output, serverFilename) {
|
|
4919
5314
|
const filename = serverFilename || "skills.zip";
|
|
4920
|
-
if (!output) return
|
|
4921
|
-
const looksLikeDir = output.endsWith("/") ||
|
|
5315
|
+
if (!output) return join15(process.cwd(), filename);
|
|
5316
|
+
const looksLikeDir = output.endsWith("/") || existsSync13(output) && statSync4(output).isDirectory();
|
|
4922
5317
|
if (looksLikeDir) {
|
|
4923
|
-
|
|
4924
|
-
return
|
|
5318
|
+
mkdirSync11(output, { recursive: true });
|
|
5319
|
+
return join15(output, filename);
|
|
4925
5320
|
}
|
|
4926
5321
|
return output;
|
|
4927
5322
|
}
|
|
@@ -4952,7 +5347,7 @@ async function downloadZip(label, call, output) {
|
|
|
4952
5347
|
const buf = Buffer.from(res.data);
|
|
4953
5348
|
const filename = filenameFromDisposition(res.response.headers.get("content-disposition")) ?? "skills.zip";
|
|
4954
5349
|
const path = resolveOutputPath(output, filename);
|
|
4955
|
-
|
|
5350
|
+
writeFileSync11(path, buf);
|
|
4956
5351
|
return { path, bytes: buf.length, filename };
|
|
4957
5352
|
}
|
|
4958
5353
|
function registerSkills(program2) {
|
|
@@ -5120,12 +5515,12 @@ Examples:
|
|
|
5120
5515
|
}
|
|
5121
5516
|
|
|
5122
5517
|
// src/commands/sweep.ts
|
|
5123
|
-
import { existsSync as
|
|
5124
|
-
import { dirname as
|
|
5125
|
-
var DEFAULT_MANIFEST =
|
|
5518
|
+
import { existsSync as existsSync14 } from "fs";
|
|
5519
|
+
import { dirname as dirname9, join as join16, resolve as resolve2 } from "path";
|
|
5520
|
+
var DEFAULT_MANIFEST = join16(".sechroom", "repos.json");
|
|
5126
5521
|
function planEntry(entry, root) {
|
|
5127
5522
|
const dir = resolveChildDir(entry.path, root);
|
|
5128
|
-
if (!
|
|
5523
|
+
if (!existsSync14(dir)) {
|
|
5129
5524
|
return { label: entry.path, dir, disposition: "skip-missing", argv: [], reason: "directory does not exist" };
|
|
5130
5525
|
}
|
|
5131
5526
|
if (committedBindingPath(dir)) {
|
|
@@ -5201,7 +5596,7 @@ Examples:
|
|
|
5201
5596
|
`);
|
|
5202
5597
|
return;
|
|
5203
5598
|
}
|
|
5204
|
-
const root =
|
|
5599
|
+
const root = dirname9(dirname9(manifestPath));
|
|
5205
5600
|
const plans = repos.map((entry) => planEntry(entry, root));
|
|
5206
5601
|
if (!json) {
|
|
5207
5602
|
process.stderr.write(
|
|
@@ -5219,6 +5614,14 @@ Examples:
|
|
|
5219
5614
|
}
|
|
5220
5615
|
|
|
5221
5616
|
// src/commands/telemetry.ts
|
|
5617
|
+
import {
|
|
5618
|
+
existsSync as existsSync15,
|
|
5619
|
+
mkdirSync as mkdirSync12,
|
|
5620
|
+
readFileSync as readFileSync11,
|
|
5621
|
+
rmSync as rmSync4,
|
|
5622
|
+
writeFileSync as writeFileSync12
|
|
5623
|
+
} from "fs";
|
|
5624
|
+
import { dirname as dirname10, join as join17 } from "path";
|
|
5222
5625
|
function registerTelemetry(program2) {
|
|
5223
5626
|
const telemetry = program2.command("telemetry").description(
|
|
5224
5627
|
"Emit WLP run telemetry (an executor leg's progress events) into a decomposition run"
|
|
@@ -5253,10 +5656,9 @@ function registerTelemetry(program2) {
|
|
|
5253
5656
|
).action(async (opts, cmd) => {
|
|
5254
5657
|
const json = Boolean(cmd.optsWithGlobals().json);
|
|
5255
5658
|
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5256
|
-
const kind = normalizeKind(opts.kind);
|
|
5257
5659
|
const event = {
|
|
5258
5660
|
taskId: opts.task,
|
|
5259
|
-
kind,
|
|
5661
|
+
kind: normalizeKind(opts.kind),
|
|
5260
5662
|
tokensIn: opts.tokensIn ?? null,
|
|
5261
5663
|
tokensOut: opts.tokensOut ?? null,
|
|
5262
5664
|
contextUsed: opts.contextUsed ?? null,
|
|
@@ -5265,36 +5667,227 @@ function registerTelemetry(program2) {
|
|
|
5265
5667
|
approvalState: opts.approval ?? null,
|
|
5266
5668
|
verdict: opts.verdict ?? null
|
|
5267
5669
|
};
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
authorization: `Bearer ${token}`,
|
|
5275
|
-
tenant: cfg.tenant,
|
|
5276
|
-
"content-type": "application/json",
|
|
5277
|
-
"x-sechroom-surface": "cli"
|
|
5278
|
-
},
|
|
5279
|
-
body: JSON.stringify({ events: [event] })
|
|
5280
|
-
}
|
|
5281
|
-
);
|
|
5282
|
-
if (!resp.ok)
|
|
5283
|
-
fail(
|
|
5284
|
-
`Telemetry emit failed (HTTP ${resp.status}): ${await resp.text()}`
|
|
5285
|
-
);
|
|
5286
|
-
const body = await resp.json();
|
|
5670
|
+
let body;
|
|
5671
|
+
try {
|
|
5672
|
+
body = await postTelemetry(cfg, opts.decomposition, [event]);
|
|
5673
|
+
} catch (e) {
|
|
5674
|
+
return fail(`Telemetry emit failed: ${e.message}`);
|
|
5675
|
+
}
|
|
5287
5676
|
if (json) {
|
|
5288
5677
|
emit(body, true);
|
|
5289
5678
|
} else {
|
|
5290
5679
|
process.stderr.write(
|
|
5291
5680
|
style.green("telemetry emitted") + style.dim(
|
|
5292
|
-
` \u2014 ${kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
5681
|
+
` \u2014 ${event.kind} for task ${opts.task}; run now carries ${body.eventCount} event${body.eventCount === 1 ? "" : "s"}
|
|
5682
|
+
`
|
|
5683
|
+
)
|
|
5684
|
+
);
|
|
5685
|
+
}
|
|
5686
|
+
});
|
|
5687
|
+
telemetry.command("bind").description(
|
|
5688
|
+
"Bind this checkout to a decomposition+task so the Stop hook auto-emits per-turn telemetry"
|
|
5689
|
+
).requiredOption(
|
|
5690
|
+
"--decomposition <id>",
|
|
5691
|
+
"Decomposition id this session executes"
|
|
5692
|
+
).requiredOption("--task <id>", "Task id this session executes").action((opts, cmd) => {
|
|
5693
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
5694
|
+
const dir = join17(process.cwd(), ".sechroom");
|
|
5695
|
+
mkdirSync12(dir, { recursive: true });
|
|
5696
|
+
const path = join17(dir, BINDING_FILE);
|
|
5697
|
+
const binding = {
|
|
5698
|
+
decompositionId: opts.decomposition,
|
|
5699
|
+
taskId: opts.task
|
|
5700
|
+
};
|
|
5701
|
+
writeFileSync12(path, JSON.stringify(binding, null, 2) + "\n");
|
|
5702
|
+
ensureStateDirIgnored(process.cwd());
|
|
5703
|
+
if (json) {
|
|
5704
|
+
emit({ bound: true, ...binding, path }, true);
|
|
5705
|
+
} else {
|
|
5706
|
+
process.stdout.write(
|
|
5707
|
+
style.green("telemetry bound") + style.dim(
|
|
5708
|
+
` \u2014 decomposition ${binding.decompositionId}, task ${binding.taskId} (${path})
|
|
5293
5709
|
`
|
|
5294
5710
|
)
|
|
5295
5711
|
);
|
|
5296
5712
|
}
|
|
5297
5713
|
});
|
|
5714
|
+
telemetry.command("unbind").description("Clear this checkout's telemetry binding").action((_opts, cmd) => {
|
|
5715
|
+
const json = Boolean(cmd.optsWithGlobals().json);
|
|
5716
|
+
const path = join17(process.cwd(), ".sechroom", BINDING_FILE);
|
|
5717
|
+
const existed = existsSync15(path);
|
|
5718
|
+
if (existed) rmSync4(path);
|
|
5719
|
+
if (json) emit({ unbound: existed, path }, true);
|
|
5720
|
+
else
|
|
5721
|
+
process.stdout.write(
|
|
5722
|
+
existed ? "telemetry binding cleared\n" : "no telemetry binding to clear\n"
|
|
5723
|
+
);
|
|
5724
|
+
});
|
|
5725
|
+
telemetry.command("hook").description(
|
|
5726
|
+
"Per-turn telemetry self-report for a Claude Code Stop hook (reads stdin; no-op unless bound). Fail-soft."
|
|
5727
|
+
).action(async (_opts, cmd) => {
|
|
5728
|
+
try {
|
|
5729
|
+
const raw = await readStdin2();
|
|
5730
|
+
const input = parseHookInput2(raw);
|
|
5731
|
+
const cwd = input.cwd ?? process.cwd();
|
|
5732
|
+
const binding = findBinding(cwd);
|
|
5733
|
+
if (!binding) return process.exit(0);
|
|
5734
|
+
const usage = input.transcript_path ? parseTranscript(input.transcript_path) : null;
|
|
5735
|
+
if (!usage) return process.exit(0);
|
|
5736
|
+
const cfg = resolveConfig(cmd.optsWithGlobals());
|
|
5737
|
+
await postTelemetry(cfg, binding.decompositionId, [
|
|
5738
|
+
{
|
|
5739
|
+
taskId: binding.taskId,
|
|
5740
|
+
kind: "Parsed",
|
|
5741
|
+
tokensIn: usage.tokensIn,
|
|
5742
|
+
tokensOut: usage.tokensOut,
|
|
5743
|
+
contextUsed: usage.contextUsed,
|
|
5744
|
+
contextWindow: usage.contextWindow,
|
|
5745
|
+
text: null,
|
|
5746
|
+
approvalState: null,
|
|
5747
|
+
verdict: null
|
|
5748
|
+
}
|
|
5749
|
+
]);
|
|
5750
|
+
return process.exit(0);
|
|
5751
|
+
} catch {
|
|
5752
|
+
return process.exit(0);
|
|
5753
|
+
}
|
|
5754
|
+
});
|
|
5755
|
+
telemetry.command("install").description(
|
|
5756
|
+
"Wire the per-turn telemetry Stop hook into Claude Code settings (also folded into `sechroom hook install`)"
|
|
5757
|
+
).option(
|
|
5758
|
+
"--scope <scope>",
|
|
5759
|
+
"global (config dir / CLAUDE_CONFIG_DIR) or project (<cwd>/.claude) \u2014 default global"
|
|
5760
|
+
).option("--local", "alias for --scope project").option("--dry-run", "Print what would change; write nothing").action((opts, cmd) => {
|
|
5761
|
+
const g = cmd.optsWithGlobals();
|
|
5762
|
+
const dryRun = Boolean(opts.dryRun);
|
|
5763
|
+
const cwd = process.cwd();
|
|
5764
|
+
let scope;
|
|
5765
|
+
try {
|
|
5766
|
+
scope = opts.local ? "project" : resolveScope(opts.scope);
|
|
5767
|
+
} catch (err2) {
|
|
5768
|
+
process.stderr.write(`${err2.message}
|
|
5769
|
+
`);
|
|
5770
|
+
return process.exit(2);
|
|
5771
|
+
}
|
|
5772
|
+
const targets = resolveClaudeTargets({
|
|
5773
|
+
override: g.claudeConfigDir,
|
|
5774
|
+
scope,
|
|
5775
|
+
cwd
|
|
5776
|
+
});
|
|
5777
|
+
const commands = { Stop: "sechroom telemetry hook" };
|
|
5778
|
+
try {
|
|
5779
|
+
const multi = targets.length > 1;
|
|
5780
|
+
const results = targets.map((t) => {
|
|
5781
|
+
const r = installClaudeCommands(t.dir, commands, dryRun);
|
|
5782
|
+
process.stdout.write(
|
|
5783
|
+
`${HOOK_SURFACE_LABEL.claude}${multi ? ` (${t.label})` : ""}:
|
|
5784
|
+
`
|
|
5785
|
+
);
|
|
5786
|
+
process.stdout.write(describe(r, dryRun) + "\n");
|
|
5787
|
+
return r;
|
|
5788
|
+
});
|
|
5789
|
+
if (dryRun) {
|
|
5790
|
+
process.stdout.write("\n(dry run \u2014 no files were written.)\n");
|
|
5791
|
+
} else if (results.every((r) => r.status === "current")) {
|
|
5792
|
+
process.stdout.write("\nAlready up to date \u2014 nothing to change.\n");
|
|
5793
|
+
} else {
|
|
5794
|
+
process.stdout.write(
|
|
5795
|
+
"\nRestart your agent for the hook to take effect, then bind a task with `sechroom telemetry bind`.\n"
|
|
5796
|
+
);
|
|
5797
|
+
}
|
|
5798
|
+
} catch (err2) {
|
|
5799
|
+
process.stderr.write(
|
|
5800
|
+
`telemetry install failed: ${err2.message}
|
|
5801
|
+
`
|
|
5802
|
+
);
|
|
5803
|
+
return process.exit(1);
|
|
5804
|
+
}
|
|
5805
|
+
warnIfSechroomNotOnPath();
|
|
5806
|
+
return process.exit(0);
|
|
5807
|
+
});
|
|
5808
|
+
}
|
|
5809
|
+
var BINDING_FILE = "telemetry.json";
|
|
5810
|
+
async function postTelemetry(cfg, decompositionId, events) {
|
|
5811
|
+
const token = await requireToken(cfg);
|
|
5812
|
+
const resp = await fetch(
|
|
5813
|
+
`${cfg.baseUrl}/decompositions/${encodeURIComponent(decompositionId)}/run/telemetry`,
|
|
5814
|
+
{
|
|
5815
|
+
method: "POST",
|
|
5816
|
+
headers: {
|
|
5817
|
+
authorization: `Bearer ${token}`,
|
|
5818
|
+
tenant: cfg.tenant,
|
|
5819
|
+
"content-type": "application/json",
|
|
5820
|
+
"x-sechroom-surface": "cli"
|
|
5821
|
+
},
|
|
5822
|
+
body: JSON.stringify({ events })
|
|
5823
|
+
}
|
|
5824
|
+
);
|
|
5825
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
|
|
5826
|
+
return await resp.json();
|
|
5827
|
+
}
|
|
5828
|
+
function findBinding(start) {
|
|
5829
|
+
let dir = start;
|
|
5830
|
+
for (; ; ) {
|
|
5831
|
+
const path = join17(dir, ".sechroom", BINDING_FILE);
|
|
5832
|
+
if (existsSync15(path)) {
|
|
5833
|
+
try {
|
|
5834
|
+
const b = JSON.parse(
|
|
5835
|
+
readFileSync11(path, "utf8")
|
|
5836
|
+
);
|
|
5837
|
+
if (b.decompositionId && b.taskId)
|
|
5838
|
+
return { decompositionId: b.decompositionId, taskId: b.taskId };
|
|
5839
|
+
} catch {
|
|
5840
|
+
}
|
|
5841
|
+
return null;
|
|
5842
|
+
}
|
|
5843
|
+
const parent = dirname10(dir);
|
|
5844
|
+
if (parent === dir) return null;
|
|
5845
|
+
dir = parent;
|
|
5846
|
+
}
|
|
5847
|
+
}
|
|
5848
|
+
function parseTranscript(path) {
|
|
5849
|
+
if (!existsSync15(path)) return null;
|
|
5850
|
+
let tokensIn = 0;
|
|
5851
|
+
let tokensOut = 0;
|
|
5852
|
+
let contextUsed = 0;
|
|
5853
|
+
let model = "";
|
|
5854
|
+
for (const line of readFileSync11(path, "utf8").split("\n")) {
|
|
5855
|
+
if (!line.trim()) continue;
|
|
5856
|
+
let obj;
|
|
5857
|
+
try {
|
|
5858
|
+
obj = JSON.parse(line);
|
|
5859
|
+
} catch {
|
|
5860
|
+
continue;
|
|
5861
|
+
}
|
|
5862
|
+
const usage = obj.type === "assistant" ? obj.message?.usage : void 0;
|
|
5863
|
+
if (!usage) continue;
|
|
5864
|
+
const input = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
5865
|
+
tokensIn += input;
|
|
5866
|
+
tokensOut += usage.output_tokens ?? 0;
|
|
5867
|
+
contextUsed = input;
|
|
5868
|
+
if (obj.message?.model) model = obj.message.model;
|
|
5869
|
+
}
|
|
5870
|
+
if (tokensIn === 0 && tokensOut === 0) return null;
|
|
5871
|
+
return { tokensIn, tokensOut, contextUsed, contextWindow: windowFor(model, contextUsed) };
|
|
5872
|
+
}
|
|
5873
|
+
function windowFor(model, contextUsed = 0) {
|
|
5874
|
+
const m = model.toLowerCase();
|
|
5875
|
+
if (m.includes("[1m]") || m.includes("-1m")) return 1e6;
|
|
5876
|
+
return contextUsed > 2e5 ? 1e6 : 2e5;
|
|
5877
|
+
}
|
|
5878
|
+
async function readStdin2() {
|
|
5879
|
+
if (process.stdin.isTTY) return "";
|
|
5880
|
+
const chunks = [];
|
|
5881
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
5882
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
5883
|
+
}
|
|
5884
|
+
function parseHookInput2(raw) {
|
|
5885
|
+
if (!raw.trim()) return {};
|
|
5886
|
+
try {
|
|
5887
|
+
return JSON.parse(raw);
|
|
5888
|
+
} catch {
|
|
5889
|
+
return {};
|
|
5890
|
+
}
|
|
5298
5891
|
}
|
|
5299
5892
|
var KINDS = {
|
|
5300
5893
|
raw: "Raw",
|
|
@@ -5516,7 +6109,7 @@ Examples:
|
|
|
5516
6109
|
function resolveVersion() {
|
|
5517
6110
|
try {
|
|
5518
6111
|
const pkg = JSON.parse(
|
|
5519
|
-
|
|
6112
|
+
readFileSync12(new URL("../package.json", import.meta.url), "utf8")
|
|
5520
6113
|
);
|
|
5521
6114
|
return pkg.version ?? "0.0.0";
|
|
5522
6115
|
} catch {
|
|
@@ -5674,6 +6267,8 @@ registerLookup(program);
|
|
|
5674
6267
|
registerRelationships(program);
|
|
5675
6268
|
registerWorkspace(program);
|
|
5676
6269
|
registerProject(program);
|
|
6270
|
+
registerDecomposition(program);
|
|
6271
|
+
registerClose(program);
|
|
5677
6272
|
registerFiling(program);
|
|
5678
6273
|
registerContinuity(program);
|
|
5679
6274
|
registerCheckpoint(program);
|