claudeos-core 2.2.0 → 2.3.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1649 -907
  2. package/CONTRIBUTING.md +92 -92
  3. package/README.de.md +32 -0
  4. package/README.es.md +32 -0
  5. package/README.fr.md +32 -0
  6. package/README.hi.md +32 -0
  7. package/README.ja.md +32 -0
  8. package/README.ko.md +1018 -986
  9. package/README.md +1020 -987
  10. package/README.ru.md +32 -0
  11. package/README.vi.md +1019 -987
  12. package/README.zh-CN.md +32 -0
  13. package/bin/cli.js +152 -148
  14. package/bin/commands/init.js +1673 -1554
  15. package/bin/commands/lint.js +62 -0
  16. package/bin/commands/memory.js +438 -438
  17. package/bin/lib/cli-utils.js +206 -206
  18. package/claude-md-validator/index.js +184 -0
  19. package/claude-md-validator/reporter.js +66 -0
  20. package/claude-md-validator/structural-checks.js +528 -0
  21. package/content-validator/index.js +666 -441
  22. package/lib/expected-guides.js +23 -23
  23. package/lib/expected-outputs.js +90 -90
  24. package/lib/language-config.js +35 -35
  25. package/lib/memory-scaffold.js +1058 -1054
  26. package/lib/plan-parser.js +165 -165
  27. package/lib/staged-rules.js +118 -118
  28. package/manifest-generator/index.js +174 -174
  29. package/package.json +90 -87
  30. package/pass-json-validator/index.js +337 -337
  31. package/pass-prompts/templates/common/claude-md-scaffold.md +52 -10
  32. package/pass-prompts/templates/common/pass3-footer.md +402 -224
  33. package/pass-prompts/templates/common/pass3b-core-header.md +43 -0
  34. package/pass-prompts/templates/common/pass4.md +375 -305
  35. package/pass-prompts/templates/common/staging-override.md +26 -26
  36. package/pass-prompts/templates/node-vite/pass1.md +117 -117
  37. package/pass-prompts/templates/node-vite/pass2.md +78 -78
  38. package/pass-prompts/templates/python-flask/pass1.md +119 -119
  39. package/pass-prompts/templates/python-flask/pass2.md +85 -85
  40. package/plan-installer/domain-grouper.js +76 -76
  41. package/plan-installer/index.js +137 -137
  42. package/plan-installer/prompt-generator.js +188 -145
  43. package/plan-installer/scanners/scan-frontend.js +505 -473
  44. package/plan-installer/scanners/scan-java.js +226 -226
  45. package/plan-installer/scanners/scan-node.js +57 -57
  46. package/plan-installer/scanners/scan-python.js +85 -85
  47. package/plan-installer/stack-detector.js +482 -482
  48. package/plan-installer/structure-scanner.js +65 -65
  49. package/sync-checker/index.js +177 -177
