@ai-content-space/loopx 0.1.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.
@@ -0,0 +1,1008 @@
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { ensureLoopXRoot, resolveLoopXRoot } from './runtime-maintenance.mjs';
7
+
8
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
9
+ const WORKSPACE_SCHEMA_VERSION = 1;
10
+ const WORKFLOW_SCHEMA_VERSION = 1;
11
+
12
+ export const STAGES = {
13
+ CLARIFY: 'clarify',
14
+ PLAN: 'plan',
15
+ BUILD: 'build',
16
+ REVIEW: 'review',
17
+ DONE: 'done',
18
+ };
19
+
20
+ export const APPROVAL_STATES = {
21
+ NOT_REQUESTED: 'not-requested',
22
+ REQUESTED: 'requested',
23
+ APPROVED: 'approved',
24
+ REJECTED: 'rejected',
25
+ };
26
+
27
+ export const TRANSITIONS = {
28
+ NONE: 'none',
29
+ CLARIFY_TO_PLAN: 'clarify->plan',
30
+ PLAN_TO_BUILD: 'plan->build',
31
+ BUILD_TO_REVIEW: 'build->review',
32
+ REVIEW_TO_PLAN: 'review->plan',
33
+ REVIEW_TO_DONE: 'review->done',
34
+ };
35
+
36
+ const PLAN_ARTIFACTS = ['plan.md', 'architecture.md', 'development-plan.md', 'test-plan.md'];
37
+ const V1_ARTIFACTS = ['spec.md', ...PLAN_ARTIFACTS, 'execution-record.md', 'review-report.md'];
38
+ const LEGACY_ARTIFACTS = ['brief.md', 'plan.md', 'detailed-design.md', 'architecture.md', 'test-plan.md', 'build-result.md', 'review-report.md'];
39
+
40
+ function normalizeSlug(raw) {
41
+ const slug = String(raw || '')
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '');
46
+ if (!slug) {
47
+ throw new Error('workflow_slug_required');
48
+ }
49
+ return slug;
50
+ }
51
+
52
+ function nowIso() {
53
+ return new Date().toISOString();
54
+ }
55
+
56
+ function nowStamp() {
57
+ return nowIso().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
58
+ }
59
+
60
+ function parseFrontmatter(text) {
61
+ if (!text.startsWith('---\n')) {
62
+ return {};
63
+ }
64
+ const end = text.indexOf('\n---\n', 4);
65
+ if (end === -1) {
66
+ return {};
67
+ }
68
+
69
+ const result = {};
70
+ for (const line of text.slice(4, end).split('\n')) {
71
+ if (!line || /^\s/.test(line)) {
72
+ continue;
73
+ }
74
+ const separator = line.indexOf(':');
75
+ if (separator === -1) {
76
+ continue;
77
+ }
78
+ const key = line.slice(0, separator).trim();
79
+ const rawValue = line.slice(separator + 1).trim();
80
+ if (rawValue === 'null') {
81
+ result[key] = null;
82
+ continue;
83
+ }
84
+ if (rawValue === 'true' || rawValue === 'false') {
85
+ result[key] = rawValue === 'true';
86
+ continue;
87
+ }
88
+ if (/^-?\d+$/.test(rawValue)) {
89
+ result[key] = Number.parseInt(rawValue, 10);
90
+ continue;
91
+ }
92
+ if (rawValue.startsWith('[') || rawValue.startsWith('{')) {
93
+ result[key] = JSON.parse(rawValue);
94
+ continue;
95
+ }
96
+ result[key] = rawValue;
97
+ }
98
+ return result;
99
+ }
100
+
101
+ function frontmatterBlock(values) {
102
+ const lines = ['---'];
103
+ for (const [key, value] of Object.entries(values)) {
104
+ if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
105
+ lines.push(`${key}: ${JSON.stringify(value)}`);
106
+ } else if (value === null) {
107
+ lines.push(`${key}: null`);
108
+ } else {
109
+ lines.push(`${key}: ${value}`);
110
+ }
111
+ }
112
+ lines.push('---', '');
113
+ return lines.join('\n');
114
+ }
115
+
116
+ function statePath(root) {
117
+ return join(root, 'state.json');
118
+ }
119
+
120
+ function workspaceConfigPath(root) {
121
+ return join(root, 'config.json');
122
+ }
123
+
124
+ function workspaceReadmePath(root) {
125
+ return join(root, 'README.md');
126
+ }
127
+
128
+ function artifactPath(root, name) {
129
+ return join(root, name);
130
+ }
131
+
132
+ async function ensureDir(path) {
133
+ await mkdir(path, { recursive: true });
134
+ }
135
+
136
+ async function readTemplate(name) {
137
+ return readFile(join(resolve(MODULE_DIR, '../templates'), name), 'utf8');
138
+ }
139
+
140
+ async function readTextIfExists(path) {
141
+ if (!existsSync(path)) {
142
+ return null;
143
+ }
144
+ return readFile(path, 'utf8');
145
+ }
146
+
147
+ async function writeText(path, text) {
148
+ await writeFile(path, `${text.replace(/\s+$/, '')}\n`);
149
+ }
150
+
151
+ async function writeState(root, state) {
152
+ await writeText(statePath(root), JSON.stringify(state, null, 2));
153
+ }
154
+
155
+ export function resolveWorkspaceRoot(cwd) {
156
+ return resolveLoopXRoot(cwd);
157
+ }
158
+
159
+ export function resolveWorkflowRoot(cwd, slug) {
160
+ return join(resolveWorkspaceRoot(cwd), 'workflows', normalizeSlug(slug));
161
+ }
162
+
163
+ function resolveSpecsRoot(cwd) {
164
+ return join(resolveWorkspaceRoot(cwd), 'specs');
165
+ }
166
+
167
+ function resolvePlansRoot(cwd) {
168
+ return join(resolveWorkspaceRoot(cwd), 'plans');
169
+ }
170
+
171
+ function canonicalClarifySpecPath(cwd, slug, stamp) {
172
+ return join(resolveSpecsRoot(cwd), `clarify-${normalizeSlug(slug)}-${stamp}.md`);
173
+ }
174
+
175
+ export async function readWorkspaceConfig(cwd) {
176
+ const path = workspaceConfigPath(resolveWorkspaceRoot(cwd));
177
+ if (!existsSync(path)) {
178
+ return null;
179
+ }
180
+ return JSON.parse(await readFile(path, 'utf8'));
181
+ }
182
+
183
+ export async function readState(cwd, slug) {
184
+ const path = statePath(resolveWorkflowRoot(cwd, slug));
185
+ if (!existsSync(path)) {
186
+ return null;
187
+ }
188
+ return JSON.parse(await readFile(path, 'utf8'));
189
+ }
190
+
191
+ function buildWorkspaceReadme() {
192
+ return [
193
+ '# LoopX Workspace',
194
+ '',
195
+ 'This directory is initialized for the LoopX skill-first runtime contract.',
196
+ '',
197
+ '## Default Flow',
198
+ '',
199
+ '`clarify -> plan -> build -> review -> done`',
200
+ '',
201
+ '## Runtime Commands',
202
+ '',
203
+ '- `loopx init [--slug <slug>]`',
204
+ '- `loopx clarify <slug>`',
205
+ '- `loopx approve <slug> --from <stage> --to <stage>`',
206
+ '- `loopx plan <slug>`',
207
+ '- `loopx build <slug>`',
208
+ '- `loopx review <slug> [--reviewer <name>]`',
209
+ '- `loopx autopilot <slug> [--reviewer <name>]`',
210
+ '- `loopx status [slug] [--json]`',
211
+ '- `loopx doctor`',
212
+ '- `loopx migrate`',
213
+ '- `loopx repair-install`',
214
+ ].join('\n');
215
+ }
216
+
217
+ function createInitialState(slug) {
218
+ return {
219
+ schema_version: WORKFLOW_SCHEMA_VERSION,
220
+ slug,
221
+ current_stage: STAGES.CLARIFY,
222
+ stage_status: 'blocked',
223
+ ambiguity_items: [
224
+ {
225
+ id: 'A-1',
226
+ question: 'What specific task should LoopX execute in this workflow?',
227
+ status: 'open',
228
+ resolution: null,
229
+ },
230
+ ],
231
+ unresolved_ambiguity_count: 1,
232
+ plan_package_status: 'missing',
233
+ review_status: 'not-started',
234
+ recommended_next_action: 'Resolve ambiguity items in spec.md before requesting approval to enter plan.',
235
+ rollback_target: 'none',
236
+ rollback_rationale: null,
237
+ pending_user_decision: TRANSITIONS.NONE,
238
+ requested_transition: TRANSITIONS.NONE,
239
+ last_confirmed_transition: TRANSITIONS.NONE,
240
+ approval: {
241
+ plan: APPROVAL_STATES.NOT_REQUESTED,
242
+ build: APPROVAL_STATES.NOT_REQUESTED,
243
+ review: APPROVAL_STATES.NOT_REQUESTED,
244
+ rollback: APPROVAL_STATES.NOT_REQUESTED,
245
+ complete: APPROVAL_STATES.NOT_REQUESTED,
246
+ },
247
+ execution_record_status: 'missing',
248
+ review_verdict: 'none',
249
+ completion_confirmed: false,
250
+ active_run_id: null,
251
+ spec_artifact_path: null,
252
+ plan_artifact_path: null,
253
+ test_spec_artifact_path: null,
254
+ };
255
+ }
256
+
257
+ function detectLegacyContract(root, state) {
258
+ if (!state) {
259
+ return false;
260
+ }
261
+ if (!('schema_version' in state)) {
262
+ return true;
263
+ }
264
+ if (existsSync(artifactPath(root, 'brief.md')) && !existsSync(artifactPath(root, 'spec.md'))) {
265
+ return true;
266
+ }
267
+ if (existsSync(artifactPath(root, 'build-result.md')) && !existsSync(artifactPath(root, 'execution-record.md'))) {
268
+ return true;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ function collectArtifactPresence(root, names) {
274
+ return Object.fromEntries(names.map((name) => [name, existsSync(artifactPath(root, name))]));
275
+ }
276
+
277
+ function withTemplateVariables(template, replacements) {
278
+ return Object.entries(replacements).reduce(
279
+ (content, [key, value]) => content.replaceAll(`<${key}>`, String(value)),
280
+ template,
281
+ );
282
+ }
283
+
284
+ async function writeTemplateArtifact(root, name, replacements) {
285
+ const template = await readTemplate(name);
286
+ await writeText(artifactPath(root, name), withTemplateVariables(template, replacements));
287
+ }
288
+
289
+ async function copyArtifact(fromRoot, toPath, name) {
290
+ await ensureDir(dirname(toPath));
291
+ const content = await readFile(artifactPath(fromRoot, name), 'utf8');
292
+ await writeText(toPath, content);
293
+ }
294
+
295
+ async function writeCanonicalPlanArtifacts(cwd, root, slug) {
296
+ const planPath = join(resolvePlansRoot(cwd), `prd-${slug}.md`);
297
+ const testSpecPath = join(resolvePlansRoot(cwd), `test-spec-${slug}.md`);
298
+ const planText = await readFile(artifactPath(root, 'plan.md'), 'utf8');
299
+ const architectureText = await readFile(artifactPath(root, 'architecture.md'), 'utf8');
300
+ const developmentPlanText = await readFile(artifactPath(root, 'development-plan.md'), 'utf8');
301
+ const testPlanText = await readFile(artifactPath(root, 'test-plan.md'), 'utf8');
302
+
303
+ await writeText(
304
+ planPath,
305
+ [
306
+ `# LoopX PRD: ${slug}`,
307
+ '',
308
+ '## Plan',
309
+ '',
310
+ planText,
311
+ '',
312
+ '## Architecture',
313
+ '',
314
+ architectureText,
315
+ '',
316
+ '## Development Plan',
317
+ '',
318
+ developmentPlanText,
319
+ ].join('\n'),
320
+ );
321
+ await writeText(testSpecPath, testPlanText);
322
+ return { planPath, testSpecPath };
323
+ }
324
+
325
+ async function readSpecSummary(root) {
326
+ const text = await readTextIfExists(artifactPath(root, 'spec.md'));
327
+ if (!text) {
328
+ return { unresolvedCount: 1 };
329
+ }
330
+ const meta = parseFrontmatter(text);
331
+ const unresolvedCount = Number.parseInt(String(meta.unresolved_ambiguity_count ?? 1), 10);
332
+ return { unresolvedCount: Number.isNaN(unresolvedCount) ? 1 : unresolvedCount };
333
+ }
334
+
335
+ async function readExecutionRecordSummary(root) {
336
+ const text = await readTextIfExists(artifactPath(root, 'execution-record.md'));
337
+ if (!text) {
338
+ return { status: 'missing', meta: {} };
339
+ }
340
+ const meta = parseFrontmatter(text);
341
+ const hasRequiredMeta = [
342
+ 'schema_version',
343
+ 'workflow_id',
344
+ 'run_id',
345
+ 'stage',
346
+ 'actor_id',
347
+ 'actor_role',
348
+ 'plan_digest',
349
+ 'started_at',
350
+ 'completed_at',
351
+ 'checkpoint_count',
352
+ 'evidence_manifest',
353
+ ].every((field) => meta[field] !== undefined && meta[field] !== null && meta[field] !== '');
354
+ const hasEvidenceManifest = Array.isArray(meta.evidence_manifest) && meta.evidence_manifest.length > 0;
355
+ const hasExecutionEvidence = /## Execution Evidence/i.test(text);
356
+ const hasVerificationEvidence = /## Verification Evidence/i.test(text);
357
+ const hasPlaceholder = /\bTODO\b|<[^>\n]+>/.test(text);
358
+ return {
359
+ status: hasRequiredMeta && hasEvidenceManifest && hasExecutionEvidence && hasVerificationEvidence && !hasPlaceholder ? 'complete' : 'partial',
360
+ meta,
361
+ };
362
+ }
363
+
364
+ function recommendedAction(state, legacy = false) {
365
+ if (legacy) {
366
+ return 'Legacy codex-helper workflow detected. Run loopx migrate or create a new LoopX workflow.';
367
+ }
368
+
369
+ switch (state.current_stage) {
370
+ case STAGES.CLARIFY:
371
+ return state.approval.plan === APPROVAL_STATES.APPROVED
372
+ ? 'Run loopx plan to consume the approved clarify -> plan transition.'
373
+ : 'Resolve ambiguity and approve clarify -> plan.';
374
+ case STAGES.PLAN:
375
+ return state.approval.build === APPROVAL_STATES.APPROVED
376
+ ? 'Run loopx build to consume the approved plan -> build transition.'
377
+ : 'Approve plan -> build when the plan package is ready.';
378
+ case STAGES.BUILD:
379
+ return state.approval.review === APPROVAL_STATES.APPROVED
380
+ ? 'Run loopx review to consume the approved build -> review transition.'
381
+ : 'Approve build -> review when execution-record.md is complete.';
382
+ case STAGES.REVIEW:
383
+ if (state.review_verdict === 'approve') {
384
+ return state.approval.complete === APPROVAL_STATES.APPROVED
385
+ ? 'Run loopx review again to consume the approved review -> done transition.'
386
+ : 'Approve review -> done to complete the workflow.';
387
+ }
388
+ if (state.review_verdict === 'request-changes') {
389
+ return state.approval.rollback === APPROVAL_STATES.APPROVED
390
+ ? 'Run loopx review again to consume the approved review -> plan transition.'
391
+ : 'Approve review -> plan to roll back for another planning pass.';
392
+ }
393
+ return 'Run loopx review after build completes.';
394
+ case STAGES.DONE:
395
+ return 'Workflow is complete.';
396
+ default:
397
+ return 'Run loopx clarify to start a workflow.';
398
+ }
399
+ }
400
+
401
+ function withRecommendedAction(state, legacy = false) {
402
+ return {
403
+ ...state,
404
+ recommended_next_action: recommendedAction(state, legacy),
405
+ };
406
+ }
407
+
408
+ async function loadWorkflowState(cwd, slug, { allowLegacy = true } = {}) {
409
+ const normalized = normalizeSlug(slug);
410
+ const root = resolveWorkflowRoot(cwd, normalized);
411
+ const state = await readState(cwd, normalized);
412
+ if (!state) {
413
+ throw new Error('workflow_not_initialized');
414
+ }
415
+ const legacy = detectLegacyContract(root, state);
416
+ if (legacy && !allowLegacy) {
417
+ throw new Error('legacy_workflow_not_supported');
418
+ }
419
+ return { slug: normalized, root, legacy, state: withRecommendedAction(state, legacy) };
420
+ }
421
+
422
+ function transitionKey(from, to) {
423
+ const value = `${from}->${to}`;
424
+ if (!Object.values(TRANSITIONS).includes(value)) {
425
+ throw new Error(`invalid_transition:${value}`);
426
+ }
427
+ return value;
428
+ }
429
+
430
+ function approvalKeyForTransition(transition) {
431
+ switch (transition) {
432
+ case TRANSITIONS.CLARIFY_TO_PLAN:
433
+ return 'plan';
434
+ case TRANSITIONS.PLAN_TO_BUILD:
435
+ return 'build';
436
+ case TRANSITIONS.BUILD_TO_REVIEW:
437
+ return 'review';
438
+ case TRANSITIONS.REVIEW_TO_PLAN:
439
+ return 'rollback';
440
+ case TRANSITIONS.REVIEW_TO_DONE:
441
+ return 'complete';
442
+ default:
443
+ throw new Error(`approval_key_not_found:${transition}`);
444
+ }
445
+ }
446
+
447
+ function ensureApprovedTransition(state, expectedTransition, key) {
448
+ if (state.requested_transition !== expectedTransition || state.approval[key] !== APPROVAL_STATES.APPROVED) {
449
+ throw new Error(`approved_transition_required:${expectedTransition}`);
450
+ }
451
+ }
452
+
453
+ function executionRecordTemplate(slug, stage, actorId, runId) {
454
+ const timestamp = nowIso();
455
+ return [
456
+ frontmatterBlock({
457
+ schema_version: WORKFLOW_SCHEMA_VERSION,
458
+ workflow_id: slug,
459
+ run_id: runId,
460
+ stage,
461
+ actor_id: actorId,
462
+ actor_role: stage,
463
+ plan_digest: `plan@${slug}`,
464
+ started_at: timestamp,
465
+ completed_at: timestamp,
466
+ checkpoint_count: 0,
467
+ evidence_manifest: [],
468
+ }),
469
+ `# LoopX Execution Record: ${slug}`,
470
+ '',
471
+ '## Changes',
472
+ '',
473
+ '- TODO: summarize the implementation result.',
474
+ '',
475
+ '## Checkpoint Log',
476
+ '',
477
+ '- TODO: record execution checkpoints.',
478
+ '',
479
+ '## Execution Evidence',
480
+ '',
481
+ '- TODO: add concrete execution evidence.',
482
+ '',
483
+ '## Verification Evidence',
484
+ '',
485
+ '- TODO: add concrete verification evidence.',
486
+ '',
487
+ '## Limitations',
488
+ '',
489
+ '- TODO: record remaining limitations.',
490
+ ].join('\n');
491
+ }
492
+
493
+ function reviewReportContent({ slug, reviewer, runId, verdict, rollbackTarget, rollbackRationale, inputManifest, evidenceManifest, findings }) {
494
+ return [
495
+ frontmatterBlock({
496
+ schema_version: WORKFLOW_SCHEMA_VERSION,
497
+ workflow_id: slug,
498
+ review_id: `${slug}-review-${Date.now()}`,
499
+ reviewer_id: reviewer,
500
+ reviewed_run_id: runId,
501
+ input_manifest: inputManifest,
502
+ evidence_manifest: evidenceManifest,
503
+ verdict: verdict.toLowerCase().replace('request changes', 'request-changes'),
504
+ rollback_target: rollbackTarget,
505
+ rollback_rationale: rollbackRationale ?? null,
506
+ }),
507
+ `# LoopX Review Report: ${slug}`,
508
+ '',
509
+ '## Verdict',
510
+ '',
511
+ `- ${verdict}`,
512
+ '',
513
+ '## Evidence Reviewed',
514
+ '',
515
+ ...inputManifest.map((item) => `- ${item}`),
516
+ '',
517
+ '## Findings',
518
+ '',
519
+ ...findings.map((item) => `- ${item}`),
520
+ '',
521
+ '## Rollback Recommendation',
522
+ '',
523
+ `- ${rollbackTarget}`,
524
+ rollbackRationale ? `- ${rollbackRationale}` : '- none',
525
+ ].join('\n');
526
+ }
527
+
528
+ async function refreshExecutionStatus(root, state) {
529
+ const summary = await readExecutionRecordSummary(root);
530
+ return {
531
+ state: {
532
+ ...state,
533
+ execution_record_status: summary.status,
534
+ },
535
+ executionSummary: summary,
536
+ };
537
+ }
538
+
539
+ export async function initWorkspace(cwd, { slug } = {}) {
540
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
541
+ await ensureLoopXRoot(cwd);
542
+ await ensureDir(join(workspaceRoot, 'context'));
543
+ await ensureDir(join(workspaceRoot, 'workflows'));
544
+ await ensureDir(join(workspaceRoot, 'specs'));
545
+ await ensureDir(join(workspaceRoot, 'plans'));
546
+ await ensureDir(join(workspaceRoot, 'autopilot'));
547
+
548
+ const config = {
549
+ schema_version: WORKSPACE_SCHEMA_VERSION,
550
+ tool: 'LoopX',
551
+ product_contract: 'skill-first-v1',
552
+ default_flow: ['clarify', 'plan', 'build', 'review', 'done'],
553
+ preferred_surface: ['clarify', 'plan', 'build', 'review', 'autopilot'],
554
+ };
555
+
556
+ if (!existsSync(workspaceConfigPath(workspaceRoot))) {
557
+ await writeText(workspaceConfigPath(workspaceRoot), JSON.stringify(config, null, 2));
558
+ }
559
+ if (!existsSync(workspaceReadmePath(workspaceRoot))) {
560
+ await writeText(workspaceReadmePath(workspaceRoot), buildWorkspaceReadme());
561
+ }
562
+
563
+ let workflow = null;
564
+ if (slug) {
565
+ workflow = await clarifyStage(cwd, slug);
566
+ }
567
+ return { workspaceRoot, config, workflow };
568
+ }
569
+
570
+ export async function clarifyStage(cwd, slug) {
571
+ const normalized = normalizeSlug(slug);
572
+ const root = resolveWorkflowRoot(cwd, normalized);
573
+ await ensureLoopXRoot(cwd);
574
+ await ensureDir(root);
575
+ const stamp = nowStamp();
576
+ await writeTemplateArtifact(root, 'spec.md', {
577
+ 'task name': normalized,
578
+ 'workflow id': normalized,
579
+ });
580
+ const specArtifactPath = canonicalClarifySpecPath(cwd, normalized, stamp);
581
+ await copyArtifact(root, specArtifactPath, 'spec.md');
582
+ const state = withRecommendedAction({
583
+ ...createInitialState(normalized),
584
+ spec_artifact_path: specArtifactPath,
585
+ });
586
+ await writeState(root, state);
587
+ return { root, state };
588
+ }
589
+
590
+ export async function approveStage(cwd, slug, { from, to }) {
591
+ const { root, state } = await loadWorkflowState(cwd, slug, { allowLegacy: false });
592
+ if (state.current_stage !== from) {
593
+ throw new Error(`approval_from_stage_mismatch:${from}`);
594
+ }
595
+ const transition = transitionKey(from, to);
596
+ const approvalKey = approvalKeyForTransition(transition);
597
+ let next = { ...state };
598
+
599
+ if (transition === TRANSITIONS.CLARIFY_TO_PLAN) {
600
+ const spec = await readSpecSummary(root);
601
+ next.unresolved_ambiguity_count = spec.unresolvedCount;
602
+ if (spec.unresolvedCount > 0) {
603
+ throw new Error('unresolved_ambiguity');
604
+ }
605
+ }
606
+
607
+ if (transition === TRANSITIONS.PLAN_TO_BUILD) {
608
+ if (!PLAN_ARTIFACTS.every((name) => existsSync(artifactPath(root, name)))) {
609
+ throw new Error('plan_package_incomplete');
610
+ }
611
+ next.plan_package_status = 'complete';
612
+ }
613
+
614
+ if (transition === TRANSITIONS.BUILD_TO_REVIEW) {
615
+ const refreshed = await refreshExecutionStatus(root, state);
616
+ next = refreshed.state;
617
+ if (next.execution_record_status !== 'complete') {
618
+ throw new Error('review_gate_blocked:execution-record.md');
619
+ }
620
+ }
621
+
622
+ if (transition === TRANSITIONS.REVIEW_TO_PLAN) {
623
+ if (!next.rollback_rationale) {
624
+ throw new Error('rollback_rationale_required');
625
+ }
626
+ }
627
+
628
+ if (transition === TRANSITIONS.REVIEW_TO_DONE && next.review_verdict !== 'approve') {
629
+ throw new Error('review_not_approved');
630
+ }
631
+
632
+ next = withRecommendedAction({
633
+ ...next,
634
+ stage_status: 'awaiting-approval',
635
+ pending_user_decision: TRANSITIONS.NONE,
636
+ requested_transition: transition,
637
+ approval: {
638
+ ...next.approval,
639
+ [approvalKey]: APPROVAL_STATES.APPROVED,
640
+ },
641
+ });
642
+ await writeState(root, next);
643
+ return { root, state: next };
644
+ }
645
+
646
+ export async function planStage(cwd, slug) {
647
+ const { root, state, slug: normalized } = await loadWorkflowState(cwd, slug, { allowLegacy: false });
648
+ ensureApprovedTransition(state, TRANSITIONS.CLARIFY_TO_PLAN, 'plan');
649
+ if (state.spec_artifact_path) {
650
+ await copyArtifact(root, state.spec_artifact_path, 'spec.md');
651
+ }
652
+ for (const name of PLAN_ARTIFACTS) {
653
+ await writeTemplateArtifact(root, name, {
654
+ 'task name': normalized,
655
+ 'workflow id': normalized,
656
+ });
657
+ }
658
+ const { planPath, testSpecPath } = await writeCanonicalPlanArtifacts(cwd, root, normalized);
659
+
660
+ const next = withRecommendedAction({
661
+ ...state,
662
+ current_stage: STAGES.PLAN,
663
+ stage_status: 'awaiting-approval',
664
+ plan_package_status: 'complete',
665
+ pending_user_decision: TRANSITIONS.NONE,
666
+ requested_transition: TRANSITIONS.NONE,
667
+ last_confirmed_transition: TRANSITIONS.CLARIFY_TO_PLAN,
668
+ approval: {
669
+ ...state.approval,
670
+ plan: APPROVAL_STATES.APPROVED,
671
+ build: APPROVAL_STATES.NOT_REQUESTED,
672
+ review: APPROVAL_STATES.NOT_REQUESTED,
673
+ rollback: APPROVAL_STATES.NOT_REQUESTED,
674
+ complete: APPROVAL_STATES.NOT_REQUESTED,
675
+ },
676
+ plan_artifact_path: planPath,
677
+ test_spec_artifact_path: testSpecPath,
678
+ });
679
+ await writeState(root, next);
680
+ return { root, state: next };
681
+ }
682
+
683
+ export async function buildStage(cwd, slug) {
684
+ const { root, state, slug: normalized } = await loadWorkflowState(cwd, slug, { allowLegacy: false });
685
+ ensureApprovedTransition(state, TRANSITIONS.PLAN_TO_BUILD, 'build');
686
+ const runId = `${normalized}-build-draft`;
687
+ await writeText(artifactPath(root, 'execution-record.md'), executionRecordTemplate(normalized, STAGES.BUILD, `${normalized}-builder-1`, runId));
688
+
689
+ const next = withRecommendedAction({
690
+ ...state,
691
+ current_stage: STAGES.BUILD,
692
+ stage_status: 'blocked',
693
+ execution_record_status: 'partial',
694
+ review_status: 'pending-input',
695
+ active_run_id: runId,
696
+ pending_user_decision: TRANSITIONS.NONE,
697
+ requested_transition: TRANSITIONS.NONE,
698
+ last_confirmed_transition: TRANSITIONS.PLAN_TO_BUILD,
699
+ approval: {
700
+ ...state.approval,
701
+ build: APPROVAL_STATES.APPROVED,
702
+ review: APPROVAL_STATES.NOT_REQUESTED,
703
+ rollback: APPROVAL_STATES.NOT_REQUESTED,
704
+ complete: APPROVAL_STATES.NOT_REQUESTED,
705
+ },
706
+ });
707
+ await writeState(root, next);
708
+ return { root, state: next };
709
+ }
710
+
711
+ function reviewFindings({ executionMeta, executionStatus, reviewer }) {
712
+ const inputManifest = ['spec.md', ...PLAN_ARTIFACTS, 'execution-record.md'];
713
+ const evidenceManifest = Array.isArray(executionMeta.evidence_manifest) ? [...executionMeta.evidence_manifest] : [];
714
+ const findings = [];
715
+ let verdict = 'APPROVE';
716
+ let rollbackTarget = 'none';
717
+ let rollbackRationale = null;
718
+
719
+ if (executionStatus !== 'complete') {
720
+ findings.push('execution-record.md is missing required execution or verification evidence.');
721
+ verdict = 'REQUEST CHANGES';
722
+ rollbackTarget = 'plan';
723
+ rollbackRationale = 'Execution evidence is incomplete, so the workflow must return to planning before another run.';
724
+ }
725
+ if (!Array.isArray(executionMeta.evidence_manifest) || executionMeta.evidence_manifest.length === 0) {
726
+ findings.push('execution-record.md is missing the required evidence_manifest schema.');
727
+ verdict = 'REQUEST CHANGES';
728
+ rollbackTarget = 'plan';
729
+ rollbackRationale = 'Execution evidence schema is incomplete, so review cannot accept the run.';
730
+ }
731
+ if (executionMeta.actor_id === reviewer) {
732
+ findings.push('Reviewer provenance matches the execution actor and is not independent.');
733
+ verdict = 'REQUEST CHANGES';
734
+ rollbackTarget = 'plan';
735
+ rollbackRationale = 'Review independence failed because reviewer provenance matches the execution actor.';
736
+ }
737
+
738
+ return {
739
+ verdict,
740
+ findings: findings.length > 0 ? findings : ['Structured evidence and provenance checks passed.'],
741
+ inputManifest,
742
+ evidenceManifest,
743
+ rollbackTarget,
744
+ rollbackRationale,
745
+ };
746
+ }
747
+
748
+ export async function reviewStage(cwd, slug, { reviewer = 'independent-reviewer' } = {}) {
749
+ const { root, state, slug: normalized } = await loadWorkflowState(cwd, slug, { allowLegacy: false });
750
+
751
+ if (state.current_stage === STAGES.REVIEW && state.approval.complete === APPROVAL_STATES.APPROVED && state.review_verdict === 'approve') {
752
+ const next = withRecommendedAction({
753
+ ...state,
754
+ current_stage: STAGES.DONE,
755
+ stage_status: 'completed',
756
+ pending_user_decision: TRANSITIONS.NONE,
757
+ requested_transition: TRANSITIONS.NONE,
758
+ last_confirmed_transition: TRANSITIONS.REVIEW_TO_DONE,
759
+ completion_confirmed: true,
760
+ });
761
+ await writeState(root, next);
762
+ return { root, state: next, verdict: 'APPROVE', rollbackTarget: 'none' };
763
+ }
764
+
765
+ if (state.current_stage === STAGES.REVIEW && state.approval.rollback === APPROVAL_STATES.APPROVED && state.review_verdict === 'request-changes') {
766
+ const next = withRecommendedAction({
767
+ ...state,
768
+ current_stage: STAGES.PLAN,
769
+ stage_status: 'awaiting-approval',
770
+ pending_user_decision: TRANSITIONS.NONE,
771
+ requested_transition: TRANSITIONS.NONE,
772
+ last_confirmed_transition: TRANSITIONS.REVIEW_TO_PLAN,
773
+ plan_package_status: 'complete',
774
+ approval: {
775
+ ...state.approval,
776
+ build: APPROVAL_STATES.NOT_REQUESTED,
777
+ review: APPROVAL_STATES.NOT_REQUESTED,
778
+ rollback: APPROVAL_STATES.APPROVED,
779
+ },
780
+ });
781
+ await writeState(root, next);
782
+ return { root, state: next, verdict: 'REQUEST CHANGES', rollbackTarget: 'plan' };
783
+ }
784
+
785
+ ensureApprovedTransition(state, TRANSITIONS.BUILD_TO_REVIEW, 'review');
786
+ const { state: refreshed, executionSummary } = await refreshExecutionStatus(root, state);
787
+ const reviewInput = reviewFindings({
788
+ executionMeta: executionSummary.meta,
789
+ executionStatus: refreshed.execution_record_status,
790
+ reviewer,
791
+ });
792
+ const runId = executionSummary.meta.run_id || refreshed.active_run_id || `${normalized}-unknown-run`;
793
+
794
+ await writeText(
795
+ artifactPath(root, 'review-report.md'),
796
+ reviewReportContent({
797
+ slug: normalized,
798
+ reviewer,
799
+ runId,
800
+ verdict: reviewInput.verdict,
801
+ rollbackTarget: reviewInput.rollbackTarget,
802
+ rollbackRationale: reviewInput.rollbackRationale,
803
+ inputManifest: reviewInput.inputManifest,
804
+ evidenceManifest: reviewInput.evidenceManifest,
805
+ findings: reviewInput.findings,
806
+ }),
807
+ );
808
+
809
+ const next = withRecommendedAction({
810
+ ...refreshed,
811
+ current_stage: STAGES.REVIEW,
812
+ stage_status: 'awaiting-approval',
813
+ review_status: 'in-review',
814
+ pending_user_decision: reviewInput.verdict === 'APPROVE' ? TRANSITIONS.REVIEW_TO_DONE : TRANSITIONS.REVIEW_TO_PLAN,
815
+ requested_transition: TRANSITIONS.NONE,
816
+ last_confirmed_transition: TRANSITIONS.BUILD_TO_REVIEW,
817
+ review_verdict: reviewInput.verdict === 'APPROVE' ? 'approve' : 'request-changes',
818
+ rollback_target: reviewInput.rollbackTarget,
819
+ rollback_rationale: reviewInput.rollbackRationale,
820
+ approval: {
821
+ ...refreshed.approval,
822
+ review: APPROVAL_STATES.APPROVED,
823
+ rollback: reviewInput.verdict === 'APPROVE' ? APPROVAL_STATES.NOT_REQUESTED : APPROVAL_STATES.REQUESTED,
824
+ complete: reviewInput.verdict === 'APPROVE' ? APPROVAL_STATES.REQUESTED : APPROVAL_STATES.NOT_REQUESTED,
825
+ },
826
+ });
827
+ await writeState(root, next);
828
+ return { root, state: next, verdict: reviewInput.verdict, rollbackTarget: reviewInput.rollbackTarget };
829
+ }
830
+
831
+ export async function autopilotStage(cwd, slug, { reviewer = 'autopilot-reviewer' } = {}) {
832
+ const normalized = normalizeSlug(slug);
833
+ const workflowRoot = resolveWorkflowRoot(cwd, normalized);
834
+ if (!existsSync(statePath(workflowRoot))) {
835
+ await clarifyStage(cwd, normalized);
836
+ }
837
+
838
+ const { root } = await loadWorkflowState(cwd, normalized, { allowLegacy: false });
839
+ const spec = await readSpecSummary(root);
840
+ if (spec.unresolvedCount > 0) {
841
+ throw new Error('autopilot_requires_resolved_spec');
842
+ }
843
+
844
+ const controlEvents = [];
845
+ const recordEvent = (transition) => controlEvents.push({
846
+ transition,
847
+ actor: 'loopx-autopilot',
848
+ recorded_at: nowIso(),
849
+ });
850
+
851
+ await approveStage(cwd, normalized, { from: STAGES.CLARIFY, to: STAGES.PLAN });
852
+ recordEvent(TRANSITIONS.CLARIFY_TO_PLAN);
853
+ await planStage(cwd, normalized);
854
+ await approveStage(cwd, normalized, { from: STAGES.PLAN, to: STAGES.BUILD });
855
+ recordEvent(TRANSITIONS.PLAN_TO_BUILD);
856
+ const build = await buildStage(cwd, normalized);
857
+ await writeText(
858
+ artifactPath(build.root, 'execution-record.md'),
859
+ [
860
+ frontmatterBlock({
861
+ schema_version: WORKFLOW_SCHEMA_VERSION,
862
+ workflow_id: normalized,
863
+ run_id: `${normalized}-autopilot-run-1`,
864
+ stage: STAGES.BUILD,
865
+ actor_id: 'loopx-autopilot',
866
+ actor_role: 'autopilot',
867
+ plan_digest: `plan@${normalized}`,
868
+ started_at: nowIso(),
869
+ completed_at: nowIso(),
870
+ checkpoint_count: 3,
871
+ evidence_manifest: [
872
+ { id: `${normalized}-autopilot`, kind: 'command', summary: 'loopx autopilot executed', ref: `loopx autopilot ${normalized}` },
873
+ { id: `${normalized}-review`, kind: 'artifact', summary: 'review report generated', ref: 'review-report.md' },
874
+ ],
875
+ }),
876
+ `# LoopX Execution Record: ${normalized}`,
877
+ '',
878
+ '## Changes',
879
+ '',
880
+ '- LoopX autopilot completed one bounded build path.',
881
+ '',
882
+ '## Checkpoint Log',
883
+ '',
884
+ '- checkpoint 1: internal approvals recorded',
885
+ '- checkpoint 2: plan and build completed',
886
+ '- checkpoint 3: review requested',
887
+ '',
888
+ '## Execution Evidence',
889
+ '',
890
+ `- \`loopx autopilot ${normalized}\``,
891
+ '',
892
+ '## Verification Evidence',
893
+ '',
894
+ '- PASS: bounded autopilot composition completed',
895
+ '- PASS: review-ready evidence generated',
896
+ '',
897
+ '## Limitations',
898
+ '',
899
+ '- none',
900
+ ].join('\n'),
901
+ );
902
+ await approveStage(cwd, normalized, { from: STAGES.BUILD, to: STAGES.REVIEW });
903
+ recordEvent(TRANSITIONS.BUILD_TO_REVIEW);
904
+ const review = await reviewStage(cwd, normalized, { reviewer });
905
+ if (review.verdict !== 'APPROVE') {
906
+ throw new Error('autopilot_review_failed');
907
+ }
908
+ await approveStage(cwd, normalized, { from: STAGES.REVIEW, to: STAGES.DONE });
909
+ recordEvent(TRANSITIONS.REVIEW_TO_DONE);
910
+ const done = await reviewStage(cwd, normalized, { reviewer });
911
+
912
+ const autopilotRoot = join(resolveWorkspaceRoot(cwd), 'autopilot', normalized);
913
+ await ensureDir(autopilotRoot);
914
+ await writeText(join(autopilotRoot, 'run.json'), JSON.stringify({
915
+ workflowId: normalized,
916
+ reviewer,
917
+ controlEvents,
918
+ reviewedRunId: `${normalized}-autopilot-run-1`,
919
+ completed: true,
920
+ }, null, 2));
921
+
922
+ return { root: done.root, state: done.state, runPath: join(autopilotRoot, 'run.json') };
923
+ }
924
+
925
+ async function listWorkflowSummaries(workflowsRoot) {
926
+ if (!existsSync(workflowsRoot)) {
927
+ return [];
928
+ }
929
+ const entries = await readdir(workflowsRoot, { withFileTypes: true });
930
+ const workflows = [];
931
+ for (const entry of entries) {
932
+ if (!entry.isDirectory()) {
933
+ continue;
934
+ }
935
+ const slug = entry.name;
936
+ const root = join(workflowsRoot, slug);
937
+ const state = existsSync(statePath(root)) ? JSON.parse(await readFile(statePath(root), 'utf8')) : null;
938
+ const legacy = detectLegacyContract(root, state);
939
+ const artifacts = collectArtifactPresence(root, legacy ? LEGACY_ARTIFACTS : V1_ARTIFACTS);
940
+ workflows.push({
941
+ slug,
942
+ current_stage: state?.current_stage ?? null,
943
+ contract: legacy ? 'legacy-codex-helper' : 'loopx-v1',
944
+ legacy,
945
+ schema_version: state?.schema_version ?? 0,
946
+ requested_transition: state?.requested_transition ?? TRANSITIONS.NONE,
947
+ last_confirmed_transition: state?.last_confirmed_transition ?? TRANSITIONS.NONE,
948
+ missing_artifact_count: Object.values(artifacts).filter((present) => !present).length,
949
+ });
950
+ }
951
+ return workflows.sort((left, right) => left.slug.localeCompare(right.slug));
952
+ }
953
+
954
+ function summarizeWorkspace(workflows) {
955
+ return workflows.reduce((summary, workflow) => {
956
+ summary.total += 1;
957
+ summary.by_stage[workflow.current_stage ?? 'unknown'] = (summary.by_stage[workflow.current_stage ?? 'unknown'] || 0) + 1;
958
+ if (workflow.legacy) {
959
+ summary.legacy += 1;
960
+ }
961
+ return summary;
962
+ }, {
963
+ total: 0,
964
+ legacy: 0,
965
+ by_stage: {},
966
+ });
967
+ }
968
+
969
+ export async function statusSummary(cwd, slug) {
970
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
971
+ const initialized = existsSync(workspaceRoot);
972
+ const config = await readWorkspaceConfig(cwd);
973
+ const workflowsRoot = join(workspaceRoot, 'workflows');
974
+
975
+ if (!slug) {
976
+ const workflows = await listWorkflowSummaries(workflowsRoot);
977
+ return {
978
+ initialized,
979
+ workspaceRoot,
980
+ config,
981
+ workflows,
982
+ workflow_count: workflows.length,
983
+ summary: summarizeWorkspace(workflows),
984
+ next_action: initialized ? 'Run loopx clarify <slug> to start a workflow, or inspect one with loopx status <slug>.' : 'Run loopx init to prepare the workspace.',
985
+ };
986
+ }
987
+
988
+ const normalized = normalizeSlug(slug);
989
+ const root = resolveWorkflowRoot(cwd, normalized);
990
+ const state = await readState(cwd, normalized);
991
+ const legacy = detectLegacyContract(root, state);
992
+ const artifacts = collectArtifactPresence(root, legacy ? LEGACY_ARTIFACTS : V1_ARTIFACTS);
993
+ const missing = Object.entries(artifacts).filter(([, present]) => !present).map(([name]) => name);
994
+ return {
995
+ initialized,
996
+ workspaceRoot,
997
+ config,
998
+ slug: normalized,
999
+ root,
1000
+ state: state ? withRecommendedAction(state, legacy) : null,
1001
+ legacy,
1002
+ contract: legacy ? 'legacy-codex-helper' : 'loopx-v1',
1003
+ schema_version: state?.schema_version ?? 0,
1004
+ artifacts,
1005
+ missing_artifacts: missing,
1006
+ next_action: state ? recommendedAction(state, legacy) : 'Run loopx clarify to start a workflow.',
1007
+ };
1008
+ }