@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.
- package/AGENTS.md +155 -0
- package/LICENSE +14 -0
- package/README.md +57 -0
- package/TODO.md +40 -0
- package/dist/filter/context-bundle.d.ts +81 -0
- package/dist/filter/context-bundle.d.ts.map +1 -0
- package/dist/filter/context-bundle.js +230 -0
- package/dist/filter/index.d.ts +85 -0
- package/dist/filter/index.d.ts.map +1 -0
- package/dist/filter/index.js +169 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/loader/index.d.ts +80 -0
- package/dist/loader/index.d.ts.map +1 -0
- package/dist/loader/index.js +253 -0
- package/dist/parser/index.d.ts +24 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +22 -0
- package/dist/parser/parse-document.d.ts +17 -0
- package/dist/parser/parse-document.d.ts.map +1 -0
- package/dist/parser/parse-document.js +219 -0
- package/dist/parser/parse-index.d.ts +31 -0
- package/dist/parser/parse-index.d.ts.map +1 -0
- package/dist/parser/parse-index.js +251 -0
- package/dist/parser/parse-task.d.ts +30 -0
- package/dist/parser/parse-task.d.ts.map +1 -0
- package/dist/parser/parse-task.js +261 -0
- package/dist/state/index.d.ts +307 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +689 -0
- package/dist/templates/generator.d.ts +71 -0
- package/dist/templates/generator.d.ts.map +1 -0
- package/dist/templates/generator.js +723 -0
- package/dist/templates/index.d.ts +5 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +4 -0
- package/dist/types/index.d.ts +131 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +107 -0
- package/dist/validator/index.d.ts +83 -0
- package/dist/validator/index.d.ts.map +1 -0
- package/dist/validator/index.js +611 -0
- package/docs/APS-Anvil-Integration.md +750 -0
- package/docs/APS-Conventions.md +635 -0
- package/docs/APS-NonGoals.md +455 -0
- package/docs/APS-Planning-Spec-v0.1.md +362 -0
- package/examples/README.md +170 -0
- package/examples/feature-auth.aps.md +87 -0
- package/examples/refactor-error-handling.aps.md +119 -0
- package/examples/system-ecommerce/APS.md +57 -0
- package/examples/system-ecommerce/modules/auth.aps.md +38 -0
- package/examples/system-ecommerce/modules/cart.aps.md +53 -0
- package/examples/system-ecommerce/modules/payments.aps.md +68 -0
- package/examples/system-ecommerce/modules/products.aps.md +53 -0
- package/package.json +34 -0
- package/project.json +37 -0
- package/scripts/generate-templates.js +33 -0
- package/src/filter/context-bundle.ts +312 -0
- package/src/filter/filter.test.ts +317 -0
- package/src/filter/index.ts +249 -0
- package/src/index.ts +16 -0
- package/src/loader/index.ts +364 -0
- package/src/loader/loader.test.ts +224 -0
- package/src/parser/__fixtures__/invalid-task-id-not-padded.aps.md +7 -0
- package/src/parser/__fixtures__/invalid-task-id.aps.md +8 -0
- package/src/parser/__fixtures__/minimal-task.aps.md +7 -0
- package/src/parser/__fixtures__/non-scope-hyphenated.aps.md +10 -0
- package/src/parser/__fixtures__/simple-index.aps.md +35 -0
- package/src/parser/__fixtures__/simple-plan.aps.md +19 -0
- package/src/parser/index.ts +30 -0
- package/src/parser/parse-document.test.ts +603 -0
- package/src/parser/parse-document.ts +262 -0
- package/src/parser/parse-index.test.ts +316 -0
- package/src/parser/parse-index.ts +298 -0
- package/src/parser/parse-task.test.ts +476 -0
- package/src/parser/parse-task.ts +325 -0
- package/src/state/__fixtures__/invalid-plan.aps.md +9 -0
- package/src/state/__fixtures__/test-plan.aps.md +20 -0
- package/src/state/index.ts +879 -0
- package/src/state/state.test.ts +645 -0
- package/src/templates/generator.test.ts +378 -0
- package/src/templates/generator.ts +776 -0
- package/src/templates/index.ts +5 -0
- package/src/types/index.ts +168 -0
- package/src/validator/__fixtures__/broken-links.aps.md +10 -0
- package/src/validator/__fixtures__/circular-deps-index.aps.md +26 -0
- package/src/validator/__fixtures__/circular-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/circular-modules/module-c.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-a.aps.md +9 -0
- package/src/validator/__fixtures__/dup-modules/module-b.aps.md +9 -0
- package/src/validator/__fixtures__/duplicate-ids-index.aps.md +15 -0
- package/src/validator/__fixtures__/invalid-task-id.aps.md +17 -0
- package/src/validator/__fixtures__/missing-confidence.aps.md +9 -0
- package/src/validator/__fixtures__/missing-h1.aps.md +5 -0
- package/src/validator/__fixtures__/missing-intent.aps.md +9 -0
- package/src/validator/__fixtures__/missing-modules-section.aps.md +7 -0
- package/src/validator/__fixtures__/missing-tasks-section.aps.md +7 -0
- package/src/validator/__fixtures__/modules/auth.aps.md +17 -0
- package/src/validator/__fixtures__/modules/payments.aps.md +13 -0
- package/src/validator/__fixtures__/scope-mismatch.aps.md +14 -0
- package/src/validator/__fixtures__/valid-index.aps.md +24 -0
- package/src/validator/__fixtures__/valid-leaf.aps.md +22 -0
- package/src/validator/index.ts +776 -0
- package/src/validator/validator.test.ts +269 -0
- package/templates/index-full.md +94 -0
- package/templates/index-minimal.md +16 -0
- package/templates/index-template.md +63 -0
- package/templates/leaf-full.md +76 -0
- package/templates/leaf-minimal.md +14 -0
- package/templates/leaf-template.md +55 -0
- package/templates/simple-full.md +56 -0
- package/templates/simple-minimal.md +14 -0
- package/templates/simple-template.md +30 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|