@basou/core 0.3.1 → 0.5.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,3 +1,26 @@
1
+ // src/adapters/claude-code/claude-code-adapter.ts
2
+ import { spawn } from "child_process";
3
+ var claudeCodeAdapterMetadata = {
4
+ kind: "claude-code-adapter",
5
+ version: "0.1.0"
6
+ };
7
+ async function resolveClaudeCodeCommand(lookup = isOnPath) {
8
+ for (const candidate of ["claude-code", "claude"]) {
9
+ if (await lookup(candidate)) return { command: candidate };
10
+ }
11
+ throw new Error("Claude Code CLI not found in PATH. Install claude-code (or claude) first.");
12
+ }
13
+ async function isOnPath(command) {
14
+ return new Promise((resolve2) => {
15
+ const child = spawn("which", [command], { stdio: "ignore" });
16
+ child.on("error", () => resolve2(false));
17
+ child.on("exit", (code) => resolve2(code === 0));
18
+ });
19
+ }
20
+ function summarizeAdapterOutput(_stream, _raw) {
21
+ throw new Error("adapter_output summary is not implemented in this release");
22
+ }
23
+
1
24
  // src/ids/ulid.ts
2
25
  import { isValid as isValidUlid, monotonicFactory } from "ulid";
3
26
  var ID_PREFIXES = Object.freeze(["ws", "task", "ses", "evt", "appr", "decision"]);
@@ -23,6 +46,500 @@ function isValidPrefixedId(value) {
23
46
  return isValidUlid(ulidPart);
24
47
  }
25
48
 
49
+ // src/stats/active-time.ts
50
+ var ACTIVE_GAP_CAP_MS = 5 * 60 * 1e3;
51
+ var ENGAGED_TURNS_METHOD = "engaged-turns";
52
+ function activeTimeFromTimestamps(timestampsMs, capMs) {
53
+ const sorted = timestampsMs.filter((t) => Number.isFinite(t)).sort((a, b) => a - b);
54
+ const raw = [];
55
+ for (let i = 1; i < sorted.length; i++) {
56
+ const prev = sorted[i - 1];
57
+ const curr = sorted[i];
58
+ if (prev === void 0 || curr === void 0) continue;
59
+ const gap = curr - prev;
60
+ if (gap <= 0) continue;
61
+ raw.push([prev, prev + Math.min(gap, capMs)]);
62
+ }
63
+ const intervals = mergeIntervals(raw);
64
+ return { ms: sumDurations(intervals), intervals };
65
+ }
66
+ function mergeIntervals(intervals) {
67
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
68
+ const merged = [];
69
+ for (const [start, end] of sorted) {
70
+ const last = merged[merged.length - 1];
71
+ if (last !== void 0 && start <= last[1]) {
72
+ if (end > last[1]) last[1] = end;
73
+ } else {
74
+ merged.push([start, end]);
75
+ }
76
+ }
77
+ return merged;
78
+ }
79
+ function unionDurationMs(intervals) {
80
+ const merged = mergeIntervals(intervals);
81
+ return { ms: sumDurations(merged), merged };
82
+ }
83
+ function intervalsMsToIso(intervals) {
84
+ return intervals.map(([start, end]) => ({
85
+ start: new Date(start).toISOString(),
86
+ end: new Date(end).toISOString()
87
+ }));
88
+ }
89
+ function intervalsIsoToMs(intervals) {
90
+ const out = [];
91
+ for (const { start, end } of intervals) {
92
+ const s = Date.parse(start);
93
+ const e = Date.parse(end);
94
+ if (Number.isFinite(s) && Number.isFinite(e) && e >= s) out.push([s, e]);
95
+ }
96
+ return out;
97
+ }
98
+ function sumDurations(intervals) {
99
+ let total = 0;
100
+ for (const [start, end] of intervals) total += end - start;
101
+ return total;
102
+ }
103
+
104
+ // src/adapters/claude-code/transcript-importer.ts
105
+ var CLAUDE_IMPORT_SOURCE = "claude-code-import";
106
+ function claudeTranscriptToImportPayload(records, options) {
107
+ const placeholderSessionId = prefixedUlid("ses");
108
+ const askAnswers = indexAskAnswers(records);
109
+ const derived = [];
110
+ const relatedFiles = /* @__PURE__ */ new Set();
111
+ let minTs;
112
+ let maxTs;
113
+ let workingDir;
114
+ let claudeSessionId;
115
+ let outputTokens = 0;
116
+ let inputTokens = 0;
117
+ let cachedInputTokens = 0;
118
+ const seenMessageIds = /* @__PURE__ */ new Set();
119
+ const engagementTsMs = [];
120
+ const seenEngagementMessageIds = /* @__PURE__ */ new Set();
121
+ for (const record of records) {
122
+ const ts = readString(record.timestamp);
123
+ if (ts === void 0) continue;
124
+ if (minTs === void 0 || Date.parse(ts) < Date.parse(minTs)) minTs = ts;
125
+ if (maxTs === void 0 || Date.parse(ts) > Date.parse(maxTs)) maxTs = ts;
126
+ if (workingDir === void 0) workingDir = readString(record.cwd);
127
+ if (claudeSessionId === void 0) claudeSessionId = readString(record.sessionId);
128
+ if (record.isSidechain !== true) {
129
+ const tsMs = Date.parse(ts);
130
+ if (Number.isFinite(tsMs)) {
131
+ const recType = readString(record.type);
132
+ if (recType === "user") {
133
+ if (isHumanUserMessage(record)) engagementTsMs.push(tsMs);
134
+ } else if (recType === "assistant") {
135
+ const msg = isObject(record.message) ? record.message : void 0;
136
+ const mid = msg !== void 0 ? readString(msg.id) : void 0;
137
+ if (mid === void 0 || !seenEngagementMessageIds.has(mid)) {
138
+ if (mid !== void 0) seenEngagementMessageIds.add(mid);
139
+ engagementTsMs.push(tsMs);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ if (readString(record.type) !== "assistant") continue;
145
+ const message = isObject(record.message) ? record.message : void 0;
146
+ const usage = message !== void 0 && isObject(message.usage) ? message.usage : void 0;
147
+ if (usage !== void 0) {
148
+ const messageId = message !== void 0 ? readString(message.id) : void 0;
149
+ const alreadyCounted = messageId !== void 0 && seenMessageIds.has(messageId);
150
+ if (!alreadyCounted) {
151
+ if (messageId !== void 0) seenMessageIds.add(messageId);
152
+ outputTokens += readNonNegInt(usage.output_tokens);
153
+ inputTokens += readNonNegInt(usage.input_tokens);
154
+ cachedInputTokens += readNonNegInt(usage.cache_read_input_tokens);
155
+ }
156
+ }
157
+ const cwd = readString(record.cwd) ?? workingDir ?? ".";
158
+ for (const item of toolUses(record)) {
159
+ const name = readString(item.name);
160
+ const input = isObject(item.input) ? item.input : void 0;
161
+ if (input === void 0) continue;
162
+ if (name === "Bash") {
163
+ const command = readString(input.command);
164
+ if (command !== void 0) {
165
+ derived.push(commandExecutedEvent(ts, placeholderSessionId, command, cwd));
166
+ }
167
+ continue;
168
+ }
169
+ if (name === "AskUserQuestion") {
170
+ const useId = readString(item.id);
171
+ const answers = useId !== void 0 ? askAnswers.get(useId) : void 0;
172
+ if (answers !== void 0) {
173
+ for (const [question, answer] of Object.entries(answers)) {
174
+ if (question.length === 0) continue;
175
+ const answerStr = typeof answer === "string" && answer.length > 0 ? answer : void 0;
176
+ const title = answerStr !== void 0 ? `${question} -> ${answerStr}` : question;
177
+ derived.push(decisionRecordedEvent(ts, placeholderSessionId, title));
178
+ }
179
+ }
180
+ continue;
181
+ }
182
+ if (name === "Edit" || name === "Write" || name === "NotebookEdit") {
183
+ const path2 = readString(input.file_path) ?? readString(input.notebook_path);
184
+ if (path2 !== void 0) {
185
+ const changeType = name === "Write" ? "added" : "modified";
186
+ relatedFiles.add(path2);
187
+ derived.push(fileChangedEvent(ts, placeholderSessionId, path2, changeType));
188
+ }
189
+ }
190
+ }
191
+ }
192
+ if (minTs === void 0 || maxTs === void 0) return null;
193
+ if (derived.length === 0) return null;
194
+ derived.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at));
195
+ const events = [
196
+ sessionStartedEvent(minTs, placeholderSessionId),
197
+ ...derived,
198
+ sessionEndedEvent(maxTs, placeholderSessionId)
199
+ ];
200
+ const externalId = options.externalId ?? claudeSessionId;
201
+ const commandCount = derived.reduce((n, e) => e.type === "command_executed" ? n + 1 : n, 0);
202
+ const fileCount = relatedFiles.size;
203
+ const date = minTs.slice(0, 10);
204
+ const label = `claude-code ${date}: ${commandCount} ${commandCount === 1 ? "command" : "commands"}, ${fileCount} ${fileCount === 1 ? "file" : "files"}`;
205
+ const active = engagementTsMs.length >= 2 ? activeTimeFromTimestamps(engagementTsMs, ACTIVE_GAP_CAP_MS) : void 0;
206
+ const metricsFields = {
207
+ ...outputTokens > 0 ? { output_tokens: outputTokens } : {},
208
+ ...inputTokens > 0 ? { input_tokens: inputTokens } : {},
209
+ ...cachedInputTokens > 0 ? { cached_input_tokens: cachedInputTokens } : {},
210
+ ...active !== void 0 && active.ms > 0 ? {
211
+ active_time_ms: active.ms,
212
+ active_intervals: intervalsMsToIso(active.intervals),
213
+ active_gap_cap_ms: ACTIVE_GAP_CAP_MS,
214
+ active_time_method: ENGAGED_TURNS_METHOD
215
+ } : {}
216
+ };
217
+ const metrics = Object.keys(metricsFields).length > 0 ? metricsFields : void 0;
218
+ const payload = {
219
+ schema_version: "0.1.0",
220
+ session: {
221
+ label,
222
+ workspace_id: options.workspaceId,
223
+ source: {
224
+ kind: CLAUDE_IMPORT_SOURCE,
225
+ version: "0.1.0",
226
+ ...externalId !== void 0 ? { external_id: externalId } : {}
227
+ },
228
+ started_at: minTs,
229
+ ended_at: maxTs,
230
+ // Validated against the canonical enum here; importSessionFromJson
231
+ // overwrites it with the literal "imported" regardless.
232
+ status: "imported",
233
+ working_directory: workingDir ?? ".",
234
+ invocation: { command: "claude", args: [], exit_code: null },
235
+ related_files: [...relatedFiles].sort(),
236
+ summary: null,
237
+ ...metrics !== void 0 ? { metrics } : {}
238
+ },
239
+ events
240
+ };
241
+ return payload;
242
+ }
243
+ function baseEvent(occurredAt, sessionId) {
244
+ return {
245
+ schema_version: "0.1.0",
246
+ id: prefixedUlid("evt"),
247
+ session_id: sessionId,
248
+ occurred_at: occurredAt,
249
+ source: CLAUDE_IMPORT_SOURCE
250
+ };
251
+ }
252
+ function sessionStartedEvent(occurredAt, sessionId) {
253
+ return { ...baseEvent(occurredAt, sessionId), type: "session_started" };
254
+ }
255
+ function sessionEndedEvent(occurredAt, sessionId) {
256
+ return { ...baseEvent(occurredAt, sessionId), type: "session_ended" };
257
+ }
258
+ function commandExecutedEvent(occurredAt, sessionId, command, cwd) {
259
+ return {
260
+ ...baseEvent(occurredAt, sessionId),
261
+ type: "command_executed",
262
+ command: "bash",
263
+ args: ["-c", command],
264
+ cwd,
265
+ exit_code: null,
266
+ duration_ms: 0
267
+ };
268
+ }
269
+ function fileChangedEvent(occurredAt, sessionId, path2, changeType) {
270
+ return {
271
+ ...baseEvent(occurredAt, sessionId),
272
+ type: "file_changed",
273
+ path: path2,
274
+ change_type: changeType
275
+ };
276
+ }
277
+ function decisionRecordedEvent(occurredAt, sessionId, title) {
278
+ return {
279
+ ...baseEvent(occurredAt, sessionId),
280
+ type: "decision_recorded",
281
+ decision_id: prefixedUlid("decision"),
282
+ title
283
+ };
284
+ }
285
+ function readString(value) {
286
+ return typeof value === "string" && value.length > 0 ? value : void 0;
287
+ }
288
+ function readNonNegInt(value) {
289
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
290
+ }
291
+ function isObject(value) {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
293
+ }
294
+ function isHumanUserMessage(record) {
295
+ const message = isObject(record.message) ? record.message : void 0;
296
+ if (message === void 0) return false;
297
+ const content = message.content;
298
+ if (typeof content === "string") return content.length > 0;
299
+ if (Array.isArray(content)) {
300
+ return content.some((block) => {
301
+ if (!isObject(block)) return false;
302
+ const type = readString(block.type);
303
+ return type !== void 0 && type !== "tool_result";
304
+ });
305
+ }
306
+ return false;
307
+ }
308
+ function toolUses(record) {
309
+ const message = isObject(record.message) ? record.message : void 0;
310
+ const content = message !== void 0 && Array.isArray(message.content) ? message.content : [];
311
+ const result = [];
312
+ for (const item of content) {
313
+ if (isObject(item) && readString(item.type) === "tool_use") {
314
+ result.push(item);
315
+ }
316
+ }
317
+ return result;
318
+ }
319
+ function indexAskAnswers(records) {
320
+ const byId = /* @__PURE__ */ new Map();
321
+ for (const record of records) {
322
+ const result = record.toolUseResult;
323
+ if (!isObject(result)) continue;
324
+ const answers = result.answers;
325
+ if (!isObject(answers)) continue;
326
+ const message = isObject(record.message) ? record.message : void 0;
327
+ const content = message !== void 0 && Array.isArray(message.content) ? message.content : [];
328
+ for (const item of content) {
329
+ if (isObject(item) && readString(item.type) === "tool_result") {
330
+ const id = readString(item.tool_use_id);
331
+ if (id !== void 0) byId.set(id, answers);
332
+ }
333
+ }
334
+ }
335
+ return byId;
336
+ }
337
+
338
+ // src/adapters/codex/rollout-importer.ts
339
+ var CODEX_IMPORT_SOURCE = "codex-import";
340
+ function codexRolloutToImportPayload(records, options) {
341
+ const placeholderSessionId = prefixedUlid("ses");
342
+ const outputsByCallId = indexOutputs(records);
343
+ const derived = [];
344
+ let minTs;
345
+ let maxTs;
346
+ let workingDir;
347
+ let codexSessionId;
348
+ let lastTokenTotals;
349
+ const engagementTsMs = [];
350
+ for (const record of records) {
351
+ const ts = readString2(record.timestamp);
352
+ if (ts === void 0) continue;
353
+ if (minTs === void 0 || Date.parse(ts) < Date.parse(minTs)) minTs = ts;
354
+ if (maxTs === void 0 || Date.parse(ts) > Date.parse(maxTs)) maxTs = ts;
355
+ const payload2 = isObject2(record.payload) ? record.payload : void 0;
356
+ if (payload2 === void 0) continue;
357
+ if (readString2(record.type) === "session_meta") {
358
+ if (workingDir === void 0) workingDir = readString2(payload2.cwd);
359
+ if (codexSessionId === void 0) codexSessionId = readString2(payload2.id);
360
+ continue;
361
+ }
362
+ if (readString2(record.type) === "event_msg" && readString2(payload2.type) === "token_count") {
363
+ const info = isObject2(payload2.info) ? payload2.info : void 0;
364
+ const totals = info !== void 0 && isObject2(info.total_token_usage) ? info.total_token_usage : void 0;
365
+ if (totals !== void 0) lastTokenTotals = totals;
366
+ continue;
367
+ }
368
+ if (readString2(record.type) === "event_msg") {
369
+ const pt = readString2(payload2.type);
370
+ if (pt === "user_message" || pt === "agent_message" || pt === "task_started" || pt === "task_complete") {
371
+ const tsMs = Date.parse(ts);
372
+ if (Number.isFinite(tsMs)) engagementTsMs.push(tsMs);
373
+ }
374
+ continue;
375
+ }
376
+ if (readString2(record.type) !== "response_item") continue;
377
+ if (readString2(payload2.type) !== "function_call") continue;
378
+ if (readString2(payload2.name) !== "exec_command") continue;
379
+ const command = readExecCommand(payload2.arguments);
380
+ if (command === void 0) continue;
381
+ const cwd = command.workdir ?? workingDir ?? ".";
382
+ const output = readCallId(payload2.call_id, outputsByCallId);
383
+ const execTsMs = Date.parse(ts);
384
+ if (Number.isFinite(execTsMs)) engagementTsMs.push(execTsMs);
385
+ derived.push(
386
+ commandExecutedEvent2(ts, placeholderSessionId, command.cmd, cwd, {
387
+ exitCode: parseExitCode(output),
388
+ durationMs: parseWallTimeMs(output)
389
+ })
390
+ );
391
+ }
392
+ if (minTs === void 0 || maxTs === void 0) return null;
393
+ if (derived.length === 0) return null;
394
+ derived.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at));
395
+ const events = [
396
+ sessionStartedEvent2(minTs, placeholderSessionId),
397
+ ...derived,
398
+ sessionEndedEvent2(maxTs, placeholderSessionId)
399
+ ];
400
+ const externalId = options.externalId ?? codexSessionId;
401
+ const commandCount = derived.length;
402
+ const date = minTs.slice(0, 10);
403
+ const label = `codex ${date}: ${commandCount} ${commandCount === 1 ? "command" : "commands"}`;
404
+ const active = engagementTsMs.length >= 2 ? activeTimeFromTimestamps(engagementTsMs, ACTIVE_GAP_CAP_MS) : void 0;
405
+ const tokenFields = lastTokenTotals === void 0 ? {} : {
406
+ ...readNonNegInt2(lastTokenTotals.output_tokens) > 0 ? { output_tokens: readNonNegInt2(lastTokenTotals.output_tokens) } : {},
407
+ ...readNonNegInt2(lastTokenTotals.input_tokens) > 0 ? { input_tokens: readNonNegInt2(lastTokenTotals.input_tokens) } : {},
408
+ ...readNonNegInt2(lastTokenTotals.cached_input_tokens) > 0 ? { cached_input_tokens: readNonNegInt2(lastTokenTotals.cached_input_tokens) } : {},
409
+ ...readNonNegInt2(lastTokenTotals.reasoning_output_tokens) > 0 ? { reasoning_output_tokens: readNonNegInt2(lastTokenTotals.reasoning_output_tokens) } : {}
410
+ };
411
+ const metricsFields = {
412
+ ...tokenFields,
413
+ ...active !== void 0 && active.ms > 0 ? {
414
+ active_time_ms: active.ms,
415
+ active_intervals: intervalsMsToIso(active.intervals),
416
+ active_gap_cap_ms: ACTIVE_GAP_CAP_MS,
417
+ active_time_method: ENGAGED_TURNS_METHOD
418
+ } : {}
419
+ };
420
+ const metrics = Object.keys(metricsFields).length > 0 ? metricsFields : void 0;
421
+ const payload = {
422
+ schema_version: "0.1.0",
423
+ session: {
424
+ label,
425
+ workspace_id: options.workspaceId,
426
+ source: {
427
+ kind: CODEX_IMPORT_SOURCE,
428
+ version: "0.1.0",
429
+ ...externalId !== void 0 ? { external_id: externalId } : {}
430
+ },
431
+ started_at: minTs,
432
+ ended_at: maxTs,
433
+ // Validated against the canonical enum here; importSessionFromJson
434
+ // overwrites it with the literal "imported" regardless.
435
+ status: "imported",
436
+ working_directory: workingDir ?? ".",
437
+ invocation: { command: "codex", args: [], exit_code: null },
438
+ related_files: [],
439
+ summary: null,
440
+ ...metrics !== void 0 ? { metrics } : {}
441
+ },
442
+ events
443
+ };
444
+ return payload;
445
+ }
446
+ function baseEvent2(occurredAt, sessionId) {
447
+ return {
448
+ schema_version: "0.1.0",
449
+ id: prefixedUlid("evt"),
450
+ session_id: sessionId,
451
+ occurred_at: occurredAt,
452
+ source: CODEX_IMPORT_SOURCE
453
+ };
454
+ }
455
+ function sessionStartedEvent2(occurredAt, sessionId) {
456
+ return { ...baseEvent2(occurredAt, sessionId), type: "session_started" };
457
+ }
458
+ function sessionEndedEvent2(occurredAt, sessionId) {
459
+ return { ...baseEvent2(occurredAt, sessionId), type: "session_ended" };
460
+ }
461
+ function commandExecutedEvent2(occurredAt, sessionId, command, cwd, outcome) {
462
+ return {
463
+ ...baseEvent2(occurredAt, sessionId),
464
+ type: "command_executed",
465
+ command: "bash",
466
+ args: ["-c", command],
467
+ cwd,
468
+ exit_code: outcome.exitCode,
469
+ duration_ms: outcome.durationMs
470
+ };
471
+ }
472
+ function readString2(value) {
473
+ return typeof value === "string" && value.length > 0 ? value : void 0;
474
+ }
475
+ function readNonNegInt2(value) {
476
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
477
+ }
478
+ function isObject2(value) {
479
+ return typeof value === "object" && value !== null && !Array.isArray(value);
480
+ }
481
+ function readExecCommand(value) {
482
+ const raw = readString2(value);
483
+ if (raw === void 0) return void 0;
484
+ let parsed;
485
+ try {
486
+ parsed = JSON.parse(raw);
487
+ } catch {
488
+ return void 0;
489
+ }
490
+ if (!isObject2(parsed)) return void 0;
491
+ const cmd = readString2(parsed.cmd);
492
+ if (cmd === void 0) return void 0;
493
+ return { cmd, workdir: readString2(parsed.workdir) };
494
+ }
495
+ function readCallId(value, outputs) {
496
+ const callId = readString2(value);
497
+ return callId !== void 0 ? outputs.get(callId) : void 0;
498
+ }
499
+ function parseExitCode(output) {
500
+ if (output === void 0) return null;
501
+ const match = output.match(/Process exited with code (-?\d+)/);
502
+ return match?.[1] !== void 0 ? Number.parseInt(match[1], 10) : null;
503
+ }
504
+ function parseWallTimeMs(output) {
505
+ if (output === void 0) return 0;
506
+ const match = output.match(/Wall time:\s*([\d.]+)\s*seconds/);
507
+ if (match?.[1] === void 0) return 0;
508
+ const seconds = Number.parseFloat(match[1]);
509
+ return Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0;
510
+ }
511
+ function indexOutputs(records) {
512
+ const byId = /* @__PURE__ */ new Map();
513
+ for (const record of records) {
514
+ if (readString2(record.type) !== "response_item") continue;
515
+ const payload = isObject2(record.payload) ? record.payload : void 0;
516
+ if (payload === void 0) continue;
517
+ if (readString2(payload.type) !== "function_call_output") continue;
518
+ const callId = readString2(payload.call_id);
519
+ const output = readString2(payload.output);
520
+ if (callId !== void 0 && output !== void 0) byId.set(callId, output);
521
+ }
522
+ return byId;
523
+ }
524
+
525
+ // src/approval/approval-store.ts
526
+ import { readdir } from "fs/promises";
527
+ import { join } from "path";
528
+
529
+ // src/lib/error-codes.ts
530
+ function findErrorCode(error, code, depth = 4) {
531
+ let cur = error;
532
+ for (let i = 0; i < depth && cur instanceof Error; i++) {
533
+ const c = cur.code;
534
+ if (typeof c === "string" && c === code) return true;
535
+ cur = cur.cause;
536
+ }
537
+ return false;
538
+ }
539
+
540
+ // src/schemas/approval.schema.ts
541
+ import { z as z2 } from "zod";
542
+
26
543
  // src/schemas/shared.schema.ts
27
544
  import { z } from "zod";
28
545
  var SchemaVersionSchema = z.literal("0.1.0");
@@ -40,395 +557,30 @@ var DecisionIdSchema = createPrefixedIdSchema("decision");
40
557
  var RiskLevelSchema = z.enum(["low", "medium", "high", "critical"]);
41
558
  var EventSourceSchema = z.string().min(1);
42
559
 
