@bunshin-cc/bunshin 0.1.2 → 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/bin.js +231 -94
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,6 +16,8 @@ In Bunshin, open **Agent keys** and create a key for this computer. Paste it int
16
16
 
17
17
  Run `codex` interactively in the same project. Review and accept its project trust prompt, then run `/hooks` and review and trust the four Bunshin hooks. Project trust and hook trust are separate steps. If `/hooks` does not list Bunshin, check that you opened the connected project and trusted its configuration. This guide tests interactive sessions; `codex exec` is not a substitute for this setup check.
18
18
 
19
+ For a linked Git worktree, setup puts Codex hooks in the primary checkout, where Codex reads them. The printed path identifies that shared hook file. Each connected worktree keeps its own `.bunshin` store and private connection. Worktrees created from a bare repository are not supported yet; use a full checkout.
20
+
19
21
  For Claude Code, use `bunshin setup --harness claude-code`, then start a new session in the project.
20
22
 
21
23
  For Pi, use Node.js 22.19 or later and run `bunshin setup --harness pi`. It installs a project extension in `.pi/extensions/bunshin.js`. Start Pi in the project (or run `/reload` in an existing session), review its project resources, and check `/bunshin status`. The extension ships in this npm package; you do not need a second package or a repository checkout.
@@ -43,4 +45,6 @@ npm uninstall -g @bunshin-cc/bunshin
43
45
 
44
46
  Use `--harness claude-code` to remove Claude Code hooks or `--harness pi` to remove the Pi extension. Revoke this computer’s key in Bunshin when disconnecting it. Removing hooks keeps local lessons.
45
47
 
