@cloverleaf/reference-impl 0.4.1 → 0.5.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/lib/cli.ts CHANGED
@@ -14,15 +14,27 @@
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
15
  * ui-review-config --repo-root <repoRoot>
16
16
  * plugin-root
17
+ * load-rfc <repoRoot> <id>
18
+ * save-rfc <repoRoot> <filePath>
19
+ * advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]
20
+ * load-spike <repoRoot> <id>
21
+ * save-spike <repoRoot> <filePath>
22
+ * advance-spike <repoRoot> <id> <toStatus> <agent|human>
23
+ * load-plan <repoRoot> <id>
24
+ * save-plan <repoRoot> <filePath>
25
+ * advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]
26
+ * materialise-tasks <repoRoot> <planId>
27
+ * next-work-item-id <repoRoot> <project>
28
+ * discovery-config --repo-root <repoRoot>
17
29
  */
18
30
 
19
31
  import { readFileSync } from 'node:fs';
20
32
  import { execSync } from 'node:child_process';
21
- import { loadTask } from './state.js';
22
- import { advanceStatus } from './state.js';
33
+ import { loadTask } from './task.js';
34
+ import { advanceStatus } from './task.js';
23
35
  import { emitGateDecision } from './events.js';
24
36
  import { writeFeedback, latestFeedback } from './feedback.js';
25
- import { nextTaskId, inferProject } from './ids.js';
37
+ import { nextTaskId, inferProject, nextWorkItemId } from './ids.js';
26
38
  import { matchesUiPaths } from './ui-paths.js';
27
39
  import { loadUiPathsConfig } from './ui-paths.js';
28
40
  import { computeAffectedRoutes } from './affected-routes.js';
@@ -30,6 +42,10 @@ import { loadAffectedRoutesConfig } from './affected-routes.js';
30
42
  import { loadUiReviewConfig } from './ui-review-config.js';
31
43
  import { getPluginRoot } from './plugin-path.js';
32
44
  import type { FeedbackEnvelope } from './feedback.js';
45
+ import { loadRfc, saveRfc, advanceRfcStatus, type RfcDoc } from './rfc.js';
46
+ import { loadSpike, saveSpike, advanceSpikeStatus, type SpikeDoc } from './spike.js';
47
+ import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type PlanDoc } from './plan.js';
48
+ import { loadDiscoveryConfig } from './discovery-config.js';
33
49
 
34
50
  function die(msg: string, code = 1): never {
35
51
  process.stderr.write(msg + '\n');
@@ -49,7 +65,19 @@ function usage(msg?: string): never {
49
65
  ' latest-feedback <repoRoot> <taskId>\n' +
50
66
  ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
51
67
  ' ui-review-config --repo-root <repoRoot>\n' +
52
- ' plugin-root\n'
68
+ ' plugin-root\n' +
69
+ ' load-rfc <repoRoot> <id>\n' +
70
+ ' save-rfc <repoRoot> <filePath>\n' +
71
+ ' advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
72
+ ' load-spike <repoRoot> <id>\n' +
73
+ ' save-spike <repoRoot> <filePath>\n' +
74
+ ' advance-spike <repoRoot> <id> <toStatus> <agent|human>\n' +
75
+ ' load-plan <repoRoot> <id>\n' +
76
+ ' save-plan <repoRoot> <filePath>\n' +
77
+ ' advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
78
+ ' materialise-tasks <repoRoot> <planId>\n' +
79
+ ' next-work-item-id <repoRoot> <project>\n' +
80
+ ' discovery-config --repo-root <repoRoot>\n'
53
81
  );
54
82
  process.exit(2);
55
83
  }
@@ -252,6 +280,101 @@ try {
252
280
  process.exit(0);
253
281
  }
254
282
 
