@basou/core 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,47 @@
1
+ // src/adapters/claude-code/claude-code-adapter.ts
2
+ import { spawn } from "child_process";
3
+ var claudeCodeAdapterMetadata = {
4
+ kind: "claude-code-adapter",
5
+ version: "0.1.0"
6
+ };
7
+ async function resolveClaudeCodeCommand(lookup = isOnPath) {
8
+ for (const candidate of ["claude-code", "claude"]) {
9
+ if (await lookup(candidate)) return { command: candidate };
10
+ }
11
+ throw new Error("Claude Code CLI not found in PATH. Install claude-code (or claude) first.");
12
+ }
13
+ async function isOnPath(command) {
14
+ return new Promise((resolve2) => {
15
+ const child = spawn("which", [command], { stdio: "ignore" });
16
+ child.on("error", () => resolve2(false));
17
+ child.on("exit", (code) => resolve2(code === 0));
18
+ });
19
+ }
20
+ function summarizeAdapterOutput(_stream, _raw) {
21
+ throw new Error("adapter_output summary is not implemented in this release");
22
+ }
23
+
24
+ // src/approval/approval-store.ts
25
+ import { readdir } from "fs/promises";
26
+ import { join } from "path";
27
+
28
+ // src/lib/error-codes.ts
29
+ function findErrorCode(error, code, depth = 4) {
30
+ let cur = error;
31
+ for (let i = 0; i < depth && cur instanceof Error; i++) {
32
+ const c = cur.code;
33
+ if (typeof c === "string" && c === code) return true;
34
+ cur = cur.cause;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ // src/schemas/approval.schema.ts
40
+ import { z as z2 } from "zod";
41
+
42
+ // src/schemas/shared.schema.ts
43
+ import { z } from "zod";
44
+
1
45
  // src/ids/ulid.ts
2
46
  import { isValid as isValidUlid, monotonicFactory } from "ulid";
3
47
  var ID_PREFIXES = Object.freeze(["ws", "task", "ses", "evt", "appr", "decision"]);
@@ -24,7 +68,6 @@ function isValidPrefixedId(value) {
24
68
  }
25
69
 
26
70
  // src/schemas/shared.schema.ts
27
- import { z } from "zod";
28
71
  var SchemaVersionSchema = z.literal("0.1.0");
29
72
  var IsoTimestampSchema = z.string().datetime({ offset: true });
30
73
  var createPrefixedIdSchema = (prefix) => {
@@ -40,395 +83,30 @@ var DecisionIdSchema = createPrefixedIdSchema("decision");
40
83
  var RiskLevelSchema = z.enum(["low", "medium", "high", "critical"]);
41
84
  var EventSourceSchema = z.string().min(1);
42
85
 
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
86
  // 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({
87
+ var ApprovalStatusSchema = z2.enum(["pending", "approved", "rejected", "expired"]);
88
+ var ApprovalSchema = z2.object({
211
89
  schema_version: SchemaVersionSchema,
212
90
  id: ApprovalIdSchema,
213
91
  session_id: SessionIdSchema,
214
92
  created_at: IsoTimestampSchema,
215
93
  status: ApprovalStatusSchema,
216
94
  risk_level: RiskLevelSchema,
217
- action: z7.object({ kind: z7.string() }).passthrough(),
218
- reason: z7.string(),
95
+ action: z2.object({ kind: z2.string() }).passthrough(),
96
+ reason: z2.string(),
219
97
  expires_at: IsoTimestampSchema.nullable().default(null),
220
98
  // The four fields below are null while `status === "pending"` and set
221
99
  // once a resolver records a decision. Defaulting to null keeps the
222
100
  // pending YAML free of explicit nulls if a producer omits them.
223
- resolver: z7.string().nullable().default(null),
101
+ resolver: z2.string().nullable().default(null),
224
102
  resolved_at: IsoTimestampSchema.nullable().default(null),
225
- note: z7.string().nullable().default(null),
226
- rejection_reason: z7.string().nullable().default(null)
103
+ note: z2.string().nullable().default(null),
104
+ rejection_reason: z2.string().nullable().default(null)
227
105
  });
228
106
 
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";
107
+ // src/storage/yaml-store.ts
108
+ import { readFile } from "fs/promises";
109
+ import { parse, stringify } from "yaml";
432
110
 
433
111
  // src/storage/atomic.ts
434
112
  import { randomUUID } from "crypto";
@@ -553,338 +231,566 @@ function isLazyExpired(approval, now) {
553
231
  return expiresMs < now.getTime();
554
232
  }
555
233
 
556
- // src/storage/basou-dir.ts
557
- import { lstat, mkdir } from "fs/promises";
234
+ // src/decisions/decisions-renderer.ts
235
+ import { lstat } from "fs/promises";
236
+ import { dirname, join as join4, resolve } from "path";
237
+
238
+ // src/events/event-replay.ts
239
+ import { createReadStream } from "fs";
240
+ import { stat } from "fs/promises";
558
241
  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;
242
+
243
+ // src/schemas/event.schema.ts
244
+ import { z as z3 } from "zod";
245
+ var BaseEventSchema = z3.object({
246
+ schema_version: SchemaVersionSchema,
247
+ id: EventIdSchema,
248
+ session_id: SessionIdSchema,
249
+ occurred_at: IsoTimestampSchema,
250
+ source: EventSourceSchema
251
+ });
252
+ var SessionStartedEventSchema = BaseEventSchema.extend({
253
+ type: z3.literal("session_started")
254
+ });
255
+ var SessionEndedEventSchema = BaseEventSchema.extend({
256
+ type: z3.literal("session_ended"),
257
+ exit_code: z3.number().int().optional()
258
+ });
259
+ var SessionStatusChangedEventSchema = BaseEventSchema.extend({
260
+ type: z3.literal("session_status_changed"),
261
+ from: z3.string(),
262
+ to: z3.string()
263
+ });
264
+ var ApprovalRequestedEventSchema = BaseEventSchema.extend({
265
+ type: z3.literal("approval_requested"),
266
+ approval_id: ApprovalIdSchema,
267
+ expires_at: IsoTimestampSchema.nullable().default(null),
268
+ risk_level: RiskLevelSchema,
269
+ // `action.kind` is required; additional fields are allowed to support
270
+ // future action shapes (shell_command, external_send, ...).
271
+ action: z3.object({ kind: z3.string() }).passthrough(),
272
+ reason: z3.string(),
273
+ status: z3.literal("pending")
274
+ });
275
+ var ApprovalApprovedEventSchema = BaseEventSchema.extend({
276
+ type: z3.literal("approval_approved"),
277
+ approval_id: ApprovalIdSchema,
278
+ resolver: z3.string().optional(),
279
+ note: z3.string().nullable().optional()
280
+ });
281
+ var ApprovalRejectedEventSchema = BaseEventSchema.extend({
282
+ type: z3.literal("approval_rejected"),
283
+ approval_id: ApprovalIdSchema,
284
+ resolver: z3.string().optional(),
285
+ reason: z3.string()
286
+ });
287
+ var ApprovalExpiredEventSchema = BaseEventSchema.extend({
288
+ type: z3.literal("approval_expired"),
289
+ approval_id: ApprovalIdSchema
290
+ });
291
+ var CommandExecutedEventSchema = BaseEventSchema.extend({
292
+ type: z3.literal("command_executed"),
293
+ command: z3.string(),
294
+ args: z3.array(z3.string()),
295
+ cwd: z3.string(),
296
+ exit_code: z3.number().int().nullable(),
297
+ signal: z3.string().nullable().optional(),
298
+ received_signal: z3.string().nullable().optional(),
299
+ duration_ms: z3.number().int().nonnegative()
300
+ });
301
+ var GitSnapshotEventSchema = BaseEventSchema.extend({
302
+ type: z3.literal("git_snapshot"),
303
+ head: z3.string(),
304
+ branch: z3.string(),
305
+ dirty: z3.boolean(),
306
+ staged: z3.array(z3.string()),
307
+ unstaged: z3.array(z3.string()),
308
+ untracked: z3.array(z3.string()),
309
+ ahead: z3.number().int().nonnegative().optional(),
310
+ behind: z3.number().int().nonnegative().optional()
311
+ });
312
+ var FileChangedEventSchema = BaseEventSchema.extend({
313
+ type: z3.literal("file_changed"),
314
+ path: z3.string(),
315
+ change_type: z3.enum(["added", "modified", "deleted", "renamed"]),
316
+ // Renamed entries record the previous path here. Optional + nullable to
317
+ // keep the wire format stable for added / modified / deleted events.
318
+ old_path: z3.string().nullable().optional()
319
+ });
320
+ var DecisionRecordedEventSchema = BaseEventSchema.extend({
321
+ type: z3.literal("decision_recorded"),
322
+ decision_id: DecisionIdSchema,
323
+ title: z3.string(),
324
+ rationale: z3.string().nullable().optional(),
325
+ alternatives: z3.array(z3.string().min(1)).optional(),
326
+ rejected_reason: z3.string().nullable().optional(),
327
+ linked_events: z3.array(EventIdSchema).optional(),
328
+ linked_files: z3.array(z3.string().min(1).max(4096)).optional()
329
+ });
330
+ var TaskCreatedEventSchema = BaseEventSchema.extend({
331
+ type: z3.literal("task_created"),
332
+ task_id: TaskIdSchema,
333
+ title: z3.string()
334
+ });
335
+ var TaskStatusChangedEventSchema = BaseEventSchema.extend({
336
+ type: z3.literal("task_status_changed"),
337
+ task_id: TaskIdSchema,
338
+ from: z3.string(),
339
+ to: z3.string()
340
+ });
341
+ var TaskReconciledEventSchema = BaseEventSchema.extend({
342
+ type: z3.literal("task_reconciled"),
343
+ task_id: TaskIdSchema,
344
+ removed_created_in_session: SessionIdSchema.nullable().default(null),
345
+ created_in_session_replacement: SessionIdSchema.nullable().default(null),
346
+ removed_linked_sessions: z3.array(SessionIdSchema).default([])
347
+ }).strict();
348
+ var TaskLinkageRefreshedEventSchema = BaseEventSchema.extend({
349
+ type: z3.literal("task_linkage_refreshed"),
350
+ task_id: TaskIdSchema,
351
+ added_linked_sessions: z3.array(SessionIdSchema).default([]),
352
+ removed_linked_sessions: z3.array(SessionIdSchema).default([]),
353
+ final_count: z3.number().int().nonnegative().optional()
354
+ }).strict();
355
+ var TaskDeletedEventSchema = BaseEventSchema.extend({
356
+ type: z3.literal("task_deleted"),
357
+ task_id: TaskIdSchema,
358
+ title: z3.string().min(1)
359
+ }).strict();
360
+ var TaskArchivedEventSchema = BaseEventSchema.extend({
361
+ type: z3.literal("task_archived"),
362
+ task_id: TaskIdSchema,
363
+ title: z3.string().min(1)
364
+ }).strict();
365
+ var NoteAddedEventSchema = BaseEventSchema.extend({
366
+ type: z3.literal("note_added"),
367
+ body: z3.string()
368
+ });
369
+ var AdapterOutputEventSchema = BaseEventSchema.extend({
370
+ type: z3.literal("adapter_output"),
371
+ stream: z3.enum(["stdout", "stderr"]),
372
+ summary: z3.string(),
373
+ raw_ref: z3.string(),
374
+ redacted: z3.boolean().optional()
375
+ }).strict();
376
+ var EventSchema = z3.discriminatedUnion("type", [
377
+ SessionStartedEventSchema,
378
+ SessionEndedEventSchema,
379
+ SessionStatusChangedEventSchema,
380
+ ApprovalRequestedEventSchema,
381
+ ApprovalApprovedEventSchema,
382
+ ApprovalRejectedEventSchema,
383
+ ApprovalExpiredEventSchema,
384
+ CommandExecutedEventSchema,
385
+ GitSnapshotEventSchema,
386
+ FileChangedEventSchema,
387
+ DecisionRecordedEventSchema,
388
+ TaskCreatedEventSchema,
389
+ TaskStatusChangedEventSchema,
390
+ TaskReconciledEventSchema,
391
+ TaskLinkageRefreshedEventSchema,
392
+ TaskDeletedEventSchema,
393
+ TaskArchivedEventSchema,
394
+ NoteAddedEventSchema,
395
+ AdapterOutputEventSchema
396
+ ]);
397
+
398
+ // src/events/event-replay.ts
399
+ async function* replayEvents(sessionDir, options = {}) {
400
+ const filePath = join2(sessionDir, "events.jsonl");
401
+ try {
402
+ await stat(filePath);
403
+ } catch (error) {
404
+ if (findErrorCode(error, "ENOENT")) return;
405
+ throw new Error("Failed to read events.jsonl", { cause: error });
406
+ }
407
+ let stream;
595
408
  try {
596
- existing = await lstat(paths.root);
409
+ stream = createReadStream(filePath, { encoding: "utf8" });
597
410
  } catch (error) {
598
- if (!hasErrorCode2(error) || error.code !== "ENOENT") {
599
- throw new Error("Failed to inspect .basou directory", { cause: error });
411
+ throw new Error("Failed to read events.jsonl", { cause: error });
412
+ }
413
+ let buffer = "";
414
+ let lineNo = 0;
415
+ try {
416
+ for await (const chunk of stream) {
417
+ buffer += chunk;
418
+ let newlineIdx = buffer.indexOf("\n");
419
+ while (newlineIdx !== -1) {
420
+ lineNo += 1;
421
+ const rawLine = buffer.slice(0, newlineIdx);
422
+ buffer = buffer.slice(newlineIdx + 1);
423
+ const ev = processLine(rawLine, lineNo, options);
424
+ if (ev !== null) yield ev;
425
+ newlineIdx = buffer.indexOf("\n");
426
+ }
600
427
  }
428
+ } catch (error) {
429
+ throw new Error("Failed to read events.jsonl", { cause: error });
601
430
  }
602
- if (existing !== void 0 && !existing.isDirectory()) {
603
- throw new Error("Basou root .basou exists but is not a directory");
431
+ const trimmed = buffer.replace(/[\r\n\t ]+$/u, "");
432
+ if (trimmed.length === 0) return;
433
+ lineNo += 1;
434
+ let parsed;
435
+ try {
436
+ parsed = JSON.parse(trimmed);
437
+ } catch (cause) {
438
+ options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
439
+ return;
604
440
  }
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;
441
+ const result = EventSchema.safeParse(parsed);
442
+ if (!result.success) {
443
+ options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
444
+ return;
445
+ }
446
+ options.onWarning?.({ kind: "partial_trailing_line", line: lineNo });
616
447
  }
617
- async function mkdirLabeled(target, label) {
448
+ function processLine(rawLine, lineNo, options) {
449
+ const trimmed = rawLine.trim();
450
+ if (trimmed.length === 0) return null;
451
+ let parsed;
618
452
  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 });
453
+ parsed = JSON.parse(trimmed);
454
+ } catch (cause) {
455
+ options.onWarning?.({ kind: "malformed_json", line: lineNo, cause });
456
+ return null;
457
+ }
458
+ const result = EventSchema.safeParse(parsed);
459
+ if (!result.success) {
460
+ options.onWarning?.({ kind: "schema_violation", line: lineNo, cause: result.error });
461
+ return null;
625
462
  }
463
+ return result.data;
626
464
  }
627
- function hasErrorCode2(error) {
628
- if (!(error instanceof Error)) return false;
629
- const codeProp = error.code;
630
- return typeof codeProp === "string";
465
+ async function readAllEvents(sessionDir, options = {}) {
466
+ const out = [];
467
+ for await (const ev of replayEvents(sessionDir, options)) {
468
+ out.push(ev);
469
+ }
470
+ return out;
631
471
  }
632
472
 
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";
473
+ // src/storage/sessions.ts
474
+ import { readdir as readdir2 } from "fs/promises";
700
475
  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;
476
+
477
+ // src/schemas/session.schema.ts
478
+ import { z as z4 } from "zod";
479
+ var SessionStatusSchema = z4.enum([
480
+ "initialized",
481
+ "running",
482
+ "waiting_approval",
483
+ "completed",
484
+ "failed",
485
+ "interrupted",
486
+ "imported",
487
+ "archived"
488
+ ]);
489
+ var SessionSourceKindSchema = z4.enum([
490
+ "claude-code-adapter",
491
+ "human",
492
+ "import",
493
+ "terminal"
494
+ ]);
495
+ var SessionSourceSchema = z4.object({
496
+ kind: SessionSourceKindSchema,
497
+ version: z4.literal("0.1.0")
498
+ });
499
+ var InvocationSchema = z4.object({
500
+ command: z4.string().min(1),
501
+ args: z4.array(z4.string()).default([]),
502
+ // Nullable to record signal-terminated runs where the child has no exit
503
+ // code; the same nullability is mirrored in CommandExecutedEventSchema.
504
+ exit_code: z4.number().int().nullable()
505
+ });
506
+ var SessionInnerSchema = z4.object({
507
+ id: SessionIdSchema,
508
+ label: z4.string().optional(),
509
+ task_id: TaskIdSchema.nullable().optional(),
510
+ workspace_id: WorkspaceIdSchema,
511
+ source: SessionSourceSchema,
512
+ started_at: IsoTimestampSchema,
513
+ // ended_at is optional because initialized / running sessions have no end time yet.
514
+ ended_at: IsoTimestampSchema.optional(),
515
+ status: SessionStatusSchema,
516
+ working_directory: z4.string().min(1),
517
+ invocation: InvocationSchema,
518
+ related_files: z4.array(z4.string()).default([]),
519
+ events_log: z4.string().default("events.jsonl"),
520
+ summary: z4.string().nullable().optional()
521
+ });
522
+ var SessionSchema = z4.object({
523
+ schema_version: SchemaVersionSchema,
524
+ session: SessionInnerSchema
525
+ });
526
+
527
+ // src/storage/sessions.ts
528
+ var STUCK_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
529
+ async function enumerateSessionDirs(paths) {
707
530
  try {
708
- body = await readFile2(gitignorePath, "utf8");
709
- existed = true;
531
+ const dirents = await readdir2(paths.sessions, { withFileTypes: true });
532
+ return dirents.filter((d) => d.isDirectory()).map((d) => d.name).sort();
710
533
  } 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 };
534
+ if (findErrorCode(error, "ENOENT")) return [];
535
+ throw new Error("Failed to enumerate sessions", { cause: error });
720
536
  }
721
- const next = composeNextBody(body);
537
+ }
538
+ async function readSessionYaml(paths, sessionId) {
539
+ const filePath = join3(paths.sessions, sessionId, "session.yaml");
540
+ let raw;
722
541
  try {
723
- await writeFile2(gitignorePath, next, { encoding: "utf8" });
542
+ raw = await readYamlFile(filePath);
724
543
  } catch (error) {
725
- throw new Error("Failed to write .gitignore", { cause: error });
544
+ if (error instanceof Error && error.message === "YAML file not found") throw error;
545
+ throw new Error("Failed to read session.yaml", { cause: error });
726
546
  }
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;
547
+ const result = SessionSchema.safeParse(raw);
548
+ if (!result.success) {
549
+ throw new Error("Failed to read session.yaml", { cause: result.error });
732
550
  }
733
- return false;
551
+ return result.data;
734
552
  }
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}`;
553
+ async function classifySuspect(paths, sessionId, session, now, onWarning) {
554
+ if (session.session.status !== "running") {
555
+ return { suspect: false, suspectReason: null };
556
+ }
557
+ const sessionDir = join3(paths.sessions, sessionId);
558
+ let endedFound = false;
559
+ let lastEventOccurredAt = null;
560
+ const replayOpts = onWarning !== void 0 ? { onWarning } : {};
561
+ for await (const ev of replayEvents(sessionDir, replayOpts)) {
562
+ lastEventOccurredAt = ev.occurred_at;
563
+ if (ev.type === "session_ended") endedFound = true;
564
+ }
565
+ if (endedFound) {
566
+ return { suspect: true, suspectReason: "events_say_ended_but_yaml_running" };
567
+ }
568
+ if (lastEventOccurredAt !== null) {
569
+ const ageMs = now.getTime() - Date.parse(lastEventOccurredAt);
570
+ if (Number.isFinite(ageMs) && ageMs > STUCK_THRESHOLD_MS) {
571
+ return { suspect: true, suspectReason: "running_no_end_event" };
572
+ }
573
+ }
574
+ return { suspect: false, suspectReason: null };
741
575
  }
742
- function hasErrorCode4(error) {
743
- if (!(error instanceof Error)) return false;
744
- return typeof error.code === "string";
576
+ async function loadSessionEntries(paths, options) {
577
+ const sessionIds = await enumerateSessionDirs(paths);
578
+ const entries = [];
579
+ for (const sid of sessionIds) {
580
+ let session;
581
+ try {
582
+ session = await readSessionYaml(paths, sid);
583
+ } catch (error) {
584
+ if (error instanceof Error && error.message === "YAML file not found") {
585
+ options.onSkip?.(sid, "session_yaml_missing");
586
+ } else {
587
+ options.onSkip?.(sid, "session_yaml_invalid");
588
+ }
589
+ continue;
590
+ }
591
+ let suspect = false;
592
+ let suspectReason = null;
593
+ try {
594
+ const r = await classifySuspect(
595
+ paths,
596
+ sid,
597
+ session,
598
+ options.now,
599
+ (w) => options.onWarning?.(w, sid)
600
+ );
601
+ suspect = r.suspect;
602
+ suspectReason = r.suspectReason;
603
+ } catch {
604
+ options.onSkip?.(sid, "events_jsonl_unreadable");
605
+ }
606
+ entries.push({ sessionId: sid, session, suspect, suspectReason });
607
+ }
608
+ return entries;
745
609
  }
746
610
 
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()
611
+ // src/decisions/decisions-renderer.ts
612
+ async function renderDecisions(input) {
613
+ const now = new Date(input.nowIso);
614
+ const unreadableEmitted = /* @__PURE__ */ new Set();
615
+ const wrappedSkip = (sid, reason) => {
616
+ if (reason === "events_jsonl_unreadable") unreadableEmitted.add(sid);
617
+ input.onSessionSkip?.(sid, reason);
756
618
  };
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);
619
+ const loadOpts = { now, onSkip: wrappedSkip };
620
+ if (input.onWarning !== void 0) loadOpts.onWarning = input.onWarning;
621
+ const entries = await loadSessionEntries(input.paths, loadOpts);
622
+ const decisions = [];
623
+ const knownEventIds = /* @__PURE__ */ new Set();
624
+ for (const entry of entries) {
625
+ const sessionDir = join4(input.paths.sessions, entry.sessionId);
769
626
  try {
770
- await atomicCreate(lockPath, serialised);
771
- } catch (retryError) {
772
- throw new Error("Lock is held by another process", { cause: retryError });
627
+ for await (const ev of replayEvents(sessionDir, {
628
+ onWarning: (w) => input.onWarning?.(w, entry.sessionId)
629
+ })) {
630
+ knownEventIds.add(ev.id);
631
+ if (ev.type === "decision_recorded") {
632
+ decisions.push({
633
+ decisionId: ev.decision_id,
634
+ title: ev.title,
635
+ occurredAt: ev.occurred_at,
636
+ sessionId: entry.sessionId,
637
+ rationale: ev.rationale,
638
+ alternatives: ev.alternatives,
639
+ rejectedReason: ev.rejected_reason,
640
+ linkedEvents: ev.linked_events,
641
+ linkedFiles: ev.linked_files
642
+ });
643
+ }
644
+ }
645
+ } catch {
646
+ if (!unreadableEmitted.has(entry.sessionId)) {
647
+ wrappedSkip(entry.sessionId, "events_jsonl_unreadable");
648
+ }
773
649
  }
774
650
  }
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;
651
+ decisions.sort((a, b) => {
652
+ const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
653
+ return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
654
+ });
655
+ const repoRoot = dirname(input.paths.root);
656
+ const fileExistenceCache = /* @__PURE__ */ new Map();
657
+ async function fileExists(relPath) {
658
+ const cached = fileExistenceCache.get(relPath);
659
+ if (cached !== void 0) return cached;
660
+ const abs = resolve(repoRoot, relPath);
661
+ let exists;
662
+ try {
663
+ await lstat(abs);
664
+ exists = true;
665
+ } catch {
666
+ exists = false;
790
667
  }
791
- body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
792
- } catch {
793
- return true;
668
+ fileExistenceCache.set(relPath, exists);
669
+ return exists;
794
670
  }
795
- const ageMs = Date.now() - Date.parse(body.acquired_at);
796
- if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
797
- return true;
671
+ const body = await formatDecisionsBody({
672
+ nowIso: input.nowIso,
673
+ decisions,
674
+ knownEventIds,
675
+ fileExists
676
+ });
677
+ return { body, decisionCount: decisions.length };
678
+ }
679
+ async function formatDecisionsBody(args) {
680
+ const lines = [];
681
+ lines.push("# Decisions");
682
+ lines.push("");
683
+ lines.push(`> Generated at ${args.nowIso}`);
684
+ lines.push("");
685
+ if (args.decisions.length === 0) {
686
+ lines.push("(no decisions recorded yet)");
687
+ return lines.join("\n");
798
688
  }
799
- try {
800
- process.kill(body.pid, 0);
801
- return false;
802
- } catch (error) {
803
- if (findErrorCode(error, "ESRCH")) return true;
804
- return false;
689
+ for (const d of args.decisions) {
690
+ lines.push(`## ${d.decisionId}: ${d.title}`);
691
+ lines.push("");
692
+ const occurredDate = d.occurredAt.slice(0, 10);
693
+ lines.push(`- \u6C7A\u5B9A\u65E5: ${occurredDate}`);
694
+ lines.push(`- session: ${shortDecisionSessionId(d.sessionId)}`);
695
+ lines.push(`- \u5224\u65AD: ${d.title}`);
696
+ if (typeof d.rationale === "string" && d.rationale.length > 0) {
697
+ lines.push(`- rationale: ${d.rationale}`);
698
+ }
699
+ if (d.alternatives !== void 0 && d.alternatives.length > 0) {
700
+ lines.push(`- alternatives: ${d.alternatives.join(", ")}`);
701
+ }
702
+ if (typeof d.rejectedReason === "string" && d.rejectedReason.length > 0) {
703
+ lines.push(`- rejected_reason: ${d.rejectedReason}`);
704
+ }
705
+ if (d.linkedEvents !== void 0 && d.linkedEvents.length > 0) {
706
+ const parts = d.linkedEvents.map(
707
+ (eid) => args.knownEventIds.has(eid) ? eid : `${eid} (missing)`
708
+ );
709
+ lines.push(`- linked_events: ${parts.join(", ")}`);
710
+ }
711
+ if (d.linkedFiles !== void 0 && d.linkedFiles.length > 0) {
712
+ const parts = await Promise.all(
713
+ d.linkedFiles.map(
714
+ async (path2) => await args.fileExists(path2) ? path2 : `${path2} (missing)`
715
+ )
716
+ );
717
+ lines.push(`- linked_files: ${parts.join(", ")}`);
718
+ }
719
+ lines.push("");
805
720
  }
