@basou/cli 0.38.0 → 0.40.0

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 CHANGED
@@ -632,10 +632,217 @@ function printNoApprovals(options) {
632
632
  }
633
633
  }
634
634
 
635
+ // src/lib/context-channel.ts
636
+ import { homedir } from "os";
637
+ import { join as join3 } from "path";
638
+ import {
639
+ ORIENTATION_END,
640
+ ORIENTATION_START,
641
+ parseMarkers,
642
+ readMarkdownFile,
643
+ removeMarkerSection
644
+ } from "@basou/core";
645
+
646
+ // src/lib/durable-write.ts
647
+ import { randomUUID } from "crypto";
648
+ import { lstat, open, rename, stat, unlink as unlink2 } from "fs/promises";
649
+ import { basename, dirname, join as join2 } from "path";
650
+ async function assertNotSymlink(targetPath) {
651
+ try {
652
+ const st = await lstat(targetPath);
653
+ if (st.isSymbolicLink()) {
654
+ throw new Error(
655
+ "Refusing to write through a symlink. Replace the symlinked target with a regular file (or remove it) and retry."
656
+ );
657
+ }
658
+ } catch (error) {
659
+ if (error instanceof Error && error.code === "ENOENT") return;
660
+ throw error;
661
+ }
662
+ }
663
+ async function writeFileDurable(targetPath, content) {
664
+ const dir = dirname(targetPath);
665
+ const tmpPath = join2(dir, `.${basename(targetPath)}.tmp.${randomUUID()}`);
666
+ let mode = 420;
667
+ try {
668
+ mode = (await stat(targetPath)).mode & 511;
669
+ } catch (error) {
670
+ if (!(error instanceof Error && error.code === "ENOENT")) {
671
+ throw error;
672
+ }
673
+ }
674
+ let handle;
675
+ try {
676
+ handle = await open(tmpPath, "wx", mode);
677
+ await handle.writeFile(content, "utf8");
678
+ await handle.chmod(mode);
679
+ await handle.sync();
680
+ await handle.close();
681
+ handle = void 0;
682
+ await rename(tmpPath, targetPath);
683
+ } catch (error) {
684
+ if (handle) await handle.close().catch(() => void 0);
685
+ await unlink2(tmpPath).catch(() => void 0);
686
+ throw error;
687
+ }
688
+ try {
689
+ const dirHandle = await open(dir, "r");
690
+ try {
691
+ await dirHandle.sync();
692
+ } finally {
693
+ await dirHandle.close();
694
+ }
695
+ } catch {
696
+ }
697
+ }
698
+
699
+ // src/lib/context-channel.ts
700
+ var CODEX_TARGET_PATH = join3(homedir(), ".codex", "AGENTS.md");
701
+ var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
702
+ function buildTargetBody(existing, block, markers) {
703
+ const wrapped = `${markers.start}
704
+ ${block}${markers.end}
705
+ `;
706
+ if (existing === null || existing === "") return wrapped;
707
+ const section = parseMarkers(existing, markers);
708
+ switch (section.kind) {
709
+ case "ok":
710
+ return `${section.before}${markers.start}
711
+ ${block}${markers.end}${section.after}`;
712
+ case "no_markers": {
713
+ const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
714
+ return `${existing}${sep}${wrapped}`;
715
+ }
716
+ default:
717
+ throw new Error(
718
+ "The basou-managed markers in the target are malformed (a marker is missing, duplicated, or out of order). Fix or remove them, then retry."
719
+ );
720
+ }
721
+ }
722
+ async function backupOnce(target, existing) {
723
+ const bak = `${target}.basou-bak`;
724
+ const already = await readMarkdownFile(bak);
725
+ if (already !== null) return;
726
+ await writeFileDurable(bak, existing ?? "");
727
+ }
728
+ async function syncMarkerBlock(opts) {
729
+ const { target, markers, block } = opts;
730
+ await assertNotSymlink(target);
731
+ const existing = await readMarkdownFile(target);
732
+ const newBody = buildTargetBody(existing, block, markers);
733
+ if (newBody === existing) return { action: "unchanged" };
734
+ const hadBlock = existing !== null && parseMarkers(existing, markers).kind === "ok";
735
+ const action = hadBlock ? "updated" : "installed";
736
+ if (opts.dryRun === true) return { action };
737
+ const recheck = await readMarkdownFile(target);
738
+ if (recheck !== existing) {
739
+ throw new Error(
740
+ "The target changed during sync; aborting so a concurrent edit is not overwritten. Re-run the command."
741
+ );
742
+ }
743
+ await backupOnce(target, existing);
744
+ await writeFileDurable(target, newBody);
745
+ return { action };
746
+ }
747
+ function assertNoMarkerLine(body, markers) {
748
+ for (const line of body.split(/\r?\n/)) {
749
+ if (line === markers.start || line === markers.end) {
750
+ throw new Error(
751
+ "The content contains a basou marker line, which would corrupt the managed block. Remove that line from the source."
752
+ );
753
+ }
754
+ }
755
+ }
756
+ async function removeMarkerBlock(opts) {
757
+ const { target, markers, fileLabel } = opts;
758
+ await assertNotSymlink(target);
759
+ const existing = await readMarkdownFile(target);
760
+ if (existing === null) return { removed: false };
761
+ const newBody = removeMarkerSection(existing, fileLabel, markers);
762
+ if (newBody === existing) return { removed: false };
763
+ if (opts.dryRun === true) return { removed: true };
764
+ const recheck = await readMarkdownFile(target);
765
+ if (recheck !== existing) {
766
+ throw new Error(
767
+ "The target changed during unsync; aborting so a concurrent edit is not overwritten. Re-run the command."
768
+ );
769
+ }
770
+ if (opts.backup !== false) await backupOnce(target, existing);
771
+ await writeFileDurable(target, newBody);
772
+ return { removed: true };
773
+ }
774
+ async function clearOrientationChannel(opts) {
775
+ return removeMarkerBlock({
776
+ target: opts.target ?? CODEX_TARGET_PATH,
777
+ markers: ORIENTATION_MARKERS,
778
+ // Name the file actually acted on, so an error under the test seam does not
779
+ // point at the locked path.
780
+ fileLabel: opts.target ?? "~/.codex/AGENTS.md",
781
+ // The block is being removed because it should not be on this machine;
782
+ // preserving it in `.basou-bak` would defeat the command.
783
+ backup: false,
784
+ ...opts.dryRun === true ? { dryRun: true } : {}
785
+ });
786
+ }
787
+
788
+ // src/commands/channel.ts
789
+ function registerChannelCommand(program2) {
790
+ const channel = program2.command("channel").description(
791
+ "Manage the user-global context faces an AI tool auto-loads for every project (~/.codex/AGENTS.md): remove what an earlier basou rendered there"
792
+ );
793
+ channel.command("clear").argument(
794
+ "<face>",
795
+ "the face to clear: `codex` (the basou:orientation block in ~/.codex/AGENTS.md)"
796
+ ).description(
797
+ "Remove the orientation block an older basou rendered into a user-global context face, so no workspace's position is left in a file that another project's tool reads"
798
+ ).option("--dry-run", "Report whether a block would be removed without writing").option("--json", "Output the result as JSON").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (face, opts) => {
799
+ await runChannelClear(face, opts);
800
+ });
801
+ }
802
+ async function runChannelClear(face, options) {
803
+ if (face !== "codex") {
804
+ console.error(
805
+ `Unknown face '${face}'. Faces: codex (~/.codex/AGENTS.md). The basou:protocols block in ~/.claude/CLAUDE.md is removed with \`basou protocol unsync\`.`
806
+ );
807
+ process.exitCode = 1;
808
+ return;
809
+ }
810
+ try {
811
+ await doRunChannelClear(face, options);
812
+ } catch (error) {
813
+ renderCliError(error, { verbose: isVerbose(options) });
814
+ process.exitCode = 1;
815
+ }
816
+ }
817
+ async function doRunChannelClear(face, options) {
818
+ const isDry = options.dryRun === true;
819
+ const { removed } = await clearOrientationChannel({
820
+ ...options.target !== void 0 ? { target: options.target } : {},
821
+ ...isDry ? { dryRun: true } : {}
822
+ });
823
+ const target = options.target ?? CODEX_TARGET_PATH;
824
+ const label = options.target ?? "~/.codex/AGENTS.md";
825
+ const result = { face, target, removed, dry_run: isDry };
826
+ if (options.json === true) {
827
+ console.log(JSON.stringify(result));
828
+ return result;
829
+ }
830
+ if (!removed) {
831
+ console.log(`Nothing to clear: ${label} carries no basou:orientation block.`);
832
+ } else if (isDry) {
833
+ console.log(`[dry-run] Would remove the basou:orientation block from ${label}.`);
834
+ } else {
835
+ console.log(
836
+ `Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file, and nothing renders it again.`
837
+ );
838
+ }
839
+ return result;
840
+ }
841
+
635
842
  // src/commands/decision.ts
636
843
  import { readFile } from "fs/promises";