283
+ case 'load-rfc': {
284
+ const [repoRoot, id] = rest;
285
+ if (!repoRoot || !id) usage('load-rfc <repoRoot> <id>');
286
+ process.stdout.write(JSON.stringify(loadRfc(repoRoot, id), null, 2));
287
+ break;
288
+ }
289
+
290
+ case 'save-rfc': {
291
+ const [repoRoot, filePath] = rest;
292
+ if (!repoRoot || !filePath) usage('save-rfc <repoRoot> <filePath>');
293
+ const rfc = JSON.parse(readFileSync(filePath, 'utf-8')) as RfcDoc;
294
+ saveRfc(repoRoot, rfc);
295
+ break;
296
+ }
297
+
298
+ case 'advance-rfc': {
299
+ const [repoRoot, id, toStatus, actor, gate] = rest;
300
+ if (!repoRoot || !id || !toStatus || !actor) usage('advance-rfc <repoRoot> <id> <toStatus> <agent|human> [gate]');
301
+ if (actor !== 'agent' && actor !== 'human') usage('advance-rfc: actor must be agent or human');
302
+ const opts = gate ? { gate } : {};
303
+ advanceRfcStatus(repoRoot, id, toStatus, actor as 'agent' | 'human', opts);
304
+ break;
305
+ }
306
+
307
+ case 'load-spike': {
308
+ const [repoRoot, id] = rest;
309
+ if (!repoRoot || !id) usage('load-spike <repoRoot> <id>');
310
+ process.stdout.write(JSON.stringify(loadSpike(repoRoot, id), null, 2));
311
+ break;
312
+ }
313
+
314
+ case 'save-spike': {
315
+ const [repoRoot, filePath] = rest;
316
+ if (!repoRoot || !filePath) usage('save-spike <repoRoot> <filePath>');
317
+ const spike = JSON.parse(readFileSync(filePath, 'utf-8')) as SpikeDoc;
318
+ saveSpike(repoRoot, spike);
319
+ break;
320
+ }
321
+
322
+ case 'advance-spike': {
323
+ const [repoRoot, id, toStatus, actor] = rest;
324
+ if (!repoRoot || !id || !toStatus || !actor) usage('advance-spike <repoRoot> <id> <toStatus> <agent|human>');
325
+ if (actor !== 'agent' && actor !== 'human') usage('advance-spike: actor must be agent or human');
326
+ advanceSpikeStatus(repoRoot, id, toStatus, actor as 'agent' | 'human');
327
+ break;
328
+ }
329
+
330
+ case 'load-plan': {
331
+ const [repoRoot, id] = rest;
332
+ if (!repoRoot || !id) usage('load-plan <repoRoot> <id>');
333
+ process.stdout.write(JSON.stringify(loadPlan(repoRoot, id), null, 2));
334
+ break;
335
+ }
336
+
337
+ case 'save-plan': {
338
+ const [repoRoot, filePath] = rest;
339
+ if (!repoRoot || !filePath) usage('save-plan <repoRoot> <filePath>');
340
+ const plan = JSON.parse(readFileSync(filePath, 'utf-8')) as PlanDoc;
341
+ savePlan(repoRoot, plan);
342
+ break;
343
+ }
344
+
345
+ case 'advance-plan': {
346
+ const [repoRoot, id, toStatus, actor, gate] = rest;
347
+ if (!repoRoot || !id || !toStatus || !actor) usage('advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]');
348
+ if (actor !== 'agent' && actor !== 'human') usage('advance-plan: actor must be agent or human');
349
+ const opts = gate ? { gate } : {};
350
+ advancePlanStatus(repoRoot, id, toStatus, actor as 'agent' | 'human', opts);
351
+ break;
352
+ }
353
+
354
+ case 'materialise-tasks': {
355
+ const [repoRoot, planId] = rest;
356
+ if (!repoRoot || !planId) usage('materialise-tasks <repoRoot> <planId>');
357
+ const plan = loadPlan(repoRoot, planId);
358
+ const ids = materialiseTasksFromPlan(repoRoot, plan);
359
+ process.stdout.write(JSON.stringify({ task_ids: ids }));
360
+ break;
361
+ }
362
+
363
+ case 'next-work-item-id': {
364
+ const [repoRoot, project] = rest;
365
+ if (!repoRoot || !project) usage('next-work-item-id <repoRoot> <project>');
366
+ process.stdout.write(nextWorkItemId(repoRoot, project));
367
+ break;
368
+ }
369
+
370
+ case 'discovery-config': {
371
+ const idx = rest.indexOf('--repo-root');
372
+ if (idx < 0 || !rest[idx + 1]) usage('discovery-config --repo-root <repoRoot>');
373
+ const c = loadDiscoveryConfig(rest[idx + 1]);
374
+ process.stdout.write(JSON.stringify(c, null, 2));
375
+ break;
376
+ }
377
+
255
378
  default:
