@michaelxwb/code-flow 0.6.2 → 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 (54) 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/start.md +18 -17
  7. package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
  8. package/src/adapters/claude/commands/cf-validate.md +12 -13
  9. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
  10. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
  11. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
  12. package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
  13. package/src/adapters/codex/skills/cf-task-start/SKILL.md +18 -17
  14. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
  15. package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
  16. package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
  17. package/src/adapters/costrict/commands/cf-task/block.md +15 -9
  18. package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
  19. package/src/adapters/costrict/commands/cf-task/note.md +1 -4
  20. package/src/adapters/costrict/commands/cf-task/start.md +18 -17
  21. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
  22. package/src/adapters/costrict/commands/cf-validate.md +12 -13
  23. package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
  24. package/src/adapters/opencode/commands/cf-task/block.md +15 -9
  25. package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
  26. package/src/adapters/opencode/commands/cf-task/note.md +1 -4
  27. package/src/adapters/opencode/commands/cf-task/start.md +18 -17
  28. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
  29. package/src/adapters/opencode/commands/cf-validate.md +12 -13
  30. package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
  31. package/src/cli.js +177 -63
  32. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
  33. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +85 -36
  34. package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
  35. package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
  36. package/src/core/code-flow/scripts/cf_core.py +6 -0
  37. package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
  38. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
  39. package/src/core/code-flow/scripts/cf_scan.py +8 -6
  40. package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
  41. package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
  42. package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
  43. package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
  44. package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
  45. package/src/core/code-flow/scripts/cf_stats.py +41 -9
  46. package/src/core/code-flow/scripts/cf_stop_hook.py +98 -59
  47. package/src/core/code-flow/scripts/cf_task_index.py +147 -0
  48. package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
  49. package/src/core/code-flow/scripts/cf_task_state.py +3 -0
  50. package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
  51. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
  52. package/src/core/code-flow/scripts/cf_validation.py +81 -0
  53. package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
  54. package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
@@ -8,7 +8,7 @@ description: 根据变更文件自动匹配并执行验证规则
8
8
 
9
9
  ## 输入
10
10
 
11
- - `/project:cf-validate` — 基于 git diff 自动获取变更文件
11
+ - `/project:cf-validate` — 自动获取任务基线或 Git 全部变更文件
12
12
  - `/project:cf-validate src/Foo.tsx` — 验证指定文件
13
13
  - `/project:cf-validate --files=src/a.ts,src/b.ts` — 验证多个文件
14
14
 
@@ -19,10 +19,10 @@ description: 根据变更文件自动匹配并执行验证规则
19
19
  用 Bash 执行:
20
20
 
21
21
  ```bash
22
- git diff --name-only HEAD
22
+ python3 .code-flow/scripts/cf_validation.py --root "$PWD" --scope-only --json
23
23
  ```
24
24
 
25
- 如果用户指定了文件路径,使用用户指定的文件。如果无变更文件,输出"无变更需要验证"。
25
+ 有 active TASK 时使用冻结基线以来的全部任务变更(含已提交文件);无 active TASK 时包含暂存、未暂存和未跟踪文件。用户指定路径时用 `--files "src/a.py" "src/b.py"` 原样传递;若结果确实为空才输出“无变更需要验证”。
26
26
 
27
27
  ### 2. 读取验证规则
28
28
 
@@ -41,14 +41,13 @@ validators:
41
41
 
42
42
  ### 3. 匹配并执行验证
43
43
 
44
- 对每条验证规则:
45
- - 将 `trigger` glob 与变更文件列表做匹配
46
- - 匹配到 → 用 Bash 执行 `command`
47
- - 如果 command 包含 `{files}` → 替换为匹配到的文件路径列表,**每个路径用单引号包裹**(如 `'src/foo.ts' 'src/bar.ts'`)
48
- - 如果 command 不含 `{files}` → 直接执行原命令(如 `npx tsc --noEmit` 本身就检查全局)
49
- - 未匹配 → 跳过该规则
44
+ 执行统一验证入口:
50
45
 
