@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.
Files changed (62) hide show
  1. package/README.md +156 -0
  2. package/package.json +48 -0
  3. package/src/backends/base.ts +18 -0
  4. package/src/backends/deterministic.ts +105 -0
  5. package/src/backends/index.ts +26 -0
  6. package/src/backends/prompt.ts +169 -0
  7. package/src/backends/subprocess.ts +198 -0
  8. package/src/cli.ts +111 -0
  9. package/src/commands/agents.ts +16 -0
  10. package/src/commands/current.ts +95 -0
  11. package/src/commands/doctor.ts +12 -0
  12. package/src/commands/handoff.ts +64 -0
  13. package/src/commands/init.ts +101 -0
  14. package/src/commands/reshape.ts +20 -0
  15. package/src/commands/resume.ts +19 -0
  16. package/src/commands/spec.ts +108 -0
  17. package/src/commands/status.ts +98 -0
  18. package/src/commands/task.ts +190 -0
  19. package/src/commands/ui.ts +35 -0
  20. package/src/domain.ts +357 -0
  21. package/src/engine.ts +290 -0
  22. package/src/frontmatter.ts +83 -0
  23. package/src/index.ts +75 -0
  24. package/src/paths.ts +32 -0
  25. package/src/repository.ts +807 -0
  26. package/src/services/adoption.ts +169 -0
  27. package/src/services/agents.ts +191 -0
  28. package/src/services/dashboard.ts +776 -0
  29. package/src/services/details.ts +453 -0
  30. package/src/services/doctor.ts +452 -0
  31. package/src/services/layout.ts +420 -0
  32. package/src/services/spec-answer-evaluation.ts +103 -0
  33. package/src/services/spec-import.ts +217 -0
  34. package/src/services/spec-questions.ts +343 -0
  35. package/src/services/ui.ts +34 -0
  36. package/src/storage.ts +57 -0
  37. package/src/templates.ts +270 -0
  38. package/tsconfig.json +17 -0
  39. package/web/package.json +24 -0
  40. package/web/src/app.css +83 -0
  41. package/web/src/app.d.ts +6 -0
  42. package/web/src/app.html +11 -0
  43. package/web/src/lib/components/AnalysisFilters.svelte +293 -0
  44. package/web/src/lib/components/DocumentBody.svelte +100 -0
  45. package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
  46. package/web/src/lib/components/SelectDropdown.svelte +265 -0
  47. package/web/src/lib/server/project-root.ts +34 -0
  48. package/web/src/lib/task-board.ts +20 -0
  49. package/web/src/routes/+layout.server.ts +57 -0
  50. package/web/src/routes/+layout.svelte +421 -0
  51. package/web/src/routes/+layout.ts +1 -0
  52. package/web/src/routes/+page.svelte +530 -0
  53. package/web/src/routes/specs/+page.svelte +416 -0
  54. package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
  55. package/web/src/routes/specs/[specId]/+page.svelte +675 -0
  56. package/web/src/routes/tasks/+page.svelte +341 -0
  57. package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
  58. package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
  59. package/web/src/routes/timeline/+page.svelte +1093 -0
  60. package/web/svelte.config.js +10 -0
  61. package/web/tsconfig.json +9 -0
  62. package/web/vite.config.ts +11 -0