721
+ return lines.join("\n");
806
722
  }
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`);
723
+ function shortDecisionSessionId(sessionId) {
724
+ const SES = "ses_";
725
+ if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
726
+ return sessionId.slice(0, 10);
811
727
  }
812
728
 
813
- // src/storage/task-index.ts
814
- import { readFile as readFile4 } from "fs/promises";
729
+ // src/events/event-writer.ts
730
+ import { appendFile } from "fs/promises";
815
731
  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;
732
+ async function appendEvent(sessionDir, event) {
733
+ let validated;
822
734
  try {
823
- raw = await readFile4(filePath, "utf8");
735
+ validated = EventSchema.parse(event);
824
736
  } 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 });
737
+ throw new Error("Invalid Basou event payload", { cause: error });
829
738
  }
830
- let parsedJson;
739
+ const line = `${JSON.stringify(validated)}
740
+ `;
831
741
  try {
832
- parsedJson = JSON.parse(raw);
742
+ await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
833
743
  } 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
- });
744
+ throw new Error("Failed to append event to events.jsonl", { cause: error });
844
745
  }
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
746
  }
859
- async function updateTaskIndex(paths, op, options) {
860
- const nowFn = options?.now ?? (() => /* @__PURE__ */ new Date());
861
- let current;
747
+ async function writeEventsBulk(sessionDir, events) {
748
+ const validated = [];
862
749
  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
- };
750
+ for (const event of events) {
751
+ validated.push(EventSchema.parse(event));
752
+ }
753
+ } catch (error) {
754
+ throw new Error("Invalid Basou event payload", { cause: error });
870
755
  }
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;
756
+ const filePath = join5(sessionDir, "events.jsonl");
757
+ const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
758
+ ` : "";
759
+ try {
760
+ await atomicReplace(filePath, body);
761
+ } catch (error) {
762
+ throw new Error("Failed to write events.jsonl", { cause: error });
882
763
  }
