@basou/core 0.48.1 → 0.50.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
@@ -1,10 +1,10 @@
1
1
  // src/adapters/command-lookup.ts
2
2
  import { spawn } from "child_process";
3
3
  async function isOnPath(command) {
4
- return new Promise((resolve4) => {
4
+ return new Promise((resolve5) => {
5
5
  const child = spawn("which", [command], { stdio: "ignore" });
6
- child.on("error", () => resolve4(false));
7
- child.on("exit", (code) => resolve4(code === 0));
6
+ child.on("error", () => resolve5(false));
7
+ child.on("exit", (code) => resolve5(code === 0));
8
8
  });
9
9
  }
10
10
 
@@ -23,13 +23,142 @@ function summarizeAdapterOutput(_stream, _raw) {
23
23
  throw new Error("adapter_output summary is not implemented in this release");
24
24
  }
25
25
 
26
+ // src/adapters/codex/hooks-json.ts
27
+ var SESSION_START_HOOK_TIMEOUT_SECONDS = 30;
28
+ var SESSION_START_HOOK_MATCHER = "startup|resume|clear";
29
+ var SESSION_START_HOOK_CONTEXT_LIMIT = 0;
30
+ var SESSION_START_HOOK_STATUS_MESSAGE = "basou orient";
31
+ var BASOU_SESSION_START_HOOK = /(?:\bbasou|(?:@basou|packages)\/cli\/dist\/index\.js['"]?)\s+hook\s+session-start\b/;
32
+ function isBasouSessionStartHookCommand(command) {
33
+ return BASOU_SESSION_START_HOOK.test(command);
34
+ }
35
+ function shellQuote(value) {
36
+ return `'${value.replace(/'/g, "'\\''")}'`;
37
+ }
38
+ function buildSessionStartHookCommand(options) {
39
+ return `node ${shellQuote(options.cliEntry)} hook session-start 2>/dev/null || true`;
40
+ }
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+ function cloneHooksFile(hooksFile) {
45
+ if (hooksFile === void 0 || hooksFile === null) return {};
46
+ if (!isRecord(hooksFile)) {
47
+ throw new Error("The Codex hooks.json is not a JSON object.");
48
+ }
49
+ return structuredClone(hooksFile);
50
+ }
51
+ function canonicalHandler(command) {
52
+ return {
53
+ type: "command",
54
+ command,
55
+ timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
56
+ statusMessage: SESSION_START_HOOK_STATUS_MESSAGE,
57
+ additionalContextLimit: SESSION_START_HOOK_CONTEXT_LIMIT
58
+ };
59
+ }
60
+ function handlerIsCanonical(entry, command) {
61
+ const want = canonicalHandler(command);
62
+ return Object.keys(want).every((k) => entry[k] === want[k]);
63
+ }
64
+ function upsertSessionStartHook(hooksFile, command) {
65
+ const root = cloneHooksFile(hooksFile);
66
+ if (root.hooks === void 0) {
67
+ root.hooks = {};
68
+ } else if (!isRecord(root.hooks)) {
69
+ throw new Error("The 'hooks' key in the Codex hooks.json is not an object.");
70
+ }
71
+ const hooks = root.hooks;
72
+ if (hooks.SessionStart === void 0) {
73
+ hooks.SessionStart = [];
74
+ } else if (!Array.isArray(hooks.SessionStart)) {
75
+ throw new Error("The 'hooks.SessionStart' key in the Codex hooks.json is not an array.");
76
+ }
77
+ const groups = hooks.SessionStart;
78
+ for (const group of groups) {
79
+ if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
80
+ for (const entry of group.hooks) {
81
+ if (!isRecord(entry)) continue;
82
+ if (typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
83
+ const unchanged = handlerIsCanonical(entry, command);
84
+ Object.assign(entry, canonicalHandler(command));
85
+ return { hooksFile: root, action: unchanged ? "unchanged" : "updated" };
86
+ }
87
+ }
88
+ }
89
+ groups.push({ matcher: SESSION_START_HOOK_MATCHER, hooks: [canonicalHandler(command)] });
90
+ return { hooksFile: root, action: "installed" };
91
+ }
92
+ function removeSessionStartHook(hooksFile) {
93
+ const root = cloneHooksFile(hooksFile);
94
+ if (!isRecord(root.hooks) || !Array.isArray(root.hooks.SessionStart)) {
95
+ return { hooksFile: root, action: "absent" };
96
+ }
97
+ const hooks = root.hooks;
98
+ const groups = hooks.SessionStart;
99
+ let removed = false;
100
+ const kept = [];
101
+ for (const group of groups) {
102
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
103
+ kept.push(group);
104
+ continue;
105
+ }
106
+ const keptHandlers = group.hooks.filter((entry) => {
107
+ if (isRecord(entry) && typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
108
+ removed = true;
109
+ return false;
110
+ }
111
+ return true;
112
+ });
113
+ if (keptHandlers.length === group.hooks.length) {
114
+ kept.push(group);
115
+ } else if (keptHandlers.length > 0) {
116
+ group.hooks = keptHandlers;
117
+ kept.push(group);
118
+ }
119
+ }
120
+ if (!removed) return { hooksFile: root, action: "absent" };
121
+ if (kept.length === 0) {
122
+ delete hooks.SessionStart;
123
+ } else {
124
+ hooks.SessionStart = kept;
125
+ }
126
+ if (Object.keys(hooks).length === 0) {
127
+ delete root.hooks;
128
+ }
129
+ return { hooksFile: root, action: "removed" };
130
+ }
131
+ function findBasouSessionStartHook(hooksFile) {
132
+ if (!isRecord(hooksFile) || !isRecord(hooksFile.hooks) || !Array.isArray(hooksFile.hooks.SessionStart)) {
133
+ return null;
134
+ }
135
+ const groups = hooksFile.hooks.SessionStart;
136
+ for (let g = 0; g < groups.length; g++) {
137
+ const group = groups[g];
138
+ if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
139
+ for (let h = 0; h < group.hooks.length; h++) {
140
+ const entry = group.hooks[h];
141
+ if (isRecord(entry) && typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
142
+ return {
143
+ command: entry.command,
144
+ groupIndex: g,
145
+ handlerIndex: h,
146
+ matcher: typeof group.matcher === "string" ? group.matcher : void 0,
147
+ handler: structuredClone(entry)
148
+ };
149
+ }
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
26
155
  // src/adapters/claude-code/settings-hook.ts
27
156
  var STOP_HOOK_TIMEOUT_SECONDS = 20;
28
157
  var BASOU_STOP_HOOK = /(?:\bbasou|(?:@basou|packages)\/cli\/dist\/index\.js['"]?)\s+hook\s+stop\b/;
29
158
  function isBasouStopHookCommand(command) {
30
159
  return BASOU_STOP_HOOK.test(command);
31
160
  }
32
- function shellQuote(value) {
161
+ function shellQuote2(value) {
33
162
  return `'${value.replace(/'/g, "'\\''")}'`;
34
163
  }
35
164
  function buildStopHookCommand(options) {
@@ -38,14 +167,14 @@ function buildStopHookCommand(options) {
38
167
  if (options.requireReview === true) flags.push("--require-review");
39
168
  if (options.minEdits !== void 0) flags.push(`--min-edits ${options.minEdits}`);
40
169
  const suffix = flags.length > 0 ? ` ${flags.join(" ")}` : "";
41
- return `node ${shellQuote(options.cliEntry)} hook stop${suffix} 2>/dev/null || true`;
170
+ return `node ${shellQuote2(options.cliEntry)} hook stop${suffix} 2>/dev/null || true`;
42
171
  }
43
- function isRecord(value) {
172
+ function isRecord2(value) {
44
173
  return typeof value === "object" && value !== null && !Array.isArray(value);
45
174
  }
46
175
  function cloneSettings(settings) {
47
176
  if (settings === void 0 || settings === null) return {};
48
- if (!isRecord(settings)) {
177
+ if (!isRecord2(settings)) {
49
178
  throw new Error("Claude settings is not a JSON object.");
50
179
  }
51
180
  return structuredClone(settings);
@@ -54,7 +183,7 @@ function upsertStopHook(settings, command) {
54
183
  const root = cloneSettings(settings);
55
184
  if (root.hooks === void 0) {
56
185
  root.hooks = {};
57
- } else if (!isRecord(root.hooks)) {
186
+ } else if (!isRecord2(root.hooks)) {
58
187
  throw new Error("The 'hooks' key in Claude settings is not an object.");
59
188
  }
60
189
  const hooks = root.hooks;
@@ -65,9 +194,9 @@ function upsertStopHook(settings, command) {
65
194
  }
66
195
  const stop = hooks.Stop;
67
196
  for (const group of stop) {
68
- if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
197
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
69
198
  for (const entry of group.hooks) {
70
- if (!isRecord(entry)) continue;
199
+ if (!isRecord2(entry)) continue;
71
200
  if (typeof entry.command === "string" && isBasouStopHookCommand(entry.command)) {
72
201
  const unchanged = entry.type === "command" && entry.command === command && entry.timeout === STOP_HOOK_TIMEOUT_SECONDS;
73
202
  entry.type = "command";
@@ -82,7 +211,7 @@ function upsertStopHook(settings, command) {
82
211
  }
83
212
  function removeStopHook(settings) {
84
213
  const root = cloneSettings(settings);
85
- if (!isRecord(root.hooks) || !Array.isArray(root.hooks.Stop)) {
214
+ if (!isRecord2(root.hooks) || !Array.isArray(root.hooks.Stop)) {
86
215
  return { settings: root, action: "absent" };
87
216
  }
88
217
  const hooks = root.hooks;
@@ -90,12 +219,12 @@ function removeStopHook(settings) {
90
219
  let removed = false;
91
220
  const newStop = [];
92
221
  for (const group of stop) {
93
- if (!isRecord(group) || !Array.isArray(group.hooks)) {
222
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) {
94
223
  newStop.push(group);
95
224
  continue;
96
225
  }
97
226
  const keptHooks = group.hooks.filter((entry) => {
98
- if (isRecord(entry) && typeof entry.command === "string" && isBasouStopHookCommand(entry.command)) {
227
+ if (isRecord2(entry) && typeof entry.command === "string" && isBasouStopHookCommand(entry.command)) {
99
228
  removed = true;
100
229
  return false;
101
230
  }
@@ -122,19 +251,198 @@ function removeStopHook(settings) {
122
251
  return { settings: root, action: "removed" };
123
252
  }
124
253
  function findBasouStopHookCommand(settings) {
125
- if (!isRecord(settings) || !isRecord(settings.hooks) || !Array.isArray(settings.hooks.Stop)) {
254
+ if (!isRecord2(settings) || !isRecord2(settings.hooks) || !Array.isArray(settings.hooks.Stop)) {
126
255
  return null;
127
256
  }
128
257
  for (const group of settings.hooks.Stop) {
129
- if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
258
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
130
259
  for (const entry of group.hooks) {
131
- if (isRecord(entry) && typeof entry.command === "string" && isBasouStopHookCommand(entry.command)) {
260
+ if (isRecord2(entry) && typeof entry.command === "string" && isBasouStopHookCommand(entry.command)) {
132
261
  return entry.command;
133
262
  }
134
263
  }
135
264
  }
136
265
  return null;
137
266
  }
267
+ var ENTRY = String.raw`(?:[^\s'"]*/)?(?:@basou|packages)/cli/dist/index\.js`;
268
+ var INVOCATION = String.raw`(?:basou|node[ \t]+(?:'(?:[^']*/)?(?:@basou|packages)/cli/dist/index\.js'|"(?:[^"]*/)?(?:@basou|packages)/cli/dist/index\.js"|${ENTRY}))`;
269
+ var WRAPPER = String.raw`(?:[ \t]+2>[ \t]*/dev/null)?(?:[ \t]*\|\|[ \t]*true)?`;
270
+ var CLAUDE_SESSION_START = new RegExp(
271
+ String.raw`^[ \t]*${INVOCATION}[ \t]+hook[ \t]+session-start${WRAPPER}[ \t]*$`
272
+ );
273
+ var CLAUDE_ORIENT_SESSION_START = new RegExp(
274
+ String.raw`^[ \t]*${INVOCATION}[ \t]+orient${WRAPPER}[ \t]*$`
275
+ );
276
+ function isClaudeSessionStartHookCommand(command) {
277
+ return CLAUDE_SESSION_START.test(command);
278
+ }
279
+ function isBasouOrientSessionStartCommand(command) {
280
+ return CLAUDE_ORIENT_SESSION_START.test(command);
281
+ }
282
+ function basouSessionStartKind(entry) {
283
+ if (!isRecord2(entry) || typeof entry.command !== "string") return null;
284
+ if (isClaudeSessionStartHookCommand(entry.command)) return "session-start";
285
+ if (isBasouOrientSessionStartCommand(entry.command)) return "orient";
286
+ return null;
287
+ }
288
+ function matcherKey(group) {
289
+ const m = group.matcher;
290
+ return m === void 0 || m === "" || m === "*" ? "*" : String(m);
291
+ }
292
+ function upsertClaudeSessionStartHook(settings, command) {
293
+ const root = cloneSettings(settings);
294
+ if (root.hooks === void 0) {
295
+ root.hooks = {};
296
+ } else if (!isRecord2(root.hooks)) {
297
+ throw new Error("The 'hooks' key in Claude settings is not an object.");
298
+ }
299
+ const hooks = root.hooks;
300
+ if (hooks.SessionStart === void 0) {
301
+ hooks.SessionStart = [];
302
+ } else if (!Array.isArray(hooks.SessionStart)) {
303
+ throw new Error("The 'hooks.SessionStart' key in Claude settings is not an array.");
304
+ }
305
+ const groups = hooks.SessionStart;
306
+ let found = false;
307
+ let changed = false;
308
+ let replacedOrient = false;
309
+ const keptUnder = /* @__PURE__ */ new Set();
310
+ const nextGroups = [];
311
+ for (const group of groups) {
312
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) {
313
+ nextGroups.push(group);
314
+ continue;
315
+ }
316
+ const key = matcherKey(group);
317
+ const nextHooks = [];
318
+ let removedHere = false;
319
+ for (const entry of group.hooks) {
320
+ const kind = basouSessionStartKind(entry);
321
+ if (kind === null || !isRecord2(entry)) {
322
+ nextHooks.push(entry);
323
+ continue;
324
+ }
325
+ found = true;
326
+ if (kind === "orient") replacedOrient = true;
327
+ if (keptUnder.has(key)) {
328
+ changed = true;
329
+ removedHere = true;
330
+ continue;
331
+ }
332
+ if (entry.type !== "command" || entry.command !== command || entry.timeout !== SESSION_START_HOOK_TIMEOUT_SECONDS) {
333
+ changed = true;
334
+ }
335
+ entry.type = "command";
336
+ entry.command = command;
337
+ entry.timeout = SESSION_START_HOOK_TIMEOUT_SECONDS;
338
+ keptUnder.add(key);
339
+ nextHooks.push(entry);
340
+ }
341
+ if (removedHere && nextHooks.length === 0) continue;
342
+ group.hooks = nextHooks;
343
+ nextGroups.push(group);
344
+ }
345
+ hooks.SessionStart = nextGroups;
346
+ if (!found) {
347
+ nextGroups.push({
348
+ matcher: SESSION_START_HOOK_MATCHER,
349
+ hooks: [{ type: "command", command, timeout: SESSION_START_HOOK_TIMEOUT_SECONDS }]
350
+ });
351
+ return { settings: root, action: "installed" };
352
+ }
353
+ if (replacedOrient) return { settings: root, action: "replaced" };
354
+ return { settings: root, action: changed ? "updated" : "unchanged" };
355
+ }
356
+ function removeClaudeSessionStartHook(settings) {
357
+ const root = cloneSettings(settings);
358
+ if (!isRecord2(root.hooks) || !Array.isArray(root.hooks.SessionStart)) {
359
+ return { settings: root, action: "absent" };
360
+ }
361
+ const hooks = root.hooks;
362
+ let removed = false;
363
+ const nextGroups = [];
364
+ for (const group of hooks.SessionStart) {
365
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) {
366
+ nextGroups.push(group);
367
+ continue;
368
+ }
369
+ const keptHooks = group.hooks.filter((entry) => basouSessionStartKind(entry) === null);
370
+ if (keptHooks.length === group.hooks.length) {
371
+ nextGroups.push(group);
372
+ } else {
373
+ removed = true;
374
+ if (keptHooks.length > 0) {
375
+ group.hooks = keptHooks;
376
+ nextGroups.push(group);
377
+ }
378
+ }
379
+ }
380
+ if (!removed) return { settings: root, action: "absent" };
381
+ if (nextGroups.length === 0) {
382
+ delete hooks.SessionStart;
383
+ } else {
384
+ hooks.SessionStart = nextGroups;
385
+ }
386
+ if (Object.keys(hooks).length === 0) delete root.hooks;
387
+ return { settings: root, action: "removed" };
388
+ }
389
+ function findClaudeSessionStartHooks(settings) {
390
+ const found = [];
391
+ if (!isRecord2(settings) || !isRecord2(settings.hooks)) return found;
392
+ const groups = settings.hooks.SessionStart;
393
+ if (!Array.isArray(groups)) return found;
394
+ for (const group of groups) {
395
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
396
+ for (const entry of group.hooks) {
397
+ const kind = basouSessionStartKind(entry);
398
+ if (kind === null || !isRecord2(entry) || typeof entry.command !== "string") continue;
399
+ found.push({
400
+ command: entry.command,
401
+ kind,
402
+ matcher: typeof group.matcher === "string" ? group.matcher : void 0
403
+ });
404
+ }
405
+ }
406
+ return found;
407
+ }
408
+ function isClaudeSessionStartMalformed(settings) {
409
+ return isRecord2(settings) && isRecord2(settings.hooks) && settings.hooks.SessionStart !== void 0 && !Array.isArray(settings.hooks.SessionStart);
410
+ }
411
+ var RUNS_BASOU = new RegExp(
412
+ String.raw`(?:^|[\s;&|(])(?:basou|node(?:[ \t]+-[^\s]+)*[ \t]+(?:'[^']*(?:@basou|packages)/cli/dist/index\.js'|"[^"]*(?:@basou|packages)/cli/dist/index\.js"|${ENTRY})|npx(?:[ \t]+-y)?[ \t]+@basou/cli)[ \t]+(orient|hook[ \t]+session-start)(?=$|[\s;&|)])`,
413
+ "g"
414
+ );
415
+ function isInsideQuotes(text, index) {
416
+ let single = false;
417
+ let double = false;
418
+ for (let i = 0; i < index; i++) {
419
+ const c = text[i];
420
+ if (c === "'" && !double) single = !single;
421
+ else if (c === '"' && !single) double = !double;
422
+ }
423
+ return single || double;
424
+ }
425
+ function findUnrecognizedSessionStart(settings) {
426
+ const found = [];
427
+ if (!isRecord2(settings) || !isRecord2(settings.hooks)) return found;
428
+ const groups = settings.hooks.SessionStart;
429
+ if (!Array.isArray(groups)) return found;
430
+ for (const group of groups) {
431
+ if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
432
+ for (const entry of group.hooks) {
433
+ if (!isRecord2(entry) || typeof entry.command !== "string") continue;
434
+ if (basouSessionStartKind(entry) !== null) continue;
435
+ const command = entry.command;
436
+ for (const m of command.matchAll(RUNS_BASOU)) {
437
+ const at = (m.index ?? 0) + (m[0].length - m[0].trimStart().length);
438
+ if (isInsideQuotes(command, at)) continue;
439
+ found.push({ command, runs: m[1] === "orient" ? "orient" : "session-start" });
440
+ break;
441
+ }
442
+ }
443
+ }
444
+ return found;
445
+ }
138
446
 
139
447
  // src/adapters/claude-code/ask-user-question.ts
140
448
  function readString(value) {
@@ -913,6 +1221,9 @@ function claudeTranscriptToImportPayload(records, options) {
913
1221
  }
914
1222
  if (minTs === void 0 || maxTs === void 0) return null;
915
1223
  if (derived.length === 0) return null;
1224
+ for (const observed of options.observedFiles ?? []) {
1225
+ relatedFiles.add(observed.path);
1226
+ }
916
1227
  derived.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at));
917
1228
  const events = [
918
1229
  sessionStartedEvent(minTs, placeholderSessionId),
@@ -1062,135 +1373,6 @@ async function resolveCodexCommand(lookup = isOnPath) {
1062
1373
  throw new Error("Codex CLI not found in PATH. Install codex first.");
1063
1374
  }
1064
1375
 
1065
- // src/adapters/codex/hooks-json.ts
1066
- var SESSION_START_HOOK_TIMEOUT_SECONDS = 30;
1067
- var SESSION_START_HOOK_MATCHER = "startup|resume|clear";
1068
- var SESSION_START_HOOK_CONTEXT_LIMIT = 0;
1069
- var SESSION_START_HOOK_STATUS_MESSAGE = "basou orient";
1070
- var BASOU_SESSION_START_HOOK = /(?:\bbasou|(?:@basou|packages)\/cli\/dist\/index\.js['"]?)\s+hook\s+session-start\b/;
1071
- function isBasouSessionStartHookCommand(command) {
1072
- return BASOU_SESSION_START_HOOK.test(command);
1073
- }
1074
- function shellQuote2(value) {
1075
- return `'${value.replace(/'/g, "'\\''")}'`;
1076
- }
1077
- function buildSessionStartHookCommand(options) {
1078
- return `node ${shellQuote2(options.cliEntry)} hook session-start 2>/dev/null || true`;
1079
- }
1080
- function isRecord2(value) {
1081
- return typeof value === "object" && value !== null && !Array.isArray(value);
1082
- }
1083
- function cloneHooksFile(hooksFile) {
1084
- if (hooksFile === void 0 || hooksFile === null) return {};
1085
- if (!isRecord2(hooksFile)) {
1086
- throw new Error("The Codex hooks.json is not a JSON object.");
1087
- }
1088
- return structuredClone(hooksFile);
1089
- }
1090
- function canonicalHandler(command) {
1091
- return {
1092
- type: "command",
1093
- command,
1094
- timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
1095
- statusMessage: SESSION_START_HOOK_STATUS_MESSAGE,
1096
- additionalContextLimit: SESSION_START_HOOK_CONTEXT_LIMIT
1097
- };
1098
- }
1099
- function handlerIsCanonical(entry, command) {
1100
- const want = canonicalHandler(command);
1101
- return Object.keys(want).every((k) => entry[k] === want[k]);
1102
- }
1103
- function upsertSessionStartHook(hooksFile, command) {
1104
- const root = cloneHooksFile(hooksFile);
1105
- if (root.hooks === void 0) {
1106
- root.hooks = {};
1107
- } else if (!isRecord2(root.hooks)) {
1108
- throw new Error("The 'hooks' key in the Codex hooks.json is not an object.");
1109
- }
1110
- const hooks = root.hooks;
1111
- if (hooks.SessionStart === void 0) {
1112
- hooks.SessionStart = [];
1113
- } else if (!Array.isArray(hooks.SessionStart)) {
1114
- throw new Error("The 'hooks.SessionStart' key in the Codex hooks.json is not an array.");
1115
- }
1116
- const groups = hooks.SessionStart;
1117
- for (const group of groups) {
1118
- if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
1119
- for (const entry of group.hooks) {
1120
- if (!isRecord2(entry)) continue;
1121
- if (typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
1122
- const unchanged = handlerIsCanonical(entry, command);
1123
- Object.assign(entry, canonicalHandler(command));
1124
- return { hooksFile: root, action: unchanged ? "unchanged" : "updated" };
1125
- }
1126
- }
1127
- }
1128
- groups.push({ matcher: SESSION_START_HOOK_MATCHER, hooks: [canonicalHandler(command)] });
1129
- return { hooksFile: root, action: "installed" };
1130
- }
1131
- function removeSessionStartHook(hooksFile) {
1132
- const root = cloneHooksFile(hooksFile);
1133
- if (!isRecord2(root.hooks) || !Array.isArray(root.hooks.SessionStart)) {
1134
- return { hooksFile: root, action: "absent" };
1135
- }
1136
- const hooks = root.hooks;
1137
- const groups = hooks.SessionStart;
1138
- let removed = false;
1139
- const kept = [];
1140
- for (const group of groups) {
1141
- if (!isRecord2(group) || !Array.isArray(group.hooks)) {
1142
- kept.push(group);
1143
- continue;
1144
- }
1145
- const keptHandlers = group.hooks.filter((entry) => {
1146
- if (isRecord2(entry) && typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
1147
- removed = true;
1148
- return false;
1149
- }
1150
- return true;
1151
- });
1152
- if (keptHandlers.length === group.hooks.length) {
1153
- kept.push(group);
1154
- } else if (keptHandlers.length > 0) {
1155
- group.hooks = keptHandlers;
1156
- kept.push(group);
1157
- }
1158
- }
1159
- if (!removed) return { hooksFile: root, action: "absent" };
1160
- if (kept.length === 0) {
1161
- delete hooks.SessionStart;
1162
- } else {
1163
- hooks.SessionStart = kept;
1164
- }
1165
- if (Object.keys(hooks).length === 0) {
1166
- delete root.hooks;
1167
- }
1168
- return { hooksFile: root, action: "removed" };
1169
- }
1170
- function findBasouSessionStartHook(hooksFile) {
1171
- if (!isRecord2(hooksFile) || !isRecord2(hooksFile.hooks) || !Array.isArray(hooksFile.hooks.SessionStart)) {
1172
- return null;
1173
- }
1174
- const groups = hooksFile.hooks.SessionStart;
1175
- for (let g = 0; g < groups.length; g++) {
1176
- const group = groups[g];
1177
- if (!isRecord2(group) || !Array.isArray(group.hooks)) continue;
1178
- for (let h = 0; h < group.hooks.length; h++) {
1179
- const entry = group.hooks[h];
1180
- if (isRecord2(entry) && typeof entry.command === "string" && isBasouSessionStartHookCommand(entry.command)) {
1181
- return {
1182
- command: entry.command,
1183
- groupIndex: g,
1184
- handlerIndex: h,
1185
- matcher: typeof group.matcher === "string" ? group.matcher : void 0,
1186
- handler: structuredClone(entry)
1187
- };
1188
- }
1189
- }
1190
- }
1191
- return null;
1192
- }
1193
-
1194
1376
  // src/schemas/observed-duration.ts
1195
1377
  function readObservedDuration(ev) {
1196
1378
  const stored = ev.duration_ms;
@@ -5619,20 +5801,41 @@ async function getDiff(repoRoot, baseRef, headRef) {
5619
5801
  let raw;
5620
5802
  try {
5621
5803
  raw = await git.raw(["diff", "--name-status", `${baseRef}..${headRef}`]);
5804
+ } catch (error) {
5805
+ throw translateDiffError(error);
5806
+ }
5807
+ return { changed_files: parseDiffNameStatus(raw) };
5808
+ }
5809
+ async function getChangesSince(repoRoot, baseRef) {
5810
+ let git;
5811
+ try {
5812
+ git = safeSimpleGit(repoRoot);
5622
5813
  } catch (error) {
5623
5814
  if (isGitNotFound(error)) {
5624
5815
  throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5625
5816
  }
5626
- const message = error instanceof Error ? error.message : "";
5627
- if (/not a git repository/i.test(message)) {
5628
- throw new Error("Not a git repository", { cause: error });
5629
- }
5630
- if (message.includes("bad revision") || message.includes("unknown revision") || message.includes("ambiguous argument")) {
5631
- throw new Error("Invalid ref", { cause: error });
5632
- }
5633
- throw new Error("Failed to compute git diff", { cause: error });
5817
+ throw new Error("Not a git repository", { cause: error });
5634
5818
  }
5635
- return { changed_files: parseDiffNameStatus(raw) };
5819
+ let raw;
5820
+ try {
5821
+ raw = await git.raw(["-c", "core.quotePath=false", "diff", "--name-status", baseRef]);
5822
+ } catch (error) {
5823
+ throw translateDiffError(error);
5824
+ }
5825
+ return parseDiffNameStatus(raw);
5826
+ }
5827
+ function translateDiffError(error) {
5828
+ if (isGitNotFound(error)) {
5829
+ return new Error("Git executable not found in PATH. Install git first.", { cause: error });
5830
+ }
5831
+ const message = error instanceof Error ? error.message : "";
5832
+ if (/not a git repository/i.test(message)) {
5833
+ return new Error("Not a git repository", { cause: error });
5834
+ }
5835
+ if (message.includes("bad revision") || message.includes("unknown revision") || message.includes("ambiguous argument") || message.includes("bad object") || message.includes("Invalid revision range")) {
5836
+ return new Error("Invalid ref", { cause: error });
5837
+ }
5838
+ return new Error("Failed to compute git diff", { cause: error });
5636
5839
  }
5637
5840
  function parseDiffNameStatus(raw) {
5638
5841
  const lines = raw.split("\n").filter((l) => l.trim() !== "");
@@ -5661,6 +5864,135 @@ function parseDiffNameStatus(raw) {
5661
5864
  return changes;
5662
5865
  }
5663
5866
 
5867
+ // src/git/working-tree.ts
5868
+ async function getWorkingTreeChanges(repoRoot) {
5869
+ let git;
5870
+ try {
5871
+ git = safeSimpleGit(repoRoot);
5872
+ } catch (error) {
5873
+ if (isGitNotFound(error)) {
5874
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5875
+ }
5876
+ throw new Error("Not a git repository", { cause: error });
5877
+ }
5878
+ let status;
5879
+ try {
5880
+ status = await git.status();
5881
+ } catch (error) {
5882
+ if (isGitNotFound(error)) {
5883
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5884
+ }
5885
+ const message = error instanceof Error ? error.message : "";
5886
+ if (/not a git repository/i.test(message)) {
5887
+ throw new Error("Not a git repository", { cause: error });
5888
+ }
5889
+ throw new Error("Failed to read git status", { cause: error });
5890
+ }
5891
+ const byPath = /* @__PURE__ */ new Map();
5892
+ const put = (change) => {
5893
+ if (!byPath.has(change.path)) byPath.set(change.path, change);
5894
+ };
5895
+ const conflicted = new Set(status.conflicted);
5896
+ for (const entry of status.renamed) {
5897
+ if (conflicted.has(entry.to)) continue;
5898
+ put({ path: entry.to, status: "renamed", old_path: entry.from });
5899
+ }
5900
+ for (const path2 of status.deleted) {
5901
+ if (conflicted.has(path2)) continue;
5902
+ put({ path: path2, status: "deleted" });
5903
+ }
5904
+ for (const path2 of [...status.created, ...status.not_added]) {
5905
+ if (conflicted.has(path2)) continue;
5906
+ put({ path: path2, status: "added" });
5907
+ }
5908
+ for (const path2 of status.modified) {
5909
+ if (conflicted.has(path2)) continue;
5910
+ put({ path: path2, status: "modified" });
5911
+ }
5912
+ return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
5913
+ }
5914
+ async function readHeadSha(repoRoot) {
5915
+ let git;
5916
+ try {
5917
+ git = safeSimpleGit(repoRoot);
5918
+ } catch (error) {
5919
+ if (isGitNotFound(error)) {
5920
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5921
+ }
5922
+ throw new Error("Not a git repository", { cause: error });
5923
+ }
5924
+ let inside;
5925
+ try {
5926
+ inside = await git.checkIsRepo();
5927
+ } catch (error) {
5928
+ if (isGitNotFound(error)) {
5929
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5930
+ }
5931
+ throw new Error("Failed to read git status", { cause: error });
5932
+ }
5933
+ if (!inside) throw new Error("Not a git repository");
5934
+ try {
5935
+ const head = (await git.revparse(["HEAD"])).trimEnd();
5936
+ return head.length > 0 ? head : null;
5937
+ } catch {
5938
+ return null;
5939
+ }
5940
+ }
5941
+ async function getUntrackedFiles(repoRoot) {
5942
+ let git;
5943
+ try {
5944
+ git = safeSimpleGit(repoRoot);
5945
+ } catch (error) {
5946
+ if (isGitNotFound(error)) {
5947
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5948
+ }
5949
+ throw new Error("Not a git repository", { cause: error });
5950
+ }
5951
+ let raw;
5952
+ try {
5953
+ raw = await git.raw([
5954
+ "-c",
5955
+ "core.quotePath=false",
5956
+ "ls-files",
5957
+ "--others",
5958
+ "--exclude-standard",
5959
+ "-z"
5960
+ ]);
5961
+ } catch (error) {
5962
+ if (isGitNotFound(error)) {
5963
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5964
+ }
5965
+ const message = error instanceof Error ? error.message : "";
5966
+ if (/not a git repository/i.test(message)) {
5967
+ throw new Error("Not a git repository", { cause: error });
5968
+ }
5969
+ throw new Error("Failed to read git status", { cause: error });
5970
+ }
5971
+ const changes = [];
5972
+ for (const path2 of raw.split("\0")) {
5973
+ if (path2.length === 0) continue;
5974
+ if (path2.endsWith("/")) continue;
5975
+ changes.push({ path: path2, status: "added" });
5976
+ }
5977
+ return changes.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
5978
+ }
5979
+ async function readEmptyTreeSha(repoRoot) {
5980
+ let git;
5981
+ try {
5982
+ git = safeSimpleGit(repoRoot);
5983
+ } catch (error) {
5984
+ if (isGitNotFound(error)) {
5985
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
5986
+ }
5987
+ throw new Error("Not a git repository", { cause: error });
5988
+ }
5989
+ try {
5990
+ return (await git.raw(["hash-object", "-t", "tree", "/dev/null"])).trimEnd();
5991
+ } catch (error) {
5992
+ throw new Error("Failed to read git status", { cause: error });
5993
+ }
5994
+ }
5995
+
5664
5996
  // src/handoff/handoff-renderer.ts
5665
5997
  import { join as join14 } from "path";
5666
5998
 
@@ -5670,13 +6002,36 @@ function isTrailingStale(latestActivityAt, recordedAt) {
5670
6002
  if (latestActivityAt === null) return false;
5671
6003
  return Date.parse(latestActivityAt) - Date.parse(recordedAt) > DECISION_TRAILING_ACTIVITY_GAP_MS;
5672
6004
  }
5673
- function pickLatestSubstantiveEntry(entries) {
5674
- return [...entries].sort((a, b) => {
5675
- const aSubstantive = (a.session.session.related_files?.length ?? 0) > 0 ? 1 : 0;
5676
- const bSubstantive = (b.session.session.related_files?.length ?? 0) > 0 ? 1 : 0;
5677
- if (aSubstantive !== bSubstantive) return bSubstantive - aSubstantive;
5678
- return Date.parse(b.session.session.started_at) - Date.parse(a.session.session.started_at);
5679
- })[0];
6005
+ var WRAPPER_SESSION_COMMAND_COUNT = 1;
6006
+ function pickLatestSubstantiveEntry(entries, commandCounts, unmeasured) {
6007
+ const didWork = (e) => {
6008
+ if ((e.session.session.related_files?.length ?? 0) > 0) return true;
6009
+ if (unmeasured.has(e.sessionId)) return true;
6010
+ return (commandCounts.get(e.sessionId) ?? 0) > WRAPPER_SESSION_COMMAND_COUNT;
6011
+ };
6012
+ const working = entries.filter(didWork);
6013
+ const outermost = working.filter((e) => !isNestedInAnother(e, working));
6014
+ const pool = outermost.length > 0 ? outermost : working.length > 0 ? working : entries;
6015
+ return [...pool].sort(
6016
+ (a, b) => Date.parse(b.session.session.started_at) - Date.parse(a.session.session.started_at)
6017
+ )[0];
6018
+ }
6019
+ function isNestedInAnother(entry, working) {
6020
+ const end = entry.session.session.ended_at;
6021
+ if (end === void 0) return false;
6022
+ const start = Date.parse(entry.session.session.started_at);
6023
+ const finish = Date.parse(end);
6024
+ if (!Number.isFinite(start) || !Number.isFinite(finish)) return false;
6025
+ return working.some((other) => {
6026
+ if (other.sessionId === entry.sessionId) return false;
6027
+ const otherEnd = other.session.session.ended_at;
6028
+ if (otherEnd === void 0) return false;
6029
+ const oStart = Date.parse(other.session.session.started_at);
6030
+ const oFinish = Date.parse(otherEnd);
6031
+ if (!Number.isFinite(oStart) || !Number.isFinite(oFinish)) return false;
6032
+ if (oStart > start || oFinish < finish) return false;
6033
+ return oStart < start || oFinish > finish;
6034
+ });
5680
6035
  }
5681
6036
 
5682
6037
  // src/lib/transient-paths.ts
@@ -5720,6 +6075,8 @@ async function renderHandoff(input) {
5720
6075
  const tasksCreated = [];
5721
6076
  const tasksStatusChanged = [];
5722
6077
  let latestActivityAt = null;
6078
+ const commandCounts = /* @__PURE__ */ new Map();
6079
+ const unmeasuredSessions = /* @__PURE__ */ new Set();
5723
6080
  const noteActivity = (iso) => {
5724
6081
  if (latestActivityAt === null || Date.parse(iso) > Date.parse(latestActivityAt)) {
5725
6082
  latestActivityAt = iso;
@@ -5750,6 +6107,8 @@ async function renderHandoff(input) {
5750
6107
  sessionId: entry.sessionId
5751
6108
  });
5752
6109
  }
6110
+ } else if (ev.type === "command_executed") {
6111
+ commandCounts.set(entry.sessionId, (commandCounts.get(entry.sessionId) ?? 0) + 1);
5753
6112
  } else if (ev.type === "decision_voided") {
5754
6113
  voidedDecisionIds.add(ev.decision_id);
5755
6114
  } else if (ev.type === "task_created") {
@@ -5768,6 +6127,7 @@ async function renderHandoff(input) {
5768
6127
  }
5769
6128
  }
5770
6129
  } catch {
6130
+ unmeasuredSessions.add(entry.sessionId);
5771
6131
  if (!unreadableEmitted.has(entry.sessionId)) {
5772
6132
  wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
5773
6133
  }
@@ -5821,7 +6181,7 @@ async function renderHandoff(input) {
5821
6181
  const liveEntries = entries.filter(
5822
6182
  (e) => e.session.session.status !== "archived" && e.session.session.source.kind !== "import"
5823
6183
  );
5824
- const latestSession = pickLatestSubstantiveEntry(liveEntries);
6184
+ const latestSession = pickLatestSubstantiveEntry(liveEntries, commandCounts, unmeasuredSessions);
5825
6185
  const latestFiles = (latestSession?.session.session.related_files ?? []).filter(
5826
6186
  (file) => !isTransientToolPath(file)
5827
6187
  );
@@ -6070,7 +6430,7 @@ function parseBuildStamp(raw) {
6070
6430
  }
6071
6431
  }
6072
6432
  var BASOU_CORE_BUILD = parseBuildStamp(
6073
- true ? '{"version":"0.48.1","commit":"bb3398c","committedAt":"2026-09-21T21:38:14+09:00"}' : void 0
6433
+ true ? '{"version":"0.50.0","commit":"5b4c5dc","committedAt":"2026-09-23T19:12:06+09:00"}' : void 0
6074
6434
  );
6075
6435
 
6076
6436
  // src/lib/duration.ts
@@ -6280,6 +6640,8 @@ async function summarizeOrientation(input) {
6280
6640
  let latestActivityAt = null;
6281
6641
  let taskCreatedSeen = false;
6282
6642
  let latestNote = null;
6643
+ const commandCounts = /* @__PURE__ */ new Map();
6644
+ const unmeasuredSessions = /* @__PURE__ */ new Set();
6283
6645
  const noteActivity = (iso) => {
6284
6646
  if (latestActivityAt === null || Date.parse(iso) > Date.parse(latestActivityAt)) {
6285
6647
  latestActivityAt = iso;
@@ -6329,6 +6691,8 @@ async function summarizeOrientation(input) {
6329
6691
  taskCreatedSeen = true;
6330
6692
  } else if (ev.type === "decision_voided") {
6331
6693
  voidedDecisionIds.add(ev.decision_id);
6694
+ } else if (ev.type === "command_executed") {
6695
+ commandCounts.set(entry.sessionId, (commandCounts.get(entry.sessionId) ?? 0) + 1);
6332
6696
  }
6333
6697
  if (counted && ev.type === "note_added" && ev.kind === "next_step") {
6334
6698
  recordDirection(entry.sessionId, "note", ev.body);
@@ -6344,6 +6708,7 @@ async function summarizeOrientation(input) {
6344
6708
  if (counted) noteActivity(ev.occurred_at);
6345
6709
  }
6346
6710
  } catch {
6711
+ unmeasuredSessions.add(entry.sessionId);
6347
6712
  input.onSessionSkip?.(entry.sessionId, "events_jsonl_unreadable");
6348
6713
  }
6349
6714
  }
@@ -6437,7 +6802,7 @@ async function summarizeOrientation(input) {
6437
6802
  const liveEntries = entries.filter(
6438
6803
  (e) => e.session.session.status !== "archived" && e.session.session.source.kind !== "import"
6439
6804
  );
6440
- const latestEntry = pickLatestSubstantiveEntry(liveEntries);
6805
+ const latestEntry = pickLatestSubstantiveEntry(liveEntries, commandCounts, unmeasuredSessions);
6441
6806
  const latestSession = latestEntry !== void 0 ? {
6442
6807
  sessionId: latestEntry.sessionId,
6443
6808
  label: latestEntry.session.session.label ?? null,
@@ -9486,7 +9851,7 @@ var ChildProcessRunner = class {
9486
9851
  if (killTimer !== null) clearTimeout(killTimer);
9487
9852
  options.signal?.removeEventListener("abort", onAbort);
9488
9853
  };
9489
- return new Promise((resolve4, reject) => {
9854
+ return new Promise((resolve5, reject) => {
9490
9855
  child.once("error", (error) => {
9491
9856
  if (settled) return;
9492
9857
  settled = true;
@@ -9498,7 +9863,7 @@ var ChildProcessRunner = class {
9498
9863
  settled = true;
9499
9864
  cleanup();
9500
9865
  const ended_at = /* @__PURE__ */ new Date();
9501
- resolve4({
9866
+ resolve5({
9502
9867
  command: snapshotCommand,
9503
9868
  args: snapshotArgs,
9504
9869
  cwd: snapshotCwd,
@@ -9613,30 +9978,190 @@ function serializeJsonSchema(schema) {
9613
9978
  `;
9614
9979
  }
9615
9980
 
9616
- // src/storage/basou-dir.ts
9617
- import { lstat as lstat4, mkdir as mkdir4 } from "fs/promises";
9981
+ // src/session/observation.ts
9982
+ import { mkdir as mkdir4, readFile as readFile10 } from "fs/promises";
9618
9983
  import { join as join20 } from "path";
9984
+ import { z as z12 } from "zod";
9985
+ var SESSION_OBSERVATION_SCHEMA_VERSION = "0.1.0";
9986
+ var ObservedFileSchema = z12.object({
9987
+ /** Absolute path, matching the paths the transcript importer records. */
9988
+ path: z12.string().min(1),
9989
+ change_type: z12.enum(["added", "modified", "deleted", "renamed"]),
9990
+ old_path: z12.string().min(1).optional()
9991
+ });
9992
+ var ObservedRepoSchema = z12.object({
9993
+ /** Absolute path of the git repository root this entry speaks for. */
9994
+ path: z12.string().min(1),
9995
+ /**
9996
+ * `HEAD` as it stood when the session started. `null` when the repository
9997
+ * had no commits yet (a fresh `git init`), in which case only working-tree
9998
+ * changes are observable.
9999
+ */
10000
+ base_head: z12.string().nullable(),
10001
+ /**
10002
+ * Paths already dirty at session start. They are SUBTRACTED from the
10003
+ * working-tree observation: a file the operator left modified before the
10004
+ * session opened was not changed BY this session, and attributing it would
10005
+ * make the first session after any interrupted work claim the interruption.
10006
+ */
10007
+ base_dirty: z12.array(z12.string()).default([]),
10008
+ /** Latest full recomputation for this repository (see `observeSession`). */
10009
+ files: z12.array(ObservedFileSchema).default([])
10010
+ });
10011
+ var SessionObservationSchema = z12.object({
10012
+ schema_version: z12.string().min(1),
10013
+ external_id: z12.string().min(1),
10014
+ started_at: z12.string().min(1),
10015
+ updated_at: z12.string().min(1),
10016
+ repos: z12.array(ObservedRepoSchema).default([])
10017
+ });
10018
+ var SAFE_EXTERNAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10019
+ function sessionObservationPath(observationsDir, externalId) {
10020
+ if (!SAFE_EXTERNAL_ID.test(externalId)) return null;
10021
+ if (externalId === "." || externalId === "..") return null;
10022
+ return join20(observationsDir, `${externalId}.json`);
10023
+ }
10024
+ async function readSessionObservation(observationsDir, externalId) {
10025
+ const file = sessionObservationPath(observationsDir, externalId);
10026
+ if (file === null) return null;
10027
+ let raw;
10028
+ try {
10029
+ raw = await readFile10(file, "utf8");
10030
+ } catch {
10031
+ return null;
10032
+ }
10033
+ let parsed;
10034
+ try {
10035
+ parsed = JSON.parse(raw);
10036
+ } catch {
10037
+ return null;
10038
+ }
10039
+ const result = SessionObservationSchema.safeParse(parsed);
10040
+ if (!result.success) return null;
10041
+ if (result.data.schema_version !== SESSION_OBSERVATION_SCHEMA_VERSION) return null;
10042
+ return result.data;
10043
+ }
10044
+ async function writeSessionObservation(observationsDir, observation) {
10045
+ const file = sessionObservationPath(observationsDir, observation.external_id);
10046
+ if (file === null) return;
10047
+ await mkdir4(observationsDir, { recursive: true });
10048
+ await atomicReplace(file, `${JSON.stringify(observation, null, 2)}
10049
+ `);
10050
+ }
10051
+ function observedFilesOf(observation) {
10052
+ const files = [];
10053
+ for (const repo of observation.repos) files.push(...repo.files);
10054
+ return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10055
+ }
10056
+ function observedFileFrom(repoRoot, change) {
10057
+ return {
10058
+ path: join20(repoRoot, change.path),
10059
+ change_type: change.status,
10060
+ ...change.old_path !== void 0 ? { old_path: join20(repoRoot, change.old_path) } : {}
10061
+ };
10062
+ }
10063
+
10064
+ // src/session/observe.ts
10065
+ import { resolve as resolve4 } from "path";
10066
+ function observedRepoRoots(root, manifest) {
10067
+ const declared = manifest.repos ?? [];
10068
+ const roots = declared.length > 0 ? declared.map((repo) => resolve4(root, repo.path)) : [root];
10069
+ return [...new Set(roots)];
10070
+ }
10071
+ async function recordSessionBaseline(input) {
10072
+ const existing = await readSessionObservation(input.observationsDir, input.externalId);
10073
+ if (existing !== null) return existing;
10074
+ const repos = [];
10075
+ for (const repoRoot of input.repoRoots) {
10076
+ let baseHead;
10077
+ try {
10078
+ baseHead = await readHeadSha(repoRoot);
10079
+ } catch {
10080
+ continue;
10081
+ }
10082
+ let baseDirty = [];
10083
+ try {
10084
+ baseDirty = (await getWorkingTreeChanges(repoRoot)).map(
10085
+ (change) => observedFileFrom(repoRoot, change).path
10086
+ );
10087
+ } catch {
10088
+ }
10089
+ repos.push({ path: repoRoot, base_head: baseHead, base_dirty: baseDirty, files: [] });
10090
+ }
10091
+ if (repos.length === 0) return null;
10092
+ const observation = {
10093
+ schema_version: SESSION_OBSERVATION_SCHEMA_VERSION,
10094
+ external_id: input.externalId,
10095
+ started_at: input.nowIso,
10096
+ updated_at: input.nowIso,
10097
+ repos
10098
+ };
10099
+ await writeSessionObservation(input.observationsDir, observation);
10100
+ return observation;
10101
+ }
10102
+ async function observeSessionChanges(input) {
10103
+ const existing = await readSessionObservation(input.observationsDir, input.externalId);
10104
+ if (existing === null) return null;
10105
+ const repos = [];
10106
+ for (const repo of existing.repos) {
10107
+ let files;
10108
+ try {
10109
+ files = await changedSinceBaseline(repo);
10110
+ } catch {
10111
+ repos.push(repo);
10112
+ continue;
10113
+ }
10114
+ repos.push({ ...repo, files });
10115
+ }
10116
+ const updated = { ...existing, updated_at: input.nowIso, repos };
10117
+ await writeSessionObservation(input.observationsDir, updated);
10118
+ return updated;
10119
+ }
10120
+ function isBasouStorePath(relativePath) {
10121
+ return relativePath === ".basou" || relativePath.startsWith(".basou/");
10122
+ }
10123
+ async function changedSinceBaseline(repo) {
10124
+ const byPath = /* @__PURE__ */ new Map();
10125
+ const base = repo.base_head ?? await readEmptyTreeSha(repo.path);
10126
+ for (const change of await getChangesSince(repo.path, base)) {
10127
+ if (isBasouStorePath(change.path)) continue;
10128
+ const file = observedFileFrom(repo.path, change);
10129
+ byPath.set(file.path, file);
10130
+ }
10131
+ for (const change of await getUntrackedFiles(repo.path)) {
10132
+ if (isBasouStorePath(change.path)) continue;
10133
+ const file = observedFileFrom(repo.path, change);
10134
+ if (!byPath.has(file.path)) byPath.set(file.path, file);
10135
+ }
10136
+ const preexisting = new Set(repo.base_dirty);
10137
+ return [...byPath.values()].filter((file) => !preexisting.has(file.path)).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10138
+ }
10139
+
10140
+ // src/storage/basou-dir.ts
10141
+ import { lstat as lstat4, mkdir as mkdir5 } from "fs/promises";
10142
+ import { join as join21 } from "path";
9619
10143
  function basouPaths(repositoryRoot) {
9620
- const root = join20(repositoryRoot, ".basou");
9621
- const approvalsBase = join20(root, "approvals");
10144
+ const root = join21(repositoryRoot, ".basou");
10145
+ const approvalsBase = join21(root, "approvals");
9622
10146
  return {
9623
10147
  root,
9624
- sessions: join20(root, "sessions"),
9625
- tasks: join20(root, "tasks"),
10148
+ sessions: join21(root, "sessions"),
10149
+ tasks: join21(root, "tasks"),
9626
10150
  approvals: {
9627
- pending: join20(approvalsBase, "pending"),
9628
- resolved: join20(approvalsBase, "resolved")
10151
+ pending: join21(approvalsBase, "pending"),
10152
+ resolved: join21(approvalsBase, "resolved")
9629
10153
  },
9630
- locks: join20(root, "locks"),
9631
- logs: join20(root, "logs"),
9632
- raw: join20(root, "raw"),
9633
- tmp: join20(root, "tmp"),
10154
+ locks: join21(root, "locks"),
10155
+ logs: join21(root, "logs"),
10156
+ raw: join21(root, "raw"),
10157
+ tmp: join21(root, "tmp"),
10158
+ observations: join21(root, "tmp", "observations"),
9634
10159
  files: {
9635
- manifest: join20(root, "manifest.yaml"),
9636
- status: join20(root, "status.json"),
9637
- handoff: join20(root, "handoff.md"),
9638
- decisions: join20(root, "decisions.md"),
9639
- orientation: join20(root, "orientation.md")
10160
+ manifest: join21(root, "manifest.yaml"),
10161
+ status: join21(root, "status.json"),
10162
+ handoff: join21(root, "handoff.md"),
10163
+ decisions: join21(root, "decisions.md"),
10164
+ orientation: join21(root, "orientation.md")
9640
10165
  }
9641
10166
  };
9642
10167
  }
@@ -9677,7 +10202,7 @@ async function ensureBasouDirectory(repositoryRoot) {
9677
10202
  }
9678
10203
  async function mkdirLabeled(target, label) {
9679
10204
  try {
9680
- await mkdir4(target, { recursive: true });
10205
+ await mkdir5(target, { recursive: true });
9681
10206
  } catch (error) {
9682
10207
  if (hasErrorCode5(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
9683
10208
  throw new Error(`${label} exists but is not a directory`, { cause: error });
@@ -9692,17 +10217,17 @@ function hasErrorCode5(error) {
9692
10217
  }
9693
10218
 
9694
10219
  // src/storage/gitignore.ts
9695
- import { readFile as readFile10, writeFile as writeFile2 } from "fs/promises";
9696
- import { join as join21 } from "path";
10220
+ import { readFile as readFile11, writeFile as writeFile2 } from "fs/promises";
10221
+ import { join as join22 } from "path";
9697
10222
  var MARKER = "# Basou - default ignore";
9698
10223
  var BASOU_GITIGNORE_BLOCK = "# Basou - default ignore\n.basou/logs/\n.basou/raw/\n.basou/tmp/\n.basou/locks/\n.basou/status.json\n.basou/orientation.md\n.basou/sessions/*/events.jsonl\n.basou/sessions/*/artifacts/\n.basou/approvals/pending/\n.basou/approvals/resolved/\n\n# Basou - default commit\n# .basou/manifest.yaml\n# .basou/handoff.md\n# .basou/decisions.md\n# .basou/tasks/\n# .basou/sessions/*/session.yaml\n# .basou/sessions/*/transcript.md\n# .basou/sessions/*/changed-files.json\n";
9699
10224
  var BASOU_GITIGNORE_BLOCK_LOCAL_ONLY = "# Basou - default ignore\n# Local-only: basou's trail is never committed (personal/local state,\n# regenerable by re-importing from the agents' own logs). Recommended for\n# monitored repos and any workspace kept out of version control.\n.basou/\n";
9700
10225
  async function appendBasouGitignore(repositoryRoot, options = {}) {
9701
- const gitignorePath = join21(repositoryRoot, ".gitignore");
10226
+ const gitignorePath = join22(repositoryRoot, ".gitignore");
9702
10227
  let body;
9703
10228
  let existed;
9704
10229
  try {
9705
- body = await readFile10(gitignorePath, "utf8");
10230
+ body = await readFile11(gitignorePath, "utf8");
9706
10231
  existed = true;
9707
10232
  } catch (error) {
9708
10233
  if (hasErrorCode6(error) && error.code === "ENOENT") {
@@ -9745,9 +10270,9 @@ function hasErrorCode6(error) {
9745
10270
  }
9746
10271
 
9747
10272
  // src/storage/session-import.ts
9748
- import { mkdir as mkdir5, readFile as readFile11, rm as rm2 } from "fs/promises";
10273
+ import { mkdir as mkdir6, readFile as readFile12, rm as rm2 } from "fs/promises";
9749
10274
  import { homedir as homedir4 } from "os";
9750
- import { join as join22 } from "path";
10275
+ import { join as join23 } from "path";
9751
10276
  async function importSessionFromJson(paths, manifest, payload, options) {
9752
10277
  if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
9753
10278
  throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
@@ -9772,9 +10297,9 @@ async function importSessionFromJson(paths, manifest, payload, options) {
9772
10297
  pathSanitizeReport
9773
10298
  };
9774
10299
  }
9775
- const sessionDir = join22(paths.sessions, newSessionId);
10300
+ const sessionDir = join23(paths.sessions, newSessionId);
9776
10301
  try {
9777
- await mkdir5(sessionDir, { recursive: true });
10302
+ await mkdir6(sessionDir, { recursive: true });
9778
10303
  } catch (error) {
9779
10304
  throw new Error("Failed to create session directory", { cause: error });
9780
10305
  }
@@ -9786,7 +10311,7 @@ async function importSessionFromJson(paths, manifest, payload, options) {
9786
10311
  throw error;
9787
10312
  }
9788
10313
  try {
9789
- const sessionYamlPath = join22(sessionDir, "session.yaml");
10314
+ const sessionYamlPath = join23(sessionDir, "session.yaml");
9790
10315
  await linkYamlFile(sessionYamlPath, withIntegrity(sessionRecord, chainResult));
9791
10316
  } catch (error) {
9792
10317
  await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
@@ -9954,7 +10479,7 @@ function reuseDerivedIds(priorDerived, freshDerived, sessionId) {
9954
10479
  async function reimportPreservingId(paths, manifest, priorSessionId, freshPayload, options = {}) {
9955
10480
  const sessionId = priorSessionId;
9956
10481
  const importSource = freshPayload.session.source.kind;
9957
- const sessionDir = join22(paths.sessions, priorSessionId);
10482
+ const sessionDir = join23(paths.sessions, priorSessionId);
9958
10483
  const lock = options.dryRun === true ? null : await acquireLock(paths, "session", priorSessionId);
9959
10484
  try {
9960
10485
  const priorVerdict = await verifyEventsChain(paths, priorSessionId);
@@ -9999,10 +10524,10 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
9999
10524
  session: preservedInner
10000
10525
  };
10001
10526
  if (options.dryRun !== true) {
10002
- const eventsPath = join22(sessionDir, "events.jsonl");
10527
+ const eventsPath = join23(sessionDir, "events.jsonl");
10003
10528
  let priorEventsRaw = null;
10004
10529
  try {
10005
- priorEventsRaw = await readFile11(eventsPath);
10530
+ priorEventsRaw = await readFile12(eventsPath);
10006
10531
  } catch (error) {
10007
10532
  if (!findErrorCode(error, "ENOENT")) {
10008
10533
  throw new Error("Failed to read events.jsonl", { cause: error });
@@ -10011,7 +10536,7 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
10011
10536
  const chainResult = await writeEventsBulk(sessionDir, mergedEvents, { chain: true });
10012
10537
  try {
10013
10538
  await overwriteYamlFile(
10014
- join22(sessionDir, "session.yaml"),
10539
+ join23(sessionDir, "session.yaml"),
10015
10540
  withIntegrity(updatedRecord, chainResult)
10016
10541
  );
10017
10542
  } catch (error) {
@@ -10035,7 +10560,7 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
10035
10560
  }
10036
10561
  }
10037
10562
  async function rechainSessionInPlace(paths, sessionId, options = {}) {
10038
- const sessionDir = join22(paths.sessions, sessionId);
10563
+ const sessionDir = join23(paths.sessions, sessionId);
10039
10564
  let lock;
10040
10565
  try {
10041
10566
  lock = await acquireLock(paths, "session", sessionId);
@@ -10068,10 +10593,10 @@ async function rechainSessionInPlace(paths, sessionId, options = {}) {
10068
10593
  if (verdict.status !== "unchained") {
10069
10594
  return { status: "skipped", reason: "tampered" };
10070
10595
  }
10071
- const eventsPath = join22(sessionDir, "events.jsonl");
10596
+ const eventsPath = join23(sessionDir, "events.jsonl");
10072
10597
  let priorRaw;
10073
10598
  try {
10074
- priorRaw = await readFile11(eventsPath);
10599
+ priorRaw = await readFile12(eventsPath);
10075
10600
  } catch (error) {
10076
10601
  throw new Error("Failed to read events.jsonl", { cause: error });
10077
10602
  }
@@ -10116,7 +10641,7 @@ async function rechainSessionInPlace(paths, sessionId, options = {}) {
10116
10641
  }
10117
10642
  try {
10118
10643
  await overwriteYamlFile(
10119
- join22(sessionDir, "session.yaml"),
10644
+ join23(sessionDir, "session.yaml"),
10120
10645
  withIntegrity(record, { headHash: chainResult.headHash, count: chainResult.count })
10121
10646
  );
10122
10647
  } catch (error) {
@@ -10167,6 +10692,7 @@ export {
10167
10692
  REVIEW_RECORD_NO_INPUT_HINT,
10168
10693
  RiskLevelSchema,
10169
10694
  SESSION_IMPORT_SCHEMA_VERSION,
10695
+ SESSION_OBSERVATION_SCHEMA_VERSION,
10170
10696
  SESSION_SCHEMA_VERSION,
10171
10697
  SESSION_START_HOOK_CONTEXT_LIMIT,
10172
10698
  SESSION_START_HOOK_MATCHER,
@@ -10231,20 +10757,27 @@ export {
10231
10757
  finalizeSessionYaml,
10232
10758
  findBasouSessionStartHook,
10233
10759
  findBasouStopHookCommand,
10760
+ findClaudeSessionStartHooks,
10234
10761
  findDecisionGaps,
10235
10762
  findErrorCode,
10236
10763
  findReviewGaps,
10237
10764
  findUnbindableRepos,
10765
+ findUnrecognizedSessionStart,
10238
10766
  formatDurationMs,
10239
10767
  genesisHash,
10768
+ getChangesSince,
10240
10769
  getDiff,
10241
10770
  getSnapshot,
10771
+ getWorkingTreeChanges,
10242
10772
  hasRetiredZeroDuration,
10243
10773
  importSessionFromJson,
10244
10774
  inspectChainTail,
10245
10775
  instructionMode,
10776
+ isBasouOrientSessionStartCommand,
10246
10777
  isBasouSessionStartHookCommand,
10247
10778
  isBasouStopHookCommand,
10779
+ isClaudeSessionStartHookCommand,
10780
+ isClaudeSessionStartMalformed,
10248
10781
  isGitNotFound,
10249
10782
  isImportDerivedSource,
10250
10783
  isLazyExpired,
@@ -10259,6 +10792,9 @@ export {
10259
10792
  loadTaskEntries,
10260
10793
  normalizeRepoKey,
10261
10794
  normalizeRepoPath,
10795
+ observeSessionChanges,
10796
+ observedFilesOf,
10797
+ observedRepoRoots,
10262
10798
  overwriteYamlFile,
10263
10799
  parseBuildStamp,
10264
10800
  parseDuration,
@@ -10277,9 +10813,11 @@ export {
10277
10813
  protocolSectionsFrom,
10278
10814
  protocolUpdateToken,
10279
10815
  readAllEvents,
10816
+ readHeadSha,
10280
10817
  readManifest,
10281
10818
  readMarkdownFile,
10282
10819
  readObservedDuration,
10820
+ readSessionObservation,
10283
10821
  readSessionYaml,
10284
10822
  readStatus,
10285
10823
  readTaskFile,
@@ -10289,8 +10827,10 @@ export {
10289
10827
  reconcileAllTasks,
10290
10828
  reconcileSourceRoots,
10291
10829
  reconcileTask,
10830
+ recordSessionBaseline,
10292
10831
  refreshTaskLinkedSessions,
10293
10832
  reimportPreservingId,
10833
+ removeClaudeSessionStartHook,
10294
10834
  removeMarkerSection,
10295
10835
  removeSessionStartHook,
10296
10836
  removeStopHook,
@@ -10323,6 +10863,7 @@ export {
10323
10863
  seedMarkers,
10324
10864
  serializeEventLine,
10325
10865
  serializeJsonSchema,
10866
+ sessionObservationPath,
10326
10867
  sessionWorkStatsFromEvents,
10327
10868
  summarizeAdapterOutput,
10328
10869
  summarizeOrientation,
@@ -10337,6 +10878,7 @@ export {
10337
10878
  unknownManifestKeys,
10338
10879
  unstampedProtocolSectionsFrom,
10339
10880
  updateTaskStatusWithEvent,
10881
+ upsertClaudeSessionStartHook,
10340
10882
  upsertSessionStartHook,
10341
10883
  upsertStopHook,
10342
10884
  verifyEventsChain,
@@ -10345,6 +10887,7 @@ export {
10345
10887
  writeManifest,
10346
10888
  writeMarkdownFile,
10347
10889
  writeObservedDuration,
10890
+ writeSessionObservation,
10348
10891
  writeStatus,
10349
10892
  writeTaskFile,
10350
10893
  writeYamlFile