@basou/cli 0.39.0 → 0.41.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/program.js CHANGED
@@ -699,7 +699,6 @@ async function writeFileDurable(targetPath, content) {
699
699
  // src/lib/context-channel.ts
700
700
  var CODEX_TARGET_PATH = join3(homedir(), ".codex", "AGENTS.md");
701
701
  var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
702
- 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. -->";
703
702
  function buildTargetBody(existing, block, markers) {
704
703
  const wrapped = `${markers.start}
705
704
  ${block}${markers.end}
@@ -711,8 +710,8 @@ ${block}${markers.end}
711
710
  return `${section.before}${markers.start}
712
711
  ${block}${markers.end}${section.after}`;
713
712
  case "no_markers": {
714
- const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
715
- return `${existing}${sep}${wrapped}`;
713
+ const sep2 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
714
+ return `${existing}${sep2}${wrapped}`;
716
715
  }
717
716
  default:
718
717
  throw new Error(
@@ -772,19 +771,6 @@ async function removeMarkerBlock(opts) {
772
771
  await writeFileDurable(target, newBody);
773
772
  return { removed: true };
774
773
  }
775
- async function syncOrientationChannel(opts) {
776
- assertNoMarkerLine(opts.body, ORIENTATION_MARKERS);
777
- const block = `${ORIENTATION_MANAGED_NOTE}
778
-
779
- ${opts.body.replace(/\s+$/, "")}
780
- `;
781
- return syncMarkerBlock({
782
- target: opts.target ?? CODEX_TARGET_PATH,
783
- markers: ORIENTATION_MARKERS,
784
- block,
785
- ...opts.dryRun === true ? { dryRun: true } : {}
786
- });
787
- }
788
774
  async function clearOrientationChannel(opts) {
789
775
  return removeMarkerBlock({
790
776
  target: opts.target ?? CODEX_TARGET_PATH,
@@ -798,29 +784,17 @@ async function clearOrientationChannel(opts) {
798
784
  ...opts.dryRun === true ? { dryRun: true } : {}
799
785
  });
800
786
  }
801
- async function renderOrientationToCodexChannel(opts) {
802
- const body = await readMarkdownFile(opts.orientationPath);
803
- if (body === null) return null;
804
- const { action } = await syncOrientationChannel({
805
- body,
806
- ...opts.channelPath !== void 0 ? { target: opts.channelPath } : {}
807
- });
808
- return {
809
- action,
810
- line: `codex channel: orientation ${action} in ${opts.channelPath ?? "~/.codex/AGENTS.md"}`
811
- };
812
- }
813
787
 
814
788
  // src/commands/channel.ts
815
789
  function registerChannelCommand(program) {
816
790
  const channel = program.command("channel").description(
817
- "Manage the user-global context faces basou renders into \u2014 files every project's AI tool auto-loads (~/.codex/AGENTS.md)"
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"
818
792
  );
819
793
  channel.command("clear").argument(
820
794
  "<face>",
821
795
  "the face to clear: `codex` (the basou:orientation block in ~/.codex/AGENTS.md)"
822
796
  ).description(
823
- "Remove basou's block from a user-global context face, so no workspace's position is left in a file that another project's tool reads"
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"
824
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) => {
825
799
  await runChannelClear(face, opts);
826
800
  });
@@ -859,7 +833,7 @@ async function doRunChannelClear(face, options) {
859
833
  console.log(`[dry-run] Would remove the basou:orientation block from ${label}.`);
860
834
  } else {
861
835
  console.log(
862
- `Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file; the next opted-in \`basou refresh\` renders it again.`
836
+ `Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file, and nothing renders it again.`
863
837
  );
864
838
  }
865
839
  return result;
@@ -2267,340 +2241,357 @@ async function assertWorkspaceInitialized4(basouRoot) {
2267
2241
  }
2268
2242
 
2269
2243
  // src/commands/hook.ts
2270
- import { open as open2, readFile as readFile2, stat as stat3 } from "fs/promises";
2271
- import { homedir as homedir5 } from "os";
2272
- import { join as join7 } from "path";
2244
+ import { open as open2, readFile as readFile3, realpath as realpath3, stat as stat4 } from "fs/promises";
2245
+ import { homedir as homedir7 } from "os";
2246
+ import { join as join9 } from "path";
2273
2247
  import { fileURLToPath } from "url";
2274
2248
  import {
2249
+ buildSessionStartHookCommand,
2275
2250
  buildStopHookCommand,
2276
2251
  DEFAULT_STOP_HOOK_MIN_EDITS,
2277
2252
  evaluateStopHook,
2253
+ findBasouSessionStartHook,
2278
2254
  findBasouStopHookCommand,
2255
+ ORIENTATION_END as ORIENTATION_END2,
2256
+ ORIENTATION_START as ORIENTATION_START2,
2257
+ parseMarkers as parseMarkers2,
2258
+ readMarkdownFile as readMarkdownFile6,
2259
+ removeSessionStartHook,
2279
2260
  removeStopHook,
2261
+ upsertSessionStartHook,
2280
2262
  upsertStopHook
2281
2263
  } from "@basou/core";
2282
- var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
2283
- function registerHookCommand(program) {
2284
- const hook = program.command("hook").description(
2285
- "Claude Code hook handlers (read a hook payload on stdin, emit hook JSON on stdout)"
2286
- );
2287
- hook.command("stop").description(
2288
- "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."
2289
- ).option(
2290
- "--min-edits <n>",
2291
- `Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
2292
- ).option(
2293
- "--block",
2294
- "Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
2295
- ).option(
2296
- "--require-review",
2297
- "Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
2298
- ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
2299
- const minEdits = parseMinEdits(options.minEdits);
2300
- await runHookStop({
2301
- ...minEdits !== void 0 ? { minEdits } : {},
2302
- ...options.block === true ? { block: true } : {},
2303
- ...options.requireReview === true ? { requireReview: true } : {}
2304
- });
2305
- });
2306
- hook.command("install").description(
2307
- "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."
2308
- ).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) => {
2309
- await runHookInstall(opts);
2310
- });
2311
- hook.command("uninstall").description(
2312
- "Remove the basou Stop hook from ~/.claude/settings.json (leaves other hooks intact)"
2313
- ).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) => {
2314
- await runHookUninstall(opts);
2315
- });
2316
- 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) => {
2317
- await runHookStatus(opts);
2318
- });
2319
- }
2320
- var HOOK_STOP_HELP = `
2321
- Register this Stop hook reproducibly with 'basou hook install' (it writes the
2322
- correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
2323
- removes it; 'basou hook status' reports whether it is registered.
2324
-
2325
- On every turn end basou inspects the session transcript. If the session did
2326
- content-substantive work but ran no capture verb ('basou decision capture' /
2327
- 'decision record' / 'note'), it reminds the agent to record the why / next step.
2328
- Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
2329
- answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
2330
- git status) does NOT count.
2331
-
2332
- With --require-review (opt-in, 'basou hook install --require-review') it also
2333
- reminds when the session SHIPPED substantive code (git push / git merge /
2334
- gh pr create|merge) without recording a review ('basou review record'). This
2335
- gate is off by default; when on, its reminder is composed into the same
2336
- envelope as the capture reminder.
2337
2264
 
2338
- By default the reminder is non-blocking: Claude sees it and may act on it or
2339
- stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
2340
- returns decision:block, holding the agent in-turn to act on the reminder; the
2341
- 'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
2342
- single turn. Either way the hook fails open: a bad payload or unreadable
2343
- transcript exits cleanly with no output.
2344
- `;
2345
- async function runHookStop(options, ctx = {}) {
2346
- try {
2347
- await doRunHookStop(options, ctx);
2348
- } catch {
2349
- }
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;
2350
2278
  }
2351
- async function doRunHookStop(options, ctx) {
2352
- const readStdin = ctx.readStdin ?? defaultReadStdin;
2353
- const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
2354
- const write = ctx.write ?? ((text) => void process.stdout.write(text));
2355
- const raw = await readStdin();
2356
- if (raw.trim().length === 0) return;
2357
- let payload;
2358
- try {
2359
- payload = JSON.parse(raw);
2360
- } catch {
2361
- return;
2362
- }
2363
- if (typeof payload !== "object" || payload === null) return;
2364
- const fields = payload;
2365
- if (fields.stop_hook_active === true) return;
2366
- const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
2367
- if (transcriptPath.length === 0) return;
2368
- let transcript;
2369
- try {
2370
- transcript = await readTranscript(transcriptPath);
2371
- } catch {
2372
- return;
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;
2373
2286
  }
2374
- const records = parseTranscript(transcript);
2375
- const evaluation = evaluateStopHook({
2376
- records,
2377
- // stop_hook_active was already handled by the early return above.
2378
- stopHookActive: false,
2379
- ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
2380
- });
2381
- const parts = [];
2382
- if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
2383
- if (options.requireReview === true && evaluation.review.fires) {
2384
- parts.push(evaluation.review.additionalContext);
2287
+ return value;
2288
+ }
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})`;
2385
2381
  }
2386
- if (parts.length === 0) return;
2387
- const reason = parts.join("\n\n");
2388
- const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
2389
- hookSpecificOutput: {
2390
- hookEventName: "Stop",
2391
- additionalContext: reason
2392
- }
2393
- });
2394
- write(`${payloadJson}
2395
- `);
2396
2382
  }
