@eddacraft/anvil-aps 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 (121) hide show
  1. package/AGENTS.md +155 -0
  2. package/LICENSE +14 -0
  3. package/README.md +57 -0
  4. package/TODO.md +40 -0
  5. package/dist/filter/context-bundle.d.ts +81 -0
  6. package/dist/filter/context-bundle.d.ts.map +1 -0
  7. package/dist/filter/context-bundle.js +230 -0
  8. package/dist/filter/index.d.ts +85 -0
  9. package/dist/filter/index.d.ts.map +1 -0
  10. package/dist/filter/index.js +169 -0
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/loader/index.d.ts +80 -0
  15. package/dist/loader/index.d.ts.map +1 -0
  16. package/dist/loader/index.js +253 -0
  17. package/dist/parser/index.d.ts +24 -0
  18. package/dist/parser/index.d.ts.map +1 -0
  19. package/dist/parser/index.js +22 -0
  20. package/dist/parser/parse-document.d.ts +17 -0
  21. package/dist/parser/parse-document.d.ts.map +1 -0
  22. package/dist/parser/parse-document.js +219 -0
  23. package/dist/parser/parse-index.d.ts +31 -0
  24. package/dist/parser/parse-index.d.ts.map +1 -0
  25. package/dist/parser/parse-index.js +251 -0
  26. package/dist/parser/parse-task.d.ts +30 -0
  27. package/dist/parser/parse-task.d.ts.map +1 -0
  28. package/dist/parser/parse-task.js +261 -0
  29. package/dist/state/index.d.ts +307 -0
  30. package/dist/state/index.d.ts.map +1 -0
  31. package/dist/state/index.js +689 -0
  32. package/dist/templates/generator.d.ts +71 -0
  33. package/dist/templates/generator.d.ts.map +1 -0
  34. package/dist/templates/generator.js +723 -0
  35. package/dist/templates/index.d.ts +5 -0
  36. package/dist/templates/index.d.ts.map +1 -0
  37. package/dist/templates/index.js +4 -0
  38. package/dist/types/index.d.ts +131 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/index.js +107 -0
  41. package/dist/validator/index.d.ts +83 -0
  42. package/dist/validator/index.d.ts.map +1 -0
  43. package/dist/validator/index.js +611 -0
  44. package/docs/APS-Anvil-Integration.md +750 -0
  45. package/docs/APS-Conventions.md +635 -0
  46. package/docs/APS-NonGoals.md +455 -0
  47. package/docs/APS-Planning-Spec-v0.1.md +362 -0
  48. package/examples/README.md +170 -0
  49. package/examples/feature-auth.aps.md +87 -0
  50. package/examples/refactor-error-handling.aps.md +119 -0
  51. package/examples/system-ecommerce/APS.md +57 -0
  52. package/examples/system-ecommerce/modules/auth.aps.md +38 -0
  53. package/examples/system-ecommerce/modules/cart.aps.md +53 -0
  54. package/examples/system-ecommerce/modules/payments.aps.md +68 -0
  55. package/examples/system-ecommerce/modules/products.aps.md +53 -0
  56. package/package.json +34 -0
  57. package/project.json +37 -0
  58. package/scripts/generate-templates.js +33 -0
  59. package/src/filter/context-bundle.ts +312 -0
  60. package/src/filter/filter.test.ts +317 -0
  61. package/src/filter/index.ts +249 -0
  62. package/src/index.ts +16 -0
  63. package/src/loader/index.ts +364 -0
  64. package/src/loader/loader.test.ts +224 -0
  65. package/src/parser/__fixtures__/invalid-task-id-not-padded.aps.md +7 -0
  66. package/src/parser/__fixtures__/invalid-task-id.aps.md +8 -0
  67. package/src/parser/__fixtures__/minimal-task.aps.md +7 -0
  68. package/src/parser/__fixtures__/non-scope-hyphenated.aps.md +10 -0
  69. package/src/parser/__fixtures__/simple-index.aps.md +35 -0
  70. package/src/parser/__fixtures__/simple-plan.aps.md +19 -0
  71. package/src/parser/index.ts +30 -0
  72. package/src/parser/parse-document.test.ts +603 -0
  73. package/src/parser/parse-document.ts +262 -0
  74. package/src/parser/parse-index.test.ts +316 -0
  75. package/src/parser/parse-index.ts +298 -0
  76. package/src/parser/parse-task.test.ts +476 -0
  77. package/src/parser/parse-task.ts +325 -0
  78. package/src/state/__fixtures__/invalid-plan.aps.md +9 -0
  79. package/src/state/__fixtures__/test-plan.aps.md +20 -0
  80. package/src/state/index.ts +879 -0
  81. package/src/state/state.test.ts +645 -0
  82. package/src/templates/generator.test.ts +378 -0
  83. package/src/templates/generator.ts +776 -0
  84. package/src/templates/index.ts +5 -0
  85. package/src/types/index.ts +168 -0
  86. package/src/validator/__fixtures__/broken-links.aps.md +10 -0
  87. package/src/validator/__fixtures__/circular-deps-index.aps.md +26 -0
  88. package/src/validator/__fixtures__/circular-modules/module-a.aps.md +9 -0
  89. package/src/validator/__fixtures__/circular-modules/module-b.aps.md +9 -0
  90. package/src/validator/__fixtures__/circular-modules/module-c.aps.md +9 -0
  91. package/src/validator/__fixtures__/dup-modules/module-a.aps.md +9 -0
  92. package/src/validator/__fixtures__/dup-modules/module-b.aps.md +9 -0
  93. package/src/validator/__fixtures__/duplicate-ids-index.aps.md +15 -0
  94. package/src/validator/__fixtures__/invalid-task-id.aps.md +17 -0
  95. package/src/validator/__fixtures__/missing-confidence.aps.md +9 -0
  96. package/src/validator/__fixtures__/missing-h1.aps.md +5 -0
  97. package/src/validator/__fixtures__/missing-intent.aps.md +9 -0
  98. package/src/validator/__fixtures__/missing-modules-section.aps.md +7 -0
  99. package/src/validator/__fixtures__/missing-tasks-section.aps.md +7 -0
  100. package/src/validator/__fixtures__/modules/auth.aps.md +17 -0
  101. package/src/validator/__fixtures__/modules/payments.aps.md +13 -0
  102. package/src/validator/__fixtures__/scope-mismatch.aps.md +14 -0
  103. package/src/validator/__fixtures__/valid-index.aps.md +24 -0
  104. package/src/validator/__fixtures__/valid-leaf.aps.md +22 -0
  105. package/src/validator/index.ts +776 -0
  106. package/src/validator/validator.test.ts +269 -0
  107. package/templates/index-full.md +94 -0
  108. package/templates/index-minimal.md +16 -0
  109. package/templates/index-template.md +63 -0
  110. package/templates/leaf-full.md +76 -0
  111. package/templates/leaf-minimal.md +14 -0
  112. package/templates/leaf-template.md +55 -0
  113. package/templates/simple-full.md +56 -0
  114. package/templates/simple-minimal.md +14 -0
  115. package/templates/simple-template.md +30 -0
  116. package/tsconfig.json +19 -0
  117. package/tsconfig.lib.json +14 -0
  118. package/tsconfig.lib.tsbuildinfo +1 -0
  119. package/tsconfig.spec.json +9 -0
  120. package/tsconfig.tsbuildinfo +1 -0
  121. package/vitest.config.ts +15 -0
