@basou/cli 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3594 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/commands/approval.ts
|
|
8
|
+
import { unlink } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import {
|
|
11
|
+
ApprovalSchema,
|
|
12
|
+
ApprovalStatusSchema,
|
|
13
|
+
appendEvent,
|
|
14
|
+
assertBasouRootSafe,
|
|
15
|
+
basouPaths,
|
|
16
|
+
enumerateApprovals,
|
|
17
|
+
findErrorCode,
|
|
18
|
+
isLazyExpired,
|
|
19
|
+
linkYamlFile,
|
|
20
|
+
loadApproval,
|
|
21
|
+
prefixedUlid,
|
|
22
|
+
readYamlFile,
|
|
23
|
+
replayEvents,
|
|
24
|
+
resolveRepositoryRoot
|
|
25
|
+
} from "@basou/core";
|
|
26
|
+
|
|
27
|
+
// src/lib/error-render.ts
|
|
28
|
+
import {
|
|
29
|
+
FailedToFinalizeError
|
|
30
|
+
} from "@basou/core";
|
|
31
|
+
var SES_PREFIX = "ses_";
|
|
32
|
+
var TASK_PREFIX = "task_";
|
|
33
|
+
var SHORT_ID_LEN = 6;
|
|
34
|
+
function shortSessionId(id) {
|
|
35
|
+
if (id.startsWith(SES_PREFIX))
|
|
36
|
+
return id.slice(SES_PREFIX.length, SES_PREFIX.length + SHORT_ID_LEN);
|
|
37
|
+
return id.slice(0, SHORT_ID_LEN);
|
|
38
|
+
}
|
|
39
|
+
function shortTaskId(id) {
|
|
40
|
+
if (id.startsWith(TASK_PREFIX))
|
|
41
|
+
return id.slice(TASK_PREFIX.length, TASK_PREFIX.length + SHORT_ID_LEN);
|
|
42
|
+
return id.slice(0, SHORT_ID_LEN);
|
|
43
|
+
}
|
|
44
|
+
function isVerbose(options) {
|
|
45
|
+
return options?.verbose === true || process.env.BASOU_DEBUG === "1";
|
|
46
|
+
}
|
|
47
|
+
var CAUSE_CHAIN_MAX_DEPTH = 4;
|
|
48
|
+
function extractCauseLabel(error) {
|
|
49
|
+
let current = error.cause;
|
|
50
|
+
let constructorName;
|
|
51
|
+
for (let depth = 0; depth < CAUSE_CHAIN_MAX_DEPTH; depth += 1) {
|
|
52
|
+
if (!(current instanceof Error)) break;
|
|
53
|
+
const code = current.code;
|
|
54
|
+
if (typeof code === "string" && code.length > 0) return code;
|
|
55
|
+
constructorName = current.constructor.name;
|
|
56
|
+
current = current.cause;
|
|
57
|
+
}
|
|
58
|
+
return constructorName;
|
|
59
|
+
}
|
|
60
|
+
var failedToFinalizeClassifier = {
|
|
61
|
+
match: (error) => error instanceof FailedToFinalizeError,
|
|
62
|
+
additionalLines: (error) => {
|
|
63
|
+
const e = error;
|
|
64
|
+
const sid = shortSessionId(e.sessionId);
|
|
65
|
+
const anchor = e.targetEventIds[0];
|
|
66
|
+
return [
|
|
67
|
+
`Recorded ${anchor} in session ${sid}; do not rerun`,
|
|
68
|
+
"Warning: session.yaml status update failed; events.jsonl is consistent"
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function renderCliError(error, options) {
|
|
73
|
+
if (!(error instanceof Error)) {
|
|
74
|
+
console.error(String(error));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.error(error.message);
|
|
78
|
+
for (const classifier of options.classifiers ?? []) {
|
|
79
|
+
if (classifier.match(error)) {
|
|
80
|
+
for (const line of classifier.additionalLines(error)) console.error(line);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (options.verbose) {
|
|
84
|
+
const label = extractCauseLabel(error);
|
|
85
|
+
if (label !== void 0) console.error(`Caused by: ${label}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function printReplayWarning(warning, sessionId) {
|
|
89
|
+
const short = shortSessionId(sessionId);
|
|
90
|
+
switch (warning.kind) {
|
|
91
|
+
case "partial_trailing_line":
|
|
92
|
+
console.error(`Warning: ignored partial trailing line in ${short}/events.jsonl`);
|
|
93
|
+
break;
|
|
94
|
+
case "malformed_json":
|
|
95
|
+
console.error(
|
|
96
|
+
`Warning: skipped malformed JSON at line ${warning.line} in ${short}/events.jsonl`
|
|
97
|
+
);
|
|
98
|
+
break;
|
|
99
|
+
case "schema_violation":
|
|
100
|
+
console.error(
|
|
101
|
+
`Warning: skipped invalid event at line ${warning.line} in ${short}/events.jsonl`
|
|
102
|
+
);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function printSessionSkip(sid, reason) {
|
|
107
|
+
const short = shortSessionId(sid);
|
|
108
|
+
if (reason === "events_jsonl_unreadable") {
|
|
109
|
+
console.error(`Warning: skipped suspect check for ${short}: events.jsonl unreadable`);
|
|
110
|
+
} else {
|
|
111
|
+
console.error(`Skipped ${short}: ${reason}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function printSessionListSkip(sid, reason) {
|
|
115
|
+
const short = shortSessionId(sid);
|
|
116
|
+
switch (reason) {
|
|
117
|
+
case "session_yaml_missing":
|
|
118
|
+
console.error(`Skipped ${short}: session.yaml not found`);
|
|
119
|
+
break;
|
|
120
|
+
case "session_yaml_invalid":
|
|
121
|
+
console.error(`Skipped ${short}: invalid session schema`);
|
|
122
|
+
break;
|
|
123
|
+
case "events_jsonl_unreadable":
|
|
124
|
+
console.error(`Warning: skipped suspect check for ${short}: events.jsonl unreadable`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function printTaskSkip(taskId, reason) {
|
|
129
|
+
console.error(`Skipped ${shortTaskId(taskId)}: ${reason}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/commands/approval.ts
|
|
133
|
+
var APPR_PREFIX = "appr_";
|
|
134
|
+
var SHORT_ID_BASE_LEN = 6;
|
|
135
|
+
var SHORT_ID_MAX_LEN = 26;
|
|
136
|
+
var ACTION_KEY_DETAIL_MAX_LEN = 60;
|
|
137
|
+
var REASON_TEXT_MAX_LEN = 80;
|
|
138
|
+
var STATUS_VALUES = ApprovalStatusSchema.options;
|
|
139
|
+
function registerApprovalCommand(program2) {
|
|
140
|
+
const approval = program2.command("approval").description("Manage Basou approval requests under .basou/approvals/");
|
|
141
|
+
approval.command("list").description("List approvals across pending and resolved (newest first)").option("--json", "Output the list as a JSON array").option(
|
|
142
|
+
"--status <state>",
|
|
143
|
+
`Filter by approval status (one of: ${STATUS_VALUES.join(", ")})`,
|
|
144
|
+
parseApprovalStatus
|
|
145
|
+
).option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
146
|
+
await runApprovalList(options);
|
|
147
|
+
});
|
|
148
|
+
approval.command("show <id>").description("Show an approval's metadata and related events").option("--json", "Output the approval and events as JSON").option("-v, --verbose", "Show error causes").action(async (id, options) => {
|
|
149
|
+
await runApprovalShow(id, options);
|
|
150
|
+
});
|
|
151
|
+
approval.command("approve <id>").description("Approve a pending approval").option("--note <text>", "Optional note to attach to the approval_approved event").option("-v, --verbose", "Show error causes").action(async (id, options) => {
|
|
152
|
+
await runApprovalApprove(id, options);
|
|
153
|
+
});
|
|
154
|
+
approval.command("reject <id>").description("Reject a pending approval").requiredOption("--reason <text>", "Reason for rejection (required)").option("-v, --verbose", "Show error causes").action(async (id, options) => {
|
|
155
|
+
await runApprovalReject(id, options);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function runApprovalList(options, ctx = {}) {
|
|
159
|
+
try {
|
|
160
|
+
await doRunApprovalList(options, ctx);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function doRunApprovalList(options, ctx) {
|
|
167
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
168
|
+
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "list");
|
|
169
|
+
const paths = basouPaths(repositoryRoot);
|
|
170
|
+
await assertWorkspaceInitialized(paths.root);
|
|
171
|
+
const ids = await enumerateApprovals(paths);
|
|
172
|
+
const now = /* @__PURE__ */ new Date();
|
|
173
|
+
const records = [];
|
|
174
|
+
const resolvedSet = new Set(ids.resolved);
|
|
175
|
+
for (const id of ids.pending) {
|
|
176
|
+
if (resolvedSet.has(id)) {
|
|
177
|
+
console.error(`Warning: stale pending entry for ${shortId(id)}; resolved version preferred`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const rec = await readApprovalListRecord(paths, id, "pending", now);
|
|
181
|
+
if (rec !== null) records.push(rec);
|
|
182
|
+
}
|
|
183
|
+
for (const id of ids.resolved) {
|
|
184
|
+
const rec = await readApprovalListRecord(paths, id, "resolved", now);
|
|
185
|
+
if (rec !== null) records.push(rec);
|
|
186
|
+
}
|
|
187
|
+
records.sort((a, b) => Date.parse(b.approval.created_at) - Date.parse(a.approval.created_at));
|
|
188
|
+
const filtered = options.status !== void 0 ? records.filter((r) => r.approval.status === options.status) : records;
|
|
189
|
+
if (filtered.length === 0) {
|
|
190
|
+
printNoApprovals(options);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (options.json === true) {
|
|
194
|
+
console.log(
|
|
195
|
+
JSON.stringify(
|
|
196
|
+
filtered.map((r) => ({ ...r.approval, lazy_expired: r.lazyExpired })),
|
|
197
|
+
null,
|
|
198
|
+
2
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
printApprovalListText(filtered);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function readApprovalListRecord(paths, id, location, now) {
|
|
206
|
+
const filePath = join(paths.approvals[location], `${id}.yaml`);
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await readYamlFile(filePath);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(`Skipped ${shortId(id)}: ${describeReadError(error)}`);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const parse = ApprovalSchema.safeParse(raw);
|
|
215
|
+
if (!parse.success) {
|
|
216
|
+
console.error(`Skipped ${shortId(id)}: invalid approval schema`);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (parse.data.id !== id) {
|
|
220
|
+
console.error(`Skipped ${shortId(id)}: filename and YAML body id disagree`);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const approval = parse.data;
|
|
224
|
+
return { approval, location, lazyExpired: isLazyExpired(approval, now) };
|
|
225
|
+
}
|
|
226
|
+
async function runApprovalShow(idInput, options, ctx = {}) {
|
|
227
|
+
try {
|
|
228
|
+
await doRunApprovalShow(idInput, options, ctx);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function doRunApprovalShow(idInput, options, ctx) {
|
|
235
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
236
|
+
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, "show");
|
|
237
|
+
const paths = basouPaths(repositoryRoot);
|
|
238
|
+
await assertWorkspaceInitialized(paths.root);
|
|
239
|
+
const { id } = await resolveApprovalId(paths, idInput);
|
|
240
|
+
const loaded = await loadApproval(paths, id);
|
|
241
|
+
if (loaded === null) {
|
|
242
|
+
throw new Error(`Approval not found: ${idInput}`);
|
|
243
|
+
}
|
|
244
|
+
const sessionDir = join(paths.sessions, loaded.approval.session_id);
|
|
245
|
+
const relatedEvents = [];
|
|
246
|
+
for await (const ev of replayEvents(sessionDir, {
|
|
247
|
+
onWarning: (w) => printReplayWarning(w, loaded.approval.session_id)
|
|
248
|
+
})) {
|
|
249
|
+
if (isApprovalEvent(ev) && ev.approval_id === id) {
|
|
250
|
+
relatedEvents.push(ev);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const now = /* @__PURE__ */ new Date();
|
|
254
|
+
const lazyExpired = isLazyExpired(loaded.approval, now);
|
|
255
|
+
if (options.json === true) {
|
|
256
|
+
console.log(
|
|
257
|
+
JSON.stringify(
|
|
258
|
+
{
|
|
259
|
+
approval: { ...loaded.approval, lazy_expired: lazyExpired },
|
|
260
|
+
events: relatedEvents
|
|
261
|
+
},
|
|
262
|
+
null,
|
|
263
|
+
2
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
printApprovalShowText(loaded.approval, loaded.location, relatedEvents, lazyExpired);
|
|
269
|
+
}
|
|
270
|
+
async function runApprovalApprove(idInput, options, ctx = {}) {
|
|
271
|
+
try {
|
|
272
|
+
await doRunApprovalResolve(idInput, options, ctx, "approve");
|
|
273
|
+
} catch (error) {
|
|
274
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
async function runApprovalReject(idInput, options, ctx = {}) {
|
|
279
|
+
try {
|
|
280
|
+
await doRunApprovalResolve(idInput, options, ctx, "reject");
|
|
281
|
+
} catch (error) {
|
|
282
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function doRunApprovalResolve(idInput, options, ctx, decision) {
|
|
287
|
+
if (decision === "reject") {
|
|
288
|
+
const reason = options.reason;
|
|
289
|
+
if (reason.length === 0) {
|
|
290
|
+
throw new Error("--reason must not be empty");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
294
|
+
const repositoryRoot = await resolveRepositoryRootForApproval(cwd, decision);
|
|
295
|
+
const paths = basouPaths(repositoryRoot);
|
|
296
|
+
await assertWorkspaceInitialized(paths.root);
|
|
297
|
+
const { id, location } = await resolveApprovalId(paths, idInput);
|
|
298
|
+
if (location === "resolved") {
|
|
299
|
+
throw new Error(`Approval already resolved: ${idInput}`);
|
|
300
|
+
}
|
|
301
|
+
const pendingPath = join(paths.approvals.pending, `${id}.yaml`);
|
|
302
|
+
let pendingRaw;
|
|
303
|
+
try {
|
|
304
|
+
pendingRaw = await readYamlFile(pendingPath);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
307
|
+
throw new Error(`Approval not found: ${idInput}`);
|
|
308
|
+
}
|
|
309
|
+
throw new Error("Failed to read approval", { cause: error });
|
|
310
|
+
}
|
|
311
|
+
const approvalParse = ApprovalSchema.safeParse(pendingRaw);
|
|
312
|
+
if (!approvalParse.success) {
|
|
313
|
+
throw new Error("Failed to read approval", { cause: approvalParse.error });
|
|
314
|
+
}
|
|
315
|
+
const approval = approvalParse.data;
|
|
316
|
+
if (approval.id !== id) {
|
|
317
|
+
throw new Error("Failed to read approval", {
|
|
318
|
+
cause: new Error(`Approval id mismatch: filename id ${id} vs YAML body id ${approval.id}`)
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (approval.status !== "pending") {
|
|
322
|
+
throw new Error(`Approval status mismatch: pending YAML has status=${approval.status}`);
|
|
323
|
+
}
|
|
324
|
+
const sessionDir = join(paths.sessions, approval.session_id);
|
|
325
|
+
for await (const ev of replayEvents(sessionDir, {
|
|
326
|
+
onWarning: (w) => printReplayWarning(w, approval.session_id)
|
|
327
|
+
})) {
|
|
328
|
+
if (isApprovalEvent(ev) && ev.approval_id === approval.id && (ev.type === "approval_approved" || ev.type === "approval_rejected" || ev.type === "approval_expired")) {
|
|
329
|
+
throw new Error(`Approval already resolved (per events.jsonl): ${idInput}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const now = /* @__PURE__ */ new Date();
|
|
333
|
+
if (isLazyExpired(approval, now)) {
|
|
334
|
+
throw new Error(`Approval already expired: ${idInput}`);
|
|
335
|
+
}
|
|
336
|
+
const occurredAt = now.toISOString();
|
|
337
|
+
const eventId = prefixedUlid("evt");
|
|
338
|
+
if (decision === "approve") {
|
|
339
|
+
const note = options.note ?? null;
|
|
340
|
+
await appendEvent(sessionDir, {
|
|
341
|
+
schema_version: "0.1.0",
|
|
342
|
+
id: eventId,
|
|
343
|
+
session_id: approval.session_id,
|
|
344
|
+
occurred_at: occurredAt,
|
|
345
|
+
source: "local-cli",
|
|
346
|
+
type: "approval_approved",
|
|
347
|
+
approval_id: approval.id,
|
|
348
|
+
resolver: "local-cli",
|
|
349
|
+
note
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
const reason = options.reason;
|
|
353
|
+
await appendEvent(sessionDir, {
|
|
354
|
+
schema_version: "0.1.0",
|
|
355
|
+
id: eventId,
|
|
356
|
+
session_id: approval.session_id,
|
|
357
|
+
occurred_at: occurredAt,
|
|
358
|
+
source: "local-cli",
|
|
359
|
+
type: "approval_rejected",
|
|
360
|
+
approval_id: approval.id,
|
|
361
|
+
resolver: "local-cli",
|
|
362
|
+
reason
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const resolvedApproval = decision === "approve" ? {
|
|
366
|
+
...approval,
|
|
367
|
+
status: "approved",
|
|
368
|
+
resolver: "local-cli",
|
|
369
|
+
resolved_at: occurredAt,
|
|
370
|
+
note: options.note ?? null
|
|
371
|
+
} : {
|
|
372
|
+
...approval,
|
|
373
|
+
status: "rejected",
|
|
374
|
+
resolver: "local-cli",
|
|
375
|
+
resolved_at: occurredAt,
|
|
376
|
+
rejection_reason: options.reason
|
|
377
|
+
};
|
|
378
|
+
const resolvedPath = join(paths.approvals.resolved, `${id}.yaml`);
|
|
379
|
+
try {
|
|
380
|
+
await linkYamlFile(resolvedPath, resolvedApproval);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
383
|
+
if (cause instanceof Error && cause.code === "EEXIST") {
|
|
384
|
+
throw new Error("Approval already resolved at the same time", { cause });
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
await unlink(pendingPath);
|
|
390
|
+
} catch {
|
|
391
|
+
console.error(
|
|
392
|
+
`Warning: failed to unlink pending entry for ${shortId(id)}; events.jsonl is consistent`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const verb = decision === "approve" ? "Approved" : "Rejected";
|
|
396
|
+
console.log(`${verb} approval ${shortId(id)}`);
|
|
397
|
+
}
|
|
398
|
+
async function resolveApprovalId(paths, input) {
|
|
399
|
+
const trimmed = input.trim();
|
|
400
|
+
if (trimmed.length === 0) {
|
|
401
|
+
throw new Error("Approval id is empty");
|
|
402
|
+
}
|
|
403
|
+
const normalized = trimmed.startsWith(APPR_PREFIX) ? trimmed : `${APPR_PREFIX}${trimmed}`;
|
|
404
|
+
if (normalized.length <= APPR_PREFIX.length) {
|
|
405
|
+
throw new Error(`Approval not found: ${input}`);
|
|
406
|
+
}
|
|
407
|
+
const enumeration = await enumerateApprovals(paths);
|
|
408
|
+
const byId = /* @__PURE__ */ new Map();
|
|
409
|
+
for (const id2 of enumeration.pending) {
|
|
410
|
+
if (id2.startsWith(normalized)) byId.set(id2, "pending");
|
|
411
|
+
}
|
|
412
|
+
for (const id2 of enumeration.resolved) {
|
|
413
|
+
if (!id2.startsWith(normalized)) continue;
|
|
414
|
+
if (byId.get(id2) === "pending") {
|
|
415
|
+
console.error(`Warning: stale pending entry for ${shortId(id2)}; resolved version preferred`);
|
|
416
|
+
}
|
|
417
|
+
byId.set(id2, "resolved");
|
|
418
|
+
}
|
|
419
|
+
if (byId.size === 0) {
|
|
420
|
+
throw new Error(`Approval not found: ${input}`);
|
|
421
|
+
}
|
|
422
|
+
if (byId.size > 1) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Ambiguous approval id '${input}': matched ${byId.size} approvals. Disambiguate with a longer prefix.`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
const first = byId.entries().next().value;
|
|
428
|
+
if (first === void 0) {
|
|
429
|
+
throw new Error(`Approval not found: ${input}`);
|
|
430
|
+
}
|
|
431
|
+
const [id, location] = first;
|
|
432
|
+
return { id, location };
|
|
433
|
+
}
|
|
434
|
+
function isApprovalEvent(ev) {
|
|
435
|
+
return ev.type === "approval_requested" || ev.type === "approval_approved" || ev.type === "approval_rejected" || ev.type === "approval_expired";
|
|
436
|
+
}
|
|
437
|
+
function printApprovalListText(records) {
|
|
438
|
+
const allIds = records.map((r) => r.approval.id);
|
|
439
|
+
const shortLen = computeUniquePrefixLen(allIds);
|
|
440
|
+
const rows = records.map((r) => {
|
|
441
|
+
const sid = sliceShort(r.approval.id, shortLen);
|
|
442
|
+
const status = r.lazyExpired ? `${r.approval.status} (expired)` : r.approval.status;
|
|
443
|
+
const risk = r.approval.risk_level;
|
|
444
|
+
const action = r.approval.action.kind;
|
|
445
|
+
const createdAt = r.approval.created_at;
|
|
446
|
+
const reason = truncate(r.approval.reason, REASON_TEXT_MAX_LEN);
|
|
447
|
+
return { sid, status, risk, action, createdAt, reason };
|
|
448
|
+
});
|
|
449
|
+
const widths = {
|
|
450
|
+
sid: maxLen(
|
|
451
|
+
rows.map((r) => r.sid),
|
|
452
|
+
"SHORT_ID".length
|
|
453
|
+
),
|
|
454
|
+
status: maxLen(
|
|
455
|
+
rows.map((r) => r.status),
|
|
456
|
+
"STATUS".length
|
|
457
|
+
),
|
|
458
|
+
risk: maxLen(
|
|
459
|
+
rows.map((r) => r.risk),
|
|
460
|
+
"RISK".length
|
|
461
|
+
),
|
|
462
|
+
action: maxLen(
|
|
463
|
+
rows.map((r) => r.action),
|
|
464
|
+
"ACTION".length
|
|
465
|
+
),
|
|
466
|
+
createdAt: maxLen(
|
|
467
|
+
rows.map((r) => r.createdAt),
|
|
468
|
+
"CREATED_AT".length
|
|
469
|
+
)
|
|
470
|
+
};
|
|
471
|
+
console.log(
|
|
472
|
+
`${pad("SHORT_ID", widths.sid)} ${pad("STATUS", widths.status)} ${pad("RISK", widths.risk)} ${pad("ACTION", widths.action)} ${pad("CREATED_AT", widths.createdAt)} REASON`
|
|
473
|
+
);
|
|
474
|
+
for (const row of rows) {
|
|
475
|
+
console.log(
|
|
476
|
+
`${pad(row.sid, widths.sid)} ${pad(row.status, widths.status)} ${pad(row.risk, widths.risk)} ${pad(row.action, widths.action)} ${pad(row.createdAt, widths.createdAt)} ${row.reason}`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function printApprovalShowText(approval, _location, events, lazyExpired) {
|
|
481
|
+
console.log(`Approval: ${approval.id} (status: ${approval.status})`);
|
|
482
|
+
console.log(`Session: ${approval.session_id}`);
|
|
483
|
+
console.log(`Created at: ${approval.created_at}`);
|
|
484
|
+
console.log(`Risk level: ${approval.risk_level}`);
|
|
485
|
+
console.log(`Action: ${formatActionLine(approval.action)}`);
|
|
486
|
+
console.log(`Reason: ${approval.reason}`);
|
|
487
|
+
const expiresLabel = formatExpiresLabel(approval.expires_at, lazyExpired);
|
|
488
|
+
console.log(`Expires at: ${expiresLabel}`);
|
|
489
|
+
console.log(`Resolver: ${approval.resolver ?? "(none)"}`);
|
|
490
|
+
console.log(`Resolved at: ${approval.resolved_at ?? "(none)"}`);
|
|
491
|
+
console.log(`Note: ${approval.note ?? "(none)"}`);
|
|
492
|
+
console.log(`Rejection reason: ${approval.rejection_reason ?? "(none)"}`);
|
|
493
|
+
console.log("");
|
|
494
|
+
console.log(`Related events: ${events.length} total`);
|
|
495
|
+
for (const ev of events) {
|
|
496
|
+
console.log(` ${formatApprovalEventLine(ev)}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function formatActionLine(action) {
|
|
500
|
+
const extras = [];
|
|
501
|
+
for (const [key, value] of Object.entries(action)) {
|
|
502
|
+
if (key === "kind") continue;
|
|
503
|
+
if (typeof value !== "string") continue;
|
|
504
|
+
extras.push(`${key}="${truncate(value, ACTION_KEY_DETAIL_MAX_LEN)}"`);
|
|
505
|
+
if (extras.length >= 2) break;
|
|
506
|
+
}
|
|
507
|
+
return extras.length === 0 ? action.kind : `${action.kind} (${extras.join(", ")})`;
|
|
508
|
+
}
|
|
509
|
+
function formatExpiresLabel(expiresAt, lazyExpired) {
|
|
510
|
+
if (expiresAt === null) return "(none)";
|
|
511
|
+
return lazyExpired ? `${expiresAt} (expired)` : expiresAt;
|
|
512
|
+
}
|
|
513
|
+
function formatApprovalEventLine(ev) {
|
|
514
|
+
const summary = approvalEventSummary(ev);
|
|
515
|
+
return `${ev.occurred_at} [${ev.source}] ${ev.type} ${summary}`;
|
|
516
|
+
}
|
|
517
|
+
function approvalEventSummary(ev) {
|
|
518
|
+
switch (ev.type) {
|
|
519
|
+
case "approval_requested":
|
|
520
|
+
return `${ev.action.kind} risk=${ev.risk_level}`;
|
|
521
|
+
case "approval_approved":
|
|
522
|
+
return ev.resolver !== void 0 ? `by ${ev.resolver}` : "(approved)";
|
|
523
|
+
case "approval_rejected":
|
|
524
|
+
return ev.resolver !== void 0 ? `by ${ev.resolver}: ${ev.reason}` : ev.reason;
|
|
525
|
+
case "approval_expired":
|
|
526
|
+
return `approval=${ev.approval_id}`;
|
|
527
|
+
default:
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function shortId(id) {
|
|
532
|
+
return sliceShort(id, SHORT_ID_BASE_LEN);
|
|
533
|
+
}
|
|
534
|
+
var KNOWN_ID_PREFIXES = ["appr_", "ses_", "evt_", "ws_", "task_", "decision_"];
|
|
535
|
+
function sliceShort(id, len) {
|
|
536
|
+
for (const prefix of KNOWN_ID_PREFIXES) {
|
|
537
|
+
if (id.startsWith(prefix)) {
|
|
538
|
+
return id.slice(prefix.length, prefix.length + len);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return id.slice(0, len);
|
|
542
|
+
}
|
|
543
|
+
function computeUniquePrefixLen(ids) {
|
|
544
|
+
if (ids.length <= 1) return SHORT_ID_BASE_LEN;
|
|
545
|
+
for (let len = SHORT_ID_BASE_LEN; len <= SHORT_ID_MAX_LEN; len += 2) {
|
|
546
|
+
const seen = /* @__PURE__ */ new Set();
|
|
547
|
+
let collided = false;
|
|
548
|
+
for (const id of ids) {
|
|
549
|
+
const key = sliceShort(id, len);
|
|
550
|
+
if (seen.has(key)) {
|
|
551
|
+
collided = true;
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
seen.add(key);
|
|
555
|
+
}
|
|
556
|
+
if (!collided) return len;
|
|
557
|
+
}
|
|
558
|
+
return SHORT_ID_MAX_LEN;
|
|
559
|
+
}
|
|
560
|
+
function pad(value, width) {
|
|
561
|
+
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
562
|
+
}
|
|
563
|
+
function maxLen(values, floor) {
|
|
564
|
+
let max = floor;
|
|
565
|
+
for (const v of values) if (v.length > max) max = v.length;
|
|
566
|
+
return max;
|
|
567
|
+
}
|
|
568
|
+
function truncate(value, maxLength) {
|
|
569
|
+
if (value.length <= maxLength) return value;
|
|
570
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
571
|
+
}
|
|
572
|
+
async function resolveRepositoryRootForApproval(cwd, subcmd) {
|
|
573
|
+
try {
|
|
574
|
+
return await resolveRepositoryRoot(cwd);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
577
|
+
throw new Error(
|
|
578
|
+
`Not a git repository. Run 'git init' first, then re-run 'basou approval ${subcmd}'.`,
|
|
579
|
+
{ cause: error }
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function assertWorkspaceInitialized(basouRoot) {
|
|
586
|
+
try {
|
|
587
|
+
await assertBasouRootSafe(basouRoot);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (findErrorCode(error, "ENOENT")) {
|
|
590
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
591
|
+
}
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function describeReadError(error) {
|
|
596
|
+
if (error instanceof Error) {
|
|
597
|
+
if (error.message === "YAML file not found") return "approval YAML not found";
|
|
598
|
+
if (error.message === "Failed to parse YAML content") return "invalid YAML";
|
|
599
|
+
return error.message;
|
|
600
|
+
}
|
|
601
|
+
return String(error);
|
|
602
|
+
}
|
|
603
|
+
function parseApprovalStatus(raw) {
|
|
604
|
+
const result = ApprovalStatusSchema.safeParse(raw);
|
|
605
|
+
if (!result.success) {
|
|
606
|
+
throw new Error(`Invalid approval status: ${raw}. Valid values: ${STATUS_VALUES.join(", ")}`);
|
|
607
|
+
}
|
|
608
|
+
return result.data;
|
|
609
|
+
}
|
|
610
|
+
function printNoApprovals(options) {
|
|
611
|
+
if (options.json === true) {
|
|
612
|
+
console.log("[]");
|
|
613
|
+
} else {
|
|
614
|
+
console.log("No approvals found.");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/commands/decision.ts
|
|
619
|
+
import {
|
|
620
|
+
acquireLock,
|
|
621
|
+
appendEventToExistingSession,
|
|
622
|
+
assertBasouRootSafe as assertBasouRootSafe2,
|
|
623
|
+
basouPaths as basouPaths2,
|
|
624
|
+
createAdHocSessionWithEvent,
|
|
625
|
+
findErrorCode as findErrorCode2,
|
|
626
|
+
prefixedUlid as prefixedUlid2,
|
|
627
|
+
readManifest,
|
|
628
|
+
resolveRepositoryRoot as resolveRepositoryRoot2,
|
|
629
|
+
resolveSessionId
|
|
630
|
+
} from "@basou/core";
|
|
631
|
+
import { InvalidArgumentError } from "commander";
|
|
632
|
+
var LABEL_TITLE_MAX = 80;
|
|
633
|
+
var LABEL_TRUNCATE_HEAD = LABEL_TITLE_MAX - 3;
|
|
634
|
+
function registerDecisionCommand(program2) {
|
|
635
|
+
const decision = program2.command("decision").description("Record human-authored decisions as events");
|
|
636
|
+
decision.command("record").description("Record a decision_recorded event").requiredOption("--title <text>", "Decision title", parseTitle).option("--rationale <text>", "Rationale for the decision", parseRationale).option(
|
|
637
|
+
"--rejected-reason <text>",
|
|
638
|
+
"Reason rejected alternatives were not chosen",
|
|
639
|
+
parseRejectedReason
|
|
640
|
+
).option(
|
|
641
|
+
"--alternative <text>",
|
|
642
|
+
"Alternative considered (repeatable: --alternative yup --alternative joi)",
|
|
643
|
+
collectAlternative,
|
|
644
|
+
[]
|
|
645
|
+
).option(
|
|
646
|
+
"--linked-event <event_id>",
|
|
647
|
+
"Related event id (repeatable). Schema only checks the prefix; existence is verified at render time.",
|
|
648
|
+
collectLinkedEvent,
|
|
649
|
+
[]
|
|
650
|
+
).option(
|
|
651
|
+
"--linked-file <path>",
|
|
652
|
+
"Related file path (repeatable). Path is opaque; existence is verified at render time.",
|
|
653
|
+
collectLinkedFile,
|
|
654
|
+
[]
|
|
655
|
+
).option(
|
|
656
|
+
"--session <session_id>",
|
|
657
|
+
"Attach to an existing session; otherwise an ad-hoc session is created"
|
|
658
|
+
).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
659
|
+
await runDecisionRecord(options);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async function runDecisionRecord(options, ctx = {}) {
|
|
663
|
+
try {
|
|
664
|
+
await doRunDecisionRecord(options, ctx);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
renderCliError(error, {
|
|
667
|
+
verbose: isVerbose(options),
|
|
668
|
+
classifiers: [failedToFinalizeClassifier]
|
|
669
|
+
});
|
|
670
|
+
process.exitCode = 1;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function doRunDecisionRecord(options, ctx) {
|
|
674
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
675
|
+
const repositoryRoot = await resolveRepositoryRootForDecision(cwd);
|
|
676
|
+
const paths = basouPaths2(repositoryRoot);
|
|
677
|
+
await assertWorkspaceInitialized2(paths.root);
|
|
678
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
679
|
+
const occurredAt = now.toISOString();
|
|
680
|
+
const decisionId = prefixedUlid2("decision");
|
|
681
|
+
const rich = pickRichFields(options);
|
|
682
|
+
if (options.session !== void 0) {
|
|
683
|
+
const sessionId = await resolveSessionId(paths, options.session);
|
|
684
|
+
const sesId = sessionId;
|
|
685
|
+
const sessionLock = await acquireLock(paths, "session", sesId);
|
|
686
|
+
let result;
|
|
687
|
+
try {
|
|
688
|
+
result = await appendEventToExistingSession({
|
|
689
|
+
paths,
|
|
690
|
+
sessionId: sesId,
|
|
691
|
+
eventBuilder: (eventId) => buildDecisionEvent({
|
|
692
|
+
eventId,
|
|
693
|
+
sessionId: sesId,
|
|
694
|
+
decisionId,
|
|
695
|
+
title: options.title,
|
|
696
|
+
occurredAt,
|
|
697
|
+
rich
|
|
698
|
+
})
|
|
699
|
+
});
|
|
700
|
+
} finally {
|
|
701
|
+
await sessionLock.release();
|
|
702
|
+
}
|
|
703
|
+
printDecisionResult(options, {
|
|
704
|
+
mode: "attached",
|
|
705
|
+
sessionId,
|
|
706
|
+
decisionId,
|
|
707
|
+
eventId: result.eventId,
|
|
708
|
+
sessionStatus: result.sessionStatus,
|
|
709
|
+
title: options.title,
|
|
710
|
+
rich
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const manifest = await readManifest(paths);
|
|
715
|
+
const adHoc = await createAdHocSessionWithEvent({
|
|
716
|
+
paths,
|
|
717
|
+
manifest,
|
|
718
|
+
label: buildAdHocLabel(options.title),
|
|
719
|
+
occurredAt,
|
|
720
|
+
sessionSource: "human",
|
|
721
|
+
workingDirectory: repositoryRoot,
|
|
722
|
+
invocation: {
|
|
723
|
+
command: "basou decision record",
|
|
724
|
+
args: ["--title", options.title]
|
|
725
|
+
},
|
|
726
|
+
targetEventBuilders: [
|
|
727
|
+
(sessionId, eventId) => buildDecisionEvent({
|
|
728
|
+
eventId,
|
|
729
|
+
sessionId,
|
|
730
|
+
decisionId,
|
|
731
|
+
title: options.title,
|
|
732
|
+
occurredAt,
|
|
733
|
+
rich
|
|
734
|
+
})
|
|
735
|
+
]
|
|
736
|
+
});
|
|
737
|
+
printDecisionResult(options, {
|
|
738
|
+
mode: "ad-hoc",
|
|
739
|
+
sessionId: adHoc.sessionId,
|
|
740
|
+
decisionId,
|
|
741
|
+
eventId: adHoc.targetEventIds[0],
|
|
742
|
+
sessionStatus: "completed",
|
|
743
|
+
title: options.title,
|
|
744
|
+
rich
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
function pickRichFields(options) {
|
|
748
|
+
const out = {};
|
|
749
|
+
if (options.rationale !== void 0) out.rationale = options.rationale;
|
|
750
|
+
if (options.rejectedReason !== void 0) out.rejected_reason = options.rejectedReason;
|
|
751
|
+
if (options.alternative !== void 0 && options.alternative.length > 0) {
|
|
752
|
+
out.alternatives = [...options.alternative];
|
|
753
|
+
}
|
|
754
|
+
if (options.linkedEvent !== void 0 && options.linkedEvent.length > 0) {
|
|
755
|
+
out.linked_events = [...options.linkedEvent];
|
|
756
|
+
}
|
|
757
|
+
if (options.linkedFile !== void 0 && options.linkedFile.length > 0) {
|
|
758
|
+
out.linked_files = [...options.linkedFile];
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
function buildDecisionEvent(input) {
|
|
763
|
+
return {
|
|
764
|
+
schema_version: "0.1.0",
|
|
765
|
+
id: input.eventId,
|
|
766
|
+
session_id: input.sessionId,
|
|
767
|
+
occurred_at: input.occurredAt,
|
|
768
|
+
source: "local-cli",
|
|
769
|
+
type: "decision_recorded",
|
|
770
|
+
decision_id: input.decisionId,
|
|
771
|
+
title: input.title,
|
|
772
|
+
...input.rich.rationale !== void 0 ? { rationale: input.rich.rationale } : {},
|
|
773
|
+
...input.rich.alternatives !== void 0 ? { alternatives: input.rich.alternatives } : {},
|
|
774
|
+
...input.rich.rejected_reason !== void 0 ? { rejected_reason: input.rich.rejected_reason } : {},
|
|
775
|
+
...input.rich.linked_events !== void 0 ? { linked_events: input.rich.linked_events } : {},
|
|
776
|
+
...input.rich.linked_files !== void 0 ? { linked_files: input.rich.linked_files } : {}
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function buildAdHocLabel(title) {
|
|
780
|
+
const truncated = title.length > LABEL_TITLE_MAX ? `${title.slice(0, LABEL_TRUNCATE_HEAD)}...` : title;
|
|
781
|
+
return `Ad-hoc decision: ${truncated}`;
|
|
782
|
+
}
|
|
783
|
+
function parseTitle(raw) {
|
|
784
|
+
if (raw.length === 0) {
|
|
785
|
+
throw new InvalidArgumentError("Title must not be empty");
|
|
786
|
+
}
|
|
787
|
+
return raw;
|
|
788
|
+
}
|
|
789
|
+
function parseRationale(raw) {
|
|
790
|
+
if (raw.length === 0) {
|
|
791
|
+
throw new InvalidArgumentError("Rationale must not be empty");
|
|
792
|
+
}
|
|
793
|
+
return raw;
|
|
794
|
+
}
|
|
795
|
+
function parseRejectedReason(raw) {
|
|
796
|
+
if (raw.length === 0) {
|
|
797
|
+
throw new InvalidArgumentError("Rejected reason must not be empty");
|
|
798
|
+
}
|
|
799
|
+
return raw;
|
|
800
|
+
}
|
|
801
|
+
function collectAlternative(value, prev) {
|
|
802
|
+
if (value.length === 0) {
|
|
803
|
+
throw new InvalidArgumentError("Alternative must not be empty");
|
|
804
|
+
}
|
|
805
|
+
return prev.concat(value);
|
|
806
|
+
}
|
|
807
|
+
var EVENT_ID_RE = /^evt_[A-Z0-9]+$/;
|
|
808
|
+
function collectLinkedEvent(value, prev) {
|
|
809
|
+
if (!EVENT_ID_RE.test(value)) {
|
|
810
|
+
throw new InvalidArgumentError(`Linked event id must match evt_<ULID>, got '${value}'`);
|
|
811
|
+
}
|
|
812
|
+
return prev.concat(value);
|
|
813
|
+
}
|
|
814
|
+
function collectLinkedFile(value, prev) {
|
|
815
|
+
if (value.length === 0) {
|
|
816
|
+
throw new InvalidArgumentError("Linked file path must not be empty");
|
|
817
|
+
}
|
|
818
|
+
if (value.length > 4096) {
|
|
819
|
+
throw new InvalidArgumentError("Linked file path exceeds 4096 chars");
|
|
820
|
+
}
|
|
821
|
+
return prev.concat(value);
|
|
822
|
+
}
|
|
823
|
+
function printDecisionResult(options, result) {
|
|
824
|
+
const sid = shortSessionId(result.sessionId);
|
|
825
|
+
if (options.json === true) {
|
|
826
|
+
const payload = {
|
|
827
|
+
decision_id: result.decisionId,
|
|
828
|
+
event_id: result.eventId,
|
|
829
|
+
session_id: result.sessionId,
|
|
830
|
+
session_status: result.sessionStatus,
|
|
831
|
+
mode: result.mode,
|
|
832
|
+
title: result.title
|
|
833
|
+
};
|
|
834
|
+
if (result.rich.rationale !== void 0) payload.rationale = result.rich.rationale;
|
|
835
|
+
if (result.rich.alternatives !== void 0) payload.alternatives = result.rich.alternatives;
|
|
836
|
+
if (result.rich.rejected_reason !== void 0) {
|
|
837
|
+
payload.rejected_reason = result.rich.rejected_reason;
|
|
838
|
+
}
|
|
839
|
+
if (result.rich.linked_events !== void 0) payload.linked_events = result.rich.linked_events;
|
|
840
|
+
if (result.rich.linked_files !== void 0) payload.linked_files = result.rich.linked_files;
|
|
841
|
+
console.log(JSON.stringify(payload));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const rationaleSuffix = result.rich.rationale !== void 0 ? ` (rationale: ${result.rich.rationale})` : "";
|
|
845
|
+
if (result.mode === "ad-hoc") {
|
|
846
|
+
console.log(`Recorded ${result.decisionId} in ad-hoc session ${sid}${rationaleSuffix}`);
|
|
847
|
+
} else {
|
|
848
|
+
console.log(
|
|
849
|
+
`Recorded ${result.decisionId} in session ${sid} (${result.sessionStatus})${rationaleSuffix}`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async function resolveRepositoryRootForDecision(cwd) {
|
|
854
|
+
try {
|
|
855
|
+
return await resolveRepositoryRoot2(cwd);
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
858
|
+
throw new Error(
|
|
859
|
+
"Not a git repository. Run 'git init' first, then re-run 'basou decision record'.",
|
|
860
|
+
{ cause: error }
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function assertWorkspaceInitialized2(basouRoot) {
|
|
867
|
+
try {
|
|
868
|
+
await assertBasouRootSafe2(basouRoot);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
if (findErrorCode2(error, "ENOENT")) {
|
|
871
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
872
|
+
}
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// src/commands/decisions.ts
|
|
878
|
+
import {
|
|
879
|
+
assertBasouRootSafe as assertBasouRootSafe3,
|
|
880
|
+
basouPaths as basouPaths3,
|
|
881
|
+
findErrorCode as findErrorCode3,
|
|
882
|
+
readMarkdownFile,
|
|
883
|
+
renderDecisions,
|
|
884
|
+
renderWithMarkers,
|
|
885
|
+
resolveRepositoryRoot as resolveRepositoryRoot3,
|
|
886
|
+
writeMarkdownFile
|
|
887
|
+
} from "@basou/core";
|
|
888
|
+
function registerDecisionsCommand(program2) {
|
|
889
|
+
const decisions = program2.command("decisions").description("Generate or inspect .basou/decisions.md");
|
|
890
|
+
decisions.command("generate").description("Regenerate .basou/decisions.md from recorded decision events").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
891
|
+
await runDecisionsGenerate(opts);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
async function runDecisionsGenerate(options, ctx = {}) {
|
|
895
|
+
try {
|
|
896
|
+
await doRunDecisionsGenerate(options, ctx);
|
|
897
|
+
} catch (error) {
|
|
898
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
899
|
+
process.exitCode = 1;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
async function doRunDecisionsGenerate(options, ctx) {
|
|
903
|
+
void options;
|
|
904
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
905
|
+
const repositoryRoot = await resolveRepositoryRootForDecisions(cwd);
|
|
906
|
+
const paths = basouPaths3(repositoryRoot);
|
|
907
|
+
await assertWorkspaceInitialized3(paths.root);
|
|
908
|
+
const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
909
|
+
const result = await renderDecisions({
|
|
910
|
+
paths,
|
|
911
|
+
nowIso,
|
|
912
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
913
|
+
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason)
|
|
914
|
+
});
|
|
915
|
+
const existing = await readMarkdownFile(paths.files.decisions);
|
|
916
|
+
const finalBody = renderWithMarkers(existing, result.body, "decisions.md");
|
|
917
|
+
await writeMarkdownFile(paths.files.decisions, finalBody);
|
|
918
|
+
console.log(`Generated .basou/decisions.md (decisions: ${result.decisionCount})`);
|
|
919
|
+
}
|
|
920
|
+
async function resolveRepositoryRootForDecisions(cwd) {
|
|
921
|
+
try {
|
|
922
|
+
return await resolveRepositoryRoot3(cwd);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
925
|
+
throw new Error(
|
|
926
|
+
"Not a git repository. Run 'git init' first, then re-run 'basou decisions generate'.",
|
|
927
|
+
{ cause: error }
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async function assertWorkspaceInitialized3(basouRoot) {
|
|
934
|
+
try {
|
|
935
|
+
await assertBasouRootSafe3(basouRoot);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (findErrorCode3(error, "ENOENT")) {
|
|
938
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
939
|
+
}
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/commands/exec.ts
|
|
945
|
+
import { mkdir } from "fs/promises";
|
|
946
|
+
import { homedir } from "os";
|
|
947
|
+
import { join as join2 } from "path";
|
|
948
|
+
import {
|
|
949
|
+
ChildProcessRunner,
|
|
950
|
+
SessionSchema,
|
|
951
|
+
assertBasouRootSafe as assertBasouRootSafe4,
|
|
952
|
+
basouPaths as basouPaths4,
|
|
953
|
+
appendEvent as coreAppendEvent,
|
|
954
|
+
getSnapshot,
|
|
955
|
+
overwriteYamlFile,
|
|
956
|
+
parseDuration,
|
|
957
|
+
prefixedUlid as prefixedUlid3,
|
|
958
|
+
readManifest as readManifest2,
|
|
959
|
+
readYamlFile as readYamlFile2,
|
|
960
|
+
resolveRepositoryRoot as resolveRepositoryRoot4,
|
|
961
|
+
sanitizeWorkingDirectory,
|
|
962
|
+
writeYamlFile
|
|
963
|
+
} from "@basou/core";
|
|
964
|
+
function registerExecCommand(program2) {
|
|
965
|
+
program2.command("exec <command> [args...]").description("Execute a command and record it as a Basou session").passThroughOptions().option("--timeout <duration>", "Kill the child after this duration (e.g. 30s, 5m, 1h)").option("--no-snapshot", "Skip git_snapshot before/after the command").option("--cwd <path>", "Run from a Basou root other than process.cwd()").option("-v, --verbose", "Show error causes").action(async (command, args, options) => {
|
|
966
|
+
try {
|
|
967
|
+
const exitCode = await runExec(command, args, options);
|
|
968
|
+
process.exit(exitCode);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
971
|
+
process.exit(1);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
async function runExec(command, args, options, ctx = {}) {
|
|
976
|
+
const runner = ctx.runner ?? new ChildProcessRunner();
|
|
977
|
+
const now = ctx.now ?? (() => /* @__PURE__ */ new Date());
|
|
978
|
+
const appendEvent2 = ctx.appendEvent ?? coreAppendEvent;
|
|
979
|
+
const cwd = options.cwd ?? process.cwd();
|
|
980
|
+
const timeout_ms = options.timeout !== void 0 ? parseDuration(options.timeout) : void 0;
|
|
981
|
+
const repoRoot = await resolveRepositoryRootForExec(cwd);
|
|
982
|
+
const paths = basouPaths4(repoRoot);
|
|
983
|
+
await assertBasouRootSafe4(paths.root);
|
|
984
|
+
const manifest = await readManifest2(paths);
|
|
985
|
+
const sessionId = prefixedUlid3("ses");
|
|
986
|
+
const sessionDir = join2(paths.sessions, sessionId);
|
|
987
|
+
await mkdir(sessionDir, { recursive: true });
|
|
988
|
+
const startedAt = now().toISOString();
|
|
989
|
+
const sessionYamlPath = join2(sessionDir, "session.yaml");
|
|
990
|
+
const session = buildInitialSession({
|
|
991
|
+
id: sessionId,
|
|
992
|
+
command,
|
|
993
|
+
args,
|
|
994
|
+
cwd,
|
|
995
|
+
workspaceId: manifest.workspace.id,
|
|
996
|
+
startedAt
|
|
997
|
+
});
|
|
998
|
+
await writeYamlFile(sessionYamlPath, session);
|
|
999
|
+
await appendEvent2(sessionDir, {
|
|
1000
|
+
schema_version: "0.1.0",
|
|
1001
|
+
type: "session_started",
|
|
1002
|
+
id: prefixedUlid3("evt"),
|
|
1003
|
+
session_id: sessionId,
|
|
1004
|
+
occurred_at: startedAt,
|
|
1005
|
+
source: "terminal-recording"
|
|
1006
|
+
});
|
|
1007
|
+
if (options.snapshot !== false) {
|
|
1008
|
+
await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2);
|
|
1009
|
+
}
|
|
1010
|
+
const runningAt = now().toISOString();
|
|
1011
|
+
await appendEvent2(sessionDir, {
|
|
1012
|
+
schema_version: "0.1.0",
|
|
1013
|
+
type: "session_status_changed",
|
|
1014
|
+
id: prefixedUlid3("evt"),
|
|
1015
|
+
session_id: sessionId,
|
|
1016
|
+
occurred_at: runningAt,
|
|
1017
|
+
source: "terminal-recording",
|
|
1018
|
+
from: "initialized",
|
|
1019
|
+
to: "running"
|
|
1020
|
+
});
|
|
1021
|
+
await mutateSessionYaml(sessionYamlPath, (s) => {
|
|
1022
|
+
s.session.status = "running";
|
|
1023
|
+
});
|
|
1024
|
+
const controller = new AbortController();
|
|
1025
|
+
let signalReceived = null;
|
|
1026
|
+
let activeChild = null;
|
|
1027
|
+
const signalHandler = (sig) => {
|
|
1028
|
+
if (signalReceived !== null) return;
|
|
1029
|
+
signalReceived = sig;
|
|
1030
|
+
controller.abort();
|
|
1031
|
+
};
|
|
1032
|
+
const exitHandler = () => {
|
|
1033
|
+
if (activeChild !== null) {
|
|
1034
|
+
try {
|
|
1035
|
+
activeChild.kill("SIGKILL");
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
const onSigInt = () => signalHandler("SIGINT");
|
|
1041
|
+
const onSigTerm = () => signalHandler("SIGTERM");
|
|
1042
|
+
process.on("SIGINT", onSigInt);
|
|
1043
|
+
process.on("SIGTERM", onSigTerm);
|
|
1044
|
+
process.on("exit", exitHandler);
|
|
1045
|
+
ctx.onExitHookInstalled?.(exitHandler);
|
|
1046
|
+
let result;
|
|
1047
|
+
try {
|
|
1048
|
+
try {
|
|
1049
|
+
result = await runner.run(command, args, {
|
|
1050
|
+
cwd,
|
|
1051
|
+
capture: "none",
|
|
1052
|
+
...timeout_ms !== void 0 ? { timeout_ms } : {},
|
|
1053
|
+
signal: controller.signal,
|
|
1054
|
+
onSpawn: (child) => {
|
|
1055
|
+
activeChild = child;
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
} catch (spawnError) {
|
|
1059
|
+
await finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, appendEvent2, {
|
|
1060
|
+
command,
|
|
1061
|
+
args,
|
|
1062
|
+
cwd,
|
|
1063
|
+
occurredAt: now().toISOString(),
|
|
1064
|
+
signalReceived
|
|
1065
|
+
});
|
|
1066
|
+
throw spawnError;
|
|
1067
|
+
}
|
|
1068
|
+
} finally {
|
|
1069
|
+
process.off("SIGINT", onSigInt);
|
|
1070
|
+
process.off("SIGTERM", onSigTerm);
|
|
1071
|
+
process.off("exit", exitHandler);
|
|
1072
|
+
activeChild = null;
|
|
1073
|
+
}
|
|
1074
|
+
const endedAt = now().toISOString();
|
|
1075
|
+
await appendEvent2(sessionDir, {
|
|
1076
|
+
schema_version: "0.1.0",
|
|
1077
|
+
type: "command_executed",
|
|
1078
|
+
id: prefixedUlid3("evt"),
|
|
1079
|
+
session_id: sessionId,
|
|
1080
|
+
occurred_at: endedAt,
|
|
1081
|
+
source: "terminal-recording",
|
|
1082
|
+
command,
|
|
1083
|
+
args,
|
|
1084
|
+
cwd,
|
|
1085
|
+
exit_code: result.exit_code,
|
|
1086
|
+
...result.signal !== null ? { signal: result.signal } : {},
|
|
1087
|
+
...signalReceived !== null ? { received_signal: signalReceived } : {},
|
|
1088
|
+
duration_ms: result.duration_ms
|
|
1089
|
+
});
|
|
1090
|
+
if (options.snapshot !== false) {
|
|
1091
|
+
await tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2);
|
|
1092
|
+
}
|
|
1093
|
+
const finalStatus = decideFinalStatus(result, signalReceived);
|
|
1094
|
+
await appendEvent2(sessionDir, {
|
|
1095
|
+
schema_version: "0.1.0",
|
|
1096
|
+
type: "session_status_changed",
|
|
1097
|
+
id: prefixedUlid3("evt"),
|
|
1098
|
+
session_id: sessionId,
|
|
1099
|
+
occurred_at: endedAt,
|
|
1100
|
+
source: "terminal-recording",
|
|
1101
|
+
from: "running",
|
|
1102
|
+
to: finalStatus
|
|
1103
|
+
});
|
|
1104
|
+
await appendEvent2(sessionDir, {
|
|
1105
|
+
schema_version: "0.1.0",
|
|
1106
|
+
type: "session_ended",
|
|
1107
|
+
id: prefixedUlid3("evt"),
|
|
1108
|
+
session_id: sessionId,
|
|
1109
|
+
occurred_at: endedAt,
|
|
1110
|
+
source: "terminal-recording",
|
|
1111
|
+
...result.exit_code !== null ? { exit_code: result.exit_code } : {}
|
|
1112
|
+
});
|
|
1113
|
+
await mutateSessionYaml(sessionYamlPath, (s) => {
|
|
1114
|
+
s.session.status = finalStatus;
|
|
1115
|
+
s.session.ended_at = endedAt;
|
|
1116
|
+
s.session.invocation.exit_code = result.exit_code;
|
|
1117
|
+
});
|
|
1118
|
+
if (result.exit_code !== null) {
|
|
1119
|
+
return result.exit_code;
|
|
1120
|
+
}
|
|
1121
|
+
return signalToExitCode(signalReceived ?? result.signal);
|
|
1122
|
+
}
|
|
1123
|
+
function decideFinalStatus(result, signalReceived) {
|
|
1124
|
+
if (signalReceived === "SIGINT" || signalReceived === "SIGTERM") return "interrupted";
|
|
1125
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM" || result.signal === "SIGKILL") {
|
|
1126
|
+
return "interrupted";
|
|
1127
|
+
}
|
|
1128
|
+
if (result.exit_code === 0) return "completed";
|
|
1129
|
+
return "failed";
|
|
1130
|
+
}
|
|
1131
|
+
var SIGNUM_MAP = {
|
|
1132
|
+
SIGHUP: 1,
|
|
1133
|
+
SIGINT: 2,
|
|
1134
|
+
SIGQUIT: 3,
|
|
1135
|
+
SIGKILL: 9,
|
|
1136
|
+
SIGTERM: 15
|
|
1137
|
+
};
|
|
1138
|
+
function signalToExitCode(sig) {
|
|
1139
|
+
if (sig === null) return 1;
|
|
1140
|
+
const num = SIGNUM_MAP[sig] ?? 1;
|
|
1141
|
+
return 128 + num;
|
|
1142
|
+
}
|
|
1143
|
+
async function tryAppendGitSnapshot(sessionDir, sessionId, repoRoot, now, appendEvent2) {
|
|
1144
|
+
let snapshot;
|
|
1145
|
+
try {
|
|
1146
|
+
snapshot = await getSnapshot(repoRoot);
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
console.warn(normalizeGitSnapshotSkipMessage(error));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
await appendEvent2(sessionDir, {
|
|
1152
|
+
schema_version: "0.1.0",
|
|
1153
|
+
type: "git_snapshot",
|
|
1154
|
+
id: prefixedUlid3("evt"),
|
|
1155
|
+
session_id: sessionId,
|
|
1156
|
+
occurred_at: now().toISOString(),
|
|
1157
|
+
source: "git-capability",
|
|
1158
|
+
...snapshot
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
function normalizeGitSnapshotSkipMessage(error) {
|
|
1162
|
+
if (!(error instanceof Error)) {
|
|
1163
|
+
return `git_snapshot skipped: ${String(error)}`;
|
|
1164
|
+
}
|
|
1165
|
+
const msg = error.message;
|
|
1166
|
+
if (msg === "Not a git repository") {
|
|
1167
|
+
return "git_snapshot skipped: not in a git repository";
|
|
1168
|
+
}
|
|
1169
|
+
if (msg === "Git executable not found in PATH. Install git first.") {
|
|
1170
|
+
return "git_snapshot skipped: git executable not found";
|
|
1171
|
+
}
|
|
1172
|
+
if (msg === "No commits in repository") {
|
|
1173
|
+
return "git_snapshot skipped: no commits in repository";
|
|
1174
|
+
}
|
|
1175
|
+
return `git_snapshot skipped: ${msg}`;
|
|
1176
|
+
}
|
|
1177
|
+
function buildInitialSession(input) {
|
|
1178
|
+
const cmdline = [input.command, ...input.args].join(" ");
|
|
1179
|
+
return {
|
|
1180
|
+
schema_version: "0.1.0",
|
|
1181
|
+
session: {
|
|
1182
|
+
id: input.id,
|
|
1183
|
+
label: `basou exec ${cmdline} (${input.startedAt})`,
|
|
1184
|
+
task_id: null,
|
|
1185
|
+
workspace_id: input.workspaceId,
|
|
1186
|
+
source: { kind: "terminal", version: "0.1.0" },
|
|
1187
|
+
started_at: input.startedAt,
|
|
1188
|
+
status: "initialized",
|
|
1189
|
+
working_directory: sanitizeWorkingDirectory(input.cwd, { homedir: homedir() }),
|
|
1190
|
+
invocation: {
|
|
1191
|
+
command: input.command,
|
|
1192
|
+
args: [...input.args],
|
|
1193
|
+
exit_code: null
|
|
1194
|
+
},
|
|
1195
|
+
related_files: [],
|
|
1196
|
+
events_log: "events.jsonl"
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
async function mutateSessionYaml(filePath, mutator) {
|
|
1201
|
+
const raw = await readYamlFile2(filePath);
|
|
1202
|
+
const parsed = SessionSchema.parse(raw);
|
|
1203
|
+
mutator(parsed);
|
|
1204
|
+
const validated = SessionSchema.parse(parsed);
|
|
1205
|
+
await overwriteYamlFile(filePath, validated);
|
|
1206
|
+
}
|
|
1207
|
+
async function finalizeSessionAsFailed(sessionDir, sessionYamlPath, sessionId, appendEvent2, ctx) {
|
|
1208
|
+
await appendEvent2(sessionDir, {
|
|
1209
|
+
schema_version: "0.1.0",
|
|
1210
|
+
type: "command_executed",
|
|
1211
|
+
id: prefixedUlid3("evt"),
|
|
1212
|
+
session_id: sessionId,
|
|
1213
|
+
occurred_at: ctx.occurredAt,
|
|
1214
|
+
source: "terminal-recording",
|
|
1215
|
+
command: ctx.command,
|
|
1216
|
+
args: ctx.args,
|
|
1217
|
+
cwd: ctx.cwd,
|
|
1218
|
+
exit_code: null,
|
|
1219
|
+
signal: null,
|
|
1220
|
+
...ctx.signalReceived !== null ? { received_signal: ctx.signalReceived } : {},
|
|
1221
|
+
duration_ms: 0
|
|
1222
|
+
});
|
|
1223
|
+
await appendEvent2(sessionDir, {
|
|
1224
|
+
schema_version: "0.1.0",
|
|
1225
|
+
type: "session_status_changed",
|
|
1226
|
+
id: prefixedUlid3("evt"),
|
|
1227
|
+
session_id: sessionId,
|
|
1228
|
+
occurred_at: ctx.occurredAt,
|
|
1229
|
+
source: "terminal-recording",
|
|
1230
|
+
from: "running",
|
|
1231
|
+
to: "failed"
|
|
1232
|
+
});
|
|
1233
|
+
await appendEvent2(sessionDir, {
|
|
1234
|
+
schema_version: "0.1.0",
|
|
1235
|
+
type: "session_ended",
|
|
1236
|
+
id: prefixedUlid3("evt"),
|
|
1237
|
+
session_id: sessionId,
|
|
1238
|
+
occurred_at: ctx.occurredAt,
|
|
1239
|
+
source: "terminal-recording"
|
|
1240
|
+
});
|
|
1241
|
+
await mutateSessionYaml(sessionYamlPath, (s) => {
|
|
1242
|
+
s.session.status = "failed";
|
|
1243
|
+
s.session.ended_at = ctx.occurredAt;
|
|
1244
|
+
s.session.invocation.exit_code = null;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
async function resolveRepositoryRootForExec(cwd) {
|
|
1248
|
+
try {
|
|
1249
|
+
return await resolveRepositoryRoot4(cwd);
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
1252
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou exec'.", {
|
|
1253
|
+
cause: error
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/commands/handoff.ts
|
|
1261
|
+
import {
|
|
1262
|
+
assertBasouRootSafe as assertBasouRootSafe5,
|
|
1263
|
+
basouPaths as basouPaths5,
|
|
1264
|
+
findErrorCode as findErrorCode4,
|
|
1265
|
+
readMarkdownFile as readMarkdownFile2,
|
|
1266
|
+
renderHandoff,
|
|
1267
|
+
renderWithMarkers as renderWithMarkers2,
|
|
1268
|
+
resolveRepositoryRoot as resolveRepositoryRoot5,
|
|
1269
|
+
writeMarkdownFile as writeMarkdownFile2
|
|
1270
|
+
} from "@basou/core";
|
|
1271
|
+
function registerHandoffCommand(program2) {
|
|
1272
|
+
const handoff = program2.command("handoff").description("Generate or inspect .basou/handoff.md");
|
|
1273
|
+
handoff.command("generate").description("Regenerate .basou/handoff.md from current session state").option("-v, --verbose", "Show error causes").action(async (opts) => {
|
|
1274
|
+
await runHandoffGenerate(opts);
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
async function runHandoffGenerate(options, ctx = {}) {
|
|
1278
|
+
try {
|
|
1279
|
+
await doRunHandoffGenerate(options, ctx);
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
1282
|
+
process.exitCode = 1;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async function doRunHandoffGenerate(options, ctx) {
|
|
1286
|
+
void options;
|
|
1287
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1288
|
+
const repositoryRoot = await resolveRepositoryRootForHandoff(cwd);
|
|
1289
|
+
const paths = basouPaths5(repositoryRoot);
|
|
1290
|
+
await assertWorkspaceInitialized4(paths.root);
|
|
1291
|
+
const nowIso = (ctx.nowProvider?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1292
|
+
const result = await renderHandoff({
|
|
1293
|
+
paths,
|
|
1294
|
+
nowIso,
|
|
1295
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
1296
|
+
onSessionSkip: (sid, reason) => printSessionSkip(sid, reason),
|
|
1297
|
+
onTaskSkip: (taskId, reason) => printTaskSkip(taskId, reason)
|
|
1298
|
+
});
|
|
1299
|
+
const existing = await readMarkdownFile2(paths.files.handoff);
|
|
1300
|
+
const finalBody = renderWithMarkers2(existing, result.body, "handoff.md");
|
|
1301
|
+
await writeMarkdownFile2(paths.files.handoff, finalBody);
|
|
1302
|
+
console.log(
|
|
1303
|
+
`Generated .basou/handoff.md (sessions: ${result.sessionCount}, tasks: ${result.taskCount}, decisions: ${result.decisionCount}, pending approvals: ${result.pendingApprovalsCount})`
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
async function resolveRepositoryRootForHandoff(cwd) {
|
|
1307
|
+
try {
|
|
1308
|
+
return await resolveRepositoryRoot5(cwd);
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
1311
|
+
throw new Error(
|
|
1312
|
+
"Not a git repository. Run 'git init' first, then re-run 'basou handoff generate'.",
|
|
1313
|
+
{ cause: error }
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
throw error;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
async function assertWorkspaceInitialized4(basouRoot) {
|
|
1320
|
+
try {
|
|
1321
|
+
await assertBasouRootSafe5(basouRoot);
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
if (findErrorCode4(error, "ENOENT")) {
|
|
1324
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
1325
|
+
}
|
|
1326
|
+
throw error;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/commands/init.ts
|
|
1331
|
+
import { basename } from "path";
|
|
1332
|
+
import {
|
|
1333
|
+
appendBasouGitignore,
|
|
1334
|
+
createManifest,
|
|
1335
|
+
ensureBasouDirectory,
|
|
1336
|
+
resolveRepositoryRoot as resolveRepositoryRoot6,
|
|
1337
|
+
tryRemoteUrl,
|
|
1338
|
+
writeManifest
|
|
1339
|
+
} from "@basou/core";
|
|
1340
|
+
function registerInitCommand(program2) {
|
|
1341
|
+
program2.command("init").description("Initialize a Basou workspace at the current Git repository root").option("--name <name>", "Workspace name (defaults to the repository directory name)").option("--project-name <name>", "Project display name").option("--project-description <description>", "Project description").option(
|
|
1342
|
+
"--repo-url <url>",
|
|
1343
|
+
"Repository URL (defaults to git remote.origin.url; pass empty string for null)"
|
|
1344
|
+
).option("-f, --force", "Overwrite an existing manifest").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
1345
|
+
await runInit(options);
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
async function runInit(options, ctx = {}) {
|
|
1349
|
+
try {
|
|
1350
|
+
await doRunInit(options, ctx);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
1353
|
+
process.exitCode = 1;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
async function doRunInit(options, ctx) {
|
|
1357
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1358
|
+
const repositoryRoot = await resolveRepositoryRootForInit(cwd);
|
|
1359
|
+
const workspaceName = options.name ?? basename(repositoryRoot);
|
|
1360
|
+
let repositoryUrl;
|
|
1361
|
+
if (options.repoUrl !== void 0) {
|
|
1362
|
+
repositoryUrl = options.repoUrl === "" ? null : options.repoUrl;
|
|
1363
|
+
} else {
|
|
1364
|
+
repositoryUrl = await tryRemoteUrl(repositoryRoot);
|
|
1365
|
+
}
|
|
1366
|
+
const paths = await ensureBasouDirectory(repositoryRoot);
|
|
1367
|
+
const manifest = createManifest({
|
|
1368
|
+
workspaceName,
|
|
1369
|
+
...options.projectName !== void 0 ? { projectName: options.projectName } : {},
|
|
1370
|
+
...options.projectDescription !== void 0 ? { projectDescription: options.projectDescription } : {},
|
|
1371
|
+
...repositoryUrl !== void 0 ? { repositoryUrl } : {}
|
|
1372
|
+
});
|
|
1373
|
+
await writeManifest(paths, manifest, { force: options.force === true });
|
|
1374
|
+
try {
|
|
1375
|
+
await appendBasouGitignore(repositoryRoot);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
renderGitignoreWarning(error, isVerbose(options));
|
|
1378
|
+
}
|
|
1379
|
+
console.log(`Initialized Basou workspace: ${manifest.workspace.id}`);
|
|
1380
|
+
}
|
|
1381
|
+
function renderGitignoreWarning(error, verbose) {
|
|
1382
|
+
const baseMessage = error instanceof Error ? error.message : String(error);
|
|
1383
|
+
console.error(
|
|
1384
|
+
`Warning: Could not update .gitignore (${baseMessage}). Add Basou's default .gitignore block manually.`
|
|
1385
|
+
);
|
|
1386
|
+
if (verbose && error instanceof Error) {
|
|
1387
|
+
const label = extractCauseLabel(error);
|
|
1388
|
+
if (label !== void 0) console.error(`Caused by: ${label}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
async function resolveRepositoryRootForInit(cwd) {
|
|
1392
|
+
try {
|
|
1393
|
+
return await resolveRepositoryRoot6(cwd);
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
1396
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou init'.", {
|
|
1397
|
+
cause: error
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
throw error;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// src/commands/run.ts
|
|
1405
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
1406
|
+
import { homedir as homedir2 } from "os";
|
|
1407
|
+
import { join as join3 } from "path";
|
|
1408
|
+
import {
|
|
1409
|
+
ChildProcessRunner as ChildProcessRunner2,
|
|
1410
|
+
SessionSchema as SessionSchema2,
|
|
1411
|
+
assertBasouRootSafe as assertBasouRootSafe6,
|
|
1412
|
+
basouPaths as basouPaths6,
|
|
1413
|
+
claudeCodeAdapterMetadata,
|
|
1414
|
+
appendEvent as coreAppendEvent2,
|
|
1415
|
+
getDiff,
|
|
1416
|
+
getSnapshot as getSnapshot2,
|
|
1417
|
+
overwriteYamlFile as overwriteYamlFile2,
|
|
1418
|
+
prefixedUlid as prefixedUlid4,
|
|
1419
|
+
readManifest as readManifest3,
|
|
1420
|
+
readYamlFile as readYamlFile3,
|
|
1421
|
+
resolveClaudeCodeCommand,
|
|
1422
|
+
resolveRepositoryRoot as resolveRepositoryRoot7,
|
|
1423
|
+
sanitizeRelatedFiles,
|
|
1424
|
+
sanitizeWorkingDirectory as sanitizeWorkingDirectory2,
|
|
1425
|
+
writeYamlFile as writeYamlFile2
|
|
1426
|
+
} from "@basou/core";
|
|
1427
|
+
function registerRunCommand(program2, ctx = {}) {
|
|
1428
|
+
const runCommand = program2.command("run").description("Run an AI coding tool through Basou as a tracked session").enablePositionalOptions().option("--no-snapshot", "Skip git_snapshot before/after the session").option("--cwd <path>", "Run from a Basou root other than process.cwd()").option("-v, --verbose", "Show error causes");
|
|
1429
|
+
runCommand.command("claude-code [args...]").description("Run Claude Code CLI as a Basou-tracked session").option("--no-snapshot", "Skip git_snapshot before/after the session").option("--cwd <path>", "Run from a Basou root other than process.cwd()").option("-v, --verbose", "Show error causes").passThroughOptions().action(async (args, options, command) => {
|
|
1430
|
+
const parentOptions = command.parent?.opts() ?? {};
|
|
1431
|
+
const snapshotOn = parentOptions.snapshot !== false && options.snapshot !== false;
|
|
1432
|
+
const merged = {
|
|
1433
|
+
...parentOptions,
|
|
1434
|
+
...options,
|
|
1435
|
+
snapshot: snapshotOn
|
|
1436
|
+
};
|
|
1437
|
+
try {
|
|
1438
|
+
const exitCode = await runClaudeCode(args, merged, ctx);
|
|
1439
|
+
process.exit(exitCode);
|
|
1440
|
+
} catch (error) {
|
|
1441
|
+
renderCliError(error, { verbose: isVerbose(merged) });
|
|
1442
|
+
process.exit(1);
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
async function runClaudeCode(args, options, ctx = {}) {
|
|
1447
|
+
const runner = ctx.runner ?? new ChildProcessRunner2();
|
|
1448
|
+
const now = ctx.now ?? (() => /* @__PURE__ */ new Date());
|
|
1449
|
+
const appendEvent2 = ctx.appendEvent ?? coreAppendEvent2;
|
|
1450
|
+
const resolveCommand = ctx.resolveCommand ?? resolveClaudeCodeCommand;
|
|
1451
|
+
const getDiffFn = ctx.getDiff ?? getDiff;
|
|
1452
|
+
const { command } = await resolveCommand();
|
|
1453
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1454
|
+
const repoRoot = await resolveRepositoryRootForRun(cwd);
|
|
1455
|
+
const paths = basouPaths6(repoRoot);
|
|
1456
|
+
await assertBasouRootSafe6(paths.root);
|
|
1457
|
+
const manifest = await readManifest3(paths);
|
|
1458
|
+
const sessionId = prefixedUlid4("ses");
|
|
1459
|
+
const sessionDir = join3(paths.sessions, sessionId);
|
|
1460
|
+
await mkdir2(sessionDir, { recursive: true });
|
|
1461
|
+
const startedAt = now().toISOString();
|
|
1462
|
+
const sessionYamlPath = join3(sessionDir, "session.yaml");
|
|
1463
|
+
const session = buildInitialSession2({
|
|
1464
|
+
id: sessionId,
|
|
1465
|
+
command,
|
|
1466
|
+
args,
|
|
1467
|
+
cwd: repoRoot,
|
|
1468
|
+
workspaceId: manifest.workspace.id,
|
|
1469
|
+
startedAt
|
|
1470
|
+
});
|
|
1471
|
+
await writeYamlFile2(sessionYamlPath, session);
|
|
1472
|
+
await appendEvent2(sessionDir, {
|
|
1473
|
+
schema_version: "0.1.0",
|
|
1474
|
+
type: "session_started",
|
|
1475
|
+
id: prefixedUlid4("evt"),
|
|
1476
|
+
session_id: sessionId,
|
|
1477
|
+
occurred_at: startedAt,
|
|
1478
|
+
source: claudeCodeAdapterMetadata.kind
|
|
1479
|
+
});
|
|
1480
|
+
let preSnapshot = null;
|
|
1481
|
+
if (options.snapshot !== false) {
|
|
1482
|
+
preSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2);
|
|
1483
|
+
}
|
|
1484
|
+
const runningAt = now().toISOString();
|
|
1485
|
+
await appendEvent2(sessionDir, {
|
|
1486
|
+
schema_version: "0.1.0",
|
|
1487
|
+
type: "session_status_changed",
|
|
1488
|
+
id: prefixedUlid4("evt"),
|
|
1489
|
+
session_id: sessionId,
|
|
1490
|
+
occurred_at: runningAt,
|
|
1491
|
+
source: claudeCodeAdapterMetadata.kind,
|
|
1492
|
+
from: "initialized",
|
|
1493
|
+
to: "running"
|
|
1494
|
+
});
|
|
1495
|
+
await mutateSessionYaml2(sessionYamlPath, (s) => {
|
|
1496
|
+
s.session.status = "running";
|
|
1497
|
+
});
|
|
1498
|
+
const controller = new AbortController();
|
|
1499
|
+
let signalReceived = null;
|
|
1500
|
+
let activeChild = null;
|
|
1501
|
+
const signalHandler = (sig) => {
|
|
1502
|
+
if (signalReceived !== null) return;
|
|
1503
|
+
signalReceived = sig;
|
|
1504
|
+
controller.abort();
|
|
1505
|
+
};
|
|
1506
|
+
const exitHandler = () => {
|
|
1507
|
+
if (activeChild !== null) {
|
|
1508
|
+
try {
|
|
1509
|
+
activeChild.kill("SIGKILL");
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
const onSigInt = () => signalHandler("SIGINT");
|
|
1515
|
+
const onSigTerm = () => signalHandler("SIGTERM");
|
|
1516
|
+
process.on("SIGINT", onSigInt);
|
|
1517
|
+
process.on("SIGTERM", onSigTerm);
|
|
1518
|
+
process.on("exit", exitHandler);
|
|
1519
|
+
ctx.onExitHookInstalled?.(exitHandler);
|
|
1520
|
+
let result;
|
|
1521
|
+
try {
|
|
1522
|
+
try {
|
|
1523
|
+
result = await runner.run(command, args, {
|
|
1524
|
+
cwd: repoRoot,
|
|
1525
|
+
capture: "none",
|
|
1526
|
+
signal: controller.signal,
|
|
1527
|
+
onSpawn: (child) => {
|
|
1528
|
+
activeChild = child;
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
} catch (spawnError) {
|
|
1532
|
+
await finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId, appendEvent2, {
|
|
1533
|
+
command,
|
|
1534
|
+
args,
|
|
1535
|
+
cwd: repoRoot,
|
|
1536
|
+
occurredAt: now().toISOString(),
|
|
1537
|
+
signalReceived
|
|
1538
|
+
});
|
|
1539
|
+
throw spawnError;
|
|
1540
|
+
}
|
|
1541
|
+
} finally {
|
|
1542
|
+
process.off("SIGINT", onSigInt);
|
|
1543
|
+
process.off("SIGTERM", onSigTerm);
|
|
1544
|
+
process.off("exit", exitHandler);
|
|
1545
|
+
activeChild = null;
|
|
1546
|
+
}
|
|
1547
|
+
const endedAt = now().toISOString();
|
|
1548
|
+
await appendEvent2(sessionDir, {
|
|
1549
|
+
schema_version: "0.1.0",
|
|
1550
|
+
type: "command_executed",
|
|
1551
|
+
id: prefixedUlid4("evt"),
|
|
1552
|
+
session_id: sessionId,
|
|
1553
|
+
occurred_at: endedAt,
|
|
1554
|
+
source: "terminal-recording",
|
|
1555
|
+
command,
|
|
1556
|
+
args,
|
|
1557
|
+
cwd: repoRoot,
|
|
1558
|
+
exit_code: result.exit_code,
|
|
1559
|
+
...result.signal !== null ? { signal: result.signal } : {},
|
|
1560
|
+
...signalReceived !== null ? { received_signal: signalReceived } : {},
|
|
1561
|
+
duration_ms: result.duration_ms
|
|
1562
|
+
});
|
|
1563
|
+
let postSnapshot = null;
|
|
1564
|
+
if (options.snapshot !== false) {
|
|
1565
|
+
postSnapshot = await tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2);
|
|
1566
|
+
}
|
|
1567
|
+
let diff = null;
|
|
1568
|
+
if (preSnapshot !== null && postSnapshot !== null) {
|
|
1569
|
+
diff = await tryAppendFileChangedEvents(
|
|
1570
|
+
sessionDir,
|
|
1571
|
+
sessionId,
|
|
1572
|
+
repoRoot,
|
|
1573
|
+
preSnapshot.head,
|
|
1574
|
+
postSnapshot.head,
|
|
1575
|
+
now().toISOString(),
|
|
1576
|
+
appendEvent2,
|
|
1577
|
+
getDiffFn
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
const rawRelated = computeRelatedFiles(preSnapshot, postSnapshot, diff);
|
|
1581
|
+
const relatedFiles = sanitizeRelatedFiles(rawRelated, {
|
|
1582
|
+
workingDirectory: repoRoot,
|
|
1583
|
+
homedir: homedir2()
|
|
1584
|
+
}).sanitized;
|
|
1585
|
+
const finalStatus = decideFinalStatus2(result, signalReceived);
|
|
1586
|
+
await appendEvent2(sessionDir, {
|
|
1587
|
+
schema_version: "0.1.0",
|
|
1588
|
+
type: "session_status_changed",
|
|
1589
|
+
id: prefixedUlid4("evt"),
|
|
1590
|
+
session_id: sessionId,
|
|
1591
|
+
occurred_at: endedAt,
|
|
1592
|
+
source: claudeCodeAdapterMetadata.kind,
|
|
1593
|
+
from: "running",
|
|
1594
|
+
to: finalStatus
|
|
1595
|
+
});
|
|
1596
|
+
await appendEvent2(sessionDir, {
|
|
1597
|
+
schema_version: "0.1.0",
|
|
1598
|
+
type: "session_ended",
|
|
1599
|
+
id: prefixedUlid4("evt"),
|
|
1600
|
+
session_id: sessionId,
|
|
1601
|
+
occurred_at: endedAt,
|
|
1602
|
+
source: claudeCodeAdapterMetadata.kind,
|
|
1603
|
+
...result.exit_code !== null ? { exit_code: result.exit_code } : {}
|
|
1604
|
+
});
|
|
1605
|
+
await mutateSessionYaml2(sessionYamlPath, (s) => {
|
|
1606
|
+
s.session.status = finalStatus;
|
|
1607
|
+
s.session.ended_at = endedAt;
|
|
1608
|
+
s.session.invocation.exit_code = result.exit_code;
|
|
1609
|
+
s.session.related_files = relatedFiles;
|
|
1610
|
+
});
|
|
1611
|
+
if (result.exit_code !== null) return result.exit_code;
|
|
1612
|
+
return signalToExitCode2(signalReceived ?? result.signal);
|
|
1613
|
+
}
|
|
1614
|
+
function decideFinalStatus2(result, signalReceived) {
|
|
1615
|
+
if (signalReceived === "SIGINT" || signalReceived === "SIGTERM") return "interrupted";
|
|
1616
|
+
if (result.signal === "SIGINT" || result.signal === "SIGTERM" || result.signal === "SIGKILL") {
|
|
1617
|
+
return "interrupted";
|
|
1618
|
+
}
|
|
1619
|
+
if (result.exit_code === 0) return "completed";
|
|
1620
|
+
return "failed";
|
|
1621
|
+
}
|
|
1622
|
+
var SIGNUM_MAP2 = {
|
|
1623
|
+
SIGHUP: 1,
|
|
1624
|
+
SIGINT: 2,
|
|
1625
|
+
SIGQUIT: 3,
|
|
1626
|
+
SIGKILL: 9,
|
|
1627
|
+
SIGTERM: 15
|
|
1628
|
+
};
|
|
1629
|
+
function signalToExitCode2(sig) {
|
|
1630
|
+
if (sig === null) return 1;
|
|
1631
|
+
const num = SIGNUM_MAP2[sig] ?? 1;
|
|
1632
|
+
return 128 + num;
|
|
1633
|
+
}
|
|
1634
|
+
async function tryAppendGitSnapshot2(sessionDir, sessionId, repoRoot, now, appendEvent2) {
|
|
1635
|
+
let snapshot;
|
|
1636
|
+
try {
|
|
1637
|
+
snapshot = await getSnapshot2(repoRoot);
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
console.warn(normalizeGitSnapshotSkipMessage2(error));
|
|
1640
|
+
return null;
|
|
1641
|
+
}
|
|
1642
|
+
await appendEvent2(sessionDir, {
|
|
1643
|
+
schema_version: "0.1.0",
|
|
1644
|
+
type: "git_snapshot",
|
|
1645
|
+
id: prefixedUlid4("evt"),
|
|
1646
|
+
session_id: sessionId,
|
|
1647
|
+
occurred_at: now().toISOString(),
|
|
1648
|
+
source: "git-capability",
|
|
1649
|
+
...snapshot
|
|
1650
|
+
});
|
|
1651
|
+
return snapshot;
|
|
1652
|
+
}
|
|
1653
|
+
async function tryAppendFileChangedEvents(sessionDir, sessionId, repoRoot, baseRef, headRef, occurredAt, appendEvent2, getDiffFn) {
|
|
1654
|
+
let diff;
|
|
1655
|
+
try {
|
|
1656
|
+
diff = await getDiffFn(repoRoot, baseRef, headRef);
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
console.warn(normalizeFileChangedSkipMessage(error));
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
for (const change of diff.changed_files) {
|
|
1662
|
+
await appendEvent2(sessionDir, {
|
|
1663
|
+
schema_version: "0.1.0",
|
|
1664
|
+
type: "file_changed",
|
|
1665
|
+
id: prefixedUlid4("evt"),
|
|
1666
|
+
session_id: sessionId,
|
|
1667
|
+
occurred_at: occurredAt,
|
|
1668
|
+
source: "git-capability",
|
|
1669
|
+
path: change.path,
|
|
1670
|
+
change_type: change.status,
|
|
1671
|
+
...change.old_path !== void 0 ? { old_path: change.old_path } : {}
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
return diff;
|
|
1675
|
+
}
|
|
1676
|
+
function computeRelatedFiles(preSnapshot, postSnapshot, diff) {
|
|
1677
|
+
const set = /* @__PURE__ */ new Set();
|
|
1678
|
+
for (const snap of [preSnapshot, postSnapshot]) {
|
|
1679
|
+
if (snap === null) continue;
|
|
1680
|
+
for (const p of snap.staged) set.add(p);
|
|
1681
|
+
for (const p of snap.unstaged) set.add(p);
|
|
1682
|
+
for (const p of snap.untracked) set.add(p);
|
|
1683
|
+
}
|
|
1684
|
+
if (diff !== null) {
|
|
1685
|
+
for (const change of diff.changed_files) set.add(change.path);
|
|
1686
|
+
}
|
|
1687
|
+
return [...set].sort();
|
|
1688
|
+
}
|
|
1689
|
+
function normalizeGitSnapshotSkipMessage2(error) {
|
|
1690
|
+
if (!(error instanceof Error)) {
|
|
1691
|
+
return `git_snapshot skipped: ${String(error)}`;
|
|
1692
|
+
}
|
|
1693
|
+
const msg = error.message;
|
|
1694
|
+
if (msg === "Not a git repository") return "git_snapshot skipped: not in a git repository";
|
|
1695
|
+
if (msg === "Git executable not found in PATH. Install git first.") {
|
|
1696
|
+
return "git_snapshot skipped: git executable not found";
|
|
1697
|
+
}
|
|
1698
|
+
if (msg === "No commits in repository") return "git_snapshot skipped: no commits in repository";
|
|
1699
|
+
return `git_snapshot skipped: ${msg}`;
|
|
1700
|
+
}
|
|
1701
|
+
function normalizeFileChangedSkipMessage(error) {
|
|
1702
|
+
if (!(error instanceof Error)) {
|
|
1703
|
+
return `file_changed skipped: ${String(error)}`;
|
|
1704
|
+
}
|
|
1705
|
+
const msg = error.message;
|
|
1706
|
+
if (msg === "Not a git repository") return "file_changed skipped: not in a git repository";
|
|
1707
|
+
if (msg === "Git executable not found in PATH. Install git first.") {
|
|
1708
|
+
return "file_changed skipped: git executable not found";
|
|
1709
|
+
}
|
|
1710
|
+
if (msg === "Invalid ref") return "file_changed skipped: invalid git ref";
|
|
1711
|
+
if (msg === "Failed to compute git diff")
|
|
1712
|
+
return "file_changed skipped: failed to compute git diff";
|
|
1713
|
+
return `file_changed skipped: ${msg}`;
|
|
1714
|
+
}
|
|
1715
|
+
function buildInitialSession2(input) {
|
|
1716
|
+
const cmdline = [input.command, ...input.args].join(" ");
|
|
1717
|
+
return {
|
|
1718
|
+
schema_version: "0.1.0",
|
|
1719
|
+
session: {
|
|
1720
|
+
id: input.id,
|
|
1721
|
+
label: `basou run ${cmdline} (${input.startedAt})`,
|
|
1722
|
+
task_id: null,
|
|
1723
|
+
workspace_id: input.workspaceId,
|
|
1724
|
+
source: { ...claudeCodeAdapterMetadata },
|
|
1725
|
+
started_at: input.startedAt,
|
|
1726
|
+
status: "initialized",
|
|
1727
|
+
working_directory: sanitizeWorkingDirectory2(input.cwd, { homedir: homedir2() }),
|
|
1728
|
+
invocation: {
|
|
1729
|
+
command: input.command,
|
|
1730
|
+
args: [...input.args],
|
|
1731
|
+
exit_code: null
|
|
1732
|
+
},
|
|
1733
|
+
related_files: [],
|
|
1734
|
+
events_log: "events.jsonl"
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
async function mutateSessionYaml2(filePath, mutator) {
|
|
1739
|
+
const raw = await readYamlFile3(filePath);
|
|
1740
|
+
const parsed = SessionSchema2.parse(raw);
|
|
1741
|
+
mutator(parsed);
|
|
1742
|
+
const validated = SessionSchema2.parse(parsed);
|
|
1743
|
+
await overwriteYamlFile2(filePath, validated);
|
|
1744
|
+
}
|
|
1745
|
+
async function finalizeSessionAsFailed2(sessionDir, sessionYamlPath, sessionId, appendEvent2, ctx) {
|
|
1746
|
+
await appendEvent2(sessionDir, {
|
|
1747
|
+
schema_version: "0.1.0",
|
|
1748
|
+
type: "command_executed",
|
|
1749
|
+
id: prefixedUlid4("evt"),
|
|
1750
|
+
session_id: sessionId,
|
|
1751
|
+
occurred_at: ctx.occurredAt,
|
|
1752
|
+
source: "terminal-recording",
|
|
1753
|
+
command: ctx.command,
|
|
1754
|
+
args: ctx.args,
|
|
1755
|
+
cwd: ctx.cwd,
|
|
1756
|
+
exit_code: null,
|
|
1757
|
+
signal: null,
|
|
1758
|
+
...ctx.signalReceived !== null ? { received_signal: ctx.signalReceived } : {},
|
|
1759
|
+
duration_ms: 0
|
|
1760
|
+
});
|
|
1761
|
+
await appendEvent2(sessionDir, {
|
|
1762
|
+
schema_version: "0.1.0",
|
|
1763
|
+
type: "session_status_changed",
|
|
1764
|
+
id: prefixedUlid4("evt"),
|
|
1765
|
+
session_id: sessionId,
|
|
1766
|
+
occurred_at: ctx.occurredAt,
|
|
1767
|
+
source: claudeCodeAdapterMetadata.kind,
|
|
1768
|
+
from: "running",
|
|
1769
|
+
to: "failed"
|
|
1770
|
+
});
|
|
1771
|
+
await appendEvent2(sessionDir, {
|
|
1772
|
+
schema_version: "0.1.0",
|
|
1773
|
+
type: "session_ended",
|
|
1774
|
+
id: prefixedUlid4("evt"),
|
|
1775
|
+
session_id: sessionId,
|
|
1776
|
+
occurred_at: ctx.occurredAt,
|
|
1777
|
+
source: claudeCodeAdapterMetadata.kind
|
|
1778
|
+
});
|
|
1779
|
+
await mutateSessionYaml2(sessionYamlPath, (s) => {
|
|
1780
|
+
s.session.status = "failed";
|
|
1781
|
+
s.session.ended_at = ctx.occurredAt;
|
|
1782
|
+
s.session.invocation.exit_code = null;
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
async function resolveRepositoryRootForRun(cwd) {
|
|
1786
|
+
try {
|
|
1787
|
+
return await resolveRepositoryRoot7(cwd);
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
1790
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou run'.", {
|
|
1791
|
+
cause: error
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
throw error;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/commands/session.ts
|
|
1799
|
+
import { readFile } from "fs/promises";
|
|
1800
|
+
import { basename as basename2, isAbsolute, join as join4, relative } from "path";
|
|
1801
|
+
import {
|
|
1802
|
+
SessionImportPayloadSchema,
|
|
1803
|
+
SessionSchema as SessionSchema3,
|
|
1804
|
+
SessionStatusSchema,
|
|
1805
|
+
acquireLock as acquireLock2,
|
|
1806
|
+
appendEventToExistingSession as appendEventToExistingSession2,
|
|
1807
|
+
assertBasouRootSafe as assertBasouRootSafe7,
|
|
1808
|
+
basouPaths as basouPaths7,
|
|
1809
|
+
findErrorCode as findErrorCode5,
|
|
1810
|
+
importSessionFromJson,
|
|
1811
|
+
loadSessionEntries,
|
|
1812
|
+
readAllEvents,
|
|
1813
|
+
readManifest as readManifest4,
|
|
1814
|
+
readYamlFile as readYamlFile4,
|
|
1815
|
+
resolveRepositoryRoot as resolveRepositoryRoot8,
|
|
1816
|
+
resolveSessionId as resolveSessionId2,
|
|
1817
|
+
resolveTaskId
|
|
1818
|
+
} from "@basou/core";
|
|
1819
|
+
import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
|
|
1820
|
+
var SES_PREFIX2 = "ses_";
|
|
1821
|
+
var TASK_PREFIX2 = "task_";
|
|
1822
|
+
var SHORT_ID_BASE_LEN2 = 6;
|
|
1823
|
+
var SHORT_ID_MAX_LEN2 = 26;
|
|
1824
|
+
var STATUS_VALUES2 = SessionStatusSchema.options;
|
|
1825
|
+
function registerSessionCommand(program2) {
|
|
1826
|
+
const session = program2.command("session").description("Inspect Basou sessions stored under .basou/sessions/");
|
|
1827
|
+
session.command("list").description("List sessions in the current workspace (newest first)").option("--json", "Output the list as a JSON array").option(
|
|
1828
|
+
"--status <state>",
|
|
1829
|
+
`Filter by session status (one of: ${STATUS_VALUES2.join(", ")})`,
|
|
1830
|
+
parseSessionStatus
|
|
1831
|
+
).option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
1832
|
+
await runSessionList(options);
|
|
1833
|
+
});
|
|
1834
|
+
session.command("show <id>").description("Show a session's metadata and recent events").option("--json", "Output the session and events as JSON").option("--events", "List all events instead of just the trailing few").option("--last <n>", "Number of trailing events to display (default: 5)", parsePositiveInt).option(
|
|
1835
|
+
"--full-path",
|
|
1836
|
+
"Show working_directory as an absolute path instead of repository-relative"
|
|
1837
|
+
).option("-v, --verbose", "Show error causes").action(async (id, options) => {
|
|
1838
|
+
await runSessionShow(id, options);
|
|
1839
|
+
});
|
|
1840
|
+
session.command("import").description("Import a session from a JSON file").requiredOption("--format <format>", "Input format (currently only 'json')", parseImportFormat).requiredOption("--from <path>", "Path to the input JSON file").option("--label <text>", "Override the session label", parseLabelOverride).option("--task <task_id>", "Override the session task_id", parseTaskIdOverride).option("--dry-run", "Validate input only; do not write to disk").option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
1841
|
+
await runSessionImport(options);
|
|
1842
|
+
});
|
|
1843
|
+
session.command("note <session_id>").description("Append a note_added event to an existing session").option("--body <text>", "Note body (inline)", parseNoteBodyOption).option("--from-file <path>", "Read note body from a file").option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (sessionIdInput, options) => {
|
|
1844
|
+
await runSessionNote(sessionIdInput, options);
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
async function runSessionList(options, ctx = {}) {
|
|
1848
|
+
try {
|
|
1849
|
+
await doRunSessionList(options, ctx);
|
|
1850
|
+
} catch (error) {
|
|
1851
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
1852
|
+
process.exitCode = 1;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
async function doRunSessionList(options, ctx) {
|
|
1856
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1857
|
+
const repositoryRoot = await resolveRepositoryRootForSession(cwd, "list");
|
|
1858
|
+
const paths = basouPaths7(repositoryRoot);
|
|
1859
|
+
await assertWorkspaceInitialized5(paths.root);
|
|
1860
|
+
const now = /* @__PURE__ */ new Date();
|
|
1861
|
+
const records = (await loadSessionEntries(paths, {
|
|
1862
|
+
now,
|
|
1863
|
+
onWarning: (w, sid) => printReplayWarning(w, sid),
|
|
1864
|
+
onSkip: (sid, reason) => printSessionListSkip(sid, reason)
|
|
1865
|
+
})).map((entry) => ({
|
|
1866
|
+
sessionId: entry.sessionId,
|
|
1867
|
+
session: entry.session,
|
|
1868
|
+
suspect: entry.suspect,
|
|
1869
|
+
suspectReason: entry.suspectReason
|
|
1870
|
+
}));
|
|
1871
|
+
if (records.length === 0) {
|
|
1872
|
+
printNoSessions(options);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
records.sort(
|
|
1876
|
+
(a, b) => Date.parse(b.session.session.started_at) - Date.parse(a.session.session.started_at)
|
|
1877
|
+
);
|
|
1878
|
+
const filtered = options.status !== void 0 ? records.filter((r) => r.session.session.status === options.status) : records;
|
|
1879
|
+
if (filtered.length === 0) {
|
|
1880
|
+
printNoSessions(options);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (options.json === true) {
|
|
1884
|
+
console.log(
|
|
1885
|
+
JSON.stringify(
|
|
1886
|
+
filtered.map((r) => ({
|
|
1887
|
+
...r.session.session,
|
|
1888
|
+
suspect: r.suspect,
|
|
1889
|
+
suspect_reason: r.suspectReason
|
|
1890
|
+
})),
|
|
1891
|
+
null,
|
|
1892
|
+
2
|
|
1893
|
+
)
|
|
1894
|
+
);
|
|
1895
|
+
} else {
|
|
1896
|
+
printSessionListText(filtered);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
async function runSessionShow(idInput, options, ctx = {}) {
|
|
1900
|
+
try {
|
|
1901
|
+
await doRunSessionShow(idInput, options, ctx);
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
1904
|
+
process.exitCode = 1;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
async function doRunSessionShow(idInput, options, ctx) {
|
|
1908
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1909
|
+
const repositoryRoot = await resolveRepositoryRootForSession(cwd, "show");
|
|
1910
|
+
const paths = basouPaths7(repositoryRoot);
|
|
1911
|
+
await assertWorkspaceInitialized5(paths.root);
|
|
1912
|
+
const sessionId = await resolveSessionId2(paths, idInput);
|
|
1913
|
+
const sessionDir = join4(paths.sessions, sessionId);
|
|
1914
|
+
const sessionYamlPath = join4(sessionDir, "session.yaml");
|
|
1915
|
+
let session;
|
|
1916
|
+
try {
|
|
1917
|
+
const raw = await readYamlFile4(sessionYamlPath);
|
|
1918
|
+
session = SessionSchema3.parse(raw);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
1921
|
+
throw new Error(`Session not found: ${idInput}`);
|
|
1922
|
+
}
|
|
1923
|
+
throw new Error("Failed to read session", { cause: error });
|
|
1924
|
+
}
|
|
1925
|
+
const events = await readAllEvents(sessionDir, {
|
|
1926
|
+
onWarning: (w) => printReplayWarning(w, sessionId)
|
|
1927
|
+
});
|
|
1928
|
+
if (options.json === true) {
|
|
1929
|
+
console.log(JSON.stringify({ session: session.session, events }, null, 2));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
printSessionShowText(session, events, options, repositoryRoot);
|
|
1933
|
+
}
|
|
1934
|
+
function suspectLabel(reason) {
|
|
1935
|
+
if (reason === "events_say_ended_but_yaml_running") return " \u26A0 ended (yaml stale)";
|
|
1936
|
+
if (reason === "running_no_end_event") return " \u26A0 no end event";
|
|
1937
|
+
return "";
|
|
1938
|
+
}
|
|
1939
|
+
function printSessionListText(records) {
|
|
1940
|
+
const shortLen = computeUniquePrefixLen2(records.map((r) => r.sessionId));
|
|
1941
|
+
const rows = records.map((r) => {
|
|
1942
|
+
const sid = sliceShort2(r.sessionId, shortLen);
|
|
1943
|
+
const status = `${r.session.session.status}${suspectLabel(r.suspectReason)}`;
|
|
1944
|
+
const source = r.session.session.source.kind;
|
|
1945
|
+
const startedAt = r.session.session.started_at;
|
|
1946
|
+
const fileCount = r.session.session.related_files.length;
|
|
1947
|
+
const filesSuffix = fileCount > 0 ? ` (${fileCount} files)` : "";
|
|
1948
|
+
const label = (r.session.session.label ?? "") + filesSuffix;
|
|
1949
|
+
return { sid, status, source, startedAt, label };
|
|
1950
|
+
});
|
|
1951
|
+
const widths = {
|
|
1952
|
+
sid: maxLen2(
|
|
1953
|
+
rows.map((r) => r.sid),
|
|
1954
|
+
"SHORT_ID".length
|
|
1955
|
+
),
|
|
1956
|
+
status: maxLen2(
|
|
1957
|
+
rows.map((r) => r.status),
|
|
1958
|
+
"STATUS".length
|
|
1959
|
+
),
|
|
1960
|
+
source: maxLen2(
|
|
1961
|
+
rows.map((r) => r.source),
|
|
1962
|
+
"SOURCE".length
|
|
1963
|
+
),
|
|
1964
|
+
startedAt: maxLen2(
|
|
1965
|
+
rows.map((r) => r.startedAt),
|
|
1966
|
+
"STARTED_AT".length
|
|
1967
|
+
)
|
|
1968
|
+
};
|
|
1969
|
+
console.log(
|
|
1970
|
+
`${pad2("SHORT_ID", widths.sid)} ${pad2("STATUS", widths.status)} ${pad2("SOURCE", widths.source)} ${pad2("STARTED_AT", widths.startedAt)} LABEL`
|
|
1971
|
+
);
|
|
1972
|
+
for (const row of rows) {
|
|
1973
|
+
console.log(
|
|
1974
|
+
`${pad2(row.sid, widths.sid)} ${pad2(row.status, widths.status)} ${pad2(row.source, widths.source)} ${pad2(row.startedAt, widths.startedAt)} ${row.label}`
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
function printSessionShowText(session, events, options, repositoryRoot) {
|
|
1979
|
+
const s = session.session;
|
|
1980
|
+
console.log(`Session: ${s.id} (status: ${s.status})`);
|
|
1981
|
+
console.log(`Source: ${s.source.kind} (v${s.source.version})`);
|
|
1982
|
+
console.log(`Workspace: ${s.workspace_id}`);
|
|
1983
|
+
console.log(`Started at: ${s.started_at}`);
|
|
1984
|
+
if (s.ended_at !== void 0) {
|
|
1985
|
+
console.log(`Ended at: ${s.ended_at}`);
|
|
1986
|
+
}
|
|
1987
|
+
console.log(`Working dir: ${formatWorkingDir(s.working_directory, repositoryRoot, options)}`);
|
|
1988
|
+
const invocationArgs = s.invocation.args.length > 0 ? ` ${s.invocation.args.join(" ")}` : "";
|
|
1989
|
+
console.log(`Invocation: ${s.invocation.command}${invocationArgs}`);
|
|
1990
|
+
if (s.invocation.exit_code !== null) {
|
|
1991
|
+
console.log(`Exit code: ${s.invocation.exit_code}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (s.label !== void 0) {
|
|
1994
|
+
console.log(`Label: ${s.label}`);
|
|
1995
|
+
}
|
|
1996
|
+
console.log(`Related files: ${formatRelatedFiles(s.related_files)}`);
|
|
1997
|
+
console.log("");
|
|
1998
|
+
console.log(`Events: ${events.length} total`);
|
|
1999
|
+
const counts = countByType(events);
|
|
2000
|
+
for (const [type, n] of counts) {
|
|
2001
|
+
console.log(` ${pad2(`${type}:`, 24)} ${n}`);
|
|
2002
|
+
}
|
|
2003
|
+
if (events.length === 0) return;
|
|
2004
|
+
const last = options.last ?? 5;
|
|
2005
|
+
const showAll = options.events === true && options.last === void 0;
|
|
2006
|
+
const slice = showAll ? events : events.slice(-last);
|
|
2007
|
+
const heading = showAll ? "All events:" : `Last ${slice.length} events:`;
|
|
2008
|
+
console.log("");
|
|
2009
|
+
console.log(heading);
|
|
2010
|
+
for (const ev of slice) {
|
|
2011
|
+
console.log(` ${formatEventLine(ev)}`);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
function formatWorkingDir(workingDir, repositoryRoot, options) {
|
|
2015
|
+
if (options.fullPath === true) return workingDir;
|
|
2016
|
+
if (!isAbsolute(workingDir)) {
|
|
2017
|
+
if (workingDir === ".") return "<repository_root>";
|
|
2018
|
+
return workingDir;
|
|
2019
|
+
}
|
|
2020
|
+
if (workingDir === repositoryRoot) return "<repository_root>";
|
|
2021
|
+
const rel = relative(repositoryRoot, workingDir);
|
|
2022
|
+
if (rel.length === 0 || rel === ".") return "<repository_root>";
|
|
2023
|
+
if (rel.startsWith("..")) return rel;
|
|
2024
|
+
return `./${rel}`;
|
|
2025
|
+
}
|
|
2026
|
+
function formatRelatedFiles(files) {
|
|
2027
|
+
if (files.length === 0) return "0 paths";
|
|
2028
|
+
const head = files.slice(0, 3).join(", ");
|
|
2029
|
+
const remaining = files.length - 3;
|
|
2030
|
+
if (remaining <= 0) return `${files.length} paths (${head})`;
|
|
2031
|
+
return `${files.length} paths (${head}, ... +${remaining} more)`;
|
|
2032
|
+
}
|
|
2033
|
+
function countByType(events) {
|
|
2034
|
+
const map = /* @__PURE__ */ new Map();
|
|
2035
|
+
for (const ev of events) {
|
|
2036
|
+
map.set(ev.type, (map.get(ev.type) ?? 0) + 1);
|
|
2037
|
+
}
|
|
2038
|
+
return [...map.entries()];
|
|
2039
|
+
}
|
|
2040
|
+
function formatEventLine(ev) {
|
|
2041
|
+
return `${ev.occurred_at} [${ev.source}] ${ev.type} ${eventVariantSummary(ev)}`;
|
|
2042
|
+
}
|
|
2043
|
+
function eventVariantSummary(ev) {
|
|
2044
|
+
switch (ev.type) {
|
|
2045
|
+
case "command_executed": {
|
|
2046
|
+
const argsPart = ev.args.length > 0 ? ` ${ev.args.join(" ")}` : "";
|
|
2047
|
+
const exitPart = ev.exit_code === null ? "exit=signal" : `exit=${ev.exit_code}`;
|
|
2048
|
+
return `${ev.command}${argsPart} (${exitPart}, ${ev.duration_ms}ms)`;
|
|
2049
|
+
}
|
|
2050
|
+
case "git_snapshot":
|
|
2051
|
+
return `branch=${ev.branch} dirty=${ev.dirty}`;
|
|
2052
|
+
case "file_changed":
|
|
2053
|
+
return `${ev.change_type} ${ev.path}`;
|
|
2054
|
+
case "session_status_changed":
|
|
2055
|
+
return `${ev.from} -> ${ev.to}`;
|
|
2056
|
+
case "session_started":
|
|
2057
|
+
return "(start)";
|
|
2058
|
+
case "session_ended":
|
|
2059
|
+
return ev.exit_code !== void 0 ? `exit_code=${ev.exit_code}` : "(end)";
|
|
2060
|
+
case "approval_requested":
|
|
2061
|
+
return `${ev.action.kind} risk=${ev.risk_level}`;
|
|
2062
|
+
case "approval_approved":
|
|
2063
|
+
return ev.resolver !== void 0 ? `by ${ev.resolver}` : "(approved)";
|
|
2064
|
+
case "approval_rejected":
|
|
2065
|
+
return ev.resolver !== void 0 ? `by ${ev.resolver}: ${ev.reason}` : ev.reason;
|
|
2066
|
+
case "approval_expired":
|
|
2067
|
+
return `approval=${ev.approval_id}`;
|
|
2068
|
+
case "decision_recorded":
|
|
2069
|
+
return ev.title;
|
|
2070
|
+
case "task_created":
|
|
2071
|
+
return ev.title;
|
|
2072
|
+
case "task_status_changed":
|
|
2073
|
+
return `${ev.from} -> ${ev.to}`;
|
|
2074
|
+
case "task_reconciled": {
|
|
2075
|
+
const createdPart = ev.removed_created_in_session !== null ? "1 created_in_session" : "0 created_in_session";
|
|
2076
|
+
return `task ${shortTaskId2(ev.task_id)}: cleared ${ev.removed_linked_sessions.length} linked + ${createdPart}`;
|
|
2077
|
+
}
|
|
2078
|
+
case "task_linkage_refreshed": {
|
|
2079
|
+
const added = ev.added_linked_sessions.length;
|
|
2080
|
+
const removed = ev.removed_linked_sessions.length;
|
|
2081
|
+
const final = ev.final_count !== void 0 ? ` final=${ev.final_count}` : "";
|
|
2082
|
+
return `task ${shortTaskId2(ev.task_id)}: +${added} / -${removed} linked${final}`;
|
|
2083
|
+
}
|
|
2084
|
+
case "task_deleted":
|
|
2085
|
+
return `task ${shortTaskId2(ev.task_id)}: ${ev.title} (deleted)`;
|
|
2086
|
+
case "task_archived":
|
|
2087
|
+
return `task ${shortTaskId2(ev.task_id)}: ${ev.title} (archived)`;
|
|
2088
|
+
case "note_added":
|
|
2089
|
+
return ev.body.length > 80 ? `${ev.body.slice(0, 77)}...` : ev.body;
|
|
2090
|
+
case "adapter_output":
|
|
2091
|
+
return `${ev.stream} "${ev.summary}" raw_ref=${ev.raw_ref}`;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
function shortId2(id) {
|
|
2095
|
+
return sliceShort2(id, SHORT_ID_BASE_LEN2);
|
|
2096
|
+
}
|
|
2097
|
+
function shortTaskId2(id) {
|
|
2098
|
+
if (id.startsWith(TASK_PREFIX2)) {
|
|
2099
|
+
return id.slice(TASK_PREFIX2.length, TASK_PREFIX2.length + SHORT_ID_BASE_LEN2);
|
|
2100
|
+
}
|
|
2101
|
+
return id.slice(0, SHORT_ID_BASE_LEN2);
|
|
2102
|
+
}
|
|
2103
|
+
function sliceShort2(id, len) {
|
|
2104
|
+
if (id.startsWith(SES_PREFIX2)) {
|
|
2105
|
+
return id.slice(SES_PREFIX2.length, SES_PREFIX2.length + len);
|
|
2106
|
+
}
|
|
2107
|
+
return id.slice(0, len);
|
|
2108
|
+
}
|
|
2109
|
+
function computeUniquePrefixLen2(sessionIds) {
|
|
2110
|
+
if (sessionIds.length <= 1) return SHORT_ID_BASE_LEN2;
|
|
2111
|
+
for (let len = SHORT_ID_BASE_LEN2; len <= SHORT_ID_MAX_LEN2; len += 2) {
|
|
2112
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2113
|
+
let collided = false;
|
|
2114
|
+
for (const sid of sessionIds) {
|
|
2115
|
+
const key = sliceShort2(sid, len);
|
|
2116
|
+
if (seen.has(key)) {
|
|
2117
|
+
collided = true;
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
seen.add(key);
|
|
2121
|
+
}
|
|
2122
|
+
if (!collided) return len;
|
|
2123
|
+
}
|
|
2124
|
+
return SHORT_ID_MAX_LEN2;
|
|
2125
|
+
}
|
|
2126
|
+
function pad2(value, width) {
|
|
2127
|
+
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
2128
|
+
}
|
|
2129
|
+
function maxLen2(values, floor) {
|
|
2130
|
+
let max = floor;
|
|
2131
|
+
for (const v of values) if (v.length > max) max = v.length;
|
|
2132
|
+
return max;
|
|
2133
|
+
}
|
|
2134
|
+
async function resolveRepositoryRootForSession(cwd, subcmd) {
|
|
2135
|
+
try {
|
|
2136
|
+
return await resolveRepositoryRoot8(cwd);
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
2139
|
+
throw new Error(
|
|
2140
|
+
`Not a git repository. Run 'git init' first, then re-run 'basou session ${subcmd}'.`,
|
|
2141
|
+
{ cause: error }
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
throw error;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
async function assertWorkspaceInitialized5(basouRoot) {
|
|
2148
|
+
try {
|
|
2149
|
+
await assertBasouRootSafe7(basouRoot);
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
2152
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
2153
|
+
}
|
|
2154
|
+
throw error;
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function parsePositiveInt(raw) {
|
|
2158
|
+
const n = Number.parseInt(raw, 10);
|
|
2159
|
+
if (!Number.isInteger(n) || n < 1 || raw.trim() !== String(n)) {
|
|
2160
|
+
throw new Error(`Invalid number: ${raw}`);
|
|
2161
|
+
}
|
|
2162
|
+
return n;
|
|
2163
|
+
}
|
|
2164
|
+
function parseSessionStatus(raw) {
|
|
2165
|
+
const result = SessionStatusSchema.safeParse(raw);
|
|
2166
|
+
if (!result.success) {
|
|
2167
|
+
throw new Error(`Invalid session status: ${raw}. Valid values: ${STATUS_VALUES2.join(", ")}`);
|
|
2168
|
+
}
|
|
2169
|
+
return result.data;
|
|
2170
|
+
}
|
|
2171
|
+
function printNoSessions(options) {
|
|
2172
|
+
if (options.json === true) {
|
|
2173
|
+
console.log("[]");
|
|
2174
|
+
} else {
|
|
2175
|
+
console.log("No sessions found.");
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function runSessionImport(options, ctx = {}) {
|
|
2179
|
+
try {
|
|
2180
|
+
await doRunSessionImport(options, ctx);
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
2183
|
+
process.exitCode = 1;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
async function doRunSessionImport(options, ctx) {
|
|
2187
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2188
|
+
const repositoryRoot = await resolveRepositoryRootForSession(cwd, "import");
|
|
2189
|
+
const paths = basouPaths7(repositoryRoot);
|
|
2190
|
+
await assertWorkspaceInitialized5(paths.root);
|
|
2191
|
+
const manifest = await readManifest4(paths);
|
|
2192
|
+
const rawBody = await readInputFile(options.from);
|
|
2193
|
+
const json = parseJsonStrict(rawBody);
|
|
2194
|
+
const parsed = SessionImportPayloadSchema.safeParse(json);
|
|
2195
|
+
if (!parsed.success) {
|
|
2196
|
+
throw new Error("Invalid import payload", { cause: parsed.error });
|
|
2197
|
+
}
|
|
2198
|
+
if (parsed.data.schema_version !== "0.1.0") {
|
|
2199
|
+
throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`);
|
|
2200
|
+
}
|
|
2201
|
+
const importOptions = { dryRun: options.dryRun === true };
|
|
2202
|
+
if (options.label !== void 0) importOptions.labelOverride = options.label;
|
|
2203
|
+
if (options.task !== void 0) {
|
|
2204
|
+
importOptions.taskIdOverride = await resolveTaskId(paths, options.task);
|
|
2205
|
+
}
|
|
2206
|
+
const result = await importSessionFromJson(paths, manifest, parsed.data, importOptions);
|
|
2207
|
+
const sanitizeReport = result.pathSanitizeReport;
|
|
2208
|
+
if (sanitizeReport.relatedFiles > 0 || sanitizeReport.workingDirectoryRewritten) {
|
|
2209
|
+
const wdCount = sanitizeReport.workingDirectoryRewritten ? 1 : 0;
|
|
2210
|
+
console.error(
|
|
2211
|
+
`Imported session: ${sanitizeReport.relatedFiles + wdCount} path(s) sanitized (related_files: ${sanitizeReport.relatedFiles}, working_directory: ${wdCount})`
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
printSessionImportResult(options, result);
|
|
2215
|
+
}
|
|
2216
|
+
async function readInputFile(path) {
|
|
2217
|
+
try {
|
|
2218
|
+
return await readFile(path, "utf8");
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
2221
|
+
throw new Error("Import source not found", { cause: error });
|
|
2222
|
+
}
|
|
2223
|
+
if (findErrorCode5(error, "EISDIR")) {
|
|
2224
|
+
throw new Error("Import source is not a file", { cause: error });
|
|
2225
|
+
}
|
|
2226
|
+
throw new Error("Failed to read import source", { cause: error });
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
function parseJsonStrict(body) {
|
|
2230
|
+
try {
|
|
2231
|
+
return JSON.parse(body);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
throw new Error("Failed to parse import JSON", { cause: error });
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
function parseImportFormat(raw) {
|
|
2237
|
+
if (raw !== "json") {
|
|
2238
|
+
throw new InvalidArgumentError2(`Unsupported format: ${raw}. Valid values: json`);
|
|
2239
|
+
}
|
|
2240
|
+
return "json";
|
|
2241
|
+
}
|
|
2242
|
+
function parseLabelOverride(raw) {
|
|
2243
|
+
if (raw.length === 0) {
|
|
2244
|
+
throw new InvalidArgumentError2("Label must not be empty");
|
|
2245
|
+
}
|
|
2246
|
+
return raw;
|
|
2247
|
+
}
|
|
2248
|
+
function parseTaskIdOverride(raw) {
|
|
2249
|
+
if (raw.length === 0) {
|
|
2250
|
+
throw new InvalidArgumentError2("Task id is empty");
|
|
2251
|
+
}
|
|
2252
|
+
return raw;
|
|
2253
|
+
}
|
|
2254
|
+
function printSessionImportResult(options, result) {
|
|
2255
|
+
const isDry = options.dryRun === true;
|
|
2256
|
+
const sid = shortId2(result.sessionId);
|
|
2257
|
+
if (options.json === true) {
|
|
2258
|
+
console.log(
|
|
2259
|
+
JSON.stringify({
|
|
2260
|
+
session_id: result.sessionId,
|
|
2261
|
+
event_count: result.eventCount,
|
|
2262
|
+
dry_run: isDry,
|
|
2263
|
+
source: { kind: result.finalSourceKind, version: "0.1.0" },
|
|
2264
|
+
status: result.finalStatus
|
|
2265
|
+
})
|
|
2266
|
+
);
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
if (isDry) {
|
|
2270
|
+
console.log(
|
|
2271
|
+
`Dry run: would import ${result.eventCount} events into ${sid} (illustrative ID; not reserved, no files written)`
|
|
2272
|
+
);
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
console.log(
|
|
2276
|
+
`Imported session ${sid} (${result.eventCount} events) from ${basename2(options.from)}`
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
var NOTE_BODY_PREVIEW_LIMIT = 80;
|
|
2280
|
+
var NOTE_BODY_PREVIEW_HEAD = 77;
|
|
2281
|
+
async function runSessionNote(sessionIdInput, options, ctx = {}) {
|
|
2282
|
+
try {
|
|
2283
|
+
await doRunSessionNote(sessionIdInput, options, ctx);
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
2286
|
+
process.exitCode = 1;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
async function doRunSessionNote(sessionIdInput, options, ctx) {
|
|
2290
|
+
const hasBody = options.body !== void 0;
|
|
2291
|
+
const hasFromFile = options.fromFile !== void 0;
|
|
2292
|
+
if (!hasBody && !hasFromFile) {
|
|
2293
|
+
throw new Error("Provide --body or --from-file");
|
|
2294
|
+
}
|
|
2295
|
+
if (hasBody && hasFromFile) {
|
|
2296
|
+
throw new Error("--body and --from-file are mutually exclusive");
|
|
2297
|
+
}
|
|
2298
|
+
if (hasFromFile && options.fromFile === "-") {
|
|
2299
|
+
throw new Error("--from-file - (stdin) is not supported in v0.1");
|
|
2300
|
+
}
|
|
2301
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2302
|
+
const repositoryRoot = await resolveRepositoryRootForSession(cwd, "note");
|
|
2303
|
+
const paths = basouPaths7(repositoryRoot);
|
|
2304
|
+
await assertWorkspaceInitialized5(paths.root);
|
|
2305
|
+
const sessionId = await resolveSessionId2(paths, sessionIdInput);
|
|
2306
|
+
const body = hasBody ? options.body : await readNoteFile(options.fromFile);
|
|
2307
|
+
if (body.length === 0) {
|
|
2308
|
+
throw new Error("Note body is empty");
|
|
2309
|
+
}
|
|
2310
|
+
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2311
|
+
const sesId = sessionId;
|
|
2312
|
+
const sessionLock = await acquireLock2(paths, "session", sesId);
|
|
2313
|
+
let result;
|
|
2314
|
+
try {
|
|
2315
|
+
result = await appendEventToExistingSession2({
|
|
2316
|
+
paths,
|
|
2317
|
+
sessionId: sesId,
|
|
2318
|
+
eventBuilder: (eventId) => ({
|
|
2319
|
+
schema_version: "0.1.0",
|
|
2320
|
+
id: eventId,
|
|
2321
|
+
session_id: sesId,
|
|
2322
|
+
occurred_at: occurredAt,
|
|
2323
|
+
source: "local-cli",
|
|
2324
|
+
type: "note_added",
|
|
2325
|
+
body
|
|
2326
|
+
})
|
|
2327
|
+
});
|
|
2328
|
+
} finally {
|
|
2329
|
+
await sessionLock.release();
|
|
2330
|
+
}
|
|
2331
|
+
printSessionNoteResult(options, sessionId, result.eventId, result.sessionStatus, body);
|
|
2332
|
+
}
|
|
2333
|
+
async function readNoteFile(path) {
|
|
2334
|
+
try {
|
|
2335
|
+
return await readFile(path, "utf8");
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
if (findErrorCode5(error, "ENOENT")) {
|
|
2338
|
+
throw new Error("Note source not found", { cause: error });
|
|
2339
|
+
}
|
|
2340
|
+
if (findErrorCode5(error, "EISDIR")) {
|
|
2341
|
+
throw new Error("Note source is not a file", { cause: error });
|
|
2342
|
+
}
|
|
2343
|
+
throw new Error("Failed to read note source", { cause: error });
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
function parseNoteBodyOption(raw) {
|
|
2347
|
+
if (raw.length === 0) {
|
|
2348
|
+
throw new InvalidArgumentError2("--body must not be empty");
|
|
2349
|
+
}
|
|
2350
|
+
return raw;
|
|
2351
|
+
}
|
|
2352
|
+
function printSessionNoteResult(options, sessionId, eventId, sessionStatus, body) {
|
|
2353
|
+
const sid = shortId2(sessionId);
|
|
2354
|
+
if (options.json === true) {
|
|
2355
|
+
console.log(
|
|
2356
|
+
JSON.stringify({
|
|
2357
|
+
event_id: eventId,
|
|
2358
|
+
session_id: sessionId,
|
|
2359
|
+
session_status: sessionStatus,
|
|
2360
|
+
body_length: body.length
|
|
2361
|
+
})
|
|
2362
|
+
);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
const preview = body.length > NOTE_BODY_PREVIEW_LIMIT ? `${body.slice(0, NOTE_BODY_PREVIEW_HEAD)}...` : body;
|
|
2366
|
+
console.log(`Added note to session ${sid} (${sessionStatus}): ${preview}`);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// src/commands/status.ts
|
|
2370
|
+
import {
|
|
2371
|
+
assertBasouRootSafe as assertBasouRootSafe8,
|
|
2372
|
+
basouPaths as basouPaths8,
|
|
2373
|
+
buildStatusSnapshot,
|
|
2374
|
+
findErrorCode as findErrorCode6,
|
|
2375
|
+
readManifest as readManifest5,
|
|
2376
|
+
resolveRepositoryRoot as resolveRepositoryRoot9,
|
|
2377
|
+
writeStatus
|
|
2378
|
+
} from "@basou/core";
|
|
2379
|
+
function registerStatusCommand(program2) {
|
|
2380
|
+
program2.command("status").description("Show the current Basou workspace status").option("--json", "Output the snapshot as JSON to stdout").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
2381
|
+
await runStatus(options);
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
async function runStatus(options, ctx = {}) {
|
|
2385
|
+
try {
|
|
2386
|
+
await doRunStatus(options, ctx);
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
renderCliError(error, { verbose: isVerbose(options) });
|
|
2389
|
+
process.exitCode = 1;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
async function doRunStatus(options, ctx) {
|
|
2393
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2394
|
+
const repositoryRoot = await resolveRepositoryRootForStatus(cwd);
|
|
2395
|
+
const paths = basouPaths8(repositoryRoot);
|
|
2396
|
+
try {
|
|
2397
|
+
await assertBasouRootSafe8(paths.root);
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
if (findErrorCode6(error, "ENOENT")) {
|
|
2400
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
2401
|
+
}
|
|
2402
|
+
throw error;
|
|
2403
|
+
}
|
|
2404
|
+
let manifest;
|
|
2405
|
+
try {
|
|
2406
|
+
manifest = await readManifest5(paths);
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
if (findErrorCode6(error, "ENOENT")) {
|
|
2409
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
2410
|
+
}
|
|
2411
|
+
throw new Error("Failed to read workspace manifest", { cause: error });
|
|
2412
|
+
}
|
|
2413
|
+
const snapshot = await buildStatusSnapshot({ manifest, paths });
|
|
2414
|
+
await writeStatus(paths, snapshot);
|
|
2415
|
+
if (options.json === true) {
|
|
2416
|
+
console.log(JSON.stringify(snapshot, null, 2));
|
|
2417
|
+
} else {
|
|
2418
|
+
renderTextStatus(snapshot);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
function renderTextStatus(s) {
|
|
2422
|
+
console.log(`Workspace: ${s.workspace.name} (${s.workspace.id})`);
|
|
2423
|
+
console.log(`Spec version: ${s.workspace.basou_version}`);
|
|
2424
|
+
console.log(`Generated at: ${s.generated_at}`);
|
|
2425
|
+
const dp = s.directories_present;
|
|
2426
|
+
const total = Object.keys(dp).length;
|
|
2427
|
+
const present = Object.values(dp).filter((v) => v === true).length;
|
|
2428
|
+
console.log(`Subdirectories present: ${present}/${total}`);
|
|
2429
|
+
}
|
|
2430
|
+
async function resolveRepositoryRootForStatus(cwd) {
|
|
2431
|
+
try {
|
|
2432
|
+
return await resolveRepositoryRoot9(cwd);
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
2435
|
+
throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou status'.", {
|
|
2436
|
+
cause: error
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
throw error;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// src/commands/task.ts
|
|
2444
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2445
|
+
import { join as join5 } from "path";
|
|
2446
|
+
import {
|
|
2447
|
+
TaskStatusSchema,
|
|
2448
|
+
TaskWriteAfterEventError,
|
|
2449
|
+
archiveTask,
|
|
2450
|
+
assertBasouRootSafe as assertBasouRootSafe9,
|
|
2451
|
+
basouPaths as basouPaths9,
|
|
2452
|
+
createTaskWithEvent,
|
|
2453
|
+
deleteTask,
|
|
2454
|
+
editTask,
|
|
2455
|
+
enumerateArchivedTaskIds,
|
|
2456
|
+
findErrorCode as findErrorCode7,
|
|
2457
|
+
loadSessionEntries as loadSessionEntries2,
|
|
2458
|
+
loadTaskEntries,
|
|
2459
|
+
prefixedUlid as prefixedUlid5,
|
|
2460
|
+
readManifest as readManifest6,
|
|
2461
|
+
readTaskFile,
|
|
2462
|
+
readTaskFileWithArchiveFallback,
|
|
2463
|
+
reconcileAllTasks,
|
|
2464
|
+
reconcileTask,
|
|
2465
|
+
refreshTaskLinkedSessions,
|
|
2466
|
+
replayEvents as replayEvents2,
|
|
2467
|
+
resolveRepositoryRoot as resolveRepositoryRoot10,
|
|
2468
|
+
resolveSessionId as resolveSessionId3,
|
|
2469
|
+
resolveTaskId as resolveTaskId2,
|
|
2470
|
+
updateTaskStatusWithEvent
|
|
2471
|
+
} from "@basou/core";
|
|
2472
|
+
import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
|
|
2473
|
+
var STATUS_VALUES3 = TaskStatusSchema.options;
|
|
2474
|
+
function registerTaskCommand(program2) {
|
|
2475
|
+
const task = program2.command("task").description("Manage Basou tasks (purpose units that span sessions)");
|
|
2476
|
+
task.command("new").description("Create a new task and fire a task_created event").requiredOption("--title <text>", "Task title", parseTitle2).option("--label <text>", "Optional label for the task", parseLabel).option(
|
|
2477
|
+
"--status <status>",
|
|
2478
|
+
`Initial status (one of: ${STATUS_VALUES3.join(", ")}; default planned). For done/cancelled the orchestrator also emits a task_status_changed event so the audit trail records the implicit transition.`,
|
|
2479
|
+
parseInitialTaskStatus
|
|
2480
|
+
).option(
|
|
2481
|
+
"--completed-at <iso>",
|
|
2482
|
+
"ISO-8601 timestamp to record as the task's updated_at when --status is done or cancelled (rejected otherwise)",
|
|
2483
|
+
parseIsoTimestampOption
|
|
2484
|
+
).option("--session <session_id>", "Attach to existing session; otherwise ad-hoc").option("--description <text>", "Task description body (inline)", parseDescriptionOption).option("--from-file <path>", "Read description body from a file").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
2485
|
+
await runTaskNew(options);
|
|
2486
|
+
});
|
|
2487
|
+
task.command("list").description("List tasks in the current workspace (newest first)").option(
|
|
2488
|
+
"--status <status>",
|
|
2489
|
+
`Filter by task status (one of: ${STATUS_VALUES3.join(", ")})`,
|
|
2490
|
+
parseTaskStatusFilter
|
|
2491
|
+
).option("--include-archived", "Also list tasks under .basou/tasks/archive/ (hidden by default)").option("--json", "Output the list as a JSON array").option("-v, --verbose", "Show error causes").action(async (options) => {
|
|
2492
|
+
await runTaskList(options);
|
|
2493
|
+
});
|
|
2494
|
+
task.command("show <task_id>").description("Show a task with its metadata, linked sessions, and events").option("--json", "Output as JSON").option("--events", "Show all related events instead of trailing few").option("--last <n>", "Number of trailing events to display (default: 5)", parsePositiveInt2).option("-v, --verbose", "Show error causes").action(async (id, options) => {
|
|
2495
|
+
await runTaskShow(id, options);
|
|
2496
|
+
});
|
|
2497
|
+
task.command("status <task_id> <new_status>").description("Change task status and fire a task_status_changed event").option("--session <session_id>", "Attach to existing session; otherwise ad-hoc").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (taskIdInput, newStatusInput, options) => {
|
|
2498
|
+
await runTaskStatus(taskIdInput, newStatusInput, options);
|
|
2499
|
+
});
|
|
2500
|
+
task.command("reconcile").description(
|
|
2501
|
+
"Dry-run audit of task session references; use --write to repair broken refs. Forward sync (events -> task.md linked_sessions) is out of scope."
|
|
2502
|
+
).option("--task <task_id>", "Limit to a single task (otherwise scan all)").option("--write", "Apply repairs (default: dry-run)").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes and broken session_id values").action(async (options) => {
|
|
2503
|
+
await runTaskReconcile(options);
|
|
2504
|
+
});
|
|
2505
|
+
task.command("refresh-linkage <task_id>").description(
|
|
2506
|
+
"Re-derive task.md linked_sessions[] from session.yaml.task_id matches across the workspace (forward sync events -> task.md). Dry-run default; use --write to apply."
|
|
2507
|
+
).option("--write", "Apply the refresh (default: dry-run)").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (taskIdInput, options) => {
|
|
2508
|
+
await runTaskRefreshLinkage(taskIdInput, options);
|
|
2509
|
+
});
|
|
2510
|
+
task.command("edit <task_id>").description(
|
|
2511
|
+
"Update --title and/or --status on an existing task. Status changes fire a task_status_changed event; title changes update task.md only (no event)."
|
|
2512
|
+
).option("--title <text>", "New title (must be non-empty)", parseTitle2).option(
|
|
2513
|
+
"--status <status>",
|
|
2514
|
+
`New status (one of: ${STATUS_VALUES3.join(", ")}); routed through STATUS_TRANSITIONS so only valid edges are accepted`,
|
|
2515
|
+
parseInitialTaskStatus
|
|
2516
|
+
).option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (taskIdInput, options) => {
|
|
2517
|
+
await runTaskEdit(taskIdInput, options);
|
|
2518
|
+
});
|
|
2519
|
+
task.command("delete <task_id>").description(
|
|
2520
|
+
"Hard-delete a task.md file and fire a task_deleted event. Requires confirmation by default; use --yes to skip the prompt."
|
|
2521
|
+
).option("--yes", "Skip the confirmation prompt (required when stdin is not a TTY)").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (taskIdInput, options) => {
|
|
2522
|
+
await runTaskDelete(taskIdInput, options);
|
|
2523
|
+
});
|
|
2524
|
+
task.command("archive <task_id>").description(
|
|
2525
|
+
"Move task.md into .basou/tasks/archive/ and fire a task_archived event. Requires confirmation by default; use --yes to skip the prompt."
|
|
2526
|
+
).option("--yes", "Skip the confirmation prompt (required when stdin is not a TTY)").option("--json", "Output as JSON").option("-v, --verbose", "Show error causes").action(async (taskIdInput, options) => {
|
|
2527
|
+
await runTaskArchive(taskIdInput, options);
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
async function runTaskNew(options, ctx = {}) {
|
|
2531
|
+
try {
|
|
2532
|
+
await doRunTaskNew(options, ctx);
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
2535
|
+
process.exitCode = 1;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
async function doRunTaskNew(options, ctx) {
|
|
2539
|
+
if (options.description !== void 0 && options.fromFile !== void 0) {
|
|
2540
|
+
throw new Error("--description and --from-file are mutually exclusive");
|
|
2541
|
+
}
|
|
2542
|
+
if (options.fromFile === "-") {
|
|
2543
|
+
throw new Error("--from-file - (stdin) is not supported in v0.1");
|
|
2544
|
+
}
|
|
2545
|
+
const initialStatus = options.status ?? "planned";
|
|
2546
|
+
if (options.completedAt !== void 0 && !isTerminalStatusForCli(initialStatus)) {
|
|
2547
|
+
throw new Error("--completed-at requires --status done or cancelled");
|
|
2548
|
+
}
|
|
2549
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2550
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "new");
|
|
2551
|
+
const paths = basouPaths9(repositoryRoot);
|
|
2552
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
2553
|
+
const description = options.description !== void 0 ? options.description : options.fromFile !== void 0 ? await readDescriptionFile(options.fromFile) : "";
|
|
2554
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
2555
|
+
const occurredAt = now.toISOString();
|
|
2556
|
+
const taskId = prefixedUlid5("task");
|
|
2557
|
+
if (options.session !== void 0) {
|
|
2558
|
+
const sessionId = await resolveSessionId3(paths, options.session);
|
|
2559
|
+
const result2 = await createTaskWithEvent({
|
|
2560
|
+
mode: "attach",
|
|
2561
|
+
paths,
|
|
2562
|
+
occurredAt,
|
|
2563
|
+
sessionId,
|
|
2564
|
+
taskId,
|
|
2565
|
+
title: options.title,
|
|
2566
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
2567
|
+
initialStatus,
|
|
2568
|
+
description,
|
|
2569
|
+
...options.completedAt !== void 0 ? { completedAt: options.completedAt } : {}
|
|
2570
|
+
});
|
|
2571
|
+
printTaskNewResult(options, {
|
|
2572
|
+
mode: "attached",
|
|
2573
|
+
taskId: result2.taskId,
|
|
2574
|
+
eventId: result2.eventId,
|
|
2575
|
+
sessionId: result2.sessionId,
|
|
2576
|
+
sessionStatus: result2.sessionStatus,
|
|
2577
|
+
title: options.title,
|
|
2578
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
2579
|
+
status: initialStatus,
|
|
2580
|
+
occurredAt,
|
|
2581
|
+
...options.completedAt !== void 0 ? { completedAt: options.completedAt } : {},
|
|
2582
|
+
descriptionLength: description.length
|
|
2583
|
+
});
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
const manifest = await readManifest6(paths);
|
|
2587
|
+
const result = await createTaskWithEvent({
|
|
2588
|
+
mode: "ad-hoc",
|
|
2589
|
+
paths,
|
|
2590
|
+
manifest,
|
|
2591
|
+
occurredAt,
|
|
2592
|
+
taskId,
|
|
2593
|
+
title: options.title,
|
|
2594
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
2595
|
+
initialStatus,
|
|
2596
|
+
description,
|
|
2597
|
+
workingDirectory: repositoryRoot,
|
|
2598
|
+
...options.completedAt !== void 0 ? { completedAt: options.completedAt } : {}
|
|
2599
|
+
});
|
|
2600
|
+
printTaskNewResult(options, {
|
|
2601
|
+
mode: "ad-hoc",
|
|
2602
|
+
taskId: result.taskId,
|
|
2603
|
+
eventId: result.eventId,
|
|
2604
|
+
sessionId: result.sessionId,
|
|
2605
|
+
sessionStatus: result.sessionStatus,
|
|
2606
|
+
title: options.title,
|
|
2607
|
+
...options.label !== void 0 ? { label: options.label } : {},
|
|
2608
|
+
status: initialStatus,
|
|
2609
|
+
occurredAt,
|
|
2610
|
+
...options.completedAt !== void 0 ? { completedAt: options.completedAt } : {},
|
|
2611
|
+
descriptionLength: description.length
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
function isTerminalStatusForCli(status) {
|
|
2615
|
+
return status === "done" || status === "cancelled";
|
|
2616
|
+
}
|
|
2617
|
+
function printTaskNewResult(options, result) {
|
|
2618
|
+
if (options.json === true) {
|
|
2619
|
+
console.log(
|
|
2620
|
+
JSON.stringify({
|
|
2621
|
+
task_id: result.taskId,
|
|
2622
|
+
event_id: result.eventId,
|
|
2623
|
+
session_id: result.sessionId,
|
|
2624
|
+
session_status: result.sessionStatus,
|
|
2625
|
+
mode: result.mode,
|
|
2626
|
+
title: result.title,
|
|
2627
|
+
label: result.label ?? null,
|
|
2628
|
+
status: result.status,
|
|
2629
|
+
recorded_at: result.occurredAt,
|
|
2630
|
+
completed_at: result.completedAt ?? null,
|
|
2631
|
+
description_length: result.descriptionLength
|
|
2632
|
+
})
|
|
2633
|
+
);
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
const shortSes = shortSessionId(result.sessionId);
|
|
2637
|
+
const created = result.mode === "ad-hoc" ? `Created ${result.taskId} in ad-hoc session ${shortSes}` : `Created ${result.taskId} in session ${shortSes} (${result.sessionStatus})`;
|
|
2638
|
+
console.log(created);
|
|
2639
|
+
console.log(` Title: ${result.title}`);
|
|
2640
|
+
if (result.completedAt !== void 0) {
|
|
2641
|
+
console.log(
|
|
2642
|
+
` Status: ${result.status} (recorded at ${result.occurredAt}, completed at ${result.completedAt})`
|
|
2643
|
+
);
|
|
2644
|
+
} else {
|
|
2645
|
+
console.log(` Status: ${result.status}`);
|
|
2646
|
+
}
|
|
2647
|
+
console.log(` Label: ${result.label ?? "(none)"}`);
|
|
2648
|
+
}
|
|
2649
|
+
async function runTaskList(options, ctx = {}) {
|
|
2650
|
+
try {
|
|
2651
|
+
await doRunTaskList(options, ctx);
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
2654
|
+
process.exitCode = 1;
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
async function doRunTaskList(options, ctx) {
|
|
2658
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2659
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "list");
|
|
2660
|
+
const paths = basouPaths9(repositoryRoot);
|
|
2661
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
2662
|
+
const entries = await loadTaskEntries(paths, {
|
|
2663
|
+
onSkip: (id, reason) => printTaskSkip(id, reason)
|
|
2664
|
+
});
|
|
2665
|
+
const archivedEntries = [];
|
|
2666
|
+
if (options.includeArchived === true) {
|
|
2667
|
+
const archivedIds = await enumerateArchivedTaskIds(paths);
|
|
2668
|
+
for (const id of archivedIds) {
|
|
2669
|
+
try {
|
|
2670
|
+
const { doc } = await readTaskFileWithArchiveFallback(paths, id);
|
|
2671
|
+
archivedEntries.push({ doc, archived: true });
|
|
2672
|
+
} catch {
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
const combined = [...entries, ...archivedEntries.map((a) => a.doc)];
|
|
2677
|
+
const archivedIdSet = new Set(archivedEntries.map((a) => a.doc.task.task.id));
|
|
2678
|
+
const ordered = [...combined].sort(
|
|
2679
|
+
(a, b) => Date.parse(b.task.task.created_at) - Date.parse(a.task.task.created_at)
|
|
2680
|
+
);
|
|
2681
|
+
const filtered = options.status !== void 0 ? ordered.filter((t) => t.task.task.status === options.status) : ordered;
|
|
2682
|
+
if (filtered.length === 0) {
|
|
2683
|
+
if (options.json === true) {
|
|
2684
|
+
console.log("[]");
|
|
2685
|
+
} else {
|
|
2686
|
+
console.log("No tasks found.");
|
|
2687
|
+
}
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (options.json === true) {
|
|
2691
|
+
console.log(
|
|
2692
|
+
JSON.stringify(
|
|
2693
|
+
filtered.map((t) => ({
|
|
2694
|
+
task_id: t.task.task.id,
|
|
2695
|
+
title: t.task.task.title,
|
|
2696
|
+
label: t.task.task.label ?? null,
|
|
2697
|
+
status: t.task.task.status,
|
|
2698
|
+
created_at: t.task.task.created_at,
|
|
2699
|
+
updated_at: t.task.task.updated_at,
|
|
2700
|
+
linked_session_count: t.task.task.linked_sessions.length,
|
|
2701
|
+
archived: archivedIdSet.has(t.task.task.id)
|
|
2702
|
+
})),
|
|
2703
|
+
null,
|
|
2704
|
+
2
|
|
2705
|
+
)
|
|
2706
|
+
);
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
printTaskListText(filtered, archivedIdSet);
|
|
2710
|
+
}
|
|
2711
|
+
function printTaskListText(entries, archivedIds) {
|
|
2712
|
+
const rows = entries.map((t) => ({
|
|
2713
|
+
sid: shortTaskId(t.task.task.id),
|
|
2714
|
+
status: t.task.task.status,
|
|
2715
|
+
createdAt: t.task.task.created_at,
|
|
2716
|
+
label: t.task.task.label ?? "(none)",
|
|
2717
|
+
// Mark archived entries with a leading [archived] tag so the operator
|
|
2718
|
+
// can distinguish them from live tasks when --include-archived is on.
|
|
2719
|
+
title: archivedIds.has(t.task.task.id) ? `[archived] ${t.task.task.title}` : t.task.task.title,
|
|
2720
|
+
linkedCount: String(t.task.task.linked_sessions.length)
|
|
2721
|
+
}));
|
|
2722
|
+
const widths = {
|
|
2723
|
+
sid: maxLen3(
|
|
2724
|
+
rows.map((r) => r.sid),
|
|
2725
|
+
"SHORT_ID".length
|
|
2726
|
+
),
|
|
2727
|
+
status: maxLen3(
|
|
2728
|
+
rows.map((r) => r.status),
|
|
2729
|
+
"STATUS".length
|
|
2730
|
+
),
|
|
2731
|
+
createdAt: maxLen3(
|
|
2732
|
+
rows.map((r) => r.createdAt),
|
|
2733
|
+
"CREATED_AT".length
|
|
2734
|
+
),
|
|
2735
|
+
linkedCount: maxLen3(
|
|
2736
|
+
rows.map((r) => r.linkedCount),
|
|
2737
|
+
"LINKS".length
|
|
2738
|
+
),
|
|
2739
|
+
label: maxLen3(
|
|
2740
|
+
rows.map((r) => r.label),
|
|
2741
|
+
"LABEL".length
|
|
2742
|
+
)
|
|
2743
|
+
};
|
|
2744
|
+
console.log(
|
|
2745
|
+
`${pad3("SHORT_ID", widths.sid)} ${pad3("STATUS", widths.status)} ${pad3("CREATED_AT", widths.createdAt)} ${pad3("LINKS", widths.linkedCount)} ${pad3("LABEL", widths.label)} TITLE`
|
|
2746
|
+
);
|
|
2747
|
+
for (const r of rows) {
|
|
2748
|
+
console.log(
|
|
2749
|
+
`${pad3(r.sid, widths.sid)} ${pad3(r.status, widths.status)} ${pad3(r.createdAt, widths.createdAt)} ${pad3(r.linkedCount, widths.linkedCount)} ${pad3(r.label, widths.label)} ${r.title}`
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
async function runTaskShow(idInput, options, ctx = {}) {
|
|
2754
|
+
try {
|
|
2755
|
+
await doRunTaskShow(idInput, options, ctx);
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
2758
|
+
process.exitCode = 1;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
async function doRunTaskShow(idInput, options, ctx) {
|
|
2762
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2763
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "show");
|
|
2764
|
+
const paths = basouPaths9(repositoryRoot);
|
|
2765
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
2766
|
+
const taskId = await resolveTaskId2(paths, idInput, { includeArchived: true });
|
|
2767
|
+
const { doc, archived } = await readTaskFileWithArchiveFallback(paths, taskId);
|
|
2768
|
+
const sessions = await loadSessionEntries2(paths, { now: /* @__PURE__ */ new Date() });
|
|
2769
|
+
const events = [];
|
|
2770
|
+
const linkedSessionIds = new Set(doc.task.task.linked_sessions);
|
|
2771
|
+
for (const s of sessions) {
|
|
2772
|
+
const sessionDir = join5(paths.sessions, s.sessionId);
|
|
2773
|
+
try {
|
|
2774
|
+
for await (const ev of replayEvents2(sessionDir, {
|
|
2775
|
+
onWarning: (w) => printReplayWarning(w, s.sessionId)
|
|
2776
|
+
})) {
|
|
2777
|
+
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") && ev.task_id === taskId) {
|
|
2778
|
+
events.push(ev);
|
|
2779
|
+
linkedSessionIds.add(s.sessionId);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
} catch (error) {
|
|
2783
|
+
const short = shortSessionId(s.sessionId);
|
|
2784
|
+
const suffix = error instanceof Error ? `: ${error.message}` : "";
|
|
2785
|
+
console.error(`Warning: events unavailable for session ${short}${suffix}`);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
events.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at));
|
|
2789
|
+
if (options.json === true) {
|
|
2790
|
+
console.log(
|
|
2791
|
+
JSON.stringify(
|
|
2792
|
+
{
|
|
2793
|
+
task: doc.task.task,
|
|
2794
|
+
body: doc.body,
|
|
2795
|
+
linked_sessions: [...linkedSessionIds],
|
|
2796
|
+
archived,
|
|
2797
|
+
events
|
|
2798
|
+
},
|
|
2799
|
+
null,
|
|
2800
|
+
2
|
|
2801
|
+
)
|
|
2802
|
+
);
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
printTaskShowText(doc, [...linkedSessionIds], events, sessions, options, archived);
|
|
2806
|
+
}
|
|
2807
|
+
function printTaskShowText(doc, linkedSessions, events, sessionEntries, options, archived) {
|
|
2808
|
+
const t = doc.task.task;
|
|
2809
|
+
const archivedTag = archived ? " [archived]" : "";
|
|
2810
|
+
console.log(`Task: ${t.id}${archivedTag}`);
|
|
2811
|
+
console.log(` Title: ${t.title}`);
|
|
2812
|
+
console.log(` Status: ${t.status}`);
|
|
2813
|
+
console.log(` Label: ${t.label ?? "(none)"}`);
|
|
2814
|
+
console.log(` Created at: ${t.created_at}`);
|
|
2815
|
+
console.log(` Updated at: ${t.updated_at}`);
|
|
2816
|
+
console.log(` Workspace: ${t.workspace_id}`);
|
|
2817
|
+
console.log("");
|
|
2818
|
+
console.log(`Linked sessions (${linkedSessions.length}):`);
|
|
2819
|
+
const sessionStatusMap = new Map(
|
|
2820
|
+
sessionEntries.map((s) => [s.sessionId, s.session.session.status])
|
|
2821
|
+
);
|
|
2822
|
+
for (const sid of linkedSessions) {
|
|
2823
|
+
const status = sessionStatusMap.get(sid) ?? "unknown";
|
|
2824
|
+
console.log(` ${sid} (${status})`);
|
|
2825
|
+
}
|
|
2826
|
+
console.log("");
|
|
2827
|
+
console.log("Description:");
|
|
2828
|
+
if (doc.body.length === 0) {
|
|
2829
|
+
console.log("(no description)");
|
|
2830
|
+
} else {
|
|
2831
|
+
console.log(doc.body);
|
|
2832
|
+
}
|
|
2833
|
+
console.log("");
|
|
2834
|
+
console.log(`Events: ${events.length} total`);
|
|
2835
|
+
if (events.length === 0) return;
|
|
2836
|
+
const showAll = options.events === true && options.last === void 0;
|
|
2837
|
+
const last = options.last ?? 5;
|
|
2838
|
+
const slice = showAll ? events : events.slice(-last);
|
|
2839
|
+
const heading = showAll ? "All events:" : `Last ${slice.length} events:`;
|
|
2840
|
+
console.log("");
|
|
2841
|
+
console.log(heading);
|
|
2842
|
+
const verbose = isVerbose(options);
|
|
2843
|
+
for (const ev of slice) {
|
|
2844
|
+
console.log(` ${formatTaskEvent(ev)}`);
|
|
2845
|
+
if (verbose && ev.type === "task_reconciled") {
|
|
2846
|
+
for (const line of formatTaskReconciledDetails(ev)) {
|
|
2847
|
+
console.log(line);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
function formatTaskEvent(ev) {
|
|
2853
|
+
if (ev.type === "task_created") {
|
|
2854
|
+
return `${ev.occurred_at} [${ev.source}] task_created ${ev.title}`;
|
|
2855
|
+
}
|
|
2856
|
+
if (ev.type === "task_status_changed") {
|
|
2857
|
+
return `${ev.occurred_at} [${ev.source}] task_status_changed ${ev.from} -> ${ev.to}`;
|
|
2858
|
+
}
|
|
2859
|
+
if (ev.type === "task_reconciled") {
|
|
2860
|
+
const removedCount = (ev.removed_created_in_session !== null ? 1 : 0) + ev.removed_linked_sessions.length;
|
|
2861
|
+
return `${ev.occurred_at} [${ev.source}] task_reconciled ${removedCount} broken ref${removedCount === 1 ? "" : "s"} (use -v for details)`;
|
|
2862
|
+
}
|
|
2863
|
+
if (ev.type === "task_linkage_refreshed") {
|
|
2864
|
+
const added = ev.added_linked_sessions.length;
|
|
2865
|
+
const removed = ev.removed_linked_sessions.length;
|
|
2866
|
+
const finalPart = ev.final_count !== void 0 ? `, final=${ev.final_count}` : "";
|
|
2867
|
+
return `${ev.occurred_at} [${ev.source}] task_linkage_refreshed +${added} / -${removed}${finalPart}`;
|
|
2868
|
+
}
|
|
2869
|
+
if (ev.type === "task_deleted") {
|
|
2870
|
+
return `${ev.occurred_at} [${ev.source}] task_deleted ${ev.title}`;
|
|
2871
|
+
}
|
|
2872
|
+
if (ev.type === "task_archived") {
|
|
2873
|
+
return `${ev.occurred_at} [${ev.source}] task_archived ${ev.title}`;
|
|
2874
|
+
}
|
|
2875
|
+
return `${ev.occurred_at} [${ev.source}] ${ev.type}`;
|
|
2876
|
+
}
|
|
2877
|
+
function formatTaskReconciledDetails(ev) {
|
|
2878
|
+
const lines = [];
|
|
2879
|
+
if (ev.removed_created_in_session !== null) {
|
|
2880
|
+
lines.push(` removed_created_in_session: ${ev.removed_created_in_session}`);
|
|
2881
|
+
}
|
|
2882
|
+
if (ev.created_in_session_replacement !== null) {
|
|
2883
|
+
lines.push(` created_in_session_replacement: ${ev.created_in_session_replacement}`);
|
|
2884
|
+
}
|
|
2885
|
+
if (ev.removed_linked_sessions.length > 0) {
|
|
2886
|
+
lines.push(" removed_linked_sessions:");
|
|
2887
|
+
for (const sid of ev.removed_linked_sessions) {
|
|
2888
|
+
lines.push(` - ${sid}`);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return lines;
|
|
2892
|
+
}
|
|
2893
|
+
async function runTaskStatus(taskIdInput, newStatusInput, options, ctx = {}) {
|
|
2894
|
+
try {
|
|
2895
|
+
await doRunTaskStatus(taskIdInput, newStatusInput, options, ctx);
|
|
2896
|
+
} catch (error) {
|
|
2897
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
2898
|
+
process.exitCode = 1;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
async function doRunTaskStatus(taskIdInput, newStatusInput, options, ctx) {
|
|
2902
|
+
if (taskIdInput.trim().length === 0) {
|
|
2903
|
+
throw new Error("Task id is empty");
|
|
2904
|
+
}
|
|
2905
|
+
const newStatus = parseTaskStatusPositional(newStatusInput);
|
|
2906
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2907
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "status");
|
|
2908
|
+
const paths = basouPaths9(repositoryRoot);
|
|
2909
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
2910
|
+
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
2911
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
2912
|
+
const occurredAt = now.toISOString();
|
|
2913
|
+
if (options.session !== void 0) {
|
|
2914
|
+
const sessionId = await resolveSessionId3(paths, options.session);
|
|
2915
|
+
const result2 = await updateTaskStatusWithEvent({
|
|
2916
|
+
mode: "attach",
|
|
2917
|
+
paths,
|
|
2918
|
+
occurredAt,
|
|
2919
|
+
sessionId,
|
|
2920
|
+
taskId,
|
|
2921
|
+
newStatus
|
|
2922
|
+
});
|
|
2923
|
+
printTaskStatusResult(options, {
|
|
2924
|
+
mode: "attached",
|
|
2925
|
+
taskId: result2.taskId,
|
|
2926
|
+
eventId: result2.eventId,
|
|
2927
|
+
sessionId: result2.sessionId,
|
|
2928
|
+
sessionStatus: result2.sessionStatus,
|
|
2929
|
+
previousStatus: result2.previousStatus,
|
|
2930
|
+
newStatus: result2.newStatus
|
|
2931
|
+
});
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
const manifest = await readManifest6(paths);
|
|
2935
|
+
const result = await updateTaskStatusWithEvent({
|
|
2936
|
+
mode: "ad-hoc",
|
|
2937
|
+
paths,
|
|
2938
|
+
manifest,
|
|
2939
|
+
occurredAt,
|
|
2940
|
+
taskId,
|
|
2941
|
+
newStatus,
|
|
2942
|
+
workingDirectory: repositoryRoot
|
|
2943
|
+
});
|
|
2944
|
+
printTaskStatusResult(options, {
|
|
2945
|
+
mode: "ad-hoc",
|
|
2946
|
+
taskId: result.taskId,
|
|
2947
|
+
eventId: result.eventId,
|
|
2948
|
+
sessionId: result.sessionId,
|
|
2949
|
+
sessionStatus: result.sessionStatus,
|
|
2950
|
+
previousStatus: result.previousStatus,
|
|
2951
|
+
newStatus: result.newStatus
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
function printTaskStatusResult(options, result) {
|
|
2955
|
+
if (options.json === true) {
|
|
2956
|
+
console.log(
|
|
2957
|
+
JSON.stringify({
|
|
2958
|
+
task_id: result.taskId,
|
|
2959
|
+
event_id: result.eventId,
|
|
2960
|
+
session_id: result.sessionId,
|
|
2961
|
+
session_status: result.sessionStatus,
|
|
2962
|
+
mode: result.mode,
|
|
2963
|
+
previous_status: result.previousStatus,
|
|
2964
|
+
new_status: result.newStatus
|
|
2965
|
+
})
|
|
2966
|
+
);
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
const sid = shortSessionId(result.sessionId);
|
|
2970
|
+
console.log(
|
|
2971
|
+
`Updated ${result.taskId} status: ${result.previousStatus} -> ${result.newStatus} (in session ${sid})`
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
async function runTaskReconcile(options, ctx = {}) {
|
|
2975
|
+
try {
|
|
2976
|
+
await doRunTaskReconcile(options, ctx);
|
|
2977
|
+
} catch (error) {
|
|
2978
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
2979
|
+
process.exitCode = 1;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
async function doRunTaskReconcile(options, ctx) {
|
|
2983
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
2984
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "reconcile");
|
|
2985
|
+
const paths = basouPaths9(repositoryRoot);
|
|
2986
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
2987
|
+
const manifest = await readManifest6(paths);
|
|
2988
|
+
const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
|
|
2989
|
+
const write = options.write === true;
|
|
2990
|
+
const verbose = isVerbose(options);
|
|
2991
|
+
const json = options.json === true;
|
|
2992
|
+
if (options.task !== void 0) {
|
|
2993
|
+
const taskId = await resolveTaskId2(paths, options.task);
|
|
2994
|
+
const result = await reconcileTask(paths, manifest, {
|
|
2995
|
+
taskId,
|
|
2996
|
+
occurredAt: nowProvider().toISOString(),
|
|
2997
|
+
workingDirectory: repositoryRoot,
|
|
2998
|
+
write,
|
|
2999
|
+
scope: "single"
|
|
3000
|
+
});
|
|
3001
|
+
if (json) {
|
|
3002
|
+
printReconcileJson({ dryRun: !write, scanned: 1, results: [result], failed: [] });
|
|
3003
|
+
} else {
|
|
3004
|
+
await printReconcileSingleText(result, paths, { write, verbose });
|
|
3005
|
+
}
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
const all = await reconcileAllTasks(paths, manifest, {
|
|
3009
|
+
occurredAt: () => nowProvider().toISOString(),
|
|
3010
|
+
workingDirectory: repositoryRoot,
|
|
3011
|
+
write
|
|
3012
|
+
});
|
|
3013
|
+
if (json) {
|
|
3014
|
+
printReconcileJson({
|
|
3015
|
+
dryRun: !write,
|
|
3016
|
+
scanned: all.scanned,
|
|
3017
|
+
results: all.results,
|
|
3018
|
+
failed: all.failed
|
|
3019
|
+
});
|
|
3020
|
+
} else {
|
|
3021
|
+
printReconcileAllText(all.results, all.failed, all.scanned, { write, verbose });
|
|
3022
|
+
}
|
|
3023
|
+
if (all.failed.length > 0) {
|
|
3024
|
+
process.exitCode = 1;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
function printReconcileJson(input) {
|
|
3028
|
+
console.log(
|
|
3029
|
+
JSON.stringify(
|
|
3030
|
+
{
|
|
3031
|
+
dry_run: input.dryRun,
|
|
3032
|
+
scanned: input.scanned,
|
|
3033
|
+
reconciled: input.results.map((r) => ({
|
|
3034
|
+
task_id: r.taskId,
|
|
3035
|
+
removed_created_in_session: r.brokenCreatedInSession,
|
|
3036
|
+
created_in_session_replacement: r.brokenCreatedInSession !== null && r.reconcileSession !== null ? r.reconcileSession.sessionId : null,
|
|
3037
|
+
removed_linked_sessions: r.brokenLinkedSessions,
|
|
3038
|
+
reconcile_session_id: r.reconcileSession?.sessionId ?? null,
|
|
3039
|
+
event_id: r.reconcileSession?.eventId ?? null
|
|
3040
|
+
})),
|
|
3041
|
+
failed: input.failed.map((f) => ({
|
|
3042
|
+
task_id: f.taskId,
|
|
3043
|
+
error_class: f.errorClass,
|
|
3044
|
+
phase: f.phase
|
|
3045
|
+
}))
|
|
3046
|
+
},
|
|
3047
|
+
null,
|
|
3048
|
+
2
|
|
3049
|
+
)
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
async function printReconcileSingleText(result, paths, options) {
|
|
3053
|
+
if (result.clean) {
|
|
3054
|
+
let createdCount = 0;
|
|
3055
|
+
let linkedCount = 0;
|
|
3056
|
+
try {
|
|
3057
|
+
const doc = await readTaskFile(paths, result.taskId);
|
|
3058
|
+
createdCount = 1;
|
|
3059
|
+
linkedCount = doc.task.task.linked_sessions.length;
|
|
3060
|
+
} catch {
|
|
3061
|
+
}
|
|
3062
|
+
console.log(
|
|
3063
|
+
`${result.taskId}: no broken refs (${createdCount} created_in_session + ${linkedCount} linked_sessions, all reachable).`
|
|
3064
|
+
);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
if (options.write) {
|
|
3068
|
+
const sessionPart = result.reconcileSession !== null ? ` (in session ${shortSessionId(result.reconcileSession.sessionId)})` : "";
|
|
3069
|
+
console.log(`Reconciled ${result.taskId}: ${describeReconcileSummary(result)}${sessionPart}.`);
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
const summary = describeBrokenSummary(result, "task", options.verbose);
|
|
3073
|
+
console.log(`(dry-run) Would reconcile ${result.taskId}: ${summary}`);
|
|
3074
|
+
console.log("Note: events -> task.md forward sync is handled by `basou task refresh-linkage`.");
|
|
3075
|
+
console.log("Re-run with --write to apply.");
|
|
3076
|
+
}
|
|
3077
|
+
function printReconcileAllText(results, failed, scanned, options) {
|
|
3078
|
+
if (results.length === 0 && failed.length === 0) {
|
|
3079
|
+
console.log(`Scanned ${scanned} tasks, no broken refs detected.`);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
let totalBrokenRefs = 0;
|
|
3083
|
+
for (const r of results) {
|
|
3084
|
+
totalBrokenRefs += r.brokenLinkedSessions.length + (r.brokenCreatedInSession !== null ? 1 : 0);
|
|
3085
|
+
}
|
|
3086
|
+
if (options.write) {
|
|
3087
|
+
for (const r of results) {
|
|
3088
|
+
const sessionPart = r.reconcileSession !== null ? ` (in session ${shortSessionId(r.reconcileSession.sessionId)})` : "";
|
|
3089
|
+
console.log(`Reconciled ${r.taskId}: ${describeReconcileSummary(r)}${sessionPart}`);
|
|
3090
|
+
}
|
|
3091
|
+
for (const f of failed) {
|
|
3092
|
+
const phase = f.phase ?? "unknown";
|
|
3093
|
+
console.error(
|
|
3094
|
+
`Failed to reconcile ${f.taskId}: ${f.errorClass} (phase: ${phase}); see Caused by with -v`
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
const reconciledCount = results.length;
|
|
3098
|
+
const reconciledRefs = totalBrokenRefs;
|
|
3099
|
+
const reconciledPart = `reconciled ${reconciledCount} task${reconciledCount === 1 ? "" : "s"} (${reconciledRefs} broken ref${reconciledRefs === 1 ? "" : "s"})`;
|
|
3100
|
+
const failedPart = failed.length === 0 ? "" : `, ${failed.length} task${failed.length === 1 ? "" : "s"} failed`;
|
|
3101
|
+
console.log(`Scanned ${scanned} tasks, ${reconciledPart}${failedPart}.`);
|
|
3102
|
+
if (failed.length > 0) {
|
|
3103
|
+
console.error("(exit code 1)");
|
|
3104
|
+
}
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
for (const r of results) {
|
|
3108
|
+
const summary = describeBrokenSummary(r, "all", options.verbose);
|
|
3109
|
+
console.log(`(dry-run) Would reconcile ${r.taskId}: ${summary}`);
|
|
3110
|
+
}
|
|
3111
|
+
console.log(
|
|
3112
|
+
`Scanned ${scanned} tasks, would reconcile ${results.length} task${results.length === 1 ? "" : "s"} (${totalBrokenRefs} broken ref${totalBrokenRefs === 1 ? "" : "s"}).`
|
|
3113
|
+
);
|
|
3114
|
+
console.log("Note: events -> task.md forward sync is handled by `basou task refresh-linkage`.");
|
|
3115
|
+
console.log("Re-run with --write to apply.");
|
|
3116
|
+
}
|
|
3117
|
+
function describeReconcileSummary(r) {
|
|
3118
|
+
const linkedCount = r.brokenLinkedSessions.length;
|
|
3119
|
+
const parts = [];
|
|
3120
|
+
if (r.brokenCreatedInSession !== null) {
|
|
3121
|
+
parts.push("replaced created_in_session");
|
|
3122
|
+
}
|
|
3123
|
+
if (linkedCount > 0) {
|
|
3124
|
+
parts.push(`removed ${linkedCount} linked_sessions entr${linkedCount === 1 ? "y" : "ies"}`);
|
|
3125
|
+
}
|
|
3126
|
+
return parts.join(" + ");
|
|
3127
|
+
}
|
|
3128
|
+
function describeBrokenSummary(r, scope, verbose) {
|
|
3129
|
+
const showIds = scope === "task" || verbose;
|
|
3130
|
+
const parts = [];
|
|
3131
|
+
if (r.brokenCreatedInSession !== null) {
|
|
3132
|
+
parts.push(
|
|
3133
|
+
showIds ? `broken created_in_session ${formatSessionIdForDisplay(r.brokenCreatedInSession, verbose, scope)}` : "broken created_in_session"
|
|
3134
|
+
);
|
|
3135
|
+
}
|
|
3136
|
+
const linkedCount = r.brokenLinkedSessions.length;
|
|
3137
|
+
if (linkedCount > 0) {
|
|
3138
|
+
if (showIds) {
|
|
3139
|
+
const ids = r.brokenLinkedSessions.map((id) => formatSessionIdForDisplay(id, verbose, scope)).join(", ");
|
|
3140
|
+
parts.push(`${linkedCount} linked_sessions entr${linkedCount === 1 ? "y" : "ies"} [${ids}]`);
|
|
3141
|
+
} else {
|
|
3142
|
+
parts.push(`${linkedCount} linked_sessions entr${linkedCount === 1 ? "y" : "ies"}`);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
return parts.join(" + ");
|
|
3146
|
+
}
|
|
3147
|
+
function formatSessionIdForDisplay(id, verbose, scope) {
|
|
3148
|
+
if (verbose && scope === "task") return id;
|
|
3149
|
+
return `ses_${shortSessionId(id)}`;
|
|
3150
|
+
}
|
|
3151
|
+
async function runTaskRefreshLinkage(taskIdInput, options, ctx = {}) {
|
|
3152
|
+
try {
|
|
3153
|
+
await doRunTaskRefreshLinkage(taskIdInput, options, ctx);
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
3156
|
+
process.exitCode = 1;
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
async function doRunTaskRefreshLinkage(taskIdInput, options, ctx) {
|
|
3160
|
+
if (taskIdInput.trim().length === 0) {
|
|
3161
|
+
throw new Error("Task id is empty");
|
|
3162
|
+
}
|
|
3163
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
3164
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "refresh-linkage");
|
|
3165
|
+
const paths = basouPaths9(repositoryRoot);
|
|
3166
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
3167
|
+
const manifest = await readManifest6(paths);
|
|
3168
|
+
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
3169
|
+
const nowProvider = ctx.nowProvider ?? (() => /* @__PURE__ */ new Date());
|
|
3170
|
+
const write = options.write === true;
|
|
3171
|
+
const result = await refreshTaskLinkedSessions(paths, manifest, {
|
|
3172
|
+
taskId,
|
|
3173
|
+
occurredAt: nowProvider().toISOString(),
|
|
3174
|
+
workingDirectory: repositoryRoot,
|
|
3175
|
+
write
|
|
3176
|
+
});
|
|
3177
|
+
if (options.json === true) {
|
|
3178
|
+
printRefreshLinkageJson(result, { dryRun: !write });
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
printRefreshLinkageText(result, { dryRun: !write });
|
|
3182
|
+
}
|
|
3183
|
+
function printRefreshLinkageJson(result, input) {
|
|
3184
|
+
console.log(
|
|
3185
|
+
JSON.stringify(
|
|
3186
|
+
{
|
|
3187
|
+
task_id: result.taskId,
|
|
3188
|
+
clean: result.clean,
|
|
3189
|
+
dry_run: input.dryRun,
|
|
3190
|
+
added_linked_sessions: result.addedLinkedSessions,
|
|
3191
|
+
removed_linked_sessions: result.removedLinkedSessions,
|
|
3192
|
+
final_count: result.finalCount,
|
|
3193
|
+
refresh_session_id: result.refreshSession?.sessionId ?? null,
|
|
3194
|
+
event_id: result.refreshSession?.eventId ?? null
|
|
3195
|
+
},
|
|
3196
|
+
null,
|
|
3197
|
+
2
|
|
3198
|
+
)
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
function printRefreshLinkageText(result, input) {
|
|
3202
|
+
if (result.clean) {
|
|
3203
|
+
console.log(
|
|
3204
|
+
`${result.taskId}: linked_sessions already fresh (${result.finalCount} entr${result.finalCount === 1 ? "y" : "ies"}).`
|
|
3205
|
+
);
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
const addedCount = result.addedLinkedSessions.length;
|
|
3209
|
+
const removedCount = result.removedLinkedSessions.length;
|
|
3210
|
+
const summaryParts = [];
|
|
3211
|
+
if (addedCount > 0) {
|
|
3212
|
+
summaryParts.push(`+${addedCount} added`);
|
|
3213
|
+
}
|
|
3214
|
+
if (removedCount > 0) {
|
|
3215
|
+
summaryParts.push(`-${removedCount} removed`);
|
|
3216
|
+
}
|
|
3217
|
+
const summary = summaryParts.join(", ");
|
|
3218
|
+
if (input.dryRun) {
|
|
3219
|
+
console.log(`(dry-run) Would refresh ${result.taskId} linked_sessions: ${summary}.`);
|
|
3220
|
+
console.log("Re-run with --write to apply.");
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
const sid = result.refreshSession !== null ? ` (in session ${shortSessionId(result.refreshSession.sessionId)})` : "";
|
|
3224
|
+
console.log(
|
|
3225
|
+
`Refreshed ${result.taskId} linked_sessions: ${summary}${sid}; final count ${result.finalCount}.`
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
async function runTaskEdit(taskIdInput, options, ctx = {}) {
|
|
3229
|
+
try {
|
|
3230
|
+
await doRunTaskEdit(taskIdInput, options, ctx);
|
|
3231
|
+
} catch (error) {
|
|
3232
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
3233
|
+
process.exitCode = 1;
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
async function doRunTaskEdit(taskIdInput, options, ctx) {
|
|
3237
|
+
if (taskIdInput.trim().length === 0) {
|
|
3238
|
+
throw new Error("Task id is empty");
|
|
3239
|
+
}
|
|
3240
|
+
if (options.title === void 0 && options.status === void 0) {
|
|
3241
|
+
throw new Error("Nothing to edit: provide --title or --status");
|
|
3242
|
+
}
|
|
3243
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
3244
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "edit");
|
|
3245
|
+
const paths = basouPaths9(repositoryRoot);
|
|
3246
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
3247
|
+
const manifest = await readManifest6(paths);
|
|
3248
|
+
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
3249
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
3250
|
+
const occurredAt = now.toISOString();
|
|
3251
|
+
const result = await editTask({
|
|
3252
|
+
paths,
|
|
3253
|
+
taskId,
|
|
3254
|
+
occurredAt,
|
|
3255
|
+
manifest,
|
|
3256
|
+
workingDirectory: repositoryRoot,
|
|
3257
|
+
...options.title !== void 0 ? { title: options.title } : {},
|
|
3258
|
+
...options.status !== void 0 ? { newStatus: options.status } : {}
|
|
3259
|
+
});
|
|
3260
|
+
if (options.json === true) {
|
|
3261
|
+
console.log(
|
|
3262
|
+
JSON.stringify({
|
|
3263
|
+
task_id: result.taskId,
|
|
3264
|
+
title_updated: result.titleUpdated,
|
|
3265
|
+
status_updated: result.statusUpdated,
|
|
3266
|
+
previous_status: result.previousStatus,
|
|
3267
|
+
new_status: result.newStatus,
|
|
3268
|
+
status_change_session_id: result.statusChangeSession?.sessionId ?? null,
|
|
3269
|
+
status_change_event_id: result.statusChangeSession?.eventId ?? null
|
|
3270
|
+
})
|
|
3271
|
+
);
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
if (result.statusUpdated) {
|
|
3275
|
+
const sid = result.statusChangeSession !== null ? ` (in session ${shortSessionId(result.statusChangeSession.sessionId)})` : "";
|
|
3276
|
+
console.log(
|
|
3277
|
+
`Updated ${result.taskId} status: ${result.previousStatus} -> ${result.newStatus}${sid}`
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
3280
|
+
if (result.titleUpdated) {
|
|
3281
|
+
console.log(`Updated ${result.taskId} title.`);
|
|
3282
|
+
}
|
|
3283
|
+
if (!result.statusUpdated && !result.titleUpdated) {
|
|
3284
|
+
console.log(`No changes for ${result.taskId}.`);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
async function runTaskDelete(taskIdInput, options, ctx = {}) {
|
|
3288
|
+
try {
|
|
3289
|
+
await doRunTaskDelete(taskIdInput, options, ctx);
|
|
3290
|
+
} catch (error) {
|
|
3291
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
3292
|
+
process.exitCode = 1;
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
async function doRunTaskDelete(taskIdInput, options, ctx) {
|
|
3296
|
+
if (taskIdInput.trim().length === 0) {
|
|
3297
|
+
throw new Error("Task id is empty");
|
|
3298
|
+
}
|
|
3299
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
3300
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "delete");
|
|
3301
|
+
const paths = basouPaths9(repositoryRoot);
|
|
3302
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
3303
|
+
const manifest = await readManifest6(paths);
|
|
3304
|
+
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
3305
|
+
if (options.yes !== true) {
|
|
3306
|
+
await confirmDestructiveAction("delete", taskId);
|
|
3307
|
+
}
|
|
3308
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
3309
|
+
const occurredAt = now.toISOString();
|
|
3310
|
+
const result = await deleteTask({
|
|
3311
|
+
paths,
|
|
3312
|
+
manifest,
|
|
3313
|
+
taskId,
|
|
3314
|
+
occurredAt,
|
|
3315
|
+
workingDirectory: repositoryRoot
|
|
3316
|
+
});
|
|
3317
|
+
if (options.json === true) {
|
|
3318
|
+
console.log(
|
|
3319
|
+
JSON.stringify({
|
|
3320
|
+
task_id: result.taskId,
|
|
3321
|
+
title: result.title,
|
|
3322
|
+
session_id: result.sessionId,
|
|
3323
|
+
event_id: result.eventId
|
|
3324
|
+
})
|
|
3325
|
+
);
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
console.log(
|
|
3329
|
+
`Deleted ${result.taskId} ("${result.title}") in ad-hoc session ${shortSessionId(result.sessionId)}.`
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
3332
|
+
async function runTaskArchive(taskIdInput, options, ctx = {}) {
|
|
3333
|
+
try {
|
|
3334
|
+
await doRunTaskArchive(taskIdInput, options, ctx);
|
|
3335
|
+
} catch (error) {
|
|
3336
|
+
renderCliError(error, { verbose: isVerbose(options), classifiers: TASK_CLASSIFIERS });
|
|
3337
|
+
process.exitCode = 1;
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
async function doRunTaskArchive(taskIdInput, options, ctx) {
|
|
3341
|
+
if (taskIdInput.trim().length === 0) {
|
|
3342
|
+
throw new Error("Task id is empty");
|
|
3343
|
+
}
|
|
3344
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
3345
|
+
const repositoryRoot = await resolveRepositoryRootForTask(cwd, "archive");
|
|
3346
|
+
const paths = basouPaths9(repositoryRoot);
|
|
3347
|
+
await assertWorkspaceInitialized6(paths.root);
|
|
3348
|
+
const manifest = await readManifest6(paths);
|
|
3349
|
+
const taskId = await resolveTaskId2(paths, taskIdInput);
|
|
3350
|
+
if (options.yes !== true) {
|
|
3351
|
+
await confirmDestructiveAction("archive", taskId);
|
|
3352
|
+
}
|
|
3353
|
+
const now = ctx.nowProvider !== void 0 ? ctx.nowProvider() : /* @__PURE__ */ new Date();
|
|
3354
|
+
const occurredAt = now.toISOString();
|
|
3355
|
+
const result = await archiveTask({
|
|
3356
|
+
paths,
|
|
3357
|
+
manifest,
|
|
3358
|
+
taskId,
|
|
3359
|
+
occurredAt,
|
|
3360
|
+
workingDirectory: repositoryRoot
|
|
3361
|
+
});
|
|
3362
|
+
if (options.json === true) {
|
|
3363
|
+
console.log(
|
|
3364
|
+
JSON.stringify({
|
|
3365
|
+
task_id: result.taskId,
|
|
3366
|
+
title: result.title,
|
|
3367
|
+
session_id: result.sessionId,
|
|
3368
|
+
event_id: result.eventId
|
|
3369
|
+
})
|
|
3370
|
+
);
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
console.log(
|
|
3374
|
+
`Archived ${result.taskId} ("${result.title}") in ad-hoc session ${shortSessionId(result.sessionId)}.`
|
|
3375
|
+
);
|
|
3376
|
+
}
|
|
3377
|
+
async function confirmDestructiveAction(action, taskId) {
|
|
3378
|
+
if (process.stdin.isTTY !== true) {
|
|
3379
|
+
throw new Error(`Refusing to ${action} without TTY; rerun with --yes to skip confirmation.`);
|
|
3380
|
+
}
|
|
3381
|
+
const verb = action === "delete" ? "Delete" : "Archive";
|
|
3382
|
+
process.stdout.write(`${verb} task \`${taskId}\`? [y/N] `);
|
|
3383
|
+
const answer = await readSingleLineFromStdin();
|
|
3384
|
+
const normalized = answer.trim().toLowerCase();
|
|
3385
|
+
if (normalized !== "y" && normalized !== "yes") {
|
|
3386
|
+
throw new Error(`${verb} aborted by user.`);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
async function readSingleLineFromStdin() {
|
|
3390
|
+
const { createInterface } = await import("readline/promises");
|
|
3391
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3392
|
+
try {
|
|
3393
|
+
const line = await rl.question("");
|
|
3394
|
+
return line;
|
|
3395
|
+
} finally {
|
|
3396
|
+
rl.close();
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function parseTitle2(raw) {
|
|
3400
|
+
if (raw.length === 0) {
|
|
3401
|
+
throw new InvalidArgumentError3("Title must not be empty");
|
|
3402
|
+
}
|
|
3403
|
+
return raw;
|
|
3404
|
+
}
|
|
3405
|
+
function parseLabel(raw) {
|
|
3406
|
+
if (raw.length === 0) {
|
|
3407
|
+
throw new InvalidArgumentError3("Label must not be empty");
|
|
3408
|
+
}
|
|
3409
|
+
return raw;
|
|
3410
|
+
}
|
|
3411
|
+
function parseInitialTaskStatus(raw) {
|
|
3412
|
+
const result = TaskStatusSchema.safeParse(raw);
|
|
3413
|
+
if (!result.success) {
|
|
3414
|
+
throw new InvalidArgumentError3(
|
|
3415
|
+
`Initial task status must be one of: ${STATUS_VALUES3.join(", ")}`
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
return result.data;
|
|
3419
|
+
}
|
|
3420
|
+
var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
3421
|
+
function parseIsoTimestampOption(raw) {
|
|
3422
|
+
if (!ISO_DATE_RE.test(raw) || Number.isNaN(Date.parse(raw))) {
|
|
3423
|
+
throw new InvalidArgumentError3(
|
|
3424
|
+
"Invalid --completed-at value; expected ISO-8601 timestamp like 2026-05-10T12:34:56+09:00"
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3427
|
+
return raw;
|
|
3428
|
+
}
|
|
3429
|
+
function parseTaskStatusFilter(raw) {
|
|
3430
|
+
const result = TaskStatusSchema.safeParse(raw);
|
|
3431
|
+
if (!result.success) {
|
|
3432
|
+
throw new InvalidArgumentError3(
|
|
3433
|
+
`Invalid task status: ${raw}. Valid values: ${STATUS_VALUES3.join(", ")}`
|
|
3434
|
+
);
|
|
3435
|
+
}
|
|
3436
|
+
return result.data;
|
|
3437
|
+
}
|
|
3438
|
+
function parseTaskStatusPositional(raw) {
|
|
3439
|
+
const result = TaskStatusSchema.safeParse(raw);
|
|
3440
|
+
if (!result.success) {
|
|
3441
|
+
throw new Error(`Invalid task status: ${raw}. Valid values: ${STATUS_VALUES3.join(", ")}`);
|
|
3442
|
+
}
|
|
3443
|
+
return result.data;
|
|
3444
|
+
}
|
|
3445
|
+
function parseDescriptionOption(raw) {
|
|
3446
|
+
if (raw.length === 0) {
|
|
3447
|
+
throw new InvalidArgumentError3("Description must not be empty");
|
|
3448
|
+
}
|
|
3449
|
+
return raw;
|
|
3450
|
+
}
|
|
3451
|
+
function parsePositiveInt2(raw) {
|
|
3452
|
+
const n = Number.parseInt(raw, 10);
|
|
3453
|
+
if (!Number.isInteger(n) || n < 1 || raw.trim() !== String(n)) {
|
|
3454
|
+
throw new InvalidArgumentError3(`Invalid number: ${raw}`);
|
|
3455
|
+
}
|
|
3456
|
+
return n;
|
|
3457
|
+
}
|
|
3458
|
+
async function readDescriptionFile(path) {
|
|
3459
|
+
try {
|
|
3460
|
+
return await readFile2(path, "utf8");
|
|
3461
|
+
} catch (error) {
|
|
3462
|
+
if (findErrorCode7(error, "ENOENT")) {
|
|
3463
|
+
throw new Error("Description source not found", { cause: error });
|
|
3464
|
+
}
|
|
3465
|
+
if (findErrorCode7(error, "EISDIR")) {
|
|
3466
|
+
throw new Error("Description source is not a file", { cause: error });
|
|
3467
|
+
}
|
|
3468
|
+
throw new Error("Failed to read description source", { cause: error });
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
async function resolveRepositoryRootForTask(cwd, subcmd) {
|
|
3472
|
+
try {
|
|
3473
|
+
return await resolveRepositoryRoot10(cwd);
|
|
3474
|
+
} catch (error) {
|
|
3475
|
+
if (error instanceof Error && error.message === "Not a git repository") {
|
|
3476
|
+
throw new Error(
|
|
3477
|
+
`Not a git repository. Run 'git init' first, then re-run 'basou task ${subcmd}'.`,
|
|
3478
|
+
{ cause: error }
|
|
3479
|
+
);
|
|
3480
|
+
}
|
|
3481
|
+
throw error;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
async function assertWorkspaceInitialized6(basouRoot) {
|
|
3485
|
+
try {
|
|
3486
|
+
await assertBasouRootSafe9(basouRoot);
|
|
3487
|
+
} catch (error) {
|
|
3488
|
+
if (findErrorCode7(error, "ENOENT")) {
|
|
3489
|
+
throw new Error("Workspace not initialized. Run 'basou init' first.");
|
|
3490
|
+
}
|
|
3491
|
+
throw error;
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
var taskWriteAfterEventClassifier = {
|
|
3495
|
+
match: (error) => error instanceof TaskWriteAfterEventError,
|
|
3496
|
+
additionalLines: (error) => {
|
|
3497
|
+
const e = error;
|
|
3498
|
+
const sid = shortSessionId(e.sessionId);
|
|
3499
|
+
const tid = shortTaskId(e.taskId);
|
|
3500
|
+
const unsafeArtefact = describeUnsafeArtefact(e.phase, tid, sid);
|
|
3501
|
+
const warning = describeWriteFailureWarning(e.phase);
|
|
3502
|
+
const hint = e.phase === "reconcile-concurrent" ? "re-run `basou task reconcile`" : e.phase === "linkage-refresh-concurrent" ? "re-run `basou task refresh-linkage`" : "manual repair required; see `basou task show -v` for event payload";
|
|
3503
|
+
return [
|
|
3504
|
+
`Recorded ${e.eventId} in session ${sid}; ${unsafeArtefact} is in unsafe state; do not rerun`,
|
|
3505
|
+
`Warning: ${warning}; events.jsonl is consistent; ${hint}`
|
|
3506
|
+
];
|
|
3507
|
+
}
|
|
3508
|
+
};
|
|
3509
|
+
var TASK_CLASSIFIERS = [
|
|
3510
|
+
taskWriteAfterEventClassifier,
|
|
3511
|
+
failedToFinalizeClassifier
|
|
3512
|
+
];
|
|
3513
|
+
function describeUnsafeArtefact(phase, tid, sid) {
|
|
3514
|
+
switch (phase) {
|
|
3515
|
+
case "create":
|
|
3516
|
+
return `task ${tid} file`;
|
|
3517
|
+
case "overwrite":
|
|
3518
|
+
return `task ${tid} file`;
|
|
3519
|
+
case "link-session":
|
|
3520
|
+
return "session-task linkage";
|
|
3521
|
+
case "reconcile":
|
|
3522
|
+
return `task ${tid} file (reconcile incomplete)`;
|
|
3523
|
+
case "reconcile-finalize":
|
|
3524
|
+
return `reconcile session ${sid} (finalize incomplete)`;
|
|
3525
|
+
case "reconcile-concurrent":
|
|
3526
|
+
return `task ${tid} file (concurrent modification detected)`;
|
|
3527
|
+
case "linkage-refresh":
|
|
3528
|
+
return `task ${tid} file (linkage refresh incomplete)`;
|
|
3529
|
+
case "linkage-refresh-finalize":
|
|
3530
|
+
return `linkage refresh session ${sid} (finalize incomplete)`;
|
|
3531
|
+
case "linkage-refresh-concurrent":
|
|
3532
|
+
return `task ${tid} file (concurrent modification detected)`;
|
|
3533
|
+
case "delete":
|
|
3534
|
+
return `task ${tid} file (delete incomplete; file still on disk)`;
|
|
3535
|
+
case "archive":
|
|
3536
|
+
return `task ${tid} file (archive incomplete; check tasks/ and tasks/archive/)`;
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
function describeWriteFailureWarning(phase) {
|
|
3540
|
+
switch (phase) {
|
|
3541
|
+
case "create":
|
|
3542
|
+
return "task.md creation failed";
|
|
3543
|
+
case "overwrite":
|
|
3544
|
+
return "task.md update failed";
|
|
3545
|
+
case "link-session":
|
|
3546
|
+
return "session.yaml task_id update failed";
|
|
3547
|
+
case "reconcile":
|
|
3548
|
+
return "task.md reconciliation failed";
|
|
3549
|
+
case "reconcile-finalize":
|
|
3550
|
+
return "reconcile session finalize failed (session.yaml status update)";
|
|
3551
|
+
case "reconcile-concurrent":
|
|
3552
|
+
return "task.md was modified concurrently; re-run reconcile to retry";
|
|
3553
|
+
case "linkage-refresh":
|
|
3554
|
+
return "task.md linkage refresh write failed";
|
|
3555
|
+
case "linkage-refresh-finalize":
|
|
3556
|
+
return "linkage refresh session finalize failed (session.yaml status update)";
|
|
3557
|
+
case "linkage-refresh-concurrent":
|
|
3558
|
+
return "task.md was modified concurrently; re-run refresh-linkage to retry";
|
|
3559
|
+
case "delete":
|
|
3560
|
+
return "task.md unlink failed after task_deleted event committed";
|
|
3561
|
+
case "archive":
|
|
3562
|
+
return "task.md move to archive/ failed after task_archived event committed";
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
function pad3(value, width) {
|
|
3566
|
+
return value.length >= width ? value : value + " ".repeat(width - value.length);
|
|
3567
|
+
}
|
|
3568
|
+
function maxLen3(values, floor) {
|
|
3569
|
+
let max = floor;
|
|
3570
|
+
for (const v of values) if (v.length > max) max = v.length;
|
|
3571
|
+
return max;
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
// src/index.ts
|
|
3575
|
+
var require2 = createRequire(import.meta.url);
|
|
3576
|
+
var pkg = require2("../package.json");
|
|
3577
|
+
var BASOU_CLI_VERSION = pkg.version;
|
|
3578
|
+
var program = new Command();
|
|
3579
|
+
program.name("basou").description("Provenance layer for AI development").version(BASOU_CLI_VERSION).enablePositionalOptions();
|
|
3580
|
+
registerInitCommand(program);
|
|
3581
|
+
registerStatusCommand(program);
|
|
3582
|
+
registerExecCommand(program);
|
|
3583
|
+
registerRunCommand(program);
|
|
3584
|
+
registerSessionCommand(program);
|
|
3585
|
+
registerApprovalCommand(program);
|
|
3586
|
+
registerDecisionCommand(program);
|
|
3587
|
+
registerTaskCommand(program);
|
|
3588
|
+
registerHandoffCommand(program);
|
|
3589
|
+
registerDecisionsCommand(program);
|
|
3590
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
3591
|
+
renderCliError(err, { verbose: isVerbose(void 0) });
|
|
3592
|
+
process.exit(1);
|
|
3593
|
+
});
|
|
3594
|
+
//# sourceMappingURL=index.js.map
|