@@ -1,165 +1,165 @@
1
- /**
2
- * ClaudeOS-Core — Plan Parser (shared)
3
- *
4
- * Shared parsing logic for plan files used by both manifest-generator and plan-validator.
5
- * Two block formats:
6
- * - <file path="..."> ... </file> (standard plan format)
7
- * - ## N. `path` \n```markdown ... ``` (code block format, e.g. sync-rules-master)
8
- *
9
- * Each function accepts a content string and returns parsed blocks.
10
- * Replace functions modify content in-place and return the updated string.
11
- */
12
-
13
- // ─── Extract <file path="..."> blocks ───────────────────────
14
-
15
- /**
16
- * Parse <file path="..."> ... </file> blocks from plan content.
17
- * @param {string} content - plan file content
18
- * @param {{ includeContent?: boolean }} options
19
- * @returns {Array<{ path: string, content?: string }>}
20
- */
21
- function parseFileBlocks(content, { includeContent = false } = {}) {
22
- const result = [];
23
- // Filter out placeholder paths that can appear in prose/documentation inside
24
- // the plan file (e.g., a sentence like: use <file path="..."> to wrap files).
25
- // Real paths are any non-empty string; only the literal ellipsis placeholders
26
- // and angle-bracket templates are rejected.
27
- const isRealPath = (p) =>
28
- typeof p === "string" &&
29
- p.length > 0 &&
30
- p !== "..." &&
31
- !p.startsWith("...") &&
32
- !/^<[^>]+>$/.test(p);
33
- if (includeContent) {
34
- const re = /<file\s+path="([^"]+)">\s*\n([\s\S]*?)\n<\/file>/g;
35
- let m;
36
- while ((m = re.exec(content)) !== null) {
37
- if (!isRealPath(m[1])) continue;
38
- result.push({ path: m[1], content: m[2] });
39
- }
40
- } else {
41
- const re = /<file\s+path="([^"]+)">/g;
42
- let m;
43
- while ((m = re.exec(content)) !== null) {
44
- if (!isRealPath(m[1])) continue;
45
- result.push({ path: m[1] });
46
- }
47
- }
48
- return result;
49
- }
50
-
51
- // ─── Extract ## N. `path` ```markdown ... ``` blocks ────────
52
-
53
- /**
54
- * Parse ## N. `path` + ```markdown ... ``` blocks from plan content.
55
- * Uses indexOf-based parsing to correctly handle nested code fences.
56
- * @param {string} content - plan file content
57
- * @param {{ includeContent?: boolean }} options
58
- * @returns {Array<{ path: string, content?: string }>}
59
- */
60
- function parseCodeBlocks(content, { includeContent = false } = {}) {
61
- const result = [];
62
- const headingRe = includeContent
63
- ? /^##\s+\d+\.\s+([^\n]+)/gm
64
- : /^##\s+\d+\.\s+`?([^`\n]+)`?/gm;
65
- let headingMatch;
66
- while ((headingMatch = headingRe.exec(content)) !== null) {
67
- const filePath = headingMatch[1].replace(/`/g, "").replace(/\s+[—–\-].*$/, "").trim();
68
-
69
- if (!includeContent) {
70
- // Path-only mode: just validate and collect
71
- if (filePath && filePath.includes("/")) {
72
- result.push({ path: filePath });
73
- }
74
- continue;
75
- }
76
-
77
- // Content mode: find opening ```markdown and matching closing ```
78
- const openFence = content.indexOf("```markdown\n", headingMatch.index);
79
- if (openFence < 0) continue;
80
- const contentStart = openFence + "```markdown\n".length;
81
- const closingPos = findClosingFence(content, contentStart);
82
- if (closingPos < 0) continue;
83
- const blockContent = content.substring(contentStart, closingPos).trimEnd();
84
- result.push({ path: filePath, content: blockContent });
85
- // Advance headingRe past this block to avoid re-matching inside content
86
- headingRe.lastIndex = closingPos;
87
- }
88
- return result;
89
- }
90
-
91
- // ─── Replace <file> block content ───────────────────────────
92
-
93
- /**
94
- * Replace content inside a <file path="..."> block.
95
- * @param {string} content - full plan file content
96
- * @param {string} filePath - path attribute to match
97
- * @param {string} newContent - replacement content
98
- * @returns {string} updated content
99
- */
100
- function replaceFileBlock(content, filePath, newContent) {
101
- // Escape regex special chars in filePath (for the pattern)
102
- const escaped = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
- // CRITICAL: use a replacement *function* (not a string) to avoid
104
- // `$1`/`$&`/`$$` special-character interpretation inside newContent.
105
- // When users document things like `$1`, `price: $100`, or shell `$$PID`
106
- // in their memory/rule/standard files, a string replacement would
107
- // corrupt the data.
108
- const re = new RegExp(`(<file\\s+path="${escaped}">\\s*\\n)[\\s\\S]*?(\\n</file>)`, "g");
109
- return content.replace(re, (_match, open, close) => open + newContent + close);
110
- }
111
-
112
- // ─── Replace code block content ─────────────────────────────
113
-
114
- /**
115
- * Replace content inside a ## N. `path` ```markdown ... ``` block.
116
- * Uses indexOf-based approach to handle nested code fences.
117
- * @param {string} content - full plan file content
118
- * @param {string} filePath - path to match in heading
119
- * @param {string} newContent - replacement content
120
- * @returns {string} updated content
121
- */
122
- function replaceCodeBlock(content, filePath, newContent) {
123
- const cleanPath = filePath.replace(/`/g, "");
124
- const headingPattern = new RegExp(`^##\\s+\\d+\\.\\s+\`?${cleanPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\`?`, "m");
125
- const headingMatch = headingPattern.exec(content);
126
- if (!headingMatch) return content;
127
- const afterHeading = content.indexOf("```markdown\n", headingMatch.index);
128
- if (afterHeading < 0) return content;
129
- const contentStart = afterHeading + "```markdown\n".length;
130
- const closingPos = findClosingFence(content, contentStart);
131
- if (closingPos < 0) return content;
132
- return content.substring(0, contentStart) + newContent + "\n" + content.substring(closingPos);
133
- }
134
-
135
- // ─── Internal: find closing ``` with nesting ────────────────
136
-
137
- function findClosingFence(content, startPos) {
138
- let searchPos = startPos;
139
- let nestDepth = 0;
140
- while (searchPos < content.length) {
141
- const nextFence = content.indexOf("\n```", searchPos);
142
- if (nextFence < 0) break;
143
- const fenceLineStart = nextFence + 1;
144
- const nextNewline = content.indexOf("\n", fenceLineStart + 3);
145
- const restOfLine = nextNewline >= 0
146
- ? content.substring(fenceLineStart + 3, nextNewline)
147
- : content.substring(fenceLineStart + 3);
148
- const isOpening = /^[a-zA-Z]/.test(restOfLine.trim());
149
- if (isOpening) {
150
- nestDepth++;
151
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
152
- } else if (nestDepth > 0) {
153
- nestDepth--;
154
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
155
- } else {
156
- return fenceLineStart;
157
- }
158
- }
159
- return -1;
160
- }
161
-
162
- // Plan files that use code block format instead of <file> block format
163
- const CODE_BLOCK_PLANS = ["21.sync-rules-master.md"];
164
-
165
- module.exports = { parseFileBlocks, parseCodeBlocks, replaceFileBlock, replaceCodeBlock, CODE_BLOCK_PLANS };
1
+ /**
2
+ * ClaudeOS-Core — Plan Parser (shared)
3
+ *
4
+ * Shared parsing logic for plan files used by both manifest-generator and plan-validator.
5
+ * Two block formats:
6
+ * - <file path="..."> ... </file> (standard plan format)
7
+ * - ## N. `path` \n```markdown ... ``` (code block format, e.g. sync-rules-master)
8
+ *
9
+ * Each function accepts a content string and returns parsed blocks.
10
+ * Replace functions modify content in-place and return the updated string.
11
+ */
12
+
13
+ // ─── Extract <file path="..."> blocks ───────────────────────
14
+
15
+ /**
16
+ * Parse <file path="..."> ... </file> blocks from plan content.
17
+ * @param {string} content - plan file content
18
+ * @param {{ includeContent?: boolean }} options
19
+ * @returns {Array<{ path: string, content?: string }>}
20
+ */
21
+ function parseFileBlocks(content, { includeContent = false } = {}) {
22
+ const result = [];
23
+ // Filter out placeholder paths that can appear in prose/documentation inside
24
+ // the plan file (e.g., a sentence like: use <file path="..."> to wrap files).
25
+ // Real paths are any non-empty string; only the literal ellipsis placeholders
26
+ // and angle-bracket templates are rejected.
27
+ const isRealPath = (p) =>
28
+ typeof p === "string" &&
29
+ p.length > 0 &&
30
+ p !== "..." &&
31
+ !p.startsWith("...") &&
32
+ !/^<[^>]+>$/.test(p);
33
+ if (includeContent) {
34
+ const re = /<file\s+path="([^"]+)">\s*\n([\s\S]*?)\n<\/file>/g;
35
+ let m;
36
+ while ((m = re.exec(content)) !== null) {
37
+ if (!isRealPath(m[1])) continue;
38
+ result.push({ path: m[1], content: m[2] });
39
+ }
40
+ } else {
41
+ const re = /<file\s+path="([^"]+)">/g;
42
+ let m;
43
+ while ((m = re.exec(content)) !== null) {
44
+ if (!isRealPath(m[1])) continue;
45
+ result.push({ path: m[1] });
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ // ─── Extract ## N. `path` ```markdown ... ``` blocks ────────
52
+
53
+ /**
54
+ * Parse ## N. `path` + ```markdown ... ``` blocks from plan content.
55
+ * Uses indexOf-based parsing to correctly handle nested code fences.
56
+ * @param {string} content - plan file content
57
+ * @param {{ includeContent?: boolean }} options
58
+ * @returns {Array<{ path: string, content?: string }>}
59
+ */
60
+ function parseCodeBlocks(content, { includeContent = false } = {}) {
61
+ const result = [];
62
+ const headingRe = includeContent
63
+ ? /^##\s+\d+\.\s+([^\n]+)/gm
64
+ : /^##\s+\d+\.\s+`?([^`\n]+)`?/gm;
65
+ let headingMatch;
66
+ while ((headingMatch = headingRe.exec(content)) !== null) {
67
+ const filePath = headingMatch[1].replace(/`/g, "").replace(/\s+[—–\-].*$/, "").trim();
68
+
69
+ if (!includeContent) {
70
+ // Path-only mode: just validate and collect
71
+ if (filePath && filePath.includes("/")) {
72
+ result.push({ path: filePath });
73
+ }
74
+ continue;
75
+ }
76
+
77
+ // Content mode: find opening ```markdown and matching closing ```
78
+ const openFence = content.indexOf("```markdown\n", headingMatch.index);
79
+ if (openFence < 0) continue;
80
+ const contentStart = openFence + "```markdown\n".length;
81
+ const closingPos = findClosingFence(content, contentStart);
82
+ if (closingPos < 0) continue;
83
+ const blockContent = content.substring(contentStart, closingPos).trimEnd();
84
+ result.push({ path: filePath, content: blockContent });
85
+ // Advance headingRe past this block to avoid re-matching inside content
86
+ headingRe.lastIndex = closingPos;
87
+ }
88
+ return result;
89
+ }
90
+
91
+ // ─── Replace <file> block content ───────────────────────────
92
+
93
+ /**
94
+ * Replace content inside a <file path="..."> block.
95
+ * @param {string} content - full plan file content
96
+ * @param {string} filePath - path attribute to match
97
+ * @param {string} newContent - replacement content
98
+ * @returns {string} updated content
99
+ */
100
+ function replaceFileBlock(content, filePath, newContent) {
101
+ // Escape regex special chars in filePath (for the pattern)
102
+ const escaped = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ // CRITICAL: use a replacement *function* (not a string) to avoid
104
+ // `$1`/`$&`/`$$` special-character interpretation inside newContent.
105
+ // When users document things like `$1`, `price: $100`, or shell `$$PID`
106
+ // in their memory/rule/standard files, a string replacement would
107
+ // corrupt the data.
108
+ const re = new RegExp(`(<file\\s+path="${escaped}">\\s*\\n)[\\s\\S]*?(\\n</file>)`, "g");
109
+ return content.replace(re, (_match, open, close) => open + newContent + close);
110
+ }
111
+
112
+ // ─── Replace code block content ─────────────────────────────
113
+
114
+ /**
115
+ * Replace content inside a ## N. `path` ```markdown ... ``` block.
116
+ * Uses indexOf-based approach to handle nested code fences.
117
+ * @param {string} content - full plan file content
118
+ * @param {string} filePath - path to match in heading
119
+ * @param {string} newContent - replacement content
120
+ * @returns {string} updated content
121
+ */
122
+ function replaceCodeBlock(content, filePath, newContent) {
123
+ const cleanPath = filePath.replace(/`/g, "");
124
+ const headingPattern = new RegExp(`^##\\s+\\d+\\.\\s+\`?${cleanPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\`?`, "m");
125
+ const headingMatch = headingPattern.exec(content);
126
+ if (!headingMatch) return content;
127
+ const afterHeading = content.indexOf("```markdown\n", headingMatch.index);
128
+ if (afterHeading < 0) return content;
129
+ const contentStart = afterHeading + "```markdown\n".length;
130
+ const closingPos = findClosingFence(content, contentStart);
131
+ if (closingPos < 0) return content;
132
+ return content.substring(0, contentStart) + newContent + "\n" + content.substring(closingPos);
133
+ }
134
+
135
+ // ─── Internal: find closing ``` with nesting ────────────────
136
+
137
+ function findClosingFence(content, startPos) {
138
+ let searchPos = startPos;
139
+ let nestDepth = 0;
140
+ while (searchPos < content.length) {
141
+ const nextFence = content.indexOf("\n```", searchPos);
142
+ if (nextFence < 0) break;
143
+ const fenceLineStart = nextFence + 1;
144
+ const nextNewline = content.indexOf("\n", fenceLineStart + 3);
145
+ const restOfLine = nextNewline >= 0
146
+ ? content.substring(fenceLineStart + 3, nextNewline)
147
+ : content.substring(fenceLineStart + 3);
148
+ const isOpening = /^[a-zA-Z]/.test(restOfLine.trim());
149
+ if (isOpening) {
150
+ nestDepth++;
151
+ searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
152
+ } else if (nestDepth > 0) {
153
+ nestDepth--;
154
+ searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
155
+ } else {
156
+ return fenceLineStart;
157
+ }
158
+ }
159
+ return -1;
160
+ }
161
+
162
+ // Plan files that use code block format instead of <file> block format
163
+ const CODE_BLOCK_PLANS = ["21.sync-rules-master.md"];
164
+
165
+ module.exports = { parseFileBlocks, parseCodeBlocks, replaceFileBlock, replaceCodeBlock, CODE_BLOCK_PLANS };
@@ -1,118 +1,118 @@
1
- /**
2
- * ClaudeOS-Core — Staged Rules Mover
3
- *
4
- * Pass 3 and Pass 4 write rule files into
5
- * claudeos-core/generated/.staged-rules/**
6
- * rather than
7
- * .claude/rules/**
8
- * because Claude Code's sensitive-path policy blocks direct writes to
9
- * `.claude/` from the `claude -p` subprocess (even with
10
- * `--dangerously-skip-permissions`).
11
- *
12
- * After each pass, this module moves everything under the staging dir into the
13
- * real `.claude/rules/` tree, preserving subpaths, then removes the staging
14
- * dir. The Node.js orchestrator is not subject to Claude Code's policy, so the
15
- * final writes succeed.
16
- */
17
-
18
- const fs = require("fs");
19
- const path = require("path");
20
-
21
- /**
22
- * Recursively list files under dir (absolute paths). Returns [] if dir missing.
23
- */
24
- function walkFiles(dir) {
25
- const out = [];
26
- if (!fs.existsSync(dir)) return out;
27
- const stack = [dir];
28
- while (stack.length > 0) {
29
- const cur = stack.pop();
30
- let entries;
31
- try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
32
- catch (_e) { continue; }
33
- for (const e of entries) {
34
- const full = path.join(cur, e.name);
35
- if (e.isDirectory()) stack.push(full);
36
- else if (e.isFile()) out.push(full);
37
- }
38
- }
39
- return out;
40
- }
41
-
42
- /**
43
- * Move a single file, falling back to copy+unlink if rename fails
44
- * (Windows cross-volume/overwrite edge cases).
45
- */
46
- function moveFile(src, dst) {
47
- fs.mkdirSync(path.dirname(dst), { recursive: true });
48
- try {
49
- fs.renameSync(src, dst);
50
- return;
51
- } catch (_e) {
52
- // Fallback: copy then unlink. copyFileSync overwrites by default.
53
- fs.copyFileSync(src, dst);
54
- try { fs.unlinkSync(src); } catch (_e2) { /* best-effort cleanup */ }
55
- }
56
- }
57
-
58
- /**
59
- * Remove a directory tree. No-op if missing.
60
- */
61
- function removeDir(dir) {
62
- if (!fs.existsSync(dir)) return;
63
- fs.rmSync(dir, { recursive: true, force: true });
64
- }
65
-
66
- /**
67
- * Move everything from <projectRoot>/claudeos-core/generated/.staged-rules/**
68
- * to <projectRoot>/.claude/rules/**, preserving subpaths.
69
- *
70
- * @param {string} projectRoot - absolute path to the user's project root
71
- * @returns {{ moved: number, failed: number, files: string[], errors: string[], skipped: boolean }}
72
- * - skipped: true when the staging dir does not exist (nothing to move)
73
- * - files: list of subpaths moved (relative to .claude/rules/)
74
- * - errors: per-file error messages (empty on full success)
75
- */
76
- function moveStagedRules(projectRoot) {
77
- const stagingRoot = path.join(projectRoot, "claudeos-core", "generated", ".staged-rules");
78
- const rulesRoot = path.join(projectRoot, ".claude", "rules");
79
-
80
- if (!fs.existsSync(stagingRoot)) {
81
- return { moved: 0, failed: 0, files: [], errors: [], skipped: true };
82
- }
83
-
84
- const staged = walkFiles(stagingRoot);
85
- const result = { moved: 0, failed: 0, files: [], errors: [], skipped: false };
86
-
87
- for (const srcAbs of staged) {
88
- const rel = path.relative(stagingRoot, srcAbs);
89
- const dstAbs = path.join(rulesRoot, rel);
90
- try {
91
- moveFile(srcAbs, dstAbs);
92
- result.moved++;
93
- result.files.push(rel.split(path.sep).join("/"));
94
- } catch (e) {
95
- result.failed++;
96
- result.errors.push(`${rel}: ${e.code || e.message}`);
97
- }
98
- }
99
-
100
- // Clean up the staging tree on full success; keep it on partial failure so
101
- // the user (or a re-run) can inspect what didn't make it across.
102
- if (result.failed === 0) {
103
- try { removeDir(stagingRoot); } catch (_e) { /* non-fatal */ }
104
- }
105
-
106
- return result;
107
- }
108
-
109
- /**
110
- * Count the total number of regular files under a directory, recursively.
111
- * Returns 0 if the directory doesn't exist. Unreadable subdirectories are
112
- * skipped silently (consistent with walkFiles behavior).
113
- */
114
- function countFilesRecursive(dir) {
115
- return walkFiles(dir).length;
116
- }
117
-
118
- module.exports = { moveStagedRules, countFilesRecursive };
1
+ /**
2
+ * ClaudeOS-Core — Staged Rules Mover
3
+ *
4
+ * Pass 3 and Pass 4 write rule files into
5
+ * claudeos-core/generated/.staged-rules/**
6
+ * rather than
7
+ * .claude/rules/**
8
+ * because Claude Code's sensitive-path policy blocks direct writes to
9
+ * `.claude/` from the `claude -p` subprocess (even with
10
+ * `--dangerously-skip-permissions`).
11
+ *
12
+ * After each pass, this module moves everything under the staging dir into the
13
+ * real `.claude/rules/` tree, preserving subpaths, then removes the staging
14
+ * dir. The Node.js orchestrator is not subject to Claude Code's policy, so the
15
+ * final writes succeed.
16
+ */
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ /**
22
+ * Recursively list files under dir (absolute paths). Returns [] if dir missing.
23
+ */
24
+ function walkFiles(dir) {
25
+ const out = [];
26
+ if (!fs.existsSync(dir)) return out;
27
+ const stack = [dir];
28
+ while (stack.length > 0) {
29
+ const cur = stack.pop();
30
+ let entries;
31
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
32
+ catch (_e) { continue; }
33
+ for (const e of entries) {
34
+ const full = path.join(cur, e.name);
35
+ if (e.isDirectory()) stack.push(full);
36
+ else if (e.isFile()) out.push(full);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Move a single file, falling back to copy+unlink if rename fails
44
+ * (Windows cross-volume/overwrite edge cases).
45
+ */
46
+ function moveFile(src, dst) {
47
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
48
+ try {
49
+ fs.renameSync(src, dst);
50
+ return;
51
+ } catch (_e) {
52
+ // Fallback: copy then unlink. copyFileSync overwrites by default.
53
+ fs.copyFileSync(src, dst);
54
+ try { fs.unlinkSync(src); } catch (_e2) { /* best-effort cleanup */ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Remove a directory tree. No-op if missing.
60
+ */
61
+ function removeDir(dir) {
62
+ if (!fs.existsSync(dir)) return;
63
+ fs.rmSync(dir, { recursive: true, force: true });
64
+ }
65
+
66
+ /**
67
+ * Move everything from <projectRoot>/claudeos-core/generated/.staged-rules/**
68
+ * to <projectRoot>/.claude/rules/**, preserving subpaths.
69
+ *
70
+ * @param {string} projectRoot - absolute path to the user's project root
71
+ * @returns {{ moved: number, failed: number, files: string[], errors: string[], skipped: boolean }}
72
+ * - skipped: true when the staging dir does not exist (nothing to move)
73
+ * - files: list of subpaths moved (relative to .claude/rules/)
74
+ * - errors: per-file error messages (empty on full success)
75
+ */
76
+ function moveStagedRules(projectRoot) {
77
+ const stagingRoot = path.join(projectRoot, "claudeos-core", "generated", ".staged-rules");
78
+ const rulesRoot = path.join(projectRoot, ".claude", "rules");
79
+
80
+ if (!fs.existsSync(stagingRoot)) {
81
+ return { moved: 0, failed: 0, files: [], errors: [], skipped: true };
82
+ }
83
+
84
+ const staged = walkFiles(stagingRoot);
85
+ const result = { moved: 0, failed: 0, files: [], errors: [], skipped: false };
86
+
87
+ for (const srcAbs of staged) {
88
+ const rel = path.relative(stagingRoot, srcAbs);
89
+ const dstAbs = path.join(rulesRoot, rel);
90
+ try {
91
+ moveFile(srcAbs, dstAbs);
92
+ result.moved++;
93
+ result.files.push(rel.split(path.sep).join("/"));
94
+ } catch (e) {
95
+ result.failed++;
96
+ result.errors.push(`${rel}: ${e.code || e.message}`);
97
+ }
98
+ }
99
+
100
+ // Clean up the staging tree on full success; keep it on partial failure so
101
+ // the user (or a re-run) can inspect what didn't make it across.
102
+ if (result.failed === 0) {
103
+ try { removeDir(stagingRoot); } catch (_e) { /* non-fatal */ }
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Count the total number of regular files under a directory, recursively.
111
+ * Returns 0 if the directory doesn't exist. Unreadable subdirectories are
112
+ * skipped silently (consistent with walkFiles behavior).
113
+ */
114
+ function countFilesRecursive(dir) {
115
+ return walkFiles(dir).length;
116
+ }
117
+
118
+ module.exports = { moveStagedRules, countFilesRecursive };