@basou/core 0.3.1

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 ADDED
@@ -0,0 +1,3885 @@
1
+ // src/ids/ulid.ts
2
+ import { isValid as isValidUlid, monotonicFactory } from "ulid";
3
+ var ID_PREFIXES = Object.freeze(["ws", "task", "ses", "evt", "appr", "decision"]);
4
+ var PREFIX_SET = new Set(ID_PREFIXES);
5
+ var ULID_BODY_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
6
+ var monotonic = monotonicFactory();
7
+ function ulid(seedTime) {
8
+ return monotonic(seedTime);
9
+ }
10
+ function prefixedUlid(prefix) {
11
+ if (!PREFIX_SET.has(prefix)) {
12
+ throw new Error(`Unknown ID prefix: ${prefix}`);
13
+ }
14
+ return `${prefix}_${ulid()}`;
15
+ }
16
+ function isValidPrefixedId(value) {
17
+ const idx = value.indexOf("_");
18
+ if (idx <= 0) return false;
19
+ const prefix = value.slice(0, idx);
20
+ const ulidPart = value.slice(idx + 1);
21
+ if (!PREFIX_SET.has(prefix)) return false;
22
+ if (!ULID_BODY_REGEX.test(ulidPart)) return false;
23
+ return isValidUlid(ulidPart);
24
+ }
25
+
26
+ // src/schemas/shared.schema.ts
27
+ import { z } from "zod";
28
+ var SchemaVersionSchema = z.literal("0.1.0");
29
+ var IsoTimestampSchema = z.string().datetime({ offset: true });
30
+ var createPrefixedIdSchema = (prefix) => {
31
+ const refiner = (value) => isValidPrefixedId(value) && value.startsWith(`${prefix}_`);
32
+ return z.string().refine(refiner, { message: `Expected ${prefix}_<ULID>` });
33
+ };
34
+ var WorkspaceIdSchema = createPrefixedIdSchema("ws");
35
+ var TaskIdSchema = createPrefixedIdSchema("task");
36
+ var SessionIdSchema = createPrefixedIdSchema("ses");
37
+ var EventIdSchema = createPrefixedIdSchema("evt");
38
+ var ApprovalIdSchema = createPrefixedIdSchema("appr");
39
+ var DecisionIdSchema = createPrefixedIdSchema("decision");
40
+ var RiskLevelSchema = z.enum(["low", "medium", "high", "critical"]);
41
+ var EventSourceSchema = z.string().min(1);
42
+
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({
74
+ 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
82
+ });
83
+
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";
432
+
433
+ // src/storage/atomic.ts
434
+ import { randomUUID } from "crypto";
435
+ import { link, rename, unlink, writeFile } from "fs/promises";
436
+ async function atomicCreate(targetPath, content) {
437
+ const tmpPath = `${targetPath}.tmp.${randomUUID()}`;
438
+ try {
439
+ await writeFile(tmpPath, content, { encoding: "utf8", flag: "wx" });
440
+ await link(tmpPath, targetPath);
441
+ } catch (error) {
442
+ await unlink(tmpPath).catch(() => void 0);
443
+ throw error;
444
+ }
445
+ await unlink(tmpPath).catch(() => void 0);
446
+ }
447
+ async function atomicReplace(targetPath, content) {
448
+ const tmpPath = `${targetPath}.tmp.${randomUUID()}`;
449
+ try {
450
+ await writeFile(tmpPath, content, { encoding: "utf8", flag: "wx" });
451
+ await rename(tmpPath, targetPath);
452
+ } catch (error) {
453
+ await unlink(tmpPath).catch(() => void 0);
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ // src/storage/yaml-store.ts
459
+ async function readYamlFile(filePath) {
460
+ let body;
461
+ try {
462
+ body = await readFile(filePath, "utf8");
463
+ } catch (error) {
464
+ if (hasErrorCode(error) && error.code === "ENOENT") {
465
+ throw new Error("YAML file not found", { cause: error });
466
+ }
467
+ throw new Error("Failed to read YAML file", { cause: error });
468
+ }
469
+ try {
470
+ return parse(body);
471
+ } catch (error) {
472
+ throw new Error("Failed to parse YAML content", { cause: error });
473
+ }
474
+ }
475
+ async function writeYamlFile(filePath, value) {
476
+ const body = stringify(value);
477
+ try {
478
+ await atomicReplace(filePath, body);
479
+ } catch (error) {
480
+ throw new Error("Failed to write YAML file", { cause: error });
481
+ }
482
+ }
483
+ async function linkYamlFile(filePath, value) {
484
+ const body = stringify(value);
485
+ try {
486
+ await atomicCreate(filePath, body);
487
+ } catch (error) {
488
+ throw new Error("Failed to write YAML file", { cause: error });
489
+ }
490
+ }
491
+ async function overwriteYamlFile(filePath, value) {
492
+ const body = stringify(value);
493
+ try {
494
+ await atomicReplace(filePath, body);
495
+ } catch (error) {
496
+ throw new Error("Failed to overwrite YAML file", { cause: error });
497
+ }
498
+ }
499
+ function hasErrorCode(error) {
500
+ if (!(error instanceof Error)) return false;
501
+ return typeof error.code === "string";
502
+ }
503
+
504
+ // src/approval/approval-store.ts
505
+ async function loadApproval(paths, approvalId) {
506
+ for (const location of ["resolved", "pending"]) {
507
+ const filePath = join(paths.approvals[location], `${approvalId}.yaml`);
508
+ let raw;
509
+ try {
510
+ raw = await readYamlFile(filePath);
511
+ } catch (error) {
512
+ if (error instanceof Error && error.message === "YAML file not found") continue;
513
+ throw new Error("Failed to read approval", { cause: error });
514
+ }
515
+ const result = ApprovalSchema.safeParse(raw);
516
+ if (!result.success) {
517
+ throw new Error("Failed to read approval", { cause: result.error });
518
+ }
519
+ if (result.data.id !== approvalId) {
520
+ throw new Error("Failed to read approval", {
521
+ cause: new Error(
522
+ `Approval id mismatch: filename id ${approvalId} vs YAML body id ${result.data.id}`
523
+ )
524
+ });
525
+ }
526
+ return { approval: result.data, location };
527
+ }
528
+ return null;
529
+ }
530
+ async function enumerateApprovals(paths) {
531
+ const [pending, resolved] = await Promise.all([
532
+ enumerateIds(paths.approvals.pending),
533
+ enumerateIds(paths.approvals.resolved)
534
+ ]);
535
+ return { pending, resolved };
536
+ }
537
+ async function enumerateIds(dir) {
538
+ let entries;
539
+ try {
540
+ const dirents = await readdir(dir, { withFileTypes: true });
541
+ entries = dirents.filter((e) => e.isFile() && e.name.endsWith(".yaml")).map((e) => e.name.slice(0, -".yaml".length));
542
+ } catch (error) {
543
+ if (findErrorCode(error, "ENOENT")) return [];
544
+ throw new Error("Failed to enumerate approvals", { cause: error });
545
+ }
546
+ return entries;
547
+ }
548
+ function isLazyExpired(approval, now) {
549
+ if (approval.status !== "pending") return false;
550
+ if (approval.expires_at === null) return false;
551
+ const expiresMs = Date.parse(approval.expires_at);
552
+ if (!Number.isFinite(expiresMs)) return false;
553
+ return expiresMs < now.getTime();
554
+ }
555
+
556
+ // src/storage/basou-dir.ts
557
+ import { lstat, mkdir } from "fs/promises";
558
+ 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
+
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 });
681
+ }
682
+ }
683
+ if (existed) {
684
+ throw new Error("Already initialized. Use --force to overwrite.");
685
+ }
686
+ }
687
+ await writeYamlFile(paths.files.manifest, validated);
688
+ }
689
+ async function readManifest(paths) {
690
+ const raw = await readYamlFile(paths.files.manifest);
691
+ return ManifestSchema.parse(raw);
692
+ }
693
+ function hasErrorCode3(error) {
694
+ if (!(error instanceof Error)) return false;
695
+ return typeof error.code === "string";
696
+ }
697
+
698
+ // src/storage/gitignore.ts
699
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
700
+ 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;
707
+ try {
708
+ body = await readFile2(gitignorePath, "utf8");
709
+ existed = true;
710
+ } 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 };
720
+ }
721
+ const next = composeNextBody(body);
722
+ try {
723
+ await writeFile2(gitignorePath, next, { encoding: "utf8" });
724
+ } catch (error) {
725
+ throw new Error("Failed to write .gitignore", { cause: error });
726
+ }
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;
732
+ }
733
+ return false;
734
+ }
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}`;
741
+ }
742
+ function hasErrorCode4(error) {
743
+ if (!(error instanceof Error)) return false;
744
+ return typeof error.code === "string";
745
+ }
746
+
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()
756
+ };
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 });
767
+ }
768
+ await unlink2(lockPath).catch(() => void 0);
769
+ try {
770
+ await atomicCreate(lockPath, serialised);
771
+ } catch (retryError) {
772
+ throw new Error("Lock is held by another process", { cause: retryError });
773
+ }
774
+ }
775
+ return {
776
+ release: async () => {
777
+ await unlink2(lockPath).catch(() => void 0);
778
+ }
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;
790
+ }
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;
805
+ }
806
+ }
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`);
811
+ }
812
+
813
+ // src/storage/task-index.ts
814
+ import { readFile as readFile4 } from "fs/promises";
815
+ 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;
822
+ try {
823
+ raw = await readFile4(filePath, "utf8");
824
+ } 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 });
829
+ }
830
+ let parsedJson;
831
+ try {
832
+ parsedJson = JSON.parse(raw);
833
+ } 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
+ });
844
+ }
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
+ }
859
+ async function updateTaskIndex(paths, op, options) {
860
+ const nowFn = options?.now ?? (() => /* @__PURE__ */ new Date());
861
+ let current;
862
+ 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
+ };
870
+ }
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;
882
+ }
883
+ return await rebuildTaskIndex(paths, nextTasks, nowFn);
884
+ }
885
+
886
+ // src/storage/status.ts
887
+ import * as fsp from "fs/promises";
888
+ var DIRECTORY_CHECKS = {
889
+ sessions: (p) => p.sessions,
890
+ tasks: (p) => p.tasks,
891
+ approvals_pending: (p) => p.approvals.pending,
892
+ approvals_resolved: (p) => p.approvals.resolved,
893
+ logs: (p) => p.logs,
894
+ raw: (p) => p.raw,
895
+ tmp: (p) => p.tmp
896
+ };
897
+ async function assertBasouRootSafe(rootPath) {
898
+ let stat3;
899
+ try {
900
+ stat3 = await fsp.lstat(rootPath);
901
+ } catch (error) {
902
+ if (hasErrorCode5(error) && error.code === "ENOENT") {
903
+ throw new Error("Basou workspace not found", { cause: error });
904
+ }
905
+ throw new Error("Failed to inspect .basou root", { cause: error });
906
+ }
907
+ if (stat3.isSymbolicLink()) {
908
+ throw new Error(".basou root is a symlink; refusing to operate");
909
+ }
910
+ if (!stat3.isDirectory()) {
911
+ throw new Error(".basou root exists but is not a directory");
912
+ }
913
+ }
914
+ async function dirPresent(path2) {
915
+ try {
916
+ return (await fsp.lstat(path2)).isDirectory();
917
+ } catch (error) {
918
+ if (hasErrorCode5(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
919
+ return false;
920
+ }
921
+ throw new Error("Failed to inspect .basou subdirectory", { cause: error });
922
+ }
923
+ }
924
+ async function buildStatusSnapshot(input) {
925
+ const { manifest, paths } = input;
926
+ const generatedAt = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
927
+ const entries = Object.entries(DIRECTORY_CHECKS);
928
+ const presence = await Promise.all(
929
+ entries.map(async ([key, get]) => [key, await dirPresent(get(paths))])
930
+ );
931
+ const directoriesEntries = Object.fromEntries(presence);
932
+ const snapshot = {
933
+ schema_version: "0.1.0",
934
+ generated_at: generatedAt,
935
+ workspace: {
936
+ id: manifest.workspace.id,
937
+ name: manifest.workspace.name,
938
+ basou_version: manifest.basou_version
939
+ },
940
+ directories_present: directoriesEntries
941
+ };
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;
1057
+ }
1058
+
1059
+ // src/storage/sessions.ts
1060
+ var STUCK_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
1061
+ async function enumerateSessionDirs(paths) {
1062
+ try {
1063
+ const dirents = await readdir2(paths.sessions, { withFileTypes: true });
1064
+ return dirents.filter((d) => d.isDirectory()).map((d) => d.name).sort();
1065
+ } catch (error) {
1066
+ if (findErrorCode(error, "ENOENT")) return [];
1067
+ throw new Error("Failed to enumerate sessions", { cause: error });
1068
+ }
1069
+ }
1070
+ async function readSessionYaml(paths, sessionId) {
1071
+ const filePath = join7(paths.sessions, sessionId, "session.yaml");
1072
+ let raw;
1073
+ try {
1074
+ raw = await readYamlFile(filePath);
1075
+ } 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 });
1078
+ }
1079
+ const result = SessionSchema.safeParse(raw);
1080
+ if (!result.success) {
1081
+ throw new Error("Failed to read session.yaml", { cause: result.error });
1082
+ }
1083
+ return result.data;
1084
+ }
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
+ }
1105
+ }
1106
+ return { suspect: false, suspectReason: null };
1107
+ }
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;
1122
+ }
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");
1137
+ }
1138
+ entries.push({ sessionId: sid, session, suspect, suspectReason });
1139
+ }
1140
+ return entries;
1141
+ }
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) {
1148
+ 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 });
1153
+ }
1154
+ }
1155
+ async function writeMarkdownFile(filePath, body) {
1156
+ try {
1157
+ await atomicReplace(filePath, body);
1158
+ } catch (error) {
1159
+ throw new Error("Failed to write markdown file", { cause: error });
1160
+ }
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);
1169
+ }
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
+ `;
1195
+ }
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}`);
1208
+ }
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
+ }
1226
+ }
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";
1244
+ }
1245
+
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;
1256
+ try {
1257
+ validated = EventSchema.parse(event);
1258
+ } catch (error) {
1259
+ throw new Error("Invalid Basou event payload", { cause: error });
1260
+ }
1261
+ const line = `${JSON.stringify(validated)}
1262
+ `;
1263
+ try {
1264
+ await appendFile(join8(sessionDir, "events.jsonl"), line, "utf8");
1265
+ } catch (error) {
1266
+ throw new Error("Failed to append event to events.jsonl", { cause: error });
1267
+ }
1268
+ }
1269
+ async function writeEventsBulk(sessionDir, events) {
1270
+ const validated = [];
1271
+ try {
1272
+ for (const event of events) {
1273
+ validated.push(EventSchema.parse(event));
1274
+ }
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
+ }
1286
+ }
1287
+
1288
+ // src/lib/path-sanitizer.ts
1289
+ import { posix as path } from "path";
1290
+ function sanitizePath(rawPath, opts) {
1291
+ if (rawPath.includes("\0")) {
1292
+ throw new Error("Invalid path: contains null byte");
1293
+ }
1294
+ const normalized = path.normalize(rawPath.replace(/\\/g, "/"));
1295
+ const wd = path.normalize(opts.workingDirectory.replace(/\\/g, "/"));
1296
+ const home = path.normalize(opts.homedir.replace(/\\/g, "/"));
1297
+ if (!path.isAbsolute(normalized)) {
1298
+ return normalized;
1299
+ }
1300
+ if (normalized === wd) return ".";
1301
+ const wdRel = path.relative(wd, normalized);
1302
+ if (wdRel !== "" && !wdRel.startsWith("..")) {
1303
+ return wdRel;
1304
+ }
1305
+ if (normalized === home) return "~";
1306
+ const homeRel = path.relative(home, normalized);
1307
+ if (homeRel !== "" && !homeRel.startsWith("..")) {
1308
+ return `~/${homeRel}`;
1309
+ }
1310
+ return normalized;
1311
+ }
1312
+ function sanitizeWorkingDirectory(rawPath, opts) {
1313
+ return sanitizePath(rawPath, {
1314
+ workingDirectory: "/__basou_sentinel_never_match__",
1315
+ homedir: opts.homedir
1316
+ });
1317
+ }
1318
+ function sanitizeRelatedFiles(paths, opts) {
1319
+ const sanitized = [];
1320
+ let mutationCount = 0;
1321
+ for (const p of paths) {
1322
+ const next = sanitizePath(p, opts);
1323
+ sanitized.push(next);
1324
+ if (next !== p) mutationCount += 1;
1325
+ }
1326
+ return { sanitized, mutationCount };
1327
+ }
1328
+
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
+ // 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
+ var FailedToFinalizeError = class extends Error {
1341
+ sessionId;
1342
+ targetEventIds;
1343
+ constructor(sessionId, targetEventIds, cause) {
1344
+ super("Failed to finalize ad-hoc session", { cause });
1345
+ this.name = "FailedToFinalizeError";
1346
+ if (targetEventIds.length === 0) {
1347
+ throw new Error("FailedToFinalizeError requires at least one target event id");
1348
+ }
1349
+ this.sessionId = sessionId;
1350
+ this.targetEventIds = targetEventIds;
1351
+ }
1352
+ };
1353
+ async function createAdHocSessionWithEvent(input) {
1354
+ SessionSourceKindSchema.parse(input.sessionSource);
1355
+ if (input.targetEventBuilders.length === 0) {
1356
+ throw new Error("Ad-hoc session requires at least one target event builder");
1357
+ }
1358
+ const sessionId = prefixedUlid("ses");
1359
+ const startedEventId = prefixedUlid("evt");
1360
+ const statusToRunningEventId = prefixedUlid("evt");
1361
+ const targetEventIds = input.targetEventBuilders.map(() => prefixedUlid("evt"));
1362
+ const statusToCompletedEventId = prefixedUlid("evt");
1363
+ const endedEventId = prefixedUlid("evt");
1364
+ const initialSession = SessionSchema.parse(
1365
+ buildInitialSession({
1366
+ sessionId,
1367
+ workspaceId: input.manifest.workspace.id,
1368
+ sourceKind: input.sessionSource,
1369
+ startedAt: input.occurredAt,
1370
+ label: input.label,
1371
+ workingDirectory: input.workingDirectory,
1372
+ invocation: input.invocation,
1373
+ taskId: input.taskId ?? null
1374
+ })
1375
+ );
1376
+ const sessionDir = join9(input.paths.sessions, sessionId);
1377
+ try {
1378
+ await mkdir2(sessionDir, { recursive: true });
1379
+ } catch (error) {
1380
+ throw new Error("Failed to create session directory", { cause: error });
1381
+ }
1382
+ const sessionYamlPath = join9(sessionDir, "session.yaml");
1383
+ try {
1384
+ await linkYamlFile(sessionYamlPath, initialSession);
1385
+ } catch (error) {
1386
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
1387
+ if (findErrorCode(error, "EEXIST")) {
1388
+ throw new Error("Session directory collision (retry the command)", {
1389
+ cause: error
1390
+ });
1391
+ }
1392
+ throw error;
1393
+ }
1394
+ try {
1395
+ const targetEvents = input.targetEventBuilders.map((build, index) => {
1396
+ const targetEventId = targetEventIds[index];
1397
+ return assertTargetEventIdentity(build(sessionId, targetEventId), sessionId, targetEventId);
1398
+ });
1399
+ const events = [
1400
+ {
1401
+ schema_version: "0.1.0",
1402
+ id: startedEventId,
1403
+ session_id: sessionId,
1404
+ occurred_at: input.occurredAt,
1405
+ source: "local-cli",
1406
+ type: "session_started"
1407
+ },
1408
+ {
1409
+ schema_version: "0.1.0",
1410
+ id: statusToRunningEventId,
1411
+ session_id: sessionId,
1412
+ occurred_at: input.occurredAt,
1413
+ source: "local-cli",
1414
+ type: "session_status_changed",
1415
+ from: "initialized",
1416
+ to: "running"
1417
+ },
1418
+ ...targetEvents,
1419
+ {
1420
+ schema_version: "0.1.0",
1421
+ id: statusToCompletedEventId,
1422
+ session_id: sessionId,
1423
+ occurred_at: input.occurredAt,
1424
+ source: "local-cli",
1425
+ type: "session_status_changed",
1426
+ from: "running",
1427
+ to: "completed"
1428
+ },
1429
+ {
1430
+ schema_version: "0.1.0",
1431
+ id: endedEventId,
1432
+ session_id: sessionId,
1433
+ occurred_at: input.occurredAt,
1434
+ source: "local-cli",
1435
+ type: "session_ended",
1436
+ exit_code: 0
1437
+ }
1438
+ ];
1439
+ await writeEventsBulk(sessionDir, events);
1440
+ } catch (error) {
1441
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
1442
+ throw error;
1443
+ }
1444
+ try {
1445
+ const finalSession = SessionSchema.parse({
1446
+ ...initialSession,
1447
+ session: {
1448
+ ...initialSession.session,
1449
+ status: "completed",
1450
+ ended_at: input.occurredAt,
1451
+ invocation: { ...initialSession.session.invocation, exit_code: 0 }
1452
+ }
1453
+ });
1454
+ await overwriteYamlFile(sessionYamlPath, finalSession);
1455
+ } catch (error) {
1456
+ throw new FailedToFinalizeError(sessionId, targetEventIds, error);
1457
+ }
1458
+ return {
1459
+ sessionId,
1460
+ targetEventIds,
1461
+ lifecycleEventIds: [
1462
+ startedEventId,
1463
+ statusToRunningEventId,
1464
+ statusToCompletedEventId,
1465
+ endedEventId
1466
+ ]
1467
+ };
1468
+ }
1469
+ function buildInitialSession(input) {
1470
+ return {
1471
+ schema_version: "0.1.0",
1472
+ session: {
1473
+ id: input.sessionId,
1474
+ label: input.label,
1475
+ task_id: input.taskId,
1476
+ workspace_id: input.workspaceId,
1477
+ source: { kind: input.sourceKind, version: "0.1.0" },
1478
+ started_at: input.startedAt,
1479
+ status: "initialized",
1480
+ working_directory: sanitizeWorkingDirectory(input.workingDirectory, { homedir: homedir() }),
1481
+ invocation: { ...input.invocation, exit_code: null },
1482
+ related_files: [],
1483
+ events_log: "events.jsonl"
1484
+ }
1485
+ };
1486
+ }
1487
+ var DEFAULT_ATTACHABLE_STATUSES = /* @__PURE__ */ new Set([
1488
+ "initialized",
1489
+ "running",
1490
+ "waiting_approval"
1491
+ ]);
1492
+ async function appendEventToExistingSession(input) {
1493
+ SessionIdSchema.parse(input.sessionId);
1494
+ const sessionDoc = await readSessionYaml(input.paths, input.sessionId);
1495
+ const status = sessionDoc.session.status;
1496
+ if (status === "imported") {
1497
+ throw new Error("Cannot attach to imported session");
1498
+ }
1499
+ const attachable = input.attachableStatuses ?? DEFAULT_ATTACHABLE_STATUSES;
1500
+ if (!attachable.has(status)) {
1501
+ throw new Error(`Session is not active: ${status}`);
1502
+ }
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 };
1508
+ }
1509
+ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1510
+ if (event.session_id !== expectedSessionId) {
1511
+ throw new Error("Target event session_id mismatch");
1512
+ }
1513
+ if (event.id !== expectedEventId) {
1514
+ throw new Error("Target event id mismatch");
1515
+ }
1516
+ return event;
1517
+ }
1518
+
1519
+ // src/storage/tasks.ts
1520
+ var FRONT_MATTER_DELIM = "---";
1521
+ var LABEL_TITLE_MAX = 80;
1522
+ var LABEL_TRUNCATE_HEAD = LABEL_TITLE_MAX - 3;
1523
+ var DEFAULT_ATTACHABLE_STATUSES2 = /* @__PURE__ */ new Set([
1524
+ "initialized",
1525
+ "running",
1526
+ "waiting_approval"
1527
+ ]);
1528
+ var InitialTaskStatusSchema = TaskStatusSchema;
1529
+ var TaskTitleSchema = z10.string().min(1);
1530
+ var TaskLabelSchema = z10.string().min(1);
1531
+ var CompletedAtSchema = IsoTimestampSchema;
1532
+ var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set(["done", "cancelled"]);
1533
+ function isTerminalTaskStatus(status) {
1534
+ return TERMINAL_TASK_STATUSES.has(status);
1535
+ }
1536
+ function splitFrontMatter(raw) {
1537
+ if (raw.length > 0 && raw.charCodeAt(0) === 65279) {
1538
+ throw new Error("Invalid task file format");
1539
+ }
1540
+ const normalised = raw.replace(/\r\n/g, "\n");
1541
+ if (!normalised.startsWith(`${FRONT_MATTER_DELIM}
1542
+ `)) {
1543
+ throw new Error("Invalid task file format");
1544
+ }
1545
+ const remainder = normalised.slice(FRONT_MATTER_DELIM.length + 1);
1546
+ const lines = remainder.split("\n");
1547
+ let closingIdx = -1;
1548
+ for (let i = 0; i < lines.length; i++) {
1549
+ if (lines[i] === FRONT_MATTER_DELIM) {
1550
+ closingIdx = i;
1551
+ break;
1552
+ }
1553
+ }
1554
+ if (closingIdx < 0) {
1555
+ throw new Error("Invalid task file format");
1556
+ }
1557
+ const yamlText = lines.slice(0, closingIdx).join("\n");
1558
+ const afterClosing = lines.slice(closingIdx + 1);
1559
+ let body = afterClosing.join("\n");
1560
+ if (body.startsWith("\n")) body = body.slice(1);
1561
+ return { yamlText, body };
1562
+ }
1563
+ async function readTaskFile(paths, taskId) {
1564
+ const filePath = join10(paths.tasks, `${taskId}.md`);
1565
+ let raw;
1566
+ try {
1567
+ raw = await readFile7(filePath, "utf8");
1568
+ } catch (error) {
1569
+ if (findErrorCode(error, "ENOENT")) {
1570
+ throw new Error("Task file not found", { cause: error });
1571
+ }
1572
+ throw new Error("Failed to read task file", { cause: error });
1573
+ }
1574
+ let split;
1575
+ try {
1576
+ split = splitFrontMatter(raw);
1577
+ } catch (error) {
1578
+ if (error instanceof Error && error.message === "Invalid task file format") {
1579
+ throw error;
1580
+ }
1581
+ throw new Error("Failed to read task file", { cause: error });
1582
+ }
1583
+ let parsed;
1584
+ try {
1585
+ parsed = parseYaml(split.yamlText);
1586
+ } catch (error) {
1587
+ throw new Error("Failed to read task file", { cause: error });
1588
+ }
1589
+ const result = TaskSchema.safeParse(parsed);
1590
+ if (!result.success) {
1591
+ throw new Error("Failed to read task file", { cause: result.error });
1592
+ }
1593
+ return { task: result.data, body: split.body };
1594
+ }
1595
+ async function writeTaskFile(paths, taskId, doc, options) {
1596
+ const validated = TaskSchema.parse(doc.task);
1597
+ const filePath = join10(paths.tasks, `${taskId}.md`);
1598
+ const yamlText = stringifyYaml(validated);
1599
+ const trimmedBody = doc.body.length === 0 ? "" : `
1600
+ ${doc.body.endsWith("\n") ? doc.body : `${doc.body}
1601
+ `}`;
1602
+ const fileBody = `${FRONT_MATTER_DELIM}
1603
+ ${yamlText}${FRONT_MATTER_DELIM}
1604
+ ${trimmedBody}`;
1605
+ if (options.mode === "create") {
1606
+ try {
1607
+ await atomicCreate(filePath, fileBody);
1608
+ } catch (error) {
1609
+ if (findErrorCode(error, "EEXIST")) {
1610
+ throw new Error("Task file already exists", { cause: error });
1611
+ }
1612
+ throw new Error("Failed to write task file", { cause: error });
1613
+ }
1614
+ return;
1615
+ }
1616
+ try {
1617
+ await atomicReplace(filePath, fileBody);
1618
+ } catch (error) {
1619
+ throw new Error("Failed to write task file", { cause: error });
1620
+ }
1621
+ }
1622
+ var TASK_FILENAME_RE = /^(.+)\.md$/;
1623
+ async function enumerateTaskIds(paths) {
1624
+ try {
1625
+ const index = await readTaskIndex(paths);
1626
+ return index.tasks.map((t) => t.id);
1627
+ } catch {
1628
+ }
1629
+ const ids = await enumerateTaskIdsFromDisk(paths);
1630
+ if (ids.length === 0) {
1631
+ return ids;
1632
+ }
1633
+ const entries = [];
1634
+ for (const id of ids) {
1635
+ try {
1636
+ const doc = await readTaskFile(paths, id);
1637
+ entries.push(buildTaskIndexEntry(doc.task.task));
1638
+ } catch {
1639
+ }
1640
+ }
1641
+ await rebuildTaskIndex(paths, entries).catch(() => {
1642
+ console.warn("Failed to rebuild tasks/index.json; subsequent reads will retry");
1643
+ });
1644
+ return ids;
1645
+ }
1646
+ async function enumerateTaskIdsFromDisk(paths) {
1647
+ let entries;
1648
+ try {
1649
+ entries = (await readdir3(paths.tasks, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
1650
+ } catch (error) {
1651
+ if (findErrorCode(error, "ENOENT")) return [];
1652
+ throw new Error("Failed to enumerate tasks", { cause: error });
1653
+ }
1654
+ const taskIds = [];
1655
+ for (const name of entries) {
1656
+ const match = TASK_FILENAME_RE.exec(name);
1657
+ if (match === null) continue;
1658
+ const candidate = match[1];
1659
+ if (!TaskIdSchema.safeParse(candidate).success) continue;
1660
+ taskIds.push(candidate);
1661
+ }
1662
+ taskIds.sort();
1663
+ return taskIds;
1664
+ }
1665
+ function buildTaskIndexEntry(task) {
1666
+ return {
1667
+ id: task.id,
1668
+ status: task.status,
1669
+ ...task.label !== void 0 ? { label: task.label } : {},
1670
+ updated_at: task.updated_at
1671
+ };
1672
+ }
1673
+ async function safeUpdateTaskIndex(paths, op) {
1674
+ try {
1675
+ await updateTaskIndex(paths, op);
1676
+ } catch {
1677
+ console.warn("Index update failed; rebuild on next read");
1678
+ }
1679
+ }
1680
+ var ARCHIVE_DIR_NAME = "archive";
1681
+ function archiveTasksDir(paths) {
1682
+ return join10(paths.tasks, ARCHIVE_DIR_NAME);
1683
+ }
1684
+ async function enumerateArchivedTaskIds(paths) {
1685
+ let entries;
1686
+ try {
1687
+ entries = (await readdir3(archiveTasksDir(paths), { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name);
1688
+ } catch (error) {
1689
+ if (findErrorCode(error, "ENOENT")) return [];
1690
+ throw new Error("Failed to enumerate archived tasks", { cause: error });
1691
+ }
1692
+ const taskIds = [];
1693
+ for (const name of entries) {
1694
+ const match = TASK_FILENAME_RE.exec(name);
1695
+ if (match === null) continue;
1696
+ const candidate = match[1];
1697
+ if (!TaskIdSchema.safeParse(candidate).success) continue;
1698
+ taskIds.push(candidate);
1699
+ }
1700
+ taskIds.sort();
1701
+ return taskIds;
1702
+ }
1703
+ async function readTaskFileWithArchiveFallback(paths, taskId) {
1704
+ try {
1705
+ const doc = await readTaskFile(paths, taskId);
1706
+ return { doc, archived: false };
1707
+ } catch (error) {
1708
+ if (!(error instanceof Error && error.message === "Task file not found")) {
1709
+ throw error;
1710
+ }
1711
+ }
1712
+ const archiveFilePath = join10(archiveTasksDir(paths), `${taskId}.md`);
1713
+ let raw;
1714
+ try {
1715
+ raw = await readFile7(archiveFilePath, "utf8");
1716
+ } catch (error) {
1717
+ if (findErrorCode(error, "ENOENT")) {
1718
+ throw new Error("Task file not found", { cause: error });
1719
+ }
1720
+ throw new Error("Failed to read task file", { cause: error });
1721
+ }
1722
+ let split;
1723
+ try {
1724
+ split = splitFrontMatter(raw);
1725
+ } catch (error) {
1726
+ if (error instanceof Error && error.message === "Invalid task file format") {
1727
+ throw error;
1728
+ }
1729
+ throw new Error("Failed to read task file", { cause: error });
1730
+ }
1731
+ let parsed;
1732
+ try {
1733
+ parsed = parseYaml(split.yamlText);
1734
+ } catch (error) {
1735
+ throw new Error("Failed to read task file", { cause: error });
1736
+ }
1737
+ const result = TaskSchema.safeParse(parsed);
1738
+ if (!result.success) {
1739
+ throw new Error("Failed to read task file", { cause: result.error });
1740
+ }
1741
+ return { doc: { task: result.data, body: split.body }, archived: true };
1742
+ }
1743
+ async function loadTaskEntries(paths, options = {}) {
1744
+ const ids = await enumerateTaskIds(paths);
1745
+ const entries = [];
1746
+ for (const id of ids) {
1747
+ let doc;
1748
+ try {
1749
+ doc = await readTaskFile(paths, id);
1750
+ } catch (error) {
1751
+ if (error instanceof Error && error.message === "Invalid task file format") {
1752
+ options.onSkip?.(id, "task_file_invalid");
1753
+ } else if (error instanceof Error && error.message === "Failed to read task file") {
1754
+ options.onSkip?.(id, "task_file_invalid");
1755
+ } else if (error instanceof Error && error.message === "Task file not found") {
1756
+ options.onSkip?.(id, "task_file_unreadable");
1757
+ } else {
1758
+ options.onSkip?.(id, "task_file_unreadable");
1759
+ }
1760
+ continue;
1761
+ }
1762
+ entries.push(doc);
1763
+ }
1764
+ entries.sort((a, b) => {
1765
+ const c = Date.parse(a.task.task.created_at) - Date.parse(b.task.task.created_at);
1766
+ return c !== 0 ? c : a.task.task.id.localeCompare(b.task.task.id);
1767
+ });
1768
+ return entries;
1769
+ }
1770
+ var ALLOWED_TRANSITIONS = {
1771
+ planned: /* @__PURE__ */ new Set(["in_progress", "done", "cancelled"]),
1772
+ in_progress: /* @__PURE__ */ new Set(["done", "cancelled"]),
1773
+ done: /* @__PURE__ */ new Set(),
1774
+ cancelled: /* @__PURE__ */ new Set()
1775
+ };
1776
+ function assertTransitionAllowed(from, to) {
1777
+ const allowed = ALLOWED_TRANSITIONS[from];
1778
+ if (!allowed.has(to)) {
1779
+ throw new Error(`Invalid task status transition: ${from} -> ${to}`);
1780
+ }
1781
+ }
1782
+ var TaskWriteAfterEventError = class extends Error {
1783
+ taskId;
1784
+ eventId;
1785
+ sessionId;
1786
+ phase;
1787
+ constructor(args) {
1788
+ super("Failed to write task file after event was persisted", { cause: args.cause });
1789
+ this.name = "TaskWriteAfterEventError";
1790
+ this.taskId = args.taskId;
1791
+ this.eventId = args.eventId;
1792
+ this.sessionId = args.sessionId;
1793
+ this.phase = args.phase;
1794
+ }
1795
+ };
1796
+ function buildTaskCreatedEvent(input) {
1797
+ return {
1798
+ schema_version: "0.1.0",
1799
+ id: input.eventId,
1800
+ session_id: input.sessionId,
1801
+ occurred_at: input.occurredAt,
1802
+ source: "local-cli",
1803
+ type: "task_created",
1804
+ task_id: input.taskId,
1805
+ title: input.title
1806
+ };
1807
+ }
1808
+ function buildTaskStatusChangedEvent(input) {
1809
+ return {
1810
+ schema_version: "0.1.0",
1811
+ id: input.eventId,
1812
+ session_id: input.sessionId,
1813
+ occurred_at: input.occurredAt,
1814
+ source: "local-cli",
1815
+ type: "task_status_changed",
1816
+ task_id: input.taskId,
1817
+ from: input.from,
1818
+ to: input.to
1819
+ };
1820
+ }
1821
+ function buildAdHocTaskLabel(title, mode) {
1822
+ const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
1823
+ return mode === "new" ? `Ad-hoc task: ${truncated}` : `Ad-hoc task status: ${truncated}`;
1824
+ }
1825
+ function buildAdHocReconcileLabel(title) {
1826
+ const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
1827
+ return `Ad-hoc task reconcile: ${truncated}`;
1828
+ }
1829
+ function buildAdHocRefreshLinkageLabel(title) {
1830
+ const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
1831
+ return `Ad-hoc task refresh-linkage: ${truncated}`;
1832
+ }
1833
+ function buildAdHocDeleteLabel(title) {
1834
+ const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
1835
+ return `Ad-hoc task delete: ${truncated}`;
1836
+ }
1837
+ function buildAdHocArchiveLabel(title) {
1838
+ const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
1839
+ return `Ad-hoc task archive: ${truncated}`;
1840
+ }
1841
+ function buildTaskReconciledEvent(input) {
1842
+ return {
1843
+ schema_version: "0.1.0",
1844
+ id: input.eventId,
1845
+ session_id: input.sessionId,
1846
+ occurred_at: input.occurredAt,
1847
+ source: "local-cli",
1848
+ type: "task_reconciled",
1849
+ task_id: input.taskId,
1850
+ removed_created_in_session: input.removedCreatedInSession,
1851
+ created_in_session_replacement: input.createdInSessionReplacement,
1852
+ removed_linked_sessions: input.removedLinkedSessions
1853
+ };
1854
+ }
1855
+ function buildTaskDeletedEvent(input) {
1856
+ return {
1857
+ schema_version: "0.1.0",
1858
+ id: input.eventId,
1859
+ session_id: input.sessionId,
1860
+ occurred_at: input.occurredAt,
1861
+ source: "local-cli",
1862
+ type: "task_deleted",
1863
+ task_id: input.taskId,
1864
+ title: input.title
1865
+ };
1866
+ }
1867
+ function buildTaskArchivedEvent(input) {
1868
+ return {
1869
+ schema_version: "0.1.0",
1870
+ id: input.eventId,
1871
+ session_id: input.sessionId,
1872
+ occurred_at: input.occurredAt,
1873
+ source: "local-cli",
1874
+ type: "task_archived",
1875
+ task_id: input.taskId,
1876
+ title: input.title
1877
+ };
1878
+ }
1879
+ function buildTaskLinkageRefreshedEvent(input) {
1880
+ return {
1881
+ schema_version: "0.1.0",
1882
+ id: input.eventId,
1883
+ session_id: input.sessionId,
1884
+ occurred_at: input.occurredAt,
1885
+ source: "local-cli",
1886
+ type: "task_linkage_refreshed",
1887
+ task_id: input.taskId,
1888
+ added_linked_sessions: input.addedLinkedSessions,
1889
+ removed_linked_sessions: input.removedLinkedSessions,
1890
+ final_count: input.finalCount
1891
+ };
1892
+ }
1893
+ async function createTaskWithEvent(input) {
1894
+ TaskIdSchema.parse(input.taskId);
1895
+ InitialTaskStatusSchema.parse(input.initialStatus);
1896
+ TaskTitleSchema.parse(input.title);
1897
+ if (input.label !== void 0) {
1898
+ TaskLabelSchema.parse(input.label);
1899
+ }
1900
+ if (input.completedAt !== void 0) {
1901
+ CompletedAtSchema.parse(input.completedAt);
1902
+ }
1903
+ if (input.mode === "ad-hoc") {
1904
+ return createTaskAdHoc(input);
1905
+ }
1906
+ return createTaskAttach(input);
1907
+ }
1908
+ async function createTaskAdHoc(input) {
1909
+ const adHoc = await createAdHocSessionWithEvent({
1910
+ paths: input.paths,
1911
+ manifest: input.manifest,
1912
+ label: buildAdHocTaskLabel(input.title, "new"),
1913
+ occurredAt: input.occurredAt,
1914
+ sessionSource: "human",
1915
+ workingDirectory: input.workingDirectory,
1916
+ invocation: {
1917
+ command: "basou task new",
1918
+ args: buildTaskNewInvocationArgs(input.title, input.initialStatus, input.completedAt)
1919
+ },
1920
+ taskId: input.taskId,
1921
+ targetEventBuilders: buildTaskNewTargetEventBuilders({
1922
+ taskId: input.taskId,
1923
+ title: input.title,
1924
+ initialStatus: input.initialStatus,
1925
+ occurredAt: input.occurredAt
1926
+ })
1927
+ });
1928
+ const task = buildInitialTask({
1929
+ taskId: input.taskId,
1930
+ title: input.title,
1931
+ ...input.label !== void 0 ? { label: input.label } : {},
1932
+ status: input.initialStatus,
1933
+ occurredAt: input.occurredAt,
1934
+ ...input.completedAt !== void 0 ? { completedAt: input.completedAt } : {},
1935
+ workspaceId: input.manifest.workspace.id,
1936
+ createdInSession: adHoc.sessionId
1937
+ });
1938
+ const anchorEventId = adHoc.targetEventIds[0];
1939
+ try {
1940
+ await writeTaskFile(
1941
+ input.paths,
1942
+ input.taskId,
1943
+ { task, body: input.description },
1944
+ { mode: "create" }
1945
+ );
1946
+ } catch (error) {
1947
+ throw new TaskWriteAfterEventError({
1948
+ taskId: input.taskId,
1949
+ eventId: anchorEventId,
1950
+ sessionId: adHoc.sessionId,
1951
+ phase: "create",
1952
+ cause: error
1953
+ });
1954
+ }
1955
+ await safeUpdateTaskIndex(input.paths, { kind: "add", entry: buildTaskIndexEntry(task.task) });
1956
+ return {
1957
+ taskId: input.taskId,
1958
+ eventId: anchorEventId,
1959
+ sessionId: adHoc.sessionId,
1960
+ sessionStatus: "completed"
1961
+ };
1962
+ }
1963
+ async function createTaskAttach(input) {
1964
+ SessionIdSchema.parse(input.sessionId);
1965
+ const sessionLock = await acquireLock(input.paths, "session", input.sessionId);
1966
+ try {
1967
+ return await createTaskAttachLocked(input);
1968
+ } finally {
1969
+ await sessionLock.release();
1970
+ }
1971
+ }
1972
+ async function createTaskAttachLocked(input) {
1973
+ const sessionDoc = await readSessionYaml(input.paths, input.sessionId);
1974
+ const status = sessionDoc.session.status;
1975
+ if (status === "imported") {
1976
+ throw new Error("Cannot attach to imported session");
1977
+ }
1978
+ const attachable = input.attachableStatuses ?? DEFAULT_ATTACHABLE_STATUSES2;
1979
+ if (!attachable.has(status)) {
1980
+ throw new Error(`Session is not active: ${status}`);
1981
+ }
1982
+ const existingTaskId = sessionDoc.session.task_id ?? null;
1983
+ if (existingTaskId !== null && existingTaskId !== input.taskId) {
1984
+ throw new Error(`Session already linked to a different task: ${existingTaskId}`);
1985
+ }
1986
+ if (existingTaskId === input.taskId) {
1987
+ throw new Error(`Task already exists: ${input.taskId}`);
1988
+ }
1989
+ const appendResult = await appendEventToExistingSession({
1990
+ paths: input.paths,
1991
+ sessionId: input.sessionId,
1992
+ ...input.attachableStatuses !== void 0 ? { attachableStatuses: input.attachableStatuses } : {},
1993
+ eventBuilder: (eventId) => buildTaskCreatedEvent({
1994
+ eventId,
1995
+ sessionId: input.sessionId,
1996
+ taskId: input.taskId,
1997
+ title: input.title,
1998
+ occurredAt: input.occurredAt
1999
+ })
2000
+ });
2001
+ try {
2002
+ const updated = {
2003
+ ...sessionDoc,
2004
+ session: { ...sessionDoc.session, task_id: input.taskId }
2005
+ };
2006
+ await overwriteYamlFile(join10(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2007
+ } catch (error) {
2008
+ throw new TaskWriteAfterEventError({
2009
+ taskId: input.taskId,
2010
+ eventId: appendResult.eventId,
2011
+ sessionId: input.sessionId,
2012
+ phase: "link-session",
2013
+ cause: error
2014
+ });
2015
+ }
2016
+ if (isTerminalTaskStatus(input.initialStatus)) {
2017
+ await appendEventToExistingSession({
2018
+ paths: input.paths,
2019
+ sessionId: input.sessionId,
2020
+ ...input.attachableStatuses !== void 0 ? { attachableStatuses: input.attachableStatuses } : {},
2021
+ eventBuilder: (eventId) => buildTaskStatusChangedEvent({
2022
+ eventId,
2023
+ sessionId: input.sessionId,
2024
+ taskId: input.taskId,
2025
+ from: "planned",
2026
+ to: input.initialStatus,
2027
+ occurredAt: input.occurredAt
2028
+ })
2029
+ });
2030
+ }
2031
+ const task = buildInitialTask({
2032
+ taskId: input.taskId,
2033
+ title: input.title,
2034
+ ...input.label !== void 0 ? { label: input.label } : {},
2035
+ status: input.initialStatus,
2036
+ occurredAt: input.occurredAt,
2037
+ ...input.completedAt !== void 0 ? { completedAt: input.completedAt } : {},
2038
+ workspaceId: sessionDoc.session.workspace_id,
2039
+ createdInSession: input.sessionId
2040
+ });
2041
+ try {
2042
+ await writeTaskFile(
2043
+ input.paths,
2044
+ input.taskId,
2045
+ { task, body: input.description },
2046
+ { mode: "create" }
2047
+ );
2048
+ } catch (error) {
2049
+ throw new TaskWriteAfterEventError({
2050
+ taskId: input.taskId,
2051
+ eventId: appendResult.eventId,
2052
+ sessionId: input.sessionId,
2053
+ phase: "create",
2054
+ cause: error
2055
+ });
2056
+ }
2057
+ await safeUpdateTaskIndex(input.paths, { kind: "add", entry: buildTaskIndexEntry(task.task) });
2058
+ return {
2059
+ taskId: input.taskId,
2060
+ eventId: appendResult.eventId,
2061
+ sessionId: input.sessionId,
2062
+ sessionStatus: status
2063
+ };
2064
+ }
2065
+ function buildInitialTask(input) {
2066
+ const updatedAt = input.completedAt !== void 0 && isTerminalTaskStatus(input.status) ? input.completedAt : input.occurredAt;
2067
+ return {
2068
+ schema_version: "0.1.0",
2069
+ task: {
2070
+ id: input.taskId,
2071
+ title: input.title,
2072
+ ...input.label !== void 0 ? { label: input.label } : {},
2073
+ status: input.status,
2074
+ created_at: input.occurredAt,
2075
+ updated_at: updatedAt,
2076
+ workspace_id: input.workspaceId,
2077
+ created_in_session: input.createdInSession,
2078
+ linked_sessions: [input.createdInSession]
2079
+ }
2080
+ };
2081
+ }
2082
+ function buildTaskNewInvocationArgs(title, initialStatus, completedAt) {
2083
+ const args = ["--title", title];
2084
+ if (initialStatus !== "planned") {
2085
+ args.push("--status", initialStatus);
2086
+ }
2087
+ if (completedAt !== void 0 && isTerminalTaskStatus(initialStatus)) {
2088
+ args.push("--completed-at", completedAt);
2089
+ }
2090
+ return args;
2091
+ }
2092
+ function buildTaskNewTargetEventBuilders(input) {
2093
+ const createdBuilder = (sessionId, eventId) => buildTaskCreatedEvent({
2094
+ eventId,
2095
+ sessionId,
2096
+ taskId: input.taskId,
2097
+ title: input.title,
2098
+ occurredAt: input.occurredAt
2099
+ });
2100
+ if (!isTerminalTaskStatus(input.initialStatus)) {
2101
+ return [createdBuilder];
2102
+ }
2103
+ const statusChangedBuilder = (sessionId, eventId) => buildTaskStatusChangedEvent({
2104
+ eventId,
2105
+ sessionId,
2106
+ taskId: input.taskId,
2107
+ from: "planned",
2108
+ to: input.initialStatus,
2109
+ occurredAt: input.occurredAt
2110
+ });
2111
+ return [createdBuilder, statusChangedBuilder];
2112
+ }
2113
+ async function updateTaskStatusWithEvent(input) {
2114
+ TaskIdSchema.parse(input.taskId);
2115
+ const handle = await acquireLock(input.paths, "task", input.taskId);
2116
+ try {
2117
+ const currentDoc = await readTaskFile(input.paths, input.taskId);
2118
+ const previousStatus = currentDoc.task.task.status;
2119
+ assertTransitionAllowed(previousStatus, input.newStatus);
2120
+ if (input.mode === "ad-hoc") {
2121
+ return await updateTaskStatusAdHoc(input, currentDoc, previousStatus);
2122
+ }
2123
+ return await updateTaskStatusAttach(input, currentDoc, previousStatus);
2124
+ } finally {
2125
+ await handle.release();
2126
+ }
2127
+ }
2128
+ async function updateTaskStatusAdHoc(input, currentDoc, previousStatus) {
2129
+ const title = currentDoc.task.task.title;
2130
+ const adHoc = await createAdHocSessionWithEvent({
2131
+ paths: input.paths,
2132
+ manifest: input.manifest,
2133
+ label: buildAdHocTaskLabel(title, "status"),
2134
+ occurredAt: input.occurredAt,
2135
+ sessionSource: "human",
2136
+ workingDirectory: input.workingDirectory,
2137
+ invocation: { command: "basou task status", args: [input.taskId, input.newStatus] },
2138
+ taskId: input.taskId,
2139
+ targetEventBuilders: [
2140
+ (sessionId, eventId) => buildTaskStatusChangedEvent({
2141
+ eventId,
2142
+ sessionId,
2143
+ taskId: input.taskId,
2144
+ from: previousStatus,
2145
+ to: input.newStatus,
2146
+ occurredAt: input.occurredAt
2147
+ })
2148
+ ]
2149
+ });
2150
+ const anchorEventId = adHoc.targetEventIds[0];
2151
+ const updatedDoc = buildUpdatedDoc({
2152
+ currentDoc,
2153
+ newStatus: input.newStatus,
2154
+ occurredAt: input.occurredAt,
2155
+ appendSessionId: adHoc.sessionId
2156
+ });
2157
+ try {
2158
+ await writeTaskFile(input.paths, input.taskId, updatedDoc, { mode: "overwrite" });
2159
+ } catch (error) {
2160
+ throw new TaskWriteAfterEventError({
2161
+ taskId: input.taskId,
2162
+ eventId: anchorEventId,
2163
+ sessionId: adHoc.sessionId,
2164
+ phase: "overwrite",
2165
+ cause: error
2166
+ });
2167
+ }
2168
+ await safeUpdateTaskIndex(input.paths, {
2169
+ kind: "update",
2170
+ entry: buildTaskIndexEntry(updatedDoc.task.task)
2171
+ });
2172
+ return {
2173
+ taskId: input.taskId,
2174
+ eventId: anchorEventId,
2175
+ sessionId: adHoc.sessionId,
2176
+ sessionStatus: "completed",
2177
+ previousStatus,
2178
+ newStatus: input.newStatus
2179
+ };
2180
+ }
2181
+ async function updateTaskStatusAttach(input, currentDoc, previousStatus) {
2182
+ SessionIdSchema.parse(input.sessionId);
2183
+ const sessionLock = await acquireLock(input.paths, "session", input.sessionId);
2184
+ try {
2185
+ return await updateTaskStatusAttachLocked(input, currentDoc, previousStatus);
2186
+ } finally {
2187
+ await sessionLock.release();
2188
+ }
2189
+ }
2190
+ async function updateTaskStatusAttachLocked(input, currentDoc, previousStatus) {
2191
+ const sessionDoc = await readSessionYaml(input.paths, input.sessionId);
2192
+ const status = sessionDoc.session.status;
2193
+ if (status === "imported") {
2194
+ throw new Error("Cannot attach to imported session");
2195
+ }
2196
+ const attachable = input.attachableStatuses ?? DEFAULT_ATTACHABLE_STATUSES2;
2197
+ if (!attachable.has(status)) {
2198
+ throw new Error(`Session is not active: ${status}`);
2199
+ }
2200
+ const existingTaskId = sessionDoc.session.task_id ?? null;
2201
+ if (existingTaskId === null) {
2202
+ throw new Error(`Session is not linked to task: ${input.taskId}`);
2203
+ }
2204
+ if (existingTaskId !== input.taskId) {
2205
+ throw new Error(`Session already linked to a different task: ${existingTaskId}`);
2206
+ }
2207
+ const appendResult = await appendEventToExistingSession({
2208
+ paths: input.paths,
2209
+ sessionId: input.sessionId,
2210
+ ...input.attachableStatuses !== void 0 ? { attachableStatuses: input.attachableStatuses } : {},
2211
+ eventBuilder: (eventId) => buildTaskStatusChangedEvent({
2212
+ eventId,
2213
+ sessionId: input.sessionId,
2214
+ taskId: input.taskId,
2215
+ from: previousStatus,
2216
+ to: input.newStatus,
2217
+ occurredAt: input.occurredAt
2218
+ })
2219
+ });
2220
+ const updatedDoc = buildUpdatedDoc({
2221
+ currentDoc,
2222
+ newStatus: input.newStatus,
2223
+ occurredAt: input.occurredAt,
2224
+ appendSessionId: input.sessionId
2225
+ });
2226
+ try {
2227
+ await writeTaskFile(input.paths, input.taskId, updatedDoc, { mode: "overwrite" });
2228
+ } catch (error) {
2229
+ throw new TaskWriteAfterEventError({
2230
+ taskId: input.taskId,
2231
+ eventId: appendResult.eventId,
2232
+ sessionId: input.sessionId,
2233
+ phase: "overwrite",
2234
+ cause: error
2235
+ });
2236
+ }
2237
+ await safeUpdateTaskIndex(input.paths, {
2238
+ kind: "update",
2239
+ entry: buildTaskIndexEntry(updatedDoc.task.task)
2240
+ });
2241
+ return {
2242
+ taskId: input.taskId,
2243
+ eventId: appendResult.eventId,
2244
+ sessionId: input.sessionId,
2245
+ sessionStatus: status,
2246
+ previousStatus,
2247
+ newStatus: input.newStatus
2248
+ };
2249
+ }
2250
+ function buildUpdatedDoc(input) {
2251
+ const linked = input.currentDoc.task.task.linked_sessions;
2252
+ const merged = linked.includes(input.appendSessionId) ? linked : [...linked, input.appendSessionId];
2253
+ const next = {
2254
+ ...input.currentDoc.task,
2255
+ task: {
2256
+ ...input.currentDoc.task.task,
2257
+ status: input.newStatus,
2258
+ updated_at: input.occurredAt,
2259
+ linked_sessions: merged
2260
+ }
2261
+ };
2262
+ return { task: next, body: input.currentDoc.body };
2263
+ }
2264
+ async function computeTaskMdSnapshot(paths, taskId) {
2265
+ const filePath = join10(paths.tasks, `${taskId}.md`);
2266
+ const [stats, raw] = await Promise.all([stat2(filePath), readFile7(filePath)]);
2267
+ const hash = createHash("sha256").update(raw).digest("hex");
2268
+ return { mtimeMs: stats.mtimeMs, hash };
2269
+ }
2270
+ async function readTaskFileWithSnapshot(paths, taskId) {
2271
+ const filePath = join10(paths.tasks, `${taskId}.md`);
2272
+ let rawBuffer;
2273
+ let stats;
2274
+ try {
2275
+ [rawBuffer, stats] = await Promise.all([readFile7(filePath), stat2(filePath)]);
2276
+ } catch (error) {
2277
+ if (findErrorCode(error, "ENOENT")) {
2278
+ throw new Error("Task file not found", { cause: error });
2279
+ }
2280
+ throw new Error("Failed to read task file", { cause: error });
2281
+ }
2282
+ const raw = rawBuffer.toString("utf8");
2283
+ const hash = createHash("sha256").update(rawBuffer).digest("hex");
2284
+ let split;
2285
+ try {
2286
+ split = splitFrontMatter(raw);
2287
+ } catch (error) {
2288
+ if (error instanceof Error && error.message === "Invalid task file format") {
2289
+ throw error;
2290
+ }
2291
+ throw new Error("Failed to read task file", { cause: error });
2292
+ }
2293
+ let parsed;
2294
+ try {
2295
+ parsed = parseYaml(split.yamlText);
2296
+ } catch (error) {
2297
+ throw new Error("Failed to read task file", { cause: error });
2298
+ }
2299
+ const result = TaskSchema.safeParse(parsed);
2300
+ if (!result.success) {
2301
+ throw new Error("Failed to read task file", { cause: result.error });
2302
+ }
2303
+ return {
2304
+ doc: { task: result.data, body: split.body },
2305
+ snapshot: { mtimeMs: stats.mtimeMs, hash }
2306
+ };
2307
+ }
2308
+ async function detectBrokenRefs(paths, task) {
2309
+ const sessionDirs = new Set(await enumerateSessionDirs(paths));
2310
+ const brokenCreatedInSession = sessionDirs.has(task.created_in_session) ? null : task.created_in_session;
2311
+ const seen = /* @__PURE__ */ new Set();
2312
+ const brokenLinkedSessions = [];
2313
+ for (const sid of task.linked_sessions) {
2314
+ if (sessionDirs.has(sid)) continue;
2315
+ if (seen.has(sid)) continue;
2316
+ seen.add(sid);
2317
+ brokenLinkedSessions.push(sid);
2318
+ }
2319
+ return { brokenCreatedInSession, brokenLinkedSessions };
2320
+ }
2321
+ function buildReconciledDoc(input) {
2322
+ const brokenSet = new Set(input.brokenLinkedSessions);
2323
+ const filtered = input.currentDoc.task.task.linked_sessions.filter((sid) => !brokenSet.has(sid));
2324
+ const merged = [...filtered];
2325
+ if (!merged.includes(input.reconcileSessionId)) {
2326
+ merged.push(input.reconcileSessionId);
2327
+ }
2328
+ const nextCreatedInSession = input.brokenCreatedInSession !== null ? input.reconcileSessionId : input.currentDoc.task.task.created_in_session;
2329
+ const next = {
2330
+ ...input.currentDoc.task,
2331
+ task: {
2332
+ ...input.currentDoc.task.task,
2333
+ created_in_session: nextCreatedInSession,
2334
+ updated_at: input.occurredAt,
2335
+ linked_sessions: merged
2336
+ }
2337
+ };
2338
+ return { task: next, body: input.currentDoc.body };
2339
+ }
2340
+ async function reconcileTask(paths, manifest, input) {
2341
+ TaskIdSchema.parse(input.taskId);
2342
+ const handle = await acquireLock(paths, "task", input.taskId);
2343
+ try {
2344
+ return await reconcileTaskLocked(paths, manifest, input);
2345
+ } finally {
2346
+ await handle.release();
2347
+ }
2348
+ }
2349
+ async function reconcileTaskLocked(paths, manifest, input) {
2350
+ const { doc: currentDoc, snapshot: preSnapshot } = await readTaskFileWithSnapshot(
2351
+ paths,
2352
+ input.taskId
2353
+ );
2354
+ const { brokenCreatedInSession, brokenLinkedSessions } = await detectBrokenRefs(
2355
+ paths,
2356
+ currentDoc.task.task
2357
+ );
2358
+ if (brokenCreatedInSession === null && brokenLinkedSessions.length === 0) {
2359
+ return {
2360
+ taskId: input.taskId,
2361
+ clean: true,
2362
+ brokenCreatedInSession: null,
2363
+ brokenLinkedSessions: [],
2364
+ reconcileSession: null
2365
+ };
2366
+ }
2367
+ if (!input.write) {
2368
+ return {
2369
+ taskId: input.taskId,
2370
+ clean: false,
2371
+ brokenCreatedInSession,
2372
+ brokenLinkedSessions,
2373
+ reconcileSession: null
2374
+ };
2375
+ }
2376
+ if (input._onPhaseCompleted !== void 0) {
2377
+ await input._onPhaseCompleted("phase-4-snapshot");
2378
+ }
2379
+ let adHoc;
2380
+ try {
2381
+ adHoc = await createAdHocSessionWithEvent({
2382
+ paths,
2383
+ manifest,
2384
+ label: buildAdHocReconcileLabel(currentDoc.task.task.title),
2385
+ occurredAt: input.occurredAt,
2386
+ sessionSource: "human",
2387
+ workingDirectory: input.workingDirectory,
2388
+ invocation: {
2389
+ command: "basou task reconcile",
2390
+ args: (input.scope ?? "single") === "single" ? ["--task", input.taskId, "--write"] : ["--write"]
2391
+ },
2392
+ taskId: input.taskId,
2393
+ targetEventBuilders: [
2394
+ (sessionId, eventId) => buildTaskReconciledEvent({
2395
+ eventId,
2396
+ sessionId,
2397
+ taskId: input.taskId,
2398
+ removedCreatedInSession: brokenCreatedInSession,
2399
+ createdInSessionReplacement: brokenCreatedInSession !== null ? sessionId : null,
2400
+ removedLinkedSessions: brokenLinkedSessions,
2401
+ occurredAt: input.occurredAt
2402
+ })
2403
+ ]
2404
+ });
2405
+ } catch (error) {
2406
+ if (error instanceof FailedToFinalizeError) {
2407
+ throw new TaskWriteAfterEventError({
2408
+ taskId: input.taskId,
2409
+ eventId: error.targetEventIds[0],
2410
+ sessionId: error.sessionId,
2411
+ phase: "reconcile-finalize",
2412
+ cause: error
2413
+ });
2414
+ }
2415
+ throw error;
2416
+ }
2417
+ if (input._onPhaseCompleted !== void 0) {
2418
+ await input._onPhaseCompleted("phase-5-bulk-write");
2419
+ }
2420
+ const anchorEventId = adHoc.targetEventIds[0];
2421
+ const postSnapshot = await computeTaskMdSnapshot(paths, input.taskId);
2422
+ if (postSnapshot.mtimeMs !== preSnapshot.mtimeMs || postSnapshot.hash !== preSnapshot.hash) {
2423
+ throw new TaskWriteAfterEventError({
2424
+ taskId: input.taskId,
2425
+ eventId: anchorEventId,
2426
+ sessionId: adHoc.sessionId,
2427
+ phase: "reconcile-concurrent",
2428
+ cause: new Error("task.md changed during reconcile")
2429
+ });
2430
+ }
2431
+ const repaired = buildReconciledDoc({
2432
+ currentDoc,
2433
+ brokenCreatedInSession,
2434
+ brokenLinkedSessions,
2435
+ reconcileSessionId: adHoc.sessionId,
2436
+ occurredAt: input.occurredAt
2437
+ });
2438
+ try {
2439
+ await writeTaskFile(paths, input.taskId, repaired, { mode: "overwrite" });
2440
+ } catch (error) {
2441
+ throw new TaskWriteAfterEventError({
2442
+ taskId: input.taskId,
2443
+ eventId: anchorEventId,
2444
+ sessionId: adHoc.sessionId,
2445
+ phase: "reconcile",
2446
+ cause: error
2447
+ });
2448
+ }
2449
+ await safeUpdateTaskIndex(paths, {
2450
+ kind: "update",
2451
+ entry: buildTaskIndexEntry(repaired.task.task)
2452
+ });
2453
+ return {
2454
+ taskId: input.taskId,
2455
+ clean: false,
2456
+ brokenCreatedInSession,
2457
+ brokenLinkedSessions,
2458
+ reconcileSession: {
2459
+ sessionId: adHoc.sessionId,
2460
+ eventId: anchorEventId
2461
+ }
2462
+ };
2463
+ }
2464
+ async function reconcileAllTasks(paths, manifest, input, options = {}) {
2465
+ const taskIds = await enumerateTaskIds(paths);
2466
+ const results = [];
2467
+ const failed = [];
2468
+ let scanned = 0;
2469
+ for (const id of taskIds) {
2470
+ try {
2471
+ await readTaskFile(paths, id);
2472
+ } catch {
2473
+ continue;
2474
+ }
2475
+ scanned += 1;
2476
+ try {
2477
+ const r = await reconcileTask(paths, manifest, {
2478
+ taskId: id,
2479
+ occurredAt: input.occurredAt(),
2480
+ workingDirectory: input.workingDirectory,
2481
+ write: input.write,
2482
+ scope: "all"
2483
+ });
2484
+ if (options.includeClean === true || !r.clean) {
2485
+ results.push(r);
2486
+ }
2487
+ } catch (error) {
2488
+ const errorClass = error instanceof Error ? error.constructor.name : "Error";
2489
+ const phase = error instanceof TaskWriteAfterEventError ? error.phase : null;
2490
+ failed.push({
2491
+ taskId: id,
2492
+ errorClass,
2493
+ phase
2494
+ });
2495
+ }
2496
+ }
2497
+ return { results, failed, scanned };
2498
+ }
2499
+ async function detectLinkageDelta(paths, task) {
2500
+ const sessionIds = await enumerateSessionDirs(paths);
2501
+ const reachable = /* @__PURE__ */ new Set();
2502
+ for (const sid of sessionIds) {
2503
+ try {
2504
+ const doc = await readSessionYaml(paths, sid);
2505
+ if (doc.session.task_id === task.id) {
2506
+ reachable.add(sid);
2507
+ }
2508
+ } catch {
2509
+ }
2510
+ }
2511
+ const finalSet = new Set(reachable);
2512
+ finalSet.add(task.created_in_session);
2513
+ const currentSet = new Set(task.linked_sessions);
2514
+ const addedLinkedSessions = [];
2515
+ const removedLinkedSessions = [];
2516
+ for (const sid of finalSet) {
2517
+ if (!currentSet.has(sid)) addedLinkedSessions.push(sid);
2518
+ }
2519
+ for (const sid of currentSet) {
2520
+ if (!finalSet.has(sid)) removedLinkedSessions.push(sid);
2521
+ }
2522
+ addedLinkedSessions.sort();
2523
+ removedLinkedSessions.sort();
2524
+ const finalLinkedSessions = [...finalSet].sort();
2525
+ return { addedLinkedSessions, removedLinkedSessions, finalLinkedSessions };
2526
+ }
2527
+ function buildRefreshedDoc(input) {
2528
+ const merged = new Set(input.finalLinkedSessions);
2529
+ merged.add(input.refreshSessionId);
2530
+ const linked = [...merged].sort();
2531
+ const next = {
2532
+ ...input.currentDoc.task,
2533
+ task: {
2534
+ ...input.currentDoc.task.task,
2535
+ updated_at: input.occurredAt,
2536
+ linked_sessions: linked
2537
+ }
2538
+ };
2539
+ return { task: next, body: input.currentDoc.body };
2540
+ }
2541
+ async function refreshTaskLinkedSessions(paths, manifest, input) {
2542
+ TaskIdSchema.parse(input.taskId);
2543
+ const handle = await acquireLock(paths, "task", input.taskId);
2544
+ try {
2545
+ return await refreshTaskLinkedSessionsLocked(paths, manifest, input);
2546
+ } finally {
2547
+ await handle.release();
2548
+ }
2549
+ }
2550
+ async function refreshTaskLinkedSessionsLocked(paths, manifest, input) {
2551
+ const { doc: currentDoc, snapshot: preSnapshot } = await readTaskFileWithSnapshot(
2552
+ paths,
2553
+ input.taskId
2554
+ );
2555
+ const { addedLinkedSessions, removedLinkedSessions, finalLinkedSessions } = await detectLinkageDelta(paths, currentDoc.task.task);
2556
+ if (addedLinkedSessions.length === 0 && removedLinkedSessions.length === 0) {
2557
+ return {
2558
+ taskId: input.taskId,
2559
+ clean: true,
2560
+ addedLinkedSessions: [],
2561
+ removedLinkedSessions: [],
2562
+ finalCount: finalLinkedSessions.length,
2563
+ refreshSession: null
2564
+ };
2565
+ }
2566
+ if (!input.write) {
2567
+ return {
2568
+ taskId: input.taskId,
2569
+ clean: false,
2570
+ addedLinkedSessions,
2571
+ removedLinkedSessions,
2572
+ finalCount: finalLinkedSessions.length,
2573
+ refreshSession: null
2574
+ };
2575
+ }
2576
+ const finalCountWithRefreshSession = finalLinkedSessions.length + 1;
2577
+ let adHoc;
2578
+ try {
2579
+ adHoc = await createAdHocSessionWithEvent({
2580
+ paths,
2581
+ manifest,
2582
+ label: buildAdHocRefreshLinkageLabel(currentDoc.task.task.title),
2583
+ occurredAt: input.occurredAt,
2584
+ sessionSource: "human",
2585
+ workingDirectory: input.workingDirectory,
2586
+ invocation: {
2587
+ command: "basou task refresh-linkage",
2588
+ args: [input.taskId, "--write"]
2589
+ },
2590
+ taskId: input.taskId,
2591
+ targetEventBuilders: [
2592
+ (sessionId, eventId) => buildTaskLinkageRefreshedEvent({
2593
+ eventId,
2594
+ sessionId,
2595
+ taskId: input.taskId,
2596
+ addedLinkedSessions,
2597
+ removedLinkedSessions,
2598
+ finalCount: finalCountWithRefreshSession,
2599
+ occurredAt: input.occurredAt
2600
+ })
2601
+ ]
2602
+ });
2603
+ } catch (error) {
2604
+ if (error instanceof FailedToFinalizeError) {
2605
+ throw new TaskWriteAfterEventError({
2606
+ taskId: input.taskId,
2607
+ eventId: error.targetEventIds[0],
2608
+ sessionId: error.sessionId,
2609
+ phase: "linkage-refresh-finalize",
2610
+ cause: error
2611
+ });
2612
+ }
2613
+ throw error;
2614
+ }
2615
+ const anchorEventId = adHoc.targetEventIds[0];
2616
+ const postSnapshot = await computeTaskMdSnapshot(paths, input.taskId);
2617
+ if (postSnapshot.mtimeMs !== preSnapshot.mtimeMs || postSnapshot.hash !== preSnapshot.hash) {
2618
+ throw new TaskWriteAfterEventError({
2619
+ taskId: input.taskId,
2620
+ eventId: anchorEventId,
2621
+ sessionId: adHoc.sessionId,
2622
+ phase: "linkage-refresh-concurrent",
2623
+ cause: new Error("task.md changed during linkage refresh")
2624
+ });
2625
+ }
2626
+ const refreshed = buildRefreshedDoc({
2627
+ currentDoc,
2628
+ finalLinkedSessions,
2629
+ refreshSessionId: adHoc.sessionId,
2630
+ occurredAt: input.occurredAt
2631
+ });
2632
+ 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
+ );
2838
+ } catch (error) {
2839
+ throw new TaskWriteAfterEventError({
2840
+ taskId: input.taskId,
2841
+ eventId,
2842
+ sessionId: adHoc.sessionId,
2843
+ phase: "archive",
2844
+ cause: error
2845
+ });
2846
+ }
2847
+ await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
2848
+ return {
2849
+ 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
+ });
2902
+ }
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
+ };
2912
+ }
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);
2922
+ }
2923
+ if (taskIdsToCheck.size === 0) {
2924
+ return;
2925
+ }
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");
2930
+ }
2931
+ }
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");
2949
+ }
2950
+ }
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
+ return {
2978
+ record: { schema_version: "0.1.0", session: inner },
2979
+ pathSanitizeReport: {
2980
+ relatedFiles: relatedSanitized.mutationCount,
2981
+ workingDirectoryRewritten: workingDirectorySanitized !== workingDirectoryRaw
2982
+ }
2983
+ };
2984
+ }
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
+ }
3025
+ }
3026
+ if (merged.size === 0) {
3027
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3028
+ }
3029
+ const matches = [...merged].filter((e) => e.startsWith(normalized));
3030
+ if (matches.length === 0) {
3031
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3032
+ }
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.`
3036
+ );
3037
+ }
3038
+ return matches[0];
3039
+ }
3040
+
3041
+ // src/handoff/handoff-renderer.ts
3042
+ import { join as join12 } from "path";
3043
+ async function renderHandoff(input) {
3044
+ const limit = input.relatedFilesLimit ?? 20;
3045
+ const now = new Date(input.nowIso);
3046
+ const unreadableEmitted = /* @__PURE__ */ new Set();
3047
+ const wrappedSkip = (sid, reason) => {
3048
+ if (reason === "events_jsonl_unreadable") unreadableEmitted.add(sid);
3049
+ input.onSessionSkip?.(sid, reason);
3050
+ };
3051
+ const loadOpts = { now, onSkip: wrappedSkip };
3052
+ if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
3053
+ const entries = await loadSessionEntries(input.paths, loadOpts);
3054
+ const decisions = [];
3055
+ const tasksCreated = [];
3056
+ const tasksStatusChanged = [];
3057
+ for (const entry of entries) {
3058
+ const sessionDir = join12(input.paths.sessions, entry.sessionId);
3059
+ try {
3060
+ for await (const ev of replayEvents(sessionDir, {
3061
+ onWarning: (w) => input.onWarning?.(w, entry.sessionId)
3062
+ })) {
3063
+ if (ev.type === "decision_recorded") {
3064
+ decisions.push({
3065
+ decisionId: ev.decision_id,
3066
+ title: ev.title,
3067
+ occurredAt: ev.occurred_at,
3068
+ sessionId: entry.sessionId
3069
+ });
3070
+ } else if (ev.type === "task_created") {
3071
+ tasksCreated.push({
3072
+ taskId: ev.task_id,
3073
+ title: ev.title,
3074
+ occurredAt: ev.occurred_at,
3075
+ sessionId: entry.sessionId
3076
+ });
3077
+ } else if (ev.type === "task_status_changed") {
3078
+ tasksStatusChanged.push({
3079
+ taskId: ev.task_id,
3080
+ occurredAt: ev.occurred_at,
3081
+ sessionId: entry.sessionId
3082
+ });
3083
+ }
3084
+ }
3085
+ } catch {
3086
+ if (!unreadableEmitted.has(entry.sessionId)) {
3087
+ wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
3088
+ }
3089
+ }
3090
+ }
3091
+ decisions.sort((a, b) => {
3092
+ const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
3093
+ return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
3094
+ });
3095
+ tasksCreated.sort((a, b) => {
3096
+ const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
3097
+ return c !== 0 ? c : a.taskId.localeCompare(b.taskId);
3098
+ });
3099
+ tasksStatusChanged.sort((a, b) => {
3100
+ const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
3101
+ return c !== 0 ? c : a.taskId.localeCompare(b.taskId);
3102
+ });
3103
+ const taskLoadOpts = {};
3104
+ if (input.onTaskSkip !== void 0) taskLoadOpts.onSkip = input.onTaskSkip;
3105
+ const taskEntries = await loadTaskEntries(input.paths, taskLoadOpts);
3106
+ const taskById = /* @__PURE__ */ new Map();
3107
+ for (const t of taskEntries) taskById.set(t.task.task.id, t);
3108
+ const latestStatusChange = tasksStatusChanged[tasksStatusChanged.length - 1];
3109
+ const latestCreatedRecord = tasksCreated[tasksCreated.length - 1];
3110
+ const latestActivityTaskId = latestStatusChange?.taskId ?? latestCreatedRecord?.taskId;
3111
+ const latestActivityTitle = latestActivityTaskId !== void 0 ? tasksCreated.find((t) => t.taskId === latestActivityTaskId)?.title ?? "(title unknown)" : void 0;
3112
+ const latestActivityRecord = latestActivityTaskId !== void 0 && latestActivityTitle !== void 0 ? { taskId: latestActivityTaskId, title: latestActivityTitle } : void 0;
3113
+ const latestTaskDoc = latestActivityRecord !== void 0 ? taskById.get(latestActivityRecord.taskId) : void 0;
3114
+ const pendingTasks = taskEntries.filter(
3115
+ (t) => t.task.task.status === "planned" || t.task.task.status === "in_progress"
3116
+ );
3117
+ const approvals = await enumerateApprovals(input.paths);
3118
+ const pendingApprovalsCount = approvals.pending.length;
3119
+ const liveEntries = entries.filter(
3120
+ (e) => e.session.session.status !== "archived" && e.session.session.source.kind !== "import"
3121
+ );
3122
+ const latestSession = [...liveEntries].sort(
3123
+ (a, b) => Date.parse(b.session.session.started_at) - Date.parse(a.session.session.started_at)
3124
+ )[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();
3131
+ const displayedFiles = sortedFiles.slice(0, limit);
3132
+ const overflow = Math.max(0, sortedFiles.length - limit);
3133
+ const suspectCount = entries.filter((e) => e.suspect).length;
3134
+ const firstEntry = entries[0];
3135
+ const lastEntry = entries[entries.length - 1];
3136
+ const sessionRange = firstEntry !== void 0 && lastEntry !== void 0 ? `${firstEntry.sessionId}..${lastEntry.sessionId}` : "";
3137
+ const body = formatHandoffBody({
3138
+ nowIso: input.nowIso,
3139
+ sessionRange,
3140
+ sessionCount: entries.length,
3141
+ latestSession,
3142
+ decisions,
3143
+ pendingApprovalsCount,
3144
+ suspectCount,
3145
+ displayedFiles,
3146
+ overflow,
3147
+ entries,
3148
+ latestActivityRecord,
3149
+ latestTaskDoc,
3150
+ pendingTasks,
3151
+ totalTaskCount: taskEntries.length
3152
+ });
3153
+ return {
3154
+ body,
3155
+ sessionCount: entries.length,
3156
+ decisionCount: decisions.length,
3157
+ pendingApprovalsCount,
3158
+ suspectCount,
3159
+ taskCount: taskEntries.length,
3160
+ pendingTaskCount: pendingTasks.length
3161
+ };
3162
+ }
3163
+ function formatHandoffBody(args) {
3164
+ const lines = [];
3165
+ lines.push("# Handoff");
3166
+ lines.push("");
3167
+ if (args.sessionRange !== "") {
3168
+ lines.push(`> Generated at ${args.nowIso} from ${args.sessionRange}`);
3169
+ } else {
3170
+ lines.push(`> Generated at ${args.nowIso}`);
3171
+ }
3172
+ lines.push("");
3173
+ lines.push("## \u73FE\u5728\u306E\u72B6\u614B");
3174
+ lines.push("");
3175
+ if (args.latestSession !== void 0) {
3176
+ const sid = args.latestSession.sessionId;
3177
+ const status = args.latestSession.session.session.status;
3178
+ lines.push(`- \u6700\u7D42 session: ${sid} (${status})`);
3179
+ } else {
3180
+ lines.push("- \u6700\u7D42 session: (no live sessions)");
3181
+ }
3182
+ if (args.latestActivityRecord !== void 0) {
3183
+ const statusLabel = args.latestTaskDoc !== void 0 ? args.latestTaskDoc.task.task.status : "status unknown \u2014 task.md missing or invalid";
3184
+ const linkedCount = args.latestTaskDoc?.task.task.linked_sessions?.length;
3185
+ const linkedSuffix = linkedCount !== void 0 && linkedCount > 1 ? ` (linked_sessions: ${linkedCount})` : "";
3186
+ lines.push(
3187
+ `- \u6700\u7D42 task: ${args.latestActivityRecord.taskId} (${statusLabel}): ${args.latestActivityRecord.title}${linkedSuffix}`
3188
+ );
3189
+ } else {
3190
+ lines.push("- \u6700\u7D42 task: (no tasks recorded yet)");
3191
+ }
3192
+ lines.push("");
3193
+ lines.push("## \u76F4\u8FD1\u306E\u5909\u66F4\u30D5\u30A1\u30A4\u30EB");
3194
+ lines.push("");
3195
+ if (args.displayedFiles.length === 0) {
3196
+ lines.push("(no related files recorded)");
3197
+ } else {
3198
+ for (const f of args.displayedFiles) lines.push(`- ${f}`);
3199
+ if (args.overflow > 0) lines.push(`- ... +${args.overflow} more`);
3200
+ }
3201
+ lines.push("");
3202
+ lines.push("## \u76F4\u8FD1\u306E\u5224\u65AD");
3203
+ lines.push("");
3204
+ if (args.decisions.length === 0) {
3205
+ lines.push("(no decisions recorded yet)");
3206
+ } else {
3207
+ const last = args.decisions[args.decisions.length - 1];
3208
+ lines.push(`- ${last.decisionId}: ${last.title}`);
3209
+ lines.push("");
3210
+ lines.push(`(${args.decisions.length} decisions total \u2014 see decisions.md)`);
3211
+ }
3212
+ lines.push("");
3213
+ lines.push("## \u672A\u6C7A\u4E8B\u9805");
3214
+ lines.push("");
3215
+ if (args.pendingApprovalsCount > 0) {
3216
+ lines.push(`- ${args.pendingApprovalsCount} pending approvals`);
3217
+ }
3218
+ if (args.suspectCount > 0) {
3219
+ lines.push(`- ${args.suspectCount} suspect sessions detected`);
3220
+ }
3221
+ if (args.pendingApprovalsCount === 0 && args.suspectCount === 0) {
3222
+ lines.push("(none)");
3223
+ }
3224
+ lines.push("");
3225
+ lines.push("## \u6B21\u306B\u8AAD\u3080\u3079\u304D\u30D5\u30A1\u30A4\u30EB");
3226
+ lines.push("");
3227
+ lines.push("- .basou/decisions.md");
3228
+ for (const f of args.displayedFiles.slice(0, 3)) lines.push(`- ${f}`);
3229
+ lines.push("");
3230
+ lines.push("## \u6B21\u306B\u5B9F\u884C\u3059\u3079\u304D\u4F5C\u696D");
3231
+ lines.push("");
3232
+ if (args.pendingTasks.length === 0) {
3233
+ lines.push("(no pending tasks)");
3234
+ } else {
3235
+ for (const t of args.pendingTasks) {
3236
+ lines.push(`- ${t.task.task.id} (${t.task.task.status}): ${t.task.task.title}`);
3237
+ }
3238
+ }
3239
+ lines.push("");
3240
+ const liveTableEntries = args.entries.filter((e) => e.session.session.source.kind !== "import");
3241
+ const importedTableEntries = args.entries.filter(
3242
+ (e) => e.session.session.source.kind === "import"
3243
+ );
3244
+ lines.push("## \u30BB\u30C3\u30B7\u30E7\u30F3\u4E00\u89A7");
3245
+ lines.push("");
3246
+ if (args.entries.length === 0) {
3247
+ lines.push("(no sessions yet)");
3248
+ } else if (liveTableEntries.length === 0) {
3249
+ lines.push("(no live sessions; see Imported sessions below)");
3250
+ } else {
3251
+ lines.push("| short_id | status | started_at | label |");
3252
+ lines.push("|---|---|---|---|");
3253
+ for (const e of [...liveTableEntries].reverse()) {
3254
+ const sid = shortHandoffId(e.sessionId);
3255
+ const status = e.session.session.status + suspectLabel(e.suspectReason);
3256
+ const startedAt = e.session.session.started_at;
3257
+ const label = e.session.session.label ?? "";
3258
+ lines.push(`| ${sid} | ${status} | ${startedAt} | ${label} |`);
3259
+ }
3260
+ }
3261
+ if (importedTableEntries.length > 0) {
3262
+ lines.push("");
3263
+ lines.push("### Imported sessions");
3264
+ lines.push("");
3265
+ lines.push("| short_id | status | started_at | label |");
3266
+ lines.push("|---|---|---|---|");
3267
+ for (const e of [...importedTableEntries].reverse()) {
3268
+ const sid = shortHandoffId(e.sessionId);
3269
+ const status = e.session.session.status + suspectLabel(e.suspectReason);
3270
+ const startedAt = e.session.session.started_at;
3271
+ const label = e.session.session.label ?? "";
3272
+ lines.push(`| ${sid} | ${status} | ${startedAt} | ${label} |`);
3273
+ }
3274
+ }
3275
+ lines.push("");
3276
+ const statusCounts = /* @__PURE__ */ new Map();
3277
+ for (const e of args.entries) {
3278
+ const s = e.session.session.status;
3279
+ statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
3280
+ }
3281
+ const orderedStatuses = [
3282
+ "completed",
3283
+ "failed",
3284
+ "running",
3285
+ "interrupted",
3286
+ "waiting_approval",
3287
+ "initialized",
3288
+ "imported"
3289
+ ];
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
+ }
3418
+ return lines.join("\n");
3419
+ }
3420
+ function shortDecisionSessionId(sessionId) {
3421
+ const SES = "ses_";
3422
+ if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
3423
+ return sessionId.slice(0, 10);
3424
+ }
3425
+
3426
+ // src/runtime/child-process-runner.ts
3427
+ import { spawn } from "child_process";
3428
+ var DEFAULT_KILL_GRACE_MS = 5e3;
3429
+ var ChildProcessRunner = class {
3430
+ async run(command, args, options) {
3431
+ validateOptions(options);
3432
+ if (options.signal?.aborted) {
3433
+ throw new Error("Process aborted before spawn", {
3434
+ cause: options.signal.reason
3435
+ });
3436
+ }
3437
+ const snapshotCommand = command;
3438
+ const snapshotArgs = [...args];
3439
+ const snapshotCwd = options.cwd;
3440
+ const captureMode = options.capture ?? "buffer";
3441
+ const started_at = /* @__PURE__ */ new Date();
3442
+ let child;
3443
+ try {
3444
+ child = spawn(snapshotCommand, [...snapshotArgs], {
3445
+ cwd: snapshotCwd,
3446
+ env: options.env ?? process.env,
3447
+ stdio: captureMode === "none" ? ["inherit", "inherit", "inherit"] : ["pipe", "pipe", "pipe"],
3448
+ shell: false,
3449
+ detached: false
3450
+ });
3451
+ } catch (error) {
3452
+ throw classifySpawnError(error);
3453
+ }
3454
+ if (options.onSpawn) {
3455
+ try {
3456
+ options.onSpawn(child);
3457
+ } catch {
3458
+ }
3459
+ }
3460
+ let timeoutTimer = null;
3461
+ let killTimer = null;
3462
+ let killed = false;
3463
+ let settled = false;
3464
+ const triggerKill = () => {
3465
+ if (killed || child.exitCode !== null) return;
3466
+ killed = true;
3467
+ child.kill("SIGTERM");
3468
+ killTimer = setTimeout(() => {
3469
+ if (child.exitCode === null) {
3470
+ child.kill("SIGKILL");
3471
+ }
3472
+ }, DEFAULT_KILL_GRACE_MS);
3473
+ };
3474
+ const onAbort = () => {
3475
+ triggerKill();
3476
+ };
3477
+ options.signal?.addEventListener("abort", onAbort);
3478
+ if (options.signal?.aborted) {
3479
+ triggerKill();
3480
+ }
3481
+ let stdout = "";
3482
+ let stderr = "";
3483
+ if (captureMode === "buffer") {
3484
+ child.stdout?.setEncoding("utf8");
3485
+ child.stderr?.setEncoding("utf8");
3486
+ child.stdout?.on("data", (chunk) => {
3487
+ stdout += chunk;
3488
+ });
3489
+ child.stderr?.on("data", (chunk) => {
3490
+ stderr += chunk;
3491
+ });
3492
+ if (options.stdin !== void 0) {
3493
+ child.stdin?.end(options.stdin);
3494
+ } else {
3495
+ child.stdin?.end();
3496
+ }
3497
+ }
3498
+ if (options.timeout_ms !== void 0) {
3499
+ timeoutTimer = setTimeout(triggerKill, options.timeout_ms);
3500
+ }
3501
+ const cleanup = () => {
3502
+ if (timeoutTimer !== null) clearTimeout(timeoutTimer);
3503
+ if (killTimer !== null) clearTimeout(killTimer);
3504
+ options.signal?.removeEventListener("abort", onAbort);
3505
+ };
3506
+ return new Promise((resolve2, reject) => {
3507
+ child.once("error", (error) => {
3508
+ if (settled) return;
3509
+ settled = true;
3510
+ cleanup();
3511
+ reject(classifySpawnError(error));
3512
+ });
3513
+ child.once("close", (code, signal) => {
3514
+ if (settled) return;
3515
+ settled = true;
3516
+ cleanup();
3517
+ const ended_at = /* @__PURE__ */ new Date();
3518
+ resolve2({
3519
+ command: snapshotCommand,
3520
+ args: snapshotArgs,
3521
+ cwd: snapshotCwd,
3522
+ exit_code: code,
3523
+ signal,
3524
+ stdout,
3525
+ stderr,
3526
+ started_at: started_at.toISOString(),
3527
+ ended_at: ended_at.toISOString(),
3528
+ duration_ms: ended_at.getTime() - started_at.getTime(),
3529
+ pid: child.pid ?? null
3530
+ });
3531
+ });
3532
+ });
3533
+ }
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");
3538
+ }
3539
+ if (options.capture === "none" && options.stdin !== void 0) {
3540
+ throw new Error('Combination of capture: "none" and stdin is not supported');
3541
+ }
3542
+ }
3543
+ function classifySpawnError(error) {
3544
+ if (findErrorCode(error, "ENOENT")) {
3545
+ return new Error("Command not found", { cause: error });
3546
+ }
3547
+ return new Error("Failed to spawn child process", { cause: error });
3548
+ }
3549
+
3550
+ // src/git/snapshot.ts
3551
+ import { simpleGit } from "simple-git";
3552
+ function safeSimpleGit(repoRoot) {
3553
+ return simpleGit({ baseDir: repoRoot });
3554
+ }
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;
3561
+ }
3562
+ return false;
3563
+ }
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 });
3575
+ }
3576
+ if (error instanceof Error && error.message === "Not a git repository") {
3577
+ throw error;
3578
+ }
3579
+ throw new Error("Not a git repository", { cause: error });
3580
+ }
3581
+ }
3582
+ async function tryRemoteUrl(repositoryRoot) {
3583
+ const git = safeSimpleGit(repositoryRoot);
3584
+ 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;
3590
+ }
3591
+ }
3592
+ async function getSnapshot(repositoryRoot) {
3593
+ const git = safeSimpleGit(repositoryRoot);
3594
+ let inside;
3595
+ try {
3596
+ inside = await git.checkIsRepo();
3597
+ } catch (error) {
3598
+ if (isGitNotFound(error)) {
3599
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
3600
+ }
3601
+ throw new Error("Failed to read git state", { cause: error });
3602
+ }
3603
+ if (!inside) {
3604
+ throw new Error("Not a git repository");
3605
+ }
3606
+ let head;
3607
+ try {
3608
+ head = (await git.revparse(["HEAD"])).trimEnd();
3609
+ } 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");
3617
+ }
3618
+ let branch;
3619
+ try {
3620
+ const raw = (await git.raw(["branch", "--show-current"])).trimEnd();
3621
+ branch = raw.length > 0 ? raw : "HEAD";
3622
+ } catch (error) {
3623
+ throw new Error("Failed to read git state", { cause: error });
3624
+ }
3625
+ let dirty;
3626
+ const staged = [];
3627
+ const unstaged = [];
3628
+ const untracked = [];
3629
+ 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
+ }
3640
+ } 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 {
3655
+ }
3656
+ }
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 } : {}
3666
+ };
3667
+ return snapshot;
3668
+ }
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 });
3678
+ }
3679
+ throw new Error("Not a git repository", { cause: error });
3680
+ }
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 });
3697
+ }
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" });
3722
+ }
3723
+ }
3724
+ return changes;
3725
+ }
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 };
3736
+ }
3737
+ throw new Error("Claude Code CLI not found in PATH. Install claude-code (or claude) first.");
3738
+ }
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));
3744
+ });
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;
3783
+ }
3784
+
3785
+ // src/index.ts
3786
+ var BASOU_CORE_VERSION = "0.1.0";
3787
+ export {
3788
+ ApprovalIdSchema,
3789
+ ApprovalSchema,
3790
+ ApprovalStatusSchema,
3791
+ BASOU_CORE_VERSION,
3792
+ ChildProcessRunner,
3793
+ DecisionIdSchema,
3794
+ EventIdSchema,
3795
+ EventSchema,
3796
+ EventSourceSchema,
3797
+ FailedToFinalizeError,
3798
+ GENERATED_END,
3799
+ GENERATED_START,
3800
+ ID_PREFIXES,
3801
+ IsoTimestampSchema,
3802
+ ManifestSchema,
3803
+ RiskLevelSchema,
3804
+ STUCK_THRESHOLD_MS,
3805
+ SchemaVersionSchema,
3806
+ SessionIdSchema,
3807
+ SessionImportPayloadSchema,
3808
+ SessionInnerImportSchema,
3809
+ SessionSchema,
3810
+ SessionSourceKindSchema,
3811
+ SessionStatusSchema,
3812
+ StatusSchema,
3813
+ TaskIdSchema,
3814
+ TaskSchema,
3815
+ TaskStatusSchema,
3816
+ TaskWriteAfterEventError,
3817
+ WorkspaceIdSchema,
3818
+ acquireLock,
3819
+ appendBasouGitignore,
3820
+ appendEvent,
3821
+ appendEventToExistingSession,
3822
+ archiveTask,
3823
+ assertBasouRootSafe,
3824
+ basouPaths,
3825
+ buildStatusSnapshot,
3826
+ classifySuspect,
3827
+ claudeCodeAdapterMetadata,
3828
+ createAdHocSessionWithEvent,
3829
+ createManifest,
3830
+ createTaskWithEvent,
3831
+ deleteTask,
3832
+ editTask,
3833
+ ensureBasouDirectory,
3834
+ enumerateApprovals,
3835
+ enumerateArchivedTaskIds,
3836
+ enumerateSessionDirs,
3837
+ enumerateTaskIds,
3838
+ findErrorCode,
3839
+ getDiff,
3840
+ getSnapshot,
3841
+ importSessionFromJson,
3842
+ isLazyExpired,
3843
+ isValidPrefixedId,
3844
+ linkYamlFile,
3845
+ loadApproval,
3846
+ loadSessionEntries,
3847
+ loadTaskEntries,
3848
+ overwriteYamlFile,
3849
+ parseDuration,
3850
+ parseMarkers,
3851
+ prefixedUlid,
3852
+ readAllEvents,
3853
+ readManifest,
3854
+ readMarkdownFile,
3855
+ readSessionYaml,
3856
+ readStatus,
3857
+ readTaskFile,
3858
+ readTaskFileWithArchiveFallback,
3859
+ readYamlFile,
3860
+ reconcileAllTasks,
3861
+ reconcileTask,
3862
+ refreshTaskLinkedSessions,
3863
+ renderDecisions,
3864
+ renderHandoff,
3865
+ renderWithMarkers,
3866
+ replayEvents,
3867
+ resolveClaudeCodeCommand,
3868
+ resolveRepositoryRoot,
3869
+ resolveSessionId,
3870
+ resolveTaskId,
3871
+ sanitizePath,
3872
+ sanitizeRelatedFiles,
3873
+ sanitizeWorkingDirectory,
3874
+ summarizeAdapterOutput,
3875
+ tryRemoteUrl,
3876
+ ulid,
3877
+ updateTaskStatusWithEvent,
3878
+ writeEventsBulk,
3879
+ writeManifest,
3880
+ writeMarkdownFile,
3881
+ writeStatus,
3882
+ writeTaskFile,
3883
+ writeYamlFile
3884
+ };
3885
+ //# sourceMappingURL=index.js.map