883
- return await rebuildTaskIndex(paths, nextTasks, nowFn);
884
764
  }
885
765
 
766
+ // src/git/snapshot.ts
767
+ import { simpleGit } from "simple-git";
768
+
886
769
  // src/storage/status.ts
887
770
  import * as fsp from "fs/promises";
771
+
772
+ // src/schemas/status.schema.ts
773
+ import { z as z5 } from "zod";
774
+ var StatusSchema = z5.object({
775
+ schema_version: SchemaVersionSchema,
776
+ generated_at: IsoTimestampSchema,
777
+ workspace: z5.object({
778
+ id: WorkspaceIdSchema,
779
+ name: z5.string().min(1),
780
+ basou_version: z5.literal("0.1.0")
781
+ }).strict(),
782
+ directories_present: z5.object({
783
+ sessions: z5.boolean(),
784
+ tasks: z5.boolean(),
785
+ approvals_pending: z5.boolean(),
786
+ approvals_resolved: z5.boolean(),
787
+ logs: z5.boolean(),
788
+ raw: z5.boolean(),
789
+ tmp: z5.boolean()
790
+ }).strict()
791
+ }).strict();
792
+
793
+ // src/storage/status.ts
888
794
  var DIRECTORY_CHECKS = {
889
795
  sessions: (p) => p.sessions,
890
796
  tasks: (p) => p.tasks,
@@ -899,7 +805,7 @@ async function assertBasouRootSafe(rootPath) {
899
805
  try {
900
806
  stat3 = await fsp.lstat(rootPath);
901
807
  } catch (error) {
902
- if (hasErrorCode5(error) && error.code === "ENOENT") {
808
+ if (hasErrorCode2(error) && error.code === "ENOENT") {
903
809
  throw new Error("Basou workspace not found", { cause: error });
904
810
  }
905
811
  throw new Error("Failed to inspect .basou root", { cause: error });
@@ -915,7 +821,7 @@ async function dirPresent(path2) {
915
821
  try {
916
822
  return (await fsp.lstat(path2)).isDirectory();
917
823
  } catch (error) {
918
- if (hasErrorCode5(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
824
+ if (hasErrorCode2(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
919
825
  return false;
920
826
  }
921
827
  throw new Error("Failed to inspect .basou subdirectory", { cause: error });
@@ -955,336 +861,253 @@ async function readStatus(paths) {
955
861
  let body;
956
862
  try {
957
863
  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" };
864
+ } catch (error) {
865
+ if (hasErrorCode2(error) && error.code === "ENOENT") {
866
+ throw new Error("Status file not found", { cause: error });
1104
867
  }
868
+ throw new Error("Failed to read status file", { cause: error });
1105
869
  }
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 });
870
+ let parsed;
871
+ try {
872
+ parsed = JSON.parse(body);
873
+ } catch (error) {
874
+ throw new Error("Failed to parse status JSON", { cause: error });
1139
875
  }
1140
- return entries;
876
+ return StatusSchema.parse(parsed);
877
+ }
878
+ function hasErrorCode2(error) {
879
+ if (!(error instanceof Error)) return false;
880
+ return typeof error.code === "string";
1141
881
  }
1142
882
 
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 });
883
+ // src/git/snapshot.ts
884
+ function safeSimpleGit(repoRoot) {
885
+ return simpleGit({ baseDir: repoRoot });
886
+ }
887
+ function isGitNotFound(error) {
888
+ if (findErrorCode(error, "ENOENT")) return true;
889
+ let cur = error;
890
+ for (let i = 0; i < 4 && cur instanceof Error; i++) {
891
+ if (/\bENOENT\b/.test(cur.message)) return true;
892
+ cur = cur.cause;
1153
893
  }
894
+ return false;
1154
895
  }
1155
- async function writeMarkdownFile(filePath, body) {
896
+ async function resolveRepositoryRoot(cwd) {
897
+ const git = safeSimpleGit(cwd);
1156
898
  try {
1157
- await atomicReplace(filePath, body);
899
+ const root = (await git.revparse(["--show-toplevel"])).trimEnd();
900
+ if (root.length === 0) {
901
+ throw new Error("Not a git repository");
902
+ }
903
+ return root;
1158
904
  } catch (error) {
1159
- throw new Error("Failed to write markdown file", { cause: error });
905
+ if (isGitNotFound(error)) {
906
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
907
+ }
908
+ if (error instanceof Error && error.message === "Not a git repository") {
909
+ throw error;
910
+ }
911
+ throw new Error("Not a git repository", { cause: error });
1160
912
  }
1161
913
  }
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);
914
+ async function tryRemoteUrl(repositoryRoot) {
915
+ const git = safeSimpleGit(repositoryRoot);
916
+ try {
917
+ const result = await git.getConfig("remote.origin.url", "local");
918
+ const url = (result.value ?? "").trimEnd();
919
+ return url.length > 0 ? url : void 0;
920
+ } catch {
921
+ return void 0;
1169
922
  }
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
923
  }
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
- `;
924
+ async function getSnapshot(repositoryRoot) {
925
+ const git = safeSimpleGit(repositoryRoot);
926
+ let inside;
927
+ try {
928
+ inside = await git.checkIsRepo();
929
+ } catch (error) {
930
+ if (isGitNotFound(error)) {
931
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
932
+ }
933
+ throw new Error("Failed to read git state", { cause: error });
1195
934
  }
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}`);
935
+ if (!inside) {
936
+ throw new Error("Not a git repository");
1208
937
  }
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;
938
+ let head;
939
+ try {
940
+ head = (await git.revparse(["HEAD"])).trimEnd();
941
+ } catch (error) {
942
+ if (isGitNotFound(error)) {
943
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1225
944
  }
945
+ throw new Error("No commits in repository", { cause: error });
1226
946
  }
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;
947
+ if (head.length === 0) {
948
+ throw new Error("No commits in repository");
949
+ }
950
+ let branch;
1256
951
  try {
1257
- validated = EventSchema.parse(event);
952
+ const raw = (await git.raw(["branch", "--show-current"])).trimEnd();
953
+ branch = raw.length > 0 ? raw : "HEAD";
1258
954
  } catch (error) {
1259
- throw new Error("Invalid Basou event payload", { cause: error });
955
+ throw new Error("Failed to read git state", { cause: error });
1260
956
  }
1261
- const line = `${JSON.stringify(validated)}
1262
- `;
957
+ let dirty;
958
+ const staged = [];
959
+ const unstaged = [];
960
+ const untracked = [];
1263
961
  try {
1264
- await appendFile(join8(sessionDir, "events.jsonl"), line, "utf8");
962
+ const status = await git.status();
963
+ dirty = !status.isClean();
964
+ for (const f of status.files) {
965
+ if (f.index === "?" && f.working_dir === "?") {
966
+ untracked.push(f.path);
967
+ continue;
968
+ }
969
+ if (f.index !== " " && f.index !== "?") staged.push(f.path);
970
+ if (f.working_dir !== " " && f.working_dir !== "?") unstaged.push(f.path);
971
+ }
1265
972
  } catch (error) {
1266
- throw new Error("Failed to append event to events.jsonl", { cause: error });
973
+ throw new Error("Failed to read git state", { cause: error });
974
+ }
975
+ let ahead;
976
+ let behind;
977
+ if (branch !== "HEAD") {
978
+ try {
979
+ const upstream = `${branch}@{upstream}`;
980
+ const counts = (await git.raw(["rev-list", "--left-right", "--count", `${upstream}...HEAD`])).trim();
981
+ const [behindStr, aheadStr] = counts.split(/\s+/);
982
+ const parsedBehind = Number.parseInt(behindStr ?? "", 10);
983
+ const parsedAhead = Number.parseInt(aheadStr ?? "", 10);
984
+ if (Number.isFinite(parsedBehind) && parsedBehind >= 0) behind = parsedBehind;
985
+ if (Number.isFinite(parsedAhead) && parsedAhead >= 0) ahead = parsedAhead;
986
+ } catch {
987
+ }
1267
988
  }
989
+ const snapshot = {
990
+ head,
991
+ branch,
992
+ dirty,
993
+ staged,
994
+ unstaged,
995
+ untracked,
996
+ ...ahead !== void 0 ? { ahead } : {},
997
+ ...behind !== void 0 ? { behind } : {}
998
+ };
999
+ return snapshot;
1268
1000
  }
1269
- async function writeEventsBulk(sessionDir, events) {
1270
- const validated = [];
1001
+
1002
+ // src/git/diff.ts
1003
+ async function getDiff(repoRoot, baseRef, headRef) {
1004
+ let git;
1271
1005
  try {
1272
- for (const event of events) {
1273
- validated.push(EventSchema.parse(event));
1274
- }
1006
+ git = safeSimpleGit(repoRoot);
1275
1007
  } catch (error) {
1276
- throw new Error("Invalid Basou event payload", { cause: error });
1008
+ if (isGitNotFound(error)) {
1009
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1010
+ }
1011
+ throw new Error("Not a git repository", { cause: error });
1277
1012
  }
1278
- const filePath = join8(sessionDir, "events.jsonl");
1279
- const body = validated.length > 0 ? `${validated.map((e) => JSON.stringify(e)).join("\n")}
1280
- ` : "";
1013
+ if (baseRef === headRef) return { changed_files: [] };
1014
+ let raw;
1281
1015
  try {
1282
- await atomicReplace(filePath, body);
1016
+ raw = await git.raw(["diff", "--name-status", `${baseRef}..${headRef}`]);
1283
1017
  } catch (error) {
1284
- throw new Error("Failed to write events.jsonl", { cause: error });
1018
+ if (isGitNotFound(error)) {
1019
+ throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
1020
+ }
1021
+ const message = error instanceof Error ? error.message : "";
1022
+ if (/not a git repository/i.test(message)) {
1023
+ throw new Error("Not a git repository", { cause: error });
1024
+ }
1025
+ if (message.includes("bad revision") || message.includes("unknown revision") || message.includes("ambiguous argument")) {
1026
+ throw new Error("Invalid ref", { cause: error });
1027
+ }
1028
+ throw new Error("Failed to compute git diff", { cause: error });
1029
+ }
1030
+ return { changed_files: parseDiffNameStatus(raw) };
1031
+ }
1032
+ function parseDiffNameStatus(raw) {
1033
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
1034
+ const changes = [];
1035
+ for (const line of lines) {
1036
+ const parts = line.split(" ");
1037
+ const code = parts[0];
1038
+ if (code === void 0 || code.length === 0) continue;
1039
+ if (code.startsWith("R") && parts.length >= 3) {
1040
+ const newPath = parts[2];
1041
+ const oldPath = parts[1];
1042
+ if (newPath === void 0) continue;
1043
+ changes.push({
1044
+ path: newPath,
1045
+ status: "renamed",
1046
+ ...oldPath !== void 0 ? { old_path: oldPath } : {}
1047
+ });
1048
+ } else if (code === "A" && parts[1]) {
1049
+ changes.push({ path: parts[1], status: "added" });
1050
+ } else if (code === "M" && parts[1]) {
1051
+ changes.push({ path: parts[1], status: "modified" });
1052
+ } else if (code === "D" && parts[1]) {
1053
+ changes.push({ path: parts[1], status: "deleted" });
1054
+ }
1285
1055
  }
1056
+ return changes;
1286
1057
  }
1287
1058
 
1059
+ // src/handoff/handoff-renderer.ts
1060
+ import { join as join10 } from "path";
1061
+
1062
+ // src/storage/tasks.ts
1063
+ import { createHash } from "crypto";
1064
+ import { mkdir as mkdir2, readdir as readdir3, readFile as readFile5, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
1065
+ import { join as join9 } from "path";
1066
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
1067
+ import { z as z8 } from "zod";
1068
+
1069
+ // src/schemas/task.schema.ts
1070
+ import { z as z6 } from "zod";
1071
+ var TaskStatusSchema = z6.enum(["planned", "in_progress", "done", "cancelled"]);
1072
+ var TaskInnerSchema = z6.object({
1073
+ id: TaskIdSchema,
1074
+ title: z6.string().min(1),
1075
+ label: z6.string().min(1).optional(),
1076
+ status: TaskStatusSchema,
1077
+ created_at: IsoTimestampSchema,
1078
+ updated_at: IsoTimestampSchema,
1079
+ workspace_id: WorkspaceIdSchema,
1080
+ /**
1081
+ * Session id that anchors this task. For freshly created tasks it is the
1082
+ * session that wrote the `task_created` event (= ad-hoc reconcile target
1083
+ * for ad-hoc paths, or the target session id for attach paths). After
1084
+ * `basou task reconcile --write` repairs a broken anchor the
1085
+ * value is replaced with the ad-hoc reconcile session id; the old broken
1086
+ * session_id is preserved on the `task_reconciled` event payload via
1087
+ * `removed_created_in_session` for audit. So this field always names a
1088
+ * reachable session, even after the original anchor is gone.
1089
+ */
1090
+ created_in_session: SessionIdSchema,
1091
+ /**
1092
+ * Snapshot of sessions linked to this task. The events.jsonl history is
1093
+ * the source of truth (see
1094
+ * `docs/spec/generated-markdown.md#105-decisionsmd-generation-principle`);
1095
+ * this field is maintained as a UX-only cache so editors can read the
1096
+ * task.md and immediately see related sessions. Defaults to `[]` for
1097
+ * backward compatibility.
1098
+ */
1099
+ linked_sessions: z6.array(SessionIdSchema).default([])
1100
+ });
1101
+ var TaskSchema = z6.object({
1102
+ schema_version: SchemaVersionSchema,
1103
+ task: TaskInnerSchema
1104
+ });
1105
+
1106
+ // src/storage/ad-hoc-session.ts
1107
+ import { mkdir, rm } from "fs/promises";
1108
+ import { homedir } from "os";
1109
+ import { join as join6 } from "path";
1110
+
1288
1111
  // src/lib/path-sanitizer.ts
1289
1112
  import { posix as path } from "path";
1290
1113
  function sanitizePath(rawPath, opts) {
@@ -1326,17 +1149,7 @@ function sanitizeRelatedFiles(paths, opts) {
1326
1149
  return { sanitized, mutationCount };
1327
1150
  }
1328
1151
 
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
1152
  // 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
1153
  var FailedToFinalizeError = class extends Error {
1341
1154
  sessionId;
1342
1155
  targetEventIds;
@@ -1373,13 +1186,13 @@ async function createAdHocSessionWithEvent(input) {
1373
1186
  taskId: input.taskId ?? null
1374
1187
  })
1375
1188
  );
1376
- const sessionDir = join9(input.paths.sessions, sessionId);
1189
+ const sessionDir = join6(input.paths.sessions, sessionId);
1377
1190
  try {
1378
- await mkdir2(sessionDir, { recursive: true });
1191
+ await mkdir(sessionDir, { recursive: true });
1379
1192
  } catch (error) {
1380
1193
  throw new Error("Failed to create session directory", { cause: error });
1381
1194
  }
1382
- const sessionYamlPath = join9(sessionDir, "session.yaml");
1195
+ const sessionYamlPath = join6(sessionDir, "session.yaml");
1383
1196
  try {
1384
1197
  await linkYamlFile(sessionYamlPath, initialSession);
1385
1198
  } catch (error) {
@@ -1502,7 +1315,7 @@ async function appendEventToExistingSession(input) {
1502
1315
  }
1503
1316
  const eventId = prefixedUlid("evt");
1504
1317
  const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
1505
- const sessionDir = join9(input.paths.sessions, input.sessionId);
1318
+ const sessionDir = join6(input.paths.sessions, input.sessionId);
1506
1319
  await appendEvent(sessionDir, event);
1507
1320
  return { eventId, sessionStatus: status };
1508
1321
  }
@@ -1513,7 +1326,163 @@ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
1513
1326
  if (event.id !== expectedEventId) {
1514
1327
  throw new Error("Target event id mismatch");
1515
1328
  }
1516
- return event;
1329
+ return event;
1330
+ }
1331
+
1332
+ // src/storage/lockfile.ts
1333
+ import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1334
+ import { join as join7 } from "path";
1335
+ var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
1336
+ async function acquireLock(paths, scope, resourceId) {
1337
+ const lockPath = lockfilePath(paths, scope, resourceId);
1338
+ const body = {
1339
+ pid: process.pid,
1340
+ acquired_at: (/* @__PURE__ */ new Date()).toISOString()
1341
+ };
1342
+ const serialised = JSON.stringify(body);
1343
+ try {
1344
+ await atomicCreate(lockPath, serialised);
1345
+ } catch (error) {
1346
+ if (!findErrorCode(error, "EEXIST")) {
1347
+ throw error;
1348
+ }
1349
+ const stale = await isStaleLock(lockPath);
1350
+ if (!stale) {
1351
+ throw new Error("Lock is held by another process", { cause: error });
1352
+ }
1353
+ await unlink2(lockPath).catch(() => void 0);
1354
+ try {
1355
+ await atomicCreate(lockPath, serialised);
1356
+ } catch (retryError) {
1357
+ throw new Error("Lock is held by another process", { cause: retryError });
1358
+ }
1359
+ }
1360
+ return {
1361
+ release: async () => {
1362
+ await unlink2(lockPath).catch(() => void 0);
1363
+ }
1364
+ };
1365
+ }
1366
+ async function isStaleLock(lockPath) {
1367
+ let body;
1368
+ try {
1369
+ const raw = await readFile3(lockPath, "utf8");
1370
+ const parsed = JSON.parse(raw);
1371
+ if (typeof parsed !== "object" || parsed === null) return true;
1372
+ const candidate = parsed;
1373
+ if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
1374
+ return true;
1375
+ }
1376
+ body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
1377
+ } catch {
1378
+ return true;
1379
+ }
1380
+ const ageMs = Date.now() - Date.parse(body.acquired_at);
1381
+ if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
1382
+ return true;
1383
+ }
1384
+ try {
1385
+ process.kill(body.pid, 0);
1386
+ return false;
1387
+ } catch (error) {
1388
+ if (findErrorCode(error, "ESRCH")) return true;
1389
+ return false;
1390
+ }
1391
+ }
1392
+ function lockfilePath(paths, scope, resourceId) {
1393
+ const sep = resourceId.indexOf("_");
1394
+ const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
1395
+ return join7(paths.locks, `${scope}_${ulid2}.lock`);
1396
+ }
1397
+
1398
+ // src/storage/task-index.ts
1399
+ import { readFile as readFile4 } from "fs/promises";
1400
+ import { join as join8 } from "path";
1401
+
1402
+ // src/schemas/task-index.schema.ts
1403
+ import { z as z7 } from "zod";
1404
+ var TaskIndexEntrySchema = z7.object({
1405
+ id: TaskIdSchema,
1406
+ status: TaskStatusSchema,
1407
+ label: z7.string().min(1).optional(),
1408
+ updated_at: IsoTimestampSchema
1409
+ }).strict();
1410
+ var TaskIndexSchema = z7.object({
1411
+ schema_version: SchemaVersionSchema,
1412
+ tasks: z7.array(TaskIndexEntrySchema),
1413
+ last_rebuilt_at: IsoTimestampSchema
1414
+ }).strict();
1415
+ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
1416
+
1417
+ // src/storage/task-index.ts
1418
+ function taskIndexPath(paths) {
1419
+ return join8(paths.tasks, "index.json");
1420
+ }
1421
+ async function readTaskIndex(paths) {
1422
+ const filePath = taskIndexPath(paths);
1423
+ let raw;
1424
+ try {
1425
+ raw = await readFile4(filePath, "utf8");
1426
+ } catch (error) {
1427
+ if (findErrorCode(error, "ENOENT")) {
1428
+ throw new Error("Task index not found", { cause: error });
1429
+ }
1430
+ throw new Error("Failed to read task index", { cause: error });
1431
+ }
1432
+ let parsedJson;
1433
+ try {
1434
+ parsedJson = JSON.parse(raw);
1435
+ } catch (error) {
1436
+ throw new Error("Invalid task index", { cause: error });
1437
+ }
1438
+ const result = TaskIndexSchema.safeParse(parsedJson);
1439
+ if (!result.success) {
1440
+ throw new Error("Invalid task index", { cause: result.error });
1441
+ }
1442
+ if (result.data.schema_version !== TASK_INDEX_SCHEMA_VERSION) {
1443
+ throw new Error("Invalid task index", {
1444
+ cause: new Error(`Unsupported task index schema_version: ${result.data.schema_version}`)
1445
+ });
1446
+ }
1447
+ return result.data;
1448
+ }
1449
+ async function rebuildTaskIndex(paths, entries, now) {
1450
+ const sorted = [...entries].sort((a, b) => a.id.localeCompare(b.id));
1451
+ const payload = {
1452
+ schema_version: TASK_INDEX_SCHEMA_VERSION,
1453
+ tasks: sorted,
1454
+ last_rebuilt_at: (now ?? (() => /* @__PURE__ */ new Date()))().toISOString()
1455
+ };
1456
+ TaskIndexSchema.parse(payload);
1457
+ await atomicReplace(taskIndexPath(paths), `${JSON.stringify(payload, null, 2)}
1458
+ `);
1459
+ return payload;
1460
+ }
1461
+ async function updateTaskIndex(paths, op, options) {
1462
+ const nowFn = options?.now ?? (() => /* @__PURE__ */ new Date());
1463
+ let current;
1464
+ try {
1465
+ current = await readTaskIndex(paths);
1466
+ } catch {
1467
+ current = {
1468
+ schema_version: TASK_INDEX_SCHEMA_VERSION,
1469
+ tasks: [],
1470
+ last_rebuilt_at: nowFn().toISOString()
1471
+ };
1472
+ }
1473
+ let nextTasks;
1474
+ switch (op.kind) {
1475
+ case "add":
1476
+ 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];
1477
+ break;
1478
+ case "update":
1479
+ 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];
1480
+ break;
1481
+ case "remove":
1482
+ nextTasks = current.tasks.filter((t) => t.id !== op.id);
1483
+ break;
1484
+ }
1485
+ return await rebuildTaskIndex(paths, nextTasks, nowFn);
1517
1486
  }
