@bunshin-cc/bunshin 0.1.3 → 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 +150 -67
  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
  }
@@ -491,6 +495,13 @@ async function runSync(store) {
491
495
  store.saveConflict(c);
492
496
  result.pulledConflicts++;
493
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
+ }
494
505
  } catch (e) {
495
506
  result.errors.push(String(e instanceof Error ? e.message : e));
496
507
  }
@@ -503,8 +514,7 @@ async function resolveConflict(store, id, answer, note) {
503
514
  const local = store.listConflicts().find((c) => c.id === id);
504
515
  if (!local) throw new Error(`no pending conflict ${id}`);
505
516
  const resolution = { chosen: answer, by: config.userId, at: nowIso(), note };
506
- if (canSync(config)) await api(config, "POST", `/conflicts/${encodeURIComponent(id)}/resolve`, resolution);
507
- 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 };
508
518
  store.saveConflict(resolved);
509
519
  store.log("conflict-resolve", { id, answer });
510
520
  return resolved;
@@ -517,7 +527,7 @@ function pendingConflictQuestion(store, asked) {
517
527
  const options = c.options?.length ? `
518
528
  Options: ${c.options.map((o, i) => `(${i + 1}) ${o}`).join(" ")}` : "";
519
529
  const text = `## Knowledge conflict to resolve (Bunshin)
520
- 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.
521
531
 
522
532
  > ${c.question}${options}
523
533
 
@@ -716,6 +726,41 @@ var init_llm = __esm({
716
726
  }
717
727
  });
718
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
+
719
764
  // src/init.ts
720
765
  var init_exports = {};
721
766
  __export(init_exports, {
@@ -731,8 +776,8 @@ __export(init_exports, {
731
776
  removeBunshinHooks: () => removeBunshinHooks,
732
777
  runInit: () => runInit
733
778
  });
734
- import fs4 from "node:fs";
735
- import path5 from "node:path";
779
+ import fs5 from "node:fs";
780
+ import path6 from "node:path";
736
781
  import { fileURLToPath, pathToFileURL } from "node:url";
737
782
  function isBunshinEntry(entry) {
738
783
  return entry?._bunshin === true || typeof entry?.command === "string" && /\bbunshin\b.*\bhook\b/.test(entry.command);
@@ -785,16 +830,16 @@ function countBunshinHooks(settings) {
785
830
  );
786
831
  }
787
832
  function claudeSettingsPath() {
788
- return path5.join(homeDir(), ".claude", "settings.json");
833
+ return path6.join(homeDir(), ".claude", "settings.json");
789
834
  }
790
835
  function installPiExtension(cwd, remove = false, scope = "repo") {
791
- 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");
792
837
  const marker = "// Managed by Bunshin setup.";
793
838
  try {
794
- const previous = fileExists(file) ? fs4.readFileSync(file, "utf8") : "";
839
+ const previous = fileExists(file) ? fs5.readFileSync(file, "utf8") : "";
795
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.");
796
841
  if (remove) {
797
- if (previous) fs4.unlinkSync(file);
842
+ if (previous) fs5.unlinkSync(file);
798
843
  return { file, installed: 0, changed: !!previous };
799
844
  }
800
845
  const extension = fileURLToPath(new URL("./pi-extension.js", import.meta.url));
@@ -803,8 +848,8 @@ function installPiExtension(cwd, remove = false, scope = "repo") {
803
848
  export { default } from ${JSON.stringify(pathToFileURL(extension).href)};
804
849
  `;
805
850
  if (text !== previous) {
806
- ensureDir(path5.dirname(file));
807
- fs4.writeFileSync(file, text);
851
+ ensureDir(path6.dirname(file));
852
+ fs5.writeFileSync(file, text);
808
853
  }
809
854
  return { file, installed: 1, changed: text !== previous };
810
855
  } catch (error) {
@@ -813,14 +858,14 @@ export { default } from ${JSON.stringify(pathToFileURL(extension).href)};
813
858
  }