2397
- function parseTranscript(transcript) {
2398
- const records = [];
2399
- for (const line of transcript.split(/\r?\n/)) {
2400
- if (line.trim().length === 0) continue;
2401
- try {
2402
- const parsed = JSON.parse(line);
2403
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
2404
- records.push(parsed);
2383
+
2384
+ // src/lib/foreign-workspace-warn.ts
2385
+ import { realpath as realpath2 } from "fs/promises";
2386
+ import { dirname as dirname2 } from "path";
2387
+ import { readMarkdownFile as readMarkdownFile4 } from "@basou/core";
2388
+
2389
+ // src/lib/foreign-workspace-scan.ts
2390
+ import { homedir as osHomedir } from "os";
2391
+ import { basename as basename3, normalize, sep } from "path";
2392
+ var MIN_TOKEN_LENGTH = 4;
2393
+ function stripTrailingSep(p) {
2394
+ return p.length > 1 && p.endsWith(sep) ? p.slice(0, -sep.length) : p;
2395
+ }
2396
+ function toTildePath(absPath, home) {
2397
+ if (absPath === home) return "~";
2398
+ if (absPath.startsWith(home + sep)) return `~${absPath.slice(home.length)}`;
2399
+ return null;
2400
+ }
2401
+ function encodedSpelling(name) {
2402
+ return name.replace(/[^a-zA-Z0-9]/g, "-");
2403
+ }
2404
+ function directoryNameSpellings(dir) {
2405
+ const paired = /^(.+)-(planning|workspace)$/.exec(dir);
2406
+ const plain = paired !== null ? [`${paired[1]}-planning`, `${paired[1]}-workspace`] : [dir, `${dir}-workspace`];
2407
+ return [...plain, ...plain.map(encodedSpelling)];
2408
+ }
2409
+ function nameTokensFor(workspacePath, home) {
2410
+ const abs = stripTrailingSep(normalize(workspacePath));
2411
+ const tokens = /* @__PURE__ */ new Set([abs]);
2412
+ const tilde = toTildePath(abs, home);
2413
+ if (tilde !== null) tokens.add(tilde);
2414
+ for (const name of directoryNameSpellings(basename3(abs))) tokens.add(name);
2415
+ return [...tokens].filter((t) => t.length >= MIN_TOKEN_LENGTH);
2416
+ }
2417
+ function isSpellingOfSelf(workspacePath, selfPath) {
2418
+ const own = new Set(directoryNameSpellings(basename3(stripTrailingSep(normalize(selfPath)))));
2419
+ return directoryNameSpellings(basename3(stripTrailingSep(normalize(workspacePath)))).some(
2420
+ (s) => own.has(s)
2421
+ );
2422
+ }
2423
+ function scanForeignWorkspaceNames(input) {
2424
+ if (input.text.length === 0 || input.workspacePaths.length === 0) return [];
2425
+ const home = input.homedir ?? osHomedir();
2426
+ const own = input.selfPath === void 0 ? [] : nameTokensFor(input.selfPath, home);
2427
+ const lines = input.text.split("\n");
2428
+ const hits = [];
2429
+ for (const workspacePath of input.workspacePaths) {
2430
+ if (input.selfPath !== void 0 && isSpellingOfSelf(workspacePath, input.selfPath)) continue;
2431
+ const tokens = nameTokensFor(workspacePath, home).filter(
2432
+ (token) => !own.some((ownToken) => ownToken.includes(token))
2433
+ );
2434
+ if (tokens.length === 0) continue;
2435
+ const matched = /* @__PURE__ */ new Set();
2436
+ const matchedLines = [];
2437
+ for (const [index, line] of lines.entries()) {
2438
+ let lineMatched = false;
2439
+ for (const token of tokens) {
2440
+ if (line.includes(token)) {
2441
+ matched.add(token);
2442
+ lineMatched = true;
2443
+ }
2405
2444
  }
2406
- } catch {
2445
+ if (lineMatched) matchedLines.push(index + 1);
2407
2446
  }
2447
+ if (matchedLines.length === 0) continue;
2448
+ hits.push({
2449
+ workspacePath,
2450
+ tokens: [...matched].sort((a, b) => b.length - a.length || a.localeCompare(b)),
2451
+ lines: matchedLines
2452
+ });
2408
2453
  }
2409
- return records;
2454
+ return hits;
2410
2455
  }
2411
- async function defaultReadStdin() {
2412
- if (process.stdin.isTTY === true) return "";
2413
- const chunks = [];
2414
- for await (const chunk of process.stdin) {
2415
- chunks.push(chunk);
2456
+
2457
+ // src/lib/foreign-workspace-warn.ts
2458
+ var MAX_LISTED_LINES = 5;
2459
+ async function canonicalize2(path) {
2460
+ try {
2461
+ return await realpath2(path);
2462
+ } catch {
2463
+ return path;
2416
2464
  }
2417
- return Buffer.concat(chunks).toString("utf8");
2418
2465
  }
2419
- async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
2420
- const { size } = await stat3(path);
2421
- if (size <= maxBytes) return readFile2(path, "utf8");
2422
- const handle = await open2(path, "r");
2466
+ async function findForeignWorkspaceNames(args) {
2467
+ let workspacePaths;
2468
+ let selfPath;
2423
2469
  try {
2424
- const buffer = Buffer.alloc(maxBytes);
2425
- const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
2426
- const text = buffer.subarray(0, bytesRead).toString("utf8");
2427
- const firstNewline = text.indexOf("\n");
2428
- return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
2429
- } finally {
2430
- await handle.close();
2470
+ const workspaces = await loadPortfolioConfig(args.configPath);
2471
+ workspacePaths = await Promise.all(workspaces.map((w) => canonicalize2(w.path)));
2472
+ selfPath = args.selfPath === void 0 ? void 0 : await canonicalize2(args.selfPath);
2473
+ } catch {
2474
+ return null;
2431
2475
  }
2432
- }
2433
- function parseMinEdits(raw) {
2434
- if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
2435
- return Number(raw);
2436
- }
2437
- var DEFAULT_CLAUDE_SETTINGS_PATH = join7(homedir5(), ".claude", "settings.json");
2438
- function resolveCliEntry() {
2439
- return fileURLToPath(import.meta.url);
2440
- }
2441
- function normalizeInstallOptions(raw) {
2442
- const out = {};
2443
- if (raw.block === true) out.block = true;
2444
- if (raw.requireReview === true) out.requireReview = true;
2445
- if (raw.settings !== void 0) out.settings = raw.settings;
2446
- if (raw.dryRun === true) out.dryRun = true;
2447
- if (raw.verbose === true) out.verbose = true;
2448
- if (raw.minEdits !== void 0) {
2449
- const parsed = parseMinEdits(raw.minEdits);
2450
- if (parsed === void 0) {
2451
- throw new Error("--min-edits must be a non-negative integer.");
2476
+ const hits = scanForeignWorkspaceNames({
2477
+ text: args.text,
2478
+ workspacePaths,
2479
+ selfPath
2480
+ });
2481
+ if (hits.length === 0) return null;
2482
+ const lines = [...new Set(hits.flatMap((h) => h.lines))].sort((a, b) => a - b);
2483
+ return { workspaceCount: hits.length, lines };
2484
+ }
2485
+ function describeForeignWorkspaceLines(lines) {
2486
+ const listed = lines.slice(0, MAX_LISTED_LINES).join(", ");
2487
+ const rest = lines.length - MAX_LISTED_LINES;
2488
+ const numbers = rest > 0 ? `${listed} and ${rest} more` : listed;
2489
+ return `${lines.length === 1 ? "line" : "lines"} ${numbers}`;
2490
+ }
2491
+ function positionForeignWorkspaceWarning(report, where) {
2492
+ const subject = report.workspaceCount === 1 ? "names another registered workspace" : `names ${report.workspaceCount} other registered workspaces`;
2493
+ return `basou: this workspace's position ${subject} (${where}, ${describeForeignWorkspaceLines(report.lines)}). The position is handed to the agent session running in this workspace, so those names travel with it. Advisory only: nothing was withheld.`;
2494
+ }
2495
+ function protocolForeignWorkspaceWarning(report) {
2496
+ const subject = report.workspaceCount === 1 ? "names a registered workspace" : `names ${report.workspaceCount} registered workspaces`;
2497
+ return `basou: the protocol block ${subject} (block ${describeForeignWorkspaceLines(report.lines)}, counted from the block's first line, not the file's). Standing protocols are written to the user-global CLAUDE.md, which every project on this machine loads, so a workspace-specific name there reaches every workspace's sessions. Only the basou-managed block is checked; the rest of that file is not basou's to inspect. Advisory only: nothing was withheld.`;
2498
+ }
2499
+ async function warnIfPositionNamesOtherWorkspaces(args) {
2500
+ try {
2501
+ const body = await readMarkdownFile4(args.paths.files.orientation);
2502
+ if (body === null) return;
2503
+ const report = await findForeignWorkspaceNames({
2504
+ text: body,
2505
+ selfPath: dirname2(args.paths.root),
2506
+ configPath: args.configPath
2507
+ });
2508
+ if (report !== null) {
2509
+ console.error(positionForeignWorkspaceWarning(report, args.paths.files.orientation));
2452
2510
  }
2453
- out.minEdits = parsed;
2511
+ } catch {
2454
2512
  }
2455
- return out;
2456
2513
  }
2457
- async function readSettings(path) {
2514
+
2515
+ // src/commands/orient.ts
2516
+ import {
2517
+ assertBasouRootSafe as assertBasouRootSafe7,
2518
+ basouPaths as basouPaths8,
2519
+ findErrorCode as findErrorCode6,
2520
+ renderOrientation as renderOrientation2,
2521
+ writeMarkdownFile as writeMarkdownFile4
2522
+ } from "@basou/core";
2523
+
2524
+ // src/lib/hosts-config.ts
2525
+ import { homedir as homedir5 } from "os";
2526
+ import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
2527
+ import { readYamlFile as readYamlFile4 } from "@basou/core";
2528
+ var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
2529
+ function expandTilde2(p) {
2530
+ if (p === "~") return homedir5();
2531
+ if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
2532
+ return p;
2533
+ }
2534
+ function isRecord2(value) {
2535
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2536
+ }
2537
+ async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
2458
2538
  let raw;
2459
2539
  try {
2460
- raw = await readFile2(path, "utf8");
2540
+ raw = await readYamlFile4(configPath);
2461
2541
  } catch (error) {
2462
- if (error instanceof Error && error.code === "ENOENT") {
2463
- return { raw: null, parsed: void 0 };
2542
+ if (error instanceof Error && error.message === "YAML file not found") {
2543
+ return null;
2544
+ }
2545
+ if (error instanceof Error && error.message === "Failed to parse YAML content") {
2546
+ throw new Error("~/.basou/hosts.yaml is not valid YAML.");
2464
2547
  }
2465
2548
  throw error;
2466
2549
  }
2467
- if (raw.trim().length === 0) return { raw, parsed: void 0 };
2468
- try {
2469
- return { raw, parsed: JSON.parse(raw) };
2470
- } catch (error) {
2471
- throw new Error(
2472
- "The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
2473
- {
2474
- cause: error
2475
- }
2476
- );
2550
+ if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
2551
+ throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
2477
2552
  }
2478
- }
2479
- async function backupSettingsOnce(path, raw) {
2480
- if (raw === null) return;
2481
- const bak = `${path}.basou-bak`;
2482
- try {
2483
- await stat3(bak);
2484
- return;
2485
- } catch (error) {
2486
- if (!(error instanceof Error && error.code === "ENOENT")) throw error;
2553
+ const seenPaths = /* @__PURE__ */ new Set();
2554
+ const seenLabels = /* @__PURE__ */ new Set();
2555
+ const result = [];
2556
+ for (const entry of raw.hosts) {
2557
+ if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
2558
+ throw new Error("Each host needs a non-empty string 'label'.");
2559
+ }
2560
+ const label = entry.label.trim();
2561
+ if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
2562
+ throw new Error("Each host needs a non-empty string 'path'.");
2563
+ }
2564
+ const expanded = expandTilde2(entry.path.trim());
2565
+ if (!isAbsolute2(expanded)) {
2566
+ throw new Error("Host paths must be absolute (or start with '~').");
2567
+ }
2568
+ const abs = resolve4(expanded);
2569
+ if (seenPaths.has(abs)) continue;
2570
+ if (seenLabels.has(label)) {
2571
+ throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
2572
+ }
2573
+ seenPaths.add(abs);
2574
+ seenLabels.add(label);
2575
+ result.push({ label, path: abs });
2487
2576
  }
2488
- await writeFileDurable(bak, raw);
2489
- }
2490
- async function runHookInstall(options, ctx = {}) {
2491
- try {
2492
- await doRunHookInstall(normalizeInstallOptions(options), ctx);
2493
- } catch (error) {
2494
- renderCliError(error, { verbose: isVerbose(options) });
2495
- process.exitCode = 1;
2496
- }
2497
- }
2498
- async function doRunHookInstall(options, ctx = {}) {
2499
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2500
- const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
2501
- const command = buildStopHookCommand({
2502
- cliEntry,
2503
- ...options.block === true ? { block: true } : {},
2504
- ...options.requireReview === true ? { requireReview: true } : {},
2505
- ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
2506
- });
2507
- const mode = describeHookMode({
2508
- block: options.block === true,
2509
- review: options.requireReview === true
2510
- });
2511
- await assertNotSymlink(settingsPath);
2512
- const { raw, parsed } = await readSettings(settingsPath);
2513
- const { settings, action } = upsertStopHook(parsed, command);
2514
- const newBody = `${JSON.stringify(settings, null, 2)}
2515
- `;
2516
- if (raw !== null && newBody === raw) {
2517
- console.log(`The basou Stop hook is already registered (${mode}); no change.`);
2518
- return;
2519
- }
2520
- if (options.dryRun === true) {
2521
- console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
2522
- return;
2523
- }
2524
- const recheck = await readSettings(settingsPath);
2525
- if (recheck.raw !== raw) {
2526
- throw new Error(
2527
- "The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
2528
- );
2529
- }
2530
- await backupSettingsOnce(settingsPath, raw);
2531
- await writeFileDurable(settingsPath, newBody);
2532
- console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
2533
- }
2534
- async function runHookUninstall(options) {
2535
- try {
2536
- await doRunHookUninstall(normalizeInstallOptions(options));
2537
- } catch (error) {
2538
- renderCliError(error, { verbose: isVerbose(options) });
2539
- process.exitCode = 1;
2540
- }
2541
- }
2542
- async function doRunHookUninstall(options) {
2543
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2544
- await assertNotSymlink(settingsPath);
2545
- const { raw, parsed } = await readSettings(settingsPath);
2546
- if (raw === null) {
2547
- console.log("No settings.json; nothing to remove.");
2548
- return;
2549
- }
2550
- const { settings, action } = removeStopHook(parsed);
2551
- if (action === "absent") {
2552
- console.log("No basou Stop hook found; nothing removed.");
2553
- return;
2554
- }
2555
- const newBody = `${JSON.stringify(settings, null, 2)}
2556
- `;
2557
- if (options.dryRun === true) {
2558
- console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
2559
- return;
2560
- }
2561
- const recheck = await readSettings(settingsPath);
2562
- if (recheck.raw !== raw) {
2563
- throw new Error(
2564
- "The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
2565
- );
2566
- }
2567
- await backupSettingsOnce(settingsPath, raw);
2568
- await writeFileDurable(settingsPath, newBody);
2569
- console.log("Removed the basou Stop hook from settings.json.");
2570
- }
2571
- async function runHookStatus(options) {
2572
- try {
2573
- await doRunHookStatus(normalizeInstallOptions(options));
2574
- } catch (error) {
2575
- renderCliError(error, { verbose: isVerbose(options) });
2576
- process.exitCode = 1;
2577
- }
2578
- }
2579
- async function doRunHookStatus(options) {
2580
- const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
2581
- const { parsed } = await readSettings(settingsPath);
2582
- const command = findBasouStopHookCommand(parsed);
2583
- if (command === null) {
2584
- console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
2585
- return;
2586
- }
2587
- const mode = describeHookMode({
2588
- block: / --block\b/.test(command),
2589
- review: / --require-review\b/.test(command)
2590
- });
2591
- console.log(`basou Stop hook: registered, ${mode}.`);
2592
- }
2593
- function describeHookMode(tiers) {
2594
- const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
2595
- const gates = tiers.review ? "capture + review" : "capture";
2596
- return `${enforcement}, ${gates}`;
2577
+ return result;
2597
2578
  }
2598
2579
 
2580
+ // src/lib/provenance-actions.ts
2581
+ import {
2582
+ readMarkdownFile as readMarkdownFile5,
2583
+ renderDecisions as renderDecisions2,
2584
+ renderHandoff as renderHandoff2,
2585
+ renderOrientation,
2586
+ renderWithMarkers as renderWithMarkers3,
2587
+ writeMarkdownFile as writeMarkdownFile3
2588
+ } from "@basou/core";
2589
+
2599
2590
  // src/commands/import.ts
2600
2591
  import { createReadStream } from "fs";
2601
- import { readdir, readFile as readFile3, rm, stat as stat4 } from "fs/promises";
2592
+ import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
2602
2593
  import { homedir as homedir6 } from "os";
2603
- import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve4 } from "path";
2594
+ import { basename as basename4, dirname as dirname3, join as join8, resolve as resolve5 } from "path";
2604
2595
  import { createInterface } from "readline";
2605
2596
  import {
2606
2597
  AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
@@ -2670,10 +2661,10 @@ function resolveSourceRoots(args) {
2670
2661
  const { projectFlags, manifest, repoRoot, cwd } = args;
2671
2662
  let resolved;
2672
2663
  if (projectFlags.length > 0) {
2673
- resolved = projectFlags.map((p) => resolve4(cwd, p));
2664
+ resolved = projectFlags.map((p) => resolve5(cwd, p));
2674
2665
  } else {
2675
2666
  const roots = manifest.import?.source_roots;
2676
- resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve4(repoRoot, r)) : [repoRoot];
2667
+ resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
2677
2668
  }
2678
2669
  return [...new Set(resolved)];
2679
2670
  }
@@ -2690,7 +2681,7 @@ async function doRunImportClaudeCode(options, ctx) {
2690
2681
  const files = await selectTranscriptFiles(projectsRoot, projectPaths, options);
2691
2682
  const projectSet = new Set(projectPaths);
2692
2683
  const candidates = files.map((file) => {
2693
- const externalId = basename3(file, ".jsonl");
2684
+ const externalId = basename4(file, ".jsonl");
2694
2685
  return {
2695
2686
  externalId,
2696
2687
  sourcePath: file,
@@ -2780,7 +2771,7 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
2780
2771
  files: payload.session.related_files ?? [],
2781
2772
  workingDirectory: payload.session.working_directory,
2782
2773
  sourceRoots: projectPaths,
2783
- masterRoot: dirname2(paths.root),
2774
+ masterRoot: dirname3(paths.root),
2784
2775
  extraInRoot: AGENT_INFRA_DIRS2
2785
2776
  });
2786
2777
  if (scope.outOfRoot.length > 0) crossProject.push({ externalId, outOfRoot: scope.outOfRoot });
@@ -2996,7 +2987,7 @@ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
2996
2987
  }
2997
2988
  async function pathExists(file) {
2998
2989
  try {
2999
- await stat4(file);
2990
+ await stat3(file);
3000
2991
  return true;
3001
2992
  } catch (error) {
3002
2993
  if (findErrorCode5(error, "ENOENT")) return false;
@@ -3005,7 +2996,7 @@ async function pathExists(file) {
3005
2996
  }
3006
2997
  async function statSize(file) {
3007
2998
  try {
3008
- return (await stat4(file)).size;
2999
+ return (await stat3(file)).size;
3009
3000
  } catch (error) {
3010
3001
  if (findErrorCode5(error, "ENOENT")) return void 0;
3011
3002
  throw error;
@@ -3091,7 +3082,7 @@ async function readFirstLine(file) {
3091
3082
  async function readJsonlRecords(file) {
3092
3083
  let buffer;
3093
3084
  try {
3094
- buffer = await readFile3(file);
3085
+ buffer = await readFile2(file);
3095
3086
  } catch (error) {
3096
3087
  if (findErrorCode5(error, "ENOENT")) {
3097
3088
  throw new Error("Source log not found", { cause: error });
@@ -3220,569 +3211,1163 @@ async function assertWorkspaceInitialized5(basouRoot) {
3220
3211
  }
3221
3212
  }
3222
3213
 
3223
- // src/commands/init.ts
3224
- import { basename as basename4, relative, resolve as resolve5 } from "path";
3225
- import {
3226
- appendBasouGitignore,
3227
- createManifest,
3228
- ensureBasouDirectory,
3229
- resolveRepositoryRoot as resolveRepositoryRoot7,
3230
- writeManifest
3231
- } from "@basou/core";
3232
- function collectValue(value, previous) {
3233
- return [...previous, value];
3234
- }
3235
- function registerInitCommand(program) {
3236
- program.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(
3237
- "--repo-url <url>",
3238
- "Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
3239
- ).option(
3240
- "--source-root <path>",
3241
- "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
3242
- collectValue,
3243
- []
3244
- ).option(
3245
- "--local-only",
3246
- "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
3247
- ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
3248
- await runInit(options);
3249
- });
3250
- }
3251
- async function runInit(options, ctx = {}) {
3252
- try {
3253
- await doRunInit(options, ctx);
3254
- } catch (error) {
3255
- renderCliError(error, { verbose: isVerbose(options) });
3256
- process.exitCode = 1;
3257
- }
3258
- }
3259
- async function doRunInit(options, ctx) {
3260
- const cwd = ctx.cwd ?? process.cwd();
3261
- const repositoryRoot = await resolveRepositoryRootForInit(cwd);
3262
- const workspaceName = options.name ?? basename4(repositoryRoot);
3263
- if (options.repoUrl !== void 0) {
3264
- console.error(
3265
- "Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
3266
- );
3267
- }
3268
- const sourceRoots = (options.sourceRoot ?? []).map((p) => {
3269
- const rel = relative(repositoryRoot, resolve5(cwd, p));
3270
- return rel === "" ? "." : rel;
3214
+ // src/lib/provenance-actions.ts
3215
+ async function captureImportJson(fn) {
3216
+ const stdout = [];
3217
+ const originalLog = console.log;
3218
+ const originalError = console.error;
3219
+ console.log = ((...args) => {
3220
+ stdout.push(args.map((a) => String(a)).join(" "));
3271
3221
  });
3272
- const paths = await ensureBasouDirectory(repositoryRoot);
3273
- const manifest = createManifest({
3274
- workspaceName,
3275
- ...options.projectName !== void 0 ? { projectName: options.projectName } : {},
3276
- ...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
3277
- ...sourceRoots.length > 0 ? { sourceRoots } : {}
3222
+ console.error = (() => {
3278
3223
  });
3279
- await writeManifest(paths, manifest, { force: options.force === true });
3280
3224
  try {
3281
- await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
3282
- } catch (error) {
3283
- renderGitignoreWarning(error, isVerbose(options));
3225
+ await fn();
3226
+ } finally {
3227
+ console.log = originalLog;
3228
+ console.error = originalError;
3284
3229
  }
3285
- console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
3286
- }
3287
- function renderGitignoreWarning(error, verbose) {
3288
- const baseMessage = error instanceof Error ? error.message : String(error);
3289
- console.error(
3290
- `Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
3291
- );
3292
- if (verbose && error instanceof Error) {
3293
- const label = extractCauseLabel(error);
3294
- if (label !== void 0) console.error(`Caused by: ${label}`);
3230
+ for (let i = stdout.length - 1; i >= 0; i--) {
3231
+ const line = stdout[i];
3232
+ if (line === void 0) continue;
3233
+ try {
3234
+ const parsed = JSON.parse(line);
3235
+ if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
3236
+ return parsed;
3237
+ }
3238
+ } catch {
3239
+ }
3295
3240
  }
3241
+ throw new Error("Import produced no parseable result");
3296
3242
  }
3297
- async function resolveRepositoryRootForInit(cwd) {
3243
+ function readCount(value) {
3244
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
3245
+ }
3246
+ function isMissingSourceDir(error) {
3247
+ if (!(error instanceof Error)) return false;
3248
+ return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
3249
+ }
3250
+ async function runImport(adapter, fn) {
3298
3251
  try {
3299
- return await resolveRepositoryRoot7(cwd);
3252
+ const json = await captureImportJson(fn);
3253
+ return {
3254
+ adapter,
3255
+ status: "ran",
3256
+ importedCount: readCount(json.imported_count),
3257
+ replacedCount: readCount(json.replaced_count),
3258
+ reimportedCount: readCount(json.reimported_count),
3259
+ skippedNoAction: readCount(json.skipped_no_action),
3260
+ skippedAlreadyImported: readCount(json.skipped_already_imported),
3261
+ skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3262
+ skippedDecreased: readCount(json.skipped_decreased),
3263
+ skippedDuplicate: readCount(json.skipped_duplicate),
3264
+ skippedUnverifiable: readCount(json.skipped_unverifiable),
3265
+ eventTotal: readCount(json.event_total),
3266
+ dryRun: json.dry_run === true
3267
+ };
3300
3268
  } catch (error) {
3301
- if (error instanceof Error && error.message === "Not a git repository") {
3302
- throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
3303
- cause: error
3304
- });
3269
+ if (isMissingSourceDir(error)) {
3270
+ return { adapter, status: "skipped", reason: "no source logs for this project" };
3305
3271
  }
3306
3272
  throw error;
3307
3273
  }
3308
3274
  }
3309
-
3310
- // src/commands/note.ts
3311
- import {
3312
- acquireLock as acquireLock4,
3313
- appendEventToExistingSession as appendEventToExistingSession2,
3314
- assertBasouRootSafe as assertBasouRootSafe7,
3315
- basouPaths as basouPaths8,
3316
- createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
3317
- findErrorCode as findErrorCode6,
3318
- readManifest as readManifest5,
3319
- resolveSessionId as resolveSessionId2
3320
- } from "@basou/core";
3321
- import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
3322
- var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
3323
- "list",
3324
- "ls",
3325
- "show",
3326
- "get",
3327
- "add",
3328
- "new",
3329
- "edit",
3330
- "rm",
3331
- "remove",
3332
- "delete",
3333
- "help"
3334
- ]);
3335
- var LABEL_BODY_MAX = 80;
3336
- var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
3337
- function registerNoteCommand(program) {
3338
- program.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
3339
- "--session <session_id>",
3340
- "Attach to an existing session; otherwise an ad-hoc session is created"
3341
- ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
3342
- await runNote(body, options);
3275
+ function importOptions(options) {
3276
+ return {
3277
+ all: true,
3278
+ json: true,
3279
+ ...options.project !== void 0 ? { project: options.project } : {},
3280
+ ...options.force === true ? { force: true } : {},
3281
+ ...options.dryRun === true ? { dryRun: true } : {}
3282
+ };
3283
+ }
3284
+ function importClaudeCode(options, ctx) {
3285
+ return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3286
+ }
3287
+ function importCodex(options, ctx) {
3288
+ return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
3289
+ }
3290
+ async function regenerateHandoff(paths, nowIso, callbacks) {
3291
+ const result = await renderHandoff2({ paths, nowIso, ...callbacks });
3292
+ const existing = await readMarkdownFile5(paths.files.handoff);
3293
+ await writeMarkdownFile3(
3294
+ paths.files.handoff,
3295
+ renderWithMarkers3(existing, result.body, "handoff.md")
3296
+ );
3297
+ return {
3298
+ sessionCount: result.sessionCount,
3299
+ taskCount: result.taskCount,
3300
+ decisionCount: result.decisionCount,
3301
+ pendingApprovalsCount: result.pendingApprovalsCount
3302
+ };
3303
+ }
3304
+ async function regenerateDecisions(paths, nowIso, callbacks) {
3305
+ const result = await renderDecisions2({ paths, nowIso, ...callbacks });
3306
+ const existing = await readMarkdownFile5(paths.files.decisions);
3307
+ await writeMarkdownFile3(
3308
+ paths.files.decisions,
3309
+ renderWithMarkers3(existing, result.body, "decisions.md")
3310
+ );
3311
+ return { decisionCount: result.decisionCount };
3312
+ }
3313
+ async function regenerateOrientation(paths, nowIso, callbacks) {
3314
+ const result = await renderOrientation({ paths, nowIso, ...callbacks });
3315
+ await writeMarkdownFile3(paths.files.orientation, `${result.body}
3316
+ `);
3317
+ return {
3318
+ sessionCount: result.sessionCount,
3319
+ inFlightTaskCount: result.inFlightTaskCount,
3320
+ pendingApprovalsCount: result.pendingApprovalsCount,
3321
+ suspectCount: result.suspectCount
3322
+ };
3323
+ }
3324
+ async function refreshAll(args) {
3325
+ const { options, ctx, paths, nowIso } = args;
3326
+ const dryRun = options.dryRun === true;
3327
+ const claudeCode = await importClaudeCode(options, ctx);
3328
+ const codex = await importCodex(options, ctx);
3329
+ if (dryRun) {
3330
+ const skipped = { status: "skipped", reason: "dry-run" };
3331
+ return {
3332
+ claudeCode,
3333
+ codex,
3334
+ handoff: skipped,
3335
+ decisions: skipped,
3336
+ orientation: skipped,
3337
+ dryRun
3338
+ };
3339
+ }
3340
+ const handoffCounts = await regenerateHandoff(paths, nowIso);
3341
+ const decisionCounts = await regenerateDecisions(paths, nowIso);
3342
+ const scoped = options.project !== void 0 && options.project.length > 0;
3343
+ const orientationCounts = await regenerateOrientation(
3344
+ paths,
3345
+ nowIso,
3346
+ scoped ? {} : {
3347
+ staleness: {
3348
+ newSessions: 0,
3349
+ updatedSessions: 0,
3350
+ unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
3351
+ }
3352
+ }
3353
+ );
3354
+ return {
3355
+ claudeCode,
3356
+ codex,
3357
+ handoff: { status: "generated", ...handoffCounts },
3358
+ decisions: { status: "generated", ...decisionCounts },
3359
+ orientation: { status: "generated", ...orientationCounts },
3360
+ dryRun
3361
+ };
3362
+ }
3363
+ function wouldImport(outcome) {
3364
+ return outcome.status === "ran" ? outcome.importedCount : 0;
3365
+ }
3366
+ function wouldUpdate(outcome) {
3367
+ return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
3368
+ }
3369
+ function wouldBlock(outcome) {
3370
+ return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
3371
+ }
3372
+ async function probeStaleness(args) {
3373
+ try {
3374
+ const dry = await refreshAll({
3375
+ options: { dryRun: true },
3376
+ ctx: args.ctx,
3377
+ paths: args.paths,
3378
+ nowIso: args.nowIso
3379
+ });
3380
+ return {
3381
+ newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
3382
+ updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
3383
+ unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
3384
+ };
3385
+ } catch {
3386
+ return null;
3387
+ }
3388
+ }
3389
+
3390
+ // src/commands/orient.ts
3391
+ function registerOrientCommand(program) {
3392
+ program.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(
3393
+ "--refresh",
3394
+ "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
3395
+ ).option("-v, --verbose", "Show error causes").action(async (opts) => {
3396
+ await runOrient(opts);
3343
3397
  });
3344
3398
  }
3345
- async function runNote(body, options, ctx = {}) {
3399
+ async function runOrient(options, ctx = {}) {
3346
3400
  try {
3347
- await doRunNote(body, options, ctx);
3401
+ await doRunOrient(options, ctx);
3348
3402
  } catch (error) {
3349
- renderCliError(error, {
3350
- verbose: isVerbose(options),
3351
- classifiers: [failedToFinalizeClassifier]
3352
- });
3403
+ renderCliError(error, { verbose: isVerbose(options) });
3353
3404
  process.exitCode = 1;
3354
3405
  }
3355
3406
  }
3356
- async function doRunNote(body, options, ctx) {
3357
- if (body.trim().length === 0) {
3358
- throw new Error("Note body must not be empty");
3407
+ async function doRunOrient(options, ctx) {
3408
+ const result = await renderOrientationForCwd(options, ctx);
3409
+ if (options.quiet === true) {
3410
+ console.log(
3411
+ `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
3412
+ );
3413
+ } else {
3414
+ console.log(result.body);
3359
3415
  }
3360
- const reserved = body.trim().toLowerCase();
3361
- if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
3362
- throw new Error(
3363
- `'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>"\`).`
3416
+ await warnIfPositionNamesOtherWorkspaces2(result, ctx);
3417
+ }
3418
+ async function warnIfPositionNamesOtherWorkspaces2(result, ctx) {
3419
+ const report = await findForeignWorkspaceNames({
3420
+ text: result.body,
3421
+ selfPath: result.workspaceRoot,
3422
+ configPath: ctx.portfolioConfigPath
3423
+ });
3424
+ if (report !== null) {
3425
+ console.error(
3426
+ positionForeignWorkspaceWarning(report, basouPaths8(result.workspaceRoot).files.orientation)
3364
3427
  );
3365
3428
  }
3429
+ }
3430
+ async function renderOrientationForCwd(options, ctx) {
3366
3431
  const cwd = ctx.cwd ?? process.cwd();
3367
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
3432
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
3433
+ return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
3434
+ }
3435
+ async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
3368
3436
  const paths = basouPaths8(repositoryRoot);
3369
3437
  await assertWorkspaceInitialized6(paths.root);
3370
- const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
3371
- const occurredAt = now.toISOString();
3372
- if (options.session !== void 0) {
3373
- const sessionId = await resolveSessionId2(paths, options.session);
3374
- const sesId = sessionId;
3375
- const sessionLock = await acquireLock4(paths, "session", sesId);
3376
- let result;
3377
- try {
3378
- result = await appendEventToExistingSession2({
3379
- paths,
3380
- sessionId: sesId,
3381
- eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
3382
- });
3383
- } finally {
3384
- await sessionLock.release();
3438
+ const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
3439
+ const probeCtx = { cwd: repositoryRoot };
3440
+ if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
3441
+ if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
3442
+ if (options.refresh === true) {
3443
+ await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
3444
+ }
3445
+ const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
3446
+ let federatedRoots = [];
3447
+ try {
3448
+ const hosts = await loadHostsConfig(ctx.hostsConfigPath);
3449
+ if (hosts !== null) {
3450
+ federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
3385
3451
  }
3386
- printNoteResult(options, {
3387
- mode: "attached",
3388
- sessionId,
3389
- eventId: result.eventId,
3390
- sessionStatus: result.sessionStatus,
3391
- body
3452
+ } catch (error) {
3453
+ console.error(
3454
+ `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
3455
+ );
3456
+ }
3457
+ const result = await renderOrientation2({
3458
+ paths,
3459
+ nowIso,
3460
+ staleness,
3461
+ verbose: options.verbose === true,
3462
+ federatedRoots,
3463
+ onWarning: (w, sid) => printReplayWarning(w, sid),
3464
+ onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
3465
+ onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
3466
+ onHostUnavailable: (host, error) => console.error(
3467
+ `basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
3468
+ )
3469
+ });
3470
+ if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
3471
+ `);
3472
+ return {
3473
+ body: result.body,
3474
+ workspaceRoot: repositoryRoot,
3475
+ sessionCount: result.sessionCount,
3476
+ inFlightTaskCount: result.inFlightTaskCount,
3477
+ pendingApprovalsCount: result.pendingApprovalsCount,
3478
+ suspectCount: result.suspectCount
3479
+ };
3480
+ }
3481
+ async function assertWorkspaceInitialized6(basouRoot) {
3482
+ try {
3483
+ await assertBasouRootSafe7(basouRoot);
3484
+ } catch (error) {
3485
+ if (findErrorCode6(error, "ENOENT")) {
3486
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
3487
+ }
3488
+ throw error;
3489
+ }
3490
+ }
3491
+
3492
+ // src/commands/hook.ts
3493
+ var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
3494
+ function registerHookCommand(program) {
3495
+ const hook = program.command("hook").description(
3496
+ "Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
3497
+ );
3498
+ hook.command("session-start").description(
3499
+ "SessionStart hook (Codex; Claude Code too): print the current position of the workspace the session was opened in (read from the payload's cwd) so the tool adds it to that session's context. Stays silent outside a registered basou workspace, and when the position names another registered workspace; never fails the session."
3500
+ ).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
3501
+ await runHookSessionStart();
3502
+ });
3503
+ hook.command("stop").description(
3504
+ "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."
3505
+ ).option(
3506
+ "--min-edits <n>",
3507
+ `Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
3508
+ ).option(
3509
+ "--block",
3510
+ "Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
3511
+ ).option(
3512
+ "--require-review",
3513
+ "Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
3514
+ ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
3515
+ const minEdits = parseMinEdits(options.minEdits);
3516
+ await runHookStop({
3517
+ ...minEdits !== void 0 ? { minEdits } : {},
3518
+ ...options.block === true ? { block: true } : {},
3519
+ ...options.requireReview === true ? { requireReview: true } : {}
3392
3520
  });
3521
+ });
3522
+ hook.command("install [target]").description(
3523
+ "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."
3524
+ ).option(
3525
+ "--block",
3526
+ "claude: register the blocking (opt-in enforcement) form instead of advisory"
3527
+ ).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(
3528
+ "--codex-config <path>",
3529
+ "codex: override the Codex config.toml path (intended for tests)"
3530
+ ).option(
3531
+ "--codex-face <path>",
3532
+ "codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
3533
+ ).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3534
+ await dispatchHookTarget(target, opts, {
3535
+ claude: () => runHookInstall(opts),
3536
+ codex: () => runCodexHookInstall(opts)
3537
+ });
3538
+ });
3539
+ hook.command("uninstall [target]").description(
3540
+ "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."
3541
+ ).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) => {
3542
+ await dispatchHookTarget(target, opts, {
3543
+ claude: () => runHookUninstall(opts),
3544
+ codex: () => runCodexHookUninstall(opts)
3545
+ });
3546
+ });
3547
+ hook.command("status [target]").description(
3548
+ "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."
3549
+ ).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(
3550
+ "--codex-config <path>",
3551
+ "codex: override the Codex config.toml path (intended for tests)"
3552
+ ).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3553
+ await dispatchHookTarget(target, opts, {
3554
+ claude: () => runHookStatus(opts),
3555
+ codex: () => runCodexHookStatus(opts)
3556
+ });
3557
+ });
3558
+ }
3559
+ async function dispatchHookTarget(target, options, handlers) {
3560
+ const resolved = target ?? "claude";
3561
+ if (resolved !== "claude" && resolved !== "codex") {
3562
+ renderCliError(
3563
+ new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
3564
+ { verbose: isVerbose(options) }
3565
+ );
3566
+ process.exitCode = 1;
3393
3567
  return;
3394
3568
  }
3395
- const manifest = await readManifest5(paths);
3396
- const adHoc = await createAdHocSessionWithEvent2({
3397
- paths,
3398
- manifest,
3399
- label: buildAdHocLabel2(body),
3400
- occurredAt,
3401
- sessionSource: "human",
3402
- workingDirectory: repositoryRoot,
3403
- invocation: {
3404
- command: "basou note",
3405
- args: [body]
3406
- },
3407
- targetEventBuilders: [
3408
- (sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
3409
- ]
3569
+ await handlers[resolved]();
3570
+ }
3571
+ var HOOK_SESSION_START_HELP = `
3572
+ Register this hook reproducibly with 'basou hook install codex' (it writes the
3573
+ correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
3574
+ removes it; 'basou hook status codex' reports whether it is registered and
3575
+ whether Codex has trusted it.
3576
+
3577
+ Codex runs the hook when a session starts and passes the session's cwd on stdin.
3578
+ basou resolves the workspace from that cwd (a member repo resolves to its
3579
+ planning master, a workspace view to its master) and prints the workspace's
3580
+ current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
3581
+ session's context as developer text. The position is computed at that moment
3582
+ from that cwd and stored nowhere: a Codex opened in another workspace gets that
3583
+ workspace's position, and one opened outside any basou workspace (or before the
3584
+ desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
3585
+ user-global hook serves every workspace without any workspace's position ever
3586
+ being written where another workspace's session would read it.
3587
+
3588
+ The hook stays silent in one more case: when the position it would print names
3589
+ ANOTHER registered workspace (a recorded path under it, a captured decision that
3590
+ mentions it). 'basou orient' and 'basou refresh' report that as a stderr
3591
+ advisory the operator can read; a hook has no reader for stderr and its stdout
3592
+ becomes the session's trusted context, so it withholds the position instead.
3593
+ Run 'basou refresh' to see which lines are responsible.
3594
+
3595
+ Claude Code's SessionStart hook sends the same kind of payload (a JSON object
3596
+ with 'cwd') and adds stdout to context the same way, so a Claude Code user may
3597
+ register this command in ~/.claude/settings.json in place of 'basou orient' to
3598
+ get both gates. basou does not install that one.
3599
+
3600
+ Codex trusts hooks by hash. A newly installed or changed hook is skipped until
3601
+ you review it: the interactive CLI asks at startup ("Hooks need review"), the
3602
+ desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
3603
+ an untrusted hook silently.
3604
+ `;
3605
+ var HOOK_STOP_HELP = `
3606
+ Register this Stop hook reproducibly with 'basou hook install' (it writes the
3607
+ correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
3608
+ removes it; 'basou hook status' reports whether it is registered.
3609
+
3610
+ On every turn end basou inspects the session transcript. If the session did
3611
+ content-substantive work but ran no capture verb ('basou decision capture' /
3612
+ 'decision record' / 'note'), it reminds the agent to record the why / next step.
3613
+ Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
3614
+ answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
3615
+ git status) does NOT count.
3616
+
3617
+ With --require-review (opt-in, 'basou hook install --require-review') it also
3618
+ reminds when the session SHIPPED substantive code (git push / git merge /
3619
+ gh pr create|merge) without recording a review ('basou review record'). This
3620
+ gate is off by default; when on, its reminder is composed into the same
3621
+ envelope as the capture reminder.
3622
+
3623
+ By default the reminder is non-blocking: Claude sees it and may act on it or
3624
+ stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
3625
+ returns decision:block, holding the agent in-turn to act on the reminder; the
3626
+ 'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
3627
+ single turn. Either way the hook fails open: a bad payload or unreadable
3628
+ transcript exits cleanly with no output.
3629
+ `;
3630
+ async function runHookStop(options, ctx = {}) {
3631
+ try {
3632
+ await doRunHookStop(options, ctx);
3633
+ } catch {
3634
+ }
3635
+ }
3636
+ async function doRunHookStop(options, ctx) {
3637
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3638
+ const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
3639
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3640
+ const raw = await readStdin();
3641
+ if (raw.trim().length === 0) return;
3642
+ let payload;
3643
+ try {
3644
+ payload = JSON.parse(raw);
3645
+ } catch {
3646
+ return;
3647
+ }
3648
+ if (typeof payload !== "object" || payload === null) return;
3649
+ const fields = payload;
3650
+ if (fields.stop_hook_active === true) return;
3651
+ const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
3652
+ if (transcriptPath.length === 0) return;
3653
+ let transcript;
3654
+ try {
3655
+ transcript = await readTranscript(transcriptPath);
3656
+ } catch {
3657
+ return;
3658
+ }
3659
+ const records = parseTranscript(transcript);
3660
+ const evaluation = evaluateStopHook({
3661
+ records,
3662
+ // stop_hook_active was already handled by the early return above.
3663
+ stopHookActive: false,
3664
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3410
3665
  });
3411
- printNoteResult(options, {
3412
- mode: "ad-hoc",
3413
- sessionId: adHoc.sessionId,
3414
- eventId: adHoc.targetEventIds[0],
3415
- sessionStatus: "completed",
3416
- body
3666
+ const parts = [];
3667
+ if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
3668
+ if (options.requireReview === true && evaluation.review.fires) {
3669
+ parts.push(evaluation.review.additionalContext);
3670
+ }
3671
+ if (parts.length === 0) return;
3672
+ const reason = parts.join("\n\n");
3673
+ const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
3674
+ hookSpecificOutput: {
3675
+ hookEventName: "Stop",
3676
+ additionalContext: reason
3677
+ }
3417
3678
  });
3679
+ write(`${payloadJson}
3680
+ `);
3418
3681
  }
3419
- function buildNoteEvent(input) {
3420
- return {
3421
- schema_version: "0.1.0",
3422
- id: input.eventId,
3423
- session_id: input.sessionId,
3424
- occurred_at: input.occurredAt,
3425
- source: "local-cli",
3426
- type: "note_added",
3427
- body: input.body,
3428
- // `basou note` is the resume-hint command; mark it so orientation surfaces
3429
- // it as the next step and a plain `basou session note` annotation does not.
3430
- kind: "next_step"
3431
- };
3682
+ async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
3683
+ const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
3684
+ portfolioConfigPath
3685
+ });
3686
+ if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
3687
+ throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
3688
+ }
3689
+ const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
3690
+ const foreign = await findForeignWorkspaceNames({
3691
+ text: rendered.body,
3692
+ selfPath: root,
3693
+ configPath: portfolioConfigPath
3694
+ });
3695
+ if (foreign !== null) {
3696
+ throw new Error("The position names another registered workspace; the hook stays silent.");
3697
+ }
3698
+ return { body: rendered.body };
3432
3699
  }
3433
- function buildAdHocLabel2(body) {
3434
- const oneLine2 = body.replace(/\s+/g, " ").trim();
3435
- const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
3436
- return `Ad-hoc note: ${truncated}`;
3700
+ async function isRegisteredWorkspace(root, portfolioConfigPath) {
3701
+ let entries;
3702
+ try {
3703
+ entries = await loadPortfolioConfig(portfolioConfigPath);
3704
+ } catch {
3705
+ return false;
3706
+ }
3707
+ const rootReal = await realpath3(root).catch(() => root);
3708
+ for (const entry of entries) {
3709
+ const entryReal = await realpath3(entry.path).catch(() => null);
3710
+ if (entryReal !== null && entryReal === rootReal) return true;
3711
+ }
3712
+ return false;
3437
3713
  }
3438
- function parseBody(raw) {
3439
- if (raw.trim().length === 0) {
3440
- throw new InvalidArgumentError2("Note body must not be empty");
3714
+ async function runHookSessionStart(ctx = {}) {
3715
+ try {
3716
+ await doRunHookSessionStart(ctx);
3717
+ } catch {
3718
+ }
3719
+ }
3720
+ async function doRunHookSessionStart(ctx) {
3721
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3722
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3723
+ const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
3724
+ const raw = await readStdin();
3725
+ if (raw.trim().length === 0) return;
3726
+ let payload;
3727
+ try {
3728
+ payload = JSON.parse(raw);
3729
+ } catch {
3730
+ return;
3731
+ }
3732
+ if (typeof payload !== "object" || payload === null) return;
3733
+ const cwd = payload.cwd;
3734
+ if (typeof cwd !== "string" || cwd.length === 0) return;
3735
+ let body;
3736
+ try {
3737
+ body = (await render(cwd)).body;
3738
+ } catch {
3739
+ return;
3441
3740
  }
3442
- return raw;
3741
+ if (body.trim().length === 0) return;
3742
+ write(`${body.replace(/\s+$/, "")}
3743
+ `);
3443
3744
  }
3444
- function printNoteResult(options, result) {
3445
- const sid = shortSessionId(result.sessionId);
3446
- if (options.json === true) {
3447
- console.log(
3448
- JSON.stringify({
3449
- event_id: result.eventId,
3450
- session_id: result.sessionId,
3451
- session_status: result.sessionStatus,
3452
- mode: result.mode,
3453
- body: result.body
3454
- })
3455
- );
3456
- return;
3745
+ function parseTranscript(transcript) {
3746
+ const records = [];
3747
+ for (const line of transcript.split(/\r?\n/)) {
3748
+ if (line.trim().length === 0) continue;
3749
+ try {
3750
+ const parsed = JSON.parse(line);
3751
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3752
+ records.push(parsed);
3753
+ }
3754
+ } catch {
3755
+ }
3457
3756
  }
3458
- if (result.mode === "ad-hoc") {
3459
- console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
3460
- } else {
3461
- console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
3757
+ return records;
3758
+ }
3759
+ async function defaultReadStdin() {
3760
+ if (process.stdin.isTTY === true) return "";
3761
+ const chunks = [];
3762
+ for await (const chunk of process.stdin) {
3763
+ chunks.push(chunk);
3462
3764
  }
3765
+ return Buffer.concat(chunks).toString("utf8");
3463
3766
  }
3464
- async function assertWorkspaceInitialized6(basouRoot) {
3767
+ async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
3768
+ const { size } = await stat4(path);
3769
+ if (size <= maxBytes) return readFile3(path, "utf8");
3770
+ const handle = await open2(path, "r");
3465
3771
  try {
3466
- await assertBasouRootSafe7(basouRoot);
3467
- } catch (error) {
3468
- if (findErrorCode6(error, "ENOENT")) {
3469
- throw new Error("Workspace not initialized. Run 'basou init' first.");
3470
- }
3471
- throw error;
3772
+ const buffer = Buffer.alloc(maxBytes);
3773
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
3774
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
3775
+ const firstNewline = text.indexOf("\n");
3776
+ return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
3777
+ } finally {
3778
+ await handle.close();
3472
3779
  }
3473
3780
  }
3474
-
3475
- // src/commands/orient.ts
3476
- import {
3477
- assertBasouRootSafe as assertBasouRootSafe8,
3478
- basouPaths as basouPaths9,
3479
- findErrorCode as findErrorCode7,
3480
- renderOrientation as renderOrientation2,
3481
- writeMarkdownFile as writeMarkdownFile4
3482
- } from "@basou/core";
3483
-
3484
- // src/lib/hosts-config.ts
3485
- import { homedir as homedir7 } from "os";
3486
- import { isAbsolute as isAbsolute2, join as join9, resolve as resolve6 } from "path";
3487
- import { readYamlFile as readYamlFile4 } from "@basou/core";
3488
- var DEFAULT_HOSTS_CONFIG_PATH = join9(homedir7(), ".basou", "hosts.yaml");
3489
- function expandTilde2(p) {
3490
- if (p === "~") return homedir7();
3491
- if (p.startsWith("~/")) return join9(homedir7(), p.slice(2));
3492
- return p;
3781
+ function parseMinEdits(raw) {
3782
+ if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
3783
+ return Number(raw);
3493
3784
  }
3494
- function isRecord2(value) {
3495
- return typeof value === "object" && value !== null && !Array.isArray(value);
3785
+ var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
3786
+ function resolveCliEntry() {
3787
+ return fileURLToPath(import.meta.url);
3496
3788
  }
3497
- async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
3789
+ function normalizeInstallOptions(raw) {
3790
+ const out = {};
3791
+ if (raw.block === true) out.block = true;
3792
+ if (raw.requireReview === true) out.requireReview = true;
3793
+ if (raw.settings !== void 0) out.settings = raw.settings;
3794
+ if (raw.hooks !== void 0) out.hooks = raw.hooks;
3795
+ if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
3796
+ if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
3797
+ if (raw.dryRun === true) out.dryRun = true;
3798
+ if (raw.verbose === true) out.verbose = true;
3799
+ if (raw.minEdits !== void 0) {
3800
+ const parsed = parseMinEdits(raw.minEdits);
3801
+ if (parsed === void 0) {
3802
+ throw new Error("--min-edits must be a non-negative integer.");
3803
+ }
3804
+ out.minEdits = parsed;
3805
+ }
3806
+ return out;
3807
+ }
3808
+ async function readSettings(path) {
3498
3809
  let raw;
3499
3810
  try {
3500
- raw = await readYamlFile4(configPath);
3811
+ raw = await readFile3(path, "utf8");
3501
3812
  } catch (error) {
3502
- if (error instanceof Error && error.message === "YAML file not found") {
3503
- return null;
3504
- }
3505
- if (error instanceof Error && error.message === "Failed to parse YAML content") {
3506
- throw new Error("~/.basou/hosts.yaml is not valid YAML.");
3813
+ if (error instanceof Error && error.code === "ENOENT") {
3814
+ return { raw: null, parsed: void 0 };
3507
3815
  }
3508
3816
  throw error;
3509
3817
  }
3510
- if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
3511
- throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
3818
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3819
+ try {
3820
+ return { raw, parsed: JSON.parse(raw) };
3821
+ } catch (error) {
3822
+ throw new Error(
3823
+ "The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
3824
+ {
3825
+ cause: error
3826
+ }
3827
+ );
3512
3828
  }
3513
- const seenPaths = /* @__PURE__ */ new Set();
3514
- const seenLabels = /* @__PURE__ */ new Set();
3515
- const result = [];
3516
- for (const entry of raw.hosts) {
3517
- if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
3518
- throw new Error("Each host needs a non-empty string 'label'.");
3519
- }
3520
- const label = entry.label.trim();
3521
- if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
3522
- throw new Error("Each host needs a non-empty string 'path'.");
3523
- }
3524
- const expanded = expandTilde2(entry.path.trim());
3525
- if (!isAbsolute2(expanded)) {
3526
- throw new Error("Host paths must be absolute (or start with '~').");
3527
- }
3528
- const abs = resolve6(expanded);
3529
- if (seenPaths.has(abs)) continue;
3530
- if (seenLabels.has(label)) {
3531
- throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
3532
- }
3533
- seenPaths.add(abs);
3534
- seenLabels.add(label);
3535
- result.push({ label, path: abs });
3829
+ }
3830
+ async function backupSettingsOnce(path, raw) {
3831
+ if (raw === null) return;
3832
+ const bak = `${path}.basou-bak`;
3833
+ try {
3834
+ await stat4(bak);
3835
+ return;
3836
+ } catch (error) {
3837
+ if (!(error instanceof Error && error.code === "ENOENT")) throw error;
3536
3838
  }
3537
- return result;
3839
+ await writeFileDurable(bak, raw);
3538
3840
  }
3539
-
3540
- // src/lib/provenance-actions.ts
3541
- import {
3542
- readMarkdownFile as readMarkdownFile4,
3543
- renderDecisions as renderDecisions2,
3544
- renderHandoff as renderHandoff2,
3545
- renderOrientation,
3546
- renderWithMarkers as renderWithMarkers3,
3547
- writeMarkdownFile as writeMarkdownFile3
3548
- } from "@basou/core";
3549
- async function captureImportJson(fn) {
3550
- const stdout = [];
3551
- const originalLog = console.log;
3552
- const originalError = console.error;
3553
- console.log = ((...args) => {
3554
- stdout.push(args.map((a) => String(a)).join(" "));
3841
+ async function runHookInstall(options, ctx = {}) {
3842
+ try {
3843
+ await doRunHookInstall(normalizeInstallOptions(options), ctx);
3844
+ } catch (error) {
3845
+ renderCliError(error, { verbose: isVerbose(options) });
3846
+ process.exitCode = 1;
3847
+ }
3848
+ }
3849
+ async function doRunHookInstall(options, ctx = {}) {
3850
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3851
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3852
+ const command = buildStopHookCommand({
3853
+ cliEntry,
3854
+ ...options.block === true ? { block: true } : {},
3855
+ ...options.requireReview === true ? { requireReview: true } : {},
3856
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3555
3857
  });
3556
- console.error = (() => {
3858
+ const mode = describeHookMode({
3859
+ block: options.block === true,
3860
+ review: options.requireReview === true
3557
3861
  });
3862
+ await assertNotSymlink(settingsPath);
3863
+ const { raw, parsed } = await readSettings(settingsPath);
3864
+ const { settings, action } = upsertStopHook(parsed, command);
3865
+ const newBody = `${JSON.stringify(settings, null, 2)}
3866
+ `;
3867
+ if (raw !== null && newBody === raw) {
3868
+ console.log(`The basou Stop hook is already registered (${mode}); no change.`);
3869
+ return;
3870
+ }
3871
+ if (options.dryRun === true) {
3872
+ console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
3873
+ return;
3874
+ }
3875
+ const recheck = await readSettings(settingsPath);
3876
+ if (recheck.raw !== raw) {
3877
+ throw new Error(
3878
+ "The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
3879
+ );
3880
+ }
3881
+ await backupSettingsOnce(settingsPath, raw);
3882
+ await writeFileDurable(settingsPath, newBody);
3883
+ console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
3884
+ }
3885
+ async function runHookUninstall(options) {
3558
3886
  try {
3559
- await fn();
3560
- } finally {
3561
- console.log = originalLog;
3562
- console.error = originalError;
3887
+ await doRunHookUninstall(normalizeInstallOptions(options));
3888
+ } catch (error) {
3889
+ renderCliError(error, { verbose: isVerbose(options) });
3890
+ process.exitCode = 1;
3563
3891
  }
3564
- for (let i = stdout.length - 1; i >= 0; i--) {
3565
- const line = stdout[i];
3566
- if (line === void 0) continue;
3567
- try {
3568
- const parsed = JSON.parse(line);
3569
- if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
3570
- return parsed;
3571
- }
3572
- } catch {
3573
- }
3892
+ }
3893
+ async function doRunHookUninstall(options) {
3894
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3895
+ await assertNotSymlink(settingsPath);
3896
+ const { raw, parsed } = await readSettings(settingsPath);
3897
+ if (raw === null) {
3898
+ console.log("No settings.json; nothing to remove.");
3899
+ return;
3900
+ }
3901
+ const { settings, action } = removeStopHook(parsed);
3902
+ if (action === "absent") {
3903
+ console.log("No basou Stop hook found; nothing removed.");
3904
+ return;
3905
+ }
3906
+ const newBody = `${JSON.stringify(settings, null, 2)}
3907
+ `;
3908
+ if (options.dryRun === true) {
3909
+ console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
3910
+ return;
3911
+ }
3912
+ const recheck = await readSettings(settingsPath);
3913
+ if (recheck.raw !== raw) {
3914
+ throw new Error(
3915
+ "The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
3916
+ );
3917
+ }
3918
+ await backupSettingsOnce(settingsPath, raw);
3919
+ await writeFileDurable(settingsPath, newBody);
3920
+ console.log("Removed the basou Stop hook from settings.json.");
3921
+ }
3922
+ async function runHookStatus(options) {
3923
+ try {
3924
+ await doRunHookStatus(normalizeInstallOptions(options));
3925
+ } catch (error) {
3926
+ renderCliError(error, { verbose: isVerbose(options) });
3927
+ process.exitCode = 1;
3574
3928
  }
3575
- throw new Error("Import produced no parseable result");
3576
3929
  }
3577
- function readCount(value) {
3578
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
3930
+ async function doRunHookStatus(options) {
3931
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3932
+ const { parsed } = await readSettings(settingsPath);
3933
+ const command = findBasouStopHookCommand(parsed);
3934
+ if (command === null) {
3935
+ console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
3936
+ return;
3937
+ }
3938
+ const mode = describeHookMode({
3939
+ block: / --block\b/.test(command),
3940
+ review: / --require-review\b/.test(command)
3941
+ });
3942
+ console.log(`basou Stop hook: registered, ${mode}.`);
3579
3943
  }
3580
- function isMissingSourceDir(error) {
3581
- if (!(error instanceof Error)) return false;
3582
- return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
3944
+ function describeHookMode(tiers) {
3945
+ const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
3946
+ const gates = tiers.review ? "capture + review" : "capture";
3947
+ return `${enforcement}, ${gates}`;
3583
3948
  }
3584
- async function runImport(adapter, fn) {
3949
+ var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
3950
+ 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.`;
3951
+ async function faceHasLeftoverOrientationBlock(facePath) {
3585
3952
  try {
3586
- const json = await captureImportJson(fn);
3587
- return {
3588
- adapter,
3589
- status: "ran",
3590
- importedCount: readCount(json.imported_count),
3591
- replacedCount: readCount(json.replaced_count),
3592
- reimportedCount: readCount(json.reimported_count),
3593
- skippedNoAction: readCount(json.skipped_no_action),
3594
- skippedAlreadyImported: readCount(json.skipped_already_imported),
3595
- skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3596
- skippedDecreased: readCount(json.skipped_decreased),
3597
- skippedDuplicate: readCount(json.skipped_duplicate),
3598
- skippedUnverifiable: readCount(json.skipped_unverifiable),
3599
- eventTotal: readCount(json.event_total),
3600
- dryRun: json.dry_run === true
3601
- };
3953
+ const existing = await readMarkdownFile6(facePath);
3954
+ if (existing === null) return false;
3955
+ const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
3956
+ return section.kind !== "no_markers";
3957
+ } catch {
3958
+ return false;
3959
+ }
3960
+ }
3961
+ var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
3962
+ var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
3963
+ async function readHooksFile(path) {
3964
+ let raw;
3965
+ try {
3966
+ raw = await readFile3(path, "utf8");
3602
3967
  } catch (error) {
3603
- if (isMissingSourceDir(error)) {
3604
- return { adapter, status: "skipped", reason: "no source logs for this project" };
3968
+ if (error instanceof Error && error.code === "ENOENT") {
3969
+ return { raw: null, parsed: void 0 };
3605
3970
  }
3606
3971
  throw error;
3607
3972
  }
3973
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3974
+ try {
3975
+ return { raw, parsed: JSON.parse(raw) };
3976
+ } catch (error) {
3977
+ throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
3978
+ cause: error
3979
+ });
3980
+ }
3608
3981
  }
3609
- function importOptions(options) {
3610
- return {
3611
- all: true,
3612
- json: true,
3613
- ...options.project !== void 0 ? { project: options.project } : {},
3614
- ...options.force === true ? { force: true } : {},
3615
- ...options.dryRun === true ? { dryRun: true } : {}
3616
- };
3982
+ 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.";
3983
+ async function runCodexHookInstall(options, ctx = {}) {
3984
+ try {
3985
+ await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
3986
+ } catch (error) {
3987
+ renderCliError(error, { verbose: isVerbose(options) });
3988
+ process.exitCode = 1;
3989
+ }
3617
3990
  }
3618
- function importClaudeCode(options, ctx) {
3619
- return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3991
+ async function doRunCodexHookInstall(options, ctx = {}) {
3992
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3993
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3994
+ const command = buildSessionStartHookCommand({ cliEntry });
3995
+ await assertNotSymlink(hooksPath);
3996
+ const { raw, parsed } = await readHooksFile(hooksPath);
3997
+ const { hooksFile, action } = upsertSessionStartHook(parsed, command);
3998
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
3999
+ `;
4000
+ if (action === "unchanged" || raw !== null && newBody === raw) {
4001
+ console.log("The basou Codex SessionStart hook is already registered; no change.");
4002
+ await reportCodexHookState(hooksPath, hooksFile, options);
4003
+ return;
4004
+ }
4005
+ if (options.dryRun === true) {
4006
+ console.log(
4007
+ `[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
4008
+ );
4009
+ return;
4010
+ }
4011
+ const recheck = await readHooksFile(hooksPath);
4012
+ if (recheck.raw !== raw) {
4013
+ throw new Error(
4014
+ "The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
4015
+ );
4016
+ }
4017
+ await backupSettingsOnce(hooksPath, raw);
4018
+ await writeFileDurable(hooksPath, newBody);
4019
+ console.log(
4020
+ `${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
4021
+ );
4022
+ await reportCodexHookState(hooksPath, hooksFile, options);
3620
4023
  }
3621
- function importCodex(options, ctx) {
3622
- return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
4024
+ async function reportCodexHookState(hooksPath, hooksFile, options) {
4025
+ const location = findBasouSessionStartHook(hooksFile);
4026
+ if (location !== null) {
4027
+ const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
4028
+ console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
4029
+ if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
4030
+ }
4031
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
4032
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
4033
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
4034
+ }
3623
4035
  }
3624
- async function regenerateHandoff(paths, nowIso, callbacks) {
3625
- const result = await renderHandoff2({ paths, nowIso, ...callbacks });
3626
- const existing = await readMarkdownFile4(paths.files.handoff);
3627
- await writeMarkdownFile3(
3628
- paths.files.handoff,
3629
- renderWithMarkers3(existing, result.body, "handoff.md")
3630
- );
3631
- return {
3632
- sessionCount: result.sessionCount,
3633
- taskCount: result.taskCount,
3634
- decisionCount: result.decisionCount,
3635
- pendingApprovalsCount: result.pendingApprovalsCount
3636
- };
4036
+ async function runCodexHookUninstall(options) {
4037
+ try {
4038
+ await doRunCodexHookUninstall(normalizeInstallOptions(options));
4039
+ } catch (error) {
4040
+ renderCliError(error, { verbose: isVerbose(options) });
4041
+ process.exitCode = 1;
4042
+ }
3637
4043
  }
3638
- async function regenerateDecisions(paths, nowIso, callbacks) {
3639
- const result = await renderDecisions2({ paths, nowIso, ...callbacks });
3640
- const existing = await readMarkdownFile4(paths.files.decisions);
3641
- await writeMarkdownFile3(
3642
- paths.files.decisions,
3643
- renderWithMarkers3(existing, result.body, "decisions.md")
3644
- );
3645
- return { decisionCount: result.decisionCount };
4044
+ async function doRunCodexHookUninstall(options) {
4045
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
4046
+ await assertNotSymlink(hooksPath);
4047
+ const { raw, parsed } = await readHooksFile(hooksPath);
4048
+ if (raw === null) {
4049
+ console.log("No hooks.json; nothing to remove.");
4050
+ return;
4051
+ }
4052
+ const { hooksFile, action } = removeSessionStartHook(parsed);
4053
+ if (action === "absent") {
4054
+ console.log("No basou Codex SessionStart hook found; nothing removed.");
4055
+ return;
4056
+ }
4057
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
4058
+ `;
4059
+ if (options.dryRun === true) {
4060
+ console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
4061
+ return;
4062
+ }
4063
+ const recheck = await readHooksFile(hooksPath);
4064
+ if (recheck.raw !== raw) {
4065
+ throw new Error(
4066
+ "The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
4067
+ );
4068
+ }
4069
+ await backupSettingsOnce(hooksPath, raw);
4070
+ await writeFileDurable(hooksPath, newBody);
4071
+ console.log("Removed the basou Codex SessionStart hook from hooks.json.");
3646
4072
  }
3647
- async function regenerateOrientation(paths, nowIso, callbacks) {
3648
- const result = await renderOrientation({ paths, nowIso, ...callbacks });
3649
- await writeMarkdownFile3(paths.files.orientation, `${result.body}
3650
- `);
3651
- return {
3652
- sessionCount: result.sessionCount,
3653
- inFlightTaskCount: result.inFlightTaskCount,
3654
- pendingApprovalsCount: result.pendingApprovalsCount,
3655
- suspectCount: result.suspectCount
3656
- };
4073
+ async function runCodexHookStatus(options) {
4074
+ try {
4075
+ await doRunCodexHookStatus(normalizeInstallOptions(options));
4076
+ } catch (error) {
4077
+ renderCliError(error, { verbose: isVerbose(options) });
4078
+ process.exitCode = 1;
4079
+ }
3657
4080
  }
3658
- async function refreshAll(args) {
3659
- const { options, ctx, paths, nowIso } = args;
3660
- const dryRun = options.dryRun === true;
3661
- const claudeCode = await importClaudeCode(options, ctx);
3662
- const codex = await importCodex(options, ctx);
3663
- if (dryRun) {
3664
- const skipped = { status: "skipped", reason: "dry-run" };
4081
+ async function doRunCodexHookStatus(options) {
4082
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
4083
+ const { parsed } = await readHooksFile(hooksPath);
4084
+ const location = findBasouSessionStartHook(parsed);
4085
+ if (location === null) {
4086
+ console.log(
4087
+ "basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
4088
+ );
4089
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
4090
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
4091
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
4092
+ }
4093
+ return;
4094
+ }
4095
+ const matcher = location.matcher ?? "(every source)";
4096
+ console.log(
4097
+ `basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
4098
+ );
4099
+ await reportCodexHookState(hooksPath, parsed, options);
4100
+ }
4101
+ async function codexHookTrustFor(hooksPath, location, configPath) {
4102
+ const fields = commandHandlerFields(location.handler);
4103
+ if (fields === null)
4104
+ return { status: "unknown", detail: "the installed handler is not a command hook" };
4105
+ let configToml;
4106
+ try {
4107
+ configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
4108
+ } catch (error) {
4109
+ if (error instanceof Error && error.code === "ENOENT") {
4110
+ return { status: "untrusted" };
4111
+ }
3665
4112
  return {
3666
- claudeCode,
3667
- codex,
3668
- handoff: skipped,
3669
- decisions: skipped,
3670
- orientation: skipped,
3671
- dryRun
4113
+ status: "unknown",
4114
+ detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
3672
4115
  };
3673
4116
  }
3674
- const handoffCounts = await regenerateHandoff(paths, nowIso);
3675
- const decisionCounts = await regenerateDecisions(paths, nowIso);
3676
- const scoped = options.project !== void 0 && options.project.length > 0;
3677
- const orientationCounts = await regenerateOrientation(
3678
- paths,
3679
- nowIso,
3680
- scoped ? {} : {
3681
- staleness: {
3682
- newSessions: 0,
3683
- updatedSessions: 0,
3684
- unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
3685
- }
3686
- }
4117
+ const key = codexHookStateKey(
4118
+ hooksPath,
4119
+ "session_start",
4120
+ location.groupIndex,
4121
+ location.handlerIndex
3687
4122
  );
3688
- return {
3689
- claudeCode,
3690
- codex,
3691
- handoff: { status: "generated", ...handoffCounts },
3692
- decisions: { status: "generated", ...decisionCounts },
3693
- orientation: { status: "generated", ...orientationCounts },
3694
- dryRun
3695
- };
4123
+ const state = readCodexHookState(configToml, key);
4124
+ const current = computeCodexHookIdentityHash({
4125
+ eventKey: "session_start",
4126
+ matcher: location.matcher,
4127
+ handler: fields
4128
+ });
4129
+ return judgeCodexHookTrust(state, current);
3696
4130
  }
3697
- function wouldImport(outcome) {
3698
- return outcome.status === "ran" ? outcome.importedCount : 0;
4131
+
4132
+ // src/commands/init.ts
4133
+ import { basename as basename5, relative, resolve as resolve6 } from "path";
4134
+ import {
4135
+ appendBasouGitignore,
4136
+ createManifest,
4137
+ ensureBasouDirectory,
4138
+ resolveRepositoryRoot as resolveRepositoryRoot7,
4139
+ writeManifest
4140
+ } from "@basou/core";
4141
+ function collectValue(value, previous) {
4142
+ return [...previous, value];
3699
4143
  }
3700
- function wouldUpdate(outcome) {
3701
- return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
4144
+ function registerInitCommand(program) {
4145
+ program.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(
4146
+ "--repo-url <url>",
4147
+ "Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
4148
+ ).option(
4149
+ "--source-root <path>",
4150
+ "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
4151
+ collectValue,
4152
+ []
4153
+ ).option(
4154
+ "--local-only",
4155
+ "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
4156
+ ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
4157
+ await runInit(options);
4158
+ });
4159
+ }
4160
+ async function runInit(options, ctx = {}) {
4161
+ try {
4162
+ await doRunInit(options, ctx);
4163
+ } catch (error) {
4164
+ renderCliError(error, { verbose: isVerbose(options) });
4165
+ process.exitCode = 1;
4166
+ }
4167
+ }
4168
+ async function doRunInit(options, ctx) {
4169
+ const cwd = ctx.cwd ?? process.cwd();
4170
+ const repositoryRoot = await resolveRepositoryRootForInit(cwd);
4171
+ const workspaceName = options.name ?? basename5(repositoryRoot);
4172
+ if (options.repoUrl !== void 0) {
4173
+ console.error(
4174
+ "Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
4175
+ );
4176
+ }
4177
+ const sourceRoots = (options.sourceRoot ?? []).map((p) => {
4178
+ const rel = relative(repositoryRoot, resolve6(cwd, p));
4179
+ return rel === "" ? "." : rel;
4180
+ });
4181
+ const paths = await ensureBasouDirectory(repositoryRoot);
4182
+ const manifest = createManifest({
4183
+ workspaceName,
4184
+ ...options.projectName !== void 0 ? { projectName: options.projectName } : {},
4185
+ ...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
4186
+ ...sourceRoots.length > 0 ? { sourceRoots } : {}
4187
+ });
4188
+ await writeManifest(paths, manifest, { force: options.force === true });
4189
+ try {
4190
+ await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
4191
+ } catch (error) {
4192
+ renderGitignoreWarning(error, isVerbose(options));
4193
+ }
4194
+ console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
3702
4195
  }
3703
- function wouldBlock(outcome) {
3704
- return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
4196
+ function renderGitignoreWarning(error, verbose) {
4197
+ const baseMessage = error instanceof Error ? error.message : String(error);
4198
+ console.error(
4199
+ `Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
4200
+ );
4201
+ if (verbose && error instanceof Error) {
4202
+ const label = extractCauseLabel(error);
4203
+ if (label !== void 0) console.error(`Caused by: ${label}`);
4204
+ }
3705
4205
  }
3706
- async function probeStaleness(args) {
4206
+ async function resolveRepositoryRootForInit(cwd) {
3707
4207
  try {
3708
- const dry = await refreshAll({
3709
- options: { dryRun: true },
3710
- ctx: args.ctx,
3711
- paths: args.paths,
3712
- nowIso: args.nowIso
3713
- });
3714
- return {
3715
- newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
3716
- updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
3717
- unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
3718
- };
3719
- } catch {
3720
- return null;
4208
+ return await resolveRepositoryRoot7(cwd);
4209
+ } catch (error) {
4210
+ if (error instanceof Error && error.message === "Not a git repository") {
4211
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
4212
+ cause: error
4213
+ });
4214
+ }
4215
+ throw error;
3721
4216
  }
3722
4217
  }
3723
4218
 
3724
- // src/commands/orient.ts
3725
- function registerOrientCommand(program) {
3726
- program.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(
3727
- "--refresh",
3728
- "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
3729
- ).option("-v, --verbose", "Show error causes").action(async (opts) => {
3730
- await runOrient(opts);
4219
+ // src/commands/note.ts
4220
+ import {
4221
+ acquireLock as acquireLock4,
4222
+ appendEventToExistingSession as appendEventToExistingSession2,
4223
+ assertBasouRootSafe as assertBasouRootSafe8,
4224
+ basouPaths as basouPaths9,
4225
+ createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
4226
+ findErrorCode as findErrorCode7,
4227
+ readManifest as readManifest5,
4228
+ resolveSessionId as resolveSessionId2
4229
+ } from "@basou/core";
4230
+ import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
4231
+ var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
4232
+ "list",
4233
+ "ls",
4234
+ "show",
4235
+ "get",
4236
+ "add",
4237
+ "new",
4238
+ "edit",
4239
+ "rm",
4240
+ "remove",
4241
+ "delete",
4242
+ "help"
4243
+ ]);
4244
+ var LABEL_BODY_MAX = 80;
4245
+ var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
4246
+ function registerNoteCommand(program) {
4247
+ program.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
4248
+ "--session <session_id>",
4249
+ "Attach to an existing session; otherwise an ad-hoc session is created"
4250
+ ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
4251
+ await runNote(body, options);
3731
4252
  });
3732
4253
  }
3733
- async function runOrient(options, ctx = {}) {
4254
+ async function runNote(body, options, ctx = {}) {
3734
4255
  try {
3735
- await doRunOrient(options, ctx);
4256
+ await doRunNote(body, options, ctx);
3736
4257
  } catch (error) {
3737
- renderCliError(error, { verbose: isVerbose(options) });
4258
+ renderCliError(error, {
4259
+ verbose: isVerbose(options),
4260
+ classifiers: [failedToFinalizeClassifier]
4261
+ });
3738
4262
  process.exitCode = 1;
3739
4263
  }
3740
4264
  }
3741
- async function doRunOrient(options, ctx) {
4265
+ async function doRunNote(body, options, ctx) {
4266
+ if (body.trim().length === 0) {
4267
+ throw new Error("Note body must not be empty");
4268
+ }
4269
+ const reserved = body.trim().toLowerCase();
4270
+ if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
4271
+ throw new Error(
4272
+ `'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>"\`).`
4273
+ );
4274
+ }
3742
4275
  const cwd = ctx.cwd ?? process.cwd();
3743
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
4276
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
3744
4277
  const paths = basouPaths9(repositoryRoot);
3745
4278
  await assertWorkspaceInitialized7(paths.root);
3746
- const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
3747
- const probeCtx = { cwd: repositoryRoot };
3748
- if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
3749
- if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
3750
- if (options.refresh === true) {
3751
- await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
3752
- }
3753
- const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
3754
- let federatedRoots = [];
3755
- try {
3756
- const hosts = await loadHostsConfig(ctx.hostsConfigPath);
3757
- if (hosts !== null) {
3758
- federatedRoots = hosts.map((h) => ({ paths: basouPaths9(h.path), host: h.label }));
4279
+ const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
4280
+ const occurredAt = now.toISOString();
4281
+ if (options.session !== void 0) {
4282
+ const sessionId = await resolveSessionId2(paths, options.session);
4283
+ const sesId = sessionId;
4284
+ const sessionLock = await acquireLock4(paths, "session", sesId);
4285
+ let result;
4286
+ try {
4287
+ result = await appendEventToExistingSession2({
4288
+ paths,
4289
+ sessionId: sesId,
4290
+ eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
4291
+ });
4292
+ } finally {
4293
+ await sessionLock.release();
3759
4294
  }
3760
- } catch (error) {
3761
- console.error(
3762
- `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
3763
- );
4295
+ printNoteResult(options, {
4296
+ mode: "attached",
4297
+ sessionId,
4298
+ eventId: result.eventId,
4299
+ sessionStatus: result.sessionStatus,
4300
+ body
4301
+ });
4302
+ return;
3764
4303
  }
3765
- const result = await renderOrientation2({
4304
+ const manifest = await readManifest5(paths);
4305
+ const adHoc = await createAdHocSessionWithEvent2({
3766
4306
  paths,
3767
- nowIso,
3768
- staleness,
3769
- verbose: options.verbose === true,
3770
- federatedRoots,
3771
- onWarning: (w, sid) => printReplayWarning(w, sid),
3772
- onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
3773
- onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
3774
- onHostUnavailable: (host, error) => console.error(
3775
- `basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
3776
- )
4307
+ manifest,
4308
+ label: buildAdHocLabel2(body),
4309
+ occurredAt,
4310
+ sessionSource: "human",
4311
+ workingDirectory: repositoryRoot,
4312
+ invocation: {
4313
+ command: "basou note",
4314
+ args: [body]
4315
+ },
4316
+ targetEventBuilders: [
4317
+ (sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
4318
+ ]
3777
4319
  });
3778
- await writeMarkdownFile4(paths.files.orientation, `${result.body}
3779
- `);
3780
- if (options.quiet === true) {
4320
+ printNoteResult(options, {
4321
+ mode: "ad-hoc",
4322
+ sessionId: adHoc.sessionId,
4323
+ eventId: adHoc.targetEventIds[0],
4324
+ sessionStatus: "completed",
4325
+ body
4326
+ });
4327
+ }
4328
+ function buildNoteEvent(input) {
4329
+ return {
4330
+ schema_version: "0.1.0",
4331
+ id: input.eventId,
4332
+ session_id: input.sessionId,
4333
+ occurred_at: input.occurredAt,
4334
+ source: "local-cli",
4335
+ type: "note_added",
4336
+ body: input.body,
4337
+ // `basou note` is the resume-hint command; mark it so orientation surfaces
4338
+ // it as the next step and a plain `basou session note` annotation does not.
4339
+ kind: "next_step"
4340
+ };
4341
+ }
4342
+ function buildAdHocLabel2(body) {
4343
+ const oneLine2 = body.replace(/\s+/g, " ").trim();
4344
+ const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
4345
+ return `Ad-hoc note: ${truncated}`;
4346
+ }
4347
+ function parseBody(raw) {
4348
+ if (raw.trim().length === 0) {
4349
+ throw new InvalidArgumentError2("Note body must not be empty");
4350
+ }
4351
+ return raw;
4352
+ }
4353
+ function printNoteResult(options, result) {
4354
+ const sid = shortSessionId(result.sessionId);
4355
+ if (options.json === true) {
3781
4356
  console.log(
3782
- `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
4357
+ JSON.stringify({
4358
+ event_id: result.eventId,
4359
+ session_id: result.sessionId,
4360
+ session_status: result.sessionStatus,
4361
+ mode: result.mode,
4362
+ body: result.body
4363
+ })
3783
4364
  );
4365
+ return;
4366
+ }
4367
+ if (result.mode === "ad-hoc") {
4368
+ console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
3784
4369
  } else {
3785
- console.log(result.body);
4370
+ console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
3786
4371
  }
3787
4372
  }
3788
4373
  async function assertWorkspaceInitialized7(basouRoot) {
@@ -3909,7 +4494,7 @@ import {
3909
4494
  writeFileSync,
3910
4495
  writeSync
3911
4496
  } from "fs";
3912
- import { basename as basename5, dirname as dirname3, isAbsolute as isAbsolute3, join as join11, relative as relative2, resolve as resolve7 } from "path";
4497
+ import { basename as basename6, dirname as dirname4, isAbsolute as isAbsolute3, join as join11, relative as relative2, resolve as resolve7 } from "path";
3913
4498
  import {
3914
4499
  appendBasouGitignore as appendBasouGitignore2,
3915
4500
  basouPaths as basouPaths10,
@@ -3920,7 +4505,7 @@ import {
3920
4505
  GENERATED_START,
3921
4506
  instructionMode,
3922
4507
  isGitNotFound,
3923
- parseMarkers as parseMarkers2,
4508
+ parseMarkers as parseMarkers3,
3924
4509
  pathBasename,
3925
4510
  planArchive,
3926
4511
  planGitignore,
@@ -3928,7 +4513,7 @@ import {
3928
4513
  planRosterAdoption,
3929
4514
  planWorkspaceView,
3930
4515
  readManifest as readManifest6,
3931
- readMarkdownFile as readMarkdownFile5,
4516
+ readMarkdownFile as readMarkdownFile7,
3932
4517
  reconcileSourceRoots,
3933
4518
  removeMarkerSection as removeMarkerSection2,
3934
4519
  renderAnchorStarter,
@@ -4540,9 +5125,9 @@ function applyGitignorePlan(repositoryRoot, plan) {
4540
5125
  throw new Error("Failed to read .gitignore", { cause: error });
4541
5126
  }
4542
5127
  }
4543
- const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
5128
+ const sep2 = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
4544
5129
  try {
4545
- writeFileSync(file, `${existing}${sep}${plan.toAdd.join("\n")}
5130
+ writeFileSync(file, `${existing}${sep2}${plan.toAdd.join("\n")}
4546
5131
  `);
4547
5132
  } catch (error) {
4548
5133
  throw new Error("Failed to write .gitignore", { cause: error });
@@ -4682,7 +5267,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4682
5267
  isAnchor: true,
4683
5268
  reachable: true,
4684
5269
  canonicalPresent: true,
4685
- canonicalName: basename5(real),
5270
+ canonicalName: basename6(real),
4686
5271
  files: [{ name: CANONICAL_FILE, expectedTarget: CANONICAL_FILE, state: "blocked" }]
4687
5272
  };
4688
5273
  }
@@ -4704,14 +5289,14 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4704
5289
  isAnchor: true,
4705
5290
  reachable: true,
4706
5291
  canonicalPresent: true,
4707
- canonicalName: basename5(real),
5292
+ canonicalName: basename6(real),
4708
5293
  files: anchorFiles
4709
5294
  };
4710
5295
  }
4711
5296
  if (!existsSync2(join11(real, ".git"))) {
4712
5297
  return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
4713
5298
  }
4714
- const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents", basename5(real), CANONICAL_FILE);
5299
+ const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents", basename6(real), CANONICAL_FILE);
4715
5300
  if (!existsSync2(canonicalFile)) {
4716
5301
  return { ...base, isAnchor: false, reachable: true, canonicalPresent: false, files: [] };
4717
5302
  }
@@ -4731,7 +5316,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
4731
5316
  isAnchor: false,
4732
5317
  reachable: true,
4733
5318
  canonicalPresent: true,
4734
- canonicalName: basename5(real),
5319
+ canonicalName: basename6(real),
4735
5320
  files
4736
5321
  };
4737
5322
  }
@@ -4748,7 +5333,7 @@ function applySymlinkPlan(repositoryRoot, plan) {
4748
5333
  for (const { name, target } of plan.toCreate) {
4749
5334
  const filePath = join11(real, name);
4750
5335
  try {
4751
- mkdirSync(dirname3(filePath), { recursive: true });
5336
+ mkdirSync(dirname4(filePath), { recursive: true });
4752
5337
  symlinkSync(target, filePath);
4753
5338
  created.push(name);
4754
5339
  } catch (error) {
@@ -4764,16 +5349,16 @@ function viewCanonicalCollision(repositoryRoot, roster, viewName) {
4764
5349
  for (const entry of roster) {
4765
5350
  let name;
4766
5351
  try {
4767
- name = basename5(realpathSync(resolve7(repositoryRoot, entry.path)));
5352
+ name = basename6(realpathSync(resolve7(repositoryRoot, entry.path)));
4768
5353
  } catch {
4769
- name = basename5(resolve7(repositoryRoot, entry.path));
5354
+ name = basename6(resolve7(repositoryRoot, entry.path));
4770
5355
  }
4771
5356
  if (name === viewName) return entry.path;
4772
5357
  }
4773
5358
  return void 0;
4774
5359
  }
4775
5360
  function gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir) {
4776
- const viewName = basename5(viewDir);
5361
+ const viewName = basename6(viewDir);
4777
5362
  const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
4778
5363
  if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
4779
5364
  const canonicalFile = canonicalFileFor(anchorReal, viewName);
@@ -4798,7 +5383,7 @@ function applyViewSymlinks(viewDir, files) {
4798
5383
  if (f.state !== "missing") continue;
4799
5384
  const filePath = join11(viewDir, f.name);
4800
5385
  try {
4801
- mkdirSync(dirname3(filePath), { recursive: true });
5386
+ mkdirSync(dirname4(filePath), { recursive: true });
4802
5387
  symlinkSync(f.expectedTarget, filePath);
4803
5388
  created.push(f.name);
4804
5389
  } catch (error) {
@@ -5018,7 +5603,7 @@ function resolveViewDir(repositoryRoot, viewPath) {
5018
5603
  return realpathSync(abs);
5019
5604
  } catch {
5020
5605
  try {
5021
- return join11(realpathSync(dirname3(abs)), basename5(abs));
5606
+ return join11(realpathSync(dirname4(abs)), basename6(abs));
5022
5607
  } catch {
5023
5608
  return abs;
5024
5609
  }
@@ -5035,7 +5620,7 @@ function gatherViewRepo(repositoryRoot, viewDir, entry) {
5035
5620
  if (expectedTarget === "" || expectedTarget === ".") {
5036
5621
  return { path: entry.path, reachable: false };
5037
5622
  }
5038
- const linkName = basename5(repoReal);
5623
+ const linkName = basename6(repoReal);
5039
5624
  const { state, actualTarget } = inspectSymlink(join11(viewDir, linkName), expectedTarget);
5040
5625
  return {
5041
5626
  path: entry.path,
@@ -5052,7 +5637,7 @@ function applyViewPlan(viewDir, toCreate) {
5052
5637
  for (const { name, target } of toCreate) {
5053
5638
  const filePath = join11(viewDir, name);
5054
5639
  try {
5055
- mkdirSync(dirname3(filePath), { recursive: true });
5640
+ mkdirSync(dirname4(filePath), { recursive: true });
5056
5641
  symlinkSync(target, filePath);
5057
5642
  created.push(name);
5058
5643
  } catch (error) {
@@ -5165,7 +5750,7 @@ async function doRunProjectWorkspace(options, ctx) {
5165
5750
  } else {
5166
5751
  const viewDir = resolveViewDir(repositoryRoot, viewPath);
5167
5752
  const facts = roster.map((entry) => gatherViewRepo(repositoryRoot, viewDir, entry));
5168
- const rosterNames = roster.map((entry) => basename5(resolve7(repositoryRoot, entry.path)));
5753
+ const rosterNames = roster.map((entry) => basename6(resolve7(repositoryRoot, entry.path)));
5169
5754
  const rosterRealpaths = /* @__PURE__ */ new Set();
5170
5755
  for (const entry of roster) {
5171
5756
  try {
@@ -5359,10 +5944,10 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5359
5944
  if (!existsSync2(join11(real, ".git"))) {
5360
5945
  return { ...declared, isAnchor: false, reachable: false, canonicalPresent: false };
5361
5946
  }
5362
- const canonicalName = basename5(real);
5947
+ const canonicalName = basename6(real);
5363
5948
  let content;
5364
5949
  try {
5365
- content = await readMarkdownFile5(canonicalFileFor(anchorReal, canonicalName));
5950
+ content = await readMarkdownFile7(canonicalFileFor(anchorReal, canonicalName));
5366
5951
  } catch {
5367
5952
  return {
5368
5953
  ...declared,
@@ -5382,7 +5967,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5382
5967
  canonicalPresent: false
5383
5968
  };
5384
5969
  }
5385
- const section = parseMarkers2(content);
5970
+ const section = parseMarkers3(content);
5386
5971
  return {
5387
5972
  ...declared,
5388
5973
  isAnchor: false,
@@ -5412,7 +5997,7 @@ function viewPresetReposFor(repositoryRoot, roster) {
5412
5997
  } catch {
5413
5998
  real = void 0;
5414
5999
  }
5415
- const name = basename5(real ?? abs);
6000
+ const name = basename6(real ?? abs);
5416
6001
  const isAnchor = real !== void 0 && anchorReal !== void 0 ? real === anchorReal : abs === repositoryRoot;
5417
6002
  return {
5418
6003
  name,
@@ -5445,7 +6030,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
5445
6030
  }
5446
6031
  return { kind: "unreadable", canonicalName: viewName, viewName };
5447
6032
  }
5448
- const section = parseMarkers2(content);
6033
+ const section = parseMarkers3(content);
5449
6034
  if (section.kind === "ok") {
5450
6035
  if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
5451
6036
  return { kind: "in-sync", canonicalName: viewName, viewName };
@@ -5470,8 +6055,8 @@ async function applyViewPreset(anchorReal, outcome) {
5470
6055
  isLink = false;
5471
6056
  }
5472
6057
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5473
- if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
5474
- const existing = await readMarkdownFile5(file);
6058
+ if (outcome.action === "create") mkdirSync(dirname4(file), { recursive: true });
6059
+ const existing = await readMarkdownFile7(file);
5475
6060
  await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
5476
6061
  }
5477
6062
  async function applyPresetPlan(anchorReal, plan) {
@@ -5484,8 +6069,8 @@ async function applyPresetPlan(anchorReal, plan) {
5484
6069
  isLink = false;
5485
6070
  }
5486
6071
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5487
- if (plan.action === "create") mkdirSync(dirname3(file), { recursive: true });
5488
- const existing = await readMarkdownFile5(file);
6072
+ if (plan.action === "create") mkdirSync(dirname4(file), { recursive: true });
6073
+ const existing = await readMarkdownFile7(file);
5489
6074
  await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
5490
6075
  }
5491
6076
  function presetFailureReason(error) {
@@ -5507,7 +6092,7 @@ async function doRunProjectPreset(options, ctx) {
5507
6092
  const facts = [];
5508
6093
  for (const entry of roster) facts.push(await gatherRepoPreset(repositoryRoot, anchorReal, entry));
5509
6094
  const viewPath = manifest.workspace.view;
5510
- const viewCanonicalName = roster.length > 0 && viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
6095
+ const viewCanonicalName = roster.length > 0 && viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
5511
6096
  const summary = summarizePresetPlan(
5512
6097
  facts,
5513
6098
  viewCanonicalName !== void 0 ? { viewCanonicalName } : void 0
@@ -5734,7 +6319,7 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
5734
6319
  return empty;
5735
6320
  }
5736
6321
  const anchorReal = realpathSync(repositoryRoot);
5737
- const canonicalName = basename5(real);
6322
+ const canonicalName = basename6(real);
5738
6323
  const instructionFiles = [];
5739
6324
  for (const name of INSTRUCTION_FILES) {
5740
6325
  try {
@@ -5804,7 +6389,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5804
6389
  }
5805
6390
  const isAnchor = repoReal !== void 0 && repoReal === anchorReal;
5806
6391
  const targetAbs = resolve7(repositoryRoot, target);
5807
- const canonicalName = basename5(repoReal ?? targetAbs);
6392
+ const canonicalName = basename6(repoReal ?? targetAbs);
5808
6393
  const roster = manifest.repos ?? [];
5809
6394
  const declaredEntry = roster.find((r) => {
5810
6395
  try {
@@ -5825,10 +6410,10 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5825
6410
  }
5826
6411
  if (rReal !== null) {
5827
6412
  if (repoReal !== void 0 && rReal === repoReal) return false;
5828
- return basename5(rReal).toLowerCase() === cnFold;
6413
+ return basename6(rReal).toLowerCase() === cnFold;
5829
6414
  }
5830
6415
  if (resolve7(repositoryRoot, r.path) === targetAbs) return false;
5831
- return basename5(resolve7(repositoryRoot, r.path)).toLowerCase() === cnFold;
6416
+ return basename6(resolve7(repositoryRoot, r.path)).toLowerCase() === cnFold;
5832
6417
  });
5833
6418
  const collisionNote = "shared with another repo of the same basename, so it cannot be removed (check manually)";
5834
6419
  const items = [];
@@ -5949,7 +6534,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5949
6534
  });
5950
6535
  }
5951
6536
  if (content !== void 0 && content !== "") {
5952
- const section = parseMarkers2(content);
6537
+ const section = parseMarkers3(content);
5953
6538
  if (section.kind === "ok" && canonicalShared) {
5954
6539
  items.push({
5955
6540
  kind: "canonical-block",
@@ -6078,7 +6663,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
6078
6663
  }
6079
6664
  try {
6080
6665
  const content = readFileSync(fd, "utf8");
6081
- if (parseMarkers2(content).kind !== "ok") {
6666
+ if (parseMarkers3(content).kind !== "ok") {
6082
6667
  changed(item.label);
6083
6668
  continue;
6084
6669
  }
@@ -6293,7 +6878,7 @@ function renderProjectArchive(result) {
6293
6878
  if (t.gitignorePatterns.length > 0)
6294
6879
  items.push(`.gitignore instruction patterns (${t.gitignorePatterns.join(", ")})`);
6295
6880
  if (t.canonical)
6296
- items.push(`the anchor's canonical (agents/${basename5(result.target)}/AGENTS.md)`);
6881
+ items.push(`the anchor's canonical (agents/${basename6(result.target)}/AGENTS.md)`);
6297
6882
  if (!t.inspected) {
6298
6883
  lines.push(
6299
6884
  "## Manual teardown (the repo could not be resolved on disk, so it was not inspected)"
@@ -6504,7 +7089,7 @@ function validateProjectName(name) {
6504
7089
  async function doRunProjectNew(repos, options, ctx) {
6505
7090
  const cwd = ctx.cwd ?? process.cwd();
6506
7091
  const repositoryRoot = await resolveRepositoryRootForNew(cwd);
6507
- const workspaceName = basename5(repositoryRoot);
7092
+ const workspaceName = basename6(repositoryRoot);
6508
7093
  const productName = options.projectName !== void 0 ? validateProjectName(options.projectName) : void 0;
6509
7094
  const viewStem = productName ?? workspaceName;
6510
7095
  const viewOverridesProjectName = productName !== void 0 && typeof options.view === "string";
@@ -6703,10 +7288,10 @@ async function doRunProjectSeedAnchor(options, ctx) {
6703
7288
  return;
6704
7289
  }
6705
7290
  const viewPath = manifest.workspace.view;
6706
- const viewName = viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
7291
+ const viewName = viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
6707
7292
  const repos = viewPresetReposFor(repositoryRoot, roster);
6708
7293
  const content = renderAnchorStarter({
6709
- anchorName: basename5(repositoryRoot),
7294
+ anchorName: basename6(repositoryRoot),
6710
7295
  ...manifest.project?.name !== void 0 ? { projectName: manifest.project.name } : {},
6711
7296
  ...viewName !== void 0 ? { viewName } : {},
6712
7297
  repos
@@ -6787,7 +7372,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6787
7372
  const self = declaredEntry !== void 0 && instructionMode(declaredEntry) === "self";
6788
7373
  const displayRel = argReal !== void 0 ? relative2(anchorReal, argReal) : relative2(repositoryRoot, argAbs);
6789
7374
  const path = displayRel === "" ? "." : displayRel;
6790
- const canonicalName = basename5(argReal ?? argAbs);
7375
+ const canonicalName = basename6(argReal ?? argAbs);
6791
7376
  if (argReal === void 0) {
6792
7377
  return {
6793
7378
  path,
@@ -6821,7 +7406,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
6821
7406
  function relocateAgentsFile(repoReal, canonicalFile) {
6822
7407
  const agentsFile = join11(repoReal, CANONICAL_FILE);
6823
7408
  try {
6824
- mkdirSync(dirname3(canonicalFile), { recursive: true });
7409
+ mkdirSync(dirname4(canonicalFile), { recursive: true });
6825
7410
  } catch (error) {
6826
7411
  return { ok: false, message: failureReason(error), partial: false };
6827
7412
  }
@@ -6842,7 +7427,7 @@ function relocateAgentsFile(repoReal, canonicalFile) {
6842
7427
  function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
6843
7428
  if (viewPath === void 0) return { kind: "no-view" };
6844
7429
  const viewDir = resolveViewDir(repositoryRoot, viewPath);
6845
- const viewName = basename5(viewDir);
7430
+ const viewName = basename6(viewDir);
6846
7431
  const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
6847
7432
  if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
6848
7433
  const canonicalFile = canonicalFileFor(anchorReal, viewName);
@@ -6853,7 +7438,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
6853
7438
  if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
6854
7439
  return { kind: "unreadable", viewName };
6855
7440
  }
6856
- const section = parseMarkers2(content);
7441
+ const section = parseMarkers3(content);
6857
7442
  if (section.kind === "ok") return { kind: "already-marked", viewName };
6858
7443
  if (section.kind === "no_markers") {
6859
7444
  const block = renderViewPresetBlock({
@@ -6874,7 +7459,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
6874
7459
  isLink = false;
6875
7460
  }
6876
7461
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
6877
- const existing = await readMarkdownFile5(file);
7462
+ const existing = await readMarkdownFile7(file);
6878
7463
  await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
6879
7464
  }
6880
7465
  async function doRunProjectRetrofit(repo, options, ctx) {
@@ -6921,7 +7506,7 @@ async function doRunProjectRetrofit(repo, options, ctx) {
6921
7506
  } catch {
6922
7507
  argReal = void 0;
6923
7508
  }
6924
- const viewCanonicalName = viewPath !== void 0 ? basename5(resolveViewDir(repositoryRoot, viewPath)) : void 0;
7509
+ const viewCanonicalName = viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
6925
7510
  const facts = gatherRetrofit(
6926
7511
  repositoryRoot,
6927
7512
  anchorReal,
@@ -7137,7 +7722,7 @@ function renderProjectRetrofit(result) {
7137
7722
 
7138
7723
  // src/commands/protocol.ts
7139
7724
  import { readFile as readFile4 } from "fs/promises";
7140
- import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers3, readMarkdownFile as readMarkdownFile6 } from "@basou/core";
7725
+ import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile8 } from "@basou/core";
7141
7726
 
7142
7727
  // src/lib/protocols-config.ts
7143
7728
  import { homedir as homedir8 } from "os";
@@ -7221,7 +7806,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
7221
7806
  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. -->";
7222
7807
  function registerProtocolCommand(program) {
7223
7808
  const protocol = program.command("protocol").description("Manage the basou-managed standing-protocol block in the global CLAUDE.md");
7224
- 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) => {
7809
+ protocol.command("sync").description(
7810
+ "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."
7811
+ ).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) => {
7225
7812
  await runProtocolSync(opts);
7226
7813
  });
7227
7814
  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) => {
@@ -7287,18 +7874,23 @@ ${body}` : body;
7287
7874
  ${sections.join("\n\n")}
7288
7875
  `;
7289
7876
  }
7290
- async function doRunProtocolSync(options) {
7877
+ async function doRunProtocolSync(options, ctx = {}) {
7291
7878
  const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
7292
7879
  const target = options.target ?? DEFAULT_TARGET_PATH;
7293
7880
  const entries = await loadProtocolsConfig(configPath);
7294
7881
  const sources = await readProtocolSources(entries);
7295
7882
  const block = buildBlock(sources);
7883
+ const foreign = await findForeignWorkspaceNames({
7884
+ text: block,
7885
+ configPath: ctx.portfolioConfigPath
7886
+ });
7296
7887
  const result = await syncMarkerBlock({
7297
7888
  target,
7298
7889
  markers: PROTOCOL_MARKERS,
7299
7890
  block,
7300
7891
  ...options.dryRun === true ? { dryRun: true } : {}
7301
7892
  });
7893
+ if (foreign !== null) console.error(protocolForeignWorkspaceWarning(foreign));
7302
7894
  if (result.action === "unchanged") {
7303
7895
  console.log(`The basou:protocols block is already up to date (${entries.length} protocol(s)).`);
7304
7896
  return;
@@ -7320,8 +7912,8 @@ async function doRunProtocolList(options) {
7320
7912
  const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
7321
7913
  const target = options.target ?? DEFAULT_TARGET_PATH;
7322
7914
  const entries = await loadProtocolsConfig(configPath);
7323
- const existing = await readMarkdownFile6(target);
7324
- const installed = existing !== null && parseMarkers3(existing, PROTOCOL_MARKERS).kind === "ok";
7915
+ const existing = await readMarkdownFile8(target);
7916
+ const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
7325
7917
  console.log(`Declared protocols (${entries.length}):`);
7326
7918
  for (const entry of entries) {
7327
7919
  console.log(` - ${entry.title ?? entry.source}`);
@@ -7356,21 +7948,6 @@ import {
7356
7948
  } from "@basou/core";
7357
7949
  import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
7358
7950
 
7359
- // src/lib/channel-policy.ts
7360
- function decideCodexChannel(manifest) {
7361
- if (manifest.policies?.confidential === true) return { write: false, reason: "confidential" };
7362
- if (manifest.channels?.codex === true) return { write: true };
7363
- return { write: false, reason: "not_enabled" };
7364
- }
7365
- function describeCodexChannelSkip(reason) {
7366
- switch (reason) {
7367
- case "confidential":
7368
- return "codex channel: skipped (confidential workspace \u2014 nothing is written to the user-global ~/.codex/AGENTS.md)";
7369
- case "not_enabled":
7370
- return "codex channel: skipped (this workspace has not opted in; the user-global ~/.codex/AGENTS.md is left untouched \u2014 see docs/spec/schemas.md \xA74.2)";
7371
- }
7372
- }
7373
-
7374
7951
  // src/commands/refresh-watch.ts
7375
7952
  import { readdir as readdir2, stat as stat5 } from "fs/promises";
7376
7953
  import { homedir as homedir9 } from "os";
@@ -7522,7 +8099,7 @@ function abortableSleep(ms, signal) {
7522
8099
  }
7523
8100
  function registerRefreshCommand(program) {
7524
8101
  program.command("refresh").description(
7525
- "Import all adapters for the project and regenerate handoff + decisions in one step"
8102
+ "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`)."
7526
8103
  ).option(
7527
8104
  "--project <path>",
7528
8105
  "Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
@@ -7568,7 +8145,7 @@ async function doRunRefreshPortfolio(options, ctx) {
7568
8145
  for (const ws of workspaces) {
7569
8146
  const label = ws.label ?? ws.path;
7570
8147
  try {
7571
- const { result } = await computeRefresh(
8148
+ const { result, paths } = await computeRefresh(
7572
8149
  { ...options, portfolio: false },
7573
8150
  { ...ctx, cwd: ws.path }
7574
8151
  );
@@ -7578,6 +8155,7 @@ async function doRunRefreshPortfolio(options, ctx) {
7578
8155
  ## ${label} (${ws.path})`);
7579
8156
  printRefreshSummary(result);
7580
8157
  }
8158
+ await warnPosition(paths, result, ctx);
7581
8159
  } catch (error) {
7582
8160
  const message = error instanceof Error ? error.message : String(error);
7583
8161
  rollup.push({ label, path: ws.path, status: "failed", error: message });
@@ -7653,47 +8231,31 @@ async function computeRefresh(options, ctx) {
7653
8231
  }
7654
8232
  async function doRunRefresh(options, ctx) {
7655
8233
  const { result, paths } = await computeRefresh(options, ctx);
7656
- const channel = options.dryRun === true ? { outcome: { status: "skipped", reason: "dry_run" }, line: null } : await syncCodexOrientationChannel(paths, ctx.codexChannelPath);
7657
- const reported = { ...result, codexChannel: channel.outcome };
8234
+ const reported = { ...result, codexChannel: { status: "retired" } };
7658
8235
  if (options.json === true) {
7659
8236
  console.log(JSON.stringify(reported));
7660
8237
  } else {
7661
8238
  printRefreshSummary(result);
7662
- if (channel.line !== null) console.log(channel.line);
8239
+ const line = await retiredChannelNotice(paths);
8240
+ if (line !== null) console.log(line);
7663
8241
  }
8242
+ await warnPosition(paths, result, ctx);
7664
8243
  return reported;
7665
8244
  }
7666
- async function syncCodexOrientationChannel(paths, channelPath) {
7667
- let decision;
7668
- try {
7669
- decision = decideCodexChannel(await readManifest7(paths));
7670
- } catch (error) {
7671
- const detail = error instanceof Error ? error.message : String(error);
7672
- return {
7673
- outcome: { status: "skipped", reason: "error", detail },
7674
- line: `codex channel: skipped (manifest could not be re-read, so the opt-in cannot be confirmed: ${detail})`
7675
- };
7676
- }
7677
- if (!decision.write) {
7678
- return {
7679
- outcome: { status: "skipped", reason: decision.reason },
7680
- line: describeCodexChannelSkip(decision.reason)
7681
- };
7682
- }
8245
+ async function warnPosition(paths, result, ctx) {
8246
+ if (result.orientation.status !== "generated") return;
8247
+ await warnIfPositionNamesOtherWorkspaces({
8248
+ paths,
8249
+ configPath: ctx.portfolioConfigPath
8250
+ });
8251
+ }
8252
+ async function retiredChannelNotice(paths) {
7683
8253
  try {
7684
- const rendered = await renderOrientationToCodexChannel({
7685
- orientationPath: paths.files.orientation,
7686
- ...channelPath !== void 0 ? { channelPath } : {}
7687
- });
7688
- if (rendered === null)
7689
- return { outcome: { status: "skipped", reason: "no_orientation" }, line: null };
7690
- return { outcome: { status: "written", action: rendered.action }, line: rendered.line };
7691
- } catch (error) {
7692
- const detail = error instanceof Error ? error.message : String(error);
7693
- return {
7694
- outcome: { status: "skipped", reason: "error", detail },
7695
- line: `codex channel skipped: ${detail}`
7696
- };
8254
+ const manifest = await readManifest7(paths);
8255
+ if (manifest.channels?.codex !== true) return null;
8256
+ 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";
8257
+ } catch {
8258
+ return null;
7697
8259
  }
7698
8260
  }
7699
8261
  function describeImport(outcome) {
@@ -8271,7 +8833,7 @@ function unattachedLines(u) {
8271
8833
  }
8272
8834
 
8273
8835
  // src/commands/run.ts
8274
- import { mkdir as mkdir2 } from "fs/promises";
8836
+ import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
8275
8837
  import { homedir as homedir11 } from "os";
8276
8838
  import { join as join14 } from "path";
8277
8839
  import {
@@ -8283,6 +8845,7 @@ import {
8283
8845
  codexAdapterMetadata,
8284
8846
  appendChainedEvent as coreAppendChainedEvent2,
8285
8847
  finalizeSessionYaml as finalizeSessionYaml2,
8848
+ findBasouSessionStartHook as findBasouSessionStartHook2,
8286
8849
  getDiff,
8287
8850
  getSnapshot as getSnapshot2,
8288
8851
  overwriteYamlFile as overwriteYamlFile2,
@@ -8334,7 +8897,7 @@ function runCodex(args, options, ctx = {}) {
8334
8897
  resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
8335
8898
  metadata: codexAdapterMetadata,
8336
8899
  transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
8337
- preSpawn: syncCodexOrientationChannelPreSpawn
8900
+ preSpawn: noteCodexHookStatusPreSpawn
8338
8901
  });
8339
8902
  }
8340
8903
  async function runTrackedTool(args, options, ctx, adapter) {
@@ -8701,25 +9264,26 @@ async function resolveRepositoryRootForRun(cwd) {
8701
9264
  throw error;
8702
9265
  }
8703
9266
  }
8704
- async function syncCodexOrientationChannelPreSpawn(cwd, ctx) {
9267
+ async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
9268
+ const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
9269
+ let location;
8705
9270
  try {
8706
- const root = await resolveBasouRootForCommand(cwd, "run");
8707
- const paths = basouPaths15(root);
8708
- const decision = decideCodexChannel(await readManifest9(paths));
8709
- if (!decision.write) return describeCodexChannelSkip(decision.reason);
8710
- const rendered = await renderOrientationToCodexChannel({
8711
- orientationPath: paths.files.orientation,
8712
- ...ctx.codexChannelPath !== void 0 ? { channelPath: ctx.codexChannelPath } : {}
8713
- });
8714
- return rendered === null ? null : rendered.line;
8715
- } catch {
8716
- return null;
9271
+ location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
9272
+ } catch (error) {
9273
+ if (!(error instanceof Error && error.code === "ENOENT")) return null;
9274
+ location = null;
9275
+ }
9276
+ if (location === null) {
9277
+ 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)";
8717
9278
  }
9279
+ const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
9280
+ if (trust.status === "trusted" || trust.status === "unknown") return null;
9281
+ return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
8718
9282
  }
8719
9283
 
8720
9284
  // src/commands/session.ts
8721
- import { readFile as readFile6 } from "fs/promises";
8722
- import { basename as basename6, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
9285
+ import { readFile as readFile7 } from "fs/promises";
9286
+ import { basename as basename7, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
8723
9287
  import {
8724
9288
  acquireLock as acquireLock6,
8725
9289
  appendEventToExistingSession as appendEventToExistingSession3,
@@ -9169,7 +9733,7 @@ async function doRunSessionImport(options, ctx) {
9169
9733
  }
9170
9734
  async function readInputFile(path) {
9171
9735
  try {
9172
- return await readFile6(path, "utf8");
9736
+ return await readFile7(path, "utf8");
9173
9737
  } catch (error) {
9174
9738
  if (findErrorCode12(error, "ENOENT")) {
9175
9739
  throw new Error("Import source not found", { cause: error });
@@ -9227,7 +9791,7 @@ function printSessionImportResult(options, result) {
9227
9791
  return;
9228
9792
  }
9229
9793
  console.log(
9230
- `Imported session ${sid} (${result.eventCount} events) from ${basename6(options.from)}`
9794
+ `Imported session ${sid} (${result.eventCount} events) from ${basename7(options.from)}`
9231
9795
  );
9232
9796
  }
9233
9797
  var NOTE_BODY_PREVIEW_LIMIT = 80;
@@ -9286,7 +9850,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
9286
9850
  }
9287
9851
  async function readNoteFile(path) {
9288
9852
  try {
9289
- return await readFile6(path, "utf8");
9853
+ return await readFile7(path, "utf8");
9290
9854
  } catch (error) {
9291
9855
  if (findErrorCode12(error, "ENOENT")) {
9292
9856
  throw new Error("Note source not found", { cause: error });
@@ -9607,7 +10171,7 @@ function formatVersionGateMessage(error) {
9607
10171
  }
9608
10172
 
9609
10173
  // src/commands/task.ts
9610
- import { readFile as readFile7 } from "fs/promises";
10174
+ import { readFile as readFile8 } from "fs/promises";
9611
10175
  import { join as join16 } from "path";
9612
10176
  import {
9613
10177
  archiveTask,
@@ -10622,7 +11186,7 @@ function parsePositiveInt2(raw) {
10622
11186
  }
10623
11187
  async function readDescriptionFile(path) {
10624
11188
  try {
10625
- return await readFile7(path, "utf8");
11189
+ return await readFile8(path, "utf8");
10626
11190
  } catch (error) {
10627
11191
  if (findErrorCode15(error, "ENOENT")) {
10628
11192
  throw new Error("Description source not found", { cause: error });
@@ -10826,8 +11390,8 @@ async function assertWorkspaceInitialized14(basouRoot) {
10826
11390
 
10827
11391
  // src/commands/view.ts
10828
11392
  import { spawn } from "child_process";
10829
- import { createHash } from "crypto";
10830
- import { basename as basename9, resolve as resolve13 } from "path";
11393
+ import { createHash as createHash2 } from "crypto";
11394
+ import { basename as basename10, resolve as resolve13 } from "path";
10831
11395
  import {
10832
11396
  assertBasouRootSafe as assertBasouRootSafe18,
10833
11397
  basouPaths as basouPaths22,
@@ -10841,7 +11405,7 @@ import { InvalidArgumentError as InvalidArgumentError7 } from "commander";
10841
11405
  import { createReadStream as createReadStream2 } from "fs";
10842
11406
  import { readdir as readdir3, stat as stat6 } from "fs/promises";
10843
11407
  import { homedir as homedir12 } from "os";
10844
- import { basename as basename7, dirname as dirname4, join as join17 } from "path";
11408
+ import { basename as basename8, dirname as dirname5, join as join17 } from "path";
10845
11409
  import { createInterface as createInterface2 } from "readline";
10846
11410
  import { basouPaths as basouPaths21, readManifest as readManifest13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
10847
11411
  function uncapturedTotal(result) {
@@ -10862,7 +11426,7 @@ async function checkPortfolioCoverage(workspaces, ctx = {}) {
10862
11426
  file,
10863
11427
  "claude-code",
10864
11428
  claudeTranscriptCwd,
10865
- listedDirs.has(basename7(dirname4(file)))
11429
+ listedDirs.has(basename8(dirname5(file)))
10866
11430
  );
10867
11431
  }
10868
11432
  }
@@ -11151,7 +11715,7 @@ function inertLines(result) {
11151
11715
 
11152
11716
  // src/lib/portfolio-safety.ts
11153
11717
  import { execFile } from "child_process";
11154
- import { lstat as lstat2, realpath as realpath2 } from "fs/promises";
11718
+ import { lstat as lstat2, realpath as realpath4 } from "fs/promises";
11155
11719
  import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
11156
11720
  import { promisify } from "util";
11157
11721
  import { readManifest as readManifest14 } from "@basou/core";
@@ -11161,7 +11725,7 @@ function errorCode(error) {
11161
11725
  }
11162
11726
  async function canonical(p) {
11163
11727
  try {
11164
- return await realpath2(p);
11728
+ return await realpath4(p);
11165
11729
  } catch {
11166
11730
  return resolve11(p);
11167
11731
  }
@@ -11330,7 +11894,7 @@ function formatSafetyReport(result) {
11330
11894
 
11331
11895
  // src/lib/view-server.ts
11332
11896
  import { createServer } from "http";
11333
- import { basename as basename8, join as join19, resolve as resolve12 } from "path";
11897
+ import { basename as basename9, join as join19, resolve as resolve12 } from "path";
11334
11898
  import {
11335
11899
  computeWorkStats as computeWorkStats2,
11336
11900
  enumerateApprovals as enumerateApprovals2,
@@ -11341,7 +11905,7 @@ import {
11341
11905
  loadTaskEntries as loadTaskEntries2,
11342
11906
  readAllEvents as readAllEvents2,
11343
11907
  readManifest as readManifest15,
11344
- readMarkdownFile as readMarkdownFile7,
11908
+ readMarkdownFile as readMarkdownFile9,
11345
11909
  readSessionYaml as readSessionYaml3,
11346
11910
  readTaskFile as readTaskFile2,
11347
11911
  renderDecisions as renderDecisions3,
@@ -12206,6 +12770,9 @@ async function handleWorkspacePost(res, sub, ws, body, deps, runExclusive) {
12206
12770
  const result = await runExclusive(
12207
12771
  () => refreshAll({ options: actionOptions, ctx: ws.importCtx, paths: ws.paths, nowIso })
12208
12772
  );
12773
+ if (result.orientation.status === "generated") {
12774
+ await warnIfPositionNamesOtherWorkspaces({ paths: ws.paths });
12775
+ }
12209
12776
  sendJson(res, 200, result);
12210
12777
  return true;
12211
12778
  }
@@ -12336,7 +12903,7 @@ async function rosterRepos(repoRoot, manifest, resolveRemoteUrl) {
12336
12903
  const remote = await resolveRemoteUrl(abs);
12337
12904
  const url = remote !== void 0 ? toBrowserUrl(remote) : null;
12338
12905
  return {
12339
- name: basename8(abs),
12906
+ name: basename9(abs),
12340
12907
  path: repo.path,
12341
12908
  ...url !== null ? { url } : {},
12342
12909
  ...repo.visibility !== void 0 ? { visibility: repo.visibility } : {}
@@ -12393,7 +12960,7 @@ async function taskDetail(ws, taskId) {
12393
12960
  }
12394
12961
  }
12395
12962
  async function decisionsView(ws, nowProvider) {
12396
- const fromDisk = await readMarkdownFile7(ws.paths.files.decisions);
12963
+ const fromDisk = await readMarkdownFile9(ws.paths.files.decisions);
12397
12964
  if (fromDisk !== null) {
12398
12965
  return { body: fromDisk, fromDisk: true };
12399
12966
  }
@@ -12416,7 +12983,7 @@ async function approvalsView(ws, nowProvider) {
12416
12983
  return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
12417
12984
  }
12418
12985
  async function handoffView(ws, nowProvider) {
12419
- const fromDisk = await readMarkdownFile7(ws.paths.files.handoff);
12986
+ const fromDisk = await readMarkdownFile9(ws.paths.files.handoff);
12420
12987
  if (fromDisk !== null) {
12421
12988
  return { body: fromDisk, fromDisk: true };
12422
12989
  }
@@ -12650,8 +13217,8 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
12650
13217
  } catch (error) {
12651
13218
  const notFound = error instanceof Error && error.message === "YAML file not found";
12652
13219
  return {
12653
- key: `ws-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
12654
- label: labelOverride ?? basename9(repoRoot),
13220
+ key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
13221
+ label: labelOverride ?? basename10(repoRoot),
12655
13222
  paths,
12656
13223
  repoRoot,
12657
13224
  importCtx,