43
- // src/schemas/manifest.schema.ts
44
- import { z as z2 } from "zod";
45
- var ProjectSchema = z2.object({
46
- name: z2.string().optional(),
47
- description: z2.string().optional(),
48
- repository_url: z2.string().nullable().optional()
49
- });
50
- var CapabilitiesSchema = z2.object({
51
- enabled: z2.array(z2.string())
52
- });
53
- var ApprovalConfigSchema = z2.object({
54
- required_for: z2.array(z2.string()).optional(),
55
- default_risk_level: z2.enum(["low", "medium", "high", "critical"])
56
- });
57
- var ClaudeCodeAdapterConfigSchema = z2.object({
58
- enabled: z2.boolean(),
59
- config_path: z2.string().optional()
60
- });
61
- var AdaptersSchema = z2.object({
62
- "claude-code": ClaudeCodeAdapterConfigSchema
63
- });
64
- var GitConfigSchema = z2.object({
65
- events_log: z2.enum(["ignore", "commit"]).default("ignore")
66
- });
67
- var WorkspaceMetaSchema = z2.object({
68
- id: WorkspaceIdSchema,
69
- name: z2.string().min(1),
70
- created_at: IsoTimestampSchema,
71
- updated_at: IsoTimestampSchema
72
- });
73
- var ManifestSchema = z2.object({
560
+ // src/schemas/approval.schema.ts
561
+ var ApprovalStatusSchema = z2.enum(["pending", "approved", "rejected", "expired"]);
562
+ var ApprovalSchema = z2.object({
74
563
  schema_version: SchemaVersionSchema,
75
- basou_version: z2.literal("0.1.0"),
76
- workspace: WorkspaceMetaSchema,
77
- project: ProjectSchema,
78
- capabilities: CapabilitiesSchema,
79
- approval: ApprovalConfigSchema,
80
- adapters: AdaptersSchema,
81
- git: GitConfigSchema
564
+ id: ApprovalIdSchema,
565
+ session_id: SessionIdSchema,
566
+ created_at: IsoTimestampSchema,
567
+ status: ApprovalStatusSchema,
568
+ risk_level: RiskLevelSchema,
569
+ action: z2.object({ kind: z2.string() }).passthrough(),
570
+ reason: z2.string(),
571
+ expires_at: IsoTimestampSchema.nullable().default(null),
572
+ // The four fields below are null while `status === "pending"` and set
573
+ // once a resolver records a decision. Defaulting to null keeps the
574
+ // pending YAML free of explicit nulls if a producer omits them.
575
+ resolver: z2.string().nullable().default(null),
576
+ resolved_at: IsoTimestampSchema.nullable().default(null),
577
+ note: z2.string().nullable().default(null),
578
+ rejection_reason: z2.string().nullable().default(null)
82
579
  });
83
580
 
84
- // src/schemas/status.schema.ts
85
- import { z as z3 } from "zod";
86
- var StatusSchema = z3.object({
87
- schema_version: SchemaVersionSchema,
88
- generated_at: IsoTimestampSchema,
89
- workspace: z3.object({
90
- id: WorkspaceIdSchema,
91
- name: z3.string().min(1),
92
- basou_version: z3.literal("0.1.0")
93
- }).strict(),
94
- directories_present: z3.object({
95
- sessions: z3.boolean(),
96
- tasks: z3.boolean(),
97
- approvals_pending: z3.boolean(),
98
- approvals_resolved: z3.boolean(),
99
- logs: z3.boolean(),
100
- raw: z3.boolean(),
101
- tmp: z3.boolean()
102
- }).strict()
103
- }).strict();
104
-
105
- // src/schemas/session.schema.ts
106
- import { z as z4 } from "zod";
107
- var SessionStatusSchema = z4.enum([
108
- "initialized",
109
- "running",
110
- "waiting_approval",
111
- "completed",
112
- "failed",
113
- "interrupted",
114
- "imported",
115
- "archived"
116
- ]);
117
- var SessionSourceKindSchema = z4.enum([
118
- "claude-code-adapter",
119
- "human",
120
- "import",
121
- "terminal"
122
- ]);
123
- var SessionSourceSchema = z4.object({
124
- kind: SessionSourceKindSchema,
125
- version: z4.literal("0.1.0")
126
- });
127
- var InvocationSchema = z4.object({
128
- command: z4.string().min(1),
129
- args: z4.array(z4.string()).default([]),
130
- // Nullable to record signal-terminated runs where the child has no exit
131
- // code; the same nullability is mirrored in CommandExecutedEventSchema.
132
- exit_code: z4.number().int().nullable()
133
- });
134
- var SessionInnerSchema = z4.object({
135
- id: SessionIdSchema,
136
- label: z4.string().optional(),
137
- task_id: TaskIdSchema.nullable().optional(),
138
- workspace_id: WorkspaceIdSchema,
139
- source: SessionSourceSchema,
140
- started_at: IsoTimestampSchema,
141
- // ended_at is optional because initialized / running sessions have no end time yet.
142
- ended_at: IsoTimestampSchema.optional(),
143
- status: SessionStatusSchema,
144
- working_directory: z4.string().min(1),
145
- invocation: InvocationSchema,
146
- related_files: z4.array(z4.string()).default([]),
147
- events_log: z4.string().default("events.jsonl"),
148
- summary: z4.string().nullable().optional()
149
- });
150
- var SessionSchema = z4.object({
151
- schema_version: SchemaVersionSchema,
152
- session: SessionInnerSchema
153
- });
154
-
155
- // src/schemas/task.schema.ts
156
- import { z as z5 } from "zod";
157
- var TaskStatusSchema = z5.enum(["planned", "in_progress", "done", "cancelled"]);
158
- var TaskInnerSchema = z5.object({
159
- id: TaskIdSchema,
160
- title: z5.string().min(1),
161
- label: z5.string().min(1).optional(),
162
- status: TaskStatusSchema,
163
- created_at: IsoTimestampSchema,
164
- updated_at: IsoTimestampSchema,
165
- workspace_id: WorkspaceIdSchema,
166
- /**
167
- * Session id that anchors this task. For freshly created tasks it is the
168
- * session that wrote the `task_created` event (= ad-hoc reconcile target
169
- * for ad-hoc paths, or the target session id for attach paths). After
170
- * `basou task reconcile --write` repairs a broken anchor the
171
- * value is replaced with the ad-hoc reconcile session id; the old broken
172
- * session_id is preserved on the `task_reconciled` event payload via
173
- * `removed_created_in_session` for audit. So this field always names a
174
- * reachable session, even after the original anchor is gone.
175
- */
176
- created_in_session: SessionIdSchema,
177
- /**
178
- * Snapshot of sessions linked to this task. The events.jsonl history is
179
- * the source of truth (see
180
- * `docs/spec/generated-markdown.md#105-decisionsmd-generation-principle`);
181
- * this field is maintained as a UX-only cache so editors can read the
182
- * task.md and immediately see related sessions. Defaults to `[]` for
183
- * backward compatibility.
184
- */
185
- linked_sessions: z5.array(SessionIdSchema).default([])
186
- });
187
- var TaskSchema = z5.object({
188
- schema_version: SchemaVersionSchema,
189
- task: TaskInnerSchema
190
- });
191
-
192
- // src/schemas/task-index.schema.ts
193
- import { z as z6 } from "zod";
194
- var TaskIndexEntrySchema = z6.object({
195
- id: TaskIdSchema,
196
- status: TaskStatusSchema,
197
- label: z6.string().min(1).optional(),
198
- updated_at: IsoTimestampSchema
199
- }).strict();
200
- var TaskIndexSchema = z6.object({
201
- schema_version: SchemaVersionSchema,
202
- tasks: z6.array(TaskIndexEntrySchema),
203
- last_rebuilt_at: IsoTimestampSchema
204
- }).strict();
205
- var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
206
-
207
- // src/schemas/approval.schema.ts
208
- import { z as z7 } from "zod";
209
- var ApprovalStatusSchema = z7.enum(["pending", "approved", "rejected", "expired"]);
210
- var ApprovalSchema = z7.object({
211
- schema_version: SchemaVersionSchema,
212
- id: ApprovalIdSchema,
213
- session_id: SessionIdSchema,
214
- created_at: IsoTimestampSchema,
215
- status: ApprovalStatusSchema,
216
- risk_level: RiskLevelSchema,
217
- action: z7.object({ kind: z7.string() }).passthrough(),
218
- reason: z7.string(),
219
- expires_at: IsoTimestampSchema.nullable().default(null),
220
- // The four fields below are null while `status === "pending"` and set
221
- // once a resolver records a decision. Defaulting to null keeps the
222
- // pending YAML free of explicit nulls if a producer omits them.
223
- resolver: z7.string().nullable().default(null),
224
- resolved_at: IsoTimestampSchema.nullable().default(null),
225
- note: z7.string().nullable().default(null),
226
- rejection_reason: z7.string().nullable().default(null)
227
- });
228
-
229
- // src/schemas/event.schema.ts
230
- import { z as z8 } from "zod";
231
- var BaseEventSchema = z8.object({
232
- schema_version: SchemaVersionSchema,
233
- id: EventIdSchema,
234
- session_id: SessionIdSchema,
235
- occurred_at: IsoTimestampSchema,
236
- source: EventSourceSchema
237
- });
238
- var SessionStartedEventSchema = BaseEventSchema.extend({
239
- type: z8.literal("session_started")
240
- });
241
- var SessionEndedEventSchema = BaseEventSchema.extend({
242
- type: z8.literal("session_ended"),
243
- exit_code: z8.number().int().optional()
244
- });
245
- var SessionStatusChangedEventSchema = BaseEventSchema.extend({
246
- type: z8.literal("session_status_changed"),
247
- from: z8.string(),
248
- to: z8.string()
249
- });
250
- var ApprovalRequestedEventSchema = BaseEventSchema.extend({
251
- type: z8.literal("approval_requested"),
252
- approval_id: ApprovalIdSchema,
253
- expires_at: IsoTimestampSchema.nullable().default(null),
254
- risk_level: RiskLevelSchema,
255
- // `action.kind` is required; additional fields are allowed to support
256
- // future action shapes (shell_command, external_send, ...).
257
- action: z8.object({ kind: z8.string() }).passthrough(),
258
- reason: z8.string(),
259
- status: z8.literal("pending")
260
- });
261
- var ApprovalApprovedEventSchema = BaseEventSchema.extend({
262
- type: z8.literal("approval_approved"),
263
- approval_id: ApprovalIdSchema,
264
- resolver: z8.string().optional(),
265
- note: z8.string().nullable().optional()
266
- });
267
- var ApprovalRejectedEventSchema = BaseEventSchema.extend({
268
- type: z8.literal("approval_rejected"),
269
- approval_id: ApprovalIdSchema,
270
- resolver: z8.string().optional(),
271
- reason: z8.string()
272
- });
273
- var ApprovalExpiredEventSchema = BaseEventSchema.extend({
274
- type: z8.literal("approval_expired"),
275
- approval_id: ApprovalIdSchema
276
- });
277
- var CommandExecutedEventSchema = BaseEventSchema.extend({
278
- type: z8.literal("command_executed"),
279
- command: z8.string(),
280
- args: z8.array(z8.string()),
281
- cwd: z8.string(),
282
- exit_code: z8.number().int().nullable(),
283
- signal: z8.string().nullable().optional(),
284
- received_signal: z8.string().nullable().optional(),
285
- duration_ms: z8.number().int().nonnegative()
286
- });
287
- var GitSnapshotEventSchema = BaseEventSchema.extend({
288
- type: z8.literal("git_snapshot"),
289
- head: z8.string(),
290
- branch: z8.string(),
291
- dirty: z8.boolean(),
292
- staged: z8.array(z8.string()),
293
- unstaged: z8.array(z8.string()),
294
- untracked: z8.array(z8.string()),
295
- ahead: z8.number().int().nonnegative().optional(),
296
- behind: z8.number().int().nonnegative().optional()
297
- });
298
- var FileChangedEventSchema = BaseEventSchema.extend({
299
- type: z8.literal("file_changed"),
300
- path: z8.string(),
301
- change_type: z8.enum(["added", "modified", "deleted", "renamed"]),
302
- // Renamed entries record the previous path here. Optional + nullable to
303
- // keep the wire format stable for added / modified / deleted events.
304
- old_path: z8.string().nullable().optional()
305
- });
306
- var DecisionRecordedEventSchema = BaseEventSchema.extend({
307
- type: z8.literal("decision_recorded"),
308
- decision_id: DecisionIdSchema,
309
- title: z8.string(),
310
- rationale: z8.string().nullable().optional(),
311
- alternatives: z8.array(z8.string().min(1)).optional(),
312
- rejected_reason: z8.string().nullable().optional(),
313
- linked_events: z8.array(EventIdSchema).optional(),
314
- linked_files: z8.array(z8.string().min(1).max(4096)).optional()
315
- });
316
- var TaskCreatedEventSchema = BaseEventSchema.extend({
317
- type: z8.literal("task_created"),
318
- task_id: TaskIdSchema,
319
- title: z8.string()
320
- });
321
- var TaskStatusChangedEventSchema = BaseEventSchema.extend({
322
- type: z8.literal("task_status_changed"),
323
- task_id: TaskIdSchema,
324
- from: z8.string(),
325
- to: z8.string()
326
- });
327
- var TaskReconciledEventSchema = BaseEventSchema.extend({
328
- type: z8.literal("task_reconciled"),
329
- task_id: TaskIdSchema,
330
- removed_created_in_session: SessionIdSchema.nullable().default(null),
331
- created_in_session_replacement: SessionIdSchema.nullable().default(null),
332
- removed_linked_sessions: z8.array(SessionIdSchema).default([])
333
- }).strict();
334
- var TaskLinkageRefreshedEventSchema = BaseEventSchema.extend({
335
- type: z8.literal("task_linkage_refreshed"),
336
- task_id: TaskIdSchema,
337
- added_linked_sessions: z8.array(SessionIdSchema).default([]),
338
- removed_linked_sessions: z8.array(SessionIdSchema).default([]),
339
- final_count: z8.number().int().nonnegative().optional()
340
- }).strict();
341
- var TaskDeletedEventSchema = BaseEventSchema.extend({
342
- type: z8.literal("task_deleted"),
343
- task_id: TaskIdSchema,
344
- title: z8.string().min(1)
345
- }).strict();
346
- var TaskArchivedEventSchema = BaseEventSchema.extend({
347
- type: z8.literal("task_archived"),
348
- task_id: TaskIdSchema,
349
- title: z8.string().min(1)
350
- }).strict();
351
- var NoteAddedEventSchema = BaseEventSchema.extend({
352
- type: z8.literal("note_added"),
353
- body: z8.string()
354
- });
355
- var AdapterOutputEventSchema = BaseEventSchema.extend({
356
- type: z8.literal("adapter_output"),
357
- stream: z8.enum(["stdout", "stderr"]),
358
- summary: z8.string(),
359
- raw_ref: z8.string(),
360
- redacted: z8.boolean().optional()
361
- }).strict();
362
- var EventSchema = z8.discriminatedUnion("type", [
363
- SessionStartedEventSchema,
364
- SessionEndedEventSchema,
365
- SessionStatusChangedEventSchema,
366
- ApprovalRequestedEventSchema,
367
- ApprovalApprovedEventSchema,
368
- ApprovalRejectedEventSchema,
369
- ApprovalExpiredEventSchema,
370
- CommandExecutedEventSchema,
371
- GitSnapshotEventSchema,
372
- FileChangedEventSchema,
373
- DecisionRecordedEventSchema,
374
- TaskCreatedEventSchema,
375
- TaskStatusChangedEventSchema,
376
- TaskReconciledEventSchema,
377
- TaskLinkageRefreshedEventSchema,
378
- TaskDeletedEventSchema,
379
- TaskArchivedEventSchema,
380
- NoteAddedEventSchema,
381
- AdapterOutputEventSchema
382
- ]);
383
-
384
- // src/schemas/session-import.schema.ts
385
- import { z as z9 } from "zod";
386
- var SessionInnerImportSchema = z9.object({
387
- id: SessionIdSchema.optional(),
388
- label: z9.string().optional(),
389
- task_id: TaskIdSchema.nullable().optional(),
390
- workspace_id: WorkspaceIdSchema,
391
- source: z9.object({
392
- kind: SessionSourceKindSchema,
393
- version: z9.literal("0.1.0")
394
- }),
395
- started_at: IsoTimestampSchema,
396
- ended_at: IsoTimestampSchema.optional(),
397
- status: SessionStatusSchema,
398
- working_directory: z9.string().min(1),
399
- invocation: z9.object({
400
- command: z9.string().min(1),
401
- args: z9.array(z9.string()),
402
- exit_code: z9.number().int().nullable()
403
- }),
404
- related_files: z9.array(z9.string()).default([]),
405
- events_log: z9.string().optional(),
406
- summary: z9.string().nullable().optional()
407
- }).strict();
408
- var SessionImportPayloadSchema = z9.object({
409
- schema_version: z9.string(),
410
- session: SessionInnerImportSchema,
411
- events: z9.array(EventSchema)
412
- }).strict();
413
-
414
- // src/approval/approval-store.ts
415
- import { readdir } from "fs/promises";
416
- import { join } from "path";
417
-
418
- // src/lib/error-codes.ts
419
- function findErrorCode(error, code, depth = 4) {
420
- let cur = error;
421
- for (let i = 0; i < depth && cur instanceof Error; i++) {
422
- const c = cur.code;
423
- if (typeof c === "string" && c === code) return true;
424
- cur = cur.cause;
425
- }
426
- return false;
427
- }
428
-
429
- // src/storage/yaml-store.ts
430
- import { readFile } from "fs/promises";
431
- import { parse, stringify } from "yaml";
581
+ // src/storage/yaml-store.ts
582
+ import { readFile } from "fs/promises";
583
+ import { parse, stringify } from "yaml";
432
584
 
433
585
  // src/storage/atomic.ts
434
586
  import { randomUUID } from "crypto";
@@ -553,338 +705,583 @@ function isLazyExpired(approval, now) {
553
705
  return expiresMs < now.getTime();
554
706
  }
555
707
 
556
- // src/storage/basou-dir.ts
557
- import { lstat, mkdir } from "fs/promises";
708
+ // src/decisions/decisions-renderer.ts
709
+ import { lstat } from "fs/promises";
710
+ import { dirname, join as join4, resolve } from "path";
711
+
712
+ // src/events/event-replay.ts
713
+ import { createReadStream } from "fs";
714
+ import { stat } from "fs/promises";
558
715
  import { join as join2 } from "path";
559
- function basouPaths(repositoryRoot) {
560
- const root = join2(repositoryRoot, ".basou");
561
- const approvalsBase = join2(root, "approvals");
562
- return {
563
- root,
564
- sessions: join2(root, "sessions"),
565
- tasks: join2(root, "tasks"),
566
- approvals: {
567
- pending: join2(approvalsBase, "pending"),
568
- resolved: join2(approvalsBase, "resolved")
569
- },
570
- locks: join2(root, "locks"),
571
- logs: join2(root, "logs"),
572
- raw: join2(root, "raw"),
573
- tmp: join2(root, "tmp"),
574
- files: {
575
- manifest: join2(root, "manifest.yaml"),
576
- status: join2(root, "status.json"),
577
- handoff: join2(root, "handoff.md"),
578
- decisions: join2(root, "decisions.md")
579
- }
580
- };
581
- }
582
- var PATH_LABELS = {
583
- sessions: ".basou/sessions",
584
- tasks: ".basou/tasks",
585
- approvalsPending: ".basou/approvals/pending",
586
- approvalsResolved: ".basou/approvals/resolved",
587
- locks: ".basou/locks",
588
- logs: ".basou/logs",
589
- raw: ".basou/raw",
590
- tmp: ".basou/tmp"
591
- };
592
- async function ensureBasouDirectory(repositoryRoot) {
593
- const paths = basouPaths(repositoryRoot);
594
- let existing;
595
- try {
596
- existing = await lstat(paths.root);
597
- } catch (error) {
598
- if (!hasErrorCode2(error) || error.code !== "ENOENT") {
599
- throw new Error("Failed to inspect .basou directory", { cause: error });
600
- }
601
- }
602
- if (existing !== void 0 && !existing.isDirectory()) {
603
- throw new Error("Basou root .basou exists but is not a directory");
604
- }
605
- await Promise.all([
606
- mkdirLabeled(paths.sessions, PATH_LABELS.sessions),
607
- mkdirLabeled(paths.tasks, PATH_LABELS.tasks),
608
- mkdirLabeled(paths.approvals.pending, PATH_LABELS.approvalsPending),
609
- mkdirLabeled(paths.approvals.resolved, PATH_LABELS.approvalsResolved),
610
- mkdirLabeled(paths.locks, PATH_LABELS.locks),
611
- mkdirLabeled(paths.logs, PATH_LABELS.logs),
612
- mkdirLabeled(paths.raw, PATH_LABELS.raw),
613
- mkdirLabeled(paths.tmp, PATH_LABELS.tmp)
614
- ]);
615
- return paths;
616
- }
617
- async function mkdirLabeled(target, label) {
618
- try {
619
- await mkdir(target, { recursive: true });
620
- } catch (error) {
621
- if (hasErrorCode2(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
622
- throw new Error(`${label} exists but is not a directory`, { cause: error });
623
- }
624
- throw new Error(`Failed to create ${label}`, { cause: error });
625
- }
626
- }
627
- function hasErrorCode2(error) {
628
- if (!(error instanceof Error)) return false;
629
- const codeProp = error.code;
630
- return typeof codeProp === "string";
631
- }
632
716
 
633
- // src/storage/manifest.ts
634
- import { lstat as lstat2 } from "fs/promises";
635
- function createManifest(input) {
636
- if (input.workspaceName.length === 0) {
637
- throw new Error("Workspace name is empty. Pass --name explicitly.");
638
- }
639
- const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
640
- const workspaceId = input.workspaceId ?? prefixedUlid("ws");
641
- const project = {
642
- ...input.projectName !== void 0 ? { name: input.projectName } : {},
643
- ...input.projectDescription !== void 0 ? { description: input.projectDescription } : {},
644
- ...input.repositoryUrl !== void 0 ? { repository_url: input.repositoryUrl } : {}
645
- };
646
- const manifest = {
647
- schema_version: "0.1.0",
648
- basou_version: "0.1.0",
649
- workspace: {
650
- id: workspaceId,
651
- name: input.workspaceName,
652
- created_at: now,
653
- updated_at: now
654
- },
655
- project,
656
- capabilities: {
657
- enabled: ["core", "claude-code-adapter", "terminal-recording", "git-capability", "approval"]
658
- },
659
- approval: {
660
- required_for: ["destructive_command", "external_send"],
661
- default_risk_level: "medium"
662
- },
663
- adapters: {
664
- "claude-code": { enabled: true }
665
- },
666
- git: { events_log: "ignore" }
667
- };
668
- return ManifestSchema.parse(manifest);
669
- }
670
- async function writeManifest(paths, manifest, options) {
671
- const force = options?.force === true;
672
- const validated = ManifestSchema.parse(manifest);
673
- if (!force) {
674
- let existed = false;
675
- try {
676
- await lstat2(paths.files.manifest);
677
- existed = true;
678
- } catch (error) {
679
- if (!hasErrorCode3(error) || error.code !== "ENOENT") {
680
- throw new Error("Failed to inspect existing manifest", { cause: error });
717
+ // src/schemas/event.schema.ts
718
+ import { z as z3 } from "zod";
719
+ var BaseEventSchema = z3.object({
720
+ schema_version: SchemaVersionSchema,
721
+ id: EventIdSchema,
722
+ session_id: SessionIdSchema,
723
+ occurred_at: IsoTimestampSchema,
724
+ source: EventSourceSchema
725
+ });
726
+ var SessionStartedEventSchema = BaseEventSchema.extend({
727
+ type: z3.literal("session_started")
728
+ });
729
+ var SessionEndedEventSchema = BaseEventSchema.extend({
730
+ type: z3.literal("session_ended"),
731
+ exit_code: z3.number().int().optional()
732
+ });
733
+ var SessionStatusChangedEventSchema = BaseEventSchema.extend({
734
+ type: z3.literal("session_status_changed"),
735
+ from: z3.string(),
736
+ to: z3.string()
737
+ });
738
+ var ApprovalRequestedEventSchema = BaseEventSchema.extend({
739
+ type: z3.literal("approval_requested"),
740
+ approval_id: ApprovalIdSchema,
741
+ expires_at: IsoTimestampSchema.nullable().default(null),
742
+ risk_level: RiskLevelSchema,
743
+ // `action.kind` is required; additional fields are allowed to support
744
+ // future action shapes (shell_command, external_send, ...).
745
+ action: z3.object({ kind: z3.string() }).passthrough(),
746
+ reason: z3.string(),
747
+ status: z3.literal("pending")
748
+ });
749
+ var ApprovalApprovedEventSchema = BaseEventSchema.extend({
750
+ type: z3.literal("approval_approved"),
751
+ approval_id: ApprovalIdSchema,
752
+ resolver: z3.string().optional(),
753
+ note: z3.string().nullable().optional()
754
+ });
755
+ var ApprovalRejectedEventSchema = BaseEventSchema.extend({
756
+ type: z3.literal("approval_rejected"),
757
+ approval_id: ApprovalIdSchema,
758
+ resolver: z3.string().optional(),
759
+ reason: z3.string()
760
+ });
761
+ var ApprovalExpiredEventSchema = BaseEventSchema.extend({
762
+ type: z3.literal("approval_expired"),
763
+ approval_id: ApprovalIdSchema
764
+ });
765
+ var CommandExecutedEventSchema = BaseEventSchema.extend({
766
+ type: z3.literal("command_executed"),
767
+ command: z3.string(),
768
+ args: z3.array(z3.string()),
769
+ cwd: z3.string(),
770
+ exit_code: z3.number().int().nullable(),
771
+ signal: z3.string().nullable().optional(),
772
+ received_signal: z3.string().nullable().optional(),
773
+ duration_ms: z3.number().int().nonnegative()
774
+ });
775
+ var GitSnapshotEventSchema = BaseEventSchema.extend({
776
+ type: z3.literal("git_snapshot"),
777
+ head: z3.string(),
778
+ branch: z3.string(),
779
+ dirty: z3.boolean(),
780
+ staged: z3.array(z3.string()),
781
+ unstaged: z3.array(z3.string()),
782
+ untracked: z3.array(z3.string()),
783
+ ahead: z3.number().int().nonnegative().optional(),
784
+ behind: z3.number().int().nonnegative().optional()
785
+ });
786
+ var FileChangedEventSchema = BaseEventSchema.extend({
787
+ type: z3.literal("file_changed"),
788
+ path: z3.string(),
789
+ change_type: z3.enum(["added", "modified", "deleted", "renamed"]),
790
+ // Renamed entries record the previous path here. Optional + nullable to
791
+ // keep the wire format stable for added / modified / deleted events.
792
+ old_path: z3.string().nullable().optional()
793
+ });
794
+ var DecisionRecordedEventSchema = BaseEventSchema.extend({
795
+ type: z3.literal("decision_recorded"),
796
+ decision_id: DecisionIdSchema,
797
+ title: z3.string(),
798
+ rationale: z3.string().nullable().optional(),
799
+ alternatives: z3.array(z3.string().min(1)).optional(),
800
+ rejected_reason: z3.string().nullable().optional(),
801
+ linked_events: z3.array(EventIdSchema).optional(),
802
+ linked_files: z3.array(z3.string().min(1).max(4096)).optional()
803
+ });
804
+ var TaskCreatedEventSchema = BaseEventSchema.extend({
805
+ type: z3.literal("task_created"),
806
+ task_id: TaskIdSchema,
807
+ title: z3.string()
808
+ });
809
+ var TaskStatusChangedEventSchema = BaseEventSchema.extend({
810
+ type: z3.literal("task_status_changed"),
811
+ task_id: TaskIdSchema,
812
+ from: z3.string(),
813
+ to: z3.string()
814
+ });
815
+ var TaskReconciledEventSchema = BaseEventSchema.extend({
816
+ type: z3.literal("task_reconciled"),
817
+ task_id: TaskIdSchema,
818
+ removed_created_in_session: SessionIdSchema.nullable().default(null),
819
+ created_in_session_replacement: SessionIdSchema.nullable().default(null),
820
+ removed_linked_sessions: z3.array(SessionIdSchema).default([])
821
+ }).strict();
822
+ var TaskLinkageRefreshedEventSchema = BaseEventSchema.extend({
823
+ type: z3.literal("task_linkage_refreshed"),
824
+ task_id: TaskIdSchema,
825
+ added_linked_sessions: z3.array(SessionIdSchema).default([]),
826
+ removed_linked_sessions: z3.array(SessionIdSchema).default([]),
827
+ final_count: z3.number().int().nonnegative().optional()
828
+ }).strict();
829
+ var TaskDeletedEventSchema = BaseEventSchema.extend({
830
+ type: z3.literal("task_deleted"),
831
+ task_id: TaskIdSchema,
832
+ title: z3.string().min(1)
833
+ }).strict();
834
+ var TaskArchivedEventSchema = BaseEventSchema.extend({
835
+ type: z3.literal("task_archived"),
836
+ task_id: TaskIdSchema,
837
+ title: z3.string().min(1)
838
+ }).strict();
839
+ var NoteAddedEventSchema = BaseEventSchema.extend({
840
+ type: z3.literal("note_added"),
841
+ body: z3.string()
842
+ });
843
+ var AdapterOutputEventSchema = BaseEventSchema.extend({
844
+ type: z3.literal("adapter_output"),
845
+ stream: z3.enum(["stdout", "stderr"]),
846
+ summary: z3.string(),
847
+ raw_ref: z3.string(),
848
+ redacted: z3.boolean().optional()
849
+ }).strict();
850
+ var EventSchema = z3.discriminatedUnion("type", [
851
+ SessionStartedEventSchema,
852
+ SessionEndedEventSchema,
853
+ SessionStatusChangedEventSchema,
854
+ ApprovalRequestedEventSchema,
855
+ ApprovalApprovedEventSchema,
856
+ ApprovalRejectedEventSchema,
857
+ ApprovalExpiredEventSchema,
858
+ CommandExecutedEventSchema,
859
+ GitSnapshotEventSchema,
860
+ FileChangedEventSchema,
861
+ DecisionRecordedEventSchema,
862
+ TaskCreatedEventSchema,
863
+ TaskStatusChangedEventSchema,
864
+ TaskReconciledEventSchema,
865
+ TaskLinkageRefreshedEventSchema,
866
+ TaskDeletedEventSchema,
867
+ TaskArchivedEventSchema,
868
+ NoteAddedEventSchema,
869
+ AdapterOutputEventSchema
870
+ ]);
871
+
872
+ // src/events/event-replay.ts
873
+ async function* replayEvents(sessionDir, options = {}) {
874
+ const filePath = join2(sessionDir, "events.jsonl");
875
+ try {
876
+ await stat(filePath);
877
+ } catch (error) {
878
+ if (findErrorCode(error, "ENOENT")) return;
879
+ throw new Error("Failed to read events.jsonl", { cause: error });
880
+ }
881
+ let stream;
882
+ try {
883
+ stream = createReadStream(filePath, { encoding: "utf8" });
884
+ } catch (error) {
885
+ throw new Error("Failed to read events.jsonl", { cause: error });
886
+ }
887
+ let buffer = "";
888
+ let lineNo = 0;
889
+ try {
890
+ for await (const chunk of stream) {
891
+ buffer += chunk;
892
+ let newlineIdx = buffer.indexOf("\n");
893
+ while (newlineIdx !== -1) {
894
+ lineNo += 1;
895
+ const rawLine = buffer.slice(0, newlineIdx);
896
+ buffer = buffer.slice(newlineIdx + 1);
897
+ const ev = processLine(rawLine, lineNo, options);
898
+ if (ev !== null) yield ev;
899
+ newlineIdx = buffer.indexOf("\n");
681
900
  }
682
901
  }
683
- if (existed) {
684
- throw new Error("Already initialized. Use --force to overwrite.");
685
- }
902
+ } catch (error) {
903
+ throw new Error("Failed to read events.jsonl", { cause: error });
686
904
  }
687
- await writeYamlFile(paths.files.manifest, validated);
905
+ const trimmed = buffer.replace(/[\r\n\t ]+$/u, "");
906
+ if (trimmed.length === 0) return;
907
+ lineNo += 1;
908
+ let parsed;
909
+ try {
910
+ parsed = JSON.parse(trimmed);
911
+ } catch (cause) {
912
+ options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
913
+ return;
914
+ }
915
+ const result = EventSchema.safeParse(parsed);
916
+ if (!result.success) {
917
+ options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
918
+ return;
919
+ }
920
+ options.onWarning?.({ kind: "partial_trailing_line", line: lineNo });
688
921
  }
689
- async function readManifest(paths) {
690
- const raw = await readYamlFile(paths.files.manifest);
691
- return ManifestSchema.parse(raw);
922
+ function processLine(rawLine, lineNo, options) {
923
+ const trimmed = rawLine.trim();
924
+ if (trimmed.length === 0) return null;
925
+ let parsed;
926
+ try {
927
+ parsed = JSON.parse(trimmed);
928
+ } catch (cause) {
929
+ options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
930
+ return null;
931
+ }
932
+ const result = EventSchema.safeParse(parsed);
933
+ if (!result.success) {
934
+ options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
935
+ return null;
936
+ }
937
+ return result.data;
692
938
  }
693
- function hasErrorCode3(error) {
694
- if (!(error instanceof Error)) return false;
695
- return typeof error.code === "string";
939
+ async function readAllEvents(sessionDir, options = {}) {
940
+ const out = [];
941
+ for await (const ev of replayEvents(sessionDir, options)) {
942
+ out.push(ev);
943
+ }
944
+ return out;
696
945
  }
697
946
 
698
- // src/storage/gitignore.ts
699
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
947
+ // src/storage/sessions.ts
948
+ import { readdir as readdir2 } from "fs/promises";
700
949
  import { join as join3 } from "path";
701
- var MARKER = "# Basou - default ignore";
702
- 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/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";
703
- async function appendBasouGitignore(repositoryRoot) {
704
- const gitignorePath = join3(repositoryRoot, ".gitignore");
705
- let body;
706
- let existed;
950
+
951
+ // src/schemas/session.schema.ts
952
+ import { z as z4 } from "zod";
953
+ var SessionStatusSchema = z4.enum([
954
+ "initialized",
955
+ "running",
956
+ "waiting_approval",
957
+ "completed",
958
+ "failed",
959
+ "interrupted",
960
+ "imported",
961
+ "archived"
962
+ ]);
963
+ var SessionSourceKindSchema = z4.enum([
964
+ "claude-code-adapter",
965
+ "claude-code-import",
966
+ "codex-import",
967
+ "human",
968
+ "import",
969
+ "terminal"
970
+ ]);
971
+ var SessionSourceSchema = z4.object({
972
+ kind: SessionSourceKindSchema,
973
+ version: z4.literal("0.1.0"),
974
+ // Optional id of the originating session in the SOURCE tool's own
975
+ // namespace (e.g. the Claude Code session UUID for a `claude-code-import`).
976
+ // Lets re-imports of the same source be deduplicated; absent for live runs.
977
+ external_id: z4.string().optional()
978
+ });
979
+ var InvocationSchema = z4.object({
980
+ command: z4.string().min(1),
981
+ args: z4.array(z4.string()).default([]),
982
+ // Nullable to record signal-terminated runs where the child has no exit
983
+ // code; the same nullability is mirrored in CommandExecutedEventSchema.
984
+ exit_code: z4.number().int().nullable()
985
+ });
986
+ var SessionMetricsSchema = z4.object({
987
+ output_tokens: z4.number().int().nonnegative().optional(),
988
+ input_tokens: z4.number().int().nonnegative().optional(),
989
+ cached_input_tokens: z4.number().int().nonnegative().optional(),
990
+ reasoning_output_tokens: z4.number().int().nonnegative().optional(),
991
+ active_time_ms: z4.number().int().nonnegative().optional(),
992
+ active_intervals: z4.array(z4.object({ start: IsoTimestampSchema, end: IsoTimestampSchema })).optional(),
993
+ active_gap_cap_ms: z4.number().int().nonnegative().optional(),
994
+ active_time_method: z4.string().optional()
995
+ });
996
+ var SessionInnerSchema = z4.object({
997
+ id: SessionIdSchema,
998
+ label: z4.string().optional(),
999
+ task_id: TaskIdSchema.nullable().optional(),
1000
+ workspace_id: WorkspaceIdSchema,
1001
+ source: SessionSourceSchema,
1002
+ started_at: IsoTimestampSchema,
1003
+ // ended_at is optional because initialized / running sessions have no end time yet.
1004
+ ended_at: IsoTimestampSchema.optional(),
1005
+ status: SessionStatusSchema,
1006
+ working_directory: z4.string().min(1),
1007
+ invocation: InvocationSchema,
1008
+ related_files: z4.array(z4.string()).default([]),
1009
+ events_log: z4.string().default("events.jsonl"),
1010
+ summary: z4.string().nullable().optional(),
1011
+ metrics: SessionMetricsSchema.optional()
1012
+ });
1013
+ var SessionSchema = z4.object({
1014
+ schema_version: SchemaVersionSchema,
1015
+ session: SessionInnerSchema
1016
+ });
1017
+
1018
+ // src/storage/sessions.ts
1019
+ var STUCK_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
1020
+ async function enumerateSessionDirs(paths) {
707
1021
  try {
708
- body = await readFile2(gitignorePath, "utf8");
709
- existed = true;
1022
+ const dirents = await readdir2(paths.sessions, { withFileTypes: true });
1023
+ return dirents.filter((d) => d.isDirectory()).map((d) => d.name).sort();
710
1024
  } catch (error) {
711
- if (hasErrorCode4(error) && error.code === "ENOENT") {
712
- body = "";
713
- existed = false;
714
- } else {
715
- throw new Error("Failed to read .gitignore", { cause: error });
716
- }
717
- }
718
- if (existed && hasBasouMarker(body)) {
719
- return { appended: false };
1025
+ if (findErrorCode(error, "ENOENT")) return [];
1026
+ throw new Error("Failed to enumerate sessions", { cause: error });
720
1027
  }
721
- const next = composeNextBody(body);
1028
+ }
1029
+ async function readSessionYaml(paths, sessionId) {
1030
+ const filePath = join3(paths.sessions, sessionId, "session.yaml");
1031
+ let raw;
722
1032
  try {
723
- await writeFile2(gitignorePath, next, { encoding: "utf8" });
1033
+ raw = await readYamlFile(filePath);
724
1034
  } catch (error) {
725
- throw new Error("Failed to write .gitignore", { cause: error });
1035
+ if (error instanceof Error && error.message === "YAML file not found") throw error;
1036
+ throw new Error("Failed to read session.yaml", { cause: error });
726
1037
  }
727
- return { appended: true };
728
- }
729
- function hasBasouMarker(body) {
730
- for (const rawLine of body.split("\n")) {
731
- if (rawLine.trimEnd().startsWith(MARKER)) return true;
1038
+ const result = SessionSchema.safeParse(raw);
1039
+ if (!result.success) {
1040
+ throw new Error("Failed to read session.yaml", { cause: result.error });
732
1041
  }
733
- return false;
1042
+ return result.data;
734
1043
  }
735
- function composeNextBody(existing) {
736
- if (existing.length === 0) return BASOU_GITIGNORE_BLOCK;
737
- const normalized = existing.endsWith("\n") ? existing : `${existing}
738
- `;
739
- return `${normalized}
740
- ${BASOU_GITIGNORE_BLOCK}`;
1044
+ async function classifySuspect(paths, sessionId, session, now, onWarning) {
1045
+ if (session.session.status !== "running") {
1046
+ return { suspect: false, suspectReason: null };
1047
+ }
1048
+ const sessionDir = join3(paths.sessions, sessionId);
1049
+ let endedFound = false;
1050
+ let lastEventOccurredAt = null;
1051
+ const replayOpts = onWarning !== void 0 ? { onWarning } : {};
1052
+ for await (const ev of replayEvents(sessionDir, replayOpts)) {
1053
+ lastEventOccurredAt = ev.occurred_at;
1054
+ if (ev.type === "session_ended") endedFound = true;
1055
+ }
1056
+ if (endedFound) {
1057
+ return { suspect: true, suspectReason: "events_say_ended_but_yaml_running" };
1058
+ }
1059
+ if (lastEventOccurredAt !== null) {
1060
+ const ageMs = now.getTime() - Date.parse(lastEventOccurredAt);
1061
+ if (Number.isFinite(ageMs) && ageMs > STUCK_THRESHOLD_MS) {
1062
+ return { suspect: true, suspectReason: "running_no_end_event" };
1063
+ }
1064
+ }
1065
+ return { suspect: false, suspectReason: null };
741
1066
  }
742
- function hasErrorCode4(error) {
743
- if (!(error instanceof Error)) return false;
744
- return typeof error.code === "string";
1067
+ async function loadSessionEntries(paths, options) {
1068
+ const sessionIds = await enumerateSessionDirs(paths);
1069
+ const entries = [];
1070
+ for (const sid of sessionIds) {
1071
+ let session;
1072
+ try {
1073
+ session = await readSessionYaml(paths, sid);
1074
+ } catch (error) {
1075
+ if (error instanceof Error && error.message === "YAML file not found") {
1076
+ options.onSkip?.(sid, "session_yaml_missing");
1077
+ } else {
1078
+ options.onSkip?.(sid, "session_yaml_invalid");
1079
+ }
1080
+ continue;
1081
+ }
1082
+ let suspect = false;
1083
+ let suspectReason = null;
1084
+ try {
1085
+ const r = await classifySuspect(
1086
+ paths,
1087
+ sid,
1088
+ session,
1089
+ options.now,
1090
+ (w) => options.onWarning?.(w, sid)
1091
+ );
1092
+ suspect = r.suspect;
1093
+ suspectReason = r.suspectReason;
1094
+ } catch {
1095
+ options.onSkip?.(sid, "events_jsonl_unreadable");
1096
+ }
1097
+ entries.push({ sessionId: sid, session, suspect, suspectReason });
1098
+ }
1099
+ return entries;
745
1100
  }
746
1101
 
747
- // src/storage/lockfile.ts
748
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
749
- import { join as join4 } from "path";
750
- var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
751
- async function acquireLock(paths, scope, resourceId) {
752
- const lockPath = lockfilePath(paths, scope, resourceId);
753
- const body = {
754
- pid: process.pid,
755
- acquired_at: (/* @__PURE__ */ new Date()).toISOString()
1102
+ // src/decisions/decisions-renderer.ts
1103
+ async function renderDecisions(input) {
1104
+ const now = new Date(input.nowIso);
1105
+ const unreadableEmitted = /* @__PURE__ */ new Set();
1106
+ const wrappedSkip = (sid, reason) => {
1107
+ if (reason === "events_jsonl_unreadable") unreadableEmitted.add(sid);
1108
+ input.onSessionSkip?.(sid, reason);
756
1109
  };
757
- const serialised = JSON.stringify(body);
758
- try {
759
- await atomicCreate(lockPath, serialised);
760
- } catch (error) {
761
- if (!findErrorCode(error, "EEXIST")) {
762
- throw error;
763
- }
764
- const stale = await isStaleLock(lockPath);
765
- if (!stale) {
766
- throw new Error("Lock is held by another process", { cause: error });
1110
+ const loadOpts = { now, onSkip: wrappedSkip };
1111
+ if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
1112
+ const entries = await loadSessionEntries(input.paths, loadOpts);
1113
+ const decisions = [];
1114
+ const knownEventIds = /* @__PURE__ */ new Set();
1115
+ for (const entry of entries) {
1116
+ const sessionDir = join4(input.paths.sessions, entry.sessionId);
1117
+ try {
1118
+ for await (const ev of replayEvents(sessionDir, {
1119
+ onWarning: (w) => input.onWarning?.(w, entry.sessionId)
1120
+ })) {
1121
+ knownEventIds.add(ev.id);
1122
+ if (ev.type === "decision_recorded") {
1123
+ decisions.push({
1124
+ decisionId: ev.decision_id,
1125
+ title: ev.title,
1126
+ occurredAt: ev.occurred_at,
1127
+ sessionId: entry.sessionId,
1128
+ rationale: ev.rationale,
1129
+ alternatives: ev.alternatives,
1130
+ rejectedReason: ev.rejected_reason,
1131
+ linkedEvents: ev.linked_events,
1132
+ linkedFiles: ev.linked_files
1133
+ });
1134
+ }
1135
+ }
1136
+ } catch {
1137
+ if (!unreadableEmitted.has(entry.sessionId)) {
1138
+ wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
1139
+ }
767
1140
  }
768
- await unlink2(lockPath).catch(() => void 0);
1141
+ }
1142
+ decisions.sort((a, b) => {
1143
+ const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
1144
+ return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
1145
+ });
1146
+ const repoRoot = dirname(input.paths.root);
1147
+ const fileExistenceCache = /* @__PURE__ */ new Map();
1148
+ async function fileExists(relPath) {
1149
+ const cached = fileExistenceCache.get(relPath);
1150
+ if (cached !== void 0) return cached;
1151
+ const abs = resolve(repoRoot, relPath);
1152
+ let exists;
769
1153
  try {
770
- await atomicCreate(lockPath, serialised);
771
- } catch (retryError) {
772
- throw new Error("Lock is held by another process", { cause: retryError });
1154
+ await lstat(abs);
1155
+ exists = true;
1156
+ } catch {
1157
+ exists = false;
773
1158
  }
1159
+ fileExistenceCache.set(relPath, exists);
1160
+ return exists;
774
1161
  }
775
- return {
776
- release: async () => {
777
- await unlink2(lockPath).catch(() => void 0);
1162
+ const body = await formatDecisionsBody({
1163
+ nowIso: input.nowIso,
1164
+ decisions,
1165
+ knownEventIds,
1166
+ fileExists
1167
+ });
1168
+ return { body, decisionCount: decisions.length };
1169
+ }
1170
+ async function formatDecisionsBody(args) {
1171
+ const lines = [];
1172
+ lines.push("# Decisions");
1173
+ lines.push("");
1174
+ lines.push(`> Generated at ${args.nowIso}`);
1175
+ lines.push("");
1176
+ if (args.decisions.length === 0) {
1177
+ lines.push("(no decisions recorded yet)");
1178
+ return lines.join("\n");
1179
+ }
1180
+ for (const d of args.decisions) {
1181
+ lines.push(`## ${d.decisionId}: ${d.title}`);
1182
+ lines.push("");
1183
+ const occurredDate = d.occurredAt.slice(0, 10);
1184
+ lines.push(`- \u6C7A\u5B9A\u65E5: ${occurredDate}`);
1185
+ lines.push(`- session: ${shortDecisionSessionId(d.sessionId)}`);
1186
+ lines.push(`- \u5224\u65AD: ${d.title}`);
1187
+ if (typeof d.rationale === "string" && d.rationale.length > 0) {
1188
+ lines.push(`- rationale: ${d.rationale}`);
1189
+ }
1190
+ if (d.alternatives !== void 0 && d.alternatives.length > 0) {
1191
+ lines.push(`- alternatives: ${d.alternatives.join(", ")}`);
1192
+ }
1193
+ if (typeof d.rejectedReason === "string" && d.rejectedReason.length > 0) {
1194
+ lines.push(`- rejected_reason: ${d.rejectedReason}`);
778
1195
  }
779
- };
780
- }
781
- async function isStaleLock(lockPath) {
782
- let body;
783
- try {
784
- const raw = await readFile3(lockPath, "utf8");
785
- const parsed = JSON.parse(raw);
786
- if (typeof parsed !== "object" || parsed === null) return true;
787
- const candidate = parsed;
788
- if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
789
- return true;
1196
+ if (d.linkedEvents !== void 0 && d.linkedEvents.length > 0) {
1197
+ const parts = d.linkedEvents.map(
1198
+ (eid) => args.knownEventIds.has(eid) ? eid : `${eid} (missing)`
1199
+ );
1200
+ lines.push(`- linked_events: ${parts.join(", ")}`);
790
1201
  }
791
- body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
792
- } catch {
793
- return true;
794
- }
795
- const ageMs = Date.now() - Date.parse(body.acquired_at);
796
- if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
797
- return true;
798
- }
799
- try {
800
- process.kill(body.pid, 0);
801
- return false;
802
- } catch (error) {
803
- if (findErrorCode(error, "ESRCH")) return true;
804
- return false;
1202
+ if (d.linkedFiles !== void 0 && d.linkedFiles.length > 0) {
1203
+ const parts = await Promise.all(
1204
+ d.linkedFiles.map(
1205
+ async (path2) => await args.fileExists(path2) ? path2 : `${path2} (missing)`
1206
+ )
1207
+ );
1208
+ lines.push(`- linked_files: ${parts.join(", ")}`);
1209
+ }
1210
+ lines.push("");
805
1211
  }
1212
+ return lines.join("\n");
806
1213
  }
807
- function lockfilePath(paths, scope, resourceId) {
808
- const sep = resourceId.indexOf("_");
809
- const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
810
- return join4(paths.locks, `${scope}_${ulid2}.lock`);
1214
+ function shortDecisionSessionId(sessionId) {
1215
+ const SES = "ses_";
1216
+ if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
1217
+ return sessionId.slice(0, 10);
811
1218
  }
812
1219
 
813
- // src/storage/task-index.ts
814
- import { readFile as readFile4 } from "fs/promises";
1220
+ // src/events/event-writer.ts
1221
+ import { appendFile } from "fs/promises";
815
1222
  import { join as join5 } from "path";
816
- function taskIndexPath(paths) {
817
- return join5(paths.tasks, "index.json");
818
- }
819
- async function readTaskIndex(paths) {
820
- const filePath = taskIndexPath(paths);
821
- let raw;
1223
+ async function appendEvent(sessionDir, event) {
1224
+ let validated;
822
1225
  try {
823
- raw = await readFile4(filePath, "utf8");
1226
+ validated = EventSchema.parse(event);
824
1227
  } catch (error) {
825
- if (findErrorCode(error, "ENOENT")) {
826
- throw new Error("Task index not found", { cause: error });
827
- }
828
- throw new Error("Failed to read task index", { cause: error });
1228
+ throw new Error("Invalid Basou event payload", { cause: error });
829
1229
  }
830
- let parsedJson;
1230
+ const line = `${JSON.stringify(validated)}
1231
+ `;
831
1232
  try {
832
- parsedJson = JSON.parse(raw);
1233
+ await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
833
1234
  } catch (error) {
834
- throw new Error("Invalid task index", { cause: error });
835
- }
836
- const result = TaskIndexSchema.safeParse(parsedJson);
837
- if (!result.success) {
838
- throw new Error("Invalid task index", { cause: result.error });
839
- }
840
- if (result.data.schema_version !== TASK_INDEX_SCHEMA_VERSION) {
841
- throw new Error("Invalid task index", {
842
- cause: new Error(`Unsupported task index schema_version: ${result.data.schema_version}`)
843
- });
1235
+ throw new Error("Failed to append event to events.jsonl", { cause: error });
844
1236
  }
845
- return result.data;
846
- }
847
- async function rebuildTaskIndex(paths, entries, now) {
848
- const sorted = [...entries].sort((a, b) => a.id.localeCompare(b.id));
849
- const payload = {
850
- schema_version: TASK_INDEX_SCHEMA_VERSION,
851
- tasks: sorted,
852
- last_rebuilt_at: (now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
853
- };
854
- TaskIndexSchema.parse(payload);
855
- await atomicReplace(taskIndexPath(paths), `${JSON.stringify(payload, null, 2)}
856
- `);
857
- return payload;
858
1237
  }
859
- async function updateTaskIndex(paths, op, options) {
860
- const nowFn = options?.now ?? (() => /* @__PURE__ */ new Date());
861
- let current;
1238
+ async function writeEventsBulk(sessionDir, events) {
1239
+ const validated = [];
862
1240
  try {
863
- current = await readTaskIndex(paths);
864
- } catch {
865
- current = {
866
- schema_version: TASK_INDEX_SCHEMA_VERSION,
867
- tasks: [],
868
- last_rebuilt_at: nowFn().toISOString()
869
- };
1241
+ for (const event of events) {
1242
+ validated.push(EventSchema.parse(event));
1243
+ }
1244
+ } catch (error) {
1245
+ throw new Error("Invalid Basou event payload", { cause: error });
870
1246
  }
871
- let nextTasks;
872
- switch (op.kind) {
873
- case "add":
874
- nextTasks = current.tasks.some((t) => t.id === op.entry.id) ? current.tasks.map((t) => t.id === op.entry.id ? op.entry : t) : [...current.tasks, op.entry];
875
- break;
876
- case "update":
877
- nextTasks = current.tasks.some((t) => t.id === op.entry.id) ? current.tasks.map((t) => t.id === op.entry.id ? op.entry : t) : [...current.tasks, op.entry];
878
- break;
879
- case "remove":
880
- nextTasks = current.tasks.filter((t) => t.id !== op.id);
881
- break;
1247
+ const filePath = join5(sessionDir, "events.jsonl");
1248
+ const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
1249
+ ` : "";
1250
+ try {
1251
+ await atomicReplace(filePath, body);
1252
+ } catch (error) {
1253
+ throw new Error("Failed to write events.jsonl", { cause: error });
882
1254
  }
883
- return await rebuildTaskIndex(paths, nextTasks, nowFn);
884
1255
  }
885
1256
 
1257
+ // src/git/snapshot.ts
1258
+ import { simpleGit } from "simple-git";
1259
+
886
1260
  // src/storage/status.ts
887
1261
  import * as fsp from "fs/promises";
1262
+
1263
+ // src/schemas/status.schema.ts
1264
+ import { z as z5 } from "zod";
1265
+ var StatusSchema = z5.object({
1266
+ schema_version: SchemaVersionSchema,
1267
+ generated_at: IsoTimestampSchema,
1268
+ workspace: z5.object({
1269
+ id: WorkspaceIdSchema,
1270
+ name: z5.string().min(1),
1271
+ basou_version: z5.literal("0.1.0")
1272
+ }).strict(),
1273
+ directories_present: z5.object({
1274
+ sessions: z5.boolean(),
1275
+ tasks: z5.boolean(),
1276
+ approvals_pending: z5.boolean(),
1277
+ approvals_resolved: z5.boolean(),
1278
+ logs: z5.boolean(),
1279
+ raw: z5.boolean(),
1280
+ tmp: z5.boolean()
1281
+ }).strict()
1282
+ }).strict();
1283
+
1284
+ // src/storage/status.ts
888
1285
  var DIRECTORY_CHECKS = {
889
1286
  sessions: (p) => p.sessions,
890
1287
  tasks: (p) => p.tasks,
@@ -899,7 +1296,7 @@ async function assertBasouRootSafe(rootPath) {
899
1296
  try {
900
1297
  stat3 = await fsp.lstat(rootPath);
901
1298
  } catch (error) {
902
- if (hasErrorCode5(error) && error.code === "ENOENT") {
1299
+ if (hasErrorCode2(error) && error.code === "ENOENT") {
903
1300
  throw new Error("Basou workspace not found", { cause: error });
904
1301
  }
905
1302
  throw new Error("Failed to inspect .basou root", { cause: error });
@@ -915,7 +1312,7 @@ async function dirPresent(path2) {
915
1312
  try {
916
1313
  return (await fsp.lstat(path2)).isDirectory();
917
1314
  } catch (error) {
918
- if (hasErrorCode5(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
1315
+ if (hasErrorCode2(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
919
1316
  return false;
920
1317
  }
921
1318
  throw new Error("Failed to inspect .basou subdirectory", { cause: error });
@@ -939,352 +1336,269 @@ async function buildStatusSnapshot(input) {
939
1336
  },
940
1337
  directories_present: directoriesEntries
941
1338
  };
942
- return StatusSchema.parse(snapshot);
943
- }
944
- async function writeStatus(paths, snapshot) {
945
- const validated = StatusSchema.parse(snapshot);
946
- const body = `${JSON.stringify(validated, null, 2)}
947
- `;
948
- try {
949
- await atomicReplace(paths.files.status, body);
950
- } catch (error) {
951
- throw new Error("Failed to write status file", { cause: error });
952
- }
953
- }
954
- async function readStatus(paths) {
955
- let body;
956
- try {
957
- body = await fsp.readFile(paths.files.status, "utf8");
958
- } catch (error) {
959
- if (hasErrorCode5(error) && error.code === "ENOENT") {
960
- throw new Error("Status file not found", { cause: error });
961
- }
962
- throw new Error("Failed to read status file", { cause: error });
963
- }
964
- let parsed;
965
- try {
966
- parsed = JSON.parse(body);
967
- } catch (error) {
968
- throw new Error("Failed to parse status JSON", { cause: error });
969
- }
970
- return StatusSchema.parse(parsed);
971
- }
972
- function hasErrorCode5(error) {
973
- if (!(error instanceof Error)) return false;
974
- return typeof error.code === "string";
975
- }
976
-
977
- // src/storage/sessions.ts
978
- import { readdir as readdir2 } from "fs/promises";
979
- import { join as join7 } from "path";
980
-
981
- // src/events/event-replay.ts
982
- import { createReadStream } from "fs";
983
- import { stat } from "fs/promises";
984
- import { join as join6 } from "path";
985
- async function* replayEvents(sessionDir, options = {}) {
986
- const filePath = join6(sessionDir, "events.jsonl");
987
- try {
988
- await stat(filePath);
989
- } catch (error) {
990
- if (findErrorCode(error, "ENOENT")) return;
991
- throw new Error("Failed to read events.jsonl", { cause: error });
992
- }
993
- let stream;
994
- try {
995
- stream = createReadStream(filePath, { encoding: "utf8" });
996
- } catch (error) {
997
- throw new Error("Failed to read events.jsonl", { cause: error });
998
- }
999
- let buffer = "";
1000
- let lineNo = 0;
1001
- try {
1002
- for await (const chunk of stream) {
1003
- buffer += chunk;
1004
- let newlineIdx = buffer.indexOf("\n");
1005
- while (newlineIdx !== -1) {
1006
- lineNo += 1;
1007
- const rawLine = buffer.slice(0, newlineIdx);
1008
- buffer = buffer.slice(newlineIdx + 1);
1009
- const ev = processLine(rawLine, lineNo, options);
1010
- if (ev !== null) yield ev;
1011
- newlineIdx = buffer.indexOf("\n");
1012
- }
1013
- }
1014
- } catch (error) {
1015
- throw new Error("Failed to read events.jsonl", { cause: error });
1016
- }
1017
- const trimmed = buffer.replace(/[\r\n\t ]+$/u, "");
1018
- if (trimmed.length === 0) return;
1019
- lineNo += 1;
1020
- let parsed;
1021
- try {
1022
- parsed = JSON.parse(trimmed);
1023
- } catch (cause) {
1024
- options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
1025
- return;
1026
- }
1027
- const result = EventSchema.safeParse(parsed);
1028
- if (!result.success) {
1029
- options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
1030
- return;
1031
- }
1032
- options.onWarning?.({ kind: "partial_trailing_line", line: lineNo });
1033
- }
1034
- function processLine(rawLine, lineNo, options) {
1035
- const trimmed = rawLine.trim();
1036
- if (trimmed.length === 0) return null;
1037
- let parsed;
1038
- try {
1039
- parsed = JSON.parse(trimmed);
1040
- } catch (cause) {
1041
- options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
1042
- return null;
1043
- }
1044
- const result = EventSchema.safeParse(parsed);
1045
- if (!result.success) {
1046
- options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
1047
- return null;
1048
- }
1049
- return result.data;
1050
- }
1051
- async function readAllEvents(sessionDir, options = {}) {
1052
- const out = [];
1053
- for await (const ev of replayEvents(sessionDir, options)) {
1054
- out.push(ev);
1055
- }
1056
- return out;
1339
+ return StatusSchema.parse(snapshot);
1057
1340
  }
1058
-
1059
- // src/storage/sessions.ts
1060
- var STUCK_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
1061
- async function enumerateSessionDirs(paths) {
1341
+ async function writeStatus(paths, snapshot) {
1342
+ const validated = StatusSchema.parse(snapshot);
1343
+ const body = `${JSON.stringify(validated, null, 2)}
1344
+ `;
1062
1345
  try {
1063
- const dirents = await readdir2(paths.sessions, { withFileTypes: true });
1064
- return dirents.filter((d) => d.isDirectory()).map((d) => d.name).sort();
1346
+ await atomicReplace(paths.files.status, body);
1065
1347
  } catch (error) {
1066
- if (findErrorCode(error, "ENOENT")) return [];
1067
- throw new Error("Failed to enumerate sessions", { cause: error });
1348
+ throw new Error("Failed to write status file", { cause: error });
1068
1349
  }
1069
1350
  }
1070
- async function readSessionYaml(paths, sessionId) {
1071
- const filePath = join7(paths.sessions, sessionId, "session.yaml");
1072
- let raw;
1351
+ async function readStatus(paths) {
1352
+ let body;
1073
1353
  try {
1074
- raw = await readYamlFile(filePath);
1354
+ body = await fsp.readFile(paths.files.status, "utf8");
1075
1355
  } catch (error) {
1076
- if (error instanceof Error && error.message === "YAML file not found") throw error;
1077
- throw new Error("Failed to read session.yaml", { cause: error });
1356
+ if (hasErrorCode2(error) && error.code === "ENOENT") {
1357
+ throw new Error("Status file not found", { cause: error });
1358
+ }
1359
+ throw new Error("Failed to read status file", { cause: error });
1078
1360
  }
1079
- const result = SessionSchema.safeParse(raw);
1080
- if (!result.success) {
1081
- throw new Error("Failed to read session.yaml", { cause: result.error });
1361
+ let parsed;
1362
+ try {
1363
+ parsed = JSON.parse(body);
1364
+ } catch (error) {
1365
+ throw new Error("Failed to parse status JSON", { cause: error });
1082
1366
  }
1083
- return result.data;
1367
+ return StatusSchema.parse(parsed);
1084
1368
  }
1085
- async function classifySuspect(paths, sessionId, session, now, onWarning) {
1086
- if (session.session.status !== "running") {
1087
- return { suspect: false, suspectReason: null };
1088
- }
1089
- const sessionDir = join7(paths.sessions, sessionId);
1090
- let endedFound = false;
1091
- let lastEventOccurredAt = null;
1092
- const replayOpts = onWarning !== void 0 ? { onWarning } : {};
1093
- for await (const ev of replayEvents(sessionDir, replayOpts)) {
1094
- lastEventOccurredAt = ev.occurred_at;
1095
- if (ev.type === "session_ended") endedFound = true;
1096
- }
1097
- if (endedFound) {
1098
- return { suspect: true, suspectReason: "events_say_ended_but_yaml_running" };
1099
- }
1100
- if (lastEventOccurredAt !== null) {
1101
- const ageMs = now.getTime() - Date.parse(lastEventOccurredAt);
1102
- if (Number.isFinite(ageMs) && ageMs > STUCK_THRESHOLD_MS) {
1103
- return { suspect: true, suspectReason: "running_no_end_event" };
1104
- }
1369
+ function hasErrorCode2(error) {
1370
+ if (!(error instanceof Error)) return false;
1371
+ return typeof error.code === "string";
1372
+ }
1373
+
1374
+ // src/git/snapshot.ts
1375
+ function safeSimpleGit(repoRoot) {
1376
+ return simpleGit({ baseDir: repoRoot });
1377
+ }
1378
+ function isGitNotFound(error) {
1379
+ if (findErrorCode(error, "ENOENT")) return true;
1380
+ let cur = error;
1381
+ for (let i = 0; i < 4 && cur instanceof Error; i++) {
1382
+ if (/\bENOENT\b/.test(cur.message)) return true;
1383
+ cur = cur.cause;
1105
1384
  }
1106
- return { suspect: false, suspectReason: null };
1385
+ return false;
1107
1386
  }
1108
- async function loadSessionEntries(paths, options) {
1109
- const sessionIds = await enumerateSessionDirs(paths);
1110
- const entries = [];
1111
- for (const sid of sessionIds) {
1112
- let session;
1113
- try {
1114
- session = await readSessionYaml(paths, sid);
1115
- } catch (error) {
1116
- if (error instanceof Error && error.message === "YAML file not found") {
1117
- options.onSkip?.(sid, "session_yaml_missing");
1118
- } else {
1119
- options.onSkip?.(sid, "session_yaml_invalid");
1120
- }
1121
- continue;
1387
+ async function resolveRepositoryRoot(cwd) {
1388
+ const git = safeSimpleGit(cwd);
1389
+ try {
1390
+ const root = (await git.revparse(["--show-toplevel"])).trimEnd();
1391
+ if (root.length === 0) {
1392
+ throw new Error("Not a git repository");
1122
1393
  }
1123
- let suspect = false;
1124
- let suspectReason = null;
1125
- try {
1126
- const r = await classifySuspect(
1127
- paths,
1128
- sid,
1129
- session,
1130
- options.now,
1131
- (w) => options.onWarning?.(w, sid)
1132
- );
1133
- suspect = r.suspect;
1134
- suspectReason = r.suspectReason;
1135
- } catch {
1136
- options.onSkip?.(sid, "events_jsonl_unreadable");
1394
+ return root;
1395
+ } catch (error) {
1396
+ if (isGitNotFound(error)) {
1397
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1137
1398
  }
1138
- entries.push({ sessionId: sid, session, suspect, suspectReason });
1399
+ if (error instanceof Error && error.message === "Not a git repository") {
1400
+ throw error;
1401
+ }
1402
+ throw new Error("Not a git repository", { cause: error });
1139
1403
  }
1140
- return entries;
1141
1404
  }
1142
-
1143
- // src/storage/markdown-store.ts
1144
- import { readFile as readFile6 } from "fs/promises";
1145
- var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
1146
- var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
1147
- async function readMarkdownFile(filePath) {
1405
+ async function tryRemoteUrl(repositoryRoot) {
1406
+ const git = safeSimpleGit(repositoryRoot);
1148
1407
  try {
1149
- return await readFile6(filePath, "utf8");
1150
- } catch (error) {
1151
- if (hasErrorCode6(error) && error.code === "ENOENT") return null;
1152
- throw new Error("Failed to read markdown file", { cause: error });
1408
+ const result = await git.getConfig("remote.origin.url", "local");
1409
+ const url = (result.value ?? "").trimEnd();
1410
+ return url.length > 0 ? url : void 0;
1411
+ } catch {
1412
+ return void 0;
1153
1413
  }
1154
1414
  }
1155
- async function writeMarkdownFile(filePath, body) {
1415
+ async function getSnapshot(repositoryRoot) {
1416
+ const git = safeSimpleGit(repositoryRoot);
1417
+ let inside;
1156
1418
  try {
1157
- await atomicReplace(filePath, body);
1419
+ inside = await git.checkIsRepo();
1158
1420
  } catch (error) {
1159
- throw new Error("Failed to write markdown file", { cause: error });
1421
+ if (isGitNotFound(error)) {
1422
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1423
+ }
1424
+ throw new Error("Failed to read git state", { cause: error });
1160
1425
  }
1161
- }
1162
- function parseMarkers(content) {
1163
- const lines = content.split(/\r?\n/);
1164
- const startLines = [];
1165
- const endLines = [];
1166
- for (let i = 0; i < lines.length; i++) {
1167
- if (lines[i] === GENERATED_START) startLines.push(i);
1168
- else if (lines[i] === GENERATED_END) endLines.push(i);
1426
+ if (!inside) {
1427
+ throw new Error("Not a git repository");
1169
1428
  }
1170
- if (startLines.length === 0 && endLines.length === 0) return { kind: "no_markers" };
1171
- if (startLines.length === 0) return { kind: "missing_start" };
1172
- if (endLines.length === 0) return { kind: "missing_end" };
1173
- if (startLines.length >= 2 || endLines.length >= 2) return { kind: "multiple_pairs" };
1174
- const startLineIdx = startLines[0];
1175
- const endLineIdx = endLines[0];
1176
- if (endLineIdx < startLineIdx) return { kind: "wrong_order" };
1177
- const startOffset = lineStartOffset(content, startLineIdx);
1178
- const endLineStart = lineStartOffset(content, endLineIdx);
1179
- const startLineEnd = startOffset + GENERATED_START.length;
1180
- const endLineEnd = endLineStart + GENERATED_END.length;
1181
- const before = content.slice(0, startOffset);
1182
- const afterStartNewline = skipOneNewline(content, startLineEnd);
1183
- const beforeEndNewline = trimOneNewline(content, endLineStart);
1184
- const generated = content.slice(afterStartNewline, beforeEndNewline);
1185
- const after = content.slice(endLineEnd);
1186
- return { kind: "ok", before, generated, after };
1187
- }
1188
- function renderWithMarkers(existing, generated, fileLabel) {
1189
- const normalized = generated.endsWith("\n") ? generated : `${generated}
1190
- `;
1191
- if (existing === null) {
1192
- return `${GENERATED_START}
1193
- ${normalized}${GENERATED_END}
1194
- `;
1429
+ let head;
1430
+ try {
1431
+ head = (await git.revparse(["HEAD"])).trimEnd();
1432
+ } catch (error) {
1433
+ if (isGitNotFound(error)) {
1434
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1435
+ }
1436
+ throw new Error("No commits in repository", { cause: error });
1195
1437
  }
1196
- const section = parseMarkers(existing);
1197
- switch (section.kind) {
1198
- case "ok":
1199
- return `${section.before}${GENERATED_START}
1200
- ${normalized}${GENERATED_END}${section.after}`;
1201
- case "no_markers":
1202
- throw new Error(`Markers missing in ${fileLabel}`);
1203
- case "missing_start":
1204
- case "missing_end":
1205
- case "multiple_pairs":
1206
- case "wrong_order":
1207
- throw new Error(`Markers mismatched in ${fileLabel}`);
1438
+ if (head.length === 0) {
1439
+ throw new Error("No commits in repository");
1208
1440
  }
1209
- }
1210
- function lineStartOffset(content, lineIdx) {
1211
- if (lineIdx === 0) return 0;
1212
- let offset = 0;
1213
- let line = 0;
1214
- while (offset < content.length && line < lineIdx) {
1215
- const ch = content[offset];
1216
- if (ch === "\n") {
1217
- line += 1;
1218
- offset += 1;
1219
- } else if (ch === "\r") {
1220
- offset += 1;
1221
- if (content[offset] === "\n") offset += 1;
1222
- line += 1;
1223
- } else {
1224
- offset += 1;
1225
- }
1441
+ let branch;
1442
+ try {
1443
+ const raw = (await git.raw(["branch", "--show-current"])).trimEnd();
1444
+ branch = raw.length > 0 ? raw : "HEAD";
1445
+ } catch (error) {
1446
+ throw new Error("Failed to read git state", { cause: error });
1226
1447
  }
1227
- return offset;
1228
- }
1229
- function skipOneNewline(content, offset) {
1230
- if (content[offset] === "\r" && content[offset + 1] === "\n") return offset + 2;
1231
- if (content[offset] === "\n") return offset + 1;
1232
- return offset;
1233
- }
1234
- function trimOneNewline(content, offset) {
1235
- if (offset >= 2 && content[offset - 2] === "\r" && content[offset - 1] === "\n")
1236
- return offset - 2;
1237
- if (offset >= 1 && content[offset - 1] === "\n") return offset - 1;
1238
- return offset;
1239
- }
1240
- function hasErrorCode6(error) {
1241
- if (!(error instanceof Error)) return false;
1242
- const codeProp = error.code;
1243
- return typeof codeProp === "string";
1448
+ let dirty;
1449
+ const staged = [];
1450
+ const unstaged = [];
1451
+ const untracked = [];
1452
+ try {
1453
+ const status = await git.status();
1454
+ dirty = !status.isClean();
1455
+ for (const f of status.files) {
1456
+ if (f.index === "?" && f.working_dir === "?") {
1457
+ untracked.push(f.path);
1458
+ continue;
1459
+ }
1460
+ if (f.index !== " " && f.index !== "?") staged.push(f.path);
1461
+ if (f.working_dir !== " " && f.working_dir !== "?") unstaged.push(f.path);
1462
+ }
1463
+ } catch (error) {
1464
+ throw new Error("Failed to read git state", { cause: error });
1465
+ }
1466
+ let ahead;
1467
+ let behind;
1468
+ if (branch !== "HEAD") {
1469
+ try {
1470
+ const upstream = `${branch}@{upstream}`;
1471
+ const counts = (await git.raw(["rev-list", "--left-right", "--count", `${upstream}...HEAD`])).trim();
1472
+ const [behindStr, aheadStr] = counts.split(/\s+/);
1473
+ const parsedBehind = Number.parseInt(behindStr ?? "", 10);
1474
+ const parsedAhead = Number.parseInt(aheadStr ?? "", 10);
1475
+ if (Number.isFinite(parsedBehind) && parsedBehind >= 0) behind = parsedBehind;
1476
+ if (Number.isFinite(parsedAhead) && parsedAhead >= 0) ahead = parsedAhead;
1477
+ } catch {
1478
+ }
1479
+ }
1480
+ const snapshot = {
1481
+ head,
1482
+ branch,
1483
+ dirty,
1484
+ staged,
1485
+ unstaged,
1486
+ untracked,
1487
+ ...ahead !== void 0 ? { ahead } : {},
1488
+ ...behind !== void 0 ? { behind } : {}
1489
+ };
1490
+ return snapshot;
1244
1491
  }
1245
1492
 
1246
- // src/storage/session-import.ts
1247
- import { mkdir as mkdir4, rm as rm2 } from "fs/promises";
1248
- import { homedir as homedir2 } from "os";
1249
- import { join as join11 } from "path";
1250
-
1251
- // src/events/event-writer.ts
1252
- import { appendFile } from "fs/promises";
1253
- import { join as join8 } from "path";
1254
- async function appendEvent(sessionDir, event) {
1255
- let validated;
1493
+ // src/git/diff.ts
1494
+ async function getDiff(repoRoot, baseRef, headRef) {
1495
+ let git;
1256
1496
  try {
1257
- validated = EventSchema.parse(event);
1497
+ git = safeSimpleGit(repoRoot);
1258
1498
  } catch (error) {
1259
- throw new Error("Invalid Basou event payload", { cause: error });
1499
+ if (isGitNotFound(error)) {
1500
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1501
+ }
1502
+ throw new Error("Not a git repository", { cause: error });
1260
1503
  }
1261
- const line = `${JSON.stringify(validated)}
1262
- `;
1504
+ if (baseRef === headRef) return { changed_files: [] };
1505
+ let raw;
1263
1506
  try {
1264
- await appendFile(join8(sessionDir, "events.jsonl"), line, "utf8");
1507
+ raw = await git.raw(["diff", "--name-status", `${baseRef}..${headRef}`]);
1265
1508
  } catch (error) {
1266
- throw new Error("Failed to append event to events.jsonl", { cause: error });
1509
+ if (isGitNotFound(error)) {
1510
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1511
+ }
1512
+ const message = error instanceof Error ? error.message : "";
1513
+ if (/not a git repository/i.test(message)) {
1514
+ throw new Error("Not a git repository", { cause: error });
1515
+ }
1516
+ if (message.includes("bad revision") || message.includes("unknown revision") || message.includes("ambiguous argument")) {
1517
+ throw new Error("Invalid ref", { cause: error });
1518
+ }
1519
+ throw new Error("Failed to compute git diff", { cause: error });
1267
1520
  }
1521
+ return { changed_files: parseDiffNameStatus(raw) };
1268
1522
  }
1269
- async function writeEventsBulk(sessionDir, events) {
1270
- const validated = [];
1271
- try {
1272
- for (const event of events) {
1273
- validated.push(EventSchema.parse(event));
1523
+ function parseDiffNameStatus(raw) {
1524
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
1525
+ const changes = [];
1526
+ for (const line of lines) {
1527
+ const parts = line.split(" ");
1528
+ const code = parts[0];
1529
+ if (code === void 0 || code.length === 0) continue;
1530
+ if (code.startsWith("R") && parts.length >= 3) {
1531
+ const newPath = parts[2];
1532
+ const oldPath = parts[1];
1533
+ if (newPath === void 0) continue;
1534
+ changes.push({
1535
+ path: newPath,
1536
+ status: "renamed",
1537
+ ...oldPath !== void 0 ? { old_path: oldPath } : {}
1538
+ });
1539
+ } else if (code === "A" && parts[1]) {
1540
+ changes.push({ path: parts[1], status: "added" });
1541
+ } else if (code === "M" && parts[1]) {
1542
+ changes.push({ path: parts[1], status: "modified" });
1543
+ } else if (code === "D" && parts[1]) {
1544
+ changes.push({ path: parts[1], status: "deleted" });
1274
1545
  }
1275
- } catch (error) {
1276
- throw new Error("Invalid Basou event payload", { cause: error });
1277
- }
1278
- const filePath = join8(sessionDir, "events.jsonl");
1279
- const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
1280
- ` : "";
1281
- try {
1282
- await atomicReplace(filePath, body);
1283
- } catch (error) {
1284
- throw new Error("Failed to write events.jsonl", { cause: error });
1285
1546
  }
1547
+ return changes;
1286
1548
  }
1287
1549
 
1550
+ // src/handoff/handoff-renderer.ts
1551
+ import { join as join10 } from "path";
1552
+
1553
+ // src/storage/tasks.ts
1554
+ import { createHash } from "crypto";
1555
+ import { mkdir as mkdir2, readdir as readdir3, readFile as readFile5, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
1556
+ import { join as join9 } from "path";
1557
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1558
+ import { z as z8 } from "zod";
1559
+
1560
+ // src/schemas/task.schema.ts
1561
+ import { z as z6 } from "zod";
1562
+ var TaskStatusSchema = z6.enum(["planned", "in_progress", "done", "cancelled"]);
1563
+ var TaskInnerSchema = z6.object({
1564
+ id: TaskIdSchema,
1565
+ title: z6.string().min(1),
1566
+ label: z6.string().min(1).optional(),
1567
+ status: TaskStatusSchema,
1568
+ created_at: IsoTimestampSchema,
1569
+ updated_at: IsoTimestampSchema,
1570
+ workspace_id: WorkspaceIdSchema,
1571
+ /**
1572
+ * Session id that anchors this task. For freshly created tasks it is the
1573
+ * session that wrote the `task_created` event (= ad-hoc reconcile target
1574
+ * for ad-hoc paths, or the target session id for attach paths). After
1575
+ * `basou task reconcile --write` repairs a broken anchor the
1576
+ * value is replaced with the ad-hoc reconcile session id; the old broken
1577
+ * session_id is preserved on the `task_reconciled` event payload via
1578
+ * `removed_created_in_session` for audit. So this field always names a
1579
+ * reachable session, even after the original anchor is gone.
1580
+ */
1581
+ created_in_session: SessionIdSchema,
1582
+ /**
1583
+ * Snapshot of sessions linked to this task. The events.jsonl history is
1584
+ * the source of truth (see
1585
+ * `docs/spec/generated-markdown.md#105-decisionsmd-generation-principle`);
1586
+ * this field is maintained as a UX-only cache so editors can read the
1587
+ * task.md and immediately see related sessions. Defaults to `[]` for
1588
+ * backward compatibility.
1589
+ */
1590
+ linked_sessions: z6.array(SessionIdSchema).default([])
1591
+ });
1592
+ var TaskSchema = z6.object({
1593
+ schema_version: SchemaVersionSchema,
1594
+ task: TaskInnerSchema
1595
+ });
1596
+
1597
+ // src/storage/ad-hoc-session.ts
1598
+ import { mkdir, rm } from "fs/promises";
1599
+ import { homedir } from "os";
1600
+ import { join as join6 } from "path";
1601
+
1288
1602
  // src/lib/path-sanitizer.ts
1289
1603
  import { posix as path } from "path";
1290
1604
  function sanitizePath(rawPath, opts) {
@@ -1326,17 +1640,7 @@ function sanitizeRelatedFiles(paths, opts) {
1326
1640
  return { sanitized, mutationCount };
1327
1641
  }
1328
1642
 
1329
- // src/storage/tasks.ts
1330
- import { createHash } from "crypto";
1331
- import { mkdir as mkdir3, readFile as readFile7, readdir as readdir3, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
1332
- import { join as join10 } from "path";
1333
- import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1334
- import { z as z10 } from "zod";
1335
-
1336
1643
  // src/storage/ad-hoc-session.ts
1337
- import { mkdir as mkdir2, rm } from "fs/promises";
1338
- import { homedir } from "os";
1339
- import { join as join9 } from "path";
1340
1644
  var FailedToFinalizeError = class extends Error {
1341
1645
  sessionId;
1342
1646
  targetEventIds;
@@ -1373,13 +1677,13 @@ async function createAdHocSessionWithEvent(input) {
1373
1677
  taskId: input.taskId ?? null
1374
1678
  })
1375
1679
  );
1376
- const sessionDir = join9(input.paths.sessions, sessionId);
1680
+ const sessionDir = join6(input.paths.sessions, sessionId);
1377
1681
  try {
1378
- await mkdir2(sessionDir, { recursive: true });
1682
+ await mkdir(sessionDir, { recursive: true });
1379
1683
  } catch (error) {
1380
1684
  throw new Error("Failed to create session directory", { cause: error });
1381
1685
  }
1382
- const sessionYamlPath = join9(sessionDir, "session.yaml");
1686
+ const sessionYamlPath = join6(sessionDir, "session.yaml");
1383
1687
  try {
1384
1688
  await linkYamlFile(sessionYamlPath, initialSession);
1385
1689
  } catch (error) {
@@ -1496,24 +1800,180 @@ async function appendEventToExistingSession(input) {
1496
1800
  if (status === "imported") {
1497
1801
  throw new Error("Cannot attach to imported session");
1498
1802
  }
1499
- const attachable = input.attachableStatuses ?? DEFAULT_ATTACHABLE_STATUSES;
1500
- if (!attachable.has(status)) {
1501
- throw new Error(`Session is not active: ${status}`);
1803
+ const attachable = input.attachableStatuses ?? DEFAULT_ATTACHABLE_STATUSES;
1804
+ if (!attachable.has(status)) {
1805
+ throw new Error(`Session is not active: ${status}`);
1806
+ }
1807
+ const eventId = prefixedUlid("evt");
1808
+ const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
1809
+ const sessionDir = join6(input.paths.sessions, input.sessionId);
1810
+ await appendEvent(sessionDir, event);
1811
+ return { eventId, sessionStatus: status };
1812
+ }
1813
+ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1814
+ if (event.session_id !== expectedSessionId) {
1815
+ throw new Error("Target event session_id mismatch");
1816
+ }
1817
+ if (event.id !== expectedEventId) {
1818
+ throw new Error("Target event id mismatch");
1819
+ }
1820
+ return event;
1821
+ }
1822
+
1823
+ // src/storage/lockfile.ts
1824
+ import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1825
+ import { join as join7 } from "path";
1826
+ var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
1827
+ async function acquireLock(paths, scope, resourceId) {
1828
+ const lockPath = lockfilePath(paths, scope, resourceId);
1829
+ const body = {
1830
+ pid: process.pid,
1831
+ acquired_at: (/* @__PURE__ */ new Date()).toISOString()
1832
+ };
1833
+ const serialised = JSON.stringify(body);
1834
+ try {
1835
+ await atomicCreate(lockPath, serialised);
1836
+ } catch (error) {
1837
+ if (!findErrorCode(error, "EEXIST")) {
1838
+ throw error;
1839
+ }
1840
+ const stale = await isStaleLock(lockPath);
1841
+ if (!stale) {
1842
+ throw new Error("Lock is held by another process", { cause: error });
1843
+ }
1844
+ await unlink2(lockPath).catch(() => void 0);
1845
+ try {
1846
+ await atomicCreate(lockPath, serialised);
1847
+ } catch (retryError) {
1848
+ throw new Error("Lock is held by another process", { cause: retryError });
1849
+ }
1850
+ }
1851
+ return {
1852
+ release: async () => {
1853
+ await unlink2(lockPath).catch(() => void 0);
1854
+ }
1855
+ };
1856
+ }
1857
+ async function isStaleLock(lockPath) {
1858
+ let body;
1859
+ try {
1860
+ const raw = await readFile3(lockPath, "utf8");
1861
+ const parsed = JSON.parse(raw);
1862
+ if (typeof parsed !== "object" || parsed === null) return true;
1863
+ const candidate = parsed;
1864
+ if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
1865
+ return true;
1866
+ }
1867
+ body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
1868
+ } catch {
1869
+ return true;
1870
+ }
1871
+ const ageMs = Date.now() - Date.parse(body.acquired_at);
1872
+ if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
1873
+ return true;
1874
+ }
1875
+ try {
1876
+ process.kill(body.pid, 0);
1877
+ return false;
1878
+ } catch (error) {
1879
+ if (findErrorCode(error, "ESRCH")) return true;
1880
+ return false;
1881
+ }
1882
+ }
1883
+ function lockfilePath(paths, scope, resourceId) {
1884
+ const sep = resourceId.indexOf("_");
1885
+ const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
1886
+ return join7(paths.locks, `${scope}_${ulid2}.lock`);
1887
+ }
1888
+
1889
+ // src/storage/task-index.ts
1890
+ import { readFile as readFile4 } from "fs/promises";
1891
+ import { join as join8 } from "path";
1892
+
1893
+ // src/schemas/task-index.schema.ts
1894
+ import { z as z7 } from "zod";
1895
+ var TaskIndexEntrySchema = z7.object({
1896
+ id: TaskIdSchema,
1897
+ status: TaskStatusSchema,
1898
+ label: z7.string().min(1).optional(),
1899
+ updated_at: IsoTimestampSchema
1900
+ }).strict();
1901
+ var TaskIndexSchema = z7.object({
1902
+ schema_version: SchemaVersionSchema,
1903
+ tasks: z7.array(TaskIndexEntrySchema),
1904
+ last_rebuilt_at: IsoTimestampSchema
1905
+ }).strict();
1906
+ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
1907
+
1908
+ // src/storage/task-index.ts
1909
+ function taskIndexPath(paths) {
1910
+ return join8(paths.tasks, "index.json");
1911
+ }
1912
+ async function readTaskIndex(paths) {
1913
+ const filePath = taskIndexPath(paths);
1914
+ let raw;
1915
+ try {
1916
+ raw = await readFile4(filePath, "utf8");
1917
+ } catch (error) {
1918
+ if (findErrorCode(error, "ENOENT")) {
1919
+ throw new Error("Task index not found", { cause: error });
1920
+ }
1921
+ throw new Error("Failed to read task index", { cause: error });
1922
+ }
1923
+ let parsedJson;
1924
+ try {
1925
+ parsedJson = JSON.parse(raw);
1926
+ } catch (error) {
1927
+ throw new Error("Invalid task index", { cause: error });
1502
1928
  }
1503
- const eventId = prefixedUlid("evt");
1504
- const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
1505
- const sessionDir = join9(input.paths.sessions, input.sessionId);
1506
- await appendEvent(sessionDir, event);
1507
- return { eventId, sessionStatus: status };
1929
+ const result = TaskIndexSchema.safeParse(parsedJson);
1930
+ if (!result.success) {
1931
+ throw new Error("Invalid task index", { cause: result.error });
1932
+ }
1933
+ if (result.data.schema_version !== TASK_INDEX_SCHEMA_VERSION) {
1934
+ throw new Error("Invalid task index", {
1935
+ cause: new Error(`Unsupported task index schema_version: ${result.data.schema_version}`)
1936
+ });
1937
+ }
1938
+ return result.data;
1508
1939
  }
1509
- function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1510
- if (event.session_id !== expectedSessionId) {
1511
- throw new Error("Target event session_id mismatch");
1940
+ async function rebuildTaskIndex(paths, entries, now) {
1941
+ const sorted = [...entries].sort((a, b) => a.id.localeCompare(b.id));
1942
+ const payload = {
1943
+ schema_version: TASK_INDEX_SCHEMA_VERSION,
1944
+ tasks: sorted,
1945
+ last_rebuilt_at: (now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
1946
+ };
1947
+ TaskIndexSchema.parse(payload);
1948
+ await atomicReplace(taskIndexPath(paths), `${JSON.stringify(payload, null, 2)}
1949
+ `);
1950
+ return payload;
1951
+ }
1952
+ async function updateTaskIndex(paths, op, options) {
1953
+ const nowFn = options?.now ?? (() => /* @__PURE__ */ new Date());
1954
+ let current;
1955
+ try {
1956
+ current = await readTaskIndex(paths);
1957
+ } catch {
1958
+ current = {
1959
+ schema_version: TASK_INDEX_SCHEMA_VERSION,
1960
+ tasks: [],
1961
+ last_rebuilt_at: nowFn().toISOString()
1962
+ };
1512
1963
  }
1513
- if (event.id !== expectedEventId) {
1514
- throw new Error("Target event id mismatch");
1964
+ let nextTasks;
1965
+ switch (op.kind) {
1966
+ case "add":
1967
+ nextTasks = current.tasks.some((t) => t.id === op.entry.id) ? current.tasks.map((t) => t.id === op.entry.id ? op.entry : t) : [...current.tasks, op.entry];
1968
+ break;
1969
+ case "update":
1970
+ nextTasks = current.tasks.some((t) => t.id === op.entry.id) ? current.tasks.map((t) => t.id === op.entry.id ? op.entry : t) : [...current.tasks, op.entry];
1971
+ break;
1972
+ case "remove":
1973
+ nextTasks = current.tasks.filter((t) => t.id !== op.id);
1974
+ break;
1515
1975
  }
1516
- return event;
1976
+ return await rebuildTaskIndex(paths, nextTasks, nowFn);
1517
1977
  }
1518
1978
 
1519
1979
  // src/storage/tasks.ts
@@ -1526,8 +1986,8 @@ var DEFAULT_ATTACHABLE_STATUSES2 = /* @__PURE__ */ new Set([
1526
1986
  "waiting_approval"
1527
1987
  ]);
1528
1988
  var InitialTaskStatusSchema = TaskStatusSchema;
1529
- var TaskTitleSchema = z10.string().min(1);
1530
- var TaskLabelSchema = z10.string().min(1);
1989
+ var TaskTitleSchema = z8.string().min(1);
1990
+ var TaskLabelSchema = z8.string().min(1);
1531
1991
  var CompletedAtSchema = IsoTimestampSchema;
1532
1992
  var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set(["done", "cancelled"]);
1533
1993
  function isTerminalTaskStatus(status) {
@@ -1561,10 +2021,10 @@ function splitFrontMatter(raw) {
1561
2021
  return { yamlText, body };
1562
2022
  }
1563
2023
  async function readTaskFile(paths, taskId) {
1564
- const filePath = join10(paths.tasks, `${taskId}.md`);
2024
+ const filePath = join9(paths.tasks, `${taskId}.md`);
1565
2025
  let raw;
1566
2026
  try {
1567
- raw = await readFile7(filePath, "utf8");
2027
+ raw = await readFile5(filePath, "utf8");
1568
2028
  } catch (error) {
1569
2029
  if (findErrorCode(error, "ENOENT")) {
1570
2030
  throw new Error("Task file not found", { cause: error });
@@ -1594,7 +2054,7 @@ async function readTaskFile(paths, taskId) {
1594
2054
  }
1595
2055
  async function writeTaskFile(paths, taskId, doc, options) {
1596
2056
  const validated = TaskSchema.parse(doc.task);
1597
- const filePath = join10(paths.tasks, `${taskId}.md`);
2057
+ const filePath = join9(paths.tasks, `${taskId}.md`);
1598
2058
  const yamlText = stringifyYaml(validated);
1599
2059
  const trimmedBody = doc.body.length === 0 ? "" : `
1600
2060
  ${doc.body.endsWith("\n") ? doc.body : `${doc.body}
@@ -1679,7 +2139,7 @@ async function safeUpdateTaskIndex(paths, op) {
1679
2139
  }
1680
2140
  var ARCHIVE_DIR_NAME = "archive";
1681
2141
  function archiveTasksDir(paths) {
1682
- return join10(paths.tasks, ARCHIVE_DIR_NAME);
2142
+ return join9(paths.tasks, ARCHIVE_DIR_NAME);
1683
2143
  }
1684
2144
  async function enumerateArchivedTaskIds(paths) {
1685
2145
  let entries;
@@ -1709,10 +2169,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
1709
2169
  throw error;
1710
2170
  }
1711
2171
  }
1712
- const archiveFilePath = join10(archiveTasksDir(paths), `${taskId}.md`);
2172
+ const archiveFilePath = join9(archiveTasksDir(paths), `${taskId}.md`);
1713
2173
  let raw;
1714
2174
  try {
1715
- raw = await readFile7(archiveFilePath, "utf8");
2175
+ raw = await readFile5(archiveFilePath, "utf8");
1716
2176
  } catch (error) {
1717
2177
  if (findErrorCode(error, "ENOENT")) {
1718
2178
  throw new Error("Task file not found", { cause: error });
@@ -2003,7 +2463,7 @@ async function createTaskAttachLocked(input) {
2003
2463
  ...sessionDoc,
2004
2464
  session: { ...sessionDoc.session, task_id: input.taskId }
2005
2465
  };
2006
- await overwriteYamlFile(join10(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2466
+ await overwriteYamlFile(join9(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2007
2467
  } catch (error) {
2008
2468
  throw new TaskWriteAfterEventError({
2009
2469
  taskId: input.taskId,
@@ -2262,17 +2722,17 @@ function buildUpdatedDoc(input) {
2262
2722
  return { task: next, body: input.currentDoc.body };
2263
2723
  }
2264
2724
  async function computeTaskMdSnapshot(paths, taskId) {
2265
- const filePath = join10(paths.tasks, `${taskId}.md`);
2266
- const [stats, raw] = await Promise.all([stat2(filePath), readFile7(filePath)]);
2725
+ const filePath = join9(paths.tasks, `${taskId}.md`);
2726
+ const [stats, raw] = await Promise.all([stat2(filePath), readFile5(filePath)]);
2267
2727
  const hash = createHash("sha256").update(raw).digest("hex");
2268
2728
  return { mtimeMs: stats.mtimeMs, hash };
2269
2729
  }
2270
2730
  async function readTaskFileWithSnapshot(paths, taskId) {
2271
- const filePath = join10(paths.tasks, `${taskId}.md`);
2731
+ const filePath = join9(paths.tasks, `${taskId}.md`);
2272
2732
  let rawBuffer;
2273
2733
  let stats;
2274
2734
  try {
2275
- [rawBuffer, stats] = await Promise.all([readFile7(filePath), stat2(filePath)]);
2735
+ [rawBuffer, stats] = await Promise.all([readFile5(filePath), stat2(filePath)]);
2276
2736
  } catch (error) {
2277
2737
  if (findErrorCode(error, "ENOENT")) {
2278
2738
  throw new Error("Task file not found", { cause: error });
@@ -2630,416 +3090,230 @@ async function refreshTaskLinkedSessionsLocked(paths, manifest, input) {
2630
3090
  occurredAt: input.occurredAt
2631
3091
  });
2632
3092
  try {
2633
- await writeTaskFile(paths, input.taskId, refreshed, { mode: "overwrite" });
2634
- } catch (error) {
2635
- throw new TaskWriteAfterEventError({
2636
- taskId: input.taskId,
2637
- eventId: anchorEventId,
2638
- sessionId: adHoc.sessionId,
2639
- phase: "linkage-refresh",
2640
- cause: error
2641
- });
2642
- }
2643
- await safeUpdateTaskIndex(paths, {
2644
- kind: "update",
2645
- entry: buildTaskIndexEntry(refreshed.task.task)
2646
- });
2647
- return {
2648
- taskId: input.taskId,
2649
- clean: false,
2650
- addedLinkedSessions,
2651
- removedLinkedSessions,
2652
- finalCount: finalCountWithRefreshSession,
2653
- refreshSession: {
2654
- sessionId: adHoc.sessionId,
2655
- eventId: anchorEventId
2656
- }
2657
- };
2658
- }
2659
- async function editTask(input) {
2660
- TaskIdSchema.parse(input.taskId);
2661
- if (input.title === void 0 && input.newStatus === void 0) {
2662
- throw new Error("Nothing to edit: provide --title or --status");
2663
- }
2664
- if (input.title !== void 0) {
2665
- TaskTitleSchema.parse(input.title);
2666
- }
2667
- let statusUpdated = false;
2668
- let previousStatus = null;
2669
- let newStatus = null;
2670
- let statusChangeSession = null;
2671
- if (input.newStatus !== void 0) {
2672
- if (input.manifest === void 0 || input.workingDirectory === void 0) {
2673
- throw new Error("editTask requires manifest + workingDirectory when newStatus is supplied");
2674
- }
2675
- const result = await updateTaskStatusWithEvent({
2676
- mode: "ad-hoc",
2677
- paths: input.paths,
2678
- manifest: input.manifest,
2679
- occurredAt: input.occurredAt,
2680
- taskId: input.taskId,
2681
- newStatus: input.newStatus,
2682
- workingDirectory: input.workingDirectory
2683
- });
2684
- statusUpdated = true;
2685
- previousStatus = result.previousStatus;
2686
- newStatus = result.newStatus;
2687
- statusChangeSession = { sessionId: result.sessionId, eventId: result.eventId };
2688
- }
2689
- let titleUpdated = false;
2690
- if (input.title !== void 0) {
2691
- const handle = await acquireLock(input.paths, "task", input.taskId);
2692
- try {
2693
- const doc = await readTaskFile(input.paths, input.taskId);
2694
- if (doc.task.task.title !== input.title) {
2695
- const next = {
2696
- ...doc.task,
2697
- task: {
2698
- ...doc.task.task,
2699
- title: input.title,
2700
- updated_at: input.occurredAt
2701
- }
2702
- };
2703
- await writeTaskFile(
2704
- input.paths,
2705
- input.taskId,
2706
- { task: next, body: doc.body },
2707
- { mode: "overwrite" }
2708
- );
2709
- await safeUpdateTaskIndex(input.paths, {
2710
- kind: "update",
2711
- entry: buildTaskIndexEntry(next.task)
2712
- });
2713
- titleUpdated = true;
2714
- }
2715
- } finally {
2716
- await handle.release();
2717
- }
2718
- }
2719
- return {
2720
- taskId: input.taskId,
2721
- titleUpdated,
2722
- statusUpdated,
2723
- previousStatus,
2724
- newStatus,
2725
- statusChangeSession
2726
- };
2727
- }
2728
- async function deleteTask(input) {
2729
- TaskIdSchema.parse(input.taskId);
2730
- const handle = await acquireLock(input.paths, "task", input.taskId);
2731
- try {
2732
- return await deleteTaskLocked(input);
2733
- } finally {
2734
- await handle.release();
2735
- }
2736
- }
2737
- async function deleteTaskLocked(input) {
2738
- const doc = await readTaskFile(input.paths, input.taskId);
2739
- const title = doc.task.task.title;
2740
- const adHoc = await createAdHocSessionWithEvent({
2741
- paths: input.paths,
2742
- manifest: input.manifest,
2743
- label: buildAdHocDeleteLabel(title),
2744
- occurredAt: input.occurredAt,
2745
- sessionSource: "human",
2746
- workingDirectory: input.workingDirectory,
2747
- invocation: {
2748
- command: "basou task delete",
2749
- args: [input.taskId, "--yes"]
2750
- },
2751
- targetEventBuilders: [
2752
- (sessionId, eventId2) => buildTaskDeletedEvent({
2753
- eventId: eventId2,
2754
- sessionId,
2755
- taskId: input.taskId,
2756
- title,
2757
- occurredAt: input.occurredAt
2758
- })
2759
- ]
2760
- });
2761
- const eventId = adHoc.targetEventIds[0];
2762
- try {
2763
- await unlink3(join10(input.paths.tasks, `${input.taskId}.md`));
2764
- } catch (error) {
2765
- throw new TaskWriteAfterEventError({
2766
- taskId: input.taskId,
2767
- eventId,
2768
- sessionId: adHoc.sessionId,
2769
- phase: "delete",
2770
- cause: error
2771
- });
2772
- }
2773
- await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
2774
- return {
2775
- taskId: input.taskId,
2776
- title,
2777
- sessionId: adHoc.sessionId,
2778
- eventId
2779
- };
2780
- }
2781
- async function archiveTask(input) {
2782
- TaskIdSchema.parse(input.taskId);
2783
- const handle = await acquireLock(input.paths, "task", input.taskId);
2784
- try {
2785
- return await archiveTaskLocked(input);
2786
- } finally {
2787
- await handle.release();
2788
- }
2789
- }
2790
- async function archiveTaskLocked(input) {
2791
- const doc = await readTaskFile(input.paths, input.taskId);
2792
- const title = doc.task.task.title;
2793
- const adHoc = await createAdHocSessionWithEvent({
2794
- paths: input.paths,
2795
- manifest: input.manifest,
2796
- label: buildAdHocArchiveLabel(title),
2797
- occurredAt: input.occurredAt,
2798
- sessionSource: "human",
2799
- workingDirectory: input.workingDirectory,
2800
- invocation: {
2801
- command: "basou task archive",
2802
- args: [input.taskId, "--yes"]
2803
- },
2804
- taskId: input.taskId,
2805
- targetEventBuilders: [
2806
- (sessionId, eventId2) => buildTaskArchivedEvent({
2807
- eventId: eventId2,
2808
- sessionId,
2809
- taskId: input.taskId,
2810
- title,
2811
- occurredAt: input.occurredAt
2812
- })
2813
- ]
2814
- });
2815
- const eventId = adHoc.targetEventIds[0];
2816
- try {
2817
- const linked = doc.task.task.linked_sessions;
2818
- const merged = linked.includes(adHoc.sessionId) ? linked : [...linked, adHoc.sessionId];
2819
- const next = {
2820
- ...doc.task,
2821
- task: {
2822
- ...doc.task.task,
2823
- updated_at: input.occurredAt,
2824
- linked_sessions: merged
2825
- }
2826
- };
2827
- await writeTaskFile(
2828
- input.paths,
2829
- input.taskId,
2830
- { task: next, body: doc.body },
2831
- { mode: "overwrite" }
2832
- );
2833
- await mkdir3(archiveTasksDir(input.paths), { recursive: true });
2834
- await rename2(
2835
- join10(input.paths.tasks, `${input.taskId}.md`),
2836
- join10(archiveTasksDir(input.paths), `${input.taskId}.md`)
2837
- );
3093
+ await writeTaskFile(paths, input.taskId, refreshed, { mode: "overwrite" });
2838
3094
  } catch (error) {
2839
3095
  throw new TaskWriteAfterEventError({
2840
3096
  taskId: input.taskId,
2841
- eventId,
3097
+ eventId: anchorEventId,
2842
3098
  sessionId: adHoc.sessionId,
2843
- phase: "archive",
3099
+ phase: "linkage-refresh",
2844
3100
  cause: error
2845
3101
  });
2846
3102
  }
2847
- await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
3103
+ await safeUpdateTaskIndex(paths, {
3104
+ kind: "update",
3105
+ entry: buildTaskIndexEntry(refreshed.task.task)
3106
+ });
2848
3107
  return {
2849
3108
  taskId: input.taskId,
2850
- title,
2851
- sessionId: adHoc.sessionId,
2852
- eventId
2853
- };
2854
- }
2855
-
2856
- // src/storage/session-import.ts
2857
- async function importSessionFromJson(paths, manifest, payload, options) {
2858
- if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
2859
- throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
2860
- }
2861
- const effectiveSessionTaskId = options.taskIdOverride ?? payload.session.task_id ?? null;
2862
- await assertImportedTaskReferencesAreReachable(paths, payload.events, effectiveSessionTaskId);
2863
- const newSessionId = prefixedUlid("ses");
2864
- const rewrittenEvents = rewriteEvents(payload.events, newSessionId);
2865
- assertChronologicalOrder(rewrittenEvents);
2866
- const { record: sessionRecord, pathSanitizeReport } = buildSessionRecord(
2867
- payload.session,
2868
- manifest,
2869
- newSessionId,
2870
- options
2871
- );
2872
- if (options.dryRun === true) {
2873
- return {
2874
- sessionId: newSessionId,
2875
- eventCount: rewrittenEvents.length,
2876
- finalStatus: "imported",
2877
- finalSourceKind: sessionRecord.session.source.kind,
2878
- pathSanitizeReport
2879
- };
2880
- }
2881
- const sessionDir = join11(paths.sessions, newSessionId);
2882
- try {
2883
- await mkdir4(sessionDir, { recursive: true });
2884
- } catch (error) {
2885
- throw new Error("Failed to create session directory", { cause: error });
2886
- }
2887
- try {
2888
- await writeEventsBulk(sessionDir, rewrittenEvents);
2889
- } catch (error) {
2890
- await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
2891
- throw error;
2892
- }
2893
- try {
2894
- const sessionYamlPath = join11(sessionDir, "session.yaml");
2895
- await linkYamlFile(sessionYamlPath, sessionRecord);
2896
- } catch (error) {
2897
- await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
2898
- if (findErrorCode(error, "EEXIST")) {
2899
- throw new Error("Session directory collision (retry the command)", {
2900
- cause: error
2901
- });
3109
+ clean: false,
3110
+ addedLinkedSessions,
3111
+ removedLinkedSessions,
3112
+ finalCount: finalCountWithRefreshSession,
3113
+ refreshSession: {
3114
+ sessionId: adHoc.sessionId,
3115
+ eventId: anchorEventId
2902
3116
  }
2903
- throw error;
2904
- }
2905
- return {
2906
- sessionId: newSessionId,
2907
- eventCount: rewrittenEvents.length,
2908
- finalStatus: "imported",
2909
- finalSourceKind: sessionRecord.session.source.kind,
2910
- pathSanitizeReport
2911
3117
  };
2912
3118
  }
2913
- async function assertImportedTaskReferencesAreReachable(paths, events, effectiveSessionTaskId) {
2914
- const taskIdsToCheck = /* @__PURE__ */ new Set();
2915
- for (const ev of events) {
2916
- if (ev.type === "task_created" || ev.type === "task_status_changed" || ev.type === "task_reconciled" || ev.type === "task_linkage_refreshed" || ev.type === "task_deleted" || ev.type === "task_archived") {
2917
- taskIdsToCheck.add(ev.task_id);
2918
- }
2919
- }
2920
- if (effectiveSessionTaskId !== null) {
2921
- taskIdsToCheck.add(effectiveSessionTaskId);
3119
+ async function editTask(input) {
3120
+ TaskIdSchema.parse(input.taskId);
3121
+ if (input.title === void 0 && input.newStatus === void 0) {
3122
+ throw new Error("Nothing to edit: provide --title or --status");
2922
3123
  }
2923
- if (taskIdsToCheck.size === 0) {
2924
- return;
3124
+ if (input.title !== void 0) {
3125
+ TaskTitleSchema.parse(input.title);
2925
3126
  }
2926
- const knownTaskIds = new Set(await enumerateTaskIds(paths));
2927
- for (const id of taskIdsToCheck) {
2928
- if (!knownTaskIds.has(id)) {
2929
- throw new Error("Imported session references unknown task_id");
3127
+ let statusUpdated = false;
3128
+ let previousStatus = null;
3129
+ let newStatus = null;
3130
+ let statusChangeSession = null;
3131
+ if (input.newStatus !== void 0) {
3132
+ if (input.manifest === void 0 || input.workingDirectory === void 0) {
3133
+ throw new Error("editTask requires manifest + workingDirectory when newStatus is supplied");
2930
3134
  }
3135
+ const result = await updateTaskStatusWithEvent({
3136
+ mode: "ad-hoc",
3137
+ paths: input.paths,
3138
+ manifest: input.manifest,
3139
+ occurredAt: input.occurredAt,
3140
+ taskId: input.taskId,
3141
+ newStatus: input.newStatus,
3142
+ workingDirectory: input.workingDirectory
3143
+ });
3144
+ statusUpdated = true;
3145
+ previousStatus = result.previousStatus;
3146
+ newStatus = result.newStatus;
3147
+ statusChangeSession = { sessionId: result.sessionId, eventId: result.eventId };
2931
3148
  }
2932
- }
2933
- function rewriteEvents(events, newSessionId) {
2934
- return events.map((event) => ({
2935
- ...event,
2936
- id: prefixedUlid("evt"),
2937
- session_id: newSessionId
2938
- }));
2939
- }
2940
- function assertChronologicalOrder(events) {
2941
- for (let i = 1; i < events.length; i++) {
2942
- const prevEvent = events[i - 1];
2943
- const currEvent = events[i];
2944
- if (prevEvent === void 0 || currEvent === void 0) continue;
2945
- const prev = Date.parse(prevEvent.occurred_at);
2946
- const curr = Date.parse(currEvent.occurred_at);
2947
- if (!Number.isFinite(prev) || !Number.isFinite(curr) || curr < prev) {
2948
- throw new Error("Events are not in chronological order");
3149
+ let titleUpdated = false;
3150
+ if (input.title !== void 0) {
3151
+ const handle = await acquireLock(input.paths, "task", input.taskId);
3152
+ try {
3153
+ const doc = await readTaskFile(input.paths, input.taskId);
3154
+ if (doc.task.task.title !== input.title) {
3155
+ const next = {
3156
+ ...doc.task,
3157
+ task: {
3158
+ ...doc.task.task,
3159
+ title: input.title,
3160
+ updated_at: input.occurredAt
3161
+ }
3162
+ };
3163
+ await writeTaskFile(
3164
+ input.paths,
3165
+ input.taskId,
3166
+ { task: next, body: doc.body },
3167
+ { mode: "overwrite" }
3168
+ );
3169
+ await safeUpdateTaskIndex(input.paths, {
3170
+ kind: "update",
3171
+ entry: buildTaskIndexEntry(next.task)
3172
+ });
3173
+ titleUpdated = true;
3174
+ }
3175
+ } finally {
3176
+ await handle.release();
2949
3177
  }
2950
3178
  }
2951
- }
2952
- function buildSessionRecord(input, manifest, newSessionId, options) {
2953
- const home = homedir2();
2954
- const workingDirectoryRaw = input.working_directory;
2955
- const workingDirectorySanitized = sanitizeWorkingDirectory(workingDirectoryRaw, {
2956
- homedir: home
2957
- });
2958
- const relatedSanitized = sanitizeRelatedFiles(input.related_files, {
2959
- workingDirectory: workingDirectoryRaw,
2960
- homedir: home
2961
- });
2962
- const inner = {
2963
- id: newSessionId,
2964
- ...options.labelOverride !== void 0 || input.label !== void 0 ? { label: options.labelOverride ?? input.label } : {},
2965
- task_id: options.taskIdOverride !== void 0 ? options.taskIdOverride : input.task_id ?? null,
2966
- workspace_id: manifest.workspace.id,
2967
- source: input.source,
2968
- started_at: input.started_at,
2969
- ...input.ended_at !== void 0 ? { ended_at: input.ended_at } : {},
2970
- status: "imported",
2971
- working_directory: workingDirectorySanitized,
2972
- invocation: input.invocation,
2973
- related_files: relatedSanitized.sanitized,
2974
- events_log: "events.jsonl",
2975
- summary: input.summary ?? null
2976
- };
2977
3179
  return {
2978
- record: { schema_version: "0.1.0", session: inner },
2979
- pathSanitizeReport: {
2980
- relatedFiles: relatedSanitized.mutationCount,
2981
- workingDirectoryRewritten: workingDirectorySanitized !== workingDirectoryRaw
2982
- }
3180
+ taskId: input.taskId,
3181
+ titleUpdated,
3182
+ statusUpdated,
3183
+ previousStatus,
3184
+ newStatus,
3185
+ statusChangeSession
2983
3186
  };
2984
3187
  }
2985
-
2986
- // src/lib/id-resolver.ts
2987
- async function resolveSessionId(paths, input) {
2988
- return resolveIdInternal(paths, input, "session");
2989
- }
2990
- async function resolveTaskId(paths, input, options = {}) {
2991
- return resolveIdInternal(paths, input, "task", options);
2992
- }
2993
- var KIND_CONFIG = {
2994
- session: {
2995
- prefix: "ses_",
2996
- noun: "session",
2997
- nounPlural: "sessions",
2998
- capNoun: "Session",
2999
- enumerate: enumerateSessionDirs
3000
- },
3001
- task: {
3002
- prefix: "task_",
3003
- noun: "task",
3004
- nounPlural: "tasks",
3005
- capNoun: "Task",
3006
- enumerate: enumerateTaskIds
3007
- }
3008
- };
3009
- async function resolveIdInternal(paths, input, kind, options = {}) {
3010
- const cfg = KIND_CONFIG[kind];
3011
- const trimmed = input.trim();
3012
- if (trimmed.length === 0) {
3013
- throw new Error(`${cfg.capNoun} id is empty`);
3014
- }
3015
- const normalized = trimmed.startsWith(cfg.prefix) ? trimmed : `${cfg.prefix}${trimmed}`;
3016
- if (normalized.length <= cfg.prefix.length) {
3017
- throw new Error(`${cfg.capNoun} not found: ${input}`);
3018
- }
3019
- const primary = await cfg.enumerate(paths);
3020
- const merged = new Set(primary);
3021
- if (kind === "task" && options.includeArchived === true) {
3022
- for (const id of await enumerateArchivedTaskIds(paths)) {
3023
- merged.add(id);
3024
- }
3188
+ async function deleteTask(input) {
3189
+ TaskIdSchema.parse(input.taskId);
3190
+ const handle = await acquireLock(input.paths, "task", input.taskId);
3191
+ try {
3192
+ return await deleteTaskLocked(input);
3193
+ } finally {
3194
+ await handle.release();
3025
3195
  }
3026
- if (merged.size === 0) {
3027
- throw new Error(`${cfg.capNoun} not found: ${input}`);
3196
+ }
3197
+ async function deleteTaskLocked(input) {
3198
+ const doc = await readTaskFile(input.paths, input.taskId);
3199
+ const title = doc.task.task.title;
3200
+ const adHoc = await createAdHocSessionWithEvent({
3201
+ paths: input.paths,
3202
+ manifest: input.manifest,
3203
+ label: buildAdHocDeleteLabel(title),
3204
+ occurredAt: input.occurredAt,
3205
+ sessionSource: "human",
3206
+ workingDirectory: input.workingDirectory,
3207
+ invocation: {
3208
+ command: "basou task delete",
3209
+ args: [input.taskId, "--yes"]
3210
+ },
3211
+ targetEventBuilders: [
3212
+ (sessionId, eventId2) => buildTaskDeletedEvent({
3213
+ eventId: eventId2,
3214
+ sessionId,
3215
+ taskId: input.taskId,
3216
+ title,
3217
+ occurredAt: input.occurredAt
3218
+ })
3219
+ ]
3220
+ });
3221
+ const eventId = adHoc.targetEventIds[0];
3222
+ try {
3223
+ await unlink3(join9(input.paths.tasks, `${input.taskId}.md`));
3224
+ } catch (error) {
3225
+ throw new TaskWriteAfterEventError({
3226
+ taskId: input.taskId,
3227
+ eventId,
3228
+ sessionId: adHoc.sessionId,
3229
+ phase: "delete",
3230
+ cause: error
3231
+ });
3028
3232
  }
3029
- const matches = [...merged].filter((e) => e.startsWith(normalized));
3030
- if (matches.length === 0) {
3031
- throw new Error(`${cfg.capNoun} not found: ${input}`);
3233
+ await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
3234
+ return {
3235
+ taskId: input.taskId,
3236
+ title,
3237
+ sessionId: adHoc.sessionId,
3238
+ eventId
3239
+ };
3240
+ }
3241
+ async function archiveTask(input) {
3242
+ TaskIdSchema.parse(input.taskId);
3243
+ const handle = await acquireLock(input.paths, "task", input.taskId);
3244
+ try {
3245
+ return await archiveTaskLocked(input);
3246
+ } finally {
3247
+ await handle.release();
3032
3248
  }
3033
- if (matches.length > 1) {
3034
- throw new Error(
3035
- `Ambiguous ${cfg.noun} id '${input}': matched ${matches.length} ${cfg.nounPlural}. Disambiguate with a longer prefix.`
3249
+ }
3250
+ async function archiveTaskLocked(input) {
3251
+ const doc = await readTaskFile(input.paths, input.taskId);
3252
+ const title = doc.task.task.title;
3253
+ const adHoc = await createAdHocSessionWithEvent({
3254
+ paths: input.paths,
3255
+ manifest: input.manifest,
3256
+ label: buildAdHocArchiveLabel(title),
3257
+ occurredAt: input.occurredAt,
3258
+ sessionSource: "human",
3259
+ workingDirectory: input.workingDirectory,
3260
+ invocation: {
3261
+ command: "basou task archive",
3262
+ args: [input.taskId, "--yes"]
3263
+ },
3264
+ taskId: input.taskId,
3265
+ targetEventBuilders: [
3266
+ (sessionId, eventId2) => buildTaskArchivedEvent({
3267
+ eventId: eventId2,
3268
+ sessionId,
3269
+ taskId: input.taskId,
3270
+ title,
3271
+ occurredAt: input.occurredAt
3272
+ })
3273
+ ]
3274
+ });
3275
+ const eventId = adHoc.targetEventIds[0];
3276
+ try {
3277
+ const linked = doc.task.task.linked_sessions;
3278
+ const merged = linked.includes(adHoc.sessionId) ? linked : [...linked, adHoc.sessionId];
3279
+ const next = {
3280
+ ...doc.task,
3281
+ task: {
3282
+ ...doc.task.task,
3283
+ updated_at: input.occurredAt,
3284
+ linked_sessions: merged
3285
+ }
3286
+ };
3287
+ await writeTaskFile(
3288
+ input.paths,
3289
+ input.taskId,
3290
+ { task: next, body: doc.body },
3291
+ { mode: "overwrite" }
3036
3292
  );
3293
+ await mkdir2(archiveTasksDir(input.paths), { recursive: true });
3294
+ await rename2(
3295
+ join9(input.paths.tasks, `${input.taskId}.md`),
3296
+ join9(archiveTasksDir(input.paths), `${input.taskId}.md`)
3297
+ );
3298
+ } catch (error) {
3299
+ throw new TaskWriteAfterEventError({
3300
+ taskId: input.taskId,
3301
+ eventId,
3302
+ sessionId: adHoc.sessionId,
3303
+ phase: "archive",
3304
+ cause: error
3305
+ });
3037
3306
  }
3038
- return matches[0];
3307
+ await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
3308
+ return {
3309
+ taskId: input.taskId,
3310
+ title,
3311
+ sessionId: adHoc.sessionId,
3312
+ eventId
3313
+ };
3039
3314
  }
3040
3315
 
3041
3316
  // src/handoff/handoff-renderer.ts
3042
- import { join as join12 } from "path";
3043
3317
  async function renderHandoff(input) {
3044
3318
  const limit = input.relatedFilesLimit ?? 20;
3045
3319
  const now = new Date(input.nowIso);
@@ -3055,7 +3329,7 @@ async function renderHandoff(input) {
3055
3329
  const tasksCreated = [];
3056
3330
  const tasksStatusChanged = [];
3057
3331
  for (const entry of entries) {
3058
- const sessionDir = join12(input.paths.sessions, entry.sessionId);
3332
+ const sessionDir = join10(input.paths.sessions, entry.sessionId);
3059
3333
  try {
3060
3334
  for await (const ev of replayEvents(sessionDir, {
3061
3335
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -3122,18 +3396,14 @@ async function renderHandoff(input) {
3122
3396
  const latestSession = [...liveEntries].sort(
3123
3397
  (a, b) => Date.parse(b.session.session.started_at) - Date.parse(a.session.session.started_at)
3124
3398
  )[0];
3125
- const allFiles = /* @__PURE__ */ new Set();
3126
- for (const e of entries) {
3127
- if (e.session.session.source.kind === "import") continue;
3128
- for (const f of e.session.session.related_files) allFiles.add(f);
3129
- }
3130
- const sortedFiles = [...allFiles].sort();
3399
+ const latestFiles = latestSession?.session.session.related_files ?? [];
3400
+ const sortedFiles = [...new Set(latestFiles)].sort();
3131
3401
  const displayedFiles = sortedFiles.slice(0, limit);
3132
3402
  const overflow = Math.max(0, sortedFiles.length - limit);
3133
3403
  const suspectCount = entries.filter((e) => e.suspect).length;
3134
3404
  const firstEntry = entries[0];
3135
3405
  const lastEntry = entries[entries.length - 1];
3136
- const sessionRange = firstEntry !== void 0 && lastEntry !== void 0 ? `${firstEntry.sessionId}..${lastEntry.sessionId}` : "";
3406
+ const sessionRange = firstEntry !== void 0 && lastEntry !== void 0 ? `${shortIdWithPrefix(firstEntry.sessionId)}..${shortIdWithPrefix(lastEntry.sessionId)}` : "";
3137
3407
  const body = formatHandoffBody({
3138
3408
  nowIso: input.nowIso,
3139
3409
  sessionRange,
@@ -3173,18 +3443,23 @@ function formatHandoffBody(args) {
3173
3443
  lines.push("## \u73FE\u5728\u306E\u72B6\u614B");
3174
3444
  lines.push("");
3175
3445
  if (args.latestSession !== void 0) {
3176
- const sid = args.latestSession.sessionId;
3177
3446
  const status = args.latestSession.session.session.status;
3178
- lines.push(`- \u6700\u7D42 session: ${sid} (${status})`);
3447
+ const label = args.latestSession.session.session.label;
3448
+ const shortId = shortIdWithPrefix(args.latestSession.sessionId);
3449
+ if (label !== void 0 && label !== "") {
3450
+ lines.push(`- \u6700\u7D42 session: ${label} (${status}) [${shortId}]`);
3451
+ } else {
3452
+ lines.push(`- \u6700\u7D42 session: ${shortId} (${status})`);
3453
+ }
3179
3454
  } else {
3180
3455
  lines.push("- \u6700\u7D42 session: (no live sessions)");
3181
3456
  }
3182
3457
  if (args.latestActivityRecord !== void 0) {
3183
3458
  const statusLabel = args.latestTaskDoc !== void 0 ? args.latestTaskDoc.task.task.status : "status unknown \u2014 task.md missing or invalid";
3184
3459
  const linkedCount = args.latestTaskDoc?.task.task.linked_sessions?.length;
3185
- const linkedSuffix = linkedCount !== void 0 && linkedCount > 1 ? ` (linked_sessions: ${linkedCount})` : "";
3460
+ const linkedSuffix = linkedCount !== void 0 && linkedCount > 1 ? `, linked_sessions: ${linkedCount}` : "";
3186
3461
  lines.push(
3187
- `- \u6700\u7D42 task: ${args.latestActivityRecord.taskId} (${statusLabel}): ${args.latestActivityRecord.title}${linkedSuffix}`
3462
+ `- \u6700\u7D42 task: ${args.latestActivityRecord.title} (${statusLabel}${linkedSuffix}) [${shortIdWithPrefix(args.latestActivityRecord.taskId)}]`
3188
3463
  );
3189
3464
  } else {
3190
3465
  lines.push("- \u6700\u7D42 task: (no tasks recorded yet)");
@@ -3205,7 +3480,7 @@ function formatHandoffBody(args) {
3205
3480
  lines.push("(no decisions recorded yet)");
3206
3481
  } else {
3207
3482
  const last = args.decisions[args.decisions.length - 1];
3208
- lines.push(`- ${last.decisionId}: ${last.title}`);
3483
+ lines.push(`- ${last.title} [${shortIdWithPrefix(last.decisionId)}]`);
3209
3484
  lines.push("");
3210
3485
  lines.push(`(${args.decisions.length} decisions total \u2014 see decisions.md)`);
3211
3486
  }
@@ -3233,7 +3508,9 @@ function formatHandoffBody(args) {
3233
3508
  lines.push("(no pending tasks)");
3234
3509
  } else {
3235
3510
  for (const t of args.pendingTasks) {
3236
- lines.push(`- ${t.task.task.id} (${t.task.task.status}): ${t.task.task.title}`);
3511
+ lines.push(
3512
+ `- ${t.task.task.title} (${t.task.task.status}) [${shortIdWithPrefix(t.task.task.id)}]`
3513
+ );
3237
3514
  }
3238
3515
  }
3239
3516
  lines.push("");
@@ -3287,144 +3564,119 @@ function formatHandoffBody(args) {
3287
3564
  "initialized",
3288
3565
  "imported"
3289
3566
  ];
3290
- const breakdown = orderedStatuses.filter((s) => (statusCounts.get(s) ?? 0) > 0).map((s) => `${s} ${statusCounts.get(s)}`).join(", ");
3291
- const sessionsLine = breakdown !== "" ? `Sessions: ${args.sessionCount} (${breakdown}). Tasks: ${args.totalTaskCount}.` : `Sessions: ${args.sessionCount}. Tasks: ${args.totalTaskCount}.`;
3292
- lines.push(sessionsLine);
3293
- return lines.join("\n");
3294
- }
3295
- function suspectLabel(reason) {
3296
- if (reason === "events_say_ended_but_yaml_running") return " \u26A0 ended (yaml stale)";
3297
- if (reason === "running_no_end_event") return " \u26A0 no end event";
3298
- return "";
3299
- }
3300
- function shortHandoffId(sessionId) {
3301
- const SES = "ses_";
3302
- if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
3303
- return sessionId.slice(0, 10);
3304
- }
3305
-
3306
- // src/decisions/decisions-renderer.ts
3307
- import { lstat as lstat4 } from "fs/promises";
3308
- import { dirname, join as join13, resolve } from "path";
3309
- async function renderDecisions(input) {
3310
- const now = new Date(input.nowIso);
3311
- const unreadableEmitted = /* @__PURE__ */ new Set();
3312
- const wrappedSkip = (sid, reason) => {
3313
- if (reason === "events_jsonl_unreadable") unreadableEmitted.add(sid);
3314
- input.onSessionSkip?.(sid, reason);
3315
- };
3316
- const loadOpts = { now, onSkip: wrappedSkip };
3317
- if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
3318
- const entries = await loadSessionEntries(input.paths, loadOpts);
3319
- const decisions = [];
3320
- const knownEventIds = /* @__PURE__ */ new Set();
3321
- for (const entry of entries) {
3322
- const sessionDir = join13(input.paths.sessions, entry.sessionId);
3323
- try {
3324
- for await (const ev of replayEvents(sessionDir, {
3325
- onWarning: (w) => input.onWarning?.(w, entry.sessionId)
3326
- })) {
3327
- knownEventIds.add(ev.id);
3328
- if (ev.type === "decision_recorded") {
3329
- decisions.push({
3330
- decisionId: ev.decision_id,
3331
- title: ev.title,
3332
- occurredAt: ev.occurred_at,
3333
- sessionId: entry.sessionId,
3334
- rationale: ev.rationale,
3335
- alternatives: ev.alternatives,
3336
- rejectedReason: ev.rejected_reason,
3337
- linkedEvents: ev.linked_events,
3338
- linkedFiles: ev.linked_files
3339
- });
3340
- }
3341
- }
3342
- } catch {
3343
- if (!unreadableEmitted.has(entry.sessionId)) {
3344
- wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
3345
- }
3346
- }
3347
- }
3348
- decisions.sort((a, b) => {
3349
- const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
3350
- return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
3351
- });
3352
- const repoRoot = dirname(input.paths.root);
3353
- const fileExistenceCache = /* @__PURE__ */ new Map();
3354
- async function fileExists(relPath) {
3355
- const cached = fileExistenceCache.get(relPath);
3356
- if (cached !== void 0) return cached;
3357
- const abs = resolve(repoRoot, relPath);
3358
- let exists;
3359
- try {
3360
- await lstat4(abs);
3361
- exists = true;
3362
- } catch {
3363
- exists = false;
3364
- }
3365
- fileExistenceCache.set(relPath, exists);
3366
- return exists;
3367
- }
3368
- const body = await formatDecisionsBody({
3369
- nowIso: input.nowIso,
3370
- decisions,
3371
- knownEventIds,
3372
- fileExists
3373
- });
3374
- return { body, decisionCount: decisions.length };
3375
- }
3376
- async function formatDecisionsBody(args) {
3377
- const lines = [];
3378
- lines.push("# Decisions");
3379
- lines.push("");
3380
- lines.push(`> Generated at ${args.nowIso}`);
3381
- lines.push("");
3382
- if (args.decisions.length === 0) {
3383
- lines.push("(no decisions recorded yet)");
3384
- return lines.join("\n");
3385
- }
3386
- for (const d of args.decisions) {
3387
- lines.push(`## ${d.decisionId}: ${d.title}`);
3388
- lines.push("");
3389
- const occurredDate = d.occurredAt.slice(0, 10);
3390
- lines.push(`- \u6C7A\u5B9A\u65E5: ${occurredDate}`);
3391
- lines.push(`- session: ${shortDecisionSessionId(d.sessionId)}`);
3392
- lines.push(`- \u5224\u65AD: ${d.title}`);
3393
- if (typeof d.rationale === "string" && d.rationale.length > 0) {
3394
- lines.push(`- rationale: ${d.rationale}`);
3395
- }
3396
- if (d.alternatives !== void 0 && d.alternatives.length > 0) {
3397
- lines.push(`- alternatives: ${d.alternatives.join(", ")}`);
3398
- }
3399
- if (typeof d.rejectedReason === "string" && d.rejectedReason.length > 0) {
3400
- lines.push(`- rejected_reason: ${d.rejectedReason}`);
3401
- }
3402
- if (d.linkedEvents !== void 0 && d.linkedEvents.length > 0) {
3403
- const parts = d.linkedEvents.map(
3404
- (eid) => args.knownEventIds.has(eid) ? eid : `${eid} (missing)`
3405
- );
3406
- lines.push(`- linked_events: ${parts.join(", ")}`);
3407
- }
3408
- if (d.linkedFiles !== void 0 && d.linkedFiles.length > 0) {
3409
- const parts = await Promise.all(
3410
- d.linkedFiles.map(
3411
- async (path2) => await args.fileExists(path2) ? path2 : `${path2} (missing)`
3412
- )
3413
- );
3414
- lines.push(`- linked_files: ${parts.join(", ")}`);
3415
- }
3416
- lines.push("");
3417
- }
3567
+ const breakdown = orderedStatuses.filter((s) => (statusCounts.get(s) ?? 0) > 0).map((s) => `${s} ${statusCounts.get(s)}`).join(", ");
3568
+ const sessionsLine = breakdown !== "" ? `Sessions: ${args.sessionCount} (${breakdown}). Tasks: ${args.totalTaskCount}.` : `Sessions: ${args.sessionCount}. Tasks: ${args.totalTaskCount}.`;
3569
+ lines.push(sessionsLine);
3418
3570
  return lines.join("\n");
3419
3571
  }
3420
- function shortDecisionSessionId(sessionId) {
3572
+ function suspectLabel(reason) {
3573
+ if (reason === "events_say_ended_but_yaml_running") return " \u26A0 ended (yaml stale)";
3574
+ if (reason === "running_no_end_event") return " \u26A0 no end event";
3575
+ return "";
3576
+ }
3577
+ function shortHandoffId(sessionId) {
3421
3578
  const SES = "ses_";
3422
3579
  if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
3423
3580
  return sessionId.slice(0, 10);
3424
3581
  }
3582
+ function shortIdWithPrefix(id) {
3583
+ const sep = id.indexOf("_");
3584
+ if (sep === -1) return id.slice(0, 10);
3585
+ return id.slice(0, sep + 1) + id.slice(sep + 1, sep + 1 + 10);
3586
+ }
3587
+
3588
+ // src/lib/duration.ts
3589
+ var DURATION_RE = /^([1-9]\d*)(ms|s|m|h)$/;
3590
+ function parseDuration(input) {
3591
+ const trimmed = input.trim();
3592
+ const match = DURATION_RE.exec(trimmed);
3593
+ if (!match) {
3594
+ throw new Error(
3595
+ `Invalid duration: ${trimmed}. Expected format: <positive-integer><unit> where unit is ms/s/m/h`
3596
+ );
3597
+ }
3598
+ const value = Number(match[1]);
3599
+ const unit = match[2];
3600
+ let ms;
3601
+ switch (unit) {
3602
+ case "ms":
3603
+ ms = value;
3604
+ break;
3605
+ case "s":
3606
+ ms = value * 1e3;
3607
+ break;
3608
+ case "m":
3609
+ ms = value * 6e4;
3610
+ break;
3611
+ case "h":
3612
+ ms = value * 36e5;
3613
+ break;
3614
+ default:
3615
+ throw new Error(`Invalid duration unit: ${unit}`);
3616
+ }
3617
+ if (!Number.isFinite(ms)) {
3618
+ throw new Error(`Duration overflow: ${trimmed}`);
3619
+ }
3620
+ return ms;
3621
+ }
3622
+
3623
+ // src/lib/id-resolver.ts
3624
+ async function resolveSessionId(paths, input) {
3625
+ return resolveIdInternal(paths, input, "session");
3626
+ }
3627
+ async function resolveTaskId(paths, input, options = {}) {
3628
+ return resolveIdInternal(paths, input, "task", options);
3629
+ }
3630
+ var KIND_CONFIG = {
3631
+ session: {
3632
+ prefix: "ses_",
3633
+ noun: "session",
3634
+ nounPlural: "sessions",
3635
+ capNoun: "Session",
3636
+ enumerate: enumerateSessionDirs
3637
+ },
3638
+ task: {
3639
+ prefix: "task_",
3640
+ noun: "task",
3641
+ nounPlural: "tasks",
3642
+ capNoun: "Task",
3643
+ enumerate: enumerateTaskIds
3644
+ }
3645
+ };
3646
+ async function resolveIdInternal(paths, input, kind, options = {}) {
3647
+ const cfg = KIND_CONFIG[kind];
3648
+ const trimmed = input.trim();
3649
+ if (trimmed.length === 0) {
3650
+ throw new Error(`${cfg.capNoun} id is empty`);
3651
+ }
3652
+ const normalized = trimmed.startsWith(cfg.prefix) ? trimmed : `${cfg.prefix}${trimmed}`;
3653
+ if (normalized.length <= cfg.prefix.length) {
3654
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3655
+ }
3656
+ const primary = await cfg.enumerate(paths);
3657
+ const merged = new Set(primary);
3658
+ if (kind === "task" && options.includeArchived === true) {
3659
+ for (const id of await enumerateArchivedTaskIds(paths)) {
3660
+ merged.add(id);
3661
+ }
3662
+ }
3663
+ if (merged.size === 0) {
3664
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3665
+ }
3666
+ const matches = [...merged].filter((e) => e.startsWith(normalized));
3667
+ if (matches.length === 0) {
3668
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3669
+ }
3670
+ if (matches.length > 1) {
3671
+ throw new Error(
3672
+ `Ambiguous ${cfg.noun} id '${input}': matched ${matches.length} ${cfg.nounPlural}. Disambiguate with a longer prefix.`
3673
+ );
3674
+ }
3675
+ return matches[0];
3676
+ }
3425
3677
 
3426
3678
  // src/runtime/child-process-runner.ts
3427
- import { spawn } from "child_process";
3679
+ import { spawn as spawn2 } from "child_process";
3428
3680
  var DEFAULT_KILL_GRACE_MS = 5e3;
3429
3681
  var ChildProcessRunner = class {
3430
3682
  async run(command, args, options) {
@@ -3441,7 +3693,7 @@ var ChildProcessRunner = class {
3441
3693
  const started_at = /* @__PURE__ */ new Date();
3442
3694
  let child;
3443
3695
  try {
3444
- child = spawn(snapshotCommand, [...snapshotArgs], {
3696
+ child = spawn2(snapshotCommand, [...snapshotArgs], {
3445
3697
  cwd: snapshotCwd,
3446
3698
  env: options.env ?? process.env,
3447
3699
  stdio: captureMode === "none" ? ["inherit", "inherit", "inherit"] : ["pipe", "pipe", "pipe"],
@@ -3531,264 +3783,807 @@ var ChildProcessRunner = class {
3531
3783
  });
3532
3784
  });
3533
3785
  }
3534
- };
3535
- function validateOptions(options) {
3536
- if (options.timeout_ms !== void 0 && (!Number.isFinite(options.timeout_ms) || options.timeout_ms <= 0)) {
3537
- throw new Error("Invalid timeout_ms");
3786
+ };
3787
+ function validateOptions(options) {
3788
+ if (options.timeout_ms !== void 0 && (!Number.isFinite(options.timeout_ms) || options.timeout_ms <= 0)) {
3789
+ throw new Error("Invalid timeout_ms");
3790
+ }
3791
+ if (options.capture === "none" && options.stdin !== void 0) {
3792
+ throw new Error('Combination of capture: "none" and stdin is not supported');
3793
+ }
3794
+ }
3795
+ function classifySpawnError(error) {
3796
+ if (findErrorCode(error, "ENOENT")) {
3797
+ return new Error("Command not found", { cause: error });
3798
+ }
3799
+ return new Error("Failed to spawn child process", { cause: error });
3800
+ }
3801
+
3802
+ // src/schemas/manifest.schema.ts
3803
+ import { z as z9 } from "zod";
3804
+ var ProjectSchema = z9.object({
3805
+ name: z9.string().optional(),
3806
+ description: z9.string().optional(),
3807
+ repository_url: z9.string().nullable().optional()
3808
+ });
3809
+ var CapabilitiesSchema = z9.object({
3810
+ enabled: z9.array(z9.string())
3811
+ });
3812
+ var ApprovalConfigSchema = z9.object({
3813
+ required_for: z9.array(z9.string()).optional(),
3814
+ default_risk_level: z9.enum(["low", "medium", "high", "critical"])
3815
+ });
3816
+ var ClaudeCodeAdapterConfigSchema = z9.object({
3817
+ enabled: z9.boolean(),
3818
+ config_path: z9.string().optional()
3819
+ });
3820
+ var AdaptersSchema = z9.object({
3821
+ "claude-code": ClaudeCodeAdapterConfigSchema
3822
+ });
3823
+ var GitConfigSchema = z9.object({
3824
+ events_log: z9.enum(["ignore", "commit"]).default("ignore")
3825
+ });
3826
+ var WorkspaceMetaSchema = z9.object({
3827
+ id: WorkspaceIdSchema,
3828
+ name: z9.string().min(1),
3829
+ created_at: IsoTimestampSchema,
3830
+ updated_at: IsoTimestampSchema
3831
+ });
3832
+ var ManifestSchema = z9.object({
3833
+ schema_version: SchemaVersionSchema,
3834
+ basou_version: z9.literal("0.1.0"),
3835
+ workspace: WorkspaceMetaSchema,
3836
+ project: ProjectSchema,
3837
+ capabilities: CapabilitiesSchema,
3838
+ approval: ApprovalConfigSchema,
3839
+ adapters: AdaptersSchema,
3840
+ git: GitConfigSchema
3841
+ });
3842
+
3843
+ // src/schemas/session-import.schema.ts
3844
+ import { z as z10 } from "zod";
3845
+ var SessionInnerImportSchema = z10.object({
3846
+ id: SessionIdSchema.optional(),
3847
+ label: z10.string().optional(),
3848
+ task_id: TaskIdSchema.nullable().optional(),
3849
+ workspace_id: WorkspaceIdSchema,
3850
+ source: z10.object({
3851
+ kind: SessionSourceKindSchema,
3852
+ version: z10.literal("0.1.0"),
3853
+ // Source-tool-native id (e.g. Claude Code session UUID), retained so
3854
+ // re-imports of the same source can be deduplicated.
3855
+ external_id: z10.string().optional()
3856
+ }),
3857
+ started_at: IsoTimestampSchema,
3858
+ ended_at: IsoTimestampSchema.optional(),
3859
+ status: SessionStatusSchema,
3860
+ working_directory: z10.string().min(1),
3861
+ invocation: z10.object({
3862
+ command: z10.string().min(1),
3863
+ args: z10.array(z10.string()),
3864
+ exit_code: z10.number().int().nullable()
3865
+ }),
3866
+ related_files: z10.array(z10.string()).default([]),
3867
+ events_log: z10.string().optional(),
3868
+ summary: z10.string().nullable().optional(),
3869
+ metrics: SessionMetricsSchema.optional()
3870
+ }).strict();
3871
+ var SessionImportPayloadSchema = z10.object({
3872
+ schema_version: z10.string(),
3873
+ session: SessionInnerImportSchema,
3874
+ events: z10.array(EventSchema)
3875
+ }).strict();
3876
+
3877
+ // src/stats/work-stats.ts
3878
+ import { join as join11 } from "path";
3879
+ function resolveTimeZone(timeZone) {
3880
+ if (timeZone !== void 0 && timeZone.length > 0) return timeZone;
3881
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
3882
+ }
3883
+ var STATUS_ORDER = [
3884
+ "completed",
3885
+ "failed",
3886
+ "running",
3887
+ "interrupted",
3888
+ "waiting_approval",
3889
+ "initialized",
3890
+ "imported",
3891
+ "archived"
3892
+ ];
3893
+ async function computeWorkStats(input) {
3894
+ const { now } = input;
3895
+ const timeZone = resolveTimeZone(input.timeZone);
3896
+ const unreadableEmitted = /* @__PURE__ */ new Set();
3897
+ const wrappedSkip = (sid, reason) => {
3898
+ if (reason === "events_jsonl_unreadable") unreadableEmitted.add(sid);
3899
+ input.onSessionSkip?.(sid, reason);
3900
+ };
3901
+ const loadOpts = { now, onSkip: wrappedSkip };
3902
+ if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
3903
+ const entries = await loadSessionEntries(input.paths, loadOpts);
3904
+ const sessions = [];
3905
+ for (const entry of entries) {
3906
+ const events = [];
3907
+ let eventsUnreadable = false;
3908
+ try {
3909
+ for await (const ev of replayEvents(join11(input.paths.sessions, entry.sessionId), {
3910
+ onWarning: (w) => input.onWarning?.(w, entry.sessionId)
3911
+ })) {
3912
+ events.push(ev);
3913
+ }
3914
+ } catch {
3915
+ eventsUnreadable = true;
3916
+ if (!unreadableEmitted.has(entry.sessionId)) {
3917
+ wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
3918
+ }
3919
+ }
3920
+ sessions.push(
3921
+ sessionWorkStatsFromEvents(
3922
+ entry.sessionId,
3923
+ entry.session.session,
3924
+ events,
3925
+ now,
3926
+ eventsUnreadable
3927
+ )
3928
+ );
3929
+ }
3930
+ const allIntervals = [];
3931
+ for (const s of sessions) allIntervals.push(...intervalsIsoToMs(s.activeIntervals));
3932
+ const union = unionDurationMs(allIntervals);
3933
+ return {
3934
+ generatedAt: now.toISOString(),
3935
+ activeGapCapMs: ACTIVE_GAP_CAP_MS,
3936
+ timeZone,
3937
+ totals: computeTotals(sessions, union.ms),
3938
+ sessions,
3939
+ bySource: computeBySource(sessions),
3940
+ byStatus: computeByStatus(sessions),
3941
+ byDay: computeByDay(sessions, union.merged, timeZone)
3942
+ };
3943
+ }
3944
+ function sessionWorkStatsFromEvents(sessionId, inner, events, now, eventsUnreadable = false) {
3945
+ let commandCount = 0;
3946
+ let fileChangedCount = 0;
3947
+ let decisionCount = 0;
3948
+ let commandTimeMs = 0;
3949
+ const timestamps = [];
3950
+ for (const ev of events) {
3951
+ const t = Date.parse(ev.occurred_at);
3952
+ if (Number.isFinite(t)) timestamps.push(t);
3953
+ if (ev.type === "command_executed") {
3954
+ commandCount++;
3955
+ commandTimeMs += ev.duration_ms;
3956
+ } else if (ev.type === "file_changed") {
3957
+ fileChangedCount++;
3958
+ } else if (ev.type === "decision_recorded") {
3959
+ decisionCount++;
3960
+ }
3961
+ }
3962
+ const span = computeSpan(inner.started_at, inner.ended_at, now);
3963
+ const tokens = readTokens(inner.metrics);
3964
+ const active = resolveActiveTime(inner.metrics, timestamps);
3965
+ return {
3966
+ sessionId,
3967
+ label: inner.label,
3968
+ status: inner.status,
3969
+ sourceKind: inner.source.kind,
3970
+ startedAt: inner.started_at,
3971
+ endedAt: inner.ended_at,
3972
+ open: inner.ended_at === void 0,
3973
+ sessionSpanMs: span.ms,
3974
+ commandTimeMs,
3975
+ activeTimeMs: active.ms,
3976
+ activeTimeBasis: active.basis,
3977
+ activeIntervals: intervalsMsToIso(active.intervals),
3978
+ commandCount,
3979
+ fileChangedCount,
3980
+ decisionCount,
3981
+ eventCount: events.length,
3982
+ tokens,
3983
+ availability: {
3984
+ span: true,
3985
+ commandTime: inner.source.kind !== "claude-code-import",
3986
+ activeTime: active.intervals.length > 0,
3987
+ tokens: hasTokens(tokens)
3988
+ },
3989
+ spanClamped: span.clamped,
3990
+ eventsUnreadable
3991
+ };
3992
+ }
3993
+ function resolveActiveTime(metrics, eventTimestamps) {
3994
+ const stored = metrics?.active_intervals;
3995
+ if (stored !== void 0 && stored.length > 0) {
3996
+ const intervals = intervalsIsoToMs(stored);
3997
+ const ms = intervals.reduce((n, [start, end]) => n + (end - start), 0);
3998
+ return { ms, intervals, basis: "engaged-turns" };
3999
+ }
4000
+ const derived = activeTimeFromTimestamps(eventTimestamps, ACTIVE_GAP_CAP_MS);
4001
+ return { ms: derived.ms, intervals: derived.intervals, basis: "events" };
4002
+ }
4003
+ function computeSpan(startedAt, endedAt, now) {
4004
+ const start = Date.parse(startedAt);
4005
+ const end = endedAt !== void 0 ? Date.parse(endedAt) : now.getTime();
4006
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return { ms: 0, clamped: true };
4007
+ const raw = end - start;
4008
+ return raw < 0 ? { ms: 0, clamped: true } : { ms: raw, clamped: false };
4009
+ }
4010
+ function readTokens(metrics) {
4011
+ return {
4012
+ output: metrics?.output_tokens ?? 0,
4013
+ input: metrics?.input_tokens ?? 0,
4014
+ cached: metrics?.cached_input_tokens ?? 0,
4015
+ reasoning: metrics?.reasoning_output_tokens ?? 0
4016
+ };
4017
+ }
4018
+ function hasTokens(t) {
4019
+ return t.output > 0 || t.input > 0 || t.cached > 0 || t.reasoning > 0;
4020
+ }
4021
+ function emptyTokens() {
4022
+ return { output: 0, input: 0, cached: 0, reasoning: 0 };
4023
+ }
4024
+ function addTokens(a, b) {
4025
+ a.output += b.output;
4026
+ a.input += b.input;
4027
+ a.cached += b.cached;
4028
+ a.reasoning += b.reasoning;
4029
+ }
4030
+ function computeTotals(sessions, billableActiveTimeMs) {
4031
+ const tokens = emptyTokens();
4032
+ const totals = {
4033
+ sessionCount: sessions.length,
4034
+ openSessionCount: 0,
4035
+ sessionSpanMs: 0,
4036
+ commandTimeMs: 0,
4037
+ activeTimeMs: 0,
4038
+ billableActiveTimeMs,
4039
+ commandCount: 0,
4040
+ fileChangedCount: 0,
4041
+ decisionCount: 0,
4042
+ eventCount: 0,
4043
+ tokens,
4044
+ commandTimeReliable: true,
4045
+ tokensAvailable: false
4046
+ };
4047
+ for (const s of sessions) {
4048
+ if (s.open) totals.openSessionCount++;
4049
+ totals.sessionSpanMs += s.sessionSpanMs;
4050
+ totals.commandTimeMs += s.commandTimeMs;
4051
+ totals.activeTimeMs += s.activeTimeMs;
4052
+ totals.commandCount += s.commandCount;
4053
+ totals.fileChangedCount += s.fileChangedCount;
4054
+ totals.decisionCount += s.decisionCount;
4055
+ totals.eventCount += s.eventCount;
4056
+ addTokens(tokens, s.tokens);
4057
+ if (!s.availability.commandTime) totals.commandTimeReliable = false;
4058
+ if (s.availability.tokens) totals.tokensAvailable = true;
4059
+ }
4060
+ return totals;
4061
+ }
4062
+ function computeBySource(sessions) {
4063
+ const map = /* @__PURE__ */ new Map();
4064
+ for (const s of sessions) {
4065
+ let row = map.get(s.sourceKind);
4066
+ if (row === void 0) {
4067
+ row = {
4068
+ sourceKind: s.sourceKind,
4069
+ sessionCount: 0,
4070
+ sessionSpanMs: 0,
4071
+ commandTimeMs: 0,
4072
+ activeTimeMs: 0,
4073
+ commandCount: 0,
4074
+ fileChangedCount: 0,
4075
+ decisionCount: 0,
4076
+ eventCount: 0,
4077
+ tokens: emptyTokens(),
4078
+ commandTimeReliable: true,
4079
+ tokensAvailable: false
4080
+ };
4081
+ map.set(s.sourceKind, row);
4082
+ }
4083
+ row.sessionCount++;
4084
+ row.sessionSpanMs += s.sessionSpanMs;
4085
+ row.commandTimeMs += s.commandTimeMs;
4086
+ row.activeTimeMs += s.activeTimeMs;
4087
+ row.commandCount += s.commandCount;
4088
+ row.fileChangedCount += s.fileChangedCount;
4089
+ row.decisionCount += s.decisionCount;
4090
+ row.eventCount += s.eventCount;
4091
+ addTokens(row.tokens, s.tokens);
4092
+ if (!s.availability.commandTime) row.commandTimeReliable = false;
4093
+ if (s.availability.tokens) row.tokensAvailable = true;
4094
+ }
4095
+ return [...map.values()].sort((a, b) => a.sourceKind.localeCompare(b.sourceKind));
4096
+ }
4097
+ function computeByStatus(sessions) {
4098
+ const counts = /* @__PURE__ */ new Map();
4099
+ for (const s of sessions) counts.set(s.status, (counts.get(s.status) ?? 0) + 1);
4100
+ const ordered = [];
4101
+ for (const status of STATUS_ORDER) {
4102
+ const count = counts.get(status);
4103
+ if (count !== void 0 && count > 0) ordered.push({ status, count });
4104
+ }
4105
+ return ordered;
4106
+ }
4107
+ function computeByDay(sessions, unionMerged, timeZone) {
4108
+ const days = /* @__PURE__ */ new Map();
4109
+ const ensure = (date) => {
4110
+ let day = days.get(date);
4111
+ if (day === void 0) {
4112
+ day = {
4113
+ date,
4114
+ billableActiveTimeMs: 0,
4115
+ sessionCount: 0,
4116
+ commandCount: 0,
4117
+ fileChangedCount: 0,
4118
+ decisionCount: 0,
4119
+ tokens: emptyTokens()
4120
+ };
4121
+ days.set(date, day);
4122
+ }
4123
+ return day;
4124
+ };
4125
+ for (const [start, end] of unionMerged) {
4126
+ ensure(tzDate(start, timeZone)).billableActiveTimeMs += end - start;
4127
+ }
4128
+ for (const s of sessions) {
4129
+ const startedMs = Date.parse(s.startedAt);
4130
+ if (!Number.isFinite(startedMs)) continue;
4131
+ const day = ensure(tzDate(startedMs, timeZone));
4132
+ day.sessionCount++;
4133
+ day.commandCount += s.commandCount;
4134
+ day.fileChangedCount += s.fileChangedCount;
4135
+ day.decisionCount += s.decisionCount;
4136
+ addTokens(day.tokens, s.tokens);
4137
+ }
4138
+ return [...days.values()].sort((a, b) => a.date.localeCompare(b.date));
4139
+ }
4140
+ function tzDate(ms, timeZone) {
4141
+ return new Intl.DateTimeFormat("en-CA", {
4142
+ timeZone,
4143
+ year: "numeric",
4144
+ month: "2-digit",
4145
+ day: "2-digit"
4146
+ }).format(new Date(ms));
4147
+ }
4148
+
4149
+ // src/storage/basou-dir.ts
4150
+ import { lstat as lstat3, mkdir as mkdir3 } from "fs/promises";
4151
+ import { join as join12 } from "path";
4152
+ function basouPaths(repositoryRoot) {
4153
+ const root = join12(repositoryRoot, ".basou");
4154
+ const approvalsBase = join12(root, "approvals");
4155
+ return {
4156
+ root,
4157
+ sessions: join12(root, "sessions"),
4158
+ tasks: join12(root, "tasks"),
4159
+ approvals: {
4160
+ pending: join12(approvalsBase, "pending"),
4161
+ resolved: join12(approvalsBase, "resolved")
4162
+ },
4163
+ locks: join12(root, "locks"),
4164
+ logs: join12(root, "logs"),
4165
+ raw: join12(root, "raw"),
4166
+ tmp: join12(root, "tmp"),
4167
+ files: {
4168
+ manifest: join12(root, "manifest.yaml"),
4169
+ status: join12(root, "status.json"),
4170
+ handoff: join12(root, "handoff.md"),
4171
+ decisions: join12(root, "decisions.md")
4172
+ }
4173
+ };
4174
+ }
4175
+ var PATH_LABELS = {
4176
+ sessions: ".basou/sessions",
4177
+ tasks: ".basou/tasks",
4178
+ approvalsPending: ".basou/approvals/pending",
4179
+ approvalsResolved: ".basou/approvals/resolved",
4180
+ locks: ".basou/locks",
4181
+ logs: ".basou/logs",
4182
+ raw: ".basou/raw",
4183
+ tmp: ".basou/tmp"
4184
+ };
4185
+ async function ensureBasouDirectory(repositoryRoot) {
4186
+ const paths = basouPaths(repositoryRoot);
4187
+ let existing;
4188
+ try {
4189
+ existing = await lstat3(paths.root);
4190
+ } catch (error) {
4191
+ if (!hasErrorCode3(error) || error.code !== "ENOENT") {
4192
+ throw new Error("Failed to inspect .basou directory", { cause: error });
4193
+ }
4194
+ }
4195
+ if (existing !== void 0 && !existing.isDirectory()) {
4196
+ throw new Error("Basou root .basou exists but is not a directory");
4197
+ }
4198
+ await Promise.all([
4199
+ mkdirLabeled(paths.sessions, PATH_LABELS.sessions),
4200
+ mkdirLabeled(paths.tasks, PATH_LABELS.tasks),
4201
+ mkdirLabeled(paths.approvals.pending, PATH_LABELS.approvalsPending),
4202
+ mkdirLabeled(paths.approvals.resolved, PATH_LABELS.approvalsResolved),
4203
+ mkdirLabeled(paths.locks, PATH_LABELS.locks),
4204
+ mkdirLabeled(paths.logs, PATH_LABELS.logs),
4205
+ mkdirLabeled(paths.raw, PATH_LABELS.raw),
4206
+ mkdirLabeled(paths.tmp, PATH_LABELS.tmp)
4207
+ ]);
4208
+ return paths;
4209
+ }
4210
+ async function mkdirLabeled(target, label) {
4211
+ try {
4212
+ await mkdir3(target, { recursive: true });
4213
+ } catch (error) {
4214
+ if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
4215
+ throw new Error(`${label} exists but is not a directory`, { cause: error });
4216
+ }
4217
+ throw new Error(`Failed to create ${label}`, { cause: error });
4218
+ }
4219
+ }
4220
+ function hasErrorCode3(error) {
4221
+ if (!(error instanceof Error)) return false;
4222
+ const codeProp = error.code;
4223
+ return typeof codeProp === "string";
4224
+ }
4225
+
4226
+ // src/storage/gitignore.ts
4227
+ import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
4228
+ import { join as join13 } from "path";
4229
+ var MARKER = "# Basou - default ignore";
4230
+ 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/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";
4231
+ async function appendBasouGitignore(repositoryRoot) {
4232
+ const gitignorePath = join13(repositoryRoot, ".gitignore");
4233
+ let body;
4234
+ let existed;
4235
+ try {
4236
+ body = await readFile6(gitignorePath, "utf8");
4237
+ existed = true;
4238
+ } catch (error) {
4239
+ if (hasErrorCode4(error) && error.code === "ENOENT") {
4240
+ body = "";
4241
+ existed = false;
4242
+ } else {
4243
+ throw new Error("Failed to read .gitignore", { cause: error });
4244
+ }
3538
4245
  }
3539
- if (options.capture === "none" && options.stdin !== void 0) {
3540
- throw new Error('Combination of capture: "none" and stdin is not supported');
4246
+ if (existed && hasBasouMarker(body)) {
4247
+ return { appended: false };
4248
+ }
4249
+ const next = composeNextBody(body);
4250
+ try {
4251
+ await writeFile2(gitignorePath, next, { encoding: "utf8" });
4252
+ } catch (error) {
4253
+ throw new Error("Failed to write .gitignore", { cause: error });
3541
4254
  }
4255
+ return { appended: true };
3542
4256
  }
3543
- function classifySpawnError(error) {
3544
- if (findErrorCode(error, "ENOENT")) {
3545
- return new Error("Command not found", { cause: error });
4257
+ function hasBasouMarker(body) {
4258
+ for (const rawLine of body.split("\n")) {
4259
+ if (rawLine.trimEnd().startsWith(MARKER)) return true;
3546
4260
  }
3547
- return new Error("Failed to spawn child process", { cause: error });
4261
+ return false;
3548
4262
  }
3549
-
3550
- // src/git/snapshot.ts
3551
- import { simpleGit } from "simple-git";
3552
- function safeSimpleGit(repoRoot) {
3553
- return simpleGit({ baseDir: repoRoot });
4263
+ function composeNextBody(existing) {
4264
+ if (existing.length === 0) return BASOU_GITIGNORE_BLOCK;
4265
+ const normalized = existing.endsWith("\n") ? existing : `${existing}
4266
+ `;
4267
+ return `${normalized}
4268
+ ${BASOU_GITIGNORE_BLOCK}`;
3554
4269
  }
3555
- function isGitNotFound(error) {
3556
- if (findErrorCode(error, "ENOENT")) return true;
3557
- let cur = error;
3558
- for (let i = 0; i < 4 && cur instanceof Error; i++) {
3559
- if (/\bENOENT\b/.test(cur.message)) return true;
3560
- cur = cur.cause;
4270
+ function hasErrorCode4(error) {
4271
+ if (!(error instanceof Error)) return false;
4272
+ return typeof error.code === "string";
4273
+ }
4274
+
4275
+ // src/storage/manifest.ts
4276
+ import { lstat as lstat4 } from "fs/promises";
4277
+ function createManifest(input) {
4278
+ if (input.workspaceName.length === 0) {
4279
+ throw new Error("Workspace name is empty. Pass --name explicitly.");
3561
4280
  }
3562
- return false;
4281
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
4282
+ const workspaceId = input.workspaceId ?? prefixedUlid("ws");
4283
+ const project = {
4284
+ ...input.projectName !== void 0 ? { name: input.projectName } : {},
4285
+ ...input.projectDescription !== void 0 ? { description: input.projectDescription } : {},
4286
+ ...input.repositoryUrl !== void 0 ? { repository_url: input.repositoryUrl } : {}
4287
+ };
4288
+ const manifest = {
4289
+ schema_version: "0.1.0",
4290
+ basou_version: "0.1.0",
4291
+ workspace: {
4292
+ id: workspaceId,
4293
+ name: input.workspaceName,
4294
+ created_at: now,
4295
+ updated_at: now
4296
+ },
4297
+ project,
4298
+ capabilities: {
4299
+ enabled: ["core", "claude-code-adapter", "terminal-recording", "git-capability", "approval"]
4300
+ },
4301
+ approval: {
4302
+ required_for: ["destructive_command", "external_send"],
4303
+ default_risk_level: "medium"
4304
+ },
4305
+ adapters: {
4306
+ "claude-code": { enabled: true }
4307
+ },
4308
+ git: { events_log: "ignore" }
4309
+ };
4310
+ return ManifestSchema.parse(manifest);
3563
4311
  }
3564
- async function resolveRepositoryRoot(cwd) {
3565
- const git = safeSimpleGit(cwd);
3566
- try {
3567
- const root = (await git.revparse(["--show-toplevel"])).trimEnd();
3568
- if (root.length === 0) {
3569
- throw new Error("Not a git repository");
3570
- }
3571
- return root;
3572
- } catch (error) {
3573
- if (isGitNotFound(error)) {
3574
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
4312
+ async function writeManifest(paths, manifest, options) {
4313
+ const force = options?.force === true;
4314
+ const validated = ManifestSchema.parse(manifest);
4315
+ if (!force) {
4316
+ let existed = false;
4317
+ try {
4318
+ await lstat4(paths.files.manifest);
4319
+ existed = true;
4320
+ } catch (error) {
4321
+ if (!hasErrorCode5(error) || error.code !== "ENOENT") {
4322
+ throw new Error("Failed to inspect existing manifest", { cause: error });
4323
+ }
3575
4324
  }
3576
- if (error instanceof Error && error.message === "Not a git repository") {
3577
- throw error;
4325
+ if (existed) {
4326
+ throw new Error("Already initialized. Use --force to overwrite.");
3578
4327
  }
3579
- throw new Error("Not a git repository", { cause: error });
3580
4328
  }
4329
+ await writeYamlFile(paths.files.manifest, validated);
3581
4330
  }
3582
- async function tryRemoteUrl(repositoryRoot) {
3583
- const git = safeSimpleGit(repositoryRoot);
4331
+ async function readManifest(paths) {
4332
+ const raw = await readYamlFile(paths.files.manifest);
4333
+ return ManifestSchema.parse(raw);
4334
+ }
4335
+ function hasErrorCode5(error) {
4336
+ if (!(error instanceof Error)) return false;
4337
+ return typeof error.code === "string";
4338
+ }
4339
+
4340
+ // src/storage/markdown-store.ts
4341
+ import { readFile as readFile7 } from "fs/promises";
4342
+ var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
4343
+ var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
4344
+ async function readMarkdownFile(filePath) {
3584
4345
  try {
3585
- const result = await git.getConfig("remote.origin.url", "local");
3586
- const url = (result.value ?? "").trimEnd();
3587
- return url.length > 0 ? url : void 0;
3588
- } catch {
3589
- return void 0;
4346
+ return await readFile7(filePath, "utf8");
4347
+ } catch (error) {
4348
+ if (hasErrorCode6(error) && error.code === "ENOENT") return null;
4349
+ throw new Error("Failed to read markdown file", { cause: error });
3590
4350
  }
3591
4351
  }
3592
- async function getSnapshot(repositoryRoot) {
3593
- const git = safeSimpleGit(repositoryRoot);
3594
- let inside;
4352
+ async function writeMarkdownFile(filePath, body) {
3595
4353
  try {
3596
- inside = await git.checkIsRepo();
4354
+ await atomicReplace(filePath, body);
3597
4355
  } catch (error) {
3598
- if (isGitNotFound(error)) {
3599
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
4356
+ throw new Error("Failed to write markdown file", { cause: error });
4357
+ }
4358
+ }
4359
+ function parseMarkers(content) {
4360
+ const lines = content.split(/\r?\n/);
4361
+ const startLines = [];
4362
+ const endLines = [];
4363
+ for (let i = 0; i < lines.length; i++) {
4364
+ if (lines[i] === GENERATED_START) startLines.push(i);
4365
+ else if (lines[i] === GENERATED_END) endLines.push(i);
4366
+ }
4367
+ if (startLines.length === 0 && endLines.length === 0) return { kind: "no_markers" };
4368
+ if (startLines.length === 0) return { kind: "missing_start" };
4369
+ if (endLines.length === 0) return { kind: "missing_end" };
4370
+ if (startLines.length >= 2 || endLines.length >= 2) return { kind: "multiple_pairs" };
4371
+ const startLineIdx = startLines[0];
4372
+ const endLineIdx = endLines[0];
4373
+ if (endLineIdx < startLineIdx) return { kind: "wrong_order" };
4374
+ const startOffset = lineStartOffset(content, startLineIdx);
4375
+ const endLineStart = lineStartOffset(content, endLineIdx);
4376
+ const startLineEnd = startOffset + GENERATED_START.length;
4377
+ const endLineEnd = endLineStart + GENERATED_END.length;
4378
+ const before = content.slice(0, startOffset);
4379
+ const afterStartNewline = skipOneNewline(content, startLineEnd);
4380
+ const beforeEndNewline = trimOneNewline(content, endLineStart);
4381
+ const generated = content.slice(afterStartNewline, beforeEndNewline);
4382
+ const after = content.slice(endLineEnd);
4383
+ return { kind: "ok", before, generated, after };
4384
+ }
4385
+ function renderWithMarkers(existing, generated, fileLabel) {
4386
+ const normalized = generated.endsWith("\n") ? generated : `${generated}
4387
+ `;
4388
+ if (existing === null) {
4389
+ return `${GENERATED_START}
4390
+ ${normalized}${GENERATED_END}
4391
+ `;
4392
+ }
4393
+ const section = parseMarkers(existing);
4394
+ switch (section.kind) {
4395
+ case "ok":
4396
+ return `${section.before}${GENERATED_START}
4397
+ ${normalized}${GENERATED_END}${section.after}`;
4398
+ case "no_markers":
4399
+ throw new Error(`Markers missing in ${fileLabel}`);
4400
+ case "missing_start":
4401
+ case "missing_end":
4402
+ case "multiple_pairs":
4403
+ case "wrong_order":
4404
+ throw new Error(`Markers mismatched in ${fileLabel}`);
4405
+ }
4406
+ }
4407
+ function lineStartOffset(content, lineIdx) {
4408
+ if (lineIdx === 0) return 0;
4409
+ let offset = 0;
4410
+ let line = 0;
4411
+ while (offset < content.length && line < lineIdx) {
4412
+ const ch = content[offset];
4413
+ if (ch === "\n") {
4414
+ line += 1;
4415
+ offset += 1;
4416
+ } else if (ch === "\r") {
4417
+ offset += 1;
4418
+ if (content[offset] === "\n") offset += 1;
4419
+ line += 1;
4420
+ } else {
4421
+ offset += 1;
3600
4422
  }
3601
- throw new Error("Failed to read git state", { cause: error });
3602
4423
  }
3603
- if (!inside) {
3604
- throw new Error("Not a git repository");
4424
+ return offset;
4425
+ }
4426
+ function skipOneNewline(content, offset) {
4427
+ if (content[offset] === "\r" && content[offset + 1] === "\n") return offset + 2;
4428
+ if (content[offset] === "\n") return offset + 1;
4429
+ return offset;
4430
+ }
4431
+ function trimOneNewline(content, offset) {
4432
+ if (offset >= 2 && content[offset - 2] === "\r" && content[offset - 1] === "\n")
4433
+ return offset - 2;
4434
+ if (offset >= 1 && content[offset - 1] === "\n") return offset - 1;
4435
+ return offset;
4436
+ }
4437
+ function hasErrorCode6(error) {
4438
+ if (!(error instanceof Error)) return false;
4439
+ const codeProp = error.code;
4440
+ return typeof codeProp === "string";
4441
+ }
4442
+
4443
+ // src/storage/session-import.ts
4444
+ import { mkdir as mkdir4, rm as rm2 } from "fs/promises";
4445
+ import { homedir as homedir2 } from "os";
4446
+ import { join as join14 } from "path";
4447
+ async function importSessionFromJson(paths, manifest, payload, options) {
4448
+ if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
4449
+ throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
4450
+ }
4451
+ const effectiveSessionTaskId = options.taskIdOverride ?? payload.session.task_id ?? null;
4452
+ await assertImportedTaskReferencesAreReachable(paths, payload.events, effectiveSessionTaskId);
4453
+ const newSessionId = prefixedUlid("ses");
4454
+ const rewrittenEvents = rewriteEvents(payload.events, newSessionId);
4455
+ assertChronologicalOrder(rewrittenEvents);
4456
+ const { record: sessionRecord, pathSanitizeReport } = buildSessionRecord(
4457
+ payload.session,
4458
+ manifest,
4459
+ newSessionId,
4460
+ options
4461
+ );
4462
+ if (options.dryRun === true) {
4463
+ return {
4464
+ sessionId: newSessionId,
4465
+ eventCount: rewrittenEvents.length,
4466
+ finalStatus: "imported",
4467
+ finalSourceKind: sessionRecord.session.source.kind,
4468
+ pathSanitizeReport
4469
+ };
3605
4470
  }
3606
- let head;
4471
+ const sessionDir = join14(paths.sessions, newSessionId);
3607
4472
  try {
3608
- head = (await git.revparse(["HEAD"])).trimEnd();
4473
+ await mkdir4(sessionDir, { recursive: true });
3609
4474
  } catch (error) {
3610
- if (isGitNotFound(error)) {
3611
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
3612
- }
3613
- throw new Error("No commits in repository", { cause: error });
3614
- }
3615
- if (head.length === 0) {
3616
- throw new Error("No commits in repository");
4475
+ throw new Error("Failed to create session directory", { cause: error });
3617
4476
  }
3618
- let branch;
3619
4477
  try {
3620
- const raw = (await git.raw(["branch", "--show-current"])).trimEnd();
3621
- branch = raw.length > 0 ? raw : "HEAD";
4478
+ await writeEventsBulk(sessionDir, rewrittenEvents);
3622
4479
  } catch (error) {
3623
- throw new Error("Failed to read git state", { cause: error });
4480
+ await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4481
+ throw error;
3624
4482
  }
3625
- let dirty;
3626
- const staged = [];
3627
- const unstaged = [];
3628
- const untracked = [];
3629
4483
  try {
3630
- const status = await git.status();
3631
- dirty = !status.isClean();
3632
- for (const f of status.files) {
3633
- if (f.index === "?" && f.working_dir === "?") {
3634
- untracked.push(f.path);
3635
- continue;
3636
- }
3637
- if (f.index !== " " && f.index !== "?") staged.push(f.path);
3638
- if (f.working_dir !== " " && f.working_dir !== "?") unstaged.push(f.path);
3639
- }
4484
+ const sessionYamlPath = join14(sessionDir, "session.yaml");
4485
+ await linkYamlFile(sessionYamlPath, sessionRecord);
3640
4486
  } catch (error) {
3641
- throw new Error("Failed to read git state", { cause: error });
3642
- }
3643
- let ahead;
3644
- let behind;
3645
- if (branch !== "HEAD") {
3646
- try {
3647
- const upstream = `${branch}@{upstream}`;
3648
- const counts = (await git.raw(["rev-list", "--left-right", "--count", `${upstream}...HEAD`])).trim();
3649
- const [behindStr, aheadStr] = counts.split(/\s+/);
3650
- const parsedBehind = Number.parseInt(behindStr ?? "", 10);
3651
- const parsedAhead = Number.parseInt(aheadStr ?? "", 10);
3652
- if (Number.isFinite(parsedBehind) && parsedBehind >= 0) behind = parsedBehind;
3653
- if (Number.isFinite(parsedAhead) && parsedAhead >= 0) ahead = parsedAhead;
3654
- } catch {
4487
+ await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
4488
+ if (findErrorCode(error, "EEXIST")) {
4489
+ throw new Error("Session directory collision (retry the command)", {
4490
+ cause: error
4491
+ });
3655
4492
  }
4493
+ throw error;
3656
4494
  }
3657
- const snapshot = {
3658
- head,
3659
- branch,
3660
- dirty,
3661
- staged,
3662
- unstaged,
3663
- untracked,
3664
- ...ahead !== void 0 ? { ahead } : {},
3665
- ...behind !== void 0 ? { behind } : {}
4495
+ return {
4496
+ sessionId: newSessionId,
4497
+ eventCount: rewrittenEvents.length,
4498
+ finalStatus: "imported",
4499
+ finalSourceKind: sessionRecord.session.source.kind,
4500
+ pathSanitizeReport
3666
4501
  };
3667
- return snapshot;
3668
4502
  }
3669
-
3670
- // src/git/diff.ts
3671
- async function getDiff(repoRoot, baseRef, headRef) {
3672
- let git;
3673
- try {
3674
- git = safeSimpleGit(repoRoot);
3675
- } catch (error) {
3676
- if (isGitNotFound(error)) {
3677
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
4503
+ async function assertImportedTaskReferencesAreReachable(paths, events, effectiveSessionTaskId) {
4504
+ const taskIdsToCheck = /* @__PURE__ */ new Set();
4505
+ for (const ev of events) {
4506
+ if (ev.type === "task_created" || ev.type === "task_status_changed" || ev.type === "task_reconciled" || ev.type === "task_linkage_refreshed" || ev.type === "task_deleted" || ev.type === "task_archived") {
4507
+ taskIdsToCheck.add(ev.task_id);
3678
4508
  }
3679
- throw new Error("Not a git repository", { cause: error });
3680
4509
  }
3681
- if (baseRef === headRef) return { changed_files: [] };
3682
- let raw;
3683
- try {
3684
- raw = await git.raw(["diff", "--name-status", `${baseRef}..${headRef}`]);
3685
- } catch (error) {
3686
- if (isGitNotFound(error)) {
3687
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
3688
- }
3689
- const message = error instanceof Error ? error.message : "";
3690
- if (/not a git repository/i.test(message)) {
3691
- throw new Error("Not a git repository", { cause: error });
3692
- }
3693
- if (message.includes("bad revision") || message.includes("unknown revision") || message.includes("ambiguous argument")) {
3694
- throw new Error("Invalid ref", { cause: error });
3695
- }
3696
- throw new Error("Failed to compute git diff", { cause: error });
4510
+ if (effectiveSessionTaskId !== null) {
4511
+ taskIdsToCheck.add(effectiveSessionTaskId);
3697
4512
  }
3698
- return { changed_files: parseDiffNameStatus(raw) };
3699
- }
3700
- function parseDiffNameStatus(raw) {
3701
- const lines = raw.split("\n").filter((l) => l.trim() !== "");
3702
- const changes = [];
3703
- for (const line of lines) {
3704
- const parts = line.split(" ");
3705
- const code = parts[0];
3706
- if (code === void 0 || code.length === 0) continue;
3707
- if (code.startsWith("R") && parts.length >= 3) {
3708
- const newPath = parts[2];
3709
- const oldPath = parts[1];
3710
- if (newPath === void 0) continue;
3711
- changes.push({
3712
- path: newPath,
3713
- status: "renamed",
3714
- ...oldPath !== void 0 ? { old_path: oldPath } : {}
3715
- });
3716
- } else if (code === "A" && parts[1]) {
3717
- changes.push({ path: parts[1], status: "added" });
3718
- } else if (code === "M" && parts[1]) {
3719
- changes.push({ path: parts[1], status: "modified" });
3720
- } else if (code === "D" && parts[1]) {
3721
- changes.push({ path: parts[1], status: "deleted" });
4513
+ if (taskIdsToCheck.size === 0) {
4514
+ return;
4515
+ }
4516
+ const knownTaskIds = new Set(await enumerateTaskIds(paths));
4517
+ for (const id of taskIdsToCheck) {
4518
+ if (!knownTaskIds.has(id)) {
4519
+ throw new Error("Imported session references unknown task_id");
3722
4520
  }
3723
4521
  }
3724
- return changes;
3725
4522
  }
3726
-
3727
- // src/adapters/claude-code/claude-code-adapter.ts
3728
- import { spawn as spawn2 } from "child_process";
3729
- var claudeCodeAdapterMetadata = {
3730
- kind: "claude-code-adapter",
3731
- version: "0.1.0"
3732
- };
3733
- async function resolveClaudeCodeCommand(lookup = isOnPath) {
3734
- for (const candidate of ["claude-code", "claude"]) {
3735
- if (await lookup(candidate)) return { command: candidate };
4523
+ function rewriteEvents(events, newSessionId) {
4524
+ return events.map((event) => ({
4525
+ ...event,
4526
+ id: prefixedUlid("evt"),
4527
+ session_id: newSessionId
4528
+ }));
4529
+ }
4530
+ function assertChronologicalOrder(events) {
4531
+ for (let i = 1; i < events.length; i++) {
4532
+ const prevEvent = events[i - 1];
4533
+ const currEvent = events[i];
4534
+ if (prevEvent === void 0 || currEvent === void 0) continue;
4535
+ const prev = Date.parse(prevEvent.occurred_at);
4536
+ const curr = Date.parse(currEvent.occurred_at);
4537
+ if (!Number.isFinite(prev) || !Number.isFinite(curr) || curr < prev) {
4538
+ throw new Error("Events are not in chronological order");
4539
+ }
3736
4540
  }
3737
- throw new Error("Claude Code CLI not found in PATH. Install claude-code (or claude) first.");
3738
4541
  }
3739
- async function isOnPath(command) {
3740
- return new Promise((resolve2) => {
3741
- const child = spawn2("which", [command], { stdio: "ignore" });
3742
- child.on("error", () => resolve2(false));
3743
- child.on("exit", (code) => resolve2(code === 0));
4542
+ function buildSessionRecord(input, manifest, newSessionId, options) {
4543
+ const home = homedir2();
4544
+ const workingDirectoryRaw = input.working_directory;
4545
+ const workingDirectorySanitized = sanitizeWorkingDirectory(workingDirectoryRaw, {
4546
+ homedir: home
3744
4547
  });
3745
- }
3746
- function summarizeAdapterOutput(_stream, _raw) {
3747
- throw new Error("adapter_output summary is not implemented in v0.1 Step 11");
3748
- }
3749
-
3750
- // src/lib/duration.ts
3751
- var DURATION_RE = /^([1-9]\d*)(ms|s|m|h)$/;
3752
- function parseDuration(input) {
3753
- const trimmed = input.trim();
3754
- const match = DURATION_RE.exec(trimmed);
3755
- if (!match) {
3756
- throw new Error(
3757
- `Invalid duration: ${trimmed}. Expected format: <positive-integer><unit> where unit is ms/s/m/h`
3758
- );
3759
- }
3760
- const value = Number(match[1]);
3761
- const unit = match[2];
3762
- let ms;
3763
- switch (unit) {
3764
- case "ms":
3765
- ms = value;
3766
- break;
3767
- case "s":
3768
- ms = value * 1e3;
3769
- break;
3770
- case "m":
3771
- ms = value * 6e4;
3772
- break;
3773
- case "h":
3774
- ms = value * 36e5;
3775
- break;
3776
- default:
3777
- throw new Error(`Invalid duration unit: ${unit}`);
3778
- }
3779
- if (!Number.isFinite(ms)) {
3780
- throw new Error(`Duration overflow: ${trimmed}`);
3781
- }
3782
- return ms;
4548
+ const relatedSanitized = sanitizeRelatedFiles(input.related_files, {
4549
+ workingDirectory: workingDirectoryRaw,
4550
+ homedir: home
4551
+ });
4552
+ const inner = {
4553
+ id: newSessionId,
4554
+ ...options.labelOverride !== void 0 || input.label !== void 0 ? { label: options.labelOverride ?? input.label } : {},
4555
+ task_id: options.taskIdOverride !== void 0 ? options.taskIdOverride : input.task_id ?? null,
4556
+ workspace_id: manifest.workspace.id,
4557
+ source: input.source,
4558
+ started_at: input.started_at,
4559
+ ...input.ended_at !== void 0 ? { ended_at: input.ended_at } : {},
4560
+ status: "imported",
4561
+ working_directory: workingDirectorySanitized,
4562
+ invocation: input.invocation,
4563
+ related_files: relatedSanitized.sanitized,
4564
+ events_log: "events.jsonl",
4565
+ summary: input.summary ?? null,
4566
+ ...input.metrics !== void 0 ? { metrics: input.metrics } : {}
4567
+ };
4568
+ return {
4569
+ record: { schema_version: "0.1.0", session: inner },
4570
+ pathSanitizeReport: {
4571
+ relatedFiles: relatedSanitized.mutationCount,
4572
+ workingDirectoryRewritten: workingDirectorySanitized !== workingDirectoryRaw
4573
+ }
4574
+ };
3783
4575
  }
3784
4576
 
3785
4577
  // src/index.ts
3786
4578
  var BASOU_CORE_VERSION = "0.1.0";
3787
4579
  export {
4580
+ ACTIVE_GAP_CAP_MS,
3788
4581
  ApprovalIdSchema,
3789
4582
  ApprovalSchema,
3790
4583
  ApprovalStatusSchema,
3791
4584
  BASOU_CORE_VERSION,
4585
+ CLAUDE_IMPORT_SOURCE,
4586
+ CODEX_IMPORT_SOURCE,
3792
4587
  ChildProcessRunner,
3793
4588
  DecisionIdSchema,
3794
4589
  EventIdSchema,
@@ -3806,6 +4601,7 @@ export {
3806
4601
  SessionIdSchema,
3807
4602
  SessionImportPayloadSchema,
3808
4603
  SessionInnerImportSchema,
4604
+ SessionMetricsSchema,
3809
4605
  SessionSchema,
3810
4606
  SessionSourceKindSchema,
3811
4607
  SessionStatusSchema,
@@ -3825,6 +4621,9 @@ export {
3825
4621
  buildStatusSnapshot,
3826
4622
  classifySuspect,
3827
4623
  claudeCodeAdapterMetadata,
4624
+ claudeTranscriptToImportPayload,
4625
+ codexRolloutToImportPayload,
4626
+ computeWorkStats,
3828
4627
  createAdHocSessionWithEvent,
3829
4628
  createManifest,
3830
4629
  createTaskWithEvent,
@@ -3871,6 +4670,7 @@ export {
3871
4670
  sanitizePath,
3872
4671
  sanitizeRelatedFiles,
3873
4672
  sanitizeWorkingDirectory,
4673
+ sessionWorkStatsFromEvents,
3874
4674
  summarizeAdapterOutput,
3875
4675
  tryRemoteUrl,
3876
4676
  ulid,