@basou/cli 0.49.0 → 0.50.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
@@ -2474,6 +2474,9 @@ import {
2474
2474
  evaluateStopHook,
2475
2475
  findBasouSessionStartHook,
2476
2476
  findBasouStopHookCommand,
2477
+ findClaudeSessionStartHooks,
2478
+ findUnrecognizedSessionStart,
2479
+ isClaudeSessionStartMalformed,
2477
2480
  isProtocolUpdateDue,
2478
2481
  ORIENTATION_END as ORIENTATION_END2,
2479
2482
  ORIENTATION_START as ORIENTATION_START2,
@@ -2488,10 +2491,12 @@ import {
2488
2491
  readManifest as readManifest5,
2489
2492
  readMarkdownFile as readMarkdownFile6,
2490
2493
  recordSessionBaseline,
2494
+ removeClaudeSessionStartHook,
2491
2495
  removeSessionStartHook,
2492
2496
  removeStopHook,
2493
2497
  renderProtocolUpdate,
2494
2498
  transcriptStartedAt,
2499
+ upsertClaudeSessionStartHook,
2495
2500
  upsertSessionStartHook,
2496
2501
  upsertStopHook
2497
2502
  } from "@basou/core";
@@ -3842,11 +3847,14 @@ function registerHookCommand(program2) {
3842
3847
  });
3843
3848
  });
3844
3849
  hook.command("install [target]").description(
3845
- "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."
3850
+ "Register a basou hook (reproducible, idempotent). Target `claude` (default): the Stop hook and the SessionStart hook in ~/.claude/settings.json. The Stop hook is advisory capture-only by default; --block opts into in-turn enforcement, --require-review into the review gate. The SessionStart hook hands each session the workspace's position and records the git baseline the Stop hook measures the session's file changes against; --no-session-start leaves it out. 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."
3846
3851
  ).option(
3847
3852
  "--block",
3848
3853
  "claude: register the blocking (opt-in enforcement) form instead of advisory"
3849
- ).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(
3854
+ ).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(
3855
+ "--no-session-start",
3856
+ "claude: register the Stop hook only, and leave any SessionStart hook as it is"
3857
+ ).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(
3850
3858
  "--codex-config <path>",
3851
3859
  "codex: override the Codex config.toml path (intended for tests)"
3852
3860
  ).option(
@@ -3855,11 +3863,23 @@ function registerHookCommand(program2) {
3855
3863
  ).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
3856
3864
  await dispatchHookTarget(target, opts, {
3857
3865
  claude: () => runHookInstall(opts),
3858
- codex: () => runCodexHookInstall(opts)
3866
+ codex: async () => {
3867
+ if (opts.sessionStart === false) {
3868
+ renderCliError(
3869
+ new Error(
3870
+ "--no-session-start applies to target claude only; the Codex hook is itself a SessionStart hook."
3871
+ ),
3872
+ { verbose: isVerbose(opts) }
3873
+ );
3874
+ process.exitCode = 1;
3875
+ return;
3876
+ }
3877
+ await runCodexHookInstall(opts);
3878
+ }
3859
3879
  });
3860
3880
  });
3861
3881
  hook.command("uninstall [target]").description(
3862
- "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."
3882
+ "Remove a basou hook, leaving other hooks intact. Target `claude` (default): the Stop hook and the SessionStart hook in ~/.claude/settings.json. Target `codex`: the SessionStart hook in ~/.codex/hooks.json."
3863
3883
  ).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) => {
3864
3884
  await dispatchHookTarget(target, opts, {
3865
3885
  claude: () => runHookUninstall(opts),
@@ -3867,7 +3887,7 @@ function registerHookCommand(program2) {
3867
3887
  });
3868
3888
  });