637
- import { homedir as homedir2 } from "os";
638
- import { join as join3, resolve as resolve3 } from "path";
844
+ import { homedir as homedir3 } from "os";
845
+ import { join as join5, resolve as resolve3 } from "path";
639
846
  import {
640
847
  AGENT_INFRA_DIRS,
641
848
  acquireLock as acquireLock2,
@@ -657,18 +864,18 @@ import {
657
864
  import { InvalidArgumentError } from "commander";
658
865
 
659
866
  // src/lib/repo-root.ts
660
- import { realpath, stat } from "fs/promises";
661
- import { basename, resolve as resolve2 } from "path";
867
+ import { realpath, stat as stat2 } from "fs/promises";
868
+ import { basename as basename2, resolve as resolve2 } from "path";
662
869
  import { basouPaths as basouPaths2, readManifest, resolveBasouRepositoryRoot } from "@basou/core";
663
870
 
664
871
  // src/lib/portfolio-config.ts
665
- import { homedir } from "os";
666
- import { isAbsolute, join as join2, resolve } from "path";
872
+ import { homedir as homedir2 } from "os";
873
+ import { isAbsolute, join as join4, resolve } from "path";
667
874
  import { readYamlFile as readYamlFile2 } from "@basou/core";
668
- var DEFAULT_PORTFOLIO_CONFIG_PATH = join2(homedir(), ".basou", "portfolio.yaml");
875
+ var DEFAULT_PORTFOLIO_CONFIG_PATH = join4(homedir2(), ".basou", "portfolio.yaml");
669
876
  function expandTilde(p) {
670
- if (p === "~") return homedir();
671
- if (p.startsWith("~/")) return join2(homedir(), p.slice(2));
877
+ if (p === "~") return homedir2();
878
+ if (p.startsWith("~/")) return join4(homedir2(), p.slice(2));
672
879
  return p;
673
880
  }
674
881
  function isRecord(value) {
@@ -750,7 +957,7 @@ async function resolveBasouRootForCommand(cwd, commandName, opts = {}) {
750
957
  }
751
958
  async function hasBasouStore(root) {
752
959
  try {
753
- return (await stat(basouPaths2(root).root)).isDirectory();
960
+ return (await stat2(basouPaths2(root).root)).isDirectory();
754
961
  } catch {
755
962
  return false;
756
963
  }
@@ -782,7 +989,7 @@ async function resolveMemberToMaster(repoRoot, configPath) {
782
989
  } catch (error) {
783
990
  if (error instanceof Error && error.message !== "YAML file not found") {
784
991
  console.error(
785
- `Skipping portfolio workspace '${ws.label ?? basename(masterReal)}': could not read its manifest (${error.message}).`
992
+ `Skipping portfolio workspace '${ws.label ?? basename2(masterReal)}': could not read its manifest (${error.message}).`
786
993
  );
787
994
  }
788
995
  continue;
@@ -791,7 +998,7 @@ async function resolveMemberToMaster(repoRoot, configPath) {
791
998
  for (const sr of sourceRoots) {
792
999
  const real = await realpathOrNull(resolve2(masterReal, sr));
793
1000
  if (real !== null && real === memberReal) {
794
- claimants.set(masterReal, { root: masterReal, label: ws.label ?? basename(masterReal) });
1001
+ claimants.set(masterReal, { root: masterReal, label: ws.label ?? basename2(masterReal) });
795
1002
  break;
796
1003
  }
797
1004
  }
@@ -1044,7 +1251,7 @@ async function doRunDecisionCapture(options, ctx) {
1044
1251
  "--file",
1045
1252
  sanitizePath(resolve3(cwd, options.file), {
1046
1253
  workingDirectory: repositoryRoot,
1047
- homedir: homedir2()
1254
+ homedir: homedir3()
1048
1255
  })
1049
1256
  ] : [];
1050
1257
  const adHoc = await createAdHocSessionWithEvent({
@@ -1181,7 +1388,7 @@ function isDecisionId(value) {
1181
1388
  async function decisionExists(paths, decisionId) {
1182
1389
  const entries = await loadSessionEntries(paths, { now: /* @__PURE__ */ new Date() });
1183
1390
  for (const entry of entries) {
1184
- const sessionDir = join3(paths.sessions, entry.sessionId);
1391
+ const sessionDir = join5(paths.sessions, entry.sessionId);
1185
1392
  try {
1186
1393
  for await (const ev of replayEvents2(sessionDir, {})) {
1187
1394
  if (ev.type === "decision_recorded" && ev.decision_id === decisionId) return true;
@@ -1576,7 +1783,7 @@ import {
1576
1783
  assertBasouRootSafe as assertBasouRootSafe3,
1577
1784
  basouPaths as basouPaths4,
1578
1785
  findErrorCode as findErrorCode3,
1579
- readMarkdownFile,
1786
+ readMarkdownFile as readMarkdownFile2,
1580
1787
  renderDecisions,
1581
1788
  renderWithMarkers,
1582
1789
  resolveRepositoryRoot as resolveRepositoryRoot3,
@@ -1609,7 +1816,7 @@ async function doRunDecisionsGenerate(options, ctx) {
1609
1816
  onWarning: (w, sid) => printReplayWarning(w, sid),
1610
1817
  onSessionSkip: (sid, reason) => printSessionSkip(sid, reason)
1611
1818
  });
1612
- const existing = await readMarkdownFile(paths.files.decisions);
1819
+ const existing = await readMarkdownFile2(paths.files.decisions);
1613
1820
  const finalBody = renderWithMarkers(existing, result.body, "decisions.md");
1614
1821
  await writeMarkdownFile(paths.files.decisions, finalBody);
1615
1822
  console.log(`Generated .basou/decisions.md (decisions: ${result.decisionCount})`);
@@ -1640,8 +1847,8 @@ async function assertWorkspaceInitialized3(basouRoot) {
1640
1847
 
1641
1848
  // src/commands/exec.ts
1642
1849
  import { mkdir } from "fs/promises";
1643
- import { homedir as homedir3 } from "os";
1644
- import { join as join4 } from "path";
1850
+ import { homedir as homedir4 } from "os";
1851
+ import { join as join6 } from "path";
1645
1852
  import {
1646
1853
  acquireLock as acquireLock3,
1647
1854
  assertBasouRootSafe as assertBasouRootSafe4,
@@ -1681,13 +1888,13 @@ async function runExec(command, args, options, ctx = {}) {
1681
1888
  await assertBasouRootSafe4(paths.root);
1682
1889
  const manifest = await readManifest3(paths);
1683
1890
  const sessionId = prefixedUlid3("ses");
1684
- const sessionDir = join4(paths.sessions, sessionId);
1891
+ const sessionDir = join6(paths.sessions, sessionId);
1685
1892
  await mkdir(sessionDir, { recursive: true });
1686
1893
  const appendEvent = ctx.appendEvent ?? (async (_sessionDir, event) => {
1687
1894
  await coreAppendChainedEvent(paths, sessionId, event);
1688
1895
  });
1689
1896
  const startedAt = now().toISOString();
1690
- const sessionYamlPath = join4(sessionDir, "session.yaml");
1897
+ const sessionYamlPath = join6(sessionDir, "session.yaml");
1691
1898
  const session = buildInitialSession({
1692
1899
  id: sessionId,
1693
1900
  command,
@@ -1892,7 +2099,7 @@ function buildInitialSession(input) {
1892
2099
  source: { kind: "terminal", version: "0.1.0" },
1893
2100
  started_at: input.startedAt,
1894
2101
  status: "initialized",
1895
- working_directory: sanitizeWorkingDirectory(input.cwd, { homedir: homedir3() }),
2102
+ working_directory: sanitizeWorkingDirectory(input.cwd, { homedir: homedir4() }),
1896
2103
  invocation: {
1897
2104
  command: input.command,
1898
2105
  args: [...input.args],
@@ -1968,7 +2175,7 @@ import {
1968
2175
  assertBasouRootSafe as assertBasouRootSafe5,
1969
2176
  basouPaths as basouPaths6,
1970
2177
  findErrorCode as findErrorCode4,
1971
- readMarkdownFile as readMarkdownFile2,
2178
+ readMarkdownFile as readMarkdownFile3,
1972
2179
  renderHandoff,
1973
2180
  renderWithMarkers as renderWithMarkers2,
1974
2181
  resolveRepositoryRoot as resolveRepositoryRoot5,
@@ -2002,7 +2209,7 @@ async function doRunHandoffGenerate(options, ctx) {
2002
2209
  onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
2003
2210
  onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason)
2004
2211
  });
2005
- const existing = await readMarkdownFile2(paths.files.handoff);
2212
+ const existing = await readMarkdownFile3(paths.files.handoff);
2006
2213
  const finalBody = renderWithMarkers2(existing, result.body, "handoff.md");
2007
2214
  await writeMarkdownFile2(paths.files.handoff, finalBody);
2008
2215
  console.log(
@@ -2034,395 +2241,226 @@ async function assertWorkspaceInitialized4(basouRoot) {
2034
2241
  }
2035
2242
 
2036
2243
  // src/commands/hook.ts
2037
- import { open as open2, readFile as readFile2, stat as stat3 } from "fs/promises";
2038
- import { homedir as homedir4 } from "os";
2039
- import { join as join6 } from "path";
2244
+ import { open as open2, readFile as readFile3, realpath as realpath2, stat as stat4 } from "fs/promises";
2245
+ import { homedir as homedir7 } from "os";
2246
+ import { join as join9 } from "path";
2040
2247
  import { fileURLToPath } from "url";
2041
2248
  import {
2249
+ buildSessionStartHookCommand,
2042
2250
  buildStopHookCommand,
2043
2251
  DEFAULT_STOP_HOOK_MIN_EDITS,
2044
2252
  evaluateStopHook,
2253
+ findBasouSessionStartHook,
2045
2254
  findBasouStopHookCommand,
2255
+ ORIENTATION_END as ORIENTATION_END2,
2256
+ ORIENTATION_START as ORIENTATION_START2,
2257
+ parseMarkers as parseMarkers2,
2258
+ readMarkdownFile as readMarkdownFile5,
2259
+ removeSessionStartHook,
2046
2260
  removeStopHook,
2261
+ upsertSessionStartHook,
2047
2262
  upsertStopHook
2048
2263
  } from "@basou/core";
2049
2264
 
2050
- // src/lib/durable-write.ts
2051
- import { randomUUID } from "crypto";
2052
- import { lstat, open, rename, stat as stat2, unlink as unlink2 } from "fs/promises";
2053
- import { basename as basename2, dirname, join as join5 } from "path";
2054
- async function assertNotSymlink(targetPath) {
2055
- try {
2056
- const st = await lstat(targetPath);
2057
- if (st.isSymbolicLink()) {
2058
- throw new Error(
2059
- "Refusing to write through a symlink. Replace the symlinked target with a regular file (or remove it) and retry."
2060
- );
2061
- }
2062
- } catch (error) {
2063
- if (error instanceof Error && error.code === "ENOENT") return;
2064
- throw error;
2265
+ // src/lib/codex-hook-trust.ts
2266
+ import { createHash } from "crypto";
2267
+ var CODEX_DEFAULT_CONTEXT_LIMIT = 2500;
2268
+ var CODEX_DEFAULT_TIMEOUT_SECONDS = 600;
2269
+ function commandHandlerFields(handler) {
2270
+ if (handler.type !== "command" || typeof handler.command !== "string") return null;
2271
+ const out = { command: handler.command };
2272
+ if (typeof handler.timeout === "number") out.timeout = handler.timeout;
2273
+ if (typeof handler.async === "boolean") out.async = handler.async;
2274
+ if (typeof handler.statusMessage === "string") out.statusMessage = handler.statusMessage;
2275
+ if (typeof handler.additionalContextLimit === "number")
2276
+ out.additionalContextLimit = handler.additionalContextLimit;
2277
+ return out;
2278
+ }
2279
+ function canonicalize(value) {
2280
+ if (Array.isArray(value)) return value.map(canonicalize);
2281
+ if (typeof value === "object" && value !== null) {
2282
+ const src = value;
2283
+ const out = {};
2284
+ for (const key of Object.keys(src).sort()) out[key] = canonicalize(src[key]);
2285
+ return out;
2065
2286
  }
2287
+ return value;
2066
2288
  }
2067
- async function writeFileDurable(targetPath, content) {
2068
- const dir = dirname(targetPath);
2069
- const tmpPath = join5(dir, `.${basename2(targetPath)}.tmp.${randomUUID()}`);
2070
- let mode = 420;
2071
- try {
2072
- mode = (await stat2(targetPath)).mode & 511;
2073
- } catch (error) {
2074
- if (!(error instanceof Error && error.code === "ENOENT")) {
2075
- throw error;
2076
- }
2289
+ function computeCodexHookIdentityHash(input) {
2290
+ const h = input.handler;
2291
+ const normalized = {
2292
+ type: "command",
2293
+ command: h.command,
2294
+ timeout: Math.max(1, h.timeout ?? CODEX_DEFAULT_TIMEOUT_SECONDS),
2295
+ async: h.async ?? false
2296
+ };
2297
+ if (h.statusMessage !== void 0) normalized.statusMessage = h.statusMessage;
2298
+ if (h.additionalContextLimit !== void 0 && h.additionalContextLimit !== CODEX_DEFAULT_CONTEXT_LIMIT) {
2299
+ normalized.additionalContextLimit = h.additionalContextLimit;
2300
+ }
2301
+ const identity = { event_name: input.eventKey, hooks: [normalized] };
2302
+ if (input.matcher !== void 0) identity.matcher = input.matcher;
2303
+ const blob = JSON.stringify(canonicalize(identity));
2304
+ return `sha256:${createHash("sha256").update(blob, "utf8").digest("hex")}`;
2305
+ }
2306
+ function codexHookStateKey(hooksPath, eventKey, groupIndex, handlerIndex) {
2307
+ return `${hooksPath}:${eventKey}:${groupIndex}:${handlerIndex}`;
2308
+ }
2309
+ function tomlBasicString(value) {
2310
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
2311
+ }
2312
+ function stripTomlComment(line) {
2313
+ let quote = null;
2314
+ for (let i = 0; i < line.length; i++) {
2315
+ const ch = line[i];
2316
+ if (quote !== null) {
2317
+ if (ch === "\\" && quote === '"') i++;
2318
+ else if (ch === quote) quote = null;
2319
+ } else if (ch === '"' || ch === "'") {
2320
+ quote = ch;
2321
+ } else if (ch === "#") {
2322
+ return line.slice(0, i);
2323
+ }
2324
+ }
2325
+ return line;
2326
+ }
2327
+ function readCodexHookState(configToml, key) {
2328
+ const basicHeader = `[hooks.state.${tomlBasicString(key)}]`;
2329
+ const literalHeader = key.includes("'") ? null : `[hooks.state.'${key}']`;
2330
+ const lines = configToml.split(/\r?\n/);
2331
+ const start = lines.findIndex((raw) => {
2332
+ const l = stripTomlComment(raw).trim();
2333
+ return l === basicHeader || literalHeader !== null && l === literalHeader;
2334
+ });
2335
+ if (start < 0) {
2336
+ const mentioned = lines.some((raw) => raw.includes("hooks.state") && raw.includes(key));
2337
+ return mentioned ? { kind: "unreadable", detail: "config.toml names this hook in a shape basou cannot read" } : { kind: "absent" };
2338
+ }
2339
+ const state = {};
2340
+ for (let i = start + 1; i < lines.length; i++) {
2341
+ const line = stripTomlComment(lines[i] ?? "").trim();
2342
+ if (line.startsWith("[")) break;
2343
+ const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/.exec(line);
2344
+ if (m === null) continue;
2345
+ const [, k, rawValue] = m;
2346
+ const v = (rawValue ?? "").trim();
2347
+ if (k === "trusted_hash") {
2348
+ const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(v);
2349
+ const literal = /^'([^']*)'$/.exec(v);
2350
+ if (basic?.[1] !== void 0) state.trustedHash = basic[1].replace(/\\(.)/g, "$1");
2351
+ else if (literal?.[1] !== void 0) state.trustedHash = literal[1];
2352
+ else return { kind: "unreadable", detail: "trusted_hash is not a quoted string" };
2353
+ } else if (k === "enabled") {
2354
+ if (v === "true") state.enabled = true;
2355
+ else if (v === "false") state.enabled = false;
2356
+ else return { kind: "unreadable", detail: "enabled is not a boolean" };
2357
+ }
2358
+ }
2359
+ return { kind: "found", state };
2360
+ }
2361
+ function judgeCodexHookTrust(lookup, currentHash) {
2362
+ if (lookup.kind === "unreadable") return { status: "unknown", detail: lookup.detail };
2363
+ if (lookup.kind === "absent") return { status: "untrusted" };
2364
+ const { state } = lookup;
2365
+ if (state.enabled === false) return { status: "disabled" };
2366
+ if (state.trustedHash === void 0) return { status: "untrusted" };
2367
+ return state.trustedHash === currentHash ? { status: "trusted" } : { status: "modified" };
2368
+ }
2369
+ function describeCodexHookTrust(trust) {
2370
+ switch (trust.status) {
2371
+ case "trusted":
2372
+ return "trusted by Codex";
2373
+ case "untrusted":
2374
+ return "not yet trusted by Codex (review pending \u2014 it is skipped until you trust it)";
2375
+ case "modified":
2376
+ return "Codex's trust record does not match what basou computes for the installed hook \u2014 the hook changed since it was trusted, or Codex changed its hashing (review it again in Codex; it is skipped until re-trusted)";
2377
+ case "disabled":
2378
+ return "disabled in the Codex config (enabled = false)";
2379
+ case "unknown":
2380
+ return `trust state unknown (${trust.detail})`;
2077
2381
  }
2078
- let handle;
2382
+ }
2383
+
2384
+ // src/commands/orient.ts
2385
+ import {
2386
+ assertBasouRootSafe as assertBasouRootSafe7,
2387
+ basouPaths as basouPaths8,
2388
+ findErrorCode as findErrorCode6,
2389
+ renderOrientation as renderOrientation2,
2390
+ writeMarkdownFile as writeMarkdownFile4
2391
+ } from "@basou/core";
2392
+
2393
+ // src/lib/hosts-config.ts
2394
+ import { homedir as homedir5 } from "os";
2395
+ import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
2396
+ import { readYamlFile as readYamlFile4 } from "@basou/core";
2397
+ var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
2398
+ function expandTilde2(p) {
2399
+ if (p === "~") return homedir5();
2400
+ if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
2401
+ return p;
2402
+ }
2403
+ function isRecord2(value) {
2404
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2405
+ }
2406
+ async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
2407
+ let raw;
2079
2408
  try {
2080
- handle = await open(tmpPath, "wx", mode);
2081
- await handle.writeFile(content, "utf8");
2082
- await handle.chmod(mode);
2083
- await handle.sync();
2084
- await handle.close();
2085
- handle = void 0;
2086
- await rename(tmpPath, targetPath);
2409
+ raw = await readYamlFile4(configPath);
2087
2410
  } catch (error) {
2088
- if (handle) await handle.close().catch(() => void 0);
2089
- await unlink2(tmpPath).catch(() => void 0);
2411
+ if (error instanceof Error && error.message === "YAML file not found") {
2412
+ return null;
2413
+ }
2414
+ if (error instanceof Error && error.message === "Failed to parse YAML content") {
2415
+ throw new Error("~/.basou/hosts.yaml is not valid YAML.");
2416
+ }
2090
2417
  throw error;
2091
2418
  }
2092
- try {
2093
- const dirHandle = await open(dir, "r");
2094
- try {
2095
- await dirHandle.sync();
2096
- } finally {
2097
- await dirHandle.close();
2098
- }
2099
- } catch {
2419
+ if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
2420
+ throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
2100
2421
  }
2101
- }
2102
-
2103
- // src/commands/hook.ts
2104
- var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
2105
- function registerHookCommand(program2) {
2106
- const hook = program2.command("hook").description(
2107
- "Claude Code hook handlers (read a hook payload on stdin, emit hook JSON on stdout)"
2108
- );
2109
- hook.command("stop").description(
2110
- "Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
2111
- ).option(
2112
- "--min-edits <n>",
2113
- `Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
2114
- ).option(
2115
- "--block",
2116
- "Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
2117
- ).option(
2118
- "--require-review",
2119
- "Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
2120
- ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
2121
- const minEdits = parseMinEdits(options.minEdits);
2122
- await runHookStop({
2123
- ...minEdits !== void 0 ? { minEdits } : {},
2124
- ...options.block === true ? { block: true } : {},
2125
- ...options.requireReview === true ? { requireReview: true } : {}
2126
- });
2127
- });
2128
- hook.command("install").description(
2129
- "Register the Stop hook in ~/.claude/settings.json (reproducible, idempotent). Default is advisory capture-only; --block opts into in-turn enforcement, --require-review opts into the review gate."
2130
- ).option("--block", "Register the blocking (opt-in enforcement) form instead of advisory").option("--require-review", "Register with the opt-in review gate enabled").option("--min-edits <n>", "Pass a custom file-edit threshold to the registered hook").option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
2131
- await runHookInstall(opts);
2132
- });
2133
- hook.command("uninstall").description(
2134
- "Remove the basou Stop hook from ~/.claude/settings.json (leaves other hooks intact)"
2135
- ).option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
2136
- await runHookUninstall(opts);
2137
- });
2138
- hook.command("status").description("Report whether the basou Stop hook is registered, and in which mode").option("--settings <path>", "Override the settings.json path (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
2139
- await runHookStatus(opts);
2140
- });
2141
- }
2142
- var HOOK_STOP_HELP = `
2143
- Register this Stop hook reproducibly with 'basou hook install' (it writes the
2144
- correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
2145
- removes it; 'basou hook status' reports whether it is registered.
2146
-
2147
- On every turn end basou inspects the session transcript. If the session did
2148
- content-substantive work but ran no capture verb ('basou decision capture' /
2149
- 'decision record' / 'note'), it reminds the agent to record the why / next step.
2150
- Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
2151
- answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
2152
- git status) does NOT count.
2153
-
2154
- With --require-review (opt-in, 'basou hook install --require-review') it also
2155
- reminds when the session SHIPPED substantive code (git push / git merge /
2156
- gh pr create|merge) without recording a review ('basou review record'). This
2157
- gate is off by default; when on, its reminder is composed into the same
2158
- envelope as the capture reminder.
2159
-
2160
- By default the reminder is non-blocking: Claude sees it and may act on it or
2161
- stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
2162
- returns decision:block, holding the agent in-turn to act on the reminder; the
2163
- 'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
2164
- single turn. Either way the hook fails open: a bad payload or unreadable
2165
- transcript exits cleanly with no output.
2166
- `;
2167
- async function runHookStop(options, ctx = {}) {
2168
- try {
2169
- await doRunHookStop(options, ctx);
2170
- } catch {
2171
- }
2172
- }
2173
- async function doRunHookStop(options, ctx) {
2174
- const readStdin = ctx.readStdin ?? defaultReadStdin;
2175
- const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
2176
- const write = ctx.write ?? ((text) => void process.stdout.write(text));
2177
- const raw = await readStdin();
2178
- if (raw.trim().length === 0) return;
2179
- let payload;
2180
- try {
2181
- payload = JSON.parse(raw);
2182
- } catch {
2183
- return;
2184
- }
2185
- if (typeof payload !== "object" || payload === null) return;
2186
- const fields = payload;
2187
- if (fields.stop_hook_active === true) return;
2188
- const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
2189
- if (transcriptPath.length === 0) return;
2190
- let transcript;
2191
- try {
2192
- transcript = await readTranscript(transcriptPath);
2193
- } catch {
2194
- return;
2195
- }
2196
- const records = parseTranscript(transcript);
2197
- const evaluation = evaluateStopHook({
2198
- records,
2199
- // stop_hook_active was already handled by the early return above.
2200
- stopHookActive: false,
2201
- ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
2202
- });
2203
- const parts = [];
2204
- if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
2205
- if (options.requireReview === true && evaluation.review.fires) {
2206
- parts.push(evaluation.review.additionalContext);
2207
- }
2208
- if (parts.length === 0) return;
2209
- const reason = parts.join("\n\n");
2210
- const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
2211
- hookSpecificOutput: {
2212
- hookEventName: "Stop",
2213
- additionalContext: reason
2422
+ const seenPaths = /* @__PURE__ */ new Set();
2423
+ const seenLabels = /* @__PURE__ */ new Set();
2424
+ const result = [];
2425
+ for (const entry of raw.hosts) {
2426
+ if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
2427
+ throw new Error("Each host needs a non-empty string 'label'.");
2214
2428
  }
2215
- });
2216
- write(`${payloadJson}
2217
- `);
2218
- }
2219
- function parseTranscript(transcript) {
2220
- const records = [];
2221
- for (const line of transcript.split(/\r?\n/)) {
2222
- if (line.trim().length === 0) continue;
2223
- try {
2224
- const parsed = JSON.parse(line);
2225
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
2226
- records.push(parsed);
2227
- }
2228
- } catch {
2429
+ const label = entry.label.trim();
2430
+ if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
2431
+ throw new Error("Each host needs a non-empty string 'path'.");
2229
2432
  }
2230
- }
2231
- return records;
2232
- }
2233
- async function defaultReadStdin() {
2234
- if (process.stdin.isTTY === true) return "";
2235
- const chunks = [];
2236
- for await (const chunk of process.stdin) {
2237
- chunks.push(chunk);
2238
- }
2239
- return Buffer.concat(chunks).toString("utf8");
2240
- }
2241
- async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
2242
- const { size } = await stat3(path);
2243
- if (size <= maxBytes) return readFile2(path, "utf8");
2244
- const handle = await open2(path, "r");
2245
- try {
2246
- const buffer = Buffer.alloc(maxBytes);
2247
- const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
2248
- const text = buffer.subarray(0, bytesRead).toString("utf8");
2249
- const firstNewline = text.indexOf("\n");
2250
- return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
2251
- } finally {
2252
- await handle.close();
2253
- }
2254
- }
2255
- function parseMinEdits(raw) {
2256
- if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
2257
- return Number(raw);
2258
- }
2259
- var DEFAULT_CLAUDE_SETTINGS_PATH = join6(homedir4(), ".claude", "settings.json");
2260
- function resolveCliEntry() {
2261
- return fileURLToPath(import.meta.url);
2262
- }
2263
- function normalizeInstallOptions(raw) {
2264
- const out = {};
2265
- if (raw.block === true) out.block = true;
2266
- if (raw.requireReview === true) out.requireReview = true;
2267
- if (raw.settings !== void 0) out.settings = raw.settings;
2268
- if (raw.dryRun === true) out.dryRun = true;
2269
- if (raw.verbose === true) out.verbose = true;
2270
- if (raw.minEdits !== void 0) {
2271
- const parsed = parseMinEdits(raw.minEdits);
2272
- if (parsed === void 0) {
2273
- throw new Error("--min-edits must be a non-negative integer.");
2433
+ const expanded = expandTilde2(entry.path.trim());
2434
+ if (!isAbsolute2(expanded)) {
2435
+ throw new Error("Host paths must be absolute (or start with '~').");
2274
2436
  }
2275
- out.minEdits = parsed;
2276
- }
2277
- return out;
2278
- }
2279
- async function readSettings(path) {
2280
- let raw;
2281
- try {
2282
- raw = await readFile2(path, "utf8");
2283
- } catch (error) {
2284
- if (error instanceof Error && error.code === "ENOENT") {
2285
- return { raw: null, parsed: void 0 };
2437
+ const abs = resolve4(expanded);
2438
+ if (seenPaths.has(abs)) continue;
2439
+ if (seenLabels.has(label)) {
2440
+ throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
2286
2441
  }
2287
- throw error;
2288
- }
2289
- if (raw.trim().length === 0) return { raw, parsed: void 0 };
2290
- try {
2291
- return { raw, parsed: JSON.parse(raw) };
2292
- } catch (error) {
2293
- throw new Error(
2294
- "The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
2295
- {
2296
- cause: error
2297
- }
2298
- );
2299
- }
2300
- }
2301
- async function backupSettingsOnce(path, raw) {
2302
- if (raw === null) return;
2303
- const bak = `${path}.basou-bak`;
2304
- try {
2305
- await stat3(bak);
2306
- return;
2307
- } catch (error) {
2308
- if (!(error instanceof Error && error.code === "ENOENT")) throw error;
2309
- }
2310
- await writeFileDurable(bak, raw);
2311
- }
2312
- async function runHookInstall(options, ctx = {}) {
2313
- try {
2314
- await doRunHookInstall(normalizeInstallOptions(options), ctx);
2315
- } catch (error) {
2316
- renderCliError(error, { verbose: isVerbose(options) });
2317
- process.exitCode = 1;
2318
- }
2319
- }
2320
- async function doRunHookInstall(options, ctx = {}) {
2321
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2322
- const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
2323
- const command = buildStopHookCommand({
2324
- cliEntry,
2325
- ...options.block === true ? { block: true } : {},
2326
- ...options.requireReview === true ? { requireReview: true } : {},
2327
- ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
2328
- });
2329
- const mode = describeHookMode({
2330
- block: options.block === true,
2331
- review: options.requireReview === true
2332
- });
2333
- await assertNotSymlink(settingsPath);
2334
- const { raw, parsed } = await readSettings(settingsPath);
2335
- const { settings, action } = upsertStopHook(parsed, command);
2336
- const newBody = `${JSON.stringify(settings, null, 2)}
2337
- `;
2338
- if (raw !== null && newBody === raw) {
2339
- console.log(`The basou Stop hook is already registered (${mode}); no change.`);
2340
- return;
2341
- }
2342
- if (options.dryRun === true) {
2343
- console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
2344
- return;
2345
- }
2346
- const recheck = await readSettings(settingsPath);
2347
- if (recheck.raw !== raw) {
2348
- throw new Error(
2349
- "The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
2350
- );
2351
- }
2352
- await backupSettingsOnce(settingsPath, raw);
2353
- await writeFileDurable(settingsPath, newBody);
2354
- console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
2355
- }
2356
- async function runHookUninstall(options) {
2357
- try {
2358
- await doRunHookUninstall(normalizeInstallOptions(options));
2359
- } catch (error) {
2360
- renderCliError(error, { verbose: isVerbose(options) });
2361
- process.exitCode = 1;
2362
- }
2363
- }
2364
- async function doRunHookUninstall(options) {
2365
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2366
- await assertNotSymlink(settingsPath);
2367
- const { raw, parsed } = await readSettings(settingsPath);
2368
- if (raw === null) {
2369
- console.log("No settings.json; nothing to remove.");
2370
- return;
2371
- }
2372
- const { settings, action } = removeStopHook(parsed);
2373
- if (action === "absent") {
2374
- console.log("No basou Stop hook found; nothing removed.");
2375
- return;
2376
- }
2377
- const newBody = `${JSON.stringify(settings, null, 2)}
2378
- `;
2379
- if (options.dryRun === true) {
2380
- console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
2381
- return;
2382
- }
2383
- const recheck = await readSettings(settingsPath);
2384
- if (recheck.raw !== raw) {
2385
- throw new Error(
2386
- "The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
2387
- );
2388
- }
2389
- await backupSettingsOnce(settingsPath, raw);
2390
- await writeFileDurable(settingsPath, newBody);
2391
- console.log("Removed the basou Stop hook from settings.json.");
2392
- }
2393
- async function runHookStatus(options) {
2394
- try {
2395
- await doRunHookStatus(normalizeInstallOptions(options));
2396
- } catch (error) {
2397
- renderCliError(error, { verbose: isVerbose(options) });
2398
- process.exitCode = 1;
2399
- }
2400
- }
2401
- async function doRunHookStatus(options) {
2402
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2403
- const { parsed } = await readSettings(settingsPath);
2404
- const command = findBasouStopHookCommand(parsed);
2405
- if (command === null) {
2406
- console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
2407
- return;
2442
+ seenPaths.add(abs);
2443
+ seenLabels.add(label);
2444
+ result.push({ label, path: abs });
2408
2445
  }
2409
- const mode = describeHookMode({
2410
- block: / --block\b/.test(command),
2411
- review: / --require-review\b/.test(command)
2412
- });
2413
- console.log(`basou Stop hook: registered, ${mode}.`);
2414
- }
2415
- function describeHookMode(tiers) {
2416
- const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
2417
- const gates = tiers.review ? "capture + review" : "capture";
2418
- return `${enforcement}, ${gates}`;
2446
+ return result;
2419
2447
  }
2420
2448
 
2449
+ // src/lib/provenance-actions.ts
2450
+ import {
2451
+ readMarkdownFile as readMarkdownFile4,
2452
+ renderDecisions as renderDecisions2,
2453
+ renderHandoff as renderHandoff2,
2454
+ renderOrientation,
2455
+ renderWithMarkers as renderWithMarkers3,
2456
+ writeMarkdownFile as writeMarkdownFile3
2457
+ } from "@basou/core";
2458
+
2421
2459
  // src/commands/import.ts
2422
2460
  import { createReadStream } from "fs";
2423
- import { readdir, readFile as readFile3, rm, stat as stat4 } from "fs/promises";
2424
- import { homedir as homedir5 } from "os";
2425
- import { basename as basename3, dirname as dirname2, join as join7, resolve as resolve4 } from "path";
2461
+ import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
2462
+ import { homedir as homedir6 } from "os";
2463
+ import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve5 } from "path";
2426
2464
  import { createInterface } from "readline";
2427
2465
  import {
2428
2466
  AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
@@ -2492,10 +2530,10 @@ function resolveSourceRoots(args) {
2492
2530
  const { projectFlags, manifest, repoRoot, cwd } = args;
2493
2531
  let resolved;
2494
2532
  if (projectFlags.length > 0) {
2495
- resolved = projectFlags.map((p) => resolve4(cwd, p));
2533
+ resolved = projectFlags.map((p) => resolve5(cwd, p));
2496
2534
  } else {
2497
2535
  const roots = manifest.import?.source_roots;
2498
- resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve4(repoRoot, r)) : [repoRoot];
2536
+ resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
2499
2537
  }
2500
2538
  return [...new Set(resolved)];
2501
2539
  }
@@ -2508,7 +2546,7 @@ async function doRunImportClaudeCode(options, ctx) {
2508
2546
  repoRoot: repositoryRoot,
2509
2547
  cwd: ctx.cwd ?? process.cwd()
2510
2548
  });
2511
- const projectsRoot = ctx.claudeProjectsDir ?? join7(homedir5(), ".claude", "projects");
2549
+ const projectsRoot = ctx.claudeProjectsDir ?? join8(homedir6(), ".claude", "projects");
2512
2550
  const files = await selectTranscriptFiles(projectsRoot, projectPaths, options);
2513
2551
  const projectSet = new Set(projectPaths);
2514
2552
  const candidates = files.map((file) => {
@@ -2547,7 +2585,7 @@ async function doRunImportCodex(options, ctx) {
2547
2585
  repoRoot: repositoryRoot,
2548
2586
  cwd: ctx.cwd ?? process.cwd()
2549
2587
  });
2550
- const sessionsRoot = ctx.codexSessionsDir ?? join7(homedir5(), ".codex", "sessions");
2588
+ const sessionsRoot = ctx.codexSessionsDir ?? join8(homedir6(), ".codex", "sessions");
2551
2589
  const rollouts = await discoverCodexRollouts(sessionsRoot, projectPaths, options);
2552
2590
  const candidates = rollouts.map(({ file, externalId }) => ({
2553
2591
  externalId,
@@ -2668,943 +2706,1503 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
2668
2706
  await noteCrossProject(externalId, payload2);
2669
2707
  continue;
2670
2708
  }
2671
- const payload = validate(await toPayload());
2672
- if (payload === null) {
2673
- counts.skippedNoAction++;
2674
- continue;
2709
+ const payload = validate(await toPayload());
2710
+ if (payload === null) {
2711
+ counts.skippedNoAction++;
2712
+ continue;
2713
+ }
2714
+ if (priors.length > 0 && options.force === true) {
2715
+ if (options.dryRun !== true) {
2716
+ for (const { sessionId } of priors) {
2717
+ await rm(join8(paths.sessions, sessionId), { recursive: true, force: true });
2718
+ }
2719
+ }
2720
+ counts.replaced++;
2721
+ }
2722
+ const result = await importSessionFromJson(paths, manifest, payload, {
2723
+ dryRun: options.dryRun === true
2724
+ });
2725
+ results.push(result);
2726
+ seenThisRun.add(externalId);
2727
+ sanitizedPaths += result.pathSanitizeReport.relatedFiles + (result.pathSanitizeReport.workingDirectoryRewritten ? 1 : 0);
2728
+ await noteCrossProject(externalId, payload);
2729
+ }
2730
+ if (sanitizedPaths > 0) {
2731
+ console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`);
2732
+ }
2733
+ if (crossProject.length > 0) {
2734
+ const PATH_SAMPLE = 5;
2735
+ for (const { externalId, outOfRoot } of crossProject) {
2736
+ const sample = outOfRoot.slice(0, PATH_SAMPLE).join(", ");
2737
+ const more = outOfRoot.length > PATH_SAMPLE ? ` (... +${outOfRoot.length - PATH_SAMPLE} more)` : "";
2738
+ console.error(
2739
+ `basou: session ${externalId} edited ${outOfRoot.length} file(s) outside this project's source_roots: ${sample}${more} \u2014 they may belong to another project.`
2740
+ );
2741
+ }
2742
+ }
2743
+ printImportResult(options, results, counts);
2744
+ }
2745
+ async function classifyReimport(priors, sourcePath, externalId, counts) {
2746
+ if (priors.length > 1) {
2747
+ console.error(
2748
+ `Import: ${externalId} has ${priors.length} prior sessions; re-import skipped (use --force)`
2749
+ );
2750
+ counts.skippedDuplicate++;
2751
+ return null;
2752
+ }
2753
+ const prior = priors[0];
2754
+ if (prior === void 0) {
2755
+ counts.skippedExisting++;
2756
+ return null;
2757
+ }
2758
+ const currentSize = await statSize(sourcePath);
2759
+ if (currentSize === void 0) {
2760
+ counts.skippedExisting++;
2761
+ return null;
2762
+ }
2763
+ if (prior.sourceSizeBytes === void 0) {
2764
+ counts.skippedLegacy++;
2765
+ return null;
2766
+ }
2767
+ if (currentSize === prior.sourceSizeBytes) {
2768
+ counts.skippedExisting++;
2769
+ return null;
2770
+ }
2771
+ if (currentSize < prior.sourceSizeBytes) {
2772
+ console.error(
2773
+ `Import: ${externalId} source shrank (${currentSize} < ${prior.sourceSizeBytes} bytes); re-import skipped (use --force to replace)`
2774
+ );
2775
+ counts.skippedDecreased++;
2776
+ return null;
2777
+ }
2778
+ return prior;
2779
+ }
2780
+ function encodeProjectDir(projectPath) {
2781
+ return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
2782
+ }
2783
+ function firstTranscriptCwd(records) {
2784
+ for (const record of records) {
2785
+ const cwd = record.cwd;
2786
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
2787
+ }
2788
+ return void 0;
2789
+ }
2790
+ async function loadExistingByExternalId(paths, sourceKind) {
2791
+ const byExternalId = /* @__PURE__ */ new Map();
2792
+ const add = (externalId, prior) => {
2793
+ const list = byExternalId.get(externalId);
2794
+ if (list === void 0) byExternalId.set(externalId, [prior]);
2795
+ else list.push(prior);
2796
+ };
2797
+ let sessionIds;
2798
+ try {
2799
+ sessionIds = await enumerateSessionDirs(paths);
2800
+ } catch {
2801
+ return byExternalId;
2802
+ }
2803
+ for (const sessionId of sessionIds) {
2804
+ let session;
2805
+ try {
2806
+ session = await readSessionYaml2(paths, sessionId);
2807
+ } catch {
2808
+ continue;
2809
+ }
2810
+ if (session.session.source.kind !== sourceKind) continue;
2811
+ const sourceSizeBytes = session.session.source.source_size_bytes;
2812
+ const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
2813
+ const ext = session.session.source.external_id;
2814
+ if (typeof ext === "string" && ext.length > 0) {
2815
+ add(ext, prior);
2816
+ continue;
2817
+ }
2818
+ const label = session.session.label;
2819
+ const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
2820
+ if (match?.[1] !== void 0) add(match[1], prior);
2821
+ }
2822
+ return byExternalId;
2823
+ }
2824
+ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
2825
+ if (options.session !== void 0) {
2826
+ const matches = [];
2827
+ for (const projectPath of projectPaths) {
2828
+ const file = join8(projectsRoot, encodeProjectDir(projectPath), `${options.session}.jsonl`);
2829
+ if (await pathExists(file)) matches.push(file);
2675
2830
  }
2676
- if (priors.length > 0 && options.force === true) {
2677
- if (options.dryRun !== true) {
2678
- for (const { sessionId } of priors) {
2679
- await rm(join7(paths.sessions, sessionId), { recursive: true, force: true });
2831
+ if (matches.length === 0) {
2832
+ throw new Error("Claude transcript not found for session id in project");
2833
+ }
2834
+ return [...new Set(matches)];
2835
+ }
2836
+ const files = [];
2837
+ let anyDirFound = false;
2838
+ for (const projectPath of projectPaths) {
2839
+ const transcriptDir = join8(projectsRoot, encodeProjectDir(projectPath));
2840
+ let entries;
2841
+ try {
2842
+ entries = await readdir(transcriptDir);
2843
+ } catch (error) {
2844
+ if (findErrorCode5(error, "ENOENT")) continue;
2845
+ throw new Error("Failed to read Claude transcript directory", { cause: error });
2846
+ }
2847
+ anyDirFound = true;
2848
+ for (const name of entries) {
2849
+ if (name.endsWith(".jsonl")) files.push(join8(transcriptDir, name));
2850
+ }
2851
+ }
2852
+ if (!anyDirFound) {
2853
+ throw new Error("Claude transcript directory not found for project");
2854
+ }
2855
+ return [...new Set(files)].sort();
2856
+ }
2857
+ async function pathExists(file) {
2858
+ try {
2859
+ await stat3(file);
2860
+ return true;
2861
+ } catch (error) {
2862
+ if (findErrorCode5(error, "ENOENT")) return false;
2863
+ throw error;
2864
+ }
2865
+ }
2866
+ async function statSize(file) {
2867
+ try {
2868
+ return (await stat3(file)).size;
2869
+ } catch (error) {
2870
+ if (findErrorCode5(error, "ENOENT")) return void 0;
2871
+ throw error;
2872
+ }
2873
+ }
2874
+ async function discoverCodexRollouts(sessionsRoot, projectPaths, options) {
2875
+ const projectSet = new Set(projectPaths);
2876
+ const files = await findRolloutFiles(sessionsRoot);
2877
+ const matched = [];
2878
+ for (const file of files) {
2879
+ const meta = await readRolloutMeta(file);
2880
+ if (meta === void 0) continue;
2881
+ if (!projectSet.has(meta.cwd)) continue;
2882
+ if (options.session !== void 0 && meta.id !== options.session) continue;
2883
+ matched.push({ file, externalId: meta.id });
2884
+ }
2885
+ if (options.session !== void 0 && matched.length === 0) {
2886
+ throw new Error("Codex rollout not found for session id in project");
2887
+ }
2888
+ return matched;
2889
+ }
2890
+ async function findRolloutFiles(sessionsRoot) {
2891
+ const found = [];
2892
+ const walk = async (dir, isRoot) => {
2893
+ let entries;
2894
+ try {
2895
+ entries = await readdir(dir, { withFileTypes: true });
2896
+ } catch (error) {
2897
+ if (findErrorCode5(error, "ENOENT")) {
2898
+ if (isRoot) {
2899
+ throw new Error("Codex sessions directory not found", { cause: error });
2680
2900
  }
2901
+ return;
2681
2902
  }
2682
- counts.replaced++;
2903
+ throw new Error("Failed to read Codex sessions directory", { cause: error });
2683
2904
  }
2684
- const result = await importSessionFromJson(paths, manifest, payload, {
2685
- dryRun: options.dryRun === true
2686
- });
2687
- results.push(result);
2688
- seenThisRun.add(externalId);
2689
- sanitizedPaths += result.pathSanitizeReport.relatedFiles + (result.pathSanitizeReport.workingDirectoryRewritten ? 1 : 0);
2690
- await noteCrossProject(externalId, payload);
2691
- }
2692
- if (sanitizedPaths > 0) {
2693
- console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`);
2905
+ for (const entry of entries) {
2906
+ const full = join8(dir, entry.name);
2907
+ if (entry.isDirectory()) {
2908
+ await walk(full, false);
2909
+ } else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
2910
+ found.push(full);
2911
+ }
2912
+ }
2913
+ };
2914
+ await walk(sessionsRoot, true);
2915
+ return found.sort();
2916
+ }
2917
+ async function readRolloutMeta(file) {
2918
+ const firstLine = await readFirstLine(file);
2919
+ if (firstLine === void 0) return void 0;
2920
+ let parsed;
2921
+ try {
2922
+ parsed = JSON.parse(firstLine);
2923
+ } catch {
2924
+ return void 0;
2694
2925
  }
2695
- if (crossProject.length > 0) {
2696
- const PATH_SAMPLE = 5;
2697
- for (const { externalId, outOfRoot } of crossProject) {
2698
- const sample = outOfRoot.slice(0, PATH_SAMPLE).join(", ");
2699
- const more = outOfRoot.length > PATH_SAMPLE ? ` (... +${outOfRoot.length - PATH_SAMPLE} more)` : "";
2700
- console.error(
2701
- `basou: session ${externalId} edited ${outOfRoot.length} file(s) outside this project's source_roots: ${sample}${more} \u2014 they may belong to another project.`
2702
- );
2926
+ if (!isObject(parsed) || parsed.type !== "session_meta") return void 0;
2927
+ const payload = isObject(parsed.payload) ? parsed.payload : void 0;
2928
+ if (payload === void 0) return void 0;
2929
+ const id = payload.id;
2930
+ const cwd = payload.cwd;
2931
+ if (typeof id !== "string" || id.length === 0) return void 0;
2932
+ if (typeof cwd !== "string" || cwd.length === 0) return void 0;
2933
+ return { id, cwd };
2934
+ }
2935
+ async function readFirstLine(file) {
2936
+ const stream = createReadStream(file, { encoding: "utf8" });
2937
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
2938
+ try {
2939
+ for await (const line of rl) {
2940
+ const trimmed = line.trim();
2941
+ if (trimmed.length > 0) return trimmed;
2703
2942
  }
2943
+ return void 0;
2944
+ } catch {
2945
+ return void 0;
2946
+ } finally {
2947
+ rl.close();
2948
+ stream.destroy();
2704
2949
  }
2705
- printImportResult(options, results, counts);
2706
2950
  }
2707
- async function classifyReimport(priors, sourcePath, externalId, counts) {
2708
- if (priors.length > 1) {
2709
- console.error(
2710
- `Import: ${externalId} has ${priors.length} prior sessions; re-import skipped (use --force)`
2711
- );
2712
- counts.skippedDuplicate++;
2713
- return null;
2951
+ async function readJsonlRecords(file) {
2952
+ let buffer;
2953
+ try {
2954
+ buffer = await readFile2(file);
2955
+ } catch (error) {
2956
+ if (findErrorCode5(error, "ENOENT")) {
2957
+ throw new Error("Source log not found", { cause: error });
2958
+ }
2959
+ if (findErrorCode5(error, "EISDIR")) {
2960
+ throw new Error("Source log path is not a file", { cause: error });
2961
+ }
2962
+ throw new Error("Failed to read source log", { cause: error });
2714
2963
  }
2715
- const prior = priors[0];
2716
- if (prior === void 0) {
2717
- counts.skippedExisting++;
2718
- return null;
2964
+ const records = [];
2965
+ for (const line of buffer.toString("utf8").split("\n")) {
2966
+ const trimmed = line.trim();
2967
+ if (trimmed.length === 0) continue;
2968
+ try {
2969
+ const parsed = JSON.parse(trimmed);
2970
+ if (isObject(parsed)) {
2971
+ records.push(parsed);
2972
+ }
2973
+ } catch {
2974
+ }
2719
2975
  }
2720
- const currentSize = await statSize(sourcePath);
2721
- if (currentSize === void 0) {
2722
- counts.skippedExisting++;
2723
- return null;
2976
+ return { records, sizeBytes: buffer.length };
2977
+ }
2978
+ function isObject(value) {
2979
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2980
+ }
2981
+ function printImportResult(options, results, counts) {
2982
+ const isDry = options.dryRun === true;
2983
+ const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0);
2984
+ const {
2985
+ skippedNoAction,
2986
+ skippedExisting,
2987
+ replaced,
2988
+ reimported,
2989
+ skippedLegacy,
2990
+ skippedDecreased,
2991
+ skippedDuplicate,
2992
+ skippedUnverifiable
2993
+ } = counts;
2994
+ if (options.json === true) {
2995
+ console.log(
2996
+ JSON.stringify({
2997
+ imported: results.map((r) => ({
2998
+ session_id: r.sessionId,
2999
+ event_count: r.eventCount,
3000
+ status: r.finalStatus,
3001
+ source: { kind: r.finalSourceKind, version: "0.1.0" }
3002
+ })),
3003
+ imported_count: results.length,
3004
+ replaced_count: replaced,
3005
+ reimported_count: reimported,
3006
+ skipped_no_action: skippedNoAction,
3007
+ skipped_already_imported: skippedExisting,
3008
+ skipped_legacy_untracked: skippedLegacy,
3009
+ skipped_decreased: skippedDecreased,
3010
+ skipped_duplicate: skippedDuplicate,
3011
+ skipped_unverifiable: skippedUnverifiable,
3012
+ event_total: eventTotal,
3013
+ dry_run: isDry
3014
+ })
3015
+ );
3016
+ return;
2724
3017
  }
2725
- if (prior.sourceSizeBytes === void 0) {
2726
- counts.skippedLegacy++;
2727
- return null;
3018
+ const skipParts = [];
3019
+ if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
3020
+ if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
3021
+ if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
3022
+ if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
3023
+ if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
3024
+ if (skippedUnverifiable > 0)
3025
+ skipParts.push(`${skippedUnverifiable} unverifiable (run 'basou verify')`);
3026
+ const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
3027
+ const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
3028
+ if (isDry) {
3029
+ const parts = [];
3030
+ if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
3031
+ if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
3032
+ const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
3033
+ console.log(`${head}${skipSuffix}`);
3034
+ return;
2728
3035
  }
2729
- if (currentSize === prior.sourceSizeBytes) {
2730
- counts.skippedExisting++;
2731
- return null;
3036
+ if (results.length === 0 && reimported === 0) {
3037
+ console.log(
3038
+ skipParts.length > 0 ? `No new sessions imported (skipped ${skipParts.join(", ")})` : "No transcripts found to import"
3039
+ );
3040
+ return;
2732
3041
  }
2733
- if (currentSize < prior.sourceSizeBytes) {
2734
- console.error(
2735
- `Import: ${externalId} source shrank (${currentSize} < ${prior.sourceSizeBytes} bytes); re-import skipped (use --force to replace)`
3042
+ const segments = [];
3043
+ if (results.length > 0) {
3044
+ const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
3045
+ segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
3046
+ }
3047
+ if (reimported > 0) {
3048
+ segments.push(
3049
+ `${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
2736
3050
  );
2737
- counts.skippedDecreased++;
2738
- return null;
2739
3051
  }
2740
- return prior;
2741
- }
2742
- function encodeProjectDir(projectPath) {
2743
- return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
3052
+ console.log(`${segments.join(", ")}${skipSuffix}`);
2744
3053
  }
2745
- function firstTranscriptCwd(records) {
2746
- for (const record of records) {
2747
- const cwd = record.cwd;
2748
- if (typeof cwd === "string" && cwd.length > 0) return cwd;
3054
+ function shortId2(id) {
3055
+ if (id.startsWith(SES_PREFIX2)) {
3056
+ return id.slice(SES_PREFIX2.length, SES_PREFIX2.length + SHORT_ID_LEN2);
2749
3057
  }
2750
- return void 0;
3058
+ return id.slice(0, SHORT_ID_LEN2);
2751
3059
  }
2752
- async function loadExistingByExternalId(paths, sourceKind) {
2753
- const byExternalId = /* @__PURE__ */ new Map();
2754
- const add = (externalId, prior) => {
2755
- const list = byExternalId.get(externalId);
2756
- if (list === void 0) byExternalId.set(externalId, [prior]);
2757
- else list.push(prior);
2758
- };
2759
- let sessionIds;
3060
+ async function resolveRepositoryRootForImport(cwd) {
2760
3061
  try {
2761
- sessionIds = await enumerateSessionDirs(paths);
2762
- } catch {
2763
- return byExternalId;
2764
- }
2765
- for (const sessionId of sessionIds) {
2766
- let session;
2767
- try {
2768
- session = await readSessionYaml2(paths, sessionId);
2769
- } catch {
2770
- continue;
2771
- }
2772
- if (session.session.source.kind !== sourceKind) continue;
2773
- const sourceSizeBytes = session.session.source.source_size_bytes;
2774
- const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
2775
- const ext = session.session.source.external_id;
2776
- if (typeof ext === "string" && ext.length > 0) {
2777
- add(ext, prior);
2778
- continue;
3062
+ return await resolveRepositoryRoot6(cwd);
3063
+ } catch (error) {
3064
+ if (error instanceof Error && error.message === "Not a git repository") {
3065
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", {
3066
+ cause: error
3067
+ });
2779
3068
  }
2780
- const label = session.session.label;
2781
- const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
2782
- if (match?.[1] !== void 0) add(match[1], prior);
3069
+ throw error;
2783
3070
  }
2784
- return byExternalId;
2785
3071
  }
2786
- async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
2787
- if (options.session !== void 0) {
2788
- const matches = [];
2789
- for (const projectPath of projectPaths) {
2790
- const file = join7(projectsRoot, encodeProjectDir(projectPath), `${options.session}.jsonl`);
2791
- if (await pathExists(file)) matches.push(file);
2792
- }
2793
- if (matches.length === 0) {
2794
- throw new Error("Claude transcript not found for session id in project");
3072
+ async function assertWorkspaceInitialized5(basouRoot) {
3073
+ try {
3074
+ await assertBasouRootSafe6(basouRoot);
3075
+ } catch (error) {
3076
+ if (findErrorCode5(error, "ENOENT")) {
3077
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
2795
3078
  }
2796
- return [...new Set(matches)];
3079
+ throw error;
2797
3080
  }
2798
- const files = [];
2799
- let anyDirFound = false;
2800
- for (const projectPath of projectPaths) {
2801
- const transcriptDir = join7(projectsRoot, encodeProjectDir(projectPath));
2802
- let entries;
3081
+ }
3082
+
3083
+ // src/lib/provenance-actions.ts
3084
+ async function captureImportJson(fn) {
3085
+ const stdout = [];
3086
+ const originalLog = console.log;
3087
+ const originalError = console.error;
3088
+ console.log = ((...args) => {
3089
+ stdout.push(args.map((a) => String(a)).join(" "));
3090
+ });
3091
+ console.error = (() => {
3092
+ });
3093
+ try {
3094
+ await fn();
3095
+ } finally {
3096
+ console.log = originalLog;
3097
+ console.error = originalError;
3098
+ }
3099
+ for (let i = stdout.length - 1; i >= 0; i--) {
3100
+ const line = stdout[i];
3101
+ if (line === void 0) continue;
2803
3102
  try {
2804
- entries = await readdir(transcriptDir);
2805
- } catch (error) {
2806
- if (findErrorCode5(error, "ENOENT")) continue;
2807
- throw new Error("Failed to read Claude transcript directory", { cause: error });
2808
- }
2809
- anyDirFound = true;
2810
- for (const name of entries) {
2811
- if (name.endsWith(".jsonl")) files.push(join7(transcriptDir, name));
3103
+ const parsed = JSON.parse(line);
3104
+ if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
3105
+ return parsed;
3106
+ }
3107
+ } catch {
2812
3108
  }
2813
3109
  }
2814
- if (!anyDirFound) {
2815
- throw new Error("Claude transcript directory not found for project");
2816
- }
2817
- return [...new Set(files)].sort();
3110
+ throw new Error("Import produced no parseable result");
2818
3111
  }
2819
- async function pathExists(file) {
2820
- try {
2821
- await stat4(file);
2822
- return true;
2823
- } catch (error) {
2824
- if (findErrorCode5(error, "ENOENT")) return false;
2825
- throw error;
2826
- }
3112
+ function readCount(value) {
3113
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2827
3114
  }
2828
- async function statSize(file) {
3115
+ function isMissingSourceDir(error) {
3116
+ if (!(error instanceof Error)) return false;
3117
+ return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
3118
+ }
3119
+ async function runImport(adapter, fn) {
2829
3120
  try {
2830
- return (await stat4(file)).size;
3121
+ const json = await captureImportJson(fn);
3122
+ return {
3123
+ adapter,
3124
+ status: "ran",
3125
+ importedCount: readCount(json.imported_count),
3126
+ replacedCount: readCount(json.replaced_count),
3127
+ reimportedCount: readCount(json.reimported_count),
3128
+ skippedNoAction: readCount(json.skipped_no_action),
3129
+ skippedAlreadyImported: readCount(json.skipped_already_imported),
3130
+ skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3131
+ skippedDecreased: readCount(json.skipped_decreased),
3132
+ skippedDuplicate: readCount(json.skipped_duplicate),
3133
+ skippedUnverifiable: readCount(json.skipped_unverifiable),
3134
+ eventTotal: readCount(json.event_total),
3135
+ dryRun: json.dry_run === true
3136
+ };
2831
3137
  } catch (error) {
2832
- if (findErrorCode5(error, "ENOENT")) return void 0;
3138
+ if (isMissingSourceDir(error)) {
3139
+ return { adapter, status: "skipped", reason: "no source logs for this project" };
3140
+ }
2833
3141
  throw error;
2834
3142
  }
2835
3143
  }
2836
- async function discoverCodexRollouts(sessionsRoot, projectPaths, options) {
2837
- const projectSet = new Set(projectPaths);
2838
- const files = await findRolloutFiles(sessionsRoot);
2839
- const matched = [];
2840
- for (const file of files) {
2841
- const meta = await readRolloutMeta(file);
2842
- if (meta === void 0) continue;
2843
- if (!projectSet.has(meta.cwd)) continue;
2844
- if (options.session !== void 0 && meta.id !== options.session) continue;
2845
- matched.push({ file, externalId: meta.id });
2846
- }
2847
- if (options.session !== void 0 && matched.length === 0) {
2848
- throw new Error("Codex rollout not found for session id in project");
2849
- }
2850
- return matched;
3144
+ function importOptions(options) {
3145
+ return {
3146
+ all: true,
3147
+ json: true,
3148
+ ...options.project !== void 0 ? { project: options.project } : {},
3149
+ ...options.force === true ? { force: true } : {},
3150
+ ...options.dryRun === true ? { dryRun: true } : {}
3151
+ };
2851
3152
  }
2852
- async function findRolloutFiles(sessionsRoot) {
2853
- const found = [];
2854
- const walk = async (dir, isRoot) => {
2855
- let entries;
2856
- try {
2857
- entries = await readdir(dir, { withFileTypes: true });
2858
- } catch (error) {
2859
- if (findErrorCode5(error, "ENOENT")) {
2860
- if (isRoot) {
2861
- throw new Error("Codex sessions directory not found", { cause: error });
2862
- }
2863
- return;
2864
- }
2865
- throw new Error("Failed to read Codex sessions directory", { cause: error });
2866
- }
2867
- for (const entry of entries) {
2868
- const full = join7(dir, entry.name);
2869
- if (entry.isDirectory()) {
2870
- await walk(full, false);
2871
- } else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
2872
- found.push(full);
3153
+ function importClaudeCode(options, ctx) {
3154
+ return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3155
+ }
3156
+ function importCodex(options, ctx) {
3157
+ return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
3158
+ }
3159
+ async function regenerateHandoff(paths, nowIso, callbacks) {
3160
+ const result = await renderHandoff2({ paths, nowIso, ...callbacks });
3161
+ const existing = await readMarkdownFile4(paths.files.handoff);
3162
+ await writeMarkdownFile3(
3163
+ paths.files.handoff,
3164
+ renderWithMarkers3(existing, result.body, "handoff.md")
3165
+ );
3166
+ return {
3167
+ sessionCount: result.sessionCount,
3168
+ taskCount: result.taskCount,
3169
+ decisionCount: result.decisionCount,
3170
+ pendingApprovalsCount: result.pendingApprovalsCount
3171
+ };
3172
+ }
3173
+ async function regenerateDecisions(paths, nowIso, callbacks) {
3174
+ const result = await renderDecisions2({ paths, nowIso, ...callbacks });
3175
+ const existing = await readMarkdownFile4(paths.files.decisions);
3176
+ await writeMarkdownFile3(
3177
+ paths.files.decisions,
3178
+ renderWithMarkers3(existing, result.body, "decisions.md")
3179
+ );
3180
+ return { decisionCount: result.decisionCount };
3181
+ }
3182
+ async function regenerateOrientation(paths, nowIso, callbacks) {
3183
+ const result = await renderOrientation({ paths, nowIso, ...callbacks });
3184
+ await writeMarkdownFile3(paths.files.orientation, `${result.body}
3185
+ `);
3186
+ return {
3187
+ sessionCount: result.sessionCount,
3188
+ inFlightTaskCount: result.inFlightTaskCount,
3189
+ pendingApprovalsCount: result.pendingApprovalsCount,
3190
+ suspectCount: result.suspectCount
3191
+ };
3192
+ }
3193
+ async function refreshAll(args) {
3194
+ const { options, ctx, paths, nowIso } = args;
3195
+ const dryRun = options.dryRun === true;
3196
+ const claudeCode = await importClaudeCode(options, ctx);
3197
+ const codex = await importCodex(options, ctx);
3198
+ if (dryRun) {
3199
+ const skipped = { status: "skipped", reason: "dry-run" };
3200
+ return {
3201
+ claudeCode,
3202
+ codex,
3203
+ handoff: skipped,
3204
+ decisions: skipped,
3205
+ orientation: skipped,
3206
+ dryRun
3207
+ };
3208
+ }
3209
+ const handoffCounts = await regenerateHandoff(paths, nowIso);
3210
+ const decisionCounts = await regenerateDecisions(paths, nowIso);
3211
+ const scoped = options.project !== void 0 && options.project.length > 0;
3212
+ const orientationCounts = await regenerateOrientation(
3213
+ paths,
3214
+ nowIso,
3215
+ scoped ? {} : {
3216
+ staleness: {
3217
+ newSessions: 0,
3218
+ updatedSessions: 0,
3219
+ unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
2873
3220
  }
2874
3221
  }
3222
+ );
3223
+ return {
3224
+ claudeCode,
3225
+ codex,
3226
+ handoff: { status: "generated", ...handoffCounts },
3227
+ decisions: { status: "generated", ...decisionCounts },
3228
+ orientation: { status: "generated", ...orientationCounts },
3229
+ dryRun
2875
3230
  };
2876
- await walk(sessionsRoot, true);
2877
- return found.sort();
2878
3231
  }
2879
- async function readRolloutMeta(file) {
2880
- const firstLine = await readFirstLine(file);
2881
- if (firstLine === void 0) return void 0;
2882
- let parsed;
2883
- try {
2884
- parsed = JSON.parse(firstLine);
2885
- } catch {
2886
- return void 0;
2887
- }
2888
- if (!isObject(parsed) || parsed.type !== "session_meta") return void 0;
2889
- const payload = isObject(parsed.payload) ? parsed.payload : void 0;
2890
- if (payload === void 0) return void 0;
2891
- const id = payload.id;
2892
- const cwd = payload.cwd;
2893
- if (typeof id !== "string" || id.length === 0) return void 0;
2894
- if (typeof cwd !== "string" || cwd.length === 0) return void 0;
2895
- return { id, cwd };
3232
+ function wouldImport(outcome) {
3233
+ return outcome.status === "ran" ? outcome.importedCount : 0;
2896
3234
  }
2897
- async function readFirstLine(file) {
2898
- const stream = createReadStream(file, { encoding: "utf8" });
2899
- const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
3235
+ function wouldUpdate(outcome) {
3236
+ return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
3237
+ }
3238
+ function wouldBlock(outcome) {
3239
+ return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
3240
+ }
3241
+ async function probeStaleness(args) {
2900
3242
  try {
2901
- for await (const line of rl) {
2902
- const trimmed = line.trim();
2903
- if (trimmed.length > 0) return trimmed;
2904
- }
2905
- return void 0;
3243
+ const dry = await refreshAll({
3244
+ options: { dryRun: true },
3245
+ ctx: args.ctx,
3246
+ paths: args.paths,
3247
+ nowIso: args.nowIso
3248
+ });
3249
+ return {
3250
+ newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
3251
+ updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
3252
+ unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
3253
+ };
2906
3254
  } catch {
2907
- return void 0;
2908
- } finally {
2909
- rl.close();
2910
- stream.destroy();
3255
+ return null;
2911
3256
  }
2912
3257
  }
2913
- async function readJsonlRecords(file) {
2914
- let buffer;
3258
+
3259
+ // src/commands/orient.ts
3260
+ function registerOrientCommand(program2) {
3261
+ program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
3262
+ "--refresh",
3263
+ "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
3264
+ ).option("-v, --verbose", "Show error causes").action(async (opts) => {
3265
+ await runOrient(opts);
3266
+ });
3267
+ }
3268
+ async function runOrient(options, ctx = {}) {
2915
3269
  try {
2916
- buffer = await readFile3(file);
3270
+ await doRunOrient(options, ctx);
2917
3271
  } catch (error) {
2918
- if (findErrorCode5(error, "ENOENT")) {
2919
- throw new Error("Source log not found", { cause: error });
2920
- }
2921
- if (findErrorCode5(error, "EISDIR")) {
2922
- throw new Error("Source log path is not a file", { cause: error });
2923
- }
2924
- throw new Error("Failed to read source log", { cause: error });
2925
- }
2926
- const records = [];
2927
- for (const line of buffer.toString("utf8").split("\n")) {
2928
- const trimmed = line.trim();
2929
- if (trimmed.length === 0) continue;
2930
- try {
2931
- const parsed = JSON.parse(trimmed);
2932
- if (isObject(parsed)) {
2933
- records.push(parsed);
2934
- }
2935
- } catch {
2936
- }
3272
+ renderCliError(error, { verbose: isVerbose(options) });
3273
+ process.exitCode = 1;
2937
3274
  }
2938
- return { records, sizeBytes: buffer.length };
2939
- }
2940
- function isObject(value) {
2941
- return typeof value === "object" && value !== null && !Array.isArray(value);
2942
3275
  }
2943
- function printImportResult(options, results, counts) {
2944
- const isDry = options.dryRun === true;
2945
- const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0);
2946
- const {
2947
- skippedNoAction,
2948
- skippedExisting,
2949
- replaced,
2950
- reimported,
2951
- skippedLegacy,
2952
- skippedDecreased,
2953
- skippedDuplicate,
2954
- skippedUnverifiable
2955
- } = counts;
2956
- if (options.json === true) {
2957
- console.log(
2958
- JSON.stringify({
2959
- imported: results.map((r) => ({
2960
- session_id: r.sessionId,
2961
- event_count: r.eventCount,
2962
- status: r.finalStatus,
2963
- source: { kind: r.finalSourceKind, version: "0.1.0" }
2964
- })),
2965
- imported_count: results.length,
2966
- replaced_count: replaced,
2967
- reimported_count: reimported,
2968
- skipped_no_action: skippedNoAction,
2969
- skipped_already_imported: skippedExisting,
2970
- skipped_legacy_untracked: skippedLegacy,
2971
- skipped_decreased: skippedDecreased,
2972
- skipped_duplicate: skippedDuplicate,
2973
- skipped_unverifiable: skippedUnverifiable,
2974
- event_total: eventTotal,
2975
- dry_run: isDry
2976
- })
2977
- );
2978
- return;
2979
- }
2980
- const skipParts = [];
2981
- if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
2982
- if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
2983
- if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
2984
- if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
2985
- if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
2986
- if (skippedUnverifiable > 0)
2987
- skipParts.push(`${skippedUnverifiable} unverifiable (run 'basou verify')`);
2988
- const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
2989
- const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
2990
- if (isDry) {
2991
- const parts = [];
2992
- if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
2993
- if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
2994
- const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
2995
- console.log(`${head}${skipSuffix}`);
2996
- return;
2997
- }
2998
- if (results.length === 0 && reimported === 0) {
3276
+ async function doRunOrient(options, ctx) {
3277
+ const result = await renderOrientationForCwd(options, ctx);
3278
+ if (options.quiet === true) {
2999
3279
  console.log(
3000
- skipParts.length > 0 ? `No new sessions imported (skipped ${skipParts.join(", ")})` : "No transcripts found to import"
3001
- );
3002
- return;
3003
- }
3004
- const segments = [];
3005
- if (results.length > 0) {
3006
- const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
3007
- segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
3008
- }
3009
- if (reimported > 0) {
3010
- segments.push(
3011
- `${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
3280
+ `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
3012
3281
  );
3282
+ } else {
3283
+ console.log(result.body);
3013
3284
  }
3014
- console.log(`${segments.join(", ")}${skipSuffix}`);
3015
3285
  }
3016
- function shortId2(id) {
3017
- if (id.startsWith(SES_PREFIX2)) {
3018
- return id.slice(SES_PREFIX2.length, SES_PREFIX2.length + SHORT_ID_LEN2);
3019
- }
3020
- return id.slice(0, SHORT_ID_LEN2);
3286
+ async function renderOrientationForCwd(options, ctx) {
3287
+ const cwd = ctx.cwd ?? process.cwd();
3288
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
3289
+ return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
3021
3290
  }
3022
- async function resolveRepositoryRootForImport(cwd) {
3291
+ async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
3292
+ const paths = basouPaths8(repositoryRoot);
3293
+ await assertWorkspaceInitialized6(paths.root);
3294
+ const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
3295
+ const probeCtx = { cwd: repositoryRoot };
3296
+ if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
3297
+ if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
3298
+ if (options.refresh === true) {
3299
+ await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
3300
+ }
3301
+ const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
3302
+ let federatedRoots = [];
3023
3303
  try {
3024
- return await resolveRepositoryRoot6(cwd);
3025
- } catch (error) {
3026
- if (error instanceof Error && error.message === "Not a git repository") {
3027
- throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", {
3028
- cause: error
3029
- });
3304
+ const hosts = await loadHostsConfig(ctx.hostsConfigPath);
3305
+ if (hosts !== null) {
3306
+ federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
3030
3307
  }
3031
- throw error;
3308
+ } catch (error) {
3309
+ console.error(
3310
+ `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
3311
+ );
3032
3312
  }
3313
+ const result = await renderOrientation2({
3314
+ paths,
3315
+ nowIso,
3316
+ staleness,
3317
+ verbose: options.verbose === true,
3318
+ federatedRoots,
3319
+ onWarning: (w, sid) => printReplayWarning(w, sid),
3320
+ onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
3321
+ onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
3322
+ onHostUnavailable: (host, error) => console.error(
3323
+ `basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
3324
+ )
3325
+ });
3326
+ if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
3327
+ `);
3328
+ return {
3329
+ body: result.body,
3330
+ sessionCount: result.sessionCount,
3331
+ inFlightTaskCount: result.inFlightTaskCount,
3332
+ pendingApprovalsCount: result.pendingApprovalsCount,
3333
+ suspectCount: result.suspectCount
3334
+ };
3033
3335
  }
3034
- async function assertWorkspaceInitialized5(basouRoot) {
3336
+ async function assertWorkspaceInitialized6(basouRoot) {
3035
3337
  try {
3036
- await assertBasouRootSafe6(basouRoot);
3338
+ await assertBasouRootSafe7(basouRoot);
3037
3339
  } catch (error) {
3038
- if (findErrorCode5(error, "ENOENT")) {
3340
+ if (findErrorCode6(error, "ENOENT")) {
3039
3341
  throw new Error("Workspace not initialized. Run 'basou init' first.");
3040
3342
  }
3041
3343
  throw error;
3042
3344
  }
3043
3345
  }
3044
3346
 
3045
- // src/commands/init.ts
3046
- import { basename as basename4, relative, resolve as resolve5 } from "path";
3047
- import {
3048
- appendBasouGitignore,
3049
- createManifest,
3050
- ensureBasouDirectory,
3051
- resolveRepositoryRoot as resolveRepositoryRoot7,
3052
- writeManifest
3053
- } from "@basou/core";
3054
- function collectValue(value, previous) {
3055
- return [...previous, value];
3056
- }
3057
- function registerInitCommand(program2) {
3058
- program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
3059
- "--repo-url <url>",
3060
- "Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
3347
+ // src/commands/hook.ts
3348
+ var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
3349
+ function registerHookCommand(program2) {
3350
+ const hook = program2.command("hook").description(
3351
+ "Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
3352
+ );
3353
+ hook.command("session-start").description(
3354
+ "Codex SessionStart hook: print the current position of the workspace Codex was opened in (read from the payload's cwd) so Codex adds it to that session's context. Stays silent outside a basou workspace; never fails the session."
3355
+ ).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
3356
+ await runHookSessionStart();
3357
+ });
3358
+ hook.command("stop").description(
3359
+ "Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
3061
3360
  ).option(
3062
- "--source-root <path>",
3063
- "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
3064
- collectValue,
3065
- []
3361
+ "--min-edits <n>",
3362
+ `Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
3066
3363
  ).option(
3067
- "--local-only",
3068
- "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
3069
- ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
3070
- await runInit(options);
3364
+ "--block",
3365
+ "Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
3366
+ ).option(
3367
+ "--require-review",
3368
+ "Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
3369
+ ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
3370
+ const minEdits = parseMinEdits(options.minEdits);
3371
+ await runHookStop({
3372
+ ...minEdits !== void 0 ? { minEdits } : {},
3373
+ ...options.block === true ? { block: true } : {},
3374
+ ...options.requireReview === true ? { requireReview: true } : {}
3375
+ });
3376
+ });
3377
+ hook.command("install [target]").description(
3378
+ "Register a basou hook (reproducible, idempotent). Target `claude` (default): the Stop hook in ~/.claude/settings.json \u2014 advisory capture-only by default; --block opts into in-turn enforcement, --require-review into the review gate. Target `codex`: the SessionStart hook in ~/.codex/hooks.json, which hands each Codex session the position of the workspace it was opened in. Codex asks you to review and trust a new hook once before it runs."
3379
+ ).option(
3380
+ "--block",
3381
+ "claude: register the blocking (opt-in enforcement) form instead of advisory"
3382
+ ).option("--require-review", "claude: register with the opt-in review gate enabled").option("--min-edits <n>", "claude: pass a custom file-edit threshold to the registered hook").option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
3383
+ "--codex-config <path>",
3384
+ "codex: override the Codex config.toml path (intended for tests)"
3385
+ ).option(
3386
+ "--codex-face <path>",
3387
+ "codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
3388
+ ).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3389
+ await dispatchHookTarget(target, opts, {
3390
+ claude: () => runHookInstall(opts),
3391
+ codex: () => runCodexHookInstall(opts)
3392
+ });
3393
+ });
3394
+ hook.command("uninstall [target]").description(
3395
+ "Remove a basou hook, leaving other hooks intact. Target `claude` (default): the Stop hook in ~/.claude/settings.json. Target `codex`: the SessionStart hook in ~/.codex/hooks.json."
3396
+ ).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3397
+ await dispatchHookTarget(target, opts, {
3398
+ claude: () => runHookUninstall(opts),
3399
+ codex: () => runCodexHookUninstall(opts)
3400
+ });
3401
+ });
3402
+ hook.command("status [target]").description(
3403
+ "Report whether a basou hook is registered. Target `claude` (default): the Stop hook and its mode. Target `codex`: the SessionStart hook, and whether Codex has trusted it yet."
3404
+ ).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
3405
+ "--codex-config <path>",
3406
+ "codex: override the Codex config.toml path (intended for tests)"
3407
+ ).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3408
+ await dispatchHookTarget(target, opts, {
3409
+ claude: () => runHookStatus(opts),
3410
+ codex: () => runCodexHookStatus(opts)
3411
+ });
3071
3412
  });
3072
3413
  }
3073
- async function runInit(options, ctx = {}) {
3074
- try {
3075
- await doRunInit(options, ctx);
3076
- } catch (error) {
3077
- renderCliError(error, { verbose: isVerbose(options) });
3414
+ async function dispatchHookTarget(target, options, handlers) {
3415
+ const resolved = target ?? "claude";
3416
+ if (resolved !== "claude" && resolved !== "codex") {
3417
+ renderCliError(
3418
+ new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
3419
+ { verbose: isVerbose(options) }
3420
+ );
3078
3421
  process.exitCode = 1;
3422
+ return;
3079
3423
  }
3424
+ await handlers[resolved]();
3080
3425
  }
3081
- async function doRunInit(options, ctx) {
3082
- const cwd = ctx.cwd ?? process.cwd();
3083
- const repositoryRoot = await resolveRepositoryRootForInit(cwd);
3084
- const workspaceName = options.name ?? basename4(repositoryRoot);
3085
- if (options.repoUrl !== void 0) {
3086
- console.error(
3087
- "Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
3088
- );
3089
- }
3090
- const sourceRoots = (options.sourceRoot ?? []).map((p) => {
3091
- const rel = relative(repositoryRoot, resolve5(cwd, p));
3092
- return rel === "" ? "." : rel;
3093
- });
3094
- const paths = await ensureBasouDirectory(repositoryRoot);
3095
- const manifest = createManifest({
3096
- workspaceName,
3097
- ...options.projectName !== void 0 ? { projectName: options.projectName } : {},
3098
- ...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
3099
- ...sourceRoots.length > 0 ? { sourceRoots } : {}
3100
- });
3101
- await writeManifest(paths, manifest, { force: options.force === true });
3426
+ var HOOK_SESSION_START_HELP = `
3427
+ Register this hook reproducibly with 'basou hook install codex' (it writes the
3428
+ correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
3429
+ removes it; 'basou hook status codex' reports whether it is registered and
3430
+ whether Codex has trusted it.
3431
+
3432
+ Codex runs the hook when a session starts and passes the session's cwd on stdin.
3433
+ basou resolves the workspace from that cwd (a member repo resolves to its
3434
+ planning master, a workspace view to its master) and prints the workspace's
3435
+ current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
3436
+ session's context as developer text. The position is computed at that moment
3437
+ from that cwd and stored nowhere: a Codex opened in another workspace gets that
3438
+ workspace's position, and one opened outside any basou workspace (or before the
3439
+ desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
3440
+ user-global hook serves every workspace without any workspace's position ever
3441
+ being written where another workspace's session would read it.
3442
+
3443
+ Codex trusts hooks by hash. A newly installed or changed hook is skipped until
3444
+ you review it: the interactive CLI asks at startup ("Hooks need review"), the
3445
+ desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
3446
+ an untrusted hook silently.
3447
+ `;
3448
+ var HOOK_STOP_HELP = `
3449
+ Register this Stop hook reproducibly with 'basou hook install' (it writes the
3450
+ correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
3451
+ removes it; 'basou hook status' reports whether it is registered.
3452
+
3453
+ On every turn end basou inspects the session transcript. If the session did
3454
+ content-substantive work but ran no capture verb ('basou decision capture' /
3455
+ 'decision record' / 'note'), it reminds the agent to record the why / next step.
3456
+ Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
3457
+ answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
3458
+ git status) does NOT count.
3459
+
3460
+ With --require-review (opt-in, 'basou hook install --require-review') it also
3461
+ reminds when the session SHIPPED substantive code (git push / git merge /
3462
+ gh pr create|merge) without recording a review ('basou review record'). This
3463
+ gate is off by default; when on, its reminder is composed into the same
3464
+ envelope as the capture reminder.
3465
+
3466
+ By default the reminder is non-blocking: Claude sees it and may act on it or
3467
+ stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
3468
+ returns decision:block, holding the agent in-turn to act on the reminder; the
3469
+ 'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
3470
+ single turn. Either way the hook fails open: a bad payload or unreadable
3471
+ transcript exits cleanly with no output.
3472
+ `;
3473
+ async function runHookStop(options, ctx = {}) {
3102
3474
  try {
3103
- await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
3104
- } catch (error) {
3105
- renderGitignoreWarning(error, isVerbose(options));
3475
+ await doRunHookStop(options, ctx);
3476
+ } catch {
3106
3477
  }
3107
- console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
3108
3478
  }
3109
- function renderGitignoreWarning(error, verbose) {
3110
- const baseMessage = error instanceof Error ? error.message : String(error);
3111
- console.error(
3112
- `Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
3113
- );
3114
- if (verbose && error instanceof Error) {
3115
- const label = extractCauseLabel(error);
3116
- if (label !== void 0) console.error(`Caused by: ${label}`);
3479
+ async function doRunHookStop(options, ctx) {
3480
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3481
+ const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
3482
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3483
+ const raw = await readStdin();
3484
+ if (raw.trim().length === 0) return;
3485
+ let payload;
3486
+ try {
3487
+ payload = JSON.parse(raw);
3488
+ } catch {
3489
+ return;
3117
3490
  }
3118
- }
3119
- async function resolveRepositoryRootForInit(cwd) {
3491
+ if (typeof payload !== "object" || payload === null) return;
3492
+ const fields = payload;
3493
+ if (fields.stop_hook_active === true) return;
3494
+ const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
3495
+ if (transcriptPath.length === 0) return;
3496
+ let transcript;
3120
3497
  try {
3121
- return await resolveRepositoryRoot7(cwd);
3122
- } catch (error) {
3123
- if (error instanceof Error && error.message === "Not a git repository") {
3124
- throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
3125
- cause: error
3126
- });
3127
- }
3128
- throw error;
3498
+ transcript = await readTranscript(transcriptPath);
3499
+ } catch {
3500
+ return;
3501
+ }
3502
+ const records = parseTranscript(transcript);
3503
+ const evaluation = evaluateStopHook({
3504
+ records,
3505
+ // stop_hook_active was already handled by the early return above.
3506
+ stopHookActive: false,
3507
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3508
+ });
3509
+ const parts = [];
3510
+ if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
3511
+ if (options.requireReview === true && evaluation.review.fires) {
3512
+ parts.push(evaluation.review.additionalContext);
3129
3513
  }
3514
+ if (parts.length === 0) return;
3515
+ const reason = parts.join("\n\n");
3516
+ const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
3517
+ hookSpecificOutput: {
3518
+ hookEventName: "Stop",
3519
+ additionalContext: reason
3520
+ }
3521
+ });
3522
+ write(`${payloadJson}
3523
+ `);
3130
3524
  }
3131
-
3132
- // src/commands/note.ts
3133
- import {
3134
- acquireLock as acquireLock4,
3135
- appendEventToExistingSession as appendEventToExistingSession2,
3136
- assertBasouRootSafe as assertBasouRootSafe7,
3137
- basouPaths as basouPaths8,
3138
- createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
3139
- findErrorCode as findErrorCode6,
3140
- readManifest as readManifest5,
3141
- resolveSessionId as resolveSessionId2
3142
- } from "@basou/core";
3143
- import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
3144
- var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
3145
- "list",
3146
- "ls",
3147
- "show",
3148
- "get",
3149
- "add",
3150
- "new",
3151
- "edit",
3152
- "rm",
3153
- "remove",
3154
- "delete",
3155
- "help"
3156
- ]);
3157
- var LABEL_BODY_MAX = 80;
3158
- var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
3159
- function registerNoteCommand(program2) {
3160
- program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
3161
- "--session <session_id>",
3162
- "Attach to an existing session; otherwise an ad-hoc session is created"
3163
- ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
3164
- await runNote(body, options);
3525
+ async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
3526
+ const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
3527
+ portfolioConfigPath
3165
3528
  });
3529
+ if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
3530
+ throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
3531
+ }
3532
+ const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
3533
+ return { body: rendered.body };
3166
3534
  }
3167
- async function runNote(body, options, ctx = {}) {
3535
+ async function isRegisteredWorkspace(root, portfolioConfigPath) {
3536
+ let entries;
3168
3537
  try {
3169
- await doRunNote(body, options, ctx);
3170
- } catch (error) {
3171
- renderCliError(error, {
3172
- verbose: isVerbose(options),
3173
- classifiers: [failedToFinalizeClassifier]
3174
- });
3175
- process.exitCode = 1;
3538
+ entries = await loadPortfolioConfig(portfolioConfigPath);
3539
+ } catch {
3540
+ return false;
3541
+ }
3542
+ const rootReal = await realpath2(root).catch(() => root);
3543
+ for (const entry of entries) {
3544
+ const entryReal = await realpath2(entry.path).catch(() => null);
3545
+ if (entryReal !== null && entryReal === rootReal) return true;
3176
3546
  }
3547
+ return false;
3177
3548
  }
3178
- async function doRunNote(body, options, ctx) {
3179
- if (body.trim().length === 0) {
3180
- throw new Error("Note body must not be empty");
3549
+ async function runHookSessionStart(ctx = {}) {
3550
+ try {
3551
+ await doRunHookSessionStart(ctx);
3552
+ } catch {
3181
3553
  }
3182
- const reserved = body.trim().toLowerCase();
3183
- if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
3184
- throw new Error(
3185
- `'basou note' records a free-text note and has no '${body.trim()}' subcommand. To record a note, pass its full text (e.g. \`basou note "<your note>"\`).`
3186
- );
3554
+ }
3555
+ async function doRunHookSessionStart(ctx) {
3556
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3557
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3558
+ const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
3559
+ const raw = await readStdin();
3560
+ if (raw.trim().length === 0) return;
3561
+ let payload;
3562
+ try {
3563
+ payload = JSON.parse(raw);
3564
+ } catch {
3565
+ return;
3187
3566
  }
3188
- const cwd = ctx.cwd ?? process.cwd();
3189
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
3190
- const paths = basouPaths8(repositoryRoot);
3191
- await assertWorkspaceInitialized6(paths.root);
3192
- const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
3193
- const occurredAt = now.toISOString();
3194
- if (options.session !== void 0) {
3195
- const sessionId = await resolveSessionId2(paths, options.session);
3196
- const sesId = sessionId;
3197
- const sessionLock = await acquireLock4(paths, "session", sesId);
3198
- let result;
3199
- try {
3200
- result = await appendEventToExistingSession2({
3201
- paths,
3202
- sessionId: sesId,
3203
- eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
3204
- });
3205
- } finally {
3206
- await sessionLock.release();
3207
- }
3208
- printNoteResult(options, {
3209
- mode: "attached",
3210
- sessionId,
3211
- eventId: result.eventId,
3212
- sessionStatus: result.sessionStatus,
3213
- body
3214
- });
3567
+ if (typeof payload !== "object" || payload === null) return;
3568
+ const cwd = payload.cwd;
3569
+ if (typeof cwd !== "string" || cwd.length === 0) return;
3570
+ let body;
3571
+ try {
3572
+ body = (await render(cwd)).body;
3573
+ } catch {
3215
3574
  return;
3216
3575
  }
3217
- const manifest = await readManifest5(paths);
3218
- const adHoc = await createAdHocSessionWithEvent2({
3219
- paths,
3220
- manifest,
3221
- label: buildAdHocLabel2(body),
3222
- occurredAt,
3223
- sessionSource: "human",
3224
- workingDirectory: repositoryRoot,
3225
- invocation: {
3226
- command: "basou note",
3227
- args: [body]
3228
- },
3229
- targetEventBuilders: [
3230
- (sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
3231
- ]
3232
- });
3233
- printNoteResult(options, {
3234
- mode: "ad-hoc",
3235
- sessionId: adHoc.sessionId,
3236
- eventId: adHoc.targetEventIds[0],
3237
- sessionStatus: "completed",
3238
- body
3239
- });
3240
- }
3241
- function buildNoteEvent(input) {
3242
- return {
3243
- schema_version: "0.1.0",
3244
- id: input.eventId,
3245
- session_id: input.sessionId,
3246
- occurred_at: input.occurredAt,
3247
- source: "local-cli",
3248
- type: "note_added",
3249
- body: input.body,
3250
- // `basou note` is the resume-hint command; mark it so orientation surfaces
3251
- // it as the next step and a plain `basou session note` annotation does not.
3252
- kind: "next_step"
3253
- };
3576
+ if (body.trim().length === 0) return;
3577
+ write(`${body.replace(/\s+$/, "")}
3578
+ `);
3254
3579
  }
3255
- function buildAdHocLabel2(body) {
3256
- const oneLine2 = body.replace(/\s+/g, " ").trim();
3257
- const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
3258
- return `Ad-hoc note: ${truncated}`;
3580
+ function parseTranscript(transcript) {
3581
+ const records = [];
3582
+ for (const line of transcript.split(/\r?\n/)) {
3583
+ if (line.trim().length === 0) continue;
3584
+ try {
3585
+ const parsed = JSON.parse(line);
3586
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3587
+ records.push(parsed);
3588
+ }
3589
+ } catch {
3590
+ }
3591
+ }
3592
+ return records;
3259
3593
  }
3260
- function parseBody(raw) {
3261
- if (raw.trim().length === 0) {
3262
- throw new InvalidArgumentError2("Note body must not be empty");
3594
+ async function defaultReadStdin() {
3595
+ if (process.stdin.isTTY === true) return "";
3596
+ const chunks = [];
3597
+ for await (const chunk of process.stdin) {
3598
+ chunks.push(chunk);
3263
3599
  }
3264
- return raw;
3600
+ return Buffer.concat(chunks).toString("utf8");
3265
3601
  }
3266
- function printNoteResult(options, result) {
3267
- const sid = shortSessionId(result.sessionId);
3268
- if (options.json === true) {
3269
- console.log(
3270
- JSON.stringify({
3271
- event_id: result.eventId,
3272
- session_id: result.sessionId,
3273
- session_status: result.sessionStatus,
3274
- mode: result.mode,
3275
- body: result.body
3276
- })
3277
- );
3278
- return;
3602
+ async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
3603
+ const { size } = await stat4(path);
3604
+ if (size <= maxBytes) return readFile3(path, "utf8");
3605
+ const handle = await open2(path, "r");
3606
+ try {
3607
+ const buffer = Buffer.alloc(maxBytes);
3608
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
3609
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
3610
+ const firstNewline = text.indexOf("\n");
3611
+ return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
3612
+ } finally {
3613
+ await handle.close();
3279
3614
  }
3280
- if (result.mode === "ad-hoc") {
3281
- console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
3282
- } else {
3283
- console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
3615
+ }
3616
+ function parseMinEdits(raw) {
3617
+ if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
3618
+ return Number(raw);
3619
+ }
3620
+ var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
3621
+ function resolveCliEntry() {
3622
+ return fileURLToPath(import.meta.url);
3623
+ }
3624
+ function normalizeInstallOptions(raw) {
3625
+ const out = {};
3626
+ if (raw.block === true) out.block = true;
3627
+ if (raw.requireReview === true) out.requireReview = true;
3628
+ if (raw.settings !== void 0) out.settings = raw.settings;
3629
+ if (raw.hooks !== void 0) out.hooks = raw.hooks;
3630
+ if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
3631
+ if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
3632
+ if (raw.dryRun === true) out.dryRun = true;
3633
+ if (raw.verbose === true) out.verbose = true;
3634
+ if (raw.minEdits !== void 0) {
3635
+ const parsed = parseMinEdits(raw.minEdits);
3636
+ if (parsed === void 0) {
3637
+ throw new Error("--min-edits must be a non-negative integer.");
3638
+ }
3639
+ out.minEdits = parsed;
3284
3640
  }
3641
+ return out;
3285
3642
  }
3286
- async function assertWorkspaceInitialized6(basouRoot) {
3643
+ async function readSettings(path) {
3644
+ let raw;
3287
3645
  try {
3288
- await assertBasouRootSafe7(basouRoot);
3646
+ raw = await readFile3(path, "utf8");
3289
3647
  } catch (error) {
3290
- if (findErrorCode6(error, "ENOENT")) {
3291
- throw new Error("Workspace not initialized. Run 'basou init' first.");
3648
+ if (error instanceof Error && error.code === "ENOENT") {
3649
+ return { raw: null, parsed: void 0 };
3292
3650
  }
3293
3651
  throw error;
3294
3652
  }
3653
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3654
+ try {
3655
+ return { raw, parsed: JSON.parse(raw) };
3656
+ } catch (error) {
3657
+ throw new Error(
3658
+ "The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
3659
+ {
3660
+ cause: error
3661
+ }
3662
+ );
3663
+ }
3295
3664
  }
3296
-
3297
- // src/commands/orient.ts
3298
- import {
3299
- assertBasouRootSafe as assertBasouRootSafe8,
3300
- basouPaths as basouPaths9,
3301
- findErrorCode as findErrorCode7,
3302
- renderOrientation as renderOrientation2,
3303
- writeMarkdownFile as writeMarkdownFile4
3304
- } from "@basou/core";
3305
-
3306
- // src/lib/hosts-config.ts
3307
- import { homedir as homedir6 } from "os";
3308
- import { isAbsolute as isAbsolute2, join as join8, resolve as resolve6 } from "path";
3309
- import { readYamlFile as readYamlFile4 } from "@basou/core";
3310
- var DEFAULT_HOSTS_CONFIG_PATH = join8(homedir6(), ".basou", "hosts.yaml");
3311
- function expandTilde2(p) {
3312
- if (p === "~") return homedir6();
3313
- if (p.startsWith("~/")) return join8(homedir6(), p.slice(2));
3314
- return p;
3665
+ async function backupSettingsOnce(path, raw) {
3666
+ if (raw === null) return;
3667
+ const bak = `${path}.basou-bak`;
3668
+ try {
3669
+ await stat4(bak);
3670
+ return;
3671
+ } catch (error) {
3672
+ if (!(error instanceof Error && error.code === "ENOENT")) throw error;
3673
+ }
3674
+ await writeFileDurable(bak, raw);
3315
3675
  }
3316
- function isRecord2(value) {
3317
- return typeof value === "object" && value !== null && !Array.isArray(value);
3676
+ async function runHookInstall(options, ctx = {}) {
3677
+ try {
3678
+ await doRunHookInstall(normalizeInstallOptions(options), ctx);
3679
+ } catch (error) {
3680
+ renderCliError(error, { verbose: isVerbose(options) });
3681
+ process.exitCode = 1;
3682
+ }
3318
3683
  }
3319
- async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
3320
- let raw;
3684
+ async function doRunHookInstall(options, ctx = {}) {
3685
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3686
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3687
+ const command = buildStopHookCommand({
3688
+ cliEntry,
3689
+ ...options.block === true ? { block: true } : {},
3690
+ ...options.requireReview === true ? { requireReview: true } : {},
3691
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3692
+ });
3693
+ const mode = describeHookMode({
3694
+ block: options.block === true,
3695
+ review: options.requireReview === true
3696
+ });
3697
+ await assertNotSymlink(settingsPath);
3698
+ const { raw, parsed } = await readSettings(settingsPath);
3699
+ const { settings, action } = upsertStopHook(parsed, command);
3700
+ const newBody = `${JSON.stringify(settings, null, 2)}
3701
+ `;
3702
+ if (raw !== null && newBody === raw) {
3703
+ console.log(`The basou Stop hook is already registered (${mode}); no change.`);
3704
+ return;
3705
+ }
3706
+ if (options.dryRun === true) {
3707
+ console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
3708
+ return;
3709
+ }
3710
+ const recheck = await readSettings(settingsPath);
3711
+ if (recheck.raw !== raw) {
3712
+ throw new Error(
3713
+ "The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
3714
+ );
3715
+ }
3716
+ await backupSettingsOnce(settingsPath, raw);
3717
+ await writeFileDurable(settingsPath, newBody);
3718
+ console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
3719
+ }
3720
+ async function runHookUninstall(options) {
3321
3721
  try {
3322
- raw = await readYamlFile4(configPath);
3722
+ await doRunHookUninstall(normalizeInstallOptions(options));
3323
3723
  } catch (error) {
3324
- if (error instanceof Error && error.message === "YAML file not found") {
3325
- return null;
3326
- }
3327
- if (error instanceof Error && error.message === "Failed to parse YAML content") {
3328
- throw new Error("~/.basou/hosts.yaml is not valid YAML.");
3329
- }
3330
- throw error;
3724
+ renderCliError(error, { verbose: isVerbose(options) });
3725
+ process.exitCode = 1;
3331
3726
  }
3332
- if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
3333
- throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
3727
+ }
3728
+ async function doRunHookUninstall(options) {
3729
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3730
+ await assertNotSymlink(settingsPath);
3731
+ const { raw, parsed } = await readSettings(settingsPath);
3732
+ if (raw === null) {
3733
+ console.log("No settings.json; nothing to remove.");
3734
+ return;
3334
3735
  }
3335
- const seenPaths = /* @__PURE__ */ new Set();
3336
- const seenLabels = /* @__PURE__ */ new Set();
3337
- const result = [];
3338
- for (const entry of raw.hosts) {
3339
- if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
3340
- throw new Error("Each host needs a non-empty string 'label'.");
3341
- }
3342
- const label = entry.label.trim();
3343
- if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
3344
- throw new Error("Each host needs a non-empty string 'path'.");
3345
- }
3346
- const expanded = expandTilde2(entry.path.trim());
3347
- if (!isAbsolute2(expanded)) {
3348
- throw new Error("Host paths must be absolute (or start with '~').");
3349
- }
3350
- const abs = resolve6(expanded);
3351
- if (seenPaths.has(abs)) continue;
3352
- if (seenLabels.has(label)) {
3353
- throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
3354
- }
3355
- seenPaths.add(abs);
3356
- seenLabels.add(label);
3357
- result.push({ label, path: abs });
3736
+ const { settings, action } = removeStopHook(parsed);
3737
+ if (action === "absent") {
3738
+ console.log("No basou Stop hook found; nothing removed.");
3739
+ return;
3358
3740
  }
3359
- return result;
3741
+ const newBody = `${JSON.stringify(settings, null, 2)}
3742
+ `;
3743
+ if (options.dryRun === true) {
3744
+ console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
3745
+ return;
3746
+ }
3747
+ const recheck = await readSettings(settingsPath);
3748
+ if (recheck.raw !== raw) {
3749
+ throw new Error(
3750
+ "The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
3751
+ );
3752
+ }
3753
+ await backupSettingsOnce(settingsPath, raw);
3754
+ await writeFileDurable(settingsPath, newBody);
3755
+ console.log("Removed the basou Stop hook from settings.json.");
3360
3756
  }
3361
-
3362
- // src/lib/provenance-actions.ts
3363
- import {
3364
- readMarkdownFile as readMarkdownFile3,
3365
- renderDecisions as renderDecisions2,
3366
- renderHandoff as renderHandoff2,
3367
- renderOrientation,
3368
- renderWithMarkers as renderWithMarkers3,
3369
- writeMarkdownFile as writeMarkdownFile3
3370
- } from "@basou/core";
3371
- async function captureImportJson(fn) {
3372
- const stdout = [];
3373
- const originalLog = console.log;
3374
- const originalError = console.error;
3375
- console.log = ((...args) => {
3376
- stdout.push(args.map((a) => String(a)).join(" "));
3377
- });
3378
- console.error = (() => {
3379
- });
3757
+ async function runHookStatus(options) {
3380
3758
  try {
3381
- await fn();
3382
- } finally {
3383
- console.log = originalLog;
3384
- console.error = originalError;
3759
+ await doRunHookStatus(normalizeInstallOptions(options));
3760
+ } catch (error) {
3761
+ renderCliError(error, { verbose: isVerbose(options) });
3762
+ process.exitCode = 1;
3385
3763
  }
3386
- for (let i = stdout.length - 1; i >= 0; i--) {
3387
- const line = stdout[i];
3388
- if (line === void 0) continue;
3389
- try {
3390
- const parsed = JSON.parse(line);
3391
- if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
3392
- return parsed;
3393
- }
3394
- } catch {
3395
- }
3764
+ }
3765
+ async function doRunHookStatus(options) {
3766
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3767
+ const { parsed } = await readSettings(settingsPath);
3768
+ const command = findBasouStopHookCommand(parsed);
3769
+ if (command === null) {
3770
+ console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
3771
+ return;
3396
3772
  }
3397
- throw new Error("Import produced no parseable result");
3773
+ const mode = describeHookMode({
3774
+ block: / --block\b/.test(command),
3775
+ review: / --require-review\b/.test(command)
3776
+ });
3777
+ console.log(`basou Stop hook: registered, ${mode}.`);
3398
3778
  }
3399
- function readCount(value) {
3400
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
3779
+ function describeHookMode(tiers) {
3780
+ const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
3781
+ const gates = tiers.review ? "capture + review" : "capture";
3782
+ return `${enforcement}, ${gates}`;
3401
3783
  }
3402
- function isMissingSourceDir(error) {
3403
- if (!(error instanceof Error)) return false;
3404
- return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
3784
+ var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
3785
+ var LEFTOVER_FACE_NOTE = (label) => `${label} still carries an orientation block rendered by an earlier basou (0.39 or before); every Codex session on this machine reads it. \`basou channel clear codex\` removes it.`;
3786
+ async function faceHasLeftoverOrientationBlock(facePath) {
3787
+ try {
3788
+ const existing = await readMarkdownFile5(facePath);
3789
+ if (existing === null) return false;
3790
+ const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
3791
+ return section.kind !== "no_markers";
3792
+ } catch {
3793
+ return false;
3794
+ }
3405
3795
  }
3406
- async function runImport(adapter, fn) {
3796
+ var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
3797
+ var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
3798
+ async function readHooksFile(path) {
3799
+ let raw;
3407
3800
  try {
3408
- const json = await captureImportJson(fn);
3409
- return {
3410
- adapter,
3411
- status: "ran",
3412
- importedCount: readCount(json.imported_count),
3413
- replacedCount: readCount(json.replaced_count),
3414
- reimportedCount: readCount(json.reimported_count),
3415
- skippedNoAction: readCount(json.skipped_no_action),
3416
- skippedAlreadyImported: readCount(json.skipped_already_imported),
3417
- skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3418
- skippedDecreased: readCount(json.skipped_decreased),
3419
- skippedDuplicate: readCount(json.skipped_duplicate),
3420
- skippedUnverifiable: readCount(json.skipped_unverifiable),
3421
- eventTotal: readCount(json.event_total),
3422
- dryRun: json.dry_run === true
3423
- };
3801
+ raw = await readFile3(path, "utf8");
3424
3802
  } catch (error) {
3425
- if (isMissingSourceDir(error)) {
3426
- return { adapter, status: "skipped", reason: "no source logs for this project" };
3803
+ if (error instanceof Error && error.code === "ENOENT") {
3804
+ return { raw: null, parsed: void 0 };
3427
3805
  }
3428
3806
  throw error;
3429
3807
  }
3808
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3809
+ try {
3810
+ return { raw, parsed: JSON.parse(raw) };
3811
+ } catch (error) {
3812
+ throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
3813
+ cause: error
3814
+ });
3815
+ }
3430
3816
  }
3431
- function importOptions(options) {
3432
- return {
3433
- all: true,
3434
- json: true,
3435
- ...options.project !== void 0 ? { project: options.project } : {},
3436
- ...options.force === true ? { force: true } : {},
3437
- ...options.dryRun === true ? { dryRun: true } : {}
3438
- };
3439
- }
3440
- function importClaudeCode(options, ctx) {
3441
- return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3442
- }
3443
- function importCodex(options, ctx) {
3444
- return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
3445
- }
3446
- async function regenerateHandoff(paths, nowIso, callbacks) {
3447
- const result = await renderHandoff2({ paths, nowIso, ...callbacks });
3448
- const existing = await readMarkdownFile3(paths.files.handoff);
3449
- await writeMarkdownFile3(
3450
- paths.files.handoff,
3451
- renderWithMarkers3(existing, result.body, "handoff.md")
3452
- );
3453
- return {
3454
- sessionCount: result.sessionCount,
3455
- taskCount: result.taskCount,
3456
- decisionCount: result.decisionCount,
3457
- pendingApprovalsCount: result.pendingApprovalsCount
3458
- };
3459
- }
3460
- async function regenerateDecisions(paths, nowIso, callbacks) {
3461
- const result = await renderDecisions2({ paths, nowIso, ...callbacks });
3462
- const existing = await readMarkdownFile3(paths.files.decisions);
3463
- await writeMarkdownFile3(
3464
- paths.files.decisions,
3465
- renderWithMarkers3(existing, result.body, "decisions.md")
3466
- );
3467
- return { decisionCount: result.decisionCount };
3468
- }
3469
- async function regenerateOrientation(paths, nowIso, callbacks) {
3470
- const result = await renderOrientation({ paths, nowIso, ...callbacks });
3471
- await writeMarkdownFile3(paths.files.orientation, `${result.body}
3472
- `);
3473
- return {
3474
- sessionCount: result.sessionCount,
3475
- inFlightTaskCount: result.inFlightTaskCount,
3476
- pendingApprovalsCount: result.pendingApprovalsCount,
3477
- suspectCount: result.suspectCount
3478
- };
3817
+ var CODEX_TRUST_NOTE = "Codex reviews a new or changed hook once before running it: start `codex` in a terminal and trust it when asked (desktop app: Settings -> Hooks). Until then the hook is skipped silently.";
3818
+ async function runCodexHookInstall(options, ctx = {}) {
3819
+ try {
3820
+ await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
3821
+ } catch (error) {
3822
+ renderCliError(error, { verbose: isVerbose(options) });
3823
+ process.exitCode = 1;
3824
+ }
3479
3825
  }
3480
- async function refreshAll(args) {
3481
- const { options, ctx, paths, nowIso } = args;
3482
- const dryRun = options.dryRun === true;
3483
- const claudeCode = await importClaudeCode(options, ctx);
3484
- const codex = await importCodex(options, ctx);
3485
- if (dryRun) {
3486
- const skipped = { status: "skipped", reason: "dry-run" };
3487
- return {
3488
- claudeCode,
3489
- codex,
3490
- handoff: skipped,
3491
- decisions: skipped,
3492
- orientation: skipped,
3493
- dryRun
3494
- };
3826
+ async function doRunCodexHookInstall(options, ctx = {}) {
3827
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3828
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3829
+ const command = buildSessionStartHookCommand({ cliEntry });
3830
+ await assertNotSymlink(hooksPath);
3831
+ const { raw, parsed } = await readHooksFile(hooksPath);
3832
+ const { hooksFile, action } = upsertSessionStartHook(parsed, command);
3833
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
3834
+ `;
3835
+ if (action === "unchanged" || raw !== null && newBody === raw) {
3836
+ console.log("The basou Codex SessionStart hook is already registered; no change.");
3837
+ await reportCodexHookState(hooksPath, hooksFile, options);
3838
+ return;
3495
3839
  }
3496
- const handoffCounts = await regenerateHandoff(paths, nowIso);
3497
- const decisionCounts = await regenerateDecisions(paths, nowIso);
3498
- const scoped = options.project !== void 0 && options.project.length > 0;
3499
- const orientationCounts = await regenerateOrientation(
3500
- paths,
3501
- nowIso,
3502
- scoped ? {} : {
3503
- staleness: {
3504
- newSessions: 0,
3505
- updatedSessions: 0,
3506
- unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
3507
- }
3508
- }
3840
+ if (options.dryRun === true) {
3841
+ console.log(
3842
+ `[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
3843
+ );
3844
+ return;
3845
+ }
3846
+ const recheck = await readHooksFile(hooksPath);
3847
+ if (recheck.raw !== raw) {
3848
+ throw new Error(
3849
+ "The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
3850
+ );
3851
+ }
3852
+ await backupSettingsOnce(hooksPath, raw);
3853
+ await writeFileDurable(hooksPath, newBody);
3854
+ console.log(
3855
+ `${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
3509
3856
  );
3510
- return {
3511
- claudeCode,
3512
- codex,
3513
- handoff: { status: "generated", ...handoffCounts },
3514
- decisions: { status: "generated", ...decisionCounts },
3515
- orientation: { status: "generated", ...orientationCounts },
3516
- dryRun
3517
- };
3857
+ await reportCodexHookState(hooksPath, hooksFile, options);
3518
3858
  }
3519
- function wouldImport(outcome) {
3520
- return outcome.status === "ran" ? outcome.importedCount : 0;
3859
+ async function reportCodexHookState(hooksPath, hooksFile, options) {
3860
+ const location = findBasouSessionStartHook(hooksFile);
3861
+ if (location !== null) {
3862
+ const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
3863
+ console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
3864
+ if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
3865
+ }
3866
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
3867
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
3868
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
3869
+ }
3521
3870
  }
3522
- function wouldUpdate(outcome) {
3523
- return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
3871
+ async function runCodexHookUninstall(options) {
3872
+ try {
3873
+ await doRunCodexHookUninstall(normalizeInstallOptions(options));
3874
+ } catch (error) {
3875
+ renderCliError(error, { verbose: isVerbose(options) });
3876
+ process.exitCode = 1;
3877
+ }
3524
3878
  }
3525
- function wouldBlock(outcome) {
3526
- return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
3879
+ async function doRunCodexHookUninstall(options) {
3880
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3881
+ await assertNotSymlink(hooksPath);
3882
+ const { raw, parsed } = await readHooksFile(hooksPath);
3883
+ if (raw === null) {
3884
+ console.log("No hooks.json; nothing to remove.");
3885
+ return;
3886
+ }
3887
+ const { hooksFile, action } = removeSessionStartHook(parsed);
3888
+ if (action === "absent") {
3889
+ console.log("No basou Codex SessionStart hook found; nothing removed.");
3890
+ return;
3891
+ }
3892
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
3893
+ `;
3894
+ if (options.dryRun === true) {
3895
+ console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
3896
+ return;
3897
+ }
3898
+ const recheck = await readHooksFile(hooksPath);
3899
+ if (recheck.raw !== raw) {
3900
+ throw new Error(
3901
+ "The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
3902
+ );
3903
+ }
3904
+ await backupSettingsOnce(hooksPath, raw);
3905
+ await writeFileDurable(hooksPath, newBody);
3906
+ console.log("Removed the basou Codex SessionStart hook from hooks.json.");
3527
3907
  }
3528
- async function probeStaleness(args) {
3908
+ async function runCodexHookStatus(options) {
3529
3909
  try {
3530
- const dry = await refreshAll({
3531
- options: { dryRun: true },
3532
- ctx: args.ctx,
3533
- paths: args.paths,
3534
- nowIso: args.nowIso
3535
- });
3910
+ await doRunCodexHookStatus(normalizeInstallOptions(options));
3911
+ } catch (error) {
3912
+ renderCliError(error, { verbose: isVerbose(options) });
3913
+ process.exitCode = 1;
3914
+ }
3915
+ }
3916
+ async function doRunCodexHookStatus(options) {
3917
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3918
+ const { parsed } = await readHooksFile(hooksPath);
3919
+ const location = findBasouSessionStartHook(parsed);
3920
+ if (location === null) {
3921
+ console.log(
3922
+ "basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
3923
+ );
3924
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
3925
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
3926
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
3927
+ }
3928
+ return;
3929
+ }
3930
+ const matcher = location.matcher ?? "(every source)";
3931
+ console.log(
3932
+ `basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
3933
+ );
3934
+ await reportCodexHookState(hooksPath, parsed, options);
3935
+ }
3936
+ async function codexHookTrustFor(hooksPath, location, configPath) {
3937
+ const fields = commandHandlerFields(location.handler);
3938
+ if (fields === null)
3939
+ return { status: "unknown", detail: "the installed handler is not a command hook" };
3940
+ let configToml;
3941
+ try {
3942
+ configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
3943
+ } catch (error) {
3944
+ if (error instanceof Error && error.code === "ENOENT") {
3945
+ return { status: "untrusted" };
3946
+ }
3536
3947
  return {
3537
- newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
3538
- updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
3539
- unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
3948
+ status: "unknown",
3949
+ detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
3540
3950
  };
3541
- } catch {
3542
- return null;
3543
3951
  }
3952
+ const key = codexHookStateKey(
3953
+ hooksPath,
3954
+ "session_start",
3955
+ location.groupIndex,
3956
+ location.handlerIndex
3957
+ );
3958
+ const state = readCodexHookState(configToml, key);
3959
+ const current = computeCodexHookIdentityHash({
3960
+ eventKey: "session_start",
3961
+ matcher: location.matcher,
3962
+ handler: fields
3963
+ });
3964
+ return judgeCodexHookTrust(state, current);
3544
3965
  }
3545
3966
 
3546
- // src/commands/orient.ts
3547
- function registerOrientCommand(program2) {
3548
- program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
3549
- "--refresh",
3550
- "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
3551
- ).option("-v, --verbose", "Show error causes").action(async (opts) => {
3552
- await runOrient(opts);
3967
+ // src/commands/init.ts
3968
+ import { basename as basename4, relative, resolve as resolve6 } from "path";
3969
+ import {
3970
+ appendBasouGitignore,
3971
+ createManifest,
3972
+ ensureBasouDirectory,
3973
+ resolveRepositoryRoot as resolveRepositoryRoot7,
3974
+ writeManifest
3975
+ } from "@basou/core";
3976
+ function collectValue(value, previous) {
3977
+ return [...previous, value];
3978
+ }
3979
+ function registerInitCommand(program2) {
3980
+ program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
3981
+ "--repo-url <url>",
3982
+ "Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
3983
+ ).option(
3984
+ "--source-root <path>",
3985
+ "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
3986
+ collectValue,
3987
+ []
3988
+ ).option(
3989
+ "--local-only",
3990
+ "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
3991
+ ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
3992
+ await runInit(options);
3553
3993
  });
3554
3994
  }
3555
- async function runOrient(options, ctx = {}) {
3995
+ async function runInit(options, ctx = {}) {
3556
3996
  try {
3557
- await doRunOrient(options, ctx);
3997
+ await doRunInit(options, ctx);
3558
3998
  } catch (error) {
3559
3999
  renderCliError(error, { verbose: isVerbose(options) });
3560
4000
  process.exitCode = 1;
3561
4001
  }
3562
4002
  }
3563
- async function doRunOrient(options, ctx) {
4003
+ async function doRunInit(options, ctx) {
3564
4004
  const cwd = ctx.cwd ?? process.cwd();
3565
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
3566
- const paths = basouPaths9(repositoryRoot);
3567
- await assertWorkspaceInitialized7(paths.root);
3568
- const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
3569
- const probeCtx = { cwd: repositoryRoot };
3570
- if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
3571
- if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
3572
- if (options.refresh === true) {
3573
- await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
4005
+ const repositoryRoot = await resolveRepositoryRootForInit(cwd);
4006
+ const workspaceName = options.name ?? basename4(repositoryRoot);
4007
+ if (options.repoUrl !== void 0) {
4008
+ console.error(
4009
+ "Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
4010
+ );
3574
4011
  }
3575
- const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
3576
- let federatedRoots = [];
4012
+ const sourceRoots = (options.sourceRoot ?? []).map((p) => {
4013
+ const rel = relative(repositoryRoot, resolve6(cwd, p));
4014
+ return rel === "" ? "." : rel;
4015
+ });
4016
+ const paths = await ensureBasouDirectory(repositoryRoot);
4017
+ const manifest = createManifest({
4018
+ workspaceName,
4019
+ ...options.projectName !== void 0 ? { projectName: options.projectName } : {},
4020
+ ...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
4021
+ ...sourceRoots.length > 0 ? { sourceRoots } : {}
4022
+ });
4023
+ await writeManifest(paths, manifest, { force: options.force === true });
3577
4024
  try {
3578
- const hosts = await loadHostsConfig(ctx.hostsConfigPath);
3579
- if (hosts !== null) {
3580
- federatedRoots = hosts.map((h) => ({ paths: basouPaths9(h.path), host: h.label }));
4025
+ await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
4026
+ } catch (error) {
4027
+ renderGitignoreWarning(error, isVerbose(options));
4028
+ }
4029
+ console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
4030
+ }
4031
+ function renderGitignoreWarning(error, verbose) {
4032
+ const baseMessage = error instanceof Error ? error.message : String(error);
4033
+ console.error(
4034
+ `Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
4035
+ );
4036
+ if (verbose && error instanceof Error) {
4037
+ const label = extractCauseLabel(error);
4038
+ if (label !== void 0) console.error(`Caused by: ${label}`);
4039
+ }
4040
+ }
4041
+ async function resolveRepositoryRootForInit(cwd) {
4042
+ try {
4043
+ return await resolveRepositoryRoot7(cwd);
4044
+ } catch (error) {
4045
+ if (error instanceof Error && error.message === "Not a git repository") {
4046
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
4047
+ cause: error
4048
+ });
3581
4049
  }
4050
+ throw error;
4051
+ }
4052
+ }
4053
+
4054
+ // src/commands/note.ts
4055
+ import {
4056
+ acquireLock as acquireLock4,
4057
+ appendEventToExistingSession as appendEventToExistingSession2,
4058
+ assertBasouRootSafe as assertBasouRootSafe8,
4059
+ basouPaths as basouPaths9,
4060
+ createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
4061
+ findErrorCode as findErrorCode7,
4062
+ readManifest as readManifest5,
4063
+ resolveSessionId as resolveSessionId2
4064
+ } from "@basou/core";
4065
+ import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
4066
+ var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
4067
+ "list",
4068
+ "ls",
4069
+ "show",
4070
+ "get",
4071
+ "add",
4072
+ "new",
4073
+ "edit",
4074
+ "rm",
4075
+ "remove",
4076
+ "delete",
4077
+ "help"
4078
+ ]);
4079
+ var LABEL_BODY_MAX = 80;
4080
+ var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
4081
+ function registerNoteCommand(program2) {
4082
+ program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
4083
+ "--session <session_id>",
4084
+ "Attach to an existing session; otherwise an ad-hoc session is created"
4085
+ ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
4086
+ await runNote(body, options);
4087
+ });
4088
+ }
4089
+ async function runNote(body, options, ctx = {}) {
4090
+ try {
4091
+ await doRunNote(body, options, ctx);
3582
4092
  } catch (error) {
3583
- console.error(
3584
- `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
4093
+ renderCliError(error, {
4094
+ verbose: isVerbose(options),
4095
+ classifiers: [failedToFinalizeClassifier]
4096
+ });
4097
+ process.exitCode = 1;
4098
+ }
4099
+ }
4100
+ async function doRunNote(body, options, ctx) {
4101
+ if (body.trim().length === 0) {
4102
+ throw new Error("Note body must not be empty");
4103
+ }
4104
+ const reserved = body.trim().toLowerCase();
4105
+ if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
4106
+ throw new Error(
4107
+ `'basou note' records a free-text note and has no '${body.trim()}' subcommand. To record a note, pass its full text (e.g. \`basou note "<your note>"\`).`
3585
4108
  );
3586
4109
  }
3587
- const result = await renderOrientation2({
4110
+ const cwd = ctx.cwd ?? process.cwd();
4111
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
4112
+ const paths = basouPaths9(repositoryRoot);
4113
+ await assertWorkspaceInitialized7(paths.root);
4114
+ const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
4115
+ const occurredAt = now.toISOString();
4116
+ if (options.session !== void 0) {
4117
+ const sessionId = await resolveSessionId2(paths, options.session);
4118
+ const sesId = sessionId;
4119
+ const sessionLock = await acquireLock4(paths, "session", sesId);
4120
+ let result;
4121
+ try {
4122
+ result = await appendEventToExistingSession2({
4123
+ paths,
4124
+ sessionId: sesId,
4125
+ eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
4126
+ });
4127
+ } finally {
4128
+ await sessionLock.release();
4129
+ }
4130
+ printNoteResult(options, {
4131
+ mode: "attached",
4132
+ sessionId,
4133
+ eventId: result.eventId,
4134
+ sessionStatus: result.sessionStatus,
4135
+ body
4136
+ });
4137
+ return;
4138
+ }
4139
+ const manifest = await readManifest5(paths);
4140
+ const adHoc = await createAdHocSessionWithEvent2({
3588
4141
  paths,
3589
- nowIso,
3590
- staleness,
3591
- verbose: options.verbose === true,
3592
- federatedRoots,
3593
- onWarning: (w, sid) => printReplayWarning(w, sid),
3594
- onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
3595
- onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
3596
- onHostUnavailable: (host, error) => console.error(
3597
- `basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
3598
- )
4142
+ manifest,
4143
+ label: buildAdHocLabel2(body),
4144
+ occurredAt,
4145
+ sessionSource: "human",
4146
+ workingDirectory: repositoryRoot,
4147
+ invocation: {
4148
+ command: "basou note",
4149
+ args: [body]
4150
+ },
4151
+ targetEventBuilders: [
4152
+ (sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
4153
+ ]
3599
4154
  });
3600
- await writeMarkdownFile4(paths.files.orientation, `${result.body}
3601
- `);
3602
- if (options.quiet === true) {
4155
+ printNoteResult(options, {
4156
+ mode: "ad-hoc",
4157
+ sessionId: adHoc.sessionId,
4158
+ eventId: adHoc.targetEventIds[0],
4159
+ sessionStatus: "completed",
4160
+ body
4161
+ });
4162
+ }
4163
+ function buildNoteEvent(input) {
4164
+ return {
4165
+ schema_version: "0.1.0",
4166
+ id: input.eventId,
4167
+ session_id: input.sessionId,
4168
+ occurred_at: input.occurredAt,
4169
+ source: "local-cli",
4170
+ type: "note_added",
4171
+ body: input.body,
4172
+ // `basou note` is the resume-hint command; mark it so orientation surfaces
4173
+ // it as the next step and a plain `basou session note` annotation does not.
4174
+ kind: "next_step"
4175
+ };
4176
+ }
4177
+ function buildAdHocLabel2(body) {
4178
+ const oneLine2 = body.replace(/\s+/g, " ").trim();
4179
+ const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
4180
+ return `Ad-hoc note: ${truncated}`;
4181
+ }
4182
+ function parseBody(raw) {
4183
+ if (raw.trim().length === 0) {
4184
+ throw new InvalidArgumentError2("Note body must not be empty");
4185
+ }
4186
+ return raw;
4187
+ }
4188
+ function printNoteResult(options, result) {
4189
+ const sid = shortSessionId(result.sessionId);
4190
+ if (options.json === true) {
3603
4191
  console.log(
3604
- `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
4192
+ JSON.stringify({
4193
+ event_id: result.eventId,
4194
+ session_id: result.sessionId,
4195
+ session_status: result.sessionStatus,
4196
+ mode: result.mode,
4197
+ body: result.body
4198
+ })
3605
4199
  );
4200
+ return;
4201
+ }
4202
+ if (result.mode === "ad-hoc") {
4203
+ console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
3606
4204
  } else {
3607
- console.log(result.body);
4205
+ console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
3608
4206
  }
3609
4207
  }
3610
4208
  async function assertWorkspaceInitialized7(basouRoot) {
@@ -3620,13 +4218,13 @@ async function assertWorkspaceInitialized7(basouRoot) {
3620
4218
 
3621
4219
  // src/commands/portfolio.ts
3622
4220
  import { existsSync, statSync } from "fs";
3623
- import { join as join9 } from "path";
4221
+ import { join as join10 } from "path";
3624
4222
  function registerPortfolioCommand(program2) {
3625
4223
  program2.command("portfolio").description(
3626
4224
  "List the workspaces you orient across (read-only): every planning master registered in ~/.basou/portfolio.yaml, with its path and whether it exists / is initialized. The headless text/JSON counterpart to the `basou view --portfolio` GUI \u2014 for discovering where a sibling project lives without opening a browser"
3627
4225
  ).argument("[action]", "optional literal `list` (the only, and default, action)").option("--json", "Output the result as JSON").option(
3628
4226
  "--check",
3629
- "moved: the redundancy/footprint safety preflight is `basou view --portfolio --check` (this prints that pointer and exits)"
4227
+ "moved: the redundancy/footprint preflight and the capture-coverage report are `basou view --portfolio --check` (this prints that pointer and exits)"
3630
4228
  ).option("-v, --verbose", "Show error causes").action(async (action, opts) => {
3631
4229
  await runPortfolioCommand(action, opts);
3632
4230
  });
@@ -3641,7 +4239,7 @@ function isDirectory(path) {
3641
4239
  async function runPortfolioCommand(action, options, ctx = {}) {
3642
4240
  if (options.check === true) {
3643
4241
  console.error(
3644
- "`basou portfolio` is a read-only listing; it has no safety preflight.\nRun `basou view --portfolio --check` for the redundancy/footprint check."
4242
+ "`basou portfolio` is a read-only listing; it has no preflight.\nRun `basou view --portfolio --check` for the redundancy/footprint check and the capture-coverage report."
3645
4243
  );
3646
4244
  process.exitCode = 1;
3647
4245
  return;
@@ -3666,7 +4264,7 @@ async function runPortfolioList(options, ctx = {}) {
3666
4264
  async function doRunPortfolioList(options, ctx) {
3667
4265
  const configPath = ctx.configPath ?? DEFAULT_PORTFOLIO_CONFIG_PATH;
3668
4266
  const pathExists2 = ctx.pathExists ?? ((p) => existsSync(p));
3669
- const isInitialized = ctx.isInitialized ?? ((p) => isDirectory(join9(p, ".basou")));
4267
+ const isInitialized = ctx.isInitialized ?? ((p) => isDirectory(join10(p, ".basou")));
3670
4268
  const workspaces = await loadPortfolioConfig(configPath);
3671
4269
  const result = {
3672
4270
  configPath,
@@ -3706,7 +4304,7 @@ function renderPortfolioList(result) {
3706
4304
  }
3707
4305
  lines.push("");
3708
4306
  lines.push(
3709
- "Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint safety preflight."
4307
+ "Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint preflight and the capture-coverage report (which session logs no registered workspace imports)."
3710
4308
  );
3711
4309
  return lines.join("\n");
3712
4310
  }
@@ -3731,7 +4329,7 @@ import {
3731
4329
  writeFileSync,
3732
4330
  writeSync
3733
4331
  } from "fs";
3734
- import { basename as basename5, dirname as dirname3, isAbsolute as isAbsolute3, join as join10, relative as relative2, resolve as resolve7 } from "path";
4332
+ import { basename as basename5, dirname as dirname3, isAbsolute as isAbsolute3, join as join11, relative as relative2, resolve as resolve7 } from "path";
3735
4333
  import {
3736
4334
  appendBasouGitignore as appendBasouGitignore2,
3737
4335
  basouPaths as basouPaths10,
@@ -3742,7 +4340,7 @@ import {
3742
4340
  GENERATED_START,
3743
4341
  instructionMode,
3744
4342
  isGitNotFound,
3745
- parseMarkers,
4343
+ parseMarkers as parseMarkers3,
3746
4344
  pathBasename,
3747
4345
  planArchive,
3748
4346
  planGitignore,
@@ -3750,9 +4348,9 @@ import {
3750
4348
  planRosterAdoption,
3751
4349
  planWorkspaceView,
3752
4350
  readManifest as readManifest6,
3753
- readMarkdownFile as readMarkdownFile4,
4351
+ readMarkdownFile as readMarkdownFile6,
3754
4352
  reconcileSourceRoots,
3755
- removeMarkerSection,
4353
+ removeMarkerSection as removeMarkerSection2,
3756
4354
  renderAnchorStarter,
3757
4355
  renderViewPresetBlock,
3758
4356
  renderWithMarkers as renderWithMarkers4,
@@ -4113,7 +4711,7 @@ function classifySourceRoot(repositoryRoot, declaredPath) {
4113
4711
  } catch {
4114
4712
  return { path: declaredPath, kind: "unresolved" };
4115
4713
  }
4116
- return { path: declaredPath, kind: existsSync2(join10(real, ".git")) ? "repo" : "non-repo" };
4714
+ return { path: declaredPath, kind: existsSync2(join11(real, ".git")) ? "repo" : "non-repo" };
4117
4715
  }
4118
4716
  async function doRunProjectAdopt(options, ctx) {
4119
4717
  const cwd = ctx.cwd ?? process.cwd();
@@ -4217,7 +4815,7 @@ async function gatherRepoWiring(repositoryRoot, entry) {
4217
4815
  } catch {
4218
4816
  return { ...base, reachable: false, instructionFiles: [] };
4219
4817
  }
4220
- if (!existsSync2(join10(real, ".git"))) {
4818
+ if (!existsSync2(join11(real, ".git"))) {
4221
4819
  return { ...base, reachable: false, instructionFiles: [] };
4222
4820
  }
4223
4821
  try {
@@ -4225,7 +4823,7 @@ async function gatherRepoWiring(repositoryRoot, entry) {
4225
4823
  for (const name of INSTRUCTION_FILES) {
4226
4824
  let present = true;
4227
4825
  try {
4228
- lstatSync(join10(real, name));
4826
+ lstatSync(join11(real, name));
4229
4827
  } catch {
4230
4828
  present = false;
4231
4829
  }
@@ -4336,10 +4934,10 @@ function gatherRepoGitignore(repositoryRoot, entry) {
4336
4934
  } catch {
4337
4935
  return { ...base, reachable: false, currentLines: [] };
4338
4936
  }
4339
- if (!existsSync2(join10(real, ".git"))) {
4937
+ if (!existsSync2(join11(real, ".git"))) {
4340
4938
  return { ...base, reachable: false, currentLines: [] };
4341
4939
  }
4342
- return { ...base, reachable: true, currentLines: readGitignoreLines(join10(real, ".gitignore")) };
4940
+ return { ...base, reachable: true, currentLines: readGitignoreLines(join11(real, ".gitignore")) };
4343
4941
  }
4344
4942
  function hasErrorCode(error) {
4345
4943
  return error instanceof Error && typeof error.code === "string";
@@ -4353,7 +4951,7 @@ function readGitignoreLines(file) {
4353
4951
  }
4354
4952
  }
4355
4953
  function applyGitignorePlan(repositoryRoot, plan) {
4356
- const file = join10(realpathSync(resolve7(repositoryRoot, plan.path)), ".gitignore");
4954
+ const file = join11(realpathSync(resolve7(repositoryRoot, plan.path)), ".gitignore");
4357
4955
  let existing = "";
4358
4956
  try {
4359
4957
  existing = readFileSync(file, "utf8");
@@ -4493,7 +5091,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4493
5091
  return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
4494
5092
  }
4495
5093
  if (real === anchorReal) {
4496
- const anchorCanonical = join10(real, CANONICAL_FILE);
5094
+ const anchorCanonical = join11(real, CANONICAL_FILE);
4497
5095
  const anchorState = anchorCanonicalState(anchorCanonical);
4498
5096
  if (anchorState === "absent") {
4499
5097
  return { ...base, isAnchor: true, reachable: true, canonicalPresent: false, files: [] };
@@ -4513,7 +5111,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4513
5111
  anchorCanonical,
4514
5112
  "self"
4515
5113
  ).map((spec) => {
4516
- const { state, actualTarget } = inspectSymlink(join10(real, spec.name), spec.target);
5114
+ const { state, actualTarget } = inspectSymlink(join11(real, spec.name), spec.target);
4517
5115
  return {
4518
5116
  name: spec.name,
4519
5117
  expectedTarget: spec.target,
@@ -4530,16 +5128,16 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4530
5128
  files: anchorFiles
4531
5129
  };
4532
5130
  }
4533
- if (!existsSync2(join10(real, ".git"))) {
5131
+ if (!existsSync2(join11(real, ".git"))) {
4534
5132
  return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
4535
5133
  }
4536
- const canonicalFile = isSelf ? join10(real, CANONICAL_FILE) : join10(anchorReal, "agents", basename5(real), CANONICAL_FILE);
5134
+ const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents", basename5(real), CANONICAL_FILE);
4537
5135
  if (!existsSync2(canonicalFile)) {
4538
5136
  return { ...base, isAnchor: false, reachable: true, canonicalPresent: false, files: [] };
4539
5137
  }
4540
5138
  const files = expectedSymlinkTargets(real, canonicalFile, mode).map(
4541
5139
  (spec) => {
4542
- const { state, actualTarget } = inspectSymlink(join10(real, spec.name), spec.target);
5140
+ const { state, actualTarget } = inspectSymlink(join11(real, spec.name), spec.target);
4543
5141
  return {
4544
5142
  name: spec.name,
4545
5143
  expectedTarget: spec.target,
@@ -4568,7 +5166,7 @@ function applySymlinkPlan(repositoryRoot, plan) {
4568
5166
  const created = [];
4569
5167
  const failed = [];
4570
5168
  for (const { name, target } of plan.toCreate) {
4571
- const filePath = join10(real, name);
5169
+ const filePath = join11(real, name);
4572
5170
  try {
4573
5171
  mkdirSync(dirname3(filePath), { recursive: true });
4574
5172
  symlinkSync(target, filePath);
@@ -4602,7 +5200,7 @@ function gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir) {
4602
5200
  if (!existsSync2(canonicalFile)) return { kind: "missing-canonical", viewName };
4603
5201
  const files = expectedSymlinkTargets(viewDir, canonicalFile, "hub").map(
4604
5202
  (spec) => {
4605
- const { state, actualTarget } = inspectSymlink(join10(viewDir, spec.name), spec.target);
5203
+ const { state, actualTarget } = inspectSymlink(join11(viewDir, spec.name), spec.target);
4606
5204
  return {
4607
5205
  name: spec.name,
4608
5206
  expectedTarget: spec.target,
@@ -4618,7 +5216,7 @@ function applyViewSymlinks(viewDir, files) {
4618
5216
  const failed = [];
4619
5217
  for (const f of files) {
4620
5218
  if (f.state !== "missing") continue;
4621
- const filePath = join10(viewDir, f.name);
5219
+ const filePath = join11(viewDir, f.name);
4622
5220
  try {
4623
5221
  mkdirSync(dirname3(filePath), { recursive: true });
4624
5222
  symlinkSync(f.expectedTarget, filePath);
@@ -4840,7 +5438,7 @@ function resolveViewDir(repositoryRoot, viewPath) {
4840
5438
  return realpathSync(abs);
4841
5439
  } catch {
4842
5440
  try {
4843
- return join10(realpathSync(dirname3(abs)), basename5(abs));
5441
+ return join11(realpathSync(dirname3(abs)), basename5(abs));
4844
5442
  } catch {
4845
5443
  return abs;
4846
5444
  }
@@ -4858,7 +5456,7 @@ function gatherViewRepo(repositoryRoot, viewDir, entry) {
4858
5456
  return { path: entry.path, reachable: false };
4859
5457
  }
4860
5458
  const linkName = basename5(repoReal);
4861
- const { state, actualTarget } = inspectSymlink(join10(viewDir, linkName), expectedTarget);
5459
+ const { state, actualTarget } = inspectSymlink(join11(viewDir, linkName), expectedTarget);
4862
5460
  return {
4863
5461
  path: entry.path,
4864
5462
  reachable: true,
@@ -4872,7 +5470,7 @@ function applyViewPlan(viewDir, toCreate) {
4872
5470
  const created = [];
4873
5471
  const failed = [];
4874
5472
  for (const { name, target } of toCreate) {
4875
- const filePath = join10(viewDir, name);
5473
+ const filePath = join11(viewDir, name);
4876
5474
  try {
4877
5475
  mkdirSync(dirname3(filePath), { recursive: true });
4878
5476
  symlinkSync(target, filePath);
@@ -4887,7 +5485,7 @@ var TOP_LEVEL_INSTRUCTION_FILES_LOWER = new Set(
4887
5485
  INSTRUCTION_FILES.filter((f) => !f.includes("/")).map((f) => f.toLowerCase())
4888
5486
  );
4889
5487
  function classifyViewLink(viewDir, name, rosterRealpaths) {
4890
- const filePath = join10(viewDir, name);
5488
+ const filePath = join11(viewDir, name);
4891
5489
  let isLink;
4892
5490
  try {
4893
5491
  isLink = lstatSync(filePath).isSymbolicLink();
@@ -4916,7 +5514,7 @@ function classifyViewLink(viewDir, name, rosterRealpaths) {
4916
5514
  if (!isDir) {
4917
5515
  return { target, kind: existsSync2(resolved) ? "non-repo" : "broken" };
4918
5516
  }
4919
- return { target, kind: existsSync2(join10(resolved, ".git")) ? "repo" : "non-repo" };
5517
+ return { target, kind: existsSync2(join11(resolved, ".git")) ? "repo" : "non-repo" };
4920
5518
  }
4921
5519
  function gatherExistingViewLinks(viewDir, rosterRealpaths) {
4922
5520
  let names;
@@ -4941,7 +5539,7 @@ function pruneViewLinks(viewDir, toPrune, rosterRealpaths) {
4941
5539
  const pruned = [];
4942
5540
  const failed = [];
4943
5541
  for (const { name } of toPrune) {
4944
- const filePath = join10(viewDir, name);
5542
+ const filePath = join11(viewDir, name);
4945
5543
  const c = classifyViewLink(viewDir, name, rosterRealpaths);
4946
5544
  if (c === null || c.kind !== "repo") {
4947
5545
  failed.push({
@@ -5154,10 +5752,10 @@ async function runProjectPreset(options, ctx = {}) {
5154
5752
  }
5155
5753
  }
5156
5754
  function canonicalFileFor(anchorReal, canonicalName) {
5157
- return join10(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5755
+ return join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5158
5756
  }
5159
5757
  function canonicalLabelFor(canonicalName) {
5160
- return join10("agents", canonicalName, CANONICAL_FILE);
5758
+ return join11("agents", canonicalName, CANONICAL_FILE);
5161
5759
  }
5162
5760
  async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5163
5761
  const declared = {
@@ -5178,13 +5776,13 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5178
5776
  if (real === anchorReal) {
5179
5777
  return { ...declared, isAnchor: true, reachable: true, canonicalPresent: false };
5180
5778
  }
5181
- if (!existsSync2(join10(real, ".git"))) {
5779
+ if (!existsSync2(join11(real, ".git"))) {
5182
5780
  return { ...declared, isAnchor: false, reachable: false, canonicalPresent: false };
5183
5781
  }
5184
5782
  const canonicalName = basename5(real);
5185
5783
  let content;
5186
5784
  try {
5187
- content = await readMarkdownFile4(canonicalFileFor(anchorReal, canonicalName));
5785
+ content = await readMarkdownFile6(canonicalFileFor(anchorReal, canonicalName));
5188
5786
  } catch {
5189
5787
  return {
5190
5788
  ...declared,
@@ -5204,7 +5802,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5204
5802
  canonicalPresent: false
5205
5803
  };
5206
5804
  }
5207
- const section = parseMarkers(content);
5805
+ const section = parseMarkers3(content);
5208
5806
  return {
5209
5807
  ...declared,
5210
5808
  isAnchor: false,
@@ -5267,7 +5865,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
5267
5865
  }
5268
5866
  return { kind: "unreadable", canonicalName: viewName, viewName };
5269
5867
  }
5270
- const section = parseMarkers(content);
5868
+ const section = parseMarkers3(content);
5271
5869
  if (section.kind === "ok") {
5272
5870
  if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
5273
5871
  return { kind: "in-sync", canonicalName: viewName, viewName };
@@ -5293,7 +5891,7 @@ async function applyViewPreset(anchorReal, outcome) {
5293
5891
  }
5294
5892
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5295
5893
  if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
5296
- const existing = await readMarkdownFile4(file);
5894
+ const existing = await readMarkdownFile6(file);
5297
5895
  await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
5298
5896
  }
5299
5897
  async function applyPresetPlan(anchorReal, plan) {
@@ -5307,7 +5905,7 @@ async function applyPresetPlan(anchorReal, plan) {
5307
5905
  }
5308
5906
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5309
5907
  if (plan.action === "create") mkdirSync(dirname3(file), { recursive: true });
5310
- const existing = await readMarkdownFile4(file);
5908
+ const existing = await readMarkdownFile6(file);
5311
5909
  await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
5312
5910
  }
5313
5911
  function presetFailureReason(error) {
@@ -5560,24 +6158,24 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
5560
6158
  const instructionFiles = [];
5561
6159
  for (const name of INSTRUCTION_FILES) {
5562
6160
  try {
5563
- lstatSync(join10(real, name));
6161
+ lstatSync(join11(real, name));
5564
6162
  instructionFiles.push(name);
5565
6163
  } catch {
5566
6164
  }
5567
6165
  }
5568
6166
  let ignored;
5569
6167
  try {
5570
- ignored = new Set(readGitignoreLines(join10(real, ".gitignore")).map((l) => l.trim()));
6168
+ ignored = new Set(readGitignoreLines(join11(real, ".gitignore")).map((l) => l.trim()));
5571
6169
  } catch {
5572
6170
  ignored = /* @__PURE__ */ new Set();
5573
6171
  }
5574
6172
  const gitignorePatterns = INSTRUCTION_FILES.filter((p) => ignored.has(p) || ignored.has(`/${p}`));
5575
- const canonical2 = existsSync2(join10(anchorReal, "agents", canonicalName, CANONICAL_FILE));
6173
+ const canonical2 = existsSync2(join11(anchorReal, "agents", canonicalName, CANONICAL_FILE));
5576
6174
  let viewLink = false;
5577
6175
  const viewPath = manifest.workspace.view;
5578
6176
  if (viewPath !== void 0) {
5579
6177
  try {
5580
- lstatSync(join10(resolveViewDir(repositoryRoot, viewPath), canonicalName));
6178
+ lstatSync(join11(resolveViewDir(repositoryRoot, viewPath), canonicalName));
5581
6179
  viewLink = true;
5582
6180
  } catch {
5583
6181
  }
@@ -5591,11 +6189,11 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
5591
6189
  };
5592
6190
  }
5593
6191
  function teardownExpectedTargets(repoReal, anchorReal, canonicalName) {
5594
- const canonicalFile = join10(anchorReal, "agents", canonicalName, CANONICAL_FILE);
6192
+ const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5595
6193
  return expectedSymlinkTargets(repoReal, canonicalFile);
5596
6194
  }
5597
6195
  function viewLinkPointsAt(viewDir, name, repoReal) {
5598
- const filePath = join10(viewDir, name);
6196
+ const filePath = join11(viewDir, name);
5599
6197
  try {
5600
6198
  if (!lstatSync(filePath).isSymbolicLink()) return false;
5601
6199
  const target = readlinkSync(filePath);
@@ -5606,7 +6204,7 @@ function viewLinkPointsAt(viewDir, name, repoReal) {
5606
6204
  }
5607
6205
  }
5608
6206
  function viewLinkPointsAtPath(viewDir, name, expectedRepoPath) {
5609
- const filePath = join10(viewDir, name);
6207
+ const filePath = join11(viewDir, name);
5610
6208
  try {
5611
6209
  if (!lstatSync(filePath).isSymbolicLink()) return false;
5612
6210
  const target = readlinkSync(filePath);
@@ -5657,7 +6255,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5657
6255
  if (!isAnchor) {
5658
6256
  if (repoReal !== void 0) {
5659
6257
  for (const spec of teardownExpectedTargets(repoReal, anchorReal, canonicalName)) {
5660
- const { state, actualTarget } = inspectSymlink(join10(repoReal, spec.name), spec.target);
6258
+ const { state, actualTarget } = inspectSymlink(join11(repoReal, spec.name), spec.target);
5661
6259
  if (isSelf) {
5662
6260
  if (state !== "missing")
5663
6261
  items.push({
@@ -5694,7 +6292,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5694
6292
  }
5695
6293
  let ignored;
5696
6294
  try {
5697
- ignored = new Set(readGitignoreLines(join10(repoReal, ".gitignore")).map((l) => l.trim()));
6295
+ ignored = new Set(readGitignoreLines(join11(repoReal, ".gitignore")).map((l) => l.trim()));
5698
6296
  for (const p of INSTRUCTION_FILES) {
5699
6297
  if (ignored.has(p) || ignored.has(`/${p}`)) {
5700
6298
  items.push({
@@ -5717,7 +6315,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5717
6315
  const viewPath = manifest.workspace.view;
5718
6316
  if (viewPath !== void 0) {
5719
6317
  const viewDir = resolveViewDir(repositoryRoot, viewPath);
5720
- const linkPath = join10(viewDir, canonicalName);
6318
+ const linkPath = join11(viewDir, canonicalName);
5721
6319
  let isLink = false;
5722
6320
  try {
5723
6321
  isLink = lstatSync(linkPath).isSymbolicLink();
@@ -5743,8 +6341,8 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5743
6341
  else items.push({ kind: "view-symlink", label: canonicalName, state: "removable" });
5744
6342
  }
5745
6343
  }
5746
- const canonicalFile = join10(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5747
- const canonicalLabel = join10("agents", canonicalName, CANONICAL_FILE);
6344
+ const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
6345
+ const canonicalLabel = join11("agents", canonicalName, CANONICAL_FILE);
5748
6346
  let canonicalIsLink = false;
5749
6347
  try {
5750
6348
  canonicalIsLink = lstatSync(canonicalFile).isSymbolicLink();
@@ -5771,7 +6369,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5771
6369
  });
5772
6370
  }
5773
6371
  if (content !== void 0 && content !== "") {
5774
- const section = parseMarkers(content);
6372
+ const section = parseMarkers3(content);
5775
6373
  if (section.kind === "ok" && canonicalShared) {
5776
6374
  items.push({
5777
6375
  kind: "canonical-block",
@@ -5787,7 +6385,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5787
6385
  note: "repo could not be resolved, so ownership cannot be verified (check manually)"
5788
6386
  });
5789
6387
  } else if (section.kind === "ok") {
5790
- const emptyAfter = removeMarkerSection(content, canonicalLabel).trim().length === 0;
6388
+ const emptyAfter = removeMarkerSection2(content, canonicalLabel).trim().length === 0;
5791
6389
  items.push({
5792
6390
  kind: "canonical-block",
5793
6391
  label: canonicalLabel,
@@ -5848,12 +6446,12 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
5848
6446
  );
5849
6447
  for (const item of removable.filter((i) => i.kind === "instruction-symlink")) {
5850
6448
  const expected = expectedByName.get(item.label);
5851
- if (repoReal === null || expected === void 0 || inspectSymlink(join10(repoReal, item.label), expected).state !== "correct") {
6449
+ if (repoReal === null || expected === void 0 || inspectSymlink(join11(repoReal, item.label), expected).state !== "correct") {
5852
6450
  changed(item.label);
5853
6451
  continue;
5854
6452
  }
5855
6453
  try {
5856
- unlinkSync(join10(repoReal, item.label));
6454
+ unlinkSync(join11(repoReal, item.label));
5857
6455
  removed.push(item.label);
5858
6456
  } catch (error) {
5859
6457
  failed.push({ label: item.label, message: failureReason(error) });
@@ -5872,7 +6470,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
5872
6470
  continue;
5873
6471
  }
5874
6472
  try {
5875
- unlinkSync(join10(viewDir, item.label));
6473
+ unlinkSync(join11(viewDir, item.label));
5876
6474
  removed.push(`view/${item.label}`);
5877
6475
  } catch (error) {
5878
6476
  failed.push({ label: `view/${item.label}`, message: failureReason(error) });
@@ -5880,7 +6478,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
5880
6478
  }
5881
6479
  const NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
5882
6480
  for (const item of removable.filter((i) => i.kind === "canonical-block")) {
5883
- const canonicalFile = join10(anchorReal, "agents", canonicalName, CANONICAL_FILE);
6481
+ const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5884
6482
  try {
5885
6483
  if (lstatSync(canonicalFile).isSymbolicLink()) {
5886
6484
  changed(item.label);
@@ -5900,11 +6498,11 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
5900
6498
  }
5901
6499
  try {
5902
6500
  const content = readFileSync(fd, "utf8");
5903
- if (parseMarkers(content).kind !== "ok") {
6501
+ if (parseMarkers3(content).kind !== "ok") {
5904
6502
  changed(item.label);
5905
6503
  continue;
5906
6504
  }
5907
- const next = Buffer.from(removeMarkerSection(content, item.label), "utf8");
6505
+ const next = Buffer.from(removeMarkerSection2(content, item.label), "utf8");
5908
6506
  ftruncateSync(fd, 0);
5909
6507
  writeSync(fd, next, 0, next.length, 0);
5910
6508
  removed.push(item.label);
@@ -6154,12 +6752,12 @@ function gatherRenameWiring(repositoryRoot, manifest, oldBasename) {
6154
6752
  } catch {
6155
6753
  return { canonicalDirOld: false, viewLinkOld: false };
6156
6754
  }
6157
- const canonicalDirOld = existsSync2(join10(anchorReal, "agents", oldBasename));
6755
+ const canonicalDirOld = existsSync2(join11(anchorReal, "agents", oldBasename));
6158
6756
  let viewLinkOld = false;
6159
6757
  const viewPath = manifest.workspace.view;
6160
6758
  if (viewPath !== void 0) {
6161
6759
  try {
6162
- lstatSync(join10(resolveViewDir(repositoryRoot, viewPath), oldBasename));
6760
+ lstatSync(join11(resolveViewDir(repositoryRoot, viewPath), oldBasename));
6163
6761
  viewLinkOld = true;
6164
6762
  } catch {
6165
6763
  }
@@ -6517,7 +7115,7 @@ async function doRunProjectSeedAnchor(options, ctx) {
6517
7115
  console.log("\u2139\uFE0F No repo roster declared \u2014 nothing to seed.");
6518
7116
  return;
6519
7117
  }
6520
- const anchorDoc = join10(repositoryRoot, CANONICAL_FILE);
7118
+ const anchorDoc = join11(repositoryRoot, CANONICAL_FILE);
6521
7119
  if (pathPresent(anchorDoc)) {
6522
7120
  console.log(
6523
7121
  `\u2705 The anchor's own \`${CANONICAL_FILE}\` already exists \u2014 hand-maintained, left untouched.`
@@ -6579,7 +7177,7 @@ function regularFileSpokes(repoReal) {
6579
7177
  const out = [];
6580
7178
  for (const spoke of ["CLAUDE.md", ".github/copilot-instructions.md"]) {
6581
7179
  try {
6582
- const st = lstatSync(join10(repoReal, spoke));
7180
+ const st = lstatSync(join11(repoReal, spoke));
6583
7181
  if (!st.isSymbolicLink() && st.isFile()) out.push(spoke);
6584
7182
  } catch {
6585
7183
  }
@@ -6625,8 +7223,8 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6625
7223
  };
6626
7224
  }
6627
7225
  const isAnchor = argReal === anchorReal;
6628
- const reachable = existsSync2(join10(argReal, ".git"));
6629
- const canonicalFile = join10(anchorReal, "agents", canonicalName, CANONICAL_FILE);
7226
+ const reachable = existsSync2(join11(argReal, ".git"));
7227
+ const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
6630
7228
  return {
6631
7229
  path,
6632
7230
  declared,
@@ -6635,13 +7233,13 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6635
7233
  reachable,
6636
7234
  canonicalName,
6637
7235
  ...viewCanonicalName !== void 0 ? { viewCanonicalName } : {},
6638
- agentsState: inspectAgentsState(join10(argReal, CANONICAL_FILE)),
7236
+ agentsState: inspectAgentsState(join11(argReal, CANONICAL_FILE)),
6639
7237
  canonicalExists: pathPresent(canonicalFile),
6640
7238
  regularSpokes: regularFileSpokes(argReal)
6641
7239
  };
6642
7240
  }
6643
7241
  function relocateAgentsFile(repoReal, canonicalFile) {
6644
- const agentsFile = join10(repoReal, CANONICAL_FILE);
7242
+ const agentsFile = join11(repoReal, CANONICAL_FILE);
6645
7243
  try {
6646
7244
  mkdirSync(dirname3(canonicalFile), { recursive: true });
6647
7245
  } catch (error) {
@@ -6675,7 +7273,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
6675
7273
  if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
6676
7274
  return { kind: "unreadable", viewName };
6677
7275
  }
6678
- const section = parseMarkers(content);
7276
+ const section = parseMarkers3(content);
6679
7277
  if (section.kind === "ok") return { kind: "already-marked", viewName };
6680
7278
  if (section.kind === "no_markers") {
6681
7279
  const block = renderViewPresetBlock({
@@ -6696,7 +7294,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
6696
7294
  isLink = false;
6697
7295
  }
6698
7296
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
6699
- const existing = await readMarkdownFile4(file);
7297
+ const existing = await readMarkdownFile6(file);
6700
7298
  await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
6701
7299
  }
6702
7300
  async function doRunProjectRetrofit(repo, options, ctx) {
@@ -6758,7 +7356,7 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6758
7356
  let failure;
6759
7357
  let partial = false;
6760
7358
  if (options.apply === true && plan.action === "relocate" && argReal !== void 0) {
6761
- const canonicalFile = join10(anchorReal, "agents", plan.canonicalName, CANONICAL_FILE);
7359
+ const canonicalFile = join11(anchorReal, "agents", plan.canonicalName, CANONICAL_FILE);
6762
7360
  const res = relocateAgentsFile(argReal, canonicalFile);
6763
7361
  if (res.ok) {
6764
7362
  applied = true;
@@ -6959,119 +7557,7 @@ function renderProjectRetrofit(result) {
6959
7557
 
6960
7558
  // src/commands/protocol.ts
6961
7559
  import { readFile as readFile4 } from "fs/promises";
6962
- import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers3, readMarkdownFile as readMarkdownFile6 } from "@basou/core";
6963
-
6964
- // src/lib/context-channel.ts
6965
- import { homedir as homedir7 } from "os";
6966
- import { join as join11 } from "path";
6967
- import {
6968
- ORIENTATION_END,
6969
- ORIENTATION_START,
6970
- parseMarkers as parseMarkers2,
6971
- readMarkdownFile as readMarkdownFile5,
6972
- removeMarkerSection as removeMarkerSection2
6973
- } from "@basou/core";
6974
- var CODEX_TARGET_PATH = join11(homedir7(), ".codex", "AGENTS.md");
6975
- var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
6976
- var ORIENTATION_MANAGED_NOTE = "<!-- Managed by basou: 'basou refresh' regenerates everything between the BASOU:ORIENTATION markers with the workspace's current position. This block is transient \u2014 it changes every refresh; do not edit it. -->";
6977
- function buildTargetBody(existing, block, markers) {
6978
- const wrapped = `${markers.start}
6979
- ${block}${markers.end}
6980
- `;
6981
- if (existing === null || existing === "") return wrapped;
6982
- const section = parseMarkers2(existing, markers);
6983
- switch (section.kind) {
6984
- case "ok":
6985
- return `${section.before}${markers.start}
6986
- ${block}${markers.end}${section.after}`;
6987
- case "no_markers": {
6988
- const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
6989
- return `${existing}${sep}${wrapped}`;
6990
- }
6991
- default:
6992
- throw new Error(
6993
- "The basou-managed markers in the target are malformed (a marker is missing, duplicated, or out of order). Fix or remove them, then retry."
6994
- );
6995
- }
6996
- }
6997
- async function backupOnce(target, existing) {
6998
- if (existing === null) return;
6999
- const bak = `${target}.basou-bak`;
7000
- const already = await readMarkdownFile5(bak);
7001
- if (already !== null) return;
7002
- await writeFileDurable(bak, existing);
7003
- }
7004
- async function syncMarkerBlock(opts) {
7005
- const { target, markers, block } = opts;
7006
- await assertNotSymlink(target);
7007
- const existing = await readMarkdownFile5(target);
7008
- const newBody = buildTargetBody(existing, block, markers);
7009
- if (newBody === existing) return { action: "unchanged" };
7010
- const hadBlock = existing !== null && parseMarkers2(existing, markers).kind === "ok";
7011
- const action = hadBlock ? "updated" : "installed";
7012
- if (opts.dryRun === true) return { action };
7013
- const recheck = await readMarkdownFile5(target);
7014
- if (recheck !== existing) {
7015
- throw new Error(
7016
- "The target changed during sync; aborting so a concurrent edit is not overwritten. Re-run the command."
7017
- );
7018
- }
7019
- await backupOnce(target, existing);
7020
- await writeFileDurable(target, newBody);
7021
- return { action };
7022
- }
7023
- function assertNoMarkerLine(body, markers) {
7024
- for (const line of body.split(/\r?\n/)) {
7025
- if (line === markers.start || line === markers.end) {
7026
- throw new Error(
7027
- "The content contains a basou marker line, which would corrupt the managed block. Remove that line from the source."
7028
- );
7029
- }
7030
- }
7031
- }
7032
- async function removeMarkerBlock(opts) {
7033
- const { target, markers, fileLabel } = opts;
7034
- await assertNotSymlink(target);
7035
- const existing = await readMarkdownFile5(target);
7036
- if (existing === null) return { removed: false };
7037
- const newBody = removeMarkerSection2(existing, fileLabel, markers);
7038
- if (newBody === existing) return { removed: false };
7039
- if (opts.dryRun === true) return { removed: true };
7040
- const recheck = await readMarkdownFile5(target);
7041
- if (recheck !== existing) {
7042
- throw new Error(
7043
- "The target changed during unsync; aborting so a concurrent edit is not overwritten. Re-run the command."
7044
- );
7045
- }
7046
- await backupOnce(target, existing);
7047
- await writeFileDurable(target, newBody);
7048
- return { removed: true };
7049
- }
7050
- async function syncOrientationChannel(opts) {
7051
- assertNoMarkerLine(opts.body, ORIENTATION_MARKERS);
7052
- const block = `${ORIENTATION_MANAGED_NOTE}
7053
-
7054
- ${opts.body.replace(/\s+$/, "")}
7055
- `;
7056
- return syncMarkerBlock({
7057
- target: opts.target ?? CODEX_TARGET_PATH,
7058
- markers: ORIENTATION_MARKERS,
7059
- block,
7060
- ...opts.dryRun === true ? { dryRun: true } : {}
7061
- });
7062
- }
7063
- async function renderOrientationToCodexChannel(opts) {
7064
- const body = await readMarkdownFile5(opts.orientationPath);
7065
- if (body === null) return null;
7066
- const { action } = await syncOrientationChannel({
7067
- body,
7068
- ...opts.channelPath !== void 0 ? { target: opts.channelPath } : {}
7069
- });
7070
- return {
7071
- action,
7072
- line: `codex channel: orientation ${action} in ${opts.channelPath ?? "~/.codex/AGENTS.md"}`
7073
- };
7074
- }
7560
+ import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile7 } from "@basou/core";
7075
7561
 
7076
7562
  // src/lib/protocols-config.ts
7077
7563
  import { homedir as homedir8 } from "os";
@@ -7155,7 +7641,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
7155
7641
  var MANAGED_NOTE = "<!-- Managed by basou: 'basou protocol sync' regenerates everything between the BASOU:PROTOCOLS markers from ~/.basou/protocols.yaml. Manual edits inside the block are overwritten; edit the source files instead. -->";
7156
7642
  function registerProtocolCommand(program2) {
7157
7643
  const protocol = program2.command("protocol").description("Manage the basou-managed standing-protocol block in the global CLAUDE.md");
7158
- protocol.command("sync").description("Render declared protocols into the global CLAUDE.md (creates/updates the block)").option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
7644
+ protocol.command("sync").description(
7645
+ "Render declared protocols into ~/.claude/CLAUDE.md (creates/updates the block). That file is user-global: Claude Code auto-loads it for every project on the machine, so what the protocols say is in the context of every workspace's sessions \u2014 keep workspace-specific facts out of them."
7646
+ ).option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
7159
7647
  await runProtocolSync(opts);
7160
7648
  });
7161
7649
  protocol.command("list").description("List declared protocols and whether the block is installed").option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
@@ -7254,8 +7742,8 @@ async function doRunProtocolList(options) {
7254
7742
  const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
7255
7743
  const target = options.target ?? DEFAULT_TARGET_PATH;
7256
7744
  const entries = await loadProtocolsConfig(configPath);
7257
- const existing = await readMarkdownFile6(target);
7258
- const installed = existing !== null && parseMarkers3(existing, PROTOCOL_MARKERS).kind === "ok";
7745
+ const existing = await readMarkdownFile7(target);
7746
+ const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
7259
7747
  console.log(`Declared protocols (${entries.length}):`);
7260
7748
  for (const entry of entries) {
7261
7749
  console.log(` - ${entry.title ?? entry.source}`);
@@ -7282,7 +7770,12 @@ async function doRunProtocolUnsync(options) {
7282
7770
  }
7283
7771
 
7284
7772
  // src/commands/refresh.ts
7285
- import { assertBasouRootSafe as assertBasouRootSafe9, basouPaths as basouPaths11, findErrorCode as findErrorCode9 } from "@basou/core";
7773
+ import {
7774
+ assertBasouRootSafe as assertBasouRootSafe9,
7775
+ basouPaths as basouPaths11,
7776
+ findErrorCode as findErrorCode9,
7777
+ readManifest as readManifest7
7778
+ } from "@basou/core";
7286
7779
  import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
7287
7780
 
7288
7781
  // src/commands/refresh-watch.ts
@@ -7436,7 +7929,7 @@ function abortableSleep(ms, signal) {
7436
7929
  }
7437
7930
  function registerRefreshCommand(program2) {
7438
7931
  program2.command("refresh").description(
7439
- "Import all adapters for the project and regenerate handoff + decisions in one step"
7932
+ "Import all adapters for the project and regenerate handoff + decisions in one step. Writes only inside the workspace's .basou/ \u2014 never to a user-global file another project's tool reads (a Codex session gets the position from the SessionStart hook; see `basou hook install codex`)."
7440
7933
  ).option(
7441
7934
  "--project <path>",
7442
7935
  "Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
@@ -7567,24 +8060,23 @@ async function computeRefresh(options, ctx) {
7567
8060
  }
7568
8061
  async function doRunRefresh(options, ctx) {
7569
8062
  const { result, paths } = await computeRefresh(options, ctx);
7570
- const channelLine = options.dryRun === true ? null : await syncCodexOrientationChannel(paths, ctx.codexChannelPath);
8063
+ const reported = { ...result, codexChannel: { status: "retired" } };
7571
8064
  if (options.json === true) {
7572
- console.log(JSON.stringify(result));
8065
+ console.log(JSON.stringify(reported));
7573
8066
  } else {
7574
8067
  printRefreshSummary(result);
7575
- if (channelLine !== null) console.log(channelLine);
8068
+ const line = await retiredChannelNotice(paths);
8069
+ if (line !== null) console.log(line);
7576
8070
  }
7577
- return result;
8071
+ return reported;
7578
8072
  }
7579
- async function syncCodexOrientationChannel(paths, channelPath) {
8073
+ async function retiredChannelNotice(paths) {
7580
8074
  try {
7581
- const rendered = await renderOrientationToCodexChannel({
7582
- orientationPath: paths.files.orientation,
7583
- ...channelPath !== void 0 ? { channelPath } : {}
7584
- });
7585
- return rendered === null ? null : rendered.line;
7586
- } catch (error) {
7587
- return `codex channel skipped: ${error instanceof Error ? error.message : String(error)}`;
8075
+ const manifest = await readManifest7(paths);
8076
+ if (manifest.channels?.codex !== true) return null;
8077
+ return "codex channel: retired \u2014 the manifest's channels.codex is ignored; a Codex session now receives this workspace's position from the SessionStart hook (see `basou hook status codex`), and nothing is written to the user-global ~/.codex/AGENTS.md";
8078
+ } catch {
8079
+ return null;
7588
8080
  }
7589
8081
  }
7590
8082
  function describeImport(outcome) {
@@ -7737,7 +8229,7 @@ import {
7737
8229
  findErrorCode as findErrorCode11,
7738
8230
  findUnbindableRepos,
7739
8231
  parseReviewRecordInput,
7740
- readManifest as readManifest7,
8232
+ readManifest as readManifest8,
7741
8233
  resolveRepoRoot,
7742
8234
  sanitizePath as sanitizePath2
7743
8235
  } from "@basou/core";
@@ -7824,7 +8316,7 @@ async function doRunReviewRecord(options, ctx) {
7824
8316
  }
7825
8317
  const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
7826
8318
  const occurredAt = now.toISOString();
7827
- const manifest = await readManifest7(paths);
8319
+ const manifest = await readManifest8(paths);
7828
8320
  const invocationArgs = options.file !== void 0 ? [
7829
8321
  "--file",
7830
8322
  sanitizePath2(resolve10(cwd, options.file), {
@@ -8162,7 +8654,7 @@ function unattachedLines(u) {
8162
8654
  }
8163
8655
 
8164
8656
  // src/commands/run.ts
8165
- import { mkdir as mkdir2 } from "fs/promises";
8657
+ import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
8166
8658
  import { homedir as homedir11 } from "os";
8167
8659
  import { join as join14 } from "path";
8168
8660
  import {
@@ -8174,11 +8666,12 @@ import {
8174
8666
  codexAdapterMetadata,
8175
8667
  appendChainedEvent as coreAppendChainedEvent2,
8176
8668
  finalizeSessionYaml as finalizeSessionYaml2,
8669
+ findBasouSessionStartHook as findBasouSessionStartHook2,
8177
8670
  getDiff,
8178
8671
  getSnapshot as getSnapshot2,
8179
8672
  overwriteYamlFile as overwriteYamlFile2,
8180
8673
  prefixedUlid as prefixedUlid4,
8181
- readManifest as readManifest8,
8674
+ readManifest as readManifest9,
8182
8675
  readYamlFile as readYamlFile6,
8183
8676
  resolveClaudeCodeCommand,
8184
8677
  resolveCodexCommand,
@@ -8225,7 +8718,7 @@ function runCodex(args, options, ctx = {}) {
8225
8718
  resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
8226
8719
  metadata: codexAdapterMetadata,
8227
8720
  transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
8228
- preSpawn: syncCodexOrientationChannelPreSpawn
8721
+ preSpawn: noteCodexHookStatusPreSpawn
8229
8722
  });
8230
8723
  }
8231
8724
  async function runTrackedTool(args, options, ctx, adapter) {
@@ -8238,7 +8731,7 @@ async function runTrackedTool(args, options, ctx, adapter) {
8238
8731
  const repoRoot = await resolveRepositoryRootForRun(cwd);
8239
8732
  const paths = basouPaths15(repoRoot);
8240
8733
  await assertBasouRootSafe12(paths.root);
8241
- const manifest = await readManifest8(paths);
8734
+ const manifest = await readManifest9(paths);
8242
8735
  const sessionId = prefixedUlid4("ses");
8243
8736
  const sessionDir = join14(paths.sessions, sessionId);
8244
8737
  await mkdir2(sessionDir, { recursive: true });
@@ -8592,22 +9085,25 @@ async function resolveRepositoryRootForRun(cwd) {
8592
9085
  throw error;
8593
9086
  }
8594
9087
  }
8595
- async function syncCodexOrientationChannelPreSpawn(cwd, ctx) {
9088
+ async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
9089
+ const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
9090
+ let location;
8596
9091
  try {
8597
- const root = await resolveBasouRootForCommand(cwd, "run");
8598
- const paths = basouPaths15(root);
8599
- const rendered = await renderOrientationToCodexChannel({
8600
- orientationPath: paths.files.orientation,
8601
- ...ctx.codexChannelPath !== void 0 ? { channelPath: ctx.codexChannelPath } : {}
8602
- });
8603
- return rendered === null ? null : rendered.line;
8604
- } catch {
8605
- return null;
9092
+ location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
9093
+ } catch (error) {
9094
+ if (!(error instanceof Error && error.code === "ENOENT")) return null;
9095
+ location = null;
9096
+ }
9097
+ if (location === null) {
9098
+ return "codex: the basou SessionStart hook is not registered, so this session starts without the workspace's position (`basou hook install codex` registers it once for every workspace)";
8606
9099
  }
9100
+ const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
9101
+ if (trust.status === "trusted" || trust.status === "unknown") return null;
9102
+ return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
8607
9103
  }
8608
9104
 
8609
9105
  // src/commands/session.ts
8610
- import { readFile as readFile6 } from "fs/promises";
9106
+ import { readFile as readFile7 } from "fs/promises";
8611
9107
  import { basename as basename6, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
8612
9108
  import {
8613
9109
  acquireLock as acquireLock6,
@@ -8619,7 +9115,7 @@ import {
8619
9115
  importSessionFromJson as importSessionFromJson2,
8620
9116
  loadSessionEntries as loadSessionEntries2,
8621
9117
  readAllEvents,
8622
- readManifest as readManifest9,
9118
+ readManifest as readManifest10,
8623
9119
  readYamlFile as readYamlFile7,
8624
9120
  rechainSessionInPlace,
8625
9121
  resolveSessionId as resolveSessionId3,
@@ -9031,7 +9527,7 @@ async function doRunSessionImport(options, ctx) {
9031
9527
  const repositoryRoot = await resolveRepositoryRootForSession(cwd, "import");
9032
9528
  const paths = basouPaths16(repositoryRoot);
9033
9529
  await assertWorkspaceInitialized11(paths.root);
9034
- const manifest = await readManifest9(paths);
9530
+ const manifest = await readManifest10(paths);
9035
9531
  const rawBody = await readInputFile(options.from);
9036
9532
  const json = parseJsonStrict(rawBody);
9037
9533
  const parsed = SessionImportPayloadSchema2.safeParse(json);
@@ -9058,7 +9554,7 @@ async function doRunSessionImport(options, ctx) {
9058
9554
  }
9059
9555
  async function readInputFile(path) {
9060
9556
  try {
9061
- return await readFile6(path, "utf8");
9557
+ return await readFile7(path, "utf8");
9062
9558
  } catch (error) {
9063
9559
  if (findErrorCode12(error, "ENOENT")) {
9064
9560
  throw new Error("Import source not found", { cause: error });
@@ -9175,7 +9671,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
9175
9671
  }
9176
9672
  async function readNoteFile(path) {
9177
9673
  try {
9178
- return await readFile6(path, "utf8");
9674
+ return await readFile7(path, "utf8");
9179
9675
  } catch (error) {
9180
9676
  if (findErrorCode12(error, "ENOENT")) {
9181
9677
  throw new Error("Note source not found", { cause: error });
@@ -9413,7 +9909,7 @@ import {
9413
9909
  basouPaths as basouPaths18,
9414
9910
  buildStatusSnapshot,
9415
9911
  findErrorCode as findErrorCode14,
9416
- readManifest as readManifest10,
9912
+ readManifest as readManifest11,
9417
9913
  resolveRepositoryRoot as resolveRepositoryRoot12,
9418
9914
  writeStatus
9419
9915
  } from "@basou/core";
@@ -9444,7 +9940,7 @@ async function doRunStatus(options, ctx) {
9444
9940
  }
9445
9941
  let manifest;
9446
9942
  try {
9447
- manifest = await readManifest10(paths);
9943
+ manifest = await readManifest11(paths);
9448
9944
  } catch (error) {
9449
9945
  if (findErrorCode14(error, "ENOENT")) {
9450
9946
  throw new Error("Workspace not initialized. Run 'basou init' first.");
@@ -9496,7 +9992,7 @@ function formatVersionGateMessage(error) {
9496
9992
  }
9497
9993
 
9498
9994
  // src/commands/task.ts
9499
- import { readFile as readFile7 } from "fs/promises";
9995
+ import { readFile as readFile8 } from "fs/promises";
9500
9996
  import { join as join16 } from "path";
9501
9997
  import {
9502
9998
  archiveTask,
@@ -9510,7 +10006,7 @@ import {
9510
10006
  loadSessionEntries as loadSessionEntries3,
9511
10007
  loadTaskEntries,
9512
10008
  prefixedUlid as prefixedUlid5,
9513
- readManifest as readManifest11,
10009
+ readManifest as readManifest12,
9514
10010
  readTaskFile,
9515
10011
  readTaskFileWithArchiveFallback,
9516
10012
  reconcileAllTasks,
@@ -9637,7 +10133,7 @@ async function doRunTaskNew(options, ctx) {
9637
10133
  });
9638
10134
  return;
9639
10135
  }
9640
- const manifest = await readManifest11(paths);
10136
+ const manifest = await readManifest12(paths);
9641
10137
  const result = await createTaskWithEvent({
9642
10138
  mode: "ad-hoc",
9643
10139
  paths,
@@ -9985,7 +10481,7 @@ async function doRunTaskStatus(taskIdInput, newStatusInput, options, ctx) {
9985
10481
  });
9986
10482
  return;
9987
10483
  }
9988
- const manifest = await readManifest11(paths);
10484
+ const manifest = await readManifest12(paths);
9989
10485
  const result = await updateTaskStatusWithEvent({
9990
10486
  mode: "ad-hoc",
9991
10487
  paths,
@@ -10038,7 +10534,7 @@ async function doRunTaskReconcile(options, ctx) {
10038
10534
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "reconcile");
10039
10535
  const paths = basouPaths19(repositoryRoot);
10040
10536
  await assertWorkspaceInitialized13(paths.root);
10041
- const manifest = await readManifest11(paths);
10537
+ const manifest = await readManifest12(paths);
10042
10538
  const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
10043
10539
  const write = options.write === true;
10044
10540
  const verbose = isVerbose(options);
@@ -10218,7 +10714,7 @@ async function doRunTaskRefreshLinkage(taskIdInput, options, ctx) {
10218
10714
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "refresh-linkage");
10219
10715
  const paths = basouPaths19(repositoryRoot);
10220
10716
  await assertWorkspaceInitialized13(paths.root);
10221
- const manifest = await readManifest11(paths);
10717
+ const manifest = await readManifest12(paths);
10222
10718
  const taskId = await resolveTaskId2(paths, taskIdInput);
10223
10719
  const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
10224
10720
  const write = options.write === true;
@@ -10298,7 +10794,7 @@ async function doRunTaskEdit(taskIdInput, options, ctx) {
10298
10794
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "edit");
10299
10795
  const paths = basouPaths19(repositoryRoot);
10300
10796
  await assertWorkspaceInitialized13(paths.root);
10301
- const manifest = await readManifest11(paths);
10797
+ const manifest = await readManifest12(paths);
10302
10798
  const taskId = await resolveTaskId2(paths, taskIdInput);
10303
10799
  const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
10304
10800
  const occurredAt = now.toISOString();
@@ -10354,7 +10850,7 @@ async function doRunTaskDelete(taskIdInput, options, ctx) {
10354
10850
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "delete");
10355
10851
  const paths = basouPaths19(repositoryRoot);
10356
10852
  await assertWorkspaceInitialized13(paths.root);
10357
- const manifest = await readManifest11(paths);
10853
+ const manifest = await readManifest12(paths);
10358
10854
  const taskId = await resolveTaskId2(paths, taskIdInput);
10359
10855
  if (options.yes !== true) {
10360
10856
  await confirmDestructiveAction("delete", taskId);
@@ -10399,7 +10895,7 @@ async function doRunTaskArchive(taskIdInput, options, ctx) {
10399
10895
  const repositoryRoot = await resolveRepositoryRootForTask(cwd, "archive");
10400
10896
  const paths = basouPaths19(repositoryRoot);
10401
10897
  await assertWorkspaceInitialized13(paths.root);
10402
- const manifest = await readManifest11(paths);
10898
+ const manifest = await readManifest12(paths);
10403
10899
  const taskId = await resolveTaskId2(paths, taskIdInput);
10404
10900
  if (options.yes !== true) {
10405
10901
  await confirmDestructiveAction("archive", taskId);
@@ -10441,8 +10937,8 @@ async function confirmDestructiveAction(action, taskId) {
10441
10937
  }
10442
10938
  }
10443
10939
  async function readSingleLineFromStdin() {
10444
- const { createInterface: createInterface2 } = await import("readline/promises");
10445
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
10940
+ const { createInterface: createInterface3 } = await import("readline/promises");
10941
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
10446
10942
  try {
10447
10943
  const line = await rl.question("");
10448
10944
  return line;
@@ -10511,7 +11007,7 @@ function parsePositiveInt2(raw) {
10511
11007
  }
10512
11008
  async function readDescriptionFile(path) {
10513
11009
  try {
10514
- return await readFile7(path, "utf8");
11010
+ return await readFile8(path, "utf8");
10515
11011
  } catch (error) {
10516
11012
  if (findErrorCode15(error, "ENOENT")) {
10517
11013
  throw new Error("Description source not found", { cause: error });
@@ -10715,30 +11211,342 @@ async function assertWorkspaceInitialized14(basouRoot) {
10715
11211
 
10716
11212
  // src/commands/view.ts
10717
11213
  import { spawn } from "child_process";
10718
- import { createHash } from "crypto";
10719
- import { basename as basename8, resolve as resolve13 } from "path";
11214
+ import { createHash as createHash2 } from "crypto";
11215
+ import { basename as basename9, resolve as resolve13 } from "path";
10720
11216
  import {
10721
11217
  assertBasouRootSafe as assertBasouRootSafe18,
10722
- basouPaths as basouPaths21,
11218
+ basouPaths as basouPaths22,
10723
11219
  findErrorCode as findErrorCode18,
10724
- readManifest as readManifest14,
10725
- resolveRepositoryRoot as resolveRepositoryRoot14
11220
+ readManifest as readManifest16,
11221
+ resolveRepositoryRoot as resolveRepositoryRoot15
10726
11222
  } from "@basou/core";
10727
11223
  import { InvalidArgumentError as InvalidArgumentError7 } from "commander";
10728
11224
 
11225
+ // src/lib/portfolio-coverage.ts
11226
+ import { createReadStream as createReadStream2 } from "fs";
11227
+ import { readdir as readdir3, stat as stat6 } from "fs/promises";
11228
+ import { homedir as homedir12 } from "os";
11229
+ import { basename as basename7, dirname as dirname4, join as join17 } from "path";
11230
+ import { createInterface as createInterface2 } from "readline";
11231
+ import { basouPaths as basouPaths21, readManifest as readManifest13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
11232
+ function uncapturedTotal(result) {
11233
+ return result.groups.reduce((sum, g) => sum + g.logs, 0) + result.unplaceable;
11234
+ }
11235
+ async function checkPortfolioCoverage(workspaces, ctx = {}) {
11236
+ const { roots, inertWorkspaces } = await collectDeclaredRoots(workspaces);
11237
+ const listedDirs = new Set([...roots].map((root) => encodeProjectDir(root)));
11238
+ const claudeProjectsDir = ctx.claudeProjectsDir ?? join17(homedir12(), ".claude", "projects");
11239
+ const codexSessionsDir = ctx.codexSessionsDir ?? join17(homedir12(), ".codex", "sessions");
11240
+ const tally = new Tally(roots);
11241
+ const absentTrees = [];
11242
+ const claudeFiles = await listClaudeTranscripts(claudeProjectsDir);
11243
+ if (claudeFiles === void 0) absentTrees.push(claudeProjectsDir);
11244
+ else {
11245
+ for (const file of claudeFiles) {
11246
+ await tally.add(
11247
+ file,
11248
+ "claude-code",
11249
+ claudeTranscriptCwd,
11250
+ listedDirs.has(basename7(dirname4(file)))
11251
+ );
11252
+ }
11253
+ }
11254
+ const codexFiles = await listCodexRollouts(codexSessionsDir);
11255
+ if (codexFiles === void 0) absentTrees.push(codexSessionsDir);
11256
+ else {
11257
+ for (const file of codexFiles) await tally.add(file, "codex", codexRolloutCwd, true);
11258
+ }
11259
+ return tally.finish(absentTrees, inertWorkspaces);
11260
+ }
11261
+ async function collectDeclaredRoots(workspaces) {
11262
+ const roots = /* @__PURE__ */ new Set();
11263
+ const inertWorkspaces = [];
11264
+ for (const ws of workspaces) {
11265
+ let importRoot;
11266
+ try {
11267
+ importRoot = await resolveRepositoryRoot14(ws.repoRoot);
11268
+ } catch {
11269
+ inertWorkspaces.push({ path: ws.repoRoot, reason: "not_a_git_repo" });
11270
+ continue;
11271
+ }
11272
+ let resolved;
11273
+ try {
11274
+ const manifest = await readManifest13(basouPaths21(importRoot));
11275
+ resolved = resolveSourceRoots({
11276
+ projectFlags: [],
11277
+ manifest,
11278
+ repoRoot: importRoot,
11279
+ cwd: importRoot
11280
+ });
11281
+ } catch (error) {
11282
+ const absent = error instanceof Error && error.message === "YAML file not found";
11283
+ inertWorkspaces.push({
11284
+ path: ws.repoRoot,
11285
+ reason: absent ? "no_store" : "unreadable_store"
11286
+ });
11287
+ continue;
11288
+ }
11289
+ for (const root of resolved) roots.add(root);
11290
+ }
11291
+ return { roots, inertWorkspaces };
11292
+ }
11293
+ var Tally = class {
11294
+ constructor(declaredRoots) {
11295
+ this.declaredRoots = declaredRoots;
11296
+ }
11297
+ declaredRoots;
11298
+ groups = /* @__PURE__ */ new Map();
11299
+ kinds = /* @__PURE__ */ new Map();
11300
+ attributed = 0;
11301
+ scanned = 0;
11302
+ unplaceable = 0;
11303
+ unreadable = 0;
11304
+ /**
11305
+ * Record one source log. `listedByImport` is whether the importer would even
11306
+ * read this file (the Claude directory guard); a cwd match on a file import
11307
+ * never lists is not capture.
11308
+ */
11309
+ async add(file, source, readCwd, listedByImport) {
11310
+ this.scanned++;
11311
+ const cwd = await readCwd(file);
11312
+ if (cwd === null) {
11313
+ this.unreadable++;
11314
+ return;
11315
+ }
11316
+ if (cwd === void 0) {
11317
+ this.unplaceable++;
11318
+ return;
11319
+ }
11320
+ const declared = this.declaredRoots.has(cwd);
11321
+ if (declared && listedByImport) {
11322
+ this.attributed++;
11323
+ return;
11324
+ }
11325
+ const kind = declared ? "dir_not_listed" : enclosingRoot(cwd, this.declaredRoots) !== void 0 ? "below_declared_root" : "no_declared_root";
11326
+ this.kinds.set(cwd, kind);
11327
+ const group = this.groups.get(cwd);
11328
+ if (group === void 0) this.groups.set(cwd, { logs: 1, sources: /* @__PURE__ */ new Set([source]) });
11329
+ else {
11330
+ group.logs++;
11331
+ group.sources.add(source);
11332
+ }
11333
+ }
11334
+ finish(absentTrees, inertWorkspaces) {
11335
+ const groups = [];
11336
+ for (const [cwd, { logs, sources }] of this.groups) {
11337
+ const kind = this.kinds.get(cwd) ?? "no_declared_root";
11338
+ const declaredRoot = kind === "below_declared_root" ? enclosingRoot(cwd, this.declaredRoots) : void 0;
11339
+ groups.push({
11340
+ cwd,
11341
+ logs,
11342
+ sources: [...sources].sort(),
11343
+ kind,
11344
+ ...declaredRoot !== void 0 ? { declaredRoot } : {}
11345
+ });
11346
+ }
11347
+ const rank = (k) => k === "dir_not_listed" ? 0 : k === "below_declared_root" ? 1 : 2;
11348
+ groups.sort((a, b) => {
11349
+ if (a.kind !== b.kind) return rank(a.kind) - rank(b.kind);
11350
+ if (a.logs !== b.logs) return b.logs - a.logs;
11351
+ return a.cwd < b.cwd ? -1 : a.cwd > b.cwd ? 1 : 0;
11352
+ });
11353
+ return {
11354
+ logsScanned: this.scanned,
11355
+ attributed: this.attributed,
11356
+ groups,
11357
+ unplaceable: this.unplaceable,
11358
+ unreadable: this.unreadable,
11359
+ absentTrees,
11360
+ inertWorkspaces
11361
+ };
11362
+ }
11363
+ };
11364
+ function enclosingRoot(cwd, roots) {
11365
+ for (const root of roots) {
11366
+ if (cwd.startsWith(root.endsWith("/") ? root : `${root}/`)) return root;
11367
+ }
11368
+ return void 0;
11369
+ }
11370
+ async function isDirEntry(parent, entry) {
11371
+ if (entry.isDirectory()) return true;
11372
+ if (!entry.isSymbolicLink()) return false;
11373
+ try {
11374
+ return (await stat6(join17(parent, entry.name))).isDirectory();
11375
+ } catch {
11376
+ return false;
11377
+ }
11378
+ }
11379
+ async function listClaudeTranscripts(projectsRoot) {
11380
+ let entries;
11381
+ try {
11382
+ entries = await readdir3(projectsRoot, { withFileTypes: true });
11383
+ } catch {
11384
+ return void 0;
11385
+ }
11386
+ const files = [];
11387
+ for (const entry of entries) {
11388
+ if (!await isDirEntry(projectsRoot, entry)) continue;
11389
+ const full = join17(projectsRoot, entry.name);
11390
+ let names;
11391
+ try {
11392
+ names = await readdir3(full);
11393
+ } catch {
11394
+ continue;
11395
+ }
11396
+ for (const name of names) {
11397
+ if (name.endsWith(".jsonl")) files.push(join17(full, name));
11398
+ }
11399
+ }
11400
+ return files.sort();
11401
+ }
11402
+ async function listCodexRollouts(sessionsRoot) {
11403
+ const found = [];
11404
+ const walk = async (dir) => {
11405
+ let entries;
11406
+ try {
11407
+ entries = await readdir3(dir, { withFileTypes: true });
11408
+ } catch {
11409
+ return;
11410
+ }
11411
+ for (const entry of entries) {
11412
+ const full = join17(dir, entry.name);
11413
+ if (entry.isDirectory()) await walk(full);
11414
+ else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
11415
+ found.push(full);
11416
+ }
11417
+ }
11418
+ };
11419
+ try {
11420
+ await readdir3(sessionsRoot);
11421
+ } catch {
11422
+ return void 0;
11423
+ }
11424
+ await walk(sessionsRoot);
11425
+ return found.sort();
11426
+ }
11427
+ async function claudeTranscriptCwd(file) {
11428
+ const stream = createReadStream2(file, { encoding: "utf8" });
11429
+ const lines = createInterface2({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
11430
+ try {
11431
+ for await (const line of lines) {
11432
+ if (line.length === 0) continue;
11433
+ let record;
11434
+ try {
11435
+ record = JSON.parse(line);
11436
+ } catch {
11437
+ continue;
11438
+ }
11439
+ if (typeof record !== "object" || record === null || Array.isArray(record)) continue;
11440
+ const cwd = record.cwd;
11441
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
11442
+ }
11443
+ return void 0;
11444
+ } catch {
11445
+ return null;
11446
+ } finally {
11447
+ lines.close();
11448
+ stream.destroy();
11449
+ }
11450
+ }
11451
+ async function codexRolloutCwd(file) {
11452
+ const meta = await readRolloutMeta(file);
11453
+ return meta === void 0 ? void 0 : meta.cwd;
11454
+ }
11455
+ var GROUP_LIST_CAP = 10;
11456
+ function groupNote(group) {
11457
+ if (group.kind === "below_declared_root") {
11458
+ return ` \u2014 inside declared root ${group.declaredRoot}, and the recorded cwd must EQUAL a source root`;
11459
+ }
11460
+ if (group.kind === "dir_not_listed") {
11461
+ return " \u2014 this cwd IS a declared root, but the transcript's per-project directory is not one import lists";
11462
+ }
11463
+ return "";
11464
+ }
11465
+ function formatCoverageReport(result) {
11466
+ const lines = [];
11467
+ if (result.logsScanned === 0) {
11468
+ const where = result.absentTrees.length > 0 ? ` (no source logs found: ${result.absentTrees.join(", ")})` : "";
11469
+ lines.push(
11470
+ `Capture coverage: nothing to check \u2014 no native session logs on this machine${where}.`
11471
+ );
11472
+ return [...lines, ...inertLines(result)];
11473
+ }
11474
+ const total = uncapturedTotal(result);
11475
+ const caveats = [];
11476
+ if (result.unreadable > 0) caveats.push(`${result.unreadable} unreadable, verdict unknown`);
11477
+ if (result.absentTrees.length > 0) caveats.push(`not scanned: ${result.absentTrees.join(", ")}`);
11478
+ const caveat = caveats.length > 0 ? ` (${caveats.join("; ")})` : "";
11479
+ if (total === 0 && result.unreadable === 0) {
11480
+ lines.push(
11481
+ `Capture coverage: OK. ${result.logsScanned} source log(s) scanned, all imported by a registered workspace${caveat}.`
11482
+ );
11483
+ return [...lines, ...inertLines(result)];
11484
+ }
11485
+ const pct = Math.round(total / result.logsScanned * 100);
11486
+ lines.push(
11487
+ `Capture coverage: ${total} of ${result.logsScanned} source log(s) (${pct}%) are imported by no registered workspace${caveat}:`
11488
+ );
11489
+ for (const g of result.groups.slice(0, GROUP_LIST_CAP)) {
11490
+ const via = g.sources.join("+");
11491
+ lines.push(` ${String(g.logs).padStart(4)} ${g.cwd} (${via})${groupNote(g)}`);
11492
+ }
11493
+ const rest = result.groups.length - GROUP_LIST_CAP;
11494
+ if (rest > 0) {
11495
+ const restLogs = result.groups.slice(GROUP_LIST_CAP).reduce((sum, g) => sum + g.logs, 0);
11496
+ lines.push(
11497
+ ` \u2026 +${rest} more working director${rest === 1 ? "y" : "ies"} (${restLogs} log(s))`
11498
+ );
11499
+ }
11500
+ if (result.unplaceable > 0) {
11501
+ lines.push(
11502
+ ` ${String(result.unplaceable).padStart(4)} (no directory to name: the log records no cwd import can use, so import drops it)`
11503
+ );
11504
+ }
11505
+ if (result.groups.some((g) => g.kind === "below_declared_root")) {
11506
+ lines.push(
11507
+ "A cwd inside a declared root is dropped by the exact-match rule: the enclosing declaration does not cover it. Declaring that subdirectory itself as a further import.source_roots entry captures it."
11508
+ );
11509
+ }
11510
+ if (result.groups.some((g) => g.kind === "dir_not_listed")) {
11511
+ lines.push(
11512
+ "A transcript whose cwd IS declared but whose per-project directory no declared root encodes to is never listed by the importer. Check that the workspace is registered by the same path spelling the sessions ran in."
11513
+ );
11514
+ }
11515
+ lines.push(
11516
+ "Register the project in a workspace's import.source_roots to start capturing it (registering a path in ~/.basou/portfolio.yaml alone imports nothing \u2014 it only adds the workspace to this view). A scratch directory, a temp path, or a GUI tool's own working directory has no repo to declare and is expected to stay here."
11517
+ );
11518
+ return [...lines, ...inertLines(result)];
11519
+ }
11520
+ function inertLines(result) {
11521
+ if (result.inertWorkspaces.length === 0) return [];
11522
+ const detail = {
11523
+ not_a_git_repo: "not a git repository",
11524
+ no_store: "no .basou store (never initialized)",
11525
+ unreadable_store: "the .basou manifest is unreadable"
11526
+ };
11527
+ const n = result.inertWorkspaces.length;
11528
+ const lines = [
11529
+ `Capture coverage: ${n} registered entr${n === 1 ? "y" : "ies"} import cannot run in, so ${n === 1 ? "it declares" : "they declare"} no source roots:`
11530
+ ];
11531
+ for (const ws of result.inertWorkspaces) {
11532
+ lines.push(` ${ws.path} \u2014 ${detail[ws.reason]}`);
11533
+ }
11534
+ return lines;
11535
+ }
11536
+
10729
11537
  // src/lib/portfolio-safety.ts
10730
11538
  import { execFile } from "child_process";
10731
- import { lstat as lstat2, realpath as realpath2 } from "fs/promises";
10732
- import { isAbsolute as isAbsolute7, join as join17, relative as relative4, resolve as resolve11 } from "path";
11539
+ import { lstat as lstat2, realpath as realpath3 } from "fs/promises";
11540
+ import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
10733
11541
  import { promisify } from "util";
10734
- import { readManifest as readManifest12 } from "@basou/core";
11542
+ import { readManifest as readManifest14 } from "@basou/core";
10735
11543
  var execFileAsync = promisify(execFile);
10736
11544
  function errorCode(error) {
10737
11545
  return error instanceof Error ? error.code : void 0;
10738
11546
  }
10739
11547
  async function canonical(p) {
10740
11548
  try {
10741
- return await realpath2(p);
11549
+ return await realpath3(p);
10742
11550
  } catch {
10743
11551
  return resolve11(p);
10744
11552
  }
@@ -10753,7 +11561,7 @@ function isBasouPath(p) {
10753
11561
  async function inspectRepo(repoPath) {
10754
11562
  let hasEntry = false;
10755
11563
  try {
10756
- await lstat2(join17(repoPath, ".basou"));
11564
+ await lstat2(join18(repoPath, ".basou"));
10757
11565
  hasEntry = true;
10758
11566
  } catch (error) {
10759
11567
  if (errorCode(error) !== "ENOENT") {
@@ -10787,7 +11595,7 @@ async function checkPortfolioSafety(workspaces) {
10787
11595
  let viewPath;
10788
11596
  let isMaster = false;
10789
11597
  try {
10790
- const manifest = await readManifest12(ws.paths);
11598
+ const manifest = await readManifest14(ws.paths);
10791
11599
  sourceRoots = manifest.import?.source_roots ?? [];
10792
11600
  viewPath = manifest.workspace.view;
10793
11601
  isMaster = true;
@@ -10907,7 +11715,7 @@ function formatSafetyReport(result) {
10907
11715
 
10908
11716
  // src/lib/view-server.ts
10909
11717
  import { createServer } from "http";
10910
- import { basename as basename7, join as join18, resolve as resolve12 } from "path";
11718
+ import { basename as basename8, join as join19, resolve as resolve12 } from "path";
10911
11719
  import {
10912
11720
  computeWorkStats as computeWorkStats2,
10913
11721
  enumerateApprovals as enumerateApprovals2,
@@ -10917,8 +11725,8 @@ import {
10917
11725
  loadSessionEntries as loadSessionEntries4,
10918
11726
  loadTaskEntries as loadTaskEntries2,
10919
11727
  readAllEvents as readAllEvents2,
10920
- readManifest as readManifest13,
10921
- readMarkdownFile as readMarkdownFile7,
11728
+ readManifest as readManifest15,
11729
+ readMarkdownFile as readMarkdownFile8,
10922
11730
  readSessionYaml as readSessionYaml3,
10923
11731
  readTaskFile as readTaskFile2,
10924
11732
  renderDecisions as renderDecisions3,
@@ -11873,7 +12681,7 @@ async function captureStaleness(ws, nowIso) {
11873
12681
  async function overview(ws, nowProvider, resolveRemoteUrl) {
11874
12682
  let manifest;
11875
12683
  try {
11876
- manifest = await readManifest13(ws.paths);
12684
+ manifest = await readManifest15(ws.paths);
11877
12685
  } catch (error) {
11878
12686
  if (findErrorCode17(error, "ENOENT")) {
11879
12687
  return { initialized: false, repoRoot: ws.repoRoot };
@@ -11913,7 +12721,7 @@ async function rosterRepos(repoRoot, manifest, resolveRemoteUrl) {
11913
12721
  const remote = await resolveRemoteUrl(abs);
11914
12722
  const url = remote !== void 0 ? toBrowserUrl(remote) : null;
11915
12723
  return {
11916
- name: basename7(abs),
12724
+ name: basename8(abs),
11917
12725
  path: repo.path,
11918
12726
  ...url !== null ? { url } : {},
11919
12727
  ...repo.visibility !== void 0 ? { visibility: repo.visibility } : {}
@@ -11948,7 +12756,7 @@ async function sessionDetail(ws, sessionId) {
11948
12756
  throw error;
11949
12757
  }
11950
12758
  try {
11951
- const events = await readAllEvents2(join18(ws.paths.sessions, sessionId));
12759
+ const events = await readAllEvents2(join19(ws.paths.sessions, sessionId));
11952
12760
  return { session, events };
11953
12761
  } catch {
11954
12762
  return { session, events: [], degraded: true };
@@ -11970,7 +12778,7 @@ async function taskDetail(ws, taskId) {
11970
12778
  }
11971
12779
  }
11972
12780
  async function decisionsView(ws, nowProvider) {
11973
- const fromDisk = await readMarkdownFile7(ws.paths.files.decisions);
12781
+ const fromDisk = await readMarkdownFile8(ws.paths.files.decisions);
11974
12782
  if (fromDisk !== null) {
11975
12783
  return { body: fromDisk, fromDisk: true };
11976
12784
  }
@@ -11993,7 +12801,7 @@ async function approvalsView(ws, nowProvider) {
11993
12801
  return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
11994
12802
  }
11995
12803
  async function handoffView(ws, nowProvider) {
11996
- const fromDisk = await readMarkdownFile7(ws.paths.files.handoff);
12804
+ const fromDisk = await readMarkdownFile8(ws.paths.files.handoff);
11997
12805
  if (fromDisk !== null) {
11998
12806
  return { body: fromDisk, fromDisk: true };
11999
12807
  }
@@ -12103,7 +12911,10 @@ function registerViewCommand(program2) {
12103
12911
  "--workspace <path>",
12104
12912
  "Workspace repo path to include (repeatable; implies portfolio mode; resolved against the cwd)",
12105
12913
  collectPath3
12106
- ).option("--check", "Run the portfolio safety preflight and exit (no server)").option("--skip-safety-check", "Skip the portfolio safety preflight on start (not recommended)").option("-v, --verbose", "Show error causes").action(async (options) => {
12914
+ ).option(
12915
+ "--check",
12916
+ "Run the read-only preflight and exit (no server): the portfolio safety check, plus \u2014 in portfolio mode \u2014 a capture-coverage report of session logs no registered workspace imports"
12917
+ ).option("--skip-safety-check", "Skip the portfolio safety preflight on start (not recommended)").option("-v, --verbose", "Show error causes").action(async (options) => {
12107
12918
  await runView(options);
12108
12919
  });
12109
12920
  }
@@ -12119,10 +12930,19 @@ async function doRunView(options, ctx) {
12119
12930
  const cwd = ctx.cwd ?? process.cwd();
12120
12931
  const workspaceFlags = options.workspace ?? [];
12121
12932
  const isPortfolio = workspaceFlags.length > 0 || options.portfolio === true;
12933
+ const isWholeRegistry = options.portfolio === true && workspaceFlags.length === 0;
12122
12934
  const deps = isPortfolio ? await buildPortfolioDeps(workspaceFlags, ctx, cwd) : await buildSingleDeps(ctx, cwd);
12123
12935
  if (options.check === true) {
12124
12936
  const result = await checkPortfolioSafety(deps.workspaces);
12125
12937
  for (const line of formatSafetyReport(result)) console.log(line);
12938
+ if (isWholeRegistry) {
12939
+ const coverage = await checkPortfolioCoverage(deps.workspaces, {
12940
+ ...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
12941
+ ...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
12942
+ });
12943
+ console.log("");
12944
+ for (const line of formatCoverageReport(coverage)) console.log(line);
12945
+ }
12126
12946
  if (result.findings.length > 0) process.exitCode = 1;
12127
12947
  return;
12128
12948
  }
@@ -12162,7 +12982,7 @@ async function doRunView(options, ctx) {
12162
12982
  }
12163
12983
  async function buildSingleDeps(ctx, cwd) {
12164
12984
  const repositoryRoot = await resolveRepositoryRootForView(cwd);
12165
- const paths = basouPaths21(repositoryRoot);
12985
+ const paths = basouPaths22(repositoryRoot);
12166
12986
  await assertWorkspaceInitialized15(paths.root);
12167
12987
  const entry = await buildWorkspaceEntry(repositoryRoot, ctx);
12168
12988
  return {
@@ -12196,14 +13016,14 @@ async function buildPortfolioDeps(workspaceFlags, ctx, cwd) {
12196
13016
  };
12197
13017
  }
12198
13018
  async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
12199
- const paths = basouPaths21(repoRoot);
13019
+ const paths = basouPaths22(repoRoot);
12200
13020
  const importCtx = {
12201
13021
  cwd: repoRoot,
12202
13022
  ...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
12203
13023
  ...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
12204
13024
  };
12205
13025
  try {
12206
- const manifest = await readManifest14(paths);
13026
+ const manifest = await readManifest16(paths);
12207
13027
  return {
12208
13028
  key: manifest.workspace.id,
12209
13029
  label: labelOverride ?? manifest.workspace.name,
@@ -12215,8 +13035,8 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
12215
13035
  } catch (error) {
12216
13036
  const notFound = error instanceof Error && error.message === "YAML file not found";
12217
13037
  return {
12218
- key: `ws-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
12219
- label: labelOverride ?? basename8(repoRoot),
13038
+ key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
13039
+ label: labelOverride ?? basename9(repoRoot),
12220
13040
  paths,
12221
13041
  repoRoot,
12222
13042
  importCtx,
@@ -12283,7 +13103,7 @@ function waitForShutdown(signal) {
12283
13103
  }
12284
13104
  async function resolveRepositoryRootForView(cwd) {
12285
13105
  try {
12286
- return await resolveRepositoryRoot14(cwd);
13106
+ return await resolveRepositoryRoot15(cwd);
12287
13107
  } catch (error) {
12288
13108
  if (error instanceof Error && error.message === "Not a git repository") {
12289
13109
  throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou view'.", {
@@ -12334,6 +13154,7 @@ function buildProgram() {
12334
13154
  registerReviewGapsCommand(program2);
12335
13155
  registerProjectCommand(program2);
12336
13156
  registerProtocolCommand(program2);
13157
+ registerChannelCommand(program2);
12337
13158
  registerHookCommand(program2);
12338
13159
  return program2;
12339
13160
  }