48
+ Codex hooks are shared across a repository's linked worktrees. Removing them from one removes the repository's Bunshin hooks for all its worktrees. Other tools' hooks are preserved.
49
+
46
50
  Full setup guide: [bunshin.cc/docs](https://bunshin.cc/docs/).
package/dist/bin.js CHANGED
@@ -391,6 +391,10 @@ var init_store = __esm({
391
391
  saveConflict(conflict) {
392
392
  writeJsonAtomic(path2.join(this.conflictsDir, `${conflict.id}.json`), conflict);
393
393
  }
394
+ removeCachedConflict(id) {
395
+ if (!/^c_[a-f0-9]{8}$/.test(id)) throw new Error("Invalid cached conflict ID");
396
+ fs2.rmSync(path2.join(this.conflictsDir, `${id}.json`), { force: true });
397
+ }
394
398
  readMetrics(fallback) {
395
399
  return readJson(this.metricsFile, fallback);
396
400
  }
@@ -405,6 +409,7 @@ var init_store = __esm({
405
409
  var sync_exports = {};
406
410
  __export(sync_exports, {
407
411
  canSync: () => canSync,
412
+ mothershipLookup: () => mothershipLookup,
408
413
  mothershipRecall: () => mothershipRecall,
409
414
  pendingConflictQuestion: () => pendingConflictQuestion,
410
415
  resolveConflict: () => resolveConflict,
@@ -440,6 +445,14 @@ async function mothershipRecall(config, req, timeoutMs = 8e3) {
440
445
  return null;
441
446
  }
442
447
  }
448
+ async function mothershipLookup(config, ids, timeoutMs = 8e3) {
449
+ if (!canSync(config) || !ids.length) return null;
450
+ try {
451
+ return await api(config, "POST", "/lessons/lookup", { ids }, timeoutMs);
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
443
456
  async function runSync(store) {
444
457
  const config = store.config();
445
458
  const result = { pushedLessons: 0, pushedTraces: 0, pulledConflicts: 0, errors: [] };
@@ -482,6 +495,13 @@ async function runSync(store) {
482
495
  store.saveConflict(c);
483
496
  result.pulledConflicts++;
484
497
  }
498
+ const pending = store.listConflicts().filter((c) => c.status === "open").map((c) => c.id);
499
+ for (let start = 0; start < pending.length; start += 60) {
500
+ const ids = pending.slice(start, start + 60);
501
+ const refreshed = await api(config, "POST", "/conflicts/lookup", { ids });
502
+ for (const c of refreshed.conflicts) if (ids.includes(c.id)) store.saveConflict(c);
503
+ for (const id of refreshed.unavailableIds) if (ids.includes(id)) store.removeCachedConflict(id);
504
+ }
485
505
  } catch (e) {
486
506
  result.errors.push(String(e instanceof Error ? e.message : e));
487
507
  }
@@ -494,8 +514,7 @@ async function resolveConflict(store, id, answer, note) {
494
514
  const local = store.listConflicts().find((c) => c.id === id);
495
515
  if (!local) throw new Error(`no pending conflict ${id}`);
496
516
  const resolution = { chosen: answer, by: config.userId, at: nowIso(), note };
497
- if (canSync(config)) await api(config, "POST", `/conflicts/${encodeURIComponent(id)}/resolve`, resolution);
498
- const resolved = { ...local, status: "resolved", resolution };
517
+ const resolved = canSync(config) ? (await api(config, "POST", `/conflicts/${encodeURIComponent(id)}/resolve`, resolution)).conflict : { ...local, status: "resolved", resolution };
499
518
  store.saveConflict(resolved);
500
519
  store.log("conflict-resolve", { id, answer });
501
520
  return resolved;
@@ -508,7 +527,7 @@ function pendingConflictQuestion(store, asked) {
508
527
  const options = c.options?.length ? `
509
528
  Options: ${c.options.map((o, i) => `(${i + 1}) ${o}`).join(" ")}` : "";
510
529
  const text = `## Knowledge conflict to resolve (Bunshin)
511
- The organisation's knowledge base has an unresolved ${c.kind} that only a human can settle. When it fits naturally (not before doing the user's request), ask the user this one question and record their answer by running \`bunshin conflict resolve ${c.id} "<answer>"\`:
530
+ The organisation's knowledge base has an unresolved ${c.kind} that only a human can settle. When it fits naturally (not before doing the user's request), ask the user this question. They can compare the documents and save an explicit keep/retire decision in the Bunshin workspace's Conflicts page. Do not treat a free-text answer as permission to reactivate every document.
512
531
 
513
532
  > ${c.question}${options}
514
533
 
@@ -650,6 +669,7 @@ async function askCodexCli(opts) {
650
669
  const outFile = path4.join(os.tmpdir(), `bunshin-codex-${process.pid}-${Date.now()}.txt`);
651
670
  const args = ["exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only", "-o", outFile];
652
671
  if (opts.model) args.push("-m", opts.model);
672
+ if (opts.effort) args.push("-c", `model_reasoning_effort="${opts.effort}"`);
653
673
  args.push("-");
654
674
  const prompt = `${opts.system}${jsonOnlyInstruction(opts.schema)}
655
675
 
@@ -706,6 +726,41 @@ var init_llm = __esm({
706
726
  }
707
727
  });
708
728
 
729
+ // src/project-hooks.ts
730
+ import fs4 from "node:fs";
731
+ import path5 from "node:path";
732
+ import { execFileSync } from "node:child_process";
733
+ function codexProjectHooks(cwd) {
734
+ const legacyFile = path5.join(cwd, ".codex", "hooks.json");
735
+ const git = (...args) => execFileSync("git", ["-C", cwd, "rev-parse", ...args], {
736
+ encoding: "utf8",
737
+ stdio: ["ignore", "pipe", "ignore"],
738
+ timeout: 2e3
739
+ }).trim();
740
+ let root, common, directory;
741
+ try {
742
+ root = git("--show-toplevel");
743
+ common = git("--path-format=absolute", "--git-common-dir");
744
+ directory = git("--absolute-git-dir");
745
+ } catch {
746
+ return { file: legacyFile, shared: false, legacyFile };
747
+ }
748
+ const linked = fs4.realpathSync(common) !== fs4.realpathSync(directory);
749
+ if (linked) {
750
+ if (path5.basename(common) !== ".git" || !fs4.statSync(common).isDirectory()) {
751
+ throw new Error("Codex hooks in a worktree of a bare repository are not supported yet. Use a full checkout for Bunshin setup.");
752
+ }
753
+ root = path5.dirname(common);
754
+ }
755
+ const file = fs4.realpathSync(root) === fs4.realpathSync(cwd) ? legacyFile : path5.join(root, ".codex", "hooks.json");
756
+ return { file, shared: linked, legacyFile };
757
+ }
758
+ var init_project_hooks = __esm({
759
+ "src/project-hooks.ts"() {
760
+ "use strict";
761
+ }
762
+ });
763
+
709
764
  // src/init.ts
710
765
  var init_exports = {};
711
766
  __export(init_exports, {
@@ -721,8 +776,8 @@ __export(init_exports, {
721
776
  removeBunshinHooks: () => removeBunshinHooks,
722
777
  runInit: () => runInit
723
778
  });
724
- import fs4 from "node:fs";
725
- import path5 from "node:path";
779
+ import fs5 from "node:fs";
780
+ import path6 from "node:path";
726
781
  import { fileURLToPath, pathToFileURL } from "node:url";
727
782
  function isBunshinEntry(entry) {
728
783
  return entry?._bunshin === true || typeof entry?.command === "string" && /\bbunshin\b.*\bhook\b/.test(entry.command);
@@ -775,16 +830,16 @@ function countBunshinHooks(settings) {
775
830
  );
776
831
  }
777
832
  function claudeSettingsPath() {
778
- return path5.join(homeDir(), ".claude", "settings.json");
833
+ return path6.join(homeDir(), ".claude", "settings.json");
779
834
  }
780
835
  function installPiExtension(cwd, remove = false, scope = "repo") {
781
- const file = scope === "global" ? path5.join(homeDir(), ".pi/agent/extensions/bunshin.js") : path5.join(cwd, ".pi/extensions/bunshin.js");
836
+ const file = scope === "global" ? path6.join(homeDir(), ".pi/agent/extensions/bunshin.js") : path6.join(cwd, ".pi/extensions/bunshin.js");
782
837
  const marker = "// Managed by Bunshin setup.";
783
838
  try {
784
- const previous = fileExists(file) ? fs4.readFileSync(file, "utf8") : "";
839
+ const previous = fileExists(file) ? fs5.readFileSync(file, "utf8") : "";
785
840
  if (previous && !previous.startsWith(marker)) throw new Error("An existing Bunshin extension file is not managed by setup; preserve or move it before continuing.");
786
841
  if (remove) {
787
- if (previous) fs4.unlinkSync(file);
842
+ if (previous) fs5.unlinkSync(file);
788
843
  return { file, installed: 0, changed: !!previous };
789
844
  }
790
845
  const extension = fileURLToPath(new URL("./pi-extension.js", import.meta.url));
@@ -793,8 +848,8 @@ function installPiExtension(cwd, remove = false, scope = "repo") {
793
848
  export { default } from ${JSON.stringify(pathToFileURL(extension).href)};
794
849
  `;
795
850
  if (text !== previous) {
796
- ensureDir(path5.dirname(file));
797
- fs4.writeFileSync(file, text);
851
+ ensureDir(path6.dirname(file));
852
+ fs5.writeFileSync(file, text);
798
853
  }
799
854
  return { file, installed: 1, changed: text !== previous };
800
855
  } catch (error) {
@@ -803,14 +858,14 @@ export { default } from ${JSON.stringify(pathToFileURL(extension).href)};
803
858
  }
804
859
  function installClaudeHooks({ file = claudeSettingsPath(), bin = "bunshin", remove = false, harness = "claude-code" } = {}) {
805
860
  try {
806
- const before = fileExists(file) ? JSON.parse(fs4.readFileSync(file, "utf8")) : {};
861
+ const before = fileExists(file) ? JSON.parse(fs5.readFileSync(file, "utf8")) : {};
807
862
  const after = remove ? removeBunshinHooks(before) : mergeClaudeSettings(before, { bin, harness });
808
863
  const changed = JSON.stringify(before) !== JSON.stringify(after);
809
864
  if (changed) {
810
- ensureDir(path5.dirname(file));
865
+ ensureDir(path6.dirname(file));
811
866
  if (fileExists(file)) {
812
867
  try {
813
- fs4.copyFileSync(file, `${file}.bunshin-backup`);
868
+ fs5.copyFileSync(file, `${file}.bunshin-backup`);
814
869
  } catch {
815
870
  }
816
871
  }
@@ -825,16 +880,16 @@ function runInit(opts = {}) {
825
880
  const cwd = opts.cwd ?? process.cwd();
826
881
  const warnings = [];
827
882
  const scope = opts.scope ?? "repo";
828
- const root = scope === "global" ? path5.join(homeDir(), ".bunshin") : path5.join(cwd, ".bunshin");
829
- const existed = fileExists(path5.join(root, "config.json"));
883
+ const root = scope === "global" ? path6.join(homeDir(), ".bunshin") : path6.join(cwd, ".bunshin");
884
+ const existed = fileExists(path6.join(root, "config.json"));
830
885
  const store = new Store(root);
831
886
  store.ensureLayout();
832
- fs4.chmodSync(root, 448);
887
+ fs5.chmodSync(root, 448);
833
888
  if (scope === "repo") {
834
- const ignore = path5.join(cwd, ".gitignore");
835
- const current = fileExists(ignore) ? fs4.readFileSync(ignore, "utf8") : "";
889
+ const ignore = path6.join(cwd, ".gitignore");
890
+ const current = fileExists(ignore) ? fs5.readFileSync(ignore, "utf8") : "";
836
891
  if (!current.split(/\r?\n/).some((line) => /^\/?\.bunshin\/?$/.test(line.trim()))) {
837
- fs4.appendFileSync(ignore, `${current && !current.endsWith("\n") ? "\n" : ""}.bunshin/
892
+ fs5.appendFileSync(ignore, `${current && !current.endsWith("\n") ? "\n" : ""}.bunshin/
838
893
  `);
839
894
  }
840
895
  }
@@ -861,8 +916,14 @@ function runInit(opts = {}) {
861
916
  if (hooks.error) warnings.push(hooks.error);
862
917
  }
863
918
  if ((opts.removeHooks || !opts.noHooks) && (config.harness === "codex" || config.harness === "claude-code")) {
864
- const file = opts.settingsFile ?? (config.harness === "codex" ? path5.join(scope === "repo" ? path5.join(cwd, ".codex") : process.env.CODEX_HOME ?? path5.join(homeDir(), ".codex"), "hooks.json") : scope === "repo" ? path5.join(cwd, ".claude", "settings.local.json") : claudeSettingsPath());
919
+ const projectHooks = config.harness === "codex" && scope === "repo" && !opts.settingsFile ? codexProjectHooks(cwd) : void 0;
920
+ const file = opts.settingsFile ?? (config.harness === "codex" ? projectHooks?.file ?? path6.join(process.env.CODEX_HOME ?? path6.join(homeDir(), ".codex"), "hooks.json") : scope === "repo" ? path6.join(cwd, ".claude", "settings.local.json") : claudeSettingsPath());
865
921
  hooks = installClaudeHooks({ file, bin: opts.bin, remove: opts.removeHooks, harness: config.harness });
922
+ if (projectHooks?.shared) hooks.sharedWithWorktrees = true;
923
+ if (!hooks.error && projectHooks && projectHooks.file !== projectHooks.legacyFile && fileExists(projectHooks.legacyFile) && fs5.realpathSync(projectHooks.file) !== fs5.realpathSync(projectHooks.legacyFile)) {
924
+ const old = installClaudeHooks({ file: projectHooks.legacyFile, remove: true, harness: "codex" });
925
+ if (old.error) hooks.error = `Could not remove old Bunshin hooks from ${old.file}: ${old.error}`;
926
+ }
866
927
  if (hooks.error) warnings.push(`could not update ${hooks.file}: ${hooks.error}`);
867
928
  }
868
929
  if (!resolveBackend(config)) {
@@ -892,6 +953,7 @@ var init_init = __esm({
892
953
  init_store();
893
954
  init_util();
894
955
  init_llm();
956
+ init_project_hooks();
895
957
  HOOK_EVENTS = [
896
958
  { event: "UserPromptSubmit", arg: "user-prompt-submit", timeout: 10 },
897
959
  { event: "Stop", arg: "stop", timeout: 15 },
@@ -913,16 +975,16 @@ __export(setup_exports, {
913
975
  setupProject: () => setupProject,
914
976
  shellQuote: () => shellQuote
915
977
  });
916
- import fs5 from "node:fs";
917
- import path6 from "node:path";
978
+ import fs6 from "node:fs";
979
+ import path7 from "node:path";
918
980
  import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
919
981
  function executable(name) {
920
- const directories = [...(process.env.PATH ?? "").split(path6.delimiter), path6.join(homeDir(), ".local/bin")];
982
+ const directories = [...(process.env.PATH ?? "").split(path7.delimiter), path7.join(homeDir(), ".local/bin")];
921
983
  for (const directory of directories) {
922
- const file = path6.resolve(directory, name);
984
+ const file = path7.resolve(directory, name);
923
985
  try {
924
- fs5.accessSync(file, fs5.constants.X_OK);
925
- if (fs5.statSync(file).isFile()) return file;
986
+ fs6.accessSync(file, fs6.constants.X_OK);
987
+ if (fs6.statSync(file).isFile()) return file;
926
988
  } catch {
927
989
  }
928
990
  }
@@ -930,8 +992,8 @@ function executable(name) {
930
992
  }
931
993
  function hookBinary(harness) {
932
994
  const agent = executable(harness === "claude-code" ? "claude" : harness);
933
- const directories = [.../* @__PURE__ */ new Set([path6.dirname(process.execPath), path6.join(homeDir(), ".local/bin"), ...agent ? [path6.dirname(agent)] : []])];
934
- return `PATH=${shellQuote(directories.join(path6.delimiter))}:"$PATH" ${shellQuote(process.execPath)} ${shellQuote(fs5.realpathSync(process.argv[1]))}`;
995
+ const directories = [.../* @__PURE__ */ new Set([path7.dirname(process.execPath), path7.join(homeDir(), ".local/bin"), ...agent ? [path7.dirname(agent)] : []])];
996
+ return `PATH=${shellQuote(directories.join(path7.delimiter))}:"$PATH" ${shellQuote(process.execPath)} ${shellQuote(fs6.realpathSync(process.argv[1]))}`;
935
997
  }
936
998
  function apiAddress(raw) {
937
999
  const url = new URL(raw);
@@ -1000,23 +1062,23 @@ async function setupProject(opts) {
1000
1062
  const apiKey = opts.apiKey.trim();
1001
1063
  if (!apiKey || /\s/.test(apiKey)) throw new Error("Paste the complete agent key from your Bunshin workspace.");
1002
1064
  const profile = await connectionProfile(apiUrl, apiKey);
1003
- const root = path6.join(opts.cwd, ".bunshin");
1065
+ const root = path7.join(opts.cwd, ".bunshin");
1004
1066
  const store = new Store(root);
1005
1067
  const previous = store.config();
1006
1068
  if ((previous.orgId !== profile.orgId || previous.userId !== profile.userId) && (store.listLessons().length || store.allTraces().length || store.listRecallLessons().length)) {
1007
1069
  throw new Error("This project has lessons or activity from another profile. Use a fresh project to connect this workspace.");
1008
1070
  }
1009
- const credentialDirectory = path6.join(homeDir(), ".config/bunshin/credentials");
1010
- fs5.mkdirSync(credentialDirectory, { recursive: true, mode: 448 });
1011
- fs5.chmodSync(credentialDirectory, 448);
1071
+ const credentialDirectory = path7.join(homeDir(), ".config/bunshin/credentials");
1072
+ fs6.mkdirSync(credentialDirectory, { recursive: true, mode: 448 });
1073
+ fs6.chmodSync(credentialDirectory, 448);
1012
1074
  const name = createHash2("sha256").update(`${root}
1013
1075
  ${apiUrl}
1014
1076
  ${profile.orgId}
1015
1077
  ${profile.userId}`).digest("hex");
1016
- const credentialFile = path6.join(credentialDirectory, `${name}.json`);
1078
+ const credentialFile = path7.join(credentialDirectory, `${name}.json`);
1017
1079
  const temporary = `${credentialFile}.${randomBytes2(4).toString("hex")}.tmp`;
1018
- fs5.writeFileSync(temporary, JSON.stringify({ apiUrl, apiKey }) + "\n", { mode: 384, flag: "wx" });
1019
- fs5.renameSync(temporary, credentialFile);
1080
+ fs6.writeFileSync(temporary, JSON.stringify({ apiUrl, apiKey }) + "\n", { mode: 384, flag: "wx" });
1081
+ fs6.renameSync(temporary, credentialFile);
1020
1082
  const result = runInit({
1021
1083
  cwd: opts.cwd,
1022
1084
  harness: opts.harness,
@@ -1977,10 +2039,11 @@ function redact(text) {
1977
2039
  if (name === "email") {
1978
2040
  return /^(?:no[._-]?reply|do[._-]?not[._-]?reply)@|@example\./i.test(match) ? match : "[REDACTED:email]";
1979
2041
  }
1980
- if (name === "bearer" || name === "assigned-secret") {
2042
+ if (name === "bearer" || name === "assigned-secret" || name === "json-secret") {
1981
2043
  const key = String(groups[0] ?? "");
1982
2044
  const value = String(groups[1] ?? "");
1983
2045
  if (PLACEHOLDER_RE.test(value)) return match;
2046
+ if (name === "json-secret") return `${JSON.stringify(key)}:"[REDACTED:json-secret]"`;
1984
2047
  return `${key}=[REDACTED:${name}]`;
1985
2048
  }
1986
2049
  return `[REDACTED:${name}]`;
@@ -1993,6 +2056,7 @@ var init_redact2 = __esm({
1993
2056
  "src/redact.ts"() {
1994
2057
  "use strict";
1995
2058
  PATTERNS = [
2059
+ ["bunshin-key", /\bbsk_[A-Za-z0-9]{8}_[A-Za-z0-9]{32}\b/g],
1996
2060
  ["aws-key", /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[0-9A-Z]{16}\b/g],
1997
2061
  ["github-pat", /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g],
1998
2062
  ["anthropic-key", /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g],
@@ -2007,6 +2071,7 @@ var init_redact2 = __esm({
2007
2071
  "assigned-secret",
2008
2072
  /\b([A-Za-z0-9_.-]*(?:secret|passwd|password|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|session[_-]?token)[A-Za-z0-9_.-]*)\s*[:=]\s*['"]?([^\s'"#,;]{6,})['"]?/gi
2009
2073
  ],
2074
+ ["json-secret", /"([A-Za-z0-9_.-]*(?:secret|passwd|password|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|session[_-]?token)[A-Za-z0-9_.-]*)"\s*:\s*"([^"\r\n]{6,})"/gi],
2010
2075
  ["card", /\b(?:\d[ -]?){13,19}\b/g],
2011
2076
  ["email", /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g]
2012
2077
  ];
@@ -2015,11 +2080,11 @@ var init_redact2 = __esm({
2015
2080
  });
2016
2081
 
2017
2082
  // src/transcript.ts
2018
- import fs6 from "node:fs";
2083
+ import fs7 from "node:fs";
2019
2084
  function parseTranscript(file) {
2020
2085
  let raw = "";
2021
2086
  try {
2022
- raw = fs6.readFileSync(file, "utf8");
2087
+ raw = fs7.readFileSync(file, "utf8");
2023
2088
  } catch {
2024
2089
  return { turns: [], userMessages: 0, toolCalls: 0, toolFailures: 0 };
2025
2090
  }
@@ -2055,10 +2120,13 @@ function parseTranscript(file) {
2055
2120
  turns.push({ role: "tool", tool: item.name || "tool", text: summarizeInput(input) });
2056
2121
  toolCalls++;
2057
2122
  } else if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
2058
- const text = blockText(item.output);
2059
- const isError = /(?:Process exited with code|exit_code["']?\s*:)\s*[1-9]\d*\b/i.test(text);
2060
- turns.push({ role: "tool", tool: "result", text, isError });
2061
- if (isError) toolFailures++;
2123
+ const outputs = Array.isArray(item.output) ? item.output.filter((part) => part && typeof part === "object" && typeof part.text === "string").map((part) => part.text) : [item.output];
2124
+ for (const output of outputs) {
2125
+ const result = codexToolResult(output);
2126
+ if (!result.text) continue;
2127
+ turns.push({ role: "tool", tool: "result", ...result });
2128
+ if (result.isError) toolFailures++;
2129
+ }
2062
2130
  }
2063
2131
  continue;
2064
2132
  }
@@ -2110,6 +2178,31 @@ function blockText(content) {
2110
2178
  }
2111
2179
  return content == null ? "" : JSON.stringify(content);
2112
2180
  }
2181
+ function codexToolResult(output) {
2182
+ const raw = blockText(output);
2183
+ let envelope;
2184
+ try {
2185
+ envelope = JSON.parse(raw);
2186
+ } catch {
2187
+ }
2188
+ if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
2189
+ const result = envelope;
2190
+ if (typeof result.output === "string" && (Number.isInteger(result.exit_code) || result.exit_code === null || Number.isInteger(result.session_id))) {
2191
+ const pending = !Number.isInteger(result.exit_code);
2192
+ return {
2193
+ text: `${pending ? "Command still running" : `Process exited with code ${result.exit_code}`}
2194
+ ${result.output}`,
2195
+ isError: !pending && result.exit_code !== 0,
2196
+ pending
2197
+ };
2198
+ }
2199
+ }
2200
+ return {
2201
+ text: raw,
2202
+ isError: /(?:Process exited with code|exit_code["']?\s*:)\s*[1-9]\d*\b/i.test(raw),
2203
+ pending: /(?:Process running with session ID|Script running with cell ID)/i.test(raw)
2204
+ };
2205
+ }
2113
2206
  function summarizeInput(input) {
2114
2207
  if (input == null) return "";
2115
2208
  if (typeof input !== "object") return String(input);
@@ -2119,18 +2212,26 @@ function summarizeInput(input) {
2119
2212
  }
2120
2213
  return JSON.stringify(obj);
2121
2214
  }
2122
- function digest(facts, { from = 0, maxChars = 6e4, toolResultChars = 600 } = {}) {
2215
+ function bounded(text, maxChars) {
2216
+ if (text.length <= maxChars) return text;
2217
+ const marker = "\n[... middle omitted ...]\n";
2218
+ if (maxChars <= marker.length) return text.slice(0, Math.max(0, maxChars));
2219
+ const head = Math.floor((maxChars - marker.length) / 2);
2220
+ return text.slice(0, head) + marker + text.slice(-(maxChars - marker.length - head));
2221
+ }
2222
+ function digest(facts, { from = 0, maxChars = 6e4, toolResultChars = 6e3 } = {}) {
2123
2223
  const parts = [];
2124
2224
  for (const t of facts.turns.slice(from)) {
2225
+ const text = redact(t.text);
2125
2226
  if (t.role === "user") parts.push(`[USER]
2126
- ${truncate(t.text, maxChars)}`);
2227
+ ${bounded(text, maxChars)}`);
2127
2228
  else if (t.role === "assistant") parts.push(`[ASSISTANT]
2128
- ${truncate(t.text, maxChars)}`);
2129
- else if (t.tool === "result") parts.push(`[TOOL RESULT${t.isError ? " \u2014 ERROR" : ""}]
2130
- ${truncate(t.text, toolResultChars)}`);
2131
- else parts.push(`[TOOL ${t.tool}] ${truncate(t.text, 400)}`);
2229
+ ${bounded(text, maxChars)}`);
2230
+ else if (t.tool === "result") parts.push(`[TOOL RESULT${t.isError ? " \u2014 ERROR" : t.pending ? " \u2014 RUNNING" : ""}]
2231
+ ${bounded(text, toolResultChars)}`);
2232
+ else parts.push(`[TOOL ${t.tool}] ${truncate(text, 400)}`);
2132
2233
  }
2133
- return redact(truncate(parts.join("\n\n"), maxChars));
2234
+ return bounded(parts.join("\n\n"), maxChars);
2134
2235
  }
2135
2236
  function recentContext(file, { turns = 2, maxChars = 6e3 } = {}) {
2136
2237
  const facts = parseTranscript(file);
@@ -2176,27 +2277,53 @@ async function runRecall(store, opts) {
2176
2277
  const rules = new Map(store.listRecallLessons().map((r) => [r.id, r]));
2177
2278
  let degraded;
2178
2279
  if (connected && !opts.offline) {
2179
- const remote = await mothershipRecall(config, {
2180
- task,
2181
- userId: config.userId,
2182
- role: config.role,
2183
- orgUnit: config.orgUnit,
2184
- limit: 8,
2185
- recentContext: history,
2186
- dryRun: opts.dryRun
2187
- }, remaining(8e3));
2188
- if (remote && (!remote.profile || remote.profile.orgId === config.orgId && remote.profile.userId === config.userId)) {
2189
- if (remote.profile) profile = remote.profile;
2190
- for (const lesson of remote.lessons) {
2280
+ const cached = prepareRecall(local.map((lesson) => ({ lesson })), { task, ...profile }, profile).candidates.slice(0, 60).map((candidate) => candidate.lesson);
2281
+ const cachedIds = new Set(cached.map((lesson) => lesson.id));
2282
+ const [remote, lookup] = await Promise.all([
2283
+ mothershipRecall(config, {
2284
+ task,
2285
+ userId: config.userId,
2286
+ role: config.role,
2287
+ orgUnit: config.orgUnit,
2288
+ limit: 8,
2289
+ recentContext: history,
2290
+ dryRun: opts.dryRun
2291
+ }, remaining(8e3)),
2292
+ cached.length ? mothershipLookup(config, [...cachedIds], remaining(8e3)) : null
2293
+ ]);
2294
+ const sameIdentity = (p) => p?.orgId === config.orgId && p.userId === config.userId;
2295
+ const remoteOK = remote && (!remote.profile || sameIdentity(remote.profile));
2296
+ const lookupOK = lookup && sameIdentity(lookup.profile);
2297
+ if (remoteOK || lookupOK) {
2298
+ profile = (remoteOK ? remote.profile : void 0) ?? (lookupOK ? lookup.profile : void 0) ?? profile;
2299
+ byId.clear();
2300
+ const refreshed = /* @__PURE__ */ new Map();
2301
+ if (lookupOK) {
2302
+ const unavailable = new Set(lookup.unavailableIds);
2303
+ for (const lesson of cached) {
2304
+ if (unavailable.has(lesson.id) && lesson.version === 1 && (!config.lastSyncAt || lesson.provenance.createdAt > config.lastSyncAt)) byId.set(lesson.id, lesson);
2305
+ }
2306
+ for (const lesson of lookup.lessons) {
2307
+ if (cachedIds.has(lesson.id) && lesson.provenance.orgId === profile.orgId) refreshed.set(lesson.id, lesson);
2308
+ }
2309
+ } else if (cached.length) {
2310
+ degraded = "local cache refresh unavailable; using current hosted knowledge";
2311
+ }
2312
+ for (const lesson of remoteOK ? remote.lessons : []) {
2191
2313
  if (lesson.provenance.orgId !== profile.orgId) continue;
2314
+ const checked = refreshed.get(lesson.id);
2315
+ if (!checked || lesson.version > checked.version) refreshed.set(lesson.id, lesson);
2316
+ }
2317
+ for (const lesson of refreshed.values()) {
2192
2318
  byId.set(lesson.id, lesson);
2193
2319
  if (!opts.dryRun) store.saveLesson(lesson, { reindex: false });
2194
2320
  }
2195
- for (const rule of remote.recallLessons) rules.set(rule.id, rule);
2321
+ for (const rule of remoteOK ? remote.recallLessons : []) rules.set(rule.id, rule);
2196
2322
  if (!opts.dryRun) {
2197
- for (const conflict of remote.pendingConflicts) store.saveConflict(conflict);
2198
- if (remote.lessons.length) store.rebuildIndex();
2323
+ for (const conflict of remoteOK ? remote.pendingConflicts : []) store.saveConflict(conflict);
2324
+ if (refreshed.size) store.rebuildIndex();
2199
2325
  }
2326
+ if (!remoteOK) degraded = "hosted search unavailable; using refreshed locally authored knowledge";
2200
2327
  } else {
2201
2328
  degraded = "mothership unavailable or identity mismatch; using locally authored knowledge";
2202
2329
  }
@@ -2307,11 +2434,11 @@ __export(demo_exports, {
2307
2434
  runDemo: () => runDemo
2308
2435
  });
2309
2436
  import assert from "node:assert/strict";
2310
- import fs7 from "node:fs";
2437
+ import fs8 from "node:fs";
2311
2438
  import os2 from "node:os";
2312
- import path7 from "node:path";
2439
+ import path8 from "node:path";
2313
2440
  async function runDemo(write) {
2314
- const root = fs7.mkdtempSync(path7.join(os2.tmpdir(), "bunshin-demo-"));
2441
+ const root = fs8.mkdtempSync(path8.join(os2.tmpdir(), "bunshin-demo-"));
2315
2442
  try {
2316
2443
  const store = Store.init(root, { ...APR_ENGINEER, apiUrl: "" });
2317
2444
  const { lessons, constraint, workflow } = aprScenario();
@@ -2328,7 +2455,7 @@ Task: ${APR_TASK}
2328
2455
  write(result.text);
2329
2456
  write("\nPASS: Collections constraint and workflow recalled; C-suite, other-company, and superseded lessons excluded.\n");
2330
2457
  } finally {
2331
- fs7.rmSync(root, { recursive: true, force: true });
2458
+ fs8.rmSync(root, { recursive: true, force: true });
2332
2459
  }
2333
2460
  }
2334
2461
  var init_demo = __esm({
@@ -2347,27 +2474,27 @@ __export(reflect_exports, {
2347
2474
  buildLesson: () => buildLesson,
2348
2475
  runReflection: () => runReflection
2349
2476
  });
2350
- import fs8 from "node:fs";
2351
- import path8 from "node:path";
2477
+ import fs9 from "node:fs";
2478
+ import path9 from "node:path";
2352
2479
  import { createHash as createHash3 } from "node:crypto";
2353
2480
  async function runReflection(store, opts) {
2354
2481
  const empty = { lessonIds: [], used: [], missed: [], attribution: [] };
2355
- const lock = path8.join(store.root, `reflect-${createHash3("sha256").update(opts.sessionId).digest("hex")}.lock`);
2482
+ const lock = path9.join(store.root, `reflect-${createHash3("sha256").update(opts.sessionId).digest("hex")}.lock`);
2356
2483
  const deadline = Date.now() + 66e4;
2357
2484
  for (; ; ) {
2358
2485
  try {
2359
- fs8.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
2486
+ fs9.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
2360
2487
  break;
2361
2488
  } catch (error) {
2362
2489
  if (error.code !== "EEXIST") throw error;
2363
2490
  try {
2364
- const owner = Number(fs8.readFileSync(lock, "utf8"));
2491
+ const owner = Number(fs9.readFileSync(lock, "utf8"));
2365
2492
  if (Number.isInteger(owner) && owner > 0) {
2366
2493
  try {
2367
2494
  process.kill(owner, 0);
2368
2495
  } catch (error2) {
2369
2496
  if (error2.code === "ESRCH") {
2370
- fs8.unlinkSync(lock);
2497
+ fs9.unlinkSync(lock);
2371
2498
  continue;
2372
2499
  }
2373
2500
  }
@@ -2382,7 +2509,7 @@ async function runReflection(store, opts) {
2382
2509
  return await reflectSession(store, opts);
2383
2510
  } finally {
2384
2511
  try {
2385
- fs8.unlinkSync(lock);
2512
+ fs9.unlinkSync(lock);
2386
2513
  } catch {
2387
2514
  }
2388
2515
  }
@@ -2440,7 +2567,12 @@ async function reflectSession(store, opts) {
2440
2567
  for (const raw of out.lessons) {
2441
2568
  const rejection = workflowRejection(raw, facts, excerpt);
2442
2569
  if (rejection) {
2443
- store.log("reflect-workflow-rejected", { session: opts.sessionId, title: redact(raw.title), reason: rejection });
2570
+ store.log("reflect-workflow-rejected", {
2571
+ session: opts.sessionId,
2572
+ title: redact(raw.title),
2573
+ reason: rejection,
2574
+ evidence: Array.isArray(raw.verificationEvidence) ? raw.verificationEvidence.filter((quote) => typeof quote === "string").map((quote) => redact(quote).slice(0, 1e3)) : []
2575
+ });
2444
2576
  continue;
2445
2577
  }
2446
2578
  const lesson = store.saveLesson(buildLesson(raw, store, opts), { reindex: false });
@@ -2464,13 +2596,15 @@ function workflowRejection(raw, facts, excerpt) {
2464
2596
  }
2465
2597
  const normalize = (text) => redact(text).replace(/\s+/g, " ").trim();
2466
2598
  const visible = normalize(excerpt);
2467
- const results = facts.turns.filter((turn) => turn.role === "tool" && turn.tool === "result" && !turn.isError).map((turn) => normalize(turn.text));
2599
+ const results = facts.turns.filter((turn) => turn.role === "tool" && turn.tool === "result" && !turn.isError && !turn.pending).map((turn) => normalize(turn.text));
2468
2600
  const evidence = raw.verificationEvidence;
2469
- if (!Array.isArray(evidence) || !evidence.length || evidence.some((quote) => {
2470
- if (typeof quote !== "string") return true;
2601
+ if (!Array.isArray(evidence) || !evidence.length) return "workflow requires verification quotes from successful tool results";
2602
+ for (const [index, quote] of evidence.entries()) {
2603
+ if (typeof quote !== "string") return `workflow verification quote ${index + 1} is not text`;
2471
2604
  const text = normalize(quote);
2472
- return !text || !visible.includes(text) || !results.some((result) => result.includes(text));
2473
- })) return "workflow requires matching verification evidence from successful tool results";
2605
+ if (!text || !results.some((result) => result.includes(text))) return `workflow verification quote ${index + 1} does not match a successful tool result`;
2606
+ if (!visible.includes(text)) return `workflow verification quote ${index + 1} is outside the visible excerpt`;
2607
+ }
2474
2608
  }
2475
2609
  function countFacts(turns) {
2476
2610
  return {
@@ -2710,8 +2844,8 @@ var init_meta_reflect = __esm({
2710
2844
  });
2711
2845
 
2712
2846
  // src/cli.ts
2713
- import fs9 from "node:fs";
2714
- import path9 from "node:path";
2847
+ import fs10 from "node:fs";
2848
+ import path10 from "node:path";
2715
2849
 
2716
2850
  // src/detach.ts
2717
2851
  import { spawn } from "node:child_process";
@@ -2932,6 +3066,7 @@ async function hookCommand(rawEvent, opts = {}) {
2932
3066
  init_llm();
2933
3067
  init_store();
2934
3068
  init_util();
3069
+ init_project_hooks();
2935
3070
  function parseArgs(argv) {
2936
3071
  const out = { _: [], flags: {} };
2937
3072
  const valueless = /* @__PURE__ */ new Set(["json", "quiet", "force", "global", "help", "version", "dry-run", "offline", "apply", "no-hooks", "remove-hooks", "key-stdin", "debug", "verbose"]);
@@ -2979,7 +3114,7 @@ function version() {
2979
3114
  for (const rel of ["../package.json", "../../package.json"]) {
2980
3115
  try {
2981
3116
  const file = new URL(rel, import.meta.url);
2982
- const pkg = JSON.parse(fs9.readFileSync(file, "utf8"));
3117
+ const pkg = JSON.parse(fs10.readFileSync(file, "utf8"));
2983
3118
  if (pkg.name === "@bunshin-cc/bunshin" && pkg.version) return pkg.version;
2984
3119
  } catch {
2985
3120
  }
@@ -3045,7 +3180,7 @@ async function main(argv, opts = {}) {
3045
3180
  if (harness !== "codex" && harness !== "claude-code" && harness !== "pi") throw new Error("Choose your agent: bunshin setup --harness codex, claude-code, or pi.");
3046
3181
  const agent = harness === "claude-code" ? "claude" : harness;
3047
3182
  if (!executable2(agent)) throw new Error(`${agent} was not found. Install and sign in to your agent, then run setup again.`);
3048
- const existing = new Store(path9.join(cwd, ".bunshin")).config();
3183
+ const existing = new Store(path10.join(cwd, ".bunshin")).config();
3049
3184
  const apiUrl = str2(args.flags["api-url"]);
3050
3185
  const key = bool(args.flags["key-stdin"]) ? await new Promise((resolve, reject) => {
3051
3186
  let value = "";
@@ -3066,6 +3201,7 @@ Key saved privately outside the project.
3066
3201
  if (result.hooks?.error) throw new Error(`Connected, but hooks could not be configured: ${result.hooks.error}. Fix the file and run setup again.`);
3067
3202
  if (result.hooks) out(`${result.hooks.installed} ${harness === "pi" ? "extension" : "hooks"} configured in ${result.hooks.file}
3068
3203
  `);
3204
+ if (result.hooks?.sharedWithWorktrees) out("Codex shares these hooks with the repository\u2019s linked worktrees. Each connected worktree keeps its own Bunshin store.\n");
3069
3205
  if (harness === "codex" && result.hooks) out("Run codex interactively in this project. Review and accept project trust, then run /hooks and review and trust the four Bunshin hooks.\n");
3070
3206
  else if (harness === "pi" && result.hooks) out("Open Pi in this project and approve its project extension. Check it with /bunshin status.\n");
3071
3207
  else if (result.hooks) out("Open a new Claude Code session in this project to load the hooks.\n");
@@ -3128,7 +3264,7 @@ ${HELP}`);
3128
3264
  function openStore(args, cwd) {
3129
3265
  const root = str2(args.flags.root);
3130
3266
  if (root) {
3131
- const store = new Store(path9.resolve(root));
3267
+ const store = new Store(path10.resolve(root));
3132
3268
  store.ensureLayout();
3133
3269
  return store;
3134
3270
  }
@@ -3167,6 +3303,7 @@ async function cmdInit(args, cwd, out, err) {
3167
3303
  if (result.hooks) {
3168
3304
  out(` hooks: ${result.hooks.installed} Bunshin entries in ${result.hooks.file}${result.hooks.changed ? "" : " (already up to date)"}
3169
3305
  `);
3306
+ if (result.hooks.sharedWithWorktrees) out(" Codex repository hooks are shared by its linked worktrees; each connected worktree uses its own Bunshin store.\n");
3170
3307
  if (result.hooks.error) err(` hooks: ${result.hooks.error}
3171
3308
  `);
3172
3309
  }
@@ -3184,10 +3321,10 @@ function cmdStatus(store, args, out) {
3184
3321
  const conflicts = store.listConflicts().filter((c) => c.status === "open");
3185
3322
  const metrics = store.readMetrics(void 0);
3186
3323
  const last50 = metrics?.windows?.find((w) => w.name === "last50");
3187
- const hookFiles = config.harness === "codex" ? [.../* @__PURE__ */ new Set([path9.join(process.env.CODEX_HOME ?? path9.join(homeDir(), ".codex"), "hooks.json"), path9.join(path9.dirname(store.root), ".codex", "hooks.json")])] : config.harness === "claude-code" ? [path9.join(homeDir(), ".claude", "settings.json"), path9.join(path9.dirname(store.root), ".claude", "settings.local.json")] : config.harness === "pi" ? [path9.join(homeDir(), ".pi/agent/extensions/bunshin.js"), path9.join(path9.dirname(store.root), ".pi/extensions/bunshin.js")] : [];
3324
+ const hookFiles = config.harness === "codex" ? [.../* @__PURE__ */ new Set([path10.join(process.env.CODEX_HOME ?? path10.join(homeDir(), ".codex"), "hooks.json"), codexProjectHooks(path10.dirname(store.root)).file])] : config.harness === "claude-code" ? [path10.join(homeDir(), ".claude", "settings.json"), path10.join(path10.dirname(store.root), ".claude", "settings.local.json")] : config.harness === "pi" ? [path10.join(homeDir(), ".pi/agent/extensions/bunshin.js"), path10.join(path10.dirname(store.root), ".pi/extensions/bunshin.js")] : [];
3188
3325
  const hooks = config.harness === "pi" ? hookFiles.filter((file) => {
3189
3326
  try {
3190
- return fs9.readFileSync(file, "utf8").startsWith("// Managed by Bunshin setup.");
3327
+ return fs10.readFileSync(file, "utf8").startsWith("// Managed by Bunshin setup.");
3191
3328
  } catch {
3192
3329
  return false;
3193
3330
  }
@@ -3204,7 +3341,7 @@ function cmdStatus(store, args, out) {
3204
3341
  mothership: config.apiUrl ? { apiUrl: config.apiUrl, key: config.apiKey ? "set" : "missing", lastSyncAt: config.lastSyncAt ?? null } : null,
3205
3342
  llmBackend: resolveBackend(config) ?? "none (set ANTHROPIC_API_KEY or install the claude/codex CLI)",
3206
3343
  hooks: { [config.harness]: hooks },
3207
- hookFiles: hookFiles.filter((file) => fs9.existsSync(file)),
3344
+ hookFiles: hookFiles.filter((file) => fs10.existsSync(file)),
3208
3345
  recallQuality: last50 ? { precision: last50.precision, recall: last50.recall, f1: last50.f1, traces: last50.traces } : null
3209
3346
  };
3210
3347
  if (bool(args.flags.json)) {
@@ -3236,7 +3373,7 @@ function cmdStatus(store, args, out) {
3236
3373
  }
3237
3374
  function hookCount(file) {
3238
3375
  try {
3239
- const raw = JSON.parse(fs9.readFileSync(file, "utf8"));
3376
+ const raw = JSON.parse(fs10.readFileSync(file, "utf8"));
3240
3377
  let n = 0;
3241
3378
  for (const groups of Object.values(raw.hooks ?? {})) {
3242
3379
  for (const g of groups ?? []) {
@@ -3356,7 +3493,7 @@ function cmdLessonAdd(store, args, out, err) {
3356
3493
  let content = str2(args.flags.content) ?? "";
3357
3494
  if (!content || content === "-" || args._.includes("-")) {
3358
3495
  try {
3359
- content = fs9.readFileSync(0, "utf8");
3496
+ content = fs10.readFileSync(0, "utf8");
3360
3497
  } catch {
3361
3498
  }
3362
3499
  }
@@ -3422,7 +3559,7 @@ async function cmdReflect(store, args, cwd, out, err) {
3422
3559
  }
3423
3560
  const { runReflection: runReflection2 } = await Promise.resolve().then(() => (init_reflect2(), reflect_exports));
3424
3561
  const result = await runReflection2(store, {
3425
- sessionId: str2(args.flags.session) ?? path9.basename(transcript).replace(/\.jsonl?$/, ""),
3562
+ sessionId: str2(args.flags.session) ?? path10.basename(transcript).replace(/\.jsonl?$/, ""),
3426
3563
  transcriptPath: transcript,
3427
3564
  cwd,
3428
3565
  force: bool(args.flags.force)
@@ -3525,7 +3662,7 @@ async function cmdSessionEnd(store, args, cwd, out) {
3525
3662
  const transcript = str2(args.flags.transcript);
3526
3663
  const quiet = bool(args.flags.quiet);
3527
3664
  const summary = { session: sessionId };
3528
- if (transcript && fs9.existsSync(transcript)) {
3665
+ if (transcript && fs10.existsSync(transcript)) {
3529
3666
  const { runReflection: runReflection2 } = await Promise.resolve().then(() => (init_reflect2(), reflect_exports));
3530
3667
  summary.reflect = await runReflection2(store, { sessionId, transcriptPath: transcript, cwd, force: true });
3531
3668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunshin-cc/bunshin",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Shared memory for company AI agents. Connect Codex, Claude Code, or Pi to Bunshin.",
5
5
  "type": "module",
6
6
  "bin": {