@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.
@@ -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
- if (store.getTask(id)) {
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 evidenceType = command.name === "create-task-from-plan" ? "create-task-from-plan" : "create-task";
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, { updatedFields: Object.keys(updates) }));
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,3 @@
1
+ import type { TaskEntity } from "./types.js";
2
+ export declare function validateTaskEntityForStrictMode(task: TaskEntity): string | null;
3
+ export declare function validateTaskSetForStrictMode(tasks: TaskEntity[]): string | null;
@@ -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.16.1",
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
+ }