@basou/cli 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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}
@@ -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(program2) {
816
790
  const channel = program2.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,226 @@ 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 realpath2, 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 readMarkdownFile5,
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(program2) {
2284
- const hook = program2.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
- }
2350
- }
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;
2373
- }
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);
2385
- }
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
- }
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);
2405
- }
2406
- } catch {
2407
- }
2408
- }
2409
- return records;
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;
2410
2278
  }
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);
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;
2416
2286
  }
2417
- return Buffer.concat(chunks).toString("utf8");
2287
+ return value;
2418
2288
  }
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");
2423
- 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();
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})`;
2431
2381
  }
2432
2382
  }
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);
2383
+
2384
+ // src/commands/orient.ts
2385
+ import {
2386
+ assertBasouRootSafe as assertBasouRootSafe7,
2387
+ basouPaths as basouPaths8,
2388
+ findErrorCode as findErrorCode6,
2389
+ renderOrientation as renderOrientation2,
2390
+ writeMarkdownFile as writeMarkdownFile4
2391
+ } from "@basou/core";
2392
+
2393
+ // src/lib/hosts-config.ts
2394
+ import { homedir as homedir5 } from "os";
2395
+ import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
2396
+ import { readYamlFile as readYamlFile4 } from "@basou/core";
2397
+ var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
2398
+ function expandTilde2(p) {
2399
+ if (p === "~") return homedir5();
2400
+ if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
2401
+ return p;
2440
2402
  }
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.");
2452
- }
2453
- out.minEdits = parsed;
2454
- }
2455
- return out;
2403
+ function isRecord2(value) {
2404
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2456
2405
  }
2457
- async function readSettings(path) {
2406
+ async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
2458
2407
  let raw;
2459
2408
  try {
2460
- raw = await readFile2(path, "utf8");
2409
+ raw = await readYamlFile4(configPath);
2461
2410
  } catch (error) {
2462
- if (error instanceof Error && error.code === "ENOENT") {
2463
- return { raw: null, parsed: void 0 };
2411
+ if (error instanceof Error && error.message === "YAML file not found") {
2412
+ return null;
2413
+ }
2414
+ if (error instanceof Error && error.message === "Failed to parse YAML content") {
2415
+ throw new Error("~/.basou/hosts.yaml is not valid YAML.");
2464
2416
  }
2465
2417
  throw error;
2466
2418
  }
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
- );
2477
- }
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;
2419
+ if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
2420
+ throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
2487
2421
  }
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;
2422
+ const seenPaths = /* @__PURE__ */ new Set();
2423
+ const seenLabels = /* @__PURE__ */ new Set();
2424
+ const result = [];
2425
+ for (const entry of raw.hosts) {
2426
+ if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
2427
+ throw new Error("Each host needs a non-empty string 'label'.");
2428
+ }
2429
+ const label = entry.label.trim();
2430
+ if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
2431
+ throw new Error("Each host needs a non-empty string 'path'.");
2432
+ }
2433
+ const expanded = expandTilde2(entry.path.trim());
2434
+ if (!isAbsolute2(expanded)) {
2435
+ throw new Error("Host paths must be absolute (or start with '~').");
2436
+ }
2437
+ const abs = resolve4(expanded);
2438
+ if (seenPaths.has(abs)) continue;
2439
+ if (seenLabels.has(label)) {
2440
+ throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
2441
+ }
2442
+ seenPaths.add(abs);
2443
+ seenLabels.add(label);
2444
+ result.push({ label, path: abs });
2496
2445
  }
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}`;
2446
+ return result;
2597
2447
  }
2598
2448
 
2449
+ // src/lib/provenance-actions.ts
2450
+ import {
2451
+ readMarkdownFile as readMarkdownFile4,
2452
+ renderDecisions as renderDecisions2,
2453
+ renderHandoff as renderHandoff2,
2454
+ renderOrientation,
2455
+ renderWithMarkers as renderWithMarkers3,
2456
+ writeMarkdownFile as writeMarkdownFile3
2457
+ } from "@basou/core";
2458
+
2599
2459
  // src/commands/import.ts
2600
2460
  import { createReadStream } from "fs";
2601
- import { readdir, readFile as readFile3, rm, stat as stat4 } from "fs/promises";
2461
+ import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
2602
2462
  import { homedir as homedir6 } from "os";
2603
- import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve4 } from "path";
2463
+ import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve5 } from "path";
2604
2464
  import { createInterface } from "readline";
2605
2465
  import {
2606
2466
  AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
@@ -2670,10 +2530,10 @@ function resolveSourceRoots(args) {
2670
2530
  const { projectFlags, manifest, repoRoot, cwd } = args;
2671
2531
  let resolved;
2672
2532
  if (projectFlags.length > 0) {
2673
- resolved = projectFlags.map((p) => resolve4(cwd, p));
2533
+ resolved = projectFlags.map((p) => resolve5(cwd, p));
2674
2534
  } else {
2675
2535
  const roots = manifest.import?.source_roots;
2676
- resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve4(repoRoot, r)) : [repoRoot];
2536
+ resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
2677
2537
  }
2678
2538
  return [...new Set(resolved)];
2679
2539
  }
@@ -2996,7 +2856,7 @@ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
2996
2856
  }
