@michaelxwb/code-flow 0.6.1 → 0.6.3

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.
Files changed (60) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  3. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  4. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  5. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  6. package/src/adapters/claude/commands/cf-task/plan.md +40 -0
  7. package/src/adapters/claude/commands/cf-task/start.md +22 -22
  8. package/src/adapters/claude/commands/cf-task/verify-e2e.md +110 -0
  9. package/src/adapters/claude/commands/cf-validate.md +12 -13
  10. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  11. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  12. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  13. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  14. package/src/adapters/codex/skills/cf-task-start/SKILL.md +22 -22
  15. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  16. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  17. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  18. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  19. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  20. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  21. package/src/adapters/costrict/commands/cf-task/plan.md +40 -0
  22. package/src/adapters/costrict/commands/cf-task/start.md +22 -22
  23. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +110 -0
  24. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  25. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  27. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  29. package/src/adapters/opencode/commands/cf-task/plan.md +40 -5
  30. package/src/adapters/opencode/commands/cf-task/start.md +22 -22
  31. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +110 -0
  32. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  33. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  34. package/src/cli.js +177 -63
  35. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  36. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +248 -0
  37. package/src/core/code-flow/scripts/cf_acceptance_runner.py +192 -0
  38. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  39. package/src/core/code-flow/scripts/cf_core.py +26 -0
  40. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  41. package/src/core/code-flow/scripts/cf_post_hook.py +2 -0
  42. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +13 -8
  43. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  44. package/src/core/code-flow/scripts/cf_spec_context.py +267 -28
  45. package/src/core/code-flow/scripts/cf_spec_gate.py +10 -0
  46. package/src/core/code-flow/scripts/cf_spec_metadata.py +60 -11
  47. package/src/core/code-flow/scripts/cf_spec_resolver.py +85 -9
  48. package/src/core/code-flow/scripts/cf_spec_router.py +104 -9
  49. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  50. package/src/core/code-flow/scripts/cf_spec_verify.py +159 -38
  51. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  52. package/src/core/code-flow/scripts/cf_stop_hook.py +100 -59
  53. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  54. package/src/core/code-flow/scripts/cf_task_runtime.py +84 -3
  55. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  56. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  57. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +11 -5
  58. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  59. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  60. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
package/src/cli.js CHANGED
@@ -10,7 +10,7 @@ const pkg = require('../package.json');
10
10
  const specWorkflowMigration = require('./migrate/spec-workflow');
11
11
 
