@basou/cli 0.18.0 → 0.20.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 CHANGED
@@ -838,6 +838,9 @@ function registerDecisionCommand(program2) {
838
838
  "Related file path (repeatable). Path is opaque; existence is verified at render time.",
839
839
  collectLinkedFile,
840
840
  []
841
+ ).option(
842
+ "--track",
843
+ "Record as a strategic track (an unfinished direction + why). orientation/handoff keep resurfacing open tracks until you close one with 'basou decision void'."
841
844
  ).option(
842
845
  "--session <session_id>",
843
846
  "Attach to an existing session; otherwise an ad-hoc session is created"
@@ -870,13 +873,22 @@ Input format (a JSON array; one object per decision):
870
873
  "alternatives": ["npm workspaces", "yarn"],
871
874
  "rejected_reason": "npm hoisting caused phantom-dependency bugs",
872
875
  "linked_files": ["pnpm-workspace.yaml"]
876
+ },
877
+ {
878
+ "title": "Form-based admin editing is the next track (only 6/19 sections done)",
879
+ "rationale": "Raw-JSON editing is a stopgap, not the final shape; cover the rest.",
880
+ "kind": "track"
873
881
  }
874
882
  ]
875
883
 
876
- Only "title" is required; every other field is optional. All decisions are
877
- written into one ad-hoc session timestamped now, so orientation surfaces them
878
- as the latest decisions. Run from a workspace-view directory and it resolves to
879
- the planning repo, like 'basou orient' / 'basou refresh' / 'basou note'.
884
+ Only "title" is required; every other field is optional. Set "kind": "track" to
885
+ record a strategic, UNFINISHED direction (+ why): orientation/handoff resurface
886
+ open tracks every session until you close one with 'basou decision void <id>'.
887
+ Absent / "decision" is a point-in-time decision (surfaced only as the latest).
888
+ All decisions are written into one ad-hoc session timestamped now, so
889
+ orientation surfaces them as the latest decisions. Run from a workspace-view
890
+ directory and it resolves to the planning repo, like 'basou orient' /
891
+ 'basou refresh' / 'basou note'.
880
892
 
881
893
  Example (heredoc on stdin):
882
894
  basou decision capture <<'JSON'
