@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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Telemetry Module — Agent Workflow Compliance Metrics
|
|
3
|
+
*
|
|
4
|
+
* Computes compliance metrics for WF-001 through WF-005 workflow rules
|
|
5
|
+
* defined in T063. Queries existing data from tasks, sessions, and audit_log
|
|
6
|
+
* tables — no new infrastructure required.
|
|
7
|
+
*
|
|
8
|
+
* Metrics produced:
|
|
9
|
+
* - AC compliance rate: % of tasks with ≥3 acceptance criteria
|
|
10
|
+
* - Session compliance rate: % of task completions that occurred within a session
|
|
11
|
+
* - Gate compliance rate: Average verification gates set per completed task
|
|
12
|
+
* - Workflow violations: Count of tasks that bypassed WF rules
|
|
13
|
+
*
|
|
14
|
+
* @task T065
|
|
15
|
+
* @epic T056
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getLogger } from '../logger.js';
|
|
19
|
+
|
|
20
|
+
const log = getLogger('workflow-telemetry');
|
|
21
|
+
|
|
22
|
+
/** Per-rule compliance breakdown. */
|
|
23
|
+
export interface WorkflowRuleMetric {
|
|
24
|
+
/** Rule identifier, e.g. WF-001. */
|
|
25
|
+
rule: string;
|
|
26
|
+
/** Human-readable rule description. */
|
|
27
|
+
description: string;
|
|
28
|
+
/** Total tasks or events that were subject to this rule. */
|
|
29
|
+
total: number;
|
|
30
|
+
/** Number that violated the rule. */
|
|
31
|
+
violations: number;
|
|
32
|
+
/** Compliance rate 0..1 (1 = fully compliant). */
|
|
33
|
+
complianceRate: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Full workflow compliance report. */
|
|
37
|
+
export interface WorkflowComplianceReport {
|
|
38
|
+
/** ISO timestamp when metrics were computed. */
|
|
39
|
+
generatedAt: string;
|
|
40
|
+
/** Time window filtered (ISO cutoff) or null for all-time. */
|
|
41
|
+
since: string | null;
|
|
42
|
+
/** Overall compliance score 0..1 (average of all rule rates). */
|
|
43
|
+
overallScore: number;
|
|
44
|
+
/** Grade letter derived from overallScore. */
|
|
45
|
+
grade: string;
|
|
46
|
+
/** Per-rule breakdown. */
|
|
47
|
+
rules: WorkflowRuleMetric[];
|
|
48
|
+
/** Task-level violation samples (up to 20). */
|
|
49
|
+
violationSamples: Array<{
|
|
50
|
+
taskId: string;
|
|
51
|
+
rule: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** Raw counts for context. */
|
|
55
|
+
summary: {
|
|
56
|
+
totalTasks: number;
|
|
57
|
+
completedTasks: number;
|
|
58
|
+
tasksWithAC: number;
|
|
59
|
+
tasksWithoutAC: number;
|
|
60
|
+
completionsInSession: number;
|
|
61
|
+
completionsOutsideSession: number;
|
|
62
|
+
tasksWithGates: number;
|
|
63
|
+
avgGatesSet: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Internal types for raw DB rows
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
interface RawTask {
|
|
72
|
+
id: string;
|
|
73
|
+
status: string;
|
|
74
|
+
acceptanceJson: string | null;
|
|
75
|
+
verificationJson: string | null;
|
|
76
|
+
sessionId: string | null;
|
|
77
|
+
completedAt: string | null;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface RawAuditRow {
|
|
82
|
+
action: string;
|
|
83
|
+
timestamp: string;
|
|
84
|
+
taskId: string | null;
|
|
85
|
+
sessionId: string | null;
|
|
86
|
+
afterJson: string | null;
|
|
87
|
+
operation: string | null;
|
|
88
|
+
domain: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// DB helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
async function queryTasks(cwd: string, since: string | null): Promise<RawTask[]> {
|
|
96
|
+
try {
|
|
97
|
+
const { getDb } = await import('../store/sqlite.js');
|
|
98
|
+
const { tasks } = await import('../store/tasks-schema.js');
|
|
99
|
+
const { and, gte } = await import('drizzle-orm');
|
|
100
|
+
|
|
101
|
+
const db = await getDb(cwd);
|
|
102
|
+
|
|
103
|
+
const conditions = [];
|
|
104
|
+
if (since) {
|
|
105
|
+
// Include tasks created or completed since the cutoff
|
|
106
|
+
conditions.push(gte(tasks.createdAt, since));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rows = await db
|
|
110
|
+
.select({
|
|
111
|
+
id: tasks.id,
|
|
112
|
+
status: tasks.status,
|
|
113
|
+
acceptanceJson: tasks.acceptanceJson,
|
|
114
|
+
verificationJson: tasks.verificationJson,
|
|
115
|
+
sessionId: tasks.sessionId,
|
|
116
|
+
completedAt: tasks.completedAt,
|
|
117
|
+
createdAt: tasks.createdAt,
|
|
118
|
+
})
|
|
119
|
+
.from(tasks)
|
|
120
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
121
|
+
.all();
|
|
122
|
+
|
|
123
|
+
return rows as RawTask[];
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log.warn({ err }, 'Failed to query tasks for workflow telemetry');
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function queryCompletionAuditRows(cwd: string, since: string | null): Promise<RawAuditRow[]> {
|
|
131
|
+
try {
|
|
132
|
+
const { getDb } = await import('../store/sqlite.js');
|
|
133
|
+
const { auditLog } = await import('../store/tasks-schema.js');
|
|
134
|
+
const { and, gte } = await import('drizzle-orm');
|
|
135
|
+
|
|
136
|
+
const db = await getDb(cwd);
|
|
137
|
+
|
|
138
|
+
// Completion events: action = 'task_completed' OR 'complete'
|
|
139
|
+
// OR operation = 'complete' in dispatch layer
|
|
140
|
+
const conditions = [];
|
|
141
|
+
if (since) conditions.push(gte(auditLog.timestamp, since));
|
|
142
|
+
|
|
143
|
+
const allRows = await db
|
|
144
|
+
.select({
|
|
145
|
+
action: auditLog.action,
|
|
146
|
+
timestamp: auditLog.timestamp,
|
|
147
|
+
taskId: auditLog.taskId,
|
|
148
|
+
sessionId: auditLog.sessionId,
|
|
149
|
+
afterJson: auditLog.afterJson,
|
|
150
|
+
operation: auditLog.operation,
|
|
151
|
+
domain: auditLog.domain,
|
|
152
|
+
})
|
|
153
|
+
.from(auditLog)
|
|
154
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
155
|
+
.orderBy(auditLog.timestamp)
|
|
156
|
+
.all();
|
|
157
|
+
|
|
158
|
+
// Filter to completion events only
|
|
159
|
+
return (allRows as RawAuditRow[]).filter((row) => {
|
|
160
|
+
const isComplete =
|
|
161
|
+
row.action === 'task_completed' ||
|
|
162
|
+
row.action === 'complete' ||
|
|
163
|
+
(row.operation === 'complete' && row.domain === 'tasks');
|
|
164
|
+
|
|
165
|
+
// Also catch status_changed → done
|
|
166
|
+
if (!isComplete && row.afterJson) {
|
|
167
|
+
try {
|
|
168
|
+
const after = JSON.parse(row.afterJson) as Record<string, unknown>;
|
|
169
|
+
return after?.status === 'done';
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return isComplete;
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log.warn({ err }, 'Failed to query audit log for workflow telemetry');
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Metric helpers
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function parseAcceptanceCriteria(acceptanceJson: string | null): string[] {
|
|
187
|
+
if (!acceptanceJson) return [];
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(acceptanceJson);
|
|
190
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseVerification(verificationJson: string | null): Record<string, unknown> | null {
|
|
197
|
+
if (!verificationJson) return null;
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(verificationJson) as Record<string, unknown>;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countTrueGates(gates: Record<string, unknown>): number {
|
|
206
|
+
if (!gates || typeof gates !== 'object') return 0;
|
|
207
|
+
return Object.values(gates).filter((v) => v === true).length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function gradeFromScore(score: number): string {
|
|
211
|
+
if (score >= 0.95) return 'A+';
|
|
212
|
+
if (score >= 0.9) return 'A';
|
|
213
|
+
if (score >= 0.8) return 'B';
|
|
214
|
+
if (score >= 0.7) return 'C';
|
|
215
|
+
if (score >= 0.6) return 'D';
|
|
216
|
+
return 'F';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Public API
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute workflow compliance metrics from existing task, session, and audit data.
|
|
225
|
+
*
|
|
226
|
+
* Rules evaluated:
|
|
227
|
+
* WF-001: Tasks MUST have ≥3 acceptance criteria (T058)
|
|
228
|
+
* WF-002: Task completions MUST occur within an active session (T059)
|
|
229
|
+
* WF-003: Completed tasks SHOULD have verification gates set (T061)
|
|
230
|
+
* WF-004: Tasks with verification SHOULD have all 3 gates set
|
|
231
|
+
* WF-005: Tasks MUST have session binding on creation (non-epic)
|
|
232
|
+
*
|
|
233
|
+
* @remarks
|
|
234
|
+
* Derives all metrics from existing audit_log and tasks tables — no new
|
|
235
|
+
* tracking infrastructure is required.
|
|
236
|
+
*
|
|
237
|
+
* @param opts - Report options
|
|
238
|
+
* @param opts.since - ISO 8601 date string to filter metrics from
|
|
239
|
+
* @param opts.cwd - Working directory for database resolution
|
|
240
|
+
* @returns Compliance report with per-rule pass/fail counts and overall rate
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const report = await getWorkflowComplianceReport({ cwd: '/my/project' });
|
|
245
|
+
* console.log(report.overall.passRate); // e.g. 0.85
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export async function getWorkflowComplianceReport(opts: {
|
|
249
|
+
since?: string;
|
|
250
|
+
cwd?: string;
|
|
251
|
+
}): Promise<WorkflowComplianceReport> {
|
|
252
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
253
|
+
const since = opts.since ?? null;
|
|
254
|
+
const generatedAt = new Date().toISOString();
|
|
255
|
+
|
|
256
|
+
const [allTasks, completionEvents] = await Promise.all([
|
|
257
|
+
queryTasks(cwd, since),
|
|
258
|
+
queryCompletionAuditRows(cwd, since),
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
// Include all tasks in WF checks. Epics will naturally show 0 AC criteria
|
|
262
|
+
// which counts as a violation — this is acceptable since epics should also
|
|
263
|
+
// have AC defined. The raw task query does not include the type column to
|
|
264
|
+
// keep the query minimal, so we don't filter by type here.
|
|
265
|
+
const nonEpicTasks = allTasks;
|
|
266
|
+
|
|
267
|
+
const completedTasks = allTasks.filter((t) => t.status === 'done' || t.completedAt != null);
|
|
268
|
+
|
|
269
|
+
// -------------------------------------------------------------------------
|
|
270
|
+
// WF-001: AC compliance — tasks with ≥3 acceptance criteria
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
const wf001Violations: Array<{ taskId: string; rule: string; detail: string }> = [];
|
|
273
|
+
let tasksWithAC = 0;
|
|
274
|
+
let tasksWithoutAC = 0;
|
|
275
|
+
|
|
276
|
+
for (const task of nonEpicTasks) {
|
|
277
|
+
const ac = parseAcceptanceCriteria(task.acceptanceJson);
|
|
278
|
+
if (ac.length >= 3) {
|
|
279
|
+
tasksWithAC++;
|
|
280
|
+
} else {
|
|
281
|
+
tasksWithoutAC++;
|
|
282
|
+
if (wf001Violations.length < 10) {
|
|
283
|
+
wf001Violations.push({
|
|
284
|
+
taskId: task.id,
|
|
285
|
+
rule: 'WF-001',
|
|
286
|
+
detail: `has ${ac.length} AC item(s), needs ≥3`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const wf001Total = nonEpicTasks.length;
|
|
293
|
+
const wf001Rate = wf001Total > 0 ? tasksWithAC / wf001Total : 1;
|
|
294
|
+
|
|
295
|
+
// -------------------------------------------------------------------------
|
|
296
|
+
// WF-002: Session compliance — completions that occurred inside a session
|
|
297
|
+
// -------------------------------------------------------------------------
|
|
298
|
+
const wf002Violations: Array<{ taskId: string; rule: string; detail: string }> = [];
|
|
299
|
+
let completionsInSession = 0;
|
|
300
|
+
let completionsOutsideSession = 0;
|
|
301
|
+
|
|
302
|
+
for (const event of completionEvents) {
|
|
303
|
+
if (event.sessionId && event.sessionId !== 'unknown' && event.sessionId !== 'system') {
|
|
304
|
+
completionsInSession++;
|
|
305
|
+
} else {
|
|
306
|
+
completionsOutsideSession++;
|
|
307
|
+
if (wf002Violations.length < 5) {
|
|
308
|
+
wf002Violations.push({
|
|
309
|
+
taskId: event.taskId ?? 'unknown',
|
|
310
|
+
rule: 'WF-002',
|
|
311
|
+
detail: 'task completed outside an active session',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const wf002Total = completionEvents.length;
|
|
318
|
+
const wf002Rate = wf002Total > 0 ? completionsInSession / wf002Total : 1;
|
|
319
|
+
|
|
320
|
+
// -------------------------------------------------------------------------
|
|
321
|
+
// WF-003: Gate compliance — completed tasks with verification initialized
|
|
322
|
+
// -------------------------------------------------------------------------
|
|
323
|
+
const wf003Violations: Array<{ taskId: string; rule: string; detail: string }> = [];
|
|
324
|
+
let tasksWithGates = 0;
|
|
325
|
+
let tasksWithoutGates = 0;
|
|
326
|
+
let totalGatesSet = 0;
|
|
327
|
+
let gateTaskCount = 0;
|
|
328
|
+
|
|
329
|
+
for (const task of completedTasks) {
|
|
330
|
+
const verification = parseVerification(task.verificationJson);
|
|
331
|
+
if (verification) {
|
|
332
|
+
tasksWithGates++;
|
|
333
|
+
const gates = (verification.gates ?? {}) as Record<string, unknown>;
|
|
334
|
+
const setCount = countTrueGates(gates);
|
|
335
|
+
totalGatesSet += setCount;
|
|
336
|
+
gateTaskCount++;
|
|
337
|
+
} else {
|
|
338
|
+
tasksWithoutGates++;
|
|
339
|
+
if (wf003Violations.length < 5) {
|
|
340
|
+
wf003Violations.push({
|
|
341
|
+
taskId: task.id,
|
|
342
|
+
rule: 'WF-003',
|
|
343
|
+
detail: 'completed without verification gates',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const wf003Total = completedTasks.length;
|
|
350
|
+
const wf003Rate = wf003Total > 0 ? tasksWithGates / wf003Total : 1;
|
|
351
|
+
const avgGatesSet =
|
|
352
|
+
gateTaskCount > 0 ? Math.round((totalGatesSet / gateTaskCount) * 100) / 100 : 0;
|
|
353
|
+
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
// WF-004: Full gate compliance — tasks with verification where all 3 gates are set
|
|
356
|
+
// -------------------------------------------------------------------------
|
|
357
|
+
const wf004Violations: Array<{ taskId: string; rule: string; detail: string }> = [];
|
|
358
|
+
let allGatesSet = 0;
|
|
359
|
+
let partialGates = 0;
|
|
360
|
+
|
|
361
|
+
for (const task of completedTasks) {
|
|
362
|
+
const verification = parseVerification(task.verificationJson);
|
|
363
|
+
if (!verification) continue; // Already captured in WF-003
|
|
364
|
+
|
|
365
|
+
const gates = (verification.gates ?? {}) as Record<string, unknown>;
|
|
366
|
+
const setCount = countTrueGates(gates);
|
|
367
|
+
const totalGates = Object.keys(gates).length;
|
|
368
|
+
|
|
369
|
+
if (totalGates > 0 && setCount === totalGates) {
|
|
370
|
+
allGatesSet++;
|
|
371
|
+
} else if (totalGates > 0) {
|
|
372
|
+
partialGates++;
|
|
373
|
+
if (wf004Violations.length < 5) {
|
|
374
|
+
wf004Violations.push({
|
|
375
|
+
taskId: task.id,
|
|
376
|
+
rule: 'WF-004',
|
|
377
|
+
detail: `only ${setCount}/${totalGates} verification gates set`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const wf004Total = tasksWithGates;
|
|
384
|
+
const wf004Rate = wf004Total > 0 ? allGatesSet / wf004Total : 1;
|
|
385
|
+
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// WF-005: Session binding on creation — tasks with sessionId set
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
const wf005Violations: Array<{ taskId: string; rule: string; detail: string }> = [];
|
|
390
|
+
let tasksWithSessionBinding = 0;
|
|
391
|
+
let tasksWithoutSessionBinding = 0;
|
|
392
|
+
|
|
393
|
+
for (const task of nonEpicTasks) {
|
|
394
|
+
if (task.sessionId && task.sessionId !== 'unknown' && task.sessionId !== 'system') {
|
|
395
|
+
tasksWithSessionBinding++;
|
|
396
|
+
} else {
|
|
397
|
+
tasksWithoutSessionBinding++;
|
|
398
|
+
if (wf005Violations.length < 5) {
|
|
399
|
+
wf005Violations.push({
|
|
400
|
+
taskId: task.id,
|
|
401
|
+
rule: 'WF-005',
|
|
402
|
+
detail: 'created without session binding',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const wf005Total = nonEpicTasks.length;
|
|
409
|
+
const wf005Rate = wf005Total > 0 ? tasksWithSessionBinding / wf005Total : 1;
|
|
410
|
+
|
|
411
|
+
// -------------------------------------------------------------------------
|
|
412
|
+
// Aggregate
|
|
413
|
+
// -------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
const rules: WorkflowRuleMetric[] = [
|
|
416
|
+
{
|
|
417
|
+
rule: 'WF-001',
|
|
418
|
+
description: 'Tasks must have ≥3 acceptance criteria',
|
|
419
|
+
total: wf001Total,
|
|
420
|
+
violations: tasksWithoutAC,
|
|
421
|
+
complianceRate: Math.round(wf001Rate * 10000) / 10000,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
rule: 'WF-002',
|
|
425
|
+
description: 'Task completions must occur within an active session',
|
|
426
|
+
total: wf002Total,
|
|
427
|
+
violations: completionsOutsideSession,
|
|
428
|
+
complianceRate: Math.round(wf002Rate * 10000) / 10000,
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
rule: 'WF-003',
|
|
432
|
+
description: 'Completed tasks should have verification gates initialized',
|
|
433
|
+
total: wf003Total,
|
|
434
|
+
violations: tasksWithoutGates,
|
|
435
|
+
complianceRate: Math.round(wf003Rate * 10000) / 10000,
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
rule: 'WF-004',
|
|
439
|
+
description: 'Verification gates should all be marked passed before completion',
|
|
440
|
+
total: wf004Total,
|
|
441
|
+
violations: partialGates,
|
|
442
|
+
complianceRate: Math.round(wf004Rate * 10000) / 10000,
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
rule: 'WF-005',
|
|
446
|
+
description: 'Tasks must be created with active session binding',
|
|
447
|
+
total: wf005Total,
|
|
448
|
+
violations: tasksWithoutSessionBinding,
|
|
449
|
+
complianceRate: Math.round(wf005Rate * 10000) / 10000,
|
|
450
|
+
},
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
// Overall score: weighted average (MUST rules count double vs SHOULD)
|
|
454
|
+
// WF-001, WF-002, WF-005 are MUST; WF-003, WF-004 are SHOULD
|
|
455
|
+
const mustWeight = 2;
|
|
456
|
+
const shouldWeight = 1;
|
|
457
|
+
const wf001w = wf001Total > 0 ? mustWeight : 0;
|
|
458
|
+
const wf002w = wf002Total > 0 ? mustWeight : 0;
|
|
459
|
+
const wf003w = wf003Total > 0 ? shouldWeight : 0;
|
|
460
|
+
const wf004w = wf004Total > 0 ? shouldWeight : 0;
|
|
461
|
+
const wf005w = wf005Total > 0 ? mustWeight : 0;
|
|
462
|
+
const totalWeight = wf001w + wf002w + wf003w + wf004w + wf005w;
|
|
463
|
+
|
|
464
|
+
const overallScore =
|
|
465
|
+
totalWeight > 0
|
|
466
|
+
? (wf001w * wf001Rate +
|
|
467
|
+
wf002w * wf002Rate +
|
|
468
|
+
wf003w * wf003Rate +
|
|
469
|
+
wf004w * wf004Rate +
|
|
470
|
+
wf005w * wf005Rate) /
|
|
471
|
+
totalWeight
|
|
472
|
+
: 1;
|
|
473
|
+
|
|
474
|
+
const roundedScore = Math.round(overallScore * 10000) / 10000;
|
|
475
|
+
|
|
476
|
+
const violationSamples = [
|
|
477
|
+
...wf001Violations,
|
|
478
|
+
...wf002Violations,
|
|
479
|
+
...wf003Violations,
|
|
480
|
+
...wf004Violations,
|
|
481
|
+
...wf005Violations,
|
|
482
|
+
].slice(0, 20);
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
generatedAt,
|
|
486
|
+
since,
|
|
487
|
+
overallScore: roundedScore,
|
|
488
|
+
grade: gradeFromScore(roundedScore),
|
|
489
|
+
rules,
|
|
490
|
+
violationSamples,
|
|
491
|
+
summary: {
|
|
492
|
+
totalTasks: allTasks.length,
|
|
493
|
+
completedTasks: completedTasks.length,
|
|
494
|
+
tasksWithAC,
|
|
495
|
+
tasksWithoutAC,
|
|
496
|
+
completionsInSession,
|
|
497
|
+
completionsOutsideSession,
|
|
498
|
+
tasksWithGates,
|
|
499
|
+
avgGatesSet,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
@@ -397,6 +397,9 @@ describe('Migration Safety Integration Tests', () => {
|
|
|
397
397
|
const tempDbPath = join(cleoDir, 'tasks.db.migrating');
|
|
398
398
|
const result = await migrateJsonToSqliteAtomic(tempDir, tempDbPath);
|
|
399
399
|
|
|
400
|
+
if (!result.success) {
|
|
401
|
+
console.error('ATOMIC MIGRATION ERRORS:', JSON.stringify(result.errors, null, 2));
|
|
402
|
+
}
|
|
400
403
|
expect(result.success).toBe(true);
|
|
401
404
|
|
|
402
405
|
// Verify temp file was created
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @epic T4638
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
11
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import type { Session } from '@cleocode/contracts';
|
|
@@ -34,6 +34,17 @@ describe('SQLite session-store', () => {
|
|
|
34
34
|
const cleoDir = join(tempDir, '.cleo');
|
|
35
35
|
process.env['CLEO_DIR'] = cleoDir;
|
|
36
36
|
|
|
37
|
+
// Create .cleo dir and write test config so enforcement checks don't block
|
|
38
|
+
await mkdir(cleoDir, { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(cleoDir, 'config.json'),
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
enforcement: { session: { requiredForMutate: false } },
|
|
43
|
+
lifecycle: { mode: 'off' },
|
|
44
|
+
verification: { enabled: false },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
37
48
|
const { closeDb } = await import('../sqlite.js');
|
|
38
49
|
closeDb();
|
|
39
50
|
});
|
|
@@ -89,6 +100,19 @@ describe('SQLite session-store', () => {
|
|
|
89
100
|
|
|
90
101
|
it('preserves taskWork state', async () => {
|
|
91
102
|
const { createSession, getSession } = await import('../session-store.js');
|
|
103
|
+
// Insert FK parent task before creating session with currentTask reference
|
|
104
|
+
const { getDb } = await import('../sqlite.js');
|
|
105
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
106
|
+
const db = await getDb();
|
|
107
|
+
db.insert(tasksTable)
|
|
108
|
+
.values({
|
|
109
|
+
id: 'T001',
|
|
110
|
+
title: 'Task T001',
|
|
111
|
+
status: 'pending',
|
|
112
|
+
priority: 'medium',
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
})
|
|
115
|
+
.run();
|
|
92
116
|
|
|
93
117
|
const now = new Date().toISOString();
|
|
94
118
|
const session = makeSession({
|
|
@@ -269,6 +293,20 @@ describe('SQLite session-store', () => {
|
|
|
269
293
|
describe('startTask', () => {
|
|
270
294
|
it('starts work on a task', async () => {
|
|
271
295
|
const { createSession, startTask, getCurrentTask } = await import('../session-store.js');
|
|
296
|
+
// Insert FK parent task: task_work_history.task_id -> tasks.id CASCADE
|
|
297
|
+
// and sessions.current_task -> tasks.id SET NULL.
|
|
298
|
+
const { getDb } = await import('../sqlite.js');
|
|
299
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
300
|
+
const db = await getDb();
|
|
301
|
+
db.insert(tasksTable)
|
|
302
|
+
.values({
|
|
303
|
+
id: 'T001',
|
|
304
|
+
title: 'Task T001',
|
|
305
|
+
status: 'pending',
|
|
306
|
+
priority: 'medium',
|
|
307
|
+
createdAt: new Date().toISOString(),
|
|
308
|
+
})
|
|
309
|
+
.run();
|
|
272
310
|
await createSession(makeSession({ id: 'sess-001' }));
|
|
273
311
|
|
|
274
312
|
await startTask('sess-001', 'T001');
|
|
@@ -280,6 +318,28 @@ describe('SQLite session-store', () => {
|
|
|
280
318
|
|
|
281
319
|
it('updates current task when set again', async () => {
|
|
282
320
|
const { createSession, startTask, getCurrentTask } = await import('../session-store.js');
|
|
321
|
+
// Insert FK parent tasks before calling startTask.
|
|
322
|
+
const { getDb } = await import('../sqlite.js');
|
|
323
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
324
|
+
const db = await getDb();
|
|
325
|
+
db.insert(tasksTable)
|
|
326
|
+
.values([
|
|
327
|
+
{
|
|
328
|
+
id: 'T001',
|
|
329
|
+
title: 'Task T001',
|
|
330
|
+
status: 'pending',
|
|
331
|
+
priority: 'medium',
|
|
332
|
+
createdAt: new Date().toISOString(),
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: 'T002',
|
|
336
|
+
title: 'Task T002',
|
|
337
|
+
status: 'pending',
|
|
338
|
+
priority: 'medium',
|
|
339
|
+
createdAt: new Date().toISOString(),
|
|
340
|
+
},
|
|
341
|
+
])
|
|
342
|
+
.run();
|
|
283
343
|
await createSession(makeSession({ id: 'sess-001' }));
|
|
284
344
|
|
|
285
345
|
await startTask('sess-001', 'T001');
|
|
@@ -312,6 +372,19 @@ describe('SQLite session-store', () => {
|
|
|
312
372
|
const { createSession, startTask, stopTask, getCurrentTask } = await import(
|
|
313
373
|
'../session-store.js'
|
|
314
374
|
);
|
|
375
|
+
// Insert FK parent task before calling startTask.
|
|
376
|
+
const { getDb } = await import('../sqlite.js');
|
|
377
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
378
|
+
const db = await getDb();
|
|
379
|
+
db.insert(tasksTable)
|
|
380
|
+
.values({
|
|
381
|
+
id: 'T001',
|
|
382
|
+
title: 'Task T001',
|
|
383
|
+
status: 'pending',
|
|
384
|
+
priority: 'medium',
|
|
385
|
+
createdAt: new Date().toISOString(),
|
|
386
|
+
})
|
|
387
|
+
.run();
|
|
315
388
|
await createSession(makeSession({ id: 'sess-001' }));
|
|
316
389
|
await startTask('sess-001', 'T001');
|
|
317
390
|
|
|
@@ -325,6 +398,35 @@ describe('SQLite session-store', () => {
|
|
|
325
398
|
describe('workHistory', () => {
|
|
326
399
|
it('records task work changes in history', async () => {
|
|
327
400
|
const { createSession, startTask, workHistory } = await import('../session-store.js');
|
|
401
|
+
// Insert FK parent tasks before calling startTask
|
|
402
|
+
const { getDb } = await import('../sqlite.js');
|
|
403
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
404
|
+
const db = await getDb();
|
|
405
|
+
db.insert(tasksTable)
|
|
406
|
+
.values([
|
|
407
|
+
{
|
|
408
|
+
id: 'T001',
|
|
409
|
+
title: 'Task T001',
|
|
410
|
+
status: 'pending',
|
|
411
|
+
priority: 'medium',
|
|
412
|
+
createdAt: new Date().toISOString(),
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
id: 'T002',
|
|
416
|
+
title: 'Task T002',
|
|
417
|
+
status: 'pending',
|
|
418
|
+
priority: 'medium',
|
|
419
|
+
createdAt: new Date().toISOString(),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
id: 'T003',
|
|
423
|
+
title: 'Task T003',
|
|
424
|
+
status: 'pending',
|
|
425
|
+
priority: 'medium',
|
|
426
|
+
createdAt: new Date().toISOString(),
|
|
427
|
+
},
|
|
428
|
+
])
|
|
429
|
+
.run();
|
|
328
430
|
await createSession(makeSession({ id: 'sess-001' }));
|
|
329
431
|
|
|
330
432
|
await startTask('sess-001', 'T001');
|
|
@@ -340,6 +442,35 @@ describe('SQLite session-store', () => {
|
|
|
340
442
|
|
|
341
443
|
it('respects limit parameter', async () => {
|
|
342
444
|
const { createSession, startTask, workHistory } = await import('../session-store.js');
|
|
445
|
+
// Insert FK parent tasks before calling startTask
|
|
446
|
+
const { getDb } = await import('../sqlite.js');
|
|
447
|
+
const { tasks: tasksTable } = await import('../tasks-schema.js');
|
|
448
|
+
const db = await getDb();
|
|
449
|
+
db.insert(tasksTable)
|
|
450
|
+
.values([
|
|
451
|
+
{
|
|
452
|
+
id: 'T001',
|
|
453
|
+
title: 'Task T001',
|
|
454
|
+
status: 'pending',
|
|
455
|
+
priority: 'medium',
|
|
456
|
+
createdAt: new Date().toISOString(),
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
id: 'T002',
|
|
460
|
+
title: 'Task T002',
|
|
461
|
+
status: 'pending',
|
|
462
|
+
priority: 'medium',
|
|
463
|
+
createdAt: new Date().toISOString(),
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
id: 'T003',
|
|
467
|
+
title: 'Task T003',
|
|
468
|
+
status: 'pending',
|
|
469
|
+
priority: 'medium',
|
|
470
|
+
createdAt: new Date().toISOString(),
|
|
471
|
+
},
|
|
472
|
+
])
|
|
473
|
+
.run();
|
|
343
474
|
await createSession(makeSession({ id: 'sess-001' }));
|
|
344
475
|
|
|
345
476
|
await startTask('sess-001', 'T001');
|