2997
2857
  async function pathExists(file) {
2998
2858
  try {
2999
- await stat4(file);
2859
+ await stat3(file);
3000
2860
  return true;
3001
2861
  } catch (error) {
3002
2862
  if (findErrorCode5(error, "ENOENT")) return false;
@@ -3005,7 +2865,7 @@ async function pathExists(file) {
3005
2865
  }
3006
2866
  async function statSize(file) {
3007
2867
  try {
3008
- return (await stat4(file)).size;
2868
+ return (await stat3(file)).size;
3009
2869
  } catch (error) {
3010
2870
  if (findErrorCode5(error, "ENOENT")) return void 0;
3011
2871
  throw error;
@@ -3091,7 +2951,7 @@ async function readFirstLine(file) {
3091
2951
  async function readJsonlRecords(file) {
3092
2952
  let buffer;
3093
2953
  try {
3094
- buffer = await readFile3(file);
2954
+ buffer = await readFile2(file);
3095
2955
  } catch (error) {
3096
2956
  if (findErrorCode5(error, "ENOENT")) {
3097
2957
  throw new Error("Source log not found", { cause: error });
@@ -3220,569 +3080,1129 @@ async function assertWorkspaceInitialized5(basouRoot) {
3220
3080
  }
3221
3081
  }
3222
3082
 
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(program2) {
3236
- program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
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;
3083
+ // src/lib/provenance-actions.ts
3084
+ async function captureImportJson(fn) {
3085
+ const stdout = [];
3086
+ const originalLog = console.log;
3087
+ const originalError = console.error;
3088
+ console.log = ((...args) => {
3089
+ stdout.push(args.map((a) => String(a)).join(" "));
3271
3090
  });
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 } : {}
3091
+ console.error = (() => {
3278
3092
  });
3279
- await writeManifest(paths, manifest, { force: options.force === true });
3280
3093
  try {
3281
- await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
3282
- } catch (error) {
3283
- renderGitignoreWarning(error, isVerbose(options));
3094
+ await fn();
3095
+ } finally {
3096
+ console.log = originalLog;
3097
+ console.error = originalError;
3284
3098
  }
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}`);
3099
+ for (let i = stdout.length - 1; i >= 0; i--) {
3100
+ const line = stdout[i];
3101
+ if (line === void 0) continue;
3102
+ try {
3103
+ const parsed = JSON.parse(line);
3104
+ if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
3105
+ return parsed;
3106
+ }
3107
+ } catch {
3108
+ }
3295
3109
  }
3110
+ throw new Error("Import produced no parseable result");
3296
3111
  }
3297
- async function resolveRepositoryRootForInit(cwd) {
3112
+ function readCount(value) {
3113
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
3114
+ }
3115
+ function isMissingSourceDir(error) {
3116
+ if (!(error instanceof Error)) return false;
3117
+ return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
3118
+ }
3119
+ async function runImport(adapter, fn) {
3298
3120
  try {
3299
- return await resolveRepositoryRoot7(cwd);
3121
+ const json = await captureImportJson(fn);
3122
+ return {
3123
+ adapter,
3124
+ status: "ran",
3125
+ importedCount: readCount(json.imported_count),
3126
+ replacedCount: readCount(json.replaced_count),
3127
+ reimportedCount: readCount(json.reimported_count),
3128
+ skippedNoAction: readCount(json.skipped_no_action),
3129
+ skippedAlreadyImported: readCount(json.skipped_already_imported),
3130
+ skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
3131
+ skippedDecreased: readCount(json.skipped_decreased),
3132
+ skippedDuplicate: readCount(json.skipped_duplicate),
3133
+ skippedUnverifiable: readCount(json.skipped_unverifiable),
3134
+ eventTotal: readCount(json.event_total),
3135
+ dryRun: json.dry_run === true
3136
+ };
3300
3137
  } 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
- });
3138
+ if (isMissingSourceDir(error)) {
3139
+ return { adapter, status: "skipped", reason: "no source logs for this project" };
3305
3140
  }
3306
3141
  throw error;
3307
3142
  }
3308
3143
  }
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(program2) {
3338
- program2.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);
3144
+ function importOptions(options) {
3145
+ return {
3146
+ all: true,
3147
+ json: true,
3148
+ ...options.project !== void 0 ? { project: options.project } : {},
3149
+ ...options.force === true ? { force: true } : {},
3150
+ ...options.dryRun === true ? { dryRun: true } : {}
3151
+ };
3152
+ }
3153
+ function importClaudeCode(options, ctx) {
3154
+ return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3155
+ }
3156
+ function importCodex(options, ctx) {
3157
+ return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
3158
+ }
3159
+ async function regenerateHandoff(paths, nowIso, callbacks) {
3160
+ const result = await renderHandoff2({ paths, nowIso, ...callbacks });
3161
+ const existing = await readMarkdownFile4(paths.files.handoff);
3162
+ await writeMarkdownFile3(
3163
+ paths.files.handoff,
3164
+ renderWithMarkers3(existing, result.body, "handoff.md")
3165
+ );
3166
+ return {
3167
+ sessionCount: result.sessionCount,
3168
+ taskCount: result.taskCount,
3169
+ decisionCount: result.decisionCount,
3170
+ pendingApprovalsCount: result.pendingApprovalsCount
3171
+ };
3172
+ }
3173
+ async function regenerateDecisions(paths, nowIso, callbacks) {
3174
+ const result = await renderDecisions2({ paths, nowIso, ...callbacks });
3175
+ const existing = await readMarkdownFile4(paths.files.decisions);
3176
+ await writeMarkdownFile3(
3177
+ paths.files.decisions,
3178
+ renderWithMarkers3(existing, result.body, "decisions.md")
3179
+ );
3180
+ return { decisionCount: result.decisionCount };
3181
+ }
3182
+ async function regenerateOrientation(paths, nowIso, callbacks) {
3183
+ const result = await renderOrientation({ paths, nowIso, ...callbacks });
3184
+ await writeMarkdownFile3(paths.files.orientation, `${result.body}
3185
+ `);
3186
+ return {
3187
+ sessionCount: result.sessionCount,
3188
+ inFlightTaskCount: result.inFlightTaskCount,
3189
+ pendingApprovalsCount: result.pendingApprovalsCount,
3190
+ suspectCount: result.suspectCount
3191
+ };
3192
+ }
3193
+ async function refreshAll(args) {
3194
+ const { options, ctx, paths, nowIso } = args;
3195
+ const dryRun = options.dryRun === true;
3196
+ const claudeCode = await importClaudeCode(options, ctx);
3197
+ const codex = await importCodex(options, ctx);
3198
+ if (dryRun) {
3199
+ const skipped = { status: "skipped", reason: "dry-run" };
3200
+ return {
3201
+ claudeCode,
3202
+ codex,
3203
+ handoff: skipped,
3204
+ decisions: skipped,
3205
+ orientation: skipped,
3206
+ dryRun
3207
+ };
3208
+ }
3209
+ const handoffCounts = await regenerateHandoff(paths, nowIso);
3210
+ const decisionCounts = await regenerateDecisions(paths, nowIso);
3211
+ const scoped = options.project !== void 0 && options.project.length > 0;
3212
+ const orientationCounts = await regenerateOrientation(
3213
+ paths,
3214
+ nowIso,
3215
+ scoped ? {} : {
3216
+ staleness: {
3217
+ newSessions: 0,
3218
+ updatedSessions: 0,
3219
+ unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
3220
+ }
3221
+ }
3222
+ );
3223
+ return {
3224
+ claudeCode,
3225
+ codex,
3226
+ handoff: { status: "generated", ...handoffCounts },
3227
+ decisions: { status: "generated", ...decisionCounts },
3228
+ orientation: { status: "generated", ...orientationCounts },
3229
+ dryRun
3230
+ };
3231
+ }
3232
+ function wouldImport(outcome) {
3233
+ return outcome.status === "ran" ? outcome.importedCount : 0;
3234
+ }
3235
+ function wouldUpdate(outcome) {
3236
+ return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
3237
+ }
3238
+ function wouldBlock(outcome) {
3239
+ return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
3240
+ }
3241
+ async function probeStaleness(args) {
3242
+ try {
3243
+ const dry = await refreshAll({
3244
+ options: { dryRun: true },
3245
+ ctx: args.ctx,
3246
+ paths: args.paths,
3247
+ nowIso: args.nowIso
3248
+ });
3249
+ return {
3250
+ newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
3251
+ updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
3252
+ unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
3253
+ };
3254
+ } catch {
3255
+ return null;
3256
+ }
3257
+ }
3258
+
3259
+ // src/commands/orient.ts
3260
+ function registerOrientCommand(program2) {
3261
+ program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
3262
+ "--refresh",
3263
+ "Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
3264
+ ).option("-v, --verbose", "Show error causes").action(async (opts) => {
3265
+ await runOrient(opts);
3343
3266
  });
3344
3267
  }
3345
- async function runNote(body, options, ctx = {}) {
3268
+ async function runOrient(options, ctx = {}) {
3346
3269
  try {
3347
- await doRunNote(body, options, ctx);
3270
+ await doRunOrient(options, ctx);
3348
3271
  } catch (error) {
3349
- renderCliError(error, {
3350
- verbose: isVerbose(options),
3351
- classifiers: [failedToFinalizeClassifier]
3352
- });
3272
+ renderCliError(error, { verbose: isVerbose(options) });
3353
3273
  process.exitCode = 1;
3354
3274
  }
3355
3275
  }
3356
- async function doRunNote(body, options, ctx) {
3357
- if (body.trim().length === 0) {
3358
- throw new Error("Note body must not be empty");
3359
- }
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>"\`).`
3276
+ async function doRunOrient(options, ctx) {
3277
+ const result = await renderOrientationForCwd(options, ctx);
3278
+ if (options.quiet === true) {
3279
+ console.log(
3280
+ `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
3364
3281
  );
3282
+ } else {
3283
+ console.log(result.body);
3365
3284
  }
3285
+ }
3286
+ async function renderOrientationForCwd(options, ctx) {
3366
3287
  const cwd = ctx.cwd ?? process.cwd();
3367
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
3288
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
3289
+ return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
3290
+ }
3291
+ async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
3368
3292
  const paths = basouPaths8(repositoryRoot);
3369
3293
  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();
3294
+ const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
3295
+ const probeCtx = { cwd: repositoryRoot };
3296
+ if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
3297
+ if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
3298
+ if (options.refresh === true) {
3299
+ await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
3300
+ }
3301
+ const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
3302
+ let federatedRoots = [];
3303
+ try {
3304
+ const hosts = await loadHostsConfig(ctx.hostsConfigPath);
3305
+ if (hosts !== null) {
3306
+ federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
3385
3307
  }
3386
- printNoteResult(options, {
3387
- mode: "attached",
3388
- sessionId,
3389
- eventId: result.eventId,
3390
- sessionStatus: result.sessionStatus,
3391
- body
3308
+ } catch (error) {
3309
+ console.error(
3310
+ `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
3311
+ );
3312
+ }
3313
+ const result = await renderOrientation2({
3314
+ paths,
3315
+ nowIso,
3316
+ staleness,
3317
+ verbose: options.verbose === true,
3318
+ federatedRoots,
3319
+ onWarning: (w, sid) => printReplayWarning(w, sid),
3320
+ onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
3321
+ onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
3322
+ onHostUnavailable: (host, error) => console.error(
3323
+ `basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
3324
+ )
3325
+ });
3326
+ if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
3327
+ `);
3328
+ return {
3329
+ body: result.body,
3330
+ sessionCount: result.sessionCount,
3331
+ inFlightTaskCount: result.inFlightTaskCount,
3332
+ pendingApprovalsCount: result.pendingApprovalsCount,
3333
+ suspectCount: result.suspectCount
3334
+ };
3335
+ }
3336
+ async function assertWorkspaceInitialized6(basouRoot) {
3337
+ try {
3338
+ await assertBasouRootSafe7(basouRoot);
3339
+ } catch (error) {
3340
+ if (findErrorCode6(error, "ENOENT")) {
3341
+ throw new Error("Workspace not initialized. Run 'basou init' first.");
3342
+ }
3343
+ throw error;
3344
+ }
3345
+ }
3346
+
3347
+ // src/commands/hook.ts
3348
+ var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
3349
+ function registerHookCommand(program2) {
3350
+ const hook = program2.command("hook").description(
3351
+ "Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
3352
+ );
3353
+ hook.command("session-start").description(
3354
+ "Codex SessionStart hook: print the current position of the workspace Codex was opened in (read from the payload's cwd) so Codex adds it to that session's context. Stays silent outside a basou workspace; never fails the session."
3355
+ ).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
3356
+ await runHookSessionStart();
3357
+ });
3358
+ hook.command("stop").description(
3359
+ "Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
3360
+ ).option(
3361
+ "--min-edits <n>",
3362
+ `Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
3363
+ ).option(
3364
+ "--block",
3365
+ "Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
3366
+ ).option(
3367
+ "--require-review",
3368
+ "Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
3369
+ ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
3370
+ const minEdits = parseMinEdits(options.minEdits);
3371
+ await runHookStop({
3372
+ ...minEdits !== void 0 ? { minEdits } : {},
3373
+ ...options.block === true ? { block: true } : {},
3374
+ ...options.requireReview === true ? { requireReview: true } : {}
3375
+ });
3376
+ });
3377
+ hook.command("install [target]").description(
3378
+ "Register a basou hook (reproducible, idempotent). Target `claude` (default): the Stop hook in ~/.claude/settings.json \u2014 advisory capture-only by default; --block opts into in-turn enforcement, --require-review into the review gate. Target `codex`: the SessionStart hook in ~/.codex/hooks.json, which hands each Codex session the position of the workspace it was opened in. Codex asks you to review and trust a new hook once before it runs."
3379
+ ).option(
3380
+ "--block",
3381
+ "claude: register the blocking (opt-in enforcement) form instead of advisory"
3382
+ ).option("--require-review", "claude: register with the opt-in review gate enabled").option("--min-edits <n>", "claude: pass a custom file-edit threshold to the registered hook").option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
3383
+ "--codex-config <path>",
3384
+ "codex: override the Codex config.toml path (intended for tests)"
3385
+ ).option(
3386
+ "--codex-face <path>",
3387
+ "codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
3388
+ ).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3389
+ await dispatchHookTarget(target, opts, {
3390
+ claude: () => runHookInstall(opts),
3391
+ codex: () => runCodexHookInstall(opts)
3392
+ });
3393
+ });
3394
+ hook.command("uninstall [target]").description(
3395
+ "Remove a basou hook, leaving other hooks intact. Target `claude` (default): the Stop hook in ~/.claude/settings.json. Target `codex`: the SessionStart hook in ~/.codex/hooks.json."
3396
+ ).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3397
+ await dispatchHookTarget(target, opts, {
3398
+ claude: () => runHookUninstall(opts),
3399
+ codex: () => runCodexHookUninstall(opts)
3392
3400
  });
3401
+ });
3402
+ hook.command("status [target]").description(
3403
+ "Report whether a basou hook is registered. Target `claude` (default): the Stop hook and its mode. Target `codex`: the SessionStart hook, and whether Codex has trusted it yet."
3404
+ ).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
3405
+ "--codex-config <path>",
3406
+ "codex: override the Codex config.toml path (intended for tests)"
3407
+ ).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3408
+ await dispatchHookTarget(target, opts, {
3409
+ claude: () => runHookStatus(opts),
3410
+ codex: () => runCodexHookStatus(opts)
3411
+ });
3412
+ });
3413
+ }
3414
+ async function dispatchHookTarget(target, options, handlers) {
3415
+ const resolved = target ?? "claude";
3416
+ if (resolved !== "claude" && resolved !== "codex") {
3417
+ renderCliError(
3418
+ new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
3419
+ { verbose: isVerbose(options) }
3420
+ );
3421
+ process.exitCode = 1;
3393
3422
  return;
3394
3423
  }
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
- ]
3424
+ await handlers[resolved]();
3425
+ }
3426
+ var HOOK_SESSION_START_HELP = `
3427
+ Register this hook reproducibly with 'basou hook install codex' (it writes the
3428
+ correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
3429
+ removes it; 'basou hook status codex' reports whether it is registered and
3430
+ whether Codex has trusted it.
3431
+
3432
+ Codex runs the hook when a session starts and passes the session's cwd on stdin.
3433
+ basou resolves the workspace from that cwd (a member repo resolves to its
3434
+ planning master, a workspace view to its master) and prints the workspace's
3435
+ current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
3436
+ session's context as developer text. The position is computed at that moment
3437
+ from that cwd and stored nowhere: a Codex opened in another workspace gets that
3438
+ workspace's position, and one opened outside any basou workspace (or before the
3439
+ desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
3440
+ user-global hook serves every workspace without any workspace's position ever
3441
+ being written where another workspace's session would read it.
3442
+
3443
+ Codex trusts hooks by hash. A newly installed or changed hook is skipped until
3444
+ you review it: the interactive CLI asks at startup ("Hooks need review"), the
3445
+ desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
3446
+ an untrusted hook silently.
3447
+ `;
3448
+ var HOOK_STOP_HELP = `
3449
+ Register this Stop hook reproducibly with 'basou hook install' (it writes the
3450
+ correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
3451
+ removes it; 'basou hook status' reports whether it is registered.
3452
+
3453
+ On every turn end basou inspects the session transcript. If the session did
3454
+ content-substantive work but ran no capture verb ('basou decision capture' /
3455
+ 'decision record' / 'note'), it reminds the agent to record the why / next step.
3456
+ Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
3457
+ answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
3458
+ git status) does NOT count.
3459
+
3460
+ With --require-review (opt-in, 'basou hook install --require-review') it also
3461
+ reminds when the session SHIPPED substantive code (git push / git merge /
3462
+ gh pr create|merge) without recording a review ('basou review record'). This
3463
+ gate is off by default; when on, its reminder is composed into the same
3464
+ envelope as the capture reminder.
3465
+
3466
+ By default the reminder is non-blocking: Claude sees it and may act on it or
3467
+ stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
3468
+ returns decision:block, holding the agent in-turn to act on the reminder; the
3469
+ 'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
3470
+ single turn. Either way the hook fails open: a bad payload or unreadable
3471
+ transcript exits cleanly with no output.
3472
+ `;
3473
+ async function runHookStop(options, ctx = {}) {
3474
+ try {
3475
+ await doRunHookStop(options, ctx);
3476
+ } catch {
3477
+ }
3478
+ }
3479
+ async function doRunHookStop(options, ctx) {
3480
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3481
+ const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
3482
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3483
+ const raw = await readStdin();
3484
+ if (raw.trim().length === 0) return;
3485
+ let payload;
3486
+ try {
3487
+ payload = JSON.parse(raw);
3488
+ } catch {
3489
+ return;
3490
+ }
3491
+ if (typeof payload !== "object" || payload === null) return;
3492
+ const fields = payload;
3493
+ if (fields.stop_hook_active === true) return;
3494
+ const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
3495
+ if (transcriptPath.length === 0) return;
3496
+ let transcript;
3497
+ try {
3498
+ transcript = await readTranscript(transcriptPath);
3499
+ } catch {
3500
+ return;
3501
+ }
3502
+ const records = parseTranscript(transcript);
3503
+ const evaluation = evaluateStopHook({
3504
+ records,
3505
+ // stop_hook_active was already handled by the early return above.
3506
+ stopHookActive: false,
3507
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3410
3508
  });
3411
- printNoteResult(options, {
3412
- mode: "ad-hoc",
3413
- sessionId: adHoc.sessionId,
3414
- eventId: adHoc.targetEventIds[0],
3415
- sessionStatus: "completed",
3416
- body
3509
+ const parts = [];
3510
+ if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
3511
+ if (options.requireReview === true && evaluation.review.fires) {
3512
+ parts.push(evaluation.review.additionalContext);
3513
+ }
3514
+ if (parts.length === 0) return;
3515
+ const reason = parts.join("\n\n");
3516
+ const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
3517
+ hookSpecificOutput: {
3518
+ hookEventName: "Stop",
3519
+ additionalContext: reason
3520
+ }
3417
3521
  });
3522
+ write(`${payloadJson}
3523
+ `);
3418
3524
  }
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
- };
3525
+ async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
3526
+ const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
3527
+ portfolioConfigPath
3528
+ });
3529
+ if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
3530
+ throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
3531
+ }
3532
+ const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
3533
+ return { body: rendered.body };
3432
3534
  }
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}`;
3535
+ async function isRegisteredWorkspace(root, portfolioConfigPath) {
3536
+ let entries;
3537
+ try {
3538
+ entries = await loadPortfolioConfig(portfolioConfigPath);
3539
+ } catch {
3540
+ return false;
3541
+ }
3542
+ const rootReal = await realpath2(root).catch(() => root);
3543
+ for (const entry of entries) {
3544
+ const entryReal = await realpath2(entry.path).catch(() => null);
3545
+ if (entryReal !== null && entryReal === rootReal) return true;
3546
+ }
3547
+ return false;
3437
3548
  }
3438
- function parseBody(raw) {
3439
- if (raw.trim().length === 0) {
3440
- throw new InvalidArgumentError2("Note body must not be empty");
3549
+ async function runHookSessionStart(ctx = {}) {
3550
+ try {
3551
+ await doRunHookSessionStart(ctx);
3552
+ } catch {
3553
+ }
3554
+ }
3555
+ async function doRunHookSessionStart(ctx) {
3556
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
3557
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
3558
+ const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
3559
+ const raw = await readStdin();
3560
+ if (raw.trim().length === 0) return;
3561
+ let payload;
3562
+ try {
3563
+ payload = JSON.parse(raw);
3564
+ } catch {
3565
+ return;
3566
+ }
3567
+ if (typeof payload !== "object" || payload === null) return;
3568
+ const cwd = payload.cwd;
3569
+ if (typeof cwd !== "string" || cwd.length === 0) return;
3570
+ let body;
3571
+ try {
3572
+ body = (await render(cwd)).body;
3573
+ } catch {
3574
+ return;
3441
3575
  }
3442
- return raw;
3576
+ if (body.trim().length === 0) return;
3577
+ write(`${body.replace(/\s+$/, "")}
3578
+ `);
3443
3579
  }
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;
3580
+ function parseTranscript(transcript) {
3581
+ const records = [];
3582
+ for (const line of transcript.split(/\r?\n/)) {
3583
+ if (line.trim().length === 0) continue;
3584
+ try {
3585
+ const parsed = JSON.parse(line);
3586
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3587
+ records.push(parsed);
3588
+ }
3589
+ } catch {
3590
+ }
3457
3591
  }
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})`);
3592
+ return records;
3593
+ }
3594
+ async function defaultReadStdin() {
3595
+ if (process.stdin.isTTY === true) return "";
3596
+ const chunks = [];
3597
+ for await (const chunk of process.stdin) {
3598
+ chunks.push(chunk);
3462
3599
  }
3600
+ return Buffer.concat(chunks).toString("utf8");
3463
3601
  }
3464
- async function assertWorkspaceInitialized6(basouRoot) {
3602
+ async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
3603
+ const { size } = await stat4(path);
3604
+ if (size <= maxBytes) return readFile3(path, "utf8");
3605
+ const handle = await open2(path, "r");
3465
3606
  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;
3607
+ const buffer = Buffer.alloc(maxBytes);
3608
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
3609
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
3610
+ const firstNewline = text.indexOf("\n");
3611
+ return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
3612
+ } finally {
3613
+ await handle.close();
3472
3614
  }
3473
3615
  }
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;
3616
+ function parseMinEdits(raw) {
3617
+ if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
3618
+ return Number(raw);
3493
3619
  }
3494
- function isRecord2(value) {
3495
- return typeof value === "object" && value !== null && !Array.isArray(value);
3620
+ var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
3621
+ function resolveCliEntry() {
3622
+ return fileURLToPath(import.meta.url);
3496
3623
  }
3497
- async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
3624
+ function normalizeInstallOptions(raw) {
3625
+ const out = {};
3626
+ if (raw.block === true) out.block = true;
3627
+ if (raw.requireReview === true) out.requireReview = true;
3628
+ if (raw.settings !== void 0) out.settings = raw.settings;
3629
+ if (raw.hooks !== void 0) out.hooks = raw.hooks;
3630
+ if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
3631
+ if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
3632
+ if (raw.dryRun === true) out.dryRun = true;
3633
+ if (raw.verbose === true) out.verbose = true;
3634
+ if (raw.minEdits !== void 0) {
3635
+ const parsed = parseMinEdits(raw.minEdits);
3636
+ if (parsed === void 0) {
3637
+ throw new Error("--min-edits must be a non-negative integer.");
3638
+ }
3639
+ out.minEdits = parsed;
3640
+ }
3641
+ return out;
3642
+ }
3643
+ async function readSettings(path) {
3498
3644
  let raw;
3499
3645
  try {
3500
- raw = await readYamlFile4(configPath);
3646
+ raw = await readFile3(path, "utf8");
3501
3647
  } 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.");
3648
+ if (error instanceof Error && error.code === "ENOENT") {
3649
+ return { raw: null, parsed: void 0 };
3507
3650
  }
3508
3651
  throw error;
3509
3652
  }
3510
- if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
3511
- throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
3653
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3654
+ try {
3655
+ return { raw, parsed: JSON.parse(raw) };
3656
+ } catch (error) {
3657
+ throw new Error(
3658
+ "The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
3659
+ {
3660
+ cause: error
3661
+ }
3662
+ );
3512
3663
  }
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 });
3664
+ }
3665
+ async function backupSettingsOnce(path, raw) {
3666
+ if (raw === null) return;
3667
+ const bak = `${path}.basou-bak`;
3668
+ try {
3669
+ await stat4(bak);
3670
+ return;
3671
+ } catch (error) {
3672
+ if (!(error instanceof Error && error.code === "ENOENT")) throw error;
3536
3673
  }
3537
- return result;
3674
+ await writeFileDurable(bak, raw);
3538
3675
  }
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(" "));
3676
+ async function runHookInstall(options, ctx = {}) {
3677
+ try {
3678
+ await doRunHookInstall(normalizeInstallOptions(options), ctx);
3679
+ } catch (error) {
3680
+ renderCliError(error, { verbose: isVerbose(options) });
3681
+ process.exitCode = 1;
3682
+ }
3683
+ }
3684
+ async function doRunHookInstall(options, ctx = {}) {
3685
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3686
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3687
+ const command = buildStopHookCommand({
3688
+ cliEntry,
3689
+ ...options.block === true ? { block: true } : {},
3690
+ ...options.requireReview === true ? { requireReview: true } : {},
3691
+ ...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
3555
3692
  });
3556
- console.error = (() => {
3693
+ const mode = describeHookMode({
3694
+ block: options.block === true,
3695
+ review: options.requireReview === true
3557
3696
  });
3697
+ await assertNotSymlink(settingsPath);
3698
+ const { raw, parsed } = await readSettings(settingsPath);
3699
+ const { settings, action } = upsertStopHook(parsed, command);
3700
+ const newBody = `${JSON.stringify(settings, null, 2)}
3701
+ `;
3702
+ if (raw !== null && newBody === raw) {
3703
+ console.log(`The basou Stop hook is already registered (${mode}); no change.`);
3704
+ return;
3705
+ }
3706
+ if (options.dryRun === true) {
3707
+ console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
3708
+ return;
3709
+ }
3710
+ const recheck = await readSettings(settingsPath);
3711
+ if (recheck.raw !== raw) {
3712
+ throw new Error(
3713
+ "The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
3714
+ );
3715
+ }
3716
+ await backupSettingsOnce(settingsPath, raw);
3717
+ await writeFileDurable(settingsPath, newBody);
3718
+ console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
3719
+ }
3720
+ async function runHookUninstall(options) {
3558
3721
  try {
3559
- await fn();
3560
- } finally {
3561
- console.log = originalLog;
3562
- console.error = originalError;
3722
+ await doRunHookUninstall(normalizeInstallOptions(options));
3723
+ } catch (error) {
3724
+ renderCliError(error, { verbose: isVerbose(options) });
3725
+ process.exitCode = 1;
3563
3726
  }
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
- }
3727
+ }
3728
+ async function doRunHookUninstall(options) {
3729
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3730
+ await assertNotSymlink(settingsPath);
3731
+ const { raw, parsed } = await readSettings(settingsPath);
3732
+ if (raw === null) {
3733
+ console.log("No settings.json; nothing to remove.");
3734
+ return;
3735
+ }
3736
+ const { settings, action } = removeStopHook(parsed);
3737
+ if (action === "absent") {
3738
+ console.log("No basou Stop hook found; nothing removed.");
3739
+ return;
3740
+ }
3741
+ const newBody = `${JSON.stringify(settings, null, 2)}
3742
+ `;
3743
+ if (options.dryRun === true) {
3744
+ console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
3745
+ return;
3746
+ }
3747
+ const recheck = await readSettings(settingsPath);
3748
+ if (recheck.raw !== raw) {
3749
+ throw new Error(
3750
+ "The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
3751
+ );
3752
+ }
3753
+ await backupSettingsOnce(settingsPath, raw);
3754
+ await writeFileDurable(settingsPath, newBody);
3755
+ console.log("Removed the basou Stop hook from settings.json.");
3756
+ }
3757
+ async function runHookStatus(options) {
3758
+ try {
3759
+ await doRunHookStatus(normalizeInstallOptions(options));
3760
+ } catch (error) {
3761
+ renderCliError(error, { verbose: isVerbose(options) });
3762
+ process.exitCode = 1;
3574
3763
  }
3575
- throw new Error("Import produced no parseable result");
3576
3764
  }
3577
- function readCount(value) {
3578
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
3765
+ async function doRunHookStatus(options) {
3766
+ const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
3767
+ const { parsed } = await readSettings(settingsPath);
3768
+ const command = findBasouStopHookCommand(parsed);
3769
+ if (command === null) {
3770
+ console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
3771
+ return;
3772
+ }
3773
+ const mode = describeHookMode({
3774
+ block: / --block\b/.test(command),
3775
+ review: / --require-review\b/.test(command)
3776
+ });
3777
+ console.log(`basou Stop hook: registered, ${mode}.`);
3579
3778
  }
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";
3779
+ function describeHookMode(tiers) {
3780
+ const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
3781
+ const gates = tiers.review ? "capture + review" : "capture";
3782
+ return `${enforcement}, ${gates}`;
3583
3783
  }
3584
- async function runImport(adapter, fn) {
3784
+ var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
3785
+ var LEFTOVER_FACE_NOTE = (label) => `${label} still carries an orientation block rendered by an earlier basou (0.39 or before); every Codex session on this machine reads it. \`basou channel clear codex\` removes it.`;
3786
+ async function faceHasLeftoverOrientationBlock(facePath) {
3585
3787
  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
- };
3788
+ const existing = await readMarkdownFile5(facePath);
3789
+ if (existing === null) return false;
3790
+ const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
3791
+ return section.kind !== "no_markers";
3792
+ } catch {
3793
+ return false;
3794
+ }
3795
+ }
3796
+ var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
3797
+ var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
3798
+ async function readHooksFile(path) {
3799
+ let raw;
3800
+ try {
3801
+ raw = await readFile3(path, "utf8");
3602
3802
  } catch (error) {
3603
- if (isMissingSourceDir(error)) {
3604
- return { adapter, status: "skipped", reason: "no source logs for this project" };
3803
+ if (error instanceof Error && error.code === "ENOENT") {
3804
+ return { raw: null, parsed: void 0 };
3605
3805
  }
3606
3806
  throw error;
3607
3807
  }
3808
+ if (raw.trim().length === 0) return { raw, parsed: void 0 };
3809
+ try {
3810
+ return { raw, parsed: JSON.parse(raw) };
3811
+ } catch (error) {
3812
+ throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
3813
+ cause: error
3814
+ });
3815
+ }
3608
3816
  }
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
- };
3817
+ var CODEX_TRUST_NOTE = "Codex reviews a new or changed hook once before running it: start `codex` in a terminal and trust it when asked (desktop app: Settings -> Hooks). Until then the hook is skipped silently.";
3818
+ async function runCodexHookInstall(options, ctx = {}) {
3819
+ try {
3820
+ await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
3821
+ } catch (error) {
3822
+ renderCliError(error, { verbose: isVerbose(options) });
3823
+ process.exitCode = 1;
3824
+ }
3617
3825
  }
3618
- function importClaudeCode(options, ctx) {
3619
- return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
3826
+ async function doRunCodexHookInstall(options, ctx = {}) {
3827
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3828
+ const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
3829
+ const command = buildSessionStartHookCommand({ cliEntry });
3830
+ await assertNotSymlink(hooksPath);
3831
+ const { raw, parsed } = await readHooksFile(hooksPath);
3832
+ const { hooksFile, action } = upsertSessionStartHook(parsed, command);
3833
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
3834
+ `;
3835
+ if (action === "unchanged" || raw !== null && newBody === raw) {
3836
+ console.log("The basou Codex SessionStart hook is already registered; no change.");
3837
+ await reportCodexHookState(hooksPath, hooksFile, options);
3838
+ return;
3839
+ }
3840
+ if (options.dryRun === true) {
3841
+ console.log(
3842
+ `[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
3843
+ );
3844
+ return;
3845
+ }
3846
+ const recheck = await readHooksFile(hooksPath);
3847
+ if (recheck.raw !== raw) {
3848
+ throw new Error(
3849
+ "The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
3850
+ );
3851
+ }
3852
+ await backupSettingsOnce(hooksPath, raw);
3853
+ await writeFileDurable(hooksPath, newBody);
3854
+ console.log(
3855
+ `${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
3856
+ );
3857
+ await reportCodexHookState(hooksPath, hooksFile, options);
3620
3858
  }
3621
- function importCodex(options, ctx) {
3622
- return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
3859
+ async function reportCodexHookState(hooksPath, hooksFile, options) {
3860
+ const location = findBasouSessionStartHook(hooksFile);
3861
+ if (location !== null) {
3862
+ const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
3863
+ console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
3864
+ if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
3865
+ }
3866
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
3867
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
3868
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
3869
+ }
3623
3870
  }
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
- };
3871
+ async function runCodexHookUninstall(options) {
3872
+ try {
3873
+ await doRunCodexHookUninstall(normalizeInstallOptions(options));
3874
+ } catch (error) {
3875
+ renderCliError(error, { verbose: isVerbose(options) });
3876
+ process.exitCode = 1;
3877
+ }
3637
3878
  }
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 };
3879
+ async function doRunCodexHookUninstall(options) {
3880
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3881
+ await assertNotSymlink(hooksPath);
3882
+ const { raw, parsed } = await readHooksFile(hooksPath);
3883
+ if (raw === null) {
3884
+ console.log("No hooks.json; nothing to remove.");
3885
+ return;
3886
+ }
3887
+ const { hooksFile, action } = removeSessionStartHook(parsed);
3888
+ if (action === "absent") {
3889
+ console.log("No basou Codex SessionStart hook found; nothing removed.");
3890
+ return;
3891
+ }
3892
+ const newBody = `${JSON.stringify(hooksFile, null, 2)}
3893
+ `;
3894
+ if (options.dryRun === true) {
3895
+ console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
3896
+ return;
3897
+ }
3898
+ const recheck = await readHooksFile(hooksPath);
3899
+ if (recheck.raw !== raw) {
3900
+ throw new Error(
3901
+ "The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
3902
+ );
3903
+ }
3904
+ await backupSettingsOnce(hooksPath, raw);
3905
+ await writeFileDurable(hooksPath, newBody);
3906
+ console.log("Removed the basou Codex SessionStart hook from hooks.json.");
3646
3907
  }
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
- };
3908
+ async function runCodexHookStatus(options) {
3909
+ try {
3910
+ await doRunCodexHookStatus(normalizeInstallOptions(options));
3911
+ } catch (error) {
3912
+ renderCliError(error, { verbose: isVerbose(options) });
3913
+ process.exitCode = 1;
3914
+ }
3657
3915
  }
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" };
3916
+ async function doRunCodexHookStatus(options) {
3917
+ const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
3918
+ const { parsed } = await readHooksFile(hooksPath);
3919
+ const location = findBasouSessionStartHook(parsed);
3920
+ if (location === null) {
3921
+ console.log(
3922
+ "basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
3923
+ );
3924
+ const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
3925
+ if (await faceHasLeftoverOrientationBlock(facePath)) {
3926
+ console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
3927
+ }
3928
+ return;
3929
+ }
3930
+ const matcher = location.matcher ?? "(every source)";
3931
+ console.log(
3932
+ `basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
3933
+ );
3934
+ await reportCodexHookState(hooksPath, parsed, options);
3935
+ }
3936
+ async function codexHookTrustFor(hooksPath, location, configPath) {
3937
+ const fields = commandHandlerFields(location.handler);
3938
+ if (fields === null)
3939
+ return { status: "unknown", detail: "the installed handler is not a command hook" };
3940
+ let configToml;
3941
+ try {
3942
+ configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
3943
+ } catch (error) {
3944
+ if (error instanceof Error && error.code === "ENOENT") {
3945
+ return { status: "untrusted" };
3946
+ }
3665
3947
  return {
3666
- claudeCode,
3667
- codex,
3668
- handoff: skipped,
3669
- decisions: skipped,
3670
- orientation: skipped,
3671
- dryRun
3948
+ status: "unknown",
3949
+ detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
3672
3950
  };
3673
3951
  }
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
- }
3952
+ const key = codexHookStateKey(
3953
+ hooksPath,
3954
+ "session_start",
3955
+ location.groupIndex,
3956
+ location.handlerIndex
3687
3957
  );
3688
- return {
3689
- claudeCode,
3690
- codex,
3691
- handoff: { status: "generated", ...handoffCounts },
3692
- decisions: { status: "generated", ...decisionCounts },
3693
- orientation: { status: "generated", ...orientationCounts },
3694
- dryRun
3695
- };
3958
+ const state = readCodexHookState(configToml, key);
3959
+ const current = computeCodexHookIdentityHash({
3960
+ eventKey: "session_start",
3961
+ matcher: location.matcher,
3962
+ handler: fields
3963
+ });
3964
+ return judgeCodexHookTrust(state, current);
3696
3965
  }
3697
- function wouldImport(outcome) {
3698
- return outcome.status === "ran" ? outcome.importedCount : 0;
3966
+
3967
+ // src/commands/init.ts
3968
+ import { basename as basename4, relative, resolve as resolve6 } from "path";
3969
+ import {
3970
+ appendBasouGitignore,
3971
+ createManifest,
3972
+ ensureBasouDirectory,
3973
+ resolveRepositoryRoot as resolveRepositoryRoot7,
3974
+ writeManifest
3975
+ } from "@basou/core";
3976
+ function collectValue(value, previous) {
3977
+ return [...previous, value];
3699
3978
  }
3700
- function wouldUpdate(outcome) {
3701
- return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
3979
+ function registerInitCommand(program2) {
3980
+ program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
3981
+ "--repo-url <url>",
3982
+ "Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
3983
+ ).option(
3984
+ "--source-root <path>",
3985
+ "Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
3986
+ collectValue,
3987
+ []
3988
+ ).option(
3989
+ "--local-only",
3990
+ "Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
3991
+ ).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
3992
+ await runInit(options);
3993
+ });
3994
+ }
3995
+ async function runInit(options, ctx = {}) {
3996
+ try {
3997
+ await doRunInit(options, ctx);
3998
+ } catch (error) {
3999
+ renderCliError(error, { verbose: isVerbose(options) });
4000
+ process.exitCode = 1;
4001
+ }
4002
+ }
4003
+ async function doRunInit(options, ctx) {
4004
+ const cwd = ctx.cwd ?? process.cwd();
4005
+ const repositoryRoot = await resolveRepositoryRootForInit(cwd);
4006
+ const workspaceName = options.name ?? basename4(repositoryRoot);
4007
+ if (options.repoUrl !== void 0) {
4008
+ console.error(
4009
+ "Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
4010
+ );
4011
+ }
4012
+ const sourceRoots = (options.sourceRoot ?? []).map((p) => {
4013
+ const rel = relative(repositoryRoot, resolve6(cwd, p));
4014
+ return rel === "" ? "." : rel;
4015
+ });
4016
+ const paths = await ensureBasouDirectory(repositoryRoot);
4017
+ const manifest = createManifest({
4018
+ workspaceName,
4019
+ ...options.projectName !== void 0 ? { projectName: options.projectName } : {},
4020
+ ...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
4021
+ ...sourceRoots.length > 0 ? { sourceRoots } : {}
4022
+ });
4023
+ await writeManifest(paths, manifest, { force: options.force === true });
4024
+ try {
4025
+ await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
4026
+ } catch (error) {
4027
+ renderGitignoreWarning(error, isVerbose(options));
4028
+ }
4029
+ console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
3702
4030
  }
3703
- function wouldBlock(outcome) {
3704
- return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
4031
+ function renderGitignoreWarning(error, verbose) {
4032
+ const baseMessage = error instanceof Error ? error.message : String(error);
4033
+ console.error(
4034
+ `Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
4035
+ );
4036
+ if (verbose && error instanceof Error) {
4037
+ const label = extractCauseLabel(error);
4038
+ if (label !== void 0) console.error(`Caused by: ${label}`);
4039
+ }
3705
4040
  }
3706
- async function probeStaleness(args) {
4041
+ async function resolveRepositoryRootForInit(cwd) {
3707
4042
  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;
4043
+ return await resolveRepositoryRoot7(cwd);
4044
+ } catch (error) {
4045
+ if (error instanceof Error && error.message === "Not a git repository") {
4046
+ throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
4047
+ cause: error
4048
+ });
4049
+ }
4050
+ throw error;
3721
4051
  }
3722
4052
  }
3723
4053
 
3724
- // src/commands/orient.ts
3725
- function registerOrientCommand(program2) {
3726
- program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
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);
4054
+ // src/commands/note.ts
4055
+ import {
4056
+ acquireLock as acquireLock4,
4057
+ appendEventToExistingSession as appendEventToExistingSession2,
4058
+ assertBasouRootSafe as assertBasouRootSafe8,
4059
+ basouPaths as basouPaths9,
4060
+ createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
4061
+ findErrorCode as findErrorCode7,
4062
+ readManifest as readManifest5,
4063
+ resolveSessionId as resolveSessionId2
4064
+ } from "@basou/core";
4065
+ import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
4066
+ var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
4067
+ "list",
4068
+ "ls",
4069
+ "show",
4070
+ "get",
4071
+ "add",
4072
+ "new",
4073
+ "edit",
4074
+ "rm",
4075
+ "remove",
4076
+ "delete",
4077
+ "help"
4078
+ ]);
4079
+ var LABEL_BODY_MAX = 80;
4080
+ var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
4081
+ function registerNoteCommand(program2) {
4082
+ program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
4083
+ "--session <session_id>",
4084
+ "Attach to an existing session; otherwise an ad-hoc session is created"
4085
+ ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
4086
+ await runNote(body, options);
3731
4087
  });
3732
4088
  }
3733
- async function runOrient(options, ctx = {}) {
4089
+ async function runNote(body, options, ctx = {}) {
3734
4090
  try {
3735
- await doRunOrient(options, ctx);
4091
+ await doRunNote(body, options, ctx);
3736
4092
  } catch (error) {
3737
- renderCliError(error, { verbose: isVerbose(options) });
4093
+ renderCliError(error, {
4094
+ verbose: isVerbose(options),
4095
+ classifiers: [failedToFinalizeClassifier]
4096
+ });
3738
4097
  process.exitCode = 1;
3739
4098
  }
3740
4099
  }
3741
- async function doRunOrient(options, ctx) {
4100
+ async function doRunNote(body, options, ctx) {
4101
+ if (body.trim().length === 0) {
4102
+ throw new Error("Note body must not be empty");
4103
+ }
4104
+ const reserved = body.trim().toLowerCase();
4105
+ if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
4106
+ throw new Error(
4107
+ `'basou note' records a free-text note and has no '${body.trim()}' subcommand. To record a note, pass its full text (e.g. \`basou note "<your note>"\`).`
4108
+ );
4109
+ }
3742
4110
  const cwd = ctx.cwd ?? process.cwd();
3743
- const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
4111
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
3744
4112
  const paths = basouPaths9(repositoryRoot);
3745
4113
  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 }));
4114
+ const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
4115
+ const occurredAt = now.toISOString();
4116
+ if (options.session !== void 0) {
4117
+ const sessionId = await resolveSessionId2(paths, options.session);
4118
+ const sesId = sessionId;
4119
+ const sessionLock = await acquireLock4(paths, "session", sesId);
4120
+ let result;
4121
+ try {
4122
+ result = await appendEventToExistingSession2({
4123
+ paths,
4124
+ sessionId: sesId,
4125
+ eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
4126
+ });
4127
+ } finally {
4128
+ await sessionLock.release();
3759
4129
  }
3760
- } catch (error) {
3761
- console.error(
3762
- `basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
3763
- );
4130
+ printNoteResult(options, {
4131
+ mode: "attached",
4132
+ sessionId,
4133
+ eventId: result.eventId,
4134
+ sessionStatus: result.sessionStatus,
4135
+ body
4136
+ });
4137
+ return;
3764
4138
  }
3765
- const result = await renderOrientation2({
4139
+ const manifest = await readManifest5(paths);
4140
+ const adHoc = await createAdHocSessionWithEvent2({
3766
4141
  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
- )
4142
+ manifest,
4143
+ label: buildAdHocLabel2(body),
4144
+ occurredAt,
4145
+ sessionSource: "human",
4146
+ workingDirectory: repositoryRoot,
4147
+ invocation: {
4148
+ command: "basou note",
4149
+ args: [body]
4150
+ },
4151
+ targetEventBuilders: [
4152
+ (sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
4153
+ ]
3777
4154
  });
3778
- await writeMarkdownFile4(paths.files.orientation, `${result.body}
3779
- `);
3780
- if (options.quiet === true) {
4155
+ printNoteResult(options, {
4156
+ mode: "ad-hoc",
4157
+ sessionId: adHoc.sessionId,
4158
+ eventId: adHoc.targetEventIds[0],
4159
+ sessionStatus: "completed",
4160
+ body
4161
+ });
4162
+ }
4163
+ function buildNoteEvent(input) {
4164
+ return {
4165
+ schema_version: "0.1.0",
4166
+ id: input.eventId,
4167
+ session_id: input.sessionId,
4168
+ occurred_at: input.occurredAt,
4169
+ source: "local-cli",
4170
+ type: "note_added",
4171
+ body: input.body,
4172
+ // `basou note` is the resume-hint command; mark it so orientation surfaces
4173
+ // it as the next step and a plain `basou session note` annotation does not.
4174
+ kind: "next_step"
4175
+ };
4176
+ }
4177
+ function buildAdHocLabel2(body) {
4178
+ const oneLine2 = body.replace(/\s+/g, " ").trim();
4179
+ const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
4180
+ return `Ad-hoc note: ${truncated}`;
4181
+ }
4182
+ function parseBody(raw) {
4183
+ if (raw.trim().length === 0) {
4184
+ throw new InvalidArgumentError2("Note body must not be empty");
4185
+ }
4186
+ return raw;
4187
+ }
4188
+ function printNoteResult(options, result) {
4189
+ const sid = shortSessionId(result.sessionId);
4190
+ if (options.json === true) {
3781
4191
  console.log(
3782
- `Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
4192
+ JSON.stringify({
4193
+ event_id: result.eventId,
4194
+ session_id: result.sessionId,
4195
+ session_status: result.sessionStatus,
4196
+ mode: result.mode,
4197
+ body: result.body
4198
+ })
3783
4199
  );
4200
+ return;
4201
+ }
4202
+ if (result.mode === "ad-hoc") {
4203
+ console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
3784
4204
  } else {
3785
- console.log(result.body);
4205
+ console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
3786
4206
  }
3787
4207
  }
3788
4208
  async function assertWorkspaceInitialized7(basouRoot) {
@@ -3920,7 +4340,7 @@ import {
3920
4340
  GENERATED_START,
3921
4341
  instructionMode,
3922
4342
  isGitNotFound,
3923
- parseMarkers as parseMarkers2,
4343
+ parseMarkers as parseMarkers3,
3924
4344
  pathBasename,
3925
4345
  planArchive,
3926
4346
  planGitignore,
@@ -3928,7 +4348,7 @@ import {
3928
4348
  planRosterAdoption,
3929
4349
  planWorkspaceView,
3930
4350
  readManifest as readManifest6,
3931
- readMarkdownFile as readMarkdownFile5,
4351
+ readMarkdownFile as readMarkdownFile6,
3932
4352
  reconcileSourceRoots,
3933
4353
  removeMarkerSection as removeMarkerSection2,
3934
4354
  renderAnchorStarter,
@@ -5362,7 +5782,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5362
5782
  const canonicalName = basename5(real);
5363
5783
  let content;
5364
5784
  try {
5365
- content = await readMarkdownFile5(canonicalFileFor(anchorReal, canonicalName));
5785
+ content = await readMarkdownFile6(canonicalFileFor(anchorReal, canonicalName));
5366
5786
  } catch {
5367
5787
  return {
5368
5788
  ...declared,
@@ -5382,7 +5802,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
5382
5802
  canonicalPresent: false
5383
5803
  };
5384
5804
  }
5385
- const section = parseMarkers2(content);
5805
+ const section = parseMarkers3(content);
5386
5806
  return {
5387
5807
  ...declared,
5388
5808
  isAnchor: false,
@@ -5445,7 +5865,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
5445
5865
  }
5446
5866
  return { kind: "unreadable", canonicalName: viewName, viewName };
5447
5867
  }
5448
- const section = parseMarkers2(content);
5868
+ const section = parseMarkers3(content);
5449
5869
  if (section.kind === "ok") {
5450
5870
  if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
5451
5871
  return { kind: "in-sync", canonicalName: viewName, viewName };
@@ -5471,7 +5891,7 @@ async function applyViewPreset(anchorReal, outcome) {
5471
5891
  }
5472
5892
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5473
5893
  if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
5474
- const existing = await readMarkdownFile5(file);
5894
+ const existing = await readMarkdownFile6(file);
5475
5895
  await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
5476
5896
  }
5477
5897
  async function applyPresetPlan(anchorReal, plan) {
@@ -5485,7 +5905,7 @@ async function applyPresetPlan(anchorReal, plan) {
5485
5905
  }
5486
5906
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
5487
5907
  if (plan.action === "create") mkdirSync(dirname3(file), { recursive: true });
5488
- const existing = await readMarkdownFile5(file);
5908
+ const existing = await readMarkdownFile6(file);
5489
5909
  await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
5490
5910
  }
5491
5911
  function presetFailureReason(error) {
@@ -5949,7 +6369,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
5949
6369
  });
5950
6370
  }
5951
6371
  if (content !== void 0 && content !== "") {
5952
- const section = parseMarkers2(content);
6372
+ const section = parseMarkers3(content);
5953
6373
  if (section.kind === "ok" && canonicalShared) {
5954
6374
  items.push({
5955
6375
  kind: "canonical-block",
@@ -6078,7 +6498,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
6078
6498
  }
6079
6499
  try {
6080
6500
  const content = readFileSync(fd, "utf8");
6081
- if (parseMarkers2(content).kind !== "ok") {
6501
+ if (parseMarkers3(content).kind !== "ok") {
6082
6502
  changed(item.label);
6083
6503
  continue;
6084
6504
  }
@@ -6853,7 +7273,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
6853
7273
  if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
6854
7274
  return { kind: "unreadable", viewName };
6855
7275
  }
6856
- const section = parseMarkers2(content);
7276
+ const section = parseMarkers3(content);
6857
7277
  if (section.kind === "ok") return { kind: "already-marked", viewName };
6858
7278
  if (section.kind === "no_markers") {
6859
7279
  const block = renderViewPresetBlock({
@@ -6874,7 +7294,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
6874
7294
  isLink = false;
6875
7295
  }
6876
7296
  if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
6877
- const existing = await readMarkdownFile5(file);
7297
+ const existing = await readMarkdownFile6(file);
6878
7298
  await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
6879
7299
  }
6880
7300
  async function doRunProjectRetrofit(repo, options, ctx) {
@@ -7137,7 +7557,7 @@ function renderProjectRetrofit(result) {
7137
7557
 
7138
7558
  // src/commands/protocol.ts
7139
7559
  import { readFile as readFile4 } from "fs/promises";
7140
- import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers3, readMarkdownFile as readMarkdownFile6 } from "@basou/core";
7560
+ import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile7 } from "@basou/core";
7141
7561
 
7142
7562
  // src/lib/protocols-config.ts
7143
7563
  import { homedir as homedir8 } from "os";
@@ -7221,7 +7641,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
7221
7641
  var MANAGED_NOTE = "<!-- Managed by basou: 'basou protocol sync' regenerates everything between the BASOU:PROTOCOLS markers from ~/.basou/protocols.yaml. Manual edits inside the block are overwritten; edit the source files instead. -->";
7222
7642
  function registerProtocolCommand(program2) {
7223
7643
  const protocol = program2.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) => {
7644
+ protocol.command("sync").description(
7645
+ "Render declared protocols into ~/.claude/CLAUDE.md (creates/updates the block). That file is user-global: Claude Code auto-loads it for every project on the machine, so what the protocols say is in the context of every workspace's sessions \u2014 keep workspace-specific facts out of them."
7646
+ ).option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
7225
7647
  await runProtocolSync(opts);
7226
7648
  });
7227
7649
  protocol.command("list").description("List declared protocols and whether the block is installed").option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
@@ -7320,8 +7742,8 @@ async function doRunProtocolList(options) {
7320
7742
  const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
7321
7743
  const target = options.target ?? DEFAULT_TARGET_PATH;
7322
7744
  const entries = await loadProtocolsConfig(configPath);
7323
- const existing = await readMarkdownFile6(target);
7324
- const installed = existing !== null && parseMarkers3(existing, PROTOCOL_MARKERS).kind === "ok";
7745
+ const existing = await readMarkdownFile7(target);
7746
+ const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
7325
7747
  console.log(`Declared protocols (${entries.length}):`);
7326
7748
  for (const entry of entries) {
7327
7749
  console.log(` - ${entry.title ?? entry.source}`);
@@ -7356,21 +7778,6 @@ import {
7356
7778
  } from "@basou/core";
7357
7779
  import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
7358
7780
 
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
7781
  // src/commands/refresh-watch.ts
7375
7782
  import { readdir as readdir2, stat as stat5 } from "fs/promises";
7376
7783
  import { homedir as homedir9 } from "os";
@@ -7522,7 +7929,7 @@ function abortableSleep(ms, signal) {
7522
7929
  }
7523
7930
  function registerRefreshCommand(program2) {
7524
7931
  program2.command("refresh").description(
7525
- "Import all adapters for the project and regenerate handoff + decisions in one step"
7932
+ "Import all adapters for the project and regenerate handoff + decisions in one step. Writes only inside the workspace's .basou/ \u2014 never to a user-global file another project's tool reads (a Codex session gets the position from the SessionStart hook; see `basou hook install codex`)."
7526
7933
  ).option(
7527
7934
  "--project <path>",
7528
7935
  "Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
@@ -7653,47 +8060,23 @@ async function computeRefresh(options, ctx) {
7653
8060
  }
7654
8061
  async function doRunRefresh(options, ctx) {
7655
8062
  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 };
8063
+ const reported = { ...result, codexChannel: { status: "retired" } };
7658
8064
  if (options.json === true) {
7659
8065
  console.log(JSON.stringify(reported));
7660
8066
  } else {
7661
8067
  printRefreshSummary(result);
7662
- if (channel.line !== null) console.log(channel.line);
8068
+ const line = await retiredChannelNotice(paths);
8069
+ if (line !== null) console.log(line);
7663
8070
  }
7664
8071
  return reported;
7665
8072
  }
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
- }
8073
+ async function retiredChannelNotice(paths) {
7683
8074
  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
- };
8075
+ const manifest = await readManifest7(paths);
8076
+ if (manifest.channels?.codex !== true) return null;
8077
+ return "codex channel: retired \u2014 the manifest's channels.codex is ignored; a Codex session now receives this workspace's position from the SessionStart hook (see `basou hook status codex`), and nothing is written to the user-global ~/.codex/AGENTS.md";
8078
+ } catch {
8079
+ return null;
7697
8080
  }
7698
8081
  }
7699
8082
  function describeImport(outcome) {
@@ -8271,7 +8654,7 @@ function unattachedLines(u) {
8271
8654
  }
8272
8655
 
8273
8656
  // src/commands/run.ts
8274
- import { mkdir as mkdir2 } from "fs/promises";
8657
+ import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
8275
8658
  import { homedir as homedir11 } from "os";
8276
8659
  import { join as join14 } from "path";
8277
8660
  import {
@@ -8283,6 +8666,7 @@ import {
8283
8666
  codexAdapterMetadata,
8284
8667
  appendChainedEvent as coreAppendChainedEvent2,
8285
8668
  finalizeSessionYaml as finalizeSessionYaml2,
8669
+ findBasouSessionStartHook as findBasouSessionStartHook2,
8286
8670
  getDiff,
8287
8671
  getSnapshot as getSnapshot2,
8288
8672
  overwriteYamlFile as overwriteYamlFile2,
@@ -8334,7 +8718,7 @@ function runCodex(args, options, ctx = {}) {
8334
8718
  resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
8335
8719
  metadata: codexAdapterMetadata,
8336
8720
  transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
8337
- preSpawn: syncCodexOrientationChannelPreSpawn
8721
+ preSpawn: noteCodexHookStatusPreSpawn
8338
8722
  });
8339
8723
  }
8340
8724
  async function runTrackedTool(args, options, ctx, adapter) {
@@ -8701,24 +9085,25 @@ async function resolveRepositoryRootForRun(cwd) {
8701
9085
  throw error;
8702
9086
  }
8703
9087
  }
8704
- async function syncCodexOrientationChannelPreSpawn(cwd, ctx) {
9088
+ async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
9089
+ const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
9090
+ let location;
8705
9091
  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;
9092
+ location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
9093
+ } catch (error) {
9094
+ if (!(error instanceof Error && error.code === "ENOENT")) return null;
9095
+ location = null;
9096
+ }
9097
+ if (location === null) {
9098
+ return "codex: the basou SessionStart hook is not registered, so this session starts without the workspace's position (`basou hook install codex` registers it once for every workspace)";
8717
9099
  }
9100
+ const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
9101
+ if (trust.status === "trusted" || trust.status === "unknown") return null;
9102
+ return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
8718
9103
  }
8719
9104
 
8720
9105
  // src/commands/session.ts
8721
- import { readFile as readFile6 } from "fs/promises";
9106
+ import { readFile as readFile7 } from "fs/promises";
8722
9107
  import { basename as basename6, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
8723
9108
  import {
8724
9109
  acquireLock as acquireLock6,
@@ -9169,7 +9554,7 @@ async function doRunSessionImport(options, ctx) {
9169
9554
  }
9170
9555
  async function readInputFile(path) {
9171
9556
  try {
9172
- return await readFile6(path, "utf8");
9557
+ return await readFile7(path, "utf8");
9173
9558
  } catch (error) {
9174
9559
  if (findErrorCode12(error, "ENOENT")) {
9175
9560
  throw new Error("Import source not found", { cause: error });
@@ -9286,7 +9671,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
9286
9671
  }
9287
9672
  async function readNoteFile(path) {
9288
9673
  try {
9289
- return await readFile6(path, "utf8");
9674
+ return await readFile7(path, "utf8");
9290
9675
  } catch (error) {
9291
9676
  if (findErrorCode12(error, "ENOENT")) {
9292
9677
  throw new Error("Note source not found", { cause: error });
@@ -9607,7 +9992,7 @@ function formatVersionGateMessage(error) {
9607
9992
  }
9608
9993
 
9609
9994
  // src/commands/task.ts
9610
- import { readFile as readFile7 } from "fs/promises";
9995
+ import { readFile as readFile8 } from "fs/promises";
9611
9996
  import { join as join16 } from "path";
9612
9997
  import {
9613
9998
  archiveTask,
@@ -10622,7 +11007,7 @@ function parsePositiveInt2(raw) {
10622
11007
  }
10623
11008
  async function readDescriptionFile(path) {
10624
11009
  try {
10625
- return await readFile7(path, "utf8");
11010
+ return await readFile8(path, "utf8");
10626
11011
  } catch (error) {
10627
11012
  if (findErrorCode15(error, "ENOENT")) {
10628
11013
  throw new Error("Description source not found", { cause: error });
@@ -10826,7 +11211,7 @@ async function assertWorkspaceInitialized14(basouRoot) {
10826
11211
 
10827
11212
  // src/commands/view.ts
10828
11213
  import { spawn } from "child_process";
10829
- import { createHash } from "crypto";
11214
+ import { createHash as createHash2 } from "crypto";
10830
11215
  import { basename as basename9, resolve as resolve13 } from "path";
10831
11216
  import {
10832
11217
  assertBasouRootSafe as assertBasouRootSafe18,
@@ -11151,7 +11536,7 @@ function inertLines(result) {
11151
11536
 
11152
11537
  // src/lib/portfolio-safety.ts
11153
11538
  import { execFile } from "child_process";
11154
- import { lstat as lstat2, realpath as realpath2 } from "fs/promises";
11539
+ import { lstat as lstat2, realpath as realpath3 } from "fs/promises";
11155
11540
  import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
11156
11541
  import { promisify } from "util";
11157
11542
  import { readManifest as readManifest14 } from "@basou/core";
@@ -11161,7 +11546,7 @@ function errorCode(error) {
11161
11546
  }
11162
11547
  async function canonical(p) {
11163
11548
  try {
11164
- return await realpath2(p);
11549
+ return await realpath3(p);
11165
11550
  } catch {
11166
11551
  return resolve11(p);
11167
11552
  }
@@ -11341,7 +11726,7 @@ import {
11341
11726
  loadTaskEntries as loadTaskEntries2,
11342
11727
  readAllEvents as readAllEvents2,
11343
11728
  readManifest as readManifest15,
11344
- readMarkdownFile as readMarkdownFile7,
11729
+ readMarkdownFile as readMarkdownFile8,
11345
11730
  readSessionYaml as readSessionYaml3,
11346
11731
  readTaskFile as readTaskFile2,
11347
11732
  renderDecisions as renderDecisions3,
@@ -12393,7 +12778,7 @@ async function taskDetail(ws, taskId) {
12393
12778
  }
12394
12779
  }
12395
12780
  async function decisionsView(ws, nowProvider) {
12396
- const fromDisk = await readMarkdownFile7(ws.paths.files.decisions);
12781
+ const fromDisk = await readMarkdownFile8(ws.paths.files.decisions);
12397
12782
  if (fromDisk !== null) {
12398
12783
  return { body: fromDisk, fromDisk: true };
12399
12784
  }
@@ -12416,7 +12801,7 @@ async function approvalsView(ws, nowProvider) {
12416
12801
  return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
12417
12802
  }
12418
12803
  async function handoffView(ws, nowProvider) {
12419
- const fromDisk = await readMarkdownFile7(ws.paths.files.handoff);
12804
+ const fromDisk = await readMarkdownFile8(ws.paths.files.handoff);
12420
12805
  if (fromDisk !== null) {
12421
12806
  return { body: fromDisk, fromDisk: true };
12422
12807
  }
@@ -12650,7 +13035,7 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
12650
13035
  } catch (error) {
12651
13036
  const notFound = error instanceof Error && error.message === "YAML file not found";
12652
13037
  return {
12653
- key: `ws-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
13038
+ key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
12654
13039
  label: labelOverride ?? basename9(repoRoot),
12655
13040
  paths,
12656
13041
  repoRoot,