256
379
  usage(`Unknown command: ${command}`);
257
380
  }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const PACKAGE_DEFAULT = join(here, '..', 'config', 'discovery.json');
7
+
8
+ export interface DiscoveryConfig {
9
+ docContextUri: string;
10
+ projectId: string;
11
+ idStart: number;
12
+ }
13
+
14
+ export function loadDiscoveryConfig(repoRoot: string): DiscoveryConfig {
15
+ const override = join(repoRoot, '.cloverleaf', 'config', 'discovery.json');
16
+ const fallback = JSON.parse(readFileSync(PACKAGE_DEFAULT, 'utf-8')) as DiscoveryConfig;
17
+
18
+ if (existsSync(override)) {
19
+ try {
20
+ const doc = JSON.parse(readFileSync(override, 'utf-8')) as Partial<DiscoveryConfig>;
21
+ return normalise(doc, fallback);
22
+ } catch {
23
+ // Malformed consumer JSON — fall through to package default.
24
+ }
25
+ }
26
+ return fallback;
27
+ }
28
+
29
+ function normalise(doc: Partial<DiscoveryConfig>, fallback: DiscoveryConfig): DiscoveryConfig {
30
+ return {
31
+ docContextUri: typeof doc.docContextUri === 'string' ? doc.docContextUri : fallback.docContextUri,
32
+ projectId: typeof doc.projectId === 'string' ? doc.projectId : fallback.projectId,
33
+ idStart: typeof doc.idStart === 'number' ? doc.idStart : fallback.idStart,
34
+ };
35
+ }
package/lib/ids.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, existsSync } from 'node:fs';
2
- import { tasksDir, eventsDir, feedbackDir, projectsDir } from './paths.js';
2
+ import { tasksDir, eventsDir, feedbackDir, projectsDir, rfcsDir, spikesDir, plansDir } from './paths.js';
3
3
 
