@michaelxwb/code-flow 0.6.2 → 0.6.4

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 (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/adapters/claude/commands/cf-init.md +1 -0
  4. package/src/adapters/claude/commands/cf-spec.md +7 -3
  5. package/src/adapters/claude/commands/cf-task/archive.md +5 -5
  6. package/src/adapters/claude/commands/cf-task/block.md +15 -9
  7. package/src/adapters/claude/commands/cf-task/graph.md +8 -1
  8. package/src/adapters/claude/commands/cf-task/note.md +1 -4
  9. package/src/adapters/claude/commands/cf-task/plan.md +2 -2
  10. package/src/adapters/claude/commands/cf-task/start.md +96 -25
  11. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  12. package/src/adapters/claude/commands/cf-validate.md +15 -13
  13. package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
  14. package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
  15. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  16. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  17. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  18. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  19. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
  20. package/src/adapters/codex/skills/cf-task-start/SKILL.md +96 -25
  21. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  22. package/src/adapters/codex/skills/cf-validate/SKILL.md +15 -13
  23. package/src/adapters/costrict/commands/cf-init.md +1 -0
  24. package/src/adapters/costrict/commands/cf-spec.md +7 -3
  25. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  26. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  27. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  28. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  29. package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
  30. package/src/adapters/costrict/commands/cf-task/start.md +96 -25
  31. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  32. package/src/adapters/costrict/commands/cf-validate.md +15 -13
  33. package/src/adapters/opencode/commands/cf-init.md +9 -4
  34. package/src/adapters/opencode/commands/cf-spec.md +6 -2
  35. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  36. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  37. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  38. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  39. package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
  40. package/src/adapters/opencode/commands/cf-task/start.md +96 -25
  41. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  42. package/src/adapters/opencode/commands/cf-validate.md +15 -13
  43. package/src/adapters/opencode/opencode.json +1 -2
  44. package/src/adapters/opencode/plugins/code-flow/index.js +176 -94
  45. package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
  46. package/src/cli.js +228 -64
  47. package/src/core/code-flow/config.yml +2 -1
  48. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  49. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +137 -42
  50. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  51. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  52. package/src/core/code-flow/scripts/cf_core.py +34 -1
  53. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  54. package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
  55. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +22 -7
  56. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  57. package/src/core/code-flow/scripts/cf_spec_context.py +197 -63
  58. package/src/core/code-flow/scripts/cf_spec_gate.py +26 -4
  59. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  60. package/src/core/code-flow/scripts/cf_spec_router.py +3 -1
  61. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  62. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  63. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  64. package/src/core/code-flow/scripts/cf_stop_hook.py +128 -61
  65. package/src/core/code-flow/scripts/cf_task_index.py +148 -0
  66. package/src/core/code-flow/scripts/cf_task_parallel.py +385 -0
  67. package/src/core/code-flow/scripts/cf_task_runtime.py +98 -29
  68. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  69. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  70. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  71. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  72. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  73. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
  74. package/src/core/code-flow/validation.yml +5 -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
@@ -200,6 +237,56 @@ function mergeSettingsJson(srcFile, destFile) {
200
237
  return added;
201
238
  }
202
239
 
240
+ // OpenCode v2 merge: migrate the v1 `plugin` key to `plugins`, drop the
241
+ // auto-discovered local path (`.opencode/plugins/*` loads without config),
242
+ // and union template `plugins` entries without overwriting user content.
243
+ function mergeOpencodeJson(srcFile, destFile) {
244
+ const src = JSON.parse(fs.readFileSync(srcFile, 'utf8'));
245
+ const dest = JSON.parse(fs.readFileSync(destFile, 'utf8'));
246
+ const added = [];
247
+ const isLocalEntry = (e) => {
248
+ const p = typeof e === 'string' ? e : (e && e.package);
249
+ return typeof p === 'string' && /(^|\/)\.opencode\/plugins\//.test(p);
250
+ };
251
+ if (Array.isArray(dest.plugin)) {
252
+ const carried = dest.plugin.filter((e) => !isLocalEntry(e));
253
+ delete dest.plugin;
254
+ added.push(`removed legacy 'plugin' key (v1 → v2)`);
255
+ if (carried.length > 0 && !Array.isArray(dest.plugins)) dest.plugins = carried;
256
+ } else if ('plugin' in dest) {
257
+ delete dest.plugin;
258
+ added.push(`removed legacy 'plugin' key (v1 → v2)`);
259
+ }
260
+ if (Array.isArray(dest.plugins)) {
261
+ const before = dest.plugins.length;
262
+ dest.plugins = dest.plugins.filter((e) => !isLocalEntry(e));
263
+ if (dest.plugins.length !== before) added.push('removed auto-discovered local plugin entry');
264
+ }
265
+ if (Array.isArray(src.plugins)) {
266
+ if (!Array.isArray(dest.plugins)) dest.plugins = [];
267
+ const seen = new Set(dest.plugins.map((e) => JSON.stringify(e)));
268
+ for (const entry of src.plugins) {
269
+ const key = JSON.stringify(entry);
270
+ if (!seen.has(key)) {
271
+ dest.plugins.push(entry);
272
+ seen.add(key);
273
+ added.push(`plugins +${typeof entry === 'string' ? entry : key}`.slice(0, 80));
274
+ }
275
+ }
276
+ }
277
+ for (const key of Object.keys(src)) {
278
+ if (key === 'plugins' || key === 'plugin') continue;
279
+ if (!(key in dest)) {
280
+ dest[key] = src[key];
281
+ added.push(key);
282
+ }
283
+ }
284
+ if (added.length > 0) {
285
+ fs.writeFileSync(destFile, JSON.stringify(dest, null, 2) + '\n');
286
+ }
287
+ return added;
288
+ }
289
+
203
290
  function mergeConfigYml(srcFile, destFile) {
204
291
  const srcText = fs.readFileSync(srcFile, 'utf8');
205
292
  const destText = fs.readFileSync(destFile, 'utf8');
@@ -342,6 +429,53 @@ function copyFileIfMissing(srcFile, destFile) {
342
429
  fs.copyFileSync(srcFile, destFile);
343
430
  }
344
431
 
432
+ function removeLegacyClaudeSkills(cwd, removed, opts) {
433
+ // 0.1-era code-flow deployed its own skills (cf-*.md) under .claude/skills/.
434
+ // Only those historically managed names may be touched; everything else is
435
+ // user-owned and must survive init. Managed entries are backed up before
436
+ // removal. The legacy dir belongs to the claude domain: other platforms
437
+ // must never touch it.
438
+ const options = opts || {};
439
+ if (options.platform && options.platform !== 'claude') return;
440
+ const legacySkills = path.join(cwd, '.claude', 'skills');
441
+ if (!fs.existsSync(legacySkills)) return;
442
+
443
+ let entries;
444
+ try {
445
+ entries = fs.readdirSync(legacySkills, { withFileTypes: true });
446
+ } catch (error) {
447
+ const code = error && error.code ? ` (${error.code})` : '';
448
+ process.stderr.write(`Warning: failed to list deprecated .claude/skills/${code}; left untouched. Remove it manually.\n`);
449
+ return;
450
+ }
451
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
452
+ const backupRoot = options.backupDir
453
+ ? path.resolve(cwd, options.backupDir)
454
+ : path.join(cwd, '.code-flow', 'backups', stamp);
455
+ for (const entry of entries) {
456
+ if (!MANAGED_LEGACY_SKILL_NAMES.has(entry.name)) continue; // user-owned: keep
457
+ const src = path.join(legacySkills, entry.name);
458
+ const rel = path.join('.claude', 'skills', entry.name);
459
+ try {
460
+ const dest = path.join(backupRoot, rel);
461
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
462
+ if (entry.isDirectory()) {
463
+ copyDirRecursive(src, dest, true);
464
+ } else if (entry.isFile()) {
465
+ fs.copyFileSync(src, dest);
466
+ } else {
467
+ continue;
468
+ }
469
+ fs.rmSync(src, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
470
+ removed.push(`${rel} (backup: ${path.relative(cwd, dest)})`);
471
+ } catch (error) {
472
+ const code = error && error.code ? ` (${error.code})` : '';
473
+ const message = error && error.message ? error.message : String(error);
474
+ process.stderr.write(`Warning: failed to migrate deprecated ${rel}${code}: ${message}\n`);
475
+ }
476
+ }
477
+ }
478
+
345
479
  // Files renamed in 0.x → 1.x that may linger in upgraded projects.
346
480
  // Each entry: { path: relative-to-cwd, replacedBy: hint string for the warning }.
347
481
  const ORPHAN_FILES = [
@@ -367,35 +501,30 @@ function removeOrphanFiles(cwd, removed) {
367
501
  }
368
502
  }
369
503
 
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
- }
504
+ // Skill entries historically deployed by code-flow 0.x under .claude/skills/.
505
+ // Only these names are managed legacy content; any other entry is user-owned.
506
+ const MANAGED_LEGACY_SKILL_NAMES = new Set([
507
+ 'cf-init.md',
508
+ 'cf-inject.md',
509
+ 'cf-learn.md',
510
+ 'cf-scan.md',
511
+ 'cf-stats.md',
512
+ 'cf-validate.md',
513
+ ]);
394
514
 
395
515
  // --- pyyaml dependency: probe first, fall back through PEP 668 strategies ---
396
516
 
397
- function ensurePyYaml() {
398
- const probe = spawnSync('python3', ['-c', 'import yaml'], { stdio: 'ignore' });
517
+ // --- pyyaml dependency: probe first, fall back through PEP 668 strategies ---
518
+ // Bounded waits: each attempt carries its own timeout inside a total budget,
519
+ // with progress on stderr. spawnFn/logFn/nowFn are injectable for tests.
520
+ const PIP_PER_ATTEMPT_MS = 60000;
521
+ const PIP_TOTAL_MS = 120000;
522
+
523
+ function ensurePyYaml(spawnFn, logFn, nowFn) {
524
+ const spawn = spawnFn || spawnSync;
525
+ const log = logFn || ((message) => process.stderr.write(message));
526
+ const now = nowFn || Date.now;
527
+ const probe = spawn('python3', ['-c', 'import yaml'], { stdio: 'ignore' });
399
528
  if (!probe.error && probe.status === 0) return; // already installed
400
529
 
401
530
  const attempts = [
@@ -403,11 +532,18 @@ function ensurePyYaml() {
403
532
  ['-m', 'pip', 'install', 'pyyaml'],
404
533
  ['-m', 'pip', 'install', '--user', '--break-system-packages', 'pyyaml'],
405
534
  ];
406
- for (const args of attempts) {
407
- const result = spawnSync('python3', args, { stdio: 'ignore' });
535
+ const started = now();
536
+ for (let i = 0; i < attempts.length; i++) {
537
+ const remaining = PIP_TOTAL_MS - (now() - started);
538
+ if (remaining <= 0) break;
539
+ log(`Installing pyyaml (attempt ${i + 1}/${attempts.length})...\n`);
540
+ const result = spawn('python3', attempts[i], {
541
+ stdio: 'ignore',
542
+ timeout: Math.min(PIP_PER_ATTEMPT_MS, remaining),
543
+ });
408
544
  if (!result.error && result.status === 0) return;
409
545
  }
410
- process.stderr.write(
546
+ log(
411
547
  'Warning: pyyaml install failed. Run manually:\n' +
412
548
  ' python3 -m pip install --user pyyaml\n' +
413
549
  ' # or, on PEP 668 systems:\n' +
@@ -468,10 +604,21 @@ function parsePlatform(args) {
468
604
  return null;
469
605
  }
470
606
 
607
+ function parseStringFlag(args, name) {
608
+ const prefix = `--${name}=`;
609
+ for (const arg of args) {
610
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
611
+ }
612
+ const idx = args.indexOf(`--${name}`);
613
+ if (idx !== -1) return args[idx + 1] || '';
614
+ return '';
615
+ }
616
+
471
617
  // --- Main init ---
472
618
 
473
- function runInit(force, platform) {
619
+ function runInit(force, platform, opts) {
474
620
  const cwd = process.cwd();
621
+ const options = opts || {};
475
622
  const installedVersion = readInstalledVersion(cwd);
476
623
  if (installedVersion && compareVersions(installedVersion, '0.4.2') >= 0 && compareVersions(installedVersion, '0.6.0') < 0) {
477
624
  process.stdout.write(`${JSON.stringify({ status: 'migration_required', version: installedVersion, command: 'code-flow migrate --spec-workflow --dry-run' })}\n`);
@@ -483,16 +630,28 @@ function runInit(force, platform) {
483
630
  const coreDir = path.join(baseDir, 'core');
484
631
  const adaptersDir = path.join(baseDir, 'adapters');
485
632
 
486
- // Determine mode
487
- let mode;
633
+ // Determine mode: global mode drives core files and messaging; each
634
+ // platform additionally resolves its own mode from per-platform records so
635
+ // upgrading one platform can never mask another platform's staleness.
636
+ // A global upgrade also refreshes the selected platform (previous behavior).
637
+ const mode = modeFor(installedVersion, force);
638
+ const adapterVersions = readAdapterVersions(cwd);
639
+ const adapterPrev = typeof adapterVersions.adapters[platform] === 'string'
640
+ ? adapterVersions.adapters[platform]
641
+ : null;
642
+ let platformMode;
488
643
  if (force) {
489
- mode = 'force';
490
- } else if (!installedVersion) {
491
- mode = 'fresh';
492
- } else if (compareVersions(installedVersion, pkg.version) < 0) {
493
- mode = 'upgrade';
644
+ platformMode = 'force';
645
+ } else if (!adapterPrev && !platformFilesExist(cwd, platform)) {
646
+ platformMode = 'fresh';
647
+ } else if (
648
+ (adapterPrev && compareVersions(adapterPrev, pkg.version) < 0) ||
649
+ (!adapterPrev && platformFilesExist(cwd, platform)) ||
650
+ mode === 'upgrade'
651
+ ) {
652
+ platformMode = 'upgrade';
494
653
  } else {
495
- mode = 'current';
654
+ platformMode = 'current';
496
655
  }
497
656
 
498
657
  const created = [];
@@ -502,8 +661,10 @@ function runInit(force, platform) {
502
661
  const removed = [];
503
662
  const results = { created, updated, merged, skipped };
504
663
 
505
- // Track + copy helper for directory trees
506
- const processDir = (srcDir, destDir, prefix) => {
664
+ // Track + copy helper for directory trees. dirMode is resolved per area:
665
+ // core files follow the global mode, platform files follow platformMode.
666
+ const processDir = (srcDir, destDir, prefix, dirMode) => {
667
+ const effective = dirMode || mode;
507
668
  if (!fs.existsSync(srcDir)) return;
508
669
  const files = collectFiles(srcDir, '');
509
670
  for (const rel of files) {
@@ -516,10 +677,10 @@ function runInit(force, platform) {
516
677
  created.push(label);
517
678
  fs.mkdirSync(path.dirname(dest), { recursive: true });
518
679
  fs.copyFileSync(src, dest);
519
- } else if (mode === 'force') {
680
+ } else if (effective === 'force') {
520
681
  updated.push(label);
521
682
  fs.copyFileSync(src, dest);
522
- } else if (mode === 'upgrade' && cat === 'tool') {
683
+ } else if (effective === 'upgrade' && cat === 'tool') {
523
684
  updated.push(label);
524
685
  fs.copyFileSync(src, dest);
525
686
  } else {
@@ -529,7 +690,7 @@ function runInit(force, platform) {
529
690
  };
530
691
 
531
692
  // Process .code-flow/ (core)
532
- processDir(path.join(coreDir, 'code-flow'), path.join(cwd, '.code-flow'), '.code-flow');
693
+ processDir(path.join(coreDir, 'code-flow'), path.join(cwd, '.code-flow'), '.code-flow', mode);
533
694
 
534
695
  // Process Claude adapter
535
696
  if (platform === 'claude') {
@@ -537,7 +698,7 @@ function runInit(force, platform) {
537
698
  src: path.join(adaptersDir, 'claude', 'CLAUDE.md'),
538
699
  dest: path.join(cwd, 'CLAUDE.md'),
539
700
  label: 'CLAUDE.md',
540
- mode,
701
+ mode: platformMode,
541
702
  mergeFn: mergeClaudeMd,
542
703
  toolOnUpgrade: false,
543
704
  results,
@@ -547,14 +708,14 @@ function runInit(force, platform) {
547
708
  processDir(
548
709
  path.join(adaptersDir, 'claude', 'commands'),
549
710
  path.join(cwd, '.claude', 'commands'),
550
- '.claude/commands'
711
+ '.claude/commands', platformMode
551
712
  );
552
713
 
553
714
  installAdapterFile({
554
715
  src: path.join(adaptersDir, 'claude', 'settings.local.json'),
555
716
  dest: path.join(cwd, '.claude', 'settings.local.json'),
556
717
  label: '.claude/settings.local.json',
557
- mode,
718
+ mode: platformMode,
558
719
  mergeFn: mergeSettingsJson,
559
720
  toolOnUpgrade: false,
560
721
  results,
@@ -567,7 +728,7 @@ function runInit(force, platform) {
567
728
  src: path.join(adaptersDir, 'costrict', 'CLAUDE.md'),
568
729
  dest: path.join(cwd, 'CLAUDE.md'),
569
730
  label: 'CLAUDE.md',
570
- mode,
731
+ mode: platformMode,
571
732
  mergeFn: mergeClaudeMd,
572
733
  toolOnUpgrade: false,
573
734
  results,
@@ -577,14 +738,14 @@ function runInit(force, platform) {
577
738
  processDir(
578
739
  path.join(adaptersDir, 'costrict', 'commands'),
579
740
  path.join(cwd, '.costrict', 'commands'),
580
- '.costrict/commands'
741
+ '.costrict/commands', platformMode
581
742
  );
582
743
 
583
744
  installAdapterFile({
584
745
  src: path.join(adaptersDir, 'costrict', 'settings.local.json'),
585
746
  dest: path.join(cwd, '.costrict', 'settings.local.json'),
586
747
  label: '.costrict/settings.local.json',
587
- mode,
748
+ mode: platformMode,
588
749
  mergeFn: mergeSettingsJson,
589
750
  toolOnUpgrade: false,
590
751
  results,
@@ -597,7 +758,7 @@ function runInit(force, platform) {
597
758
  src: path.join(adaptersDir, 'codex', 'AGENTS.md'),
598
759
  dest: path.join(cwd, 'AGENTS.md'),
599
760
  label: 'AGENTS.md',
600
- mode,
761
+ mode: platformMode,
601
762
  mergeFn: mergeClaudeMd,
602
763
  toolOnUpgrade: false,
603
764
  results,
@@ -607,7 +768,7 @@ function runInit(force, platform) {
607
768
  src: path.join(adaptersDir, 'codex', 'hooks.json'),
608
769
  dest: path.join(cwd, '.codex', 'hooks.json'),
609
770
  label: '.codex/hooks.json',
610
- mode,
771
+ mode: platformMode,
611
772
  mergeFn: mergeSettingsJson,
612
773
  toolOnUpgrade: false,
613
774
  results,
@@ -617,7 +778,7 @@ function runInit(force, platform) {
617
778
  src: path.join(adaptersDir, 'codex', 'config.toml'),
618
779
  dest: path.join(cwd, '.codex', 'config.toml'),
619
780
  label: '.codex/config.toml',
620
- mode,
781
+ mode: platformMode,
621
782
  mergeFn: mergeCodexConfigToml,
622
783
  toolOnUpgrade: false,
623
784
  results,
@@ -627,7 +788,7 @@ function runInit(force, platform) {
627
788
  processDir(
628
789
  path.join(adaptersDir, 'codex', 'skills'),
629
790
  path.join(cwd, '.agents', 'skills'),
630
- '.agents/skills'
791
+ '.agents/skills', platformMode
631
792
  );
632
793
  }
633
794
 
@@ -637,7 +798,7 @@ function runInit(force, platform) {
637
798
  src: path.join(adaptersDir, 'opencode', 'AGENTS.md'),
638
799
  dest: path.join(cwd, 'AGENTS.md'),
639
800
  label: 'AGENTS.md',
640
- mode,
801
+ mode: platformMode,
641
802
  mergeFn: mergeClaudeMd,
642
803
  toolOnUpgrade: false,
643
804
  results,
@@ -647,7 +808,7 @@ function runInit(force, platform) {
647
808
  processDir(
648
809
  path.join(adaptersDir, 'opencode', 'plugins'),
649
810
  path.join(cwd, '.opencode', 'plugins'),
650
- '.opencode/plugins'
811
+ '.opencode/plugins', platformMode
651
812
  );
652
813
 
653
814
  // Stamp main package version into the plugin's package.json
@@ -662,15 +823,15 @@ function runInit(force, platform) {
662
823
  processDir(
663
824
  path.join(adaptersDir, 'opencode', 'commands'),
664
825
  path.join(cwd, '.opencode', 'commands'),
665
- '.opencode/commands'
826
+ '.opencode/commands', platformMode
666
827
  );
667
828
 
668
829
  installAdapterFile({
669
830
  src: path.join(adaptersDir, 'opencode', 'opencode.json'),
670
831
  dest: path.join(cwd, 'opencode.json'),
671
832
  label: 'opencode.json',
672
- mode,
673
- mergeFn: mergeSettingsJson,
833
+ mode: platformMode,
834
+ mergeFn: mergeOpencodeJson,
674
835
  toolOnUpgrade: false,
675
836
  results,
676
837
  });
@@ -689,8 +850,8 @@ function runInit(force, platform) {
689
850
  }
690
851
  }
691
852
 
692
- // Clean up legacy .claude/skills/
693
- removeLegacyClaudeSkills(cwd, removed);
853
+ // Clean up legacy .claude/skills/ (claude domain only; user-owned kept, managed backed up)
854
+ removeLegacyClaudeSkills(cwd, removed, { platform, backupDir: options.backupDir || '' });
694
855
 
695
856
  // Clean up renamed orphan scripts (only meaningful on upgrade)
696
857
  if (mode === 'upgrade' || mode === 'force') {
@@ -700,13 +861,16 @@ function runInit(force, platform) {
700
861
  // Install pyyaml — probe first, fall back through PEP 668 strategies
701
862
  ensurePyYaml();
702
863
 
703
- // Write version
864
+ // Write version (legacy global file kept for compatibility) + per-platform records
704
865
  writeVersion(cwd, pkg.version);
866
+ adapterVersions.adapters[platform] = pkg.version;
867
+ adapterVersions.core = pkg.version;
868
+ writeAdapterVersions(cwd, adapterVersions);
705
869
 
706
870
  // Output summary
707
871
  if (mode === 'upgrade') {
708
872
  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) {
873
+ } else if (mode === 'current' && platformMode === 'current' && created.length === 0 && updated.length === 0 && merged.length === 0) {
710
874
  process.stdout.write(`code-flow v${pkg.version} already up to date.\n`);
711
875
  process.exit(0);
712
876
  } else if (mode === 'force') {
@@ -841,7 +1005,7 @@ if (args[0] === 'init') {
841
1005
  if (platform !== 'claude' && platform !== 'codex' && platform !== 'costrict' && platform !== 'opencode') {
842
1006
  fail(`Error: --platform must be "claude", "codex", "costrict", or "opencode", got "${platform}".`);
843
1007
  }
844
- runInit(force, platform);
1008
+ runInit(force, platform, { backupDir: parseStringFlag(args, 'backup-dir') });
845
1009
  }
846
1010
 
847
1011
  if (args[0] === 'migrate') {
@@ -24,7 +24,8 @@ budget:
24
24
  quality_loop:
25
25
  enabled: true
26
26
  post_check: true # PostToolUse 违规反馈(只提示不阻断)
27
- stop_check: true # Stop 收尾按 validation.yml 校验
27
+ stop_check: true # Stop 收尾按 validation.yml 校验(heavy 项跳过)
28
+ finish_check: true # 任务 finish 时全量执行 validation.yml(含 heavy,每任务一次)
28
29
  correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
29
30
  # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
30
31
  # 缺省视为 true;仅显式 false 关闭
@@ -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)