3869
3889
  hook.command("status [target]").description(
3870
- "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."
3890
+ "Report whether a basou hook is registered. Target `claude` (default): the Stop hook and its mode, and the SessionStart hook. Target `codex`: the SessionStart hook, and whether Codex has trusted it yet."
3871
3891
  ).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(
3872
3892
  "--codex-config <path>",
3873
3893
  "codex: override the Codex config.toml path (intended for tests)"
@@ -3915,9 +3935,11 @@ becomes the session's trusted context, so it withholds the position instead.
3915
3935
  Run 'basou refresh' to see which lines are responsible.
3916
3936
 
3917
3937
  Claude Code's SessionStart hook sends the same kind of payload (a JSON object
3918
- with 'cwd') and adds stdout to context the same way, so a Claude Code user may
3919
- register this command in ~/.claude/settings.json in place of 'basou orient' to
3920
- get both gates. basou does not install that one.
3938
+ with 'cwd') and adds stdout to context the same way. 'basou hook install'
3939
+ registers this command there too, in ~/.claude/settings.json, and replaces a
3940
+ hand-registered 'basou orient' SessionStart hook with it. In Claude Code it also
3941
+ records where each declared repository stood when the session started, which is
3942
+ what the Stop hook measures the session's file changes against.
3921
3943
 
3922
3944
  Codex trusts hooks by hash. A newly installed or changed hook is skipped until
3923
3945
  you review it: the interactive CLI asks at startup ("Hooks need review"), the
@@ -4231,6 +4253,7 @@ function normalizeInstallOptions(raw) {
4231
4253
  const out = {};
4232
4254
  if (raw.block === true) out.block = true;
4233
4255
  if (raw.requireReview === true) out.requireReview = true;
4256
+ if (raw.sessionStart === false) out.noSessionStart = true;
4234
4257
  if (raw.settings !== void 0) out.settings = raw.settings;
4235
4258
  if (raw.hooks !== void 0) out.hooks = raw.hooks;
4236
4259
  if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
@@ -4302,15 +4325,32 @@ async function doRunHookInstall(options, ctx = {}) {
4302
4325
  });
4303
4326
  await assertNotSymlink(settingsPath);
4304
4327
  const { raw, parsed } = await readSettings(settingsPath);
