@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,645 @@
1
+ /**
2
+ * State module tests
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { promises as fs } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import {
11
+ readStateFile,
12
+ writeStateFile,
13
+ getTaskState,
14
+ updateTaskState,
15
+ createExecutionPlan,
16
+ computeTaskHash,
17
+ writeExecutionPlan,
18
+ readExecutionPlan,
19
+ deleteExecutionPlan,
20
+ createProvenance,
21
+ getCurrentUser,
22
+ TaskLocker,
23
+ formatTaskStatus,
24
+ formatAllTaskStatus,
25
+ getStateFilePath,
26
+ getExecutionsDir,
27
+ type StateFile,
28
+ type TaskState,
29
+ } from './index.js';
30
+ import type { Task } from '../types/index.js';
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = dirname(__filename);
34
+ const fixturesDir = join(__dirname, '__fixtures__');
35
+
36
+ // Helper to create a temporary directory for tests
37
+ async function createTempDir(): Promise<string> {
38
+ const tempDir = join(
39
+ tmpdir(),
40
+ `aps-state-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
41
+ );
42
+ await fs.mkdir(tempDir, { recursive: true });
43
+ return tempDir;
44
+ }
45
+
46
+ // Helper to clean up temp directory
47
+ async function cleanupTempDir(tempDir: string): Promise<void> {
48
+ try {
49
+ await fs.rm(tempDir, { recursive: true, force: true });
50
+ } catch {
51
+ // Ignore cleanup errors
52
+ }
53
+ }
54
+
55
+ // Sample task for testing
56
+ const sampleTask: Task = {
57
+ id: 'TEST-001',
58
+ title: 'Test task',
59
+ intent: 'Test the task locking system',
60
+ confidence: 'high',
61
+ expectedOutcome: 'Task locks successfully',
62
+ scopes: ['TEST'],
63
+ tags: ['testing'],
64
+ dependencies: [],
65
+ inputs: ['Some input'],
66
+ sourcePath: 'test-plan.aps.md',
67
+ sourceLineNumber: 10,
68
+ };
69
+
70
+ describe('State File Operations', () => {
71
+ let tempDir: string;
72
+
73
+ beforeEach(async () => {
74
+ tempDir = await createTempDir();
75
+ });
76
+
77
+ afterEach(async () => {
78
+ await cleanupTempDir(tempDir);
79
+ });
80
+
81
+ describe('readStateFile', () => {
82
+ it('should return empty state if file does not exist', async () => {
83
+ const state = await readStateFile(tempDir);
84
+
85
+ expect(state.version).toBe('1.0.0');
86
+ expect(state.tasks).toEqual({});
87
+ });
88
+
89
+ it('should read existing state file', async () => {
90
+ const stateData: StateFile = {
91
+ version: '1.0.0',
92
+ tasks: {
93
+ 'TEST-001': {
94
+ status: 'locked',
95
+ locked_at: '2025-12-17T10:00:00.000Z',
96
+ locked_by: 'testuser',
97
+ },
98
+ },
99
+ };
100
+
101
+ await fs.mkdir(join(tempDir, '.anvil'), { recursive: true });
102
+ await fs.writeFile(join(tempDir, '.anvil', 'state.json'), JSON.stringify(stateData), 'utf-8');
103
+
104
+ const state = await readStateFile(tempDir);
105
+
106
+ expect(state.version).toBe('1.0.0');
107
+ expect(state.tasks['TEST-001'].status).toBe('locked');
108
+ expect(state.tasks['TEST-001'].locked_by).toBe('testuser');
109
+ });
110
+ });
111
+
112
+ describe('writeStateFile', () => {
113
+ it('should create directories and write state file', async () => {
114
+ const state: StateFile = {
115
+ version: '1.0.0',
116
+ tasks: {
117
+ 'TEST-001': {
118
+ status: 'locked',
119
+ locked_at: '2025-12-17T10:00:00.000Z',
120
+ locked_by: 'testuser',
121
+ },
122
+ },
123
+ };
124
+
125
+ await writeStateFile(tempDir, state);
126
+
127
+ const content = await fs.readFile(join(tempDir, '.anvil', 'state.json'), 'utf-8');
128
+ const parsed = JSON.parse(content);
129
+
130
+ expect(parsed.version).toBe('1.0.0');
131
+ expect(parsed.tasks['TEST-001'].status).toBe('locked');
132
+ });
133
+ });
134
+
135
+ describe('getTaskState / updateTaskState', () => {
136
+ it('should return undefined for non-existent task', async () => {
137
+ const state = await getTaskState(tempDir, 'NONEXISTENT-001');
138
+ expect(state).toBeUndefined();
139
+ });
140
+
141
+ it('should update and retrieve task state', async () => {
142
+ const taskState: TaskState = {
143
+ status: 'locked',
144
+ locked_at: '2025-12-17T10:00:00.000Z',
145
+ locked_by: 'testuser',
146
+ };
147
+
148
+ await updateTaskState(tempDir, 'TEST-001', taskState);
149
+
150
+ const retrieved = await getTaskState(tempDir, 'TEST-001');
151
+
152
+ expect(retrieved?.status).toBe('locked');
153
+ expect(retrieved?.locked_by).toBe('testuser');
154
+ });
155
+ });
156
+
157
+ describe('getStateFilePath / getExecutionsDir', () => {
158
+ const toFwd = (p: string): string => p.replace(/\\/g, '/');
159
+
160
+ it('should return correct paths', () => {
161
+ const statePath = getStateFilePath('/project');
162
+ const execDir = getExecutionsDir('/project');
163
+
164
+ expect(toFwd(statePath)).toBe('/project/.anvil/state.json');
165
+ expect(toFwd(execDir)).toBe('/project/.anvil/executions');
166
+ });
167
+ });
168
+ });
169
+
170
+ describe('Execution Plan Operations', () => {
171
+ let tempDir: string;
172
+
173
+ beforeEach(async () => {
174
+ tempDir = await createTempDir();
175
+ });
176
+
177
+ afterEach(async () => {
178
+ await cleanupTempDir(tempDir);
179
+ });
180
+
181
+ describe('computeTaskHash', () => {
182
+ it('should compute consistent hash for same task', () => {
183
+ const hash1 = computeTaskHash(sampleTask);
184
+ const hash2 = computeTaskHash(sampleTask);
185
+
186
+ expect(hash1).toBe(hash2);
187
+ expect(hash1).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex
188
+ });
189
+
190
+ it('should compute different hash for different tasks', () => {
191
+ const task2 = { ...sampleTask, intent: 'Different intent' };
192
+
193
+ const hash1 = computeTaskHash(sampleTask);
194
+ const hash2 = computeTaskHash(task2);
195
+
196
+ expect(hash1).not.toBe(hash2);
197
+ });
198
+ });
199
+
200
+ describe('createExecutionPlan', () => {
201
+ it('should create execution plan with all fields', () => {
202
+ const provenance = {
203
+ locked_by: 'testuser',
204
+ locked_at: '2025-12-17T10:00:00.000Z',
205
+ source_file: 'test-plan.aps.md',
206
+ source_line: 10,
207
+ };
208
+
209
+ const plan = createExecutionPlan(sampleTask, provenance);
210
+
211
+ expect(plan.version).toBe('1.0.0');
212
+ expect(plan.task_id).toBe('TEST-001');
213
+ expect(plan.title).toBe('Test task');
214
+ expect(plan.intent).toBe('Test the task locking system');
215
+ expect(plan.confidence).toBe('high');
216
+ expect(plan.content_hash).toMatch(/^[a-f0-9]{64}$/);
217
+ expect(plan.provenance.locked_by).toBe('testuser');
218
+ });
219
+ });
220
+
221
+ describe('writeExecutionPlan / readExecutionPlan', () => {
222
+ it('should write and read execution plan', async () => {
223
+ const provenance = {
224
+ locked_by: 'testuser',
225
+ locked_at: '2025-12-17T10:00:00.000Z',
226
+ source_file: 'test-plan.aps.md',
227
+ };
228
+
229
+ const plan = createExecutionPlan(sampleTask, provenance);
230
+ const writtenPath = await writeExecutionPlan(tempDir, plan);
231
+
232
+ expect(writtenPath).toContain('TEST-001.json');
233
+
234
+ const readPlan = await readExecutionPlan(tempDir, 'TEST-001');
235
+
236
+ expect(readPlan?.task_id).toBe('TEST-001');
237
+ expect(readPlan?.content_hash).toBe(plan.content_hash);
238
+ });
239
+
240
+ it('should return undefined for non-existent plan', async () => {
241
+ const plan = await readExecutionPlan(tempDir, 'NONEXISTENT-001');
242
+ expect(plan).toBeUndefined();
243
+ });
244
+ });
245
+
246
+ describe('deleteExecutionPlan', () => {
247
+ it('should delete execution plan file', async () => {
248
+ const provenance = {
249
+ locked_by: 'testuser',
250
+ locked_at: '2025-12-17T10:00:00.000Z',
251
+ source_file: 'test-plan.aps.md',
252
+ };
253
+
254
+ const plan = createExecutionPlan(sampleTask, provenance);
255
+ await writeExecutionPlan(tempDir, plan);
256
+
257
+ // Verify it exists
258
+ let readPlan = await readExecutionPlan(tempDir, 'TEST-001');
259
+ expect(readPlan).toBeDefined();
260
+
261
+ // Delete it
262
+ await deleteExecutionPlan(tempDir, 'TEST-001');
263
+
264
+ // Verify it's gone
265
+ readPlan = await readExecutionPlan(tempDir, 'TEST-001');
266
+ expect(readPlan).toBeUndefined();
267
+ });
268
+
269
+ it('should not throw for non-existent file', async () => {
270
+ await expect(deleteExecutionPlan(tempDir, 'NONEXISTENT-001')).resolves.not.toThrow();
271
+ });
272
+ });
273
+ });
274
+
275
+ describe('Provenance', () => {
276
+ describe('getCurrentUser', () => {
277
+ it('should return a user name', () => {
278
+ const user = getCurrentUser();
279
+ expect(typeof user).toBe('string');
280
+ expect(user.length).toBeGreaterThan(0);
281
+ });
282
+ });
283
+
284
+ describe('createProvenance', () => {
285
+ it('should create provenance with all fields', () => {
286
+ const provenance = createProvenance(sampleTask, '/project', 'testuser');
287
+
288
+ expect(provenance.locked_by).toBe('testuser');
289
+ expect(provenance.locked_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
290
+ expect(provenance.source_file).toBe('test-plan.aps.md');
291
+ expect(provenance.source_line).toBe(10);
292
+ });
293
+
294
+ it('should use current user if not specified', () => {
295
+ const provenance = createProvenance(sampleTask, '/project');
296
+
297
+ expect(provenance.locked_by).toBe(getCurrentUser());
298
+ });
299
+ });
300
+ });
301
+
302
+ describe('TaskLocker', () => {
303
+ let tempDir: string;
304
+ let planPath: string;
305
+
306
+ beforeEach(async () => {
307
+ tempDir = await createTempDir();
308
+ // Copy the test plan fixture to temp directory
309
+ planPath = join(tempDir, 'test-plan.aps.md');
310
+ await fs.copyFile(join(fixturesDir, 'test-plan.aps.md'), planPath);
311
+ });
312
+
313
+ afterEach(async () => {
314
+ await cleanupTempDir(tempDir);
315
+ });
316
+
317
+ describe('lock', () => {
318
+ it('should lock a task successfully', async () => {
319
+ const locker = new TaskLocker({
320
+ projectRoot: tempDir,
321
+ planPath: planPath,
322
+ user: 'testuser',
323
+ });
324
+
325
+ const result = await locker.lock('TEST-001');
326
+
327
+ expect(result.success).toBe(true);
328
+ expect(result.taskId).toBe('TEST-001');
329
+ expect(result.executionPlanPath).toContain('TEST-001.json');
330
+
331
+ // Verify state was updated
332
+ const state = await getTaskState(tempDir, 'TEST-001');
333
+ expect(state?.status).toBe('locked');
334
+ expect(state?.locked_by).toBe('testuser');
335
+
336
+ // Verify execution plan was written
337
+ const plan = await readExecutionPlan(tempDir, 'TEST-001');
338
+ expect(plan?.task_id).toBe('TEST-001');
339
+ });
340
+
341
+ it('should fail to lock non-existent task', async () => {
342
+ const locker = new TaskLocker({
343
+ projectRoot: tempDir,
344
+ planPath: planPath,
345
+ });
346
+
347
+ const result = await locker.lock('NONEXISTENT-001');
348
+
349
+ expect(result.success).toBe(false);
350
+ expect(result.error).toContain('not found');
351
+ });
352
+
353
+ it('should fail to lock already locked task (first lock wins)', async () => {
354
+ const locker = new TaskLocker({
355
+ projectRoot: tempDir,
356
+ planPath: planPath,
357
+ user: 'user1',
358
+ });
359
+
360
+ // First lock succeeds
361
+ const result1 = await locker.lock('TEST-001');
362
+ expect(result1.success).toBe(true);
363
+
364
+ // Second lock fails
365
+ const locker2 = new TaskLocker({
366
+ projectRoot: tempDir,
367
+ planPath: planPath,
368
+ user: 'user2',
369
+ });
370
+
371
+ const result2 = await locker2.lock('TEST-001');
372
+ expect(result2.success).toBe(false);
373
+ expect(result2.error).toContain('already locked');
374
+ expect(result2.error).toContain('user1');
375
+ });
376
+
377
+ it('should fail to lock with invalid planning doc', async () => {
378
+ const invalidPlanPath = join(tempDir, 'invalid-plan.aps.md');
379
+ await fs.copyFile(join(fixturesDir, 'invalid-plan.aps.md'), invalidPlanPath);
380
+
381
+ const locker = new TaskLocker({
382
+ projectRoot: tempDir,
383
+ planPath: invalidPlanPath,
384
+ });
385
+
386
+ const result = await locker.lock('INV-001');
387
+
388
+ expect(result.success).toBe(false);
389
+ expect(result.error).toContain('validation failed');
390
+ });
391
+
392
+ it('should allow locking with skipValidation', async () => {
393
+ const invalidPlanPath = join(tempDir, 'invalid-plan.aps.md');
394
+ await fs.copyFile(join(fixturesDir, 'invalid-plan.aps.md'), invalidPlanPath);
395
+
396
+ const locker = new TaskLocker({
397
+ projectRoot: tempDir,
398
+ planPath: invalidPlanPath,
399
+ skipValidation: true,
400
+ });
401
+
402
+ // Even with skipValidation, the task doesn't have an intent so it won't parse correctly
403
+ // But we can test that validation is skipped
404
+ const result = await locker.lock('INV-001');
405
+
406
+ // The lock will fail because the task wasn't parsed (missing Intent),
407
+ // but the error should NOT be about validation
408
+ expect(result.success).toBe(false);
409
+ expect(result.error).not.toContain('validation failed');
410
+ });
411
+ });
412
+
413
+ describe('unlock', () => {
414
+ it('should unlock a locked task', async () => {
415
+ const locker = new TaskLocker({
416
+ projectRoot: tempDir,
417
+ planPath: planPath,
418
+ user: 'testuser',
419
+ });
420
+
421
+ // Lock first
422
+ await locker.lock('TEST-001');
423
+
424
+ // Unlock
425
+ const result = await locker.unlock('TEST-001');
426
+
427
+ expect(result.success).toBe(true);
428
+ expect(result.previousStatus).toBe('locked');
429
+
430
+ // Verify state was updated
431
+ const state = await getTaskState(tempDir, 'TEST-001');
432
+ expect(state?.status).toBe('cancelled');
433
+ expect(state?.cancelled_at).toBeDefined();
434
+
435
+ // Verify execution plan was deleted
436
+ const plan = await readExecutionPlan(tempDir, 'TEST-001');
437
+ expect(plan).toBeUndefined();
438
+ });
439
+
440
+ it('should fail to unlock task that is not locked', async () => {
441
+ const locker = new TaskLocker({
442
+ projectRoot: tempDir,
443
+ planPath: planPath,
444
+ });
445
+
446
+ const result = await locker.unlock('TEST-001');
447
+
448
+ expect(result.success).toBe(false);
449
+ expect(result.error).toContain('no state record');
450
+ });
451
+
452
+ it('should fail to unlock already cancelled task', async () => {
453
+ const locker = new TaskLocker({
454
+ projectRoot: tempDir,
455
+ planPath: planPath,
456
+ });
457
+
458
+ // Lock then unlock
459
+ await locker.lock('TEST-001');
460
+ await locker.unlock('TEST-001');
461
+
462
+ // Try to unlock again
463
+ const result = await locker.unlock('TEST-001');
464
+
465
+ expect(result.success).toBe(false);
466
+ expect(result.error).toContain('not locked');
467
+ expect(result.previousStatus).toBe('cancelled');
468
+ });
469
+ });
470
+
471
+ describe('complete', () => {
472
+ it('should mark a locked task as completed', async () => {
473
+ const locker = new TaskLocker({
474
+ projectRoot: tempDir,
475
+ planPath: planPath,
476
+ });
477
+
478
+ // Lock first
479
+ await locker.lock('TEST-001');
480
+
481
+ // Complete
482
+ const result = await locker.complete('TEST-001');
483
+
484
+ expect(result.success).toBe(true);
485
+ expect(result.previousStatus).toBe('locked');
486
+
487
+ // Verify state was updated
488
+ const state = await getTaskState(tempDir, 'TEST-001');
489
+ expect(state?.status).toBe('completed');
490
+ expect(state?.completed_at).toBeDefined();
491
+
492
+ // Execution plan should still exist (for audit)
493
+ const plan = await readExecutionPlan(tempDir, 'TEST-001');
494
+ expect(plan).toBeDefined();
495
+ });
496
+
497
+ it('should fail to complete task that is not locked', async () => {
498
+ const locker = new TaskLocker({
499
+ projectRoot: tempDir,
500
+ planPath: planPath,
501
+ });
502
+
503
+ const result = await locker.complete('TEST-001');
504
+
505
+ expect(result.success).toBe(false);
506
+ expect(result.error).toContain('no state record');
507
+ });
508
+ });
509
+
510
+ describe('getStatus', () => {
511
+ it('should return open status for unlocked task', async () => {
512
+ const locker = new TaskLocker({
513
+ projectRoot: tempDir,
514
+ planPath: planPath,
515
+ });
516
+
517
+ const status = await locker.getStatus('TEST-001');
518
+
519
+ expect(status?.taskId).toBe('TEST-001');
520
+ expect(status?.status).toBe('open');
521
+ });
522
+
523
+ it('should return locked status with details', async () => {
524
+ const locker = new TaskLocker({
525
+ projectRoot: tempDir,
526
+ planPath: planPath,
527
+ user: 'testuser',
528
+ });
529
+
530
+ await locker.lock('TEST-001');
531
+
532
+ const status = await locker.getStatus('TEST-001');
533
+
534
+ expect(status?.status).toBe('locked');
535
+ expect(status?.lockedBy).toBe('testuser');
536
+ expect(status?.lockedAt).toBeDefined();
537
+ expect(status?.executionFile).toContain('TEST-001');
538
+ });
539
+
540
+ it('should return undefined for non-existent task', async () => {
541
+ const locker = new TaskLocker({
542
+ projectRoot: tempDir,
543
+ planPath: planPath,
544
+ });
545
+
546
+ const status = await locker.getStatus('NONEXISTENT-001');
547
+
548
+ expect(status).toBeUndefined();
549
+ });
550
+ });
551
+
552
+ describe('getAllStatus', () => {
553
+ it('should return status for all tasks', async () => {
554
+ const locker = new TaskLocker({
555
+ projectRoot: tempDir,
556
+ planPath: planPath,
557
+ });
558
+
559
+ // Lock one task
560
+ await locker.lock('TEST-001');
561
+
562
+ const allStatus = await locker.getAllStatus();
563
+
564
+ expect(allStatus.length).toBe(3);
565
+
566
+ const test001 = allStatus.find((s) => s.taskId === 'TEST-001');
567
+ const test002 = allStatus.find((s) => s.taskId === 'TEST-002');
568
+ const test003 = allStatus.find((s) => s.taskId === 'TEST-003');
569
+
570
+ expect(test001?.status).toBe('locked');
571
+ expect(test002?.status).toBe('open');
572
+ expect(test003?.status).toBe('open');
573
+ });
574
+ });
575
+
576
+ describe('getStatusSummary', () => {
577
+ it('should return correct summary counts', async () => {
578
+ const locker = new TaskLocker({
579
+ projectRoot: tempDir,
580
+ planPath: planPath,
581
+ });
582
+
583
+ // Lock one, complete one
584
+ await locker.lock('TEST-001');
585
+ await locker.lock('TEST-002');
586
+ await locker.complete('TEST-002');
587
+
588
+ const summary = await locker.getStatusSummary();
589
+
590
+ expect(summary.open).toBe(1); // TEST-003
591
+ expect(summary.locked).toBe(1); // TEST-001
592
+ expect(summary.completed).toBe(1); // TEST-002
593
+ expect(summary.cancelled).toBe(0);
594
+ });
595
+ });
596
+ });
597
+
598
+ describe('Formatting', () => {
599
+ describe('formatTaskStatus', () => {
600
+ it('should format locked status', () => {
601
+ const formatted = formatTaskStatus({
602
+ taskId: 'TEST-001',
603
+ status: 'locked',
604
+ lockedBy: 'testuser',
605
+ lockedAt: '2025-12-17T10:00:00.000Z',
606
+ source: { file: 'test.aps.md', line: 10 },
607
+ });
608
+
609
+ expect(formatted).toContain('TEST-001: locked');
610
+ expect(formatted).toContain('testuser');
611
+ expect(formatted).toContain('test.aps.md:10');
612
+ });
613
+
614
+ it('should format open status', () => {
615
+ const formatted = formatTaskStatus({
616
+ taskId: 'TEST-001',
617
+ status: 'open',
618
+ });
619
+
620
+ expect(formatted).toBe('TEST-001: open');
621
+ });
622
+ });
623
+
624
+ describe('formatAllTaskStatus', () => {
625
+ it('should format multiple statuses', () => {
626
+ const formatted = formatAllTaskStatus([
627
+ {
628
+ taskId: 'TEST-001',
629
+ status: 'locked',
630
+ lockedBy: 'user1',
631
+ lockedAt: '2025-12-17T10:00:00.000Z',
632
+ },
633
+ { taskId: 'TEST-002', status: 'open' },
634
+ ]);
635
+
636
+ expect(formatted).toContain('TEST-001: locked');
637
+ expect(formatted).toContain('TEST-002: open');
638
+ });
639
+
640
+ it('should return message for empty list', () => {
641
+ const formatted = formatAllTaskStatus([]);
642
+ expect(formatted).toBe('No tasks found.');
643
+ });
644
+ });
645
+ });