@@ -0,0 +1,689 @@
1
+ /**
2
+ * State module - Task state management and locking functionality
3
+ *
4
+ * Manages `.anvil/state.json` for tracking task execution states.
5
+ * Provides TaskLocker for locking tasks for execution with:
6
+ * - First-lock-wins concurrent lock handling
7
+ * - Execution plan JSON generation with hash and provenance
8
+ * - Lock/unlock/status operations
9
+ */
10
+ import { promises as fs } from 'node:fs';
11
+ import { dirname, join, isAbsolute, resolve } from 'node:path';
12
+ import { createHash } from 'node:crypto';
13
+ import { execSync } from 'node:child_process';
14
+ import { z } from 'zod';
15
+ import { TaskStatusSchema } from '../types/index.js';
16
+ import { loadPlan } from '../loader/index.js';
17
+ import { validatePlanningDoc } from '../validator/index.js';
18
+ // ============================================================================
19
+ // Schemas
20
+ // ============================================================================
21
+ /**
22
+ * Source location of a task in a planning document
23
+ */
24
+ export const TaskSourceSchema = z.object({
25
+ /** File path relative to project root */
26
+ file: z.string(),
27
+ /** Line number where task starts (1-based) */
28
+ line: z.number().optional(),
29
+ });
30
+ /**
31
+ * State of a single task
32
+ */
33
+ export const TaskStateSchema = z.object({
34
+ /** Current status */
35
+ status: TaskStatusSchema,
36
+ /** ISO timestamp when task was locked */
37
+ locked_at: z.string().optional(),
38
+ /** User who locked the task */
39
+ locked_by: z.string().optional(),
40
+ /** Path to execution plan JSON file */
41
+ execution_file: z.string().optional(),
42
+ /** Source location in planning doc */
43
+ source: TaskSourceSchema.optional(),
44
+ /** ISO timestamp when task was completed */
45
+ completed_at: z.string().optional(),
46
+ /** ISO timestamp when task was cancelled */
47
+ cancelled_at: z.string().optional(),
48
+ });
49
+ /**
50
+ * Full state file schema (.anvil/state.json)
51
+ */
52
+ export const StateFileSchema = z.object({
53
+ /** Schema version */
54
+ version: z.string().default('1.0.0'),
55
+ /** Map of task ID to task state */
56
+ tasks: z.record(z.string(), TaskStateSchema),
57
+ });
58
+ /**
59
+ * Provenance information for execution plans
60
+ */
61
+ export const ProvenanceSchema = z.object({
62
+ /** User who locked the task */
63
+ locked_by: z.string(),
64
+ /** ISO timestamp when locked */
65
+ locked_at: z.string(),
66
+ /** Git commit hash (if available) */
67
+ git_commit: z.string().optional(),
68
+ /** Git branch (if available) */
69
+ git_branch: z.string().optional(),
70
+ /** Planning doc file path */
71
+ source_file: z.string(),
72
+ /** Line number in planning doc */
73
+ source_line: z.number().optional(),
74
+ });
75
+ /**
76
+ * Execution plan JSON schema (per-task, written to .anvil/executions/)
77
+ */
78
+ export const ExecutionPlanSchema = z.object({
79
+ /** Schema version */
80
+ version: z.string().default('1.0.0'),
81
+ /** Task ID */
82
+ task_id: z.string(),
83
+ /** Task title */
84
+ title: z.string(),
85
+ /** Task intent */
86
+ intent: z.string(),
87
+ /** Expected outcome */
88
+ expected_outcome: z.string().optional(),
89
+ /** Confidence level */
90
+ confidence: z.enum(['low', 'medium', 'high']),
91
+ /** Task scopes */
92
+ scopes: z.array(z.string()).optional(),
93
+ /** Task tags */
94
+ tags: z.array(z.string()).optional(),
95
+ /** Task dependencies */
96
+ dependencies: z.array(z.string()).optional(),
97
+ /** Task inputs */
98
+ inputs: z.array(z.string()).optional(),
99
+ /** SHA-256 hash of task content */
100
+ content_hash: z.string(),
101
+ /** Provenance information */
102
+ provenance: ProvenanceSchema,
103
+ });
104
+ // ============================================================================
105
+ // State File Operations
106
+ // ============================================================================
107
+ const STATE_FILE_NAME = 'state.json';
108
+ const EXECUTIONS_DIR = 'executions';
109
+ const ANVIL_DIR = '.anvil';
110
+ /**
111
+ * Get the path to the state file
112
+ */
113
+ export function getStateFilePath(projectRoot) {
114
+ return join(projectRoot, ANVIL_DIR, STATE_FILE_NAME);
115
+ }
116
+ /**
117
+ * Get the path to the executions directory
118
+ */
119
+ export function getExecutionsDir(projectRoot) {
120
+ return join(projectRoot, ANVIL_DIR, EXECUTIONS_DIR);
121
+ }
122
+ /**
123
+ * Read the state file, returning empty state if it doesn't exist
124
+ */
125
+ export async function readStateFile(projectRoot) {
126
+ const statePath = getStateFilePath(projectRoot);
127
+ try {
128
+ const content = await fs.readFile(statePath, 'utf-8');
129
+ const data = JSON.parse(content);
130
+ return StateFileSchema.parse(data);
131
+ }
132
+ catch (error) {
133
+ if (error.code === 'ENOENT') {
134
+ return { version: '1.0.0', tasks: {} };
135
+ }
136
+ throw new StateError(`Failed to read state file: ${error instanceof Error ? error.message : String(error)}`, statePath);
137
+ }
138
+ }
139
+ /**
140
+ * Write the state file, creating directories if needed
141
+ */
142
+ export async function writeStateFile(projectRoot, state) {
143
+ const statePath = getStateFilePath(projectRoot);
144
+ const stateDir = dirname(statePath);
145
+ try {
146
+ await fs.mkdir(stateDir, { recursive: true });
147
+ const content = JSON.stringify(state, null, 2);
148
+ await fs.writeFile(statePath, content, 'utf-8');
149
+ }
150
+ catch (error) {
151
+ throw new StateError(`Failed to write state file: ${error instanceof Error ? error.message : String(error)}`, statePath);
152
+ }
153
+ }
154
+ /**
155
+ * Get the state of a specific task
156
+ */
157
+ export async function getTaskState(projectRoot, taskId) {
158
+ const state = await readStateFile(projectRoot);
159
+ return state.tasks[taskId];
160
+ }
161
+ /**
162
+ * Update the state of a specific task
163
+ */
164
+ export async function updateTaskState(projectRoot, taskId, taskState) {
165
+ const state = await readStateFile(projectRoot);
166
+ state.tasks[taskId] = taskState;
167
+ await writeStateFile(projectRoot, state);
168
+ }
169
+ // ============================================================================
170
+ // Execution Plan Operations
171
+ // ============================================================================
172
+ /**
173
+ * Generate execution plan JSON for a task
174
+ */
175
+ export function createExecutionPlan(task, provenance) {
176
+ // Compute content hash from task fields
177
+ const contentHash = computeTaskHash(task);
178
+ return {
179
+ version: '1.0.0',
180
+ task_id: task.id,
181
+ title: task.title,
182
+ intent: task.intent,
183
+ expected_outcome: task.expectedOutcome,
184
+ confidence: task.confidence,
185
+ scopes: task.scopes,
186
+ tags: task.tags,
187
+ dependencies: task.dependencies,
188
+ inputs: task.inputs,
189
+ content_hash: contentHash,
190
+ provenance,
191
+ };
192
+ }
193
+ /**
194
+ * Compute SHA-256 hash of task content
195
+ */
196
+ export function computeTaskHash(task) {
197
+ const content = JSON.stringify({
198
+ id: task.id,
199
+ title: task.title,
200
+ intent: task.intent,
201
+ expectedOutcome: task.expectedOutcome,
202
+ confidence: task.confidence,
203
+ scopes: task.scopes,
204
+ tags: task.tags,
205
+ dependencies: task.dependencies,
206
+ inputs: task.inputs,
207
+ });
208
+ return createHash('sha256').update(content).digest('hex');
209
+ }
210
+ /**
211
+ * Get execution plan file path for a task
212
+ */
213
+ export function getExecutionPlanPath(projectRoot, taskId) {
214
+ return join(getExecutionsDir(projectRoot), `${taskId}.json`);
215
+ }
216
+ /**
217
+ * Write execution plan to file
218
+ */
219
+ export async function writeExecutionPlan(projectRoot, plan) {
220
+ const execPath = getExecutionPlanPath(projectRoot, plan.task_id);
221
+ const execDir = dirname(execPath);
222
+ await fs.mkdir(execDir, { recursive: true });
223
+ await fs.writeFile(execPath, JSON.stringify(plan, null, 2), 'utf-8');
224
+ return execPath;
225
+ }
226
+ /**
227
+ * Read execution plan from file.
228
+ * Verifies the stored content_hash matches the recomputed hash.
229
+ * If the hash does not match, the plan is still returned but a
230
+ * `hashMismatch` flag is set on the result.
231
+ */
232
+ export async function readExecutionPlan(projectRoot, taskId) {
233
+ const execPath = getExecutionPlanPath(projectRoot, taskId);
234
+ try {
235
+ const content = await fs.readFile(execPath, 'utf-8');
236
+ const data = JSON.parse(content);
237
+ const plan = ExecutionPlanSchema.parse(data);
238
+ // Verify content hash integrity
239
+ const recomputed = recomputeContentHash(plan);
240
+ if (recomputed !== plan.content_hash) {
241
+ process.stderr.write(`Warning: execution plan "${taskId}" content_hash mismatch ` +
242
+ `(stored: ${plan.content_hash.slice(0, 12)}…, computed: ${recomputed.slice(0, 12)}…). ` +
243
+ `The plan file may have been tampered with.\n`);
244
+ return { ...plan, hashMismatch: true };
245
+ }
246
+ return plan;
247
+ }
248
+ catch (error) {
249
+ if (error.code === 'ENOENT') {
250
+ return undefined;
251
+ }
252
+ throw new StateError(`Failed to read execution plan: ${error instanceof Error ? error.message : String(error)}`, execPath);
253
+ }
254
+ }
255
+ /**
256
+ * Recompute the content hash for an execution plan using the same
257
+ * fields that `computeTaskHash` uses when creating the plan.
258
+ */
259
+ function recomputeContentHash(plan) {
260
+ const content = JSON.stringify({
261
+ id: plan.task_id,
262
+ title: plan.title,
263
+ intent: plan.intent,
264
+ expectedOutcome: plan.expected_outcome,
265
+ confidence: plan.confidence,
266
+ scopes: plan.scopes,
267
+ tags: plan.tags,
268
+ dependencies: plan.dependencies,
269
+ inputs: plan.inputs,
270
+ });
271
+ return createHash('sha256').update(content).digest('hex');
272
+ }
273
+ /**
274
+ * Delete execution plan file
275
+ */
276
+ export async function deleteExecutionPlan(projectRoot, taskId) {
277
+ const execPath = getExecutionPlanPath(projectRoot, taskId);
278
+ try {
279
+ await fs.unlink(execPath);
280
+ }
281
+ catch (error) {
282
+ if (error.code !== 'ENOENT') {
283
+ throw new StateError(`Failed to delete execution plan: ${error instanceof Error ? error.message : String(error)}`, execPath);
284
+ }
285
+ }
286
+ }
287
+ // ============================================================================
288
+ // Provenance Helpers
289
+ // ============================================================================
290
+ /**
291
+ * Get current user name
292
+ */
293
+ export function getCurrentUser() {
294
+ return process.env['USER'] || process.env['USERNAME'] || 'unknown';
295
+ }
296
+ /**
297
+ * Get git commit hash (if in a git repo)
298
+ */
299
+ export function getGitCommit(projectRoot) {
300
+ try {
301
+ return execSync('git rev-parse HEAD', {
302
+ cwd: projectRoot,
303
+ encoding: 'utf-8',
304
+ stdio: ['pipe', 'pipe', 'pipe'],
305
+ }).trim();
306
+ }
307
+ catch {
308
+ return undefined;
309
+ }
310
+ }
311
+ /**
312
+ * Get git branch name (if in a git repo)
313
+ */
314
+ export function getGitBranch(projectRoot) {
315
+ try {
316
+ return execSync('git rev-parse --abbrev-ref HEAD', {
317
+ cwd: projectRoot,
318
+ encoding: 'utf-8',
319
+ stdio: ['pipe', 'pipe', 'pipe'],
320
+ }).trim();
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ /**
327
+ * Create provenance info for a task
328
+ */
329
+ export function createProvenance(task, projectRoot, user) {
330
+ return {
331
+ locked_by: user ?? getCurrentUser(),
332
+ locked_at: new Date().toISOString(),
333
+ git_commit: getGitCommit(projectRoot),
334
+ git_branch: getGitBranch(projectRoot),
335
+ source_file: task.sourcePath ?? 'unknown',
336
+ source_line: task.sourceLineNumber,
337
+ };
338
+ }
339
+ // ============================================================================
340
+ // TaskLocker
341
+ // ============================================================================
342
+ /**
343
+ * Error thrown by state operations
344
+ */
345
+ export class StateError extends Error {
346
+ path;
347
+ taskId;
348
+ constructor(message, path, taskId) {
349
+ super(message);
350
+ this.path = path;
351
+ this.taskId = taskId;
352
+ this.name = 'StateError';
353
+ }
354
+ }
355
+ /**
356
+ * TaskLocker - Manages task locking for execution
357
+ *
358
+ * @example
359
+ * ```typescript
360
+ * const locker = new TaskLocker({
361
+ * projectRoot: '/path/to/project',
362
+ * planPath: 'docs/planning/APS.md',
363
+ * });
364
+ *
365
+ * // Lock a task
366
+ * const result = await locker.lock('AUTH-001');
367
+ * if (result.success) {
368
+ * console.log(`Task locked, execution plan: ${result.executionPlanPath}`);
369
+ * }
370
+ *
371
+ * // Check status
372
+ * const status = await locker.getStatus('AUTH-001');
373
+ *
374
+ * // Unlock (cancel) a task
375
+ * await locker.unlock('AUTH-001');
376
+ * ```
377
+ */
378
+ export class TaskLocker {
379
+ projectRoot;
380
+ planPath;
381
+ user;
382
+ skipValidation;
383
+ plan = null;
384
+ constructor(options) {
385
+ this.projectRoot = isAbsolute(options.projectRoot)
386
+ ? options.projectRoot
387
+ : resolve(options.projectRoot);
388
+ this.planPath = isAbsolute(options.planPath)
389
+ ? options.planPath
390
+ : resolve(this.projectRoot, options.planPath);
391
+ this.user = options.user ?? getCurrentUser();
392
+ this.skipValidation = options.skipValidation ?? false;
393
+ }
394
+ /**
395
+ * Load the plan (validates first unless skipValidation is true)
396
+ */
397
+ async loadPlan() {
398
+ if (this.plan) {
399
+ return this.plan;
400
+ }
401
+ // Validate first
402
+ if (!this.skipValidation) {
403
+ const validationResult = await validatePlanningDoc(this.planPath);
404
+ if (!validationResult.valid) {
405
+ const errorMessages = validationResult.errors
406
+ .map((e) => `${e.path ?? ''}${e.lineNumber ? `:${e.lineNumber}` : ''}: ${e.message}`)
407
+ .join('\n');
408
+ throw new StateError(`Planning document validation failed:\n${errorMessages}`, this.planPath);
409
+ }
410
+ }
411
+ this.plan = await loadPlan(this.planPath);
412
+ return this.plan;
413
+ }
414
+ /**
415
+ * Find a task by ID in the loaded plan
416
+ */
417
+ async findTask(taskId) {
418
+ const plan = await this.loadPlan();
419
+ return plan.allTasks.find((t) => t.id === taskId);
420
+ }
421
+ /**
422
+ * Lock a task for execution
423
+ *
424
+ * - Validates planning doc first (unless skipValidation)
425
+ * - Snapshots task definition
426
+ * - Generates execution plan JSON with hash and provenance
427
+ * - Updates state.json
428
+ * - First lock wins (fails if already locked)
429
+ */
430
+ async lock(taskId) {
431
+ try {
432
+ // Check if task exists
433
+ const task = await this.findTask(taskId);
434
+ if (!task) {
435
+ return {
436
+ success: false,
437
+ taskId,
438
+ error: `Task "${taskId}" not found in planning document`,
439
+ };
440
+ }
441
+ // Check current state (first lock wins)
442
+ const currentState = await getTaskState(this.projectRoot, taskId);
443
+ if (currentState?.status === 'locked') {
444
+ return {
445
+ success: false,
446
+ taskId,
447
+ error: `Task "${taskId}" is already locked by ${currentState.locked_by} at ${currentState.locked_at}`,
448
+ };
449
+ }
450
+ // Create provenance and execution plan
451
+ const provenance = createProvenance(task, this.projectRoot, this.user);
452
+ const executionPlan = createExecutionPlan(task, provenance);
453
+ // Write execution plan
454
+ const execPath = await writeExecutionPlan(this.projectRoot, executionPlan);
455
+ // Update state
456
+ const relativeExecPath = `.anvil/executions/${taskId}.json`;
457
+ const taskState = {
458
+ status: 'locked',
459
+ locked_at: provenance.locked_at,
460
+ locked_by: provenance.locked_by,
461
+ execution_file: relativeExecPath,
462
+ source: task.sourcePath
463
+ ? {
464
+ file: task.sourcePath,
465
+ line: task.sourceLineNumber,
466
+ }
467
+ : undefined,
468
+ };
469
+ await updateTaskState(this.projectRoot, taskId, taskState);
470
+ return {
471
+ success: true,
472
+ taskId,
473
+ executionPlanPath: execPath,
474
+ };
475
+ }
476
+ catch (error) {
477
+ return {
478
+ success: false,
479
+ taskId,
480
+ error: error instanceof Error ? error.message : String(error),
481
+ };
482
+ }
483
+ }
484
+ /**
485
+ * Unlock (cancel) a locked task
486
+ *
487
+ * - Moves task to 'cancelled' status
488
+ * - Removes execution plan file
489
+ */
490
+ async unlock(taskId) {
491
+ try {
492
+ const currentState = await getTaskState(this.projectRoot, taskId);
493
+ if (!currentState) {
494
+ return {
495
+ success: false,
496
+ taskId,
497
+ error: `Task "${taskId}" has no state record`,
498
+ };
499
+ }
500
+ if (currentState.status !== 'locked') {
501
+ return {
502
+ success: false,
503
+ taskId,
504
+ previousStatus: currentState.status,
505
+ error: `Task "${taskId}" is not locked (current status: ${currentState.status})`,
506
+ };
507
+ }
508
+ // Delete execution plan file
509
+ await deleteExecutionPlan(this.projectRoot, taskId);
510
+ // Update state to cancelled
511
+ const taskState = {
512
+ status: 'cancelled',
513
+ cancelled_at: new Date().toISOString(),
514
+ source: currentState.source,
515
+ };
516
+ await updateTaskState(this.projectRoot, taskId, taskState);
517
+ return {
518
+ success: true,
519
+ taskId,
520
+ previousStatus: 'locked',
521
+ };
522
+ }
523
+ catch (error) {
524
+ return {
525
+ success: false,
526
+ taskId,
527
+ error: error instanceof Error ? error.message : String(error),
528
+ };
529
+ }
530
+ }
531
+ /**
532
+ * Mark a task as completed
533
+ */
534
+ async complete(taskId) {
535
+ try {
536
+ const currentState = await getTaskState(this.projectRoot, taskId);
537
+ if (!currentState) {
538
+ return {
539
+ success: false,
540
+ taskId,
541
+ error: `Task "${taskId}" has no state record`,
542
+ };
543
+ }
544
+ if (currentState.status !== 'locked') {
545
+ return {
546
+ success: false,
547
+ taskId,
548
+ previousStatus: currentState.status,
549
+ error: `Task "${taskId}" is not locked (current status: ${currentState.status})`,
550
+ };
551
+ }
552
+ // Update state to completed (keep execution plan for audit)
553
+ const taskState = {
554
+ ...currentState,
555
+ status: 'completed',
556
+ completed_at: new Date().toISOString(),
557
+ };
558
+ await updateTaskState(this.projectRoot, taskId, taskState);
559
+ return {
560
+ success: true,
561
+ taskId,
562
+ previousStatus: 'locked',
563
+ };
564
+ }
565
+ catch (error) {
566
+ return {
567
+ success: false,
568
+ taskId,
569
+ error: error instanceof Error ? error.message : String(error),
570
+ };
571
+ }
572
+ }
573
+ /**
574
+ * Get the status of a specific task
575
+ */
576
+ async getStatus(taskId) {
577
+ const state = await getTaskState(this.projectRoot, taskId);
578
+ if (!state) {
579
+ // Task exists in plan but no state record - it's open
580
+ const task = await this.findTask(taskId);
581
+ if (task) {
582
+ return {
583
+ taskId,
584
+ status: 'open',
585
+ source: task.sourcePath
586
+ ? {
587
+ file: task.sourcePath,
588
+ line: task.sourceLineNumber,
589
+ }
590
+ : undefined,
591
+ };
592
+ }
593
+ return undefined;
594
+ }
595
+ return {
596
+ taskId,
597
+ status: state.status,
598
+ lockedAt: state.locked_at,
599
+ lockedBy: state.locked_by,
600
+ executionFile: state.execution_file,
601
+ source: state.source,
602
+ completedAt: state.completed_at,
603
+ cancelledAt: state.cancelled_at,
604
+ };
605
+ }
606
+ /**
607
+ * Get status of all tasks in the plan
608
+ */
609
+ async getAllStatus() {
610
+ const plan = await this.loadPlan();
611
+ const state = await readStateFile(this.projectRoot);
612
+ const result = [];
613
+ for (const task of plan.allTasks) {
614
+ const taskState = state.tasks[task.id];
615
+ if (taskState) {
616
+ result.push({
617
+ taskId: task.id,
618
+ status: taskState.status,
619
+ lockedAt: taskState.locked_at,
620
+ lockedBy: taskState.locked_by,
621
+ executionFile: taskState.execution_file,
622
+ source: taskState.source,
623
+ completedAt: taskState.completed_at,
624
+ cancelledAt: taskState.cancelled_at,
625
+ });
626
+ }
627
+ else {
628
+ result.push({
629
+ taskId: task.id,
630
+ status: 'open',
631
+ source: task.sourcePath
632
+ ? {
633
+ file: task.sourcePath,
634
+ line: task.sourceLineNumber,
635
+ }
636
+ : undefined,
637
+ });
638
+ }
639
+ }
640
+ return result;
641
+ }
642
+ /**
643
+ * Get summary of task statuses
644
+ */
645
+ async getStatusSummary() {
646
+ const allStatus = await this.getAllStatus();
647
+ const summary = {
648
+ open: 0,
649
+ locked: 0,
650
+ completed: 0,
651
+ cancelled: 0,
652
+ };
653
+ for (const status of allStatus) {
654
+ summary[status.status]++;
655
+ }
656
+ return summary;
657
+ }
658
+ }
659
+ /**
660
+ * Format task status for display
661
+ */
662
+ export function formatTaskStatus(status) {
663
+ const parts = [`${status.taskId}: ${status.status}`];
664
+ if (status.lockedBy && status.lockedAt) {
665
+ parts.push(` Locked by: ${status.lockedBy} at ${status.lockedAt}`);
666
+ }
667
+ if (status.completedAt) {
668
+ parts.push(` Completed: ${status.completedAt}`);
669
+ }
670
+ if (status.cancelledAt) {
671
+ parts.push(` Cancelled: ${status.cancelledAt}`);
672
+ }
673
+ if (status.source) {
674
+ const loc = status.source.line
675
+ ? `${status.source.file}:${status.source.line}`
676
+ : status.source.file;
677
+ parts.push(` Source: ${loc}`);
678
+ }
679
+ return parts.join('\n');
680
+ }
681
+ /**
682
+ * Format all task statuses for display
683
+ */
684
+ export function formatAllTaskStatus(statuses) {
685
+ if (statuses.length === 0) {
686
+ return 'No tasks found.';
687
+ }
688
+ return statuses.map(formatTaskStatus).join('\n\n');
689
+ }