12
12
  const usage = [
13
- 'Usage: code-flow init [--force] [--platform=<claude|codex|costrict|opencode>]',
13
+ 'Usage: code-flow init [--force] [--platform=<claude|codex|costrict|opencode>] [--backup-dir=<path>]',
14
14
  ' code-flow migrate --spec-workflow <--dry-run|--prepare|--apply --plan <path>|--rollback <id>>',
15
15
  ' code-flow -v | --version',
16
16
  ' code-flow -h | --help'
@@ -83,6 +83,43 @@ function compareVersions(a, b) {
83
83
  return 0;
84
84
  }
85
85
 
86
+ function modeFor(installed, force) {
87
+ if (force) return 'force';
88
+ if (!installed) return 'fresh';
89
+ if (compareVersions(installed, pkg.version) < 0) return 'upgrade';
90
+ return 'current';
91
+ }
92
+
93
+ // Per-platform install records live beside the legacy global .version so one
94
+ // platform's upgrade never masks another platform's staleness.
95
+ function readAdapterVersions(cwd) {
96
+ const file = path.join(cwd, '.code-flow', '.adapter-versions.json');
97
+ try {
98
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
99
+ if (data && typeof data === 'object' && data.adapters && typeof data.adapters === 'object') {
100
+ return { core: typeof data.core === 'string' ? data.core : null, adapters: data.adapters };
101
+ }
102
+ } catch (_) { /* missing or corrupt: fall through */ }
103
+ return { core: null, adapters: {} };
104
+ }
105
+
106
+ function writeAdapterVersions(cwd, versions) {
107
+ const file = path.join(cwd, '.code-flow', '.adapter-versions.json');
108
+ fs.mkdirSync(path.dirname(file), { recursive: true });
109
+ fs.writeFileSync(file, JSON.stringify({ core: versions.core, adapters: versions.adapters }, null, 2) + '\n');
110
+ }
111
+
112
+ // Marker dirs proving a platform was installed before per-platform tracking.
113
+ function platformFilesExist(cwd, platform) {
114
+ const markers = {
115
+ claude: ['.claude/commands'],
116
+ codex: ['.agents/skills'],
117
+ costrict: ['.costrict/commands'],
118
+ opencode: ['.opencode/commands'],
119
+ };
120
+ return (markers[platform] || []).some(rel => fs.existsSync(path.join(cwd, rel)));
121
+ }
122
+
86
123
  // --- Merge functions ---
87
124
 
88
125
  // Replace fenced code blocks with same-length whitespace so byte offsets in
@@ -342,6 +379,53 @@ function copyFileIfMissing(srcFile, destFile) {
342
379
  fs.copyFileSync(srcFile, destFile);
343
380
  }
344
381
 
382
+ function removeLegacyClaudeSkills(cwd, removed, opts) {
383
+ // 0.1-era code-flow deployed its own skills (cf-*.md) under .claude/skills/.
384
+ // Only those historically managed names may be touched; everything else is
385
+ // user-owned and must survive init. Managed entries are backed up before
386
+ // removal. The legacy dir belongs to the claude domain: other platforms
387
+ // must never touch it.
388
+ const options = opts || {};
389
+ if (options.platform && options.platform !== 'claude') return;
390
+ const legacySkills = path.join(cwd, '.claude', 'skills');
391
+ if (!fs.existsSync(legacySkills)) return;
392
+
393
+ let entries;
394
+ try {
395
+ entries = fs.readdirSync(legacySkills, { withFileTypes: true });
396
+ } catch (error) {
397
+ const code = error && error.code ? ` (${error.code})` : '';
398
+ process.stderr.write(`Warning: failed to list deprecated .claude/skills/${code}; left untouched. Remove it manually.\n`);
399
+ return;
400
+ }
401
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
402
+ const backupRoot = options.backupDir
403
+ ? path.resolve(cwd, options.backupDir)
404
+ : path.join(cwd, '.code-flow', 'backups', stamp);
405
+ for (const entry of entries) {
406
+ if (!MANAGED_LEGACY_SKILL_NAMES.has(entry.name)) continue; // user-owned: keep
407
+ const src = path.join(legacySkills, entry.name);
408
+ const rel = path.join('.claude', 'skills', entry.name);
409
+ try {
410
+ const dest = path.join(backupRoot, rel);
411
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
412
+ if (entry.isDirectory()) {
413
+ copyDirRecursive(src, dest, true);
414
+ } else if (entry.isFile()) {
415
+ fs.copyFileSync(src, dest);
416
+ } else {
417
+ continue;
418
+ }
419
+ fs.rmSync(src, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
420
+ removed.push(`${rel} (backup: ${path.relative(cwd, dest)})`);
421
+ } catch (error) {
422
+ const code = error && error.code ? ` (${error.code})` : '';
423
+ const message = error && error.message ? error.message : String(error);
424
+ process.stderr.write(`Warning: failed to migrate deprecated ${rel}${code}: ${message}\n`);
425
+ }
426
+ }
427
+ }
428
+
345
429
  // Files renamed in 0.x → 1.x that may linger in upgraded projects.
346
430
  // Each entry: { path: relative-to-cwd, replacedBy: hint string for the warning }.
347
431
  const ORPHAN_FILES = [
@@ -367,35 +451,30 @@ function removeOrphanFiles(cwd, removed) {
367
451
  }
368
452
  }
369
453
 
370
- function removeLegacyClaudeSkills(cwd, removed) {
371
- const legacySkills = path.join(cwd, '.claude', 'skills');
372
- if (!fs.existsSync(legacySkills)) return;
373
-
374
- try {
375
- fs.rmSync(legacySkills, {
376
- recursive: true,
377
- force: true,
378
- maxRetries: 5,
379
- retryDelay: 100
380
- });
381
- if (!fs.existsSync(legacySkills)) {
382
- removed.push('.claude/skills/');
383
- return;
384
- }
385
- } catch (error) {
386
- const code = error && error.code ? ` (${error.code})` : '';
387
- const message = error && error.message ? error.message : String(error);
388
- process.stderr.write(`Warning: failed to remove deprecated .claude/skills/${code}: ${message}\n`);
389
- return;
390
- }
391
-
392
- process.stderr.write('Warning: failed to remove deprecated .claude/skills/. Remove it manually.\n');
393
- }
454
+ // Skill entries historically deployed by code-flow 0.x under .claude/skills/.
455
+ // Only these names are managed legacy content; any other entry is user-owned.
456
+ const MANAGED_LEGACY_SKILL_NAMES = new Set([
457
+ 'cf-init.md',
458
+ 'cf-inject.md',
459
+ 'cf-learn.md',
460
+ 'cf-scan.md',
461
+ 'cf-stats.md',
462
+ 'cf-validate.md',
463
+ ]);
394
464
 
395
465
  // --- pyyaml dependency: probe first, fall back through PEP 668 strategies ---
396
466
 
397
- function ensurePyYaml() {
398
- const probe = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' });
467
+ // --- pyyaml dependency: probe first, fall back through PEP 668 strategies ---
468
+ // Bounded waits: each attempt carries its own timeout inside a total budget,
469
+ // with progress on stderr. spawnFn/logFn/nowFn are injectable for tests.
470
+ const PIP_PER_ATTEMPT_MS = 60000;
471
+ const PIP_TOTAL_MS = 120000;
472
+
473
+ function ensurePyYaml(spawnFn, logFn, nowFn) {
474
+ const spawn = spawnFn || spawnSync;
475
+ const log = logFn || ((message) => process.stderr.write(message));
476
+ const now = nowFn || Date.now;
477
+ const probe = spawn('python3', ['-c', 'import yaml'], { stdio: 'ignore' });
399
478
  if (!probe.error && probe.status === 0) return; // already installed
400
479
 
401
480
  const attempts = [
@@ -403,11 +482,18 @@ function ensurePyYaml() {
403
482
  ['-m', 'pip', 'install', 'pyyaml'],
404
483
  ['-m', 'pip', 'install', '--user', '--break-system-packages', 'pyyaml'],
405
484
  ];
406
- for (const args of attempts) {
407
- const result = spawnSync('python3', args, { stdio: 'ignore' });
485
+ const started = now();
486
+ for (let i = 0; i < attempts.length; i++) {
487
+ const remaining = PIP_TOTAL_MS - (now() - started);
488
+ if (remaining <= 0) break;
489
+ log(`Installing pyyaml (attempt ${i + 1}/${attempts.length})...\n`);
490
+ const result = spawn('python3', attempts[i], {
491
+ stdio: 'ignore',
492
+ timeout: Math.min(PIP_PER_ATTEMPT_MS, remaining),
493
+ });
408
494
  if (!result.error && result.status === 0) return;
409
495
  }
410
- process.stderr.write(
496
+ log(
411
497
  'Warning: pyyaml install failed. Run manually:\n' +
412
498
  ' python3 -m pip install --user pyyaml\n' +
413
499
  ' # or, on PEP 668 systems:\n' +
@@ -468,10 +554,21 @@ function parsePlatform(args) {
468
554
  return null;
469
555
  }
470
556
 
557
+ function parseStringFlag(args, name) {
558
+ const prefix = `--${name}=`;
559
+ for (const arg of args) {
560
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
561
+ }
562
+ const idx = args.indexOf(`--${name}`);
563
+ if (idx !== -1) return args[idx + 1] || '';
564
+ return '';
565
+ }
566
+
471
567
  // --- Main init ---
472
568
 
473
- function runInit(force, platform) {
569
+ function runInit(force, platform, opts) {
474
570
  const cwd = process.cwd();
571
+ const options = opts || {};
475
572
  const installedVersion = readInstalledVersion(cwd);
476
573
  if (installedVersion && compareVersions(installedVersion, '0.4.2') >= 0 && compareVersions(installedVersion, '0.6.0') < 0) {
477
574
  process.stdout.write(`${JSON.stringify({ status: 'migration_required', version: installedVersion, command: 'code-flow migrate --spec-workflow --dry-run' })}\n`);
@@ -483,16 +580,28 @@ function runInit(force, platform) {
483
580
  const coreDir = path.join(baseDir, 'core');
484
581
  const adaptersDir = path.join(baseDir, 'adapters');
485
582
 
486
- // Determine mode
487
- let mode;
583
+ // Determine mode: global mode drives core files and messaging; each
584
+ // platform additionally resolves its own mode from per-platform records so
585
+ // upgrading one platform can never mask another platform's staleness.
586
+ // A global upgrade also refreshes the selected platform (previous behavior).
587
+ const mode = modeFor(installedVersion, force);
588
+ const adapterVersions = readAdapterVersions(cwd);
589
+ const adapterPrev = typeof adapterVersions.adapters[platform] === 'string'
590
+ ? adapterVersions.adapters[platform]
591
+ : null;
592
+ let platformMode;
488
593
  if (force) {
489
- mode = 'force';
490
- } else if (!installedVersion) {
491
- mode = 'fresh';
492
- } else if (compareVersions(installedVersion, pkg.version) < 0) {
493
- mode = 'upgrade';
594
+ platformMode = 'force';
595
+ } else if (!adapterPrev && !platformFilesExist(cwd, platform)) {
596
+ platformMode = 'fresh';
597
+ } else if (
598
+ (adapterPrev && compareVersions(adapterPrev, pkg.version) < 0) ||
599
+ (!adapterPrev && platformFilesExist(cwd, platform)) ||
600
+ mode === 'upgrade'
601
+ ) {
602
+ platformMode = 'upgrade';
494
603
  } else {
495
- mode = 'current';
604
+ platformMode = 'current';
496
605
  }
497
606
 
498
607
  const created = [];
@@ -502,8 +611,10 @@ function runInit(force, platform) {
502
611
  const removed = [];
503
612
  const results = { created, updated, merged, skipped };
504
613
 
505
- // Track + copy helper for directory trees
506
- const processDir = (srcDir, destDir, prefix) => {
614
+ // Track + copy helper for directory trees. dirMode is resolved per area:
615
+ // core files follow the global mode, platform files follow platformMode.
616
+ const processDir = (srcDir, destDir, prefix, dirMode) => {
617
+ const effective = dirMode || mode;
507
618
  if (!fs.existsSync(srcDir)) return;
508
619
  const files = collectFiles(srcDir, '');
509
620
  for (const rel of files) {
@@ -516,10 +627,10 @@ function runInit(force, platform) {
516
627
  created.push(label);
517
628
  fs.mkdirSync(path.dirname(dest), { recursive: true });
518
629
  fs.copyFileSync(src, dest);
519
- } else if (mode === 'force') {
630
+ } else if (effective === 'force') {
520
631
  updated.push(label);
521
632
  fs.copyFileSync(src, dest);
522
- } else if (mode === 'upgrade' && cat === 'tool') {
633
+ } else if (effective === 'upgrade' && cat === 'tool') {
523
634
  updated.push(label);
524
635
  fs.copyFileSync(src, dest);
525
636
  } else {
@@ -529,7 +640,7 @@ function runInit(force, platform) {
529
640
  };
530
641
 
531
642
  // Process .code-flow/ (core)
532
- processDir(path.join(coreDir, 'code-flow'), path.join(cwd, '.code-flow'), '.code-flow');
643
+ processDir(path.join(coreDir, 'code-flow'), path.join(cwd, '.code-flow'), '.code-flow', mode);
533
644
 
534
645
  // Process Claude adapter
535
646
  if (platform === 'claude') {
@@ -537,7 +648,7 @@ function runInit(force, platform) {
537
648
  src: path.join(adaptersDir, 'claude', 'CLAUDE.md'),
538
649
  dest: path.join(cwd, 'CLAUDE.md'),
539
650
  label: 'CLAUDE.md',
540
- mode,
651
+ mode: platformMode,
541
652
  mergeFn: mergeClaudeMd,
542
653
  toolOnUpgrade: false,
543
654
  results,
@@ -547,14 +658,14 @@ function runInit(force, platform) {
547
658
  processDir(
548
659
  path.join(adaptersDir, 'claude', 'commands'),
549
660
  path.join(cwd, '.claude', 'commands'),
550
- '.claude/commands'
661
+ '.claude/commands', platformMode
551
662
  );
552
663
 
553
664
  installAdapterFile({
554
665
  src: path.join(adaptersDir, 'claude', 'settings.local.json'),
555
666
  dest: path.join(cwd, '.claude', 'settings.local.json'),
556
667
  label: '.claude/settings.local.json',
557
- mode,
668
+ mode: platformMode,
558
669
  mergeFn: mergeSettingsJson,
559
670
  toolOnUpgrade: false,
560
671
  results,
@@ -567,7 +678,7 @@ function runInit(force, platform) {
567
678
  src: path.join(adaptersDir, 'costrict', 'CLAUDE.md'),
568
679
  dest: path.join(cwd, 'CLAUDE.md'),
569
680
  label: 'CLAUDE.md',
570
- mode,
681
+ mode: platformMode,
571
682
  mergeFn: mergeClaudeMd,
572
683
  toolOnUpgrade: false,
573
684
  results,
@@ -577,14 +688,14 @@ function runInit(force, platform) {
577
688
  processDir(
578
689
  path.join(adaptersDir, 'costrict', 'commands'),
579
690
  path.join(cwd, '.costrict', 'commands'),
580
- '.costrict/commands'
691
+ '.costrict/commands', platformMode
581
692
  );
582
693
 
583
694
  installAdapterFile({
584
695
  src: path.join(adaptersDir, 'costrict', 'settings.local.json'),
585
696
  dest: path.join(cwd, '.costrict', 'settings.local.json'),
586
697
  label: '.costrict/settings.local.json',
587
- mode,
698
+ mode: platformMode,
588
699
  mergeFn: mergeSettingsJson,
589
700
  toolOnUpgrade: false,
590
701
  results,
@@ -597,7 +708,7 @@ function runInit(force, platform) {
597
708
  src: path.join(adaptersDir, 'codex', 'AGENTS.md'),
598
709
  dest: path.join(cwd, 'AGENTS.md'),
599
710
  label: 'AGENTS.md',
600
- mode,
711
+ mode: platformMode,
601
712
  mergeFn: mergeClaudeMd,
602
713
  toolOnUpgrade: false,
603
714
  results,
@@ -607,7 +718,7 @@ function runInit(force, platform) {
607
718
  src: path.join(adaptersDir, 'codex', 'hooks.json'),
608
719
  dest: path.join(cwd, '.codex', 'hooks.json'),
609
720
  label: '.codex/hooks.json',
610
- mode,
721
+ mode: platformMode,
611
722
  mergeFn: mergeSettingsJson,
612
723
  toolOnUpgrade: false,
613
724
  results,
@@ -617,7 +728,7 @@ function runInit(force, platform) {
617
728
  src: path.join(adaptersDir, 'codex', 'config.toml'),
618
729
  dest: path.join(cwd, '.codex', 'config.toml'),
619
730
  label: '.codex/config.toml',
620
- mode,
731
+ mode: platformMode,
621
732
  mergeFn: mergeCodexConfigToml,
622
733
  toolOnUpgrade: false,
623
734
  results,
@@ -627,7 +738,7 @@ function runInit(force, platform) {
627
738
  processDir(
628
739
  path.join(adaptersDir, 'codex', 'skills'),
629
740
  path.join(cwd, '.agents', 'skills'),
630
- '.agents/skills'
741
+ '.agents/skills', platformMode
631
742
  );
632
743
  }
633
744
 
@@ -637,7 +748,7 @@ function runInit(force, platform) {
637
748
  src: path.join(adaptersDir, 'opencode', 'AGENTS.md'),
638
749
  dest: path.join(cwd, 'AGENTS.md'),
639
750
  label: 'AGENTS.md',
640
- mode,
751
+ mode: platformMode,
641
752
  mergeFn: mergeClaudeMd,
642
753
  toolOnUpgrade: false,
643
754
  results,
@@ -647,7 +758,7 @@ function runInit(force, platform) {
647
758
  processDir(
648
759
  path.join(adaptersDir, 'opencode', 'plugins'),
649
760
  path.join(cwd, '.opencode', 'plugins'),
650
- '.opencode/plugins'
761
+ '.opencode/plugins', platformMode
651
762
  );
652
763
 
653
764
  // Stamp main package version into the plugin's package.json
@@ -662,14 +773,14 @@ function runInit(force, platform) {
662
773
  processDir(
663
774
  path.join(adaptersDir, 'opencode', 'commands'),
664
775
  path.join(cwd, '.opencode', 'commands'),
665
- '.opencode/commands'
776
+ '.opencode/commands', platformMode
666
777
  );
667
778
 
668
779
  installAdapterFile({
669
780
  src: path.join(adaptersDir, 'opencode', 'opencode.json'),
670
781
  dest: path.join(cwd, 'opencode.json'),
671
782
  label: 'opencode.json',
672
- mode,
783
+ mode: platformMode,
673
784
  mergeFn: mergeSettingsJson,
674
785
  toolOnUpgrade: false,
675
786
  results,
@@ -689,8 +800,8 @@ function runInit(force, platform) {
689
800
  }
690
801
  }
691
802
 
692
- // Clean up legacy .claude/skills/
693
- removeLegacyClaudeSkills(cwd, removed);
803
+ // Clean up legacy .claude/skills/ (claude domain only; user-owned kept, managed backed up)
804
+ removeLegacyClaudeSkills(cwd, removed, { platform, backupDir: options.backupDir || '' });
694
805
 
695
806
  // Clean up renamed orphan scripts (only meaningful on upgrade)
696
807
  if (mode === 'upgrade' || mode === 'force') {
@@ -700,13 +811,16 @@ function runInit(force, platform) {
700
811
  // Install pyyaml — probe first, fall back through PEP 668 strategies
701
812
  ensurePyYaml();
702
813
 
703
- // Write version
814
+ // Write version (legacy global file kept for compatibility) + per-platform records
704
815
  writeVersion(cwd, pkg.version);
816
+ adapterVersions.adapters[platform] = pkg.version;
817
+ adapterVersions.core = pkg.version;
818
+ writeAdapterVersions(cwd, adapterVersions);
705
819
 
706
820
  // Output summary
707
821
  if (mode === 'upgrade') {
708
822
  process.stdout.write(`\ncode-flow upgraded: ${installedVersion} → ${pkg.version}\n\n`);
709
- } else if (mode === 'current' && created.length === 0 && updated.length === 0 && merged.length === 0) {
823
+ } else if (mode === 'current' && platformMode === 'current' && created.length === 0 && updated.length === 0 && merged.length === 0) {
710
824
  process.stdout.write(`code-flow v${pkg.version} already up to date.\n`);
711
825
  process.exit(0);
712
826
  } else if (mode === 'force') {
@@ -841,7 +955,7 @@ if (args[0] === 'init') {
841
955
  if (platform !== 'claude' && platform !== 'codex' && platform !== 'costrict' && platform !== 'opencode') {
842
956
  fail(`Error: --platform must be "claude", "codex", "costrict", or "opencode", got "${platform}".`);
843
957
  }
844
- runInit(force, platform);
958
+ runInit(force, platform, { backupDir: parseStringFlag(args, 'backup-dir') });
845
959
  }
846
960
 
847
961
  if (args[0] === 'migrate') {
@@ -0,0 +1,100 @@
1
+ """Append execution history without erasing the user's acceptance contract."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Mapping, Sequence
9
+ from uuid import uuid4
10
+
11
+ STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "failed", "incomplete", "not_configured", "e2e_deferred", "manual_pending"))
12
+
13
+
14
+ def scenario_line(line: str, scenario_id: str) -> bool:
15
+ return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
16
+
17
+
18
+ def line_status(line: str, scenario_id: str) -> str:
19
+ if not scenario_line(line, scenario_id):
20
+ return ""
21
+ if line.lstrip().startswith("|"):
22
+ cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
23
+ return next((cell for cell in reversed(cells) if cell in STATES), "")
24
+ match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
25
+ return match.group(1).lower() if match and match.group(1).lower() in STATES else ""
26
+
27
+
28
+ def _status_line(line: str, scenario_id: str, status: str) -> str:
29
+ if not scenario_line(line, scenario_id):
30
+ return line
31
+ if line.lstrip().startswith("|"):
32
+ cells = line.split("|")
33
+ for index in range(len(cells) - 1, 0, -1):
34
+ if cells[index].strip().lower() in STATES:
35
+ cells[index] = f" {status} "
36
+ return "|".join(cells)
37
+ return line.rstrip().rstrip("|") + f"| {status} |"
38
+ prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
39
+ if line_status(line, scenario_id):
40
+ return re.sub(prefix + r"(?:" + "|".join(sorted(STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
41
+ return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
42
+
43
+
44
+ def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
45
+ contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
46
+ if contract:
47
+ body = "\n".join(_status_line(line, scenario_id, status) for line in contract.group(1).splitlines()) + "\n"
48
+ section = section[:contract.start(1)] + body + section[contract.end(1):]
49
+ evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
50
+ if evidence:
51
+ body = evidence.group(1)
52
+ if record not in body.splitlines():
53
+ body = body.rstrip() + "\n" + record + "\n\n"
54
+ section = section[:evidence.start(1)] + body + section[evidence.end(1):]
55
+ else:
56
+ section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
57
+ return section
58
+
59
+
60
+ def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> None:
61
+ text = task.read_text(encoding="utf-8")
62
+ task_pattern = re.escape(owner) if owner else r"TASK-\d+"
63
+ match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
64
+ if match is None:
65
+ raise ValueError(f"acceptance owner missing: {owner}")
66
+ record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
67
+ section = _patch_section(match.group(0), scenario_id, status, record)
68
+ text = text[:match.start()] + section + text[match.end():]
69
+ coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
70
+ if coverage:
71
+ body = "\n".join(_status_line(line, scenario_id, status) for line in coverage.group(1).splitlines()) + "\n"
72
+ text = text[:coverage.start(1)] + body + text[coverage.end(1):]
73
+ from cf_spec_context import _atomic_text
74
+ _atomic_text(task, text)
75
+
76
+
77
+ def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]]) -> None:
78
+ rows = {row["id"]: row for row in data["scenarios"]}
79
+ run_id, executed_at = uuid4().hex, datetime.now(timezone.utc).isoformat()
80
+ updates = []
81
+ for result in results:
82
+ state = str(result["status"])
83
+ if state in ("not_included", "manual_pending"):
84
+ continue
85
+ row = rows[result["id"]]
86
+ row["revision"] = int(row.get("revision", 0)) + 1
87
+ status = "verified" if state == "passed" else state
88
+ row["status"] = status
89
+ record = {**result, "revision": row["revision"], "run_id": run_id, "executed_at": executed_at}
90
+ row["evidence"] = record
91
+ row.setdefault("runs", []).append(record)
92
+ updates.append((row, record, status))
93
+ from cf_spec_context import _atomic_text
94
+ _atomic_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
95
+ task_name = data.get("task_file")
96
+ if isinstance(task_name, str) and task_name:
97
+ task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
98
+ for row, record, status in updates:
99
+ detail = f"automated command {record['status']}; run_id={run_id}"
100
+ sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)