@workflow-cannon/workspace-kit 0.16.1 → 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/core/config-metadata.js +13 -0
- package/dist/core/workspace-kit-config.js +2 -1
- 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
|
@@ -56,6 +56,19 @@ const REGISTRY = {
|
|
|
56
56
|
exposure: "public",
|
|
57
57
|
writableLayers: ["project", "user"]
|
|
58
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
|
+
},
|
|
59
72
|
"policy.extraSensitiveModuleCommands": {
|
|
60
73
|
key: "policy.extraSensitiveModuleCommands",
|
|
61
74
|
type: "array",
|
|
@@ -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: {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { validateKnownTaskTypeRequirements } from "./task-type-validation.js";
|
|
2
|
+
const TASK_ID_RE = /^T\d+$/;
|
|
3
|
+
const ALLOWED_STATUS = new Set(["proposed", "ready", "in_progress", "blocked", "completed", "cancelled"]);
|
|
4
|
+
function isStringArray(value) {
|
|
5
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
6
|
+
}
|
|
7
|
+
function isIsoDateLike(value) {
|
|
8
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
9
|
+
}
|
|
10
|
+
export function validateTaskEntityForStrictMode(task) {
|
|
11
|
+
if (!TASK_ID_RE.test(task.id)) {
|
|
12
|
+
return `task '${task.id}' has invalid id format`;
|
|
13
|
+
}
|
|
14
|
+
if (typeof task.title !== "string" || task.title.trim().length === 0) {
|
|
15
|
+
return `task '${task.id}' has empty title`;
|
|
16
|
+
}
|
|
17
|
+
if (!ALLOWED_STATUS.has(task.status)) {
|
|
18
|
+
return `task '${task.id}' has unsupported status '${String(task.status)}'`;
|
|
19
|
+
}
|
|
20
|
+
if (typeof task.type !== "string" || task.type.trim().length === 0) {
|
|
21
|
+
return `task '${task.id}' has empty type`;
|
|
22
|
+
}
|
|
23
|
+
if (!isIsoDateLike(task.createdAt) || !isIsoDateLike(task.updatedAt)) {
|
|
24
|
+
return `task '${task.id}' has invalid createdAt/updatedAt timestamps`;
|
|
25
|
+
}
|
|
26
|
+
if (task.dependsOn !== undefined && !isStringArray(task.dependsOn)) {
|
|
27
|
+
return `task '${task.id}' has invalid dependsOn values`;
|
|
28
|
+
}
|
|
29
|
+
if (task.unblocks !== undefined && !isStringArray(task.unblocks)) {
|
|
30
|
+
return `task '${task.id}' has invalid unblocks values`;
|
|
31
|
+
}
|
|
32
|
+
if (task.technicalScope !== undefined && !isStringArray(task.technicalScope)) {
|
|
33
|
+
return `task '${task.id}' has invalid technicalScope values`;
|
|
34
|
+
}
|
|
35
|
+
if (task.acceptanceCriteria !== undefined && !isStringArray(task.acceptanceCriteria)) {
|
|
36
|
+
return `task '${task.id}' has invalid acceptanceCriteria values`;
|
|
37
|
+
}
|
|
38
|
+
const knownTypeValidation = validateKnownTaskTypeRequirements(task);
|
|
39
|
+
if (knownTypeValidation) {
|
|
40
|
+
return `task '${task.id}': ${knownTypeValidation.message}`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function validateTaskSetForStrictMode(tasks) {
|
|
45
|
+
for (const task of tasks) {
|
|
46
|
+
const issue = validateTaskEntityForStrictMode(task);
|
|
47
|
+
if (issue) {
|
|
48
|
+
return issue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TaskEntity } from "./types.js";
|
|
2
|
+
export type KnownTaskTypeValidationError = {
|
|
3
|
+
code: "invalid-task-type-requirements";
|
|
4
|
+
message: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Optional strictness for known task types.
|
|
8
|
+
* Unknown/custom task types remain passthrough for compatibility.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateKnownTaskTypeRequirements(task: TaskEntity): KnownTaskTypeValidationError | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function nonEmptyStringArray(value) {
|
|
2
|
+
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Optional strictness for known task types.
|
|
6
|
+
* Unknown/custom task types remain passthrough for compatibility.
|
|
7
|
+
*/
|
|
8
|
+
export function validateKnownTaskTypeRequirements(task) {
|
|
9
|
+
if (task.type !== "improvement") {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const missing = [];
|
|
13
|
+
if (!nonEmptyStringArray(task.acceptanceCriteria)) {
|
|
14
|
+
missing.push("acceptanceCriteria");
|
|
15
|
+
}
|
|
16
|
+
if (!nonEmptyStringArray(task.technicalScope)) {
|
|
17
|
+
missing.push("technicalScope");
|
|
18
|
+
}
|
|
19
|
+
if (missing.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
code: "invalid-task-type-requirements",
|
|
24
|
+
message: `Type '${task.type}' requires non-empty fields: ${missing.join(", ")}`
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -65,7 +65,7 @@ export type TaskEngineError = {
|
|
|
65
65
|
code: TaskEngineErrorCode;
|
|
66
66
|
message: string;
|
|
67
67
|
};
|
|
68
|
-
export type TaskEngineErrorCode = "invalid-transition" | "guard-rejected" | "dependency-unsatisfied" | "task-not-found" | "duplicate-task-id" | "invalid-task-schema" | "invalid-task-update" | "invalid-task-id-format" | "task-archived" | "dependency-cycle" | "duplicate-dependency" | "storage-read-error" | "storage-write-error" | "invalid-adapter" | "import-parse-error";
|
|
68
|
+
export type TaskEngineErrorCode = "invalid-transition" | "guard-rejected" | "dependency-unsatisfied" | "task-not-found" | "duplicate-task-id" | "invalid-task-schema" | "invalid-task-type-requirements" | "invalid-task-update" | "invalid-task-id-format" | "task-archived" | "dependency-cycle" | "duplicate-dependency" | "storage-read-error" | "storage-write-error" | "invalid-adapter" | "import-parse-error";
|
|
69
69
|
export type TaskAdapter = {
|
|
70
70
|
name: string;
|
|
71
71
|
supports: () => TaskAdapterCapability[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workflow-cannon/workspace-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"packageManager": "pnpm@10.0.0",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "tsc -p tsconfig.json",
|
|
22
|
-
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"check": "tsc -p tsconfig.json --noEmit && node scripts/check-task-engine-run-contracts.mjs",
|
|
23
23
|
"clean": "rm -rf dist",
|
|
24
24
|
"test": "pnpm run build && node --test test/**/*.test.mjs",
|
|
25
25
|
"pack:dry-run": "pnpm run build && pnpm pack --pack-destination ./artifacts/workspace-kit-pack",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"files": [
|
|
49
49
|
"dist",
|
|
50
50
|
"src/modules/documentation",
|
|
51
|
+
"schemas",
|
|
51
52
|
"package.json"
|
|
52
53
|
],
|
|
53
54
|
"dependencies": {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://workflow-cannon.dev/schemas/compatibility-matrix.schema.json",
|
|
4
|
+
"title": "Workflow Cannon Compatibility Matrix",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "generatedAt", "runtime", "modules", "channels"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {
|
|
9
|
+
"type": "integer",
|
|
10
|
+
"minimum": 1
|
|
11
|
+
},
|
|
12
|
+
"generatedAt": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"format": "date-time"
|
|
15
|
+
},
|
|
16
|
+
"runtime": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["runtimeVersion", "moduleContractVersion", "configSchema", "policyTraceSchema"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"runtimeVersion": { "type": "string", "minLength": 1 },
|
|
21
|
+
"moduleContractVersion": { "type": "string", "enum": ["1"] },
|
|
22
|
+
"configSchema": { "type": "integer", "minimum": 1 },
|
|
23
|
+
"policyTraceSchema": { "type": "integer", "minimum": 1 }
|
|
24
|
+
},
|
|
25
|
+
"additionalProperties": false
|
|
26
|
+
},
|
|
27
|
+
"modules": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"minItems": 1,
|
|
30
|
+
"items": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"required": ["id", "version", "contractVersion", "compatibilityLevel", "supportedRuntime", "severity"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": { "type": "string", "minLength": 1 },
|
|
35
|
+
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
|
36
|
+
"contractVersion": { "type": "string", "enum": ["1"] },
|
|
37
|
+
"compatibilityLevel": { "type": "string", "enum": ["strict", "compatible", "warn-only"] },
|
|
38
|
+
"supportedRuntime": { "type": "string", "minLength": 1 },
|
|
39
|
+
"severity": { "type": "string", "enum": ["error", "warn"] },
|
|
40
|
+
"notes": { "type": "string" }
|
|
41
|
+
},
|
|
42
|
+
"additionalProperties": false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"channels": {
|
|
46
|
+
"type": "array",
|
|
47
|
+
"minItems": 3,
|
|
48
|
+
"items": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": ["name", "npmDistTag", "releaseLabel", "tagPrefix", "allowPrerelease"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"name": { "type": "string", "enum": ["canary", "stable", "lts"] },
|
|
53
|
+
"npmDistTag": { "type": "string", "minLength": 1 },
|
|
54
|
+
"releaseLabel": { "type": "string", "minLength": 1 },
|
|
55
|
+
"tagPrefix": { "type": "string", "minLength": 1 },
|
|
56
|
+
"allowPrerelease": { "type": "boolean" },
|
|
57
|
+
"rollback": { "type": "string" }
|
|
58
|
+
},
|
|
59
|
+
"additionalProperties": false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"additionalProperties": false
|
|
64
|
+
}
|