4305
- const { settings, action } = upsertStopHook(parsed, command);
4306
- const newBody = `${JSON.stringify(settings, null, 2)}
4307
- `;
4308
- if (raw !== null && newBody === raw) {
4309
- console.log(`The basou Stop hook is already registered (${mode}); no change.`);
4310
- return;
4328
+ const stop = upsertStopHook(parsed, command);
4329
+ let sessionStart;
4330
+ if (options.noSessionStart === true) {
4331
+ sessionStart = { kind: "skipped" };
4332
+ } else {
4333
+ try {
4334
+ const upsert = upsertClaudeSessionStartHook(
4335
+ stop.settings,
4336
+ buildSessionStartHookCommand({ cliEntry })
4337
+ );
4338
+ sessionStart = { kind: "done", action: upsert.action, settings: upsert.settings };
4339
+ } catch (error) {
4340
+ if (!isClaudeSessionStartMalformed(stop.settings)) throw error;
4341
+ sessionStart = { kind: "malformed" };
4342
+ }
4311
4343
  }
4312
- if (options.dryRun === true) {
4313
- console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
4344
+ const settings = sessionStart.kind === "done" ? sessionStart.settings : stop.settings;
4345
+ const changed = stop.action !== "unchanged" || sessionStart.kind === "done" && sessionStart.action !== "unchanged";
4346
+ const dryRun = options.dryRun === true;
4347
+ const lines = [
4348
+ describeStopInstall(stop.action, mode, dryRun),
4349
+ describeSessionStartInstall(sessionStart, dryRun),
4350
+ ...describeSessionStartLeftovers(settings)
4351
+ ];
4352
+ if (!changed || dryRun) {
4353
+ for (const line of lines) console.log(line);
4314
4354
  return;
4315
4355
  }
4316
4356
  const recheck = await readSettings(settingsPath);
@@ -4320,8 +4360,55 @@ async function doRunHookInstall(options, ctx = {}) {
4320
4360
  );
4321
4361
  }
4322
4362
  await backupSettingsOnce(settingsPath, raw);
4323
- await writeFileDurable(settingsPath, newBody);
4324
- console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
4363
+ await writeFileDurable(settingsPath, `${JSON.stringify(settings, null, 2)}
4364
+ `);
4365
+ for (const line of lines) console.log(line);
4366
+ }
4367
+ function describeStopInstall(action, mode, dryRun) {
4368
+ if (action === "unchanged") {
4369
+ return `The basou Stop hook is already registered (${mode}); no change.`;
4370
+ }
4371
+ if (dryRun) {
4372
+ return `[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Stop hook (${mode}).`;
4373
+ }
4374
+ return `${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`;
4375
+ }
4376
+ function describeSessionStartInstall(outcome, dryRun) {
4377
+ if (outcome.kind === "skipped") {
4378
+ return "The SessionStart hook was left as it is (--no-session-start).";
4379
+ }
4380
+ if (outcome.kind === "malformed") {
4381
+ return "The basou SessionStart hook was NOT installed: 'hooks.SessionStart' in settings.json is not a list of hook groups. Fix it and re-run 'basou hook install'.";
4382
+ }
4383
+ const action = outcome.action;
4384
+ if (action === "unchanged") {
4385
+ return "The basou SessionStart hook is already registered; no change.";
4386
+ }
4387
+ const prefix = dryRun ? "[dry-run] Would " : "";
4388
+ if (action === "replaced") {
4389
+ return `${prefix}${dryRun ? "replace" : "Replaced"} the hand-registered 'basou orient' SessionStart hook with 'basou hook session-start': the same position, plus the baseline the Stop hook measures the session's file changes against. It differs in three ways: it speaks only for a workspace registered in ~/.basou/portfolio.yaml, it does not rewrite .basou/orientation.md at session start, and it stays silent when the position names another registered workspace.`;
4390
+ }
4391
+ if (action === "updated") {
4392
+ return `${prefix}${dryRun ? "update" : "Updated"} the basou SessionStart hook.`;
4393
+ }
4394
+ return `${prefix}${dryRun ? "install" : "Installed"} the basou SessionStart hook: each session gets the workspace's position, and the Stop hook can see the files the session changes through the shell.`;
4395
+ }
4396
+ function describeSessionStartLeftovers(settings) {
4397
+ const own = findClaudeSessionStartHooks(settings);
4398
+ if (own.length === 0) return [];
4399
+ const lines = [];
4400
+ for (const other of findUnrecognizedSessionStart(settings)) {
4401
+ lines.push(
4402
+ `note: this SessionStart hook runs basou '${other.runs === "orient" ? "orient" : "hook session-start"}' inside a longer command, so it was left as written, and a session may receive the position twice: ${other.command}`
4403
+ );
4404
+ }
4405
+ if (own.length > 1) {
4406
+ const matchers = own.map((o) => o.matcher ?? "(every source)").join(", ");
4407
+ lines.push(
4408
+ `note: ${own.length} basou SessionStart hooks remain, under different matchers (${matchers}). They were kept so that no session source stops firing; a source that more than one matches receives the position twice. Remove the one you do not want by hand.`
4409
+ );
4410
+ }
4411
+ return lines;
4325
4412
  }
