@workflow-cannon/workspace-kit 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/cli.js +60 -2
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/module-contract.d.ts +62 -0
- package/dist/contracts/module-contract.js +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -0
- package/dist/core/module-command-router.d.ts +27 -0
- package/dist/core/module-command-router.js +84 -0
- package/dist/core/module-registry.d.ts +24 -0
- package/dist/core/module-registry.js +183 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/modules/approvals/index.d.ts +2 -0
- package/dist/modules/approvals/index.js +30 -0
- package/dist/modules/documentation/index.d.ts +3 -0
- package/dist/modules/documentation/index.js +98 -0
- package/dist/modules/documentation/runtime.d.ts +12 -0
- package/dist/modules/documentation/runtime.js +368 -0
- package/dist/modules/documentation/types.d.ts +46 -0
- package/dist/modules/documentation/types.js +1 -0
- package/dist/modules/improvement/index.d.ts +2 -0
- package/dist/modules/improvement/index.js +30 -0
- package/dist/modules/index.d.ts +7 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/planning/index.d.ts +2 -0
- package/dist/modules/planning/index.js +30 -0
- package/dist/modules/task-engine/generator.d.ts +2 -0
- package/dist/modules/task-engine/generator.js +101 -0
- package/dist/modules/task-engine/importer.d.ts +8 -0
- package/dist/modules/task-engine/importer.js +157 -0
- package/dist/modules/task-engine/index.d.ts +9 -0
- package/dist/modules/task-engine/index.js +253 -0
- package/dist/modules/task-engine/service.d.ts +21 -0
- package/dist/modules/task-engine/service.js +105 -0
- package/dist/modules/task-engine/store.d.ts +16 -0
- package/dist/modules/task-engine/store.js +88 -0
- package/dist/modules/task-engine/suggestions.d.ts +2 -0
- package/dist/modules/task-engine/suggestions.js +51 -0
- package/dist/modules/task-engine/transitions.d.ts +23 -0
- package/dist/modules/task-engine/transitions.js +109 -0
- package/dist/modules/task-engine/types.d.ts +82 -0
- package/dist/modules/task-engine/types.js +1 -0
- package/dist/ops/index.d.ts +1 -0
- package/dist/ops/index.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const STATUS_MARKERS = {
|
|
2
|
+
proposed: "[p]",
|
|
3
|
+
ready: "[ ]",
|
|
4
|
+
in_progress: "[~]",
|
|
5
|
+
blocked: "[!]",
|
|
6
|
+
completed: "[x]",
|
|
7
|
+
cancelled: "[-]"
|
|
8
|
+
};
|
|
9
|
+
function groupByPhase(tasks) {
|
|
10
|
+
const groups = new Map();
|
|
11
|
+
for (const task of tasks) {
|
|
12
|
+
const phase = task.phase ?? "Uncategorized";
|
|
13
|
+
const group = groups.get(phase) ?? [];
|
|
14
|
+
group.push(task);
|
|
15
|
+
groups.set(phase, group);
|
|
16
|
+
}
|
|
17
|
+
return groups;
|
|
18
|
+
}
|
|
19
|
+
function buildReadyQueueLine(tasks) {
|
|
20
|
+
const ready = tasks
|
|
21
|
+
.filter((t) => t.status === "ready")
|
|
22
|
+
.sort((a, b) => {
|
|
23
|
+
const pa = a.priority ?? "P9";
|
|
24
|
+
const pb = b.priority ?? "P9";
|
|
25
|
+
return pa.localeCompare(pb);
|
|
26
|
+
});
|
|
27
|
+
if (ready.length === 0)
|
|
28
|
+
return "- Ready queue: _(empty)_";
|
|
29
|
+
return `- Ready queue: ${ready.map((t) => `\`${t.id}\``).join(", ")}`;
|
|
30
|
+
}
|
|
31
|
+
function buildCurrentPhase(tasks) {
|
|
32
|
+
const inProgress = tasks.filter((t) => t.status === "in_progress");
|
|
33
|
+
const ready = tasks.filter((t) => t.status === "ready");
|
|
34
|
+
const active = [...inProgress, ...ready];
|
|
35
|
+
if (active.length === 0)
|
|
36
|
+
return "- Current phase in execution: _(no active tasks)_";
|
|
37
|
+
const phases = [...new Set(active.map((t) => t.phase).filter(Boolean))];
|
|
38
|
+
if (phases.length === 0)
|
|
39
|
+
return "- Current phase in execution: _(unknown)_";
|
|
40
|
+
return `- Current phase in execution: _${phases[0]}_`;
|
|
41
|
+
}
|
|
42
|
+
function renderTask(task) {
|
|
43
|
+
const marker = STATUS_MARKERS[task.status] ?? "[ ]";
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`### ${marker} ${task.id} ${task.title}`);
|
|
46
|
+
if (task.priority) {
|
|
47
|
+
lines.push(`- Priority: ${task.priority}`);
|
|
48
|
+
}
|
|
49
|
+
if (task.approach) {
|
|
50
|
+
lines.push(`- Approach: ${task.approach}`);
|
|
51
|
+
}
|
|
52
|
+
const deps = task.dependsOn ?? [];
|
|
53
|
+
lines.push(`- Depends on: ${deps.length > 0 ? deps.map((d) => `\`${d}\``).join(", ") : "none"}`);
|
|
54
|
+
const unblocks = task.unblocks ?? [];
|
|
55
|
+
if (unblocks.length > 0) {
|
|
56
|
+
lines.push(`- Unblocks: ${unblocks.map((u) => `\`${u}\``).join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
if (task.technicalScope && task.technicalScope.length > 0) {
|
|
59
|
+
lines.push("- Technical scope:");
|
|
60
|
+
for (const item of task.technicalScope) {
|
|
61
|
+
lines.push(` - ${item}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) {
|
|
65
|
+
lines.push("- Acceptance criteria:");
|
|
66
|
+
for (const item of task.acceptanceCriteria) {
|
|
67
|
+
lines.push(` - ${item}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
export function generateTasksMd(tasks) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
lines.push("# Workflow Cannon Tasks");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("> This file is generated by the Task Engine. Do not edit manually.");
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push("Status markers:");
|
|
79
|
+
lines.push("- `[p]` proposed");
|
|
80
|
+
lines.push("- `[ ]` ready");
|
|
81
|
+
lines.push("- `[~]` in progress");
|
|
82
|
+
lines.push("- `[!]` blocked");
|
|
83
|
+
lines.push("- `[x]` completed");
|
|
84
|
+
lines.push("- `[-]` cancelled");
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("## Current execution state");
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push(buildCurrentPhase(tasks));
|
|
89
|
+
lines.push(buildReadyQueueLine(tasks));
|
|
90
|
+
lines.push("");
|
|
91
|
+
const phaseGroups = groupByPhase(tasks);
|
|
92
|
+
for (const [phase, phaseTasks] of phaseGroups) {
|
|
93
|
+
lines.push(`## ${phase}`);
|
|
94
|
+
lines.push("");
|
|
95
|
+
for (const task of phaseTasks) {
|
|
96
|
+
lines.push(renderTask(task));
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { TaskEngineError } from "./transitions.js";
|
|
3
|
+
const STATUS_MAP = {
|
|
4
|
+
"[p]": "proposed",
|
|
5
|
+
"[ ]": "ready",
|
|
6
|
+
"[~]": "in_progress",
|
|
7
|
+
"[!]": "blocked",
|
|
8
|
+
"[x]": "completed",
|
|
9
|
+
"[-]": "cancelled"
|
|
10
|
+
};
|
|
11
|
+
function parseTaskId(heading) {
|
|
12
|
+
const match = heading.match(/^###\s+\[[^\]]*\]\s+(T\d+)/);
|
|
13
|
+
return match?.[1] ?? null;
|
|
14
|
+
}
|
|
15
|
+
function parseStatus(heading) {
|
|
16
|
+
for (const [marker, status] of Object.entries(STATUS_MAP)) {
|
|
17
|
+
if (heading.includes(marker))
|
|
18
|
+
return status;
|
|
19
|
+
}
|
|
20
|
+
return "ready";
|
|
21
|
+
}
|
|
22
|
+
function parseTitle(heading) {
|
|
23
|
+
const match = heading.match(/^###\s+\[[^\]]*\]\s+T\d+\s+(.+)/);
|
|
24
|
+
return match?.[1]?.trim() ?? "Untitled";
|
|
25
|
+
}
|
|
26
|
+
function extractField(lines, prefix) {
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (trimmed.startsWith(prefix)) {
|
|
30
|
+
return trimmed.slice(prefix.length).trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function extractListField(lines, fieldPrefix) {
|
|
36
|
+
const items = [];
|
|
37
|
+
let capturing = false;
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (trimmed.startsWith(fieldPrefix)) {
|
|
41
|
+
capturing = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (capturing) {
|
|
45
|
+
if (/^\s{2,}-\s/.test(line)) {
|
|
46
|
+
items.push(line.replace(/^\s*-\s*/, "").trim());
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (trimmed.startsWith("- ")) {
|
|
50
|
+
capturing = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (trimmed === "") {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
function parseTaskIds(text) {
|
|
61
|
+
if (!text || text.trim() === "none")
|
|
62
|
+
return [];
|
|
63
|
+
const ids = [];
|
|
64
|
+
const matches = text.matchAll(/`?(T\d+)`?/g);
|
|
65
|
+
for (const m of matches) {
|
|
66
|
+
ids.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
return ids;
|
|
69
|
+
}
|
|
70
|
+
function parsePriority(text) {
|
|
71
|
+
if (!text)
|
|
72
|
+
return undefined;
|
|
73
|
+
const match = text.match(/(P[123])/);
|
|
74
|
+
return match?.[1];
|
|
75
|
+
}
|
|
76
|
+
function parsePhase(sectionHeading) {
|
|
77
|
+
const match = sectionHeading.match(/^##\s+(.+)/);
|
|
78
|
+
return match?.[1]?.trim();
|
|
79
|
+
}
|
|
80
|
+
export async function importTasksFromMarkdown(sourcePath) {
|
|
81
|
+
let content;
|
|
82
|
+
try {
|
|
83
|
+
content = await fs.readFile(sourcePath, "utf8");
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new TaskEngineError("import-parse-error", `Failed to read TASKS.md: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
const lines = content.split("\n");
|
|
89
|
+
const tasks = [];
|
|
90
|
+
const errors = [];
|
|
91
|
+
let skipped = 0;
|
|
92
|
+
let currentPhase;
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
let taskStartIdx = -1;
|
|
95
|
+
let taskLines = [];
|
|
96
|
+
function flushTask() {
|
|
97
|
+
if (taskStartIdx === -1 || taskLines.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
const heading = taskLines[0];
|
|
100
|
+
const id = parseTaskId(heading);
|
|
101
|
+
if (!id) {
|
|
102
|
+
errors.push(`Line ${taskStartIdx + 1}: Could not parse task ID from heading`);
|
|
103
|
+
skipped++;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const status = parseStatus(heading);
|
|
107
|
+
const title = parseTitle(heading);
|
|
108
|
+
const priorityStr = extractField(taskLines, "- Priority:");
|
|
109
|
+
const approach = extractField(taskLines, "- Approach:");
|
|
110
|
+
const dependsOnStr = extractField(taskLines, "- Depends on:");
|
|
111
|
+
const unblocksStr = extractField(taskLines, "- Unblocks:");
|
|
112
|
+
const technicalScope = extractListField(taskLines, "- Technical scope:");
|
|
113
|
+
const acceptanceCriteria = extractListField(taskLines, "- Acceptance criteria:");
|
|
114
|
+
const task = {
|
|
115
|
+
id,
|
|
116
|
+
status,
|
|
117
|
+
type: "workspace-kit",
|
|
118
|
+
title,
|
|
119
|
+
createdAt: now,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
priority: parsePriority(priorityStr),
|
|
122
|
+
dependsOn: parseTaskIds(dependsOnStr ?? ""),
|
|
123
|
+
unblocks: parseTaskIds(unblocksStr ?? ""),
|
|
124
|
+
phase: currentPhase,
|
|
125
|
+
approach: approach || undefined,
|
|
126
|
+
technicalScope: technicalScope.length > 0 ? technicalScope : undefined,
|
|
127
|
+
acceptanceCriteria: acceptanceCriteria.length > 0 ? acceptanceCriteria : undefined
|
|
128
|
+
};
|
|
129
|
+
tasks.push(task);
|
|
130
|
+
}
|
|
131
|
+
for (let i = 0; i < lines.length; i++) {
|
|
132
|
+
const line = lines[i];
|
|
133
|
+
if (line.startsWith("## ") && !line.startsWith("### ")) {
|
|
134
|
+
flushTask();
|
|
135
|
+
taskStartIdx = -1;
|
|
136
|
+
taskLines = [];
|
|
137
|
+
currentPhase = parsePhase(line);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (line.startsWith("### ")) {
|
|
141
|
+
flushTask();
|
|
142
|
+
taskStartIdx = i;
|
|
143
|
+
taskLines = [line];
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (taskStartIdx !== -1) {
|
|
147
|
+
taskLines.push(line);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
flushTask();
|
|
151
|
+
return {
|
|
152
|
+
imported: tasks.length,
|
|
153
|
+
skipped,
|
|
154
|
+
errors,
|
|
155
|
+
tasks
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WorkflowModule } from "../../contracts/module-contract.js";
|
|
2
|
+
export type { TaskEntity, TaskStatus, TaskPriority, TaskStoreDocument, TransitionEvidence, TransitionGuard, TransitionContext, GuardResult, TaskEngineError as TaskEngineErrorType, TaskEngineErrorCode, TaskAdapter, TaskAdapterCapability, NextActionSuggestion, BlockingAnalysisEntry } from "./types.js";
|
|
3
|
+
export { TaskStore } from "./store.js";
|
|
4
|
+
export { TransitionService } from "./service.js";
|
|
5
|
+
export { TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard } from "./transitions.js";
|
|
6
|
+
export { generateTasksMd } from "./generator.js";
|
|
7
|
+
export { importTasksFromMarkdown } from "./importer.js";
|
|
8
|
+
export { getNextActions } from "./suggestions.js";
|
|
9
|
+
export declare const taskEngineModule: WorkflowModule;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { TaskStore } from "./store.js";
|
|
4
|
+
import { TransitionService } from "./service.js";
|
|
5
|
+
import { TaskEngineError } from "./transitions.js";
|
|
6
|
+
import { generateTasksMd } from "./generator.js";
|
|
7
|
+
import { importTasksFromMarkdown } from "./importer.js";
|
|
8
|
+
import { getNextActions } from "./suggestions.js";
|
|
9
|
+
export { TaskStore } from "./store.js";
|
|
10
|
+
export { TransitionService } from "./service.js";
|
|
11
|
+
export { TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard } from "./transitions.js";
|
|
12
|
+
export { generateTasksMd } from "./generator.js";
|
|
13
|
+
export { importTasksFromMarkdown } from "./importer.js";
|
|
14
|
+
export { getNextActions } from "./suggestions.js";
|
|
15
|
+
export const taskEngineModule = {
|
|
16
|
+
registration: {
|
|
17
|
+
id: "task-engine",
|
|
18
|
+
version: "0.3.0",
|
|
19
|
+
contractVersion: "1",
|
|
20
|
+
capabilities: ["task-engine"],
|
|
21
|
+
dependsOn: [],
|
|
22
|
+
enabledByDefault: true,
|
|
23
|
+
config: {
|
|
24
|
+
path: "src/modules/task-engine/config.md",
|
|
25
|
+
format: "md",
|
|
26
|
+
description: "Task Engine configuration contract."
|
|
27
|
+
},
|
|
28
|
+
state: {
|
|
29
|
+
path: "src/modules/task-engine/state.md",
|
|
30
|
+
format: "md",
|
|
31
|
+
description: "Task Engine runtime state contract."
|
|
32
|
+
},
|
|
33
|
+
instructions: {
|
|
34
|
+
directory: "src/modules/task-engine/instructions",
|
|
35
|
+
entries: [
|
|
36
|
+
{
|
|
37
|
+
name: "run-transition",
|
|
38
|
+
file: "run-transition.md",
|
|
39
|
+
description: "Execute a validated task status transition."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "get-task",
|
|
43
|
+
file: "get-task.md",
|
|
44
|
+
description: "Retrieve a single task by ID."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "list-tasks",
|
|
48
|
+
file: "list-tasks.md",
|
|
49
|
+
description: "List tasks with optional status/phase filters."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "get-ready-queue",
|
|
53
|
+
file: "get-ready-queue.md",
|
|
54
|
+
description: "Get ready tasks sorted by priority."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "import-tasks",
|
|
58
|
+
file: "import-tasks.md",
|
|
59
|
+
description: "One-time import from TASKS.md into engine state."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "generate-tasks-md",
|
|
63
|
+
file: "generate-tasks-md.md",
|
|
64
|
+
description: "Generate read-only TASKS.md from engine state."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "get-next-actions",
|
|
68
|
+
file: "get-next-actions.md",
|
|
69
|
+
description: "Get prioritized next-action suggestions with blocking analysis."
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
async onCommand(command, ctx) {
|
|
75
|
+
const args = command.args ?? {};
|
|
76
|
+
const store = new TaskStore(ctx.workspacePath);
|
|
77
|
+
try {
|
|
78
|
+
await store.load();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof TaskEngineError) {
|
|
82
|
+
return { ok: false, code: err.code, message: err.message };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: "storage-read-error",
|
|
87
|
+
message: `Failed to load task store: ${err.message}`
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (command.name === "run-transition") {
|
|
91
|
+
const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
|
|
92
|
+
const action = typeof args.action === "string" ? args.action : undefined;
|
|
93
|
+
const actor = typeof args.actor === "string" ? args.actor : undefined;
|
|
94
|
+
if (!taskId || !action) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: "invalid-task-schema",
|
|
98
|
+
message: "run-transition requires 'taskId' and 'action' arguments"
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const service = new TransitionService(store);
|
|
103
|
+
const result = await service.runTransition({ taskId, action, actor });
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
code: "transition-applied",
|
|
107
|
+
message: `${taskId}: ${result.evidence.fromState} → ${result.evidence.toState} (${action})`,
|
|
108
|
+
data: {
|
|
109
|
+
evidence: result.evidence,
|
|
110
|
+
autoUnblocked: result.autoUnblocked
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof TaskEngineError) {
|
|
116
|
+
return { ok: false, code: err.code, message: err.message };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: "invalid-transition",
|
|
121
|
+
message: err.message
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (command.name === "get-task") {
|
|
126
|
+
const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
|
|
127
|
+
if (!taskId) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "invalid-task-schema",
|
|
131
|
+
message: "get-task requires 'taskId' argument"
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const task = store.getTask(taskId);
|
|
135
|
+
if (!task) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
code: "task-not-found",
|
|
139
|
+
message: `Task '${taskId}' not found`
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
code: "task-retrieved",
|
|
145
|
+
data: { task }
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (command.name === "list-tasks") {
|
|
149
|
+
const statusFilter = typeof args.status === "string" ? args.status : undefined;
|
|
150
|
+
const phaseFilter = typeof args.phase === "string" ? args.phase : undefined;
|
|
151
|
+
let tasks = store.getAllTasks();
|
|
152
|
+
if (statusFilter) {
|
|
153
|
+
tasks = tasks.filter((t) => t.status === statusFilter);
|
|
154
|
+
}
|
|
155
|
+
if (phaseFilter) {
|
|
156
|
+
tasks = tasks.filter((t) => t.phase === phaseFilter);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
code: "tasks-listed",
|
|
161
|
+
message: `Found ${tasks.length} tasks`,
|
|
162
|
+
data: { tasks, count: tasks.length }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (command.name === "get-ready-queue") {
|
|
166
|
+
const tasks = store.getAllTasks();
|
|
167
|
+
const ready = tasks
|
|
168
|
+
.filter((t) => t.status === "ready")
|
|
169
|
+
.sort((a, b) => {
|
|
170
|
+
const pa = a.priority ?? "P9";
|
|
171
|
+
const pb = b.priority ?? "P9";
|
|
172
|
+
return pa.localeCompare(pb);
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
code: "ready-queue-retrieved",
|
|
177
|
+
message: `${ready.length} tasks in ready queue`,
|
|
178
|
+
data: { tasks: ready, count: ready.length }
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (command.name === "import-tasks") {
|
|
182
|
+
const sourcePath = typeof args.sourcePath === "string"
|
|
183
|
+
? path.resolve(ctx.workspacePath, args.sourcePath)
|
|
184
|
+
: path.resolve(ctx.workspacePath, "docs/maintainers/TASKS.md");
|
|
185
|
+
try {
|
|
186
|
+
const result = await importTasksFromMarkdown(sourcePath);
|
|
187
|
+
store.replaceAllTasks(result.tasks);
|
|
188
|
+
await store.save();
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
code: "tasks-imported",
|
|
192
|
+
message: `Imported ${result.imported} tasks (${result.skipped} skipped)`,
|
|
193
|
+
data: {
|
|
194
|
+
imported: result.imported,
|
|
195
|
+
skipped: result.skipped,
|
|
196
|
+
errors: result.errors
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err instanceof TaskEngineError) {
|
|
202
|
+
return { ok: false, code: err.code, message: err.message };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
code: "import-parse-error",
|
|
207
|
+
message: err.message
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (command.name === "generate-tasks-md") {
|
|
212
|
+
const outputPath = typeof args.outputPath === "string"
|
|
213
|
+
? path.resolve(ctx.workspacePath, args.outputPath)
|
|
214
|
+
: path.resolve(ctx.workspacePath, "docs/maintainers/TASKS.md");
|
|
215
|
+
const tasks = store.getAllTasks();
|
|
216
|
+
const markdown = generateTasksMd(tasks);
|
|
217
|
+
try {
|
|
218
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
219
|
+
await fs.writeFile(outputPath, markdown, "utf8");
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
code: "storage-write-error",
|
|
225
|
+
message: `Failed to write TASKS.md: ${err.message}`
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
code: "tasks-md-generated",
|
|
231
|
+
message: `Generated TASKS.md with ${tasks.length} tasks`,
|
|
232
|
+
data: { outputPath, taskCount: tasks.length }
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (command.name === "get-next-actions") {
|
|
236
|
+
const tasks = store.getAllTasks();
|
|
237
|
+
const suggestion = getNextActions(tasks);
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
code: "next-actions-retrieved",
|
|
241
|
+
message: suggestion.suggestedNext
|
|
242
|
+
? `Suggested next: ${suggestion.suggestedNext.id} — ${suggestion.suggestedNext.title}`
|
|
243
|
+
: "No tasks in ready queue",
|
|
244
|
+
data: suggestion
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
code: "unsupported-command",
|
|
250
|
+
message: `Task Engine does not support command '${command.name}'`
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TransitionEvidence, TransitionGuard } from "./types.js";
|
|
2
|
+
import { TransitionValidator } from "./transitions.js";
|
|
3
|
+
import { TaskStore } from "./store.js";
|
|
4
|
+
export type TransitionRequest = {
|
|
5
|
+
taskId: string;
|
|
6
|
+
action: string;
|
|
7
|
+
actor?: string;
|
|
8
|
+
};
|
|
9
|
+
export type TransitionResult = {
|
|
10
|
+
evidence: TransitionEvidence;
|
|
11
|
+
autoUnblocked: TransitionEvidence[];
|
|
12
|
+
};
|
|
13
|
+
export declare class TransitionService {
|
|
14
|
+
private readonly store;
|
|
15
|
+
private readonly validator;
|
|
16
|
+
constructor(store: TaskStore, customGuards?: TransitionGuard[]);
|
|
17
|
+
runTransition(request: TransitionRequest): Promise<TransitionResult>;
|
|
18
|
+
private autoUnblock;
|
|
19
|
+
getStore(): TaskStore;
|
|
20
|
+
getValidator(): TransitionValidator;
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { TaskEngineError, TransitionValidator, getTransitionAction, resolveTargetState } from "./transitions.js";
|
|
3
|
+
export class TransitionService {
|
|
4
|
+
store;
|
|
5
|
+
validator;
|
|
6
|
+
constructor(store, customGuards = []) {
|
|
7
|
+
this.store = store;
|
|
8
|
+
this.validator = new TransitionValidator(customGuards);
|
|
9
|
+
}
|
|
10
|
+
async runTransition(request) {
|
|
11
|
+
const task = this.store.getTask(request.taskId);
|
|
12
|
+
if (!task) {
|
|
13
|
+
throw new TaskEngineError("task-not-found", `Task '${request.taskId}' not found`);
|
|
14
|
+
}
|
|
15
|
+
const targetState = resolveTargetState(task.status, request.action);
|
|
16
|
+
if (!targetState) {
|
|
17
|
+
throw new TaskEngineError("invalid-transition", `Action '${request.action}' is not valid from state '${task.status}'`);
|
|
18
|
+
}
|
|
19
|
+
const timestamp = new Date().toISOString();
|
|
20
|
+
const context = {
|
|
21
|
+
allTasks: this.store.getAllTasks(),
|
|
22
|
+
timestamp,
|
|
23
|
+
actor: request.actor
|
|
24
|
+
};
|
|
25
|
+
const validation = this.validator.validate(task, targetState, context);
|
|
26
|
+
if (!validation.allowed) {
|
|
27
|
+
const rejection = validation.guardResults.find((r) => !r.allowed);
|
|
28
|
+
if (rejection?.code === "dependency-unsatisfied") {
|
|
29
|
+
throw new TaskEngineError("dependency-unsatisfied", rejection.message ?? "Dependencies not satisfied");
|
|
30
|
+
}
|
|
31
|
+
if (rejection?.code === "invalid-transition") {
|
|
32
|
+
throw new TaskEngineError("invalid-transition", rejection.message ?? "Invalid transition");
|
|
33
|
+
}
|
|
34
|
+
throw new TaskEngineError("guard-rejected", rejection?.message ?? "Transition rejected by guard");
|
|
35
|
+
}
|
|
36
|
+
const fromState = task.status;
|
|
37
|
+
const updatedTask = {
|
|
38
|
+
...task,
|
|
39
|
+
status: targetState,
|
|
40
|
+
updatedAt: timestamp
|
|
41
|
+
};
|
|
42
|
+
this.store.updateTask(updatedTask);
|
|
43
|
+
const action = getTransitionAction(fromState, targetState) ?? request.action;
|
|
44
|
+
const autoUnblockResults = targetState === "completed"
|
|
45
|
+
? this.autoUnblock(request.taskId, timestamp, request.actor)
|
|
46
|
+
: [];
|
|
47
|
+
const evidence = {
|
|
48
|
+
transitionId: `${request.taskId}-${timestamp}-${crypto.randomUUID().slice(0, 8)}`,
|
|
49
|
+
taskId: request.taskId,
|
|
50
|
+
fromState,
|
|
51
|
+
toState: targetState,
|
|
52
|
+
action,
|
|
53
|
+
guardResults: validation.guardResults,
|
|
54
|
+
dependentsUnblocked: autoUnblockResults.map((r) => r.taskId),
|
|
55
|
+
timestamp,
|
|
56
|
+
actor: request.actor
|
|
57
|
+
};
|
|
58
|
+
this.store.addEvidence(evidence);
|
|
59
|
+
for (const unblockEvidence of autoUnblockResults) {
|
|
60
|
+
this.store.addEvidence(unblockEvidence);
|
|
61
|
+
}
|
|
62
|
+
await this.store.save();
|
|
63
|
+
return { evidence, autoUnblocked: autoUnblockResults };
|
|
64
|
+
}
|
|
65
|
+
autoUnblock(completedTaskId, timestamp, actor) {
|
|
66
|
+
const results = [];
|
|
67
|
+
const allTasks = this.store.getAllTasks();
|
|
68
|
+
const dependents = allTasks.filter((t) => t.status === "blocked" && t.dependsOn?.includes(completedTaskId));
|
|
69
|
+
for (const dependent of dependents) {
|
|
70
|
+
const deps = dependent.dependsOn ?? [];
|
|
71
|
+
const allDepsComplete = deps.every((depId) => {
|
|
72
|
+
if (depId === completedTaskId)
|
|
73
|
+
return true;
|
|
74
|
+
const depTask = this.store.getTask(depId);
|
|
75
|
+
return depTask?.status === "completed";
|
|
76
|
+
});
|
|
77
|
+
if (!allDepsComplete)
|
|
78
|
+
continue;
|
|
79
|
+
const updatedDependent = {
|
|
80
|
+
...dependent,
|
|
81
|
+
status: "ready",
|
|
82
|
+
updatedAt: timestamp
|
|
83
|
+
};
|
|
84
|
+
this.store.updateTask(updatedDependent);
|
|
85
|
+
results.push({
|
|
86
|
+
transitionId: `${dependent.id}-${timestamp}-${crypto.randomUUID().slice(0, 8)}`,
|
|
87
|
+
taskId: dependent.id,
|
|
88
|
+
fromState: "blocked",
|
|
89
|
+
toState: "ready",
|
|
90
|
+
action: "unblock",
|
|
91
|
+
guardResults: [{ allowed: true, guardName: "auto-unblock" }],
|
|
92
|
+
dependentsUnblocked: [],
|
|
93
|
+
timestamp,
|
|
94
|
+
actor
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
getStore() {
|
|
100
|
+
return this.store;
|
|
101
|
+
}
|
|
102
|
+
getValidator() {
|
|
103
|
+
return this.validator;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TaskEntity, TransitionEvidence } from "./types.js";
|
|
2
|
+
export declare class TaskStore {
|
|
3
|
+
private document;
|
|
4
|
+
private readonly filePath;
|
|
5
|
+
constructor(workspacePath: string, storePath?: string);
|
|
6
|
+
load(): Promise<void>;
|
|
7
|
+
save(): Promise<void>;
|
|
8
|
+
getAllTasks(): TaskEntity[];
|
|
9
|
+
getTask(id: string): TaskEntity | undefined;
|
|
10
|
+
addTask(task: TaskEntity): void;
|
|
11
|
+
updateTask(task: TaskEntity): void;
|
|
12
|
+
addEvidence(evidence: TransitionEvidence): void;
|
|
13
|
+
getTransitionLog(): TransitionEvidence[];
|
|
14
|
+
replaceAllTasks(tasks: TaskEntity[]): void;
|
|
15
|
+
getFilePath(): string;
|
|
16
|
+
}
|