@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,879 @@
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
+
11
+ import { promises as fs } from 'node:fs';
12
+ import { dirname, join, isAbsolute, resolve } from 'node:path';
13
+ import { createHash } from 'node:crypto';
14
+ import { execSync } from 'node:child_process';
15
+ import { z } from 'zod';
16
+ import { TaskStatusSchema, type Task, type TaskStatus } from '../types/index.js';
17
+ import { loadPlan, type LoadedPlan } from '../loader/index.js';
18
+ import { validatePlanningDoc } from '../validator/index.js';
19
+
20
+ // ============================================================================
21
+ // Schemas
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Source location of a task in a planning document
26
+ */
27
+ export const TaskSourceSchema = z.object({
28
+ /** File path relative to project root */
29
+ file: z.string(),
30
+
31
+ /** Line number where task starts (1-based) */
32
+ line: z.number().optional(),
33
+ });
34
+
35
+ export type TaskSource = z.infer<typeof TaskSourceSchema>;
36
+
37
+ /**
38
+ * State of a single task
39
+ */
40
+ export const TaskStateSchema = z.object({
41
+ /** Current status */
42
+ status: TaskStatusSchema,
43
+
44
+ /** ISO timestamp when task was locked */
45
+ locked_at: z.string().optional(),
46
+
47
+ /** User who locked the task */
48
+ locked_by: z.string().optional(),
49
+
50
+ /** Path to execution plan JSON file */
51
+ execution_file: z.string().optional(),
52
+
53
+ /** Source location in planning doc */
54
+ source: TaskSourceSchema.optional(),
55
+
56
+ /** ISO timestamp when task was completed */
57
+ completed_at: z.string().optional(),
58
+
59
+ /** ISO timestamp when task was cancelled */
60
+ cancelled_at: z.string().optional(),
61
+ });
62
+
63
+ export type TaskState = z.infer<typeof TaskStateSchema>;
64
+
65
+ /**
66
+ * Full state file schema (.anvil/state.json)
67
+ */
68
+ export const StateFileSchema = z.object({
69
+ /** Schema version */
70
+ version: z.string().default('1.0.0'),
71
+
72
+ /** Map of task ID to task state */
73
+ tasks: z.record(z.string(), TaskStateSchema),
74
+ });
75
+
76
+ export type StateFile = z.infer<typeof StateFileSchema>;
77
+
78
+ /**
79
+ * Provenance information for execution plans
80
+ */
81
+ export const ProvenanceSchema = z.object({
82
+ /** User who locked the task */
83
+ locked_by: z.string(),
84
+
85
+ /** ISO timestamp when locked */
86
+ locked_at: z.string(),
87
+
88
+ /** Git commit hash (if available) */
89
+ git_commit: z.string().optional(),
90
+
91
+ /** Git branch (if available) */
92
+ git_branch: z.string().optional(),
93
+
94
+ /** Planning doc file path */
95
+ source_file: z.string(),
96
+
97
+ /** Line number in planning doc */
98
+ source_line: z.number().optional(),
99
+ });
100
+
101
+ export type Provenance = z.infer<typeof ProvenanceSchema>;
102
+
103
+ /**
104
+ * Execution plan JSON schema (per-task, written to .anvil/executions/)
105
+ */
106
+ export const ExecutionPlanSchema = z.object({
107
+ /** Schema version */
108
+ version: z.string().default('1.0.0'),
109
+
110
+ /** Task ID */
111
+ task_id: z.string(),
112
+
113
+ /** Task title */
114
+ title: z.string(),
115
+
116
+ /** Task intent */
117
+ intent: z.string(),
118
+
119
+ /** Expected outcome */
120
+ expected_outcome: z.string().optional(),
121
+
122
+ /** Confidence level */
123
+ confidence: z.enum(['low', 'medium', 'high']),
124
+
125
+ /** Task scopes */
126
+ scopes: z.array(z.string()).optional(),
127
+
128
+ /** Task tags */
129
+ tags: z.array(z.string()).optional(),
130
+
131
+ /** Task dependencies */
132
+ dependencies: z.array(z.string()).optional(),
133
+
134
+ /** Task inputs */
135
+ inputs: z.array(z.string()).optional(),
136
+
137
+ /** SHA-256 hash of task content */
138
+ content_hash: z.string(),
139
+
140
+ /** Provenance information */
141
+ provenance: ProvenanceSchema,
142
+ });
143
+
144
+ export type ExecutionPlan = z.infer<typeof ExecutionPlanSchema>;
145
+
146
+ // ============================================================================
147
+ // State File Operations
148
+ // ============================================================================
149
+
150
+ const STATE_FILE_NAME = 'state.json';
151
+ const EXECUTIONS_DIR = 'executions';
152
+ const ANVIL_DIR = '.anvil';
153
+
154
+ /**
155
+ * Get the path to the state file
156
+ */
157
+ export function getStateFilePath(projectRoot: string): string {
158
+ return join(projectRoot, ANVIL_DIR, STATE_FILE_NAME);
159
+ }
160
+
161
+ /**
162
+ * Get the path to the executions directory
163
+ */
164
+ export function getExecutionsDir(projectRoot: string): string {
165
+ return join(projectRoot, ANVIL_DIR, EXECUTIONS_DIR);
166
+ }
167
+
168
+ /**
169
+ * Read the state file, returning empty state if it doesn't exist
170
+ */
171
+ export async function readStateFile(projectRoot: string): Promise<StateFile> {
172
+ const statePath = getStateFilePath(projectRoot);
173
+
174
+ try {
175
+ const content = await fs.readFile(statePath, 'utf-8');
176
+ const data = JSON.parse(content);
177
+ return StateFileSchema.parse(data);
178
+ } catch (error) {
179
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
180
+ return { version: '1.0.0', tasks: {} };
181
+ }
182
+ throw new StateError(
183
+ `Failed to read state file: ${error instanceof Error ? error.message : String(error)}`,
184
+ statePath
185
+ );
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Write the state file, creating directories if needed
191
+ */
192
+ export async function writeStateFile(projectRoot: string, state: StateFile): Promise<void> {
193
+ const statePath = getStateFilePath(projectRoot);
194
+ const stateDir = dirname(statePath);
195
+
196
+ try {
197
+ await fs.mkdir(stateDir, { recursive: true });
198
+ const content = JSON.stringify(state, null, 2);
199
+ await fs.writeFile(statePath, content, 'utf-8');
200
+ } catch (error) {
201
+ throw new StateError(
202
+ `Failed to write state file: ${error instanceof Error ? error.message : String(error)}`,
203
+ statePath
204
+ );
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get the state of a specific task
210
+ */
211
+ export async function getTaskState(
212
+ projectRoot: string,
213
+ taskId: string
214
+ ): Promise<TaskState | undefined> {
215
+ const state = await readStateFile(projectRoot);
216
+ return state.tasks[taskId];
217
+ }
218
+
219
+ /**
220
+ * Update the state of a specific task
221
+ */
222
+ export async function updateTaskState(
223
+ projectRoot: string,
224
+ taskId: string,
225
+ taskState: TaskState
226
+ ): Promise<void> {
227
+ const state = await readStateFile(projectRoot);
228
+ state.tasks[taskId] = taskState;
229
+ await writeStateFile(projectRoot, state);
230
+ }
231
+
232
+ // ============================================================================
233
+ // Execution Plan Operations
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Generate execution plan JSON for a task
238
+ */
239
+ export function createExecutionPlan(task: Task, provenance: Provenance): ExecutionPlan {
240
+ // Compute content hash from task fields
241
+ const contentHash = computeTaskHash(task);
242
+
243
+ return {
244
+ version: '1.0.0',
245
+ task_id: task.id,
246
+ title: task.title,
247
+ intent: task.intent,
248
+ expected_outcome: task.expectedOutcome,
249
+ confidence: task.confidence,
250
+ scopes: task.scopes,
251
+ tags: task.tags,
252
+ dependencies: task.dependencies,
253
+ inputs: task.inputs,
254
+ content_hash: contentHash,
255
+ provenance,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Compute SHA-256 hash of task content
261
+ */
262
+ export function computeTaskHash(task: Task): string {
263
+ const content = JSON.stringify({
264
+ id: task.id,
265
+ title: task.title,
266
+ intent: task.intent,
267
+ expectedOutcome: task.expectedOutcome,
268
+ confidence: task.confidence,
269
+ scopes: task.scopes,
270
+ tags: task.tags,
271
+ dependencies: task.dependencies,
272
+ inputs: task.inputs,
273
+ });
274
+
275
+ return createHash('sha256').update(content).digest('hex');
276
+ }
277
+
278
+ /**
279
+ * Get execution plan file path for a task
280
+ */
281
+ export function getExecutionPlanPath(projectRoot: string, taskId: string): string {
282
+ return join(getExecutionsDir(projectRoot), `${taskId}.json`);
283
+ }
284
+
285
+ /**
286
+ * Write execution plan to file
287
+ */
288
+ export async function writeExecutionPlan(
289
+ projectRoot: string,
290
+ plan: ExecutionPlan
291
+ ): Promise<string> {
292
+ const execPath = getExecutionPlanPath(projectRoot, plan.task_id);
293
+ const execDir = dirname(execPath);
294
+
295
+ await fs.mkdir(execDir, { recursive: true });
296
+ await fs.writeFile(execPath, JSON.stringify(plan, null, 2), 'utf-8');
297
+
298
+ return execPath;
299
+ }
300
+
301
+ /**
302
+ * Read execution plan from file.
303
+ * Verifies the stored content_hash matches the recomputed hash.
304
+ * If the hash does not match, the plan is still returned but a
305
+ * `hashMismatch` flag is set on the result.
306
+ */
307
+ export async function readExecutionPlan(
308
+ projectRoot: string,
309
+ taskId: string
310
+ ): Promise<(ExecutionPlan & { hashMismatch?: boolean }) | undefined> {
311
+ const execPath = getExecutionPlanPath(projectRoot, taskId);
312
+
313
+ try {
314
+ const content = await fs.readFile(execPath, 'utf-8');
315
+ const data = JSON.parse(content);
316
+ const plan = ExecutionPlanSchema.parse(data);
317
+
318
+ // Verify content hash integrity
319
+ const recomputed = recomputeContentHash(plan);
320
+ if (recomputed !== plan.content_hash) {
321
+ process.stderr.write(
322
+ `Warning: execution plan "${taskId}" content_hash mismatch ` +
323
+ `(stored: ${plan.content_hash.slice(0, 12)}…, computed: ${recomputed.slice(0, 12)}…). ` +
324
+ `The plan file may have been tampered with.\n`
325
+ );
326
+ return { ...plan, hashMismatch: true };
327
+ }
328
+
329
+ return plan;
330
+ } catch (error) {
331
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
332
+ return undefined;
333
+ }
334
+ throw new StateError(
335
+ `Failed to read execution plan: ${error instanceof Error ? error.message : String(error)}`,
336
+ execPath
337
+ );
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Recompute the content hash for an execution plan using the same
343
+ * fields that `computeTaskHash` uses when creating the plan.
344
+ */
345
+ function recomputeContentHash(plan: ExecutionPlan): string {
346
+ const content = JSON.stringify({
347
+ id: plan.task_id,
348
+ title: plan.title,
349
+ intent: plan.intent,
350
+ expectedOutcome: plan.expected_outcome,
351
+ confidence: plan.confidence,
352
+ scopes: plan.scopes,
353
+ tags: plan.tags,
354
+ dependencies: plan.dependencies,
355
+ inputs: plan.inputs,
356
+ });
357
+
358
+ return createHash('sha256').update(content).digest('hex');
359
+ }
360
+
361
+ /**
362
+ * Delete execution plan file
363
+ */
364
+ export async function deleteExecutionPlan(projectRoot: string, taskId: string): Promise<void> {
365
+ const execPath = getExecutionPlanPath(projectRoot, taskId);
366
+
367
+ try {
368
+ await fs.unlink(execPath);
369
+ } catch (error) {
370
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
371
+ throw new StateError(
372
+ `Failed to delete execution plan: ${error instanceof Error ? error.message : String(error)}`,
373
+ execPath
374
+ );
375
+ }
376
+ }
377
+ }
378
+
379
+ // ============================================================================
380
+ // Provenance Helpers
381
+ // ============================================================================
382
+
383
+ /**
384
+ * Get current user name
385
+ */
386
+ export function getCurrentUser(): string {
387
+ return process.env['USER'] || process.env['USERNAME'] || 'unknown';
388
+ }
389
+
390
+ /**
391
+ * Get git commit hash (if in a git repo)
392
+ */
393
+ export function getGitCommit(projectRoot: string): string | undefined {
394
+ try {
395
+ return execSync('git rev-parse HEAD', {
396
+ cwd: projectRoot,
397
+ encoding: 'utf-8',
398
+ stdio: ['pipe', 'pipe', 'pipe'],
399
+ }).trim();
400
+ } catch {
401
+ return undefined;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Get git branch name (if in a git repo)
407
+ */
408
+ export function getGitBranch(projectRoot: string): string | undefined {
409
+ try {
410
+ return execSync('git rev-parse --abbrev-ref HEAD', {
411
+ cwd: projectRoot,
412
+ encoding: 'utf-8',
413
+ stdio: ['pipe', 'pipe', 'pipe'],
414
+ }).trim();
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Create provenance info for a task
422
+ */
423
+ export function createProvenance(task: Task, projectRoot: string, user?: string): Provenance {
424
+ return {
425
+ locked_by: user ?? getCurrentUser(),
426
+ locked_at: new Date().toISOString(),
427
+ git_commit: getGitCommit(projectRoot),
428
+ git_branch: getGitBranch(projectRoot),
429
+ source_file: task.sourcePath ?? 'unknown',
430
+ source_line: task.sourceLineNumber,
431
+ };
432
+ }
433
+
434
+ // ============================================================================
435
+ // TaskLocker
436
+ // ============================================================================
437
+
438
+ /**
439
+ * Error thrown by state operations
440
+ */
441
+ export class StateError extends Error {
442
+ constructor(
443
+ message: string,
444
+ public readonly path?: string,
445
+ public readonly taskId?: string
446
+ ) {
447
+ super(message);
448
+ this.name = 'StateError';
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Result of a lock operation
454
+ */
455
+ export interface LockResult {
456
+ success: boolean;
457
+ taskId: string;
458
+ executionPlanPath?: string;
459
+ error?: string;
460
+ }
461
+
462
+ /**
463
+ * Result of an unlock operation
464
+ */
465
+ export interface UnlockResult {
466
+ success: boolean;
467
+ taskId: string;
468
+ previousStatus?: TaskStatus;
469
+ error?: string;
470
+ }
471
+
472
+ /**
473
+ * Task status information
474
+ */
475
+ export interface TaskStatusInfo {
476
+ taskId: string;
477
+ status: TaskStatus;
478
+ lockedAt?: string;
479
+ lockedBy?: string;
480
+ executionFile?: string;
481
+ source?: TaskSource;
482
+ completedAt?: string;
483
+ cancelledAt?: string;
484
+ }
485
+
486
+ /**
487
+ * Options for TaskLocker
488
+ */
489
+ export interface TaskLockerOptions {
490
+ /** Project root directory */
491
+ projectRoot: string;
492
+
493
+ /** Path to the planning document */
494
+ planPath: string;
495
+
496
+ /** User name for provenance (defaults to current user) */
497
+ user?: string;
498
+
499
+ /** Skip validation before locking */
500
+ skipValidation?: boolean;
501
+ }
502
+
503
+ /**
504
+ * TaskLocker - Manages task locking for execution
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const locker = new TaskLocker({
509
+ * projectRoot: '/path/to/project',
510
+ * planPath: 'docs/planning/APS.md',
511
+ * });
512
+ *
513
+ * // Lock a task
514
+ * const result = await locker.lock('AUTH-001');
515
+ * if (result.success) {
516
+ * console.log(`Task locked, execution plan: ${result.executionPlanPath}`);
517
+ * }
518
+ *
519
+ * // Check status
520
+ * const status = await locker.getStatus('AUTH-001');
521
+ *
522
+ * // Unlock (cancel) a task
523
+ * await locker.unlock('AUTH-001');
524
+ * ```
525
+ */
526
+ export class TaskLocker {
527
+ private projectRoot: string;
528
+ private planPath: string;
529
+ private user: string;
530
+ private skipValidation: boolean;
531
+ private plan: LoadedPlan | null = null;
532
+
533
+ constructor(options: TaskLockerOptions) {
534
+ this.projectRoot = isAbsolute(options.projectRoot)
535
+ ? options.projectRoot
536
+ : resolve(options.projectRoot);
537
+ this.planPath = isAbsolute(options.planPath)
538
+ ? options.planPath
539
+ : resolve(this.projectRoot, options.planPath);
540
+ this.user = options.user ?? getCurrentUser();
541
+ this.skipValidation = options.skipValidation ?? false;
542
+ }
543
+
544
+ /**
545
+ * Load the plan (validates first unless skipValidation is true)
546
+ */
547
+ private async loadPlan(): Promise<LoadedPlan> {
548
+ if (this.plan) {
549
+ return this.plan;
550
+ }
551
+
552
+ // Validate first
553
+ if (!this.skipValidation) {
554
+ const validationResult = await validatePlanningDoc(this.planPath);
555
+ if (!validationResult.valid) {
556
+ const errorMessages = validationResult.errors
557
+ .map((e) => `${e.path ?? ''}${e.lineNumber ? `:${e.lineNumber}` : ''}: ${e.message}`)
558
+ .join('\n');
559
+ throw new StateError(
560
+ `Planning document validation failed:\n${errorMessages}`,
561
+ this.planPath
562
+ );
563
+ }
564
+ }
565
+
566
+ this.plan = await loadPlan(this.planPath);
567
+ return this.plan;
568
+ }
569
+
570
+ /**
571
+ * Find a task by ID in the loaded plan
572
+ */
573
+ private async findTask(taskId: string): Promise<Task | undefined> {
574
+ const plan = await this.loadPlan();
575
+ return plan.allTasks.find((t) => t.id === taskId);
576
+ }
577
+
578
+ /**
579
+ * Lock a task for execution
580
+ *
581
+ * - Validates planning doc first (unless skipValidation)
582
+ * - Snapshots task definition
583
+ * - Generates execution plan JSON with hash and provenance
584
+ * - Updates state.json
585
+ * - First lock wins (fails if already locked)
586
+ */
587
+ async lock(taskId: string): Promise<LockResult> {
588
+ try {
589
+ // Check if task exists
590
+ const task = await this.findTask(taskId);
591
+ if (!task) {
592
+ return {
593
+ success: false,
594
+ taskId,
595
+ error: `Task "${taskId}" not found in planning document`,
596
+ };
597
+ }
598
+
599
+ // Check current state (first lock wins)
600
+ const currentState = await getTaskState(this.projectRoot, taskId);
601
+ if (currentState?.status === 'locked') {
602
+ return {
603
+ success: false,
604
+ taskId,
605
+ error: `Task "${taskId}" is already locked by ${currentState.locked_by} at ${currentState.locked_at}`,
606
+ };
607
+ }
608
+
609
+ // Create provenance and execution plan
610
+ const provenance = createProvenance(task, this.projectRoot, this.user);
611
+ const executionPlan = createExecutionPlan(task, provenance);
612
+
613
+ // Write execution plan
614
+ const execPath = await writeExecutionPlan(this.projectRoot, executionPlan);
615
+
616
+ // Update state
617
+ const relativeExecPath = `.anvil/executions/${taskId}.json`;
618
+ const taskState: TaskState = {
619
+ status: 'locked',
620
+ locked_at: provenance.locked_at,
621
+ locked_by: provenance.locked_by,
622
+ execution_file: relativeExecPath,
623
+ source: task.sourcePath
624
+ ? {
625
+ file: task.sourcePath,
626
+ line: task.sourceLineNumber,
627
+ }
628
+ : undefined,
629
+ };
630
+
631
+ await updateTaskState(this.projectRoot, taskId, taskState);
632
+
633
+ return {
634
+ success: true,
635
+ taskId,
636
+ executionPlanPath: execPath,
637
+ };
638
+ } catch (error) {
639
+ return {
640
+ success: false,
641
+ taskId,
642
+ error: error instanceof Error ? error.message : String(error),
643
+ };
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Unlock (cancel) a locked task
649
+ *
650
+ * - Moves task to 'cancelled' status
651
+ * - Removes execution plan file
652
+ */
653
+ async unlock(taskId: string): Promise<UnlockResult> {
654
+ try {
655
+ const currentState = await getTaskState(this.projectRoot, taskId);
656
+
657
+ if (!currentState) {
658
+ return {
659
+ success: false,
660
+ taskId,
661
+ error: `Task "${taskId}" has no state record`,
662
+ };
663
+ }
664
+
665
+ if (currentState.status !== 'locked') {
666
+ return {
667
+ success: false,
668
+ taskId,
669
+ previousStatus: currentState.status,
670
+ error: `Task "${taskId}" is not locked (current status: ${currentState.status})`,
671
+ };
672
+ }
673
+
674
+ // Delete execution plan file
675
+ await deleteExecutionPlan(this.projectRoot, taskId);
676
+
677
+ // Update state to cancelled
678
+ const taskState: TaskState = {
679
+ status: 'cancelled',
680
+ cancelled_at: new Date().toISOString(),
681
+ source: currentState.source,
682
+ };
683
+
684
+ await updateTaskState(this.projectRoot, taskId, taskState);
685
+
686
+ return {
687
+ success: true,
688
+ taskId,
689
+ previousStatus: 'locked',
690
+ };
691
+ } catch (error) {
692
+ return {
693
+ success: false,
694
+ taskId,
695
+ error: error instanceof Error ? error.message : String(error),
696
+ };
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Mark a task as completed
702
+ */
703
+ async complete(taskId: string): Promise<UnlockResult> {
704
+ try {
705
+ const currentState = await getTaskState(this.projectRoot, taskId);
706
+
707
+ if (!currentState) {
708
+ return {
709
+ success: false,
710
+ taskId,
711
+ error: `Task "${taskId}" has no state record`,
712
+ };
713
+ }
714
+
715
+ if (currentState.status !== 'locked') {
716
+ return {
717
+ success: false,
718
+ taskId,
719
+ previousStatus: currentState.status,
720
+ error: `Task "${taskId}" is not locked (current status: ${currentState.status})`,
721
+ };
722
+ }
723
+
724
+ // Update state to completed (keep execution plan for audit)
725
+ const taskState: TaskState = {
726
+ ...currentState,
727
+ status: 'completed',
728
+ completed_at: new Date().toISOString(),
729
+ };
730
+
731
+ await updateTaskState(this.projectRoot, taskId, taskState);
732
+
733
+ return {
734
+ success: true,
735
+ taskId,
736
+ previousStatus: 'locked',
737
+ };
738
+ } catch (error) {
739
+ return {
740
+ success: false,
741
+ taskId,
742
+ error: error instanceof Error ? error.message : String(error),
743
+ };
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Get the status of a specific task
749
+ */
750
+ async getStatus(taskId: string): Promise<TaskStatusInfo | undefined> {
751
+ const state = await getTaskState(this.projectRoot, taskId);
752
+
753
+ if (!state) {
754
+ // Task exists in plan but no state record - it's open
755
+ const task = await this.findTask(taskId);
756
+ if (task) {
757
+ return {
758
+ taskId,
759
+ status: 'open',
760
+ source: task.sourcePath
761
+ ? {
762
+ file: task.sourcePath,
763
+ line: task.sourceLineNumber,
764
+ }
765
+ : undefined,
766
+ };
767
+ }
768
+ return undefined;
769
+ }
770
+
771
+ return {
772
+ taskId,
773
+ status: state.status,
774
+ lockedAt: state.locked_at,
775
+ lockedBy: state.locked_by,
776
+ executionFile: state.execution_file,
777
+ source: state.source,
778
+ completedAt: state.completed_at,
779
+ cancelledAt: state.cancelled_at,
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Get status of all tasks in the plan
785
+ */
786
+ async getAllStatus(): Promise<TaskStatusInfo[]> {
787
+ const plan = await this.loadPlan();
788
+ const state = await readStateFile(this.projectRoot);
789
+ const result: TaskStatusInfo[] = [];
790
+
791
+ for (const task of plan.allTasks) {
792
+ const taskState = state.tasks[task.id];
793
+
794
+ if (taskState) {
795
+ result.push({
796
+ taskId: task.id,
797
+ status: taskState.status,
798
+ lockedAt: taskState.locked_at,
799
+ lockedBy: taskState.locked_by,
800
+ executionFile: taskState.execution_file,
801
+ source: taskState.source,
802
+ completedAt: taskState.completed_at,
803
+ cancelledAt: taskState.cancelled_at,
804
+ });
805
+ } else {
806
+ result.push({
807
+ taskId: task.id,
808
+ status: 'open',
809
+ source: task.sourcePath
810
+ ? {
811
+ file: task.sourcePath,
812
+ line: task.sourceLineNumber,
813
+ }
814
+ : undefined,
815
+ });
816
+ }
817
+ }
818
+
819
+ return result;
820
+ }
821
+
822
+ /**
823
+ * Get summary of task statuses
824
+ */
825
+ async getStatusSummary(): Promise<Record<TaskStatus, number>> {
826
+ const allStatus = await this.getAllStatus();
827
+ const summary: Record<TaskStatus, number> = {
828
+ open: 0,
829
+ locked: 0,
830
+ completed: 0,
831
+ cancelled: 0,
832
+ };
833
+
834
+ for (const status of allStatus) {
835
+ summary[status.status]++;
836
+ }
837
+
838
+ return summary;
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Format task status for display
844
+ */
845
+ export function formatTaskStatus(status: TaskStatusInfo): string {
846
+ const parts = [`${status.taskId}: ${status.status}`];
847
+
848
+ if (status.lockedBy && status.lockedAt) {
849
+ parts.push(` Locked by: ${status.lockedBy} at ${status.lockedAt}`);
850
+ }
851
+
852
+ if (status.completedAt) {
853
+ parts.push(` Completed: ${status.completedAt}`);
854
+ }
855
+
856
+ if (status.cancelledAt) {
857
+ parts.push(` Cancelled: ${status.cancelledAt}`);
858
+ }
859
+
860
+ if (status.source) {
861
+ const loc = status.source.line
862
+ ? `${status.source.file}:${status.source.line}`
863
+ : status.source.file;
864
+ parts.push(` Source: ${loc}`);
865
+ }
866
+
867
+ return parts.join('\n');
868
+ }
869
+
870
+ /**
871
+ * Format all task statuses for display
872
+ */
873
+ export function formatAllTaskStatus(statuses: TaskStatusInfo[]): string {
874
+ if (statuses.length === 0) {
875
+ return 'No tasks found.';
876
+ }
877
+
878
+ return statuses.map(formatTaskStatus).join('\n\n');
879
+ }