4326
4413
  async function runHookUninstall(options) {
4327
4414
  try {
@@ -4339,15 +4426,22 @@ async function doRunHookUninstall(options) {
4339
4426
  console.log("No settings.json; nothing to remove.");
4340
4427
  return;
4341
4428
  }
4342
- const { settings, action } = removeStopHook(parsed);
4343
- if (action === "absent") {
4344
- console.log("No basou Stop hook found; nothing removed.");
4429
+ const stop = removeStopHook(parsed);
4430
+ const sessionStart = removeClaudeSessionStartHook(stop.settings);
4431
+ if (stop.action === "absent" && sessionStart.action === "absent") {
4432
+ console.log("No basou Stop hook or SessionStart hook found; nothing removed.");
4345
4433
  return;
4346
4434
  }
4347
- const newBody = `${JSON.stringify(settings, null, 2)}
4435
+ const newBody = `${JSON.stringify(sessionStart.settings, null, 2)}
4348
4436
  `;
4437
+ const removedNames = [
4438
+ ...stop.action === "removed" ? ["Stop hook"] : [],
4439
+ ...sessionStart.action === "removed" ? ["SessionStart hook"] : []
4440
+ ];
4349
4441
  if (options.dryRun === true) {
4350
- console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
4442
+ for (const name of removedNames) {
4443
+ console.log(`[dry-run] Would remove the basou ${name} from settings.json.`);
4444
+ }
4351
4445
  return;
4352
4446
  }
4353
4447
  const recheck = await readSettings(settingsPath);
@@ -4358,7 +4452,9 @@ async function doRunHookUninstall(options) {
4358
4452
  }
4359
4453
  await backupSettingsOnce(settingsPath, raw);
4360
4454
  await writeFileDurable(settingsPath, newBody);
4361
- console.log("Removed the basou Stop hook from settings.json.");
4455
+ for (const name of removedNames) {
4456
+ console.log(`Removed the basou ${name} from settings.json.`);
4457
+ }
4362
4458
  }
4363
4459
  async function runHookStatus(options) {
4364
4460
  try {
@@ -4374,14 +4470,49 @@ async function doRunHookStatus(options) {
4374
4470
  const command = findBasouStopHookCommand(parsed);
4375
4471
  if (command === null) {
4376
4472
  console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
4473
+ } else {
4474
+ const mode = describeHookMode({
4475
+ block: / --block\b/.test(command),
4476
+ review: / --require-review\b/.test(command)
4477
+ });
4478
+ console.log(`basou Stop hook: registered, ${mode}.`);
4479
+ await reportHookEntryBuild(command);
4480
+ }
4481
+ reportSessionStartStatus(parsed);
4482
+ }
4483
+ function reportSessionStartStatus(parsed) {
4484
+ if (isClaudeSessionStartMalformed(parsed)) {
4485
+ console.log(
4486
+ "basou SessionStart hook: cannot tell -- 'hooks.SessionStart' in settings.json is not a list of hook groups."
4487
+ );
4377
4488
  return;
4378
4489
  }
4379
- const mode = describeHookMode({
4380
- block: / --block\b/.test(command),
4381
- review: / --require-review\b/.test(command)
4382
- });
4383
- console.log(`basou Stop hook: registered, ${mode}.`);
4384
- await reportHookEntryBuild(command);
4490
+ const own = findClaudeSessionStartHooks(parsed);
4491
+ const others = findUnrecognizedSessionStart(parsed);
4492
+ for (const hook of own.filter((o) => o.kind === "session-start")) {
4493
+ console.log(`basou SessionStart hook: registered, fires ${describeFiring(hook.matcher)}.`);
4494
+ }
4495
+ for (const hook of own.filter((o) => o.kind === "orient")) {
4496
+ console.log(
4497
+ `basou SessionStart hook: 'basou orient' registered by hand, fires ${describeFiring(hook.matcher)}. It delivers the position but records no git baseline, so the files a session changes through the shell are not observed; 'basou hook install' replaces it with 'basou hook session-start'.`
4498
+ );
4499
+ }
4500
+ if (own.length === 0) {
4501
+ if (others.length === 0) {
4502
+ console.log(
4503
+ "basou SessionStart hook: not registered. Sessions get no position at start, and the files a session changes through the shell are not observed. Run 'basou hook install' to register it."
4504
+ );
4505
+ }
4506
+ for (const other of others) {
4507
+ console.log(
4508
+ other.runs === "orient" ? `basou SessionStart hook: not registered by basou. A SessionStart hook runs 'basou orient' inside a longer command, so sessions may get the position, but no git baseline is recorded: ${other.command}` : `basou SessionStart hook: not registered by basou. A SessionStart hook runs 'basou hook session-start' inside a longer command, which basou leaves as written: ${other.command}`
4509
+ );
4510
+ }
4511
+ }
4512
+ for (const line of describeSessionStartLeftovers(parsed)) console.log(line);
4513
+ }
4514
+ function describeFiring(matcher) {
4515
+ return matcher === void 0 || matcher === "" || matcher === "*" ? "on every session source" : `on ${matcher}`;
4385
4516
  }
4386
4517
  async function reportHookEntryBuild(command) {
4387
4518
  console.log(` this basou is: ${BASOU_VERSION_LINE}`);
@@ -13862,7 +13993,7 @@ async function assertWorkspaceInitialized15(basouRoot) {
13862
13993
  function readBuildStamp() {
13863
13994
  if (false) return void 0;
13864
13995
  try {
13865
- return JSON.parse('{"version":"0.49.0","commit":"6c4704a","committedAt":"2026-09-23T11:46:39+09:00"}');
13996
+ return JSON.parse('{"version":"0.50.0","commit":"5b4c5dc","committedAt":"2026-09-23T19:12:06+09:00"}');
13866
13997
  } catch {
13867
13998
  return void 0;
13868
13999
  }