@basou/cli 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1275 -890
- package/dist/index.js.map +1 -1
- package/dist/program.js +1275 -890
- 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}
|
|
@@ -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,226 @@ 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 realpath2, 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 readMarkdownFile5,
|
|
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
|
-
|
|
2350
|
-
|
|
2351
|
-
async function doRunHookStop(options, ctx) {
|
|
2352
|
-
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
2353
|
-
const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
|
|
2354
|
-
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
2355
|
-
const raw = await readStdin();
|
|
2356
|
-
if (raw.trim().length === 0) return;
|
|
2357
|
-
let payload;
|
|
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;
|
|
2373
|
-
}
|
|
2374
|
-
const records = parseTranscript(transcript);
|
|
2375
|
-
const evaluation = evaluateStopHook({
|
|
2376
|
-
records,
|
|
2377
|
-
// stop_hook_active was already handled by the early return above.
|
|
2378
|
-
stopHookActive: false,
|
|
2379
|
-
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
2380
|
-
});
|
|
2381
|
-
const parts = [];
|
|
2382
|
-
if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
|
|
2383
|
-
if (options.requireReview === true && evaluation.review.fires) {
|
|
2384
|
-
parts.push(evaluation.review.additionalContext);
|
|
2385
|
-
}
|
|
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
|
-
}
|
|
2397
|
-
function parseTranscript(transcript) {
|
|
2398
|
-
const records = [];
|
|
2399
|
-
for (const line of transcript.split(/\r?\n/)) {
|
|
2400
|
-
if (line.trim().length === 0) continue;
|
|
2401
|
-
try {
|
|
2402
|
-
const parsed = JSON.parse(line);
|
|
2403
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
2404
|
-
records.push(parsed);
|
|
2405
|
-
}
|
|
2406
|
-
} catch {
|
|
2407
|
-
}
|
|
2408
|
-
}
|
|
2409
|
-
return records;
|
|
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;
|
|
2410
2278
|
}
|
|
2411
|
-
|
|
2412
|
-
if (
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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;
|
|
2416
2286
|
}
|
|
2417
|
-
return
|
|
2287
|
+
return value;
|
|
2418
2288
|
}
|
|
2419
|
-
|
|
2420
|
-
const
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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})`;
|
|
2431
2381
|
}
|
|
2432
2382
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2383
|
+
|
|
2384
|
+
// src/commands/orient.ts
|
|
2385
|
+
import {
|
|
2386
|
+
assertBasouRootSafe as assertBasouRootSafe7,
|
|
2387
|
+
basouPaths as basouPaths8,
|
|
2388
|
+
findErrorCode as findErrorCode6,
|
|
2389
|
+
renderOrientation as renderOrientation2,
|
|
2390
|
+
writeMarkdownFile as writeMarkdownFile4
|
|
2391
|
+
} from "@basou/core";
|
|
2392
|
+
|
|
2393
|
+
// src/lib/hosts-config.ts
|
|
2394
|
+
import { homedir as homedir5 } from "os";
|
|
2395
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve4 } from "path";
|
|
2396
|
+
import { readYamlFile as readYamlFile4 } from "@basou/core";
|
|
2397
|
+
var DEFAULT_HOSTS_CONFIG_PATH = join7(homedir5(), ".basou", "hosts.yaml");
|
|
2398
|
+
function expandTilde2(p) {
|
|
2399
|
+
if (p === "~") return homedir5();
|
|
2400
|
+
if (p.startsWith("~/")) return join7(homedir5(), p.slice(2));
|
|
2401
|
+
return p;
|
|
2440
2402
|
}
|
|
2441
|
-
function
|
|
2442
|
-
|
|
2443
|
-
if (raw.block === true) out.block = true;
|
|
2444
|
-
if (raw.requireReview === true) out.requireReview = true;
|
|
2445
|
-
if (raw.settings !== void 0) out.settings = raw.settings;
|
|
2446
|
-
if (raw.dryRun === true) out.dryRun = true;
|
|
2447
|
-
if (raw.verbose === true) out.verbose = true;
|
|
2448
|
-
if (raw.minEdits !== void 0) {
|
|
2449
|
-
const parsed = parseMinEdits(raw.minEdits);
|
|
2450
|
-
if (parsed === void 0) {
|
|
2451
|
-
throw new Error("--min-edits must be a non-negative integer.");
|
|
2452
|
-
}
|
|
2453
|
-
out.minEdits = parsed;
|
|
2454
|
-
}
|
|
2455
|
-
return out;
|
|
2403
|
+
function isRecord2(value) {
|
|
2404
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2456
2405
|
}
|
|
2457
|
-
async function
|
|
2406
|
+
async function loadHostsConfig(configPath = DEFAULT_HOSTS_CONFIG_PATH) {
|
|
2458
2407
|
let raw;
|
|
2459
2408
|
try {
|
|
2460
|
-
raw = await
|
|
2409
|
+
raw = await readYamlFile4(configPath);
|
|
2461
2410
|
} catch (error) {
|
|
2462
|
-
if (error instanceof Error && error.
|
|
2463
|
-
return
|
|
2411
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
2412
|
+
return null;
|
|
2413
|
+
}
|
|
2414
|
+
if (error instanceof Error && error.message === "Failed to parse YAML content") {
|
|
2415
|
+
throw new Error("~/.basou/hosts.yaml is not valid YAML.");
|
|
2464
2416
|
}
|
|
2465
2417
|
throw error;
|
|
2466
2418
|
}
|
|
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
|
-
);
|
|
2477
|
-
}
|
|
2478
|
-
}
|
|
2479
|
-
async function backupSettingsOnce(path, raw) {
|
|
2480
|
-
if (raw === null) return;
|
|
2481
|
-
const bak = `${path}.basou-bak`;
|
|
2482
|
-
try {
|
|
2483
|
-
await stat3(bak);
|
|
2484
|
-
return;
|
|
2485
|
-
} catch (error) {
|
|
2486
|
-
if (!(error instanceof Error && error.code === "ENOENT")) throw error;
|
|
2419
|
+
if (!isRecord2(raw) || !Array.isArray(raw.hosts)) {
|
|
2420
|
+
throw new Error("~/.basou/hosts.yaml must contain a 'hosts:' list.");
|
|
2487
2421
|
}
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2422
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
2423
|
+
const seenLabels = /* @__PURE__ */ new Set();
|
|
2424
|
+
const result = [];
|
|
2425
|
+
for (const entry of raw.hosts) {
|
|
2426
|
+
if (!isRecord2(entry) || typeof entry.label !== "string" || entry.label.trim().length === 0) {
|
|
2427
|
+
throw new Error("Each host needs a non-empty string 'label'.");
|
|
2428
|
+
}
|
|
2429
|
+
const label = entry.label.trim();
|
|
2430
|
+
if (typeof entry.path !== "string" || entry.path.trim().length === 0) {
|
|
2431
|
+
throw new Error("Each host needs a non-empty string 'path'.");
|
|
2432
|
+
}
|
|
2433
|
+
const expanded = expandTilde2(entry.path.trim());
|
|
2434
|
+
if (!isAbsolute2(expanded)) {
|
|
2435
|
+
throw new Error("Host paths must be absolute (or start with '~').");
|
|
2436
|
+
}
|
|
2437
|
+
const abs = resolve4(expanded);
|
|
2438
|
+
if (seenPaths.has(abs)) continue;
|
|
2439
|
+
if (seenLabels.has(label)) {
|
|
2440
|
+
throw new Error(`Duplicate host label '${label}'; each host needs a distinct label.`);
|
|
2441
|
+
}
|
|
2442
|
+
seenPaths.add(abs);
|
|
2443
|
+
seenLabels.add(label);
|
|
2444
|
+
result.push({ label, path: abs });
|
|
2496
2445
|
}
|
|
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}`;
|
|
2446
|
+
return result;
|
|
2597
2447
|
}
|
|
2598
2448
|
|
|
2449
|
+
// src/lib/provenance-actions.ts
|
|
2450
|
+
import {
|
|
2451
|
+
readMarkdownFile as readMarkdownFile4,
|
|
2452
|
+
renderDecisions as renderDecisions2,
|
|
2453
|
+
renderHandoff as renderHandoff2,
|
|
2454
|
+
renderOrientation,
|
|
2455
|
+
renderWithMarkers as renderWithMarkers3,
|
|
2456
|
+
writeMarkdownFile as writeMarkdownFile3
|
|
2457
|
+
} from "@basou/core";
|
|
2458
|
+
|
|
2599
2459
|
// src/commands/import.ts
|
|
2600
2460
|
import { createReadStream } from "fs";
|
|
2601
|
-
import { readdir, readFile as
|
|
2461
|
+
import { readdir, readFile as readFile2, rm, stat as stat3 } from "fs/promises";
|
|
2602
2462
|
import { homedir as homedir6 } from "os";
|
|
2603
|
-
import { basename as basename3, dirname as dirname2, join as join8, resolve as
|
|
2463
|
+
import { basename as basename3, dirname as dirname2, join as join8, resolve as resolve5 } from "path";
|
|
2604
2464
|
import { createInterface } from "readline";
|
|
2605
2465
|
import {
|
|
2606
2466
|
AGENT_INFRA_DIRS as AGENT_INFRA_DIRS2,
|
|
@@ -2670,10 +2530,10 @@ function resolveSourceRoots(args) {
|
|
|
2670
2530
|
const { projectFlags, manifest, repoRoot, cwd } = args;
|
|
2671
2531
|
let resolved;
|
|
2672
2532
|
if (projectFlags.length > 0) {
|
|
2673
|
-
resolved = projectFlags.map((p) =>
|
|
2533
|
+
resolved = projectFlags.map((p) => resolve5(cwd, p));
|
|
2674
2534
|
} else {
|
|
2675
2535
|
const roots = manifest.import?.source_roots;
|
|
2676
|
-
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) =>
|
|
2536
|
+
resolved = roots !== void 0 && roots.length > 0 ? roots.map((r) => resolve5(repoRoot, r)) : [repoRoot];
|
|
2677
2537
|
}
|
|
2678
2538
|
return [...new Set(resolved)];
|
|
2679
2539
|
}
|
|
@@ -2996,7 +2856,7 @@ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
|
|
|
2996
2856
|
}
|
|
2997
2857
|
async function pathExists(file) {
|
|
2998
2858
|
try {
|
|
2999
|
-
await
|
|
2859
|
+
await stat3(file);
|
|
3000
2860
|
return true;
|
|
3001
2861
|
} catch (error) {
|
|
3002
2862
|
if (findErrorCode5(error, "ENOENT")) return false;
|
|
@@ -3005,7 +2865,7 @@ async function pathExists(file) {
|
|
|
3005
2865
|
}
|
|
3006
2866
|
async function statSize(file) {
|
|
3007
2867
|
try {
|
|
3008
|
-
return (await
|
|
2868
|
+
return (await stat3(file)).size;
|
|
3009
2869
|
} catch (error) {
|
|
3010
2870
|
if (findErrorCode5(error, "ENOENT")) return void 0;
|
|
3011
2871
|
throw error;
|
|
@@ -3091,7 +2951,7 @@ async function readFirstLine(file) {
|
|
|
3091
2951
|
async function readJsonlRecords(file) {
|
|
3092
2952
|
let buffer;
|
|
3093
2953
|
try {
|
|
3094
|
-
buffer = await
|
|
2954
|
+
buffer = await readFile2(file);
|
|
3095
2955
|
} catch (error) {
|
|
3096
2956
|
if (findErrorCode5(error, "ENOENT")) {
|
|
3097
2957
|
throw new Error("Source log not found", { cause: error });
|
|
@@ -3220,569 +3080,1129 @@ async function assertWorkspaceInitialized5(basouRoot) {
|
|
|
3220
3080
|
}
|
|
3221
3081
|
}
|
|
3222
3082
|
|
|
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;
|
|
3083
|
+
// src/lib/provenance-actions.ts
|
|
3084
|
+
async function captureImportJson(fn) {
|
|
3085
|
+
const stdout = [];
|
|
3086
|
+
const originalLog = console.log;
|
|
3087
|
+
const originalError = console.error;
|
|
3088
|
+
console.log = ((...args) => {
|
|
3089
|
+
stdout.push(args.map((a) => String(a)).join(" "));
|
|
3271
3090
|
});
|
|
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 } : {}
|
|
3091
|
+
console.error = (() => {
|
|
3278
3092
|
});
|
|
3279
|
-
await writeManifest(paths, manifest, { force: options.force === true });
|
|
3280
3093
|
try {
|
|
3281
|
-
await
|
|
3282
|
-
}
|
|
3283
|
-
|
|
3094
|
+
await fn();
|
|
3095
|
+
} finally {
|
|
3096
|
+
console.log = originalLog;
|
|
3097
|
+
console.error = originalError;
|
|
3284
3098
|
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3099
|
+
for (let i = stdout.length - 1; i >= 0; i--) {
|
|
3100
|
+
const line = stdout[i];
|
|
3101
|
+
if (line === void 0) continue;
|
|
3102
|
+
try {
|
|
3103
|
+
const parsed = JSON.parse(line);
|
|
3104
|
+
if (parsed !== null && typeof parsed === "object" && "imported_count" in parsed) {
|
|
3105
|
+
return parsed;
|
|
3106
|
+
}
|
|
3107
|
+
} catch {
|
|
3108
|
+
}
|
|
3295
3109
|
}
|
|
3110
|
+
throw new Error("Import produced no parseable result");
|
|
3296
3111
|
}
|
|
3297
|
-
|
|
3112
|
+
function readCount(value) {
|
|
3113
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
3114
|
+
}
|
|
3115
|
+
function isMissingSourceDir(error) {
|
|
3116
|
+
if (!(error instanceof Error)) return false;
|
|
3117
|
+
return error.message === "Claude transcript directory not found for project" || error.message === "Codex sessions directory not found";
|
|
3118
|
+
}
|
|
3119
|
+
async function runImport(adapter, fn) {
|
|
3298
3120
|
try {
|
|
3299
|
-
|
|
3121
|
+
const json = await captureImportJson(fn);
|
|
3122
|
+
return {
|
|
3123
|
+
adapter,
|
|
3124
|
+
status: "ran",
|
|
3125
|
+
importedCount: readCount(json.imported_count),
|
|
3126
|
+
replacedCount: readCount(json.replaced_count),
|
|
3127
|
+
reimportedCount: readCount(json.reimported_count),
|
|
3128
|
+
skippedNoAction: readCount(json.skipped_no_action),
|
|
3129
|
+
skippedAlreadyImported: readCount(json.skipped_already_imported),
|
|
3130
|
+
skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
|
|
3131
|
+
skippedDecreased: readCount(json.skipped_decreased),
|
|
3132
|
+
skippedDuplicate: readCount(json.skipped_duplicate),
|
|
3133
|
+
skippedUnverifiable: readCount(json.skipped_unverifiable),
|
|
3134
|
+
eventTotal: readCount(json.event_total),
|
|
3135
|
+
dryRun: json.dry_run === true
|
|
3136
|
+
};
|
|
3300
3137
|
} catch (error) {
|
|
3301
|
-
if (error
|
|
3302
|
-
|
|
3303
|
-
cause: error
|
|
3304
|
-
});
|
|
3138
|
+
if (isMissingSourceDir(error)) {
|
|
3139
|
+
return { adapter, status: "skipped", reason: "no source logs for this project" };
|
|
3305
3140
|
}
|
|
3306
3141
|
throw error;
|
|
3307
3142
|
}
|
|
3308
3143
|
}
|
|
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
|
-
|
|
3144
|
+
function importOptions(options) {
|
|
3145
|
+
return {
|
|
3146
|
+
all: true,
|
|
3147
|
+
json: true,
|
|
3148
|
+
...options.project !== void 0 ? { project: options.project } : {},
|
|
3149
|
+
...options.force === true ? { force: true } : {},
|
|
3150
|
+
...options.dryRun === true ? { dryRun: true } : {}
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
function importClaudeCode(options, ctx) {
|
|
3154
|
+
return runImport("claude-code", () => doRunImportClaudeCode(importOptions(options), ctx));
|
|
3155
|
+
}
|
|
3156
|
+
function importCodex(options, ctx) {
|
|
3157
|
+
return runImport("codex", () => doRunImportCodex(importOptions(options), ctx));
|
|
3158
|
+
}
|
|
3159
|
+
async function regenerateHandoff(paths, nowIso, callbacks) {
|
|
3160
|
+
const result = await renderHandoff2({ paths, nowIso, ...callbacks });
|
|
3161
|
+
const existing = await readMarkdownFile4(paths.files.handoff);
|
|
3162
|
+
await writeMarkdownFile3(
|
|
3163
|
+
paths.files.handoff,
|
|
3164
|
+
renderWithMarkers3(existing, result.body, "handoff.md")
|
|
3165
|
+
);
|
|
3166
|
+
return {
|
|
3167
|
+
sessionCount: result.sessionCount,
|
|
3168
|
+
taskCount: result.taskCount,
|
|
3169
|
+
decisionCount: result.decisionCount,
|
|
3170
|
+
pendingApprovalsCount: result.pendingApprovalsCount
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
async function regenerateDecisions(paths, nowIso, callbacks) {
|
|
3174
|
+
const result = await renderDecisions2({ paths, nowIso, ...callbacks });
|
|
3175
|
+
const existing = await readMarkdownFile4(paths.files.decisions);
|
|
3176
|
+
await writeMarkdownFile3(
|
|
3177
|
+
paths.files.decisions,
|
|
3178
|
+
renderWithMarkers3(existing, result.body, "decisions.md")
|
|
3179
|
+
);
|
|
3180
|
+
return { decisionCount: result.decisionCount };
|
|
3181
|
+
}
|
|
3182
|
+
async function regenerateOrientation(paths, nowIso, callbacks) {
|
|
3183
|
+
const result = await renderOrientation({ paths, nowIso, ...callbacks });
|
|
3184
|
+
await writeMarkdownFile3(paths.files.orientation, `${result.body}
|
|
3185
|
+
`);
|
|
3186
|
+
return {
|
|
3187
|
+
sessionCount: result.sessionCount,
|
|
3188
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3189
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3190
|
+
suspectCount: result.suspectCount
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
async function refreshAll(args) {
|
|
3194
|
+
const { options, ctx, paths, nowIso } = args;
|
|
3195
|
+
const dryRun = options.dryRun === true;
|
|
3196
|
+
const claudeCode = await importClaudeCode(options, ctx);
|
|
3197
|
+
const codex = await importCodex(options, ctx);
|
|
3198
|
+
if (dryRun) {
|
|
3199
|
+
const skipped = { status: "skipped", reason: "dry-run" };
|
|
3200
|
+
return {
|
|
3201
|
+
claudeCode,
|
|
3202
|
+
codex,
|
|
3203
|
+
handoff: skipped,
|
|
3204
|
+
decisions: skipped,
|
|
3205
|
+
orientation: skipped,
|
|
3206
|
+
dryRun
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
const handoffCounts = await regenerateHandoff(paths, nowIso);
|
|
3210
|
+
const decisionCounts = await regenerateDecisions(paths, nowIso);
|
|
3211
|
+
const scoped = options.project !== void 0 && options.project.length > 0;
|
|
3212
|
+
const orientationCounts = await regenerateOrientation(
|
|
3213
|
+
paths,
|
|
3214
|
+
nowIso,
|
|
3215
|
+
scoped ? {} : {
|
|
3216
|
+
staleness: {
|
|
3217
|
+
newSessions: 0,
|
|
3218
|
+
updatedSessions: 0,
|
|
3219
|
+
unverifiableSessions: wouldBlock(claudeCode) + wouldBlock(codex)
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
);
|
|
3223
|
+
return {
|
|
3224
|
+
claudeCode,
|
|
3225
|
+
codex,
|
|
3226
|
+
handoff: { status: "generated", ...handoffCounts },
|
|
3227
|
+
decisions: { status: "generated", ...decisionCounts },
|
|
3228
|
+
orientation: { status: "generated", ...orientationCounts },
|
|
3229
|
+
dryRun
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
function wouldImport(outcome) {
|
|
3233
|
+
return outcome.status === "ran" ? outcome.importedCount : 0;
|
|
3234
|
+
}
|
|
3235
|
+
function wouldUpdate(outcome) {
|
|
3236
|
+
return outcome.status === "ran" ? outcome.reimportedCount + outcome.replacedCount : 0;
|
|
3237
|
+
}
|
|
3238
|
+
function wouldBlock(outcome) {
|
|
3239
|
+
return outcome.status === "ran" ? outcome.skippedUnverifiable : 0;
|
|
3240
|
+
}
|
|
3241
|
+
async function probeStaleness(args) {
|
|
3242
|
+
try {
|
|
3243
|
+
const dry = await refreshAll({
|
|
3244
|
+
options: { dryRun: true },
|
|
3245
|
+
ctx: args.ctx,
|
|
3246
|
+
paths: args.paths,
|
|
3247
|
+
nowIso: args.nowIso
|
|
3248
|
+
});
|
|
3249
|
+
return {
|
|
3250
|
+
newSessions: wouldImport(dry.claudeCode) + wouldImport(dry.codex),
|
|
3251
|
+
updatedSessions: wouldUpdate(dry.claudeCode) + wouldUpdate(dry.codex),
|
|
3252
|
+
unverifiableSessions: wouldBlock(dry.claudeCode) + wouldBlock(dry.codex)
|
|
3253
|
+
};
|
|
3254
|
+
} catch {
|
|
3255
|
+
return null;
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// src/commands/orient.ts
|
|
3260
|
+
function registerOrientCommand(program2) {
|
|
3261
|
+
program2.command("orient").description("Show the workspace's current position (also writes .basou/orientation.md)").option("-q, --quiet", "Write the file without printing the body").option(
|
|
3262
|
+
"--refresh",
|
|
3263
|
+
"Import all adapters first (writes provenance), then show a guaranteed-fresh position; bare orient is read-only"
|
|
3264
|
+
).option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
3265
|
+
await runOrient(opts);
|
|
3343
3266
|
});
|
|
3344
3267
|
}
|
|
3345
|
-
async function
|
|
3268
|
+
async function runOrient(options, ctx = {}) {
|
|
3346
3269
|
try {
|
|
3347
|
-
await
|
|
3270
|
+
await doRunOrient(options, ctx);
|
|
3348
3271
|
} catch (error) {
|
|
3349
|
-
renderCliError(error, {
|
|
3350
|
-
verbose: isVerbose(options),
|
|
3351
|
-
classifiers: [failedToFinalizeClassifier]
|
|
3352
|
-
});
|
|
3272
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3353
3273
|
process.exitCode = 1;
|
|
3354
3274
|
}
|
|
3355
3275
|
}
|
|
3356
|
-
async function
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
|
|
3362
|
-
throw new Error(
|
|
3363
|
-
`'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>"\`).`
|
|
3276
|
+
async function doRunOrient(options, ctx) {
|
|
3277
|
+
const result = await renderOrientationForCwd(options, ctx);
|
|
3278
|
+
if (options.quiet === true) {
|
|
3279
|
+
console.log(
|
|
3280
|
+
`Generated .basou/orientation.md (sessions: ${result.sessionCount}, in-flight tasks: ${result.inFlightTaskCount}, pending approvals: ${result.pendingApprovalsCount}, suspect: ${result.suspectCount})`
|
|
3364
3281
|
);
|
|
3282
|
+
} else {
|
|
3283
|
+
console.log(result.body);
|
|
3365
3284
|
}
|
|
3285
|
+
}
|
|
3286
|
+
async function renderOrientationForCwd(options, ctx) {
|
|
3366
3287
|
const cwd = ctx.cwd ?? process.cwd();
|
|
3367
|
-
const repositoryRoot = await resolveBasouRootForCommand(cwd, "
|
|
3288
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "orient");
|
|
3289
|
+
return renderOrientationForRoot(repositoryRoot, options, ctx, { write: true });
|
|
3290
|
+
}
|
|
3291
|
+
async function renderOrientationForRoot(repositoryRoot, options, ctx, behaviour) {
|
|
3368
3292
|
const paths = basouPaths8(repositoryRoot);
|
|
3369
3293
|
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();
|
|
3294
|
+
const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
3295
|
+
const probeCtx = { cwd: repositoryRoot };
|
|
3296
|
+
if (ctx.claudeProjectsDir !== void 0) probeCtx.claudeProjectsDir = ctx.claudeProjectsDir;
|
|
3297
|
+
if (ctx.codexSessionsDir !== void 0) probeCtx.codexSessionsDir = ctx.codexSessionsDir;
|
|
3298
|
+
if (options.refresh === true) {
|
|
3299
|
+
await refreshAll({ options: {}, ctx: probeCtx, paths, nowIso });
|
|
3300
|
+
}
|
|
3301
|
+
const staleness = await probeStaleness({ ctx: probeCtx, paths, nowIso });
|
|
3302
|
+
let federatedRoots = [];
|
|
3303
|
+
try {
|
|
3304
|
+
const hosts = await loadHostsConfig(ctx.hostsConfigPath);
|
|
3305
|
+
if (hosts !== null) {
|
|
3306
|
+
federatedRoots = hosts.map((h) => ({ paths: basouPaths8(h.path), host: h.label }));
|
|
3385
3307
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3308
|
+
} catch (error) {
|
|
3309
|
+
console.error(
|
|
3310
|
+
`basou: ignoring ~/.basou/hosts.yaml (${error instanceof Error ? error.message : String(error)}); showing local sessions only.`
|
|
3311
|
+
);
|
|
3312
|
+
}
|
|
3313
|
+
const result = await renderOrientation2({
|
|
3314
|
+
paths,
|
|
3315
|
+
nowIso,
|
|
3316
|
+
staleness,
|
|
3317
|
+
verbose: options.verbose === true,
|
|
3318
|
+
federatedRoots,
|
|
3319
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
3320
|
+
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
|
|
3321
|
+
onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason),
|
|
3322
|
+
onHostUnavailable: (host, error) => console.error(
|
|
3323
|
+
`basou: host '${host}' mirror unreadable (${error instanceof Error ? error.message : String(error)}); skipping it.`
|
|
3324
|
+
)
|
|
3325
|
+
});
|
|
3326
|
+
if (behaviour.write) await writeMarkdownFile4(paths.files.orientation, `${result.body}
|
|
3327
|
+
`);
|
|
3328
|
+
return {
|
|
3329
|
+
body: result.body,
|
|
3330
|
+
sessionCount: result.sessionCount,
|
|
3331
|
+
inFlightTaskCount: result.inFlightTaskCount,
|
|
3332
|
+
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3333
|
+
suspectCount: result.suspectCount
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
async function assertWorkspaceInitialized6(basouRoot) {
|
|
3337
|
+
try {
|
|
3338
|
+
await assertBasouRootSafe7(basouRoot);
|
|
3339
|
+
} catch (error) {
|
|
3340
|
+
if (findErrorCode6(error, "ENOENT")) {
|
|
3341
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
3342
|
+
}
|
|
3343
|
+
throw error;
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// src/commands/hook.ts
|
|
3348
|
+
var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
|
|
3349
|
+
function registerHookCommand(program2) {
|
|
3350
|
+
const hook = program2.command("hook").description(
|
|
3351
|
+
"Hook handlers for AI coding tools (Claude Code, Codex): read a hook payload on stdin, emit the tool's hook output on stdout"
|
|
3352
|
+
);
|
|
3353
|
+
hook.command("session-start").description(
|
|
3354
|
+
"Codex SessionStart hook: print the current position of the workspace Codex was opened in (read from the payload's cwd) so Codex adds it to that session's context. Stays silent outside a basou workspace; never fails the session."
|
|
3355
|
+
).addHelpText("after", HOOK_SESSION_START_HELP).action(async () => {
|
|
3356
|
+
await runHookSessionStart();
|
|
3357
|
+
});
|
|
3358
|
+
hook.command("stop").description(
|
|
3359
|
+
"Stop-hook: when a substantive session recorded no decisions or next step, emit a non-blocking nudge to capture them. Reads the Stop hook JSON payload on stdin; never blocks and never fails the session."
|
|
3360
|
+
).option(
|
|
3361
|
+
"--min-edits <n>",
|
|
3362
|
+
`Minimum file edits before nudging on edits alone (default ${DEFAULT_STOP_HOOK_MIN_EDITS})`
|
|
3363
|
+
).option(
|
|
3364
|
+
"--block",
|
|
3365
|
+
"Opt-in enforcement: hold the agent in-turn (decision:block) instead of a non-blocking reminder"
|
|
3366
|
+
).option(
|
|
3367
|
+
"--require-review",
|
|
3368
|
+
"Opt-in review gate: also remind when a session shipped substantive code (push / PR / merge) without recording a review"
|
|
3369
|
+
).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
|
|
3370
|
+
const minEdits = parseMinEdits(options.minEdits);
|
|
3371
|
+
await runHookStop({
|
|
3372
|
+
...minEdits !== void 0 ? { minEdits } : {},
|
|
3373
|
+
...options.block === true ? { block: true } : {},
|
|
3374
|
+
...options.requireReview === true ? { requireReview: true } : {}
|
|
3375
|
+
});
|
|
3376
|
+
});
|
|
3377
|
+
hook.command("install [target]").description(
|
|
3378
|
+
"Register a basou hook (reproducible, idempotent). Target `claude` (default): the Stop hook in ~/.claude/settings.json \u2014 advisory capture-only by default; --block opts into in-turn enforcement, --require-review into the review gate. Target `codex`: the SessionStart hook in ~/.codex/hooks.json, which hands each Codex session the position of the workspace it was opened in. Codex asks you to review and trust a new hook once before it runs."
|
|
3379
|
+
).option(
|
|
3380
|
+
"--block",
|
|
3381
|
+
"claude: register the blocking (opt-in enforcement) form instead of advisory"
|
|
3382
|
+
).option("--require-review", "claude: register with the opt-in review gate enabled").option("--min-edits <n>", "claude: pass a custom file-edit threshold to the registered hook").option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
|
|
3383
|
+
"--codex-config <path>",
|
|
3384
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3385
|
+
).option(
|
|
3386
|
+
"--codex-face <path>",
|
|
3387
|
+
"codex: override the user-global AGENTS.md checked for a leftover block (intended for tests)"
|
|
3388
|
+
).option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3389
|
+
await dispatchHookTarget(target, opts, {
|
|
3390
|
+
claude: () => runHookInstall(opts),
|
|
3391
|
+
codex: () => runCodexHookInstall(opts)
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
hook.command("uninstall [target]").description(
|
|
3395
|
+
"Remove a basou hook, leaving other hooks intact. Target `claude` (default): the Stop hook in ~/.claude/settings.json. Target `codex`: the SessionStart hook in ~/.codex/hooks.json."
|
|
3396
|
+
).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3397
|
+
await dispatchHookTarget(target, opts, {
|
|
3398
|
+
claude: () => runHookUninstall(opts),
|
|
3399
|
+
codex: () => runCodexHookUninstall(opts)
|
|
3392
3400
|
});
|
|
3401
|
+
});
|
|
3402
|
+
hook.command("status [target]").description(
|
|
3403
|
+
"Report whether a basou hook is registered. Target `claude` (default): the Stop hook and its mode. Target `codex`: the SessionStart hook, and whether Codex has trusted it yet."
|
|
3404
|
+
).option("--settings <path>", "claude: override the settings.json path (intended for tests)").option("--hooks <path>", "codex: override the hooks.json path (intended for tests)").option(
|
|
3405
|
+
"--codex-config <path>",
|
|
3406
|
+
"codex: override the Codex config.toml path (intended for tests)"
|
|
3407
|
+
).option("-v, --verbose", "Show error causes").action(async (target, opts) => {
|
|
3408
|
+
await dispatchHookTarget(target, opts, {
|
|
3409
|
+
claude: () => runHookStatus(opts),
|
|
3410
|
+
codex: () => runCodexHookStatus(opts)
|
|
3411
|
+
});
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
async function dispatchHookTarget(target, options, handlers) {
|
|
3415
|
+
const resolved = target ?? "claude";
|
|
3416
|
+
if (resolved !== "claude" && resolved !== "codex") {
|
|
3417
|
+
renderCliError(
|
|
3418
|
+
new Error(`Unknown hook target '${target}'. Targets: claude (default), codex.`),
|
|
3419
|
+
{ verbose: isVerbose(options) }
|
|
3420
|
+
);
|
|
3421
|
+
process.exitCode = 1;
|
|
3393
3422
|
return;
|
|
3394
3423
|
}
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3424
|
+
await handlers[resolved]();
|
|
3425
|
+
}
|
|
3426
|
+
var HOOK_SESSION_START_HELP = `
|
|
3427
|
+
Register this hook reproducibly with 'basou hook install codex' (it writes the
|
|
3428
|
+
correct node-path command into ~/.codex/hooks.json). 'basou hook uninstall codex'
|
|
3429
|
+
removes it; 'basou hook status codex' reports whether it is registered and
|
|
3430
|
+
whether Codex has trusted it.
|
|
3431
|
+
|
|
3432
|
+
Codex runs the hook when a session starts and passes the session's cwd on stdin.
|
|
3433
|
+
basou resolves the workspace from that cwd (a member repo resolves to its
|
|
3434
|
+
planning master, a workspace view to its master) and prints the workspace's
|
|
3435
|
+
current position \u2014 the same text as 'basou orient' \u2014 which Codex adds to that
|
|
3436
|
+
session's context as developer text. The position is computed at that moment
|
|
3437
|
+
from that cwd and stored nowhere: a Codex opened in another workspace gets that
|
|
3438
|
+
workspace's position, and one opened outside any basou workspace (or before the
|
|
3439
|
+
desktop app has bound a folder, when cwd is '/') gets nothing. That is how one
|
|
3440
|
+
user-global hook serves every workspace without any workspace's position ever
|
|
3441
|
+
being written where another workspace's session would read it.
|
|
3442
|
+
|
|
3443
|
+
Codex trusts hooks by hash. A newly installed or changed hook is skipped until
|
|
3444
|
+
you review it: the interactive CLI asks at startup ("Hooks need review"), the
|
|
3445
|
+
desktop app lists it under Settings -> Hooks. Non-interactive 'codex exec' skips
|
|
3446
|
+
an untrusted hook silently.
|
|
3447
|
+
`;
|
|
3448
|
+
var HOOK_STOP_HELP = `
|
|
3449
|
+
Register this Stop hook reproducibly with 'basou hook install' (it writes the
|
|
3450
|
+
correct node-path command into ~/.claude/settings.json). 'basou hook uninstall'
|
|
3451
|
+
removes it; 'basou hook status' reports whether it is registered.
|
|
3452
|
+
|
|
3453
|
+
On every turn end basou inspects the session transcript. If the session did
|
|
3454
|
+
content-substantive work but ran no capture verb ('basou decision capture' /
|
|
3455
|
+
'decision record' / 'note'), it reminds the agent to record the why / next step.
|
|
3456
|
+
Substantive = EITHER >= ${DEFAULT_STOP_HOOK_MIN_EDITS} file edits (default) OR a free-form AskUserQuestion
|
|
3457
|
+
answer (an uncaptured conversational decision). Read-only Bash (ls / grep /
|
|
3458
|
+
git status) does NOT count.
|
|
3459
|
+
|
|
3460
|
+
With --require-review (opt-in, 'basou hook install --require-review') it also
|
|
3461
|
+
reminds when the session SHIPPED substantive code (git push / git merge /
|
|
3462
|
+
gh pr create|merge) without recording a review ('basou review record'). This
|
|
3463
|
+
gate is off by default; when on, its reminder is composed into the same
|
|
3464
|
+
envelope as the capture reminder.
|
|
3465
|
+
|
|
3466
|
+
By default the reminder is non-blocking: Claude sees it and may act on it or
|
|
3467
|
+
stop. With --block (opt-in enforcement, 'basou hook install --block') it instead
|
|
3468
|
+
returns decision:block, holding the agent in-turn to act on the reminder; the
|
|
3469
|
+
'stop_hook_active' flag and Claude Code's own loop prevention bound it to a
|
|
3470
|
+
single turn. Either way the hook fails open: a bad payload or unreadable
|
|
3471
|
+
transcript exits cleanly with no output.
|
|
3472
|
+
`;
|
|
3473
|
+
async function runHookStop(options, ctx = {}) {
|
|
3474
|
+
try {
|
|
3475
|
+
await doRunHookStop(options, ctx);
|
|
3476
|
+
} catch {
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
async function doRunHookStop(options, ctx) {
|
|
3480
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3481
|
+
const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
|
|
3482
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3483
|
+
const raw = await readStdin();
|
|
3484
|
+
if (raw.trim().length === 0) return;
|
|
3485
|
+
let payload;
|
|
3486
|
+
try {
|
|
3487
|
+
payload = JSON.parse(raw);
|
|
3488
|
+
} catch {
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3492
|
+
const fields = payload;
|
|
3493
|
+
if (fields.stop_hook_active === true) return;
|
|
3494
|
+
const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
|
|
3495
|
+
if (transcriptPath.length === 0) return;
|
|
3496
|
+
let transcript;
|
|
3497
|
+
try {
|
|
3498
|
+
transcript = await readTranscript(transcriptPath);
|
|
3499
|
+
} catch {
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const records = parseTranscript(transcript);
|
|
3503
|
+
const evaluation = evaluateStopHook({
|
|
3504
|
+
records,
|
|
3505
|
+
// stop_hook_active was already handled by the early return above.
|
|
3506
|
+
stopHookActive: false,
|
|
3507
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3410
3508
|
});
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3509
|
+
const parts = [];
|
|
3510
|
+
if (evaluation.kind === "nudge") parts.push(evaluation.additionalContext);
|
|
3511
|
+
if (options.requireReview === true && evaluation.review.fires) {
|
|
3512
|
+
parts.push(evaluation.review.additionalContext);
|
|
3513
|
+
}
|
|
3514
|
+
if (parts.length === 0) return;
|
|
3515
|
+
const reason = parts.join("\n\n");
|
|
3516
|
+
const payloadJson = options.block === true ? JSON.stringify({ decision: "block", reason }) : JSON.stringify({
|
|
3517
|
+
hookSpecificOutput: {
|
|
3518
|
+
hookEventName: "Stop",
|
|
3519
|
+
additionalContext: reason
|
|
3520
|
+
}
|
|
3417
3521
|
});
|
|
3522
|
+
write(`${payloadJson}
|
|
3523
|
+
`);
|
|
3418
3524
|
}
|
|
3419
|
-
function
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
// `basou note` is the resume-hint command; mark it so orientation surfaces
|
|
3429
|
-
// it as the next step and a plain `basou session note` annotation does not.
|
|
3430
|
-
kind: "next_step"
|
|
3431
|
-
};
|
|
3525
|
+
async function renderRegisteredWorkspacePosition(cwd, portfolioConfigPath = DEFAULT_PORTFOLIO_CONFIG_PATH) {
|
|
3526
|
+
const root = await resolveBasouRootForCommand(cwd, "hook session-start", {
|
|
3527
|
+
portfolioConfigPath
|
|
3528
|
+
});
|
|
3529
|
+
if (!await isRegisteredWorkspace(root, portfolioConfigPath)) {
|
|
3530
|
+
throw new Error("The workspace is not registered in the portfolio; the hook stays silent.");
|
|
3531
|
+
}
|
|
3532
|
+
const rendered = await renderOrientationForRoot(root, {}, { cwd }, { write: false });
|
|
3533
|
+
return { body: rendered.body };
|
|
3432
3534
|
}
|
|
3433
|
-
function
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3535
|
+
async function isRegisteredWorkspace(root, portfolioConfigPath) {
|
|
3536
|
+
let entries;
|
|
3537
|
+
try {
|
|
3538
|
+
entries = await loadPortfolioConfig(portfolioConfigPath);
|
|
3539
|
+
} catch {
|
|
3540
|
+
return false;
|
|
3541
|
+
}
|
|
3542
|
+
const rootReal = await realpath2(root).catch(() => root);
|
|
3543
|
+
for (const entry of entries) {
|
|
3544
|
+
const entryReal = await realpath2(entry.path).catch(() => null);
|
|
3545
|
+
if (entryReal !== null && entryReal === rootReal) return true;
|
|
3546
|
+
}
|
|
3547
|
+
return false;
|
|
3437
3548
|
}
|
|
3438
|
-
function
|
|
3439
|
-
|
|
3440
|
-
|
|
3549
|
+
async function runHookSessionStart(ctx = {}) {
|
|
3550
|
+
try {
|
|
3551
|
+
await doRunHookSessionStart(ctx);
|
|
3552
|
+
} catch {
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
async function doRunHookSessionStart(ctx) {
|
|
3556
|
+
const readStdin = ctx.readStdin ?? defaultReadStdin;
|
|
3557
|
+
const write = ctx.write ?? ((text) => void process.stdout.write(text));
|
|
3558
|
+
const render = ctx.render ?? ((cwd2) => renderRegisteredWorkspacePosition(cwd2, ctx.portfolioConfigPath));
|
|
3559
|
+
const raw = await readStdin();
|
|
3560
|
+
if (raw.trim().length === 0) return;
|
|
3561
|
+
let payload;
|
|
3562
|
+
try {
|
|
3563
|
+
payload = JSON.parse(raw);
|
|
3564
|
+
} catch {
|
|
3565
|
+
return;
|
|
3566
|
+
}
|
|
3567
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
3568
|
+
const cwd = payload.cwd;
|
|
3569
|
+
if (typeof cwd !== "string" || cwd.length === 0) return;
|
|
3570
|
+
let body;
|
|
3571
|
+
try {
|
|
3572
|
+
body = (await render(cwd)).body;
|
|
3573
|
+
} catch {
|
|
3574
|
+
return;
|
|
3441
3575
|
}
|
|
3442
|
-
return
|
|
3576
|
+
if (body.trim().length === 0) return;
|
|
3577
|
+
write(`${body.replace(/\s+$/, "")}
|
|
3578
|
+
`);
|
|
3443
3579
|
}
|
|
3444
|
-
function
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
);
|
|
3456
|
-
return;
|
|
3580
|
+
function parseTranscript(transcript) {
|
|
3581
|
+
const records = [];
|
|
3582
|
+
for (const line of transcript.split(/\r?\n/)) {
|
|
3583
|
+
if (line.trim().length === 0) continue;
|
|
3584
|
+
try {
|
|
3585
|
+
const parsed = JSON.parse(line);
|
|
3586
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3587
|
+
records.push(parsed);
|
|
3588
|
+
}
|
|
3589
|
+
} catch {
|
|
3590
|
+
}
|
|
3457
3591
|
}
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3592
|
+
return records;
|
|
3593
|
+
}
|
|
3594
|
+
async function defaultReadStdin() {
|
|
3595
|
+
if (process.stdin.isTTY === true) return "";
|
|
3596
|
+
const chunks = [];
|
|
3597
|
+
for await (const chunk of process.stdin) {
|
|
3598
|
+
chunks.push(chunk);
|
|
3462
3599
|
}
|
|
3600
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3463
3601
|
}
|
|
3464
|
-
async function
|
|
3602
|
+
async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
|
|
3603
|
+
const { size } = await stat4(path);
|
|
3604
|
+
if (size <= maxBytes) return readFile3(path, "utf8");
|
|
3605
|
+
const handle = await open2(path, "r");
|
|
3465
3606
|
try {
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3607
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
3608
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
|
|
3609
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
3610
|
+
const firstNewline = text.indexOf("\n");
|
|
3611
|
+
return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
|
3612
|
+
} finally {
|
|
3613
|
+
await handle.close();
|
|
3472
3614
|
}
|
|
3473
3615
|
}
|
|
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;
|
|
3616
|
+
function parseMinEdits(raw) {
|
|
3617
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
|
|
3618
|
+
return Number(raw);
|
|
3493
3619
|
}
|
|
3494
|
-
|
|
3495
|
-
|
|
3620
|
+
var DEFAULT_CLAUDE_SETTINGS_PATH = join9(homedir7(), ".claude", "settings.json");
|
|
3621
|
+
function resolveCliEntry() {
|
|
3622
|
+
return fileURLToPath(import.meta.url);
|
|
3496
3623
|
}
|
|
3497
|
-
|
|
3624
|
+
function normalizeInstallOptions(raw) {
|
|
3625
|
+
const out = {};
|
|
3626
|
+
if (raw.block === true) out.block = true;
|
|
3627
|
+
if (raw.requireReview === true) out.requireReview = true;
|
|
3628
|
+
if (raw.settings !== void 0) out.settings = raw.settings;
|
|
3629
|
+
if (raw.hooks !== void 0) out.hooks = raw.hooks;
|
|
3630
|
+
if (raw.codexConfig !== void 0) out.codexConfig = raw.codexConfig;
|
|
3631
|
+
if (raw.codexFace !== void 0) out.codexFace = raw.codexFace;
|
|
3632
|
+
if (raw.dryRun === true) out.dryRun = true;
|
|
3633
|
+
if (raw.verbose === true) out.verbose = true;
|
|
3634
|
+
if (raw.minEdits !== void 0) {
|
|
3635
|
+
const parsed = parseMinEdits(raw.minEdits);
|
|
3636
|
+
if (parsed === void 0) {
|
|
3637
|
+
throw new Error("--min-edits must be a non-negative integer.");
|
|
3638
|
+
}
|
|
3639
|
+
out.minEdits = parsed;
|
|
3640
|
+
}
|
|
3641
|
+
return out;
|
|
3642
|
+
}
|
|
3643
|
+
async function readSettings(path) {
|
|
3498
3644
|
let raw;
|
|
3499
3645
|
try {
|
|
3500
|
-
raw = await
|
|
3646
|
+
raw = await readFile3(path, "utf8");
|
|
3501
3647
|
} 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.");
|
|
3648
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3649
|
+
return { raw: null, parsed: void 0 };
|
|
3507
3650
|
}
|
|
3508
3651
|
throw error;
|
|
3509
3652
|
}
|
|
3510
|
-
if (
|
|
3511
|
-
|
|
3653
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3654
|
+
try {
|
|
3655
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
throw new Error(
|
|
3658
|
+
"The Claude settings.json is not valid JSON. Fix it (or remove it) and retry.",
|
|
3659
|
+
{
|
|
3660
|
+
cause: error
|
|
3661
|
+
}
|
|
3662
|
+
);
|
|
3512
3663
|
}
|
|
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 });
|
|
3664
|
+
}
|
|
3665
|
+
async function backupSettingsOnce(path, raw) {
|
|
3666
|
+
if (raw === null) return;
|
|
3667
|
+
const bak = `${path}.basou-bak`;
|
|
3668
|
+
try {
|
|
3669
|
+
await stat4(bak);
|
|
3670
|
+
return;
|
|
3671
|
+
} catch (error) {
|
|
3672
|
+
if (!(error instanceof Error && error.code === "ENOENT")) throw error;
|
|
3536
3673
|
}
|
|
3537
|
-
|
|
3674
|
+
await writeFileDurable(bak, raw);
|
|
3538
3675
|
}
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
const
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3676
|
+
async function runHookInstall(options, ctx = {}) {
|
|
3677
|
+
try {
|
|
3678
|
+
await doRunHookInstall(normalizeInstallOptions(options), ctx);
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3681
|
+
process.exitCode = 1;
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
async function doRunHookInstall(options, ctx = {}) {
|
|
3685
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3686
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3687
|
+
const command = buildStopHookCommand({
|
|
3688
|
+
cliEntry,
|
|
3689
|
+
...options.block === true ? { block: true } : {},
|
|
3690
|
+
...options.requireReview === true ? { requireReview: true } : {},
|
|
3691
|
+
...options.minEdits !== void 0 ? { minEdits: options.minEdits } : {}
|
|
3555
3692
|
});
|
|
3556
|
-
|
|
3693
|
+
const mode = describeHookMode({
|
|
3694
|
+
block: options.block === true,
|
|
3695
|
+
review: options.requireReview === true
|
|
3557
3696
|
});
|
|
3697
|
+
await assertNotSymlink(settingsPath);
|
|
3698
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3699
|
+
const { settings, action } = upsertStopHook(parsed, command);
|
|
3700
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3701
|
+
`;
|
|
3702
|
+
if (raw !== null && newBody === raw) {
|
|
3703
|
+
console.log(`The basou Stop hook is already registered (${mode}); no change.`);
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3706
|
+
if (options.dryRun === true) {
|
|
3707
|
+
console.log(`[dry-run] Would ${action} the basou Stop hook (${mode}).`);
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
const recheck = await readSettings(settingsPath);
|
|
3711
|
+
if (recheck.raw !== raw) {
|
|
3712
|
+
throw new Error(
|
|
3713
|
+
"The settings.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install'."
|
|
3714
|
+
);
|
|
3715
|
+
}
|
|
3716
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3717
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3718
|
+
console.log(`${action === "installed" ? "Installed" : "Updated"} the basou Stop hook (${mode}).`);
|
|
3719
|
+
}
|
|
3720
|
+
async function runHookUninstall(options) {
|
|
3558
3721
|
try {
|
|
3559
|
-
await
|
|
3560
|
-
}
|
|
3561
|
-
|
|
3562
|
-
|
|
3722
|
+
await doRunHookUninstall(normalizeInstallOptions(options));
|
|
3723
|
+
} catch (error) {
|
|
3724
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3725
|
+
process.exitCode = 1;
|
|
3563
3726
|
}
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3727
|
+
}
|
|
3728
|
+
async function doRunHookUninstall(options) {
|
|
3729
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3730
|
+
await assertNotSymlink(settingsPath);
|
|
3731
|
+
const { raw, parsed } = await readSettings(settingsPath);
|
|
3732
|
+
if (raw === null) {
|
|
3733
|
+
console.log("No settings.json; nothing to remove.");
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3736
|
+
const { settings, action } = removeStopHook(parsed);
|
|
3737
|
+
if (action === "absent") {
|
|
3738
|
+
console.log("No basou Stop hook found; nothing removed.");
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
const newBody = `${JSON.stringify(settings, null, 2)}
|
|
3742
|
+
`;
|
|
3743
|
+
if (options.dryRun === true) {
|
|
3744
|
+
console.log("[dry-run] Would remove the basou Stop hook from settings.json.");
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
const recheck = await readSettings(settingsPath);
|
|
3748
|
+
if (recheck.raw !== raw) {
|
|
3749
|
+
throw new Error(
|
|
3750
|
+
"The settings.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall'."
|
|
3751
|
+
);
|
|
3752
|
+
}
|
|
3753
|
+
await backupSettingsOnce(settingsPath, raw);
|
|
3754
|
+
await writeFileDurable(settingsPath, newBody);
|
|
3755
|
+
console.log("Removed the basou Stop hook from settings.json.");
|
|
3756
|
+
}
|
|
3757
|
+
async function runHookStatus(options) {
|
|
3758
|
+
try {
|
|
3759
|
+
await doRunHookStatus(normalizeInstallOptions(options));
|
|
3760
|
+
} catch (error) {
|
|
3761
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3762
|
+
process.exitCode = 1;
|
|
3574
3763
|
}
|
|
3575
|
-
throw new Error("Import produced no parseable result");
|
|
3576
3764
|
}
|
|
3577
|
-
function
|
|
3578
|
-
|
|
3765
|
+
async function doRunHookStatus(options) {
|
|
3766
|
+
const settingsPath = options.settings ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
3767
|
+
const { parsed } = await readSettings(settingsPath);
|
|
3768
|
+
const command = findBasouStopHookCommand(parsed);
|
|
3769
|
+
if (command === null) {
|
|
3770
|
+
console.log("basou Stop hook: not registered. Run 'basou hook install' to register it.");
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
const mode = describeHookMode({
|
|
3774
|
+
block: / --block\b/.test(command),
|
|
3775
|
+
review: / --require-review\b/.test(command)
|
|
3776
|
+
});
|
|
3777
|
+
console.log(`basou Stop hook: registered, ${mode}.`);
|
|
3579
3778
|
}
|
|
3580
|
-
function
|
|
3581
|
-
|
|
3582
|
-
|
|
3779
|
+
function describeHookMode(tiers) {
|
|
3780
|
+
const enforcement = tiers.block ? "blocking (opt-in enforcement)" : "advisory (non-blocking)";
|
|
3781
|
+
const gates = tiers.review ? "capture + review" : "capture";
|
|
3782
|
+
return `${enforcement}, ${gates}`;
|
|
3583
3783
|
}
|
|
3584
|
-
|
|
3784
|
+
var DEFAULT_CODEX_FACE_PATH = join9(homedir7(), ".codex", "AGENTS.md");
|
|
3785
|
+
var LEFTOVER_FACE_NOTE = (label) => `${label} still carries an orientation block rendered by an earlier basou (0.39 or before); every Codex session on this machine reads it. \`basou channel clear codex\` removes it.`;
|
|
3786
|
+
async function faceHasLeftoverOrientationBlock(facePath) {
|
|
3585
3787
|
try {
|
|
3586
|
-
const
|
|
3587
|
-
return
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
dryRun: json.dry_run === true
|
|
3601
|
-
};
|
|
3788
|
+
const existing = await readMarkdownFile5(facePath);
|
|
3789
|
+
if (existing === null) return false;
|
|
3790
|
+
const section = parseMarkers2(existing, { start: ORIENTATION_START2, end: ORIENTATION_END2 });
|
|
3791
|
+
return section.kind !== "no_markers";
|
|
3792
|
+
} catch {
|
|
3793
|
+
return false;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
var DEFAULT_CODEX_HOOKS_PATH = join9(homedir7(), ".codex", "hooks.json");
|
|
3797
|
+
var DEFAULT_CODEX_CONFIG_PATH = join9(homedir7(), ".codex", "config.toml");
|
|
3798
|
+
async function readHooksFile(path) {
|
|
3799
|
+
let raw;
|
|
3800
|
+
try {
|
|
3801
|
+
raw = await readFile3(path, "utf8");
|
|
3602
3802
|
} catch (error) {
|
|
3603
|
-
if (
|
|
3604
|
-
return {
|
|
3803
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3804
|
+
return { raw: null, parsed: void 0 };
|
|
3605
3805
|
}
|
|
3606
3806
|
throw error;
|
|
3607
3807
|
}
|
|
3808
|
+
if (raw.trim().length === 0) return { raw, parsed: void 0 };
|
|
3809
|
+
try {
|
|
3810
|
+
return { raw, parsed: JSON.parse(raw) };
|
|
3811
|
+
} catch (error) {
|
|
3812
|
+
throw new Error("The Codex hooks.json is not valid JSON. Fix it (or remove it) and retry.", {
|
|
3813
|
+
cause: error
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3608
3816
|
}
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
}
|
|
3817
|
+
var CODEX_TRUST_NOTE = "Codex reviews a new or changed hook once before running it: start `codex` in a terminal and trust it when asked (desktop app: Settings -> Hooks). Until then the hook is skipped silently.";
|
|
3818
|
+
async function runCodexHookInstall(options, ctx = {}) {
|
|
3819
|
+
try {
|
|
3820
|
+
await doRunCodexHookInstall(normalizeInstallOptions(options), ctx);
|
|
3821
|
+
} catch (error) {
|
|
3822
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3823
|
+
process.exitCode = 1;
|
|
3824
|
+
}
|
|
3617
3825
|
}
|
|
3618
|
-
function
|
|
3619
|
-
|
|
3826
|
+
async function doRunCodexHookInstall(options, ctx = {}) {
|
|
3827
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3828
|
+
const cliEntry = (ctx.resolveCliEntry ?? resolveCliEntry)();
|
|
3829
|
+
const command = buildSessionStartHookCommand({ cliEntry });
|
|
3830
|
+
await assertNotSymlink(hooksPath);
|
|
3831
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
3832
|
+
const { hooksFile, action } = upsertSessionStartHook(parsed, command);
|
|
3833
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
3834
|
+
`;
|
|
3835
|
+
if (action === "unchanged" || raw !== null && newBody === raw) {
|
|
3836
|
+
console.log("The basou Codex SessionStart hook is already registered; no change.");
|
|
3837
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
if (options.dryRun === true) {
|
|
3841
|
+
console.log(
|
|
3842
|
+
`[dry-run] Would ${action === "installed" ? "install" : "update"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
3843
|
+
);
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
const recheck = await readHooksFile(hooksPath);
|
|
3847
|
+
if (recheck.raw !== raw) {
|
|
3848
|
+
throw new Error(
|
|
3849
|
+
"The hooks.json changed during install; aborting so a concurrent edit is not overwritten. Re-run 'basou hook install codex'."
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
3853
|
+
await writeFileDurable(hooksPath, newBody);
|
|
3854
|
+
console.log(
|
|
3855
|
+
`${action === "installed" ? "Installed" : "Updated"} the basou Codex SessionStart hook in ${hooksPath}.`
|
|
3856
|
+
);
|
|
3857
|
+
await reportCodexHookState(hooksPath, hooksFile, options);
|
|
3620
3858
|
}
|
|
3621
|
-
function
|
|
3622
|
-
|
|
3859
|
+
async function reportCodexHookState(hooksPath, hooksFile, options) {
|
|
3860
|
+
const location = findBasouSessionStartHook(hooksFile);
|
|
3861
|
+
if (location !== null) {
|
|
3862
|
+
const trust = await codexHookTrustFor(hooksPath, location, options.codexConfig);
|
|
3863
|
+
console.log(`Codex trust: ${describeCodexHookTrust(trust)}.`);
|
|
3864
|
+
if (trust.status === "untrusted" || trust.status === "modified") console.log(CODEX_TRUST_NOTE);
|
|
3865
|
+
}
|
|
3866
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
3867
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
3868
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
3869
|
+
}
|
|
3623
3870
|
}
|
|
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
|
-
};
|
|
3871
|
+
async function runCodexHookUninstall(options) {
|
|
3872
|
+
try {
|
|
3873
|
+
await doRunCodexHookUninstall(normalizeInstallOptions(options));
|
|
3874
|
+
} catch (error) {
|
|
3875
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3876
|
+
process.exitCode = 1;
|
|
3877
|
+
}
|
|
3637
3878
|
}
|
|
3638
|
-
async function
|
|
3639
|
-
const
|
|
3640
|
-
|
|
3641
|
-
await
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3879
|
+
async function doRunCodexHookUninstall(options) {
|
|
3880
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3881
|
+
await assertNotSymlink(hooksPath);
|
|
3882
|
+
const { raw, parsed } = await readHooksFile(hooksPath);
|
|
3883
|
+
if (raw === null) {
|
|
3884
|
+
console.log("No hooks.json; nothing to remove.");
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
const { hooksFile, action } = removeSessionStartHook(parsed);
|
|
3888
|
+
if (action === "absent") {
|
|
3889
|
+
console.log("No basou Codex SessionStart hook found; nothing removed.");
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
const newBody = `${JSON.stringify(hooksFile, null, 2)}
|
|
3893
|
+
`;
|
|
3894
|
+
if (options.dryRun === true) {
|
|
3895
|
+
console.log("[dry-run] Would remove the basou Codex SessionStart hook from hooks.json.");
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
const recheck = await readHooksFile(hooksPath);
|
|
3899
|
+
if (recheck.raw !== raw) {
|
|
3900
|
+
throw new Error(
|
|
3901
|
+
"The hooks.json changed during uninstall; aborting so a concurrent edit is not overwritten. Re-run 'basou hook uninstall codex'."
|
|
3902
|
+
);
|
|
3903
|
+
}
|
|
3904
|
+
await backupSettingsOnce(hooksPath, raw);
|
|
3905
|
+
await writeFileDurable(hooksPath, newBody);
|
|
3906
|
+
console.log("Removed the basou Codex SessionStart hook from hooks.json.");
|
|
3646
3907
|
}
|
|
3647
|
-
async function
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
pendingApprovalsCount: result.pendingApprovalsCount,
|
|
3655
|
-
suspectCount: result.suspectCount
|
|
3656
|
-
};
|
|
3908
|
+
async function runCodexHookStatus(options) {
|
|
3909
|
+
try {
|
|
3910
|
+
await doRunCodexHookStatus(normalizeInstallOptions(options));
|
|
3911
|
+
} catch (error) {
|
|
3912
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
3913
|
+
process.exitCode = 1;
|
|
3914
|
+
}
|
|
3657
3915
|
}
|
|
3658
|
-
async function
|
|
3659
|
-
const
|
|
3660
|
-
const
|
|
3661
|
-
const
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3916
|
+
async function doRunCodexHookStatus(options) {
|
|
3917
|
+
const hooksPath = options.hooks ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
3918
|
+
const { parsed } = await readHooksFile(hooksPath);
|
|
3919
|
+
const location = findBasouSessionStartHook(parsed);
|
|
3920
|
+
if (location === null) {
|
|
3921
|
+
console.log(
|
|
3922
|
+
"basou Codex SessionStart hook: not registered. Run 'basou hook install codex' to register it."
|
|
3923
|
+
);
|
|
3924
|
+
const facePath = options.codexFace ?? DEFAULT_CODEX_FACE_PATH;
|
|
3925
|
+
if (await faceHasLeftoverOrientationBlock(facePath)) {
|
|
3926
|
+
console.log(LEFTOVER_FACE_NOTE(options.codexFace ?? "~/.codex/AGENTS.md"));
|
|
3927
|
+
}
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3930
|
+
const matcher = location.matcher ?? "(every source)";
|
|
3931
|
+
console.log(
|
|
3932
|
+
`basou Codex SessionStart hook: registered in ${hooksPath} (matcher: ${matcher}); speaks only for workspaces registered in ~/.basou/portfolio.yaml.`
|
|
3933
|
+
);
|
|
3934
|
+
await reportCodexHookState(hooksPath, parsed, options);
|
|
3935
|
+
}
|
|
3936
|
+
async function codexHookTrustFor(hooksPath, location, configPath) {
|
|
3937
|
+
const fields = commandHandlerFields(location.handler);
|
|
3938
|
+
if (fields === null)
|
|
3939
|
+
return { status: "unknown", detail: "the installed handler is not a command hook" };
|
|
3940
|
+
let configToml;
|
|
3941
|
+
try {
|
|
3942
|
+
configToml = await readFile3(configPath ?? DEFAULT_CODEX_CONFIG_PATH, "utf8");
|
|
3943
|
+
} catch (error) {
|
|
3944
|
+
if (error instanceof Error && error.code === "ENOENT") {
|
|
3945
|
+
return { status: "untrusted" };
|
|
3946
|
+
}
|
|
3665
3947
|
return {
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
handoff: skipped,
|
|
3669
|
-
decisions: skipped,
|
|
3670
|
-
orientation: skipped,
|
|
3671
|
-
dryRun
|
|
3948
|
+
status: "unknown",
|
|
3949
|
+
detail: `could not read ${configPath ?? DEFAULT_CODEX_CONFIG_PATH}`
|
|
3672
3950
|
};
|
|
3673
3951
|
}
|
|
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
|
-
}
|
|
3952
|
+
const key = codexHookStateKey(
|
|
3953
|
+
hooksPath,
|
|
3954
|
+
"session_start",
|
|
3955
|
+
location.groupIndex,
|
|
3956
|
+
location.handlerIndex
|
|
3687
3957
|
);
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
};
|
|
3958
|
+
const state = readCodexHookState(configToml, key);
|
|
3959
|
+
const current = computeCodexHookIdentityHash({
|
|
3960
|
+
eventKey: "session_start",
|
|
3961
|
+
matcher: location.matcher,
|
|
3962
|
+
handler: fields
|
|
3963
|
+
});
|
|
3964
|
+
return judgeCodexHookTrust(state, current);
|
|
3696
3965
|
}
|
|
3697
|
-
|
|
3698
|
-
|
|
3966
|
+
|
|
3967
|
+
// src/commands/init.ts
|
|
3968
|
+
import { basename as basename4, relative, resolve as resolve6 } from "path";
|
|
3969
|
+
import {
|
|
3970
|
+
appendBasouGitignore,
|
|
3971
|
+
createManifest,
|
|
3972
|
+
ensureBasouDirectory,
|
|
3973
|
+
resolveRepositoryRoot as resolveRepositoryRoot7,
|
|
3974
|
+
writeManifest
|
|
3975
|
+
} from "@basou/core";
|
|
3976
|
+
function collectValue(value, previous) {
|
|
3977
|
+
return [...previous, value];
|
|
3699
3978
|
}
|
|
3700
|
-
function
|
|
3701
|
-
|
|
3979
|
+
function registerInitCommand(program2) {
|
|
3980
|
+
program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
|
|
3981
|
+
"--repo-url <url>",
|
|
3982
|
+
"Deprecated and ignored (project.repository_url was removed); accepted for 0.x CLI stability, removed at 1.0"
|
|
3983
|
+
).option(
|
|
3984
|
+
"--source-root <path>",
|
|
3985
|
+
"Extra import source root, relative to the repo root (repeatable; aggregates sibling repos into this workspace)",
|
|
3986
|
+
collectValue,
|
|
3987
|
+
[]
|
|
3988
|
+
).option(
|
|
3989
|
+
"--local-only",
|
|
3990
|
+
"Write a .basou/ full-exclude .gitignore block (keep the trail out of version control) instead of the default ignore+commit block"
|
|
3991
|
+
).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
3992
|
+
await runInit(options);
|
|
3993
|
+
});
|
|
3994
|
+
}
|
|
3995
|
+
async function runInit(options, ctx = {}) {
|
|
3996
|
+
try {
|
|
3997
|
+
await doRunInit(options, ctx);
|
|
3998
|
+
} catch (error) {
|
|
3999
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
4000
|
+
process.exitCode = 1;
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
async function doRunInit(options, ctx) {
|
|
4004
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
4005
|
+
const repositoryRoot = await resolveRepositoryRootForInit(cwd);
|
|
4006
|
+
const workspaceName = options.name ?? basename4(repositoryRoot);
|
|
4007
|
+
if (options.repoUrl !== void 0) {
|
|
4008
|
+
console.error(
|
|
4009
|
+
"Warning: --repo-url is deprecated and ignored (project.repository_url was removed); the flag will be removed at 1.0."
|
|
4010
|
+
);
|
|
4011
|
+
}
|
|
4012
|
+
const sourceRoots = (options.sourceRoot ?? []).map((p) => {
|
|
4013
|
+
const rel = relative(repositoryRoot, resolve6(cwd, p));
|
|
4014
|
+
return rel === "" ? "." : rel;
|
|
4015
|
+
});
|
|
4016
|
+
const paths = await ensureBasouDirectory(repositoryRoot);
|
|
4017
|
+
const manifest = createManifest({
|
|
4018
|
+
workspaceName,
|
|
4019
|
+
...options.projectName !== void 0 ? { projectName: options.projectName } : {},
|
|
4020
|
+
...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
|
|
4021
|
+
...sourceRoots.length > 0 ? { sourceRoots } : {}
|
|
4022
|
+
});
|
|
4023
|
+
await writeManifest(paths, manifest, { force: options.force === true });
|
|
4024
|
+
try {
|
|
4025
|
+
await appendBasouGitignore(repositoryRoot, { localOnly: options.localOnly === true });
|
|
4026
|
+
} catch (error) {
|
|
4027
|
+
renderGitignoreWarning(error, isVerbose(options));
|
|
4028
|
+
}
|
|
4029
|
+
console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
|
|
3702
4030
|
}
|
|
3703
|
-
function
|
|
3704
|
-
|
|
4031
|
+
function renderGitignoreWarning(error, verbose) {
|
|
4032
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
4033
|
+
console.error(
|
|
4034
|
+
`Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
|
|
4035
|
+
);
|
|
4036
|
+
if (verbose && error instanceof Error) {
|
|
4037
|
+
const label = extractCauseLabel(error);
|
|
4038
|
+
if (label !== void 0) console.error(`Caused by: ${label}`);
|
|
4039
|
+
}
|
|
3705
4040
|
}
|
|
3706
|
-
async function
|
|
4041
|
+
async function resolveRepositoryRootForInit(cwd) {
|
|
3707
4042
|
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;
|
|
4043
|
+
return await resolveRepositoryRoot7(cwd);
|
|
4044
|
+
} catch (error) {
|
|
4045
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
4046
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
|
|
4047
|
+
cause: error
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
throw error;
|
|
3721
4051
|
}
|
|
3722
4052
|
}
|
|
3723
4053
|
|
|
3724
|
-
// src/commands/
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
4054
|
+
// src/commands/note.ts
|
|
4055
|
+
import {
|
|
4056
|
+
acquireLock as acquireLock4,
|
|
4057
|
+
appendEventToExistingSession as appendEventToExistingSession2,
|
|
4058
|
+
assertBasouRootSafe as assertBasouRootSafe8,
|
|
4059
|
+
basouPaths as basouPaths9,
|
|
4060
|
+
createAdHocSessionWithEvent as createAdHocSessionWithEvent2,
|
|
4061
|
+
findErrorCode as findErrorCode7,
|
|
4062
|
+
readManifest as readManifest5,
|
|
4063
|
+
resolveSessionId as resolveSessionId2
|
|
4064
|
+
} from "@basou/core";
|
|
4065
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
4066
|
+
var NOTE_SUBCOMMAND_LOOKALIKES = /* @__PURE__ */ new Set([
|
|
4067
|
+
"list",
|
|
4068
|
+
"ls",
|
|
4069
|
+
"show",
|
|
4070
|
+
"get",
|
|
4071
|
+
"add",
|
|
4072
|
+
"new",
|
|
4073
|
+
"edit",
|
|
4074
|
+
"rm",
|
|
4075
|
+
"remove",
|
|
4076
|
+
"delete",
|
|
4077
|
+
"help"
|
|
4078
|
+
]);
|
|
4079
|
+
var LABEL_BODY_MAX = 80;
|
|
4080
|
+
var LABEL_TRUNCATE_HEAD2 = LABEL_BODY_MAX - 3;
|
|
4081
|
+
function registerNoteCommand(program2) {
|
|
4082
|
+
program2.command("note").description("Record a free-text note (orientation surfaces the latest as the next step)").argument("<body>", "Note text", parseBody).option(
|
|
4083
|
+
"--session <session_id>",
|
|
4084
|
+
"Attach to an existing session; otherwise an ad-hoc session is created"
|
|
4085
|
+
).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (body, options) => {
|
|
4086
|
+
await runNote(body, options);
|
|
3731
4087
|
});
|
|
3732
4088
|
}
|
|
3733
|
-
async function
|
|
4089
|
+
async function runNote(body, options, ctx = {}) {
|
|
3734
4090
|
try {
|
|
3735
|
-
await
|
|
4091
|
+
await doRunNote(body, options, ctx);
|
|
3736
4092
|
} catch (error) {
|
|
3737
|
-
renderCliError(error, {
|
|
4093
|
+
renderCliError(error, {
|
|
4094
|
+
verbose: isVerbose(options),
|
|
4095
|
+
classifiers: [failedToFinalizeClassifier]
|
|
4096
|
+
});
|
|
3738
4097
|
process.exitCode = 1;
|
|
3739
4098
|
}
|
|
3740
4099
|
}
|
|
3741
|
-
async function
|
|
4100
|
+
async function doRunNote(body, options, ctx) {
|
|
4101
|
+
if (body.trim().length === 0) {
|
|
4102
|
+
throw new Error("Note body must not be empty");
|
|
4103
|
+
}
|
|
4104
|
+
const reserved = body.trim().toLowerCase();
|
|
4105
|
+
if (NOTE_SUBCOMMAND_LOOKALIKES.has(reserved)) {
|
|
4106
|
+
throw new Error(
|
|
4107
|
+
`'basou note' records a free-text note and has no '${body.trim()}' subcommand. To record a note, pass its full text (e.g. \`basou note "<your note>"\`).`
|
|
4108
|
+
);
|
|
4109
|
+
}
|
|
3742
4110
|
const cwd = ctx.cwd ?? process.cwd();
|
|
3743
|
-
const repositoryRoot = await resolveBasouRootForCommand(cwd, "
|
|
4111
|
+
const repositoryRoot = await resolveBasouRootForCommand(cwd, "note");
|
|
3744
4112
|
const paths = basouPaths9(repositoryRoot);
|
|
3745
4113
|
await assertWorkspaceInitialized7(paths.root);
|
|
3746
|
-
const
|
|
3747
|
-
const
|
|
3748
|
-
if (
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
4114
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
4115
|
+
const occurredAt = now.toISOString();
|
|
4116
|
+
if (options.session !== void 0) {
|
|
4117
|
+
const sessionId = await resolveSessionId2(paths, options.session);
|
|
4118
|
+
const sesId = sessionId;
|
|
4119
|
+
const sessionLock = await acquireLock4(paths, "session", sesId);
|
|
4120
|
+
let result;
|
|
4121
|
+
try {
|
|
4122
|
+
result = await appendEventToExistingSession2({
|
|
4123
|
+
paths,
|
|
4124
|
+
sessionId: sesId,
|
|
4125
|
+
eventBuilder: (eventId) => buildNoteEvent({ eventId, sessionId: sesId, occurredAt, body })
|
|
4126
|
+
});
|
|
4127
|
+
} finally {
|
|
4128
|
+
await sessionLock.release();
|
|
3759
4129
|
}
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
4130
|
+
printNoteResult(options, {
|
|
4131
|
+
mode: "attached",
|
|
4132
|
+
sessionId,
|
|
4133
|
+
eventId: result.eventId,
|
|
4134
|
+
sessionStatus: result.sessionStatus,
|
|
4135
|
+
body
|
|
4136
|
+
});
|
|
4137
|
+
return;
|
|
3764
4138
|
}
|
|
3765
|
-
const
|
|
4139
|
+
const manifest = await readManifest5(paths);
|
|
4140
|
+
const adHoc = await createAdHocSessionWithEvent2({
|
|
3766
4141
|
paths,
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
4142
|
+
manifest,
|
|
4143
|
+
label: buildAdHocLabel2(body),
|
|
4144
|
+
occurredAt,
|
|
4145
|
+
sessionSource: "human",
|
|
4146
|
+
workingDirectory: repositoryRoot,
|
|
4147
|
+
invocation: {
|
|
4148
|
+
command: "basou note",
|
|
4149
|
+
args: [body]
|
|
4150
|
+
},
|
|
4151
|
+
targetEventBuilders: [
|
|
4152
|
+
(sessionId, eventId) => buildNoteEvent({ eventId, sessionId, occurredAt, body })
|
|
4153
|
+
]
|
|
3777
4154
|
});
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4155
|
+
printNoteResult(options, {
|
|
4156
|
+
mode: "ad-hoc",
|
|
4157
|
+
sessionId: adHoc.sessionId,
|
|
4158
|
+
eventId: adHoc.targetEventIds[0],
|
|
4159
|
+
sessionStatus: "completed",
|
|
4160
|
+
body
|
|
4161
|
+
});
|
|
4162
|
+
}
|
|
4163
|
+
function buildNoteEvent(input) {
|
|
4164
|
+
return {
|
|
4165
|
+
schema_version: "0.1.0",
|
|
4166
|
+
id: input.eventId,
|
|
4167
|
+
session_id: input.sessionId,
|
|
4168
|
+
occurred_at: input.occurredAt,
|
|
4169
|
+
source: "local-cli",
|
|
4170
|
+
type: "note_added",
|
|
4171
|
+
body: input.body,
|
|
4172
|
+
// `basou note` is the resume-hint command; mark it so orientation surfaces
|
|
4173
|
+
// it as the next step and a plain `basou session note` annotation does not.
|
|
4174
|
+
kind: "next_step"
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
function buildAdHocLabel2(body) {
|
|
4178
|
+
const oneLine2 = body.replace(/\s+/g, " ").trim();
|
|
4179
|
+
const truncated = oneLine2.length > LABEL_BODY_MAX ? `${oneLine2.slice(0, LABEL_TRUNCATE_HEAD2)}...` : oneLine2;
|
|
4180
|
+
return `Ad-hoc note: ${truncated}`;
|
|
4181
|
+
}
|
|
4182
|
+
function parseBody(raw) {
|
|
4183
|
+
if (raw.trim().length === 0) {
|
|
4184
|
+
throw new InvalidArgumentError2("Note body must not be empty");
|
|
4185
|
+
}
|
|
4186
|
+
return raw;
|
|
4187
|
+
}
|
|
4188
|
+
function printNoteResult(options, result) {
|
|
4189
|
+
const sid = shortSessionId(result.sessionId);
|
|
4190
|
+
if (options.json === true) {
|
|
3781
4191
|
console.log(
|
|
3782
|
-
|
|
4192
|
+
JSON.stringify({
|
|
4193
|
+
event_id: result.eventId,
|
|
4194
|
+
session_id: result.sessionId,
|
|
4195
|
+
session_status: result.sessionStatus,
|
|
4196
|
+
mode: result.mode,
|
|
4197
|
+
body: result.body
|
|
4198
|
+
})
|
|
3783
4199
|
);
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
if (result.mode === "ad-hoc") {
|
|
4203
|
+
console.log(`Recorded note ${result.eventId} in ad-hoc session ${sid}`);
|
|
3784
4204
|
} else {
|
|
3785
|
-
console.log(result.
|
|
4205
|
+
console.log(`Recorded note ${result.eventId} in session ${sid} (${result.sessionStatus})`);
|
|
3786
4206
|
}
|
|
3787
4207
|
}
|
|
3788
4208
|
async function assertWorkspaceInitialized7(basouRoot) {
|
|
@@ -3920,7 +4340,7 @@ import {
|
|
|
3920
4340
|
GENERATED_START,
|
|
3921
4341
|
instructionMode,
|
|
3922
4342
|
isGitNotFound,
|
|
3923
|
-
parseMarkers as
|
|
4343
|
+
parseMarkers as parseMarkers3,
|
|
3924
4344
|
pathBasename,
|
|
3925
4345
|
planArchive,
|
|
3926
4346
|
planGitignore,
|
|
@@ -3928,7 +4348,7 @@ import {
|
|
|
3928
4348
|
planRosterAdoption,
|
|
3929
4349
|
planWorkspaceView,
|
|
3930
4350
|
readManifest as readManifest6,
|
|
3931
|
-
readMarkdownFile as
|
|
4351
|
+
readMarkdownFile as readMarkdownFile6,
|
|
3932
4352
|
reconcileSourceRoots,
|
|
3933
4353
|
removeMarkerSection as removeMarkerSection2,
|
|
3934
4354
|
renderAnchorStarter,
|
|
@@ -5362,7 +5782,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5362
5782
|
const canonicalName = basename5(real);
|
|
5363
5783
|
let content;
|
|
5364
5784
|
try {
|
|
5365
|
-
content = await
|
|
5785
|
+
content = await readMarkdownFile6(canonicalFileFor(anchorReal, canonicalName));
|
|
5366
5786
|
} catch {
|
|
5367
5787
|
return {
|
|
5368
5788
|
...declared,
|
|
@@ -5382,7 +5802,7 @@ async function gatherRepoPreset(repositoryRoot, anchorReal, entry) {
|
|
|
5382
5802
|
canonicalPresent: false
|
|
5383
5803
|
};
|
|
5384
5804
|
}
|
|
5385
|
-
const section =
|
|
5805
|
+
const section = parseMarkers3(content);
|
|
5386
5806
|
return {
|
|
5387
5807
|
...declared,
|
|
5388
5808
|
isAnchor: false,
|
|
@@ -5445,7 +5865,7 @@ function gatherViewPreset(repositoryRoot, anchorReal, viewName, roster) {
|
|
|
5445
5865
|
}
|
|
5446
5866
|
return { kind: "unreadable", canonicalName: viewName, viewName };
|
|
5447
5867
|
}
|
|
5448
|
-
const section =
|
|
5868
|
+
const section = parseMarkers3(content);
|
|
5449
5869
|
if (section.kind === "ok") {
|
|
5450
5870
|
if (normalizeViewBlock(section.generated) === normalizeViewBlock(desiredBlock)) {
|
|
5451
5871
|
return { kind: "in-sync", canonicalName: viewName, viewName };
|
|
@@ -5471,7 +5891,7 @@ async function applyViewPreset(anchorReal, outcome) {
|
|
|
5471
5891
|
}
|
|
5472
5892
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5473
5893
|
if (outcome.action === "create") mkdirSync(dirname3(file), { recursive: true });
|
|
5474
|
-
const existing = await
|
|
5894
|
+
const existing = await readMarkdownFile6(file);
|
|
5475
5895
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, outcome.block, label));
|
|
5476
5896
|
}
|
|
5477
5897
|
async function applyPresetPlan(anchorReal, plan) {
|
|
@@ -5485,7 +5905,7 @@ async function applyPresetPlan(anchorReal, plan) {
|
|
|
5485
5905
|
}
|
|
5486
5906
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
5487
5907
|
if (plan.action === "create") mkdirSync(dirname3(file), { recursive: true });
|
|
5488
|
-
const existing = await
|
|
5908
|
+
const existing = await readMarkdownFile6(file);
|
|
5489
5909
|
await writeMarkdownFile5(file, renderWithMarkers4(existing, plan.desiredBlock, label));
|
|
5490
5910
|
}
|
|
5491
5911
|
function presetFailureReason(error) {
|
|
@@ -5949,7 +6369,7 @@ function gatherRepoTeardown(repositoryRoot, manifest, target) {
|
|
|
5949
6369
|
});
|
|
5950
6370
|
}
|
|
5951
6371
|
if (content !== void 0 && content !== "") {
|
|
5952
|
-
const section =
|
|
6372
|
+
const section = parseMarkers3(content);
|
|
5953
6373
|
if (section.kind === "ok" && canonicalShared) {
|
|
5954
6374
|
items.push({
|
|
5955
6375
|
kind: "canonical-block",
|
|
@@ -6078,7 +6498,7 @@ function applyRepoTeardown(repositoryRoot, manifest, plan) {
|
|
|
6078
6498
|
}
|
|
6079
6499
|
try {
|
|
6080
6500
|
const content = readFileSync(fd, "utf8");
|
|
6081
|
-
if (
|
|
6501
|
+
if (parseMarkers3(content).kind !== "ok") {
|
|
6082
6502
|
changed(item.label);
|
|
6083
6503
|
continue;
|
|
6084
6504
|
}
|
|
@@ -6853,7 +7273,7 @@ function gatherViewRetrofit(repositoryRoot, anchorReal, viewPath, roster) {
|
|
|
6853
7273
|
if (hasErrorCode(error) && error.code === "ENOENT") return { kind: "absent", viewName };
|
|
6854
7274
|
return { kind: "unreadable", viewName };
|
|
6855
7275
|
}
|
|
6856
|
-
const section =
|
|
7276
|
+
const section = parseMarkers3(content);
|
|
6857
7277
|
if (section.kind === "ok") return { kind: "already-marked", viewName };
|
|
6858
7278
|
if (section.kind === "no_markers") {
|
|
6859
7279
|
const block = renderViewPresetBlock({
|
|
@@ -6874,7 +7294,7 @@ async function applyViewRetrofit(anchorReal, outcome) {
|
|
|
6874
7294
|
isLink = false;
|
|
6875
7295
|
}
|
|
6876
7296
|
if (isLink) throw new Error(`Canonical is a symlink in ${label}`);
|
|
6877
|
-
const existing = await
|
|
7297
|
+
const existing = await readMarkdownFile6(file);
|
|
6878
7298
|
await writeMarkdownFile5(file, seedMarkers(existing, outcome.block, label));
|
|
6879
7299
|
}
|
|
6880
7300
|
async function doRunProjectRetrofit(repo, options, ctx) {
|
|
@@ -7137,7 +7557,7 @@ function renderProjectRetrofit(result) {
|
|
|
7137
7557
|
|
|
7138
7558
|
// src/commands/protocol.ts
|
|
7139
7559
|
import { readFile as readFile4 } from "fs/promises";
|
|
7140
|
-
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as
|
|
7560
|
+
import { PROTOCOL_END, PROTOCOL_START, parseMarkers as parseMarkers4, readMarkdownFile as readMarkdownFile7 } from "@basou/core";
|
|
7141
7561
|
|
|
7142
7562
|
// src/lib/protocols-config.ts
|
|
7143
7563
|
import { homedir as homedir8 } from "os";
|
|
@@ -7221,7 +7641,9 @@ var PROTOCOL_MARKERS = { start: PROTOCOL_START, end: PROTOCOL_END };
|
|
|
7221
7641
|
var MANAGED_NOTE = "<!-- Managed by basou: 'basou protocol sync' regenerates everything between the BASOU:PROTOCOLS markers from ~/.basou/protocols.yaml. Manual edits inside the block are overwritten; edit the source files instead. -->";
|
|
7222
7642
|
function registerProtocolCommand(program2) {
|
|
7223
7643
|
const protocol = program2.command("protocol").description("Manage the basou-managed standing-protocol block in the global CLAUDE.md");
|
|
7224
|
-
protocol.command("sync").description(
|
|
7644
|
+
protocol.command("sync").description(
|
|
7645
|
+
"Render declared protocols into ~/.claude/CLAUDE.md (creates/updates the block). That file is user-global: Claude Code auto-loads it for every project on the machine, so what the protocols say is in the context of every workspace's sessions \u2014 keep workspace-specific facts out of them."
|
|
7646
|
+
).option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("--dry-run", "Print what would change without writing").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
7225
7647
|
await runProtocolSync(opts);
|
|
7226
7648
|
});
|
|
7227
7649
|
protocol.command("list").description("List declared protocols and whether the block is installed").option("--config <path>", "Path to protocols.yaml (default ~/.basou/protocols.yaml)").option("--target <path>", "Override the target file (intended for tests)").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
@@ -7320,8 +7742,8 @@ async function doRunProtocolList(options) {
|
|
|
7320
7742
|
const configPath = options.config ?? DEFAULT_PROTOCOLS_CONFIG_PATH;
|
|
7321
7743
|
const target = options.target ?? DEFAULT_TARGET_PATH;
|
|
7322
7744
|
const entries = await loadProtocolsConfig(configPath);
|
|
7323
|
-
const existing = await
|
|
7324
|
-
const installed = existing !== null &&
|
|
7745
|
+
const existing = await readMarkdownFile7(target);
|
|
7746
|
+
const installed = existing !== null && parseMarkers4(existing, PROTOCOL_MARKERS).kind === "ok";
|
|
7325
7747
|
console.log(`Declared protocols (${entries.length}):`);
|
|
7326
7748
|
for (const entry of entries) {
|
|
7327
7749
|
console.log(` - ${entry.title ?? entry.source}`);
|
|
@@ -7356,21 +7778,6 @@ import {
|
|
|
7356
7778
|
} from "@basou/core";
|
|
7357
7779
|
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
7358
7780
|
|
|
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
7781
|
// src/commands/refresh-watch.ts
|
|
7375
7782
|
import { readdir as readdir2, stat as stat5 } from "fs/promises";
|
|
7376
7783
|
import { homedir as homedir9 } from "os";
|
|
@@ -7522,7 +7929,7 @@ function abortableSleep(ms, signal) {
|
|
|
7522
7929
|
}
|
|
7523
7930
|
function registerRefreshCommand(program2) {
|
|
7524
7931
|
program2.command("refresh").description(
|
|
7525
|
-
"Import all adapters for the project and regenerate handoff + decisions in one step"
|
|
7932
|
+
"Import all adapters for the project and regenerate handoff + decisions in one step. Writes only inside the workspace's .basou/ \u2014 never to a user-global file another project's tool reads (a Codex session gets the position from the SessionStart hook; see `basou hook install codex`)."
|
|
7526
7933
|
).option(
|
|
7527
7934
|
"--project <path>",
|
|
7528
7935
|
"Source project path to import (repeatable; defaults to the manifest source roots, then the repository root)",
|
|
@@ -7653,47 +8060,23 @@ async function computeRefresh(options, ctx) {
|
|
|
7653
8060
|
}
|
|
7654
8061
|
async function doRunRefresh(options, ctx) {
|
|
7655
8062
|
const { result, paths } = await computeRefresh(options, ctx);
|
|
7656
|
-
const
|
|
7657
|
-
const reported = { ...result, codexChannel: channel.outcome };
|
|
8063
|
+
const reported = { ...result, codexChannel: { status: "retired" } };
|
|
7658
8064
|
if (options.json === true) {
|
|
7659
8065
|
console.log(JSON.stringify(reported));
|
|
7660
8066
|
} else {
|
|
7661
8067
|
printRefreshSummary(result);
|
|
7662
|
-
|
|
8068
|
+
const line = await retiredChannelNotice(paths);
|
|
8069
|
+
if (line !== null) console.log(line);
|
|
7663
8070
|
}
|
|
7664
8071
|
return reported;
|
|
7665
8072
|
}
|
|
7666
|
-
async function
|
|
7667
|
-
let decision;
|
|
7668
|
-
try {
|
|
7669
|
-
decision = decideCodexChannel(await readManifest7(paths));
|
|
7670
|
-
} catch (error) {
|
|
7671
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
7672
|
-
return {
|
|
7673
|
-
outcome: { status: "skipped", reason: "error", detail },
|
|
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
|
-
}
|
|
8073
|
+
async function retiredChannelNotice(paths) {
|
|
7683
8074
|
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
|
-
};
|
|
8075
|
+
const manifest = await readManifest7(paths);
|
|
8076
|
+
if (manifest.channels?.codex !== true) return null;
|
|
8077
|
+
return "codex channel: retired \u2014 the manifest's channels.codex is ignored; a Codex session now receives this workspace's position from the SessionStart hook (see `basou hook status codex`), and nothing is written to the user-global ~/.codex/AGENTS.md";
|
|
8078
|
+
} catch {
|
|
8079
|
+
return null;
|
|
7697
8080
|
}
|
|
7698
8081
|
}
|
|
7699
8082
|
function describeImport(outcome) {
|
|
@@ -8271,7 +8654,7 @@ function unattachedLines(u) {
|
|
|
8271
8654
|
}
|
|
8272
8655
|
|
|
8273
8656
|
// src/commands/run.ts
|
|
8274
|
-
import { mkdir as mkdir2 } from "fs/promises";
|
|
8657
|
+
import { mkdir as mkdir2, readFile as readFile6 } from "fs/promises";
|
|
8275
8658
|
import { homedir as homedir11 } from "os";
|
|
8276
8659
|
import { join as join14 } from "path";
|
|
8277
8660
|
import {
|
|
@@ -8283,6 +8666,7 @@ import {
|
|
|
8283
8666
|
codexAdapterMetadata,
|
|
8284
8667
|
appendChainedEvent as coreAppendChainedEvent2,
|
|
8285
8668
|
finalizeSessionYaml as finalizeSessionYaml2,
|
|
8669
|
+
findBasouSessionStartHook as findBasouSessionStartHook2,
|
|
8286
8670
|
getDiff,
|
|
8287
8671
|
getSnapshot as getSnapshot2,
|
|
8288
8672
|
overwriteYamlFile as overwriteYamlFile2,
|
|
@@ -8334,7 +8718,7 @@ function runCodex(args, options, ctx = {}) {
|
|
|
8334
8718
|
resolveCommand: ctx.resolveCodexCommand ?? resolveCodexCommand,
|
|
8335
8719
|
metadata: codexAdapterMetadata,
|
|
8336
8720
|
transformArgs: (a) => ["-c", "shell_environment_policy.inherit=all", ...a],
|
|
8337
|
-
preSpawn:
|
|
8721
|
+
preSpawn: noteCodexHookStatusPreSpawn
|
|
8338
8722
|
});
|
|
8339
8723
|
}
|
|
8340
8724
|
async function runTrackedTool(args, options, ctx, adapter) {
|
|
@@ -8701,24 +9085,25 @@ async function resolveRepositoryRootForRun(cwd) {
|
|
|
8701
9085
|
throw error;
|
|
8702
9086
|
}
|
|
8703
9087
|
}
|
|
8704
|
-
async function
|
|
9088
|
+
async function noteCodexHookStatusPreSpawn(_cwd, ctx) {
|
|
9089
|
+
const hooksPath = ctx.codexHooksPath ?? DEFAULT_CODEX_HOOKS_PATH;
|
|
9090
|
+
let location;
|
|
8705
9091
|
try {
|
|
8706
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
});
|
|
8714
|
-
return rendered === null ? null : rendered.line;
|
|
8715
|
-
} catch {
|
|
8716
|
-
return null;
|
|
9092
|
+
location = findBasouSessionStartHook2(JSON.parse(await readFile6(hooksPath, "utf8")));
|
|
9093
|
+
} catch (error) {
|
|
9094
|
+
if (!(error instanceof Error && error.code === "ENOENT")) return null;
|
|
9095
|
+
location = null;
|
|
9096
|
+
}
|
|
9097
|
+
if (location === null) {
|
|
9098
|
+
return "codex: the basou SessionStart hook is not registered, so this session starts without the workspace's position (`basou hook install codex` registers it once for every workspace)";
|
|
8717
9099
|
}
|
|
9100
|
+
const trust = await codexHookTrustFor(hooksPath, location, ctx.codexConfigPath);
|
|
9101
|
+
if (trust.status === "trusted" || trust.status === "unknown") return null;
|
|
9102
|
+
return `codex: the basou SessionStart hook is registered but ${describeCodexHookTrust(trust)}, so this session starts without the workspace's position`;
|
|
8718
9103
|
}
|
|
8719
9104
|
|
|
8720
9105
|
// src/commands/session.ts
|
|
8721
|
-
import { readFile as
|
|
9106
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
8722
9107
|
import { basename as basename6, isAbsolute as isAbsolute6, join as join15, relative as relative3 } from "path";
|
|
8723
9108
|
import {
|
|
8724
9109
|
acquireLock as acquireLock6,
|
|
@@ -9169,7 +9554,7 @@ async function doRunSessionImport(options, ctx) {
|
|
|
9169
9554
|
}
|
|
9170
9555
|
async function readInputFile(path) {
|
|
9171
9556
|
try {
|
|
9172
|
-
return await
|
|
9557
|
+
return await readFile7(path, "utf8");
|
|
9173
9558
|
} catch (error) {
|
|
9174
9559
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9175
9560
|
throw new Error("Import source not found", { cause: error });
|
|
@@ -9286,7 +9671,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
|
|
|
9286
9671
|
}
|
|
9287
9672
|
async function readNoteFile(path) {
|
|
9288
9673
|
try {
|
|
9289
|
-
return await
|
|
9674
|
+
return await readFile7(path, "utf8");
|
|
9290
9675
|
} catch (error) {
|
|
9291
9676
|
if (findErrorCode12(error, "ENOENT")) {
|
|
9292
9677
|
throw new Error("Note source not found", { cause: error });
|
|
@@ -9607,7 +9992,7 @@ function formatVersionGateMessage(error) {
|
|
|
9607
9992
|
}
|
|
9608
9993
|
|
|
9609
9994
|
// src/commands/task.ts
|
|
9610
|
-
import { readFile as
|
|
9995
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
9611
9996
|
import { join as join16 } from "path";
|
|
9612
9997
|
import {
|
|
9613
9998
|
archiveTask,
|
|
@@ -10622,7 +11007,7 @@ function parsePositiveInt2(raw) {
|
|
|
10622
11007
|
}
|
|
10623
11008
|
async function readDescriptionFile(path) {
|
|
10624
11009
|
try {
|
|
10625
|
-
return await
|
|
11010
|
+
return await readFile8(path, "utf8");
|
|
10626
11011
|
} catch (error) {
|
|
10627
11012
|
if (findErrorCode15(error, "ENOENT")) {
|
|
10628
11013
|
throw new Error("Description source not found", { cause: error });
|
|
@@ -10826,7 +11211,7 @@ async function assertWorkspaceInitialized14(basouRoot) {
|
|
|
10826
11211
|
|
|
10827
11212
|
// src/commands/view.ts
|
|
10828
11213
|
import { spawn } from "child_process";
|
|
10829
|
-
import { createHash } from "crypto";
|
|
11214
|
+
import { createHash as createHash2 } from "crypto";
|
|
10830
11215
|
import { basename as basename9, resolve as resolve13 } from "path";
|
|
10831
11216
|
import {
|
|
10832
11217
|
assertBasouRootSafe as assertBasouRootSafe18,
|
|
@@ -11151,7 +11536,7 @@ function inertLines(result) {
|
|
|
11151
11536
|
|
|
11152
11537
|
// src/lib/portfolio-safety.ts
|
|
11153
11538
|
import { execFile } from "child_process";
|
|
11154
|
-
import { lstat as lstat2, realpath as
|
|
11539
|
+
import { lstat as lstat2, realpath as realpath3 } from "fs/promises";
|
|
11155
11540
|
import { isAbsolute as isAbsolute7, join as join18, relative as relative4, resolve as resolve11 } from "path";
|
|
11156
11541
|
import { promisify } from "util";
|
|
11157
11542
|
import { readManifest as readManifest14 } from "@basou/core";
|
|
@@ -11161,7 +11546,7 @@ function errorCode(error) {
|
|
|
11161
11546
|
}
|
|
11162
11547
|
async function canonical(p) {
|
|
11163
11548
|
try {
|
|
11164
|
-
return await
|
|
11549
|
+
return await realpath3(p);
|
|
11165
11550
|
} catch {
|
|
11166
11551
|
return resolve11(p);
|
|
11167
11552
|
}
|
|
@@ -11341,7 +11726,7 @@ import {
|
|
|
11341
11726
|
loadTaskEntries as loadTaskEntries2,
|
|
11342
11727
|
readAllEvents as readAllEvents2,
|
|
11343
11728
|
readManifest as readManifest15,
|
|
11344
|
-
readMarkdownFile as
|
|
11729
|
+
readMarkdownFile as readMarkdownFile8,
|
|
11345
11730
|
readSessionYaml as readSessionYaml3,
|
|
11346
11731
|
readTaskFile as readTaskFile2,
|
|
11347
11732
|
renderDecisions as renderDecisions3,
|
|
@@ -12393,7 +12778,7 @@ async function taskDetail(ws, taskId) {
|
|
|
12393
12778
|
}
|
|
12394
12779
|
}
|
|
12395
12780
|
async function decisionsView(ws, nowProvider) {
|
|
12396
|
-
const fromDisk = await
|
|
12781
|
+
const fromDisk = await readMarkdownFile8(ws.paths.files.decisions);
|
|
12397
12782
|
if (fromDisk !== null) {
|
|
12398
12783
|
return { body: fromDisk, fromDisk: true };
|
|
12399
12784
|
}
|
|
@@ -12416,7 +12801,7 @@ async function approvalsView(ws, nowProvider) {
|
|
|
12416
12801
|
return { pending: await toViews(ids.pending), resolved: await toViews(ids.resolved) };
|
|
12417
12802
|
}
|
|
12418
12803
|
async function handoffView(ws, nowProvider) {
|
|
12419
|
-
const fromDisk = await
|
|
12804
|
+
const fromDisk = await readMarkdownFile8(ws.paths.files.handoff);
|
|
12420
12805
|
if (fromDisk !== null) {
|
|
12421
12806
|
return { body: fromDisk, fromDisk: true };
|
|
12422
12807
|
}
|
|
@@ -12650,7 +13035,7 @@ async function buildWorkspaceEntry(repoRoot, ctx, labelOverride) {
|
|
|
12650
13035
|
} catch (error) {
|
|
12651
13036
|
const notFound = error instanceof Error && error.message === "YAML file not found";
|
|
12652
13037
|
return {
|
|
12653
|
-
key: `ws-${
|
|
13038
|
+
key: `ws-${createHash2("sha1").update(repoRoot).digest("hex").slice(0, 12)}`,
|
|
12654
13039
|
label: labelOverride ?? basename9(repoRoot),
|
|
12655
13040
|
paths,
|
|
12656
13041
|
repoRoot,
|