@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.d.ts +1419 -1418
- package/dist/index.js +1658 -1642
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
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
|
-
|
|
209
|
-
var
|
|
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:
|
|
218
|
-
reason:
|
|
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:
|
|
101
|
+
resolver: z2.string().nullable().default(null),
|
|
224
102
|
resolved_at: IsoTimestampSchema.nullable().default(null),
|
|
225
|
-
note:
|
|
226
|
-
rejection_reason:
|
|
103
|
+
note: z2.string().nullable().default(null),
|
|
104
|
+
rejection_reason: z2.string().nullable().default(null)
|
|
227
105
|
});
|
|
228
106
|
|
|
229
|
-
// src/
|
|
230
|
-
import {
|
|
231
|
-
|
|
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/
|
|
557
|
-
import { lstat
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
409
|
+
stream = createReadStream(filePath, { encoding: "utf8" });
|
|
597
410
|
} catch (error) {
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
} catch (
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
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/
|
|
634
|
-
import {
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
709
|
-
|
|
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 (
|
|
712
|
-
|
|
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
|
-
|
|
537
|
+
}
|
|
538
|
+
async function readSessionYaml(paths, sessionId) {
|
|
539
|
+
const filePath = join3(paths.sessions, sessionId, "session.yaml");
|
|
540
|
+
let raw;
|
|
722
541
|
try {
|
|
723
|
-
await
|
|
542
|
+
raw = await readYamlFile(filePath);
|
|
724
543
|
} catch (error) {
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
551
|
+
return result.data;
|
|
734
552
|
}
|
|
735
|
-
function
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
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/
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
async function
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
return true;
|
|
668
|
+
fileExistenceCache.set(relPath, exists);
|
|
669
|
+
return exists;
|
|
794
670
|
}
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
return
|
|
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/
|
|
814
|
-
import {
|
|
729
|
+
// src/events/event-writer.ts
|
|
730
|
+
import { appendFile } from "fs/promises";
|
|
815
731
|
import { join as join5 } from "path";
|
|
816
|
-
function
|
|
817
|
-
|
|
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
|
-
|
|
735
|
+
validated = EventSchema.parse(event);
|
|
824
736
|
} catch (error) {
|
|
825
|
-
|
|
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
|
-
|
|
739
|
+
const line = `${JSON.stringify(validated)}
|
|
740
|
+
`;
|
|
831
741
|
try {
|
|
832
|
-
|
|
742
|
+
await appendFile(join5(sessionDir, "events.jsonl"), line, "utf8");
|
|
833
743
|
} catch (error) {
|
|
834
|
-
throw new 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
|
|
860
|
-
const
|
|
861
|
-
let current;
|
|
747
|
+
async function writeEventsBulk(sessionDir, events) {
|
|
748
|
+
const validated = [];
|
|
862
749
|
try {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
|
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/
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
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
|
|
896
|
+
async function resolveRepositoryRoot(cwd) {
|
|
897
|
+
const git = safeSimpleGit(cwd);
|
|
1156
898
|
try {
|
|
1157
|
-
await
|
|
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
|
-
|
|
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
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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("
|
|
955
|
+
throw new Error("Failed to read git state", { cause: error });
|
|
1260
956
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
957
|
+
let dirty;
|
|
958
|
+
const staged = [];
|
|
959
|
+
const unstaged = [];
|
|
960
|
+
const untracked = [];
|
|
1263
961
|
try {
|
|
1264
|
-
|
|
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
|
|
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
|
-
|
|
1270
|
-
|
|
1001
|
+
|
|
1002
|
+
// src/git/diff.ts
|
|
1003
|
+
async function getDiff(repoRoot, baseRef, headRef) {
|
|
1004
|
+
let git;
|
|
1271
1005
|
try {
|
|
1272
|
-
|
|
1273
|
-
validated.push(EventSchema.parse(event));
|
|
1274
|
-
}
|
|
1006
|
+
git = safeSimpleGit(repoRoot);
|
|
1275
1007
|
} catch (error) {
|
|
1276
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
1280
|
-
` : "";
|
|
1013
|
+
if (baseRef === headRef) return { changed_files: [] };
|
|
1014
|
+
let raw;
|
|
1281
1015
|
try {
|
|
1282
|
-
await
|
|
1016
|
+
raw = await git.raw(["diff", "--name-status", `${baseRef}..${headRef}`]);
|
|
1283
1017
|
} catch (error) {
|
|
1284
|
-
|
|
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 =
|
|
1189
|
+
const sessionDir = join6(input.paths.sessions, sessionId);
|
|
1377
1190
|
try {
|
|
1378
|
-
await
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1530
|
-
var TaskLabelSchema =
|
|
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 =
|
|
1533
|
+
const filePath = join9(paths.tasks, `${taskId}.md`);
|
|
1565
1534
|
let raw;
|
|
1566
1535
|
try {
|
|
1567
|
-
raw = await
|
|
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 =
|
|
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
|
|
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 =
|
|
1681
|
+
const archiveFilePath = join9(archiveTasksDir(paths), `${taskId}.md`);
|
|
1713
1682
|
let raw;
|
|
1714
1683
|
try {
|
|
1715
|
-
raw = await
|
|
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(
|
|
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 =
|
|
2266
|
-
const [stats, raw] = await Promise.all([stat2(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 =
|
|
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([
|
|
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(
|
|
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
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 (
|
|
3540
|
-
|
|
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
|
|
3544
|
-
|
|
3545
|
-
|
|
3482
|
+
function hasBasouMarker(body) {
|
|
3483
|
+
for (const rawLine of body.split("\n")) {
|
|
3484
|
+
if (rawLine.trimEnd().startsWith(MARKER)) return true;
|
|
3546
3485
|
}
|
|
3547
|
-
return
|
|
3486
|
+
return false;
|
|
3548
3487
|
}
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
return
|
|
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
|
|
3556
|
-
if (
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
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
|
-
|
|
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
|
|
3565
|
-
const
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
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 (
|
|
3577
|
-
throw
|
|
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
|
|
3583
|
-
const
|
|
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
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
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
|
|
3593
|
-
const git = safeSimpleGit(repositoryRoot);
|
|
3594
|
-
let inside;
|
|
3577
|
+
async function writeMarkdownFile(filePath, body) {
|
|
3595
3578
|
try {
|
|
3596
|
-
|
|
3579
|
+
await atomicReplace(filePath, body);
|
|
3597
3580
|
} catch (error) {
|
|
3598
|
-
|
|
3599
|
-
|
|
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
|
-
|
|
3604
|
-
|
|
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
|
-
|
|
3696
|
+
const sessionDir = join13(paths.sessions, newSessionId);
|
|
3607
3697
|
try {
|
|
3608
|
-
|
|
3698
|
+
await mkdir4(sessionDir, { recursive: true });
|
|
3609
3699
|
} catch (error) {
|
|
3610
|
-
|
|
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
|
-
|
|
3621
|
-
branch = raw.length > 0 ? raw : "HEAD";
|
|
3703
|
+
await writeEventsBulk(sessionDir, rewrittenEvents);
|
|
3622
3704
|
} catch (error) {
|
|
3623
|
-
|
|
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
|
|
3631
|
-
|
|
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
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
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
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
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 (
|
|
3682
|
-
|
|
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
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
const
|
|
3702
|
-
const
|
|
3703
|
-
|
|
3704
|
-
|
|
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
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
}
|
|
3733
|
-
|
|
3734
|
-
for (
|
|
3735
|
-
|
|
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
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
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
|
-
|
|
3747
|
-
|
|
3748
|
-
}
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
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
|