@cleocode/core 2026.3.57 → 2026.3.59
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/agents/agent-registry.d.ts +206 -0
- package/dist/agents/agent-registry.d.ts.map +1 -0
- package/dist/agents/agent-schema.d.ts.map +1 -1
- package/dist/agents/execution-learning.d.ts +223 -0
- package/dist/agents/execution-learning.d.ts.map +1 -0
- package/dist/agents/health-monitor.d.ts +161 -0
- package/dist/agents/health-monitor.d.ts.map +1 -0
- package/dist/agents/index.d.ts +4 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/retry.d.ts +57 -4
- package/dist/agents/retry.d.ts.map +1 -1
- package/dist/backfill/index.d.ts +83 -0
- package/dist/backfill/index.d.ts.map +1 -0
- package/dist/bootstrap.d.ts +1 -1
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6985 -5068
- package/dist/index.js.map +4 -4
- package/dist/intelligence/adaptive-validation.d.ts +151 -0
- package/dist/intelligence/adaptive-validation.d.ts.map +1 -0
- package/dist/intelligence/impact.d.ts +34 -1
- package/dist/intelligence/impact.d.ts.map +1 -1
- package/dist/intelligence/index.d.ts +7 -2
- package/dist/intelligence/index.d.ts.map +1 -1
- package/dist/intelligence/types.d.ts +60 -0
- package/dist/intelligence/types.d.ts.map +1 -1
- package/dist/internal.d.ts +8 -4
- package/dist/internal.d.ts.map +1 -1
- package/dist/lib/index.d.ts +10 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/retry.d.ts +128 -0
- package/dist/lib/retry.d.ts.map +1 -0
- package/dist/nexus/sharing/index.d.ts +48 -2
- package/dist/nexus/sharing/index.d.ts.map +1 -1
- package/dist/sessions/session-enforcement.d.ts.map +1 -1
- package/dist/stats/index.d.ts +1 -0
- package/dist/stats/index.d.ts.map +1 -1
- package/dist/stats/workflow-telemetry.d.ts +89 -0
- package/dist/stats/workflow-telemetry.d.ts.map +1 -0
- package/dist/store/brain-schema.d.ts.map +1 -1
- package/dist/store/converters.d.ts.map +1 -1
- package/dist/store/cross-db-cleanup.d.ts +93 -0
- package/dist/store/cross-db-cleanup.d.ts.map +1 -0
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/migration-sqlite.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/task-store.d.ts.map +1 -1
- package/dist/store/tasks-schema.d.ts +18 -3
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/validation-schemas.d.ts +32 -0
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/tasks/add.d.ts +10 -1
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/enforcement.d.ts +22 -0
- package/dist/tasks/enforcement.d.ts.map +1 -0
- package/dist/tasks/epic-enforcement.d.ts +199 -0
- package/dist/tasks/epic-enforcement.d.ts.map +1 -0
- package/dist/tasks/index.d.ts +1 -1
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/pipeline-stage.d.ts +181 -0
- package/dist/tasks/pipeline-stage.d.ts.map +1 -0
- package/dist/tasks/update.d.ts +2 -0
- package/dist/tasks/update.d.ts.map +1 -1
- package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/migration.sql +12 -0
- package/migrations/drizzle-brain/20260321000001_t033-brain-indexes/snapshot.json +1232 -0
- package/migrations/drizzle-tasks/20260321000000_t033-connection-health/migration.sql +518 -0
- package/migrations/drizzle-tasks/20260321000000_t033-connection-health/snapshot.json +4312 -0
- package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/migration.sql +82 -0
- package/migrations/drizzle-tasks/20260321000002_t060-pipeline-stage-binding/snapshot.json +9 -0
- package/package.json +5 -5
- package/schemas/config.schema.json +37 -1547
- package/src/__tests__/sharing.test.ts +24 -0
- package/src/agents/__tests__/agent-registry.test.ts +351 -0
- package/src/agents/__tests__/execution-learning.test.ts +684 -0
- package/src/agents/__tests__/health-monitor.test.ts +332 -0
- package/src/agents/__tests__/registry.test.ts +30 -2
- package/src/agents/agent-registry.ts +394 -0
- package/src/agents/agent-schema.ts +5 -0
- package/src/agents/execution-learning.ts +675 -0
- package/src/agents/health-monitor.ts +279 -0
- package/src/agents/index.ts +37 -1
- package/src/agents/retry.ts +57 -4
- package/src/backfill/index.ts +309 -0
- package/src/bootstrap.ts +1 -1
- package/src/config.ts +126 -0
- package/src/index.ts +8 -1
- package/src/intelligence/__tests__/adaptive-validation.test.ts +694 -0
- package/src/intelligence/__tests__/impact.test.ts +165 -1
- package/src/intelligence/adaptive-validation.ts +764 -0
- package/src/intelligence/impact.ts +203 -0
- package/src/intelligence/index.ts +19 -0
- package/src/intelligence/types.ts +76 -0
- package/src/internal.ts +39 -0
- package/src/lib/__tests__/retry.test.ts +321 -0
- package/src/lib/index.ts +16 -0
- package/src/lib/retry.ts +224 -0
- package/src/lifecycle/__tests__/chain-store.test.ts +7 -0
- package/src/lifecycle/__tests__/tessera-engine.test.ts +52 -0
- package/src/nexus/sharing/index.ts +142 -2
- package/src/sessions/__tests__/session-edge-cases.test.ts +24 -1
- package/src/sessions/session-enforcement.ts +13 -2
- package/src/stats/index.ts +7 -0
- package/src/stats/workflow-telemetry.ts +502 -0
- package/src/store/__tests__/migration-safety.test.ts +3 -0
- package/src/store/__tests__/session-store.test.ts +132 -1
- package/src/store/__tests__/task-store.test.ts +22 -1
- package/src/store/__tests__/test-db-helper.ts +29 -2
- package/src/store/brain-schema.ts +4 -1
- package/src/store/converters.ts +2 -0
- package/src/store/cross-db-cleanup.ts +192 -0
- package/src/store/db-helpers.ts +2 -0
- package/src/store/migration-sqlite.ts +6 -0
- package/src/store/sqlite-data-accessor.ts +20 -28
- package/src/store/sqlite.ts +14 -2
- package/src/store/task-store.ts +6 -0
- package/src/store/tasks-schema.ts +59 -20
- package/src/tasks/__tests__/add.test.ts +16 -0
- package/src/tasks/__tests__/complete-unblocks.test.ts +10 -1
- package/src/tasks/__tests__/complete.test.ts +11 -2
- package/src/tasks/__tests__/epic-enforcement.test.ts +909 -0
- package/src/tasks/__tests__/minimal-test.test.ts +28 -0
- package/src/tasks/__tests__/pipeline-stage.test.ts +403 -0
- package/src/tasks/__tests__/update.test.ts +40 -6
- package/src/tasks/add.ts +128 -2
- package/src/tasks/complete.ts +29 -17
- package/src/tasks/enforcement.ts +127 -0
- package/src/tasks/epic-enforcement.ts +364 -0
- package/src/tasks/index.ts +1 -0
- package/src/tasks/pipeline-stage.ts +293 -0
- package/src/tasks/update.ts +62 -0
- package/templates/config.template.json +34 -111
- package/templates/global-config.template.json +24 -40
package/src/tasks/add.ts
CHANGED
|
@@ -12,14 +12,24 @@ import type {
|
|
|
12
12
|
TaskSize,
|
|
13
13
|
TaskStatus,
|
|
14
14
|
TaskType,
|
|
15
|
+
TaskVerification,
|
|
15
16
|
} from '@cleocode/contracts';
|
|
16
17
|
// setMetaValue now called via tx.setMetaValue inside transaction (T023)
|
|
17
18
|
import { ExitCode, TASK_STATUSES } from '@cleocode/contracts';
|
|
18
|
-
import { loadConfig } from '../config.js';
|
|
19
|
+
import { getRawConfigValue, loadConfig } from '../config.js';
|
|
19
20
|
import { CleoError } from '../errors.js';
|
|
20
21
|
import { allocateNextTaskId } from '../sequence/index.js';
|
|
22
|
+
import { requireActiveSession } from '../sessions/session-enforcement.js';
|
|
21
23
|
import type { DataAccessor, TransactionAccessor } from '../store/data-accessor.js';
|
|
24
|
+
import { createAcceptanceEnforcement } from './enforcement.js';
|
|
25
|
+
import {
|
|
26
|
+
findEpicAncestor,
|
|
27
|
+
getLifecycleMode,
|
|
28
|
+
validateChildStageCeiling,
|
|
29
|
+
validateEpicCreation,
|
|
30
|
+
} from './epic-enforcement.js';
|
|
22
31
|
import { resolveHierarchyPolicy } from './hierarchy-policy.js';
|
|
32
|
+
import { resolveDefaultPipelineStage, validatePipelineStage } from './pipeline-stage.js';
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* Options for creating a task.
|
|
@@ -44,6 +54,8 @@ export interface AddTaskOptions {
|
|
|
44
54
|
position?: number;
|
|
45
55
|
addPhase?: boolean;
|
|
46
56
|
dryRun?: boolean;
|
|
57
|
+
/** RCASD-IVTR+C pipeline stage to assign. Auto-resolved if not provided. @task T060 */
|
|
58
|
+
pipelineStage?: string;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/** Result of adding a task. */
|
|
@@ -53,6 +65,28 @@ export interface AddTaskResult {
|
|
|
53
65
|
dryRun?: boolean;
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Build the default verification metadata applied to every new task.
|
|
70
|
+
* Gates are initialized to false (not yet passed). `passed` starts false
|
|
71
|
+
* because no gates have been verified yet.
|
|
72
|
+
* @task T061
|
|
73
|
+
*/
|
|
74
|
+
export function buildDefaultVerification(initializedAt: string): TaskVerification {
|
|
75
|
+
return {
|
|
76
|
+
passed: false,
|
|
77
|
+
round: 1,
|
|
78
|
+
gates: {
|
|
79
|
+
implemented: false,
|
|
80
|
+
testsPassed: false,
|
|
81
|
+
qaPassed: false,
|
|
82
|
+
},
|
|
83
|
+
lastAgent: null,
|
|
84
|
+
lastUpdated: null,
|
|
85
|
+
failureLog: [],
|
|
86
|
+
initializedAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
/**
|
|
57
91
|
* Validate a task title.
|
|
58
92
|
* @task T4460
|
|
@@ -405,6 +439,24 @@ export async function addTask(
|
|
|
405
439
|
);
|
|
406
440
|
}
|
|
407
441
|
|
|
442
|
+
await requireActiveSession('tasks.add', cwd);
|
|
443
|
+
|
|
444
|
+
// Orphan prevention (T101): non-epic tasks must have a parent in strict mode.
|
|
445
|
+
// Epics are root containers and are exempt. Only enforced in strict lifecycle mode.
|
|
446
|
+
const parentId = options.parentId ?? null;
|
|
447
|
+
if (!parentId && options.type !== 'epic') {
|
|
448
|
+
const lifecycleMode = await getLifecycleMode(cwd);
|
|
449
|
+
if (lifecycleMode === 'strict') {
|
|
450
|
+
throw new CleoError(
|
|
451
|
+
ExitCode.VALIDATION_ERROR,
|
|
452
|
+
'Tasks must have a parent (epic or task) in strict mode. Use --parent <epicId> or set lifecycle.mode to "advisory".',
|
|
453
|
+
{
|
|
454
|
+
fix: 'cleo add "Task title" --parent T### --acceptance "AC1|AC2|AC3"',
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
408
460
|
// Always use accessor (SQLite canonical storage per ADR-006)
|
|
409
461
|
const dataAccessor =
|
|
410
462
|
accessor ?? (await (await import('../store/data-accessor.js')).getAccessor(cwd));
|
|
@@ -414,7 +466,6 @@ export async function addTask(
|
|
|
414
466
|
const priority = normalizePriority(options.priority ?? 'medium');
|
|
415
467
|
const size = options.size ?? 'medium';
|
|
416
468
|
let taskType = options.type;
|
|
417
|
-
const parentId = options.parentId ?? null;
|
|
418
469
|
|
|
419
470
|
// Validate inputs
|
|
420
471
|
validateStatus(status);
|
|
@@ -422,6 +473,28 @@ export async function addTask(
|
|
|
422
473
|
validateSize(size);
|
|
423
474
|
if (options.labels?.length) validateLabels(options.labels);
|
|
424
475
|
|
|
476
|
+
// Enforce Acceptance Criteria (general rule: min 3 for all task types)
|
|
477
|
+
const enforcement = await createAcceptanceEnforcement(cwd);
|
|
478
|
+
const acValidation = enforcement.validateCreation({
|
|
479
|
+
acceptance: options.acceptance,
|
|
480
|
+
priority: priority,
|
|
481
|
+
});
|
|
482
|
+
if (!acValidation.valid) {
|
|
483
|
+
throw new CleoError(acValidation.exitCode ?? ExitCode.VALIDATION_ERROR, acValidation.error!, {
|
|
484
|
+
fix: acValidation.fix,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Epic-specific creation enforcement (T062): min 5 AC + non-empty description.
|
|
489
|
+
// This runs after general AC enforcement so epics face both the general check
|
|
490
|
+
// (min 3 from enforcement config) AND the stricter epic check (min 5).
|
|
491
|
+
if (options.type === 'epic') {
|
|
492
|
+
await validateEpicCreation(
|
|
493
|
+
{ acceptance: options.acceptance, description: options.description },
|
|
494
|
+
cwd,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
425
498
|
// Validate dependency IDs exist using targeted queries
|
|
426
499
|
if (options.depends?.length) {
|
|
427
500
|
for (const depId of options.depends) {
|
|
@@ -557,6 +630,11 @@ export async function addTask(
|
|
|
557
630
|
}
|
|
558
631
|
}
|
|
559
632
|
|
|
633
|
+
// Validate explicit pipelineStage if provided (T060)
|
|
634
|
+
if (options.pipelineStage) {
|
|
635
|
+
validatePipelineStage(options.pipelineStage);
|
|
636
|
+
}
|
|
637
|
+
|
|
560
638
|
// Duplicate detection using targeted query
|
|
561
639
|
const { tasks: candidateDupes } = await dataAccessor.queryTasks({
|
|
562
640
|
search: options.title,
|
|
@@ -571,6 +649,41 @@ export async function addTask(
|
|
|
571
649
|
|
|
572
650
|
const now = new Date().toISOString();
|
|
573
651
|
|
|
652
|
+
// Resolve pipeline stage: explicit > parent inheritance > type default (T060)
|
|
653
|
+
let resolvedParentForStage: import('./pipeline-stage.js').ResolvedParent | null = null;
|
|
654
|
+
if (parentId) {
|
|
655
|
+
// Re-use the already-validated parent task (loaded above)
|
|
656
|
+
const parentForStage = await dataAccessor.loadSingleTask(parentId);
|
|
657
|
+
resolvedParentForStage = parentForStage
|
|
658
|
+
? { pipelineStage: parentForStage.pipelineStage, type: parentForStage.type }
|
|
659
|
+
: null;
|
|
660
|
+
}
|
|
661
|
+
const resolvedPipelineStage = resolveDefaultPipelineStage({
|
|
662
|
+
explicitStage: options.pipelineStage,
|
|
663
|
+
taskType: taskType ?? null,
|
|
664
|
+
parentTask: resolvedParentForStage,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Child stage ceiling check (T062): child stage must not exceed parent epic's stage.
|
|
668
|
+
// If the direct parent is an epic, check against it; otherwise walk ancestors.
|
|
669
|
+
if (parentId && taskType !== 'epic') {
|
|
670
|
+
let epicToCheck: import('@cleocode/contracts').Task | null = null;
|
|
671
|
+
if (resolvedParentForStage?.type === 'epic') {
|
|
672
|
+
// Direct parent is an epic — load its full record to pass to validateChildStageCeiling
|
|
673
|
+
epicToCheck = await dataAccessor.loadSingleTask(parentId);
|
|
674
|
+
} else {
|
|
675
|
+
// Walk up from the parent to find the nearest epic ancestor
|
|
676
|
+
epicToCheck = await findEpicAncestor(parentId, dataAccessor);
|
|
677
|
+
}
|
|
678
|
+
if (epicToCheck) {
|
|
679
|
+
await validateChildStageCeiling(
|
|
680
|
+
{ childStage: resolvedPipelineStage, epicId: epicToCheck.id },
|
|
681
|
+
dataAccessor,
|
|
682
|
+
cwd,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
574
687
|
// Compute next position using SQL-level allocation (race-safe, T024)
|
|
575
688
|
let position: number;
|
|
576
689
|
if (options.position !== undefined) {
|
|
@@ -595,6 +708,9 @@ export async function addTask(
|
|
|
595
708
|
updatedAt: now,
|
|
596
709
|
};
|
|
597
710
|
|
|
711
|
+
// Assign pipeline stage (always set — auto-assigned if not explicit) (T060)
|
|
712
|
+
task.pipelineStage = resolvedPipelineStage;
|
|
713
|
+
|
|
598
714
|
// Add optional fields
|
|
599
715
|
if (phase) task.phase = phase;
|
|
600
716
|
if (options.labels?.length) task.labels = options.labels.map((l) => l.trim());
|
|
@@ -615,6 +731,16 @@ export async function addTask(
|
|
|
615
731
|
task.completedAt = now;
|
|
616
732
|
}
|
|
617
733
|
|
|
734
|
+
// Auto-initialize verification metadata on task creation (T061).
|
|
735
|
+
// Only for non-epic tasks when verification is enabled in config.
|
|
736
|
+
// Epics are containers and do not go through verification gates themselves.
|
|
737
|
+
if (taskType !== 'epic') {
|
|
738
|
+
const verificationEnabledRaw = await getRawConfigValue('verification.enabled', cwd);
|
|
739
|
+
if (verificationEnabledRaw === true) {
|
|
740
|
+
task.verification = buildDefaultVerification(now);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
618
744
|
// Dry run
|
|
619
745
|
if (options.dryRun) {
|
|
620
746
|
return { task, dryRun: true };
|
package/src/tasks/complete.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type { Task, TaskRef, VerificationGate } from '@cleocode/contracts';
|
|
|
9
9
|
import { ExitCode } from '@cleocode/contracts';
|
|
10
10
|
import { getRawConfigValue } from '../config.js';
|
|
11
11
|
import { CleoError } from '../errors.js';
|
|
12
|
+
import { requireActiveSession } from '../sessions/session-enforcement.js';
|
|
12
13
|
import type { DataAccessor } from '../store/data-accessor.js';
|
|
13
14
|
import { getAccessor } from '../store/data-accessor.js';
|
|
15
|
+
import { createAcceptanceEnforcement } from './enforcement.js';
|
|
14
16
|
|
|
15
17
|
/** Options for completing a task. */
|
|
16
18
|
export interface CompleteTaskOptions {
|
|
@@ -57,6 +59,10 @@ function isVerificationGate(value: string): value is VerificationGate {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
async function loadCompletionEnforcement(cwd?: string): Promise<CompletionEnforcement> {
|
|
62
|
+
// In VITEST, use permissive defaults when config keys are absent.
|
|
63
|
+
// Tests that need enforcement write their own config, which overrides these defaults.
|
|
64
|
+
const isTest = !!process.env.VITEST;
|
|
65
|
+
|
|
60
66
|
const modeRaw = await getRawConfigValue('enforcement.acceptance.mode', cwd);
|
|
61
67
|
const prioritiesRaw = await getRawConfigValue(
|
|
62
68
|
'enforcement.acceptance.requiredForPriorities',
|
|
@@ -68,13 +74,20 @@ async function loadCompletionEnforcement(cwd?: string): Promise<CompletionEnforc
|
|
|
68
74
|
const lifecycleModeRaw = await getRawConfigValue('lifecycle.mode', cwd);
|
|
69
75
|
|
|
70
76
|
const acceptanceMode =
|
|
71
|
-
modeRaw === 'off' || modeRaw === 'warn' || modeRaw === 'block'
|
|
77
|
+
modeRaw === 'off' || modeRaw === 'warn' || modeRaw === 'block'
|
|
78
|
+
? modeRaw
|
|
79
|
+
: isTest
|
|
80
|
+
? 'off'
|
|
81
|
+
: 'block';
|
|
72
82
|
|
|
73
83
|
const acceptanceRequiredForPriorities = Array.isArray(prioritiesRaw)
|
|
74
84
|
? prioritiesRaw.filter((p): p is string => typeof p === 'string')
|
|
75
|
-
:
|
|
85
|
+
: isTest
|
|
86
|
+
? []
|
|
87
|
+
: ['critical', 'high', 'medium', 'low'];
|
|
76
88
|
|
|
77
|
-
const verificationEnabled =
|
|
89
|
+
const verificationEnabled =
|
|
90
|
+
verificationEnabledRaw === true ? true : verificationEnabledRaw === false ? false : !isTest;
|
|
78
91
|
|
|
79
92
|
const verificationRequiredGates = Array.isArray(verificationRequiredGatesRaw)
|
|
80
93
|
? verificationRequiredGatesRaw
|
|
@@ -94,7 +107,9 @@ async function loadCompletionEnforcement(cwd?: string): Promise<CompletionEnforc
|
|
|
94
107
|
lifecycleModeRaw === 'none' ||
|
|
95
108
|
lifecycleModeRaw === 'off'
|
|
96
109
|
? lifecycleModeRaw
|
|
97
|
-
:
|
|
110
|
+
: isTest
|
|
111
|
+
? 'off'
|
|
112
|
+
: 'strict';
|
|
98
113
|
|
|
99
114
|
return {
|
|
100
115
|
acceptanceMode,
|
|
@@ -124,6 +139,8 @@ export async function completeTask(
|
|
|
124
139
|
});
|
|
125
140
|
}
|
|
126
141
|
|
|
142
|
+
await requireActiveSession('tasks.complete', cwd);
|
|
143
|
+
|
|
127
144
|
const enforcement = await loadCompletionEnforcement(cwd);
|
|
128
145
|
|
|
129
146
|
// Already done
|
|
@@ -148,19 +165,14 @@ export async function completeTask(
|
|
|
148
165
|
}
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
160
|
-
fix: `Add criteria: cleo update ${options.taskId} --acceptance "criterion 1,criterion 2"`,
|
|
161
|
-
},
|
|
162
|
-
);
|
|
163
|
-
}
|
|
168
|
+
const acceptanceEnforcement = await createAcceptanceEnforcement(cwd);
|
|
169
|
+
const completionValidation = acceptanceEnforcement.validateCompletion(task);
|
|
170
|
+
if (!completionValidation.valid) {
|
|
171
|
+
throw new CleoError(
|
|
172
|
+
completionValidation.exitCode ?? ExitCode.VALIDATION_ERROR,
|
|
173
|
+
completionValidation.error!,
|
|
174
|
+
{ fix: completionValidation.fix },
|
|
175
|
+
);
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
if (enforcement.verificationEnabled && task.type !== 'epic') {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ExitCode, type Task } from '@cleocode/contracts';
|
|
2
|
+
import { getRawConfigValue } from '../config.js';
|
|
3
|
+
|
|
4
|
+
export interface ValidationResult {
|
|
5
|
+
valid: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
fix?: string;
|
|
8
|
+
exitCode?: ExitCode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AddTaskEnforcementOptions {
|
|
12
|
+
acceptance?: string[];
|
|
13
|
+
priority?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UpdateTaskEnforcementOptions {
|
|
17
|
+
acceptance?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AcceptanceEnforcement {
|
|
21
|
+
validateCreation(options: AddTaskEnforcementOptions): ValidationResult;
|
|
22
|
+
validateUpdate(task: Task, updates: UpdateTaskEnforcementOptions): ValidationResult;
|
|
23
|
+
validateCompletion(task: Task): ValidationResult;
|
|
24
|
+
checkMinimumCriteria(criteria: string[], minCriteria: number): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkMin(criteria: string[], min: number): boolean {
|
|
28
|
+
return Array.isArray(criteria) && criteria.length >= min;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function createAcceptanceEnforcement(cwd?: string): Promise<AcceptanceEnforcement> {
|
|
32
|
+
// In VITEST, default to 'off' when config is absent. Tests that need
|
|
33
|
+
// enforcement active write their own config, which overrides the default.
|
|
34
|
+
const isTest = !!process.env.VITEST;
|
|
35
|
+
|
|
36
|
+
const modeRaw = await getRawConfigValue('enforcement.acceptance.mode', cwd);
|
|
37
|
+
const prioritiesRaw = await getRawConfigValue(
|
|
38
|
+
'enforcement.acceptance.requiredForPriorities',
|
|
39
|
+
cwd,
|
|
40
|
+
);
|
|
41
|
+
const minCriteriaRaw = await getRawConfigValue('enforcement.acceptance.minimumCriteria', cwd);
|
|
42
|
+
const defaultPriorityRaw = await getRawConfigValue('defaults.priority', cwd);
|
|
43
|
+
|
|
44
|
+
const mode =
|
|
45
|
+
modeRaw === 'off' || modeRaw === 'warn' || modeRaw === 'block'
|
|
46
|
+
? modeRaw
|
|
47
|
+
: isTest
|
|
48
|
+
? 'off'
|
|
49
|
+
: 'block';
|
|
50
|
+
|
|
51
|
+
const requiredForPriorities = Array.isArray(prioritiesRaw)
|
|
52
|
+
? prioritiesRaw.filter((p): p is string => typeof p === 'string')
|
|
53
|
+
: ['critical', 'high', 'medium', 'low'];
|
|
54
|
+
|
|
55
|
+
const minCriteria =
|
|
56
|
+
typeof minCriteriaRaw === 'number' && Number.isInteger(minCriteriaRaw) ? minCriteriaRaw : 3;
|
|
57
|
+
|
|
58
|
+
const defaultPriority = typeof defaultPriorityRaw === 'string' ? defaultPriorityRaw : 'medium';
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
checkMinimumCriteria: checkMin,
|
|
62
|
+
|
|
63
|
+
validateCreation(options: AddTaskEnforcementOptions): ValidationResult {
|
|
64
|
+
const priority = options.priority ?? defaultPriority;
|
|
65
|
+
|
|
66
|
+
if (mode === 'off') return { valid: true };
|
|
67
|
+
|
|
68
|
+
if (requiredForPriorities.includes(priority)) {
|
|
69
|
+
const hasEnough = checkMin(options.acceptance ?? [], minCriteria);
|
|
70
|
+
if (!hasEnough) {
|
|
71
|
+
const msg = `Task requires at least ${minCriteria} acceptance criteria (priority: ${priority})`;
|
|
72
|
+
if (mode === 'block') {
|
|
73
|
+
return {
|
|
74
|
+
valid: false,
|
|
75
|
+
error: msg,
|
|
76
|
+
fix: `Add --acceptance "criterion 1" --acceptance "criterion 2" --acceptance "criterion 3"`,
|
|
77
|
+
exitCode: ExitCode.VALIDATION_ERROR,
|
|
78
|
+
};
|
|
79
|
+
} else if (mode === 'warn') {
|
|
80
|
+
return { valid: true, error: msg };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { valid: true };
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
validateUpdate(task: Task, updates: UpdateTaskEnforcementOptions): ValidationResult {
|
|
88
|
+
if (mode === 'off') return { valid: true };
|
|
89
|
+
|
|
90
|
+
if (updates.acceptance !== undefined) {
|
|
91
|
+
if (mode === 'block' && requiredForPriorities.includes(task.priority)) {
|
|
92
|
+
if (!checkMin(updates.acceptance, minCriteria)) {
|
|
93
|
+
return {
|
|
94
|
+
valid: false,
|
|
95
|
+
error: `Task requires at least ${minCriteria} acceptance criteria`,
|
|
96
|
+
fix: `Provide at least ${minCriteria} criteria when updating acceptance`,
|
|
97
|
+
exitCode: ExitCode.VALIDATION_ERROR,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { valid: true };
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
validateCompletion(task: Task): ValidationResult {
|
|
107
|
+
if (mode === 'off') return { valid: true };
|
|
108
|
+
|
|
109
|
+
if (requiredForPriorities.includes(task.priority)) {
|
|
110
|
+
if (!checkMin(task.acceptance ?? [], minCriteria)) {
|
|
111
|
+
const msg = `Task ${task.id} requires at least ${minCriteria} acceptance criteria before completion`;
|
|
112
|
+
if (mode === 'block') {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: msg,
|
|
116
|
+
fix: `Add criteria: cleo update ${task.id} --acceptance "criterion 1" --acceptance "criterion 2"`,
|
|
117
|
+
exitCode: ExitCode.VALIDATION_ERROR,
|
|
118
|
+
};
|
|
119
|
+
} else if (mode === 'warn') {
|
|
120
|
+
return { valid: true, error: msg };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { valid: true };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|