@@ -973,7 +985,7 @@ async function doRunDecisionRecord(options, ctx) {
973
985
  workingDirectory: repositoryRoot,
974
986
  invocation: {
975
987
  command: "basou decision record",
976
- args: ["--title", options.title]
988
+ args: options.track === true ? ["--title", options.title, "--track"] : ["--title", options.title]
977
989
  },
978
990
  targetEventBuilders: [
979
991
  (sessionId, eventId) => buildDecisionEvent({
@@ -1254,7 +1266,8 @@ var CAPTURE_ALLOWED_KEYS = /* @__PURE__ */ new Set([
1254
1266
  "rejected_reason",
1255
1267
  "alternatives",
1256
1268
  "linked_events",
1257
- "linked_files"
1269
+ "linked_files",
1270
+ "kind"
1258
1271
  ]);
1259
1272
  function parseCaptureInput(raw) {
1260
1273
  if (raw.trim().length === 0) {
@@ -1283,7 +1296,7 @@ function validateCaptureItem(item, index) {
1283
1296
  for (const key of Object.keys(obj)) {
1284
1297
  if (!CAPTURE_ALLOWED_KEYS.has(key)) {
1285
1298
  throw new Error(
1286
- `decision[${index}]: unknown field '${key}'. Allowed: title, rationale, rejected_reason, alternatives, linked_events, linked_files.`
1299
+ `decision[${index}]: unknown field '${key}'. Allowed: title, rationale, rejected_reason, alternatives, linked_events, linked_files, kind.`
1287
1300
  );
1288
1301
  }
1289
1302
  }
@@ -1291,6 +1304,12 @@ function validateCaptureItem(item, index) {
1291
1304
  throw new Error(`decision[${index}].title must be a non-empty string.`);
1292
1305
  }
1293
1306
  const out = { title: obj.title };
1307
+ if (obj.kind !== void 0) {
1308
+ if (obj.kind !== "decision" && obj.kind !== "track") {
1309
+ throw new Error(`decision[${index}].kind must be "decision" or "track", got '${obj.kind}'.`);
1310
+ }
1311
+ if (obj.kind === "track") out.kind = "track";
1312
+ }
1294
1313
  if (obj.rationale !== void 0) {
1295
1314
  out.rationale = requireNonEmptyString(obj.rationale, index, "rationale");
1296
1315
  }
@@ -1358,6 +1377,7 @@ function toRichFields(decision) {
1358
1377
  if (decision.alternatives !== void 0) out.alternatives = [...decision.alternatives];
1359
1378
  if (decision.linked_events !== void 0) out.linked_events = [...decision.linked_events];
1360
1379
  if (decision.linked_files !== void 0) out.linked_files = [...decision.linked_files];
1380
+ if (decision.kind !== void 0) out.kind = decision.kind;
1361
1381
  return out;
1362
1382
  }
1363
1383
  function buildCaptureLabel(count) {
@@ -1375,6 +1395,7 @@ function captureItemToPayload(item) {
1375
1395
  payload.rejected_reason = item.input.rejected_reason;
1376
1396
  if (item.input.linked_events !== void 0) payload.linked_events = item.input.linked_events;
1377
1397
  if (item.input.linked_files !== void 0) payload.linked_files = item.input.linked_files;
1398
+ if (item.input.kind !== void 0) payload.kind = item.input.kind;
1378
1399
  return payload;
1379
1400
  }
1380
1401
  function printCapturePreview(options, decisions) {
@@ -1386,7 +1407,7 @@ function printCapturePreview(options, decisions) {
1386
1407
  `Would capture ${decisions.length} decision${decisions.length === 1 ? "" : "s"} (dry run; nothing written):`
1387
1408
  );
1388
1409
  for (const decision of decisions) {
1389
- console.log(`- ${decision.title}`);
1410
+ console.log(`- ${decision.title}${decision.kind === "track" ? " [TRACK]" : ""}`);
1390
1411
  }
1391
1412
  }
1392
1413
  function printCaptureResult(options, result) {
@@ -1407,7 +1428,9 @@ function printCaptureResult(options, result) {
1407
1428
  `Captured ${result.items.length} decision${result.items.length === 1 ? "" : "s"} in ad-hoc session ${sid}:`
1408
1429
  );
1409
1430
  for (const item of result.items) {
1410
- console.log(`- ${item.decisionId}: ${item.input.title}`);
1431
+ console.log(
1432
+ `- ${item.decisionId}: ${item.input.title}${item.input.kind === "track" ? " [TRACK]" : ""}`
1433
+ );
1411
1434
  }
1412
1435
  }
1413
1436
  function pickRichFields(options) {
@@ -1423,6 +1446,7 @@ function pickRichFields(options) {
1423
1446
  if (options.linkedFile !== void 0 && options.linkedFile.length > 0) {
1424
1447
  out.linked_files = [...options.linkedFile];
1425
1448
  }
1449
+ if (options.track === true) out.kind = "track";
1426
1450
  return out;
1427
1451
  }
1428
1452
  function buildDecisionEvent(input) {
@@ -1439,7 +1463,8 @@ function buildDecisionEvent(input) {
1439
1463
  ...input.rich.alternatives !== void 0 ? { alternatives: input.rich.alternatives } : {},
1440
1464
  ...input.rich.rejected_reason !== void 0 ? { rejected_reason: input.rich.rejected_reason } : {},
1441
1465
  ...input.rich.linked_events !== void 0 ? { linked_events: input.rich.linked_events } : {},
1442
- ...input.rich.linked_files !== void 0 ? { linked_files: input.rich.linked_files } : {}
1466
+ ...input.rich.linked_files !== void 0 ? { linked_files: input.rich.linked_files } : {},
1467
+ ...input.rich.kind !== void 0 ? { kind: input.rich.kind } : {}
1443
1468
  };
1444
1469
  }
1445
1470
  function buildAdHocLabel(title) {
@@ -1506,15 +1531,19 @@ function printDecisionResult(options, result) {
1506
1531
  }
1507
1532
  if (result.rich.linked_events !== void 0) payload.linked_events = result.rich.linked_events;
1508
1533
  if (result.rich.linked_files !== void 0) payload.linked_files = result.rich.linked_files;
1534
+ if (result.rich.kind !== void 0) payload.kind = result.rich.kind;
1509
1535
  console.log(JSON.stringify(payload));
1510
1536
  return;
1511
1537
  }
1538
+ const trackPrefix = result.rich.kind === "track" ? "track " : "";
1512
1539
  const rationaleSuffix = result.rich.rationale !== void 0 ? ` (rationale: ${result.rich.rationale})` : "";
1513
1540
  if (result.mode === "ad-hoc") {
1514
- console.log(`Recorded ${result.decisionId} in ad-hoc session ${sid}${rationaleSuffix}`);
1541
+ console.log(
1542
+ `Recorded ${trackPrefix}${result.decisionId} in ad-hoc session ${sid}${rationaleSuffix}`
1543
+ );
1515
1544
  } else {
1516
1545
  console.log(
1517
- `Recorded ${result.decisionId} in session ${sid} (${result.sessionStatus})${rationaleSuffix}`
1546
+ `Recorded ${trackPrefix}${result.decisionId} in session ${sid} (${result.sessionStatus})${rationaleSuffix}`
1518
1547
  );
1519
1548
  }
1520
1549
  }
@@ -2004,9 +2033,135 @@ async function assertWorkspaceInitialized4(basouRoot) {
2004
2033
  }
2005
2034
  }
2006
2035
 
2036
+ // src/commands/hook.ts
2037
+ import { open, readFile as readFile2, stat as stat2 } from "fs/promises";
2038
+ import {
2039
+ DEFAULT_STOP_HOOK_MIN_ACTIONS,
2040
+ evaluateStopHook
2041
+ } from "@basou/core";
2042
+ var MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
2043
+ function registerHookCommand(program2) {
2044
+ const hook = program2.command("hook").description(
2045
+ "Claude Code hook handlers (read a hook payload on stdin, emit hook JSON on stdout)"
2046
+ );
2047
+ hook.command("stop").description(
2048
+ "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."
2049
+ ).option(
2050
+ "--min-actions <n>",
2051
+ `Minimum commands+edits before nudging (default ${DEFAULT_STOP_HOOK_MIN_ACTIONS})`
2052
+ ).addHelpText("after", HOOK_STOP_HELP).action(async (options) => {
2053
+ const minActions = parseMinActions(options.minActions);
2054
+ await runHookStop(minActions !== void 0 ? { minActions } : {});
2055
+ });
2056
+ }
2057
+ var HOOK_STOP_HELP = `
2058
+ Install as a Claude Code Stop hook in ~/.claude/settings.json:
2059
+ {
2060
+ "hooks": {
2061
+ "Stop": [
2062
+ { "hooks": [ { "type": "command", "command": "basou hook stop" } ] }
2063
+ ]
2064
+ }
2065
+ }
2066
+
2067
+ On every turn end basou inspects the session transcript. If the session did
2068
+ substantive work (>= ${DEFAULT_STOP_HOOK_MIN_ACTIONS} commands + file edits by default) but ran no capture
2069
+ verb ('basou decision capture' / 'decision record' / 'note'), it emits a
2070
+ non-blocking reminder so the agent can record the why / next step. The reminder
2071
+ continues the conversation (Claude may act on it or stop); it never forces.
2072
+ The 'stop_hook_active' flag is honored so the nudge cannot loop.
2073
+ `;
2074
+ async function runHookStop(options, ctx = {}) {
2075
+ try {
2076
+ await doRunHookStop(options, ctx);
2077
+ } catch {
2078
+ }
2079
+ }
2080
+ async function doRunHookStop(options, ctx) {
2081
+ const readStdin = ctx.readStdin ?? defaultReadStdin;
2082
+ const readTranscript = ctx.readTranscript ?? readTranscriptBounded;
2083
+ const write = ctx.write ?? ((text) => void process.stdout.write(text));
2084
+ const raw = await readStdin();
2085
+ if (raw.trim().length === 0) return;
2086
+ let payload;
2087
+ try {
2088
+ payload = JSON.parse(raw);
2089
+ } catch {
2090
+ return;
2091
+ }
2092
+ if (typeof payload !== "object" || payload === null) return;
2093
+ const fields = payload;
2094
+ if (fields.stop_hook_active === true) return;
2095
+ const transcriptPath = typeof fields.transcript_path === "string" ? fields.transcript_path : "";
2096
+ if (transcriptPath.length === 0) return;
2097
+ let transcript;
2098
+ try {
2099
+ transcript = await readTranscript(transcriptPath);
2100
+ } catch {
2101
+ return;
2102
+ }
2103
+ const records = parseTranscript(transcript);
2104
+ const evaluation = evaluateStopHook({
2105
+ records,
2106
+ // stop_hook_active was already handled by the early return above.
2107
+ stopHookActive: false,
2108
+ ...options.minActions !== void 0 ? { minActions: options.minActions } : {}
2109
+ });
2110
+ if (evaluation.kind !== "nudge") return;
2111
+ write(
2112
+ `${JSON.stringify({
2113
+ hookSpecificOutput: {
2114
+ hookEventName: "Stop",
2115
+ additionalContext: evaluation.additionalContext
2116
+ }
2117
+ })}
2118
+ `
2119
+ );
2120
+ }
2121
+ function parseTranscript(transcript) {
2122
+ const records = [];
2123
+ for (const line of transcript.split(/\r?\n/)) {
2124
+ if (line.trim().length === 0) continue;
2125
+ try {
2126
+ const parsed = JSON.parse(line);
2127
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
2128
+ records.push(parsed);
2129
+ }
2130
+ } catch {
2131
+ }
2132
+ }
2133
+ return records;
2134
+ }
2135
+ async function defaultReadStdin() {
2136
+ if (process.stdin.isTTY === true) return "";
2137
+ const chunks = [];
2138
+ for await (const chunk of process.stdin) {
2139
+ chunks.push(chunk);
2140
+ }
2141
+ return Buffer.concat(chunks).toString("utf8");
2142
+ }
2143
+ async function readTranscriptBounded(path, maxBytes = MAX_TRANSCRIPT_BYTES) {
2144
+ const { size } = await stat2(path);
2145
+ if (size <= maxBytes) return readFile2(path, "utf8");
2146
+ const handle = await open(path, "r");
2147
+ try {
2148
+ const buffer = Buffer.alloc(maxBytes);
2149
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes);
2150
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
2151
+ const firstNewline = text.indexOf("\n");
2152
+ return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
2153
+ } finally {
2154
+ await handle.close();
2155
+ }
2156
+ }
2157
+ function parseMinActions(raw) {
2158
+ if (raw === void 0 || !/^\d+$/.test(raw)) return void 0;
2159
+ return Number(raw);
2160
+ }
2161
+
2007
2162
  // src/commands/import.ts
2008
2163
  import { createReadStream } from "fs";
2009
- import { readdir, readFile as readFile2, rm, stat as stat2 } from "fs/promises";
2164
+ import { readdir, readFile as readFile3, rm, stat as stat3 } from "fs/promises";
2010
2165
  import { homedir as homedir4 } from "os";
2011
2166
  import { basename as basename2, dirname, join as join5, resolve as resolve4 } from "path";
2012
2167
  import { createInterface } from "readline";
@@ -2404,7 +2559,7 @@ async function selectTranscriptFiles(projectsRoot, projectPaths, options) {
2404
2559
  }
2405
2560
  async function pathExists(file) {
2406
2561
  try {
2407
- await stat2(file);
2562
+ await stat3(file);
2408
2563
  return true;
2409
2564
  } catch (error) {
2410
2565
  if (findErrorCode5(error, "ENOENT")) return false;
@@ -2413,7 +2568,7 @@ async function pathExists(file) {
2413
2568
  }
2414
2569
  async function statSize(file) {
2415
2570
  try {
2416
- return (await stat2(file)).size;
2571
+ return (await stat3(file)).size;
2417
2572
  } catch (error) {
2418
2573
  if (findErrorCode5(error, "ENOENT")) return void 0;
2419
2574
  throw error;
@@ -2499,7 +2654,7 @@ async function readFirstLine(file) {
2499
2654
  async function readJsonlRecords(file) {
2500
2655
  let buffer;
2501
2656
  try {
2502
- buffer = await readFile2(file);
2657
+ buffer = await readFile3(file);
2503
2658
  } catch (error) {
2504
2659
  if (findErrorCode5(error, "ENOENT")) {
2505
2660
  throw new Error("Source log not found", { cause: error });
@@ -4858,7 +5013,7 @@ function renderProjectRename(result) {
4858
5013
  }
4859
5014
 
4860
5015
  // src/commands/protocol.ts
4861
- import { readFile as readFile3 } from "fs/promises";
5016
+ import { readFile as readFile4 } from "fs/promises";
4862
5017
  import {
4863
5018
  PROTOCOL_END,
4864
5019
  PROTOCOL_START,
@@ -4869,7 +5024,7 @@ import {
4869
5024
 
4870
5025
  // src/lib/durable-write.ts
4871
5026
  import { randomUUID } from "crypto";
4872
- import { lstat, open, rename, stat as stat3, unlink as unlink2 } from "fs/promises";
5027
+ import { lstat, open as open2, rename, stat as stat4, unlink as unlink2 } from "fs/promises";
4873
5028
  import { basename as basename5, dirname as dirname3, join as join8 } from "path";
4874
5029
  async function assertNotSymlink(targetPath) {
4875
5030
  try {
@@ -4889,7 +5044,7 @@ async function writeFileDurable(targetPath, content) {
4889
5044
  const tmpPath = join8(dir, `.${basename5(targetPath)}.tmp.${randomUUID()}`);
4890
5045
  let mode = 420;
4891
5046
  try {
4892
- mode = (await stat3(targetPath)).mode & 511;
5047
+ mode = (await stat4(targetPath)).mode & 511;
4893
5048
  } catch (error) {
4894
5049
  if (!(error instanceof Error && error.code === "ENOENT")) {
4895
5050
  throw error;
@@ -4897,7 +5052,7 @@ async function writeFileDurable(targetPath, content) {
4897
5052
  }
4898
5053
  let handle;
4899
5054
  try {
4900
- handle = await open(tmpPath, "wx", mode);
5055
+ handle = await open2(tmpPath, "wx", mode);
4901
5056
  await handle.writeFile(content, "utf8");
4902
5057
  await handle.chmod(mode);
4903
5058
  await handle.sync();
@@ -4910,7 +5065,7 @@ async function writeFileDurable(targetPath, content) {
4910
5065
  throw error;
4911
5066
  }
4912
5067
  try {
4913
- const dirHandle = await open(dir, "r");
5068
+ const dirHandle = await open2(dir, "r");
4914
5069
  try {
4915
5070
  await dirHandle.sync();
4916
5071
  } finally {
@@ -5041,7 +5196,7 @@ async function readProtocolSources(entries) {
5041
5196
  for (const entry of entries) {
5042
5197
  let content;
5043
5198
  try {
5044
- content = await readFile3(entry.source, "utf8");
5199
+ content = await readFile4(entry.source, "utf8");
5045
5200
  } catch (error) {
5046
5201
  if (error instanceof Error && error.code === "ENOENT") {
5047
5202
  throw new Error(
@@ -5181,7 +5336,7 @@ import { assertBasouRootSafe as assertBasouRootSafe9, basouPaths as basouPaths11
5181
5336
  import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
5182
5337
 
5183
5338
  // src/commands/refresh-watch.ts
5184
- import { readdir as readdir2, stat as stat4 } from "fs/promises";
5339
+ import { readdir as readdir2, stat as stat5 } from "fs/promises";
5185
5340
  import { homedir as homedir7 } from "os";
5186
5341
  import { join as join10 } from "path";
5187
5342
  import { findErrorCode as findErrorCode8 } from "@basou/core";
@@ -5210,7 +5365,7 @@ async function scanSourceLogs(roots) {
5210
5365
  await walk(full);
5211
5366
  } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
5212
5367
  try {
5213
- const info = await stat4(full);
5368
+ const info = await stat5(full);
5214
5369
  out.set(full, { mtimeMs: info.mtimeMs, size: info.size });
5215
5370
  } catch (error) {
5216
5371
  if (findErrorCode8(error, "ENOENT")) continue;
@@ -6128,7 +6283,7 @@ async function resolveRepositoryRootForRun(cwd) {
6128
6283
  }
6129
6284
 
6130
6285
  // src/commands/session.ts
6131
- import { readFile as readFile4 } from "fs/promises";
6286
+ import { readFile as readFile5 } from "fs/promises";
6132
6287
  import { basename as basename6, isAbsolute as isAbsolute6, join as join12, relative as relative3 } from "path";
6133
6288
  import {
6134
6289
  acquireLock as acquireLock6,
@@ -6573,7 +6728,7 @@ async function doRunSessionImport(options, ctx) {
6573
6728
  }
6574
6729
  async function readInputFile(path) {
6575
6730
  try {
6576
- return await readFile4(path, "utf8");
6731
+ return await readFile5(path, "utf8");
6577
6732
  } catch (error) {
6578
6733
  if (findErrorCode11(error, "ENOENT")) {
6579
6734
  throw new Error("Import source not found", { cause: error });
@@ -6690,7 +6845,7 @@ async function doRunSessionNote(sessionIdInput, options, ctx) {
6690
6845
  }
6691
6846
  async function readNoteFile(path) {
6692
6847
  try {
6693
- return await readFile4(path, "utf8");
6848
+ return await readFile5(path, "utf8");
6694
6849
  } catch (error) {
6695
6850
  if (findErrorCode11(error, "ENOENT")) {
6696
6851
  throw new Error("Note source not found", { cause: error });
@@ -6997,7 +7152,7 @@ async function resolveRepositoryRootForStatus(cwd) {
6997
7152
  }
6998
7153
 
6999
7154
  // src/commands/task.ts
7000
- import { readFile as readFile5 } from "fs/promises";
7155
+ import { readFile as readFile6 } from "fs/promises";
7001
7156
  import { join as join13 } from "path";
7002
7157
  import {
7003
7158
  archiveTask,
@@ -8013,7 +8168,7 @@ function parsePositiveInt2(raw) {
8013
8168
  }
8014
8169
  async function readDescriptionFile(path) {
8015
8170
  try {
8016
- return await readFile5(path, "utf8");
8171
+ return await readFile6(path, "utf8");
8017
8172
  } catch (error) {
8018
8173
  if (findErrorCode14(error, "ENOENT")) {
8019
8174
  throw new Error("Description source not found", { cause: error });
@@ -9699,6 +9854,7 @@ function buildProgram() {
9699
9854
  registerReviewGapsCommand(program2);
9700
9855
  registerProjectCommand(program2);
9701
9856
  registerProtocolCommand(program2);
9857
+ registerHookCommand(program2);
9702
9858
  return program2;
9703
9859
  }
9704
9860