4
4
  export function nextTaskId(repoRoot: string, project: string): string {
5
5
  const dir = tasksDir(repoRoot);
@@ -55,6 +55,30 @@ export function inferProject(repoRoot: string, explicit?: string): string {
55
55
  return projects[0];
56
56
  }
57
57
 
58
+ /**
59
+ * Returns unpadded per-project IDs (e.g., `CLV-13`) by scanning all four
60
+ * work-item directories and taking max+1. Matches the canonical convention
61
+ * used in @cloverleaf/standard example scenarios (oauth-rollout: ACME-100,
62
+ * ACME-200). Legacy `nextTaskId` retains three-digit padding (e.g., CLV-001)
63
+ * for back-compat with existing task files.
64
+ */
65
+ export function nextWorkItemId(repoRoot: string, project: string): string {
66
+ const dirs = [rfcsDir(repoRoot), spikesDir(repoRoot), plansDir(repoRoot), tasksDir(repoRoot)];
67
+ const pat = new RegExp(`^${escapeRegex(project)}-(\\d+)\\.json$`);
68
+ let max = 0;
69
+ for (const d of dirs) {
70
+ if (!existsSync(d)) continue;
71
+ for (const f of readdirSync(d)) {
72
+ const m = pat.exec(f);
73
+ if (m) {
74
+ const n = parseInt(m[1], 10);
75
+ if (n > max) max = n;
76
+ }
77
+ }
78
+ }
79
+ return `${project}-${max + 1}`;
80
+ }
81
+
58
82
  function escapeRegex(s: string): string {
59
83
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
84
  }
package/lib/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from './paths.js';
2
2
  export * from './ids.js';
3
- export * from './state.js';
3
+ export * from './task.js';
4
4
  export * from './events.js';
5
5
  export * from './feedback.js';
6
6
  export * from './validate.js';
package/lib/plan.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { plansDir, tasksDir } from './paths.js';
4
+ import { validateOrThrow } from './validate.js';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
6
+
7
+ export interface WorkItemRef {
8
+ project: string;
9
+ id: string;
10
+ }
11
+
12
+ export interface TaskDag {
13
+ nodes: WorkItemRef[];
14
+ edges: Array<{ from: WorkItemRef; to: WorkItemRef }>;
15
+ }
16
+
17
+ export interface PlanDoc {
18
+ type: 'plan';
19
+ project: string;
20
+ id: string;
21
+ status: string;
22
+ owner: { kind: 'agent' | 'human' | 'system'; id: string };
23
+ parent_rfc: WorkItemRef;
24
+ task_dag: TaskDag;
25
+ tasks: Array<Record<string, unknown>>;
26
+ path_reviewer_map?: Array<{ pattern: string; role: string }>;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ export function loadPlan(repoRoot: string, id: string): PlanDoc {
31
+ const path = join(plansDir(repoRoot), `${id}.json`);
32
+ if (!existsSync(path)) throw new Error(`Plan ${id} not found at ${path}`);
33
+ return JSON.parse(readFileSync(path, 'utf-8')) as PlanDoc;
34
+ }
35
+
36
+ export function savePlan(repoRoot: string, plan: PlanDoc): void {
37
+ validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
38
+ const path = join(plansDir(repoRoot), `${plan.id}.json`);
39
+ writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
40
+ }
41
+
42
+ export function advancePlanStatus(
43
+ repoRoot: string,
44
+ id: string,
45
+ toStatus: string,
46
+ actor: 'agent' | 'human',
47
+ options: { gate?: string } = {}
48
+ ): PlanDoc {
49
+ const plan = loadPlan(repoRoot, id);
50
+ const from = plan.status;
51
+ const sm = loadStateMachine('plan');
52
+ const fixture = { type: 'plan', id: plan.id, project: plan.project, status: plan.status };
53
+
54
+ const proposed = { ...plan, status: toStatus };
55
+ advanceWorkItemStatus({
56
+ repoRoot,
57
+ workItemType: 'plan',
58
+ project: plan.project,
59
+ id: plan.id,
60
+ from,
61
+ to: toStatus,
62
+ actor,
63
+ stateMachine: sm,
64
+ validateFixture: fixture,
65
+ save: (p) => savePlan(repoRoot, p as PlanDoc),
66
+ proposed,
67
+ gate: options.gate,
68
+ });
69
+ return proposed;
70
+ }
71
+
72
+ /**
73
+ * Build a directed graph from the DAG's edges and detect any cycle.
74
+ * Returns the first node id involved in a cycle, or null.
75
+ */
76
+ function detectCycle(dag: TaskDag): string | null {
77
+ // Build adjacency: for each node, list of node ids it points TO.
78
+ const adj = new Map<string, string[]>();
79
+ for (const n of dag.nodes) adj.set(n.id, []);
80
+ for (const e of dag.edges) {
81
+ const from = e.from.id;
82
+ const to = e.to.id;
83
+ if (!adj.has(from)) adj.set(from, []);
84
+ adj.get(from)!.push(to);
85
+ }
86
+
87
+ const state = new Map<string, 'white' | 'grey' | 'black'>();
88
+ for (const n of dag.nodes) state.set(n.id, 'white');
89
+
90
+ const visit = (id: string): boolean => {
91
+ const s = state.get(id);
92
+ if (s === 'grey') return true; // back-edge → cycle
93
+ if (s === 'black') return false;
94
+ state.set(id, 'grey');
95
+ for (const next of adj.get(id) ?? []) {
96
+ if (visit(next)) return true;
97
+ }
98
+ state.set(id, 'black');
99
+ return false;
100
+ };
101
+
102
+ for (const n of dag.nodes) {
103
+ if (visit(n.id)) return n.id;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Materialise all inline tasks from an approved Plan onto disk as
110
+ * .cloverleaf/tasks/<id>.json. Atomic: pre-validates every task before
111
+ * any file write. Throws on cycle in task_dag or AJV failure — no
112
+ * partial materialisation on failure. Returns the ordered list of
113
+ * materialised task IDs.
114
+ *
115
+ * If a task file already exists at the target path, it is OVERWRITTEN.
116
+ * Callers responsible for Delivery state consistency should not invoke
117
+ * this on a Plan whose tasks are already materialised and in-flight.
118
+ *
119
+ * Called by /cloverleaf-discover after a human approves the Plan at
120
+ * task_batch_gate. The gate ensures this function is invoked at most
121
+ * once per Plan in normal operation.
122
+ */
123
+ export function materialiseTasksFromPlan(repoRoot: string, plan: PlanDoc): string[] {
124
+ // 1. Cycle check on edges.
125
+ const cycleAt = detectCycle(plan.task_dag);
126
+ if (cycleAt) throw new Error(`Plan task_dag contains a cycle involving ${cycleAt}`);
127
+
128
+ // 2. Pre-validate every task before ANY file write.
129
+ for (const task of plan.tasks) {
130
+ validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
131
+ }
132
+
133
+ // 3. Ensure the tasks directory exists (no-op on fresh repos that haven't
134
+ // initialised .cloverleaf/tasks/ yet). Placed after cycle-check and
135
+ // validation so those fast-path shorts-circuit without any FS side-effect.
136
+ mkdirSync(tasksDir(repoRoot), { recursive: true });
137
+
138
+ // 4. Write all task files.
139
+ const ids: string[] = [];
140
+ for (const task of plan.tasks) {
141
+ const id = String(task['id']);
142
+ const path = join(tasksDir(repoRoot), `${id}.json`);
143
+ writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
144
+ ids.push(id);
145
+ }
146
+ return ids;
147
+ }
package/lib/rfc.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { rfcsDir } from './paths.js';
4
+ import { validateOrThrow } from './validate.js';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
6
+
7
+ export interface RfcDoc {
8
+ type: 'rfc';
9
+ project: string;
10
+ id: string;
11
+ title: string;
12
+ status: string;
13
+ owner: { kind: 'agent' | 'human' | 'system'; id: string };
14
+ problem: string;
15
+ solution: string;
16
+ unknowns: string[];
17
+ acceptance_criteria: string[];
18
+ out_of_scope: string[];
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export function loadRfc(repoRoot: string, id: string): RfcDoc {
23
+ const path = join(rfcsDir(repoRoot), `${id}.json`);
24
+ if (!existsSync(path)) throw new Error(`RFC ${id} not found at ${path}`);
25
+ return JSON.parse(readFileSync(path, 'utf-8')) as RfcDoc;
26
+ }
27
+
28
+ export function saveRfc(repoRoot: string, rfc: RfcDoc): void {
29
+ validateOrThrow('https://cloverleaf.example/schemas/rfc.schema.json', rfc);
30
+ const path = join(rfcsDir(repoRoot), `${rfc.id}.json`);
31
+ writeFileSync(path, JSON.stringify(rfc, null, 2) + '\n');
32
+ }
33
+
34
+ export function advanceRfcStatus(
35
+ repoRoot: string,
36
+ id: string,
37
+ toStatus: string,
38
+ actor: 'agent' | 'human',
39
+ options: { gate?: string } = {}
40
+ ): RfcDoc {
41
+ const rfc = loadRfc(repoRoot, id);
42
+ const from = rfc.status;
43
+ const sm = loadStateMachine('rfc');
44
+ const fixture = { type: 'rfc', id: rfc.id, project: rfc.project, status: rfc.status };
45
+
46
+ const proposed = { ...rfc, status: toStatus };
47
+ advanceWorkItemStatus({
48
+ repoRoot,
49
+ workItemType: 'rfc',
50
+ project: rfc.project,
51
+ id: rfc.id,
52
+ from,
53
+ to: toStatus,
54
+ actor,
55
+ stateMachine: sm,
56
+ validateFixture: fixture,
57
+ save: (p) => saveRfc(repoRoot, p as RfcDoc),
58
+ proposed,
59
+ gate: options.gate,
60
+ });
61
+ return proposed;
62
+ }
package/lib/spike.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spikesDir } from './paths.js';
4
+ import { validateOrThrow } from './validate.js';
5
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
6
+
7
+ export interface SpikeDoc {
8
+ type: 'spike';
9
+ project: string;
10
+ id: string;
11
+ title: string;
12
+ status: string;
13
+ owner: { kind: 'agent' | 'human' | 'system'; id: string };
14
+ parent_rfc: { project: string; id: string };
15
+ question: string;
16
+ method: 'research' | 'prototype' | 'benchmark';
17
+ findings?: string;
18
+ recommendation?: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export function loadSpike(repoRoot: string, id: string): SpikeDoc {
23
+ const path = join(spikesDir(repoRoot), `${id}.json`);
24
+ if (!existsSync(path)) throw new Error(`Spike ${id} not found at ${path}`);
25
+ return JSON.parse(readFileSync(path, 'utf-8')) as SpikeDoc;
26
+ }
27
+
28
+ export function saveSpike(repoRoot: string, spike: SpikeDoc): void {
29
+ validateOrThrow('https://cloverleaf.example/schemas/spike.schema.json', spike);
30
+ const path = join(spikesDir(repoRoot), `${spike.id}.json`);
31
+ writeFileSync(path, JSON.stringify(spike, null, 2) + '\n');
32
+ }
33
+
34
+ export function advanceSpikeStatus(
35
+ repoRoot: string,
36
+ id: string,
37
+ toStatus: string,
38
+ actor: 'agent' | 'human'
39
+ ): SpikeDoc {
40
+ const spike = loadSpike(repoRoot, id);
41
+ const from = spike.status;
42
+ const sm = loadStateMachine('spike');
43
+ const fixture = { type: 'spike', id: spike.id, project: spike.project, status: spike.status };
44
+
45
+ const proposed = { ...spike, status: toStatus };
46
+ advanceWorkItemStatus({
47
+ repoRoot,
48
+ workItemType: 'spike',
49
+ project: spike.project,
50
+ id: spike.id,
51
+ from,
52
+ to: toStatus,
53
+ actor,
54
+ stateMachine: sm,
55
+ validateFixture: fixture,
56
+ save: (p) => saveSpike(repoRoot, p as SpikeDoc),
57
+ proposed,
58
+ });
59
+ return proposed;
60
+ }
package/lib/task.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tasksDir, projectsDir } from './paths.js';
4
+ import type { Task as SMTask } from '@cloverleaf/standard/validators/index.js';
5
+ import { validateOrThrow } from './validate.js';
6
+ import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
7
+
8
+ export interface TaskDoc {
9
+ type: 'task';
10
+ project: string;
11
+ id: string;
12
+ title: string;
13
+ status: string;
14
+ risk_class: 'low' | 'high';
15
+ owner: { kind: 'agent' | 'human' | 'system'; id: string };
16
+ acceptance_criteria: string[];
17
+ definition_of_done: string[];
18
+ context: Record<string, unknown>;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export interface ProjectDoc {
23
+ key: string;
24
+ name: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export function loadTask(repoRoot: string, taskId: string): TaskDoc {
29
+ const path = join(tasksDir(repoRoot), `${taskId}.json`);
30
+ if (!existsSync(path)) throw new Error(`Task ${taskId} not found at ${path}`);
31
+ return JSON.parse(readFileSync(path, 'utf-8')) as TaskDoc;
32
+ }
33
+
34
+ export function saveTask(repoRoot: string, task: TaskDoc): void {
35
+ validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
36
+ const path = join(tasksDir(repoRoot), `${task.id}.json`);
37
+ writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
38
+ }
39
+
40
+ export function loadProject(repoRoot: string, projectId: string): ProjectDoc {
41
+ const path = join(projectsDir(repoRoot), `${projectId}.json`);
42
+ if (!existsSync(path)) throw new Error(`Project ${projectId} not found at ${path}`);
43
+ return JSON.parse(readFileSync(path, 'utf-8')) as ProjectDoc;
44
+ }
45
+
46
+ export function advanceStatus(
47
+ repoRoot: string,
48
+ taskId: string,
49
+ toStatus: string,
50
+ actor: 'agent' | 'human',
51
+ options: { gate?: string; path?: 'fast_lane' | 'full_pipeline' } = {}
52
+ ): TaskDoc {
53
+ const task = loadTask(repoRoot, taskId);
54
+ const from = task.status;
55
+ const sm = loadStateMachine('task');
56
+
57
+ const riskClass: 'low' | 'high' =
58
+ options.path === 'fast_lane' ? 'low'
59
+ : options.path === 'full_pipeline' ? 'high'
60
+ : (task.risk_class ?? 'low');
61
+
62
+ const workItemForValidator: SMTask = {
63
+ type: 'task',
64
+ id: task.id,
65
+ project: task.project,
66
+ status: task.status,
67
+ risk_class: riskClass,
68
+ context: { rfc: { project: task.project, id: task.id } },
69
+ definition_of_done: task.definition_of_done,
70
+ acceptance_criteria: task.acceptance_criteria,
71
+ };
72
+
73
+ const proposed = { ...task, status: toStatus };
74
+ advanceWorkItemStatus({
75
+ repoRoot,
76
+ workItemType: 'task',
77
+ project: task.project,
78
+ id: task.id,
79
+ from,
80
+ to: toStatus,
81
+ actor,
82
+ stateMachine: sm,
83
+ validateFixture: workItemForValidator as unknown as Record<string, unknown>,
84
+ save: (p) => saveTask(repoRoot, p as TaskDoc),
85
+ proposed,
86
+ gate: options.gate,
87
+ path: options.path,
88
+ });
89
+ return proposed;
90
+ }
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { emitStatusTransition, formatReason } from './events.js';
5
+ import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
6
+ import type { StatusTransitions } from '@cloverleaf/standard/validators/index.js';
7
+
8
+ const req = createRequire(import.meta.url);
9
+
10
+ export function loadStateMachine(type: 'task' | 'rfc' | 'spike' | 'plan'): StatusTransitions {
11
+ const pkgPath = req.resolve('@cloverleaf/standard/package.json');
12
+ const pkgDir = pkgPath.replace(/\/package\.json$/, '');
13
+ return JSON.parse(readFileSync(`${pkgDir}/state-machines/${type}.json`, 'utf-8')) as StatusTransitions;
14
+ }
15
+
16
+ export interface AdvanceWorkItemParams<T> {
17
+ repoRoot: string;
18
+ workItemType: 'task' | 'rfc' | 'spike' | 'plan';
19
+ project: string;
20
+ id: string;
21
+ from: string;
22
+ to: string;
23
+ actor: 'agent' | 'human';
24
+ stateMachine: StatusTransitions;
25
+ validateFixture: Record<string, unknown>;
26
+ save: (proposed: T & { status: string }) => void;
27
+ proposed: T;
28
+ gate?: string;
29
+ path?: 'fast_lane' | 'full_pipeline';
30
+ }
31
+
32
+ export interface AdvanceWorkItemResult {
33
+ from: string;
34
+ to: string;
35
+ }
36
+
37
+ export function advanceWorkItemStatus<T>(params: AdvanceWorkItemParams<T>): AdvanceWorkItemResult {
38
+ const { repoRoot, workItemType, project, id, from, to, actor, stateMachine, validateFixture, save, gate, path } = params;
39
+
40
+ const reason = formatReason({ gate, path });
41
+ const event = {
42
+ event_id: randomUUID(),
43
+ event_type: 'status_transition' as const,
44
+ occurred_at: new Date().toISOString(),
45
+ work_item_id: { project, id },
46
+ work_item_type: workItemType,
47
+ from_status: from,
48
+ to_status: to,
49
+ actor: { kind: actor, id: actor },
50
+ ...(reason ? { reason } : {}),
51
+ };
52
+
53
+ const result = validateStatusTransitionLegality(event, stateMachine, validateFixture as never);
54
+ if (!result.ok) {
55
+ const msgs = result.violations.map((v) => v.message).join('; ');
56
+ throw new Error(`Illegal transition ${from} → ${to}: ${msgs}`);
57
+ }
58
+
59
+ const emittedPath = emitStatusTransition(repoRoot, {
60
+ project,
61
+ workItemType,
62
+ workItemId: id,
63
+ from,
64
+ to,
65
+ actor,
66
+ gate,
67
+ path,
68
+ });
69
+
70
+ try {
71
+ save(params.proposed as T & { status: string });
72
+ } catch (err) {
73
+ const inner = err instanceof Error ? err.message : String(err);
74
+ throw new Error(`orphan event written to ${emittedPath} but ${workItemType} save failed: ${inner}`);
75
+ }
76
+
77
+ return { from, to };
78
+ }