@basou/cli 0.39.0 → 0.41.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 +1495 -928
- package/dist/index.js.map +1 -1
- package/dist/program.js +1495 -928
- package/dist/program.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -699,7 +699,6 @@ async function writeFileDurable(targetPath, content) {
|
|
|
699
699
|
// src/lib/context-channel.ts
|
|
700
700
|
var CODEX_TARGET_PATH = join3(homedir(), ".codex", "AGENTS.md");
|
|
701
701
|
var ORIENTATION_MARKERS = { start: ORIENTATION_START, end: ORIENTATION_END };
|
|
702
|
-
var ORIENTATION_MANAGED_NOTE = "<!-- Managed by basou: 'basou refresh' regenerates everything between the BASOU:ORIENTATION markers with the workspace's current position. This block is transient \u2014 it changes every refresh; do not edit it. -->";
|
|
703
702
|
function buildTargetBody(existing, block, markers) {
|
|
704
703
|
const wrapped = `${markers.start}
|
|
705
704
|
${block}${markers.end}
|
|
@@ -711,8 +710,8 @@ ${block}${markers.end}
|
|
|
711
710
|
return `${section.before}${markers.start}
|
|
712
711
|
${block}${markers.end}${section.after}`;
|
|
713
712
|
case "no_markers": {
|
|
714
|
-
const
|
|
715
|
-
return `${existing}${
|
|
713
|
+
const sep2 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
714
|
+
return `${existing}${sep2}${wrapped}`;
|
|
716
715
|
}
|
|
717
716
|
default:
|
|
718
717
|
throw new Error(
|
|
@@ -772,19 +771,6 @@ async function removeMarkerBlock(opts) {
|
|
|
772
771
|
await writeFileDurable(target, newBody);
|
|
773
772
|
return { removed: true };
|
|
774
773
|
}
|
|
775
|
-
async function syncOrientationChannel(opts) {
|
|
776
|
-
assertNoMarkerLine(opts.body, ORIENTATION_MARKERS);
|
|
777
|
-
const block = `${ORIENTATION_MANAGED_NOTE}
|
|
778
|
-
|
|
779
|
-
${opts.body.replace(/\s+$/, "")}
|
|
780
|
-
`;
|
|
781
|
-
return syncMarkerBlock({
|
|
782
|
-
target: opts.target ?? CODEX_TARGET_PATH,
|
|
783
|
-
markers: ORIENTATION_MARKERS,
|
|
784
|
-
block,
|
|
785
|
-
...opts.dryRun === true ? { dryRun: true } : {}
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
774
|
async function clearOrientationChannel(opts) {
|
|
789
775
|
return removeMarkerBlock({
|
|
790
776
|
target: opts.target ?? CODEX_TARGET_PATH,
|
|
@@ -798,29 +784,17 @@ async function clearOrientationChannel(opts) {
|
|
|
798
784
|
...opts.dryRun === true ? { dryRun: true } : {}
|
|
799
785
|
});
|
|
800
786
|
}
|
|
801
|
-
async function renderOrientationToCodexChannel(opts) {
|
|
802
|
-
const body = await readMarkdownFile(opts.orientationPath);
|
|
803
|
-
if (body === null) return null;
|
|
804
|
-
const { action } = await syncOrientationChannel({
|
|
805
|
-
body,
|
|
806
|
-
...opts.channelPath !== void 0 ? { target: opts.channelPath } : {}
|
|
807
|
-
});
|
|
808
|
-
return {
|
|
809
|
-
action,
|
|
810
|
-
line: `codex channel: orientation ${action} in ${opts.channelPath ?? "~/.codex/AGENTS.md"}`
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
787
|
|
|
814
788
|
// src/commands/channel.ts
|
|
815
789
|
function registerChannelCommand(program2) {
|
|
816
790
|
const channel = program2.command("channel").description(
|
|
817
|
-
"Manage the user-global context faces
|
|
791
|
+
"Manage the user-global context faces an AI tool auto-loads for every project (~/.codex/AGENTS.md): remove what an earlier basou rendered there"
|
|
818
792
|
);
|
|
819
793
|
channel.command("clear").argument(
|
|
820
794
|
"<face>",
|
|
821
795
|
"the face to clear: `codex` (the basou:orientation block in ~/.codex/AGENTS.md)"
|
|
822
796
|
).description(
|
|
823
|
-
"Remove
|
|
797
|
+
"Remove the orientation block an older basou rendered into a user-global context face, so no workspace's position is left in a file that another project's tool reads"
|
|
824
798
|
).option("--dry-run", "Report whether a block would be removed without writing").option("--json", "Output the result as JSON").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (face, opts) => {
|
|
825
799
|
await runChannelClear(face, opts);
|
|
826
800
|
});
|
|
@@ -859,7 +833,7 @@ async function doRunChannelClear(face, options) {
|
|
|
859
833
|
console.log(`[dry-run] Would remove the basou:orientation block from ${label}.`);
|
|
860
834
|
} else {
|
|
861
835
|
console.log(
|
|
862
|
-
`Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file
|
|
836
|
+
`Removed the basou:orientation block from ${label}. Nothing basou wrote remains in that file, and nothing renders it again.`
|
|
863
837
|
);
|
|
864
838
|
}
|
|
865
839
|
return result;
|
|
@@ -2267,340 +2241,357 @@ async function assertWorkspaceInitialized4(basouRoot) {
|
|
|
2267
2241
|
}
|
|
2268
2242
|
|
|
2269
2243
|
// src/commands/hook.ts
|
|
2270
|
-
import { open as open2, readFile as
|
|
2271
|
-
import { homedir as
|
|
2272
|
-
import { join as
|
|
2244
|
+
import { open as open2, readFile as readFile3, realpath as realpath3, stat as stat4 } from "fs/promises";
|
|
2245
|
+
import { homedir as homedir7 } from "os";
|
|
2246
|
+
import { join as join9 } from "path";
|
|
2273
2247
|
import { fileURLToPath } from "url";
|
|
2274
2248
|
import {
|
|
2249
|
+
buildSessionStartHookCommand,
|
|
2275
2250
|
buildStopHookCommand,
|
|
2276
2251
|
DEFAULT_STOP_HOOK_MIN_EDITS,
|
|
2277
2252
|
evaluateStopHook,
|
|
2253
|
+
findBasouSessionStartHook,
|
|
2278
2254
|
findBasouStopHookCommand,
|
|
2255
|
+
ORIENTATION_END as ORIENTATION_END2,
|
|
2256
|
+
ORIENTATION_START as ORIENTATION_START2,
|
|
2257
|
+
parseMarkers as parseMarkers2,
|
|
2258
|
+
readMarkdownFile as readMarkdownFile6,
|
|
2259
|
+
removeSessionStartHook,
|
|
2279
2260
|
removeStopHook,
|
|
2261
|
+
upsertSessionStartHook,
|
|
2280
2262
|
upsertStopHook
|
|
2281
2263
|
} from "@basou/core";
|
|
2282
|
-
var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
|
|
2283
|
-
function registerHookCommand(program2) {
|
|
2284
|
-
const hook = program2.command("hook").description(
|
|
2285
|
-
"Claude Code hook handlers (read a hook payload on stdin, emit hook JSON on stdout)"
|
|
2286
|
-
);
|
|
2287
|
-
hook.command("stop").description(
|
|
2288
|
-
"Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
|
|
2289
|
-
).option(
|
|
2290
|
-
"--min-edits <n>",
|
|
2291
|
-
`Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
|
|
2292
|
-
).option(
|
|
2293
|
-
"--block",
|
|
2294
|
-
"Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
|
|
2295
|
-
).option(
|
|
2296
|
-
"--require-review",
|
|
2297
|
-
"Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
|
|
2298
|
-
).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
|
|
2299
|
-
const minEdits = parseMinEdits(options.minEdits);
|
|
2300
|
-
await runHookStop({
|
|
2301
|
-
...minEdits !== void 0 ? { minEdits } : {},
|
|
2302
|
-
...options.block === true ? { block: true } : {},
|
|
2303
|
-
...options.requireReview === true ? { requireReview: true } : {}
|
|
2304
|
-
});
|
|
2305
|
-
});
|
|
2306
|
-
hook.command("install").description(
|
|
2307
|
-
"Register the Stop hook in ~/.claude/settings.json (reproducible, idempotent). Default is advisory capture-only; --block opts into in-turn enforcement, --require-review opts into the review gate."
|
|
2308
|
-
).option("--block", "Register the blocking (opt-in enforcement) form instead of advisory").option("--require-review", "Register with the opt-in review gate enabled").option("--min-edits <n>", "Pass a custom file-edit threshold to the registered hook").option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2309
|
-
await runHookInstall(opts);
|
|
2310
|
-
});
|
|
2311
|
-
hook.command("uninstall").description(
|
|
2312
|
-
"Remove the basou Stop hook from ~/.claude/settings.json (leaves other hooks intact)"
|
|
2313
|
-
).option("--settings <path>", "Override the settings.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2314
|
-
await runHookUninstall(opts);
|
|
2315
|
-
});
|
|
2316
|
-
hook.command("status").description("Report whether the basou Stop hook is registered, and in which mode").option("--settings <path>", "Override the settings.json path (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
2317
|
-
await runHookStatus(opts);
|
|
2318
|
-
});
|
|
2319
|
-
}
|
|
2320
|
-
var HOOK_STOP_HELP = `
|
|
2321
|
-
Register this Stop hook reproducibly with 'basou hook install' (it writes the
|
|
2322
|
-
correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
|
|
2323
|
-
removes it; 'basou hook status' reports whether it is registered.
|
|
2324
|
-
|
|
2325
|
-
On every turn end basou inspects the session transcript. If the session did
|
|
2326
|
-
content-substantive work but ran no capture verb ('basou decision capture' /
|
|
2327
|
-
'decision record' / 'note'), it reminds the agent to record the why / next step.
|
|
2328
|
-
Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
|
|
2329
|
-
answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
|
|
2330
|
-
git status) does NOT count.
|
|
2331
|
-
|
|
2332
|
-
With --require-review (opt-in, 'basou hook install --require-review') it also
|
|
2333
|
-
reminds when the session SHIPPED substantive code (git push / git merge /
|
|
2334
|
-
gh pr create|merge) without recording a review ('basou review record'). This
|
|
2335
|
-
gate is off by default; when on, its reminder is composed into the same
|
|
2336
|
-
envelope as the capture reminder.
|
|
2337
2264
|
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
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;
|
|
2350
2278
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
try {
|
|
2359
|
-
payload = JSON.parse(raw);
|
|
2360
|
-
} catch {
|
|
2361
|
-
return;
|
|
2362
|
-
}
|
|
2363
|
-
if (typeof payload !== "object" || payload === null) return;
|
|
2364
|
-
const fields = payload;
|
|
2365
|
-
if (fields.stop_hook_active === true) return;
|
|
2366
|
-
const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
|
|
2367
|
-
if (transcriptPath.length === 0) return;
|
|
2368
|
-
let transcript;
|
|
2369
|
-
try {
|
|
2370
|
-
transcript = await readTranscript(transcriptPath);
|
|
2371
|
-
} catch {
|
|
2372
|
-
return;
|
|
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;
|
|
2373
2286
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2287
|
+
return value;
|
|
2288
|
+
}
|
|
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})`;
|
|
2385
2381
|
}
|
|
2386
|
-
if (parts.length === 0) return;
|
|
2387
|
-
const reason = parts.join("\n\n");
|
|
2388
|
-
const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
|
|
2389
|
-
hookSpecificOutput: {
|
|
2390
|
-
hookEventName: "Stop",
|
|
2391
|
-
additionalContext: reason
|
|
2392
|
-
}
|
|
2393
|
-
});
|
|
2394
|
-
write(`${payloadJson}
|
|
2395
|
-
`);
|
|
2396
2382
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2383
|
+
|
|
2384
|
+
// src/lib/foreign-workspace-warn.ts
|
|
2385
|
+
import { realpath as realpath2 } from "fs/promises";
|
|
2386
|
+
import { dirname as dirname2 } from "path";
|
|
2387
|
+
import { readMarkdownFile as readMarkdownFile4 } from "@basou/core";
|
|
2388
|
+
|
|
2389
|
+
// src/lib/foreign-workspace-scan.ts
|
|
2390
|
+
import { homedir as osHomedir } from "os";
|
|
2391
|
+
import { basename as basename3, normalize, sep } from "path";
|
|
2392
|
+
var MIN_TOKEN_LENGTH = 4;
|
|
2393
|
+
function stripTrailingSep(p) {
|
|
2394
|
+
return p.length > 1 && p.endsWith(sep) ? p.slice(0, -sep.length) : p;
|
|
2395
|
+
}
|
|
2396
|
+
function toTildePath(absPath, home) {
|
|
2397
|
+
if (absPath === home) return "~";
|
|
2398
|
+
if (absPath.startsWith(home + sep)) return `~${absPath.slice(home.length)}`;
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
function encodedSpelling(name) {
|
|
2402
|
+
return name.replace(/[^a-zA-Z0-9]/g, "-");
|
|
2403
|
+
}
|
|
2404
|
+
function directoryNameSpellings(dir) {
|
|
2405
|
+
const paired = /^(.+)-(planning|workspace)$/.exec(dir);
|
|
2406
|
+
const plain = paired !== null ? [`${paired[1]}-planning`, `${paired[1]}-workspace`] : [dir, `${dir}-workspace`];
|
|
2407
|
+
return [...plain, ...plain.map(encodedSpelling)];
|
|
2408
|
+
}
|
|
2409
|
+
function nameTokensFor(workspacePath, home) {
|
|
2410
|
+
const abs = stripTrailingSep(normalize(workspacePath));
|
|
2411
|
+
const tokens = /* @__PURE__ */ new Set([abs]);
|
|
2412
|
+
const tilde = toTildePath(abs, home);
|
|
2413
|
+
if (tilde !== null) tokens.add(tilde);
|
|
2414
|
+
for (const name of directoryNameSpellings(basename3(abs))) tokens.add(name);
|
|
2415
|
+
return [...tokens].filter((t) => t.length >= MIN_TOKEN_LENGTH);
|
|
2416
|
+
}
|
|
2417
|
+
function isSpellingOfSelf(workspacePath, selfPath) {
|
|
2418
|
+
const own = new Set(directoryNameSpellings(basename3(stripTrailingSep(normalize(selfPath)))));
|
|
2419
|
+
return directoryNameSpellings(basename3(stripTrailingSep(normalize(workspacePath)))).some(
|
|
2420
|
+
(s) => own.has(s)
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
function scanForeignWorkspaceNames(input) {
|
|
2424
|
+
if (input.text.length === 0 || input.workspacePaths.length === 0) return [];
|
|
2425
|
+
const home = input.homedir ?? osHomedir();
|
|
2426
|
+
const own = input.selfPath === void 0 ? [] : nameTokensFor(input.selfPath, home);
|
|
2427
|
+
const lines = input.text.split("\n");
|
|
2428
|
+
const hits = [];
|
|
2429
|
+
for (const workspacePath of input.workspacePaths) {
|
|
2430
|
+
if (input.selfPath !== void 0 && isSpellingOfSelf(workspacePath, input.selfPath)) continue;
|
|
2431
|
+
const tokens = nameTokensFor(workspacePath, home).filter(
|
|
2432
|
+
(token) => !own.some((ownToken) => ownToken.includes(token))
|
|
2433
|
+
);
|
|
2434
|
+
if (tokens.length === 0) continue;
|
|
2435
|
+
const matched = /* @__PURE__ */ new Set();
|
|
2436
|
+
const matchedLines = [];
|
|
2437
|
+
for (const [index, line] of lines.entries()) {
|
|
2438
|
+
let lineMatched = false;
|
|
2439
|
+
for (const token of tokens) {
|
|
2440
|
+
if (line.includes(token)) {
|
|
2441
|
+
matched.add(token);
|
|
2442
|
+
lineMatched = true;
|
|
2443
|
+
}
|
|
2405
2444
|
}
|
|
2406
|
-
|
|
2445
|
+
if (lineMatched) matchedLines.push(index + 1);
|
|
2407
2446
|
}
|
|
2447
|
+
if (matchedLines.length === 0) continue;
|
|
2448
|
+
hits.push({
|
|
2449
|
+
workspacePath,
|
|
2450
|
+
tokens: [...matched].sort((a, b) => b.length - a.length || a.localeCompare(b)),
|
|
2451
|
+
lines: matchedLines
|
|
2452
|
+
});
|
|
2408
2453
|
}
|
|
2409
|
-
return
|
|
2454
|
+
return hits;
|
|
2410
2455
|
}
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2456
|
+
|
|
2457
|
+
// src/lib/foreign-workspace-warn.ts
|
|
2458
|
+
var MAX_LISTED_LINES = 5;
|
|
2459
|
+
async function canonicalize2(path) {
|
|
2460
|
+
try {
|
|
2461
|
+
return await realpath2(path);
|
|
2462
|
+
} catch {
|
|
2463
|
+
return path;
|
|
2416
2464
|
}
|
|
2417
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
2418
2465
|
}
|
|
2419
|
-
async function
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
const handle = await open2(path, "r");
|
|
2466
|
+
async function findForeignWorkspaceNames(args) {
|
|
2467
|
+
let workspacePaths;
|
|
2468
|
+
let selfPath;
|
|
2423
2469
|
try {
|
|
2424
|
-
const
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
return
|
|
2429
|
-
} finally {
|
|
2430
|
-
await handle.close();
|
|
2470
|
+
const workspaces = await loadPortfolioConfig(args.configPath);
|
|
2471
|
+
workspacePaths = await Promise.all(workspaces.map((w) => canonicalize2(w.path)));
|
|
2472
|
+
selfPath = args.selfPath === void 0 ? void 0 : await canonicalize2(args.selfPath);
|
|
2473
|
+
} catch {
|
|
2474
|
+
return null;
|
|
2431
2475
|
}
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
return
|
|
2440
|
-
}
|
|
2441
|
-
function
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2476
|
+
const hits = scanForeignWorkspaceNames({
|
|
2477
|
+
text: args.text,
|
|
2478
|
+
workspacePaths,
|
|
2479
|
+
selfPath
|
|
2480
|
+
});
|
|
2481
|
+
if (hits.length === 0) return null;
|
|
2482
|
+
const lines = [...new Set(hits.flatMap((h) => h.lines))].sort((a, b) => a - b);
|
|
2483
|
+
return { workspaceCount: hits.length, lines };
|
|
2484
|
+
}
|
|
2485
|
+
function describeForeignWorkspaceLines(lines) {
|
|
2486
|
+
const listed = lines.slice(0, MAX_LISTED_LINES).join(", ");
|
|
2487
|
+
const rest = lines.length - MAX_LISTED_LINES;
|
|
2488
|
+
const numbers = rest > 0 ? `${listed} and ${rest} more` : listed;
|
|
2489
|
+
return `${lines.length === 1 ? "line" : "lines"} ${numbers}`;
|
|
2490
|
+
}
|
|
2491
|
+
function positionForeignWorkspaceWarning(report, where) {
|
|
2492
|
+
const subject = report.workspaceCount === 1 ? "names another registered workspace" : `names ${report.workspaceCount} other registered workspaces`;
|
|
2493
|
+
return `basou: this workspace's position ${subject} (${where}, ${describeForeignWorkspaceLines(report.lines)}). The position is handed to the agent session running in this workspace, so those names travel with it. Advisory only: nothing was withheld.`;
|
|
2494
|
+
}
|
|
2495
|
+
function protocolForeignWorkspaceWarning(report) {
|
|
2496
|
+
const subject = report.workspaceCount === 1 ? "names a registered workspace" : `names ${report.workspaceCount} registered workspaces`;
|
|
2497
|
+
return `basou: the protocol block ${subject} (block ${describeForeignWorkspaceLines(report.lines)}, counted from the block's first line, not the file's). Standing protocols are written to the user-global CLAUDE.md, which every project on this machine loads, so a workspace-specific name there reaches every workspace's sessions. Only the basou-managed block is checked; the rest of that file is not basou's to inspect. Advisory only: nothing was withheld.`;
|
|
2498
|
+
}
|
|
2499
|
+
async function warnIfPositionNamesOtherWorkspaces(args) {
|
|
2500
|
+
try {
|
|
2501
|
+
const body = await readMarkdownFile4(args.paths.files.orientation);
|
|
2502
|
+
if (body === null) return;
|
|
2503
|
+
const report = await findForeignWorkspaceNames({
|
|
2504
|
+
text: body,
|
|
2505
|
+
selfPath: dirname2(args.paths.root),
|
|
2506
|
+
configPath: args.configPath
|
|
2507
|
+
});
|
|
2508
|
+
if (report !== null) {
|
|
2509
|
+
console.error(positionForeignWorkspaceWarning(report, args.paths.files.orientation));
|
|
2452
2510
|
}
|
|
2453
|
-
|
|
2511
|
+
} catch {
|
|
2454
2512
|
}
|
|
2455
|
-
return out;
|
|
2456
2513
|
}
|
|
2457
|
-
|
|
2514
|
+
|
|
2515
|
+
// src/commands/orient.ts
|
|
2516
|
+
import {
|
|
2517
|
+
assertBasouRootSafe as assertBasouRootSafe7,
|
|
2518
|
+
basouPaths as basouPaths8,
|
|
2519
|
+
findErrorCode as findErrorCode6,
|
|
2520
|
+
renderOrientation as renderOrientation2,
|
|
2521
|
+
writeMarkdownFile as writeMarkdownFile4
|
|
2522
|
+
} from "@basou/core";
|
|
2523
|
+
|
|
2524
|
+
// src/lib/hosts-config.ts
|
|
2525
|
+
import { homedir as homedir5 } from "os";
|
|
2526
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
|
|
2527
|
+
import { readYamlFile as readYamlFile4 } from "@basou/core";
|
|
2528
|
+
var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
|
|
2529
|
+
function expandTilde2(p) {
|
|
2530
|
+
if (p === "~") return homedir5();
|
|
2531
|
+
if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
|
|
2532
|
+
return p;
|
|
2533
|
+
}
|
|
2534
|
+
function isRecord2(value) {
|
|
2535
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2536
|
+
}
|
|
2537
|
+
async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
|
|
2458
2538
|
let raw;
|
|
2459
2539
|
try {
|
|
2460
|
-
raw = await
|
|
2540
|
+
raw = await readYamlFile4(configPath);
|
|
2461
2541
|
} catch (error) {
|
|
2462
|
-
if (error instanceof Error && error.
|
|
2463
|
-
return
|
|
2542
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
2543
|
+
return null;
|
|
2544
|
+
}
|
|
2545
|
+
if (error instanceof Error && error.message === "Failed to parse YAML content") {
|
|
2546
|
+
throw new Error("~/.basou/hosts.yaml is not valid YAML.");
|
|
2464
2547
|
}
|
|
2465
2548
|
throw error;
|
|
2466
2549
|
}
|
|
2467
|
-
if (raw.
|
|
2468
|
-
|
|
2469
|
-
return { raw, parsed: JSON.parse(raw) };
|
|
2470
|
-
} catch (error) {
|
|
2471
|
-
throw new Error(
|
|
2472
|
-
"The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
|
|
2473
|
-
{
|
|
2474
|
-
cause: error
|
|
2475
|
-
}
|
|
2476
|
-
);
|
|
2550
|
+
if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
|
|
2551
|
+
throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
|
|
2477
2552
|
}
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
const
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
if (
|
|
2553
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
2554
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
2555
|
+
const result = [];
|
|
2556
|
+
for (const entry of raw.hosts) {
|
|
2557
|
+
if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
|
|
2558
|
+
throw new Error("Each host needs a non-empty string 'label'.");
|
|
2559
|
+
}
|
|
2560
|
+
const label = entry.label.trim();
|
|
2561
|
+
if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
|
|
2562
|
+
throw new Error("Each host needs a non-empty string 'path'.");
|
|
2563
|
+
}
|
|
2564
|
+
const expanded = expandTilde2(entry.path.trim());
|
|
2565
|
+
if (!isAbsolute2(expanded)) {
|
|
2566
|
+
throw new Error("Host paths must be absolute (or start with '~').");
|
|
2567
|
+
}
|
|
2568
|
+
const abs = resolve4(expanded);
|
|
2569
|
+
if (seenPaths.has(abs)) continue;
|
|
2570
|
+
if (seenLabels.has(label)) {
|
|
2571
|
+
throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
|
|
2572
|
+
}
|
|
2573
|
+
seenPaths.add(abs);
|
|
2574
|
+
seenLabels.add(label);
|
|
2575
|
+
result.push({ label, path: abs });
|
|
2487
2576
|
}
|
|
2488
|
-
|
|
2489
|
-
}
|
|
2490
|
-
async function runHookInstall(options, ctx = {}) {
|
|
2491
|
-
try {
|
|
2492
|
-
await doRunHookInstall(normalizeInstallOptions(options), ctx);
|
|
2493
|
-
} catch (error) {
|
|
2494
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2495
|
-
process.exitCode = 1;
|
|
2496
|
-
}
|
|
2497
|
-
}
|
|
2498
|
-
async function doRunHookInstall(options, ctx = {}) {
|
|
2499
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2500
|
-
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
2501
|
-
const command = buildStopHookCommand({
|
|
2502
|
-
cliEntry,
|
|
2503
|
-
...options.block === true ? { block: true } : {},
|
|
2504
|
-
...options.requireReview === true ? { requireReview: true } : {},
|
|
2505
|
-
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
2506
|
-
});
|
|
2507
|
-
const mode = describeHookMode({
|
|
2508
|
-
block: options.block === true,
|
|
2509
|
-
review: options.requireReview === true
|
|
2510
|
-
});
|
|
2511
|
-
await assertNotSymlink(settingsPath);
|
|
2512
|
-
const { raw, parsed } = await readSettings(settingsPath);
|
|
2513
|
-
const { settings, action } = upsertStopHook(parsed, command);
|
|
2514
|
-
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
2515
|
-
`;
|
|
2516
|
-
if (raw !== null && newBody === raw) {
|
|
2517
|
-
console.log(`The basou Stop hook is already registered (${mode}); no change.`);
|
|
2518
|
-
return;
|
|
2519
|
-
}
|
|
2520
|
-
if (options.dryRun === true) {
|
|
2521
|
-
console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
|
|
2522
|
-
return;
|
|
2523
|
-
}
|
|
2524
|
-
const recheck = await readSettings(settingsPath);
|
|
2525
|
-
if (recheck.raw !== raw) {
|
|
2526
|
-
throw new Error(
|
|
2527
|
-
"The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
|
|
2528
|
-
);
|
|
2529
|
-
}
|
|
2530
|
-
await backupSettingsOnce(settingsPath, raw);
|
|
2531
|
-
await writeFileDurable(settingsPath, newBody);
|
|
2532
|
-
console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
|
|
2533
|
-
}
|
|
2534
|
-
async function runHookUninstall(options) {
|
|
2535
|
-
try {
|
|
2536
|
-
await doRunHookUninstall(normalizeInstallOptions(options));
|
|
2537
|
-
} catch (error) {
|
|
2538
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2539
|
-
process.exitCode = 1;
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
async function doRunHookUninstall(options) {
|
|
2543
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2544
|
-
await assertNotSymlink(settingsPath);
|
|
2545
|
-
const { raw, parsed } = await readSettings(settingsPath);
|
|
2546
|
-
if (raw === null) {
|
|
2547
|
-
console.log("No settings.json; nothing to remove.");
|
|
2548
|
-
return;
|
|
2549
|
-
}
|
|
2550
|
-
const { settings, action } = removeStopHook(parsed);
|
|
2551
|
-
if (action === "absent") {
|
|
2552
|
-
console.log("No basou Stop hook found; nothing removed.");
|
|
2553
|
-
return;
|
|
2554
|
-
}
|
|
2555
|
-
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
2556
|
-
`;
|
|
2557
|
-
if (options.dryRun === true) {
|
|
2558
|
-
console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
|
|
2559
|
-
return;
|
|
2560
|
-
}
|
|
2561
|
-
const recheck = await readSettings(settingsPath);
|
|
2562
|
-
if (recheck.raw !== raw) {
|
|
2563
|
-
throw new Error(
|
|
2564
|
-
"The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
|
|
2565
|
-
);
|
|
2566
|
-
}
|
|
2567
|
-
await backupSettingsOnce(settingsPath, raw);
|
|
2568
|
-
await writeFileDurable(settingsPath, newBody);
|
|
2569
|
-
console.log("Removed the basou Stop hook from settings.json.");
|
|
2570
|
-
}
|
|
2571
|
-
async function runHookStatus(options) {
|
|
2572
|
-
try {
|
|
2573
|
-
await doRunHookStatus(normalizeInstallOptions(options));
|
|
2574
|
-
} catch (error) {
|
|
2575
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
2576
|
-
process.exitCode = 1;
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
2579
|
-
async function doRunHookStatus(options) {
|
|
2580
|
-
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
2581
|
-
const { parsed } = await readSettings(settingsPath);
|
|
2582
|
-
const command = findBasouStopHookCommand(parsed);
|
|
2583
|
-
if (command === null) {
|
|
2584
|
-
console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
|
|
2585
|
-
return;
|
|
2586
|
-
}
|
|
2587
|
-
const mode = describeHookMode({
|
|
2588
|
-
block: / --block\b/.test(command),
|
|
2589
|
-
review: / --require-review\b/.test(command)
|
|
2590
|
-
});
|
|
2591
|
-
console.log(`basou Stop hook: registered, ${mode}.`);
|
|
2592
|
-
}
|
|
2593
|
-
function describeHookMode(tiers) {
|
|
2594
|
-
const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
|
|
2595
|
-
const gates = tiers.review ? "capture + review" : "capture";
|
|
2596
|
-
return `${enforcement}, ${gates}`;
|
|
2577
|
+
return result;
|
|
2597
2578
|
}
|
|
2598
2579
|
|
|
2580
|
+
// src/lib/provenance-actions.ts
|
|
2581
|
+
import {
|
|
2582
|
+
readMarkdownFile as readMarkdownFile5,
|
|
2583
|
+
renderDecisions as renderDecisions2,
|
|
2584
|
+
renderHandoff as renderHandoff2,
|
|
2585
|
+
renderOrientation,
|
|
2586
|
+
renderWithMarkers as renderWithMarkers3,
|
|
2587
|
+
writeMarkdownFile as writeMarkdownFile3
|
|
2588
|
+
} from "@basou/core";
|
|
2589
|
+
|
|
2599
2590
|
// src/commands/import.ts
|
|
2600
2591
|
import { createReadStream } from "fs";
|
|
2601
|
-
import { readdir, readFile as
|
|
2592
|
+
import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
|
|
2602
2593
|
import { homedir as homedir6 } from "os";
|
|
2603
|
-
import { basename as
|
|
2594
|
+
import { basename as basename4, dirname as dirname3, join as join8, resolve as resolve5 } from "path";
|
|
2604
2595
|
import { createInterface } from "readline";
|
|
2605
2596
|
import {
|
|
2606
2597
|
AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
|
|
@@ -2670,10 +2661,10 @@ function resolveSourceRoots(args) {
|
|
|
2670
2661
|
const { projectFlags, manifest, repoRoot, cwd } = args;
|
|
2671
2662
|
let resolved;
|
|
2672
2663
|
if (projectFlags.length > 0) {
|
|
2673
|
-
resolved = projectFlags.map((p) =>
|
|
2664
|
+
resolved = projectFlags.map((p) => resolve5(cwd, p));
|
|
2674
2665
|
} else {
|
|
2675
2666
|
const roots = manifest.import?.source_roots;
|
|
2676
|
-
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) =>
|
|
2667
|
+
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
|
|
2677
2668
|
}
|
|
2678
2669
|
return [...new Set(resolved)];
|
|
2679
2670
|
}
|
|
@@ -2690,7 +2681,7 @@ async function doRunImportClaudeCode(options, ctx) {
|
|
|
2690
2681
|
const files = await selectTranscriptFiles(projectsRoot, projectPaths, options);
|
|
2691
2682
|
const projectSet = new Set(projectPaths);
|
|
2692
2683
|
const candidates = files.map((file) => {
|
|
2693
|
-
const externalId =
|
|
2684
|
+
const externalId = basename4(file, ".jsonl");
|
|
2694
2685
|
return {
|
|
2695
2686
|
externalId,
|
|
2696
2687
|
sourcePath: file,
|
|
@@ -2780,7 +2771,7 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
|
|
|
2780
2771
|
files: payload.session.related_files ?? [],
|
|
2781
2772
|
workingDirectory: payload.session.working_directory,
|
|
2782
2773
|
sourceRoots: projectPaths,
|
|
2783
|
-
masterRoot:
|
|
2774
|
+
masterRoot: dirname3(paths.root),
|
|
2784
2775
|
extraInRoot: AGENT_INFRA_DIRS2
|
|
2785
2776
|
});
|
|
2786
2777
|
if (scope.outOfRoot.length > 0) crossProject.push({ externalId, outOfRoot: scope.outOfRoot });
|
|
@@ -2996,7 +2987,7 @@ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
|
|
|
2996
2987
|
}
|
|
2997
2988
|
async function pathExists(file) {
|
|
2998
2989
|
try {
|
|
2999
|
-
await
|
|
2990
|
+
await stat3(file);
|
|
3000
2991
|
return true;
|
|
3001
2992
|
} catch (error) {
|
|
3002
2993
|
if (findErrorCode5(error, "ENOENT")) return false;
|
|
@@ -3005,7 +2996,7 @@ async function pathExists(file) {
|
|
|
3005
2996
|
}
|
|
3006
2997
|
async function statSize(file) {
|
|
3007
2998
|
try {
|
|
3008
|
-
return (await
|
|
2999
|
+
return (await stat3(file)).size;
|
|
3009
3000
|
} catch (error) {
|
|
3010
3001
|
if (findErrorCode5(error, "ENOENT")) return void 0;
|
|
3011
3002
|
throw error;
|
|
@@ -3091,7 +3082,7 @@ async function readFirstLine(file) {
|
|
|
3091
3082
|
async function readJsonlRecords(file) {
|
|
3092
3083
|
let buffer;
|
|
3093
3084
|
try {
|
|
3094
|
-
buffer = await
|
|
3085
|
+
buffer = await readFile2(file);
|
|
3095
3086
|
} catch (error) {
|
|
3096
3087
|
if (findErrorCode5(error, "ENOENT")) {
|
|
3097
3088
|
throw new Error("Source log not found", { cause: error });
|
|
@@ -3220,569 +3211,1163 @@ async function assertWorkspaceInitialized5(basouRoot) {
|
|
|
3220
3211
|
}
|
|
3221
3212
|
}
|
|
3222
3213
|
|
|
3223
|
-
// src/
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
writeManifest
|
|
3231
|
-
} from "@basou/core";
|
|
3232
|
-
function collectValue(value, previous) {
|
|
3233
|
-
return [...previous, value];
|
|
3234
|
-
}
|
|
3235
|
-
function registerInitCommand(program2) {
|
|
3236
|
-
program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
|
|
3237
|
-
"--repo-url <url>",
|
|
3238
|
-
"Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
|
|
3239
|
-
).option(
|
|
3240
|
-
"--source-root <path>",
|
|
3241
|
-
"Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
|
|
3242
|
-
collectValue,
|
|
3243
|
-
[]
|
|
3244
|
-
).option(
|
|
3245
|
-
"--local-only",
|
|
3246
|
-
"Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
|
|
3247
|
-
).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
3248
|
-
await runInit(options);
|
|
3249
|
-
});
|
|
3250
|
-
}
|
|
3251
|
-
async function runInit(options, ctx = {}) {
|
|
3252
|
-
try {
|
|
3253
|
-
await doRunInit(options, ctx);
|
|
3254
|
-
} catch (error) {
|
|
3255
|
-
renderCliError(error, { verbose: isVerbose(options) });
|
|
3256
|
-
process.exitCode = 1;
|
|
3257
|
-
}
|
|
3258
|
-
}
|
|
3259
|
-
async function doRunInit(options, ctx) {
|
|
3260
|
-
const cwd = ctx.cwd ?? process.cwd();
|
|
3261
|
-
const repositoryRoot = await resolveRepositoryRootForInit(cwd);
|
|
3262
|
-
const workspaceName = options.name ?? basename4(repositoryRoot);
|
|
3263
|
-
if (options.repoUrl !== void 0) {
|
|
3264
|
-
console.error(
|
|
3265
|
-
"Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
|
|
3266
|
-
);
|
|
3267
|
-
}
|
|
3268
|
-
const sourceRoots = (options.sourceRoot ?? []).map((p) => {
|
|
3269
|
-
const rel = relative(repositoryRoot, resolve5(cwd, p));
|
|
3270
|
-
return rel === "" ? "." : rel;
|
|
3214
|
+
// src/lib/provenance-actions.ts
|
|
3215
|
+
async function captureImportJson(fn) {
|
|
3216
|
+
const stdout = [];
|
|
3217
|
+
const originalLog = console.log;
|
|
3218
|
+
const originalError = console.error;
|
|
3219
|
+
console.log = ((...args) => {
|
|
3220
|
+
stdout.push(args.map((a) => String(a)).join(" "));
|
|
3271
3221
|
});
|
|
3272
|
-
|
|
3273
|
-
const manifest = createManifest({
|
|
3274
|
-
workspaceName,
|
|
3275
|
-
...options.projectName !== void 0 ? { projectName: options.projectName } : {},
|
|
3276
|
-
...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
|
|
3277
|
-
...sourceRoots.length > 0 ? { sourceRoots } : {}
|
|
3222
|
+
console.error = (() => {
|
|
3278
3223
|
});
|
|
3279
|
-
await writeManifest(paths, manifest, { force: options.force === true });
|
|
3280
3224
|
try {
|
|
3281
|
-
await
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3225
|
+
await fn();
|
|
3226
|
+
} finally {
|
|
3227
|
+
console.log = originalLog;
|
|
3228
|
+
console.error = originalError;
|
|
3284
3229
|
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3230
|
+
for (let i = stdout.length - 1; i >= 0; i--) {
|
|
3231
|
+
const line = stdout[i];
|
|
3232
|
+
if (line === void 0) continue;
|
|
3233
|
+
try {
|
|
3234
|
+
const parsed = JSON.parse(line);
|
|
3235
|
+
if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
|
|
3236
|
+
return parsed;
|
|
3237
|
+
}
|
|
3238
|
+
} catch {
|
|
3239
|
+
}
|
|
3295
3240
|
}
|
|
3241
|
+
throw new Error("Import produced no parseable result");
|
|
3296
3242
|
}
|
|
3297
|
-
|
|
3243
|
+
function readCount(value) {
|
|
3244
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
3245
|
+
}
|
|
3246
|
+
function isMissingSourceDir(error) {
|
|
3247
|
+
if (!(error instanceof Error)) return false;
|
|
3248
|
+
return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
|
|
3249
|
+
}
|
|
3250
|
+
async function runImport(adapter, fn) {
|
|
3298
3251
|
try {
|
|
3299
|
-
|
|
3252
|
+
const json = await captureImportJson(fn);
|
|
3253
|
+
return {
|
|
3254
|
+
adapter,
|
|
3255
|
+
status: "ran",
|
|
3256
|
+
importedCount: readCount(json.imported_count),
|
|
3257
|
+
replacedCount: readCount(json.replaced_count),
|
|
3258
|
+
reimportedCount: readCount(json.reimported_count),
|
|
3259
|
+
skippedNoAction: readCount(json.skipped_no_action),
|
|
3260
|
+
skippedAlreadyImported: readCount(json.skipped_already_imported),
|
|
3261
|
+
skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
|
|
3262
|
+
skippedDecreased: readCount(json.skipped_decreased),
|
|
3263
|
+
skippedDuplicate: readCount(json.skipped_duplicate),
|
|
3264
|
+
skippedUnverifiable: readCount(json.skipped_unverifiable),
|
|
3265
|
+
eventTotal: readCount(json.event_total),
|
|
3266
|
+
dryRun: json.dry_run === true
|
|
3267
|
+
};
|
|
3300
3268
|
} catch (error) {
|
|
3301
|
-
if (error
|
|
3302
|
-
|
|
3303
|
-
cause: error
|
|
3304
|
-
});
|
|
3269
|
+
if (isMissingSourceDir(error)) {
|
|
3270
|
+
return { adapter, status: "skipped", reason: "no source logs for this project" };
|
|
3305
3271
|
}
|
|
3306
3272
|
throw error;
|
|
3307
3273
|
}
|
|
3308
3274
|
}
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3275
|
+
function importOptions(options) {
|
|
3276
|
+
return {
|
|
3277
|
+
all: true,
|
|
3278
|
+
json: true,
|
|
3279
|
+
...options.project !== void 0 ? { project: options.project } : {},
|
|
3280
|
+
...options.force === true ? { force: true } : {},
|
|
3281
|
+
...options.dryRun === true ? { dryRun: true } : {}
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
3284
|
+
function importClaudeCode(options, ctx) {
|
|
3285
|
+
return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
|
|
3286
|
+
}
|
|
3287
|
+
function importCodex(options, ctx) {
|
|
3288
|
+
return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
|
|
3289
|
+
}
|
|
3290
|
+
async function regenerateHandoff(paths, nowIso, callbacks) {
|
|
3291
|
+
const result = await renderHandoff2({ paths, nowIso, ...callbacks });
|
|
3292
|
+
const existing = await readMarkdownFile5(paths.files.handoff);
|
|
3293
|
+
await writeMarkdownFile3(
|
|
3294
|
+
paths.files.handoff,
|
|
3295
|
+
renderWithMarkers3(existing, result.body, "handoff.md")
|
|
3296
|
+
);
|
|
3297
|
+
return {
|
|
3298
|
+
sessionCount: result.sessionCount,
|
|
3299
|
+
taskCount: result.taskCount,
|
|
3300
|
+
decisionCount: result.decisionCount,
|
|
3301
|
+
pendingApprovalsCount: result.pendingApprovalsCount
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
async function regenerateDecisions(paths, nowIso, callbacks) {
|
|
3305
|
+
const result = await renderDecisions2({ paths, nowIso, ...callbacks });
|
|
3306
|
+
const existing = await readMarkdownFile5(paths.files.decisions);
|
|
3307
|
+
await writeMarkdownFile3(
|
|
3308
|
+
paths.files.decisions,
|
|
3309
|
+
renderWithMarkers3(existing, result.body, "decisions.md")
|
|
3310
|
+
);
|
|
3311
|
+
return { decisionCount: result.decisionCount };
|
|
3312
|
+
}
|
|
3313
|
+
async function regenerateOrientation(paths, nowIso, callbacks) {
|
|
3314
|
+
const result = await renderOrientation({ paths, nowIso, ...callbacks });
|
|
3315
|
+
await writeMarkdownFile3(paths.files.orientation, `${result.body}
|
|
3316
|
+
`);
|
|
3317
|
+
return {
|
|
3318
|
+
sessionCount: result.sessionCount,
|
|
3319
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3320
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3321
|
+
suspectCount: result.suspectCount
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
async function refreshAll(args) {
|
|
3325
|
+
const { options, ctx, paths, nowIso } = args;
|
|
3326
|
+
const dryRun = options.dryRun === true;
|
|
3327
|
+
const claudeCode = await importClaudeCode(options, ctx);
|
|
3328
|
+
const codex = await importCodex(options, ctx);
|
|
3329
|
+
if (dryRun) {
|
|
3330
|
+
const skipped = { status: "skipped", reason: "dry-run" };
|
|
3331
|
+
return {
|
|
3332
|
+
claudeCode,
|
|
3333
|
+
codex,
|
|
3334
|
+
handoff: skipped,
|
|
3335
|
+
decisions: skipped,
|
|
3336
|
+
orientation: skipped,
|
|
3337
|
+
dryRun
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
const handoffCounts = await regenerateHandoff(paths, nowIso);
|
|
3341
|
+
const decisionCounts = await regenerateDecisions(paths, nowIso);
|
|
3342
|
+
const scoped = options.project !== void 0 && options.project.length > 0;
|
|
3343
|
+
const orientationCounts = await regenerateOrientation(
|
|
3344
|
+
paths,
|
|
3345
|
+
nowIso,
|
|
3346
|
+
scoped ? {} : {
|
|
3347
|
+
staleness: {
|
|
3348
|
+
newSessions: 0,
|
|
3349
|
+
updatedSessions: 0,
|
|
3350
|
+
unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
);
|
|
3354
|
+
return {
|
|
3355
|
+
claudeCode,
|
|
3356
|
+
codex,
|
|
3357
|
+
handoff: { status: "generated", ...handoffCounts },
|
|
3358
|
+
decisions: { status: "generated", ...decisionCounts },
|
|
3359
|
+
orientation: { status: "generated", ...orientationCounts },
|
|
3360
|
+
dryRun
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
function wouldImport(outcome) {
|
|
3364
|
+
return outcome.status === "ran" ? outcome.importedCount : 0;
|
|
3365
|
+
}
|
|
3366
|
+
function wouldUpdate(outcome) {
|
|
3367
|
+
return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
|
|
3368
|
+
}
|
|
3369
|
+
function wouldBlock(outcome) {
|
|
3370
|
+
return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
|
|
3371
|
+
}
|
|
3372
|
+
async function probeStaleness(args) {
|
|
3373
|
+
try {
|
|
3374
|
+
const dry = await refreshAll({
|
|
3375
|
+
options: { dryRun: true },
|
|
3376
|
+
ctx: args.ctx,
|
|
3377
|
+
paths: args.paths,
|
|
3378
|
+
nowIso: args.nowIso
|
|
3379
|
+
});
|
|
3380
|
+
return {
|
|
3381
|
+
newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
|
|
3382
|
+
updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
|
|
3383
|
+
unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
|
|
3384
|
+
};
|
|
3385
|
+
} catch {
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
// src/commands/orient.ts
|
|
3391
|
+
function registerOrientCommand(program2) {
|
|
3392
|
+
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(
|
|
3393
|
+
"--refresh",
|
|
3394
|
+
"Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
|
|
3395
|
+
).option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
3396
|
+
await runOrient(opts);
|
|
3343
3397
|
});
|
|
3344
3398
|
}
|
|
3345
|
-
async function
|
|
3399
|
+
async function runOrient(options, ctx = {}) {
|
|
3346
3400
|
try {
|
|
3347
|
-
await
|
|
3401
|
+
await doRunOrient(options, ctx);
|
|
3348
3402
|
} catch (error) {
|
|
3349
|
-
renderCliError(error, {
|
|
3350
|
-
verbose: isVerbose(options),
|
|
3351
|
-
classifiers: [failedToFinalizeClassifier]
|
|
3352
|
-
});
|
|
3403
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3353
3404
|
process.exitCode = 1;
|
|
3354
3405
|
}
|
|
3355
3406
|
}
|
|
3356
|
-
async function
|
|
3357
|
-
|
|
3358
|
-
|
|
3407
|
+
async function doRunOrient(options, ctx) {
|
|
3408
|
+
const result = await renderOrientationForCwd(options, ctx);
|
|
3409
|
+
if (options.quiet === true) {
|
|
3410
|
+
console.log(
|
|
3411
|
+
`Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
|
|
3412
|
+
);
|
|
3413
|
+
} else {
|
|
3414
|
+
console.log(result.body);
|
|
3359
3415
|
}
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3416
|
+
await warnIfPositionNamesOtherWorkspaces2(result, ctx);
|
|
3417
|
+
}
|
|
3418
|
+
async function warnIfPositionNamesOtherWorkspaces2(result, ctx) {
|
|
3419
|
+
const report = await findForeignWorkspaceNames({
|
|
3420
|
+
text: result.body,
|
|
3421
|
+
selfPath: result.workspaceRoot,
|
|
3422
|
+
configPath: ctx.portfolioConfigPath
|
|
3423
|
+
});
|
|
3424
|
+
if (report !== null) {
|
|
3425
|
+
console.error(
|
|
3426
|
+
positionForeignWorkspaceWarning(report, basouPaths8(result.workspaceRoot).files.orientation)
|
|
3364
3427
|
);
|
|
3365
3428
|
}
|
|
3429
|
+
}
|
|
3430
|
+
async function renderOrientationForCwd(options, ctx) {
|
|
3366
3431
|
const cwd = ctx.cwd ?? process.cwd();
|
|
3367
|
-
const repositoryRoot = await resolveBasouRootForCommand(cwd, "
|
|
3432
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
|
|
3433
|
+
return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
|
|
3434
|
+
}
|
|
3435
|
+
async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
|
|
3368
3436
|
const paths = basouPaths8(repositoryRoot);
|
|
3369
3437
|
await assertWorkspaceInitialized6(paths.root);
|
|
3370
|
-
const
|
|
3371
|
-
const
|
|
3372
|
-
if (
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
});
|
|
3383
|
-
} finally {
|
|
3384
|
-
await sessionLock.release();
|
|
3438
|
+
const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
3439
|
+
const probeCtx = { cwd: repositoryRoot };
|
|
3440
|
+
if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
|
|
3441
|
+
if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
|
|
3442
|
+
if (options.refresh === true) {
|
|
3443
|
+
await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
|
|
3444
|
+
}
|
|
3445
|
+
const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
|
|
3446
|
+
let federatedRoots = [];
|
|
3447
|
+
try {
|
|
3448
|
+
const hosts = await loadHostsConfig(ctx.hostsConfigPath);
|
|
3449
|
+
if (hosts !== null) {
|
|
3450
|
+
federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
|
|
3385
3451
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3452
|
+
} catch (error) {
|
|
3453
|
+
console.error(
|
|
3454
|
+
`basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
|
|
3455
|
+
);
|
|
3456
|
+
}
|
|
3457
|
+
const result = await renderOrientation2({
|
|
3458
|
+
paths,
|
|
3459
|
+
nowIso,
|
|
3460
|
+
staleness,
|
|
3461
|
+
verbose: options.verbose === true,
|
|
3462
|
+
federatedRoots,
|
|
3463
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
3464
|
+
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
|
|
3465
|
+
onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
|
|
3466
|
+
onHostUnavailable: (host, error) => console.error(
|
|
3467
|
+
`basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
|
|
3468
|
+
)
|
|
3469
|
+
});
|
|
3470
|
+
if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
|
|
3471
|
+
`);
|
|
3472
|
+
return {
|
|
3473
|
+
body: result.body,
|
|
3474
|
+
workspaceRoot: repositoryRoot,
|
|
3475
|
+
sessionCount: result.sessionCount,
|
|
3476
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3477
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3478
|
+
suspectCount: result.suspectCount
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
async function assertWorkspaceInitialized6(basouRoot) {
|
|
3482
|
+
try {
|
|
3483
|
+
await assertBasouRootSafe7(basouRoot);
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
if (findErrorCode6(error, "ENOENT")) {
|
|
3486
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
3487
|
+
}
|
|
3488
|
+
throw error;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
// src/commands/hook.ts
|
|
3493
|
+
var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
|
|
3494
|
+
function registerHookCommand(program2) {
|
|
3495
|
+
const hook = program2.command("hook").description(
|
|
3496
|
+
"Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
|
|
3497
|
+
);
|
|
3498
|
+
hook.command("session-start").description(
|
|
3499
|
+
"SessionStart hook (Codex; Claude Code too): print the current position of the workspace the session was opened in (read from the payload's cwd) so the tool adds it to that session's context. Stays silent outside a registered basou workspace, and when the position names another registered workspace; never fails the session."
|
|
3500
|
+
).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
|
|
3501
|
+
await runHookSessionStart();
|
|
3502
|
+
});
|
|
3503
|
+
hook.command("stop").description(
|
|
3504
|
+
"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."
|
|
3505
|
+
).option(
|
|
3506
|
+
"--min-edits <n>",
|
|
3507
|
+
`Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
|
|
3508
|
+
).option(
|
|
3509
|
+
"--block",
|
|
3510
|
+
"Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
|
|
3511
|
+
).option(
|
|
3512
|
+
"--require-review",
|
|
3513
|
+
"Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
|
|
3514
|
+
).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
|
|
3515
|
+
const minEdits = parseMinEdits(options.minEdits);
|
|
3516
|
+
await runHookStop({
|
|
3517
|
+
...minEdits !== void 0 ? { minEdits } : {},
|
|
3518
|
+
...options.block === true ? { block: true } : {},
|
|
3519
|
+
...options.requireReview === true ? { requireReview: true } : {}
|
|
3392
3520
|
});
|
|
3521
|
+
});
|
|
3522
|
+
hook.command("install [target]").description(
|
|
3523
|
+
"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."
|
|
3524
|
+
).option(
|
|
3525
|
+
"--block",
|
|
3526
|
+
"claude: register the blocking (opt-in enforcement) form instead of advisory"
|
|
3527
|
+
).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(
|
|
3528
|
+
"--codex-config <path>",
|
|
3529
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3530
|
+
).option(
|
|
3531
|
+
"--codex-face <path>",
|
|
3532
|
+
"codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
|
|
3533
|
+
).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3534
|
+
await dispatchHookTarget(target, opts, {
|
|
3535
|
+
claude: () => runHookInstall(opts),
|
|
3536
|
+
codex: () => runCodexHookInstall(opts)
|
|
3537
|
+
});
|
|
3538
|
+
});
|
|
3539
|
+
hook.command("uninstall [target]").description(
|
|
3540
|
+
"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."
|
|
3541
|
+
).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) => {
|
|
3542
|
+
await dispatchHookTarget(target, opts, {
|
|
3543
|
+
claude: () => runHookUninstall(opts),
|
|
3544
|
+
codex: () => runCodexHookUninstall(opts)
|
|
3545
|
+
});
|
|
3546
|
+
});
|
|
3547
|
+
hook.command("status [target]").description(
|
|
3548
|
+
"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."
|
|
3549
|
+
).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(
|
|
3550
|
+
"--codex-config <path>",
|
|
3551
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3552
|
+
).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3553
|
+
await dispatchHookTarget(target, opts, {
|
|
3554
|
+
claude: () => runHookStatus(opts),
|
|
3555
|
+
codex: () => runCodexHookStatus(opts)
|
|
3556
|
+
});
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
async function dispatchHookTarget(target, options, handlers) {
|
|
3560
|
+
const resolved = target ?? "claude";
|
|
3561
|
+
if (resolved !== "claude" && resolved !== "codex") {
|
|
3562
|
+
renderCliError(
|
|
3563
|
+
new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
|
|
3564
|
+
{ verbose: isVerbose(options) }
|
|
3565
|
+
);
|
|
3566
|
+
process.exitCode = 1;
|
|
3393
3567
|
return;
|
|
3394
3568
|
}
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3569
|
+
await handlers[resolved]();
|
|
3570
|
+
}
|
|
3571
|
+
var HOOK_SESSION_START_HELP = `
|
|
3572
|
+
Register this hook reproducibly with 'basou hook install codex' (it writes the
|
|
3573
|
+
correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
|
|
3574
|
+
removes it; 'basou hook status codex' reports whether it is registered and
|
|
3575
|
+
whether Codex has trusted it.
|
|
3576
|
+
|
|
3577
|
+
Codex runs the hook when a session starts and passes the session's cwd on stdin.
|
|
3578
|
+
basou resolves the workspace from that cwd (a member repo resolves to its
|
|
3579
|
+
planning master, a workspace view to its master) and prints the workspace's
|
|
3580
|
+
current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
|
|
3581
|
+
session's context as developer text. The position is computed at that moment
|
|
3582
|
+
from that cwd and stored nowhere: a Codex opened in another workspace gets that
|
|
3583
|
+
workspace's position, and one opened outside any basou workspace (or before the
|
|
3584
|
+
desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
|
|
3585
|
+
user-global hook serves every workspace without any workspace's position ever
|
|
3586
|
+
being written where another workspace's session would read it.
|
|
3587
|
+
|
|
3588
|
+
The hook stays silent in one more case: when the position it would print names
|
|
3589
|
+
ANOTHER registered workspace (a recorded path under it, a captured decision that
|
|
3590
|
+
mentions it). 'basou orient' and 'basou refresh' report that as a stderr
|
|
3591
|
+
advisory the operator can read; a hook has no reader for stderr and its stdout
|
|
3592
|
+
becomes the session's trusted context, so it withholds the position instead.
|
|
3593
|
+
Run 'basou refresh' to see which lines are responsible.
|
|
3594
|
+
|
|
3595
|
+
Claude Code's SessionStart hook sends the same kind of payload (a JSON object
|
|
3596
|
+
with 'cwd') and adds stdout to context the same way, so a Claude Code user may
|
|
3597
|
+
register this command in ~/.claude/settings.json in place of 'basou orient' to
|
|
3598
|
+
get both gates. basou does not install that one.
|
|
3599
|
+
|
|
3600
|
+
Codex trusts hooks by hash. A newly installed or changed hook is skipped until
|
|
3601
|
+
you review it: the interactive CLI asks at startup ("Hooks need review"), the
|
|
3602
|
+
desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
|
|
3603
|
+
an untrusted hook silently.
|
|
3604
|
+
`;
|
|
3605
|
+
var HOOK_STOP_HELP = `
|
|
3606
|
+
Register this Stop hook reproducibly with 'basou hook install' (it writes the
|
|
3607
|
+
correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
|
|
3608
|
+
removes it; 'basou hook status' reports whether it is registered.
|
|
3609
|
+
|
|
3610
|
+
On every turn end basou inspects the session transcript. If the session did
|
|
3611
|
+
content-substantive work but ran no capture verb ('basou decision capture' /
|
|
3612
|
+
'decision record' / 'note'), it reminds the agent to record the why / next step.
|
|
3613
|
+
Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
|
|
3614
|
+
answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
|
|
3615
|
+
git status) does NOT count.
|
|
3616
|
+
|
|
3617
|
+
With --require-review (opt-in, 'basou hook install --require-review') it also
|
|
3618
|
+
reminds when the session SHIPPED substantive code (git push / git merge /
|
|
3619
|
+
gh pr create|merge) without recording a review ('basou review record'). This
|
|
3620
|
+
gate is off by default; when on, its reminder is composed into the same
|
|
3621
|
+
envelope as the capture reminder.
|
|
3622
|
+
|
|
3623
|
+
By default the reminder is non-blocking: Claude sees it and may act on it or
|
|
3624
|
+
stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
|
|
3625
|
+
returns decision:block, holding the agent in-turn to act on the reminder; the
|
|
3626
|
+
'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
|
|
3627
|
+
single turn. Either way the hook fails open: a bad payload or unreadable
|
|
3628
|
+
transcript exits cleanly with no output.
|
|
3629
|
+
`;
|
|
3630
|
+
async function runHookStop(options, ctx = {}) {
|
|
3631
|
+
try {
|
|
3632
|
+
await doRunHookStop(options, ctx);
|
|
3633
|
+
} catch {
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
async function doRunHookStop(options, ctx) {
|
|
3637
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3638
|
+
const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
|
|
3639
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3640
|
+
const raw = await readStdin();
|
|
3641
|
+
if (raw.trim().length === 0) return;
|
|
3642
|
+
let payload;
|
|
3643
|
+
try {
|
|
3644
|
+
payload = JSON.parse(raw);
|
|
3645
|
+
} catch {
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3649
|
+
const fields = payload;
|
|
3650
|
+
if (fields.stop_hook_active === true) return;
|
|
3651
|
+
const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
|
|
3652
|
+
if (transcriptPath.length === 0) return;
|
|
3653
|
+
let transcript;
|
|
3654
|
+
try {
|
|
3655
|
+
transcript = await readTranscript(transcriptPath);
|
|
3656
|
+
} catch {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3659
|
+
const records = parseTranscript(transcript);
|
|
3660
|
+
const evaluation = evaluateStopHook({
|
|
3661
|
+
records,
|
|
3662
|
+
// stop_hook_active was already handled by the early return above.
|
|
3663
|
+
stopHookActive: false,
|
|
3664
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3410
3665
|
});
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3666
|
+
const parts = [];
|
|
3667
|
+
if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
|
|
3668
|
+
if (options.requireReview === true && evaluation.review.fires) {
|
|
3669
|
+
parts.push(evaluation.review.additionalContext);
|
|
3670
|
+
}
|
|
3671
|
+
if (parts.length === 0) return;
|
|
3672
|
+
const reason = parts.join("\n\n");
|
|
3673
|
+
const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
|
|
3674
|
+
hookSpecificOutput: {
|
|
3675
|
+
hookEventName: "Stop",
|
|
3676
|
+
additionalContext: reason
|
|
3677
|
+
}
|
|
3417
3678
|
});
|
|
3679
|
+
write(`${payloadJson}
|
|
3680
|
+
`);
|
|
3418
3681
|
}
|
|
3419
|
-
function
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
};
|
|
3682
|
+
async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
|
|
3683
|
+
const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
|
|
3684
|
+
portfolioConfigPath
|
|
3685
|
+
});
|
|
3686
|
+
if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
|
|
3687
|
+
throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
|
|
3688
|
+
}
|
|
3689
|
+
const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
|
|
3690
|
+
const foreign = await findForeignWorkspaceNames({
|
|
3691
|
+
text: rendered.body,
|
|
3692
|
+
selfPath: root,
|
|
3693
|
+
configPath: portfolioConfigPath
|
|
3694
|
+
});
|
|
3695
|
+
if (foreign !== null) {
|
|
3696
|
+
throw new Error("The position names another registered workspace; the hook stays silent.");
|
|
3697
|
+
}
|
|
3698
|
+
return { body: rendered.body };
|
|
3432
3699
|
}
|
|
3433
|
-
function
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3700
|
+
async function isRegisteredWorkspace(root, portfolioConfigPath) {
|
|
3701
|
+
let entries;
|
|
3702
|
+
try {
|
|
3703
|
+
entries = await loadPortfolioConfig(portfolioConfigPath);
|
|
3704
|
+
} catch {
|
|
3705
|
+
return false;
|
|
3706
|
+
}
|
|
3707
|
+
const rootReal = await realpath3(root).catch(() => root);
|
|
3708
|
+
for (const entry of entries) {
|
|
3709
|
+
const entryReal = await realpath3(entry.path).catch(() => null);
|
|
3710
|
+
if (entryReal !== null && entryReal === rootReal) return true;
|
|
3711
|
+
}
|
|
3712
|
+
return false;
|
|
3437
3713
|
}
|
|
3438
|
-
function
|
|
3439
|
-
|
|
3440
|
-
|
|
3714
|
+
async function runHookSessionStart(ctx = {}) {
|
|
3715
|
+
try {
|
|
3716
|
+
await doRunHookSessionStart(ctx);
|
|
3717
|
+
} catch {
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
async function doRunHookSessionStart(ctx) {
|
|
3721
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3722
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3723
|
+
const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
|
|
3724
|
+
const raw = await readStdin();
|
|
3725
|
+
if (raw.trim().length === 0) return;
|
|
3726
|
+
let payload;
|
|
3727
|
+
try {
|
|
3728
|
+
payload = JSON.parse(raw);
|
|
3729
|
+
} catch {
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3733
|
+
const cwd = payload.cwd;
|
|
3734
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
3735
|
+
let body;
|
|
3736
|
+
try {
|
|
3737
|
+
body = (await render(cwd)).body;
|
|
3738
|
+
} catch {
|
|
3739
|
+
return;
|
|
3441
3740
|
}
|
|
3442
|
-
return
|
|
3741
|
+
if (body.trim().length === 0) return;
|
|
3742
|
+
write(`${body.replace(/\s+$/, "")}
|
|
3743
|
+
`);
|
|
3443
3744
|
}
|
|
3444
|
-
function
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
);
|
|
3456
|
-
return;
|
|
3745
|
+
function parseTranscript(transcript) {
|
|
3746
|
+
const records = [];
|
|
3747
|
+
for (const line of transcript.split(/\r?\n/)) {
|
|
3748
|
+
if (line.trim().length === 0) continue;
|
|
3749
|
+
try {
|
|
3750
|
+
const parsed = JSON.parse(line);
|
|
3751
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3752
|
+
records.push(parsed);
|
|
3753
|
+
}
|
|
3754
|
+
} catch {
|
|
3755
|
+
}
|
|
3457
3756
|
}
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3757
|
+
return records;
|
|
3758
|
+
}
|
|
3759
|
+
async function defaultReadStdin() {
|
|
3760
|
+
if (process.stdin.isTTY === true) return "";
|
|
3761
|
+
const chunks = [];
|
|
3762
|
+
for await (const chunk of process.stdin) {
|
|
3763
|
+
chunks.push(chunk);
|
|
3462
3764
|
}
|
|
3765
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3463
3766
|
}
|
|
3464
|
-
async function
|
|
3767
|
+
async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
|
|
3768
|
+
const { size } = await stat4(path);
|
|
3769
|
+
if (size <= maxBytes) return readFile3(path, "utf8");
|
|
3770
|
+
const handle = await open2(path, "r");
|
|
3465
3771
|
try {
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3772
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
3773
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
|
|
3774
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3775
|
+
const firstNewline = text.indexOf("\n");
|
|
3776
|
+
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
3777
|
+
} finally {
|
|
3778
|
+
await handle.close();
|
|
3472
3779
|
}
|
|
3473
3780
|
}
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
assertBasouRootSafe as assertBasouRootSafe8,
|
|
3478
|
-
basouPaths as basouPaths9,
|
|
3479
|
-
findErrorCode as findErrorCode7,
|
|
3480
|
-
renderOrientation as renderOrientation2,
|
|
3481
|
-
writeMarkdownFile as writeMarkdownFile4
|
|
3482
|
-
} from "@basou/core";
|
|
3483
|
-
|
|
3484
|
-
// src/lib/hosts-config.ts
|
|
3485
|
-
import { homedir as homedir7 } from "os";
|
|
3486
|
-
import { isAbsolute as isAbsolute2, join as join9, resolve as resolve6 } from "path";
|
|
3487
|
-
import { readYamlFile as readYamlFile4 } from "@basou/core";
|
|
3488
|
-
var DEFAULT_HOSTS_CONFIG_PATH = join9(homedir7(), ".basou", "hosts.yaml");
|
|
3489
|
-
function expandTilde2(p) {
|
|
3490
|
-
if (p === "~") return homedir7();
|
|
3491
|
-
if (p.startsWith("~/")) return join9(homedir7(), p.slice(2));
|
|
3492
|
-
return p;
|
|
3781
|
+
function parseMinEdits(raw) {
|
|
3782
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
|
|
3783
|
+
return Number(raw);
|
|
3493
3784
|
}
|
|
3494
|
-
|
|
3495
|
-
|
|
3785
|
+
var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
|
|
3786
|
+
function resolveCliEntry() {
|
|
3787
|
+
return fileURLToPath(import.meta.url);
|
|
3496
3788
|
}
|
|
3497
|
-
|
|
3789
|
+
function normalizeInstallOptions(raw) {
|
|
3790
|
+
const out = {};
|
|
3791
|
+
if (raw.block === true) out.block = true;
|
|
3792
|
+
if (raw.requireReview === true) out.requireReview = true;
|
|
3793
|
+
if (raw.settings !== void 0) out.settings = raw.settings;
|
|
3794
|
+
if (raw.hooks !== void 0) out.hooks = raw.hooks;
|
|
3795
|
+
if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
|
|
3796
|
+
if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
|
|
3797
|
+
if (raw.dryRun === true) out.dryRun = true;
|
|
3798
|
+
if (raw.verbose === true) out.verbose = true;
|
|
3799
|
+
if (raw.minEdits !== void 0) {
|
|
3800
|
+
const parsed = parseMinEdits(raw.minEdits);
|
|
3801
|
+
if (parsed === void 0) {
|
|
3802
|
+
throw new Error("--min-edits must be a non-negative integer.");
|
|
3803
|
+
}
|
|
3804
|
+
out.minEdits = parsed;
|
|
3805
|
+
}
|
|
3806
|
+
return out;
|
|
3807
|
+
}
|
|
3808
|
+
async function readSettings(path) {
|
|
3498
3809
|
let raw;
|
|
3499
3810
|
try {
|
|
3500
|
-
raw = await
|
|
3811
|
+
raw = await readFile3(path, "utf8");
|
|
3501
3812
|
} catch (error) {
|
|
3502
|
-
if (error instanceof Error && error.
|
|
3503
|
-
return null;
|
|
3504
|
-
}
|
|
3505
|
-
if (error instanceof Error && error.message === "Failed to parse YAML content") {
|
|
3506
|
-
throw new Error("~/.basou/hosts.yaml is not valid YAML.");
|
|
3813
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3814
|
+
return { raw: null, parsed: void 0 };
|
|
3507
3815
|
}
|
|
3508
3816
|
throw error;
|
|
3509
3817
|
}
|
|
3510
|
-
if (
|
|
3511
|
-
|
|
3818
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3819
|
+
try {
|
|
3820
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3821
|
+
} catch (error) {
|
|
3822
|
+
throw new Error(
|
|
3823
|
+
"The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
|
|
3824
|
+
{
|
|
3825
|
+
cause: error
|
|
3826
|
+
}
|
|
3827
|
+
);
|
|
3512
3828
|
}
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
if (
|
|
3522
|
-
throw new Error("Each host needs a non-empty string 'path'.");
|
|
3523
|
-
}
|
|
3524
|
-
const expanded = expandTilde2(entry.path.trim());
|
|
3525
|
-
if (!isAbsolute2(expanded)) {
|
|
3526
|
-
throw new Error("Host paths must be absolute (or start with '~').");
|
|
3527
|
-
}
|
|
3528
|
-
const abs = resolve6(expanded);
|
|
3529
|
-
if (seenPaths.has(abs)) continue;
|
|
3530
|
-
if (seenLabels.has(label)) {
|
|
3531
|
-
throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
|
|
3532
|
-
}
|
|
3533
|
-
seenPaths.add(abs);
|
|
3534
|
-
seenLabels.add(label);
|
|
3535
|
-
result.push({ label, path: abs });
|
|
3829
|
+
}
|
|
3830
|
+
async function backupSettingsOnce(path, raw) {
|
|
3831
|
+
if (raw === null) return;
|
|
3832
|
+
const bak = `${path}.basou-bak`;
|
|
3833
|
+
try {
|
|
3834
|
+
await stat4(bak);
|
|
3835
|
+
return;
|
|
3836
|
+
} catch (error) {
|
|
3837
|
+
if (!(error instanceof Error && error.code === "ENOENT")) throw error;
|
|
3536
3838
|
}
|
|
3537
|
-
|
|
3839
|
+
await writeFileDurable(bak, raw);
|
|
3538
3840
|
}
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
const
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3841
|
+
async function runHookInstall(options, ctx = {}) {
|
|
3842
|
+
try {
|
|
3843
|
+
await doRunHookInstall(normalizeInstallOptions(options), ctx);
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3846
|
+
process.exitCode = 1;
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
async function doRunHookInstall(options, ctx = {}) {
|
|
3850
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3851
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3852
|
+
const command = buildStopHookCommand({
|
|
3853
|
+
cliEntry,
|
|
3854
|
+
...options.block === true ? { block: true } : {},
|
|
3855
|
+
...options.requireReview === true ? { requireReview: true } : {},
|
|
3856
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3555
3857
|
});
|
|
3556
|
-
|
|
3858
|
+
const mode = describeHookMode({
|
|
3859
|
+
block: options.block === true,
|
|
3860
|
+
review: options.requireReview === true
|
|
3557
3861
|
});
|
|
3862
|
+
await assertNotSymlink(settingsPath);
|
|
3863
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3864
|
+
const { settings, action } = upsertStopHook(parsed, command);
|
|
3865
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3866
|
+
`;
|
|
3867
|
+
if (raw !== null && newBody === raw) {
|
|
3868
|
+
console.log(`The basou Stop hook is already registered (${mode}); no change.`);
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
if (options.dryRun === true) {
|
|
3872
|
+
console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
const recheck = await readSettings(settingsPath);
|
|
3876
|
+
if (recheck.raw !== raw) {
|
|
3877
|
+
throw new Error(
|
|
3878
|
+
"The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
|
|
3879
|
+
);
|
|
3880
|
+
}
|
|
3881
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3882
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3883
|
+
console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
|
|
3884
|
+
}
|
|
3885
|
+
async function runHookUninstall(options) {
|
|
3558
3886
|
try {
|
|
3559
|
-
await
|
|
3560
|
-
}
|
|
3561
|
-
|
|
3562
|
-
|
|
3887
|
+
await doRunHookUninstall(normalizeInstallOptions(options));
|
|
3888
|
+
} catch (error) {
|
|
3889
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3890
|
+
process.exitCode = 1;
|
|
3563
3891
|
}
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3892
|
+
}
|
|
3893
|
+
async function doRunHookUninstall(options) {
|
|
3894
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3895
|
+
await assertNotSymlink(settingsPath);
|
|
3896
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3897
|
+
if (raw === null) {
|
|
3898
|
+
console.log("No settings.json; nothing to remove.");
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
const { settings, action } = removeStopHook(parsed);
|
|
3902
|
+
if (action === "absent") {
|
|
3903
|
+
console.log("No basou Stop hook found; nothing removed.");
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3907
|
+
`;
|
|
3908
|
+
if (options.dryRun === true) {
|
|
3909
|
+
console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3912
|
+
const recheck = await readSettings(settingsPath);
|
|
3913
|
+
if (recheck.raw !== raw) {
|
|
3914
|
+
throw new Error(
|
|
3915
|
+
"The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
|
|
3916
|
+
);
|
|
3917
|
+
}
|
|
3918
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3919
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3920
|
+
console.log("Removed the basou Stop hook from settings.json.");
|
|
3921
|
+
}
|
|
3922
|
+
async function runHookStatus(options) {
|
|
3923
|
+
try {
|
|
3924
|
+
await doRunHookStatus(normalizeInstallOptions(options));
|
|
3925
|
+
} catch (error) {
|
|
3926
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3927
|
+
process.exitCode = 1;
|
|
3574
3928
|
}
|
|
3575
|
-
throw new Error("Import produced no parseable result");
|
|
3576
3929
|
}
|
|
3577
|
-
function
|
|
3578
|
-
|
|
3930
|
+
async function doRunHookStatus(options) {
|
|
3931
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3932
|
+
const { parsed } = await readSettings(settingsPath);
|
|
3933
|
+
const command = findBasouStopHookCommand(parsed);
|
|
3934
|
+
if (command === null) {
|
|
3935
|
+
console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
3938
|
+
const mode = describeHookMode({
|
|
3939
|
+
block: / --block\b/.test(command),
|
|
3940
|
+
review: / --require-review\b/.test(command)
|
|
3941
|
+
});
|
|
3942
|
+
console.log(`basou Stop hook: registered, ${mode}.`);
|
|
3579
3943
|
}
|
|
3580
|
-
function
|
|
3581
|
-
|
|
3582
|
-
|
|
3944
|
+
function describeHookMode(tiers) {
|
|
3945
|
+
const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
|
|
3946
|
+
const gates = tiers.review ? "capture + review" : "capture";
|
|
3947
|
+
return `${enforcement}, ${gates}`;
|
|
3583
3948
|
}
|
|
3584
|
-
|
|
3949
|
+
var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
|
|
3950
|
+
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.`;
|
|
3951
|
+
async function faceHasLeftoverOrientationBlock(facePath) {
|
|
3585
3952
|
try {
|
|
3586
|
-
const
|
|
3587
|
-
return
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
dryRun: json.dry_run === true
|
|
3601
|
-
};
|
|
3953
|
+
const existing = await readMarkdownFile6(facePath);
|
|
3954
|
+
if (existing === null) return false;
|
|
3955
|
+
const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
|
|
3956
|
+
return section.kind !== "no_markers";
|
|
3957
|
+
} catch {
|
|
3958
|
+
return false;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
|
|
3962
|
+
var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
|
|
3963
|
+
async function readHooksFile(path) {
|
|
3964
|
+
let raw;
|
|
3965
|
+
try {
|
|
3966
|
+
raw = await readFile3(path, "utf8");
|
|
3602
3967
|
} catch (error) {
|
|
3603
|
-
if (
|
|
3604
|
-
return {
|
|
3968
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3969
|
+
return { raw: null, parsed: void 0 };
|
|
3605
3970
|
}
|
|
3606
3971
|
throw error;
|
|
3607
3972
|
}
|
|
3973
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3974
|
+
try {
|
|
3975
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3976
|
+
} catch (error) {
|
|
3977
|
+
throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
|
|
3978
|
+
cause: error
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3608
3981
|
}
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
}
|
|
3982
|
+
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.";
|
|
3983
|
+
async function runCodexHookInstall(options, ctx = {}) {
|
|
3984
|
+
try {
|
|
3985
|
+
await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
|
|
3986
|
+
} catch (error) {
|
|
3987
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3988
|
+
process.exitCode = 1;
|
|
3989
|
+
}
|
|
3617
3990
|
}
|
|
3618
|
-
function
|
|
3619
|
-
|
|
3991
|
+
async function doRunCodexHookInstall(options, ctx = {}) {
|
|
3992
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3993
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3994
|
+
const command = buildSessionStartHookCommand({ cliEntry });
|
|
3995
|
+
await assertNotSymlink(hooksPath);
|
|
3996
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
3997
|
+
const { hooksFile, action } = upsertSessionStartHook(parsed, command);
|
|
3998
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
3999
|
+
`;
|
|
4000
|
+
if (action === "unchanged" || raw !== null && newBody === raw) {
|
|
4001
|
+
console.log("The basou Codex SessionStart hook is already registered; no change.");
|
|
4002
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
if (options.dryRun === true) {
|
|
4006
|
+
console.log(
|
|
4007
|
+
`[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
4008
|
+
);
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
const recheck = await readHooksFile(hooksPath);
|
|
4012
|
+
if (recheck.raw !== raw) {
|
|
4013
|
+
throw new Error(
|
|
4014
|
+
"The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
|
|
4015
|
+
);
|
|
4016
|
+
}
|
|
4017
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
4018
|
+
await writeFileDurable(hooksPath, newBody);
|
|
4019
|
+
console.log(
|
|
4020
|
+
`${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
4021
|
+
);
|
|
4022
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
3620
4023
|
}
|
|
3621
|
-
function
|
|
3622
|
-
|
|
4024
|
+
async function reportCodexHookState(hooksPath, hooksFile, options) {
|
|
4025
|
+
const location = findBasouSessionStartHook(hooksFile);
|
|
4026
|
+
if (location !== null) {
|
|
4027
|
+
const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
|
|
4028
|
+
console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
|
|
4029
|
+
if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
|
|
4030
|
+
}
|
|
4031
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
4032
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
4033
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
4034
|
+
}
|
|
3623
4035
|
}
|
|
3624
|
-
async function
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
return {
|
|
3632
|
-
sessionCount: result.sessionCount,
|
|
3633
|
-
taskCount: result.taskCount,
|
|
3634
|
-
decisionCount: result.decisionCount,
|
|
3635
|
-
pendingApprovalsCount: result.pendingApprovalsCount
|
|
3636
|
-
};
|
|
4036
|
+
async function runCodexHookUninstall(options) {
|
|
4037
|
+
try {
|
|
4038
|
+
await doRunCodexHookUninstall(normalizeInstallOptions(options));
|
|
4039
|
+
} catch (error) {
|
|
4040
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
4041
|
+
process.exitCode = 1;
|
|
4042
|
+
}
|
|
3637
4043
|
}
|
|
3638
|
-
async function
|
|
3639
|
-
const
|
|
3640
|
-
|
|
3641
|
-
await
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
4044
|
+
async function doRunCodexHookUninstall(options) {
|
|
4045
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
4046
|
+
await assertNotSymlink(hooksPath);
|
|
4047
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
4048
|
+
if (raw === null) {
|
|
4049
|
+
console.log("No hooks.json; nothing to remove.");
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
const { hooksFile, action } = removeSessionStartHook(parsed);
|
|
4053
|
+
if (action === "absent") {
|
|
4054
|
+
console.log("No basou Codex SessionStart hook found; nothing removed.");
|
|
4055
|
+
return;
|
|
4056
|
+
}
|
|
4057
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
4058
|
+
`;
|
|
4059
|
+
if (options.dryRun === true) {
|
|
4060
|
+
console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
const recheck = await readHooksFile(hooksPath);
|
|
4064
|
+
if (recheck.raw !== raw) {
|
|
4065
|
+
throw new Error(
|
|
4066
|
+
"The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
|
|
4067
|
+
);
|
|
4068
|
+
}
|
|
4069
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
4070
|
+
await writeFileDurable(hooksPath, newBody);
|
|
4071
|
+
console.log("Removed the basou Codex SessionStart hook from hooks.json.");
|
|
3646
4072
|
}
|
|
3647
|
-
async function
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3655
|
-
suspectCount: result.suspectCount
|
|
3656
|
-
};
|
|
4073
|
+
async function runCodexHookStatus(options) {
|
|
4074
|
+
try {
|
|
4075
|
+
await doRunCodexHookStatus(normalizeInstallOptions(options));
|
|
4076
|
+
} catch (error) {
|
|
4077
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
4078
|
+
process.exitCode = 1;
|
|
4079
|
+
}
|
|
3657
4080
|
}
|
|
3658
|
-
async function
|
|
3659
|
-
const
|
|
3660
|
-
const
|
|
3661
|
-
const
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
4081
|
+
async function doRunCodexHookStatus(options) {
|
|
4082
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
4083
|
+
const { parsed } = await readHooksFile(hooksPath);
|
|
4084
|
+
const location = findBasouSessionStartHook(parsed);
|
|
4085
|
+
if (location === null) {
|
|
4086
|
+
console.log(
|
|
4087
|
+
"basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
|
|
4088
|
+
);
|
|
4089
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
4090
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
4091
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
4092
|
+
}
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
const matcher = location.matcher ?? "(every source)";
|
|
4096
|
+
console.log(
|
|
4097
|
+
`basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
|
|
4098
|
+
);
|
|
4099
|
+
await reportCodexHookState(hooksPath, parsed, options);
|
|
4100
|
+
}
|
|
4101
|
+
async function codexHookTrustFor(hooksPath, location, configPath) {
|
|
4102
|
+
const fields = commandHandlerFields(location.handler);
|
|
4103
|
+
if (fields === null)
|
|
4104
|
+
return { status: "unknown", detail: "the installed handler is not a command hook" };
|
|
4105
|
+
let configToml;
|
|
4106
|
+
try {
|
|
4107
|
+
configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
|
|
4108
|
+
} catch (error) {
|
|
4109
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
4110
|
+
return { status: "untrusted" };
|
|
4111
|
+
}
|
|
3665
4112
|
return {
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
handoff: skipped,
|
|
3669
|
-
decisions: skipped,
|
|
3670
|
-
orientation: skipped,
|
|
3671
|
-
dryRun
|
|
4113
|
+
status: "unknown",
|
|
4114
|
+
detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
|
|
3672
4115
|
};
|
|
3673
4116
|
}
|
|
3674
|
-
const
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
nowIso,
|
|
3680
|
-
scoped ? {} : {
|
|
3681
|
-
staleness: {
|
|
3682
|
-
newSessions: 0,
|
|
3683
|
-
updatedSessions: 0,
|
|
3684
|
-
unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
|
|
3685
|
-
}
|
|
3686
|
-
}
|
|
4117
|
+
const key = codexHookStateKey(
|
|
4118
|
+
hooksPath,
|
|
4119
|
+
"session_start",
|
|
4120
|
+
location.groupIndex,
|
|
4121
|
+
location.handlerIndex
|
|
3687
4122
|
);
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
};
|
|
4123
|
+
const state = readCodexHookState(configToml, key);
|
|
4124
|
+
const current = computeCodexHookIdentityHash({
|
|
4125
|
+
eventKey: "session_start",
|
|
4126
|
+
matcher: location.matcher,
|
|
4127
|
+
handler: fields
|
|
4128
|
+
});
|
|
4129
|
+
return judgeCodexHookTrust(state, current);
|
|
3696
4130
|
}
|
|
3697
|
-
|
|
3698
|
-
|
|
4131
|
+
|
|
4132
|
+
// src/commands/init.ts
|
|
4133
|
+
import { basename as basename5, relative, resolve as resolve6 } from "path";
|
|
4134
|
+
import {
|
|
4135
|
+
appendBasouGitignore,
|
|
4136
|
+
createManifest,
|
|
4137
|
+
ensureBasouDirectory,
|
|
4138
|
+
resolveRepositoryRoot as resolveRepositoryRoot7,
|
|
4139
|
+
writeManifest
|
|
4140
|
+
} from "@basou/core";
|
|
4141
|
+
function collectValue(value, previous) {
|
|
4142
|
+
return [...previous, value];
|
|
3699
4143
|
}
|
|
3700
|
-
function
|
|
3701
|
-
|
|
4144
|
+
function registerInitCommand(program2) {
|
|
4145
|
+
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(
|
|
4146
|
+
"--repo-url <url>",
|
|
4147
|
+
"Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
|
|
4148
|
+
).option(
|
|
4149
|
+
"--source-root <path>",
|
|
4150
|
+
"Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
|
|
4151
|
+
collectValue,
|
|
4152
|
+
[]
|
|
4153
|
+
).option(
|
|
4154
|
+
"--local-only",
|
|
4155
|
+
"Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
|
|
4156
|
+
).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
4157
|
+
await runInit(options);
|
|
4158
|
+
});
|
|
4159
|
+
}
|
|
4160
|
+
async function runInit(options, ctx = {}) {
|
|
4161
|
+
try {
|
|
4162
|
+
await doRunInit(options, ctx);
|
|
4163
|
+
} catch (error) {
|
|
4164
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
4165
|
+
process.exitCode = 1;
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
async function doRunInit(options, ctx) {
|
|
4169
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
4170
|
+
const repositoryRoot = await resolveRepositoryRootForInit(cwd);
|
|
4171
|
+
const workspaceName = options.name ?? basename5(repositoryRoot);
|
|
4172
|
+
if (options.repoUrl !== void 0) {
|
|
4173
|
+
console.error(
|
|
4174
|
+
"Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
|
|
4175
|
+
);
|
|
4176
|
+
}
|
|
4177
|
+
const sourceRoots = (options.sourceRoot ?? []).map((p) => {
|
|
4178
|
+
const rel = relative(repositoryRoot, resolve6(cwd, p));
|
|
4179
|
+
return rel === "" ? "." : rel;
|
|
4180
|
+
});
|
|
4181
|
+
const paths = await ensureBasouDirectory(repositoryRoot);
|
|
4182
|
+
const manifest = createManifest({
|
|
4183
|
+
workspaceName,
|
|
4184
|
+
...options.projectName !== void 0 ? { projectName: options.projectName } : {},
|
|
4185
|
+
...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
|
|
4186
|
+
...sourceRoots.length > 0 ? { sourceRoots } : {}
|
|
4187
|
+
});
|
|
4188
|
+
await writeManifest(paths, manifest, { force: options.force === true });
|
|
4189
|
+
try {
|
|
4190
|
+
await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
|
|
4191
|
+
} catch (error) {
|
|
4192
|
+
renderGitignoreWarning(error, isVerbose(options));
|
|
4193
|
+
}
|
|
4194
|
+
console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
|
|
3702
4195
|
}
|
|
3703
|
-
function
|
|
3704
|
-
|
|
4196
|
+
function renderGitignoreWarning(error, verbose) {
|
|
4197
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
4198
|
+
console.error(
|
|
4199
|
+
`Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
|
|
4200
|
+
);
|
|
4201
|
+
if (verbose && error instanceof Error) {
|
|
4202
|
+
const label = extractCauseLabel(error);
|
|
4203
|
+
if (label !== void 0) console.error(`Caused by: ${label}`);
|
|
4204
|
+
}
|
|
3705
4205
|
}
|
|
3706
|
-
async function
|
|
4206
|
+
async function resolveRepositoryRootForInit(cwd) {
|
|
3707
4207
|
try {
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
|
|
3717
|
-
unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
|
|
3718
|
-
};
|
|
3719
|
-
} catch {
|
|
3720
|
-
return null;
|
|
4208
|
+
return await resolveRepositoryRoot7(cwd);
|
|
4209
|
+
} catch (error) {
|
|
4210
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
4211
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
|
|
4212
|
+
cause: error
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
throw error;
|
|
3721
4216
|
}
|
|
3722
4217
|
}
|
|
3723
4218
|
|
|
3724
|
-
// src/commands/
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
4219
|
+
// src/commands/note.ts
|
|
4220
|
+
import {
|
|
4221
|
+
acquireLock as acquireLock4,
|
|
4222
|
+
appendEventToExistingSession as appendEventToExistingSession2,
|
|
4223
|
+
assertBasouRootSafe as assertBasouRootSafe8,
|
|
4224
|
+
basouPaths as basouPaths9,
|
|
4225
|
+
createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
|
|
4226
|
+
findErrorCode as findErrorCode7,
|
|
4227
|
+
readManifest as readManifest5,
|
|
4228
|
+
resolveSessionId as resolveSessionId2
|
|
4229
|
+
} from "@basou/core";
|
|
4230
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
4231
|
+
var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
|
|
4232
|
+
"list",
|
|
4233
|
+
"ls",
|
|
4234
|
+
"show",
|
|
4235
|
+
"get",
|
|
4236
|
+
"add",
|
|
4237
|
+
"new",
|
|
4238
|
+
"edit",
|
|
4239
|
+
"rm",
|
|
4240
|
+
"remove",
|
|
4241
|
+
"delete",
|
|
4242
|
+
"help"
|
|
4243
|
+
]);
|
|
4244
|
+
var LABEL_BODY_MAX = 80;
|
|
4245
|
+
var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
|
|
4246
|
+
function registerNoteCommand(program2) {
|
|
4247
|
+
program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
|
|
4248
|
+
"--session <session_id>",
|
|
4249
|
+
"Attach to an existing session; otherwise an ad-hoc session is created"
|
|
4250
|
+
).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
|
|
4251
|
+
await runNote(body, options);
|
|
3731
4252
|
});
|
|
3732
4253
|
}
|
|
3733
|
-
async function
|
|
4254
|
+
async function runNote(body, options, ctx = {}) {
|
|
3734
4255
|
try {
|
|
3735
|
-
await
|
|
4256
|
+
await doRunNote(body, options, ctx);
|
|
3736
4257
|
} catch (error) {
|
|
3737
|
-
renderCliError(error, {
|
|
4258
|
+
renderCliError(error, {
|
|
4259
|
+
verbose: isVerbose(options),
|
|
4260
|
+
classifiers: [failedToFinalizeClassifier]
|
|
4261
|
+
});
|
|
3738
4262
|
process.exitCode = 1;
|
|
3739
4263
|
}
|
|
3740
4264
|
}
|
|
3741
|
-
async function
|
|
4265
|
+
async function doRunNote(body, options, ctx) {
|
|
4266
|
+
if (body.trim().length === 0) {
|
|
4267
|
+
throw new Error("Note body must not be empty");
|
|
4268
|
+
}
|
|
4269
|
+
const reserved = body.trim().toLowerCase();
|
|
4270
|
+
if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
|
|
4271
|
+
throw new Error(
|
|
4272
|
+
`'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>"\`).`
|
|
4273
|
+
);
|
|
4274
|
+
}
|
|
3742
4275
|
const cwd = ctx.cwd ?? process.cwd();
|
|
3743
|
-
const repositoryRoot = await resolveBasouRootForCommand(cwd, "
|
|
4276
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
|
|
3744
4277
|
const paths = basouPaths9(repositoryRoot);
|
|
3745
4278
|
await assertWorkspaceInitialized7(paths.root);
|
|
3746
|
-
const
|
|
3747
|
-
const
|
|
3748
|
-
if (
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
4279
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
4280
|
+
const occurredAt = now.toISOString();
|
|
4281
|
+
if (options.session !== void 0) {
|
|
4282
|
+
const sessionId = await resolveSessionId2(paths, options.session);
|
|
4283
|
+
const sesId = sessionId;
|
|
4284
|
+
const sessionLock = await acquireLock4(paths, "session", sesId);
|
|
4285
|
+
let result;
|
|
4286
|
+
try {
|
|
4287
|
+
result = await appendEventToExistingSession2({
|
|
4288
|
+
paths,
|
|
4289
|
+
sessionId: sesId,
|
|
4290
|
+
eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
|
|
4291
|
+
});
|
|
4292
|
+
} finally {
|
|
4293
|
+
await sessionLock.release();
|
|
3759
4294
|
}
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
4295
|
+
printNoteResult(options, {
|
|
4296
|
+
mode: "attached",
|
|
4297
|
+
sessionId,
|
|
4298
|
+
eventId: result.eventId,
|
|
4299
|
+
sessionStatus: result.sessionStatus,
|
|
4300
|
+
body
|
|
4301
|
+
});
|
|
4302
|
+
return;
|
|
3764
4303
|
}
|
|
3765
|
-
const
|
|
4304
|
+
const manifest = await readManifest5(paths);
|
|
4305
|
+
const adHoc = await createAdHocSessionWithEvent2({
|
|
3766
4306
|
paths,
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
4307
|
+
manifest,
|
|
4308
|
+
label: buildAdHocLabel2(body),
|
|
4309
|
+
occurredAt,
|
|
4310
|
+
sessionSource: "human",
|
|
4311
|
+
workingDirectory: repositoryRoot,
|
|
4312
|
+
invocation: {
|
|
4313
|
+
command: "basou note",
|
|
4314
|
+
args: [body]
|
|
4315
|
+
},
|
|
4316
|
+
targetEventBuilders: [
|
|
4317
|
+
(sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
|
|
4318
|
+
]
|
|
3777
4319
|
});
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4320
|
+
printNoteResult(options, {
|
|
4321
|
+
mode: "ad-hoc",
|
|
4322
|
+
sessionId: adHoc.sessionId,
|
|
4323
|
+
eventId: adHoc.targetEventIds[0],
|
|
4324
|
+
sessionStatus: "completed",
|
|
4325
|
+
body
|
|
4326
|
+
});
|
|
4327
|
+
}
|
|
4328
|
+
function buildNoteEvent(input) {
|
|
4329
|
+
return {
|
|
4330
|
+
schema_version: "0.1.0",
|
|
4331
|
+
id: input.eventId,
|
|
4332
|
+
session_id: input.sessionId,
|
|
4333
|
+
occurred_at: input.occurredAt,
|
|
4334
|
+
source: "local-cli",
|
|
4335
|
+
type: "note_added",
|
|
4336
|
+
body: input.body,
|
|
4337
|
+
// `basou note` is the resume-hint command; mark it so orientation surfaces
|
|
4338
|
+
// it as the next step and a plain `basou session note` annotation does not.
|
|
4339
|
+
kind: "next_step"
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
function buildAdHocLabel2(body) {
|
|
4343
|
+
const oneLine2 = body.replace(/\s+/g, " ").trim();
|
|
4344
|
+
const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
|
|
4345
|
+
return `Ad-hoc note: ${truncated}`;
|
|
4346
|
+
}
|
|
4347
|
+
function parseBody(raw) {
|
|
4348
|
+
if (raw.trim().length === 0) {
|
|
4349
|
+
throw new InvalidArgumentError2("Note body must not be empty");
|
|
4350
|
+
}
|
|
4351
|
+
return raw;
|
|
4352
|
+
}
|
|
4353
|
+
function printNoteResult(options, result) {
|
|
4354
|
+
const sid = shortSessionId(result.sessionId);
|
|
4355
|
+
if (options.json === true) {
|
|
3781
4356
|
console.log(
|
|
3782
|
-
|
|
4357
|
+
JSON.stringify({
|
|
4358
|
+
event_id: result.eventId,
|
|
4359
|
+
session_id: result.sessionId,
|
|
4360
|
+
session_status: result.sessionStatus,
|
|
4361
|
+
mode: result.mode,
|
|
4362
|
+
body: result.body
|
|
4363
|
+
})
|
|
3783
4364
|
);
|
|
4365
|
+
return;
|
|
4366
|
+
}
|
|
4367
|
+
if (result.mode === "ad-hoc") {
|
|
4368
|
+
console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
|
|
3784
4369
|
} else {
|
|
3785
|
-
console.log(result.
|
|
4370
|
+
console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
|
|
3786
4371
|
}
|
|
3787
4372
|
}
|
|
3788
4373
|
async function assertWorkspaceInitialized7(basouRoot) {
|
|
@@ -3909,7 +4494,7 @@ import {
|
|
|
3909
4494
|
writeFileSync,
|
|
3910
4495
|
writeSync
|
|
3911
4496
|
} from "fs";
|
|
3912
|
-
import { basename as
|
|
4497
|
+
import { basename as basename6, dirname as dirname4, isAbsolute as isAbsolute3, join as join11, relative as relative2, resolve as resolve7 } from "path";
|
|
3913
4498
|
import {
|
|
3914
4499
|
appendBasouGitignore as appendBasouGitignore2,
|
|
3915
4500
|
basouPaths as basouPaths10,
|
|
@@ -3920,7 +4505,7 @@ import {
|
|
|
3920
4505
|
GENERATED_START,
|
|
3921
4506
|
instructionMode,
|
|
3922
4507
|
isGitNotFound,
|
|
3923
|
-
parseMarkers as
|
|
4508
|
+
parseMarkers as parseMarkers3,
|
|
3924
4509
|
pathBasename,
|
|
3925
4510
|
planArchive,
|
|
3926
4511
|
planGitignore,
|
|
@@ -3928,7 +4513,7 @@ import {
|
|
|
3928
4513
|
planRosterAdoption,
|
|
3929
4514
|
planWorkspaceView,
|
|
3930
4515
|
readManifest as readManifest6,
|
|
3931
|
-
readMarkdownFile as
|
|
4516
|
+
readMarkdownFile as readMarkdownFile7,
|
|
3932
4517
|
reconcileSourceRoots,
|
|
3933
4518
|
removeMarkerSection as removeMarkerSection2,
|
|
3934
4519
|
renderAnchorStarter,
|
|
@@ -4540,9 +5125,9 @@ function applyGitignorePlan(repositoryRoot, plan) {
|
|
|
4540
5125
|
throw new Error("Failed to read .gitignore", { cause: error });
|
|
4541
5126
|
}
|
|
4542
5127
|
}
|
|
4543
|
-
const
|
|
5128
|
+
const sep2 = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
4544
5129
|
try {
|
|
4545
|
-
writeFileSync(file, `${existing}${
|
|
5130
|
+
writeFileSync(file, `${existing}${sep2}${plan.toAdd.join("\n")}
|
|
4546
5131
|
`);
|
|
4547
5132
|
} catch (error) {
|
|
4548
5133
|
throw new Error("Failed to write .gitignore", { cause: error });
|
|
@@ -4682,7 +5267,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4682
5267
|
isAnchor: true,
|
|
4683
5268
|
reachable: true,
|
|
4684
5269
|
canonicalPresent: true,
|
|
4685
|
-
canonicalName:
|
|
5270
|
+
canonicalName: basename6(real),
|
|
4686
5271
|
files: [{ name: CANONICAL_FILE, expectedTarget: CANONICAL_FILE, state: "blocked" }]
|
|
4687
5272
|
};
|
|
4688
5273
|
}
|
|
@@ -4704,14 +5289,14 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4704
5289
|
isAnchor: true,
|
|
4705
5290
|
reachable: true,
|
|
4706
5291
|
canonicalPresent: true,
|
|
4707
|
-
canonicalName:
|
|
5292
|
+
canonicalName: basename6(real),
|
|
4708
5293
|
files: anchorFiles
|
|
4709
5294
|
};
|
|
4710
5295
|
}
|
|
4711
5296
|
if (!existsSync2(join11(real, ".git"))) {
|
|
4712
5297
|
return { ...base, isAnchor: false, reachable: false, canonicalPresent: false, files: [] };
|
|
4713
5298
|
}
|
|
4714
|
-
const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents",
|
|
5299
|
+
const canonicalFile = isSelf ? join11(real, CANONICAL_FILE) : join11(anchorReal, "agents", basename6(real), CANONICAL_FILE);
|
|
4715
5300
|
if (!existsSync2(canonicalFile)) {
|
|
4716
5301
|
return { ...base, isAnchor: false, reachable: true, canonicalPresent: false, files: [] };
|
|
4717
5302
|
}
|
|
@@ -4731,7 +5316,7 @@ function gatherRepoSymlinks(repositoryRoot, anchorReal, entry) {
|
|
|
4731
5316
|
isAnchor: false,
|
|
4732
5317
|
reachable: true,
|
|
4733
5318
|
canonicalPresent: true,
|
|
4734
|
-
canonicalName:
|
|
5319
|
+
canonicalName: basename6(real),
|
|
4735
5320
|
files
|
|
4736
5321
|
};
|
|
4737
5322
|
}
|
|
@@ -4748,7 +5333,7 @@ function applySymlinkPlan(repositoryRoot, plan) {
|
|
|
4748
5333
|
for (const { name, target } of plan.toCreate) {
|
|
4749
5334
|
const filePath = join11(real, name);
|
|
4750
5335
|
try {
|
|
4751
|
-
mkdirSync(
|
|
5336
|
+
mkdirSync(dirname4(filePath), { recursive: true });
|
|
4752
5337
|
symlinkSync(target, filePath);
|
|
4753
5338
|
created.push(name);
|
|
4754
5339
|
} catch (error) {
|
|
@@ -4764,16 +5349,16 @@ function viewCanonicalCollision(repositoryRoot, roster, viewName) {
|
|
|
4764
5349
|
for (const entry of roster) {
|
|
4765
5350
|
let name;
|
|
4766
5351
|
try {
|
|
4767
|
-
name =
|
|
5352
|
+
name = basename6(realpathSync(resolve7(repositoryRoot, entry.path)));
|
|
4768
5353
|
} catch {
|
|
4769
|
-
name =
|
|
5354
|
+
name = basename6(resolve7(repositoryRoot, entry.path));
|
|
4770
5355
|
}
|
|
4771
5356
|
if (name === viewName) return entry.path;
|
|
4772
5357
|
}
|
|
4773
5358
|
return void 0;
|
|
4774
5359
|
}
|
|
4775
5360
|
function gatherViewSymlinks(repositoryRoot, anchorReal, roster, viewDir) {
|
|
4776
|
-
const viewName =
|
|
5361
|
+
const viewName = basename6(viewDir);
|
|
4777
5362
|
const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
|
|
4778
5363
|
if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
|
|
4779
5364
|
const canonicalFile = canonicalFileFor(anchorReal, viewName);
|
|
@@ -4798,7 +5383,7 @@ function applyViewSymlinks(viewDir, files) {
|
|
|
4798
5383
|
if (f.state !== "missing") continue;
|
|
4799
5384
|
const filePath = join11(viewDir, f.name);
|
|
4800
5385
|
try {
|
|
4801
|
-
mkdirSync(
|
|
5386
|
+
mkdirSync(dirname4(filePath), { recursive: true });
|
|
4802
5387
|
symlinkSync(f.expectedTarget, filePath);
|
|
4803
5388
|
created.push(f.name);
|
|
4804
5389
|
} catch (error) {
|
|
@@ -5018,7 +5603,7 @@ function resolveViewDir(repositoryRoot, viewPath) {
|
|
|
5018
5603
|
return realpathSync(abs);
|
|
5019
5604
|
} catch {
|
|
5020
5605
|
try {
|
|
5021
|
-
return join11(realpathSync(
|
|
5606
|
+
return join11(realpathSync(dirname4(abs)), basename6(abs));
|
|
5022
5607
|
} catch {
|
|
5023
5608
|
return abs;
|
|
5024
5609
|
}
|
|
@@ -5035,7 +5620,7 @@ function gatherViewRepo(repositoryRoot, viewDir, entry) {
|
|
|
5035
5620
|
if (expectedTarget === "" || expectedTarget === ".") {
|
|
5036
5621
|
return { path: entry.path, reachable: false };
|
|
5037
5622
|
}
|
|
5038
|
-
const linkName =
|
|
5623
|
+
const linkName = basename6(repoReal);
|
|
5039
5624
|
const { state, actualTarget } = inspectSymlink(join11(viewDir, linkName), expectedTarget);
|
|
5040
5625
|
return {
|
|
5041
5626
|
path: entry.path,
|
|
@@ -5052,7 +5637,7 @@ function applyViewPlan(viewDir, toCreate) {
|
|
|
5052
5637
|
for (const { name, target } of toCreate) {
|
|
5053
5638
|
const filePath = join11(viewDir, name);
|
|
5054
5639
|
try {
|
|
5055
|
-
mkdirSync(
|
|
5640
|
+
mkdirSync(dirname4(filePath), { recursive: true });
|
|
5056
5641
|
symlinkSync(target, filePath);
|
|
5057
5642
|
created.push(name);
|
|
5058
5643
|
} catch (error) {
|
|
@@ -5165,7 +5750,7 @@ async function doRunProjectWorkspace(options, ctx) {
|
|
|
5165
5750
|
} else {
|
|
5166
5751
|
const viewDir = resolveViewDir(repositoryRoot, viewPath);
|
|
5167
5752
|
const facts = roster.map((entry) => gatherViewRepo(repositoryRoot, viewDir, entry));
|
|
5168
|
-
const rosterNames = roster.map((entry) =>
|
|
5753
|
+
const rosterNames = roster.map((entry) => basename6(resolve7(repositoryRoot, entry.path)));
|
|
5169
5754
|
const rosterRealpaths = /* @__PURE__ */ new Set();
|
|
5170
5755
|
for (const entry of roster) {
|
|
5171
5756
|
try {
|
|
@@ -5359,10 +5944,10 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5359
5944
|
if (!existsSync2(join11(real, ".git"))) {
|
|
5360
5945
|
return { ...declared, isAnchor: false, reachable: false, canonicalPresent: false };
|
|
5361
5946
|
}
|
|
5362
|
-
const canonicalName =
|
|
5947
|
+
const canonicalName = basename6(real);
|
|
5363
5948
|
let content;
|
|
5364
5949
|
try {
|
|
5365
|
-
content = await
|
|
5950
|
+
content = await readMarkdownFile7(canonicalFileFor(anchorReal, canonicalName));
|
|
5366
5951
|
} catch {
|
|
5367
5952
|
return {
|
|
5368
5953
|
...declared,
|
|
@@ -5382,7 +5967,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5382
5967
|
canonicalPresent: false
|
|
5383
5968
|
};
|
|
5384
5969
|
}
|
|
5385
|
-
const section =
|
|
5970
|
+
const section = parseMarkers3(content);
|
|
5386
5971
|
return {
|
|
5387
5972
|
...declared,
|
|
5388
5973
|
isAnchor: false,
|
|
@@ -5412,7 +5997,7 @@ function viewPresetReposFor(repositoryRoot, roster) {
|
|
|
5412
5997
|
} catch {
|
|
5413
5998
|
real = void 0;
|
|
5414
5999
|
}
|
|
5415
|
-
const name =
|
|
6000
|
+
const name = basename6(real ?? abs);
|
|
5416
6001
|
const isAnchor = real !== void 0 && anchorReal !== void 0 ? real === anchorReal : abs === repositoryRoot;
|
|
5417
6002
|
return {
|
|
5418
6003
|
name,
|
|
@@ -5445,7 +6030,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
|
|
|
5445
6030
|
}
|
|
5446
6031
|
return { kind: "unreadable", canonicalName: viewName, viewName };
|
|
5447
6032
|
}
|
|
5448
|
-
const section =
|
|
6033
|
+
const section = parseMarkers3(content);
|
|
5449
6034
|
if (section.kind === "ok") {
|
|
5450
6035
|
if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
|
|
5451
6036
|
return { kind: "in-sync", canonicalName: viewName, viewName };
|
|
@@ -5470,8 +6055,8 @@ async function applyViewPreset(anchorReal, outcome) {
|
|
|
5470
6055
|
isLink = false;
|
|
5471
6056
|
}
|
|
5472
6057
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5473
|
-
if (outcome.action === "create") mkdirSync(
|
|
5474
|
-
const existing = await
|
|
6058
|
+
if (outcome.action === "create") mkdirSync(dirname4(file), { recursive: true });
|
|
6059
|
+
const existing = await readMarkdownFile7(file);
|
|
5475
6060
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
|
|
5476
6061
|
}
|
|
5477
6062
|
async function applyPresetPlan(anchorReal, plan) {
|
|
@@ -5484,8 +6069,8 @@ async function applyPresetPlan(anchorReal, plan) {
|
|
|
5484
6069
|
isLink = false;
|
|
5485
6070
|
}
|
|
5486
6071
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5487
|
-
if (plan.action === "create") mkdirSync(
|
|
5488
|
-
const existing = await
|
|
6072
|
+
if (plan.action === "create") mkdirSync(dirname4(file), { recursive: true });
|
|
6073
|
+
const existing = await readMarkdownFile7(file);
|
|
5489
6074
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
|
|
5490
6075
|
}
|
|
5491
6076
|
function presetFailureReason(error) {
|
|
@@ -5507,7 +6092,7 @@ async function doRunProjectPreset(options, ctx) {
|
|
|
5507
6092
|
const facts = [];
|
|
5508
6093
|
for (const entry of roster) facts.push(await gatherRepoPreset(repositoryRoot, anchorReal, entry));
|
|
5509
6094
|
const viewPath = manifest.workspace.view;
|
|
5510
|
-
const viewCanonicalName = roster.length > 0 && viewPath !== void 0 ?
|
|
6095
|
+
const viewCanonicalName = roster.length > 0 && viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
|
|
5511
6096
|
const summary = summarizePresetPlan(
|
|
5512
6097
|
facts,
|
|
5513
6098
|
viewCanonicalName !== void 0 ? { viewCanonicalName } : void 0
|
|
@@ -5734,7 +6319,7 @@ function gatherArchiveTeardown(repositoryRoot, manifest, target) {
|
|
|
5734
6319
|
return empty;
|
|
5735
6320
|
}
|
|
5736
6321
|
const anchorReal = realpathSync(repositoryRoot);
|
|
5737
|
-
const canonicalName =
|
|
6322
|
+
const canonicalName = basename6(real);
|
|
5738
6323
|
const instructionFiles = [];
|
|
5739
6324
|
for (const name of INSTRUCTION_FILES) {
|
|
5740
6325
|
try {
|
|
@@ -5804,7 +6389,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5804
6389
|
}
|
|
5805
6390
|
const isAnchor = repoReal !== void 0 && repoReal === anchorReal;
|
|
5806
6391
|
const targetAbs = resolve7(repositoryRoot, target);
|
|
5807
|
-
const canonicalName =
|
|
6392
|
+
const canonicalName = basename6(repoReal ?? targetAbs);
|
|
5808
6393
|
const roster = manifest.repos ?? [];
|
|
5809
6394
|
const declaredEntry = roster.find((r) => {
|
|
5810
6395
|
try {
|
|
@@ -5825,10 +6410,10 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5825
6410
|
}
|
|
5826
6411
|
if (rReal !== null) {
|
|
5827
6412
|
if (repoReal !== void 0 && rReal === repoReal) return false;
|
|
5828
|
-
return
|
|
6413
|
+
return basename6(rReal).toLowerCase() === cnFold;
|
|
5829
6414
|
}
|
|
5830
6415
|
if (resolve7(repositoryRoot, r.path) === targetAbs) return false;
|
|
5831
|
-
return
|
|
6416
|
+
return basename6(resolve7(repositoryRoot, r.path)).toLowerCase() === cnFold;
|
|
5832
6417
|
});
|
|
5833
6418
|
const collisionNote = "shared with another repo of the same basename, so it cannot be removed (check manually)";
|
|
5834
6419
|
const items = [];
|
|
@@ -5949,7 +6534,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5949
6534
|
});
|
|
5950
6535
|
}
|
|
5951
6536
|
if (content !== void 0 && content !== "") {
|
|
5952
|
-
const section =
|
|
6537
|
+
const section = parseMarkers3(content);
|
|
5953
6538
|
if (section.kind === "ok" && canonicalShared) {
|
|
5954
6539
|
items.push({
|
|
5955
6540
|
kind: "canonical-block",
|
|
@@ -6078,7 +6663,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
6078
6663
|
}
|
|
6079
6664
|
try {
|
|
6080
6665
|
const content = readFileSync(fd, "utf8");
|
|
6081
|
-
if (
|
|
6666
|
+
if (parseMarkers3(content).kind !== "ok") {
|
|
6082
6667
|
changed(item.label);
|
|
6083
6668
|
continue;
|
|
6084
6669
|
}
|
|
@@ -6293,7 +6878,7 @@ function renderProjectArchive(result) {
|
|
|
6293
6878
|
if (t.gitignorePatterns.length > 0)
|
|
6294
6879
|
items.push(`.gitignore instruction patterns (${t.gitignorePatterns.join(", ")})`);
|
|
6295
6880
|
if (t.canonical)
|
|
6296
|
-
items.push(`the anchor's canonical (agents/${
|
|
6881
|
+
items.push(`the anchor's canonical (agents/${basename6(result.target)}/AGENTS.md)`);
|
|
6297
6882
|
if (!t.inspected) {
|
|
6298
6883
|
lines.push(
|
|
6299
6884
|
"## Manual teardown (the repo could not be resolved on disk, so it was not inspected)"
|
|
@@ -6504,7 +7089,7 @@ function validateProjectName(name) {
|
|
|
6504
7089
|
async function doRunProjectNew(repos, options, ctx) {
|
|
6505
7090
|
const cwd = ctx.cwd ?? process.cwd();
|
|
6506
7091
|
const repositoryRoot = await resolveRepositoryRootForNew(cwd);
|
|
6507
|
-
const workspaceName =
|
|
7092
|
+
const workspaceName = basename6(repositoryRoot);
|
|
6508
7093
|
const productName = options.projectName !== void 0 ? validateProjectName(options.projectName) : void 0;
|
|
6509
7094
|
const viewStem = productName ?? workspaceName;
|
|
6510
7095
|
const viewOverridesProjectName = productName !== void 0 && typeof options.view === "string";
|
|
@@ -6703,10 +7288,10 @@ async function doRunProjectSeedAnchor(options, ctx) {
|
|
|
6703
7288
|
return;
|
|
6704
7289
|
}
|
|
6705
7290
|
const viewPath = manifest.workspace.view;
|
|
6706
|
-
const viewName = viewPath !== void 0 ?
|
|
7291
|
+
const viewName = viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
|
|
6707
7292
|
const repos = viewPresetReposFor(repositoryRoot, roster);
|
|
6708
7293
|
const content = renderAnchorStarter({
|
|
6709
|
-
anchorName:
|
|
7294
|
+
anchorName: basename6(repositoryRoot),
|
|
6710
7295
|
...manifest.project?.name !== void 0 ? { projectName: manifest.project.name } : {},
|
|
6711
7296
|
...viewName !== void 0 ? { viewName } : {},
|
|
6712
7297
|
repos
|
|
@@ -6787,7 +7372,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
|
|
|
6787
7372
|
const self = declaredEntry !== void 0 && instructionMode(declaredEntry) === "self";
|
|
6788
7373
|
const displayRel = argReal !== void 0 ? relative2(anchorReal, argReal) : relative2(repositoryRoot, argAbs);
|
|
6789
7374
|
const path = displayRel === "" ? "." : displayRel;
|
|
6790
|
-
const canonicalName =
|
|
7375
|
+
const canonicalName = basename6(argReal ?? argAbs);
|
|
6791
7376
|
if (argReal === void 0) {
|
|
6792
7377
|
return {
|
|
6793
7378
|
path,
|
|
@@ -6821,7 +7406,7 @@ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, arg
|
|
|
6821
7406
|
function relocateAgentsFile(repoReal, canonicalFile) {
|
|
6822
7407
|
const agentsFile = join11(repoReal, CANONICAL_FILE);
|
|
6823
7408
|
try {
|
|
6824
|
-
mkdirSync(
|
|
7409
|
+
mkdirSync(dirname4(canonicalFile), { recursive: true });
|
|
6825
7410
|
} catch (error) {
|
|
6826
7411
|
return { ok: false, message: failureReason(error), partial: false };
|
|
6827
7412
|
}
|
|
@@ -6842,7 +7427,7 @@ function relocateAgentsFile(repoReal, canonicalFile) {
|
|
|
6842
7427
|
function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
|
|
6843
7428
|
if (viewPath === void 0) return { kind: "no-view" };
|
|
6844
7429
|
const viewDir = resolveViewDir(repositoryRoot, viewPath);
|
|
6845
|
-
const viewName =
|
|
7430
|
+
const viewName = basename6(viewDir);
|
|
6846
7431
|
const collision = viewCanonicalCollision(repositoryRoot, roster, viewName);
|
|
6847
7432
|
if (collision !== void 0) return { kind: "collision", viewName, repoPath: collision };
|
|
6848
7433
|
const canonicalFile = canonicalFileFor(anchorReal, viewName);
|
|
@@ -6853,7 +7438,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
|
|
|
6853
7438
|
if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
|
|
6854
7439
|
return { kind: "unreadable", viewName };
|
|
6855
7440
|
}
|
|
6856
|
-
const section =
|
|
7441
|
+
const section = parseMarkers3(content);
|
|
6857
7442
|
if (section.kind === "ok") return { kind: "already-marked", viewName };
|
|
6858
7443
|
if (section.kind === "no_markers") {
|
|
6859
7444
|
const block = renderViewPresetBlock({
|
|
@@ -6874,7 +7459,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
|
|
|
6874
7459
|
isLink = false;
|
|
6875
7460
|
}
|
|
6876
7461
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
6877
|
-
const existing = await
|
|
7462
|
+
const existing = await readMarkdownFile7(file);
|
|
6878
7463
|
await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
|
|
6879
7464
|
}
|
|
6880
7465
|
async function doRunProjectRetrofit(repo, options, ctx) {
|
|
@@ -6921,7 +7506,7 @@ async function doRunProjectRetrofit(repo, options, ctx) {
|
|
|
6921
7506
|
} catch {
|
|
6922
7507
|
argReal = void 0;
|
|
6923
7508
|
}
|
|
6924
|
-
const viewCanonicalName = viewPath !== void 0 ?
|
|
7509
|
+
const viewCanonicalName = viewPath !== void 0 ? basename6(resolveViewDir(repositoryRoot, viewPath)) : void 0;
|
|
6925
7510
|
const facts = gatherRetrofit(
|
|
6926
7511
|
repositoryRoot,
|
|
6927
7512
|
anchorReal,
|
|
@@ -7137,7 +7722,7 @@ function renderProjectRetrofit(result) {
|
|
|
7137
7722
|
|
|
7138
7723
|
// src/commands/protocol.ts
|
|
7139
7724
|
import { readFile as readFile4 } from "fs/promises";
|
|
7140
|
-
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as
|
|
7725
|
+
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile8 } from "@basou/core";
|
|
7141
7726
|
|
|
7142
7727
|
// src/lib/protocols-config.ts
|
|
7143
7728
|
import { homedir as homedir8 } from "os";
|
|
@@ -7221,7 +7806,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
|
|
|
7221
7806
|
var MANAGED_NOTE = "<!-- Managed by basou: 'basou protocol sync' regenerates everything between the BASOU:PROTOCOLS markers from ~/.basou/protocols.yaml. Manual edits inside the block are overwritten; edit the source files instead. -->";
|
|
7222
7807
|
function registerProtocolCommand(program2) {
|
|
7223
7808
|
const protocol = program2.command("protocol").description("Manage the basou-managed standing-protocol block in the global CLAUDE.md");
|
|
7224
|
-
protocol.command("sync").description(
|
|
7809
|
+
protocol.command("sync").description(
|
|
7810
|
+
"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."
|
|
7811
|
+
).option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
7225
7812
|
await runProtocolSync(opts);
|
|
7226
7813
|
});
|
|
7227
7814
|
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) => {
|
|
@@ -7287,18 +7874,23 @@ ${body}` : body;
|
|
|
7287
7874
|
${sections.join("\n\n")}
|
|
7288
7875
|
`;
|
|
7289
7876
|
}
|
|
7290
|
-
async function doRunProtocolSync(options) {
|
|
7877
|
+
async function doRunProtocolSync(options, ctx = {}) {
|
|
7291
7878
|
const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
|
|
7292
7879
|
const target = options.target ?? DEFAULT_TARGET_PATH;
|
|
7293
7880
|
const entries = await loadProtocolsConfig(configPath);
|
|
7294
7881
|
const sources = await readProtocolSources(entries);
|
|
7295
7882
|
const block = buildBlock(sources);
|
|
7883
|
+
const foreign = await findForeignWorkspaceNames({
|
|
7884
|
+
text: block,
|
|
7885
|
+
configPath: ctx.portfolioConfigPath
|
|
7886
|
+
});
|
|
7296
7887
|
const result = await syncMarkerBlock({
|
|
7297
7888
|
target,
|
|
7298
7889
|
markers: PROTOCOL_MARKERS,
|
|
7299
7890
|
block,
|
|
7300
7891
|
...options.dryRun === true ? { dryRun: true } : {}
|
|
7301
7892
|
});
|
|
7893
|
+
if (foreign !== null) console.error(protocolForeignWorkspaceWarning(foreign));
|
|
7302
7894
|
if (result.action === "unchanged") {
|
|
7303
7895
|
console.log(`The basou:protocols block is already up to date (${entries.length} protocol(s)).`);
|
|
7304
7896
|
return;
|
|
@@ -7320,8 +7912,8 @@ async function doRunProtocolList(options) {
|
|
|
7320
7912
|
const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
|
|
7321
7913
|
const target = options.target ?? DEFAULT_TARGET_PATH;
|
|
7322
7914
|
const entries = await loadProtocolsConfig(configPath);
|
|
7323
|
-
const existing = await
|
|
7324
|
-
const installed = existing !== null &&
|
|
7915
|
+
const existing = await readMarkdownFile8(target);
|
|
7916
|
+
const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
|
|
7325
7917
|
console.log(`Declared protocols (${entries.length}):`);
|
|
7326
7918
|
for (const entry of entries) {
|
|
7327
7919
|
console.log(` - ${entry.title ?? entry.source}`);
|
|
@@ -7356,21 +7948,6 @@ import {
|
|
|
7356
7948
|
} from "@basou/core";
|
|
7357
7949
|
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
7358
7950
|
|
|
7359
|
-
// src/lib/channel-policy.ts
|
|
7360
|
-
function decideCodexChannel(manifest) {
|
|
7361
|
-
if (manifest.policies?.confidential === true) return { write: false, reason: "confidential" };
|
|
7362
|
-
if (manifest.channels?.codex === true) return { write: true };
|
|
7363
|
-
return { write: false, reason: "not_enabled" };
|
|
7364
|
-
}
|
|
7365
|
-
function describeCodexChannelSkip(reason) {
|
|
7366
|
-
switch (reason) {
|
|
7367
|
-
case "confidential":
|
|
7368
|
-
return "codex channel: skipped (confidential workspace \u2014 nothing is written to the user-global ~/.codex/AGENTS.md)";
|
|
7369
|
-
case "not_enabled":
|
|
7370
|
-
return "codex channel: skipped (this workspace has not opted in; the user-global ~/.codex/AGENTS.md is left untouched \u2014 see docs/spec/schemas.md \xA74.2)";
|
|
7371
|
-
}
|
|
7372
|
-
}
|
|
7373
|
-
|
|
7374
7951
|
// src/commands/refresh-watch.ts
|
|
7375
7952
|
import { readdir as readdir2, stat as stat5 } from "fs/promises";
|
|
7376
7953
|
import { homedir as homedir9 } from "os";
|
|
@@ -7522,7 +8099,7 @@ function abortableSleep(ms, signal) {
|
|
|
7522
8099
|
}
|
|
7523
8100
|
function registerRefreshCommand(program2) {
|
|
7524
8101
|
program2.command("refresh").description(
|
|
7525
|
-
"Import all adapters for the project and regenerate handoff + decisions in one step"
|
|
8102
|
+
"Import all adapters for the project and regenerate handoff + decisions in one step. Writes only inside the workspace's .basou/ \u2014 never to a user-global file another project's tool reads (a Codex session gets the position from the SessionStart hook; see `basou hook install codex`)."
|
|
7526
8103
|
).option(
|
|
7527
8104
|
"--project <path>",
|
|
7528
8105
|
"Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
|
|
@@ -7568,7 +8145,7 @@ async function doRunRefreshPortfolio(options, ctx) {
|
|
|
7568
8145
|
for (const ws of workspaces) {
|
|
7569
8146
|
const label = ws.label ?? ws.path;
|
|
7570
8147
|
try {
|
|
7571
|
-
const { result } = await computeRefresh(
|
|
8148
|
+
const { result, paths } = await computeRefresh(
|
|
7572
8149
|
{ ...options, portfolio: false },
|
|
7573
8150
|
{ ...ctx, cwd: ws.path }
|
|
7574
8151
|
);
|
|
@@ -7578,6 +8155,7 @@ async function doRunRefreshPortfolio(options, ctx) {
|
|
|
7578
8155
|
## ${label} (${ws.path})`);
|
|
7579
8156
|
printRefreshSummary(result);
|
|
7580
8157
|
}
|
|
8158
|
+
await warnPosition(paths, result, ctx);
|
|
7581
8159
|
} catch (error) {
|
|
7582
8160
|
const message = error instanceof Error ? error.message : String(error);
|
|
7583
8161
|
rollup.push({ label, path: ws.path, status: "failed", error: message });
|
|
@@ -7653,47 +8231,31 @@ async function computeRefresh(options, ctx) {
|
|
|
7653
8231
|
}
|
|
7654
8232
|
async function doRunRefresh(options, ctx) {
|
|
7655
8233
|
const { result, paths } = await computeRefresh(options, ctx);
|
|
7656
|
-
const
|
|
7657
|
-
const reported = { ...result, codexChannel: channel.outcome };
|
|
8234
|
+
const reported = { ...result, codexChannel: { status: "retired" } };
|
|
7658
8235
|
if (options.json === true) {
|
|
7659
8236
|
console.log(JSON.stringify(reported));
|
|
7660
8237
|
} else {
|
|
7661
8238
|
printRefreshSummary(result);
|
|
7662
|
-
|
|
8239
|
+
const line = await retiredChannelNotice(paths);
|
|
8240
|
+
if (line !== null) console.log(line);
|
|
7663
8241
|
}
|
|
8242
|
+
await warnPosition(paths, result, ctx);
|
|
7664
8243
|
return reported;
|
|
7665
8244
|
}
|
|
7666
|
-
async function
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
line: `codex channel: skipped (manifest could not be re-read, so the opt-in cannot be confirmed: ${detail})`
|
|
7675
|
-
};
|
|
7676
|
-
}
|
|
7677
|
-
if (!decision.write) {
|
|
7678
|
-
return {
|
|
7679
|
-
outcome: { status: "skipped", reason: decision.reason },
|
|
7680
|
-
line: describeCodexChannelSkip(decision.reason)
|
|
7681
|
-
};
|
|
7682
|
-
}
|
|
8245
|
+
async function warnPosition(paths, result, ctx) {
|
|
8246
|
+
if (result.orientation.status !== "generated") return;
|
|
8247
|
+
await warnIfPositionNamesOtherWorkspaces({
|
|
8248
|
+
paths,
|
|
8249
|
+
configPath: ctx.portfolioConfigPath
|
|
8250
|
+
});
|
|
8251
|
+
}
|
|
8252
|
+
async function retiredChannelNotice(paths) {
|
|
7683
8253
|
try {
|
|
7684
|
-
const
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
return { outcome: { status: "skipped", reason: "no_orientation" }, line: null };
|
|
7690
|
-
return { outcome: { status: "written", action: rendered.action }, line: rendered.line };
|
|
7691
|
-
} catch (error) {
|
|
7692
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
7693
|
-
return {
|
|
7694
|
-
outcome: { status: "skipped", reason: "error", detail },
|
|
7695
|
-
line: `codex channel skipped: ${detail}`
|
|
7696
|
-
};
|
|
8254
|
+
const manifest = await readManifest7(paths);
|
|
8255
|
+
if (manifest.channels?.codex !== true) return null;
|
|
8256
|
+
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";
|
|
8257
|
+
} catch {
|
|
8258
|
+
return null;
|
|
7697
8259
|
}
|
|
7698
8260
|
}
|
|
7699
8261
|
function describeImport(outcome) {
|
|
@@ -8271,7 +8833,7 @@ function unattachedLines(u) {
|
|
|
8271
8833
|
}
|
|
8272
8834
|
|
|
8273
8835
|
// src/commands/run.ts
|
|
8274
|
-
import { mkdir as mkdir2 } from "fs/promises";
|
|
8836
|
+
import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
|
|
8275
8837
|
import { homedir as homedir11 } from "os";
|
|
8276
8838
|
import { join as join14 } from "path";
|
|
8277
8839
|
import {
|
|
@@ -8283,6 +8845,7 @@ import {
|
|
|
8283
8845
|
codexAdapterMetadata,
|
|
8284
8846
|
appendChainedEvent as coreAppendChainedEvent2,
|
|
8285
8847
|
finalizeSessionYaml as finalizeSessionYaml2,
|
|
8848
|
+
findBasouSessionStartHook as findBasouSessionStartHook2,
|
|
8286
8849
|
getDiff,
|
|
8287
8850
|
getSnapshot as getSnapshot2,
|
|
8288
8851
|
overwriteYamlFile as overwriteYamlFile2,
|
|
@@ -8334,7 +8897,7 @@ function runCodex(args, options, ctx = {}) {
|
|
|
8334
8897
|
resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
|
|
8335
8898
|
metadata: codexAdapterMetadata,
|
|
8336
8899
|
transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
|
|
8337
|
-
preSpawn:
|
|
8900
|
+
preSpawn: noteCodexHookStatusPreSpawn
|
|
8338
8901
|
});
|
|
8339
8902
|
}
|
|
8340
8903
|
async function runTrackedTool(args, options, ctx, adapter) {
|
|
@@ -8701,25 +9264,26 @@ async function resolveRepositoryRootForRun(cwd) {
|
|
|
8701
9264
|
throw error;
|
|
8702
9265
|
}
|
|
8703
9266
|
}
|
|
8704
|
-
async function
|
|
9267
|
+
async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
|
|
9268
|
+
const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
9269
|
+
let location;
|
|
8705
9270
|
try {
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
});
|
|
8714
|
-
return rendered === null ? null : rendered.line;
|
|
8715
|
-
} catch {
|
|
8716
|
-
return null;
|
|
9271
|
+
location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
|
|
9272
|
+
} catch (error) {
|
|
9273
|
+
if (!(error instanceof Error && error.code === "ENOENT")) return null;
|
|
9274
|
+
location = null;
|
|
9275
|
+
}
|
|
9276
|
+
if (location === null) {
|
|
9277
|
+
return "codex: the basou SessionStart hook is not registered, so this session starts without the workspace's position (`basou hook install codex` registers it once for every workspace)";
|
|
8717
9278
|
}
|
|
9279
|
+
const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
|
|
9280
|
+
if (trust.status === "trusted" || trust.status === "unknown") return null;
|
|
9281
|
+
return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
|
|
8718
9282
|
}
|
|
8719
9283
|
|
|
8720
9284
|
// src/commands/session.ts
|
|
8721
|
-
import { readFile as
|
|
8722
|
-
import { basename as
|
|
9285
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
9286
|
+
import { basename as basename7, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
|
|
8723
9287
|
import {
|
|
8724
9288
|
acquireLock as acquireLock6,
|
|
8725
9289
|
appendEventToExistingSession as appendEventToExistingSession3,
|
|
@@ -9169,7 +9733,7 @@ async function doRunSessionImport(options, ctx) {
|
|
|
9169
9733
|
}
|
|
9170
9734
|
async function readInputFile(path) {
|
|
9171
9735
|
try {
|
|
9172
|
-
return await
|
|
9736
|
+
return await readFile7(path, "utf8");
|
|
9173
9737
|
} catch (error) {
|
|
9174
9738
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9175
9739
|
throw new Error("Import source not found", { cause: error });
|
|
@@ -9227,7 +9791,7 @@ function printSessionImportResult(options, result) {
|
|
|
9227
9791
|
return;
|
|
9228
9792
|
}
|
|
9229
9793
|
console.log(
|
|
9230
|
-
`Imported session ${sid} (${result.eventCount} events) from ${
|
|
9794
|
+
`Imported session ${sid} (${result.eventCount} events) from ${basename7(options.from)}`
|
|
9231
9795
|
);
|
|
9232
9796
|
}
|
|
9233
9797
|
var NOTE_BODY_PREVIEW_LIMIT = 80;
|
|
@@ -9286,7 +9850,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
|
|
|
9286
9850
|
}
|
|
9287
9851
|
async function readNoteFile(path) {
|
|
9288
9852
|
try {
|
|
9289
|
-
return await
|
|
9853
|
+
return await readFile7(path, "utf8");
|
|
9290
9854
|
} catch (error) {
|
|
9291
9855
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9292
9856
|
throw new Error("Note source not found", { cause: error });
|
|
@@ -9607,7 +10171,7 @@ function formatVersionGateMessage(error) {
|
|
|
9607
10171
|
}
|
|
9608
10172
|
|
|
9609
10173
|
// src/commands/task.ts
|
|
9610
|
-
import { readFile as
|
|
10174
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
9611
10175
|
import { join as join16 } from "path";
|
|
9612
10176
|
import {
|
|
9613
10177
|
archiveTask,
|
|
@@ -10622,7 +11186,7 @@ function parsePositiveInt2(raw) {
|
|
|
10622
11186
|
}
|
|
10623
11187
|
async function readDescriptionFile(path) {
|
|
10624
11188
|
try {
|
|
10625
|
-
return await
|
|
11189
|
+
return await readFile8(path, "utf8");
|
|
10626
11190
|
} catch (error) {
|
|
10627
11191
|
if (findErrorCode15(error, "ENOENT")) {
|
|
10628
11192
|
throw new Error("Description source not found", { cause: error });
|
|
@@ -10826,8 +11390,8 @@ async function assertWorkspaceInitialized14(basouRoot) {
|
|
|
10826
11390
|
|
|
10827
11391
|
// src/commands/view.ts
|
|
10828
11392
|
import { spawn } from "child_process";
|
|
10829
|
-
import { createHash } from "crypto";
|
|
10830
|
-
import { basename as
|
|
11393
|
+
import { createHash as createHash2 } from "crypto";
|
|
11394
|
+
import { basename as basename10, resolve as resolve13 } from "path";
|
|
10831
11395
|
import {
|
|
10832
11396
|
assertBasouRootSafe as assertBasouRootSafe18,
|
|
10833
11397
|
basouPaths as basouPaths22,
|
|
@@ -10841,7 +11405,7 @@ import { InvalidArgumentError as InvalidArgumentError7 } from "commander";
|
|
|
10841
11405
|
import { createReadStream as createReadStream2 } from "fs";
|
|
10842
11406
|
import { readdir as readdir3, stat as stat6 } from "fs/promises";
|
|
10843
11407
|
import { homedir as homedir12 } from "os";
|
|
10844
|
-
import { basename as
|
|
11408
|
+
import { basename as basename8, dirname as dirname5, join as join17 } from "path";
|
|
10845
11409
|
import { createInterface as createInterface2 } from "readline";
|
|
10846
11410
|
import { basouPaths as basouPaths21, readManifest as readManifest13, resolveRepositoryRoot as resolveRepositoryRoot14 } from "@basou/core";
|
|
10847
11411
|
function uncapturedTotal(result) {
|
|
@@ -10862,7 +11426,7 @@ async function checkPortfolioCoverage(workspaces, ctx = {}) {
|
|
|
10862
11426
|
file,
|
|
10863
11427
|
"claude-code",
|
|
10864
11428
|
claudeTranscriptCwd,
|
|
10865
|
-
listedDirs.has(
|
|
11429
|
+
listedDirs.has(basename8(dirname5(file)))
|
|
10866
11430
|
);
|
|
10867
11431
|
}
|
|
10868
11432
|
}
|
|
@@ -11151,7 +11715,7 @@ function inertLines(result) {
|
|
|
11151
11715
|
|
|
11152
11716
|
// src/lib/portfolio-safety.ts
|
|
11153
11717
|
import { execFile } from "child_process";
|
|
11154
|
-
import { lstat as lstat2, realpath as
|
|
11718
|
+
import { lstat as lstat2, realpath as realpath4 } from "fs/promises";
|
|
11155
11719
|
import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
|
|
11156
11720
|
import { promisify } from "util";
|
|
11157
11721
|
import { readManifest as readManifest14 } from "@basou/core";
|
|
@@ -11161,7 +11725,7 @@ function errorCode(error) {
|
|
|
11161
11725
|
}
|
|
11162
11726
|
async function canonical(p) {
|
|
11163
11727
|
try {
|
|
11164
|
-
return await
|
|
11728
|
+
return await realpath4(p);
|
|
11165
11729
|
} catch {
|
|
11166
11730
|
return resolve11(p);
|
|
11167
11731
|
}
|
|
@@ -11330,7 +11894,7 @@ function formatSafetyReport(result) {
|
|
|
11330
11894
|
|
|
11331
11895
|
// src/lib/view-server.ts
|
|
11332
11896
|
import { createServer } from "http";
|
|
11333
|
-
import { basename as
|
|
11897
|
+
import { basename as basename9, join as join19, resolve as resolve12 } from "path";
|
|
11334
11898
|
import {
|
|
11335
11899
|
computeWorkStats as computeWorkStats2,
|
|
11336
11900
|
enumerateApprovals as enumerateApprovals2,
|
|
@@ -11341,7 +11905,7 @@ import {
|
|
|
11341
11905
|
loadTaskEntries as loadTaskEntries2,
|
|
11342
11906
|
readAllEvents as readAllEvents2,
|
|
11343
11907
|
readManifest as readManifest15,
|
|
11344
|
-
readMarkdownFile as
|
|
11908
|
+
readMarkdownFile as readMarkdownFile9,
|
|
11345
11909
|
readSessionYaml as readSessionYaml3,
|
|
11346
11910
|
readTaskFile as readTaskFile2,
|
|
11347
11911
|
renderDecisions as renderDecisions3,
|
|
@@ -12206,6 +12770,9 @@ async function handleWorkspacePost(res, sub, ws, body, deps, runExclusive) {
|
|
|
12206
12770
|
const result = await runExclusive(
|
|
12207
12771
|
() => refreshAll({ options: actionOptions, ctx: ws.importCtx, paths: ws.paths, nowIso })
|
|
12208
12772
|
);
|
|
12773
|
+
if (result.orientation.status === "generated") {
|
|
12774
|
+
await warnIfPositionNamesOtherWorkspaces({ paths: ws.paths });
|
|
12775
|
+
}
|
|
12209
12776
|
sendJson(res, 200, result);
|
|
12210
12777
|
return true;
|
|
12211
12778
|
}
|
|
@@ -12336,7 +12903,7 @@ async function rosterRepos(repoRoot, manifest, resolveRemoteUrl) {
|
|
|
12336
12903
|
const remote = await resolveRemoteUrl(abs);
|
|
12337
12904
|
const url = remote !== void 0 ? toBrowserUrl(remote) : null;
|
|
12338
12905
|
return {
|
|
12339
|
-
name:
|
|
12906
|
+
name: basename9(abs),
|
|
12340
12907
|
path: repo.path,
|
|
12341
12908
|
...url !== null ? { url } : {},
|
|
12342
12909
|
...repo.visibility !== void 0 ? { visibility: repo.visibility } : {}
|
|
@@ -12393,7 +12960,7 @@ async function taskDetail(ws, taskId) {
|
|
|
12393
12960
|
}
|
|
12394
12961
|
}
|
|
12395
12962
|
async function decisionsView(ws, nowProvider) {
|
|
12396
|
-
const fromDisk = await
|
|
12963
|
+
const fromDisk = await readMarkdownFile9(ws.paths.files.decisions);
|
|
12397
12964
|
if (fromDisk !== null) {
|
|
12398
12965
|
return { body: fromDisk, fromDisk: true };
|
|
12399
12966
|
}
|
|
@@ -12416,7 +12983,7 @@ async function approvalsView(ws, nowProvider) {
|
|
|
12416
12983
|
return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
|
|
12417
12984
|
}
|
|
12418
12985
|
async function handoffView(ws, nowProvider) {
|
|
12419
|
-
const fromDisk = await
|
|
12986
|
+
const fromDisk = await readMarkdownFile9(ws.paths.files.handoff);
|
|
12420
12987
|
if (fromDisk !== null) {
|
|
12421
12988
|
return { body: fromDisk, fromDisk: true };
|
|
12422
12989
|
}
|
|
@@ -12650,8 +13217,8 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
|
|
|
12650
13217
|
} catch (error) {
|
|
12651
13218
|
const notFound = error instanceof Error && error.message === "YAML file not found";
|
|
12652
13219
|
return {
|
|
12653
|
-
key: `ws-${
|
|
12654
|
-
label: labelOverride ??
|
|
13220
|
+
key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
|
|
13221
|
+
label: labelOverride ?? basename10(repoRoot),
|
|
12655
13222
|
paths,
|
|
12656
13223
|
repoRoot,
|
|
12657
13224
|
importCtx,
|