package/src/domain.ts ADDED
@@ -0,0 +1,357 @@
1
+ export const SPEC_STATUSES = ["proposed", "active", "paused", "done", "archived"] as const;
2
+ export const TASK_STATUSES = ["todo", "in_progress", "blocked", "review", "done", "cancelled"] as const;
3
+ export const HANDOFF_KINDS = ["checkpoint", "resume", "decision", "release"] as const;
4
+ export const BACKEND_NAMES = ["deterministic", "prompt", "codex", "claude"] as const;
5
+
6
+ export type SpecStatus = (typeof SPEC_STATUSES)[number];
7
+ export type TaskStatus = (typeof TASK_STATUSES)[number];
8
+ export type HandoffKind = (typeof HANDOFF_KINDS)[number];
9
+ export type BackendName = (typeof BACKEND_NAMES)[number];
10
+
11
+ export function isSpecStatus(value: string): value is SpecStatus {
12
+ return SPEC_STATUSES.includes(value as SpecStatus);
13
+ }
14
+
15
+ export function isTaskStatus(value: string): value is TaskStatus {
16
+ return TASK_STATUSES.includes(value as TaskStatus);
17
+ }
18
+
19
+ export interface BaseDocument {
20
+ docKind: string;
21
+ id: string;
22
+ title: string;
23
+ summary: string;
24
+ tags: string[];
25
+ body: string;
26
+ filePath: string;
27
+ updatedAt?: string;
28
+ }
29
+
30
+ export interface SpecDocument extends BaseDocument {
31
+ docKind: "spec";
32
+ status: SpecStatus;
33
+ taskIds: string[];
34
+ owner?: string;
35
+ }
36
+
37
+ export interface TaskDocument extends BaseDocument {
38
+ docKind: "task";
39
+ status: TaskStatus;
40
+ specIds: string[];
41
+ dependsOn: string[];
42
+ blockedBy: string[];
43
+ priority: number;
44
+ ownerRole?: string;
45
+ createdAt?: string;
46
+ completedAt?: string;
47
+ cancelledAt?: string;
48
+ archivedAt?: string;
49
+ archiveReason?: string;
50
+ }
51
+
52
+ export interface HandoffDocument extends BaseDocument {
53
+ docKind: "handoff";
54
+ handoffKind: HandoffKind;
55
+ createdAt: string;
56
+ activeTaskIds: string[];
57
+ relatedSpecIds: string[];
58
+ author: string;
59
+ }
60
+
61
+ export interface CurrentStateDocument extends BaseDocument {
62
+ docKind: "current-state";
63
+ focus: string;
64
+ activeSpecIds: string[];
65
+ activeTaskIds: string[];
66
+ blockedTaskIds: string[];
67
+ nextActionHints: string[];
68
+ lastHandoffId?: string;
69
+ }
70
+
71
+ export interface ExecutionBackendConfig {
72
+ enabled: boolean;
73
+ command?: string;
74
+ args?: string[];
75
+ }
76
+
77
+ export interface ProjectConfig {
78
+ toolVersion: string;
79
+ projectName: string;
80
+ memoryDir: string;
81
+ stateFile: string;
82
+ handoffsDir: string;
83
+ specsDir: string;
84
+ tasksDir: string;
85
+ skillsDir: string;
86
+ defaults: {
87
+ resumeBackend: BackendName;
88
+ };
89
+ backends: {
90
+ deterministic: { enabled: boolean };
91
+ prompt: { enabled: boolean };
92
+ codex: ExecutionBackendConfig;
93
+ claude: ExecutionBackendConfig;
94
+ };
95
+ }
96
+
97
+ export interface GitSnapshot {
98
+ available: boolean;
99
+ branch?: string;
100
+ shortStatus: string[];
101
+ lastCommit?: string;
102
+ dirty: boolean;
103
+ }
104
+
105
+ export interface ChangedItemSummary {
106
+ kind: "spec" | "task" | "current-state" | "handoff" | "other";
107
+ path: string;
108
+ id?: string;
109
+ title?: string;
110
+ }
111
+
112
+ export interface TaskNodeSummary {
113
+ task: TaskDocument;
114
+ unmetDependencies: string[];
115
+ downstreamCount: number;
116
+ }
117
+
118
+ export interface ProjectContext {
119
+ rootDir: string;
120
+ config: ProjectConfig;
121
+ currentState: CurrentStateDocument;
122
+ specs: SpecDocument[];
123
+ tasks: TaskDocument[];
124
+ handoffs: HandoffDocument[];
125
+ git: GitSnapshot;
126
+ }
127
+
128
+ export interface StatusSnapshot {
129
+ specCount: number;
130
+ taskCount: number;
131
+ activeSpecIds: string[];
132
+ activeTaskIds: string[];
133
+ blockedTasks: TaskNodeSummary[];
134
+ reviewTasks: TaskNodeSummary[];
135
+ readyTasks: TaskNodeSummary[];
136
+ inProgressTasks: TaskDocument[];
137
+ recentHandoff?: HandoffDocument;
138
+ criticalPath: TaskDocument[];
139
+ missingDependencies: Array<{ taskId: string; missingTaskId: string }>;
140
+ changedItems: ChangedItemSummary[];
141
+ }
142
+
143
+ export interface ResumeContext {
144
+ projectName: string;
145
+ focus: string;
146
+ summary: string;
147
+ activeSpecs: SpecDocument[];
148
+ activeTasks: TaskNodeSummary[];
149
+ blockedTasks: TaskNodeSummary[];
150
+ reviewTasks: TaskNodeSummary[];
151
+ readyTasks: TaskNodeSummary[];
152
+ criticalPath: TaskDocument[];
153
+ recentHandoff?: HandoffDocument;
154
+ nextActions: string[];
155
+ git: GitSnapshot;
156
+ changedItems: ChangedItemSummary[];
157
+ }
158
+
159
+ function fail(message: string): never {
160
+ throw new Error(message);
161
+ }
162
+
163
+ function isRecord(value: unknown): value is Record<string, unknown> {
164
+ return typeof value === "object" && value !== null && !Array.isArray(value);
165
+ }
166
+
167
+ function asString(value: unknown, field: string, filePath: string, fallback?: string): string {
168
+ if (typeof value === "string" && value.trim().length > 0) {
169
+ return value.trim();
170
+ }
171
+
172
+ if (fallback !== undefined) {
173
+ return fallback;
174
+ }
175
+
176
+ fail(`Expected "${field}" to be a non-empty string in ${filePath}`);
177
+ }
178
+
179
+ function asOptionalString(value: unknown): string | undefined {
180
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
181
+ }
182
+
183
+ function asStringArray(value: unknown, field: string, filePath: string): string[] {
184
+ if (value === undefined) {
185
+ return [];
186
+ }
187
+
188
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
189
+ return value.map((item) => item.trim()).filter(Boolean);
190
+ }
191
+
192
+ fail(`Expected "${field}" to be an array of strings in ${filePath}`);
193
+ }
194
+
195
+ function asNumber(value: unknown, field: string, filePath: string, fallback: number): number {
196
+ if (typeof value === "number" && Number.isFinite(value)) {
197
+ return value;
198
+ }
199
+
200
+ return fallback;
201
+ }
202
+
203
+ function asEnum<T extends readonly string[]>(
204
+ value: unknown,
205
+ allowed: T,
206
+ field: string,
207
+ filePath: string,
208
+ fallback?: T[number],
209
+ ): T[number] {
210
+ if (typeof value === "string" && allowed.includes(value as T[number])) {
211
+ return value as T[number];
212
+ }
213
+
214
+ if (fallback !== undefined) {
215
+ return fallback;
216
+ }
217
+
218
+ fail(`Expected "${field}" in ${filePath} to be one of: ${allowed.join(", ")}`);
219
+ }
220
+
221
+ export function parseProjectConfig(data: unknown): ProjectConfig {
222
+ if (!isRecord(data)) {
223
+ fail("Project config must be a TOML object.");
224
+ }
225
+
226
+ const defaults = isRecord(data.defaults) ? data.defaults : {};
227
+ const backends = isRecord(data.backends) ? data.backends : {};
228
+ const deterministicBackend = isRecord(backends.deterministic) ? backends.deterministic : {};
229
+ const promptBackend = isRecord(backends.prompt) ? backends.prompt : {};
230
+ const codexBackend = isRecord(backends.codex) ? backends.codex : {};
231
+ const claudeBackend = isRecord(backends.claude) ? backends.claude : {};
232
+
233
+ return {
234
+ toolVersion: asString(data.tool_version, "tool_version", ".specpm/config.toml"),
235
+ projectName: asString(data.project_name, "project_name", ".specpm/config.toml"),
236
+ memoryDir: asString(data.memory_dir, "memory_dir", ".specpm/config.toml", "docs/spm"),
237
+ stateFile: asString(data.state_file, "state_file", ".specpm/config.toml", "docs/spm/current.md"),
238
+ handoffsDir: asString(data.handoffs_dir, "handoffs_dir", ".specpm/config.toml", "docs/spm/handoffs"),
239
+ specsDir: asString(data.specs_dir, "specs_dir", ".specpm/config.toml", "docs/spm/specs"),
240
+ tasksDir: asString(data.tasks_dir, "tasks_dir", ".specpm/config.toml", "docs/spm/tasks"),
241
+ skillsDir: asString(data.skills_dir, "skills_dir", ".specpm/config.toml", "docs/spm/skills"),
242
+ defaults: {
243
+ resumeBackend: asEnum(defaults.resume_backend, BACKEND_NAMES, "defaults.resume_backend", ".specpm/config.toml", "deterministic"),
244
+ },
245
+ backends: {
246
+ deterministic: {
247
+ enabled: deterministicBackend.enabled !== false,
248
+ },
249
+ prompt: {
250
+ enabled: promptBackend.enabled !== false,
251
+ },
252
+ codex: {
253
+ enabled: codexBackend.enabled === true,
254
+ command: asOptionalString(codexBackend.command),
255
+ args: asStringArray(codexBackend.args, "backends.codex.args", ".specpm/config.toml"),
256
+ },
257
+ claude: {
258
+ enabled: claudeBackend.enabled === true,
259
+ command: asOptionalString(claudeBackend.command),
260
+ args: asStringArray(claudeBackend.args, "backends.claude.args", ".specpm/config.toml"),
261
+ },
262
+ },
263
+ };
264
+ }
265
+
266
+ export function parseSpecDocument(filePath: string, attributes: unknown, body: string): SpecDocument {
267
+ if (!isRecord(attributes)) {
268
+ fail(`Invalid frontmatter in ${filePath}`);
269
+ }
270
+
271
+ return {
272
+ docKind: asEnum(attributes.doc_kind, ["spec"] as const, "doc_kind", filePath),
273
+ id: asString(attributes.id, "id", filePath),
274
+ title: asString(attributes.title, "title", filePath),
275
+ summary: asString(attributes.summary, "summary", filePath, "No summary provided."),
276
+ status: asEnum(attributes.status, SPEC_STATUSES, "status", filePath, "proposed"),
277
+ taskIds: asStringArray(attributes.task_ids, "task_ids", filePath),
278
+ tags: asStringArray(attributes.tags, "tags", filePath),
279
+ owner: asOptionalString(attributes.owner),
280
+ updatedAt: asOptionalString(attributes.updated_at),
281
+ body: body.trim(),
282
+ filePath,
283
+ };
284
+ }
285
+
286
+ export function parseTaskDocument(filePath: string, attributes: unknown, body: string): TaskDocument {
287
+ if (!isRecord(attributes)) {
288
+ fail(`Invalid frontmatter in ${filePath}`);
289
+ }
290
+
291
+ return {
292
+ docKind: asEnum(attributes.doc_kind, ["task"] as const, "doc_kind", filePath),
293
+ id: asString(attributes.id, "id", filePath),
294
+ title: asString(attributes.title, "title", filePath),
295
+ summary: asString(attributes.summary, "summary", filePath, "No summary provided."),
296
+ status: asEnum(attributes.status, TASK_STATUSES, "status", filePath, "todo"),
297
+ specIds: asStringArray(attributes.spec_ids, "spec_ids", filePath),
298
+ dependsOn: asStringArray(attributes.depends_on, "depends_on", filePath),
299
+ blockedBy: asStringArray(attributes.blocked_by, "blocked_by", filePath),
300
+ priority: asNumber(attributes.priority, "priority", filePath, 3),
301
+ ownerRole: asOptionalString(attributes.owner_role),
302
+ createdAt: asOptionalString(attributes.created_at),
303
+ completedAt: asOptionalString(attributes.completed_at),
304
+ cancelledAt: asOptionalString(attributes.cancelled_at),
305
+ archivedAt: asOptionalString(attributes.archived_at),
306
+ archiveReason: asOptionalString(attributes.archive_reason),
307
+ tags: asStringArray(attributes.tags, "tags", filePath),
308
+ updatedAt: asOptionalString(attributes.updated_at),
309
+ body: body.trim(),
310
+ filePath,
311
+ };
312
+ }
313
+
314
+ export function parseHandoffDocument(filePath: string, attributes: unknown, body: string): HandoffDocument {
315
+ if (!isRecord(attributes)) {
316
+ fail(`Invalid frontmatter in ${filePath}`);
317
+ }
318
+
319
+ return {
320
+ docKind: asEnum(attributes.doc_kind, ["handoff"] as const, "doc_kind", filePath),
321
+ id: asString(attributes.id, "id", filePath),
322
+ title: asString(attributes.title, "title", filePath),
323
+ summary: asString(attributes.summary, "summary", filePath, "No summary provided."),
324
+ handoffKind: asEnum(attributes.handoff_kind, HANDOFF_KINDS, "handoff_kind", filePath, "checkpoint"),
325
+ createdAt: asString(attributes.created_at, "created_at", filePath),
326
+ activeTaskIds: asStringArray(attributes.active_task_ids, "active_task_ids", filePath),
327
+ relatedSpecIds: asStringArray(attributes.related_spec_ids, "related_spec_ids", filePath),
328
+ author: asString(attributes.author, "author", filePath, "spec-embryo"),
329
+ tags: asStringArray(attributes.tags, "tags", filePath),
330
+ updatedAt: asOptionalString(attributes.updated_at),
331
+ body: body.trim(),
332
+ filePath,
333
+ };
334
+ }
335
+
336
+ export function parseCurrentStateDocument(filePath: string, attributes: unknown, body: string): CurrentStateDocument {
337
+ if (!isRecord(attributes)) {
338
+ fail(`Invalid frontmatter in ${filePath}`);
339
+ }
340
+
341
+ return {
342
+ docKind: asEnum(attributes.doc_kind, ["current-state"] as const, "doc_kind", filePath),
343
+ id: asString(attributes.id, "id", filePath, "CURRENT"),
344
+ title: asString(attributes.title, "title", filePath, "Current Project State"),
345
+ summary: asString(attributes.summary, "summary", filePath, "Current project state."),
346
+ focus: asString(attributes.focus, "focus", filePath, "Define the current focus."),
347
+ activeSpecIds: asStringArray(attributes.active_spec_ids, "active_spec_ids", filePath),
348
+ activeTaskIds: asStringArray(attributes.active_task_ids, "active_task_ids", filePath),
349
+ blockedTaskIds: asStringArray(attributes.blocked_task_ids, "blocked_task_ids", filePath),
350
+ nextActionHints: asStringArray(attributes.next_action_hints, "next_action_hints", filePath),
351
+ lastHandoffId: asOptionalString(attributes.last_handoff_id),
352
+ tags: asStringArray(attributes.tags, "tags", filePath),
353
+ updatedAt: asOptionalString(attributes.updated_at),
354
+ body: body.trim(),
355
+ filePath,
356
+ };
357
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,290 @@
1
+ import { relative } from "node:path";
2
+ import type {
3
+ ChangedItemSummary,
4
+ CurrentStateDocument,
5
+ ProjectContext,
6
+ ResumeContext,
7
+ SpecDocument,
8
+ StatusSnapshot,
9
+ TaskDocument,
10
+ TaskNodeSummary,
11
+ } from "./domain.ts";
12
+
13
+ function byPriority(left: TaskDocument, right: TaskDocument): number {
14
+ return left.priority - right.priority || left.id.localeCompare(right.id);
15
+ }
16
+
17
+ function unique<T>(items: T[]): T[] {
18
+ return [...new Set(items)];
19
+ }
20
+
21
+ function parseGitStatusPath(line: string): string | undefined {
22
+ if (line.length < 4) {
23
+ return undefined;
24
+ }
25
+
26
+ const rawPath = line.slice(3).trim();
27
+ if (rawPath.length === 0) {
28
+ return undefined;
29
+ }
30
+
31
+ const renameParts = rawPath.split(" -> ");
32
+ return renameParts[renameParts.length - 1]?.trim() || undefined;
33
+ }
34
+
35
+ export function buildTaskIndex(tasks: TaskDocument[]): Map<string, TaskDocument> {
36
+ return new Map(tasks.map((task) => [task.id, task]));
37
+ }
38
+
39
+ export function summarizeTaskNode(task: TaskDocument, index: Map<string, TaskDocument>): TaskNodeSummary {
40
+ const unmetDependencies = task.dependsOn.filter((dependencyId) => index.get(dependencyId)?.status !== "done");
41
+ const downstreamCount = [...index.values()].filter((candidate) => candidate.dependsOn.includes(task.id)).length;
42
+
43
+ return {
44
+ task,
45
+ unmetDependencies,
46
+ downstreamCount,
47
+ };
48
+ }
49
+
50
+ export function deriveMissingDependencies(tasks: TaskDocument[]): Array<{ taskId: string; missingTaskId: string }> {
51
+ const index = buildTaskIndex(tasks);
52
+ return tasks.flatMap((task) =>
53
+ task.dependsOn
54
+ .filter((dependencyId) => !index.has(dependencyId))
55
+ .map((missingTaskId) => ({ taskId: task.id, missingTaskId })),
56
+ );
57
+ }
58
+
59
+ export function deriveChangedItems(context: ProjectContext): ChangedItemSummary[] {
60
+ const specByPath = new Map(context.specs.map((spec) => [relative(context.rootDir, spec.filePath), spec]));
61
+ const taskByPath = new Map(context.tasks.map((task) => [relative(context.rootDir, task.filePath), task]));
62
+ const handoffByPath = new Map(context.handoffs.map((handoff) => [relative(context.rootDir, handoff.filePath), handoff]));
63
+ const currentStatePath = relative(context.rootDir, context.currentState.filePath);
64
+ const seen = new Set<string>();
65
+ const items: ChangedItemSummary[] = [];
66
+
67
+ for (const line of context.git.shortStatus) {
68
+ const path = parseGitStatusPath(line);
69
+ if (!path || seen.has(path)) {
70
+ continue;
71
+ }
72
+
73
+ seen.add(path);
74
+
75
+ const spec = specByPath.get(path);
76
+ if (spec) {
77
+ items.push({ kind: "spec", path, id: spec.id, title: spec.title });
78
+ continue;
79
+ }
80
+
81
+ const task = taskByPath.get(path);
82
+ if (task) {
83
+ items.push({ kind: "task", path, id: task.id, title: task.title });
84
+ continue;
85
+ }
86
+
87
+ if (path === currentStatePath) {
88
+ items.push({ kind: "current-state", path, id: context.currentState.id, title: context.currentState.title });
89
+ continue;
90
+ }
91
+
92
+ const handoff = handoffByPath.get(path);
93
+ if (handoff) {
94
+ items.push({ kind: "handoff", path, id: handoff.id, title: handoff.title });
95
+ continue;
96
+ }
97
+
98
+ items.push({ kind: "other", path });
99
+ }
100
+
101
+ return items;
102
+ }
103
+
104
+ function deriveActiveTaskIds(tasks: TaskDocument[], currentState: CurrentStateDocument): string[] {
105
+ if (currentState.activeTaskIds.length > 0) {
106
+ return unique(currentState.activeTaskIds);
107
+ }
108
+
109
+ return tasks.filter((task) => task.status === "in_progress" || task.status === "review").map((task) => task.id);
110
+ }
111
+
112
+ function deriveActiveSpecIds(specs: SpecDocument[], currentState: CurrentStateDocument, activeTaskIds: string[], taskIndex: Map<string, TaskDocument>): string[] {
113
+ if (currentState.activeSpecIds.length > 0) {
114
+ return unique(currentState.activeSpecIds);
115
+ }
116
+
117
+ const inferred = activeTaskIds.flatMap((taskId) => taskIndex.get(taskId)?.specIds ?? []);
118
+ return unique(specs.filter((spec) => spec.status === "active").map((spec) => spec.id).concat(inferred));
119
+ }
120
+
121
+ function computeCriticalPath(tasks: TaskDocument[], index: Map<string, TaskDocument>): TaskDocument[] {
122
+ const unfinished = tasks.filter((task) => task.status !== "done" && task.status !== "cancelled");
123
+ const cache = new Map<string, TaskDocument[]>();
124
+
125
+ function dfs(task: TaskDocument, trail: Set<string>): TaskDocument[] {
126
+ if (cache.has(task.id)) {
127
+ return cache.get(task.id)!;
128
+ }
129
+
130
+ if (trail.has(task.id)) {
131
+ return [task];
132
+ }
133
+
134
+ const nextTrail = new Set(trail);
135
+ nextTrail.add(task.id);
136
+
137
+ let best: TaskDocument[] = [task];
138
+ for (const dependencyId of task.dependsOn) {
139
+ const dependency = index.get(dependencyId);
140
+ if (!dependency || dependency.status === "done" || dependency.status === "cancelled") {
141
+ continue;
142
+ }
143
+
144
+ const candidate = [...dfs(dependency, nextTrail), task];
145
+ if (candidate.length > best.length) {
146
+ best = candidate;
147
+ }
148
+ }
149
+
150
+ cache.set(task.id, best);
151
+ return best;
152
+ }
153
+
154
+ let overallBest: TaskDocument[] = [];
155
+ for (const task of unfinished) {
156
+ const candidate = dfs(task, new Set());
157
+ if (candidate.length > overallBest.length) {
158
+ overallBest = candidate;
159
+ }
160
+ }
161
+
162
+ return overallBest;
163
+ }
164
+
165
+ export function deriveStatusSnapshot(context: ProjectContext): StatusSnapshot {
166
+ const taskIndex = buildTaskIndex(context.tasks);
167
+ const activeTaskIds = deriveActiveTaskIds(context.tasks, context.currentState);
168
+ const activeSpecIds = deriveActiveSpecIds(context.specs, context.currentState, activeTaskIds, taskIndex);
169
+ const activeTasks = activeTaskIds.map((taskId) => taskIndex.get(taskId)).filter((task): task is TaskDocument => Boolean(task));
170
+ const activeTaskSet = new Set(activeTasks.map((task) => task.id));
171
+
172
+ const blockedTasks = context.tasks
173
+ .filter((task) => task.status === "blocked" || (task.status === "todo" && task.dependsOn.some((dependencyId) => taskIndex.get(dependencyId)?.status !== "done")))
174
+ .map((task) => summarizeTaskNode(task, taskIndex))
175
+ .sort((left, right) => byPriority(left.task, right.task));
176
+
177
+ const reviewTasks = context.tasks
178
+ .filter((task) => task.status === "review")
179
+ .map((task) => summarizeTaskNode(task, taskIndex))
180
+ .sort((left, right) => byPriority(left.task, right.task));
181
+
182
+ const readyTasks = context.tasks
183
+ .filter((task) => {
184
+ if (task.status !== "todo") {
185
+ return false;
186
+ }
187
+
188
+ return summarizeTaskNode(task, taskIndex).unmetDependencies.length === 0;
189
+ })
190
+ .filter((task) => !activeTaskSet.has(task.id))
191
+ .map((task) => summarizeTaskNode(task, taskIndex))
192
+ .sort((left, right) => byPriority(left.task, right.task));
193
+
194
+ return {
195
+ specCount: context.specs.length,
196
+ taskCount: context.tasks.length,
197
+ activeSpecIds,
198
+ activeTaskIds,
199
+ blockedTasks,
200
+ reviewTasks,
201
+ readyTasks,
202
+ inProgressTasks: context.tasks.filter((task) => task.status === "in_progress").sort(byPriority),
203
+ recentHandoff: context.handoffs[0],
204
+ criticalPath: computeCriticalPath(context.tasks, taskIndex),
205
+ missingDependencies: deriveMissingDependencies(context.tasks),
206
+ changedItems: deriveChangedItems(context),
207
+ };
208
+ }
209
+
210
+ export function deriveNextActions(context: ProjectContext, snapshot: StatusSnapshot): string[] {
211
+ const actions: string[] = [];
212
+
213
+ if (snapshot.blockedTasks.length > 0) {
214
+ for (const blocked of snapshot.blockedTasks.slice(0, 2)) {
215
+ const reason = blocked.unmetDependencies.length > 0 ? `waiting on ${blocked.unmetDependencies.join(", ")}` : "marked blocked";
216
+ actions.push(`Unblock ${blocked.task.id} (${blocked.task.title}) because it is ${reason}.`);
217
+ }
218
+ }
219
+
220
+ if (snapshot.inProgressTasks.length > 0) {
221
+ for (const task of snapshot.inProgressTasks.slice(0, 2)) {
222
+ actions.push(`Continue ${task.id} (${task.title}) and update the configured current state file when its scope shifts.`);
223
+ }
224
+ }
225
+
226
+ if (actions.length < 3 && snapshot.reviewTasks.length > 0) {
227
+ for (const review of snapshot.reviewTasks.slice(0, 3 - actions.length)) {
228
+ actions.push(`Review ${review.task.id} (${review.task.title}) before moving it to done.`);
229
+ }
230
+ }
231
+
232
+ if (actions.length < 3 && snapshot.readyTasks.length > 0) {
233
+ for (const ready of snapshot.readyTasks.slice(0, 3 - actions.length)) {
234
+ actions.push(`Start ${ready.task.id} (${ready.task.title}); all declared dependencies are satisfied.`);
235
+ }
236
+ }
237
+
238
+ if (actions.length < 4 && snapshot.changedItems.length > 0) {
239
+ const changedDocs = snapshot.changedItems
240
+ .filter((item) => item.kind !== "other")
241
+ .slice(0, 2)
242
+ .map((item) => item.id ?? item.path);
243
+
244
+ if (changedDocs.length > 0) {
245
+ actions.push(`Reconcile recent repo changes touching ${changedDocs.join(", ")} before the next checkpoint.`);
246
+ }
247
+ }
248
+
249
+ if (actions.length === 0) {
250
+ actions.push("Capture the next meaningful slice in the configured current state file or create a new task.");
251
+ }
252
+
253
+ for (const hint of context.currentState.nextActionHints) {
254
+ if (actions.length >= 4) {
255
+ break;
256
+ }
257
+
258
+ actions.push(hint);
259
+ }
260
+
261
+ return unique(actions);
262
+ }
263
+
264
+ export function buildResumeContext(context: ProjectContext): ResumeContext {
265
+ const snapshot = deriveStatusSnapshot(context);
266
+ const taskIndex = buildTaskIndex(context.tasks);
267
+ const activeSpecs = snapshot.activeSpecIds
268
+ .map((specId) => context.specs.find((spec) => spec.id === specId))
269
+ .filter((spec): spec is SpecDocument => Boolean(spec));
270
+ const activeTasks = snapshot.activeTaskIds
271
+ .map((taskId) => taskIndex.get(taskId))
272
+ .filter((task): task is TaskDocument => Boolean(task))
273
+ .map((task) => summarizeTaskNode(task, taskIndex));
274
+
275
+ return {
276
+ projectName: context.config.projectName,
277
+ focus: context.currentState.focus,
278
+ summary: context.currentState.summary,
279
+ activeSpecs,
280
+ activeTasks,
281
+ blockedTasks: snapshot.blockedTasks,
282
+ reviewTasks: snapshot.reviewTasks,
283
+ readyTasks: snapshot.readyTasks,
284
+ criticalPath: snapshot.criticalPath,
285
+ recentHandoff: snapshot.recentHandoff,
286
+ nextActions: deriveNextActions(context, snapshot),
287
+ git: context.git,
288
+ changedItems: snapshot.changedItems,
289
+ };
290
+ }