51
- 每条命令使用对应的 `timeout` 值(毫秒)。
46
+ ```bash
47
+ python3 .code-flow/scripts/cf_validation.py --root "$PWD" --json
48
+ ```
49
+
50
+ 显式范围追加 `--files "src/a.py" "src/b.py"`。程序匹配 trigger、解析 argv 并展开 `{files}`,不拼接 shell 命令;同一运行内相同 argv/cwd/timeout 去重,跨运行不复用。删除文件仍触发全局测试,但不传给文件级编译器。每条 timeout 仍按配置的毫秒值生效,总预算耗尽返回 incomplete/block。
52
51
 
53
52
  ### 4. 汇总结果
54
53
 
@@ -65,13 +64,13 @@ validators:
65
64
 
66
65
  ## 安全设计
67
66
 
68
- - 对 `{files}` 中的每个文件路径用单引号包裹,防止 shell 注入
69
- - 仅接受 `git diff` 输出的文件路径或用户显式指定的路径
67
+ - `{files}` 直接展开为 argv 参数;带空格、引号的路径仍是一个参数,不手工拼接引号或执行 shell
68
+ - 范围来自任务基线、Git 状态(包括 untracked)或用户显式指定的路径
70
69
  - 用户指定的路径必须位于项目根目录内
71
70
 
72
71
  ## 异常处理
73
72
 
74
73
  - validation.yml 不存在 → 尝试检测 package.json scripts 中的 test/lint 命令
75
74
  - 命令执行超时 → 输出超时提示,建议增大 timeout 或缩小验证范围
76
- - 命令不存在 → 提示安装依赖(如 `pip install mypy`)
75
+ - 命令不存在或配置无效 → 返回 block 并提示所缺依赖,不当作验证通过
77
76
  - 无变更文件 → 输出"无变更需要验证"
@@ -1,12 +1,21 @@
1
- import { spawnSync } from "child_process";
1
+ import { execFile } from "child_process";
2
2
  import { join } from "path";
3
3
  import { appendFileSync, mkdirSync } from "fs";
4
4
 
5
5
  const SCRIPT_DIR = ".code-flow/scripts";
6
6
 
7
- // Per-session cache of spec context to inject into system prompt
7
+ // Per-session queued feedback. Single map, append-only: idle/stop-check and
8
+ // post-check feedback accumulate here and are consumed by the next
9
+ // system.transform. Never overwrite — chat.message arriving between idle and
10
+ // transform must not drop queued stop-check feedback.
8
11
  const sessionContext = new Map();
9
12
 