814
859
  function installClaudeHooks({ file = claudeSettingsPath(), bin = "bunshin", remove = false, harness = "claude-code" } = {}) {
815
860
  try {
816
- const before = fileExists(file) ? JSON.parse(fs4.readFileSync(file, "utf8")) : {};
861
+ const before = fileExists(file) ? JSON.parse(fs5.readFileSync(file, "utf8")) : {};
817
862
  const after = remove ? removeBunshinHooks(before) : mergeClaudeSettings(before, { bin, harness });
818
863
  const changed = JSON.stringify(before) !== JSON.stringify(after);
819
864
  if (changed) {
820
- ensureDir(path5.dirname(file));
865
+ ensureDir(path6.dirname(file));
821
866
  if (fileExists(file)) {
822
867
  try {
823
- fs4.copyFileSync(file, `${file}.bunshin-backup`);
868
+ fs5.copyFileSync(file, `${file}.bunshin-backup`);
824
869
  } catch {
825
870
  }
826
871
  }
@@ -835,16 +880,16 @@ function runInit(opts = {}) {
835
880
  const cwd = opts.cwd ?? process.cwd();
836
881
  const warnings = [];
837
882
  const scope = opts.scope ?? "repo";
838
- const root = scope === "global" ? path5.join(homeDir(), ".bunshin") : path5.join(cwd, ".bunshin");
839
- 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"));
840
885
  const store = new Store(root);
841
886
  store.ensureLayout();
842
- fs4.chmodSync(root, 448);
887
+ fs5.chmodSync(root, 448);
843
888
  if (scope === "repo") {
844
- const ignore = path5.join(cwd, ".gitignore");
845
- const current = fileExists(ignore) ? fs4.readFileSync(ignore, "utf8") : "";
889
+ const ignore = path6.join(cwd, ".gitignore");
890
+ const current = fileExists(ignore) ? fs5.readFileSync(ignore, "utf8") : "";
846
891
  if (!current.split(/\r?\n/).some((line) => /^\/?\.bunshin\/?$/.test(line.trim()))) {
847
- fs4.appendFileSync(ignore, `${current && !current.endsWith("\n") ? "\n" : ""}.bunshin/
892
+ fs5.appendFileSync(ignore, `${current && !current.endsWith("\n") ? "\n" : ""}.bunshin/
848
893
  `);
849
894
  }
850
895
  }
@@ -871,8 +916,14 @@ function runInit(opts = {}) {
871
916
  if (hooks.error) warnings.push(hooks.error);
872
917
  }
873
918
  if ((opts.removeHooks || !opts.noHooks) && (config.harness === "codex" || config.harness === "claude-code")) {
874
- 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());
875
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
+ }
876
927
  if (hooks.error) warnings.push(`could not update ${hooks.file}: ${hooks.error}`);
877
928
  }
878
929
  if (!resolveBackend(config)) {
@@ -902,6 +953,7 @@ var init_init = __esm({
902
953
  init_store();
903
954
  init_util();
904
955
  init_llm();
956
+ init_project_hooks();
905
957
  HOOK_EVENTS = [
906
958
  { event: "UserPromptSubmit", arg: "user-prompt-submit", timeout: 10 },
907
959
  { event: "Stop", arg: "stop", timeout: 15 },
@@ -923,16 +975,16 @@ __export(setup_exports, {
923
975
  setupProject: () => setupProject,
924
976
  shellQuote: () => shellQuote
925
977
  });
926
- import fs5 from "node:fs";
927
- import path6 from "node:path";
978
+ import fs6 from "node:fs";
979
+ import path7 from "node:path";
928
980
  import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
929
981
  function executable(name) {
930
- 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")];
931
983
  for (const directory of directories) {
932
- const file = path6.resolve(directory, name);
984
+ const file = path7.resolve(directory, name);
933
985
  try {
934
- fs5.accessSync(file, fs5.constants.X_OK);
935
- if (fs5.statSync(file).isFile()) return file;
986
+ fs6.accessSync(file, fs6.constants.X_OK);
987
+ if (fs6.statSync(file).isFile()) return file;
936
988
  } catch {
937
989
  }
938
990
  }
@@ -940,8 +992,8 @@ function executable(name) {
940
992
  }
941
993
  function hookBinary(harness) {
942
994
  const agent = executable(harness === "claude-code" ? "claude" : harness);
943
- const directories = [.../* @__PURE__ */ new Set([path6.dirname(process.execPath), path6.join(homeDir(), ".local/bin"), ...agent ? [path6.dirname(agent)] : []])];
944
- 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]))}`;
945
997
  }
946
998
  function apiAddress(raw) {
947
999
  const url = new URL(raw);
@@ -1010,23 +1062,23 @@ async function setupProject(opts) {
1010
1062
  const apiKey = opts.apiKey.trim();
1011
1063
  if (!apiKey || /\s/.test(apiKey)) throw new Error("Paste the complete agent key from your Bunshin workspace.");
1012
1064
  const profile = await connectionProfile(apiUrl, apiKey);
1013
- const root = path6.join(opts.cwd, ".bunshin");
1065
+ const root = path7.join(opts.cwd, ".bunshin");
1014
1066
  const store = new Store(root);
1015
1067
  const previous = store.config();
1016
1068
  if ((previous.orgId !== profile.orgId || previous.userId !== profile.userId) && (store.listLessons().length || store.allTraces().length || store.listRecallLessons().length)) {
1017
1069
  throw new Error("This project has lessons or activity from another profile. Use a fresh project to connect this workspace.");
1018
1070
  }
1019
- const credentialDirectory = path6.join(homeDir(), ".config/bunshin/credentials");
1020
- fs5.mkdirSync(credentialDirectory, { recursive: true, mode: 448 });
1021
- 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);
1022
1074
  const name = createHash2("sha256").update(`${root}
1023
1075
  ${apiUrl}
1024
1076
  ${profile.orgId}
1025
1077
  ${profile.userId}`).digest("hex");
1026
- const credentialFile = path6.join(credentialDirectory, `${name}.json`);
1078
+ const credentialFile = path7.join(credentialDirectory, `${name}.json`);
1027
1079
  const temporary = `${credentialFile}.${randomBytes2(4).toString("hex")}.tmp`;
1028
- fs5.writeFileSync(temporary, JSON.stringify({ apiUrl, apiKey }) + "\n", { mode: 384, flag: "wx" });
1029
- fs5.renameSync(temporary, credentialFile);
1080
+ fs6.writeFileSync(temporary, JSON.stringify({ apiUrl, apiKey }) + "\n", { mode: 384, flag: "wx" });
1081
+ fs6.renameSync(temporary, credentialFile);
1030
1082
  const result = runInit({
1031
1083
  cwd: opts.cwd,
1032
1084
  harness: opts.harness,
@@ -2028,11 +2080,11 @@ var init_redact2 = __esm({
2028
2080
  });
2029
2081
 
2030
2082
  // src/transcript.ts
2031
- import fs6 from "node:fs";
2083
+ import fs7 from "node:fs";
2032
2084
  function parseTranscript(file) {
2033
2085
  let raw = "";
2034
2086
  try {
2035
- raw = fs6.readFileSync(file, "utf8");
2087
+ raw = fs7.readFileSync(file, "utf8");
2036
2088
  } catch {
2037
2089
  return { turns: [], userMessages: 0, toolCalls: 0, toolFailures: 0 };
2038
2090
  }
@@ -2068,10 +2120,13 @@ function parseTranscript(file) {
2068
2120
  turns.push({ role: "tool", tool: item.name || "tool", text: summarizeInput(input) });
2069
2121
  toolCalls++;
2070
2122
  } else if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
2071
- const text = blockText(item.output);
2072
- const isError = /(?:Process exited with code|exit_code["']?\s*:)\s*[1-9]\d*\b/i.test(text);
2073
- turns.push({ role: "tool", tool: "result", text, isError });
2074
- 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
+ }
2075
2130
  }
2076
2131
  continue;
2077
2132
  }
@@ -2123,6 +2178,31 @@ function blockText(content) {
2123
2178
  }
2124
2179
  return content == null ? "" : JSON.stringify(content);
2125
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
+ }
2126
2206
  function summarizeInput(input) {
2127
2207
  if (input == null) return "";
2128
2208
  if (typeof input !== "object") return String(input);
@@ -2147,7 +2227,7 @@ function digest(facts, { from = 0, maxChars = 6e4, toolResultChars = 6e3 } = {})
2147
2227
  ${bounded(text, maxChars)}`);
2148
2228
  else if (t.role === "assistant") parts.push(`[ASSISTANT]
2149
2229
  ${bounded(text, maxChars)}`);
2150
- else if (t.tool === "result") parts.push(`[TOOL RESULT${t.isError ? " \u2014 ERROR" : ""}]
2230
+ else if (t.tool === "result") parts.push(`[TOOL RESULT${t.isError ? " \u2014 ERROR" : t.pending ? " \u2014 RUNNING" : ""}]
2151
2231
  ${bounded(text, toolResultChars)}`);
2152
2232
  else parts.push(`[TOOL ${t.tool}] ${truncate(text, 400)}`);
2153
2233
  }
@@ -2354,11 +2434,11 @@ __export(demo_exports, {
2354
2434
  runDemo: () => runDemo
2355
2435
  });
2356
2436
  import assert from "node:assert/strict";
2357
- import fs7 from "node:fs";
2437
+ import fs8 from "node:fs";
2358
2438
  import os2 from "node:os";
2359
- import path7 from "node:path";
2439
+ import path8 from "node:path";
2360
2440
  async function runDemo(write) {
2361
- const root = fs7.mkdtempSync(path7.join(os2.tmpdir(), "bunshin-demo-"));
2441
+ const root = fs8.mkdtempSync(path8.join(os2.tmpdir(), "bunshin-demo-"));
2362
2442
  try {
2363
2443
  const store = Store.init(root, { ...APR_ENGINEER, apiUrl: "" });
2364
2444
  const { lessons, constraint, workflow } = aprScenario();
@@ -2375,7 +2455,7 @@ Task: ${APR_TASK}
2375
2455
  write(result.text);
2376
2456
  write("\nPASS: Collections constraint and workflow recalled; C-suite, other-company, and superseded lessons excluded.\n");
2377
2457
  } finally {
2378
- fs7.rmSync(root, { recursive: true, force: true });
2458
+ fs8.rmSync(root, { recursive: true, force: true });
2379
2459
  }
2380
2460
  }
2381
2461
  var init_demo = __esm({
@@ -2394,27 +2474,27 @@ __export(reflect_exports, {
2394
2474
  buildLesson: () => buildLesson,
2395
2475
  runReflection: () => runReflection
2396
2476
  });
2397
- import fs8 from "node:fs";
2398
- import path8 from "node:path";
2477
+ import fs9 from "node:fs";
2478
+ import path9 from "node:path";
2399
2479
  import { createHash as createHash3 } from "node:crypto";
2400
2480
  async function runReflection(store, opts) {
2401
2481
  const empty = { lessonIds: [], used: [], missed: [], attribution: [] };
2402
- 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`);
2403
2483
  const deadline = Date.now() + 66e4;
2404
2484
  for (; ; ) {
2405
2485
  try {
2406
- fs8.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
2486
+ fs9.writeFileSync(lock, String(process.pid), { flag: "wx", mode: 384 });
2407
2487
  break;
2408
2488
  } catch (error) {
2409
2489
  if (error.code !== "EEXIST") throw error;
2410
2490
  try {
2411
- const owner = Number(fs8.readFileSync(lock, "utf8"));
2491
+ const owner = Number(fs9.readFileSync(lock, "utf8"));
2412
2492
  if (Number.isInteger(owner) && owner > 0) {
2413
2493
  try {
2414
2494
  process.kill(owner, 0);
2415
2495
  } catch (error2) {
2416
2496
  if (error2.code === "ESRCH") {
2417
- fs8.unlinkSync(lock);
2497
+ fs9.unlinkSync(lock);
2418
2498
  continue;
2419
2499
  }
2420
2500
  }
@@ -2429,7 +2509,7 @@ async function runReflection(store, opts) {
2429
2509
  return await reflectSession(store, opts);
2430
2510
  } finally {
2431
2511
  try {
2432
- fs8.unlinkSync(lock);
2512
+ fs9.unlinkSync(lock);
2433
2513
  } catch {
2434
2514
  }
2435
2515
  }
@@ -2516,7 +2596,7 @@ function workflowRejection(raw, facts, excerpt) {
2516
2596
  }
2517
2597
  const normalize = (text) => redact(text).replace(/\s+/g, " ").trim();
2518
2598
  const visible = normalize(excerpt);
2519
- 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));
2520
2600
  const evidence = raw.verificationEvidence;
2521
2601
  if (!Array.isArray(evidence) || !evidence.length) return "workflow requires verification quotes from successful tool results";
2522
2602
  for (const [index, quote] of evidence.entries()) {
@@ -2764,8 +2844,8 @@ var init_meta_reflect = __esm({
2764
2844
  });
2765
2845
 
2766
2846
  // src/cli.ts
2767
- import fs9 from "node:fs";
2768
- import path9 from "node:path";
2847
+ import fs10 from "node:fs";
2848
+ import path10 from "node:path";
2769
2849
 
2770
2850
  // src/detach.ts
2771
2851
  import { spawn } from "node:child_process";
@@ -2986,6 +3066,7 @@ async function hookCommand(rawEvent, opts = {}) {
2986
3066
  init_llm();
2987
3067
  init_store();
2988
3068
  init_util();
3069
+ init_project_hooks();
2989
3070
  function parseArgs(argv) {
2990
3071
  const out = { _: [], flags: {} };
2991
3072
  const valueless = /* @__PURE__ */ new Set(["json", "quiet", "force", "global", "help", "version", "dry-run", "offline", "apply", "no-hooks", "remove-hooks", "key-stdin", "debug", "verbose"]);
@@ -3033,7 +3114,7 @@ function version() {
3033
3114
  for (const rel of ["../package.json", "../../package.json"]) {
3034
3115
  try {
3035
3116
  const file = new URL(rel, import.meta.url);
3036
- const pkg = JSON.parse(fs9.readFileSync(file, "utf8"));
3117
+ const pkg = JSON.parse(fs10.readFileSync(file, "utf8"));
3037
3118
  if (pkg.name === "@bunshin-cc/bunshin" && pkg.version) return pkg.version;
3038
3119
  } catch {
3039
3120
  }
@@ -3099,7 +3180,7 @@ async function main(argv, opts = {}) {
3099
3180
  if (harness !== "codex" && harness !== "claude-code" && harness !== "pi") throw new Error("Choose your agent: bunshin setup --harness codex, claude-code, or pi.");
3100
3181
  const agent = harness === "claude-code" ? "claude" : harness;
3101
3182
  if (!executable2(agent)) throw new Error(`${agent} was not found. Install and sign in to your agent, then run setup again.`);
3102
- const existing = new Store(path9.join(cwd, ".bunshin")).config();
3183
+ const existing = new Store(path10.join(cwd, ".bunshin")).config();
3103
3184
  const apiUrl = str2(args.flags["api-url"]);
3104
3185
  const key = bool(args.flags["key-stdin"]) ? await new Promise((resolve, reject) => {
3105
3186
  let value = "";
@@ -3120,6 +3201,7 @@ Key saved privately outside the project.
3120
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.`);
3121
3202
  if (result.hooks) out(`${result.hooks.installed} ${harness === "pi" ? "extension" : "hooks"} configured in ${result.hooks.file}
3122
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");
3123
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");
3124
3206
  else if (harness === "pi" && result.hooks) out("Open Pi in this project and approve its project extension. Check it with /bunshin status.\n");
3125
3207
  else if (result.hooks) out("Open a new Claude Code session in this project to load the hooks.\n");
@@ -3182,7 +3264,7 @@ ${HELP}`);
3182
3264
  function openStore(args, cwd) {
3183
3265
  const root = str2(args.flags.root);
3184
3266
  if (root) {
3185
- const store = new Store(path9.resolve(root));
3267
+ const store = new Store(path10.resolve(root));
3186
3268
  store.ensureLayout();
3187
3269
  return store;
3188
3270
  }
@@ -3221,6 +3303,7 @@ async function cmdInit(args, cwd, out, err) {
3221
3303
  if (result.hooks) {
3222
3304
  out(` hooks: ${result.hooks.installed} Bunshin entries in ${result.hooks.file}${result.hooks.changed ? "" : " (already up to date)"}
3223
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");
3224
3307
  if (result.hooks.error) err(` hooks: ${result.hooks.error}
3225
3308
  `);
3226
3309
  }
@@ -3238,10 +3321,10 @@ function cmdStatus(store, args, out) {
3238
3321
  const conflicts = store.listConflicts().filter((c) => c.status === "open");
3239
3322
  const metrics = store.readMetrics(void 0);
3240
3323
  const last50 = metrics?.windows?.find((w) => w.name === "last50");
3241
- 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")] : [];
3242
3325
  const hooks = config.harness === "pi" ? hookFiles.filter((file) => {
3243
3326
  try {
3244
- return fs9.readFileSync(file, "utf8").startsWith("// Managed by Bunshin setup.");
3327
+ return fs10.readFileSync(file, "utf8").startsWith("// Managed by Bunshin setup.");
3245
3328
  } catch {
3246
3329
  return false;
3247
3330
  }
@@ -3258,7 +3341,7 @@ function cmdStatus(store, args, out) {
3258
3341
  mothership: config.apiUrl ? { apiUrl: config.apiUrl, key: config.apiKey ? "set" : "missing", lastSyncAt: config.lastSyncAt ?? null } : null,
3259
3342
  llmBackend: resolveBackend(config) ?? "none (set ANTHROPIC_API_KEY or install the claude/codex CLI)",
3260
3343
  hooks: { [config.harness]: hooks },
3261
- hookFiles: hookFiles.filter((file) => fs9.existsSync(file)),
3344
+ hookFiles: hookFiles.filter((file) => fs10.existsSync(file)),
3262
3345
  recallQuality: last50 ? { precision: last50.precision, recall: last50.recall, f1: last50.f1, traces: last50.traces } : null
3263
3346
  };
3264
3347
  if (bool(args.flags.json)) {
@@ -3290,7 +3373,7 @@ function cmdStatus(store, args, out) {
3290
3373
  }
3291
3374
  function hookCount(file) {
3292
3375
  try {
3293
- const raw = JSON.parse(fs9.readFileSync(file, "utf8"));
3376
+ const raw = JSON.parse(fs10.readFileSync(file, "utf8"));
3294
3377
  let n = 0;
3295
3378
  for (const groups of Object.values(raw.hooks ?? {})) {
3296
3379
  for (const g of groups ?? []) {
@@ -3410,7 +3493,7 @@ function cmdLessonAdd(store, args, out, err) {
3410
3493
  let content = str2(args.flags.content) ?? "";
3411
3494
  if (!content || content === "-" || args._.includes("-")) {
3412
3495
  try {
3413
- content = fs9.readFileSync(0, "utf8");
3496
+ content = fs10.readFileSync(0, "utf8");
3414
3497
  } catch {
3415
3498
  }
3416
3499
  }
@@ -3476,7 +3559,7 @@ async function cmdReflect(store, args, cwd, out, err) {
3476
3559
  }
3477
3560
  const { runReflection: runReflection2 } = await Promise.resolve().then(() => (init_reflect2(), reflect_exports));
3478
3561
  const result = await runReflection2(store, {
3479
- sessionId: str2(args.flags.session) ?? path9.basename(transcript).replace(/\.jsonl?$/, ""),
3562
+ sessionId: str2(args.flags.session) ?? path10.basename(transcript).replace(/\.jsonl?$/, ""),
3480
3563
  transcriptPath: transcript,
3481
3564
  cwd,
3482
3565
  force: bool(args.flags.force)
@@ -3579,7 +3662,7 @@ async function cmdSessionEnd(store, args, cwd, out) {
3579
3662
  const transcript = str2(args.flags.transcript);
3580
3663
  const quiet = bool(args.flags.quiet);
3581
3664
  const summary = { session: sessionId };
3582
- if (transcript && fs9.existsSync(transcript)) {
3665
+ if (transcript && fs10.existsSync(transcript)) {
3583
3666
  const { runReflection: runReflection2 } = await Promise.resolve().then(() => (init_reflect2(), reflect_exports));
3584
3667
  summary.reflect = await runReflection2(store, { sessionId, transcriptPath: transcript, cwd, force: true });
3585
3668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunshin-cc/bunshin",
3
- "version": "0.1.3",
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": {