@titan-design/active-work 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/claude-commands/aw-prompt.md +21 -0
- package/dist/aw.js +183 -0
- package/dist/aw.js.map +1 -0
- package/dist/chunk-OET6AFME.js +1276 -0
- package/dist/chunk-OET6AFME.js.map +1 -0
- package/dist/cli.js +6967 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashboard/index.html +22 -0
- package/package.json +88 -0
- package/scripts/gen-cli-reference.mjs +139 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/preuninstall.js +15 -0
- package/skill/SKILL.md +54 -0
- package/skill/references/auditing-existing-work.md +125 -0
- package/skill/references/cli-dev.md +63 -0
- package/skill/references/onboarding.md +72 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
// src/utils/paths.ts
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import envPaths from "env-paths";
|
|
5
|
+
var PROJECT_NAME = "active-work";
|
|
6
|
+
function paths() {
|
|
7
|
+
return envPaths(PROJECT_NAME, { suffix: "" });
|
|
8
|
+
}
|
|
9
|
+
function expandTilde(p) {
|
|
10
|
+
if (p === "~") return os.homedir();
|
|
11
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
function getActiveRoot() {
|
|
15
|
+
const override = process.env.ACTIVE_ROOT;
|
|
16
|
+
if (override && override.length > 0) {
|
|
17
|
+
return path.resolve(expandTilde(override));
|
|
18
|
+
}
|
|
19
|
+
return paths().data;
|
|
20
|
+
}
|
|
21
|
+
function getStateRoot() {
|
|
22
|
+
return paths().log;
|
|
23
|
+
}
|
|
24
|
+
function getConfigRoot() {
|
|
25
|
+
return paths().config;
|
|
26
|
+
}
|
|
27
|
+
function getInitiativeDir(slug) {
|
|
28
|
+
return path.join(getActiveRoot(), slug);
|
|
29
|
+
}
|
|
30
|
+
function getLockPath(slug) {
|
|
31
|
+
return path.join(getInitiativeDir(slug), ".lock");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/registry/json-envelope.ts
|
|
35
|
+
function successEnvelope(data, warnings) {
|
|
36
|
+
if (warnings && warnings.length > 0) {
|
|
37
|
+
return { ok: true, data, warnings };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, data };
|
|
40
|
+
}
|
|
41
|
+
function errorEnvelope(error, code) {
|
|
42
|
+
return { ok: false, error, code };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/registry/types.ts
|
|
46
|
+
function defineCommand(cmd) {
|
|
47
|
+
return cmd;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/registry/index.ts
|
|
51
|
+
var registry = /* @__PURE__ */ new Map();
|
|
52
|
+
function register(cmd) {
|
|
53
|
+
if (registry.has(cmd.name)) {
|
|
54
|
+
throw new Error(`Command already registered: ${cmd.name}`);
|
|
55
|
+
}
|
|
56
|
+
registry.set(cmd.name, cmd);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/errors.ts
|
|
60
|
+
var EXIT = {
|
|
61
|
+
OK: 0,
|
|
62
|
+
GENERIC: 1,
|
|
63
|
+
USAGE: 64,
|
|
64
|
+
// EX_USAGE
|
|
65
|
+
DATAERR: 65,
|
|
66
|
+
// EX_DATAERR — invalid input data / validation
|
|
67
|
+
NOINPUT: 66,
|
|
68
|
+
// EX_NOINPUT — file/initiative not found
|
|
69
|
+
UNAVAILABLE: 69,
|
|
70
|
+
// EX_UNAVAILABLE — daemon unreachable
|
|
71
|
+
SOFTWARE: 70,
|
|
72
|
+
// EX_SOFTWARE — internal bug
|
|
73
|
+
CONFIG: 78
|
|
74
|
+
// EX_CONFIG — bad config
|
|
75
|
+
};
|
|
76
|
+
var ActiveWorkError = class extends Error {
|
|
77
|
+
code = EXIT.GENERIC;
|
|
78
|
+
constructor(message, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.name = "ActiveWorkError";
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var ValidationError = class extends ActiveWorkError {
|
|
84
|
+
code = EXIT.DATAERR;
|
|
85
|
+
constructor(message, options) {
|
|
86
|
+
super(message, options);
|
|
87
|
+
this.name = "ValidationError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var NotFoundError = class extends ActiveWorkError {
|
|
91
|
+
code = EXIT.NOINPUT;
|
|
92
|
+
constructor(message, options) {
|
|
93
|
+
super(message, options);
|
|
94
|
+
this.name = "NotFoundError";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var UsageError = class extends ActiveWorkError {
|
|
98
|
+
code = EXIT.USAGE;
|
|
99
|
+
constructor(message, options) {
|
|
100
|
+
super(message, options);
|
|
101
|
+
this.name = "UsageError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
var DaemonError = class extends ActiveWorkError {
|
|
105
|
+
code = EXIT.UNAVAILABLE;
|
|
106
|
+
constructor(message, options) {
|
|
107
|
+
super(message, options);
|
|
108
|
+
this.name = "DaemonError";
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
var ConfigError = class extends ActiveWorkError {
|
|
112
|
+
code = EXIT.CONFIG;
|
|
113
|
+
constructor(message, options) {
|
|
114
|
+
super(message, options);
|
|
115
|
+
this.name = "ConfigError";
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
function formatError(err) {
|
|
119
|
+
if (err instanceof ActiveWorkError) {
|
|
120
|
+
return { message: err.message, code: err.code };
|
|
121
|
+
}
|
|
122
|
+
if (err instanceof Error) {
|
|
123
|
+
return { message: err.message, code: EXIT.GENERIC };
|
|
124
|
+
}
|
|
125
|
+
return { message: String(err), code: EXIT.GENERIC };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/commands/open.ts
|
|
129
|
+
import path7 from "path";
|
|
130
|
+
import { z as z5 } from "zod";
|
|
131
|
+
|
|
132
|
+
// src/schemas/brief.ts
|
|
133
|
+
import { z } from "zod";
|
|
134
|
+
var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
135
|
+
var isValidIsoDate = (value) => {
|
|
136
|
+
if (!ISO_DATE_REGEX.test(value)) return false;
|
|
137
|
+
const parsed = new Date(value);
|
|
138
|
+
if (Number.isNaN(parsed.getTime())) return false;
|
|
139
|
+
return parsed.toISOString().slice(0, 10) === value;
|
|
140
|
+
};
|
|
141
|
+
var isoDate = z.string().refine(isValidIsoDate, { message: "Must be a valid zero-padded YYYY-MM-DD date" });
|
|
142
|
+
var positiveInt = z.number().int().positive();
|
|
143
|
+
var worktreeEntry = z.object({
|
|
144
|
+
path: z.string().min(1),
|
|
145
|
+
default: z.boolean().optional()
|
|
146
|
+
});
|
|
147
|
+
var channelTarget = z.string().min(1).regex(/^(?:(?:server|plugin):.+|[A-Za-z0-9_-]+)$/, {
|
|
148
|
+
message: 'channel must be a target like "server:voltras", "plugin:name@marketplace", or a bare server name'
|
|
149
|
+
});
|
|
150
|
+
var BriefFrontmatterSchema = z.object({
|
|
151
|
+
schema_version: positiveInt,
|
|
152
|
+
title: z.string().min(1),
|
|
153
|
+
updated: isoDate,
|
|
154
|
+
state: z.enum(["focused", "backburner", "paused", "done"]),
|
|
155
|
+
rank: positiveInt.optional(),
|
|
156
|
+
paused_since: isoDate.optional(),
|
|
157
|
+
restart_trigger: z.string().min(1).optional(),
|
|
158
|
+
ship_target: z.string().optional(),
|
|
159
|
+
owner: z.string().optional(),
|
|
160
|
+
task_prefix: z.string().min(1).regex(/^[A-Z][A-Z0-9]*$/, {
|
|
161
|
+
message: "task_prefix must be uppercase letters/digits starting with a letter"
|
|
162
|
+
}),
|
|
163
|
+
worktrees: z.record(z.string(), worktreeEntry).optional(),
|
|
164
|
+
channels: z.array(channelTarget).optional()
|
|
165
|
+
}).superRefine((value, ctx) => {
|
|
166
|
+
if (value.state === "focused" && value.rank === void 0) {
|
|
167
|
+
ctx.addIssue({
|
|
168
|
+
code: "custom",
|
|
169
|
+
path: ["rank"],
|
|
170
|
+
message: 'rank is required when state is "focused"'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (value.state === "paused") {
|
|
174
|
+
if (value.paused_since === void 0) {
|
|
175
|
+
ctx.addIssue({
|
|
176
|
+
code: "custom",
|
|
177
|
+
path: ["paused_since"],
|
|
178
|
+
message: 'paused_since is required when state is "paused"'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (value.restart_trigger === void 0) {
|
|
182
|
+
ctx.addIssue({
|
|
183
|
+
code: "custom",
|
|
184
|
+
path: ["restart_trigger"],
|
|
185
|
+
message: 'restart_trigger is required when state is "paused"'
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// src/bootstrap/prompt.ts
|
|
192
|
+
import { promises as fs3 } from "fs";
|
|
193
|
+
import path4 from "path";
|
|
194
|
+
|
|
195
|
+
// src/schemas/task.ts
|
|
196
|
+
import { z as z2 } from "zod";
|
|
197
|
+
var ISO_DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
198
|
+
var isValidIsoDate2 = (value) => {
|
|
199
|
+
if (!ISO_DATE_REGEX2.test(value)) return false;
|
|
200
|
+
const parsed = new Date(value);
|
|
201
|
+
if (Number.isNaN(parsed.getTime())) return false;
|
|
202
|
+
return parsed.toISOString().slice(0, 10) === value;
|
|
203
|
+
};
|
|
204
|
+
var isoDate2 = z2.string().refine(isValidIsoDate2, { message: "Must be a valid zero-padded YYYY-MM-DD date" });
|
|
205
|
+
var isoDateOrNull = z2.union([isoDate2, z2.null()]);
|
|
206
|
+
var TaskSchema = z2.object({
|
|
207
|
+
id: z2.string().regex(/^[A-Z][A-Z0-9]*-\d+$/, {
|
|
208
|
+
message: "id must match /^[A-Z][A-Z0-9]*-\\d+$/ (e.g. EC-1)"
|
|
209
|
+
}),
|
|
210
|
+
title: z2.string().min(1),
|
|
211
|
+
priority: z2.number().int().positive(),
|
|
212
|
+
severity: z2.enum(["critical", "high", "medium", "low"]).optional(),
|
|
213
|
+
estimate: z2.number().positive().optional(),
|
|
214
|
+
done_when: z2.string().min(1).optional(),
|
|
215
|
+
status: z2.enum(["open", "done"]),
|
|
216
|
+
tags: z2.array(z2.string()).optional(),
|
|
217
|
+
notes: z2.string().optional(),
|
|
218
|
+
created: isoDate2,
|
|
219
|
+
updated: isoDate2,
|
|
220
|
+
done_at: isoDateOrNull
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// src/schemas/session.ts
|
|
224
|
+
import { z as z3 } from "zod";
|
|
225
|
+
var ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
226
|
+
var isValidIso8601 = (value) => {
|
|
227
|
+
if (!ISO_8601_REGEX.test(value)) return false;
|
|
228
|
+
const parsed = new Date(value);
|
|
229
|
+
return !Number.isNaN(parsed.getTime());
|
|
230
|
+
};
|
|
231
|
+
var iso8601 = z3.string().refine(isValidIso8601, { message: "Must be a valid ISO 8601 datetime with timezone" });
|
|
232
|
+
var SessionFrontmatterSchema = z3.object({
|
|
233
|
+
session_id: z3.string().min(1),
|
|
234
|
+
started: iso8601,
|
|
235
|
+
ended: iso8601,
|
|
236
|
+
track: z3.enum(["canonical", "sidecar"])
|
|
237
|
+
}).superRefine((value, ctx) => {
|
|
238
|
+
const started = new Date(value.started).getTime();
|
|
239
|
+
const ended = new Date(value.ended).getTime();
|
|
240
|
+
if (Number.isFinite(started) && Number.isFinite(ended) && ended < started) {
|
|
241
|
+
ctx.addIssue({
|
|
242
|
+
code: "custom",
|
|
243
|
+
path: ["ended"],
|
|
244
|
+
message: "ended must be greater than or equal to started"
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// src/schemas/artifacts.ts
|
|
250
|
+
import { z as z4 } from "zod";
|
|
251
|
+
var BranchEntrySchema = z4.object({
|
|
252
|
+
repo: z4.string().min(1),
|
|
253
|
+
name: z4.string().min(1),
|
|
254
|
+
note: z4.string().optional()
|
|
255
|
+
});
|
|
256
|
+
var StashEntrySchema = z4.object({
|
|
257
|
+
repo: z4.string().min(1),
|
|
258
|
+
label: z4.string().min(1),
|
|
259
|
+
sha: z4.string().optional()
|
|
260
|
+
});
|
|
261
|
+
var ArtifactsSchema = z4.object({
|
|
262
|
+
branches: z4.array(BranchEntrySchema).default([]),
|
|
263
|
+
stashes: z4.array(StashEntrySchema).default([])
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// src/utils/yaml-io.ts
|
|
267
|
+
import { promises as fs2 } from "fs";
|
|
268
|
+
import YAML from "yaml";
|
|
269
|
+
|
|
270
|
+
// src/utils/fs-atomic.ts
|
|
271
|
+
import { randomBytes } from "crypto";
|
|
272
|
+
import { promises as fs } from "fs";
|
|
273
|
+
import path2 from "path";
|
|
274
|
+
import lockfile from "proper-lockfile";
|
|
275
|
+
async function atomicWrite(targetPath, content) {
|
|
276
|
+
const dir = path2.dirname(targetPath);
|
|
277
|
+
const base = path2.basename(targetPath);
|
|
278
|
+
const suffix = `${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
279
|
+
const tempPath = path2.join(dir, `${base}.tmp.${suffix}`);
|
|
280
|
+
let handle;
|
|
281
|
+
try {
|
|
282
|
+
handle = await fs.open(tempPath, "wx");
|
|
283
|
+
await handle.writeFile(content);
|
|
284
|
+
await handle.sync();
|
|
285
|
+
} finally {
|
|
286
|
+
if (handle) await handle.close();
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
await fs.rename(tempPath, targetPath);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
await fs.rm(tempPath, { force: true });
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async function withFileLock(lockTarget, fn) {
|
|
296
|
+
await fs.mkdir(path2.dirname(lockTarget), { recursive: true });
|
|
297
|
+
const release = await lockfile.lock(lockTarget, {
|
|
298
|
+
realpath: false,
|
|
299
|
+
retries: { retries: 5, factor: 1.5, minTimeout: 50 }
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
return await fn();
|
|
303
|
+
} finally {
|
|
304
|
+
await release();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/utils/coerce-dates.ts
|
|
309
|
+
function dateToString(d) {
|
|
310
|
+
if (d.getUTCHours() === 0 && d.getUTCMinutes() === 0 && d.getUTCSeconds() === 0 && d.getUTCMilliseconds() === 0) {
|
|
311
|
+
return d.toISOString().slice(0, 10);
|
|
312
|
+
}
|
|
313
|
+
return d.toISOString();
|
|
314
|
+
}
|
|
315
|
+
function coerceDates(value) {
|
|
316
|
+
if (value instanceof Date) {
|
|
317
|
+
return dateToString(value);
|
|
318
|
+
}
|
|
319
|
+
if (Array.isArray(value)) {
|
|
320
|
+
return value.map(coerceDates);
|
|
321
|
+
}
|
|
322
|
+
if (value !== null && typeof value === "object") {
|
|
323
|
+
const out = {};
|
|
324
|
+
for (const [k, v] of Object.entries(value)) {
|
|
325
|
+
out[k] = coerceDates(v);
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/utils/yaml-io.ts
|
|
333
|
+
async function readYaml(filePath, schema) {
|
|
334
|
+
const raw = await fs2.readFile(filePath, "utf8");
|
|
335
|
+
let parsed;
|
|
336
|
+
try {
|
|
337
|
+
parsed = YAML.parse(raw);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
340
|
+
throw new Error(`Failed to parse YAML at ${filePath}: ${reason}`);
|
|
341
|
+
}
|
|
342
|
+
const coerced = coerceDates(parsed);
|
|
343
|
+
const result = schema.safeParse(coerced);
|
|
344
|
+
if (!result.success) {
|
|
345
|
+
throw new Error(`Schema validation failed for ${filePath}: ${result.error.message}`);
|
|
346
|
+
}
|
|
347
|
+
return result.data;
|
|
348
|
+
}
|
|
349
|
+
async function writeYaml(filePath, data, schema) {
|
|
350
|
+
const result = schema.safeParse(data);
|
|
351
|
+
if (!result.success) {
|
|
352
|
+
throw new Error(`Schema validation failed for ${filePath}: ${result.error.message}`);
|
|
353
|
+
}
|
|
354
|
+
const yaml = YAML.stringify(result.data);
|
|
355
|
+
await atomicWrite(filePath, yaml);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/utils/git-gh.ts
|
|
359
|
+
import { spawn } from "child_process";
|
|
360
|
+
import path3 from "path";
|
|
361
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
362
|
+
var defaultRunner = (bin, args, opts = {}) => new Promise((resolve, reject) => {
|
|
363
|
+
const child = spawn(bin, args, {
|
|
364
|
+
cwd: opts.cwd,
|
|
365
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
366
|
+
});
|
|
367
|
+
const stdoutChunks = [];
|
|
368
|
+
const stderrChunks = [];
|
|
369
|
+
let settled = false;
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
settled = true;
|
|
373
|
+
child.kill("SIGKILL");
|
|
374
|
+
reject(new Error(`${bin} timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`));
|
|
375
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
376
|
+
child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
377
|
+
child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
|
|
378
|
+
child.on("error", (err) => {
|
|
379
|
+
if (settled) return;
|
|
380
|
+
settled = true;
|
|
381
|
+
clearTimeout(timer);
|
|
382
|
+
reject(err);
|
|
383
|
+
});
|
|
384
|
+
child.on("close", (code) => {
|
|
385
|
+
if (settled) return;
|
|
386
|
+
settled = true;
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
resolve({
|
|
389
|
+
code,
|
|
390
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
391
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8")
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
var gitRunner = defaultRunner;
|
|
396
|
+
var ghRunner = defaultRunner;
|
|
397
|
+
function getGitRunner() {
|
|
398
|
+
return gitRunner;
|
|
399
|
+
}
|
|
400
|
+
function getGhRunner() {
|
|
401
|
+
return ghRunner;
|
|
402
|
+
}
|
|
403
|
+
function looksLikeOrgRepo(repo) {
|
|
404
|
+
if (!repo) return false;
|
|
405
|
+
if (repo.startsWith("/") || repo.startsWith("~") || repo.startsWith(".")) return false;
|
|
406
|
+
if (/\s/.test(repo)) return false;
|
|
407
|
+
const slashCount = (repo.match(/\//g) ?? []).length;
|
|
408
|
+
return slashCount === 1;
|
|
409
|
+
}
|
|
410
|
+
function resolveLocalRepoPath(repo) {
|
|
411
|
+
if (looksLikeOrgRepo(repo)) return null;
|
|
412
|
+
return path3.resolve(expandTilde(repo));
|
|
413
|
+
}
|
|
414
|
+
async function deriveOrgRepoFromPath(repoPath) {
|
|
415
|
+
try {
|
|
416
|
+
const res = await gitRunner("git", ["-C", repoPath, "remote", "get-url", "origin"]);
|
|
417
|
+
if (res.code !== 0) return null;
|
|
418
|
+
return parseOrgRepoFromRemoteUrl(res.stdout.trim());
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function parseOrgRepoFromRemoteUrl(url) {
|
|
424
|
+
const trimmed = url.trim().replace(/\.git$/, "");
|
|
425
|
+
const sshMatch = /^[^@]+@[^:]+:([^/]+)\/(.+)$/.exec(trimmed);
|
|
426
|
+
if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
|
|
427
|
+
try {
|
|
428
|
+
const u = new URL(trimmed);
|
|
429
|
+
const parts = u.pathname.replace(/^\//, "").split("/");
|
|
430
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
431
|
+
return `${parts[0]}/${parts[1]}`;
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
async function resolveOrgRepo(repo) {
|
|
438
|
+
if (looksLikeOrgRepo(repo)) return repo;
|
|
439
|
+
const localPath = resolveLocalRepoPath(repo);
|
|
440
|
+
if (!localPath) return null;
|
|
441
|
+
return deriveOrgRepoFromPath(localPath);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/utils/today.ts
|
|
445
|
+
function today() {
|
|
446
|
+
const now = /* @__PURE__ */ new Date();
|
|
447
|
+
const year = now.getFullYear();
|
|
448
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
449
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
450
|
+
return `${year}-${month}-${day}`;
|
|
451
|
+
}
|
|
452
|
+
function nowIso() {
|
|
453
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/bootstrap/prompt.ts
|
|
457
|
+
import YAML2 from "yaml";
|
|
458
|
+
var BRIEF_BODY_MAX_LINES = 40;
|
|
459
|
+
var SESSION_BODY_MAX_LINES = 25;
|
|
460
|
+
var DEFAULT_TOP_N_TASKS = 5;
|
|
461
|
+
var DEFAULT_RECENTLY_DONE_DAYS = 14;
|
|
462
|
+
var RECENT_THRESHOLD_DAYS = 14;
|
|
463
|
+
var MS_PER_HOUR = 1e3 * 60 * 60;
|
|
464
|
+
var MS_PER_DAY = MS_PER_HOUR * 24;
|
|
465
|
+
var FRONTMATTER_DELIM = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
466
|
+
async function readMarkdownWithSchema(filePath, schema) {
|
|
467
|
+
const raw = await fs3.readFile(filePath, "utf8");
|
|
468
|
+
const match = FRONTMATTER_DELIM.exec(raw);
|
|
469
|
+
let frontmatterText = "";
|
|
470
|
+
let body = raw;
|
|
471
|
+
if (match) {
|
|
472
|
+
frontmatterText = match[1] ?? "";
|
|
473
|
+
body = match[2] ?? "";
|
|
474
|
+
}
|
|
475
|
+
const parsed = frontmatterText ? YAML2.parse(frontmatterText) : {};
|
|
476
|
+
const result = schema.safeParse(parsed);
|
|
477
|
+
if (!result.success) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`Frontmatter validation failed for ${filePath}: ${result.error.message}`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return { frontmatter: result.data, body };
|
|
483
|
+
}
|
|
484
|
+
async function loadCanonicalSessions(initiativeDir) {
|
|
485
|
+
const sessionsDir = path4.join(initiativeDir, "sessions");
|
|
486
|
+
let entries;
|
|
487
|
+
try {
|
|
488
|
+
entries = await fs3.readdir(sessionsDir);
|
|
489
|
+
} catch {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
const mdFiles = entries.filter((n) => n.endsWith(".md"));
|
|
493
|
+
const loaded = [];
|
|
494
|
+
for (const filename of mdFiles) {
|
|
495
|
+
const fullPath = path4.join(sessionsDir, filename);
|
|
496
|
+
try {
|
|
497
|
+
const { frontmatter, body } = await readMarkdownWithSchema(
|
|
498
|
+
fullPath,
|
|
499
|
+
SessionFrontmatterSchema
|
|
500
|
+
);
|
|
501
|
+
if (frontmatter.track === "canonical") {
|
|
502
|
+
loaded.push({ filename, frontmatter, body });
|
|
503
|
+
}
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
loaded.sort(
|
|
508
|
+
(a, b) => new Date(b.frontmatter.ended).getTime() - new Date(a.frontmatter.ended).getTime()
|
|
509
|
+
);
|
|
510
|
+
return loaded;
|
|
511
|
+
}
|
|
512
|
+
async function loadTasks(initiativeDir) {
|
|
513
|
+
const tasksDir = path4.join(initiativeDir, "tasks");
|
|
514
|
+
let entries;
|
|
515
|
+
try {
|
|
516
|
+
entries = await fs3.readdir(tasksDir);
|
|
517
|
+
} catch {
|
|
518
|
+
return [];
|
|
519
|
+
}
|
|
520
|
+
const ymlFiles = entries.filter(
|
|
521
|
+
(n) => n.endsWith(".yml") || n.endsWith(".yaml")
|
|
522
|
+
);
|
|
523
|
+
const tasks = [];
|
|
524
|
+
for (const filename of ymlFiles) {
|
|
525
|
+
const fullPath = path4.join(tasksDir, filename);
|
|
526
|
+
try {
|
|
527
|
+
tasks.push(await readYaml(fullPath, TaskSchema));
|
|
528
|
+
} catch {
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return tasks;
|
|
532
|
+
}
|
|
533
|
+
async function loadArtifacts(initiativeDir) {
|
|
534
|
+
const artifactsPath = path4.join(initiativeDir, "artifacts.yml");
|
|
535
|
+
try {
|
|
536
|
+
return await readYaml(artifactsPath, ArtifactsSchema);
|
|
537
|
+
} catch {
|
|
538
|
+
return { branches: [], stashes: [] };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function truncateLines(body, max) {
|
|
542
|
+
const lines = body.split("\n");
|
|
543
|
+
const trimmed = [];
|
|
544
|
+
let count = 0;
|
|
545
|
+
for (const line of lines) {
|
|
546
|
+
if (count >= max) break;
|
|
547
|
+
trimmed.push(line);
|
|
548
|
+
if (line.trim().length > 0) count++;
|
|
549
|
+
}
|
|
550
|
+
return trimmed.join("\n").replace(/\s+$/, "");
|
|
551
|
+
}
|
|
552
|
+
function formatTimeSince(from, now) {
|
|
553
|
+
const diffMs = now.getTime() - from.getTime();
|
|
554
|
+
if (diffMs < MS_PER_HOUR) return "just now";
|
|
555
|
+
if (diffMs < MS_PER_DAY) {
|
|
556
|
+
const hours = Math.floor(diffMs / MS_PER_HOUR);
|
|
557
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
558
|
+
}
|
|
559
|
+
const days = Math.floor(diffMs / MS_PER_DAY);
|
|
560
|
+
const base = `${days} day${days === 1 ? "" : "s"} ago`;
|
|
561
|
+
if (days >= RECENT_THRESHOLD_DAYS) {
|
|
562
|
+
return `${base} \u2014 likely needs context refresher`;
|
|
563
|
+
}
|
|
564
|
+
return base;
|
|
565
|
+
}
|
|
566
|
+
function compareTasksByPriority(a, b) {
|
|
567
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
568
|
+
return a.id.localeCompare(b.id);
|
|
569
|
+
}
|
|
570
|
+
function firstLine(text) {
|
|
571
|
+
if (!text) return void 0;
|
|
572
|
+
for (const line of text.split("\n")) {
|
|
573
|
+
const trimmed = line.trim();
|
|
574
|
+
if (trimmed.length > 0) return trimmed;
|
|
575
|
+
}
|
|
576
|
+
return void 0;
|
|
577
|
+
}
|
|
578
|
+
function renderTaskLine(idx, task) {
|
|
579
|
+
const meta = [`priority ${task.priority}`];
|
|
580
|
+
if (task.severity) meta.push(`severity ${task.severity}`);
|
|
581
|
+
if (task.estimate !== void 0) meta.push(`est ${task.estimate}`);
|
|
582
|
+
let line = `${idx}. [${task.id}] (${meta.join(", ")}) ${task.title}`;
|
|
583
|
+
const note = firstLine(task.notes);
|
|
584
|
+
if (note) line += `
|
|
585
|
+
${note}`;
|
|
586
|
+
return line;
|
|
587
|
+
}
|
|
588
|
+
function renderTopTasks(tasks, topN) {
|
|
589
|
+
const openTasks = tasks.filter((t) => t.status === "open").sort(compareTasksByPriority);
|
|
590
|
+
if (openTasks.length === 0) {
|
|
591
|
+
return { body: "_No open tasks._", count: 0 };
|
|
592
|
+
}
|
|
593
|
+
const shown = openTasks.slice(0, topN);
|
|
594
|
+
return {
|
|
595
|
+
body: shown.map((task, i) => renderTaskLine(i + 1, task)).join("\n"),
|
|
596
|
+
count: openTasks.length
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function renderRecentlyDone(tasks, windowDays, now) {
|
|
600
|
+
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
|
|
601
|
+
const done = tasks.filter((t) => t.status === "done" && t.done_at).filter((t) => {
|
|
602
|
+
const ts = new Date(t.done_at).getTime();
|
|
603
|
+
return Number.isFinite(ts) && ts >= cutoff;
|
|
604
|
+
}).sort((a, b) => a.done_at < b.done_at ? 1 : -1);
|
|
605
|
+
if (done.length === 0) return { body: null, count: 0 };
|
|
606
|
+
const body = done.map((t) => `- [${t.id}] ${t.title} \u2014 done ${t.done_at}`).join("\n");
|
|
607
|
+
return { body, count: done.length };
|
|
608
|
+
}
|
|
609
|
+
var LIVE_RENDER_LIMIT = 10;
|
|
610
|
+
function renderStaticBranchLine(branch) {
|
|
611
|
+
const head = `- ${branch.name} (${branch.repo})`;
|
|
612
|
+
return branch.note ? `${head} \u2014 ${branch.note}` : head;
|
|
613
|
+
}
|
|
614
|
+
function renderLiveBranchLine(status) {
|
|
615
|
+
const parts = [`- ${status.name} (${status.repo})`];
|
|
616
|
+
if (!status.present) {
|
|
617
|
+
parts.push("[missing locally]");
|
|
618
|
+
} else if (status.ahead !== null && status.behind !== null) {
|
|
619
|
+
parts.push(`+${status.ahead}/-${status.behind}`);
|
|
620
|
+
}
|
|
621
|
+
if (status.pr) {
|
|
622
|
+
const checks = status.pr.checks ? ` ${status.pr.checks}` : "";
|
|
623
|
+
parts.push(`PR #${status.pr.number} ${status.pr.state}${checks}`);
|
|
624
|
+
}
|
|
625
|
+
let line = parts.join(" ");
|
|
626
|
+
if (status.note) line += ` \u2014 ${status.note}`;
|
|
627
|
+
return line;
|
|
628
|
+
}
|
|
629
|
+
function renderStashes(artifacts) {
|
|
630
|
+
if (artifacts.stashes.length === 0) return null;
|
|
631
|
+
return artifacts.stashes.map((s) => `- ${s.repo}: ${s.label}${s.sha ? ` (${s.sha.slice(0, 12)})` : ""}`).join("\n");
|
|
632
|
+
}
|
|
633
|
+
function renderStaticArtifacts(artifacts) {
|
|
634
|
+
const sections = [];
|
|
635
|
+
if (artifacts.branches.length > 0) {
|
|
636
|
+
const branchLines = artifacts.branches.map(renderStaticBranchLine).join("\n");
|
|
637
|
+
sections.push(`Branches:
|
|
638
|
+
${branchLines}`);
|
|
639
|
+
}
|
|
640
|
+
const stashBody = renderStashes(artifacts);
|
|
641
|
+
if (stashBody) sections.push(`Stashes:
|
|
642
|
+
${stashBody}`);
|
|
643
|
+
return sections.length > 0 ? sections.join("\n\n") : null;
|
|
644
|
+
}
|
|
645
|
+
function renderLiveArtifacts(artifacts, statuses) {
|
|
646
|
+
const sections = [];
|
|
647
|
+
if (statuses.length > 0) {
|
|
648
|
+
const shown = statuses.slice(0, LIVE_RENDER_LIMIT);
|
|
649
|
+
const lines = shown.map(renderLiveBranchLine).join("\n");
|
|
650
|
+
const overflow = statuses.length - shown.length;
|
|
651
|
+
const suffix = overflow > 0 ? `
|
|
652
|
+
(+${overflow} more)` : "";
|
|
653
|
+
sections.push(`Branches (live):
|
|
654
|
+
${lines}${suffix}`);
|
|
655
|
+
} else if (artifacts.branches.length > 0) {
|
|
656
|
+
const branchLines = artifacts.branches.map(renderStaticBranchLine).join("\n");
|
|
657
|
+
sections.push(`Branches:
|
|
658
|
+
${branchLines}`);
|
|
659
|
+
}
|
|
660
|
+
const stashBody = renderStashes(artifacts);
|
|
661
|
+
if (stashBody) sections.push(`Stashes:
|
|
662
|
+
${stashBody}`);
|
|
663
|
+
return sections.length > 0 ? sections.join("\n\n") : null;
|
|
664
|
+
}
|
|
665
|
+
async function defaultLiveStatusFetcher(branches) {
|
|
666
|
+
const results = [];
|
|
667
|
+
const limit = Math.min(branches.length, LIVE_RENDER_LIMIT);
|
|
668
|
+
for (let i = 0; i < limit; i++) {
|
|
669
|
+
results.push(await fetchOne(branches[i]));
|
|
670
|
+
}
|
|
671
|
+
return results;
|
|
672
|
+
}
|
|
673
|
+
async function fetchOne(branch) {
|
|
674
|
+
const out = {
|
|
675
|
+
repo: branch.repo,
|
|
676
|
+
name: branch.name,
|
|
677
|
+
...branch.note ? { note: branch.note } : {},
|
|
678
|
+
present: false,
|
|
679
|
+
last_commit_iso: null,
|
|
680
|
+
ahead: null,
|
|
681
|
+
behind: null,
|
|
682
|
+
pr: null
|
|
683
|
+
};
|
|
684
|
+
const repoPath = resolveLocalRepoPath(branch.repo);
|
|
685
|
+
const git = getGitRunner();
|
|
686
|
+
const gh = getGhRunner();
|
|
687
|
+
if (repoPath) {
|
|
688
|
+
try {
|
|
689
|
+
const exists = await git("git", [
|
|
690
|
+
"-C",
|
|
691
|
+
repoPath,
|
|
692
|
+
"rev-parse",
|
|
693
|
+
"--verify",
|
|
694
|
+
`refs/heads/${branch.name}`
|
|
695
|
+
]);
|
|
696
|
+
out.present = exists.code === 0;
|
|
697
|
+
} catch {
|
|
698
|
+
}
|
|
699
|
+
if (out.present) {
|
|
700
|
+
try {
|
|
701
|
+
const lc = await git("git", [
|
|
702
|
+
"-C",
|
|
703
|
+
repoPath,
|
|
704
|
+
"log",
|
|
705
|
+
"-1",
|
|
706
|
+
"--format=%cI",
|
|
707
|
+
branch.name
|
|
708
|
+
]);
|
|
709
|
+
if (lc.code === 0) {
|
|
710
|
+
const s = lc.stdout.trim();
|
|
711
|
+
out.last_commit_iso = s.length > 0 ? s : null;
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
}
|
|
715
|
+
for (const base of ["main", "master"]) {
|
|
716
|
+
try {
|
|
717
|
+
const verify = await git("git", [
|
|
718
|
+
"-C",
|
|
719
|
+
repoPath,
|
|
720
|
+
"rev-parse",
|
|
721
|
+
"--verify",
|
|
722
|
+
`refs/remotes/origin/${base}`
|
|
723
|
+
]);
|
|
724
|
+
if (verify.code !== 0) continue;
|
|
725
|
+
const counts = await git("git", [
|
|
726
|
+
"-C",
|
|
727
|
+
repoPath,
|
|
728
|
+
"rev-list",
|
|
729
|
+
"--left-right",
|
|
730
|
+
"--count",
|
|
731
|
+
`origin/${base}...${branch.name}`
|
|
732
|
+
]);
|
|
733
|
+
if (counts.code === 0) {
|
|
734
|
+
const parts = counts.stdout.trim().split(/\s+/);
|
|
735
|
+
if (parts.length === 2) {
|
|
736
|
+
const b = Number(parts[0]);
|
|
737
|
+
const a = Number(parts[1]);
|
|
738
|
+
if (Number.isFinite(a) && Number.isFinite(b)) {
|
|
739
|
+
out.ahead = a;
|
|
740
|
+
out.behind = b;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
break;
|
|
745
|
+
} catch {
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const orgRepo = await resolveOrgRepo(branch.repo);
|
|
752
|
+
if (orgRepo) {
|
|
753
|
+
const res = await gh("gh", [
|
|
754
|
+
"pr",
|
|
755
|
+
"list",
|
|
756
|
+
"--head",
|
|
757
|
+
branch.name,
|
|
758
|
+
"--repo",
|
|
759
|
+
orgRepo,
|
|
760
|
+
"--json",
|
|
761
|
+
"number,state,title,url,statusCheckRollup",
|
|
762
|
+
"--limit",
|
|
763
|
+
"1"
|
|
764
|
+
]);
|
|
765
|
+
if (res.code === 0) {
|
|
766
|
+
const parsed = JSON.parse(res.stdout);
|
|
767
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
768
|
+
const first = parsed[0];
|
|
769
|
+
if (typeof first.number === "number" && typeof first.state === "string" && typeof first.title === "string" && typeof first.url === "string") {
|
|
770
|
+
const rollup = first.statusCheckRollup ?? [];
|
|
771
|
+
let pass = 0;
|
|
772
|
+
let fail = 0;
|
|
773
|
+
let pending = 0;
|
|
774
|
+
for (const entry of rollup) {
|
|
775
|
+
const tag = (entry.conclusion ?? entry.state ?? "").toUpperCase();
|
|
776
|
+
if (tag === "SUCCESS") pass++;
|
|
777
|
+
else if (tag === "FAILURE" || tag === "CANCELLED" || tag === "TIMED_OUT")
|
|
778
|
+
fail++;
|
|
779
|
+
else pending++;
|
|
780
|
+
}
|
|
781
|
+
let checks;
|
|
782
|
+
if (rollup.length > 0) {
|
|
783
|
+
if (fail > 0) checks = `fail (${fail}/${rollup.length})`;
|
|
784
|
+
else if (pending > 0) checks = `pending (${pending}/${rollup.length})`;
|
|
785
|
+
else checks = `pass (${pass}/${rollup.length})`;
|
|
786
|
+
}
|
|
787
|
+
out.pr = {
|
|
788
|
+
number: first.number,
|
|
789
|
+
state: first.state,
|
|
790
|
+
title: first.title,
|
|
791
|
+
url: first.url,
|
|
792
|
+
...checks ? { checks } : {}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
}
|
|
800
|
+
return out;
|
|
801
|
+
}
|
|
802
|
+
function endedDate(iso) {
|
|
803
|
+
return iso.slice(0, 10);
|
|
804
|
+
}
|
|
805
|
+
async function loadBrief(initiativeDir, slug) {
|
|
806
|
+
const briefPath = path4.join(initiativeDir, "brief.md");
|
|
807
|
+
try {
|
|
808
|
+
return await readMarkdownWithSchema(briefPath, BriefFrontmatterSchema);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
811
|
+
throw new NotFoundError(
|
|
812
|
+
`Initiative '${slug}' has no readable brief.md (${reason})`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function assembleBootstrap(input) {
|
|
817
|
+
const {
|
|
818
|
+
activeRoot,
|
|
819
|
+
slug,
|
|
820
|
+
now = /* @__PURE__ */ new Date(),
|
|
821
|
+
topNTasks = DEFAULT_TOP_N_TASKS,
|
|
822
|
+
recentlyDoneDays = DEFAULT_RECENTLY_DONE_DAYS,
|
|
823
|
+
includeLiveStatus = true,
|
|
824
|
+
liveStatusFetcher,
|
|
825
|
+
archivedTaskIds,
|
|
826
|
+
adhoc = false
|
|
827
|
+
} = input;
|
|
828
|
+
const initiativeDir = path4.join(activeRoot, slug);
|
|
829
|
+
const { frontmatter: brief, body: briefBody } = await loadBrief(
|
|
830
|
+
initiativeDir,
|
|
831
|
+
slug
|
|
832
|
+
);
|
|
833
|
+
const [sessions, tasks, artifacts] = await Promise.all([
|
|
834
|
+
loadCanonicalSessions(initiativeDir),
|
|
835
|
+
loadTasks(initiativeDir),
|
|
836
|
+
loadArtifacts(initiativeDir)
|
|
837
|
+
]);
|
|
838
|
+
const latestSession = sessions[0];
|
|
839
|
+
const briefExcerpt = truncateLines(briefBody, BRIEF_BODY_MAX_LINES) || "_(no brief body)_";
|
|
840
|
+
const { body: tasksBody, count: openTaskCount } = renderTopTasks(tasks, topNTasks);
|
|
841
|
+
const { body: recentlyDoneBody, count: recentlyDoneCount } = renderRecentlyDone(
|
|
842
|
+
tasks,
|
|
843
|
+
recentlyDoneDays,
|
|
844
|
+
now
|
|
845
|
+
);
|
|
846
|
+
let artifactsBody = null;
|
|
847
|
+
if (!includeLiveStatus || artifacts.branches.length === 0) {
|
|
848
|
+
artifactsBody = renderStaticArtifacts(artifacts);
|
|
849
|
+
} else {
|
|
850
|
+
const fetcher = liveStatusFetcher ?? defaultLiveStatusFetcher;
|
|
851
|
+
try {
|
|
852
|
+
const statuses = await fetcher(artifacts.branches);
|
|
853
|
+
artifactsBody = renderLiveArtifacts(artifacts, statuses);
|
|
854
|
+
} catch {
|
|
855
|
+
artifactsBody = renderStaticArtifacts(artifacts);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const timeSinceHuman = latestSession ? formatTimeSince(new Date(latestSession.frontmatter.ended), now) : void 0;
|
|
859
|
+
const sections = [];
|
|
860
|
+
sections.push(
|
|
861
|
+
adhoc ? `Starting an ad-hoc session on \`${slug}\` (${brief.title}). This session is scoped to ad-hoc work related to this workstream \u2014 not necessarily its handoff or current top task. The context below is background so you're oriented; wait for the user to describe the specific ad-hoc task before acting.` : `Starting a session on \`${slug}\` (${brief.title}).`
|
|
862
|
+
);
|
|
863
|
+
sections.push(`# Why we're doing this
|
|
864
|
+
${briefExcerpt}`);
|
|
865
|
+
if (latestSession) {
|
|
866
|
+
const sessionExcerpt = truncateLines(latestSession.body, SESSION_BODY_MAX_LINES) || "_(empty session body)_";
|
|
867
|
+
const ended = endedDate(latestSession.frontmatter.ended);
|
|
868
|
+
sections.push(
|
|
869
|
+
`# Last session (${ended}, ${latestSession.frontmatter.session_id}) \u2014 ${timeSinceHuman}
|
|
870
|
+
${sessionExcerpt}`
|
|
871
|
+
);
|
|
872
|
+
} else {
|
|
873
|
+
sections.push(`# Last session
|
|
874
|
+
No previous sessions recorded.`);
|
|
875
|
+
}
|
|
876
|
+
sections.push(`# Tasks (top ${topNTasks} open by priority)
|
|
877
|
+
${tasksBody}`);
|
|
878
|
+
if (recentlyDoneBody) {
|
|
879
|
+
sections.push(
|
|
880
|
+
`# Recently done (last ${recentlyDoneDays} days)
|
|
881
|
+
${recentlyDoneBody}`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (archivedTaskIds && archivedTaskIds.length > 0) {
|
|
885
|
+
sections.push(
|
|
886
|
+
`# Archived (housekeeping)
|
|
887
|
+
Moved ${archivedTaskIds.length} stale done task(s) to tasks/archive/: ${archivedTaskIds.join(", ")}`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
if (artifactsBody) {
|
|
891
|
+
sections.push(`# Open artifacts
|
|
892
|
+
${artifactsBody}`);
|
|
893
|
+
}
|
|
894
|
+
const bootstrapAt = nowIso();
|
|
895
|
+
const todayStr = today();
|
|
896
|
+
const contextLines = [`- Today: ${todayStr}`, `- Bootstrap: ${bootstrapAt}`];
|
|
897
|
+
if (timeSinceHuman) {
|
|
898
|
+
contextLines.push(`- Time since last session: ${timeSinceHuman}`);
|
|
899
|
+
}
|
|
900
|
+
sections.push(`# Context
|
|
901
|
+
${contextLines.join("\n")}`);
|
|
902
|
+
sections.push(
|
|
903
|
+
adhoc ? `This is an ad-hoc session: treat the context above as background, not a directive. Do not assume we're continuing the top task or the handoff \u2014 the user will describe the specific ad-hoc task. Once they do, work it with the workstream context in mind. If it turns out to be substantive, still capture it via \`active-work task add\` / \`active-work session record\`.` : `Work the top task unless redirected. Update tasks via \`active-work task done\` and capture the session via \`active-work session record\` when wrapping up.`
|
|
904
|
+
);
|
|
905
|
+
const prompt = sections.join("\n\n") + "\n";
|
|
906
|
+
const metadata = {
|
|
907
|
+
slug,
|
|
908
|
+
brief_title: brief.title,
|
|
909
|
+
open_task_count: openTaskCount,
|
|
910
|
+
recently_done_count: recentlyDoneCount,
|
|
911
|
+
bootstrap_at: bootstrapAt
|
|
912
|
+
};
|
|
913
|
+
if (latestSession) {
|
|
914
|
+
metadata.last_session = {
|
|
915
|
+
filename: latestSession.filename,
|
|
916
|
+
ended: latestSession.frontmatter.ended
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (timeSinceHuman) {
|
|
920
|
+
metadata.time_since_last_session_human = timeSinceHuman;
|
|
921
|
+
}
|
|
922
|
+
return { prompt, metadata };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/bootstrap/archive-tasks.ts
|
|
926
|
+
import { promises as fsp } from "fs";
|
|
927
|
+
import path5 from "path";
|
|
928
|
+
var MS_PER_DAY2 = 864e5;
|
|
929
|
+
async function archiveStaleTasks(initiativeDir, opts) {
|
|
930
|
+
if (!(opts.retentionDays > 0)) return [];
|
|
931
|
+
const tasksDir = path5.join(initiativeDir, "tasks");
|
|
932
|
+
let entries;
|
|
933
|
+
try {
|
|
934
|
+
entries = await fsp.readdir(tasksDir);
|
|
935
|
+
} catch {
|
|
936
|
+
return [];
|
|
937
|
+
}
|
|
938
|
+
const ymlFiles = entries.filter(
|
|
939
|
+
(n) => n.endsWith(".yml") || n.endsWith(".yaml")
|
|
940
|
+
);
|
|
941
|
+
const cutoffMs = opts.now.getTime() - opts.retentionDays * MS_PER_DAY2;
|
|
942
|
+
const archiveDir = path5.join(tasksDir, "archive");
|
|
943
|
+
const archived = [];
|
|
944
|
+
for (const filename of ymlFiles) {
|
|
945
|
+
const fullPath = path5.join(tasksDir, filename);
|
|
946
|
+
let doneAt;
|
|
947
|
+
let id;
|
|
948
|
+
try {
|
|
949
|
+
const task = await readYaml(fullPath, TaskSchema);
|
|
950
|
+
if (task.status !== "done" || !task.done_at) continue;
|
|
951
|
+
doneAt = task.done_at;
|
|
952
|
+
id = task.id;
|
|
953
|
+
} catch {
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const doneMs = new Date(doneAt).getTime();
|
|
957
|
+
if (Number.isNaN(doneMs) || doneMs > cutoffMs) continue;
|
|
958
|
+
try {
|
|
959
|
+
await fsp.mkdir(archiveDir, { recursive: true });
|
|
960
|
+
await fsp.rename(fullPath, path5.join(archiveDir, filename));
|
|
961
|
+
archived.push(id);
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return archived.sort();
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// src/commands/_open-helpers.ts
|
|
969
|
+
import { promises as fs4 } from "fs";
|
|
970
|
+
import path6 from "path";
|
|
971
|
+
async function listInitiativeSlugs(activeRoot) {
|
|
972
|
+
let entries;
|
|
973
|
+
try {
|
|
974
|
+
entries = await fs4.readdir(activeRoot, { withFileTypes: true });
|
|
975
|
+
} catch {
|
|
976
|
+
return [];
|
|
977
|
+
}
|
|
978
|
+
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name).sort();
|
|
979
|
+
}
|
|
980
|
+
async function resolveSlug(activeRoot, input) {
|
|
981
|
+
const slugs = await listInitiativeSlugs(activeRoot);
|
|
982
|
+
if (slugs.includes(input)) return input;
|
|
983
|
+
const matches = slugs.filter((s) => s.startsWith(input));
|
|
984
|
+
if (matches.length === 1) return matches[0];
|
|
985
|
+
if (matches.length > 1) {
|
|
986
|
+
throw new NotFoundError(
|
|
987
|
+
`Ambiguous slug '${input}'. Candidates: ${matches.join(", ")}`
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
if (slugs.length === 0) {
|
|
991
|
+
throw new NotFoundError(`No initiatives found under ${activeRoot}`);
|
|
992
|
+
}
|
|
993
|
+
throw new NotFoundError(
|
|
994
|
+
`No initiative matches '${input}'. Known: ${slugs.join(", ")}`
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
function isInside(child, parent) {
|
|
998
|
+
const rel = path6.relative(parent, child);
|
|
999
|
+
return rel === "" || !rel.startsWith("..") && !path6.isAbsolute(rel);
|
|
1000
|
+
}
|
|
1001
|
+
async function canonicalize(p) {
|
|
1002
|
+
try {
|
|
1003
|
+
return await fs4.realpath(p);
|
|
1004
|
+
} catch {
|
|
1005
|
+
return path6.resolve(p);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async function resolveSlugFromCwd(activeRoot, cwd) {
|
|
1009
|
+
const resolvedCwd = await canonicalize(cwd);
|
|
1010
|
+
const slugs = await listInitiativeSlugs(activeRoot);
|
|
1011
|
+
let best = null;
|
|
1012
|
+
let tiedAtBest = false;
|
|
1013
|
+
for (const slug of slugs) {
|
|
1014
|
+
const briefPath = path6.join(activeRoot, slug, "brief.md");
|
|
1015
|
+
let brief;
|
|
1016
|
+
try {
|
|
1017
|
+
({ frontmatter: brief } = await readMarkdownWithSchema(
|
|
1018
|
+
briefPath,
|
|
1019
|
+
BriefFrontmatterSchema
|
|
1020
|
+
));
|
|
1021
|
+
} catch {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
for (const entry of Object.values(brief.worktrees ?? {})) {
|
|
1025
|
+
const displayPath = expandTilde(entry.path);
|
|
1026
|
+
if (!path6.isAbsolute(displayPath)) continue;
|
|
1027
|
+
const canonical = await canonicalize(displayPath);
|
|
1028
|
+
if (!isInside(resolvedCwd, canonical)) continue;
|
|
1029
|
+
const depth = canonical.length;
|
|
1030
|
+
if (best === null || depth > best.depth) {
|
|
1031
|
+
best = { slug, worktreePath: displayPath, depth };
|
|
1032
|
+
tiedAtBest = false;
|
|
1033
|
+
} else if (depth === best.depth && slug !== best.slug) {
|
|
1034
|
+
tiedAtBest = true;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (best === null || tiedAtBest) return null;
|
|
1039
|
+
return { slug: best.slug, worktreePath: best.worktreePath };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/commands/open.ts
|
|
1043
|
+
var ARCHIVE_DONE_AFTER_DAYS = 30;
|
|
1044
|
+
var ArgsSchema = z5.object({
|
|
1045
|
+
slug: z5.string().min(1).optional(),
|
|
1046
|
+
offline: z5.boolean().optional(),
|
|
1047
|
+
// Directory used to auto-resolve an initiative when no slug is given.
|
|
1048
|
+
// Defaults to the process cwd; callers that do not share the user's shell
|
|
1049
|
+
// cwd (the daemon / MCP server) must pass this explicitly.
|
|
1050
|
+
cwd: z5.string().min(1).optional(),
|
|
1051
|
+
// Force the picker even when the cwd matches an initiative's worktree.
|
|
1052
|
+
pick: z5.boolean().optional(),
|
|
1053
|
+
// Frame the bootstrap prompt as ad-hoc work related to the workstream rather
|
|
1054
|
+
// than a continuation of its handoff / top task.
|
|
1055
|
+
adhoc: z5.boolean().optional()
|
|
1056
|
+
});
|
|
1057
|
+
var InitiativeSummarySchema = z5.object({
|
|
1058
|
+
slug: z5.string(),
|
|
1059
|
+
title: z5.string(),
|
|
1060
|
+
state: z5.enum(["focused", "backburner", "paused", "done"]),
|
|
1061
|
+
rank: z5.number().int().positive().optional()
|
|
1062
|
+
});
|
|
1063
|
+
var PickerResultSchema = z5.object({
|
|
1064
|
+
picker: z5.literal(true),
|
|
1065
|
+
initiatives: z5.array(InitiativeSummarySchema)
|
|
1066
|
+
});
|
|
1067
|
+
var OpenResultSchema = z5.object({
|
|
1068
|
+
slug: z5.string(),
|
|
1069
|
+
prompt: z5.string(),
|
|
1070
|
+
cwd_hint: z5.string(),
|
|
1071
|
+
channels: z5.array(z5.string()).optional(),
|
|
1072
|
+
metadata: z5.object({
|
|
1073
|
+
slug: z5.string(),
|
|
1074
|
+
brief_title: z5.string(),
|
|
1075
|
+
last_session: z5.object({ filename: z5.string(), ended: z5.string() }).optional(),
|
|
1076
|
+
time_since_last_session_human: z5.string().optional(),
|
|
1077
|
+
open_task_count: z5.number().int().nonnegative(),
|
|
1078
|
+
recently_done_count: z5.number().int().nonnegative(),
|
|
1079
|
+
bootstrap_at: z5.string()
|
|
1080
|
+
}),
|
|
1081
|
+
// How the initiative was selected: an explicit/prefix slug, or a match
|
|
1082
|
+
// between the caller's cwd and one of the initiative's worktrees.
|
|
1083
|
+
resolved_from: z5.enum(["slug", "cwd"]).optional()
|
|
1084
|
+
});
|
|
1085
|
+
var ResultSchema = z5.union([OpenResultSchema, PickerResultSchema]);
|
|
1086
|
+
var STATE_ORDER = {
|
|
1087
|
+
focused: 0,
|
|
1088
|
+
backburner: 1,
|
|
1089
|
+
paused: 2,
|
|
1090
|
+
done: 3
|
|
1091
|
+
};
|
|
1092
|
+
async function loadInitiativeSummary(activeRoot, slug) {
|
|
1093
|
+
const briefPath = path7.join(activeRoot, slug, "brief.md");
|
|
1094
|
+
try {
|
|
1095
|
+
const { frontmatter } = await readMarkdownWithSchema(
|
|
1096
|
+
briefPath,
|
|
1097
|
+
BriefFrontmatterSchema
|
|
1098
|
+
);
|
|
1099
|
+
return {
|
|
1100
|
+
slug,
|
|
1101
|
+
title: frontmatter.title,
|
|
1102
|
+
state: frontmatter.state,
|
|
1103
|
+
rank: frontmatter.rank
|
|
1104
|
+
};
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function compareInitiatives(a, b) {
|
|
1110
|
+
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
|
1111
|
+
if (stateDiff !== 0) return stateDiff;
|
|
1112
|
+
if (a.rank !== void 0 && b.rank !== void 0 && a.rank !== b.rank) {
|
|
1113
|
+
return a.rank - b.rank;
|
|
1114
|
+
}
|
|
1115
|
+
if (a.rank !== void 0 && b.rank === void 0) return -1;
|
|
1116
|
+
if (a.rank === void 0 && b.rank !== void 0) return 1;
|
|
1117
|
+
return a.slug.localeCompare(b.slug);
|
|
1118
|
+
}
|
|
1119
|
+
async function collectInitiatives(activeRoot) {
|
|
1120
|
+
const slugs = await listInitiativeSlugs(activeRoot);
|
|
1121
|
+
const summaries = [];
|
|
1122
|
+
for (const slug of slugs) {
|
|
1123
|
+
const summary = await loadInitiativeSummary(activeRoot, slug);
|
|
1124
|
+
if (summary) summaries.push(summary);
|
|
1125
|
+
}
|
|
1126
|
+
summaries.sort(compareInitiatives);
|
|
1127
|
+
return summaries;
|
|
1128
|
+
}
|
|
1129
|
+
function resolveCwdHint(activeRoot, slug, brief) {
|
|
1130
|
+
const worktrees = brief.worktrees ?? {};
|
|
1131
|
+
for (const entry of Object.values(worktrees)) {
|
|
1132
|
+
if (entry.default) return expandTilde(entry.path);
|
|
1133
|
+
}
|
|
1134
|
+
const entries = Object.values(worktrees);
|
|
1135
|
+
if (entries.length === 1) return expandTilde(entries[0].path);
|
|
1136
|
+
return path7.join(activeRoot, slug);
|
|
1137
|
+
}
|
|
1138
|
+
async function bootstrapInitiative(activeRoot, slug, opts) {
|
|
1139
|
+
const briefPath = path7.join(activeRoot, slug, "brief.md");
|
|
1140
|
+
const { frontmatter: brief } = await readMarkdownWithSchema(
|
|
1141
|
+
briefPath,
|
|
1142
|
+
BriefFrontmatterSchema
|
|
1143
|
+
);
|
|
1144
|
+
const cwdHint = opts.cwdHintOverride ?? resolveCwdHint(activeRoot, slug, brief);
|
|
1145
|
+
const archivedTaskIds = await archiveStaleTasks(
|
|
1146
|
+
path7.join(activeRoot, slug),
|
|
1147
|
+
{ retentionDays: ARCHIVE_DONE_AFTER_DAYS, now: /* @__PURE__ */ new Date() }
|
|
1148
|
+
);
|
|
1149
|
+
const { prompt, metadata } = await assembleBootstrap({
|
|
1150
|
+
activeRoot,
|
|
1151
|
+
slug,
|
|
1152
|
+
includeLiveStatus: !opts.offline,
|
|
1153
|
+
archivedTaskIds,
|
|
1154
|
+
adhoc: opts.adhoc
|
|
1155
|
+
});
|
|
1156
|
+
return {
|
|
1157
|
+
slug,
|
|
1158
|
+
prompt,
|
|
1159
|
+
cwd_hint: cwdHint,
|
|
1160
|
+
...brief.channels && brief.channels.length > 0 ? { channels: brief.channels } : {},
|
|
1161
|
+
metadata,
|
|
1162
|
+
resolved_from: opts.resolvedFrom
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
var openCommand = defineCommand({
|
|
1166
|
+
name: "open",
|
|
1167
|
+
description: "Bootstrap a Claude session for an initiative. Without a slug, resolves the initiative whose worktree contains the caller's cwd; falls back to the picker list when nothing matches.",
|
|
1168
|
+
args: ArgsSchema,
|
|
1169
|
+
result: ResultSchema,
|
|
1170
|
+
cli: {
|
|
1171
|
+
positional: ["slug"],
|
|
1172
|
+
options: {
|
|
1173
|
+
offline: {
|
|
1174
|
+
long: "--offline",
|
|
1175
|
+
description: "Skip the live `gh`/`git` artifact lookup; render artifacts statically."
|
|
1176
|
+
},
|
|
1177
|
+
cwd: {
|
|
1178
|
+
long: "--cwd",
|
|
1179
|
+
description: "Directory to resolve the initiative from when no slug is given (default: current directory)."
|
|
1180
|
+
},
|
|
1181
|
+
pick: {
|
|
1182
|
+
long: "--pick",
|
|
1183
|
+
description: "Always return the picker list; skip resolving the initiative from the current directory."
|
|
1184
|
+
},
|
|
1185
|
+
adhoc: {
|
|
1186
|
+
long: "--adhoc",
|
|
1187
|
+
description: "Frame the prompt as ad-hoc work on the workstream (awaiting the user\u2019s task), not a continuation of the handoff / top task."
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
usage: "active-work open [slug] [--offline] [--cwd <dir>] [--pick] [--adhoc]"
|
|
1191
|
+
},
|
|
1192
|
+
async run(args, ctx) {
|
|
1193
|
+
const activeRoot = ctx.activeRoot ?? getActiveRoot();
|
|
1194
|
+
if (args.slug) {
|
|
1195
|
+
const slug = await resolveSlug(activeRoot, args.slug);
|
|
1196
|
+
return bootstrapInitiative(activeRoot, slug, {
|
|
1197
|
+
offline: args.offline,
|
|
1198
|
+
resolvedFrom: "slug",
|
|
1199
|
+
adhoc: args.adhoc
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
const cwd = args.cwd ?? ctx.cwd;
|
|
1203
|
+
if (!args.pick && cwd) {
|
|
1204
|
+
const matched = await resolveSlugFromCwd(activeRoot, cwd);
|
|
1205
|
+
if (matched) {
|
|
1206
|
+
return bootstrapInitiative(activeRoot, matched.slug, {
|
|
1207
|
+
offline: args.offline,
|
|
1208
|
+
resolvedFrom: "cwd",
|
|
1209
|
+
cwdHintOverride: matched.worktreePath,
|
|
1210
|
+
adhoc: args.adhoc
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const initiatives = await collectInitiatives(activeRoot);
|
|
1215
|
+
return { picker: true, initiatives };
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
var open_default = openCommand;
|
|
1219
|
+
|
|
1220
|
+
// src/utils/color.ts
|
|
1221
|
+
import pc from "picocolors";
|
|
1222
|
+
var enabled = !("NO_COLOR" in process.env) && process.stdout.isTTY === true;
|
|
1223
|
+
var identity = (s) => s;
|
|
1224
|
+
var color = {
|
|
1225
|
+
enabled,
|
|
1226
|
+
bold: enabled ? pc.bold : identity,
|
|
1227
|
+
dim: enabled ? pc.dim : identity,
|
|
1228
|
+
green: enabled ? pc.green : identity,
|
|
1229
|
+
yellow: enabled ? pc.yellow : identity,
|
|
1230
|
+
red: enabled ? pc.red : identity,
|
|
1231
|
+
cyan: enabled ? pc.cyan : identity,
|
|
1232
|
+
gray: enabled ? pc.gray : identity
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
export {
|
|
1236
|
+
BriefFrontmatterSchema,
|
|
1237
|
+
expandTilde,
|
|
1238
|
+
getActiveRoot,
|
|
1239
|
+
getStateRoot,
|
|
1240
|
+
getConfigRoot,
|
|
1241
|
+
getInitiativeDir,
|
|
1242
|
+
getLockPath,
|
|
1243
|
+
defineCommand,
|
|
1244
|
+
successEnvelope,
|
|
1245
|
+
errorEnvelope,
|
|
1246
|
+
registry,
|
|
1247
|
+
register,
|
|
1248
|
+
TaskSchema,
|
|
1249
|
+
SessionFrontmatterSchema,
|
|
1250
|
+
ArtifactsSchema,
|
|
1251
|
+
atomicWrite,
|
|
1252
|
+
withFileLock,
|
|
1253
|
+
coerceDates,
|
|
1254
|
+
readYaml,
|
|
1255
|
+
writeYaml,
|
|
1256
|
+
getGitRunner,
|
|
1257
|
+
getGhRunner,
|
|
1258
|
+
resolveLocalRepoPath,
|
|
1259
|
+
resolveOrgRepo,
|
|
1260
|
+
today,
|
|
1261
|
+
nowIso,
|
|
1262
|
+
EXIT,
|
|
1263
|
+
ActiveWorkError,
|
|
1264
|
+
ValidationError,
|
|
1265
|
+
NotFoundError,
|
|
1266
|
+
UsageError,
|
|
1267
|
+
DaemonError,
|
|
1268
|
+
ConfigError,
|
|
1269
|
+
formatError,
|
|
1270
|
+
assembleBootstrap,
|
|
1271
|
+
resolveSlug,
|
|
1272
|
+
resolveSlugFromCwd,
|
|
1273
|
+
open_default,
|
|
1274
|
+
color
|
|
1275
|
+
};
|
|
1276
|
+
//# sourceMappingURL=chunk-OET6AFME.js.map
|