13
+ export function mergePending(existing, incoming) {
14
+ if (!existing) return incoming;
15
+ if (!incoming) return existing;
16
+ return existing + "\n\n" + incoming;
17
+ }
18
+
10
19
  function debugLog(projectRoot, msg) {
11
20
  if (process.env.CF_DEBUG !== "1") return;
12
21
  try {
@@ -22,25 +31,39 @@ function pythonPath(projectRoot, script) {
22
31
  }
23
32
 
24
33
  function callHook(projectRoot, script, input, timeout = 5000) {
25
- try {
26
- const proc = spawnSync("python3", [pythonPath(projectRoot, script)], {
27
- cwd: projectRoot,
28
- input: JSON.stringify(input),
29
- encoding: "utf-8",
30
- timeout,
31
- maxBuffer: 1024 * 1024,
32
- });
33
- if (proc.error || proc.status !== 0) {
34
- debugLog(projectRoot, `callHook ${script} failed: ${proc.error || proc.stderr}`);
35
- return null;
34
+ // Async child process: never block the plugin event thread. opencode's
35
+ // idle event cannot interrupt a turn, so stop-check failures queue into
36
+ // sessionContext for the next system.transform (documented platform gap
37
+ // vs. blocking Stop hooks).
38
+ return new Promise((resolve) => {
39
+ const child = execFile(
40
+ "python3",
41
+ [pythonPath(projectRoot, script)],
42
+ { cwd: projectRoot, timeout, maxBuffer: 1024 * 1024 },
43
+ (error, stdout, stderr) => {
44
+ if (error) {
45
+ debugLog(projectRoot, `callHook ${script} failed: ${error.message || stderr}`);
46
+ resolve(null);
47
+ return;
48
+ }
49
+ const text = (stdout || "").trim();
50
+ if (!text) {
51
+ resolve(null);
52
+ return;
53
+ }
54
+ try {
55
+ resolve(JSON.parse(text));
56
+ } catch (e) {
57
+ debugLog(projectRoot, `callHook ${script} bad JSON: ${e.message}`);
58
+ resolve(null);
59
+ }
60
+ }
61
+ );
62
+ if (input !== undefined && child.stdin) {
63
+ child.stdin.write(JSON.stringify(input));
64
+ child.stdin.end();
36
65
  }
37
- const stdout = (proc.stdout || "").trim();
38
- if (!stdout) return null;
39
- return JSON.parse(stdout);
40
- } catch (e) {
41
- debugLog(projectRoot, `callHook ${script} exception: ${e.message}`);
42
- return null;
43
- }
66
+ });
44
67
  }
45
68
 
46
69
  function extractPromptText(output) {
@@ -65,11 +88,10 @@ export const CodeFlow = async (ctx) => {
65
88
  const sid =
66
89
  input.event?.properties?.sessionID ||
67
90
  input.event?.properties?.info?.id || "";
68
- const result = callHook(projectRoot, "cf_stop_hook.py", { session_id: sid }, 35000);
91
+ const result = await callHook(projectRoot, "cf_stop_hook.py", { session_id: sid }, 35000);
69
92
  if (result?.reason) {
70
93
  // idle 无法阻断,校验失败排队到下一轮 system prompt
71
- const pending = sessionContext.get(sid);
72
- sessionContext.set(sid, pending ? pending + "\n\n" + result.reason : result.reason);
94
+ sessionContext.set(sid, mergePending(sessionContext.get(sid), result.reason));
73
95
  debugLog(projectRoot, `stop-check feedback queued`);
74
96
  }
75
97
  }
@@ -81,7 +103,7 @@ export const CodeFlow = async (ctx) => {
81
103
 
82
104
  debugLog(projectRoot, `chat.message sid=${input.sessionID} prompt_len=${promptText.length}`);
83
105
 
84
- const result = callHook(projectRoot, "cf_user_prompt_hook.py", {
106
+ const result = await callHook(projectRoot, "cf_user_prompt_hook.py", {
85
107
  prompt: promptText,
86
108
  session_id: input.sessionID,
87
109
  });
@@ -89,9 +111,10 @@ export const CodeFlow = async (ctx) => {
89
111
  if (result?.hookSpecificOutput?.additionalContext) {
90
112
  const ctxLen = result.hookSpecificOutput.additionalContext.length;
91
113
  debugLog(projectRoot, `hook matched — context ${ctxLen} chars cached`);
114
+ // Append, never overwrite: queued stop-check feedback survives.
92
115
  sessionContext.set(
93
116
  input.sessionID,
94
- result.hookSpecificOutput.additionalContext
117
+ mergePending(sessionContext.get(input.sessionID), result.hookSpecificOutput.additionalContext)
95
118
  );
96
119
  } else {
97
120
  debugLog(projectRoot, `hook returned no context`);
@@ -104,7 +127,7 @@ export const CodeFlow = async (ctx) => {
104
127
  const args = output?.args || input?.args || {};
105
128
  const filePath = args.filePath || args.file_path || args.path;
106
129
  if (!filePath) return;
107
- const result = callHook(projectRoot, "cf_post_hook.py", {
130
+ const result = await callHook(projectRoot, "cf_post_hook.py", {
108
131
  tool_name: tool === "write" ? "Write" : "Edit",
109
132
  tool_input: { file_path: filePath },
110
133
  session_id: input?.sessionID || "",
@@ -113,8 +136,7 @@ export const CodeFlow = async (ctx) => {
113
136
  if (ctx) {
114
137
  // 反馈排队,下一轮 system.transform 注入(opencode 无法当轮插话)
115
138
  const sid = input?.sessionID || "";
116
- const pending = sessionContext.get(sid);
117
- sessionContext.set(sid, pending ? pending + "\n\n" + ctx : ctx);
139
+ sessionContext.set(sid, mergePending(sessionContext.get(sid), ctx));
118
140
  debugLog(projectRoot, `post-check feedback queued ${ctx.length} chars`);
119
141
  }
120
142
  },
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') {