@basou/cli 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2252 -1431
- package/dist/index.js.map +1 -1
- package/dist/program.js +2252 -1431
- package/dist/program.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -632,10 +632,217 @@ function printNoApprovals(options) {
|
|
|
632
632
|
}
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
// src/lib/context-channel.ts
|
|
636
|
+
import { homedir } from "os";
|
|
637
|
+
import { join as join3 } from "path";
|
|
638
|
+
import {
|
|
639
|
+
ORIENTATION_END,
|
|
640
|
+
ORIENTATION_START,
|
|
641
|
+
parseMarkers,
|
|
642
|
+
readMarkdownFile,
|
|
643
|
+
removeMarkerSection
|
|
644
|
+
} from "@basou/core";
|
|
645
|
+
|
|
646
|
+
// src/lib/durable-write.ts
|
|
647
|
+
import { randomUUID } from "crypto";
|
|
648
|
+
import { lstat, open, rename, stat, unlink as unlink2 } from "fs/promises";
|
|
649
|
+
import { basename, dirname, join as join2 } from "path";
|
|
650
|
+
async function assertNotSymlink(targetPath) {
|
|
651
|
+
try {
|
|
652
|
+
const st = await lstat(targetPath);
|
|
653
|
+
if (st.isSymbolicLink()) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
"Refusing to write through a symlink. Replace the symlinked target with a regular file (or remove it) and retry."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
if (error instanceof Error && error.code === "ENOENT") return;
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function writeFileDurable(targetPath, content) {
|
|
664
|
+
const dir = dirname(targetPath);
|
|
665
|
+
const tmpPath = join2(dir, `.${basename(targetPath)}.tmp.${randomUUID()}`);
|
|
666
|
+
let mode = 420;
|
|
667
|
+
try {
|
|
668
|
+
mode = (await stat(targetPath)).mode & 511;
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (!(error instanceof Error && error.code === "ENOENT")) {
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
let handle;
|
|
675
|
+
try {
|
|
676
|
+
handle = await open(tmpPath, "wx", mode);
|
|
677
|
+
await handle.writeFile(content, "utf8");
|
|
678
|
+
await handle.chmod(mode);
|
|
679
|
+
await handle.sync();
|
|
680
|
+
await handle.close();
|
|
681
|
+
handle = void 0;
|
|
682
|
+
await rename(tmpPath, targetPath);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (handle) await handle.close().catch(() => void 0);
|
|
685
|
+
await unlink2(tmpPath).catch(() => void 0);
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
const dirHandle = await open(dir, "r");
|
|
690
|
+
try {
|
|
691
|
+
await dirHandle.sync();
|
|
692
|
+
} finally {
|
|
693
|
+
await dirHandle.close();
|
|
694
|
+
}
|
|
695
|
+
} catch {
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/lib/context-channel.ts
|
|
700
|
+
var CODEX_TARGET_PATH = join3(homedir(), ".codex", "AGENTS.md");
|
|
701
|
+
var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
|
|
702
|
+
function buildTargetBody(existing, block, markers) {
|
|
703
|
+
const wrapped = `${markers.start}
|
|
704
|
+
${block}${markers.end}
|
|
705
|
+
`;
|
|
706
|
+
if (existing === null || existing === "") return wrapped;
|
|
707
|
+
const section = parseMarkers(existing, markers);
|
|
708
|
+
switch (section.kind) {
|
|
709
|
+
case "ok":
|
|
710
|
+
return `${section.before}${markers.start}
|
|
711
|
+
${block}${markers.end}${section.after}`;
|
|
712
|
+
case "no_markers": {
|
|
713
|
+
const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
714
|
+
return `${existing}${sep}${wrapped}`;
|
|
715
|
+
}
|
|
716
|
+
default:
|
|
717
|
+
throw new Error(
|
|
718
|
+
"The basou-managed markers in the target are malformed (a marker is missing, duplicated, or out of order). Fix or remove them, then retry."
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function backupOnce(target, existing) {
|
|
723
|
+
const bak = `${target}.basou-bak`;
|
|
724
|
+
const already = await readMarkdownFile(bak);
|
|
725
|
+
if (already !== null) return;
|
|
726
|
+
await writeFileDurable(bak, existing ?? "");
|
|
727
|
+
}
|
|
728
|
+
async function syncMarkerBlock(opts) {
|
|
729
|
+
const { target, markers, block } = opts;
|
|
730
|
+
await assertNotSymlink(target);
|
|
731
|
+
const existing = await readMarkdownFile(target);
|
|
732
|
+
const newBody = buildTargetBody(existing, block, markers);
|
|
733
|
+
if (newBody === existing) return { action: "unchanged" };
|
|
734
|
+
const hadBlock = existing !== null && parseMarkers(existing, markers).kind === "ok";
|
|
735
|
+
const action = hadBlock ? "updated" : "installed";
|
|
736
|
+
if (opts.dryRun === true) return { action };
|
|
737
|
+
const recheck = await readMarkdownFile(target);
|
|
738
|
+
if (recheck !== existing) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
"The target changed during sync; aborting so a concurrent edit is not overwritten. Re-run the command."
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
await backupOnce(target, existing);
|
|
744
|
+
await writeFileDurable(target, newBody);
|
|
745
|
+
return { action };
|
|
746
|
+
}
|
|
747
|
+
function assertNoMarkerLine(body, markers) {
|
|
748
|
+
for (const line of body.split(/\r?\n/)) {
|
|
749
|
+
if (line === markers.start || line === markers.end) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
"The content contains a basou marker line, which would corrupt the managed block. Remove that line from the source."
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async function removeMarkerBlock(opts) {
|
|
757
|
+
const { target, markers, fileLabel } = opts;
|
|
758
|
+
await assertNotSymlink(target);
|
|
759
|
+
const existing = await readMarkdownFile(target);
|
|
760
|
+
if (existing === null) return { removed: false };
|
|
761
|
+
const newBody = removeMarkerSection(existing, fileLabel, markers);
|
|
762
|
+
if (newBody === existing) return { removed: false };
|
|
763
|
+
if (opts.dryRun === true) return { removed: true };
|
|
764
|
+
const recheck = await readMarkdownFile(target);
|
|
765
|
+
if (recheck !== existing) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
"The target changed during unsync; aborting so a concurrent edit is not overwritten. Re-run the command."
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (opts.backup !== false) await backupOnce(target, existing);
|
|
771
|
+
await writeFileDurable(target, newBody);
|
|
772
|
+
return { removed: true };
|
|
773
|
+
}
|
|
774
|
+
async function clearOrientationChannel(opts) {
|
|
775
|
+
return removeMarkerBlock({
|
|
776
|
+
target: opts.target ?? CODEX_TARGET_PATH,
|
|
777
|
+
markers: ORIENTATION_MARKERS,
|
|
778
|
+
// Name the file actually acted on, so an error under the test seam does not
|
|
779
|
+
// point at the locked path.
|
|
780
|
+
fileLabel: opts.target ?? "~/.codex/AGENTS.md",
|
|
781
|
+
// The block is being removed because it should not be on this machine;
|
|
782
|
+
// preserving it in `.basou-bak` would defeat the command.
|
|
783
|
+
backup: false,
|
|
784
|
+
...opts.dryRun === true ? { dryRun: true } : {}
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/commands/channel.ts
|
|
789
|
+
function registerChannelCommand(program2) {
|
|
790
|
+
const channel = program2.command("channel").description(
|
|
791
|
+
"Manage the user-global context faces an AI tool auto-loads for every project (~/.codex/AGENTS.md): remove what an earlier basou rendered there"
|
|
792
|
+
);
|
|
793
|
+
channel.command("clear").argument(
|
|
794
|
+
"<face>",
|
|
795
|
+
"the face to clear: `codex` (the basou:orientation block in ~/.codex/AGENTS.md)"
|
|
796
|
+
).description(
|
|
797
|
+
"Remove the orientation block an older basou rendered into a user-global context face, so no workspace's position is left in a file that another project's tool reads"
|
|
798
|
+
).option("--dry-run", "Report whether a block would be removed without writing").option("--json", "Output the result as JSON").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (face, opts) => {
|
|
799
|
+
await runChannelClear(face, opts);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
async function runChannelClear(face, options) {
|
|
803
|
+
if (face !== "codex") {
|
|
804
|
+
console.error(
|
|
805
|
+
`Unknown face '${face}'. Faces: codex (~/.codex/AGENTS.md). The basou:protocols block in ~/.claude/CLAUDE.md is removed with \`basou protocol unsync\`.`
|
|
806
|
+
);
|
|
807
|
+
process.exitCode = 1;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
await doRunChannelClear(face, options);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
814
|
+
process.exitCode = 1;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async function doRunChannelClear(face, options) {
|
|
818
|
+
const isDry = options.dryRun === true;
|
|
819
|
+
const { removed } = await clearOrientationChannel({
|
|
820
|
+
...options.target !== void 0 ? { target: options.target } : {},
|
|
821
|
+
...isDry ? { dryRun: true } : {}
|
|
822
|
+
});
|
|
823
|
+
const target = options.target ?? CODEX_TARGET_PATH;
|
|
824
|
+
const label = options.target ?? "~/.codex/AGENTS.md";
|
|
825
|
+
const result = { face, target, removed, dry_run: isDry };
|
|
826
|
+
if (options.json === true) {
|
|
827
|
+
console.log(JSON.stringify(result));
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
if (!removed) {
|
|
831
|
+
console.log(`Nothing to clear: ${label} carries no basou:orientation block.`);
|
|
832
|
+
} else if (isDry) {
|
|
833
|
+
console.log(`[dry-run] Would remove the basou:orientation block from ${label}.`);
|
|
834
|
+
} else {
|
|
835
|
+
console.log(
|
|
836
|
+
`Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file, and nothing renders it again.`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
635
842
|
// src/commands/decision.ts
|
|
636
843
|
import { readFile } from "fs/promises";
|
|
637
|
-
import { homedir as
|
|
638
|
-
import { join as
|
|
844
|
+
import { homedir as homedir3 } from "os";
|
|
845
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
639
846
|
import {
|
|
640
847
|
AGENT_INFRA_DIRS,
|
|
641
848
|
acquireLock as acquireLock2,
|
|
@@ -657,18 +864,18 @@ import {
|
|
|
657
864
|
import { InvalidArgumentError } from "commander";
|
|
658
865
|
|
|
659
866
|
// src/lib/repo-root.ts
|
|
660
|
-
import { realpath, stat } from "fs/promises";
|
|
661
|
-
import { basename, resolve as resolve2 } from "path";
|
|
867
|
+
import { realpath, stat as stat2 } from "fs/promises";
|
|
868
|
+
import { basename as basename2, resolve as resolve2 } from "path";
|
|
662
869
|
import { basouPaths as basouPaths2, readManifest, resolveBasouRepositoryRoot } from "@basou/core";
|
|
663
870
|
|
|
664
871
|
// src/lib/portfolio-config.ts
|
|
665
|
-
import { homedir } from "os";
|
|
666
|
-
import { isAbsolute, join as
|
|
872
|
+
import { homedir as homedir2 } from "os";
|
|
873
|
+
import { isAbsolute, join as join4, resolve } from "path";
|
|
667
874
|
import { readYamlFile as readYamlFile2 } from "@basou/core";
|
|
668
|
-
var DEFAULT_PORTFOLIO_CONFIG_PATH =
|
|
875
|
+
var DEFAULT_PORTFOLIO_CONFIG_PATH = join4(homedir2(), ".basou", "portfolio.yaml");
|
|
669
876
|
function expandTilde(p) {
|
|
670
|
-
if (p === "~") return
|
|
671
|
-
if (p.startsWith("~/")) return
|
|
877
|
+
if (p === "~") return homedir2();
|
|
878
|
+
if (p.startsWith("~/")) return join4(homedir2(), p.slice(2));
|
|
672
879
|
return p;
|
|
673
880
|
}
|
|
674
881
|
function isRecord(value) {
|
|
@@ -750,7 +957,7 @@ async function resolveBasouRootForCommand(cwd, commandName, opts = {}) {
|
|
|
750
957
|
}
|
|
751
958
|
async function hasBasouStore(root) {
|
|
752
959
|
try {
|
|
753
|
-
return (await
|
|
960
|
+
return (await stat2(basouPaths2(root).root)).isDirectory();
|
|
754
961
|
} catch {
|
|
755
962
|
return false;
|
|
756
963
|
}
|
|
@@ -782,7 +989,7 @@ async function resolveMemberToMaster(repoRoot, configPath) {
|
|
|
782
989
|
} catch (error) {
|
|
783
990
|
if (error instanceof Error && error.message !== "YAML file not found") {
|
|
784
991
|
console.error(
|
|
785
|
-
`Skipping portfolio workspace '${ws.label ??
|
|
992
|
+
`Skipping portfolio workspace '${ws.label ?? basename2(masterReal)}': could not read its manifest (${error.message}).`
|
|
786
993
|
);
|
|
787
994
|
}
|
|
788
995
|
continue;
|
|
@@ -791,7 +998,7 @@ async function resolveMemberToMaster(repoRoot, configPath) {
|
|
|
791
998
|
for (const sr of sourceRoots) {
|
|
792
999
|
const real = await realpathOrNull(resolve2(masterReal, sr));
|
|
793
1000
|
if (real !== null && real === memberReal) {
|
|
794
|
-
claimants.set(masterReal, { root: masterReal, label: ws.label ??
|
|
1001
|
+
claimants.set(masterReal, { root: masterReal, label: ws.label ?? basename2(masterReal) });
|
|
795
1002
|
break;
|
|
796
1003
|
}
|
|
797
1004
|
}
|
|
@@ -1044,7 +1251,7 @@ async function doRunDecisionCapture(options, ctx) {
|
|
|
1044
1251
|
"--file",
|
|
1045
1252
|
sanitizePath(resolve3(cwd, options.file), {
|
|
1046
1253
|
workingDirectory: repositoryRoot,
|
|
1047
|
-
homedir:
|
|
1254
|
+
homedir: homedir3()
|
|
1048
1255
|
})
|
|
1049
1256
|
] : [];
|
|
1050
1257
|
const adHoc = await createAdHocSessionWithEvent({
|
|
@@ -1181,7 +1388,7 @@ function isDecisionId(value) {
|
|
|
1181
1388
|
async function decisionExists(paths, decisionId) {
|
|
1182
1389
|
const entries = await loadSessionEntries(paths, { now: /* @__PURE__ */ new Date() });
|
|
1183
1390
|
for (const entry of entries) {
|
|
1184
|
-
const sessionDir =
|
|
1391
|
+
const sessionDir = join5(paths.sessions, entry.sessionId);
|
|
1185
1392
|
try {
|
|
1186
1393
|
for await (const ev of replayEvents2(sessionDir, {})) {
|
|
1187
1394
|
if (ev.type === "decision_recorded" && ev.decision_id === decisionId) return true;
|
|
@@ -1576,7 +1783,7 @@ import {
|
|
|
1576
1783
|
assertBasouRootSafe as assertBasouRootSafe3,
|
|
1577
1784
|
basouPaths as basouPaths4,
|
|
1578
1785
|
findErrorCode as findErrorCode3,
|
|
1579
|
-
readMarkdownFile,
|
|
1786
|
+
readMarkdownFile as readMarkdownFile2,
|
|
1580
1787
|
renderDecisions,
|
|
1581
1788
|
renderWithMarkers,
|
|
1582
1789
|
resolveRepositoryRoot as resolveRepositoryRoot3,
|
|
@@ -1609,7 +1816,7 @@ async function doRunDecisionsGenerate(options, ctx) {
|
|
|
1609
1816
|
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
1610
1817
|
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason)
|
|
1611
1818
|
});
|
|
1612
|
-
const existing = await
|
|
1819
|
+
const existing = await readMarkdownFile2(paths.files.decisions);
|
|
1613
1820
|
const finalBody = renderWithMarkers(existing, result.body, "decisions.md");
|
|
1614
1821
|
await writeMarkdownFile(paths.files.decisions, finalBody);
|
|
1615
1822
|
console.log(`Generated .basou/decisions.md (decisions: ${result.decisionCount})`);
|
|
@@ -1640,8 +1847,8 @@ async function assertWorkspaceInitialized3(basouRoot) {
|
|
|
1640
1847
|
|
|
1641
1848
|
// src/commands/exec.ts
|
|
1642
1849
|
import { mkdir } from "fs/promises";
|
|
1643
|
-
import { homedir as
|
|
1644
|
-
import { join as
|
|
1850
|
+
import { homedir as homedir4 } from "os";
|
|
1851
|
+
import { join as join6 } from "path";
|
|
1645
1852
|
import {
|
|
1646
1853
|
acquireLock as acquireLock3,
|
|
1647
1854
|
assertBasouRootSafe as assertBasouRootSafe4,
|
|
@@ -1681,13 +1888,13 @@ async function runExec(command, args, options, ctx = {}) {
|
|
|
1681
1888
|
await assertBasouRootSafe4(paths.root);
|
|
1682
1889
|
const manifest = await readManifest3(paths);
|
|
1683
1890
|
const sessionId = prefixedUlid3("ses");
|
|
1684
|
-
const sessionDir =
|
|
1891
|
+
const sessionDir = join6(paths.sessions, sessionId);
|
|
1685
1892
|
await mkdir(sessionDir, { recursive: true });
|
|
1686
1893
|
const appendEvent = ctx.appendEvent ?? (async (_sessionDir, event) => {
|
|
1687
1894
|
await coreAppendChainedEvent(paths, sessionId, event);
|
|
1688
1895
|
});
|
|
1689
1896
|
const startedAt = now().toISOString();
|
|
1690
|
-
const sessionYamlPath =
|
|
1897
|
+
const sessionYamlPath = join6(sessionDir, "session.yaml");
|
|
1691
1898
|
const session = buildInitialSession({
|
|
1692
1899
|
id: sessionId,
|
|
1693
1900
|
command,
|
|
@@ -1892,7 +2099,7 @@ function buildInitialSession(input) {
|
|
|
1892
2099
|
source: { kind: "terminal", version: "0.1.0" },
|
|
1893
2100
|
started_at: input.startedAt,
|
|
1894
2101
|
status: "initialized",
|
|
1895
|
-
working_directory: sanitizeWorkingDirectory(input.cwd, { homedir:
|
|
2102
|
+
working_directory: sanitizeWorkingDirectory(input.cwd, { homedir: homedir4() }),
|
|
1896
2103
|
invocation: {
|
|
1897
2104
|
command: input.command,
|
|
1898
2105
|
args: [...input.args],
|
|
@@ -1968,7 +2175,7 @@ import {
|
|
|
1968
2175
|
assertBasouRootSafe as assertBasouRootSafe5,
|
|
1969
2176
|
basouPaths as basouPaths6,
|
|
1970
2177
|
findErrorCode as findErrorCode4,
|
|
1971
|
-
readMarkdownFile as
|
|
2178
|
+
readMarkdownFile as readMarkdownFile3,
|
|
1972
2179
|
renderHandoff,
|
|
1973
2180
|
renderWithMarkers as renderWithMarkers2,
|
|
1974
2181
|
resolveRepositoryRoot as resolveRepositoryRoot5,
|
|
@@ -2002,7 +2209,7 @@ async function doRunHandoffGenerate(options, ctx) {
|
|
|
2002
2209
|
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
|
|
2003
2210
|
onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason)
|
|
2004
2211
|
});
|
|
2005
|
-
const existing = await
|
|
2212
|
+
const existing = await readMarkdownFile3(paths.files.handoff);
|
|
2006
2213
|
const finalBody = renderWithMarkers2(existing, result.body, "handoff.md");
|
|
2007
2214
|
await writeMarkdownFile2(paths.files.handoff, finalBody);
|
|
2008
2215
|
console.log(
|
|
@@ -2034,395 +2241,226 @@ async function assertWorkspaceInitialized4(basouRoot) {
|
|
|
2034
2241
|
}
|
|
2035
2242
|
|
|
2036
2243
|
// src/commands/hook.ts
|
|
2037
|
-
import { open as open2, readFile as
|
|
2038
|
-
import { homedir as
|
|
2039
|
-
import { join as
|
|
2244
|
+
import { open as open2, readFile as readFile3, realpath as realpath2, stat as stat4 } from "fs/promises";
|
|
2245
|
+
import { homedir as homedir7 } from "os";
|
|
2246
|
+
import { join as join9 } from "path";
|
|
2040
2247
|
import { fileURLToPath } from "url";
|
|
2041
2248
|
import {
|
|
2249
|
+
buildSessionStartHookCommand,
|
|
2042
2250
|
buildStopHookCommand,
|
|
2043
2251
|
DEFAULT_STOP_HOOK_MIN_EDITS,
|
|
2044
2252
|
evaluateStopHook,
|
|
2253
|
+
findBasouSessionStartHook,
|
|
2045
2254
|
findBasouStopHookCommand,
|
|
2255
|
+
ORIENTATION_END as ORIENTATION_END2,
|
|
2256
|
+
ORIENTATION_START as ORIENTATION_START2,
|
|
2257
|
+
parseMarkers as parseMarkers2,
|
|
2258
|
+
readMarkdownFile as readMarkdownFile5,
|
|
2259
|
+
removeSessionStartHook,
|
|
2046
2260
|
removeStopHook,
|
|
2261
|
+
upsertSessionStartHook,
|
|
2047
2262
|
upsertStopHook
|
|
2048
2263
|
} from "@basou/core";
|
|
2049
2264
|
|
|
2050
|
-
// src/lib/
|
|
2051
|
-
import {
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2265
|
+
// src/lib/codex-hook-trust.ts
|
|
2266
|
+
import { createHash } from "crypto";
|
|
2267
|
+
var CODEX_DEFAULT_CONTEXT_LIMIT = 2500;
|
|
2268
|
+
var CODEX_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
2269
|
+
function commandHandlerFields(handler) {
|
|
2270
|
+
if (handler.type !== "command" || typeof handler.command !== "string") return null;
|
|
2271
|
+
const out = { command: handler.command };
|
|
2272
|
+
if (typeof handler.timeout === "number") out.timeout = handler.timeout;
|
|
2273
|
+
if (typeof handler.async === "boolean") out.async = handler.async;
|
|
2274
|
+
if (typeof handler.statusMessage === "string") out.statusMessage = handler.statusMessage;
|
|
2275
|
+
if (typeof handler.additionalContextLimit === "number")
|
|
2276
|
+
out.additionalContextLimit = handler.additionalContextLimit;
|
|
2277
|
+
return out;
|
|
2278
|
+
}
|
|
2279
|
+
function canonicalize(value) {
|
|
2280
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
2281
|
+
if (typeof value === "object" && value !== null) {
|
|
2282
|
+
const src = value;
|
|
2283
|
+
const out = {};
|
|
2284
|
+
for (const key of Object.keys(src).sort()) out[key] = canonicalize(src[key]);
|
|
2285
|
+
return out;
|
|
2065
2286
|
}
|
|
2287
|
+
return value;
|
|
2066
2288
|
}
|
|
2067
|
-
|
|
2068
|
-
const
|
|
2069
|
-
const
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2289
|
+
function computeCodexHookIdentityHash(input) {
|
|
2290
|
+
const h = input.handler;
|
|
2291
|
+
const normalized = {
|
|
2292
|
+
type: "command",
|
|
2293
|
+
command: h.command,
|
|
2294
|
+
timeout: Math.max(1, h.timeout ?? CODEX_DEFAULT_TIMEOUT_SECONDS),
|
|
2295
|
+
async: h.async ?? false
|
|
2296
|
+
};
|
|
2297
|
+
if (h.statusMessage !== void 0) normalized.statusMessage = h.statusMessage;
|
|
2298
|
+
if (h.additionalContextLimit !== void 0 && h.additionalContextLimit !== CODEX_DEFAULT_CONTEXT_LIMIT) {
|
|
2299
|
+
normalized.additionalContextLimit = h.additionalContextLimit;
|
|
2300
|
+
}
|
|
2301
|
+
const identity = { event_name: input.eventKey, hooks: [normalized] };
|
|
2302
|
+
if (input.matcher !== void 0) identity.matcher = input.matcher;
|
|
2303
|
+
const blob = JSON.stringify(canonicalize(identity));
|
|
2304
|
+
return `sha256:${createHash("sha256").update(blob, "utf8").digest("hex")}`;
|
|
2305
|
+
}
|
|
2306
|
+
function codexHookStateKey(hooksPath, eventKey, groupIndex, handlerIndex) {
|
|
2307
|
+
return `${hooksPath}:${eventKey}:${groupIndex}:${handlerIndex}`;
|
|
2308
|
+
}
|
|
2309
|
+
function tomlBasicString(value) {
|
|
2310
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
2311
|
+
}
|
|
2312
|
+
function stripTomlComment(line) {
|
|
2313
|
+
let quote = null;
|
|
2314
|
+
for (let i = 0; i < line.length; i++) {
|
|
2315
|
+
const ch = line[i];
|
|
2316
|
+
if (quote !== null) {
|
|
2317
|
+
if (ch === "\\" && quote === '"') i++;
|
|
2318
|
+
else if (ch === quote) quote = null;
|
|
2319
|
+
} else if (ch === '"' || ch === "'") {
|
|
2320
|
+
quote = ch;
|
|
2321
|
+
} else if (ch === "#") {
|
|
2322
|
+
return line.slice(0, i);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
return line;
|
|
2326
|
+
}
|
|
2327
|
+
function readCodexHookState(configToml, key) {
|
|
2328
|
+
const basicHeader = `[hooks.state.${tomlBasicString(key)}]`;
|
|
2329
|
+
const literalHeader = key.includes("'") ? null : `[hooks.state.'${key}']`;
|
|
2330
|
+
const lines = configToml.split(/\r?\n/);
|
|
2331
|
+
const start = lines.findIndex((raw) => {
|
|
2332
|
+
const l = stripTomlComment(raw).trim();
|
|
2333
|
+
return l === basicHeader || literalHeader !== null && l === literalHeader;
|
|
2334
|
+
});
|
|
2335
|
+
if (start < 0) {
|
|
2336
|
+
const mentioned = lines.some((raw) => raw.includes("hooks.state") && raw.includes(key));
|
|
2337
|
+
return mentioned ? { kind: "unreadable", detail: "config.toml names this hook in a shape basou cannot read" } : { kind: "absent" };
|
|
2338
|
+
}
|
|
2339
|
+
const state = {};
|
|
2340
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
2341
|
+
const line = stripTomlComment(lines[i] ?? "").trim();
|
|
2342
|
+
if (line.startsWith("[")) break;
|
|
2343
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/.exec(line);
|
|
2344
|
+
if (m === null) continue;
|
|
2345
|
+
const [, k, rawValue] = m;
|
|
2346
|
+
const v = (rawValue ?? "").trim();
|
|
2347
|
+
if (k === "trusted_hash") {
|
|
2348
|
+
const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(v);
|
|
2349
|
+
const literal = /^'([^']*)'$/.exec(v);
|
|
2350
|
+
if (basic?.[1] !== void 0) state.trustedHash = basic[1].replace(/\\(.)/g, "$1");
|
|
2351
|
+
else if (literal?.[1] !== void 0) state.trustedHash = literal[1];
|
|
2352
|
+
else return { kind: "unreadable", detail: "trusted_hash is not a quoted string" };
|
|
2353
|
+
} else if (k === "enabled") {
|
|
2354
|
+
if (v === "true") state.enabled = true;
|
|
2355
|
+
else if (v === "false") state.enabled = false;
|
|
2356
|
+
else return { kind: "unreadable", detail: "enabled is not a boolean" };
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
return { kind: "found", state };
|
|
2360
|
+
}
|
|
2361
|
+
function judgeCodexHookTrust(lookup, currentHash) {
|
|
2362
|
+
if (lookup.kind === "unreadable") return { status: "unknown", detail: lookup.detail };
|
|
2363
|
+
if (lookup.kind === "absent") return { status: "untrusted" };
|
|
2364
|
+
const { state } = lookup;
|
|
2365
|
+
if (state.enabled === false) return { status: "disabled" };
|
|
2366
|
+
if (state.trustedHash === void 0) return { status: "untrusted" };
|
|
2367
|
+
return state.trustedHash === currentHash ? { status: "trusted" } : { status: "modified" };
|
|
2368
|
+
}
|
|
2369
|
+
function describeCodexHookTrust(trust) {
|
|
2370
|
+
switch (trust.status) {
|
|
2371
|
+
case "trusted":
|
|
2372
|
+
return "trusted by Codex";
|
|
2373
|
+
case "untrusted":
|
|
2374
|
+
return "not yet trusted by Codex (review pending \u2014 it is skipped until you trust it)";
|
|
2375
|
+
case "modified":
|
|
2376
|
+
return "Codex's trust record does not match what basou computes for the installed hook \u2014 the hook changed since it was trusted, or Codex changed its hashing (review it again in Codex; it is skipped until re-trusted)";
|
|
2377
|
+
case "disabled":
|
|
2378
|
+
return "disabled in the Codex config (enabled = false)";
|
|
2379
|
+
case "unknown":
|
|
2380
|
+
return `trust state unknown (${trust.detail})`;
|
|
2077
2381
|
}
|
|
2078
|
-
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// src/commands/orient.ts
|
|
2385
|
+
import {
|
|
2386
|
+
assertBasouRootSafe as assertBasouRootSafe7,
|
|
2387
|
+
basouPaths as basouPaths8,
|
|
2388
|
+
findErrorCode as findErrorCode6,
|
|
2389
|
+
renderOrientation as renderOrientation2,
|
|
2390
|
+
writeMarkdownFile as writeMarkdownFile4
|
|
2391
|
+
} from "@basou/core";
|
|
2392
|
+
|
|
2393
|
+
// src/lib/hosts-config.ts
|
|
2394
|
+
import { homedir as homedir5 } from "os";
|
|
2395
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
|
|
2396
|
+
import { readYamlFile as readYamlFile4 } from "@basou/core";
|
|
2397
|
+
var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
|
|
2398
|
+
function expandTilde2(p) {
|
|
2399
|
+
if (p === "~") return homedir5();
|
|
2400
|
+
if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
|
|
2401
|
+
return p;
|
|
2402
|
+
}
|
|
2403
|
+
function isRecord2(value) {
|
|
2404
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2405
|
+
}
|
|
2406
|
+
async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
|
|
2407
|
+
let raw;
|
|
2079
2408
|
try {
|
|
2080
|
-
|
|
2081
|
-
await handle.writeFile(content, "utf8");
|
|
2082
|
-
await handle.chmod(mode);
|
|
2083
|
-
await handle.sync();
|
|
2084
|
-
await handle.close();
|
|
2085
|
-
handle = void 0;
|
|
2086
|
-
await rename(tmpPath, targetPath);
|
|
2409
|
+
raw = await readYamlFile4(configPath);
|
|
2087
2410
|
} catch (error) {
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2411
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
2412
|
+
return null;
|
|
2413
|
+
}
|
|
2414
|
+
if (error instanceof Error && error.message === "Failed to parse YAML content") {
|
|
2415
|
+
throw new Error("~/.basou/hosts.yaml is not valid YAML.");
|
|
2416
|
+
}
|
|
2090
2417
|
throw error;
|
|
2091
2418
|
}
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
try {
|
|
2095
|
-
await dirHandle.sync();
|
|
2096
|
-
} finally {
|
|
2097
|
-
await dirHandle.close();
|
|
2098
|
-
}
|
|
2099
|
-
} catch {
|
|
2419
|
+
if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
|
|
2420
|
+
throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
|
|
2100
2421
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
"Claude Code hook handlers (read a hook payload on stdin, emit hook JSON on stdout)"
|
|
2108
|
-
);
|
|
2109
|
-
hook.command("stop").description(
|
|
2110
|
-
"Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
|
|
2111
|
-
).option(
|
|
2112
|
-
"--min-edits <n>",
|
|
2113
|
-
`Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
|
|
2114
|
-
).option(
|
|
2115
|
-
"--block",
|
|
2116
|
-
"Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
|
|
2117
|
-
).option(
|
|
2118
|
-
"--require-review",
|
|
2119
|
-
"Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
|
|
2120
|
-
).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
|
|
2121
|
-
const minEdits = parseMinEdits(options.minEdits);
|
|
2122
|
-
await runHookStop({
|
|
2123
|
-
...minEdits !== void 0 ? { minEdits } : {},
|
|
2124
|
-
...options.block === true ? { block: true } : {},
|
|
2125
|
-
...options.requireReview === true ? { requireReview: true } : {}
|
|
2126
|
-
});
|
|
2127
|
-
});
|
|
2128
|
-
hook.command("install").description(
|
|
2129
|
-
"Register the Stop hook in ~/.claude/settings.json (reproducible, idempotent). Default is advisory capture-only; --block opts into in-turn enforcement, --require-review opts into the review gate."
|
|
2130
|
-
).option("--block", "Register the blocking (opt-in enforcement) form instead of advisory").option("--require-review", "Register with the opt-in review gate enabled").option("--min-edits <n>", "Pass a custom file-edit threshold to the registered hook").option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2131
|
-
await runHookInstall(opts);
|
|
2132
|
-
});
|
|
2133
|
-
hook.command("uninstall").description(
|
|
2134
|
-
"Remove the basou Stop hook from ~/.claude/settings.json (leaves other hooks intact)"
|
|
2135
|
-
).option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2136
|
-
await runHookUninstall(opts);
|
|
2137
|
-
});
|
|
2138
|
-
hook.command("status").description("Report whether the basou Stop hook is registered, and in which mode").option("--settings <path>", "Override the settings.json path (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2139
|
-
await runHookStatus(opts);
|
|
2140
|
-
});
|
|
2141
|
-
}
|
|
2142
|
-
var HOOK_STOP_HELP = `
|
|
2143
|
-
Register this Stop hook reproducibly with 'basou hook install' (it writes the
|
|
2144
|
-
correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
|
|
2145
|
-
removes it; 'basou hook status' reports whether it is registered.
|
|
2146
|
-
|
|
2147
|
-
On every turn end basou inspects the session transcript. If the session did
|
|
2148
|
-
content-substantive work but ran no capture verb ('basou decision capture' /
|
|
2149
|
-
'decision record' / 'note'), it reminds the agent to record the why / next step.
|
|
2150
|
-
Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
|
|
2151
|
-
answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
|
|
2152
|
-
git status) does NOT count.
|
|
2153
|
-
|
|
2154
|
-
With --require-review (opt-in, 'basou hook install --require-review') it also
|
|
2155
|
-
reminds when the session SHIPPED substantive code (git push / git merge /
|
|
2156
|
-
gh pr create|merge) without recording a review ('basou review record'). This
|
|
2157
|
-
gate is off by default; when on, its reminder is composed into the same
|
|
2158
|
-
envelope as the capture reminder.
|
|
2159
|
-
|
|
2160
|
-
By default the reminder is non-blocking: Claude sees it and may act on it or
|
|
2161
|
-
stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
|
|
2162
|
-
returns decision:block, holding the agent in-turn to act on the reminder; the
|
|
2163
|
-
'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
|
|
2164
|
-
single turn. Either way the hook fails open: a bad payload or unreadable
|
|
2165
|
-
transcript exits cleanly with no output.
|
|
2166
|
-
`;
|
|
2167
|
-
async function runHookStop(options, ctx = {}) {
|
|
2168
|
-
try {
|
|
2169
|
-
await doRunHookStop(options, ctx);
|
|
2170
|
-
} catch {
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
async function doRunHookStop(options, ctx) {
|
|
2174
|
-
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
2175
|
-
const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
|
|
2176
|
-
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
2177
|
-
const raw = await readStdin();
|
|
2178
|
-
if (raw.trim().length === 0) return;
|
|
2179
|
-
let payload;
|
|
2180
|
-
try {
|
|
2181
|
-
payload = JSON.parse(raw);
|
|
2182
|
-
} catch {
|
|
2183
|
-
return;
|
|
2184
|
-
}
|
|
2185
|
-
if (typeof payload !== "object" || payload === null) return;
|
|
2186
|
-
const fields = payload;
|
|
2187
|
-
if (fields.stop_hook_active === true) return;
|
|
2188
|
-
const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
|
|
2189
|
-
if (transcriptPath.length === 0) return;
|
|
2190
|
-
let transcript;
|
|
2191
|
-
try {
|
|
2192
|
-
transcript = await readTranscript(transcriptPath);
|
|
2193
|
-
} catch {
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
const records = parseTranscript(transcript);
|
|
2197
|
-
const evaluation = evaluateStopHook({
|
|
2198
|
-
records,
|
|
2199
|
-
// stop_hook_active was already handled by the early return above.
|
|
2200
|
-
stopHookActive: false,
|
|
2201
|
-
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
2202
|
-
});
|
|
2203
|
-
const parts = [];
|
|
2204
|
-
if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
|
|
2205
|
-
if (options.requireReview === true && evaluation.review.fires) {
|
|
2206
|
-
parts.push(evaluation.review.additionalContext);
|
|
2207
|
-
}
|
|
2208
|
-
if (parts.length === 0) return;
|
|
2209
|
-
const reason = parts.join("\n\n");
|
|
2210
|
-
const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
|
|
2211
|
-
hookSpecificOutput: {
|
|
2212
|
-
hookEventName: "Stop",
|
|
2213
|
-
additionalContext: reason
|
|
2422
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
2423
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
2424
|
+
const result = [];
|
|
2425
|
+
for (const entry of raw.hosts) {
|
|
2426
|
+
if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
|
|
2427
|
+
throw new Error("Each host needs a non-empty string 'label'.");
|
|
2214
2428
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
}
|
|
2219
|
-
function parseTranscript(transcript) {
|
|
2220
|
-
const records = [];
|
|
2221
|
-
for (const line of transcript.split(/\r?\n/)) {
|
|
2222
|
-
if (line.trim().length === 0) continue;
|
|
2223
|
-
try {
|
|
2224
|
-
const parsed = JSON.parse(line);
|
|
2225
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
2226
|
-
records.push(parsed);
|
|
2227
|
-
}
|
|
2228
|
-
} catch {
|
|
2429
|
+
const label = entry.label.trim();
|
|
2430
|
+
if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
|
|
2431
|
+
throw new Error("Each host needs a non-empty string 'path'.");
|
|
2229
2432
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
async function defaultReadStdin() {
|
|
2234
|
-
if (process.stdin.isTTY === true) return "";
|
|
2235
|
-
const chunks = [];
|
|
2236
|
-
for await (const chunk of process.stdin) {
|
|
2237
|
-
chunks.push(chunk);
|
|
2238
|
-
}
|
|
2239
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
2240
|
-
}
|
|
2241
|
-
async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
|
|
2242
|
-
const { size } = await stat3(path);
|
|
2243
|
-
if (size <= maxBytes) return readFile2(path, "utf8");
|
|
2244
|
-
const handle = await open2(path, "r");
|
|
2245
|
-
try {
|
|
2246
|
-
const buffer = Buffer.alloc(maxBytes);
|
|
2247
|
-
const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
|
|
2248
|
-
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
2249
|
-
const firstNewline = text.indexOf("\n");
|
|
2250
|
-
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
2251
|
-
} finally {
|
|
2252
|
-
await handle.close();
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
function parseMinEdits(raw) {
|
|
2256
|
-
if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
|
|
2257
|
-
return Number(raw);
|
|
2258
|
-
}
|
|
2259
|
-
var DEFAULT_CLAUDE_SETTINGS_PATH = join6(homedir4(), ".claude", "settings.json");
|
|
2260
|
-
function resolveCliEntry() {
|
|
2261
|
-
return fileURLToPath(import.meta.url);
|
|
2262
|
-
}
|
|
2263
|
-
function normalizeInstallOptions(raw) {
|
|
2264
|
-
const out = {};
|
|
2265
|
-
if (raw.block === true) out.block = true;
|
|
2266
|
-
if (raw.requireReview === true) out.requireReview = true;
|
|
2267
|
-
if (raw.settings !== void 0) out.settings = raw.settings;
|
|
2268
|
-
if (raw.dryRun === true) out.dryRun = true;
|
|
2269
|
-
if (raw.verbose === true) out.verbose = true;
|
|
2270
|
-
if (raw.minEdits !== void 0) {
|
|
2271
|
-
const parsed = parseMinEdits(raw.minEdits);
|
|
2272
|
-
if (parsed === void 0) {
|
|
2273
|
-
throw new Error("--min-edits must be a non-negative integer.");
|
|
2433
|
+
const expanded = expandTilde2(entry.path.trim());
|
|
2434
|
+
if (!isAbsolute2(expanded)) {
|
|
2435
|
+
throw new Error("Host paths must be absolute (or start with '~').");
|
|
2274
2436
|
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
}
|
|
2279
|
-
async function readSettings(path) {
|
|
2280
|
-
let raw;
|
|
2281
|
-
try {
|
|
2282
|
-
raw = await readFile2(path, "utf8");
|
|
2283
|
-
} catch (error) {
|
|
2284
|
-
if (error instanceof Error && error.code === "ENOENT") {
|
|
2285
|
-
return { raw: null, parsed: void 0 };
|
|
2437
|
+
const abs = resolve4(expanded);
|
|
2438
|
+
if (seenPaths.has(abs)) continue;
|
|
2439
|
+
if (seenLabels.has(label)) {
|
|
2440
|
+
throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
|
|
2286
2441
|
}
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
try {
|
|
2291
|
-
return { raw, parsed: JSON.parse(raw) };
|
|
2292
|
-
} catch (error) {
|
|
2293
|
-
throw new Error(
|
|
2294
|
-
"The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
|
|
2295
|
-
{
|
|
2296
|
-
cause: error
|
|
2297
|
-
}
|
|
2298
|
-
);
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
async function backupSettingsOnce(path, raw) {
|
|
2302
|
-
if (raw === null) return;
|
|
2303
|
-
const bak = `${path}.basou-bak`;
|
|
2304
|
-
try {
|
|
2305
|
-
await stat3(bak);
|
|
2306
|
-
return;
|
|
2307
|
-
} catch (error) {
|
|
2308
|
-
if (!(error instanceof Error && error.code === "ENOENT")) throw error;
|
|
2309
|
-
}
|
|
2310
|
-
await writeFileDurable(bak, raw);
|
|
2311
|
-
}
|
|
2312
|
-
async function runHookInstall(options, ctx = {}) {
|
|
2313
|
-
try {
|
|
2314
|
-
await doRunHookInstall(normalizeInstallOptions(options), ctx);
|
|
2315
|
-
} catch (error) {
|
|
2316
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2317
|
-
process.exitCode = 1;
|
|
2318
|
-
}
|
|
2319
|
-
}
|
|
2320
|
-
async function doRunHookInstall(options, ctx = {}) {
|
|
2321
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2322
|
-
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
2323
|
-
const command = buildStopHookCommand({
|
|
2324
|
-
cliEntry,
|
|
2325
|
-
...options.block === true ? { block: true } : {},
|
|
2326
|
-
...options.requireReview === true ? { requireReview: true } : {},
|
|
2327
|
-
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
2328
|
-
});
|
|
2329
|
-
const mode = describeHookMode({
|
|
2330
|
-
block: options.block === true,
|
|
2331
|
-
review: options.requireReview === true
|
|
2332
|
-
});
|
|
2333
|
-
await assertNotSymlink(settingsPath);
|
|
2334
|
-
const { raw, parsed } = await readSettings(settingsPath);
|
|
2335
|
-
const { settings, action } = upsertStopHook(parsed, command);
|
|
2336
|
-
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
2337
|
-
`;
|
|
2338
|
-
if (raw !== null && newBody === raw) {
|
|
2339
|
-
console.log(`The basou Stop hook is already registered (${mode}); no change.`);
|
|
2340
|
-
return;
|
|
2341
|
-
}
|
|
2342
|
-
if (options.dryRun === true) {
|
|
2343
|
-
console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
const recheck = await readSettings(settingsPath);
|
|
2347
|
-
if (recheck.raw !== raw) {
|
|
2348
|
-
throw new Error(
|
|
2349
|
-
"The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
|
|
2350
|
-
);
|
|
2351
|
-
}
|
|
2352
|
-
await backupSettingsOnce(settingsPath, raw);
|
|
2353
|
-
await writeFileDurable(settingsPath, newBody);
|
|
2354
|
-
console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
|
|
2355
|
-
}
|
|
2356
|
-
async function runHookUninstall(options) {
|
|
2357
|
-
try {
|
|
2358
|
-
await doRunHookUninstall(normalizeInstallOptions(options));
|
|
2359
|
-
} catch (error) {
|
|
2360
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2361
|
-
process.exitCode = 1;
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2364
|
-
async function doRunHookUninstall(options) {
|
|
2365
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2366
|
-
await assertNotSymlink(settingsPath);
|
|
2367
|
-
const { raw, parsed } = await readSettings(settingsPath);
|
|
2368
|
-
if (raw === null) {
|
|
2369
|
-
console.log("No settings.json; nothing to remove.");
|
|
2370
|
-
return;
|
|
2371
|
-
}
|
|
2372
|
-
const { settings, action } = removeStopHook(parsed);
|
|
2373
|
-
if (action === "absent") {
|
|
2374
|
-
console.log("No basou Stop hook found; nothing removed.");
|
|
2375
|
-
return;
|
|
2376
|
-
}
|
|
2377
|
-
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
2378
|
-
`;
|
|
2379
|
-
if (options.dryRun === true) {
|
|
2380
|
-
console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
|
|
2381
|
-
return;
|
|
2382
|
-
}
|
|
2383
|
-
const recheck = await readSettings(settingsPath);
|
|
2384
|
-
if (recheck.raw !== raw) {
|
|
2385
|
-
throw new Error(
|
|
2386
|
-
"The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
|
|
2387
|
-
);
|
|
2388
|
-
}
|
|
2389
|
-
await backupSettingsOnce(settingsPath, raw);
|
|
2390
|
-
await writeFileDurable(settingsPath, newBody);
|
|
2391
|
-
console.log("Removed the basou Stop hook from settings.json.");
|
|
2392
|
-
}
|
|
2393
|
-
async function runHookStatus(options) {
|
|
2394
|
-
try {
|
|
2395
|
-
await doRunHookStatus(normalizeInstallOptions(options));
|
|
2396
|
-
} catch (error) {
|
|
2397
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2398
|
-
process.exitCode = 1;
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
async function doRunHookStatus(options) {
|
|
2402
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2403
|
-
const { parsed } = await readSettings(settingsPath);
|
|
2404
|
-
const command = findBasouStopHookCommand(parsed);
|
|
2405
|
-
if (command === null) {
|
|
2406
|
-
console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
|
|
2407
|
-
return;
|
|
2442
|
+
seenPaths.add(abs);
|
|
2443
|
+
seenLabels.add(label);
|
|
2444
|
+
result.push({ label, path: abs });
|
|
2408
2445
|
}
|
|
2409
|
-
|
|
2410
|
-
block: / --block\b/.test(command),
|
|
2411
|
-
review: / --require-review\b/.test(command)
|
|
2412
|
-
});
|
|
2413
|
-
console.log(`basou Stop hook: registered, ${mode}.`);
|
|
2414
|
-
}
|
|
2415
|
-
function describeHookMode(tiers) {
|
|
2416
|
-
const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
|
|
2417
|
-
const gates = tiers.review ? "capture + review" : "capture";
|
|
2418
|
-
return `${enforcement}, ${gates}`;
|
|
2446
|
+
return result;
|
|
2419
2447
|
}
|
|
2420
2448
|
|
|
2449
|
+
// src/lib/provenance-actions.ts
|
|
2450
|
+
import {
|
|
2451
|
+
readMarkdownFile as readMarkdownFile4,
|
|
2452
|
+
renderDecisions as renderDecisions2,
|
|
2453
|
+
renderHandoff as renderHandoff2,
|
|
2454
|
+
renderOrientation,
|
|
2455
|
+
renderWithMarkers as renderWithMarkers3,
|
|
2456
|
+
writeMarkdownFile as writeMarkdownFile3
|
|
2457
|
+
} from "@basou/core";
|
|
2458
|
+
|
|
2421
2459
|
// src/commands/import.ts
|
|
2422
2460
|
import { createReadStream } from "fs";
|
|
2423
|
-
import { readdir, readFile as
|
|
2424
|
-
import { homedir as
|
|
2425
|
-
import { basename as basename3, dirname as dirname2, join as
|
|
2461
|
+
import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
|
|
2462
|
+
import { homedir as homedir6 } from "os";
|
|
2463
|
+
import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve5 } from "path";
|
|
2426
2464
|
import { createInterface } from "readline";
|
|
2427
2465
|
import {
|
|
2428
2466
|
AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
|
|
@@ -2492,10 +2530,10 @@ function resolveSourceRoots(args) {
|
|
|
2492
2530
|
const { projectFlags, manifest, repoRoot, cwd } = args;
|
|
2493
2531
|
let resolved;
|
|
2494
2532
|
if (projectFlags.length > 0) {
|
|
2495
|
-
resolved = projectFlags.map((p) =>
|
|
2533
|
+
resolved = projectFlags.map((p) => resolve5(cwd, p));
|
|
2496
2534
|
} else {
|
|
2497
2535
|
const roots = manifest.import?.source_roots;
|
|
2498
|
-
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) =>
|
|
2536
|
+
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
|
|
2499
2537
|
}
|
|
2500
2538
|
return [...new Set(resolved)];
|
|
2501
2539
|
}
|
|
@@ -2508,7 +2546,7 @@ async function doRunImportClaudeCode(options, ctx) {
|
|
|
2508
2546
|
repoRoot: repositoryRoot,
|
|
2509
2547
|
cwd: ctx.cwd ?? process.cwd()
|
|
2510
2548
|
});
|
|
2511
|
-
const projectsRoot = ctx.claudeProjectsDir ??
|
|
2549
|
+
const projectsRoot = ctx.claudeProjectsDir ?? join8(homedir6(), ".claude", "projects");
|
|
2512
2550
|
const files = await selectTranscriptFiles(projectsRoot, projectPaths, options);
|
|
2513
2551
|
const projectSet = new Set(projectPaths);
|
|
2514
2552
|
const candidates = files.map((file) => {
|
|
@@ -2547,7 +2585,7 @@ async function doRunImportCodex(options, ctx) {
|
|
|
2547
2585
|
repoRoot: repositoryRoot,
|
|
2548
2586
|
cwd: ctx.cwd ?? process.cwd()
|
|
2549
2587
|
});
|
|
2550
|
-
const sessionsRoot = ctx.codexSessionsDir ??
|
|
2588
|
+
const sessionsRoot = ctx.codexSessionsDir ?? join8(homedir6(), ".codex", "sessions");
|
|
2551
2589
|
const rollouts = await discoverCodexRollouts(sessionsRoot, projectPaths, options);
|
|
2552
2590
|
const candidates = rollouts.map(({ file, externalId }) => ({
|
|
2553
2591
|
externalId,
|
|
@@ -2668,943 +2706,1503 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
|
|
|
2668
2706
|
await noteCrossProject(externalId, payload2);
|
|
2669
2707
|
continue;
|
|
2670
2708
|
}
|
|
2671
|
-
const payload = validate(await toPayload());
|
|
2672
|
-
if (payload === null) {
|
|
2673
|
-
counts.skippedNoAction++;
|
|
2674
|
-
continue;
|
|
2709
|
+
const payload = validate(await toPayload());
|
|
2710
|
+
if (payload === null) {
|
|
2711
|
+
counts.skippedNoAction++;
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
if (priors.length > 0 && options.force === true) {
|
|
2715
|
+
if (options.dryRun !== true) {
|
|
2716
|
+
for (const { sessionId } of priors) {
|
|
2717
|
+
await rm(join8(paths.sessions, sessionId), { recursive: true, force: true });
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
counts.replaced++;
|
|
2721
|
+
}
|
|
2722
|
+
const result = await importSessionFromJson(paths, manifest, payload, {
|
|
2723
|
+
dryRun: options.dryRun === true
|
|
2724
|
+
});
|
|
2725
|
+
results.push(result);
|
|
2726
|
+
seenThisRun.add(externalId);
|
|
2727
|
+
sanitizedPaths += result.pathSanitizeReport.relatedFiles + (result.pathSanitizeReport.workingDirectoryRewritten ? 1 : 0);
|
|
2728
|
+
await noteCrossProject(externalId, payload);
|
|
2729
|
+
}
|
|
2730
|
+
if (sanitizedPaths > 0) {
|
|
2731
|
+
console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`);
|
|
2732
|
+
}
|
|
2733
|
+
if (crossProject.length > 0) {
|
|
2734
|
+
const PATH_SAMPLE = 5;
|
|
2735
|
+
for (const { externalId, outOfRoot } of crossProject) {
|
|
2736
|
+
const sample = outOfRoot.slice(0, PATH_SAMPLE).join(", ");
|
|
2737
|
+
const more = outOfRoot.length > PATH_SAMPLE ? ` (... +${outOfRoot.length - PATH_SAMPLE} more)` : "";
|
|
2738
|
+
console.error(
|
|
2739
|
+
`basou: session ${externalId} edited ${outOfRoot.length} file(s) outside this project's source_roots: ${sample}${more} \u2014 they may belong to another project.`
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
printImportResult(options, results, counts);
|
|
2744
|
+
}
|
|
2745
|
+
async function classifyReimport(priors, sourcePath, externalId, counts) {
|
|
2746
|
+
if (priors.length > 1) {
|
|
2747
|
+
console.error(
|
|
2748
|
+
`Import: ${externalId} has ${priors.length} prior sessions; re-import skipped (use --force)`
|
|
2749
|
+
);
|
|
2750
|
+
counts.skippedDuplicate++;
|
|
2751
|
+
return null;
|
|
2752
|
+
}
|
|
2753
|
+
const prior = priors[0];
|
|
2754
|
+
if (prior === void 0) {
|
|
2755
|
+
counts.skippedExisting++;
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
const currentSize = await statSize(sourcePath);
|
|
2759
|
+
if (currentSize === void 0) {
|
|
2760
|
+
counts.skippedExisting++;
|
|
2761
|
+
return null;
|
|
2762
|
+
}
|
|
2763
|
+
if (prior.sourceSizeBytes === void 0) {
|
|
2764
|
+
counts.skippedLegacy++;
|
|
2765
|
+
return null;
|
|
2766
|
+
}
|
|
2767
|
+
if (currentSize === prior.sourceSizeBytes) {
|
|
2768
|
+
counts.skippedExisting++;
|
|
2769
|
+
return null;
|
|
2770
|
+
}
|
|
2771
|
+
if (currentSize < prior.sourceSizeBytes) {
|
|
2772
|
+
console.error(
|
|
2773
|
+
`Import: ${externalId} source shrank (${currentSize} < ${prior.sourceSizeBytes} bytes); re-import skipped (use --force to replace)`
|
|
2774
|
+
);
|
|
2775
|
+
counts.skippedDecreased++;
|
|
2776
|
+
return null;
|
|
2777
|
+
}
|
|
2778
|
+
return prior;
|
|
2779
|
+
}
|
|
2780
|
+
function encodeProjectDir(projectPath) {
|
|
2781
|
+
return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
2782
|
+
}
|
|
2783
|
+
function firstTranscriptCwd(records) {
|
|
2784
|
+
for (const record of records) {
|
|
2785
|
+
const cwd = record.cwd;
|
|
2786
|
+
if (typeof cwd === "string" && cwd.length > 0) return cwd;
|
|
2787
|
+
}
|
|
2788
|
+
return void 0;
|
|
2789
|
+
}
|
|
2790
|
+
async function loadExistingByExternalId(paths, sourceKind) {
|
|
2791
|
+
const byExternalId = /* @__PURE__ */ new Map();
|
|
2792
|
+
const add = (externalId, prior) => {
|
|
2793
|
+
const list = byExternalId.get(externalId);
|
|
2794
|
+
if (list === void 0) byExternalId.set(externalId, [prior]);
|
|
2795
|
+
else list.push(prior);
|
|
2796
|
+
};
|
|
2797
|
+
let sessionIds;
|
|
2798
|
+
try {
|
|
2799
|
+
sessionIds = await enumerateSessionDirs(paths);
|
|
2800
|
+
} catch {
|
|
2801
|
+
return byExternalId;
|
|
2802
|
+
}
|
|
2803
|
+
for (const sessionId of sessionIds) {
|
|
2804
|
+
let session;
|
|
2805
|
+
try {
|
|
2806
|
+
session = await readSessionYaml2(paths, sessionId);
|
|
2807
|
+
} catch {
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
if (session.session.source.kind !== sourceKind) continue;
|
|
2811
|
+
const sourceSizeBytes = session.session.source.source_size_bytes;
|
|
2812
|
+
const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
|
|
2813
|
+
const ext = session.session.source.external_id;
|
|
2814
|
+
if (typeof ext === "string" && ext.length > 0) {
|
|
2815
|
+
add(ext, prior);
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
const label = session.session.label;
|
|
2819
|
+
const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
|
|
2820
|
+
if (match?.[1] !== void 0) add(match[1], prior);
|
|
2821
|
+
}
|
|
2822
|
+
return byExternalId;
|
|
2823
|
+
}
|
|
2824
|
+
async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
|
|
2825
|
+
if (options.session !== void 0) {
|
|
2826
|
+
const matches = [];
|
|
2827
|
+
for (const projectPath of projectPaths) {
|
|
2828
|
+
const file = join8(projectsRoot, encodeProjectDir(projectPath), `${options.session}.jsonl`);
|
|
2829
|
+
if (await pathExists(file)) matches.push(file);
|
|
2675
2830
|
}
|
|
2676
|
-
if (
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2831
|
+
if (matches.length === 0) {
|
|
2832
|
+
throw new Error("Claude transcript not found for session id in project");
|
|
2833
|
+
}
|
|
2834
|
+
return [...new Set(matches)];
|
|
2835
|
+
}
|
|
2836
|
+
const files = [];
|
|
2837
|
+
let anyDirFound = false;
|
|
2838
|
+
for (const projectPath of projectPaths) {
|
|
2839
|
+
const transcriptDir = join8(projectsRoot, encodeProjectDir(projectPath));
|
|
2840
|
+
let entries;
|
|
2841
|
+
try {
|
|
2842
|
+
entries = await readdir(transcriptDir);
|
|
2843
|
+
} catch (error) {
|
|
2844
|
+
if (findErrorCode5(error, "ENOENT")) continue;
|
|
2845
|
+
throw new Error("Failed to read Claude transcript directory", { cause: error });
|
|
2846
|
+
}
|
|
2847
|
+
anyDirFound = true;
|
|
2848
|
+
for (const name of entries) {
|
|
2849
|
+
if (name.endsWith(".jsonl")) files.push(join8(transcriptDir, name));
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
if (!anyDirFound) {
|
|
2853
|
+
throw new Error("Claude transcript directory not found for project");
|
|
2854
|
+
}
|
|
2855
|
+
return [...new Set(files)].sort();
|
|
2856
|
+
}
|
|
2857
|
+
async function pathExists(file) {
|
|
2858
|
+
try {
|
|
2859
|
+
await stat3(file);
|
|
2860
|
+
return true;
|
|
2861
|
+
} catch (error) {
|
|
2862
|
+
if (findErrorCode5(error, "ENOENT")) return false;
|
|
2863
|
+
throw error;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
async function statSize(file) {
|
|
2867
|
+
try {
|
|
2868
|
+
return (await stat3(file)).size;
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
if (findErrorCode5(error, "ENOENT")) return void 0;
|
|
2871
|
+
throw error;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
async function discoverCodexRollouts(sessionsRoot, projectPaths, options) {
|
|
2875
|
+
const projectSet = new Set(projectPaths);
|
|
2876
|
+
const files = await findRolloutFiles(sessionsRoot);
|
|
2877
|
+
const matched = [];
|
|
2878
|
+
for (const file of files) {
|
|
2879
|
+
const meta = await readRolloutMeta(file);
|
|
2880
|
+
if (meta === void 0) continue;
|
|
2881
|
+
if (!projectSet.has(meta.cwd)) continue;
|
|
2882
|
+
if (options.session !== void 0 && meta.id !== options.session) continue;
|
|
2883
|
+
matched.push({ file, externalId: meta.id });
|
|
2884
|
+
}
|
|
2885
|
+
if (options.session !== void 0 && matched.length === 0) {
|
|
2886
|
+
throw new Error("Codex rollout not found for session id in project");
|
|
2887
|
+
}
|
|
2888
|
+
return matched;
|
|
2889
|
+
}
|
|
2890
|
+
async function findRolloutFiles(sessionsRoot) {
|
|
2891
|
+
const found = [];
|
|
2892
|
+
const walk = async (dir, isRoot) => {
|
|
2893
|
+
let entries;
|
|
2894
|
+
try {
|
|
2895
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
2898
|
+
if (isRoot) {
|
|
2899
|
+
throw new Error("Codex sessions directory not found", { cause: error });
|
|
2680
2900
|
}
|
|
2901
|
+
return;
|
|
2681
2902
|
}
|
|
2682
|
-
|
|
2903
|
+
throw new Error("Failed to read Codex sessions directory", { cause: error });
|
|
2683
2904
|
}
|
|
2684
|
-
const
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2905
|
+
for (const entry of entries) {
|
|
2906
|
+
const full = join8(dir, entry.name);
|
|
2907
|
+
if (entry.isDirectory()) {
|
|
2908
|
+
await walk(full, false);
|
|
2909
|
+
} else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
|
|
2910
|
+
found.push(full);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
await walk(sessionsRoot, true);
|
|
2915
|
+
return found.sort();
|
|
2916
|
+
}
|
|
2917
|
+
async function readRolloutMeta(file) {
|
|
2918
|
+
const firstLine = await readFirstLine(file);
|
|
2919
|
+
if (firstLine === void 0) return void 0;
|
|
2920
|
+
let parsed;
|
|
2921
|
+
try {
|
|
2922
|
+
parsed = JSON.parse(firstLine);
|
|
2923
|
+
} catch {
|
|
2924
|
+
return void 0;
|
|
2694
2925
|
}
|
|
2695
|
-
if (
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2926
|
+
if (!isObject(parsed) || parsed.type !== "session_meta") return void 0;
|
|
2927
|
+
const payload = isObject(parsed.payload) ? parsed.payload : void 0;
|
|
2928
|
+
if (payload === void 0) return void 0;
|
|
2929
|
+
const id = payload.id;
|
|
2930
|
+
const cwd = payload.cwd;
|
|
2931
|
+
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
2932
|
+
if (typeof cwd !== "string" || cwd.length === 0) return void 0;
|
|
2933
|
+
return { id, cwd };
|
|
2934
|
+
}
|
|
2935
|
+
async function readFirstLine(file) {
|
|
2936
|
+
const stream = createReadStream(file, { encoding: "utf8" });
|
|
2937
|
+
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
2938
|
+
try {
|
|
2939
|
+
for await (const line of rl) {
|
|
2940
|
+
const trimmed = line.trim();
|
|
2941
|
+
if (trimmed.length > 0) return trimmed;
|
|
2703
2942
|
}
|
|
2943
|
+
return void 0;
|
|
2944
|
+
} catch {
|
|
2945
|
+
return void 0;
|
|
2946
|
+
} finally {
|
|
2947
|
+
rl.close();
|
|
2948
|
+
stream.destroy();
|
|
2704
2949
|
}
|
|
2705
|
-
printImportResult(options, results, counts);
|
|
2706
2950
|
}
|
|
2707
|
-
async function
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2951
|
+
async function readJsonlRecords(file) {
|
|
2952
|
+
let buffer;
|
|
2953
|
+
try {
|
|
2954
|
+
buffer = await readFile2(file);
|
|
2955
|
+
} catch (error) {
|
|
2956
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
2957
|
+
throw new Error("Source log not found", { cause: error });
|
|
2958
|
+
}
|
|
2959
|
+
if (findErrorCode5(error, "EISDIR")) {
|
|
2960
|
+
throw new Error("Source log path is not a file", { cause: error });
|
|
2961
|
+
}
|
|
2962
|
+
throw new Error("Failed to read source log", { cause: error });
|
|
2714
2963
|
}
|
|
2715
|
-
const
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2964
|
+
const records = [];
|
|
2965
|
+
for (const line of buffer.toString("utf8").split("\n")) {
|
|
2966
|
+
const trimmed = line.trim();
|
|
2967
|
+
if (trimmed.length === 0) continue;
|
|
2968
|
+
try {
|
|
2969
|
+
const parsed = JSON.parse(trimmed);
|
|
2970
|
+
if (isObject(parsed)) {
|
|
2971
|
+
records.push(parsed);
|
|
2972
|
+
}
|
|
2973
|
+
} catch {
|
|
2974
|
+
}
|
|
2719
2975
|
}
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2976
|
+
return { records, sizeBytes: buffer.length };
|
|
2977
|
+
}
|
|
2978
|
+
function isObject(value) {
|
|
2979
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2980
|
+
}
|
|
2981
|
+
function printImportResult(options, results, counts) {
|
|
2982
|
+
const isDry = options.dryRun === true;
|
|
2983
|
+
const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0);
|
|
2984
|
+
const {
|
|
2985
|
+
skippedNoAction,
|
|
2986
|
+
skippedExisting,
|
|
2987
|
+
replaced,
|
|
2988
|
+
reimported,
|
|
2989
|
+
skippedLegacy,
|
|
2990
|
+
skippedDecreased,
|
|
2991
|
+
skippedDuplicate,
|
|
2992
|
+
skippedUnverifiable
|
|
2993
|
+
} = counts;
|
|
2994
|
+
if (options.json === true) {
|
|
2995
|
+
console.log(
|
|
2996
|
+
JSON.stringify({
|
|
2997
|
+
imported: results.map((r) => ({
|
|
2998
|
+
session_id: r.sessionId,
|
|
2999
|
+
event_count: r.eventCount,
|
|
3000
|
+
status: r.finalStatus,
|
|
3001
|
+
source: { kind: r.finalSourceKind, version: "0.1.0" }
|
|
3002
|
+
})),
|
|
3003
|
+
imported_count: results.length,
|
|
3004
|
+
replaced_count: replaced,
|
|
3005
|
+
reimported_count: reimported,
|
|
3006
|
+
skipped_no_action: skippedNoAction,
|
|
3007
|
+
skipped_already_imported: skippedExisting,
|
|
3008
|
+
skipped_legacy_untracked: skippedLegacy,
|
|
3009
|
+
skipped_decreased: skippedDecreased,
|
|
3010
|
+
skipped_duplicate: skippedDuplicate,
|
|
3011
|
+
skipped_unverifiable: skippedUnverifiable,
|
|
3012
|
+
event_total: eventTotal,
|
|
3013
|
+
dry_run: isDry
|
|
3014
|
+
})
|
|
3015
|
+
);
|
|
3016
|
+
return;
|
|
2724
3017
|
}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
3018
|
+
const skipParts = [];
|
|
3019
|
+
if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
|
|
3020
|
+
if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
|
|
3021
|
+
if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
|
|
3022
|
+
if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
|
|
3023
|
+
if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
|
|
3024
|
+
if (skippedUnverifiable > 0)
|
|
3025
|
+
skipParts.push(`${skippedUnverifiable} unverifiable (run 'basou verify')`);
|
|
3026
|
+
const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
|
|
3027
|
+
const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
|
|
3028
|
+
if (isDry) {
|
|
3029
|
+
const parts = [];
|
|
3030
|
+
if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
|
|
3031
|
+
if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
|
|
3032
|
+
const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
|
|
3033
|
+
console.log(`${head}${skipSuffix}`);
|
|
3034
|
+
return;
|
|
2728
3035
|
}
|
|
2729
|
-
if (
|
|
2730
|
-
|
|
2731
|
-
|
|
3036
|
+
if (results.length === 0 && reimported === 0) {
|
|
3037
|
+
console.log(
|
|
3038
|
+
skipParts.length > 0 ? `No new sessions imported (skipped ${skipParts.join(", ")})` : "No transcripts found to import"
|
|
3039
|
+
);
|
|
3040
|
+
return;
|
|
2732
3041
|
}
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
3042
|
+
const segments = [];
|
|
3043
|
+
if (results.length > 0) {
|
|
3044
|
+
const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
|
|
3045
|
+
segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
|
|
3046
|
+
}
|
|
3047
|
+
if (reimported > 0) {
|
|
3048
|
+
segments.push(
|
|
3049
|
+
`${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
|
|
2736
3050
|
);
|
|
2737
|
-
counts.skippedDecreased++;
|
|
2738
|
-
return null;
|
|
2739
3051
|
}
|
|
2740
|
-
|
|
2741
|
-
}
|
|
2742
|
-
function encodeProjectDir(projectPath) {
|
|
2743
|
-
return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
3052
|
+
console.log(`${segments.join(", ")}${skipSuffix}`);
|
|
2744
3053
|
}
|
|
2745
|
-
function
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
if (typeof cwd === "string" && cwd.length > 0) return cwd;
|
|
3054
|
+
function shortId2(id) {
|
|
3055
|
+
if (id.startsWith(SES_PREFIX2)) {
|
|
3056
|
+
return id.slice(SES_PREFIX2.length, SES_PREFIX2.length + SHORT_ID_LEN2);
|
|
2749
3057
|
}
|
|
2750
|
-
return
|
|
3058
|
+
return id.slice(0, SHORT_ID_LEN2);
|
|
2751
3059
|
}
|
|
2752
|
-
async function
|
|
2753
|
-
const byExternalId = /* @__PURE__ */ new Map();
|
|
2754
|
-
const add = (externalId, prior) => {
|
|
2755
|
-
const list = byExternalId.get(externalId);
|
|
2756
|
-
if (list === void 0) byExternalId.set(externalId, [prior]);
|
|
2757
|
-
else list.push(prior);
|
|
2758
|
-
};
|
|
2759
|
-
let sessionIds;
|
|
3060
|
+
async function resolveRepositoryRootForImport(cwd) {
|
|
2760
3061
|
try {
|
|
2761
|
-
|
|
2762
|
-
} catch {
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
try {
|
|
2768
|
-
session = await readSessionYaml2(paths, sessionId);
|
|
2769
|
-
} catch {
|
|
2770
|
-
continue;
|
|
2771
|
-
}
|
|
2772
|
-
if (session.session.source.kind !== sourceKind) continue;
|
|
2773
|
-
const sourceSizeBytes = session.session.source.source_size_bytes;
|
|
2774
|
-
const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
|
|
2775
|
-
const ext = session.session.source.external_id;
|
|
2776
|
-
if (typeof ext === "string" && ext.length > 0) {
|
|
2777
|
-
add(ext, prior);
|
|
2778
|
-
continue;
|
|
3062
|
+
return await resolveRepositoryRoot6(cwd);
|
|
3063
|
+
} catch (error) {
|
|
3064
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
3065
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", {
|
|
3066
|
+
cause: error
|
|
3067
|
+
});
|
|
2779
3068
|
}
|
|
2780
|
-
|
|
2781
|
-
const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
|
|
2782
|
-
if (match?.[1] !== void 0) add(match[1], prior);
|
|
3069
|
+
throw error;
|
|
2783
3070
|
}
|
|
2784
|
-
return byExternalId;
|
|
2785
3071
|
}
|
|
2786
|
-
async function
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
}
|
|
2793
|
-
if (matches.length === 0) {
|
|
2794
|
-
throw new Error("Claude transcript not found for session id in project");
|
|
3072
|
+
async function assertWorkspaceInitialized5(basouRoot) {
|
|
3073
|
+
try {
|
|
3074
|
+
await assertBasouRootSafe6(basouRoot);
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
3077
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
2795
3078
|
}
|
|
2796
|
-
|
|
3079
|
+
throw error;
|
|
2797
3080
|
}
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// src/lib/provenance-actions.ts
|
|
3084
|
+
async function captureImportJson(fn) {
|
|
3085
|
+
const stdout = [];
|
|
3086
|
+
const originalLog = console.log;
|
|
3087
|
+
const originalError = console.error;
|
|
3088
|
+
console.log = ((...args) => {
|
|
3089
|
+
stdout.push(args.map((a) => String(a)).join(" "));
|
|
3090
|
+
});
|
|
3091
|
+
console.error = (() => {
|
|
3092
|
+
});
|
|
3093
|
+
try {
|
|
3094
|
+
await fn();
|
|
3095
|
+
} finally {
|
|
3096
|
+
console.log = originalLog;
|
|
3097
|
+
console.error = originalError;
|
|
3098
|
+
}
|
|
3099
|
+
for (let i = stdout.length - 1; i >= 0; i--) {
|
|
3100
|
+
const line = stdout[i];
|
|
3101
|
+
if (line === void 0) continue;
|
|
2803
3102
|
try {
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
}
|
|
2809
|
-
anyDirFound = true;
|
|
2810
|
-
for (const name of entries) {
|
|
2811
|
-
if (name.endsWith(".jsonl")) files.push(join7(transcriptDir, name));
|
|
3103
|
+
const parsed = JSON.parse(line);
|
|
3104
|
+
if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
|
|
3105
|
+
return parsed;
|
|
3106
|
+
}
|
|
3107
|
+
} catch {
|
|
2812
3108
|
}
|
|
2813
3109
|
}
|
|
2814
|
-
|
|
2815
|
-
throw new Error("Claude transcript directory not found for project");
|
|
2816
|
-
}
|
|
2817
|
-
return [...new Set(files)].sort();
|
|
3110
|
+
throw new Error("Import produced no parseable result");
|
|
2818
3111
|
}
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
await stat4(file);
|
|
2822
|
-
return true;
|
|
2823
|
-
} catch (error) {
|
|
2824
|
-
if (findErrorCode5(error, "ENOENT")) return false;
|
|
2825
|
-
throw error;
|
|
2826
|
-
}
|
|
3112
|
+
function readCount(value) {
|
|
3113
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2827
3114
|
}
|
|
2828
|
-
|
|
3115
|
+
function isMissingSourceDir(error) {
|
|
3116
|
+
if (!(error instanceof Error)) return false;
|
|
3117
|
+
return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
|
|
3118
|
+
}
|
|
3119
|
+
async function runImport(adapter, fn) {
|
|
2829
3120
|
try {
|
|
2830
|
-
|
|
3121
|
+
const json = await captureImportJson(fn);
|
|
3122
|
+
return {
|
|
3123
|
+
adapter,
|
|
3124
|
+
status: "ran",
|
|
3125
|
+
importedCount: readCount(json.imported_count),
|
|
3126
|
+
replacedCount: readCount(json.replaced_count),
|
|
3127
|
+
reimportedCount: readCount(json.reimported_count),
|
|
3128
|
+
skippedNoAction: readCount(json.skipped_no_action),
|
|
3129
|
+
skippedAlreadyImported: readCount(json.skipped_already_imported),
|
|
3130
|
+
skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
|
|
3131
|
+
skippedDecreased: readCount(json.skipped_decreased),
|
|
3132
|
+
skippedDuplicate: readCount(json.skipped_duplicate),
|
|
3133
|
+
skippedUnverifiable: readCount(json.skipped_unverifiable),
|
|
3134
|
+
eventTotal: readCount(json.event_total),
|
|
3135
|
+
dryRun: json.dry_run === true
|
|
3136
|
+
};
|
|
2831
3137
|
} catch (error) {
|
|
2832
|
-
if (
|
|
3138
|
+
if (isMissingSourceDir(error)) {
|
|
3139
|
+
return { adapter, status: "skipped", reason: "no source logs for this project" };
|
|
3140
|
+
}
|
|
2833
3141
|
throw error;
|
|
2834
3142
|
}
|
|
2835
3143
|
}
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
if (options.session !== void 0 && meta.id !== options.session) continue;
|
|
2845
|
-
matched.push({ file, externalId: meta.id });
|
|
2846
|
-
}
|
|
2847
|
-
if (options.session !== void 0 && matched.length === 0) {
|
|
2848
|
-
throw new Error("Codex rollout not found for session id in project");
|
|
2849
|
-
}
|
|
2850
|
-
return matched;
|
|
3144
|
+
function importOptions(options) {
|
|
3145
|
+
return {
|
|
3146
|
+
all: true,
|
|
3147
|
+
json: true,
|
|
3148
|
+
...options.project !== void 0 ? { project: options.project } : {},
|
|
3149
|
+
...options.force === true ? { force: true } : {},
|
|
3150
|
+
...options.dryRun === true ? { dryRun: true } : {}
|
|
3151
|
+
};
|
|
2851
3152
|
}
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
3153
|
+
function importClaudeCode(options, ctx) {
|
|
3154
|
+
return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
|
|
3155
|
+
}
|
|
3156
|
+
function importCodex(options, ctx) {
|
|
3157
|
+
return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
|
|
3158
|
+
}
|
|
3159
|
+
async function regenerateHandoff(paths, nowIso, callbacks) {
|
|
3160
|
+
const result = await renderHandoff2({ paths, nowIso, ...callbacks });
|
|
3161
|
+
const existing = await readMarkdownFile4(paths.files.handoff);
|
|
3162
|
+
await writeMarkdownFile3(
|
|
3163
|
+
paths.files.handoff,
|
|
3164
|
+
renderWithMarkers3(existing, result.body, "handoff.md")
|
|
3165
|
+
);
|
|
3166
|
+
return {
|
|
3167
|
+
sessionCount: result.sessionCount,
|
|
3168
|
+
taskCount: result.taskCount,
|
|
3169
|
+
decisionCount: result.decisionCount,
|
|
3170
|
+
pendingApprovalsCount: result.pendingApprovalsCount
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
async function regenerateDecisions(paths, nowIso, callbacks) {
|
|
3174
|
+
const result = await renderDecisions2({ paths, nowIso, ...callbacks });
|
|
3175
|
+
const existing = await readMarkdownFile4(paths.files.decisions);
|
|
3176
|
+
await writeMarkdownFile3(
|
|
3177
|
+
paths.files.decisions,
|
|
3178
|
+
renderWithMarkers3(existing, result.body, "decisions.md")
|
|
3179
|
+
);
|
|
3180
|
+
return { decisionCount: result.decisionCount };
|
|
3181
|
+
}
|
|
3182
|
+
async function regenerateOrientation(paths, nowIso, callbacks) {
|
|
3183
|
+
const result = await renderOrientation({ paths, nowIso, ...callbacks });
|
|
3184
|
+
await writeMarkdownFile3(paths.files.orientation, `${result.body}
|
|
3185
|
+
`);
|
|
3186
|
+
return {
|
|
3187
|
+
sessionCount: result.sessionCount,
|
|
3188
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3189
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3190
|
+
suspectCount: result.suspectCount
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
async function refreshAll(args) {
|
|
3194
|
+
const { options, ctx, paths, nowIso } = args;
|
|
3195
|
+
const dryRun = options.dryRun === true;
|
|
3196
|
+
const claudeCode = await importClaudeCode(options, ctx);
|
|
3197
|
+
const codex = await importCodex(options, ctx);
|
|
3198
|
+
if (dryRun) {
|
|
3199
|
+
const skipped = { status: "skipped", reason: "dry-run" };
|
|
3200
|
+
return {
|
|
3201
|
+
claudeCode,
|
|
3202
|
+
codex,
|
|
3203
|
+
handoff: skipped,
|
|
3204
|
+
decisions: skipped,
|
|
3205
|
+
orientation: skipped,
|
|
3206
|
+
dryRun
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
const handoffCounts = await regenerateHandoff(paths, nowIso);
|
|
3210
|
+
const decisionCounts = await regenerateDecisions(paths, nowIso);
|
|
3211
|
+
const scoped = options.project !== void 0 && options.project.length > 0;
|
|
3212
|
+
const orientationCounts = await regenerateOrientation(
|
|
3213
|
+
paths,
|
|
3214
|
+
nowIso,
|
|
3215
|
+
scoped ? {} : {
|
|
3216
|
+
staleness: {
|
|
3217
|
+
newSessions: 0,
|
|
3218
|
+
updatedSessions: 0,
|
|
3219
|
+
unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
|
|
2873
3220
|
}
|
|
2874
3221
|
}
|
|
3222
|
+
);
|
|
3223
|
+
return {
|
|
3224
|
+
claudeCode,
|
|
3225
|
+
codex,
|
|
3226
|
+
handoff: { status: "generated", ...handoffCounts },
|
|
3227
|
+
decisions: { status: "generated", ...decisionCounts },
|
|
3228
|
+
orientation: { status: "generated", ...orientationCounts },
|
|
3229
|
+
dryRun
|
|
2875
3230
|
};
|
|
2876
|
-
await walk(sessionsRoot, true);
|
|
2877
|
-
return found.sort();
|
|
2878
3231
|
}
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
if (firstLine === void 0) return void 0;
|
|
2882
|
-
let parsed;
|
|
2883
|
-
try {
|
|
2884
|
-
parsed = JSON.parse(firstLine);
|
|
2885
|
-
} catch {
|
|
2886
|
-
return void 0;
|
|
2887
|
-
}
|
|
2888
|
-
if (!isObject(parsed) || parsed.type !== "session_meta") return void 0;
|
|
2889
|
-
const payload = isObject(parsed.payload) ? parsed.payload : void 0;
|
|
2890
|
-
if (payload === void 0) return void 0;
|
|
2891
|
-
const id = payload.id;
|
|
2892
|
-
const cwd = payload.cwd;
|
|
2893
|
-
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
2894
|
-
if (typeof cwd !== "string" || cwd.length === 0) return void 0;
|
|
2895
|
-
return { id, cwd };
|
|
3232
|
+
function wouldImport(outcome) {
|
|
3233
|
+
return outcome.status === "ran" ? outcome.importedCount : 0;
|
|
2896
3234
|
}
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
3235
|
+
function wouldUpdate(outcome) {
|
|
3236
|
+
return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
|
|
3237
|
+
}
|
|
3238
|
+
function wouldBlock(outcome) {
|
|
3239
|
+
return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
|
|
3240
|
+
}
|
|
3241
|
+
async function probeStaleness(args) {
|
|
2900
3242
|
try {
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
3243
|
+
const dry = await refreshAll({
|
|
3244
|
+
options: { dryRun: true },
|
|
3245
|
+
ctx: args.ctx,
|
|
3246
|
+
paths: args.paths,
|
|
3247
|
+
nowIso: args.nowIso
|
|
3248
|
+
});
|
|
3249
|
+
return {
|
|
3250
|
+
newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
|
|
3251
|
+
updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
|
|
3252
|
+
unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
|
|
3253
|
+
};
|
|
2906
3254
|
} catch {
|
|
2907
|
-
return
|
|
2908
|
-
} finally {
|
|
2909
|
-
rl.close();
|
|
2910
|
-
stream.destroy();
|
|
3255
|
+
return null;
|
|
2911
3256
|
}
|
|
2912
3257
|
}
|
|
2913
|
-
|
|
2914
|
-
|
|
3258
|
+
|
|
3259
|
+
// src/commands/orient.ts
|
|
3260
|
+
function registerOrientCommand(program2) {
|
|
3261
|
+
program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
|
|
3262
|
+
"--refresh",
|
|
3263
|
+
"Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
|
|
3264
|
+
).option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
3265
|
+
await runOrient(opts);
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
async function runOrient(options, ctx = {}) {
|
|
2915
3269
|
try {
|
|
2916
|
-
|
|
3270
|
+
await doRunOrient(options, ctx);
|
|
2917
3271
|
} catch (error) {
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
}
|
|
2921
|
-
if (findErrorCode5(error, "EISDIR")) {
|
|
2922
|
-
throw new Error("Source log path is not a file", { cause: error });
|
|
2923
|
-
}
|
|
2924
|
-
throw new Error("Failed to read source log", { cause: error });
|
|
2925
|
-
}
|
|
2926
|
-
const records = [];
|
|
2927
|
-
for (const line of buffer.toString("utf8").split("\n")) {
|
|
2928
|
-
const trimmed = line.trim();
|
|
2929
|
-
if (trimmed.length === 0) continue;
|
|
2930
|
-
try {
|
|
2931
|
-
const parsed = JSON.parse(trimmed);
|
|
2932
|
-
if (isObject(parsed)) {
|
|
2933
|
-
records.push(parsed);
|
|
2934
|
-
}
|
|
2935
|
-
} catch {
|
|
2936
|
-
}
|
|
3272
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3273
|
+
process.exitCode = 1;
|
|
2937
3274
|
}
|
|
2938
|
-
return { records, sizeBytes: buffer.length };
|
|
2939
|
-
}
|
|
2940
|
-
function isObject(value) {
|
|
2941
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2942
3275
|
}
|
|
2943
|
-
function
|
|
2944
|
-
const
|
|
2945
|
-
|
|
2946
|
-
const {
|
|
2947
|
-
skippedNoAction,
|
|
2948
|
-
skippedExisting,
|
|
2949
|
-
replaced,
|
|
2950
|
-
reimported,
|
|
2951
|
-
skippedLegacy,
|
|
2952
|
-
skippedDecreased,
|
|
2953
|
-
skippedDuplicate,
|
|
2954
|
-
skippedUnverifiable
|
|
2955
|
-
} = counts;
|
|
2956
|
-
if (options.json === true) {
|
|
2957
|
-
console.log(
|
|
2958
|
-
JSON.stringify({
|
|
2959
|
-
imported: results.map((r) => ({
|
|
2960
|
-
session_id: r.sessionId,
|
|
2961
|
-
event_count: r.eventCount,
|
|
2962
|
-
status: r.finalStatus,
|
|
2963
|
-
source: { kind: r.finalSourceKind, version: "0.1.0" }
|
|
2964
|
-
})),
|
|
2965
|
-
imported_count: results.length,
|
|
2966
|
-
replaced_count: replaced,
|
|
2967
|
-
reimported_count: reimported,
|
|
2968
|
-
skipped_no_action: skippedNoAction,
|
|
2969
|
-
skipped_already_imported: skippedExisting,
|
|
2970
|
-
skipped_legacy_untracked: skippedLegacy,
|
|
2971
|
-
skipped_decreased: skippedDecreased,
|
|
2972
|
-
skipped_duplicate: skippedDuplicate,
|
|
2973
|
-
skipped_unverifiable: skippedUnverifiable,
|
|
2974
|
-
event_total: eventTotal,
|
|
2975
|
-
dry_run: isDry
|
|
2976
|
-
})
|
|
2977
|
-
);
|
|
2978
|
-
return;
|
|
2979
|
-
}
|
|
2980
|
-
const skipParts = [];
|
|
2981
|
-
if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
|
|
2982
|
-
if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
|
|
2983
|
-
if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
|
|
2984
|
-
if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
|
|
2985
|
-
if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
|
|
2986
|
-
if (skippedUnverifiable > 0)
|
|
2987
|
-
skipParts.push(`${skippedUnverifiable} unverifiable (run 'basou verify')`);
|
|
2988
|
-
const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
|
|
2989
|
-
const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
|
|
2990
|
-
if (isDry) {
|
|
2991
|
-
const parts = [];
|
|
2992
|
-
if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
|
|
2993
|
-
if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
|
|
2994
|
-
const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
|
|
2995
|
-
console.log(`${head}${skipSuffix}`);
|
|
2996
|
-
return;
|
|
2997
|
-
}
|
|
2998
|
-
if (results.length === 0 && reimported === 0) {
|
|
3276
|
+
async function doRunOrient(options, ctx) {
|
|
3277
|
+
const result = await renderOrientationForCwd(options, ctx);
|
|
3278
|
+
if (options.quiet === true) {
|
|
2999
3279
|
console.log(
|
|
3000
|
-
|
|
3001
|
-
);
|
|
3002
|
-
return;
|
|
3003
|
-
}
|
|
3004
|
-
const segments = [];
|
|
3005
|
-
if (results.length > 0) {
|
|
3006
|
-
const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
|
|
3007
|
-
segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
|
|
3008
|
-
}
|
|
3009
|
-
if (reimported > 0) {
|
|
3010
|
-
segments.push(
|
|
3011
|
-
`${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
|
|
3280
|
+
`Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
|
|
3012
3281
|
);
|
|
3282
|
+
} else {
|
|
3283
|
+
console.log(result.body);
|
|
3013
3284
|
}
|
|
3014
|
-
console.log(`${segments.join(", ")}${skipSuffix}`);
|
|
3015
3285
|
}
|
|
3016
|
-
function
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
}
|
|
3020
|
-
return id.slice(0, SHORT_ID_LEN2);
|
|
3286
|
+
async function renderOrientationForCwd(options, ctx) {
|
|
3287
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
3288
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
|
|
3289
|
+
return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
|
|
3021
3290
|
}
|
|
3022
|
-
async function
|
|
3291
|
+
async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
|
|
3292
|
+
const paths = basouPaths8(repositoryRoot);
|
|
3293
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
3294
|
+
const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
3295
|
+
const probeCtx = { cwd: repositoryRoot };
|
|
3296
|
+
if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
|
|
3297
|
+
if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
|
|
3298
|
+
if (options.refresh === true) {
|
|
3299
|
+
await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
|
|
3300
|
+
}
|
|
3301
|
+
const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
|
|
3302
|
+
let federatedRoots = [];
|
|
3023
3303
|
try {
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", {
|
|
3028
|
-
cause: error
|
|
3029
|
-
});
|
|
3304
|
+
const hosts = await loadHostsConfig(ctx.hostsConfigPath);
|
|
3305
|
+
if (hosts !== null) {
|
|
3306
|
+
federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
|
|
3030
3307
|
}
|
|
3031
|
-
|
|
3308
|
+
} catch (error) {
|
|
3309
|
+
console.error(
|
|
3310
|
+
`basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
|
|
3311
|
+
);
|
|
3032
3312
|
}
|
|
3313
|
+
const result = await renderOrientation2({
|
|
3314
|
+
paths,
|
|
3315
|
+
nowIso,
|
|
3316
|
+
staleness,
|
|
3317
|
+
verbose: options.verbose === true,
|
|
3318
|
+
federatedRoots,
|
|
3319
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
3320
|
+
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
|
|
3321
|
+
onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
|
|
3322
|
+
onHostUnavailable: (host, error) => console.error(
|
|
3323
|
+
`basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
|
|
3324
|
+
)
|
|
3325
|
+
});
|
|
3326
|
+
if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
|
|
3327
|
+
`);
|
|
3328
|
+
return {
|
|
3329
|
+
body: result.body,
|
|
3330
|
+
sessionCount: result.sessionCount,
|
|
3331
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3332
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3333
|
+
suspectCount: result.suspectCount
|
|
3334
|
+
};
|
|
3033
3335
|
}
|
|
3034
|
-
async function
|
|
3336
|
+
async function assertWorkspaceInitialized6(basouRoot) {
|
|
3035
3337
|
try {
|
|
3036
|
-
await
|
|
3338
|
+
await assertBasouRootSafe7(basouRoot);
|
|
3037
3339
|
} catch (error) {
|
|
3038
|
-
if (
|
|
3340
|
+
if (findErrorCode6(error, "ENOENT")) {
|
|
3039
3341
|
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
3040
3342
|
}
|
|
3041
3343
|
throw error;
|
|
3042
3344
|
}
|
|
3043
3345
|
}
|
|
3044
3346
|
|
|
3045
|
-
// src/commands/
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
|
|
3059
|
-
"--repo-url <url>",
|
|
3060
|
-
"Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
|
|
3347
|
+
// src/commands/hook.ts
|
|
3348
|
+
var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
|
|
3349
|
+
function registerHookCommand(program2) {
|
|
3350
|
+
const hook = program2.command("hook").description(
|
|
3351
|
+
"Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
|
|
3352
|
+
);
|
|
3353
|
+
hook.command("session-start").description(
|
|
3354
|
+
"Codex SessionStart hook: print the current position of the workspace Codex was opened in (read from the payload's cwd) so Codex adds it to that session's context. Stays silent outside a basou workspace; never fails the session."
|
|
3355
|
+
).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
|
|
3356
|
+
await runHookSessionStart();
|
|
3357
|
+
});
|
|
3358
|
+
hook.command("stop").description(
|
|
3359
|
+
"Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
|
|
3061
3360
|
).option(
|
|
3062
|
-
"--
|
|
3063
|
-
|
|
3064
|
-
collectValue,
|
|
3065
|
-
[]
|
|
3361
|
+
"--min-edits <n>",
|
|
3362
|
+
`Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
|
|
3066
3363
|
).option(
|
|
3067
|
-
"--
|
|
3068
|
-
"
|
|
3069
|
-
).option(
|
|
3070
|
-
|
|
3364
|
+
"--block",
|
|
3365
|
+
"Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
|
|
3366
|
+
).option(
|
|
3367
|
+
"--require-review",
|
|
3368
|
+
"Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
|
|
3369
|
+
).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
|
|
3370
|
+
const minEdits = parseMinEdits(options.minEdits);
|
|
3371
|
+
await runHookStop({
|
|
3372
|
+
...minEdits !== void 0 ? { minEdits } : {},
|
|
3373
|
+
...options.block === true ? { block: true } : {},
|
|
3374
|
+
...options.requireReview === true ? { requireReview: true } : {}
|
|
3375
|
+
});
|
|
3376
|
+
});
|
|
3377
|
+
hook.command("install [target]").description(
|
|
3378
|
+
"Register a basou hook (reproducible, idempotent). Target `claude` (default): the Stop hook in ~/.claude/settings.json \u2014 advisory capture-only by default; --block opts into in-turn enforcement, --require-review into the review gate. Target `codex`: the SessionStart hook in ~/.codex/hooks.json, which hands each Codex session the position of the workspace it was opened in. Codex asks you to review and trust a new hook once before it runs."
|
|
3379
|
+
).option(
|
|
3380
|
+
"--block",
|
|
3381
|
+
"claude: register the blocking (opt-in enforcement) form instead of advisory"
|
|
3382
|
+
).option("--require-review", "claude: register with the opt-in review gate enabled").option("--min-edits <n>", "claude: pass a custom file-edit threshold to the registered hook").option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
|
|
3383
|
+
"--codex-config <path>",
|
|
3384
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3385
|
+
).option(
|
|
3386
|
+
"--codex-face <path>",
|
|
3387
|
+
"codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
|
|
3388
|
+
).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3389
|
+
await dispatchHookTarget(target, opts, {
|
|
3390
|
+
claude: () => runHookInstall(opts),
|
|
3391
|
+
codex: () => runCodexHookInstall(opts)
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
hook.command("uninstall [target]").description(
|
|
3395
|
+
"Remove a basou hook, leaving other hooks intact. Target `claude` (default): the Stop hook in ~/.claude/settings.json. Target `codex`: the SessionStart hook in ~/.codex/hooks.json."
|
|
3396
|
+
).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3397
|
+
await dispatchHookTarget(target, opts, {
|
|
3398
|
+
claude: () => runHookUninstall(opts),
|
|
3399
|
+
codex: () => runCodexHookUninstall(opts)
|
|
3400
|
+
});
|
|
3401
|
+
});
|
|
3402
|
+
hook.command("status [target]").description(
|
|
3403
|
+
"Report whether a basou hook is registered. Target `claude` (default): the Stop hook and its mode. Target `codex`: the SessionStart hook, and whether Codex has trusted it yet."
|
|
3404
|
+
).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
|
|
3405
|
+
"--codex-config <path>",
|
|
3406
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3407
|
+
).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3408
|
+
await dispatchHookTarget(target, opts, {
|
|
3409
|
+
claude: () => runHookStatus(opts),
|
|
3410
|
+
codex: () => runCodexHookStatus(opts)
|
|
3411
|
+
});
|
|
3071
3412
|
});
|
|
3072
3413
|
}
|
|
3073
|
-
async function
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3414
|
+
async function dispatchHookTarget(target, options, handlers) {
|
|
3415
|
+
const resolved = target ?? "claude";
|
|
3416
|
+
if (resolved !== "claude" && resolved !== "codex") {
|
|
3417
|
+
renderCliError(
|
|
3418
|
+
new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
|
|
3419
|
+
{ verbose: isVerbose(options) }
|
|
3420
|
+
);
|
|
3078
3421
|
process.exitCode = 1;
|
|
3422
|
+
return;
|
|
3079
3423
|
}
|
|
3424
|
+
await handlers[resolved]();
|
|
3080
3425
|
}
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3426
|
+
var HOOK_SESSION_START_HELP = `
|
|
3427
|
+
Register this hook reproducibly with 'basou hook install codex' (it writes the
|
|
3428
|
+
correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
|
|
3429
|
+
removes it; 'basou hook status codex' reports whether it is registered and
|
|
3430
|
+
whether Codex has trusted it.
|
|
3431
|
+
|
|
3432
|
+
Codex runs the hook when a session starts and passes the session's cwd on stdin.
|
|
3433
|
+
basou resolves the workspace from that cwd (a member repo resolves to its
|
|
3434
|
+
planning master, a workspace view to its master) and prints the workspace's
|
|
3435
|
+
current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
|
|
3436
|
+
session's context as developer text. The position is computed at that moment
|
|
3437
|
+
from that cwd and stored nowhere: a Codex opened in another workspace gets that
|
|
3438
|
+
workspace's position, and one opened outside any basou workspace (or before the
|
|
3439
|
+
desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
|
|
3440
|
+
user-global hook serves every workspace without any workspace's position ever
|
|
3441
|
+
being written where another workspace's session would read it.
|
|
3442
|
+
|
|
3443
|
+
Codex trusts hooks by hash. A newly installed or changed hook is skipped until
|
|
3444
|
+
you review it: the interactive CLI asks at startup ("Hooks need review"), the
|
|
3445
|
+
desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
|
|
3446
|
+
an untrusted hook silently.
|
|
3447
|
+
`;
|
|
3448
|
+
var HOOK_STOP_HELP = `
|
|
3449
|
+
Register this Stop hook reproducibly with 'basou hook install' (it writes the
|
|
3450
|
+
correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
|
|
3451
|
+
removes it; 'basou hook status' reports whether it is registered.
|
|
3452
|
+
|
|
3453
|
+
On every turn end basou inspects the session transcript. If the session did
|
|
3454
|
+
content-substantive work but ran no capture verb ('basou decision capture' /
|
|
3455
|
+
'decision record' / 'note'), it reminds the agent to record the why / next step.
|
|
3456
|
+
Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
|
|
3457
|
+
answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
|
|
3458
|
+
git status) does NOT count.
|
|
3459
|
+
|
|
3460
|
+
With --require-review (opt-in, 'basou hook install --require-review') it also
|
|
3461
|
+
reminds when the session SHIPPED substantive code (git push / git merge /
|
|
3462
|
+
gh pr create|merge) without recording a review ('basou review record'). This
|
|
3463
|
+
gate is off by default; when on, its reminder is composed into the same
|
|
3464
|
+
envelope as the capture reminder.
|
|
3465
|
+
|
|
3466
|
+
By default the reminder is non-blocking: Claude sees it and may act on it or
|
|
3467
|
+
stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
|
|
3468
|
+
returns decision:block, holding the agent in-turn to act on the reminder; the
|
|
3469
|
+
'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
|
|
3470
|
+
single turn. Either way the hook fails open: a bad payload or unreadable
|
|
3471
|
+
transcript exits cleanly with no output.
|
|
3472
|
+
`;
|
|
3473
|
+
async function runHookStop(options, ctx = {}) {
|
|
3102
3474
|
try {
|
|
3103
|
-
await
|
|
3104
|
-
} catch
|
|
3105
|
-
renderGitignoreWarning(error, isVerbose(options));
|
|
3475
|
+
await doRunHookStop(options, ctx);
|
|
3476
|
+
} catch {
|
|
3106
3477
|
}
|
|
3107
|
-
console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
|
|
3108
3478
|
}
|
|
3109
|
-
function
|
|
3110
|
-
const
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
);
|
|
3114
|
-
if (
|
|
3115
|
-
|
|
3116
|
-
|
|
3479
|
+
async function doRunHookStop(options, ctx) {
|
|
3480
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3481
|
+
const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
|
|
3482
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3483
|
+
const raw = await readStdin();
|
|
3484
|
+
if (raw.trim().length === 0) return;
|
|
3485
|
+
let payload;
|
|
3486
|
+
try {
|
|
3487
|
+
payload = JSON.parse(raw);
|
|
3488
|
+
} catch {
|
|
3489
|
+
return;
|
|
3117
3490
|
}
|
|
3118
|
-
|
|
3119
|
-
|
|
3491
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3492
|
+
const fields = payload;
|
|
3493
|
+
if (fields.stop_hook_active === true) return;
|
|
3494
|
+
const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
|
|
3495
|
+
if (transcriptPath.length === 0) return;
|
|
3496
|
+
let transcript;
|
|
3120
3497
|
try {
|
|
3121
|
-
|
|
3122
|
-
} catch
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3498
|
+
transcript = await readTranscript(transcriptPath);
|
|
3499
|
+
} catch {
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const records = parseTranscript(transcript);
|
|
3503
|
+
const evaluation = evaluateStopHook({
|
|
3504
|
+
records,
|
|
3505
|
+
// stop_hook_active was already handled by the early return above.
|
|
3506
|
+
stopHookActive: false,
|
|
3507
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3508
|
+
});
|
|
3509
|
+
const parts = [];
|
|
3510
|
+
if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
|
|
3511
|
+
if (options.requireReview === true && evaluation.review.fires) {
|
|
3512
|
+
parts.push(evaluation.review.additionalContext);
|
|
3129
3513
|
}
|
|
3514
|
+
if (parts.length === 0) return;
|
|
3515
|
+
const reason = parts.join("\n\n");
|
|
3516
|
+
const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
|
|
3517
|
+
hookSpecificOutput: {
|
|
3518
|
+
hookEventName: "Stop",
|
|
3519
|
+
additionalContext: reason
|
|
3520
|
+
}
|
|
3521
|
+
});
|
|
3522
|
+
write(`${payloadJson}
|
|
3523
|
+
`);
|
|
3130
3524
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
acquireLock as acquireLock4,
|
|
3135
|
-
appendEventToExistingSession as appendEventToExistingSession2,
|
|
3136
|
-
assertBasouRootSafe as assertBasouRootSafe7,
|
|
3137
|
-
basouPaths as basouPaths8,
|
|
3138
|
-
createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
|
|
3139
|
-
findErrorCode as findErrorCode6,
|
|
3140
|
-
readManifest as readManifest5,
|
|
3141
|
-
resolveSessionId as resolveSessionId2
|
|
3142
|
-
} from "@basou/core";
|
|
3143
|
-
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
3144
|
-
var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
|
|
3145
|
-
"list",
|
|
3146
|
-
"ls",
|
|
3147
|
-
"show",
|
|
3148
|
-
"get",
|
|
3149
|
-
"add",
|
|
3150
|
-
"new",
|
|
3151
|
-
"edit",
|
|
3152
|
-
"rm",
|
|
3153
|
-
"remove",
|
|
3154
|
-
"delete",
|
|
3155
|
-
"help"
|
|
3156
|
-
]);
|
|
3157
|
-
var LABEL_BODY_MAX = 80;
|
|
3158
|
-
var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
|
|
3159
|
-
function registerNoteCommand(program2) {
|
|
3160
|
-
program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
|
|
3161
|
-
"--session <session_id>",
|
|
3162
|
-
"Attach to an existing session; otherwise an ad-hoc session is created"
|
|
3163
|
-
).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
|
|
3164
|
-
await runNote(body, options);
|
|
3525
|
+
async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
|
|
3526
|
+
const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
|
|
3527
|
+
portfolioConfigPath
|
|
3165
3528
|
});
|
|
3529
|
+
if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
|
|
3530
|
+
throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
|
|
3531
|
+
}
|
|
3532
|
+
const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
|
|
3533
|
+
return { body: rendered.body };
|
|
3166
3534
|
}
|
|
3167
|
-
async function
|
|
3535
|
+
async function isRegisteredWorkspace(root, portfolioConfigPath) {
|
|
3536
|
+
let entries;
|
|
3168
3537
|
try {
|
|
3169
|
-
await
|
|
3170
|
-
} catch
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3538
|
+
entries = await loadPortfolioConfig(portfolioConfigPath);
|
|
3539
|
+
} catch {
|
|
3540
|
+
return false;
|
|
3541
|
+
}
|
|
3542
|
+
const rootReal = await realpath2(root).catch(() => root);
|
|
3543
|
+
for (const entry of entries) {
|
|
3544
|
+
const entryReal = await realpath2(entry.path).catch(() => null);
|
|
3545
|
+
if (entryReal !== null && entryReal === rootReal) return true;
|
|
3176
3546
|
}
|
|
3547
|
+
return false;
|
|
3177
3548
|
}
|
|
3178
|
-
async function
|
|
3179
|
-
|
|
3180
|
-
|
|
3549
|
+
async function runHookSessionStart(ctx = {}) {
|
|
3550
|
+
try {
|
|
3551
|
+
await doRunHookSessionStart(ctx);
|
|
3552
|
+
} catch {
|
|
3181
3553
|
}
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3554
|
+
}
|
|
3555
|
+
async function doRunHookSessionStart(ctx) {
|
|
3556
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3557
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3558
|
+
const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
|
|
3559
|
+
const raw = await readStdin();
|
|
3560
|
+
if (raw.trim().length === 0) return;
|
|
3561
|
+
let payload;
|
|
3562
|
+
try {
|
|
3563
|
+
payload = JSON.parse(raw);
|
|
3564
|
+
} catch {
|
|
3565
|
+
return;
|
|
3187
3566
|
}
|
|
3188
|
-
|
|
3189
|
-
const
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
const sessionId = await resolveSessionId2(paths, options.session);
|
|
3196
|
-
const sesId = sessionId;
|
|
3197
|
-
const sessionLock = await acquireLock4(paths, "session", sesId);
|
|
3198
|
-
let result;
|
|
3199
|
-
try {
|
|
3200
|
-
result = await appendEventToExistingSession2({
|
|
3201
|
-
paths,
|
|
3202
|
-
sessionId: sesId,
|
|
3203
|
-
eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
|
|
3204
|
-
});
|
|
3205
|
-
} finally {
|
|
3206
|
-
await sessionLock.release();
|
|
3207
|
-
}
|
|
3208
|
-
printNoteResult(options, {
|
|
3209
|
-
mode: "attached",
|
|
3210
|
-
sessionId,
|
|
3211
|
-
eventId: result.eventId,
|
|
3212
|
-
sessionStatus: result.sessionStatus,
|
|
3213
|
-
body
|
|
3214
|
-
});
|
|
3567
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3568
|
+
const cwd = payload.cwd;
|
|
3569
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
3570
|
+
let body;
|
|
3571
|
+
try {
|
|
3572
|
+
body = (await render(cwd)).body;
|
|
3573
|
+
} catch {
|
|
3215
3574
|
return;
|
|
3216
3575
|
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
manifest,
|
|
3221
|
-
label: buildAdHocLabel2(body),
|
|
3222
|
-
occurredAt,
|
|
3223
|
-
sessionSource: "human",
|
|
3224
|
-
workingDirectory: repositoryRoot,
|
|
3225
|
-
invocation: {
|
|
3226
|
-
command: "basou note",
|
|
3227
|
-
args: [body]
|
|
3228
|
-
},
|
|
3229
|
-
targetEventBuilders: [
|
|
3230
|
-
(sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
|
|
3231
|
-
]
|
|
3232
|
-
});
|
|
3233
|
-
printNoteResult(options, {
|
|
3234
|
-
mode: "ad-hoc",
|
|
3235
|
-
sessionId: adHoc.sessionId,
|
|
3236
|
-
eventId: adHoc.targetEventIds[0],
|
|
3237
|
-
sessionStatus: "completed",
|
|
3238
|
-
body
|
|
3239
|
-
});
|
|
3240
|
-
}
|
|
3241
|
-
function buildNoteEvent(input) {
|
|
3242
|
-
return {
|
|
3243
|
-
schema_version: "0.1.0",
|
|
3244
|
-
id: input.eventId,
|
|
3245
|
-
session_id: input.sessionId,
|
|
3246
|
-
occurred_at: input.occurredAt,
|
|
3247
|
-
source: "local-cli",
|
|
3248
|
-
type: "note_added",
|
|
3249
|
-
body: input.body,
|
|
3250
|
-
// `basou note` is the resume-hint command; mark it so orientation surfaces
|
|
3251
|
-
// it as the next step and a plain `basou session note` annotation does not.
|
|
3252
|
-
kind: "next_step"
|
|
3253
|
-
};
|
|
3576
|
+
if (body.trim().length === 0) return;
|
|
3577
|
+
write(`${body.replace(/\s+$/, "")}
|
|
3578
|
+
`);
|
|
3254
3579
|
}
|
|
3255
|
-
function
|
|
3256
|
-
const
|
|
3257
|
-
const
|
|
3258
|
-
|
|
3580
|
+
function parseTranscript(transcript) {
|
|
3581
|
+
const records = [];
|
|
3582
|
+
for (const line of transcript.split(/\r?\n/)) {
|
|
3583
|
+
if (line.trim().length === 0) continue;
|
|
3584
|
+
try {
|
|
3585
|
+
const parsed = JSON.parse(line);
|
|
3586
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3587
|
+
records.push(parsed);
|
|
3588
|
+
}
|
|
3589
|
+
} catch {
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
return records;
|
|
3259
3593
|
}
|
|
3260
|
-
function
|
|
3261
|
-
if (
|
|
3262
|
-
|
|
3594
|
+
async function defaultReadStdin() {
|
|
3595
|
+
if (process.stdin.isTTY === true) return "";
|
|
3596
|
+
const chunks = [];
|
|
3597
|
+
for await (const chunk of process.stdin) {
|
|
3598
|
+
chunks.push(chunk);
|
|
3263
3599
|
}
|
|
3264
|
-
return
|
|
3600
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3265
3601
|
}
|
|
3266
|
-
function
|
|
3267
|
-
const
|
|
3268
|
-
if (
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
);
|
|
3278
|
-
return;
|
|
3602
|
+
async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
|
|
3603
|
+
const { size } = await stat4(path);
|
|
3604
|
+
if (size <= maxBytes) return readFile3(path, "utf8");
|
|
3605
|
+
const handle = await open2(path, "r");
|
|
3606
|
+
try {
|
|
3607
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
3608
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
|
|
3609
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3610
|
+
const firstNewline = text.indexOf("\n");
|
|
3611
|
+
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
3612
|
+
} finally {
|
|
3613
|
+
await handle.close();
|
|
3279
3614
|
}
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3615
|
+
}
|
|
3616
|
+
function parseMinEdits(raw) {
|
|
3617
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
|
|
3618
|
+
return Number(raw);
|
|
3619
|
+
}
|
|
3620
|
+
var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
|
|
3621
|
+
function resolveCliEntry() {
|
|
3622
|
+
return fileURLToPath(import.meta.url);
|
|
3623
|
+
}
|
|
3624
|
+
function normalizeInstallOptions(raw) {
|
|
3625
|
+
const out = {};
|
|
3626
|
+
if (raw.block === true) out.block = true;
|
|
3627
|
+
if (raw.requireReview === true) out.requireReview = true;
|
|
3628
|
+
if (raw.settings !== void 0) out.settings = raw.settings;
|
|
3629
|
+
if (raw.hooks !== void 0) out.hooks = raw.hooks;
|
|
3630
|
+
if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
|
|
3631
|
+
if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
|
|
3632
|
+
if (raw.dryRun === true) out.dryRun = true;
|
|
3633
|
+
if (raw.verbose === true) out.verbose = true;
|
|
3634
|
+
if (raw.minEdits !== void 0) {
|
|
3635
|
+
const parsed = parseMinEdits(raw.minEdits);
|
|
3636
|
+
if (parsed === void 0) {
|
|
3637
|
+
throw new Error("--min-edits must be a non-negative integer.");
|
|
3638
|
+
}
|
|
3639
|
+
out.minEdits = parsed;
|
|
3284
3640
|
}
|
|
3641
|
+
return out;
|
|
3285
3642
|
}
|
|
3286
|
-
async function
|
|
3643
|
+
async function readSettings(path) {
|
|
3644
|
+
let raw;
|
|
3287
3645
|
try {
|
|
3288
|
-
await
|
|
3646
|
+
raw = await readFile3(path, "utf8");
|
|
3289
3647
|
} catch (error) {
|
|
3290
|
-
if (
|
|
3291
|
-
|
|
3648
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3649
|
+
return { raw: null, parsed: void 0 };
|
|
3292
3650
|
}
|
|
3293
3651
|
throw error;
|
|
3294
3652
|
}
|
|
3653
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3654
|
+
try {
|
|
3655
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
throw new Error(
|
|
3658
|
+
"The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
|
|
3659
|
+
{
|
|
3660
|
+
cause: error
|
|
3661
|
+
}
|
|
3662
|
+
);
|
|
3663
|
+
}
|
|
3295
3664
|
}
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
|
-
// src/lib/hosts-config.ts
|
|
3307
|
-
import { homedir as homedir6 } from "os";
|
|
3308
|
-
import { isAbsolute as isAbsolute2, join as join8, resolve as resolve6 } from "path";
|
|
3309
|
-
import { readYamlFile as readYamlFile4 } from "@basou/core";
|
|
3310
|
-
var DEFAULT_HOSTS_CONFIG_PATH = join8(homedir6(), ".basou", "hosts.yaml");
|
|
3311
|
-
function expandTilde2(p) {
|
|
3312
|
-
if (p === "~") return homedir6();
|
|
3313
|
-
if (p.startsWith("~/")) return join8(homedir6(), p.slice(2));
|
|
3314
|
-
return p;
|
|
3665
|
+
async function backupSettingsOnce(path, raw) {
|
|
3666
|
+
if (raw === null) return;
|
|
3667
|
+
const bak = `${path}.basou-bak`;
|
|
3668
|
+
try {
|
|
3669
|
+
await stat4(bak);
|
|
3670
|
+
return;
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
if (!(error instanceof Error && error.code === "ENOENT")) throw error;
|
|
3673
|
+
}
|
|
3674
|
+
await writeFileDurable(bak, raw);
|
|
3315
3675
|
}
|
|
3316
|
-
function
|
|
3317
|
-
|
|
3676
|
+
async function runHookInstall(options, ctx = {}) {
|
|
3677
|
+
try {
|
|
3678
|
+
await doRunHookInstall(normalizeInstallOptions(options), ctx);
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3681
|
+
process.exitCode = 1;
|
|
3682
|
+
}
|
|
3318
3683
|
}
|
|
3319
|
-
async function
|
|
3320
|
-
|
|
3684
|
+
async function doRunHookInstall(options, ctx = {}) {
|
|
3685
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3686
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3687
|
+
const command = buildStopHookCommand({
|
|
3688
|
+
cliEntry,
|
|
3689
|
+
...options.block === true ? { block: true } : {},
|
|
3690
|
+
...options.requireReview === true ? { requireReview: true } : {},
|
|
3691
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3692
|
+
});
|
|
3693
|
+
const mode = describeHookMode({
|
|
3694
|
+
block: options.block === true,
|
|
3695
|
+
review: options.requireReview === true
|
|
3696
|
+
});
|
|
3697
|
+
await assertNotSymlink(settingsPath);
|
|
3698
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3699
|
+
const { settings, action } = upsertStopHook(parsed, command);
|
|
3700
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3701
|
+
`;
|
|
3702
|
+
if (raw !== null && newBody === raw) {
|
|
3703
|
+
console.log(`The basou Stop hook is already registered (${mode}); no change.`);
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3706
|
+
if (options.dryRun === true) {
|
|
3707
|
+
console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
const recheck = await readSettings(settingsPath);
|
|
3711
|
+
if (recheck.raw !== raw) {
|
|
3712
|
+
throw new Error(
|
|
3713
|
+
"The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
|
|
3714
|
+
);
|
|
3715
|
+
}
|
|
3716
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3717
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3718
|
+
console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
|
|
3719
|
+
}
|
|
3720
|
+
async function runHookUninstall(options) {
|
|
3321
3721
|
try {
|
|
3322
|
-
|
|
3722
|
+
await doRunHookUninstall(normalizeInstallOptions(options));
|
|
3323
3723
|
} catch (error) {
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
}
|
|
3327
|
-
if (error instanceof Error && error.message === "Failed to parse YAML content") {
|
|
3328
|
-
throw new Error("~/.basou/hosts.yaml is not valid YAML.");
|
|
3329
|
-
}
|
|
3330
|
-
throw error;
|
|
3724
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3725
|
+
process.exitCode = 1;
|
|
3331
3726
|
}
|
|
3332
|
-
|
|
3333
|
-
|
|
3727
|
+
}
|
|
3728
|
+
async function doRunHookUninstall(options) {
|
|
3729
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3730
|
+
await assertNotSymlink(settingsPath);
|
|
3731
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3732
|
+
if (raw === null) {
|
|
3733
|
+
console.log("No settings.json; nothing to remove.");
|
|
3734
|
+
return;
|
|
3334
3735
|
}
|
|
3335
|
-
const
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
|
|
3340
|
-
throw new Error("Each host needs a non-empty string 'label'.");
|
|
3341
|
-
}
|
|
3342
|
-
const label = entry.label.trim();
|
|
3343
|
-
if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
|
|
3344
|
-
throw new Error("Each host needs a non-empty string 'path'.");
|
|
3345
|
-
}
|
|
3346
|
-
const expanded = expandTilde2(entry.path.trim());
|
|
3347
|
-
if (!isAbsolute2(expanded)) {
|
|
3348
|
-
throw new Error("Host paths must be absolute (or start with '~').");
|
|
3349
|
-
}
|
|
3350
|
-
const abs = resolve6(expanded);
|
|
3351
|
-
if (seenPaths.has(abs)) continue;
|
|
3352
|
-
if (seenLabels.has(label)) {
|
|
3353
|
-
throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
|
|
3354
|
-
}
|
|
3355
|
-
seenPaths.add(abs);
|
|
3356
|
-
seenLabels.add(label);
|
|
3357
|
-
result.push({ label, path: abs });
|
|
3736
|
+
const { settings, action } = removeStopHook(parsed);
|
|
3737
|
+
if (action === "absent") {
|
|
3738
|
+
console.log("No basou Stop hook found; nothing removed.");
|
|
3739
|
+
return;
|
|
3358
3740
|
}
|
|
3359
|
-
|
|
3741
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3742
|
+
`;
|
|
3743
|
+
if (options.dryRun === true) {
|
|
3744
|
+
console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
const recheck = await readSettings(settingsPath);
|
|
3748
|
+
if (recheck.raw !== raw) {
|
|
3749
|
+
throw new Error(
|
|
3750
|
+
"The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3754
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3755
|
+
console.log("Removed the basou Stop hook from settings.json.");
|
|
3360
3756
|
}
|
|
3361
|
-
|
|
3362
|
-
// src/lib/provenance-actions.ts
|
|
3363
|
-
import {
|
|
3364
|
-
readMarkdownFile as readMarkdownFile3,
|
|
3365
|
-
renderDecisions as renderDecisions2,
|
|
3366
|
-
renderHandoff as renderHandoff2,
|
|
3367
|
-
renderOrientation,
|
|
3368
|
-
renderWithMarkers as renderWithMarkers3,
|
|
3369
|
-
writeMarkdownFile as writeMarkdownFile3
|
|
3370
|
-
} from "@basou/core";
|
|
3371
|
-
async function captureImportJson(fn) {
|
|
3372
|
-
const stdout = [];
|
|
3373
|
-
const originalLog = console.log;
|
|
3374
|
-
const originalError = console.error;
|
|
3375
|
-
console.log = ((...args) => {
|
|
3376
|
-
stdout.push(args.map((a) => String(a)).join(" "));
|
|
3377
|
-
});
|
|
3378
|
-
console.error = (() => {
|
|
3379
|
-
});
|
|
3757
|
+
async function runHookStatus(options) {
|
|
3380
3758
|
try {
|
|
3381
|
-
await
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
|
|
3759
|
+
await doRunHookStatus(normalizeInstallOptions(options));
|
|
3760
|
+
} catch (error) {
|
|
3761
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3762
|
+
process.exitCode = 1;
|
|
3385
3763
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
} catch {
|
|
3395
|
-
}
|
|
3764
|
+
}
|
|
3765
|
+
async function doRunHookStatus(options) {
|
|
3766
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3767
|
+
const { parsed } = await readSettings(settingsPath);
|
|
3768
|
+
const command = findBasouStopHookCommand(parsed);
|
|
3769
|
+
if (command === null) {
|
|
3770
|
+
console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
|
|
3771
|
+
return;
|
|
3396
3772
|
}
|
|
3397
|
-
|
|
3773
|
+
const mode = describeHookMode({
|
|
3774
|
+
block: / --block\b/.test(command),
|
|
3775
|
+
review: / --require-review\b/.test(command)
|
|
3776
|
+
});
|
|
3777
|
+
console.log(`basou Stop hook: registered, ${mode}.`);
|
|
3398
3778
|
}
|
|
3399
|
-
function
|
|
3400
|
-
|
|
3779
|
+
function describeHookMode(tiers) {
|
|
3780
|
+
const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
|
|
3781
|
+
const gates = tiers.review ? "capture + review" : "capture";
|
|
3782
|
+
return `${enforcement}, ${gates}`;
|
|
3401
3783
|
}
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3784
|
+
var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
|
|
3785
|
+
var LEFTOVER_FACE_NOTE = (label) => `${label} still carries an orientation block rendered by an earlier basou (0.39 or before); every Codex session on this machine reads it. \`basou channel clear codex\` removes it.`;
|
|
3786
|
+
async function faceHasLeftoverOrientationBlock(facePath) {
|
|
3787
|
+
try {
|
|
3788
|
+
const existing = await readMarkdownFile5(facePath);
|
|
3789
|
+
if (existing === null) return false;
|
|
3790
|
+
const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
|
|
3791
|
+
return section.kind !== "no_markers";
|
|
3792
|
+
} catch {
|
|
3793
|
+
return false;
|
|
3794
|
+
}
|
|
3405
3795
|
}
|
|
3406
|
-
|
|
3796
|
+
var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
|
|
3797
|
+
var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
|
|
3798
|
+
async function readHooksFile(path) {
|
|
3799
|
+
let raw;
|
|
3407
3800
|
try {
|
|
3408
|
-
|
|
3409
|
-
return {
|
|
3410
|
-
adapter,
|
|
3411
|
-
status: "ran",
|
|
3412
|
-
importedCount: readCount(json.imported_count),
|
|
3413
|
-
replacedCount: readCount(json.replaced_count),
|
|
3414
|
-
reimportedCount: readCount(json.reimported_count),
|
|
3415
|
-
skippedNoAction: readCount(json.skipped_no_action),
|
|
3416
|
-
skippedAlreadyImported: readCount(json.skipped_already_imported),
|
|
3417
|
-
skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
|
|
3418
|
-
skippedDecreased: readCount(json.skipped_decreased),
|
|
3419
|
-
skippedDuplicate: readCount(json.skipped_duplicate),
|
|
3420
|
-
skippedUnverifiable: readCount(json.skipped_unverifiable),
|
|
3421
|
-
eventTotal: readCount(json.event_total),
|
|
3422
|
-
dryRun: json.dry_run === true
|
|
3423
|
-
};
|
|
3801
|
+
raw = await readFile3(path, "utf8");
|
|
3424
3802
|
} catch (error) {
|
|
3425
|
-
if (
|
|
3426
|
-
return {
|
|
3803
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3804
|
+
return { raw: null, parsed: void 0 };
|
|
3427
3805
|
}
|
|
3428
3806
|
throw error;
|
|
3429
3807
|
}
|
|
3808
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3809
|
+
try {
|
|
3810
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3811
|
+
} catch (error) {
|
|
3812
|
+
throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
|
|
3813
|
+
cause: error
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3430
3816
|
}
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
}
|
|
3439
|
-
}
|
|
3440
|
-
function importClaudeCode(options, ctx) {
|
|
3441
|
-
return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
|
|
3442
|
-
}
|
|
3443
|
-
function importCodex(options, ctx) {
|
|
3444
|
-
return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
|
|
3445
|
-
}
|
|
3446
|
-
async function regenerateHandoff(paths, nowIso, callbacks) {
|
|
3447
|
-
const result = await renderHandoff2({ paths, nowIso, ...callbacks });
|
|
3448
|
-
const existing = await readMarkdownFile3(paths.files.handoff);
|
|
3449
|
-
await writeMarkdownFile3(
|
|
3450
|
-
paths.files.handoff,
|
|
3451
|
-
renderWithMarkers3(existing, result.body, "handoff.md")
|
|
3452
|
-
);
|
|
3453
|
-
return {
|
|
3454
|
-
sessionCount: result.sessionCount,
|
|
3455
|
-
taskCount: result.taskCount,
|
|
3456
|
-
decisionCount: result.decisionCount,
|
|
3457
|
-
pendingApprovalsCount: result.pendingApprovalsCount
|
|
3458
|
-
};
|
|
3459
|
-
}
|
|
3460
|
-
async function regenerateDecisions(paths, nowIso, callbacks) {
|
|
3461
|
-
const result = await renderDecisions2({ paths, nowIso, ...callbacks });
|
|
3462
|
-
const existing = await readMarkdownFile3(paths.files.decisions);
|
|
3463
|
-
await writeMarkdownFile3(
|
|
3464
|
-
paths.files.decisions,
|
|
3465
|
-
renderWithMarkers3(existing, result.body, "decisions.md")
|
|
3466
|
-
);
|
|
3467
|
-
return { decisionCount: result.decisionCount };
|
|
3468
|
-
}
|
|
3469
|
-
async function regenerateOrientation(paths, nowIso, callbacks) {
|
|
3470
|
-
const result = await renderOrientation({ paths, nowIso, ...callbacks });
|
|
3471
|
-
await writeMarkdownFile3(paths.files.orientation, `${result.body}
|
|
3472
|
-
`);
|
|
3473
|
-
return {
|
|
3474
|
-
sessionCount: result.sessionCount,
|
|
3475
|
-
inFlightTaskCount: result.inFlightTaskCount,
|
|
3476
|
-
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3477
|
-
suspectCount: result.suspectCount
|
|
3478
|
-
};
|
|
3817
|
+
var CODEX_TRUST_NOTE = "Codex reviews a new or changed hook once before running it: start `codex` in a terminal and trust it when asked (desktop app: Settings -> Hooks). Until then the hook is skipped silently.";
|
|
3818
|
+
async function runCodexHookInstall(options, ctx = {}) {
|
|
3819
|
+
try {
|
|
3820
|
+
await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
|
|
3821
|
+
} catch (error) {
|
|
3822
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3823
|
+
process.exitCode = 1;
|
|
3824
|
+
}
|
|
3479
3825
|
}
|
|
3480
|
-
async function
|
|
3481
|
-
const
|
|
3482
|
-
const
|
|
3483
|
-
const
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
dryRun
|
|
3494
|
-
};
|
|
3826
|
+
async function doRunCodexHookInstall(options, ctx = {}) {
|
|
3827
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3828
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3829
|
+
const command = buildSessionStartHookCommand({ cliEntry });
|
|
3830
|
+
await assertNotSymlink(hooksPath);
|
|
3831
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
3832
|
+
const { hooksFile, action } = upsertSessionStartHook(parsed, command);
|
|
3833
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
3834
|
+
`;
|
|
3835
|
+
if (action === "unchanged" || raw !== null && newBody === raw) {
|
|
3836
|
+
console.log("The basou Codex SessionStart hook is already registered; no change.");
|
|
3837
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
3838
|
+
return;
|
|
3495
3839
|
}
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3840
|
+
if (options.dryRun === true) {
|
|
3841
|
+
console.log(
|
|
3842
|
+
`[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
3843
|
+
);
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
const recheck = await readHooksFile(hooksPath);
|
|
3847
|
+
if (recheck.raw !== raw) {
|
|
3848
|
+
throw new Error(
|
|
3849
|
+
"The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
3853
|
+
await writeFileDurable(hooksPath, newBody);
|
|
3854
|
+
console.log(
|
|
3855
|
+
`${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
3509
3856
|
);
|
|
3510
|
-
|
|
3511
|
-
claudeCode,
|
|
3512
|
-
codex,
|
|
3513
|
-
handoff: { status: "generated", ...handoffCounts },
|
|
3514
|
-
decisions: { status: "generated", ...decisionCounts },
|
|
3515
|
-
orientation: { status: "generated", ...orientationCounts },
|
|
3516
|
-
dryRun
|
|
3517
|
-
};
|
|
3857
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
3518
3858
|
}
|
|
3519
|
-
function
|
|
3520
|
-
|
|
3859
|
+
async function reportCodexHookState(hooksPath, hooksFile, options) {
|
|
3860
|
+
const location = findBasouSessionStartHook(hooksFile);
|
|
3861
|
+
if (location !== null) {
|
|
3862
|
+
const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
|
|
3863
|
+
console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
|
|
3864
|
+
if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
|
|
3865
|
+
}
|
|
3866
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
3867
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
3868
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
3869
|
+
}
|
|
3521
3870
|
}
|
|
3522
|
-
function
|
|
3523
|
-
|
|
3871
|
+
async function runCodexHookUninstall(options) {
|
|
3872
|
+
try {
|
|
3873
|
+
await doRunCodexHookUninstall(normalizeInstallOptions(options));
|
|
3874
|
+
} catch (error) {
|
|
3875
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3876
|
+
process.exitCode = 1;
|
|
3877
|
+
}
|
|
3524
3878
|
}
|
|
3525
|
-
function
|
|
3526
|
-
|
|
3879
|
+
async function doRunCodexHookUninstall(options) {
|
|
3880
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3881
|
+
await assertNotSymlink(hooksPath);
|
|
3882
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
3883
|
+
if (raw === null) {
|
|
3884
|
+
console.log("No hooks.json; nothing to remove.");
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
const { hooksFile, action } = removeSessionStartHook(parsed);
|
|
3888
|
+
if (action === "absent") {
|
|
3889
|
+
console.log("No basou Codex SessionStart hook found; nothing removed.");
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
3893
|
+
`;
|
|
3894
|
+
if (options.dryRun === true) {
|
|
3895
|
+
console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
const recheck = await readHooksFile(hooksPath);
|
|
3899
|
+
if (recheck.raw !== raw) {
|
|
3900
|
+
throw new Error(
|
|
3901
|
+
"The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
|
|
3902
|
+
);
|
|
3903
|
+
}
|
|
3904
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
3905
|
+
await writeFileDurable(hooksPath, newBody);
|
|
3906
|
+
console.log("Removed the basou Codex SessionStart hook from hooks.json.");
|
|
3527
3907
|
}
|
|
3528
|
-
async function
|
|
3908
|
+
async function runCodexHookStatus(options) {
|
|
3529
3909
|
try {
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3910
|
+
await doRunCodexHookStatus(normalizeInstallOptions(options));
|
|
3911
|
+
} catch (error) {
|
|
3912
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3913
|
+
process.exitCode = 1;
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
async function doRunCodexHookStatus(options) {
|
|
3917
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3918
|
+
const { parsed } = await readHooksFile(hooksPath);
|
|
3919
|
+
const location = findBasouSessionStartHook(parsed);
|
|
3920
|
+
if (location === null) {
|
|
3921
|
+
console.log(
|
|
3922
|
+
"basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
|
|
3923
|
+
);
|
|
3924
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
3925
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
3926
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
3927
|
+
}
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3930
|
+
const matcher = location.matcher ?? "(every source)";
|
|
3931
|
+
console.log(
|
|
3932
|
+
`basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
|
|
3933
|
+
);
|
|
3934
|
+
await reportCodexHookState(hooksPath, parsed, options);
|
|
3935
|
+
}
|
|
3936
|
+
async function codexHookTrustFor(hooksPath, location, configPath) {
|
|
3937
|
+
const fields = commandHandlerFields(location.handler);
|
|
3938
|
+
if (fields === null)
|
|
3939
|
+
return { status: "unknown", detail: "the installed handler is not a command hook" };
|
|
3940
|
+
let configToml;
|
|
3941
|
+
try {
|
|
3942
|
+
configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
|
|
3943
|
+
} catch (error) {
|
|
3944
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3945
|
+
return { status: "untrusted" };
|
|
3946
|
+
}
|
|
3536
3947
|
return {
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
|
|
3948
|
+
status: "unknown",
|
|
3949
|
+
detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
|
|
3540
3950
|
};
|
|
3541
|
-
} catch {
|
|
3542
|
-
return null;
|
|
3543
3951
|
}
|
|
3952
|
+
const key = codexHookStateKey(
|
|
3953
|
+
hooksPath,
|
|
3954
|
+
"session_start",
|
|
3955
|
+
location.groupIndex,
|
|
3956
|
+
location.handlerIndex
|
|
3957
|
+
);
|
|
3958
|
+
const state = readCodexHookState(configToml, key);
|
|
3959
|
+
const current = computeCodexHookIdentityHash({
|
|
3960
|
+
eventKey: "session_start",
|
|
3961
|
+
matcher: location.matcher,
|
|
3962
|
+
handler: fields
|
|
3963
|
+
});
|
|
3964
|
+
return judgeCodexHookTrust(state, current);
|
|
3544
3965
|
}
|
|
3545
3966
|
|
|
3546
|
-
// src/commands/
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3967
|
+
// src/commands/init.ts
|
|
3968
|
+
import { basename as basename4, relative, resolve as resolve6 } from "path";
|
|
3969
|
+
import {
|
|
3970
|
+
appendBasouGitignore,
|
|
3971
|
+
createManifest,
|
|
3972
|
+
ensureBasouDirectory,
|
|
3973
|
+
resolveRepositoryRoot as resolveRepositoryRoot7,
|
|
3974
|
+
writeManifest
|
|
3975
|
+
} from "@basou/core";
|
|
3976
|
+
function collectValue(value, previous) {
|
|
3977
|
+
return [...previous, value];
|
|
3978
|
+
}
|
|
3979
|
+
function registerInitCommand(program2) {
|
|
3980
|
+
program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
|
|
3981
|
+
"--repo-url <url>",
|
|
3982
|
+
"Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
|
|
3983
|
+
).option(
|
|
3984
|
+
"--source-root <path>",
|
|
3985
|
+
"Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
|
|
3986
|
+
collectValue,
|
|
3987
|
+
[]
|
|
3988
|
+
).option(
|
|
3989
|
+
"--local-only",
|
|
3990
|
+
"Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
|
|
3991
|
+
).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
3992
|
+
await runInit(options);
|
|
3553
3993
|
});
|
|
3554
3994
|
}
|
|
3555
|
-
async function
|
|
3995
|
+
async function runInit(options, ctx = {}) {
|
|
3556
3996
|
try {
|
|
3557
|
-
await
|
|
3997
|
+
await doRunInit(options, ctx);
|
|
3558
3998
|
} catch (error) {
|
|
3559
3999
|
renderCliError(error, { verbose: isVerbose(options) });
|
|
3560
4000
|
process.exitCode = 1;
|
|
3561
4001
|
}
|
|
3562
4002
|
}
|
|
3563
|
-
async function
|
|
4003
|
+
async function doRunInit(options, ctx) {
|
|
3564
4004
|
const cwd = ctx.cwd ?? process.cwd();
|
|
3565
|
-
const repositoryRoot = await
|
|
3566
|
-
const
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
|
|
3572
|
-
if (options.refresh === true) {
|
|
3573
|
-
await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
|
|
4005
|
+
const repositoryRoot = await resolveRepositoryRootForInit(cwd);
|
|
4006
|
+
const workspaceName = options.name ?? basename4(repositoryRoot);
|
|
4007
|
+
if (options.repoUrl !== void 0) {
|
|
4008
|
+
console.error(
|
|
4009
|
+
"Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
|
|
4010
|
+
);
|
|
3574
4011
|
}
|
|
3575
|
-
const
|
|
3576
|
-
|
|
4012
|
+
const sourceRoots = (options.sourceRoot ?? []).map((p) => {
|
|
4013
|
+
const rel = relative(repositoryRoot, resolve6(cwd, p));
|
|
4014
|
+
return rel === "" ? "." : rel;
|
|
4015
|
+
});
|
|
4016
|
+
const paths = await ensureBasouDirectory(repositoryRoot);
|
|
4017
|
+
const manifest = createManifest({
|
|
4018
|
+
workspaceName,
|
|
4019
|
+
...options.projectName !== void 0 ? { projectName: options.projectName } : {},
|
|
4020
|
+
...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
|
|
4021
|
+
...sourceRoots.length > 0 ? { sourceRoots } : {}
|
|
4022
|
+
});
|
|
4023
|
+
await writeManifest(paths, manifest, { force: options.force === true });
|
|
3577
4024
|
try {
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
4025
|
+
await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
|
|
4026
|
+
} catch (error) {
|
|
4027
|
+
renderGitignoreWarning(error, isVerbose(options));
|
|
4028
|
+
}
|
|
4029
|
+
console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
|
|
4030
|
+
}
|
|
4031
|
+
function renderGitignoreWarning(error, verbose) {
|
|
4032
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
4033
|
+
console.error(
|
|
4034
|
+
`Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
|
|
4035
|
+
);
|
|
4036
|
+
if (verbose && error instanceof Error) {
|
|
4037
|
+
const label = extractCauseLabel(error);
|
|
4038
|
+
if (label !== void 0) console.error(`Caused by: ${label}`);
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
async function resolveRepositoryRootForInit(cwd) {
|
|
4042
|
+
try {
|
|
4043
|
+
return await resolveRepositoryRoot7(cwd);
|
|
4044
|
+
} catch (error) {
|
|
4045
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
4046
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
|
|
4047
|
+
cause: error
|
|
4048
|
+
});
|
|
3581
4049
|
}
|
|
4050
|
+
throw error;
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
// src/commands/note.ts
|
|
4055
|
+
import {
|
|
4056
|
+
acquireLock as acquireLock4,
|
|
4057
|
+
appendEventToExistingSession as appendEventToExistingSession2,
|
|
4058
|
+
assertBasouRootSafe as assertBasouRootSafe8,
|
|
4059
|
+
basouPaths as basouPaths9,
|
|
4060
|
+
createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
|
|
4061
|
+
findErrorCode as findErrorCode7,
|
|
4062
|
+
readManifest as readManifest5,
|
|
4063
|
+
resolveSessionId as resolveSessionId2
|
|
4064
|
+
} from "@basou/core";
|
|
4065
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
4066
|
+
var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
|
|
4067
|
+
"list",
|
|
4068
|
+
"ls",
|
|
4069
|
+
"show",
|
|
4070
|
+
"get",
|
|
4071
|
+
"add",
|
|
4072
|
+
"new",
|
|
4073
|
+
"edit",
|
|
4074
|
+
"rm",
|
|
4075
|
+
"remove",
|
|
4076
|
+
"delete",
|
|
4077
|
+
"help"
|
|
4078
|
+
]);
|
|
4079
|
+
var LABEL_BODY_MAX = 80;
|
|
4080
|
+
var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
|
|
4081
|
+
function registerNoteCommand(program2) {
|
|
4082
|
+
program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
|
|
4083
|
+
"--session <session_id>",
|
|
4084
|
+
"Attach to an existing session; otherwise an ad-hoc session is created"
|
|
4085
|
+
).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
|
|
4086
|
+
await runNote(body, options);
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
async function runNote(body, options, ctx = {}) {
|
|
4090
|
+
try {
|
|
4091
|
+
await doRunNote(body, options, ctx);
|
|
3582
4092
|
} catch (error) {
|
|
3583
|
-
|
|
3584
|
-
|
|
4093
|
+
renderCliError(error, {
|
|
4094
|
+
verbose: isVerbose(options),
|
|
4095
|
+
classifiers: [failedToFinalizeClassifier]
|
|
4096
|
+
});
|
|
4097
|
+
process.exitCode = 1;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
async function doRunNote(body, options, ctx) {
|
|
4101
|
+
if (body.trim().length === 0) {
|
|
4102
|
+
throw new Error("Note body must not be empty");
|
|
4103
|
+
}
|
|
4104
|
+
const reserved = body.trim().toLowerCase();
|
|
4105
|
+
if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
|
|
4106
|
+
throw new Error(
|
|
4107
|
+
`'basou note' records a free-text note and has no '${body.trim()}' subcommand. To record a note, pass its full text (e.g. \`basou note "<your note>"\`).`
|
|
3585
4108
|
);
|
|
3586
4109
|
}
|
|
3587
|
-
const
|
|
4110
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
4111
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
|
|
4112
|
+
const paths = basouPaths9(repositoryRoot);
|
|
4113
|
+
await assertWorkspaceInitialized7(paths.root);
|
|
4114
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
4115
|
+
const occurredAt = now.toISOString();
|
|
4116
|
+
if (options.session !== void 0) {
|
|
4117
|
+
const sessionId = await resolveSessionId2(paths, options.session);
|
|
4118
|
+
const sesId = sessionId;
|
|
4119
|
+
const sessionLock = await acquireLock4(paths, "session", sesId);
|
|
4120
|
+
let result;
|
|
4121
|
+
try {
|
|
4122
|
+
result = await appendEventToExistingSession2({
|
|
4123
|
+
paths,
|
|
4124
|
+
sessionId: sesId,
|
|
4125
|
+
eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
|
|
4126
|
+
});
|
|
4127
|
+
} finally {
|
|
4128
|
+
await sessionLock.release();
|
|
4129
|
+
}
|
|
4130
|
+
printNoteResult(options, {
|
|
4131
|
+
mode: "attached",
|
|
4132
|
+
sessionId,
|
|
4133
|
+
eventId: result.eventId,
|
|
4134
|
+
sessionStatus: result.sessionStatus,
|
|
4135
|
+
body
|
|
4136
|
+
});
|
|
4137
|
+
return;
|
|
4138
|
+
}
|
|
4139
|
+
const manifest = await readManifest5(paths);
|
|
4140
|
+
const adHoc = await createAdHocSessionWithEvent2({
|
|
3588
4141
|
paths,
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
4142
|
+
manifest,
|
|
4143
|
+
label: buildAdHocLabel2(body),
|
|
4144
|
+
occurredAt,
|
|
4145
|
+
sessionSource: "human",
|
|
4146
|
+
workingDirectory: repositoryRoot,
|
|
4147
|
+
invocation: {
|
|
4148
|
+
command: "basou note",
|
|
4149
|
+
args: [body]
|
|
4150
|
+
},
|
|
4151
|
+
targetEventBuilders: [
|
|
4152
|
+
(sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
|
|
4153
|
+
]
|
|
3599
4154
|
});
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
4155
|
+
printNoteResult(options, {
|
|
4156
|
+
mode: "ad-hoc",
|
|
4157
|
+
sessionId: adHoc.sessionId,
|
|
4158
|
+
eventId: adHoc.targetEventIds[0],
|
|
4159
|
+
sessionStatus: "completed",
|
|
4160
|
+
body
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
function buildNoteEvent(input) {
|
|
4164
|
+
return {
|
|
4165
|
+
schema_version: "0.1.0",
|
|
4166
|
+
id: input.eventId,
|
|
4167
|
+
session_id: input.sessionId,
|
|
4168
|
+
occurred_at: input.occurredAt,
|
|
4169
|
+
source: "local-cli",
|
|
4170
|
+
type: "note_added",
|
|
4171
|
+
body: input.body,
|
|
4172
|
+
// `basou note` is the resume-hint command; mark it so orientation surfaces
|
|
4173
|
+
// it as the next step and a plain `basou session note` annotation does not.
|
|
4174
|
+
kind: "next_step"
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
function buildAdHocLabel2(body) {
|
|
4178
|
+
const oneLine2 = body.replace(/\s+/g, " ").trim();
|
|
4179
|
+
const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
|
|
4180
|
+
return `Ad-hoc note: ${truncated}`;
|
|
4181
|
+
}
|
|
4182
|
+
function parseBody(raw) {
|
|
4183
|
+
if (raw.trim().length === 0) {
|
|
4184
|
+
throw new InvalidArgumentError2("Note body must not be empty");
|
|
4185
|
+
}
|
|
4186
|
+
return raw;
|
|
4187
|
+
}
|
|
4188
|
+
function printNoteResult(options, result) {
|
|
4189
|
+
const sid = shortSessionId(result.sessionId);
|
|
4190
|
+
if (options.json === true) {
|
|
3603
4191
|
console.log(
|
|
3604
|
-
|
|
4192
|
+
JSON.stringify({
|
|
4193
|
+
event_id: result.eventId,
|
|
4194
|
+
session_id: result.sessionId,
|
|
4195
|
+
session_status: result.sessionStatus,
|
|
4196
|
+
mode: result.mode,
|
|
4197
|
+
body: result.body
|
|
4198
|
+
})
|
|
3605
4199
|
);
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
if (result.mode === "ad-hoc") {
|
|
4203
|
+
console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
|
|
3606
4204
|
} else {
|
|
3607
|
-
console.log(result.
|
|
4205
|
+
console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
|
|
3608
4206
|
}
|
|
3609
4207
|
}
|
|
3610
4208
|
async function assertWorkspaceInitialized7(basouRoot) {
|
|
@@ -3620,13 +4218,13 @@ async function assertWorkspaceInitialized7(basouRoot) {
|
|
|
3620
4218
|
|
|
3621
4219
|
// src/commands/portfolio.ts
|
|
3622
4220
|
import { existsSync, statSync } from "fs";
|
|
3623
|
-
import { join as
|
|
4221
|
+
import { join as join10 } from "path";
|
|
3624
4222
|
function registerPortfolioCommand(program2) {
|
|
3625
4223
|
program2.command("portfolio").description(
|
|
3626
4224
|
"List the workspaces you orient across (read-only): every planning master registered in ~/.basou/portfolio.yaml, with its path and whether it exists / is initialized. The headless text/JSON counterpart to the `basou view --portfolio` GUI \u2014 for discovering where a sibling project lives without opening a browser"
|
|
3627
4225
|
).argument("[action]", "optional literal `list` (the only, and default, action)").option("--json", "Output the result as JSON").option(
|
|
3628
4226
|
"--check",
|
|
3629
|
-
"moved: the redundancy/footprint
|
|
4227
|
+
"moved: the redundancy/footprint preflight and the capture-coverage report are `basou view --portfolio --check` (this prints that pointer and exits)"
|
|
3630
4228
|
).option("-v, --verbose", "Show error causes").action(async (action, opts) => {
|
|
3631
4229
|
await runPortfolioCommand(action, opts);
|
|
3632
4230
|
});
|
|
@@ -3641,7 +4239,7 @@ function isDirectory(path) {
|
|
|
3641
4239
|
async function runPortfolioCommand(action, options, ctx = {}) {
|
|
3642
4240
|
if (options.check === true) {
|
|
3643
4241
|
console.error(
|
|
3644
|
-
"`basou portfolio` is a read-only listing; it has no
|
|
4242
|
+
"`basou portfolio` is a read-only listing; it has no preflight.\nRun `basou view --portfolio --check` for the redundancy/footprint check and the capture-coverage report."
|
|
3645
4243
|
);
|
|
3646
4244
|
process.exitCode = 1;
|
|
3647
4245
|
return;
|
|
@@ -3666,7 +4264,7 @@ async function runPortfolioList(options, ctx = {}) {
|
|
|
3666
4264
|
async function doRunPortfolioList(options, ctx) {
|
|
3667
4265
|
const configPath = ctx.configPath ?? DEFAULT_PORTFOLIO_CONFIG_PATH;
|
|
3668
4266
|
const pathExists2 = ctx.pathExists ?? ((p) => existsSync(p));
|
|
3669
|
-
const isInitialized = ctx.isInitialized ?? ((p) => isDirectory(
|
|
4267
|
+
const isInitialized = ctx.isInitialized ?? ((p) => isDirectory(join10(p, ".basou")));
|
|
3670
4268
|
const workspaces = await loadPortfolioConfig(configPath);
|
|
3671
4269
|
const result = {
|
|
3672
4270
|
configPath,
|
|
@@ -3706,7 +4304,7 @@ function renderPortfolioList(result) {
|
|
|
3706
4304
|
}
|
|
3707
4305
|
lines.push("");
|
|
3708
4306
|
lines.push(
|
|
3709
|
-
"Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint
|
|
4307
|
+
"Note: read-only listing of ~/.basou/portfolio.yaml. Run `basou view --portfolio` for the cross-workspace GUI, or `basou view --portfolio --check` for the redundancy/footprint preflight and the capture-coverage report (which session logs no registered workspace imports)."
|
|
3710
4308
|
);
|
|
3711
4309
|
return lines.join("\n");
|
|
3712
4310
|
}
|
|
@@ -3731,7 +4329,7 @@ import {
|
|
|
3731
4329
|
writeFileSync,
|
|
3732
4330
|
writeSync
|
|
3733
4331
|
} from "fs";
|
|
3734
|
-
import { basename as basename5, dirname as dirname3, isAbsolute as isAbsolute3, join as
|
|
4332
|
+
import { basename as basename5, dirname as dirname3, isAbsolute as isAbsolute3, join as join11, relative as relative2, resolve as resolve7 } from "path";
|
|
3735
4333
|
import {
|
|
3736
4334
|
appendBasouGitignore as appendBasouGitignore2,
|
|
3737
4335
|
basouPaths as basouPaths10,
|
|
@@ -3742,7 +4340,7 @@ import {
|
|
|
3742
4340
|
GENERATED_START,
|
|
3743
4341
|
instructionMode,
|
|
3744
4342
|
isGitNotFound,
|
|
3745
|
-
parseMarkers,
|
|
4343
|
+
parseMarkers as parseMarkers3,
|
|
3746
4344
|
pathBasename,
|
|
3747
4345
|
planArchive,
|
|
3748
4346
|
planGitignore,
|
|
@@ -3750,9 +4348,9 @@ import {
|
|
|
3750
4348
|
planRosterAdoption,
|
|
3751
4349
|
planWorkspaceView,
|
|
3752
4350
|
readManifest as readManifest6,
|
|
3753
|
-
readMarkdownFile as
|
|
4351
|
+
readMarkdownFile as readMarkdownFile6,
|
|
3754
4352
|
reconcileSourceRoots,
|
|
3755
|
-
removeMarkerSection,
|
|
4353
|
+
removeMarkerSection as removeMarkerSection2,
|
|
3756
4354
|
renderAnchorStarter,
|
|
3757
4355
|
renderViewPresetBlock,
|
|
3758
4356
|
renderWithMarkers as renderWithMarkers4,
|
|
@@ -4113,7 +4711,7 @@ function classifySourceRoot(repositoryRoot, declaredPath) {
|
|
|
4113
4711
|
} catch {
|
|
4114
4712
|
return { path: declaredPath, kind: "unresolved" };
|
|
4115
4713
|
}
|
|
4116
|
-
return { path: declaredPath, kind: existsSync2(
|
|
4714
|
+
return { path: declaredPath, kind: existsSync2(join11(real, ".git")) ? "repo" : "non-repo" };
|
|
4117
4715
|
}
|
|
4118
4716
|
async function doRunProjectAdopt(options, ctx) {
|
|
4119
4717
|
const cwd = ctx.cwd ?? process.cwd();
|
|
@@ -4217,7 +4815,7 @@ async function gatherRepoWiring(repositoryRoot, entry) {
|
|
|
4217
4815
|
} catch {
|
|
4218
4816
|
return { ...base, reachable: false, instructionFiles: [] };
|
|
4219
4817
|
}
|
|
4220
|
-
if (!existsSync2(
|
|
4818
|
+
if (!existsSync2(join11(real, ".git"))) {
|
|
4221
4819
|
return { ...base, reachable: false, instructionFiles: [] };
|
|
4222
4820
|
}
|
|
4223
4821
|
try {
|
|
@@ -4225,7 +4823,7 @@ async function gatherRepoWiring(repositoryRoot, entry) {
|
|
|
4225
4823
|
for (const name of INSTRUCTION_FILES) {
|
|
4226
4824
|
let present = true;
|
|
4227
4825
|
try {
|
|
4228
|
-
lstatSync(
|
|
4826
|
+
lstatSync(join11(real, name));
|
|
4229
4827
|
} catch {
|
|
4230
4828
|
present = false;
|
|
4231
4829
|
}
|
|
@@ -4336,10 +4934,10 @@ function gatherRepoGitignore(repositoryRoot, entry) {
|
|
|
4336
4934
|
} catch {
|
|
4337
4935
|
return { ...base, reachable: false, currentLines: [] };
|
|
4338
4936
|
}
|
|
4339
|
-
if (!existsSync2(
|
|
4937
|
+
if (!existsSync2(join11(real, ".git"))) {
|
|
4340
4938
|
return { ...base, reachable: false, currentLines: [] };
|
|
4341
4939
|
}
|
|
4342
|
-
return { ...base, reachable: true, currentLines: readGitignoreLines(
|
|
4940
|
+
return { ...base, reachable: true, currentLines: readGitignoreLines(join11(real, ".gitignore")) };
|
|
4343
4941
|
}
|
|
4344
4942
|
function hasErrorCode(error) {
|
|
4345
4943
|
return error instanceof Error && typeof error.code === "string";
|
|
@@ -4353,7 +4951,7 @@ function readGitignoreLines(file) {
|
|
|
4353
4951
|
}
|
|
4354
4952
|
}
|
|
4355
4953
|
function applyGitignorePlan(repositoryRoot, plan) {
|
|
4356
|
-
const file =
|
|
4954
|
+
const file = join11(realpathSync(resolve7(repositoryRoot, plan.path)), ".gitignore");
|
|
4357
4955
|
let existing = "";
|
|
4358
4956
|
try {
|
|
4359
4957
|
existing = readFileSync(file, "utf8");
|
|
@@ -4493,7 +5091,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4493
5091
|
return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
|
|
4494
5092
|
}
|
|
4495
5093
|
if (real === anchorReal) {
|
|
4496
|
-
const anchorCanonical =
|
|
5094
|
+
const anchorCanonical = join11(real, CANONICAL_FILE);
|
|
4497
5095
|
const anchorState = anchorCanonicalState(anchorCanonical);
|
|
4498
5096
|
if (anchorState === "absent") {
|
|
4499
5097
|
return { ...base, isAnchor: true, reachable: true, canonicalPresent: false, files: [] };
|
|
@@ -4513,7 +5111,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4513
5111
|
anchorCanonical,
|
|
4514
5112
|
"self"
|
|
4515
5113
|
).map((spec) => {
|
|
4516
|
-
const { state, actualTarget } = inspectSymlink(
|
|
5114
|
+
const { state, actualTarget } = inspectSymlink(join11(real, spec.name), spec.target);
|
|
4517
5115
|
return {
|
|
4518
5116
|
name: spec.name,
|
|
4519
5117
|
expectedTarget: spec.target,
|
|
@@ -4530,16 +5128,16 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4530
5128
|
files: anchorFiles
|
|
4531
5129
|
};
|
|
4532
5130
|
}
|
|
4533
|
-
if (!existsSync2(
|
|
5131
|
+
if (!existsSync2(join11(real, ".git"))) {
|
|
4534
5132
|
return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
|
|
4535
5133
|
}
|
|
4536
|
-
const canonicalFile = isSelf ?
|
|
5134
|
+
const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents", basename5(real), CANONICAL_FILE);
|
|
4537
5135
|
if (!existsSync2(canonicalFile)) {
|
|
4538
5136
|
return { ...base, isAnchor: false, reachable: true, canonicalPresent: false, files: [] };
|
|
4539
5137
|
}
|
|
4540
5138
|
const files = expectedSymlinkTargets(real, canonicalFile, mode).map(
|
|
4541
5139
|
(spec) => {
|
|
4542
|
-
const { state, actualTarget } = inspectSymlink(
|
|
5140
|
+
const { state, actualTarget } = inspectSymlink(join11(real, spec.name), spec.target);
|
|
4543
5141
|
return {
|
|
4544
5142
|
name: spec.name,
|
|
4545
5143
|
expectedTarget: spec.target,
|
|
@@ -4568,7 +5166,7 @@ function applySymlinkPlan(repositoryRoot, plan) {
|
|
|
4568
5166
|
const created = [];
|
|
4569
5167
|
const failed = [];
|
|
4570
5168
|
for (const { name, target } of plan.toCreate) {
|
|
4571
|
-
const filePath =
|
|
5169
|
+
const filePath = join11(real, name);
|
|
4572
5170
|
try {
|
|
4573
5171
|
mkdirSync(dirname3(filePath), { recursive: true });
|
|
4574
5172
|
symlinkSync(target, filePath);
|
|
@@ -4602,7 +5200,7 @@ function gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir) {
|
|
|
4602
5200
|
if (!existsSync2(canonicalFile)) return { kind: "missing-canonical", viewName };
|
|
4603
5201
|
const files = expectedSymlinkTargets(viewDir, canonicalFile, "hub").map(
|
|
4604
5202
|
(spec) => {
|
|
4605
|
-
const { state, actualTarget } = inspectSymlink(
|
|
5203
|
+
const { state, actualTarget } = inspectSymlink(join11(viewDir, spec.name), spec.target);
|
|
4606
5204
|
return {
|
|
4607
5205
|
name: spec.name,
|
|
4608
5206
|
expectedTarget: spec.target,
|
|
@@ -4618,7 +5216,7 @@ function applyViewSymlinks(viewDir, files) {
|
|
|
4618
5216
|
const failed = [];
|
|
4619
5217
|
for (const f of files) {
|
|
4620
5218
|
if (f.state !== "missing") continue;
|
|
4621
|
-
const filePath =
|
|
5219
|
+
const filePath = join11(viewDir, f.name);
|
|
4622
5220
|
try {
|
|
4623
5221
|
mkdirSync(dirname3(filePath), { recursive: true });
|
|
4624
5222
|
symlinkSync(f.expectedTarget, filePath);
|
|
@@ -4840,7 +5438,7 @@ function resolveViewDir(repositoryRoot, viewPath) {
|
|
|
4840
5438
|
return realpathSync(abs);
|
|
4841
5439
|
} catch {
|
|
4842
5440
|
try {
|
|
4843
|
-
return
|
|
5441
|
+
return join11(realpathSync(dirname3(abs)), basename5(abs));
|
|
4844
5442
|
} catch {
|
|
4845
5443
|
return abs;
|
|
4846
5444
|
}
|
|
@@ -4858,7 +5456,7 @@ function gatherViewRepo(repositoryRoot, viewDir, entry) {
|
|
|
4858
5456
|
return { path: entry.path, reachable: false };
|
|
4859
5457
|
}
|
|
4860
5458
|
const linkName = basename5(repoReal);
|
|
4861
|
-
const { state, actualTarget } = inspectSymlink(
|
|
5459
|
+
const { state, actualTarget } = inspectSymlink(join11(viewDir, linkName), expectedTarget);
|
|
4862
5460
|
return {
|
|
4863
5461
|
path: entry.path,
|
|
4864
5462
|
reachable: true,
|
|
@@ -4872,7 +5470,7 @@ function applyViewPlan(viewDir, toCreate) {
|
|
|
4872
5470
|
const created = [];
|
|
4873
5471
|
const failed = [];
|
|
4874
5472
|
for (const { name, target } of toCreate) {
|
|
4875
|
-
const filePath =
|
|
5473
|
+
const filePath = join11(viewDir, name);
|
|
4876
5474
|
try {
|
|
4877
5475
|
mkdirSync(dirname3(filePath), { recursive: true });
|
|
4878
5476
|
symlinkSync(target, filePath);
|
|
@@ -4887,7 +5485,7 @@ var TOP_LEVEL_INSTRUCTION_FILES_LOWER = new Set(
|
|
|
4887
5485
|
INSTRUCTION_FILES.filter((f) => !f.includes("/")).map((f) => f.toLowerCase())
|
|
4888
5486
|
);
|
|
4889
5487
|
function classifyViewLink(viewDir, name, rosterRealpaths) {
|
|
4890
|
-
const filePath =
|
|
5488
|
+
const filePath = join11(viewDir, name);
|
|
4891
5489
|
let isLink;
|
|
4892
5490
|
try {
|
|
4893
5491
|
isLink = lstatSync(filePath).isSymbolicLink();
|
|
@@ -4916,7 +5514,7 @@ function classifyViewLink(viewDir, name, rosterRealpaths) {
|
|
|
4916
5514
|
if (!isDir) {
|
|
4917
5515
|
return { target, kind: existsSync2(resolved) ? "non-repo" : "broken" };
|
|
4918
5516
|
}
|
|
4919
|
-
return { target, kind: existsSync2(
|
|
5517
|
+
return { target, kind: existsSync2(join11(resolved, ".git")) ? "repo" : "non-repo" };
|
|
4920
5518
|
}
|
|
4921
5519
|
function gatherExistingViewLinks(viewDir, rosterRealpaths) {
|
|
4922
5520
|
let names;
|
|
@@ -4941,7 +5539,7 @@ function pruneViewLinks(viewDir, toPrune, rosterRealpaths) {
|
|
|
4941
5539
|
const pruned = [];
|
|
4942
5540
|
const failed = [];
|
|
4943
5541
|
for (const { name } of toPrune) {
|
|
4944
|
-
const filePath =
|
|
5542
|
+
const filePath = join11(viewDir, name);
|
|
4945
5543
|
const c = classifyViewLink(viewDir, name, rosterRealpaths);
|
|
4946
5544
|
if (c === null || c.kind !== "repo") {
|
|
4947
5545
|
failed.push({
|
|
@@ -5154,10 +5752,10 @@ async function runProjectPreset(options, ctx = {}) {
|
|
|
5154
5752
|
}
|
|
5155
5753
|
}
|
|
5156
5754
|
function canonicalFileFor(anchorReal, canonicalName) {
|
|
5157
|
-
return
|
|
5755
|
+
return join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
|
|
5158
5756
|
}
|
|
5159
5757
|
function canonicalLabelFor(canonicalName) {
|
|
5160
|
-
return
|
|
5758
|
+
return join11("agents", canonicalName, CANONICAL_FILE);
|
|
5161
5759
|
}
|
|
5162
5760
|
async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
5163
5761
|
const declared = {
|
|
@@ -5178,13 +5776,13 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5178
5776
|
if (real === anchorReal) {
|
|
5179
5777
|
return { ...declared, isAnchor: true, reachable: true, canonicalPresent: false };
|
|
5180
5778
|
}
|
|
5181
|
-
if (!existsSync2(
|
|
5779
|
+
if (!existsSync2(join11(real, ".git"))) {
|
|
5182
5780
|
return { ...declared, isAnchor: false, reachable: false, canonicalPresent: false };
|
|
5183
5781
|
}
|
|
5184
5782
|
const canonicalName = basename5(real);
|
|
5185
5783
|
let content;
|
|
5186
5784
|
try {
|
|
5187
|
-
content = await
|
|
5785
|
+
content = await readMarkdownFile6(canonicalFileFor(anchorReal, canonicalName));
|
|
5188
5786
|
} catch {
|
|
5189
5787
|
return {
|
|
5190
5788
|
...declared,
|
|
@@ -5204,7 +5802,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5204
5802
|
canonicalPresent: false
|
|
5205
5803
|
};
|
|
5206
5804
|
}
|
|
5207
|
-
const section =
|
|
5805
|
+
const section = parseMarkers3(content);
|
|
5208
5806
|
return {
|
|
5209
5807
|
...declared,
|
|
5210
5808
|
isAnchor: false,
|
|
@@ -5267,7 +5865,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
|
|
|
5267
5865
|
}
|
|
5268
5866
|
return { kind: "unreadable", canonicalName: viewName, viewName };
|
|
5269
5867
|
}
|
|
5270
|
-
const section =
|
|
5868
|
+
const section = parseMarkers3(content);
|
|
5271
5869
|
if (section.kind === "ok") {
|
|
5272
5870
|
if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
|
|
5273
5871
|
return { kind: "in-sync", canonicalName: viewName, viewName };
|
|
@@ -5293,7 +5891,7 @@ async function applyViewPreset(anchorReal, outcome) {
|
|
|
5293
5891
|
}
|
|
5294
5892
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5295
5893
|
if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
|
|
5296
|
-
const existing = await
|
|
5894
|
+
const existing = await readMarkdownFile6(file);
|
|
5297
5895
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
|
|
5298
5896
|
}
|
|
5299
5897
|
async function applyPresetPlan(anchorReal, plan) {
|
|
@@ -5307,7 +5905,7 @@ async function applyPresetPlan(anchorReal, plan) {
|
|
|
5307
5905
|
}
|
|
5308
5906
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5309
5907
|
if (plan.action === "create") mkdirSync(dirname3(file), { recursive: true });
|
|
5310
|
-
const existing = await
|
|
5908
|
+
const existing = await readMarkdownFile6(file);
|
|
5311
5909
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
|
|
5312
5910
|
}
|
|
5313
5911
|
function presetFailureReason(error) {
|
|
@@ -5560,24 +6158,24 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
|
|
|
5560
6158
|
const instructionFiles = [];
|
|
5561
6159
|
for (const name of INSTRUCTION_FILES) {
|
|
5562
6160
|
try {
|
|
5563
|
-
lstatSync(
|
|
6161
|
+
lstatSync(join11(real, name));
|
|
5564
6162
|
instructionFiles.push(name);
|
|
5565
6163
|
} catch {
|
|
5566
6164
|
}
|
|
5567
6165
|
}
|
|
5568
6166
|
let ignored;
|
|
5569
6167
|
try {
|
|
5570
|
-
ignored = new Set(readGitignoreLines(
|
|
6168
|
+
ignored = new Set(readGitignoreLines(join11(real, ".gitignore")).map((l) => l.trim()));
|
|
5571
6169
|
} catch {
|
|
5572
6170
|
ignored = /* @__PURE__ */ new Set();
|
|
5573
6171
|
}
|
|
5574
6172
|
const gitignorePatterns = INSTRUCTION_FILES.filter((p) => ignored.has(p) || ignored.has(`/${p}`));
|
|
5575
|
-
const canonical2 = existsSync2(
|
|
6173
|
+
const canonical2 = existsSync2(join11(anchorReal, "agents", canonicalName, CANONICAL_FILE));
|
|
5576
6174
|
let viewLink = false;
|
|
5577
6175
|
const viewPath = manifest.workspace.view;
|
|
5578
6176
|
if (viewPath !== void 0) {
|
|
5579
6177
|
try {
|
|
5580
|
-
lstatSync(
|
|
6178
|
+
lstatSync(join11(resolveViewDir(repositoryRoot, viewPath), canonicalName));
|
|
5581
6179
|
viewLink = true;
|
|
5582
6180
|
} catch {
|
|
5583
6181
|
}
|
|
@@ -5591,11 +6189,11 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
|
|
|
5591
6189
|
};
|
|
5592
6190
|
}
|
|
5593
6191
|
function teardownExpectedTargets(repoReal, anchorReal, canonicalName) {
|
|
5594
|
-
const canonicalFile =
|
|
6192
|
+
const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
|
|
5595
6193
|
return expectedSymlinkTargets(repoReal, canonicalFile);
|
|
5596
6194
|
}
|
|
5597
6195
|
function viewLinkPointsAt(viewDir, name, repoReal) {
|
|
5598
|
-
const filePath =
|
|
6196
|
+
const filePath = join11(viewDir, name);
|
|
5599
6197
|
try {
|
|
5600
6198
|
if (!lstatSync(filePath).isSymbolicLink()) return false;
|
|
5601
6199
|
const target = readlinkSync(filePath);
|
|
@@ -5606,7 +6204,7 @@ function viewLinkPointsAt(viewDir, name, repoReal) {
|
|
|
5606
6204
|
}
|
|
5607
6205
|
}
|
|
5608
6206
|
function viewLinkPointsAtPath(viewDir, name, expectedRepoPath) {
|
|
5609
|
-
const filePath =
|
|
6207
|
+
const filePath = join11(viewDir, name);
|
|
5610
6208
|
try {
|
|
5611
6209
|
if (!lstatSync(filePath).isSymbolicLink()) return false;
|
|
5612
6210
|
const target = readlinkSync(filePath);
|
|
@@ -5657,7 +6255,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5657
6255
|
if (!isAnchor) {
|
|
5658
6256
|
if (repoReal !== void 0) {
|
|
5659
6257
|
for (const spec of teardownExpectedTargets(repoReal, anchorReal, canonicalName)) {
|
|
5660
|
-
const { state, actualTarget } = inspectSymlink(
|
|
6258
|
+
const { state, actualTarget } = inspectSymlink(join11(repoReal, spec.name), spec.target);
|
|
5661
6259
|
if (isSelf) {
|
|
5662
6260
|
if (state !== "missing")
|
|
5663
6261
|
items.push({
|
|
@@ -5694,7 +6292,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5694
6292
|
}
|
|
5695
6293
|
let ignored;
|
|
5696
6294
|
try {
|
|
5697
|
-
ignored = new Set(readGitignoreLines(
|
|
6295
|
+
ignored = new Set(readGitignoreLines(join11(repoReal, ".gitignore")).map((l) => l.trim()));
|
|
5698
6296
|
for (const p of INSTRUCTION_FILES) {
|
|
5699
6297
|
if (ignored.has(p) || ignored.has(`/${p}`)) {
|
|
5700
6298
|
items.push({
|
|
@@ -5717,7 +6315,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5717
6315
|
const viewPath = manifest.workspace.view;
|
|
5718
6316
|
if (viewPath !== void 0) {
|
|
5719
6317
|
const viewDir = resolveViewDir(repositoryRoot, viewPath);
|
|
5720
|
-
const linkPath =
|
|
6318
|
+
const linkPath = join11(viewDir, canonicalName);
|
|
5721
6319
|
let isLink = false;
|
|
5722
6320
|
try {
|
|
5723
6321
|
isLink = lstatSync(linkPath).isSymbolicLink();
|
|
@@ -5743,8 +6341,8 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5743
6341
|
else items.push({ kind: "view-symlink", label: canonicalName, state: "removable" });
|
|
5744
6342
|
}
|
|
5745
6343
|
}
|
|
5746
|
-
const canonicalFile =
|
|
5747
|
-
const canonicalLabel =
|
|
6344
|
+
const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
|
|
6345
|
+
const canonicalLabel = join11("agents", canonicalName, CANONICAL_FILE);
|
|
5748
6346
|
let canonicalIsLink = false;
|
|
5749
6347
|
try {
|
|
5750
6348
|
canonicalIsLink = lstatSync(canonicalFile).isSymbolicLink();
|
|
@@ -5771,7 +6369,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5771
6369
|
});
|
|
5772
6370
|
}
|
|
5773
6371
|
if (content !== void 0 && content !== "") {
|
|
5774
|
-
const section =
|
|
6372
|
+
const section = parseMarkers3(content);
|
|
5775
6373
|
if (section.kind === "ok" && canonicalShared) {
|
|
5776
6374
|
items.push({
|
|
5777
6375
|
kind: "canonical-block",
|
|
@@ -5787,7 +6385,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5787
6385
|
note: "repo could not be resolved, so ownership cannot be verified (check manually)"
|
|
5788
6386
|
});
|
|
5789
6387
|
} else if (section.kind === "ok") {
|
|
5790
|
-
const emptyAfter =
|
|
6388
|
+
const emptyAfter = removeMarkerSection2(content, canonicalLabel).trim().length === 0;
|
|
5791
6389
|
items.push({
|
|
5792
6390
|
kind: "canonical-block",
|
|
5793
6391
|
label: canonicalLabel,
|
|
@@ -5848,12 +6446,12 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
5848
6446
|
);
|
|
5849
6447
|
for (const item of removable.filter((i) => i.kind === "instruction-symlink")) {
|
|
5850
6448
|
const expected = expectedByName.get(item.label);
|
|
5851
|
-
if (repoReal === null || expected === void 0 || inspectSymlink(
|
|
6449
|
+
if (repoReal === null || expected === void 0 || inspectSymlink(join11(repoReal, item.label), expected).state !== "correct") {
|
|
5852
6450
|
changed(item.label);
|
|
5853
6451
|
continue;
|
|
5854
6452
|
}
|
|
5855
6453
|
try {
|
|
5856
|
-
unlinkSync(
|
|
6454
|
+
unlinkSync(join11(repoReal, item.label));
|
|
5857
6455
|
removed.push(item.label);
|
|
5858
6456
|
} catch (error) {
|
|
5859
6457
|
failed.push({ label: item.label, message: failureReason(error) });
|
|
@@ -5872,7 +6470,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
5872
6470
|
continue;
|
|
5873
6471
|
}
|
|
5874
6472
|
try {
|
|
5875
|
-
unlinkSync(
|
|
6473
|
+
unlinkSync(join11(viewDir, item.label));
|
|
5876
6474
|
removed.push(`view/${item.label}`);
|
|
5877
6475
|
} catch (error) {
|
|
5878
6476
|
failed.push({ label: `view/${item.label}`, message: failureReason(error) });
|
|
@@ -5880,7 +6478,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
5880
6478
|
}
|
|
5881
6479
|
const NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
|
|
5882
6480
|
for (const item of removable.filter((i) => i.kind === "canonical-block")) {
|
|
5883
|
-
const canonicalFile =
|
|
6481
|
+
const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
|
|
5884
6482
|
try {
|
|
5885
6483
|
if (lstatSync(canonicalFile).isSymbolicLink()) {
|
|
5886
6484
|
changed(item.label);
|
|
@@ -5900,11 +6498,11 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
5900
6498
|
}
|
|
5901
6499
|
try {
|
|
5902
6500
|
const content = readFileSync(fd, "utf8");
|
|
5903
|
-
if (
|
|
6501
|
+
if (parseMarkers3(content).kind !== "ok") {
|
|
5904
6502
|
changed(item.label);
|
|
5905
6503
|
continue;
|
|
5906
6504
|
}
|
|
5907
|
-
const next = Buffer.from(
|
|
6505
|
+
const next = Buffer.from(removeMarkerSection2(content, item.label), "utf8");
|
|
5908
6506
|
ftruncateSync(fd, 0);
|
|
5909
6507
|
writeSync(fd, next, 0, next.length, 0);
|
|
5910
6508
|
removed.push(item.label);
|
|
@@ -6154,12 +6752,12 @@ function gatherRenameWiring(repositoryRoot, manifest, oldBasename) {
|
|
|
6154
6752
|
} catch {
|
|
6155
6753
|
return { canonicalDirOld: false, viewLinkOld: false };
|
|
6156
6754
|
}
|
|
6157
|
-
const canonicalDirOld = existsSync2(
|
|
6755
|
+
const canonicalDirOld = existsSync2(join11(anchorReal, "agents", oldBasename));
|
|
6158
6756
|
let viewLinkOld = false;
|
|
6159
6757
|
const viewPath = manifest.workspace.view;
|
|
6160
6758
|
if (viewPath !== void 0) {
|
|
6161
6759
|
try {
|
|
6162
|
-
lstatSync(
|
|
6760
|
+
lstatSync(join11(resolveViewDir(repositoryRoot, viewPath), oldBasename));
|
|
6163
6761
|
viewLinkOld = true;
|
|
6164
6762
|
} catch {
|
|
6165
6763
|
}
|
|
@@ -6517,7 +7115,7 @@ async function doRunProjectSeedAnchor(options, ctx) {
|
|
|
6517
7115
|
console.log("\u2139\uFE0F No repo roster declared \u2014 nothing to seed.");
|
|
6518
7116
|
return;
|
|
6519
7117
|
}
|
|
6520
|
-
const anchorDoc =
|
|
7118
|
+
const anchorDoc = join11(repositoryRoot, CANONICAL_FILE);
|
|
6521
7119
|
if (pathPresent(anchorDoc)) {
|
|
6522
7120
|
console.log(
|
|
6523
7121
|
`\u2705 The anchor's own \`${CANONICAL_FILE}\` already exists \u2014 hand-maintained, left untouched.`
|
|
@@ -6579,7 +7177,7 @@ function regularFileSpokes(repoReal) {
|
|
|
6579
7177
|
const out = [];
|
|
6580
7178
|
for (const spoke of ["CLAUDE.md", ".github/copilot-instructions.md"]) {
|
|
6581
7179
|
try {
|
|
6582
|
-
const st = lstatSync(
|
|
7180
|
+
const st = lstatSync(join11(repoReal, spoke));
|
|
6583
7181
|
if (!st.isSymbolicLink() && st.isFile()) out.push(spoke);
|
|
6584
7182
|
} catch {
|
|
6585
7183
|
}
|
|
@@ -6625,8 +7223,8 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
|
|
|
6625
7223
|
};
|
|
6626
7224
|
}
|
|
6627
7225
|
const isAnchor = argReal === anchorReal;
|
|
6628
|
-
const reachable = existsSync2(
|
|
6629
|
-
const canonicalFile =
|
|
7226
|
+
const reachable = existsSync2(join11(argReal, ".git"));
|
|
7227
|
+
const canonicalFile = join11(anchorReal, "agents", canonicalName, CANONICAL_FILE);
|
|
6630
7228
|
return {
|
|
6631
7229
|
path,
|
|
6632
7230
|
declared,
|
|
@@ -6635,13 +7233,13 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
|
|
|
6635
7233
|
reachable,
|
|
6636
7234
|
canonicalName,
|
|
6637
7235
|
...viewCanonicalName !== void 0 ? { viewCanonicalName } : {},
|
|
6638
|
-
agentsState: inspectAgentsState(
|
|
7236
|
+
agentsState: inspectAgentsState(join11(argReal, CANONICAL_FILE)),
|
|
6639
7237
|
canonicalExists: pathPresent(canonicalFile),
|
|
6640
7238
|
regularSpokes: regularFileSpokes(argReal)
|
|
6641
7239
|
};
|
|
6642
7240
|
}
|
|
6643
7241
|
function relocateAgentsFile(repoReal, canonicalFile) {
|
|
6644
|
-
const agentsFile =
|
|
7242
|
+
const agentsFile = join11(repoReal, CANONICAL_FILE);
|
|
6645
7243
|
try {
|
|
6646
7244
|
mkdirSync(dirname3(canonicalFile), { recursive: true });
|
|
6647
7245
|
} catch (error) {
|
|
@@ -6675,7 +7273,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
|
|
|
6675
7273
|
if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
|
|
6676
7274
|
return { kind: "unreadable", viewName };
|
|
6677
7275
|
}
|
|
6678
|
-
const section =
|
|
7276
|
+
const section = parseMarkers3(content);
|
|
6679
7277
|
if (section.kind === "ok") return { kind: "already-marked", viewName };
|
|
6680
7278
|
if (section.kind === "no_markers") {
|
|
6681
7279
|
const block = renderViewPresetBlock({
|
|
@@ -6696,7 +7294,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
|
|
|
6696
7294
|
isLink = false;
|
|
6697
7295
|
}
|
|
6698
7296
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
6699
|
-
const existing = await
|
|
7297
|
+
const existing = await readMarkdownFile6(file);
|
|
6700
7298
|
await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
|
|
6701
7299
|
}
|
|
6702
7300
|
async function doRunProjectRetrofit(repo, options, ctx) {
|
|
@@ -6758,7 +7356,7 @@ async function doRunProjectRetrofit(repo, options, ctx) {
|
|
|
6758
7356
|
let failure;
|
|
6759
7357
|
let partial = false;
|
|
6760
7358
|
if (options.apply === true && plan.action === "relocate" && argReal !== void 0) {
|
|
6761
|
-
const canonicalFile =
|
|
7359
|
+
const canonicalFile = join11(anchorReal, "agents", plan.canonicalName, CANONICAL_FILE);
|
|
6762
7360
|
const res = relocateAgentsFile(argReal, canonicalFile);
|
|
6763
7361
|
if (res.ok) {
|
|
6764
7362
|
applied = true;
|
|
@@ -6959,119 +7557,7 @@ function renderProjectRetrofit(result) {
|
|
|
6959
7557
|
|
|
6960
7558
|
// src/commands/protocol.ts
|
|
6961
7559
|
import { readFile as readFile4 } from "fs/promises";
|
|
6962
|
-
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as
|
|
6963
|
-
|
|
6964
|
-
// src/lib/context-channel.ts
|
|
6965
|
-
import { homedir as homedir7 } from "os";
|
|
6966
|
-
import { join as join11 } from "path";
|
|
6967
|
-
import {
|
|
6968
|
-
ORIENTATION_END,
|
|
6969
|
-
ORIENTATION_START,
|
|
6970
|
-
parseMarkers as parseMarkers2,
|
|
6971
|
-
readMarkdownFile as readMarkdownFile5,
|
|
6972
|
-
removeMarkerSection as removeMarkerSection2
|
|
6973
|
-
} from "@basou/core";
|
|
6974
|
-
var CODEX_TARGET_PATH = join11(homedir7(), ".codex", "AGENTS.md");
|
|
6975
|
-
var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
|
|
6976
|
-
var ORIENTATION_MANAGED_NOTE = "<!-- Managed by basou: 'basou refresh' regenerates everything between the BASOU:ORIENTATION markers with the workspace's current position. This block is transient \u2014 it changes every refresh; do not edit it. -->";
|
|
6977
|
-
function buildTargetBody(existing, block, markers) {
|
|
6978
|
-
const wrapped = `${markers.start}
|
|
6979
|
-
${block}${markers.end}
|
|
6980
|
-
`;
|
|
6981
|
-
if (existing === null || existing === "") return wrapped;
|
|
6982
|
-
const section = parseMarkers2(existing, markers);
|
|
6983
|
-
switch (section.kind) {
|
|
6984
|
-
case "ok":
|
|
6985
|
-
return `${section.before}${markers.start}
|
|
6986
|
-
${block}${markers.end}${section.after}`;
|
|
6987
|
-
case "no_markers": {
|
|
6988
|
-
const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
6989
|
-
return `${existing}${sep}${wrapped}`;
|
|
6990
|
-
}
|
|
6991
|
-
default:
|
|
6992
|
-
throw new Error(
|
|
6993
|
-
"The basou-managed markers in the target are malformed (a marker is missing, duplicated, or out of order). Fix or remove them, then retry."
|
|
6994
|
-
);
|
|
6995
|
-
}
|
|
6996
|
-
}
|
|
6997
|
-
async function backupOnce(target, existing) {
|
|
6998
|
-
if (existing === null) return;
|
|
6999
|
-
const bak = `${target}.basou-bak`;
|
|
7000
|
-
const already = await readMarkdownFile5(bak);
|
|
7001
|
-
if (already !== null) return;
|
|
7002
|
-
await writeFileDurable(bak, existing);
|
|
7003
|
-
}
|
|
7004
|
-
async function syncMarkerBlock(opts) {
|
|
7005
|
-
const { target, markers, block } = opts;
|
|
7006
|
-
await assertNotSymlink(target);
|
|
7007
|
-
const existing = await readMarkdownFile5(target);
|
|
7008
|
-
const newBody = buildTargetBody(existing, block, markers);
|
|
7009
|
-
if (newBody === existing) return { action: "unchanged" };
|
|
7010
|
-
const hadBlock = existing !== null && parseMarkers2(existing, markers).kind === "ok";
|
|
7011
|
-
const action = hadBlock ? "updated" : "installed";
|
|
7012
|
-
if (opts.dryRun === true) return { action };
|
|
7013
|
-
const recheck = await readMarkdownFile5(target);
|
|
7014
|
-
if (recheck !== existing) {
|
|
7015
|
-
throw new Error(
|
|
7016
|
-
"The target changed during sync; aborting so a concurrent edit is not overwritten. Re-run the command."
|
|
7017
|
-
);
|
|
7018
|
-
}
|
|
7019
|
-
await backupOnce(target, existing);
|
|
7020
|
-
await writeFileDurable(target, newBody);
|
|
7021
|
-
return { action };
|
|
7022
|
-
}
|
|
7023
|
-
function assertNoMarkerLine(body, markers) {
|
|
7024
|
-
for (const line of body.split(/\r?\n/)) {
|
|
7025
|
-
if (line === markers.start || line === markers.end) {
|
|
7026
|
-
throw new Error(
|
|
7027
|
-
"The content contains a basou marker line, which would corrupt the managed block. Remove that line from the source."
|
|
7028
|
-
);
|
|
7029
|
-
}
|
|
7030
|
-
}
|
|
7031
|
-
}
|
|
7032
|
-
async function removeMarkerBlock(opts) {
|
|
7033
|
-
const { target, markers, fileLabel } = opts;
|
|
7034
|
-
await assertNotSymlink(target);
|
|
7035
|
-
const existing = await readMarkdownFile5(target);
|
|
7036
|
-
if (existing === null) return { removed: false };
|
|
7037
|
-
const newBody = removeMarkerSection2(existing, fileLabel, markers);
|
|
7038
|
-
if (newBody === existing) return { removed: false };
|
|
7039
|
-
if (opts.dryRun === true) return { removed: true };
|
|
7040
|
-
const recheck = await readMarkdownFile5(target);
|
|
7041
|
-
if (recheck !== existing) {
|
|
7042
|
-
throw new Error(
|
|
7043
|
-
"The target changed during unsync; aborting so a concurrent edit is not overwritten. Re-run the command."
|
|
7044
|
-
);
|
|
7045
|
-
}
|
|
7046
|
-
await backupOnce(target, existing);
|
|
7047
|
-
await writeFileDurable(target, newBody);
|
|
7048
|
-
return { removed: true };
|
|
7049
|
-
}
|
|
7050
|
-
async function syncOrientationChannel(opts) {
|
|
7051
|
-
assertNoMarkerLine(opts.body, ORIENTATION_MARKERS);
|
|
7052
|
-
const block = `${ORIENTATION_MANAGED_NOTE}
|
|
7053
|
-
|
|
7054
|
-
${opts.body.replace(/\s+$/, "")}
|
|
7055
|
-
`;
|
|
7056
|
-
return syncMarkerBlock({
|
|
7057
|
-
target: opts.target ?? CODEX_TARGET_PATH,
|
|
7058
|
-
markers: ORIENTATION_MARKERS,
|
|
7059
|
-
block,
|
|
7060
|
-
...opts.dryRun === true ? { dryRun: true } : {}
|
|
7061
|
-
});
|
|
7062
|
-
}
|
|
7063
|
-
async function renderOrientationToCodexChannel(opts) {
|
|
7064
|
-
const body = await readMarkdownFile5(opts.orientationPath);
|
|
7065
|
-
if (body === null) return null;
|
|
7066
|
-
const { action } = await syncOrientationChannel({
|
|
7067
|
-
body,
|
|
7068
|
-
...opts.channelPath !== void 0 ? { target: opts.channelPath } : {}
|
|
7069
|
-
});
|
|
7070
|
-
return {
|
|
7071
|
-
action,
|
|
7072
|
-
line: `codex channel: orientation ${action} in ${opts.channelPath ?? "~/.codex/AGENTS.md"}`
|
|
7073
|
-
};
|
|
7074
|
-
}
|
|
7560
|
+
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile7 } from "@basou/core";
|
|
7075
7561
|
|
|
7076
7562
|
// src/lib/protocols-config.ts
|
|
7077
7563
|
import { homedir as homedir8 } from "os";
|
|
@@ -7155,7 +7641,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
|
|
|
7155
7641
|
var MANAGED_NOTE = "<!-- Managed by basou: 'basou protocol sync' regenerates everything between the BASOU:PROTOCOLS markers from ~/.basou/protocols.yaml. Manual edits inside the block are overwritten; edit the source files instead. -->";
|
|
7156
7642
|
function registerProtocolCommand(program2) {
|
|
7157
7643
|
const protocol = program2.command("protocol").description("Manage the basou-managed standing-protocol block in the global CLAUDE.md");
|
|
7158
|
-
protocol.command("sync").description(
|
|
7644
|
+
protocol.command("sync").description(
|
|
7645
|
+
"Render declared protocols into ~/.claude/CLAUDE.md (creates/updates the block). That file is user-global: Claude Code auto-loads it for every project on the machine, so what the protocols say is in the context of every workspace's sessions \u2014 keep workspace-specific facts out of them."
|
|
7646
|
+
).option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
7159
7647
|
await runProtocolSync(opts);
|
|
7160
7648
|
});
|
|
7161
7649
|
protocol.command("list").description("List declared protocols and whether the block is installed").option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
@@ -7254,8 +7742,8 @@ async function doRunProtocolList(options) {
|
|
|
7254
7742
|
const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
|
|
7255
7743
|
const target = options.target ?? DEFAULT_TARGET_PATH;
|
|
7256
7744
|
const entries = await loadProtocolsConfig(configPath);
|
|
7257
|
-
const existing = await
|
|
7258
|
-
const installed = existing !== null &&
|
|
7745
|
+
const existing = await readMarkdownFile7(target);
|
|
7746
|
+
const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
|
|
7259
7747
|
console.log(`Declared protocols (${entries.length}):`);
|
|
7260
7748
|
for (const entry of entries) {
|
|
7261
7749
|
console.log(` - ${entry.title ?? entry.source}`);
|
|
@@ -7282,7 +7770,12 @@ async function doRunProtocolUnsync(options) {
|
|
|
7282
7770
|
}
|
|
7283
7771
|
|
|
7284
7772
|
// src/commands/refresh.ts
|
|
7285
|
-
import {
|
|
7773
|
+
import {
|
|
7774
|
+
assertBasouRootSafe as assertBasouRootSafe9,
|
|
7775
|
+
basouPaths as basouPaths11,
|
|
7776
|
+
findErrorCode as findErrorCode9,
|
|
7777
|
+
readManifest as readManifest7
|
|
7778
|
+
} from "@basou/core";
|
|
7286
7779
|
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
7287
7780
|
|
|
7288
7781
|
// src/commands/refresh-watch.ts
|
|
@@ -7436,7 +7929,7 @@ function abortableSleep(ms, signal) {
|
|
|
7436
7929
|
}
|
|
7437
7930
|
function registerRefreshCommand(program2) {
|
|
7438
7931
|
program2.command("refresh").description(
|
|
7439
|
-
"Import all adapters for the project and regenerate handoff + decisions in one step"
|
|
7932
|
+
"Import all adapters for the project and regenerate handoff + decisions in one step. Writes only inside the workspace's .basou/ \u2014 never to a user-global file another project's tool reads (a Codex session gets the position from the SessionStart hook; see `basou hook install codex`)."
|
|
7440
7933
|
).option(
|
|
7441
7934
|
"--project <path>",
|
|
7442
7935
|
"Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
|
|
@@ -7567,24 +8060,23 @@ async function computeRefresh(options, ctx) {
|
|
|
7567
8060
|
}
|
|
7568
8061
|
async function doRunRefresh(options, ctx) {
|
|
7569
8062
|
const { result, paths } = await computeRefresh(options, ctx);
|
|
7570
|
-
const
|
|
8063
|
+
const reported = { ...result, codexChannel: { status: "retired" } };
|
|
7571
8064
|
if (options.json === true) {
|
|
7572
|
-
console.log(JSON.stringify(
|
|
8065
|
+
console.log(JSON.stringify(reported));
|
|
7573
8066
|
} else {
|
|
7574
8067
|
printRefreshSummary(result);
|
|
7575
|
-
|
|
8068
|
+
const line = await retiredChannelNotice(paths);
|
|
8069
|
+
if (line !== null) console.log(line);
|
|
7576
8070
|
}
|
|
7577
|
-
return
|
|
8071
|
+
return reported;
|
|
7578
8072
|
}
|
|
7579
|
-
async function
|
|
8073
|
+
async function retiredChannelNotice(paths) {
|
|
7580
8074
|
try {
|
|
7581
|
-
const
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
return
|
|
7586
|
-
} catch (error) {
|
|
7587
|
-
return `codex channel skipped: ${error instanceof Error ? error.message : String(error)}`;
|
|
8075
|
+
const manifest = await readManifest7(paths);
|
|
8076
|
+
if (manifest.channels?.codex !== true) return null;
|
|
8077
|
+
return "codex channel: retired \u2014 the manifest's channels.codex is ignored; a Codex session now receives this workspace's position from the SessionStart hook (see `basou hook status codex`), and nothing is written to the user-global ~/.codex/AGENTS.md";
|
|
8078
|
+
} catch {
|
|
8079
|
+
return null;
|
|
7588
8080
|
}
|
|
7589
8081
|
}
|
|
7590
8082
|
function describeImport(outcome) {
|
|
@@ -7737,7 +8229,7 @@ import {
|
|
|
7737
8229
|
findErrorCode as findErrorCode11,
|
|
7738
8230
|
findUnbindableRepos,
|
|
7739
8231
|
parseReviewRecordInput,
|
|
7740
|
-
readManifest as
|
|
8232
|
+
readManifest as readManifest8,
|
|
7741
8233
|
resolveRepoRoot,
|
|
7742
8234
|
sanitizePath as sanitizePath2
|
|
7743
8235
|
} from "@basou/core";
|
|
@@ -7824,7 +8316,7 @@ async function doRunReviewRecord(options, ctx) {
|
|
|
7824
8316
|
}
|
|
7825
8317
|
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
7826
8318
|
const occurredAt = now.toISOString();
|
|
7827
|
-
const manifest = await
|
|
8319
|
+
const manifest = await readManifest8(paths);
|
|
7828
8320
|
const invocationArgs = options.file !== void 0 ? [
|
|
7829
8321
|
"--file",
|
|
7830
8322
|
sanitizePath2(resolve10(cwd, options.file), {
|
|
@@ -8162,7 +8654,7 @@ function unattachedLines(u) {
|
|
|
8162
8654
|
}
|
|
8163
8655
|
|
|
8164
8656
|
// src/commands/run.ts
|
|
8165
|
-
import { mkdir as mkdir2 } from "fs/promises";
|
|
8657
|
+
import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
|
|
8166
8658
|
import { homedir as homedir11 } from "os";
|
|
8167
8659
|
import { join as join14 } from "path";
|
|
8168
8660
|
import {
|
|
@@ -8174,11 +8666,12 @@ import {
|
|
|
8174
8666
|
codexAdapterMetadata,
|
|
8175
8667
|
appendChainedEvent as coreAppendChainedEvent2,
|
|
8176
8668
|
finalizeSessionYaml as finalizeSessionYaml2,
|
|
8669
|
+
findBasouSessionStartHook as findBasouSessionStartHook2,
|
|
8177
8670
|
getDiff,
|
|
8178
8671
|
getSnapshot as getSnapshot2,
|
|
8179
8672
|
overwriteYamlFile as overwriteYamlFile2,
|
|
8180
8673
|
prefixedUlid as prefixedUlid4,
|
|
8181
|
-
readManifest as
|
|
8674
|
+
readManifest as readManifest9,
|
|
8182
8675
|
readYamlFile as readYamlFile6,
|
|
8183
8676
|
resolveClaudeCodeCommand,
|
|
8184
8677
|
resolveCodexCommand,
|
|
@@ -8225,7 +8718,7 @@ function runCodex(args, options, ctx = {}) {
|
|
|
8225
8718
|
resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
|
|
8226
8719
|
metadata: codexAdapterMetadata,
|
|
8227
8720
|
transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
|
|
8228
|
-
preSpawn:
|
|
8721
|
+
preSpawn: noteCodexHookStatusPreSpawn
|
|
8229
8722
|
});
|
|
8230
8723
|
}
|
|
8231
8724
|
async function runTrackedTool(args, options, ctx, adapter) {
|
|
@@ -8238,7 +8731,7 @@ async function runTrackedTool(args, options, ctx, adapter) {
|
|
|
8238
8731
|
const repoRoot = await resolveRepositoryRootForRun(cwd);
|
|
8239
8732
|
const paths = basouPaths15(repoRoot);
|
|
8240
8733
|
await assertBasouRootSafe12(paths.root);
|
|
8241
|
-
const manifest = await
|
|
8734
|
+
const manifest = await readManifest9(paths);
|
|
8242
8735
|
const sessionId = prefixedUlid4("ses");
|
|
8243
8736
|
const sessionDir = join14(paths.sessions, sessionId);
|
|
8244
8737
|
await mkdir2(sessionDir, { recursive: true });
|
|
@@ -8592,22 +9085,25 @@ async function resolveRepositoryRootForRun(cwd) {
|
|
|
8592
9085
|
throw error;
|
|
8593
9086
|
}
|
|
8594
9087
|
}
|
|
8595
|
-
async function
|
|
9088
|
+
async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
|
|
9089
|
+
const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
9090
|
+
let location;
|
|
8596
9091
|
try {
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
return
|
|
8604
|
-
} catch {
|
|
8605
|
-
return null;
|
|
9092
|
+
location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
|
|
9093
|
+
} catch (error) {
|
|
9094
|
+
if (!(error instanceof Error && error.code === "ENOENT")) return null;
|
|
9095
|
+
location = null;
|
|
9096
|
+
}
|
|
9097
|
+
if (location === null) {
|
|
9098
|
+
return "codex: the basou SessionStart hook is not registered, so this session starts without the workspace's position (`basou hook install codex` registers it once for every workspace)";
|
|
8606
9099
|
}
|
|
9100
|
+
const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
|
|
9101
|
+
if (trust.status === "trusted" || trust.status === "unknown") return null;
|
|
9102
|
+
return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
|
|
8607
9103
|
}
|
|
8608
9104
|
|
|
8609
9105
|
// src/commands/session.ts
|
|
8610
|
-
import { readFile as
|
|
9106
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
8611
9107
|
import { basename as basename6, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
|
|
8612
9108
|
import {
|
|
8613
9109
|
acquireLock as acquireLock6,
|
|
@@ -8619,7 +9115,7 @@ import {
|
|
|
8619
9115
|
importSessionFromJson as importSessionFromJson2,
|
|
8620
9116
|
loadSessionEntries as loadSessionEntries2,
|
|
8621
9117
|
readAllEvents,
|
|
8622
|
-
readManifest as
|
|
9118
|
+
readManifest as readManifest10,
|
|
8623
9119
|
readYamlFile as readYamlFile7,
|
|
8624
9120
|
rechainSessionInPlace,
|
|
8625
9121
|
resolveSessionId as resolveSessionId3,
|
|
@@ -9031,7 +9527,7 @@ async function doRunSessionImport(options, ctx) {
|
|
|
9031
9527
|
const repositoryRoot = await resolveRepositoryRootForSession(cwd, "import");
|
|
9032
9528
|
const paths = basouPaths16(repositoryRoot);
|
|
9033
9529
|
await assertWorkspaceInitialized11(paths.root);
|
|
9034
|
-
const manifest = await
|
|
9530
|
+
const manifest = await readManifest10(paths);
|
|
9035
9531
|
const rawBody = await readInputFile(options.from);
|
|
9036
9532
|
const json = parseJsonStrict(rawBody);
|
|
9037
9533
|
const parsed = SessionImportPayloadSchema2.safeParse(json);
|
|
@@ -9058,7 +9554,7 @@ async function doRunSessionImport(options, ctx) {
|
|
|
9058
9554
|
}
|
|
9059
9555
|
async function readInputFile(path) {
|
|
9060
9556
|
try {
|
|
9061
|
-
return await
|
|
9557
|
+
return await readFile7(path, "utf8");
|
|
9062
9558
|
} catch (error) {
|
|
9063
9559
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9064
9560
|
throw new Error("Import source not found", { cause: error });
|
|
@@ -9175,7 +9671,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
|
|
|
9175
9671
|
}
|
|
9176
9672
|
async function readNoteFile(path) {
|
|
9177
9673
|
try {
|
|
9178
|
-
return await
|
|
9674
|
+
return await readFile7(path, "utf8");
|
|
9179
9675
|
} catch (error) {
|
|
9180
9676
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9181
9677
|
throw new Error("Note source not found", { cause: error });
|
|
@@ -9413,7 +9909,7 @@ import {
|
|
|
9413
9909
|
basouPaths as basouPaths18,
|
|
9414
9910
|
buildStatusSnapshot,
|
|
9415
9911
|
findErrorCode as findErrorCode14,
|
|
9416
|
-
readManifest as
|
|
9912
|
+
readManifest as readManifest11,
|
|
9417
9913
|
resolveRepositoryRoot as resolveRepositoryRoot12,
|
|
9418
9914
|
writeStatus
|
|
9419
9915
|
} from "@basou/core";
|
|
@@ -9444,7 +9940,7 @@ async function doRunStatus(options, ctx) {
|
|
|
9444
9940
|
}
|
|
9445
9941
|
let manifest;
|
|
9446
9942
|
try {
|
|
9447
|
-
manifest = await
|
|
9943
|
+
manifest = await readManifest11(paths);
|
|
9448
9944
|
} catch (error) {
|
|
9449
9945
|
if (findErrorCode14(error, "ENOENT")) {
|
|
9450
9946
|
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
@@ -9496,7 +9992,7 @@ function formatVersionGateMessage(error) {
|
|
|
9496
9992
|
}
|
|
9497
9993
|
|
|
9498
9994
|
// src/commands/task.ts
|
|
9499
|
-
import { readFile as
|
|
9995
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
9500
9996
|
import { join as join16 } from "path";
|
|
9501
9997
|
import {
|
|
9502
9998
|
archiveTask,
|
|
@@ -9510,7 +10006,7 @@ import {
|
|
|
9510
10006
|
loadSessionEntries as loadSessionEntries3,
|
|
9511
10007
|
loadTaskEntries,
|
|
9512
10008
|
prefixedUlid as prefixedUlid5,
|
|
9513
|
-
readManifest as
|
|
10009
|
+
readManifest as readManifest12,
|
|
9514
10010
|
readTaskFile,
|
|
9515
10011
|
readTaskFileWithArchiveFallback,
|
|
9516
10012
|
reconcileAllTasks,
|
|
@@ -9637,7 +10133,7 @@ async function doRunTaskNew(options, ctx) {
|
|
|
9637
10133
|
});
|
|
9638
10134
|
return;
|
|
9639
10135
|
}
|
|
9640
|
-
const manifest = await
|
|
10136
|
+
const manifest = await readManifest12(paths);
|
|
9641
10137
|
const result = await createTaskWithEvent({
|
|
9642
10138
|
mode: "ad-hoc",
|
|
9643
10139
|
paths,
|
|
@@ -9985,7 +10481,7 @@ async function doRunTaskStatus(taskIdInput, newStatusInput, options, ctx) {
|
|
|
9985
10481
|
});
|
|
9986
10482
|
return;
|
|
9987
10483
|
}
|
|
9988
|
-
const manifest = await
|
|
10484
|
+
const manifest = await readManifest12(paths);
|
|
9989
10485
|
const result = await updateTaskStatusWithEvent({
|
|
9990
10486
|
mode: "ad-hoc",
|
|
9991
10487
|
paths,
|
|
@@ -10038,7 +10534,7 @@ async function doRunTaskReconcile(options, ctx) {
|
|
|
10038
10534
|
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "reconcile");
|
|
10039
10535
|
const paths = basouPaths19(repositoryRoot);
|
|
10040
10536
|
await assertWorkspaceInitialized13(paths.root);
|
|
10041
|
-
const manifest = await
|
|
10537
|
+
const manifest = await readManifest12(paths);
|
|
10042
10538
|
const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
|
|
10043
10539
|
const write = options.write === true;
|
|
10044
10540
|
const verbose = isVerbose(options);
|
|
@@ -10218,7 +10714,7 @@ async function doRunTaskRefreshLinkage(taskIdInput, options, ctx) {
|
|
|
10218
10714
|
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "refresh-linkage");
|
|
10219
10715
|
const paths = basouPaths19(repositoryRoot);
|
|
10220
10716
|
await assertWorkspaceInitialized13(paths.root);
|
|
10221
|
-
const manifest = await
|
|
10717
|
+
const manifest = await readManifest12(paths);
|
|
10222
10718
|
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
10223
10719
|
const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
|
|
10224
10720
|
const write = options.write === true;
|
|
@@ -10298,7 +10794,7 @@ async function doRunTaskEdit(taskIdInput, options, ctx) {
|
|
|
10298
10794
|
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "edit");
|
|
10299
10795
|
const paths = basouPaths19(repositoryRoot);
|
|
10300
10796
|
await assertWorkspaceInitialized13(paths.root);
|
|
10301
|
-
const manifest = await
|
|
10797
|
+
const manifest = await readManifest12(paths);
|
|
10302
10798
|
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
10303
10799
|
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
10304
10800
|
const occurredAt = now.toISOString();
|
|
@@ -10354,7 +10850,7 @@ async function doRunTaskDelete(taskIdInput, options, ctx) {
|
|
|
10354
10850
|
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "delete");
|
|
10355
10851
|
const paths = basouPaths19(repositoryRoot);
|
|
10356
10852
|
await assertWorkspaceInitialized13(paths.root);
|
|
10357
|
-
const manifest = await
|
|
10853
|
+
const manifest = await readManifest12(paths);
|
|
10358
10854
|
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
10359
10855
|
if (options.yes !== true) {
|
|
10360
10856
|
await confirmDestructiveAction("delete", taskId);
|
|
@@ -10399,7 +10895,7 @@ async function doRunTaskArchive(taskIdInput, options, ctx) {
|
|
|
10399
10895
|
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "archive");
|
|
10400
10896
|
const paths = basouPaths19(repositoryRoot);
|
|
10401
10897
|
await assertWorkspaceInitialized13(paths.root);
|
|
10402
|
-
const manifest = await
|
|
10898
|
+
const manifest = await readManifest12(paths);
|
|
10403
10899
|
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
10404
10900
|
if (options.yes !== true) {
|
|
10405
10901
|
await confirmDestructiveAction("archive", taskId);
|
|
@@ -10441,8 +10937,8 @@ async function confirmDestructiveAction(action, taskId) {
|
|
|
10441
10937
|
}
|
|
10442
10938
|
}
|
|
10443
10939
|
async function readSingleLineFromStdin() {
|
|
10444
|
-
const { createInterface:
|
|
10445
|
-
const rl =
|
|
10940
|
+
const { createInterface: createInterface3 } = await import("readline/promises");
|
|
10941
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
10446
10942
|
try {
|
|
10447
10943
|
const line = await rl.question("");
|
|
10448
10944
|
return line;
|
|
@@ -10511,7 +11007,7 @@ function parsePositiveInt2(raw) {
|
|
|
10511
11007
|
}
|
|
10512
11008
|
async function readDescriptionFile(path) {
|
|
10513
11009
|
try {
|
|
10514
|
-
return await
|
|
11010
|
+
return await readFile8(path, "utf8");
|
|
10515
11011
|
} catch (error) {
|
|
10516
11012
|
if (findErrorCode15(error, "ENOENT")) {
|
|
10517
11013
|
throw new Error("Description source not found", { cause: error });
|
|
@@ -10715,30 +11211,342 @@ async function assertWorkspaceInitialized14(basouRoot) {
|
|
|
10715
11211
|
|
|
10716
11212
|
// src/commands/view.ts
|
|
10717
11213
|
import { spawn } from "child_process";
|
|
10718
|
-
import { createHash } from "crypto";
|
|
10719
|
-
import { basename as
|
|
11214
|
+
import { createHash as createHash2 } from "crypto";
|
|
11215
|
+
import { basename as basename9, resolve as resolve13 } from "path";
|
|
10720
11216
|
import {
|
|
10721
11217
|
assertBasouRootSafe as assertBasouRootSafe18,
|
|
10722
|
-
basouPaths as
|
|
11218
|
+
basouPaths as basouPaths22,
|
|
10723
11219
|
findErrorCode as findErrorCode18,
|
|
10724
|
-
readManifest as
|
|
10725
|
-
resolveRepositoryRoot as
|
|
11220
|
+
readManifest as readManifest16,
|
|
11221
|
+
resolveRepositoryRoot as resolveRepositoryRoot15
|
|
10726
11222
|
} from "@basou/core";
|
|
10727
11223
|
import { InvalidArgumentError as InvalidArgumentError7 } from "commander";
|
|
10728
11224
|
|
|
11225
|
+
// src/lib/portfolio-coverage.ts
|
|
11226
|
+
import { createReadStream as createReadStream2 } from "fs";
|
|
11227
|
+
import { readdir as readdir3, stat as stat6 } from "fs/promises";
|
|
11228
|
+
import { homedir as homedir12 } from "os";
|
|
11229
|
+
import { basename as basename7, dirname as dirname4, join as join17 } from "path";
|
|
11230
|
+
import { createInterface as createInterface2 } from "readline";
|
|
11231
|
+
import { basouPaths as basouPaths21, readManifest as readManifest13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
|
|
11232
|
+
function uncapturedTotal(result) {
|
|
11233
|
+
return result.groups.reduce((sum, g) => sum + g.logs, 0) + result.unplaceable;
|
|
11234
|
+
}
|
|
11235
|
+
async function checkPortfolioCoverage(workspaces, ctx = {}) {
|
|
11236
|
+
const { roots, inertWorkspaces } = await collectDeclaredRoots(workspaces);
|
|
11237
|
+
const listedDirs = new Set([...roots].map((root) => encodeProjectDir(root)));
|
|
11238
|
+
const claudeProjectsDir = ctx.claudeProjectsDir ?? join17(homedir12(), ".claude", "projects");
|
|
11239
|
+
const codexSessionsDir = ctx.codexSessionsDir ?? join17(homedir12(), ".codex", "sessions");
|
|
11240
|
+
const tally = new Tally(roots);
|
|
11241
|
+
const absentTrees = [];
|
|
11242
|
+
const claudeFiles = await listClaudeTranscripts(claudeProjectsDir);
|
|
11243
|
+
if (claudeFiles === void 0) absentTrees.push(claudeProjectsDir);
|
|
11244
|
+
else {
|
|
11245
|
+
for (const file of claudeFiles) {
|
|
11246
|
+
await tally.add(
|
|
11247
|
+
file,
|
|
11248
|
+
"claude-code",
|
|
11249
|
+
claudeTranscriptCwd,
|
|
11250
|
+
listedDirs.has(basename7(dirname4(file)))
|
|
11251
|
+
);
|
|
11252
|
+
}
|
|
11253
|
+
}
|
|
11254
|
+
const codexFiles = await listCodexRollouts(codexSessionsDir);
|
|
11255
|
+
if (codexFiles === void 0) absentTrees.push(codexSessionsDir);
|
|
11256
|
+
else {
|
|
11257
|
+
for (const file of codexFiles) await tally.add(file, "codex", codexRolloutCwd, true);
|
|
11258
|
+
}
|
|
11259
|
+
return tally.finish(absentTrees, inertWorkspaces);
|
|
11260
|
+
}
|
|
11261
|
+
async function collectDeclaredRoots(workspaces) {
|
|
11262
|
+
const roots = /* @__PURE__ */ new Set();
|
|
11263
|
+
const inertWorkspaces = [];
|
|
11264
|
+
for (const ws of workspaces) {
|
|
11265
|
+
let importRoot;
|
|
11266
|
+
try {
|
|
11267
|
+
importRoot = await resolveRepositoryRoot14(ws.repoRoot);
|
|
11268
|
+
} catch {
|
|
11269
|
+
inertWorkspaces.push({ path: ws.repoRoot, reason: "not_a_git_repo" });
|
|
11270
|
+
continue;
|
|
11271
|
+
}
|
|
11272
|
+
let resolved;
|
|
11273
|
+
try {
|
|
11274
|
+
const manifest = await readManifest13(basouPaths21(importRoot));
|
|
11275
|
+
resolved = resolveSourceRoots({
|
|
11276
|
+
projectFlags: [],
|
|
11277
|
+
manifest,
|
|
11278
|
+
repoRoot: importRoot,
|
|
11279
|
+
cwd: importRoot
|
|
11280
|
+
});
|
|
11281
|
+
} catch (error) {
|
|
11282
|
+
const absent = error instanceof Error && error.message === "YAML file not found";
|
|
11283
|
+
inertWorkspaces.push({
|
|
11284
|
+
path: ws.repoRoot,
|
|
11285
|
+
reason: absent ? "no_store" : "unreadable_store"
|
|
11286
|
+
});
|
|
11287
|
+
continue;
|
|
11288
|
+
}
|
|
11289
|
+
for (const root of resolved) roots.add(root);
|
|
11290
|
+
}
|
|
11291
|
+
return { roots, inertWorkspaces };
|
|
11292
|
+
}
|
|
11293
|
+
var Tally = class {
|
|
11294
|
+
constructor(declaredRoots) {
|
|
11295
|
+
this.declaredRoots = declaredRoots;
|
|
11296
|
+
}
|
|
11297
|
+
declaredRoots;
|
|
11298
|
+
groups = /* @__PURE__ */ new Map();
|
|
11299
|
+
kinds = /* @__PURE__ */ new Map();
|
|
11300
|
+
attributed = 0;
|
|
11301
|
+
scanned = 0;
|
|
11302
|
+
unplaceable = 0;
|
|
11303
|
+
unreadable = 0;
|
|
11304
|
+
/**
|
|
11305
|
+
* Record one source log. `listedByImport` is whether the importer would even
|
|
11306
|
+
* read this file (the Claude directory guard); a cwd match on a file import
|
|
11307
|
+
* never lists is not capture.
|
|
11308
|
+
*/
|
|
11309
|
+
async add(file, source, readCwd, listedByImport) {
|
|
11310
|
+
this.scanned++;
|
|
11311
|
+
const cwd = await readCwd(file);
|
|
11312
|
+
if (cwd === null) {
|
|
11313
|
+
this.unreadable++;
|
|
11314
|
+
return;
|
|
11315
|
+
}
|
|
11316
|
+
if (cwd === void 0) {
|
|
11317
|
+
this.unplaceable++;
|
|
11318
|
+
return;
|
|
11319
|
+
}
|
|
11320
|
+
const declared = this.declaredRoots.has(cwd);
|
|
11321
|
+
if (declared && listedByImport) {
|
|
11322
|
+
this.attributed++;
|
|
11323
|
+
return;
|
|
11324
|
+
}
|
|
11325
|
+
const kind = declared ? "dir_not_listed" : enclosingRoot(cwd, this.declaredRoots) !== void 0 ? "below_declared_root" : "no_declared_root";
|
|
11326
|
+
this.kinds.set(cwd, kind);
|
|
11327
|
+
const group = this.groups.get(cwd);
|
|
11328
|
+
if (group === void 0) this.groups.set(cwd, { logs: 1, sources: /* @__PURE__ */ new Set([source]) });
|
|
11329
|
+
else {
|
|
11330
|
+
group.logs++;
|
|
11331
|
+
group.sources.add(source);
|
|
11332
|
+
}
|
|
11333
|
+
}
|
|
11334
|
+
finish(absentTrees, inertWorkspaces) {
|
|
11335
|
+
const groups = [];
|
|
11336
|
+
for (const [cwd, { logs, sources }] of this.groups) {
|
|
11337
|
+
const kind = this.kinds.get(cwd) ?? "no_declared_root";
|
|
11338
|
+
const declaredRoot = kind === "below_declared_root" ? enclosingRoot(cwd, this.declaredRoots) : void 0;
|
|
11339
|
+
groups.push({
|
|
11340
|
+
cwd,
|
|
11341
|
+
logs,
|
|
11342
|
+
sources: [...sources].sort(),
|
|
11343
|
+
kind,
|
|
11344
|
+
...declaredRoot !== void 0 ? { declaredRoot } : {}
|
|
11345
|
+
});
|
|
11346
|
+
}
|
|
11347
|
+
const rank = (k) => k === "dir_not_listed" ? 0 : k === "below_declared_root" ? 1 : 2;
|
|
11348
|
+
groups.sort((a, b) => {
|
|
11349
|
+
if (a.kind !== b.kind) return rank(a.kind) - rank(b.kind);
|
|
11350
|
+
if (a.logs !== b.logs) return b.logs - a.logs;
|
|
11351
|
+
return a.cwd < b.cwd ? -1 : a.cwd > b.cwd ? 1 : 0;
|
|
11352
|
+
});
|
|
11353
|
+
return {
|
|
11354
|
+
logsScanned: this.scanned,
|
|
11355
|
+
attributed: this.attributed,
|
|
11356
|
+
groups,
|
|
11357
|
+
unplaceable: this.unplaceable,
|
|
11358
|
+
unreadable: this.unreadable,
|
|
11359
|
+
absentTrees,
|
|
11360
|
+
inertWorkspaces
|
|
11361
|
+
};
|
|
11362
|
+
}
|
|
11363
|
+
};
|
|
11364
|
+
function enclosingRoot(cwd, roots) {
|
|
11365
|
+
for (const root of roots) {
|
|
11366
|
+
if (cwd.startsWith(root.endsWith("/") ? root : `${root}/`)) return root;
|
|
11367
|
+
}
|
|
11368
|
+
return void 0;
|
|
11369
|
+
}
|
|
11370
|
+
async function isDirEntry(parent, entry) {
|
|
11371
|
+
if (entry.isDirectory()) return true;
|
|
11372
|
+
if (!entry.isSymbolicLink()) return false;
|
|
11373
|
+
try {
|
|
11374
|
+
return (await stat6(join17(parent, entry.name))).isDirectory();
|
|
11375
|
+
} catch {
|
|
11376
|
+
return false;
|
|
11377
|
+
}
|
|
11378
|
+
}
|
|
11379
|
+
async function listClaudeTranscripts(projectsRoot) {
|
|
11380
|
+
let entries;
|
|
11381
|
+
try {
|
|
11382
|
+
entries = await readdir3(projectsRoot, { withFileTypes: true });
|
|
11383
|
+
} catch {
|
|
11384
|
+
return void 0;
|
|
11385
|
+
}
|
|
11386
|
+
const files = [];
|
|
11387
|
+
for (const entry of entries) {
|
|
11388
|
+
if (!await isDirEntry(projectsRoot, entry)) continue;
|
|
11389
|
+
const full = join17(projectsRoot, entry.name);
|
|
11390
|
+
let names;
|
|
11391
|
+
try {
|
|
11392
|
+
names = await readdir3(full);
|
|
11393
|
+
} catch {
|
|
11394
|
+
continue;
|
|
11395
|
+
}
|
|
11396
|
+
for (const name of names) {
|
|
11397
|
+
if (name.endsWith(".jsonl")) files.push(join17(full, name));
|
|
11398
|
+
}
|
|
11399
|
+
}
|
|
11400
|
+
return files.sort();
|
|
11401
|
+
}
|
|
11402
|
+
async function listCodexRollouts(sessionsRoot) {
|
|
11403
|
+
const found = [];
|
|
11404
|
+
const walk = async (dir) => {
|
|
11405
|
+
let entries;
|
|
11406
|
+
try {
|
|
11407
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
11408
|
+
} catch {
|
|
11409
|
+
return;
|
|
11410
|
+
}
|
|
11411
|
+
for (const entry of entries) {
|
|
11412
|
+
const full = join17(dir, entry.name);
|
|
11413
|
+
if (entry.isDirectory()) await walk(full);
|
|
11414
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
|
|
11415
|
+
found.push(full);
|
|
11416
|
+
}
|
|
11417
|
+
}
|
|
11418
|
+
};
|
|
11419
|
+
try {
|
|
11420
|
+
await readdir3(sessionsRoot);
|
|
11421
|
+
} catch {
|
|
11422
|
+
return void 0;
|
|
11423
|
+
}
|
|
11424
|
+
await walk(sessionsRoot);
|
|
11425
|
+
return found.sort();
|
|
11426
|
+
}
|
|
11427
|
+
async function claudeTranscriptCwd(file) {
|
|
11428
|
+
const stream = createReadStream2(file, { encoding: "utf8" });
|
|
11429
|
+
const lines = createInterface2({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
11430
|
+
try {
|
|
11431
|
+
for await (const line of lines) {
|
|
11432
|
+
if (line.length === 0) continue;
|
|
11433
|
+
let record;
|
|
11434
|
+
try {
|
|
11435
|
+
record = JSON.parse(line);
|
|
11436
|
+
} catch {
|
|
11437
|
+
continue;
|
|
11438
|
+
}
|
|
11439
|
+
if (typeof record !== "object" || record === null || Array.isArray(record)) continue;
|
|
11440
|
+
const cwd = record.cwd;
|
|
11441
|
+
if (typeof cwd === "string" && cwd.length > 0) return cwd;
|
|
11442
|
+
}
|
|
11443
|
+
return void 0;
|
|
11444
|
+
} catch {
|
|
11445
|
+
return null;
|
|
11446
|
+
} finally {
|
|
11447
|
+
lines.close();
|
|
11448
|
+
stream.destroy();
|
|
11449
|
+
}
|
|
11450
|
+
}
|
|
11451
|
+
async function codexRolloutCwd(file) {
|
|
11452
|
+
const meta = await readRolloutMeta(file);
|
|
11453
|
+
return meta === void 0 ? void 0 : meta.cwd;
|
|
11454
|
+
}
|
|
11455
|
+
var GROUP_LIST_CAP = 10;
|
|
11456
|
+
function groupNote(group) {
|
|
11457
|
+
if (group.kind === "below_declared_root") {
|
|
11458
|
+
return ` \u2014 inside declared root ${group.declaredRoot}, and the recorded cwd must EQUAL a source root`;
|
|
11459
|
+
}
|
|
11460
|
+
if (group.kind === "dir_not_listed") {
|
|
11461
|
+
return " \u2014 this cwd IS a declared root, but the transcript's per-project directory is not one import lists";
|
|
11462
|
+
}
|
|
11463
|
+
return "";
|
|
11464
|
+
}
|
|
11465
|
+
function formatCoverageReport(result) {
|
|
11466
|
+
const lines = [];
|
|
11467
|
+
if (result.logsScanned === 0) {
|
|
11468
|
+
const where = result.absentTrees.length > 0 ? ` (no source logs found: ${result.absentTrees.join(", ")})` : "";
|
|
11469
|
+
lines.push(
|
|
11470
|
+
`Capture coverage: nothing to check \u2014 no native session logs on this machine${where}.`
|
|
11471
|
+
);
|
|
11472
|
+
return [...lines, ...inertLines(result)];
|
|
11473
|
+
}
|
|
11474
|
+
const total = uncapturedTotal(result);
|
|
11475
|
+
const caveats = [];
|
|
11476
|
+
if (result.unreadable > 0) caveats.push(`${result.unreadable} unreadable, verdict unknown`);
|
|
11477
|
+
if (result.absentTrees.length > 0) caveats.push(`not scanned: ${result.absentTrees.join(", ")}`);
|
|
11478
|
+
const caveat = caveats.length > 0 ? ` (${caveats.join("; ")})` : "";
|
|
11479
|
+
if (total === 0 && result.unreadable === 0) {
|
|
11480
|
+
lines.push(
|
|
11481
|
+
`Capture coverage: OK. ${result.logsScanned} source log(s) scanned, all imported by a registered workspace${caveat}.`
|
|
11482
|
+
);
|
|
11483
|
+
return [...lines, ...inertLines(result)];
|
|
11484
|
+
}
|
|
11485
|
+
const pct = Math.round(total / result.logsScanned * 100);
|
|
11486
|
+
lines.push(
|
|
11487
|
+
`Capture coverage: ${total} of ${result.logsScanned} source log(s) (${pct}%) are imported by no registered workspace${caveat}:`
|
|
11488
|
+
);
|
|
11489
|
+
for (const g of result.groups.slice(0, GROUP_LIST_CAP)) {
|
|
11490
|
+
const via = g.sources.join("+");
|
|
11491
|
+
lines.push(` ${String(g.logs).padStart(4)} ${g.cwd} (${via})${groupNote(g)}`);
|
|
11492
|
+
}
|
|
11493
|
+
const rest = result.groups.length - GROUP_LIST_CAP;
|
|
11494
|
+
if (rest > 0) {
|
|
11495
|
+
const restLogs = result.groups.slice(GROUP_LIST_CAP).reduce((sum, g) => sum + g.logs, 0);
|
|
11496
|
+
lines.push(
|
|
11497
|
+
` \u2026 +${rest} more working director${rest === 1 ? "y" : "ies"} (${restLogs} log(s))`
|
|
11498
|
+
);
|
|
11499
|
+
}
|
|
11500
|
+
if (result.unplaceable > 0) {
|
|
11501
|
+
lines.push(
|
|
11502
|
+
` ${String(result.unplaceable).padStart(4)} (no directory to name: the log records no cwd import can use, so import drops it)`
|
|
11503
|
+
);
|
|
11504
|
+
}
|
|
11505
|
+
if (result.groups.some((g) => g.kind === "below_declared_root")) {
|
|
11506
|
+
lines.push(
|
|
11507
|
+
"A cwd inside a declared root is dropped by the exact-match rule: the enclosing declaration does not cover it. Declaring that subdirectory itself as a further import.source_roots entry captures it."
|
|
11508
|
+
);
|
|
11509
|
+
}
|
|
11510
|
+
if (result.groups.some((g) => g.kind === "dir_not_listed")) {
|
|
11511
|
+
lines.push(
|
|
11512
|
+
"A transcript whose cwd IS declared but whose per-project directory no declared root encodes to is never listed by the importer. Check that the workspace is registered by the same path spelling the sessions ran in."
|
|
11513
|
+
);
|
|
11514
|
+
}
|
|
11515
|
+
lines.push(
|
|
11516
|
+
"Register the project in a workspace's import.source_roots to start capturing it (registering a path in ~/.basou/portfolio.yaml alone imports nothing \u2014 it only adds the workspace to this view). A scratch directory, a temp path, or a GUI tool's own working directory has no repo to declare and is expected to stay here."
|
|
11517
|
+
);
|
|
11518
|
+
return [...lines, ...inertLines(result)];
|
|
11519
|
+
}
|
|
11520
|
+
function inertLines(result) {
|
|
11521
|
+
if (result.inertWorkspaces.length === 0) return [];
|
|
11522
|
+
const detail = {
|
|
11523
|
+
not_a_git_repo: "not a git repository",
|
|
11524
|
+
no_store: "no .basou store (never initialized)",
|
|
11525
|
+
unreadable_store: "the .basou manifest is unreadable"
|
|
11526
|
+
};
|
|
11527
|
+
const n = result.inertWorkspaces.length;
|
|
11528
|
+
const lines = [
|
|
11529
|
+
`Capture coverage: ${n} registered entr${n === 1 ? "y" : "ies"} import cannot run in, so ${n === 1 ? "it declares" : "they declare"} no source roots:`
|
|
11530
|
+
];
|
|
11531
|
+
for (const ws of result.inertWorkspaces) {
|
|
11532
|
+
lines.push(` ${ws.path} \u2014 ${detail[ws.reason]}`);
|
|
11533
|
+
}
|
|
11534
|
+
return lines;
|
|
11535
|
+
}
|
|
11536
|
+
|
|
10729
11537
|
// src/lib/portfolio-safety.ts
|
|
10730
11538
|
import { execFile } from "child_process";
|
|
10731
|
-
import { lstat as lstat2, realpath as
|
|
10732
|
-
import { isAbsolute as isAbsolute7, join as
|
|
11539
|
+
import { lstat as lstat2, realpath as realpath3 } from "fs/promises";
|
|
11540
|
+
import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
|
|
10733
11541
|
import { promisify } from "util";
|
|
10734
|
-
import { readManifest as
|
|
11542
|
+
import { readManifest as readManifest14 } from "@basou/core";
|
|
10735
11543
|
var execFileAsync = promisify(execFile);
|
|
10736
11544
|
function errorCode(error) {
|
|
10737
11545
|
return error instanceof Error ? error.code : void 0;
|
|
10738
11546
|
}
|
|
10739
11547
|
async function canonical(p) {
|
|
10740
11548
|
try {
|
|
10741
|
-
return await
|
|
11549
|
+
return await realpath3(p);
|
|
10742
11550
|
} catch {
|
|
10743
11551
|
return resolve11(p);
|
|
10744
11552
|
}
|
|
@@ -10753,7 +11561,7 @@ function isBasouPath(p) {
|
|
|
10753
11561
|
async function inspectRepo(repoPath) {
|
|
10754
11562
|
let hasEntry = false;
|
|
10755
11563
|
try {
|
|
10756
|
-
await lstat2(
|
|
11564
|
+
await lstat2(join18(repoPath, ".basou"));
|
|
10757
11565
|
hasEntry = true;
|
|
10758
11566
|
} catch (error) {
|
|
10759
11567
|
if (errorCode(error) !== "ENOENT") {
|
|
@@ -10787,7 +11595,7 @@ async function checkPortfolioSafety(workspaces) {
|
|
|
10787
11595
|
let viewPath;
|
|
10788
11596
|
let isMaster = false;
|
|
10789
11597
|
try {
|
|
10790
|
-
const manifest = await
|
|
11598
|
+
const manifest = await readManifest14(ws.paths);
|
|
10791
11599
|
sourceRoots = manifest.import?.source_roots ?? [];
|
|
10792
11600
|
viewPath = manifest.workspace.view;
|
|
10793
11601
|
isMaster = true;
|
|
@@ -10907,7 +11715,7 @@ function formatSafetyReport(result) {
|
|
|
10907
11715
|
|
|
10908
11716
|
// src/lib/view-server.ts
|
|
10909
11717
|
import { createServer } from "http";
|
|
10910
|
-
import { basename as
|
|
11718
|
+
import { basename as basename8, join as join19, resolve as resolve12 } from "path";
|
|
10911
11719
|
import {
|
|
10912
11720
|
computeWorkStats as computeWorkStats2,
|
|
10913
11721
|
enumerateApprovals as enumerateApprovals2,
|
|
@@ -10917,8 +11725,8 @@ import {
|
|
|
10917
11725
|
loadSessionEntries as loadSessionEntries4,
|
|
10918
11726
|
loadTaskEntries as loadTaskEntries2,
|
|
10919
11727
|
readAllEvents as readAllEvents2,
|
|
10920
|
-
readManifest as
|
|
10921
|
-
readMarkdownFile as
|
|
11728
|
+
readManifest as readManifest15,
|
|
11729
|
+
readMarkdownFile as readMarkdownFile8,
|
|
10922
11730
|
readSessionYaml as readSessionYaml3,
|
|
10923
11731
|
readTaskFile as readTaskFile2,
|
|
10924
11732
|
renderDecisions as renderDecisions3,
|
|
@@ -11873,7 +12681,7 @@ async function captureStaleness(ws, nowIso) {
|
|
|
11873
12681
|
async function overview(ws, nowProvider, resolveRemoteUrl) {
|
|
11874
12682
|
let manifest;
|
|
11875
12683
|
try {
|
|
11876
|
-
manifest = await
|
|
12684
|
+
manifest = await readManifest15(ws.paths);
|
|
11877
12685
|
} catch (error) {
|
|
11878
12686
|
if (findErrorCode17(error, "ENOENT")) {
|
|
11879
12687
|
return { initialized: false, repoRoot: ws.repoRoot };
|
|
@@ -11913,7 +12721,7 @@ async function rosterRepos(repoRoot, manifest, resolveRemoteUrl) {
|
|
|
11913
12721
|
const remote = await resolveRemoteUrl(abs);
|
|
11914
12722
|
const url = remote !== void 0 ? toBrowserUrl(remote) : null;
|
|
11915
12723
|
return {
|
|
11916
|
-
name:
|
|
12724
|
+
name: basename8(abs),
|
|
11917
12725
|
path: repo.path,
|
|
11918
12726
|
...url !== null ? { url } : {},
|
|
11919
12727
|
...repo.visibility !== void 0 ? { visibility: repo.visibility } : {}
|
|
@@ -11948,7 +12756,7 @@ async function sessionDetail(ws, sessionId) {
|
|
|
11948
12756
|
throw error;
|
|
11949
12757
|
}
|
|
11950
12758
|
try {
|
|
11951
|
-
const events = await readAllEvents2(
|
|
12759
|
+
const events = await readAllEvents2(join19(ws.paths.sessions, sessionId));
|
|
11952
12760
|
return { session, events };
|
|
11953
12761
|
} catch {
|
|
11954
12762
|
return { session, events: [], degraded: true };
|
|
@@ -11970,7 +12778,7 @@ async function taskDetail(ws, taskId) {
|
|
|
11970
12778
|
}
|
|
11971
12779
|
}
|
|
11972
12780
|
async function decisionsView(ws, nowProvider) {
|
|
11973
|
-
const fromDisk = await
|
|
12781
|
+
const fromDisk = await readMarkdownFile8(ws.paths.files.decisions);
|
|
11974
12782
|
if (fromDisk !== null) {
|
|
11975
12783
|
return { body: fromDisk, fromDisk: true };
|
|
11976
12784
|
}
|
|
@@ -11993,7 +12801,7 @@ async function approvalsView(ws, nowProvider) {
|
|
|
11993
12801
|
return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
|
|
11994
12802
|
}
|
|
11995
12803
|
async function handoffView(ws, nowProvider) {
|
|
11996
|
-
const fromDisk = await
|
|
12804
|
+
const fromDisk = await readMarkdownFile8(ws.paths.files.handoff);
|
|
11997
12805
|
if (fromDisk !== null) {
|
|
11998
12806
|
return { body: fromDisk, fromDisk: true };
|
|
11999
12807
|
}
|
|
@@ -12103,7 +12911,10 @@ function registerViewCommand(program2) {
|
|
|
12103
12911
|
"--workspace <path>",
|
|
12104
12912
|
"Workspace repo path to include (repeatable; implies portfolio mode; resolved against the cwd)",
|
|
12105
12913
|
collectPath3
|
|
12106
|
-
).option(
|
|
12914
|
+
).option(
|
|
12915
|
+
"--check",
|
|
12916
|
+
"Run the read-only preflight and exit (no server): the portfolio safety check, plus \u2014 in portfolio mode \u2014 a capture-coverage report of session logs no registered workspace imports"
|
|
12917
|
+
).option("--skip-safety-check", "Skip the portfolio safety preflight on start (not recommended)").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
12107
12918
|
await runView(options);
|
|
12108
12919
|
});
|
|
12109
12920
|
}
|
|
@@ -12119,10 +12930,19 @@ async function doRunView(options, ctx) {
|
|
|
12119
12930
|
const cwd = ctx.cwd ?? process.cwd();
|
|
12120
12931
|
const workspaceFlags = options.workspace ?? [];
|
|
12121
12932
|
const isPortfolio = workspaceFlags.length > 0 || options.portfolio === true;
|
|
12933
|
+
const isWholeRegistry = options.portfolio === true && workspaceFlags.length === 0;
|
|
12122
12934
|
const deps = isPortfolio ? await buildPortfolioDeps(workspaceFlags, ctx, cwd) : await buildSingleDeps(ctx, cwd);
|
|
12123
12935
|
if (options.check === true) {
|
|
12124
12936
|
const result = await checkPortfolioSafety(deps.workspaces);
|
|
12125
12937
|
for (const line of formatSafetyReport(result)) console.log(line);
|
|
12938
|
+
if (isWholeRegistry) {
|
|
12939
|
+
const coverage = await checkPortfolioCoverage(deps.workspaces, {
|
|
12940
|
+
...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
|
|
12941
|
+
...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
|
|
12942
|
+
});
|
|
12943
|
+
console.log("");
|
|
12944
|
+
for (const line of formatCoverageReport(coverage)) console.log(line);
|
|
12945
|
+
}
|
|
12126
12946
|
if (result.findings.length > 0) process.exitCode = 1;
|
|
12127
12947
|
return;
|
|
12128
12948
|
}
|
|
@@ -12162,7 +12982,7 @@ async function doRunView(options, ctx) {
|
|
|
12162
12982
|
}
|
|
12163
12983
|
async function buildSingleDeps(ctx, cwd) {
|
|
12164
12984
|
const repositoryRoot = await resolveRepositoryRootForView(cwd);
|
|
12165
|
-
const paths =
|
|
12985
|
+
const paths = basouPaths22(repositoryRoot);
|
|
12166
12986
|
await assertWorkspaceInitialized15(paths.root);
|
|
12167
12987
|
const entry = await buildWorkspaceEntry(repositoryRoot, ctx);
|
|
12168
12988
|
return {
|
|
@@ -12196,14 +13016,14 @@ async function buildPortfolioDeps(workspaceFlags, ctx, cwd) {
|
|
|
12196
13016
|
};
|
|
12197
13017
|
}
|
|
12198
13018
|
async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
|
|
12199
|
-
const paths =
|
|
13019
|
+
const paths = basouPaths22(repoRoot);
|
|
12200
13020
|
const importCtx = {
|
|
12201
13021
|
cwd: repoRoot,
|
|
12202
13022
|
...ctx.claudeProjectsDir !== void 0 ? { claudeProjectsDir: ctx.claudeProjectsDir } : {},
|
|
12203
13023
|
...ctx.codexSessionsDir !== void 0 ? { codexSessionsDir: ctx.codexSessionsDir } : {}
|
|
12204
13024
|
};
|
|
12205
13025
|
try {
|
|
12206
|
-
const manifest = await
|
|
13026
|
+
const manifest = await readManifest16(paths);
|
|
12207
13027
|
return {
|
|
12208
13028
|
key: manifest.workspace.id,
|
|
12209
13029
|
label: labelOverride ?? manifest.workspace.name,
|
|
@@ -12215,8 +13035,8 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
|
|
|
12215
13035
|
} catch (error) {
|
|
12216
13036
|
const notFound = error instanceof Error && error.message === "YAML file not found";
|
|
12217
13037
|
return {
|
|
12218
|
-
key: `ws-${
|
|
12219
|
-
label: labelOverride ??
|
|
13038
|
+
key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
|
|
13039
|
+
label: labelOverride ?? basename9(repoRoot),
|
|
12220
13040
|
paths,
|
|
12221
13041
|
repoRoot,
|
|
12222
13042
|
importCtx,
|
|
@@ -12283,7 +13103,7 @@ function waitForShutdown(signal) {
|
|
|
12283
13103
|
}
|
|
12284
13104
|
async function resolveRepositoryRootForView(cwd) {
|
|
12285
13105
|
try {
|
|
12286
|
-
return await
|
|
13106
|
+
return await resolveRepositoryRoot15(cwd);
|
|
12287
13107
|
} catch (error) {
|
|
12288
13108
|
if (error instanceof Error && error.message === "Not a git repository") {
|
|
12289
13109
|
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou view'.", {
|
|
@@ -12334,6 +13154,7 @@ function buildProgram() {
|
|
|
12334
13154
|
registerReviewGapsCommand(program2);
|
|
12335
13155
|
registerProjectCommand(program2);
|
|
12336
13156
|
registerProtocolCommand(program2);
|
|
13157
|
+
registerChannelCommand(program2);
|
|
12337
13158
|
registerHookCommand(program2);
|
|
12338
13159
|
return program2;
|
|
12339
13160
|
}
|