@leonarto/spec-embryo 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/README.md +156 -0
- package/package.json +48 -0
- package/src/backends/base.ts +18 -0
- package/src/backends/deterministic.ts +105 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/prompt.ts +169 -0
- package/src/backends/subprocess.ts +198 -0
- package/src/cli.ts +111 -0
- package/src/commands/agents.ts +16 -0
- package/src/commands/current.ts +95 -0
- package/src/commands/doctor.ts +12 -0
- package/src/commands/handoff.ts +64 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/reshape.ts +20 -0
- package/src/commands/resume.ts +19 -0
- package/src/commands/spec.ts +108 -0
- package/src/commands/status.ts +98 -0
- package/src/commands/task.ts +190 -0
- package/src/commands/ui.ts +35 -0
- package/src/domain.ts +357 -0
- package/src/engine.ts +290 -0
- package/src/frontmatter.ts +83 -0
- package/src/index.ts +75 -0
- package/src/paths.ts +32 -0
- package/src/repository.ts +807 -0
- package/src/services/adoption.ts +169 -0
- package/src/services/agents.ts +191 -0
- package/src/services/dashboard.ts +776 -0
- package/src/services/details.ts +453 -0
- package/src/services/doctor.ts +452 -0
- package/src/services/layout.ts +420 -0
- package/src/services/spec-answer-evaluation.ts +103 -0
- package/src/services/spec-import.ts +217 -0
- package/src/services/spec-questions.ts +343 -0
- package/src/services/ui.ts +34 -0
- package/src/storage.ts +57 -0
- package/src/templates.ts +270 -0
- package/tsconfig.json +17 -0
- package/web/package.json +24 -0
- package/web/src/app.css +83 -0
- package/web/src/app.d.ts +6 -0
- package/web/src/app.html +11 -0
- package/web/src/lib/components/AnalysisFilters.svelte +293 -0
- package/web/src/lib/components/DocumentBody.svelte +100 -0
- package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
- package/web/src/lib/components/SelectDropdown.svelte +265 -0
- package/web/src/lib/server/project-root.ts +34 -0
- package/web/src/lib/task-board.ts +20 -0
- package/web/src/routes/+layout.server.ts +57 -0
- package/web/src/routes/+layout.svelte +421 -0
- package/web/src/routes/+layout.ts +1 -0
- package/web/src/routes/+page.svelte +530 -0
- package/web/src/routes/specs/+page.svelte +416 -0
- package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
- package/web/src/routes/specs/[specId]/+page.svelte +675 -0
- package/web/src/routes/tasks/+page.svelte +341 -0
- package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
- package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
- package/web/src/routes/timeline/+page.svelte +1093 -0
- package/web/svelte.config.js +10 -0
- package/web/tsconfig.json +9 -0
- package/web/vite.config.ts +11 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
import type {
|
|
5
|
+
CurrentStateDocument,
|
|
6
|
+
GitSnapshot,
|
|
7
|
+
HandoffDocument,
|
|
8
|
+
ProjectConfig,
|
|
9
|
+
ProjectContext,
|
|
10
|
+
SpecDocument,
|
|
11
|
+
SpecStatus,
|
|
12
|
+
TaskDocument,
|
|
13
|
+
TaskStatus,
|
|
14
|
+
} from "./domain.ts";
|
|
15
|
+
import {
|
|
16
|
+
parseCurrentStateDocument,
|
|
17
|
+
parseHandoffDocument,
|
|
18
|
+
parseProjectConfig,
|
|
19
|
+
parseSpecDocument,
|
|
20
|
+
parseTaskDocument,
|
|
21
|
+
} from "./domain.ts";
|
|
22
|
+
import { parseTomlFrontmatter, serializeTomlFrontmatter } from "./frontmatter.ts";
|
|
23
|
+
import { resolveProjectPaths } from "./paths.ts";
|
|
24
|
+
import { ensureDir, listMarkdownFiles, pathExists, readUtf8, writeUtf8 } from "./storage.ts";
|
|
25
|
+
import { applyReshapePlan, buildReshapePlan, initSequenceDescription, resolveLayoutChoice } from "./services/layout.ts";
|
|
26
|
+
import { applySpecAnswers, type SpecAnswerInput } from "./services/spec-questions.ts";
|
|
27
|
+
import {
|
|
28
|
+
createConfigToml,
|
|
29
|
+
createCurrentStateTemplate,
|
|
30
|
+
createSeedSpecTemplate,
|
|
31
|
+
createSeedTaskTemplates,
|
|
32
|
+
createSkillsReadme,
|
|
33
|
+
createTemplateFiles,
|
|
34
|
+
inferProjectName,
|
|
35
|
+
} from "./templates.ts";
|
|
36
|
+
|
|
37
|
+
function slugify(value: string): string {
|
|
38
|
+
return value
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
41
|
+
.replace(/^-|-$/g, "")
|
|
42
|
+
.replace(/-{2,}/g, "-");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uniqueStrings(values: string[]): string[] {
|
|
46
|
+
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nextNumericId(existingIds: string[], prefix: "SPEC" | "TASK"): string {
|
|
50
|
+
const max = existingIds.reduce((highest, id) => {
|
|
51
|
+
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
52
|
+
if (!match) {
|
|
53
|
+
return highest;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Math.max(highest, Number.parseInt(match[1]!, 10));
|
|
57
|
+
}, 0);
|
|
58
|
+
|
|
59
|
+
return `${prefix}-${String(max + 1).padStart(3, "0")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertKnownIds(kind: "Spec" | "Task", provided: string[], existing: string[]): void {
|
|
63
|
+
const known = new Set(existing);
|
|
64
|
+
for (const id of provided) {
|
|
65
|
+
if (!known.has(id)) {
|
|
66
|
+
throw new Error(`${kind} not found: ${id}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function serializeCurrentStateDocument(currentState: CurrentStateDocument, updatedAt: string): string {
|
|
72
|
+
return serializeTomlFrontmatter(
|
|
73
|
+
{
|
|
74
|
+
doc_kind: currentState.docKind,
|
|
75
|
+
id: currentState.id,
|
|
76
|
+
title: currentState.title,
|
|
77
|
+
summary: currentState.summary,
|
|
78
|
+
focus: currentState.focus,
|
|
79
|
+
active_spec_ids: currentState.activeSpecIds,
|
|
80
|
+
active_task_ids: currentState.activeTaskIds,
|
|
81
|
+
blocked_task_ids: currentState.blockedTaskIds,
|
|
82
|
+
next_action_hints: currentState.nextActionHints,
|
|
83
|
+
last_handoff_id: currentState.lastHandoffId,
|
|
84
|
+
updated_at: updatedAt,
|
|
85
|
+
tags: currentState.tags,
|
|
86
|
+
},
|
|
87
|
+
currentState.body,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function git(rootDir: string, args: string[]): string {
|
|
92
|
+
const result = spawnSync("git", args, {
|
|
93
|
+
cwd: rootDir,
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (result.status !== 0) {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result.stdout.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function loadMarkdownDocument<T>(
|
|
105
|
+
filePath: string,
|
|
106
|
+
parser: (filePath: string, attributes: unknown, body: string) => T,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
const raw = await readUtf8(filePath);
|
|
109
|
+
const { attributes, body } = parseTomlFrontmatter(raw);
|
|
110
|
+
return parser(filePath, attributes, body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadMarkdownCollection<T>(
|
|
114
|
+
dirPath: string,
|
|
115
|
+
parser: (filePath: string, attributes: unknown, body: string) => T,
|
|
116
|
+
): Promise<T[]> {
|
|
117
|
+
const files = await listMarkdownFiles(dirPath);
|
|
118
|
+
return Promise.all(files.map((filePath) => loadMarkdownDocument(filePath, parser)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function serializeSpecDocument(spec: SpecDocument, updatedAt: string): string {
|
|
122
|
+
return serializeTomlFrontmatter(
|
|
123
|
+
{
|
|
124
|
+
doc_kind: spec.docKind,
|
|
125
|
+
id: spec.id,
|
|
126
|
+
title: spec.title,
|
|
127
|
+
summary: spec.summary,
|
|
128
|
+
status: spec.status,
|
|
129
|
+
task_ids: spec.taskIds,
|
|
130
|
+
tags: spec.tags,
|
|
131
|
+
owner: spec.owner,
|
|
132
|
+
updated_at: updatedAt,
|
|
133
|
+
},
|
|
134
|
+
spec.body,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function serializeTaskDocument(task: TaskDocument, updatedAt: string): string {
|
|
139
|
+
return serializeTomlFrontmatter(
|
|
140
|
+
{
|
|
141
|
+
doc_kind: task.docKind,
|
|
142
|
+
id: task.id,
|
|
143
|
+
title: task.title,
|
|
144
|
+
summary: task.summary,
|
|
145
|
+
status: task.status,
|
|
146
|
+
spec_ids: task.specIds,
|
|
147
|
+
depends_on: task.dependsOn,
|
|
148
|
+
blocked_by: task.blockedBy,
|
|
149
|
+
priority: task.priority,
|
|
150
|
+
owner_role: task.ownerRole,
|
|
151
|
+
created_at: task.createdAt,
|
|
152
|
+
completed_at: task.completedAt,
|
|
153
|
+
cancelled_at: task.cancelledAt,
|
|
154
|
+
archived_at: task.archivedAt,
|
|
155
|
+
archive_reason: task.archiveReason,
|
|
156
|
+
tags: task.tags,
|
|
157
|
+
updated_at: updatedAt,
|
|
158
|
+
},
|
|
159
|
+
task.body,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildCreatedSpecBody(input: { title: string }): string {
|
|
164
|
+
return `# Problem
|
|
165
|
+
|
|
166
|
+
Describe the problem this spec addresses.
|
|
167
|
+
|
|
168
|
+
# Goals
|
|
169
|
+
|
|
170
|
+
- Define the intended outcomes for ${input.title.toLowerCase()}.
|
|
171
|
+
|
|
172
|
+
# Non-Goals
|
|
173
|
+
|
|
174
|
+
- Capture what is intentionally out of scope for this slice.
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildCreatedTaskBody(input: { title: string }): string {
|
|
179
|
+
return `# Scope
|
|
180
|
+
|
|
181
|
+
Describe the concrete implementation scope for ${input.title.toLowerCase()}.
|
|
182
|
+
|
|
183
|
+
# Done Looks Like
|
|
184
|
+
|
|
185
|
+
- The expected behavior is implemented and verified.
|
|
186
|
+
- The linked project memory stays consistent.
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CreateSpecInput {
|
|
191
|
+
title: string;
|
|
192
|
+
summary: string;
|
|
193
|
+
status?: SpecStatus;
|
|
194
|
+
taskIds?: string[];
|
|
195
|
+
tags?: string[];
|
|
196
|
+
owner?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CreateTaskInput {
|
|
200
|
+
title: string;
|
|
201
|
+
summary: string;
|
|
202
|
+
specIds: string[];
|
|
203
|
+
status?: TaskStatus;
|
|
204
|
+
dependsOn?: string[];
|
|
205
|
+
blockedBy?: string[];
|
|
206
|
+
priority?: number;
|
|
207
|
+
ownerRole?: string;
|
|
208
|
+
tags?: string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface UpdateCurrentStateInput {
|
|
212
|
+
focus?: string;
|
|
213
|
+
summary?: string;
|
|
214
|
+
activeSpecIds?: string[];
|
|
215
|
+
activeTaskIds?: string[];
|
|
216
|
+
blockedTaskIds?: string[];
|
|
217
|
+
nextActionHints?: string[];
|
|
218
|
+
clearActiveSpecIds?: boolean;
|
|
219
|
+
clearActiveTaskIds?: boolean;
|
|
220
|
+
clearBlockedTaskIds?: boolean;
|
|
221
|
+
clearNextActionHints?: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ArchiveTaskInput {
|
|
225
|
+
reason?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface AnswerSpecQuestionsResult {
|
|
229
|
+
spec: SpecDocument;
|
|
230
|
+
answeredQuestions: string[];
|
|
231
|
+
remainingQuestions: string[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function createDefaultConfig(rootDir: string): ProjectConfig {
|
|
235
|
+
return parseProjectConfig(parseToml(createConfigToml(inferProjectName(rootDir))));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function readProjectConfig(rootDir: string): Promise<ProjectConfig> {
|
|
239
|
+
const defaultPaths = resolveProjectPaths(rootDir);
|
|
240
|
+
|
|
241
|
+
if (!(await pathExists(defaultPaths.configFile))) {
|
|
242
|
+
return createDefaultConfig(rootDir);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const raw = await readUtf8(defaultPaths.configFile);
|
|
246
|
+
return parseProjectConfig(parseToml(raw));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function readCurrentState(paths: ReturnType<typeof resolveProjectPaths>): Promise<CurrentStateDocument> {
|
|
250
|
+
if (!(await pathExists(paths.stateFile))) {
|
|
251
|
+
return parseCurrentStateDocument(
|
|
252
|
+
paths.stateFile,
|
|
253
|
+
{
|
|
254
|
+
doc_kind: "current-state",
|
|
255
|
+
id: "CURRENT",
|
|
256
|
+
title: "Current Project State",
|
|
257
|
+
summary: "No current state file exists yet.",
|
|
258
|
+
focus: "Run `spm init` or create the configured current state file.",
|
|
259
|
+
active_spec_ids: [],
|
|
260
|
+
active_task_ids: [],
|
|
261
|
+
blocked_task_ids: [],
|
|
262
|
+
next_action_hints: ["Initialize the project memory files."],
|
|
263
|
+
tags: [],
|
|
264
|
+
},
|
|
265
|
+
"# Current State\n\nNo current state file exists yet.\n",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return loadMarkdownDocument(paths.stateFile, parseCurrentStateDocument);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function readGitSnapshot(rootDir: string): Promise<GitSnapshot> {
|
|
273
|
+
const insideRepo = git(rootDir, ["rev-parse", "--is-inside-work-tree"]);
|
|
274
|
+
if (insideRepo !== "true") {
|
|
275
|
+
return {
|
|
276
|
+
available: false,
|
|
277
|
+
shortStatus: [],
|
|
278
|
+
dirty: false,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const statusOutput = git(rootDir, ["status", "--short"]);
|
|
283
|
+
const shortStatus = statusOutput ? statusOutput.split("\n").filter(Boolean) : [];
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
available: true,
|
|
287
|
+
branch: git(rootDir, ["branch", "--show-current"]) || undefined,
|
|
288
|
+
shortStatus,
|
|
289
|
+
lastCommit: git(rootDir, ["log", "-1", "--pretty=format:%h %s"]) || undefined,
|
|
290
|
+
dirty: shortStatus.length > 0,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function loadProjectContext(rootDir: string): Promise<ProjectContext> {
|
|
295
|
+
const config = await readProjectConfig(rootDir);
|
|
296
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
297
|
+
|
|
298
|
+
const [currentState, specs, tasks, handoffs, gitSnapshot] = await Promise.all([
|
|
299
|
+
readCurrentState(paths),
|
|
300
|
+
loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument),
|
|
301
|
+
loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument),
|
|
302
|
+
loadMarkdownCollection<HandoffDocument>(paths.handoffsDir, parseHandoffDocument),
|
|
303
|
+
readGitSnapshot(rootDir),
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
handoffs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
rootDir,
|
|
310
|
+
config,
|
|
311
|
+
currentState,
|
|
312
|
+
specs,
|
|
313
|
+
tasks,
|
|
314
|
+
handoffs,
|
|
315
|
+
git: gitSnapshot,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface InitResult {
|
|
320
|
+
memoryDir: string;
|
|
321
|
+
sequence: string[];
|
|
322
|
+
adoptedHome?: string;
|
|
323
|
+
created: string[];
|
|
324
|
+
skipped: string[];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function initializeProject(
|
|
328
|
+
rootDir: string,
|
|
329
|
+
options?: {
|
|
330
|
+
force?: boolean;
|
|
331
|
+
memoryDir?: string;
|
|
332
|
+
},
|
|
333
|
+
): Promise<InitResult> {
|
|
334
|
+
const projectName = inferProjectName(rootDir);
|
|
335
|
+
const memoryDir = options?.memoryDir ?? "docs/spm";
|
|
336
|
+
const config = parseProjectConfig(parseToml(createConfigToml(projectName, memoryDir)));
|
|
337
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
338
|
+
const timestamp = new Date().toISOString();
|
|
339
|
+
const created: string[] = [];
|
|
340
|
+
const skipped: string[] = [];
|
|
341
|
+
|
|
342
|
+
await Promise.all([
|
|
343
|
+
ensureDir(paths.specpmDir),
|
|
344
|
+
ensureDir(paths.templatesDir),
|
|
345
|
+
ensureDir(paths.memoryDir),
|
|
346
|
+
ensureDir(paths.handoffsDir),
|
|
347
|
+
ensureDir(paths.specsDir),
|
|
348
|
+
ensureDir(paths.tasksDir),
|
|
349
|
+
ensureDir(paths.skillsDir),
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const files: Array<{ path: string; content: string }> = [
|
|
353
|
+
{ path: paths.configFile, content: createConfigToml(projectName, memoryDir) },
|
|
354
|
+
{ path: paths.stateFile, content: createCurrentStateTemplate(timestamp) },
|
|
355
|
+
{ path: join(paths.specsDir, "SPEC-001-deterministic-local-first-core.md"), content: createSeedSpecTemplate(timestamp) },
|
|
356
|
+
...createSeedTaskTemplates(timestamp).map((item) => ({
|
|
357
|
+
path: join(paths.tasksDir, item.name),
|
|
358
|
+
content: item.content,
|
|
359
|
+
})),
|
|
360
|
+
{ path: join(paths.skillsDir, "README.md"), content: createSkillsReadme() },
|
|
361
|
+
...createTemplateFiles().map((item) => ({
|
|
362
|
+
path: join(paths.templatesDir, item.name),
|
|
363
|
+
content: item.content,
|
|
364
|
+
})),
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
for (const file of files) {
|
|
368
|
+
if (options?.force || !(await pathExists(file.path))) {
|
|
369
|
+
await writeUtf8(file.path, file.content);
|
|
370
|
+
created.push(file.path);
|
|
371
|
+
} else {
|
|
372
|
+
skipped.push(file.path);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
memoryDir,
|
|
378
|
+
sequence: initSequenceDescription(memoryDir),
|
|
379
|
+
created,
|
|
380
|
+
skipped,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface ReshapeResult {
|
|
385
|
+
targetMemoryDir: string;
|
|
386
|
+
applied: boolean;
|
|
387
|
+
actions: string[];
|
|
388
|
+
warnings: string[];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function reshapeProject(
|
|
392
|
+
rootDir: string,
|
|
393
|
+
options?: {
|
|
394
|
+
memoryDir?: string;
|
|
395
|
+
dryRun?: boolean;
|
|
396
|
+
},
|
|
397
|
+
): Promise<ReshapeResult> {
|
|
398
|
+
const currentConfig = await readProjectConfig(rootDir);
|
|
399
|
+
const layoutChoice = await resolveLayoutChoice(rootDir);
|
|
400
|
+
const inferredMemoryDir = layoutChoice.requiresChoice || layoutChoice.memoryDir.length === 0 ? currentConfig.memoryDir : layoutChoice.memoryDir;
|
|
401
|
+
const targetMemoryDir = options?.memoryDir ?? inferredMemoryDir;
|
|
402
|
+
const plan = await buildReshapePlan(rootDir, currentConfig, targetMemoryDir);
|
|
403
|
+
|
|
404
|
+
if (!options?.dryRun) {
|
|
405
|
+
await applyReshapePlan(plan);
|
|
406
|
+
const nextConfig = parseProjectConfig(parseToml(createConfigToml(currentConfig.projectName, targetMemoryDir)));
|
|
407
|
+
const paths = resolveProjectPaths(rootDir, nextConfig);
|
|
408
|
+
await writeUtf8(paths.configFile, createConfigToml(currentConfig.projectName, targetMemoryDir));
|
|
409
|
+
await ensureDir(paths.skillsDir);
|
|
410
|
+
if (!(await pathExists(join(paths.skillsDir, "README.md")))) {
|
|
411
|
+
await writeUtf8(join(paths.skillsDir, "README.md"), createSkillsReadme());
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
targetMemoryDir,
|
|
417
|
+
applied: options?.dryRun !== true,
|
|
418
|
+
actions: plan.actions.map((action) => action.message),
|
|
419
|
+
warnings: plan.warnings,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function setSpecStatus(rootDir: string, specId: string, status: SpecStatus): Promise<SpecDocument> {
|
|
424
|
+
const config = await readProjectConfig(rootDir);
|
|
425
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
426
|
+
const specs = await loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument);
|
|
427
|
+
const spec = specs.find((entry) => entry.id === specId);
|
|
428
|
+
|
|
429
|
+
if (!spec) {
|
|
430
|
+
throw new Error(`Spec not found: ${specId}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const updatedAt = new Date().toISOString();
|
|
434
|
+
const nextSpec: SpecDocument = {
|
|
435
|
+
...spec,
|
|
436
|
+
status,
|
|
437
|
+
updatedAt,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
await writeUtf8(spec.filePath, serializeSpecDocument(nextSpec, updatedAt));
|
|
441
|
+
return nextSpec;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function answerSpecOpenQuestions(
|
|
445
|
+
rootDir: string,
|
|
446
|
+
specId: string,
|
|
447
|
+
answers: SpecAnswerInput[],
|
|
448
|
+
): Promise<AnswerSpecQuestionsResult> {
|
|
449
|
+
const config = await readProjectConfig(rootDir);
|
|
450
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
451
|
+
const specs = await loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument);
|
|
452
|
+
const spec = specs.find((entry) => entry.id === specId);
|
|
453
|
+
|
|
454
|
+
if (!spec) {
|
|
455
|
+
throw new Error(`Spec not found: ${specId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const applied = applySpecAnswers(spec.body, answers);
|
|
459
|
+
const updatedAt = new Date().toISOString();
|
|
460
|
+
const nextSpec: SpecDocument = {
|
|
461
|
+
...spec,
|
|
462
|
+
body: applied.body,
|
|
463
|
+
updatedAt,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
await writeUtf8(spec.filePath, serializeSpecDocument(nextSpec, updatedAt));
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
spec: nextSpec,
|
|
470
|
+
answeredQuestions: applied.answeredQuestions,
|
|
471
|
+
remainingQuestions: applied.remainingQuestions.map((entry) => entry.question),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function setTaskStatus(rootDir: string, taskId: string, status: TaskStatus): Promise<TaskDocument> {
|
|
476
|
+
const config = await readProjectConfig(rootDir);
|
|
477
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
478
|
+
const tasks = await loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument);
|
|
479
|
+
const task = tasks.find((entry) => entry.id === taskId);
|
|
480
|
+
|
|
481
|
+
if (!task) {
|
|
482
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const updatedAt = new Date().toISOString();
|
|
486
|
+
const nextTask: TaskDocument = {
|
|
487
|
+
...task,
|
|
488
|
+
status,
|
|
489
|
+
completedAt: status === "done" && task.status !== "done" ? updatedAt : task.completedAt,
|
|
490
|
+
cancelledAt: status === "cancelled" && task.status !== "cancelled" ? updatedAt : task.cancelledAt,
|
|
491
|
+
updatedAt,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
await writeUtf8(task.filePath, serializeTaskDocument(nextTask, updatedAt));
|
|
495
|
+
return nextTask;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function updateTaskDocument(
|
|
499
|
+
rootDir: string,
|
|
500
|
+
taskId: string,
|
|
501
|
+
updater: (task: TaskDocument, now: string) => TaskDocument,
|
|
502
|
+
): Promise<TaskDocument> {
|
|
503
|
+
const config = await readProjectConfig(rootDir);
|
|
504
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
505
|
+
const tasks = await loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument);
|
|
506
|
+
const task = tasks.find((entry) => entry.id === taskId);
|
|
507
|
+
|
|
508
|
+
if (!task) {
|
|
509
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const now = new Date().toISOString();
|
|
513
|
+
const nextTask = updater(task, now);
|
|
514
|
+
await writeUtf8(task.filePath, serializeTaskDocument(nextTask, nextTask.updatedAt ?? now));
|
|
515
|
+
return nextTask;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function assertArchivableTask(task: TaskDocument): void {
|
|
519
|
+
if (task.status !== "done" && task.status !== "cancelled") {
|
|
520
|
+
throw new Error(`Only done or cancelled tasks can be archived: ${task.id}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function archiveTask(rootDir: string, taskId: string, input: ArchiveTaskInput = {}): Promise<TaskDocument> {
|
|
525
|
+
return updateTaskDocument(rootDir, taskId, (task, now) => {
|
|
526
|
+
assertArchivableTask(task);
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
...task,
|
|
530
|
+
archivedAt: now,
|
|
531
|
+
archiveReason: input.reason?.trim() || task.archiveReason,
|
|
532
|
+
updatedAt: now,
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export async function restoreTask(rootDir: string, taskId: string): Promise<TaskDocument> {
|
|
538
|
+
return updateTaskDocument(rootDir, taskId, (task, now) => ({
|
|
539
|
+
...task,
|
|
540
|
+
archivedAt: undefined,
|
|
541
|
+
archiveReason: undefined,
|
|
542
|
+
updatedAt: now,
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function parseAgeToMilliseconds(value: string): number {
|
|
547
|
+
const trimmed = value.trim().toLowerCase();
|
|
548
|
+
const match = trimmed.match(/^(\d+)([dhw])$/);
|
|
549
|
+
if (!match) {
|
|
550
|
+
throw new Error("`--older-than` must look like 7d, 12h, or 2w.");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const amount = Number.parseInt(match[1]!, 10);
|
|
554
|
+
const unit = match[2]!;
|
|
555
|
+
const multiplier = unit === "h" ? 60 * 60 * 1000 : unit === "d" ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000;
|
|
556
|
+
return amount * multiplier;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function archiveClosedTasksByAge(
|
|
560
|
+
rootDir: string,
|
|
561
|
+
kind: "done" | "cancelled",
|
|
562
|
+
olderThan: string,
|
|
563
|
+
reason?: string,
|
|
564
|
+
): Promise<TaskDocument[]> {
|
|
565
|
+
const config = await readProjectConfig(rootDir);
|
|
566
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
567
|
+
const tasks = await loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument);
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
const cutoff = now - parseAgeToMilliseconds(olderThan);
|
|
570
|
+
const timestampField = kind === "done" ? "completedAt" : "cancelledAt";
|
|
571
|
+
const eligible = tasks.filter((task) => {
|
|
572
|
+
if (task.status !== kind || task.archivedAt) {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const timestamp = task[timestampField];
|
|
577
|
+
if (!timestamp) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const value = Date.parse(timestamp);
|
|
582
|
+
return Number.isFinite(value) && value <= cutoff;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const archived: TaskDocument[] = [];
|
|
586
|
+
for (const task of eligible) {
|
|
587
|
+
archived.push(await archiveTask(rootDir, task.id, { reason }));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return archived;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function archiveDoneTasks(rootDir: string, olderThan: string, input: ArchiveTaskInput = {}): Promise<TaskDocument[]> {
|
|
594
|
+
return archiveClosedTasksByAge(rootDir, "done", olderThan, input.reason);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function archiveCancelledTasks(rootDir: string, olderThan: string, input: ArchiveTaskInput = {}): Promise<TaskDocument[]> {
|
|
598
|
+
return archiveClosedTasksByAge(rootDir, "cancelled", olderThan, input.reason);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export async function createSpec(rootDir: string, input: CreateSpecInput): Promise<SpecDocument> {
|
|
602
|
+
const config = await readProjectConfig(rootDir);
|
|
603
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
604
|
+
const specs = await loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument);
|
|
605
|
+
const tasks = await loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument);
|
|
606
|
+
const taskIds = uniqueStrings(input.taskIds ?? []);
|
|
607
|
+
assertKnownIds("Task", taskIds, tasks.map((task) => task.id));
|
|
608
|
+
|
|
609
|
+
const now = new Date().toISOString();
|
|
610
|
+
const id = nextNumericId(specs.map((spec) => spec.id), "SPEC");
|
|
611
|
+
const fileName = `${id}-${slugify(input.title) || "spec"}.md`;
|
|
612
|
+
const filePath = join(paths.specsDir, fileName);
|
|
613
|
+
|
|
614
|
+
const spec: SpecDocument = {
|
|
615
|
+
docKind: "spec",
|
|
616
|
+
id,
|
|
617
|
+
title: input.title.trim(),
|
|
618
|
+
summary: input.summary.trim(),
|
|
619
|
+
status: input.status ?? "proposed",
|
|
620
|
+
taskIds,
|
|
621
|
+
tags: uniqueStrings(input.tags ?? []),
|
|
622
|
+
owner: input.owner?.trim() || undefined,
|
|
623
|
+
updatedAt: now,
|
|
624
|
+
body: buildCreatedSpecBody({ title: input.title.trim() }),
|
|
625
|
+
filePath,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
await writeUtf8(filePath, serializeSpecDocument(spec, now));
|
|
629
|
+
|
|
630
|
+
const linkedTasks = tasks.filter((task) => taskIds.includes(task.id));
|
|
631
|
+
for (const task of linkedTasks) {
|
|
632
|
+
const nextTask: TaskDocument = {
|
|
633
|
+
...task,
|
|
634
|
+
specIds: uniqueStrings([...task.specIds, spec.id]),
|
|
635
|
+
updatedAt: now,
|
|
636
|
+
};
|
|
637
|
+
await writeUtf8(task.filePath, serializeTaskDocument(nextTask, now));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return spec;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function createTask(rootDir: string, input: CreateTaskInput): Promise<TaskDocument> {
|
|
644
|
+
const config = await readProjectConfig(rootDir);
|
|
645
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
646
|
+
const specs = await loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument);
|
|
647
|
+
const tasks = await loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument);
|
|
648
|
+
const specIds = uniqueStrings(input.specIds);
|
|
649
|
+
const dependsOn = uniqueStrings(input.dependsOn ?? []);
|
|
650
|
+
const blockedBy = uniqueStrings(input.blockedBy ?? []);
|
|
651
|
+
|
|
652
|
+
if (specIds.length === 0) {
|
|
653
|
+
throw new Error("Task creation requires at least one linked spec.");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
assertKnownIds("Spec", specIds, specs.map((spec) => spec.id));
|
|
657
|
+
assertKnownIds("Task", dependsOn, tasks.map((task) => task.id));
|
|
658
|
+
assertKnownIds("Task", blockedBy, tasks.map((task) => task.id));
|
|
659
|
+
|
|
660
|
+
const now = new Date().toISOString();
|
|
661
|
+
const id = nextNumericId(tasks.map((task) => task.id), "TASK");
|
|
662
|
+
const fileName = `${id}-${slugify(input.title) || "task"}.md`;
|
|
663
|
+
const filePath = join(paths.tasksDir, fileName);
|
|
664
|
+
|
|
665
|
+
const task: TaskDocument = {
|
|
666
|
+
status: input.status ?? "todo",
|
|
667
|
+
docKind: "task",
|
|
668
|
+
id,
|
|
669
|
+
title: input.title.trim(),
|
|
670
|
+
summary: input.summary.trim(),
|
|
671
|
+
specIds,
|
|
672
|
+
dependsOn,
|
|
673
|
+
blockedBy,
|
|
674
|
+
priority: input.priority ?? 3,
|
|
675
|
+
ownerRole: input.ownerRole?.trim() || undefined,
|
|
676
|
+
createdAt: now,
|
|
677
|
+
completedAt: (input.status ?? "todo") === "done" ? now : undefined,
|
|
678
|
+
cancelledAt: (input.status ?? "todo") === "cancelled" ? now : undefined,
|
|
679
|
+
tags: uniqueStrings(input.tags ?? []),
|
|
680
|
+
updatedAt: now,
|
|
681
|
+
body: buildCreatedTaskBody({ title: input.title.trim() }),
|
|
682
|
+
filePath,
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
await writeUtf8(filePath, serializeTaskDocument(task, now));
|
|
686
|
+
|
|
687
|
+
const linkedSpecs = specs.filter((spec) => specIds.includes(spec.id));
|
|
688
|
+
for (const spec of linkedSpecs) {
|
|
689
|
+
const nextSpec: SpecDocument = {
|
|
690
|
+
...spec,
|
|
691
|
+
taskIds: uniqueStrings([...spec.taskIds, task.id]),
|
|
692
|
+
updatedAt: now,
|
|
693
|
+
};
|
|
694
|
+
await writeUtf8(spec.filePath, serializeSpecDocument(nextSpec, now));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return task;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export async function updateCurrentState(rootDir: string, input: UpdateCurrentStateInput): Promise<CurrentStateDocument> {
|
|
701
|
+
const config = await readProjectConfig(rootDir);
|
|
702
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
703
|
+
const [currentState, specs, tasks] = await Promise.all([
|
|
704
|
+
readCurrentState(paths),
|
|
705
|
+
loadMarkdownCollection<SpecDocument>(paths.specsDir, parseSpecDocument),
|
|
706
|
+
loadMarkdownCollection<TaskDocument>(paths.tasksDir, parseTaskDocument),
|
|
707
|
+
]);
|
|
708
|
+
|
|
709
|
+
const activeSpecIds = input.clearActiveSpecIds
|
|
710
|
+
? []
|
|
711
|
+
: input.activeSpecIds
|
|
712
|
+
? uniqueStrings(input.activeSpecIds)
|
|
713
|
+
: currentState.activeSpecIds;
|
|
714
|
+
|
|
715
|
+
const activeTaskIds = input.clearActiveTaskIds
|
|
716
|
+
? []
|
|
717
|
+
: input.activeTaskIds
|
|
718
|
+
? uniqueStrings(input.activeTaskIds)
|
|
719
|
+
: currentState.activeTaskIds;
|
|
720
|
+
|
|
721
|
+
const blockedTaskIds = input.clearBlockedTaskIds
|
|
722
|
+
? []
|
|
723
|
+
: input.blockedTaskIds
|
|
724
|
+
? uniqueStrings(input.blockedTaskIds)
|
|
725
|
+
: currentState.blockedTaskIds;
|
|
726
|
+
|
|
727
|
+
const nextActionHints = input.clearNextActionHints
|
|
728
|
+
? []
|
|
729
|
+
: input.nextActionHints
|
|
730
|
+
? uniqueStrings(input.nextActionHints)
|
|
731
|
+
: currentState.nextActionHints;
|
|
732
|
+
|
|
733
|
+
assertKnownIds("Spec", activeSpecIds, specs.map((spec) => spec.id));
|
|
734
|
+
assertKnownIds("Task", activeTaskIds, tasks.map((task) => task.id));
|
|
735
|
+
assertKnownIds("Task", blockedTaskIds, tasks.map((task) => task.id));
|
|
736
|
+
|
|
737
|
+
const updatedAt = new Date().toISOString();
|
|
738
|
+
const nextCurrentState: CurrentStateDocument = {
|
|
739
|
+
...currentState,
|
|
740
|
+
summary: input.summary?.trim() || currentState.summary,
|
|
741
|
+
focus: input.focus?.trim() || currentState.focus,
|
|
742
|
+
activeSpecIds,
|
|
743
|
+
activeTaskIds,
|
|
744
|
+
blockedTaskIds,
|
|
745
|
+
nextActionHints,
|
|
746
|
+
updatedAt,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
await writeUtf8(paths.stateFile, serializeCurrentStateDocument(nextCurrentState, updatedAt));
|
|
750
|
+
return nextCurrentState;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function writeHandoff(
|
|
754
|
+
rootDir: string,
|
|
755
|
+
currentState: CurrentStateDocument,
|
|
756
|
+
handoff: {
|
|
757
|
+
id: string;
|
|
758
|
+
title: string;
|
|
759
|
+
summary: string;
|
|
760
|
+
createdAt: string;
|
|
761
|
+
activeTaskIds: string[];
|
|
762
|
+
relatedSpecIds: string[];
|
|
763
|
+
author: string;
|
|
764
|
+
body: string;
|
|
765
|
+
},
|
|
766
|
+
): Promise<string> {
|
|
767
|
+
const config = await readProjectConfig(rootDir);
|
|
768
|
+
const paths = resolveProjectPaths(rootDir, config);
|
|
769
|
+
const slug = handoff.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
770
|
+
const fileName = `${handoff.createdAt.replace(/[:]/g, "-")}--${slug || "handoff"}.md`;
|
|
771
|
+
const filePath = join(paths.handoffsDir, fileName);
|
|
772
|
+
|
|
773
|
+
await ensureDir(paths.handoffsDir);
|
|
774
|
+
await writeUtf8(
|
|
775
|
+
filePath,
|
|
776
|
+
serializeTomlFrontmatter(
|
|
777
|
+
{
|
|
778
|
+
doc_kind: "handoff",
|
|
779
|
+
id: handoff.id,
|
|
780
|
+
title: handoff.title,
|
|
781
|
+
summary: handoff.summary,
|
|
782
|
+
handoff_kind: "checkpoint",
|
|
783
|
+
created_at: handoff.createdAt,
|
|
784
|
+
active_task_ids: handoff.activeTaskIds,
|
|
785
|
+
related_spec_ids: handoff.relatedSpecIds,
|
|
786
|
+
author: handoff.author,
|
|
787
|
+
updated_at: handoff.createdAt,
|
|
788
|
+
tags: ["handoff", "checkpoint"],
|
|
789
|
+
},
|
|
790
|
+
handoff.body,
|
|
791
|
+
),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
await writeUtf8(
|
|
795
|
+
paths.stateFile,
|
|
796
|
+
serializeCurrentStateDocument(
|
|
797
|
+
{
|
|
798
|
+
...currentState,
|
|
799
|
+
lastHandoffId: handoff.id,
|
|
800
|
+
updatedAt: handoff.createdAt,
|
|
801
|
+
},
|
|
802
|
+
handoff.createdAt,
|
|
803
|
+
),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
return filePath;
|
|
807
|
+
}
|