@workflow-cannon/workspace-kit 0.16.0 → 0.17.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/dist/cli/doctor-planning-issues.d.ts +6 -0
- package/dist/cli/doctor-planning-issues.js +37 -0
- package/dist/cli.js +3 -0
- package/dist/core/config-metadata.js +69 -1
- package/dist/core/workspace-kit-config.js +2 -1
- package/dist/modules/task-engine/doctor-planning-persistence.d.ts +9 -0
- package/dist/modules/task-engine/doctor-planning-persistence.js +77 -0
- package/dist/modules/task-engine/index.js +272 -8
- package/dist/modules/task-engine/planning-config.d.ts +3 -0
- package/dist/modules/task-engine/planning-config.js +7 -0
- package/dist/modules/task-engine/strict-task-validation.d.ts +3 -0
- package/dist/modules/task-engine/strict-task-validation.js +52 -0
- package/dist/modules/task-engine/task-type-validation.d.ts +10 -0
- package/dist/modules/task-engine/task-type-validation.js +26 -0
- package/dist/modules/task-engine/types.d.ts +1 -1
- package/package.json +3 -2
- package/schemas/compatibility-matrix.schema.json +64 -0
- package/schemas/parity-evidence.schema.json +60 -0
- package/schemas/task-engine-run-contracts.schema.json +560 -0
- package/schemas/task-engine-state.schema.json +41 -0
- package/schemas/workspace-kit-profile.schema.json +55 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type DoctorPlanningIssue = {
|
|
2
|
+
path: string;
|
|
3
|
+
reason: string;
|
|
4
|
+
};
|
|
5
|
+
/** Resolve layered config and run SQLite planning persistence checks for `workspace-kit doctor`. */
|
|
6
|
+
export declare function collectDoctorPlanningPersistenceIssues(cwd: string): Promise<DoctorPlanningIssue[]>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ModuleRegistry } from "../core/module-registry.js";
|
|
2
|
+
import { resolveWorkspaceConfigWithLayers } from "../core/workspace-kit-config.js";
|
|
3
|
+
import { validatePlanningPersistenceForDoctor } from "../modules/task-engine/doctor-planning-persistence.js";
|
|
4
|
+
import { documentationModule } from "../modules/documentation/index.js";
|
|
5
|
+
import { taskEngineModule } from "../modules/task-engine/index.js";
|
|
6
|
+
import { approvalsModule } from "../modules/approvals/index.js";
|
|
7
|
+
import { planningModule } from "../modules/planning/index.js";
|
|
8
|
+
import { improvementModule } from "../modules/improvement/index.js";
|
|
9
|
+
import { workspaceConfigModule } from "../modules/workspace-config/index.js";
|
|
10
|
+
const defaultRegistryModules = [
|
|
11
|
+
workspaceConfigModule,
|
|
12
|
+
documentationModule,
|
|
13
|
+
taskEngineModule,
|
|
14
|
+
approvalsModule,
|
|
15
|
+
planningModule,
|
|
16
|
+
improvementModule
|
|
17
|
+
];
|
|
18
|
+
/** Resolve layered config and run SQLite planning persistence checks for `workspace-kit doctor`. */
|
|
19
|
+
export async function collectDoctorPlanningPersistenceIssues(cwd) {
|
|
20
|
+
try {
|
|
21
|
+
const registry = new ModuleRegistry(defaultRegistryModules);
|
|
22
|
+
const { effective } = await resolveWorkspaceConfigWithLayers({
|
|
23
|
+
workspacePath: cwd,
|
|
24
|
+
registry,
|
|
25
|
+
invocationConfig: {}
|
|
26
|
+
});
|
|
27
|
+
return validatePlanningPersistenceForDoctor(cwd, effective);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
path: "workspace-config",
|
|
33
|
+
reason: `config-resolution-failed: ${err.message}`
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
|
|
|
5
5
|
import { AGENT_CLI_MAP_HUMAN_DOC, appendPolicyTrace, parsePolicyApprovalFromEnv, resolveActorWithFallback } from "./core/policy.js";
|
|
6
6
|
import { runWorkspaceConfigCli } from "./core/config-cli.js";
|
|
7
7
|
import { handleRunCommand } from "./cli/run-command.js";
|
|
8
|
+
import { collectDoctorPlanningPersistenceIssues } from "./cli/doctor-planning-issues.js";
|
|
8
9
|
const EXIT_SUCCESS = 0;
|
|
9
10
|
const EXIT_VALIDATION_FAILURE = 1;
|
|
10
11
|
const EXIT_USAGE_ERROR = 2;
|
|
@@ -606,6 +607,7 @@ export async function runCli(args, options = {}) {
|
|
|
606
607
|
});
|
|
607
608
|
}
|
|
608
609
|
}
|
|
610
|
+
issues.push(...(await collectDoctorPlanningPersistenceIssues(cwd)));
|
|
609
611
|
if (issues.length > 0) {
|
|
610
612
|
writeError("workspace-kit doctor failed validation.");
|
|
611
613
|
for (const issue of issues) {
|
|
@@ -615,6 +617,7 @@ export async function runCli(args, options = {}) {
|
|
|
615
617
|
}
|
|
616
618
|
writeLine("workspace-kit doctor passed.");
|
|
617
619
|
writeLine("All canonical workspace-kit contract files are present and parseable JSON.");
|
|
620
|
+
writeLine("Effective workspace config resolved; task planning persistence checks passed (including SQLite when configured).");
|
|
618
621
|
writeLine(`Next: workspace-kit run — list module commands; see ${AGENT_CLI_MAP_HUMAN_DOC} for tier/policy copy-paste.`);
|
|
619
622
|
return EXIT_SUCCESS;
|
|
620
623
|
}
|
|
@@ -16,6 +16,59 @@ const REGISTRY = {
|
|
|
16
16
|
exposure: "public",
|
|
17
17
|
writableLayers: ["project", "user"]
|
|
18
18
|
},
|
|
19
|
+
"tasks.wishlistStoreRelativePath": {
|
|
20
|
+
key: "tasks.wishlistStoreRelativePath",
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Relative path (from workspace root) to the Wishlist JSON store when persistenceBackend is json.",
|
|
23
|
+
default: ".workspace-kit/wishlist/state.json",
|
|
24
|
+
domainScope: "project",
|
|
25
|
+
owningModule: "task-engine",
|
|
26
|
+
sensitive: false,
|
|
27
|
+
requiresRestart: false,
|
|
28
|
+
requiresApproval: false,
|
|
29
|
+
exposure: "public",
|
|
30
|
+
writableLayers: ["project", "user"]
|
|
31
|
+
},
|
|
32
|
+
"tasks.persistenceBackend": {
|
|
33
|
+
key: "tasks.persistenceBackend",
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Task + wishlist persistence: json (default) or sqlite.",
|
|
36
|
+
default: "json",
|
|
37
|
+
allowedValues: ["json", "sqlite"],
|
|
38
|
+
domainScope: "project",
|
|
39
|
+
owningModule: "task-engine",
|
|
40
|
+
sensitive: false,
|
|
41
|
+
requiresRestart: false,
|
|
42
|
+
requiresApproval: false,
|
|
43
|
+
exposure: "public",
|
|
44
|
+
writableLayers: ["project", "user"]
|
|
45
|
+
},
|
|
46
|
+
"tasks.sqliteDatabaseRelativePath": {
|
|
47
|
+
key: "tasks.sqliteDatabaseRelativePath",
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Relative path (from workspace root) to the SQLite file when persistenceBackend is sqlite.",
|
|
50
|
+
default: ".workspace-kit/tasks/workspace-kit.db",
|
|
51
|
+
domainScope: "project",
|
|
52
|
+
owningModule: "task-engine",
|
|
53
|
+
sensitive: false,
|
|
54
|
+
requiresRestart: false,
|
|
55
|
+
requiresApproval: false,
|
|
56
|
+
exposure: "public",
|
|
57
|
+
writableLayers: ["project", "user"]
|
|
58
|
+
},
|
|
59
|
+
"tasks.strictValidation": {
|
|
60
|
+
key: "tasks.strictValidation",
|
|
61
|
+
type: "boolean",
|
|
62
|
+
description: "When true, task mutations validate the full active task set before persistence and fail on invalid task records.",
|
|
63
|
+
default: false,
|
|
64
|
+
domainScope: "project",
|
|
65
|
+
owningModule: "task-engine",
|
|
66
|
+
sensitive: false,
|
|
67
|
+
requiresRestart: false,
|
|
68
|
+
requiresApproval: false,
|
|
69
|
+
exposure: "public",
|
|
70
|
+
writableLayers: ["project", "user"]
|
|
71
|
+
},
|
|
19
72
|
"policy.extraSensitiveModuleCommands": {
|
|
20
73
|
key: "policy.extraSensitiveModuleCommands",
|
|
21
74
|
type: "array",
|
|
@@ -315,10 +368,25 @@ export function validatePersistedConfigDocument(data, label) {
|
|
|
315
368
|
}
|
|
316
369
|
const t = tasks;
|
|
317
370
|
for (const k of Object.keys(t)) {
|
|
318
|
-
if (k !== "storeRelativePath"
|
|
371
|
+
if (k !== "storeRelativePath" &&
|
|
372
|
+
k !== "wishlistStoreRelativePath" &&
|
|
373
|
+
k !== "persistenceBackend" &&
|
|
374
|
+
k !== "sqliteDatabaseRelativePath") {
|
|
319
375
|
throw new Error(`config-invalid(${label}): unknown tasks.${k}`);
|
|
320
376
|
}
|
|
321
377
|
}
|
|
378
|
+
if (t.storeRelativePath !== undefined) {
|
|
379
|
+
validateValueForMetadata(REGISTRY["tasks.storeRelativePath"], t.storeRelativePath);
|
|
380
|
+
}
|
|
381
|
+
if (t.wishlistStoreRelativePath !== undefined) {
|
|
382
|
+
validateValueForMetadata(REGISTRY["tasks.wishlistStoreRelativePath"], t.wishlistStoreRelativePath);
|
|
383
|
+
}
|
|
384
|
+
if (t.persistenceBackend !== undefined) {
|
|
385
|
+
validateValueForMetadata(REGISTRY["tasks.persistenceBackend"], t.persistenceBackend);
|
|
386
|
+
}
|
|
387
|
+
if (t.sqliteDatabaseRelativePath !== undefined) {
|
|
388
|
+
validateValueForMetadata(REGISTRY["tasks.sqliteDatabaseRelativePath"], t.sqliteDatabaseRelativePath);
|
|
389
|
+
}
|
|
322
390
|
}
|
|
323
391
|
const policy = data.policy;
|
|
324
392
|
if (policy !== undefined) {
|
|
@@ -11,7 +11,8 @@ export function getProjectConfigPath(workspacePath) {
|
|
|
11
11
|
export const KIT_CONFIG_DEFAULTS = {
|
|
12
12
|
core: {},
|
|
13
13
|
tasks: {
|
|
14
|
-
storeRelativePath: ".workspace-kit/tasks/state.json"
|
|
14
|
+
storeRelativePath: ".workspace-kit/tasks/state.json",
|
|
15
|
+
strictValidation: false
|
|
15
16
|
},
|
|
16
17
|
documentation: {},
|
|
17
18
|
responseTemplates: {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type DoctorPlanningIssue = {
|
|
2
|
+
path: string;
|
|
3
|
+
reason: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* When effective config selects SQLite for task/wishlist persistence, verify the DB file exists
|
|
7
|
+
* and can be opened read-only; if a planning row is present, validate embedded JSON schemaVersion.
|
|
8
|
+
*/
|
|
9
|
+
export declare function validatePlanningPersistenceForDoctor(workspacePath: string, effectiveConfig: Record<string, unknown>): DoctorPlanningIssue[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath } from "./planning-config.js";
|
|
5
|
+
/**
|
|
6
|
+
* When effective config selects SQLite for task/wishlist persistence, verify the DB file exists
|
|
7
|
+
* and can be opened read-only; if a planning row is present, validate embedded JSON schemaVersion.
|
|
8
|
+
*/
|
|
9
|
+
export function validatePlanningPersistenceForDoctor(workspacePath, effectiveConfig) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
if (getTaskPersistenceBackend(effectiveConfig) !== "sqlite") {
|
|
12
|
+
return issues;
|
|
13
|
+
}
|
|
14
|
+
const ctx = { workspacePath, effectiveConfig };
|
|
15
|
+
const dbRel = planningSqliteDatabaseRelativePath(ctx);
|
|
16
|
+
const dbAbs = path.resolve(workspacePath, dbRel);
|
|
17
|
+
const relDisplay = path.relative(workspacePath, dbAbs) || dbAbs;
|
|
18
|
+
if (!fs.existsSync(dbAbs)) {
|
|
19
|
+
issues.push({
|
|
20
|
+
path: relDisplay,
|
|
21
|
+
reason: "sqlite-planning-db-missing (tasks.persistenceBackend is sqlite; run migrate-task-persistence json-to-sqlite or fix tasks.sqliteDatabaseRelativePath)"
|
|
22
|
+
});
|
|
23
|
+
return issues;
|
|
24
|
+
}
|
|
25
|
+
let db;
|
|
26
|
+
try {
|
|
27
|
+
db = new Database(dbAbs, { readonly: true });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
issues.push({
|
|
31
|
+
path: relDisplay,
|
|
32
|
+
reason: `sqlite-open-failed: ${err.message}`
|
|
33
|
+
});
|
|
34
|
+
return issues;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const row = db
|
|
38
|
+
.prepare("SELECT task_store_json, wishlist_store_json FROM workspace_planning_state WHERE id = 1")
|
|
39
|
+
.get();
|
|
40
|
+
if (row) {
|
|
41
|
+
try {
|
|
42
|
+
const taskDoc = JSON.parse(row.task_store_json);
|
|
43
|
+
if (taskDoc.schemaVersion !== 1) {
|
|
44
|
+
issues.push({
|
|
45
|
+
path: relDisplay,
|
|
46
|
+
reason: `sqlite-task_store_json: unsupported schemaVersion (expected 1, got ${taskDoc.schemaVersion})`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
issues.push({ path: relDisplay, reason: "sqlite-task_store_json: invalid JSON" });
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const wishDoc = JSON.parse(row.wishlist_store_json);
|
|
55
|
+
if (wishDoc.schemaVersion !== 1) {
|
|
56
|
+
issues.push({
|
|
57
|
+
path: relDisplay,
|
|
58
|
+
reason: `sqlite-wishlist_store_json: unsupported schemaVersion (expected 1, got ${wishDoc.schemaVersion})`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
issues.push({ path: relDisplay, reason: "sqlite-wishlist_store_json: invalid JSON" });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
issues.push({
|
|
69
|
+
path: relDisplay,
|
|
70
|
+
reason: `sqlite-schema-invalid: ${err.message}`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
db.close();
|
|
75
|
+
}
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
@@ -6,6 +6,9 @@ import { getNextActions } from "./suggestions.js";
|
|
|
6
6
|
import { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
|
|
7
7
|
import { openPlanningStores } from "./planning-open.js";
|
|
8
8
|
import { runMigrateTaskPersistence } from "./migrate-task-persistence-runtime.js";
|
|
9
|
+
import { planningStrictValidationEnabled } from "./planning-config.js";
|
|
10
|
+
import { validateTaskSetForStrictMode } from "./strict-task-validation.js";
|
|
11
|
+
import { validateKnownTaskTypeRequirements } from "./task-type-validation.js";
|
|
9
12
|
import { buildWishlistItemFromIntake, validateWishlistIntakePayload, validateWishlistUpdatePayload, WISHLIST_ID_RE } from "./wishlist-validation.js";
|
|
10
13
|
export { TaskStore } from "./store.js";
|
|
11
14
|
export { TransitionService } from "./service.js";
|
|
@@ -17,6 +20,7 @@ export { validateWishlistIntakePayload, validateWishlistUpdatePayload, buildWish
|
|
|
17
20
|
export { openPlanningStores } from "./planning-open.js";
|
|
18
21
|
export { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
|
|
19
22
|
const TASK_ID_RE = /^T\d+$/;
|
|
23
|
+
const SAFE_METADATA_PATH_RE = /^[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*$/;
|
|
20
24
|
const MUTABLE_TASK_FIELDS = new Set([
|
|
21
25
|
"title",
|
|
22
26
|
"type",
|
|
@@ -30,6 +34,70 @@ const MUTABLE_TASK_FIELDS = new Set([
|
|
|
30
34
|
"technicalScope",
|
|
31
35
|
"acceptanceCriteria"
|
|
32
36
|
]);
|
|
37
|
+
function isRecordLike(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function readMetadataPath(metadata, path) {
|
|
41
|
+
if (!metadata || !SAFE_METADATA_PATH_RE.test(path)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const parts = path.split(".");
|
|
45
|
+
let current = metadata;
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
if (!isRecordLike(current)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (!Object.prototype.hasOwnProperty.call(current, part)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
current = current[part];
|
|
54
|
+
}
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
function stableStringify(value) {
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
|
60
|
+
}
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
const record = value;
|
|
63
|
+
const keys = Object.keys(record).sort();
|
|
64
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
}
|
|
68
|
+
function digestPayload(value) {
|
|
69
|
+
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
|
|
70
|
+
}
|
|
71
|
+
function readIdempotencyValue(args) {
|
|
72
|
+
const raw = args.clientMutationId;
|
|
73
|
+
if (typeof raw !== "string") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const trimmed = raw.trim();
|
|
77
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
78
|
+
}
|
|
79
|
+
function findIdempotentMutation(store, mutationType, taskId, clientMutationId) {
|
|
80
|
+
const log = store.getMutationLog();
|
|
81
|
+
for (let idx = log.length - 1; idx >= 0; idx -= 1) {
|
|
82
|
+
const entry = log[idx];
|
|
83
|
+
if (entry.mutationType !== mutationType || entry.taskId !== taskId) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry.details || entry.details.clientMutationId !== clientMutationId) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
payloadDigest: typeof entry.details.payloadDigest === "string" ? entry.details.payloadDigest : undefined
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function strictValidationError(store, effectiveConfig) {
|
|
96
|
+
if (!planningStrictValidationEnabled({ effectiveConfig })) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return validateTaskSetForStrictMode(store.getAllTasks());
|
|
100
|
+
}
|
|
33
101
|
function nowIso() {
|
|
34
102
|
return new Date().toISOString();
|
|
35
103
|
}
|
|
@@ -244,6 +312,11 @@ export const taskEngineModule = {
|
|
|
244
312
|
name: "dashboard-summary",
|
|
245
313
|
file: "dashboard-summary.md",
|
|
246
314
|
description: "Stable JSON cockpit summary for UI clients (tasks + maintainer status snapshot)."
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "explain-task-engine-model",
|
|
318
|
+
file: "explain-task-engine-model.md",
|
|
319
|
+
description: "Explain model variants, planning boundaries, lifecycle transitions, and required fields."
|
|
247
320
|
}
|
|
248
321
|
]
|
|
249
322
|
}
|
|
@@ -323,6 +396,7 @@ export const taskEngineModule = {
|
|
|
323
396
|
const priority = typeof args.priority === "string" && ["P1", "P2", "P3"].includes(args.priority)
|
|
324
397
|
? args.priority
|
|
325
398
|
: undefined;
|
|
399
|
+
const clientMutationId = readIdempotencyValue(args);
|
|
326
400
|
if (!id || !title || !TASK_ID_RE.test(id) || !["proposed", "ready"].includes(status)) {
|
|
327
401
|
return {
|
|
328
402
|
ok: false,
|
|
@@ -330,9 +404,7 @@ export const taskEngineModule = {
|
|
|
330
404
|
message: "create-task requires id/title, id format T<number>, and status of proposed or ready"
|
|
331
405
|
};
|
|
332
406
|
}
|
|
333
|
-
|
|
334
|
-
return { ok: false, code: "duplicate-task-id", message: `Task '${id}' already exists` };
|
|
335
|
-
}
|
|
407
|
+
const evidenceType = command.name === "create-task-from-plan" ? "create-task-from-plan" : "create-task";
|
|
336
408
|
const timestamp = nowIso();
|
|
337
409
|
const task = {
|
|
338
410
|
id,
|
|
@@ -351,7 +423,6 @@ export const taskEngineModule = {
|
|
|
351
423
|
technicalScope: Array.isArray(args.technicalScope) ? args.technicalScope.filter((x) => typeof x === "string") : undefined,
|
|
352
424
|
acceptanceCriteria: Array.isArray(args.acceptanceCriteria) ? args.acceptanceCriteria.filter((x) => typeof x === "string") : undefined
|
|
353
425
|
};
|
|
354
|
-
store.addTask(task);
|
|
355
426
|
if (command.name === "create-task-from-plan") {
|
|
356
427
|
const planRef = typeof args.planRef === "string" && args.planRef.trim().length > 0 ? args.planRef.trim() : undefined;
|
|
357
428
|
if (!planRef) {
|
|
@@ -362,13 +433,63 @@ export const taskEngineModule = {
|
|
|
362
433
|
};
|
|
363
434
|
}
|
|
364
435
|
task.metadata = { ...(task.metadata ?? {}), planRef };
|
|
365
|
-
store.updateTask(task);
|
|
366
436
|
}
|
|
367
|
-
const
|
|
437
|
+
const createPayloadForDigest = {
|
|
438
|
+
id: task.id,
|
|
439
|
+
title: task.title,
|
|
440
|
+
type: task.type,
|
|
441
|
+
status: task.status,
|
|
442
|
+
priority: task.priority,
|
|
443
|
+
dependsOn: task.dependsOn ?? [],
|
|
444
|
+
unblocks: task.unblocks ?? [],
|
|
445
|
+
phase: task.phase ?? null,
|
|
446
|
+
metadata: task.metadata ?? null,
|
|
447
|
+
ownership: task.ownership ?? null,
|
|
448
|
+
approach: task.approach ?? null,
|
|
449
|
+
technicalScope: task.technicalScope ?? [],
|
|
450
|
+
acceptanceCriteria: task.acceptanceCriteria ?? []
|
|
451
|
+
};
|
|
452
|
+
const payloadDigest = digestPayload(createPayloadForDigest);
|
|
453
|
+
if (clientMutationId) {
|
|
454
|
+
const prior = findIdempotentMutation(store, evidenceType, id, clientMutationId);
|
|
455
|
+
if (prior) {
|
|
456
|
+
if (prior.payloadDigest !== payloadDigest) {
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
code: "idempotency-key-conflict",
|
|
460
|
+
message: `clientMutationId '${clientMutationId}' was already used for a different ${evidenceType} payload on ${id}`
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
code: "task-create-idempotent-replay",
|
|
466
|
+
message: `Idempotent create replay for task '${id}'`,
|
|
467
|
+
data: { task: store.getTask(id), replayed: true }
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (store.getTask(id)) {
|
|
472
|
+
return { ok: false, code: "duplicate-task-id", message: `Task '${id}' already exists` };
|
|
473
|
+
}
|
|
474
|
+
const knownTypeValidationError = validateKnownTaskTypeRequirements(task);
|
|
475
|
+
if (knownTypeValidationError) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
code: knownTypeValidationError.code,
|
|
479
|
+
message: knownTypeValidationError.message
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
store.addTask(task);
|
|
368
483
|
store.addMutationEvidence(mutationEvidence(evidenceType, id, actor, {
|
|
369
484
|
initialStatus: task.status,
|
|
370
|
-
source: command.name
|
|
485
|
+
source: command.name,
|
|
486
|
+
clientMutationId,
|
|
487
|
+
payloadDigest
|
|
371
488
|
}));
|
|
489
|
+
const strictIssue = strictValidationError(store, ctx.effectiveConfig);
|
|
490
|
+
if (strictIssue) {
|
|
491
|
+
return { ok: false, code: "strict-task-validation-failed", message: strictIssue };
|
|
492
|
+
}
|
|
372
493
|
await store.save();
|
|
373
494
|
return {
|
|
374
495
|
ok: true,
|
|
@@ -388,6 +509,7 @@ export const taskEngineModule = {
|
|
|
388
509
|
if (!taskId || !updates) {
|
|
389
510
|
return { ok: false, code: "invalid-task-schema", message: "update-task requires taskId and updates object" };
|
|
390
511
|
}
|
|
512
|
+
const clientMutationId = readIdempotencyValue(args);
|
|
391
513
|
const task = store.getTask(taskId);
|
|
392
514
|
if (!task) {
|
|
393
515
|
return { ok: false, code: "task-not-found", message: `Task '${taskId}' not found` };
|
|
@@ -401,8 +523,43 @@ export const taskEngineModule = {
|
|
|
401
523
|
};
|
|
402
524
|
}
|
|
403
525
|
const updatedTask = { ...task, ...updates, updatedAt: nowIso() };
|
|
526
|
+
const payloadDigest = digestPayload({ taskId, updates });
|
|
527
|
+
if (clientMutationId) {
|
|
528
|
+
const prior = findIdempotentMutation(store, "update-task", taskId, clientMutationId);
|
|
529
|
+
if (prior) {
|
|
530
|
+
if (prior.payloadDigest !== payloadDigest) {
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
code: "idempotency-key-conflict",
|
|
534
|
+
message: `clientMutationId '${clientMutationId}' was already used for a different update-task payload on ${taskId}`
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
ok: true,
|
|
539
|
+
code: "task-update-idempotent-replay",
|
|
540
|
+
message: `Idempotent update replay for task '${taskId}'`,
|
|
541
|
+
data: { task, replayed: true }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const knownTypeValidationError = validateKnownTaskTypeRequirements(updatedTask);
|
|
546
|
+
if (knownTypeValidationError) {
|
|
547
|
+
return {
|
|
548
|
+
ok: false,
|
|
549
|
+
code: knownTypeValidationError.code,
|
|
550
|
+
message: knownTypeValidationError.message
|
|
551
|
+
};
|
|
552
|
+
}
|
|
404
553
|
store.updateTask(updatedTask);
|
|
405
|
-
store.addMutationEvidence(mutationEvidence("update-task", taskId, actor, {
|
|
554
|
+
store.addMutationEvidence(mutationEvidence("update-task", taskId, actor, {
|
|
555
|
+
updatedFields: Object.keys(updates),
|
|
556
|
+
clientMutationId,
|
|
557
|
+
payloadDigest
|
|
558
|
+
}));
|
|
559
|
+
const strictIssue = strictValidationError(store, ctx.effectiveConfig);
|
|
560
|
+
if (strictIssue) {
|
|
561
|
+
return { ok: false, code: "strict-task-validation-failed", message: strictIssue };
|
|
562
|
+
}
|
|
406
563
|
await store.save();
|
|
407
564
|
return { ok: true, code: "task-updated", message: `Updated task '${taskId}'`, data: { task: updatedTask } };
|
|
408
565
|
}
|
|
@@ -424,6 +581,10 @@ export const taskEngineModule = {
|
|
|
424
581
|
const updatedTask = { ...task, archived: true, archivedAt, updatedAt: archivedAt };
|
|
425
582
|
store.updateTask(updatedTask);
|
|
426
583
|
store.addMutationEvidence(mutationEvidence("archive-task", taskId, actor));
|
|
584
|
+
const strictIssue = strictValidationError(store, ctx.effectiveConfig);
|
|
585
|
+
if (strictIssue) {
|
|
586
|
+
return { ok: false, code: "strict-task-validation-failed", message: strictIssue };
|
|
587
|
+
}
|
|
427
588
|
await store.save();
|
|
428
589
|
return { ok: true, code: "task-archived", message: `Archived task '${taskId}'`, data: { task: updatedTask } };
|
|
429
590
|
}
|
|
@@ -500,6 +661,10 @@ export const taskEngineModule = {
|
|
|
500
661
|
store.updateTask(updatedTask);
|
|
501
662
|
const mutationType = command.name === "add-dependency" ? "add-dependency" : "remove-dependency";
|
|
502
663
|
store.addMutationEvidence(mutationEvidence(mutationType, taskId, actor, { dependencyTaskId }));
|
|
664
|
+
const strictIssue = strictValidationError(store, ctx.effectiveConfig);
|
|
665
|
+
if (strictIssue) {
|
|
666
|
+
return { ok: false, code: "strict-task-validation-failed", message: strictIssue };
|
|
667
|
+
}
|
|
503
668
|
await store.save();
|
|
504
669
|
return {
|
|
505
670
|
ok: true,
|
|
@@ -608,6 +773,17 @@ export const taskEngineModule = {
|
|
|
608
773
|
if (command.name === "list-tasks") {
|
|
609
774
|
const statusFilter = typeof args.status === "string" ? args.status : undefined;
|
|
610
775
|
const phaseFilter = typeof args.phase === "string" ? args.phase : undefined;
|
|
776
|
+
const typeFilter = typeof args.type === "string" && args.type.trim().length > 0 ? args.type.trim() : undefined;
|
|
777
|
+
const categoryFilter = typeof args.category === "string" && args.category.trim().length > 0 ? args.category.trim() : undefined;
|
|
778
|
+
const tagsFilterRaw = args.tags;
|
|
779
|
+
const tagsFilter = typeof tagsFilterRaw === "string"
|
|
780
|
+
? [tagsFilterRaw]
|
|
781
|
+
: Array.isArray(tagsFilterRaw)
|
|
782
|
+
? tagsFilterRaw.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
783
|
+
: [];
|
|
784
|
+
const metadataFilters = isRecordLike(args.metadataFilters)
|
|
785
|
+
? Object.entries(args.metadataFilters).filter(([path]) => SAFE_METADATA_PATH_RE.test(path))
|
|
786
|
+
: [];
|
|
611
787
|
const includeArchived = args.includeArchived === true;
|
|
612
788
|
let tasks = includeArchived ? store.getAllTasks() : store.getActiveTasks();
|
|
613
789
|
if (statusFilter) {
|
|
@@ -616,6 +792,25 @@ export const taskEngineModule = {
|
|
|
616
792
|
if (phaseFilter) {
|
|
617
793
|
tasks = tasks.filter((t) => t.phase === phaseFilter);
|
|
618
794
|
}
|
|
795
|
+
if (typeFilter) {
|
|
796
|
+
tasks = tasks.filter((t) => t.type === typeFilter);
|
|
797
|
+
}
|
|
798
|
+
if (categoryFilter) {
|
|
799
|
+
tasks = tasks.filter((t) => readMetadataPath(t.metadata, "category") === categoryFilter);
|
|
800
|
+
}
|
|
801
|
+
if (tagsFilter.length > 0) {
|
|
802
|
+
tasks = tasks.filter((t) => {
|
|
803
|
+
const tags = readMetadataPath(t.metadata, "tags");
|
|
804
|
+
if (!Array.isArray(tags)) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
const normalized = tags.filter((entry) => typeof entry === "string");
|
|
808
|
+
return tagsFilter.every((tag) => normalized.includes(tag));
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (metadataFilters.length > 0) {
|
|
812
|
+
tasks = tasks.filter((t) => metadataFilters.every(([path, expected]) => readMetadataPath(t.metadata, path) === expected));
|
|
813
|
+
}
|
|
619
814
|
return {
|
|
620
815
|
ok: true,
|
|
621
816
|
code: "tasks-listed",
|
|
@@ -651,6 +846,69 @@ export const taskEngineModule = {
|
|
|
651
846
|
data: { ...suggestion, scope: "tasks-only" }
|
|
652
847
|
};
|
|
653
848
|
}
|
|
849
|
+
if (command.name === "explain-task-engine-model") {
|
|
850
|
+
const allStatuses = ["proposed", "ready", "in_progress", "blocked", "completed", "cancelled"];
|
|
851
|
+
const lifecycle = allStatuses.map((status) => ({
|
|
852
|
+
status,
|
|
853
|
+
allowedActions: getAllowedTransitionsFrom(status).map((entry) => ({
|
|
854
|
+
action: entry.action,
|
|
855
|
+
targetStatus: entry.to
|
|
856
|
+
}))
|
|
857
|
+
}));
|
|
858
|
+
return {
|
|
859
|
+
ok: true,
|
|
860
|
+
code: "task-engine-model-explained",
|
|
861
|
+
message: "Task Engine model variants, planning boundary, and lifecycle transitions.",
|
|
862
|
+
data: {
|
|
863
|
+
modelVersion: 1,
|
|
864
|
+
variants: [
|
|
865
|
+
{
|
|
866
|
+
variant: "execution-task",
|
|
867
|
+
idPattern: "^T[0-9]+$",
|
|
868
|
+
appearsInExecutionPlanning: true,
|
|
869
|
+
requiredFields: ["id", "title", "type", "status", "createdAt", "updatedAt"],
|
|
870
|
+
optionalFields: [
|
|
871
|
+
"priority",
|
|
872
|
+
"dependsOn",
|
|
873
|
+
"unblocks",
|
|
874
|
+
"phase",
|
|
875
|
+
"metadata",
|
|
876
|
+
"ownership",
|
|
877
|
+
"approach",
|
|
878
|
+
"technicalScope",
|
|
879
|
+
"acceptanceCriteria"
|
|
880
|
+
]
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
variant: "wishlist-item",
|
|
884
|
+
idPattern: "^W[0-9]+$",
|
|
885
|
+
appearsInExecutionPlanning: false,
|
|
886
|
+
requiredFields: [
|
|
887
|
+
"id",
|
|
888
|
+
"title",
|
|
889
|
+
"problemStatement",
|
|
890
|
+
"expectedOutcome",
|
|
891
|
+
"impact",
|
|
892
|
+
"constraints",
|
|
893
|
+
"successSignals",
|
|
894
|
+
"requestor",
|
|
895
|
+
"evidenceRef",
|
|
896
|
+
"status",
|
|
897
|
+
"createdAt",
|
|
898
|
+
"updatedAt"
|
|
899
|
+
],
|
|
900
|
+
optionalFields: ["metadata", "convertedTaskIds", "closedAt", "closeReason"],
|
|
901
|
+
notes: "Wishlist is ideation-only and excluded from task ready queues."
|
|
902
|
+
}
|
|
903
|
+
],
|
|
904
|
+
planningBoundary: {
|
|
905
|
+
executionQueues: "tasks-only",
|
|
906
|
+
wishlistScope: "separate-namespace"
|
|
907
|
+
},
|
|
908
|
+
executionTaskLifecycle: lifecycle
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
}
|
|
654
912
|
if (command.name === "get-task-summary") {
|
|
655
913
|
const tasks = store.getActiveTasks();
|
|
656
914
|
const suggestion = getNextActions(tasks);
|
|
@@ -864,6 +1122,12 @@ export const taskEngineModule = {
|
|
|
864
1122
|
}
|
|
865
1123
|
wishlistStore.updateItem(updatedWishlist);
|
|
866
1124
|
};
|
|
1125
|
+
if (planningStrictValidationEnabled({ effectiveConfig: ctx.effectiveConfig })) {
|
|
1126
|
+
const strictIssue = validateTaskSetForStrictMode([...store.getAllTasks(), ...built]);
|
|
1127
|
+
if (strictIssue) {
|
|
1128
|
+
return { ok: false, code: "strict-task-validation-failed", message: strictIssue };
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
867
1131
|
if (planning.kind === "sqlite") {
|
|
868
1132
|
planning.sqliteDual.withTransaction(applyConvertMutations);
|
|
869
1133
|
}
|
|
@@ -8,3 +8,6 @@ export declare function planningWishlistStoreRelativePath(ctx: {
|
|
|
8
8
|
effectiveConfig?: Record<string, unknown>;
|
|
9
9
|
}): string | undefined;
|
|
10
10
|
export declare function planningSqliteDatabaseRelativePath(ctx: ModuleLifecycleContext): string;
|
|
11
|
+
export declare function planningStrictValidationEnabled(ctx: {
|
|
12
|
+
effectiveConfig?: Record<string, unknown>;
|
|
13
|
+
}): boolean;
|
|
@@ -35,3 +35,10 @@ export function planningSqliteDatabaseRelativePath(ctx) {
|
|
|
35
35
|
? p.trim()
|
|
36
36
|
: ".workspace-kit/tasks/workspace-kit.db";
|
|
37
37
|
}
|
|
38
|
+
export function planningStrictValidationEnabled(ctx) {
|
|
39
|
+
const tasks = ctx.effectiveConfig?.tasks;
|
|
40
|
+
if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return tasks.strictValidation === true;
|
|
44
|
+
}
|