1518
1487
 
1519
1488
  // src/storage/tasks.ts
@@ -1526,8 +1495,8 @@ var DEFAULT_ATTACHABLE_STATUSES2 = /* @__PURE__ */ new Set([
1526
1495
  "waiting_approval"
1527
1496
  ]);
1528
1497
  var InitialTaskStatusSchema = TaskStatusSchema;
1529
- var TaskTitleSchema = z10.string().min(1);
1530
- var TaskLabelSchema = z10.string().min(1);
1498
+ var TaskTitleSchema = z8.string().min(1);
1499
+ var TaskLabelSchema = z8.string().min(1);
1531
1500
  var CompletedAtSchema = IsoTimestampSchema;
1532
1501
  var TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set(["done", "cancelled"]);
1533
1502
  function isTerminalTaskStatus(status) {
@@ -1561,10 +1530,10 @@ function splitFrontMatter(raw) {
1561
1530
  return { yamlText, body };
1562
1531
  }
1563
1532
  async function readTaskFile(paths, taskId) {
1564
- const filePath = join10(paths.tasks, `${taskId}.md`);
1533
+ const filePath = join9(paths.tasks, `${taskId}.md`);
1565
1534
  let raw;
1566
1535
  try {
1567
- raw = await readFile7(filePath, "utf8");
1536
+ raw = await readFile5(filePath, "utf8");
1568
1537
  } catch (error) {
1569
1538
  if (findErrorCode(error, "ENOENT")) {
1570
1539
  throw new Error("Task file not found", { cause: error });
@@ -1594,7 +1563,7 @@ async function readTaskFile(paths, taskId) {
1594
1563
  }
1595
1564
  async function writeTaskFile(paths, taskId, doc, options) {
1596
1565
  const validated = TaskSchema.parse(doc.task);
1597
- const filePath = join10(paths.tasks, `${taskId}.md`);
1566
+ const filePath = join9(paths.tasks, `${taskId}.md`);
1598
1567
  const yamlText = stringifyYaml(validated);
1599
1568
  const trimmedBody = doc.body.length === 0 ? "" : `
1600
1569
  ${doc.body.endsWith("\n") ? doc.body : `${doc.body}
@@ -1679,7 +1648,7 @@ async function safeUpdateTaskIndex(paths, op) {
1679
1648
  }
1680
1649
  var ARCHIVE_DIR_NAME = "archive";
1681
1650
  function archiveTasksDir(paths) {
1682
- return join10(paths.tasks, ARCHIVE_DIR_NAME);
1651
+ return join9(paths.tasks, ARCHIVE_DIR_NAME);
1683
1652
  }
1684
1653
  async function enumerateArchivedTaskIds(paths) {
1685
1654
  let entries;
@@ -1709,10 +1678,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
1709
1678
  throw error;
1710
1679
  }
1711
1680
  }
1712
- const archiveFilePath = join10(archiveTasksDir(paths), `${taskId}.md`);
1681
+ const archiveFilePath = join9(archiveTasksDir(paths), `${taskId}.md`);
1713
1682
  let raw;
1714
1683
  try {
1715
- raw = await readFile7(archiveFilePath, "utf8");
1684
+ raw = await readFile5(archiveFilePath, "utf8");
1716
1685
  } catch (error) {
1717
1686
  if (findErrorCode(error, "ENOENT")) {
1718
1687
  throw new Error("Task file not found", { cause: error });
@@ -2003,7 +1972,7 @@ async function createTaskAttachLocked(input) {
2003
1972
  ...sessionDoc,
2004
1973
  session: { ...sessionDoc.session, task_id: input.taskId }
2005
1974
  };
2006
- await overwriteYamlFile(join10(input.paths.sessions, input.sessionId, "session.yaml"), updated);
1975
+ await overwriteYamlFile(join9(input.paths.sessions, input.sessionId, "session.yaml"), updated);
2007
1976
  } catch (error) {
2008
1977
  throw new TaskWriteAfterEventError({
2009
1978
  taskId: input.taskId,
@@ -2262,17 +2231,17 @@ function buildUpdatedDoc(input) {
2262
2231
  return { task: next, body: input.currentDoc.body };
2263
2232
  }
2264
2233
  async function computeTaskMdSnapshot(paths, taskId) {
2265
- const filePath = join10(paths.tasks, `${taskId}.md`);
2266
- const [stats, raw] = await Promise.all([stat2(filePath), readFile7(filePath)]);
2234
+ const filePath = join9(paths.tasks, `${taskId}.md`);
2235
+ const [stats, raw] = await Promise.all([stat2(filePath), readFile5(filePath)]);
2267
2236
  const hash = createHash("sha256").update(raw).digest("hex");
2268
2237
  return { mtimeMs: stats.mtimeMs, hash };
2269
2238
  }
2270
2239
  async function readTaskFileWithSnapshot(paths, taskId) {
2271
- const filePath = join10(paths.tasks, `${taskId}.md`);
2240
+ const filePath = join9(paths.tasks, `${taskId}.md`);
2272
2241
  let rawBuffer;
2273
2242
  let stats;
2274
2243
  try {
2275
- [rawBuffer, stats] = await Promise.all([readFile7(filePath), stat2(filePath)]);
2244
+ [rawBuffer, stats] = await Promise.all([readFile5(filePath), stat2(filePath)]);
2276
2245
  } catch (error) {
2277
2246
  if (findErrorCode(error, "ENOENT")) {
2278
2247
  throw new Error("Task file not found", { cause: error });
@@ -2760,7 +2729,7 @@ async function deleteTaskLocked(input) {
2760
2729
  });
2761
2730
  const eventId = adHoc.targetEventIds[0];
2762
2731
  try {
2763
- await unlink3(join10(input.paths.tasks, `${input.taskId}.md`));
2732
+ await unlink3(join9(input.paths.tasks, `${input.taskId}.md`));
2764
2733
  } catch (error) {
2765
2734
  throw new TaskWriteAfterEventError({
2766
2735
  taskId: input.taskId,
@@ -2771,275 +2740,89 @@ async function deleteTaskLocked(input) {
2771
2740
  });
2772
2741
  }
2773
2742
  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}`);
2743
+ return {
2744
+ taskId: input.taskId,
2745
+ title,
2746
+ sessionId: adHoc.sessionId,
2747
+ eventId
2748
+ };
2749
+ }
2750
+ async function archiveTask(input) {
2751
+ TaskIdSchema.parse(input.taskId);
2752
+ const handle = await acquireLock(input.paths, "task", input.taskId);
2753
+ try {
2754
+ return await archiveTaskLocked(input);
2755
+ } finally {
2756
+ await handle.release();
3032
2757
  }
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.`
2758
+ }
2759
+ async function archiveTaskLocked(input) {
2760
+ const doc = await readTaskFile(input.paths, input.taskId);
2761
+ const title = doc.task.task.title;
2762
+ const adHoc = await createAdHocSessionWithEvent({
2763
+ paths: input.paths,
2764
+ manifest: input.manifest,
2765
+ label: buildAdHocArchiveLabel(title),
2766
+ occurredAt: input.occurredAt,
2767
+ sessionSource: "human",
2768
+ workingDirectory: input.workingDirectory,
2769
+ invocation: {
2770
+ command: "basou task archive",
2771
+ args: [input.taskId, "--yes"]
2772
+ },
2773
+ taskId: input.taskId,
2774
+ targetEventBuilders: [
2775
+ (sessionId, eventId2) => buildTaskArchivedEvent({
2776
+ eventId: eventId2,
2777
+ sessionId,
2778
+ taskId: input.taskId,
2779
+ title,
2780
+ occurredAt: input.occurredAt
2781
+ })
2782
+ ]
2783
+ });
2784
+ const eventId = adHoc.targetEventIds[0];
2785
+ try {
2786
+ const linked = doc.task.task.linked_sessions;
2787
+ const merged = linked.includes(adHoc.sessionId) ? linked : [...linked, adHoc.sessionId];
2788
+ const next = {
2789
+ ...doc.task,
2790
+ task: {
2791
+ ...doc.task.task,
2792
+ updated_at: input.occurredAt,
2793
+ linked_sessions: merged
2794
+ }
2795
+ };
2796
+ await writeTaskFile(
2797
+ input.paths,
2798
+ input.taskId,
2799
+ { task: next, body: doc.body },
2800
+ { mode: "overwrite" }
3036
2801
  );
2802
+ await mkdir2(archiveTasksDir(input.paths), { recursive: true });
2803
+ await rename2(
2804
+ join9(input.paths.tasks, `${input.taskId}.md`),
2805
+ join9(archiveTasksDir(input.paths), `${input.taskId}.md`)
2806
+ );
2807
+ } catch (error) {
2808
+ throw new TaskWriteAfterEventError({
2809
+ taskId: input.taskId,
2810
+ eventId,
2811
+ sessionId: adHoc.sessionId,
2812
+ phase: "archive",
2813
+ cause: error
2814
+ });
3037
2815
  }
3038
- return matches[0];
2816
+ await safeUpdateTaskIndex(input.paths, { kind: "remove", id: input.taskId });
2817
+ return {
2818
+ taskId: input.taskId,
2819
+ title,
2820
+ sessionId: adHoc.sessionId,
2821
+ eventId
2822
+ };
3039
2823
  }
3040
2824
 
3041
2825
  // src/handoff/handoff-renderer.ts
3042
- import { join as join12 } from "path";
3043
2826
  async function renderHandoff(input) {
3044
2827
  const limit = input.relatedFilesLimit ?? 20;
3045
2828
  const now = new Date(input.nowIso);
@@ -3055,7 +2838,7 @@ async function renderHandoff(input) {
3055
2838
  const tasksCreated = [];
3056
2839
  const tasksStatusChanged = [];
3057
2840
  for (const entry of entries) {
3058
- const sessionDir = join12(input.paths.sessions, entry.sessionId);
2841
+ const sessionDir = join10(input.paths.sessions, entry.sessionId);
3059
2842
  try {
3060
2843
  for await (const ev of replayEvents(sessionDir, {
3061
2844
  onWarning: (w) => input.onWarning?.(w, entry.sessionId)
@@ -3232,199 +3015,169 @@ function formatHandoffBody(args) {
3232
3015
  if (args.pendingTasks.length === 0) {
3233
3016
  lines.push("(no pending tasks)");
3234
3017
  } 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(", ")}`);
3018
+ for (const t of args.pendingTasks) {
3019
+ lines.push(`- ${t.task.task.id} (${t.task.task.status}): ${t.task.task.title}`);
3020
+ }
3021
+ }
3022
+ lines.push("");
3023
+ const liveTableEntries = args.entries.filter((e) => e.session.session.source.kind !== "import");
3024
+ const importedTableEntries = args.entries.filter(
3025
+ (e) => e.session.session.source.kind === "import"
3026
+ );
3027
+ lines.push("## \u30BB\u30C3\u30B7\u30E7\u30F3\u4E00\u89A7");
3028
+ lines.push("");
3029
+ if (args.entries.length === 0) {
3030
+ lines.push("(no sessions yet)");
3031
+ } else if (liveTableEntries.length === 0) {
3032
+ lines.push("(no live sessions; see Imported sessions below)");
3033
+ } else {
3034
+ lines.push("| short_id | status | started_at | label |");
3035
+ lines.push("|---|---|---|---|");
3036
+ for (const e of [...liveTableEntries].reverse()) {
3037
+ const sid = shortHandoffId(e.sessionId);
3038
+ const status = e.session.session.status + suspectLabel(e.suspectReason);
3039
+ const startedAt = e.session.session.started_at;
3040
+ const label = e.session.session.label ?? "";
3041
+ lines.push(`| ${sid} | ${status} | ${startedAt} | ${label} |`);
3415
3042
  }
3043
+ }
3044
+ if (importedTableEntries.length > 0) {
3045
+ lines.push("");
3046
+ lines.push("### Imported sessions");
3416
3047
  lines.push("");
3048
+ lines.push("| short_id | status | started_at | label |");
3049
+ lines.push("|---|---|---|---|");
3050
+ for (const e of [...importedTableEntries].reverse()) {
3051
+ const sid = shortHandoffId(e.sessionId);
3052
+ const status = e.session.session.status + suspectLabel(e.suspectReason);
3053
+ const startedAt = e.session.session.started_at;
3054
+ const label = e.session.session.label ?? "";
3055
+ lines.push(`| ${sid} | ${status} | ${startedAt} | ${label} |`);
3056
+ }
3057
+ }
3058
+ lines.push("");
3059
+ const statusCounts = /* @__PURE__ */ new Map();
3060
+ for (const e of args.entries) {
3061
+ const s = e.session.session.status;
3062
+ statusCounts.set(s, (statusCounts.get(s) ?? 0) + 1);
3417
3063
  }
3064
+ const orderedStatuses = [
3065
+ "completed",
3066
+ "failed",
3067
+ "running",
3068
+ "interrupted",
3069
+ "waiting_approval",
3070
+ "initialized",
3071
+ "imported"
3072
+ ];
3073
+ const breakdown = orderedStatuses.filter((s) => (statusCounts.get(s) ?? 0) > 0).map((s) => `${s} ${statusCounts.get(s)}`).join(", ");
3074
+ const sessionsLine = breakdown !== "" ? `Sessions: ${args.sessionCount} (${breakdown}). Tasks: ${args.totalTaskCount}.` : `Sessions: ${args.sessionCount}. Tasks: ${args.totalTaskCount}.`;
3075
+ lines.push(sessionsLine);
3418
3076
  return lines.join("\n");
3419
3077
  }
3420
- function shortDecisionSessionId(sessionId) {
3078
+ function suspectLabel(reason) {
3079
+ if (reason === "events_say_ended_but_yaml_running") return " \u26A0 ended (yaml stale)";
3080
+ if (reason === "running_no_end_event") return " \u26A0 no end event";
3081
+ return "";
3082
+ }
3083
+ function shortHandoffId(sessionId) {
3421
3084
  const SES = "ses_";
3422
3085
  if (sessionId.startsWith(SES)) return sessionId.slice(SES.length, SES.length + 10);
3423
3086
  return sessionId.slice(0, 10);
3424
3087
  }
3425
3088
 
3089
+ // src/lib/duration.ts
3090
+ var DURATION_RE = /^([1-9]\d*)(ms|s|m|h)$/;
3091
+ function parseDuration(input) {
3092
+ const trimmed = input.trim();
3093
+ const match = DURATION_RE.exec(trimmed);
3094
+ if (!match) {
3095
+ throw new Error(
3096
+ `Invalid duration: ${trimmed}. Expected format: <positive-integer><unit> where unit is ms/s/m/h`
3097
+ );
3098
+ }
3099
+ const value = Number(match[1]);
3100
+ const unit = match[2];
3101
+ let ms;
3102
+ switch (unit) {
3103
+ case "ms":
3104
+ ms = value;
3105
+ break;
3106
+ case "s":
3107
+ ms = value * 1e3;
3108
+ break;
3109
+ case "m":
3110
+ ms = value * 6e4;
3111
+ break;
3112
+ case "h":
3113
+ ms = value * 36e5;
3114
+ break;
3115
+ default:
3116
+ throw new Error(`Invalid duration unit: ${unit}`);
3117
+ }
3118
+ if (!Number.isFinite(ms)) {
3119
+ throw new Error(`Duration overflow: ${trimmed}`);
3120
+ }
3121
+ return ms;
3122
+ }
3123
+
3124
+ // src/lib/id-resolver.ts
3125
+ async function resolveSessionId(paths, input) {
3126
+ return resolveIdInternal(paths, input, "session");
3127
+ }
3128
+ async function resolveTaskId(paths, input, options = {}) {
3129
+ return resolveIdInternal(paths, input, "task", options);
3130
+ }
3131
+ var KIND_CONFIG = {
3132
+ session: {
3133
+ prefix: "ses_",
3134
+ noun: "session",
3135
+ nounPlural: "sessions",
3136
+ capNoun: "Session",
3137
+ enumerate: enumerateSessionDirs
3138
+ },
3139
+ task: {
3140
+ prefix: "task_",
3141
+ noun: "task",
3142
+ nounPlural: "tasks",
3143
+ capNoun: "Task",
3144
+ enumerate: enumerateTaskIds
3145
+ }
3146
+ };
3147
+ async function resolveIdInternal(paths, input, kind, options = {}) {
3148
+ const cfg = KIND_CONFIG[kind];
3149
+ const trimmed = input.trim();
3150
+ if (trimmed.length === 0) {
3151
+ throw new Error(`${cfg.capNoun} id is empty`);
3152
+ }
3153
+ const normalized = trimmed.startsWith(cfg.prefix) ? trimmed : `${cfg.prefix}${trimmed}`;
3154
+ if (normalized.length <= cfg.prefix.length) {
3155
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3156
+ }
3157
+ const primary = await cfg.enumerate(paths);
3158
+ const merged = new Set(primary);
3159
+ if (kind === "task" && options.includeArchived === true) {
3160
+ for (const id of await enumerateArchivedTaskIds(paths)) {
3161
+ merged.add(id);
3162
+ }
3163
+ }
3164
+ if (merged.size === 0) {
3165
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3166
+ }
3167
+ const matches = [...merged].filter((e) => e.startsWith(normalized));
3168
+ if (matches.length === 0) {
3169
+ throw new Error(`${cfg.capNoun} not found: ${input}`);
3170
+ }
3171
+ if (matches.length > 1) {
3172
+ throw new Error(
3173
+ `Ambiguous ${cfg.noun} id '${input}': matched ${matches.length} ${cfg.nounPlural}. Disambiguate with a longer prefix.`
3174
+ );
3175
+ }
3176
+ return matches[0];
3177
+ }
3178
+
3426
3179
  // src/runtime/child-process-runner.ts
3427
- import { spawn } from "child_process";
3180
+ import { spawn as spawn2 } from "child_process";
3428
3181
  var DEFAULT_KILL_GRACE_MS = 5e3;
3429
3182
  var ChildProcessRunner = class {
3430
3183
  async run(command, args, options) {
@@ -3441,7 +3194,7 @@ var ChildProcessRunner = class {
3441
3194
  const started_at = /* @__PURE__ */ new Date();
3442
3195
  let child;
3443
3196
  try {
3444
- child = spawn(snapshotCommand, [...snapshotArgs], {
3197
+ child = spawn2(snapshotCommand, [...snapshotArgs], {
3445
3198
  cwd: snapshotCwd,
3446
3199
  env: options.env ?? process.env,
3447
3200
  stdio: captureMode === "none" ? ["inherit", "inherit", "inherit"] : ["pipe", "pipe", "pipe"],
@@ -3531,255 +3284,518 @@ var ChildProcessRunner = class {
3531
3284
  });
3532
3285
  });
3533
3286
  }
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");
3287
+ };
3288
+ function validateOptions(options) {
3289
+ if (options.timeout_ms !== void 0 && (!Number.isFinite(options.timeout_ms) || options.timeout_ms <= 0)) {
3290
+ throw new Error("Invalid timeout_ms");
3291
+ }
3292
+ if (options.capture === "none" && options.stdin !== void 0) {
3293
+ throw new Error('Combination of capture: "none" and stdin is not supported');
3294
+ }
3295
+ }
3296
+ function classifySpawnError(error) {
3297
+ if (findErrorCode(error, "ENOENT")) {
3298
+ return new Error("Command not found", { cause: error });
3299
+ }
3300
+ return new Error("Failed to spawn child process", { cause: error });
3301
+ }
3302
+
3303
+ // src/schemas/manifest.schema.ts
3304
+ import { z as z9 } from "zod";
3305
+ var ProjectSchema = z9.object({
3306
+ name: z9.string().optional(),
3307
+ description: z9.string().optional(),
3308
+ repository_url: z9.string().nullable().optional()
3309
+ });
3310
+ var CapabilitiesSchema = z9.object({
3311
+ enabled: z9.array(z9.string())
3312
+ });
3313
+ var ApprovalConfigSchema = z9.object({
3314
+ required_for: z9.array(z9.string()).optional(),
3315
+ default_risk_level: z9.enum(["low", "medium", "high", "critical"])
3316
+ });
3317
+ var ClaudeCodeAdapterConfigSchema = z9.object({
3318
+ enabled: z9.boolean(),
3319
+ config_path: z9.string().optional()
3320
+ });
3321
+ var AdaptersSchema = z9.object({
3322
+ "claude-code": ClaudeCodeAdapterConfigSchema
3323
+ });
3324
+ var GitConfigSchema = z9.object({
3325
+ events_log: z9.enum(["ignore", "commit"]).default("ignore")
3326
+ });
3327
+ var WorkspaceMetaSchema = z9.object({
3328
+ id: WorkspaceIdSchema,
3329
+ name: z9.string().min(1),
3330
+ created_at: IsoTimestampSchema,
3331
+ updated_at: IsoTimestampSchema
3332
+ });
3333
+ var ManifestSchema = z9.object({
3334
+ schema_version: SchemaVersionSchema,
3335
+ basou_version: z9.literal("0.1.0"),
3336
+ workspace: WorkspaceMetaSchema,
3337
+ project: ProjectSchema,
3338
+ capabilities: CapabilitiesSchema,
3339
+ approval: ApprovalConfigSchema,
3340
+ adapters: AdaptersSchema,
3341
+ git: GitConfigSchema
3342
+ });
3343
+
3344
+ // src/schemas/session-import.schema.ts
3345
+ import { z as z10 } from "zod";
3346
+ var SessionInnerImportSchema = z10.object({
3347
+ id: SessionIdSchema.optional(),
3348
+ label: z10.string().optional(),
3349
+ task_id: TaskIdSchema.nullable().optional(),
3350
+ workspace_id: WorkspaceIdSchema,
3351
+ source: z10.object({
3352
+ kind: SessionSourceKindSchema,
3353
+ version: z10.literal("0.1.0")
3354
+ }),
3355
+ started_at: IsoTimestampSchema,
3356
+ ended_at: IsoTimestampSchema.optional(),
3357
+ status: SessionStatusSchema,
3358
+ working_directory: z10.string().min(1),
3359
+ invocation: z10.object({
3360
+ command: z10.string().min(1),
3361
+ args: z10.array(z10.string()),
3362
+ exit_code: z10.number().int().nullable()
3363
+ }),
3364
+ related_files: z10.array(z10.string()).default([]),
3365
+ events_log: z10.string().optional(),
3366
+ summary: z10.string().nullable().optional()
3367
+ }).strict();
3368
+ var SessionImportPayloadSchema = z10.object({
3369
+ schema_version: z10.string(),
3370
+ session: SessionInnerImportSchema,
3371
+ events: z10.array(EventSchema)
3372
+ }).strict();
3373
+
3374
+ // src/storage/basou-dir.ts
3375
+ import { lstat as lstat3, mkdir as mkdir3 } from "fs/promises";
3376
+ import { join as join11 } from "path";
3377
+ function basouPaths(repositoryRoot) {
3378
+ const root = join11(repositoryRoot, ".basou");
3379
+ const approvalsBase = join11(root, "approvals");
3380
+ return {
3381
+ root,
3382
+ sessions: join11(root, "sessions"),
3383
+ tasks: join11(root, "tasks"),
3384
+ approvals: {
3385
+ pending: join11(approvalsBase, "pending"),
3386
+ resolved: join11(approvalsBase, "resolved")
3387
+ },
3388
+ locks: join11(root, "locks"),
3389
+ logs: join11(root, "logs"),
3390
+ raw: join11(root, "raw"),
3391
+ tmp: join11(root, "tmp"),
3392
+ files: {
3393
+ manifest: join11(root, "manifest.yaml"),
3394
+ status: join11(root, "status.json"),
3395
+ handoff: join11(root, "handoff.md"),
3396
+ decisions: join11(root, "decisions.md")
3397
+ }
3398
+ };
3399
+ }
3400
+ var PATH_LABELS = {
3401
+ sessions: ".basou/sessions",
3402
+ tasks: ".basou/tasks",
3403
+ approvalsPending: ".basou/approvals/pending",
3404
+ approvalsResolved: ".basou/approvals/resolved",
3405
+ locks: ".basou/locks",
3406
+ logs: ".basou/logs",
3407
+ raw: ".basou/raw",
3408
+ tmp: ".basou/tmp"
3409
+ };
3410
+ async function ensureBasouDirectory(repositoryRoot) {
3411
+ const paths = basouPaths(repositoryRoot);
3412
+ let existing;
3413
+ try {
3414
+ existing = await lstat3(paths.root);
3415
+ } catch (error) {
3416
+ if (!hasErrorCode3(error) || error.code !== "ENOENT") {
3417
+ throw new Error("Failed to inspect .basou directory", { cause: error });
3418
+ }
3419
+ }
3420
+ if (existing !== void 0 && !existing.isDirectory()) {
3421
+ throw new Error("Basou root .basou exists but is not a directory");
3422
+ }
3423
+ await Promise.all([
3424
+ mkdirLabeled(paths.sessions, PATH_LABELS.sessions),
3425
+ mkdirLabeled(paths.tasks, PATH_LABELS.tasks),
3426
+ mkdirLabeled(paths.approvals.pending, PATH_LABELS.approvalsPending),
3427
+ mkdirLabeled(paths.approvals.resolved, PATH_LABELS.approvalsResolved),
3428
+ mkdirLabeled(paths.locks, PATH_LABELS.locks),
3429
+ mkdirLabeled(paths.logs, PATH_LABELS.logs),
3430
+ mkdirLabeled(paths.raw, PATH_LABELS.raw),
3431
+ mkdirLabeled(paths.tmp, PATH_LABELS.tmp)
3432
+ ]);
3433
+ return paths;
3434
+ }
3435
+ async function mkdirLabeled(target, label) {
3436
+ try {
3437
+ await mkdir3(target, { recursive: true });
3438
+ } catch (error) {
3439
+ if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
3440
+ throw new Error(`${label} exists but is not a directory`, { cause: error });
3441
+ }
3442
+ throw new Error(`Failed to create ${label}`, { cause: error });
3443
+ }
3444
+ }
3445
+ function hasErrorCode3(error) {
3446
+ if (!(error instanceof Error)) return false;
3447
+ const codeProp = error.code;
3448
+ return typeof codeProp === "string";
3449
+ }
3450
+
3451
+ // src/storage/gitignore.ts
3452
+ import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
3453
+ import { join as join12 } from "path";
3454
+ var MARKER = "# Basou - default ignore";
3455
+ 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";
3456
+ async function appendBasouGitignore(repositoryRoot) {
3457
+ const gitignorePath = join12(repositoryRoot, ".gitignore");
3458
+ let body;
3459
+ let existed;
3460
+ try {
3461
+ body = await readFile6(gitignorePath, "utf8");
3462
+ existed = true;
3463
+ } catch (error) {
3464
+ if (hasErrorCode4(error) && error.code === "ENOENT") {
3465
+ body = "";
3466
+ existed = false;
3467
+ } else {
3468
+ throw new Error("Failed to read .gitignore", { cause: error });
3469
+ }
3538
3470
  }
3539
- if (options.capture === "none" && options.stdin !== void 0) {
3540
- throw new Error('Combination of capture: "none" and stdin is not supported');
3471
+ if (existed && hasBasouMarker(body)) {
3472
+ return { appended: false };
3473
+ }
3474
+ const next = composeNextBody(body);
3475
+ try {
3476
+ await writeFile2(gitignorePath, next, { encoding: "utf8" });
3477
+ } catch (error) {
3478
+ throw new Error("Failed to write .gitignore", { cause: error });
3541
3479
  }
3480
+ return { appended: true };
3542
3481
  }
3543
- function classifySpawnError(error) {
3544
- if (findErrorCode(error, "ENOENT")) {
3545
- return new Error("Command not found", { cause: error });
3482
+ function hasBasouMarker(body) {
3483
+ for (const rawLine of body.split("\n")) {
3484
+ if (rawLine.trimEnd().startsWith(MARKER)) return true;
3546
3485
  }
3547
- return new Error("Failed to spawn child process", { cause: error });
3486
+ return false;
3548
3487
  }
3549
-
3550
- // src/git/snapshot.ts
3551
- import { simpleGit } from "simple-git";
3552
- function safeSimpleGit(repoRoot) {
3553
- return simpleGit({ baseDir: repoRoot });
3488
+ function composeNextBody(existing) {
3489
+ if (existing.length === 0) return BASOU_GITIGNORE_BLOCK;
3490
+ const normalized = existing.endsWith("\n") ? existing : `${existing}
3491
+ `;
3492
+ return `${normalized}
3493
+ ${BASOU_GITIGNORE_BLOCK}`;
3554
3494
  }
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;
3495
+ function hasErrorCode4(error) {
3496
+ if (!(error instanceof Error)) return false;
3497
+ return typeof error.code === "string";
3498
+ }
3499
+
3500
+ // src/storage/manifest.ts
3501
+ import { lstat as lstat4 } from "fs/promises";
3502
+ function createManifest(input) {
3503
+ if (input.workspaceName.length === 0) {
3504
+ throw new Error("Workspace name is empty. Pass --name explicitly.");
3561
3505
  }
3562
- return false;
3506
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
3507
+ const workspaceId = input.workspaceId ?? prefixedUlid("ws");
3508
+ const project = {
3509
+ ...input.projectName !== void 0 ? { name: input.projectName } : {},
3510
+ ...input.projectDescription !== void 0 ? { description: input.projectDescription } : {},
3511
+ ...input.repositoryUrl !== void 0 ? { repository_url: input.repositoryUrl } : {}
3512
+ };
3513
+ const manifest = {
3514
+ schema_version: "0.1.0",
3515
+ basou_version: "0.1.0",
3516
+ workspace: {
3517
+ id: workspaceId,
3518
+ name: input.workspaceName,
3519
+ created_at: now,
3520
+ updated_at: now
3521
+ },
3522
+ project,
3523
+ capabilities: {
3524
+ enabled: ["core", "claude-code-adapter", "terminal-recording", "git-capability", "approval"]
3525
+ },
3526
+ approval: {
3527
+ required_for: ["destructive_command", "external_send"],
3528
+ default_risk_level: "medium"
3529
+ },
3530
+ adapters: {
3531
+ "claude-code": { enabled: true }
3532
+ },
3533
+ git: { events_log: "ignore" }
3534
+ };
3535
+ return ManifestSchema.parse(manifest);
3563
3536
  }
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 });
3537
+ async function writeManifest(paths, manifest, options) {
3538
+ const force = options?.force === true;
3539
+ const validated = ManifestSchema.parse(manifest);
3540
+ if (!force) {
3541
+ let existed = false;
3542
+ try {
3543
+ await lstat4(paths.files.manifest);
3544
+ existed = true;
3545
+ } catch (error) {
3546
+ if (!hasErrorCode5(error) || error.code !== "ENOENT") {
3547
+ throw new Error("Failed to inspect existing manifest", { cause: error });
3548
+ }
3575
3549
  }
3576
- if (error instanceof Error && error.message === "Not a git repository") {
3577
- throw error;
3550
+ if (existed) {
3551
+ throw new Error("Already initialized. Use --force to overwrite.");
3578
3552
  }
3579
- throw new Error("Not a git repository", { cause: error });
3580
3553
  }
3554
+ await writeYamlFile(paths.files.manifest, validated);
3581
3555
  }
3582
- async function tryRemoteUrl(repositoryRoot) {
3583
- const git = safeSimpleGit(repositoryRoot);
3556
+ async function readManifest(paths) {
3557
+ const raw = await readYamlFile(paths.files.manifest);
3558
+ return ManifestSchema.parse(raw);
3559
+ }
3560
+ function hasErrorCode5(error) {
3561
+ if (!(error instanceof Error)) return false;
3562
+ return typeof error.code === "string";
3563
+ }
3564
+
3565
+ // src/storage/markdown-store.ts
3566
+ import { readFile as readFile7 } from "fs/promises";
3567
+ var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
3568
+ var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
3569
+ async function readMarkdownFile(filePath) {
3584
3570
  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;
3571
+ return await readFile7(filePath, "utf8");
3572
+ } catch (error) {
3573
+ if (hasErrorCode6(error) && error.code === "ENOENT") return null;
3574
+ throw new Error("Failed to read markdown file", { cause: error });
3590
3575
  }
3591
3576
  }
3592
- async function getSnapshot(repositoryRoot) {
3593
- const git = safeSimpleGit(repositoryRoot);
3594
- let inside;
3577
+ async function writeMarkdownFile(filePath, body) {
3595
3578
  try {
3596
- inside = await git.checkIsRepo();
3579
+ await atomicReplace(filePath, body);
3597
3580
  } catch (error) {
3598
- if (isGitNotFound(error)) {
3599
- throw new Error("Git executable not found in PATH. Install git first.", { cause: error });
3581
+ throw new Error("Failed to write markdown file", { cause: error });
3582
+ }
3583
+ }
3584
+ function parseMarkers(content) {
3585
+ const lines = content.split(/\r?\n/);
3586
+ const startLines = [];
3587
+ const endLines = [];
3588
+ for (let i = 0; i < lines.length; i++) {
3589
+ if (lines[i] === GENERATED_START) startLines.push(i);
3590
+ else if (lines[i] === GENERATED_END) endLines.push(i);
3591
+ }
3592
+ if (startLines.length === 0 && endLines.length === 0) return { kind: "no_markers" };
3593
+ if (startLines.length === 0) return { kind: "missing_start" };
3594
+ if (endLines.length === 0) return { kind: "missing_end" };
3595
+ if (startLines.length >= 2 || endLines.length >= 2) return { kind: "multiple_pairs" };
3596
+ const startLineIdx = startLines[0];
3597
+ const endLineIdx = endLines[0];
3598
+ if (endLineIdx < startLineIdx) return { kind: "wrong_order" };
3599
+ const startOffset = lineStartOffset(content, startLineIdx);
3600
+ const endLineStart = lineStartOffset(content, endLineIdx);
3601
+ const startLineEnd = startOffset + GENERATED_START.length;
3602
+ const endLineEnd = endLineStart + GENERATED_END.length;
3603
+ const before = content.slice(0, startOffset);
3604
+ const afterStartNewline = skipOneNewline(content, startLineEnd);
3605
+ const beforeEndNewline = trimOneNewline(content, endLineStart);
3606
+ const generated = content.slice(afterStartNewline, beforeEndNewline);
3607
+ const after = content.slice(endLineEnd);
3608
+ return { kind: "ok", before, generated, after };
3609
+ }
3610
+ function renderWithMarkers(existing, generated, fileLabel) {
3611
+ const normalized = generated.endsWith("\n") ? generated : `${generated}
3612
+ `;
3613
+ if (existing === null) {
3614
+ return `${GENERATED_START}
3615
+ ${normalized}${GENERATED_END}
3616
+ `;
3617
+ }
3618
+ const section = parseMarkers(existing);
3619
+ switch (section.kind) {
3620
+ case "ok":
3621
+ return `${section.before}${GENERATED_START}
3622
+ ${normalized}${GENERATED_END}${section.after}`;
3623
+ case "no_markers":
3624
+ throw new Error(`Markers missing in ${fileLabel}`);
3625
+ case "missing_start":
3626
+ case "missing_end":
3627
+ case "multiple_pairs":
3628
+ case "wrong_order":
3629
+ throw new Error(`Markers mismatched in ${fileLabel}`);
3630
+ }
3631
+ }
3632
+ function lineStartOffset(content, lineIdx) {
3633
+ if (lineIdx === 0) return 0;
3634
+ let offset = 0;
3635
+ let line = 0;
3636
+ while (offset < content.length && line < lineIdx) {
3637
+ const ch = content[offset];
3638
+ if (ch === "\n") {
3639
+ line += 1;
3640
+ offset += 1;
3641
+ } else if (ch === "\r") {
3642
+ offset += 1;
3643
+ if (content[offset] === "\n") offset += 1;
3644
+ line += 1;
3645
+ } else {
3646
+ offset += 1;
3600
3647
  }
3601
- throw new Error("Failed to read git state", { cause: error });
3602
3648
  }
3603
- if (!inside) {
3604
- throw new Error("Not a git repository");
3649
+ return offset;
3650
+ }
3651
+ function skipOneNewline(content, offset) {
3652
+ if (content[offset] === "\r" && content[offset + 1] === "\n") return offset + 2;
3653
+ if (content[offset] === "\n") return offset + 1;
3654
+ return offset;
3655
+ }
3656
+ function trimOneNewline(content, offset) {
3657
+ if (offset >= 2 && content[offset - 2] === "\r" && content[offset - 1] === "\n")
3658
+ return offset - 2;
3659
+ if (offset >= 1 && content[offset - 1] === "\n") return offset - 1;
3660
+ return offset;
3661
+ }
3662
+ function hasErrorCode6(error) {
3663
+ if (!(error instanceof Error)) return false;
3664
+ const codeProp = error.code;
3665
+ return typeof codeProp === "string";
3666
+ }
3667
+
3668
+ // src/storage/session-import.ts
3669
+ import { mkdir as mkdir4, rm as rm2 } from "fs/promises";
3670
+ import { homedir as homedir2 } from "os";
3671
+ import { join as join13 } from "path";
3672
+ async function importSessionFromJson(paths, manifest, payload, options) {
3673
+ if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
3674
+ throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
3675
+ }
3676
+ const effectiveSessionTaskId = options.taskIdOverride ?? payload.session.task_id ?? null;
3677
+ await assertImportedTaskReferencesAreReachable(paths, payload.events, effectiveSessionTaskId);
3678
+ const newSessionId = prefixedUlid("ses");
3679
+ const rewrittenEvents = rewriteEvents(payload.events, newSessionId);
3680
+ assertChronologicalOrder(rewrittenEvents);
3681
+ const { record: sessionRecord, pathSanitizeReport } = buildSessionRecord(
3682
+ payload.session,
3683
+ manifest,
3684
+ newSessionId,
3685
+ options
3686
+ );
3687
+ if (options.dryRun === true) {
3688
+ return {
3689
+ sessionId: newSessionId,
3690
+ eventCount: rewrittenEvents.length,
3691
+ finalStatus: "imported",
3692
+ finalSourceKind: sessionRecord.session.source.kind,
3693
+ pathSanitizeReport
3694
+ };
3605
3695
  }
3606
- let head;
3696
+ const sessionDir = join13(paths.sessions, newSessionId);
3607
3697
  try {
3608
- head = (await git.revparse(["HEAD"])).trimEnd();
3698
+ await mkdir4(sessionDir, { recursive: true });
3609
3699
  } 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");
3700
+ throw new Error("Failed to create session directory", { cause: error });
3617
3701
  }
3618
- let branch;
3619
3702
  try {
3620
- const raw = (await git.raw(["branch", "--show-current"])).trimEnd();
3621
- branch = raw.length > 0 ? raw : "HEAD";
3703
+ await writeEventsBulk(sessionDir, rewrittenEvents);
3622
3704
  } catch (error) {
3623
- throw new Error("Failed to read git state", { cause: error });
3705
+ await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
3706
+ throw error;
3624
3707
  }
3625
- let dirty;
3626
- const staged = [];
3627
- const unstaged = [];
3628
- const untracked = [];
3629
3708
  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
- }
3709
+ const sessionYamlPath = join13(sessionDir, "session.yaml");
3710
+ await linkYamlFile(sessionYamlPath, sessionRecord);
3640
3711
  } 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 {
3712
+ await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
3713
+ if (findErrorCode(error, "EEXIST")) {
3714
+ throw new Error("Session directory collision (retry the command)", {
3715
+ cause: error
3716
+ });
3655
3717
  }
3718
+ throw error;
3656
3719
  }
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 } : {}
3720
+ return {
3721
+ sessionId: newSessionId,
3722
+ eventCount: rewrittenEvents.length,
3723
+ finalStatus: "imported",
3724
+ finalSourceKind: sessionRecord.session.source.kind,
3725
+ pathSanitizeReport
3666
3726
  };
3667
- return snapshot;
3668
3727
  }
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 });
3728
+ async function assertImportedTaskReferencesAreReachable(paths, events, effectiveSessionTaskId) {
3729
+ const taskIdsToCheck = /* @__PURE__ */ new Set();
3730
+ for (const ev of events) {
3731
+ 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") {
3732
+ taskIdsToCheck.add(ev.task_id);
3678
3733
  }
3679
- throw new Error("Not a git repository", { cause: error });
3680
3734
  }
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 });
3735
+ if (effectiveSessionTaskId !== null) {
3736
+ taskIdsToCheck.add(effectiveSessionTaskId);
3697
3737
  }
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" });
3738
+ if (taskIdsToCheck.size === 0) {
3739
+ return;
3740
+ }
3741
+ const knownTaskIds = new Set(await enumerateTaskIds(paths));
3742
+ for (const id of taskIdsToCheck) {
3743
+ if (!knownTaskIds.has(id)) {
3744
+ throw new Error("Imported session references unknown task_id");
3722
3745
  }
3723
3746
  }
3724
- return changes;
3725
3747
  }
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 };
3748
+ function rewriteEvents(events, newSessionId) {
3749
+ return events.map((event) => ({
3750
+ ...event,
3751
+ id: prefixedUlid("evt"),
3752
+ session_id: newSessionId
3753
+ }));
3754
+ }
3755
+ function assertChronologicalOrder(events) {
3756
+ for (let i = 1; i < events.length; i++) {
3757
+ const prevEvent = events[i - 1];
3758
+ const currEvent = events[i];
3759
+ if (prevEvent === void 0 || currEvent === void 0) continue;
3760
+ const prev = Date.parse(prevEvent.occurred_at);
3761
+ const curr = Date.parse(currEvent.occurred_at);
3762
+ if (!Number.isFinite(prev) || !Number.isFinite(curr) || curr < prev) {
3763
+ throw new Error("Events are not in chronological order");
3764
+ }
3736
3765
  }
3737
- throw new Error("Claude Code CLI not found in PATH. Install claude-code (or claude) first.");
3738
3766
  }
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));
3767
+ function buildSessionRecord(input, manifest, newSessionId, options) {
3768
+ const home = homedir2();
3769
+ const workingDirectoryRaw = input.working_directory;
3770
+ const workingDirectorySanitized = sanitizeWorkingDirectory(workingDirectoryRaw, {
3771
+ homedir: home
3744
3772
  });
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;
3773
+ const relatedSanitized = sanitizeRelatedFiles(input.related_files, {
3774
+ workingDirectory: workingDirectoryRaw,
3775
+ homedir: home
3776
+ });
3777
+ const inner = {
3778
+ id: newSessionId,
3779
+ ...options.labelOverride !== void 0 || input.label !== void 0 ? { label: options.labelOverride ?? input.label } : {},
3780
+ task_id: options.taskIdOverride !== void 0 ? options.taskIdOverride : input.task_id ?? null,
3781
+ workspace_id: manifest.workspace.id,
3782
+ source: input.source,
3783
+ started_at: input.started_at,
3784
+ ...input.ended_at !== void 0 ? { ended_at: input.ended_at } : {},
3785
+ status: "imported",
3786
+ working_directory: workingDirectorySanitized,
3787
+ invocation: input.invocation,
3788
+ related_files: relatedSanitized.sanitized,
3789
+ events_log: "events.jsonl",
3790
+ summary: input.summary ?? null
3791
+ };
3792
+ return {
3793
+ record: { schema_version: "0.1.0", session: inner },
3794
+ pathSanitizeReport: {
3795
+ relatedFiles: relatedSanitized.mutationCount,
3796
+ workingDirectoryRewritten: workingDirectorySanitized !== workingDirectoryRaw
3797
+ }
3798
+ };
3783
3799
  }
3784
3800
 
3785
3801
  // src/index.ts