@oleksandr.rudnychenko/sync_loop 0.2.2 → 0.2.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.
package/src/init.js CHANGED
@@ -1,365 +1,569 @@
1
- import { readFileSync, writeFileSync, mkdirSync, cpSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
-
5
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const TEMPLATE_DIR = join(__dirname, "..", "template");
7
-
8
- // ---------------------------------------------------------------------------
9
- // Helpers
10
- // ---------------------------------------------------------------------------
11
-
12
- function readTemplate(relativePath) {
13
- return readFileSync(join(TEMPLATE_DIR, relativePath), "utf-8");
14
- }
15
-
16
- function writeOutput(projectPath, relativePath, content) {
17
- const fullPath = join(projectPath, relativePath);
18
- mkdirSync(dirname(fullPath), { recursive: true });
19
- writeFileSync(fullPath, content, "utf-8");
20
- return relativePath;
21
- }
22
-
23
- function yamlFrontmatter(fields) {
24
- const lines = ["---"];
25
- for (const [key, value] of Object.entries(fields)) {
26
- if (Array.isArray(value)) {
27
- lines.push(`${key}:`);
28
- for (const item of value) {
29
- lines.push(` - "${item}"`);
30
- }
31
- } else if (typeof value === "boolean") {
32
- lines.push(`${key}: ${value}`);
33
- } else {
34
- lines.push(`${key}: "${value}"`);
35
- }
36
- }
37
- lines.push("---");
38
- return lines.join("\n");
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Source file list (maps to template/.agent-loop/)
43
- // ---------------------------------------------------------------------------
44
-
45
- const SOURCE_FILES = [
46
- { id: "reasoning-kernel", path: ".agent-loop/reasoning-kernel.md" },
47
- { id: "feedback", path: ".agent-loop/feedback.md" },
48
- { id: "validate-env", path: ".agent-loop/validate-env.md" },
49
- { id: "validate-n", path: ".agent-loop/validate-n.md" },
50
- { id: "patterns", path: ".agent-loop/patterns.md" },
51
- { id: "glossary", path: ".agent-loop/glossary.md" },
52
- { id: "code-patterns", path: ".agent-loop/patterns/code-patterns.md" },
53
- { id: "testing-guide", path: ".agent-loop/patterns/testing-guide.md" },
54
- { id: "refactoring-workflow", path: ".agent-loop/patterns/refactoring-workflow.md" },
55
- { id: "api-standards", path: ".agent-loop/patterns/api-standards.md" },
56
- ];
57
-
58
- // ---------------------------------------------------------------------------
59
- // Platform configs — source ID → { target path, YAML frontmatter }
60
- // ---------------------------------------------------------------------------
61
-
62
- const COPILOT = {
63
- "reasoning-kernel": { target: ".github/instructions/reasoning-kernel.instructions.md", fm: { name: "SyncLoop: Reasoning Kernel", description: "7-stage agent reasoning loop with context clearage", applyTo: "**/*" } },
64
- "feedback": { target: ".github/instructions/feedback.instructions.md", fm: { name: "SyncLoop: Feedback Loop", description: "Failure diagnosis, patch protocol, branch pruning", applyTo: "**/*" } },
65
- "validate-env": { target: ".github/instructions/validate-env.instructions.md", fm: { name: "SyncLoop: Validate Environment", description: "NFR gates: types, tests, layers, complexity", applyTo: "**/*" } },
66
- "validate-n": { target: ".github/instructions/validate-n.instructions.md", fm: { name: "SyncLoop: Validate Neighbors", description: "Shape, boundary, bridge checks", applyTo: "**/*" } },
67
- "patterns": { target: ".github/instructions/patterns.instructions.md", fm: { name: "SyncLoop: Pattern Registry", description: "Pattern routing and learned patterns", applyTo: "**/*" } },
68
- "glossary": { target: ".github/instructions/glossary.instructions.md", fm: { name: "SyncLoop: Glossary", description: "Canonical terminology", applyTo: "**/*" } },
69
- "code-patterns": { target: ".github/instructions/code-patterns.instructions.md", fm: { name: "SyncLoop: Code Patterns", description: "P1-P11 implementation patterns", applyTo: "{src,app,lib}/**/*.{ts,py,js,jsx,tsx}" } },
70
- "testing-guide": { target: ".github/instructions/testing-guide.instructions.md", fm: { name: "SyncLoop: Testing Guide", description: "Test patterns and strategies", applyTo: "{tests,test,__tests__}/**/*" } },
71
- "refactoring-workflow":{ target: ".github/instructions/refactoring-workflow.instructions.md", fm: { name: "SyncLoop: Refactoring Workflow", description: "4-phase refactoring checklist", applyTo: "**/*" } },
72
- "api-standards": { target: ".github/instructions/api-standards.instructions.md", fm: { name: "SyncLoop: API Standards", description: "Boundary contracts and API conventions", applyTo: "{routes,routers,controllers,api}/**/*" } },
73
- };
74
-
75
- const CURSOR = {
76
- "reasoning-kernel": { target: ".cursor/rules/01-reasoning-kernel.md", fm: { description: "7-stage agent reasoning loop with context clearage and transitions", alwaysApply: true } },
77
- "feedback": { target: ".cursor/rules/02-feedback.md", fm: { description: "Failure diagnosis, patch protocol, micro-loop, branch pruning", alwaysApply: true } },
78
- "validate-env": { target: ".cursor/rules/03-validate-env.md", fm: { description: "Stage 1 NFR gates: types, tests, layers, complexity, debug hygiene", alwaysApply: true } },
79
- "validate-n": { target: ".cursor/rules/04-validate-n.md", fm: { description: "Stage 2 checks: shapes, boundaries, bridges", alwaysApply: true } },
80
- "patterns": { target: ".cursor/rules/05-patterns.md", fm: { description: "Pattern routing index and learned patterns", alwaysApply: true } },
81
- "glossary": { target: ".cursor/rules/06-glossary.md", fm: { description: "Canonical domain terminology and naming rules", alwaysApply: true } },
82
- "code-patterns": { target: ".cursor/rules/07-code-patterns.md", fm: { description: "P1-P11 implementation patterns for layered code", globs: "{src,app,lib}/**/*.{ts,py,js,jsx,tsx}" } },
83
- "testing-guide": { target: ".cursor/rules/08-testing-guide.md", fm: { description: "Test patterns, fixtures, mocks, strategies", globs: "{tests,test,__tests__}/**/*" } },
84
- "refactoring-workflow":{ target: ".cursor/rules/09-refactoring-workflow.md", fm: { description: "4-phase refactoring checklist for safe restructuring", alwaysApply: false } },
85
- "api-standards": { target: ".cursor/rules/10-api-standards.md", fm: { description: "Boundary contracts, typed models, error envelopes", globs: "{routes,routers,controllers,api}/**/*" } },
86
- };
87
-
88
- const CLAUDE = {
89
- "reasoning-kernel": { target: ".claude/rules/reasoning-kernel.md", fm: { paths: ["**/*"] } },
90
- "feedback": { target: ".claude/rules/feedback.md", fm: { paths: ["**/*"] } },
91
- "validate-env": { target: ".claude/rules/validate-env.md", fm: { paths: ["**/*"] } },
92
- "validate-n": { target: ".claude/rules/validate-n.md", fm: { paths: ["**/*"] } },
93
- "patterns": { target: ".claude/rules/patterns.md", fm: { paths: ["**/*"] } },
94
- "glossary": { target: ".claude/rules/glossary.md", fm: { paths: ["**/*"] } },
95
- "code-patterns": { target: ".claude/rules/code-patterns.md", fm: { paths: ["src/**", "app/**", "lib/**"] } },
96
- "testing-guide": { target: ".claude/rules/testing-guide.md", fm: { paths: ["tests/**", "test/**", "__tests__/**"] } },
97
- "refactoring-workflow":{ target: ".claude/rules/refactoring-workflow.md", fm: { paths: ["**/*"] } },
98
- "api-standards": { target: ".claude/rules/api-standards.md", fm: { paths: ["**/routes/**", "**/api/**", "**/controllers/**"] } },
99
- };
100
-
101
- const PLATFORM_CONFIGS = { copilot: COPILOT, cursor: CURSOR, claude: CLAUDE };
102
-
103
- // ---------------------------------------------------------------------------
104
- // Link rewriting — rewrite .agent-loop/ refs to platform-specific paths
105
- // ---------------------------------------------------------------------------
106
-
107
- // Map from canonical .agent-loop/ filename to source ID
108
- const CANONICAL_TO_ID = {};
109
- for (const s of SOURCE_FILES) {
110
- // ".agent-loop/reasoning-kernel.md" → "reasoning-kernel"
111
- CANONICAL_TO_ID[s.path.replace(".agent-loop/", "")] = s.id;
112
- }
113
-
114
- /**
115
- * Build a lookup: source-id → target filename (basename only, since platform
116
- * files live in one flat directory per platform).
117
- */
118
- function buildTargetMap(platform) {
119
- const config = PLATFORM_CONFIGS[platform];
120
- const map = {};
121
- for (const [id, entry] of Object.entries(config)) {
122
- // e.g. ".github/instructions/feedback.instructions.md" → "feedback.instructions.md"
123
- map[id] = entry.target.split("/").pop();
124
- }
125
- return map;
126
- }
127
-
128
- /**
129
- * Rewrite internal cross-references inside a spec file.
130
- *
131
- * Template files use relative paths like:
132
- * [reasoning-kernel.md](reasoning-kernel.md)
133
- * [patterns/code-patterns.md](patterns/code-patterns.md)
134
- * [../AGENTS.md](../AGENTS.md)
135
- *
136
- * For a platform target these must point to sibling files in the same directory.
137
- */
138
- function rewriteSpecLinks(content, sourceId, platform) {
139
- const targetMap = buildTargetMap(platform);
140
- const sourceFile = SOURCE_FILES.find(s => s.id === sourceId);
141
- // Is this file in a subdirectory (patterns/)?
142
- const isNested = sourceFile?.path.includes("patterns/") && sourceId !== "patterns";
143
-
144
- return content.replace(/\]\(([^)]+\.md)\)/g, (_match, linkPath) => {
145
- // Normalize: resolve relative paths from the source file's perspective
146
- let canonical = linkPath;
147
-
148
- if (isNested) {
149
- // Files in patterns/ use "../" to reach parent-level siblings
150
- if (canonical.startsWith("../")) {
151
- canonical = canonical.replace(/^\.\.\//, "");
152
- } else {
153
- // Sibling reference within patterns/ → prefix with "patterns/"
154
- canonical = `patterns/${canonical}`;
155
- }
156
- }
157
-
158
- // Handle ../AGENTS.md → just skip (AGENTS.md is at root, not in platform dir)
159
- if (canonical === "AGENTS.md" || canonical === "../AGENTS.md") {
160
- return "](../AGENTS.md)";
161
- }
162
-
163
- // Handle ../README.md (agent-loop readme not relevant for platform files)
164
- if (canonical === "README.md" || canonical === "../README.md") {
165
- return `](${linkPath})`;
166
- }
167
-
168
- // Look up the target filename for this canonical path
169
- const id = CANONICAL_TO_ID[canonical];
170
- if (id && targetMap[id]) {
171
- return `](${targetMap[id]})`;
172
- }
173
-
174
- // No match — keep original
175
- return `](${linkPath})`;
176
- });
177
- }
178
-
179
- /**
180
- * Rewrite AGENTS.md links from .agent-loop/ paths to platform-specific paths.
181
- *
182
- * For target="all" links stay as .agent-loop/ (canonical source exists).
183
- * For single platform, links point into that platform's directory.
184
- */
185
- function rewriteAgentsLinks(content, platform) {
186
- if (platform === "all") return content;
187
-
188
- const config = PLATFORM_CONFIGS[platform];
189
-
190
- // Build map: ".agent-loop/reasoning-kernel.md" → platform target path
191
- const pathMap = {};
192
- for (const source of SOURCE_FILES) {
193
- const entry = config[source.id];
194
- if (entry) {
195
- pathMap[source.path] = entry.target;
196
- }
197
- }
198
-
199
- // Also map the patterns/ directory reference
200
- const platformDir = Object.values(config)[0].target.split("/").slice(0, -1).join("/");
201
-
202
- let result = content;
203
-
204
- // Rewrite markdown links: [text](.agent-loop/xxx.md) → [text](platform/xxx.md)
205
- result = result.replace(/\]\(\.agent-loop\/([^)]+)\)/g, (_match, relPath) => {
206
- const fullPath = `.agent-loop/${relPath}`;
207
- if (pathMap[fullPath]) {
208
- return `](${pathMap[fullPath]})`;
209
- }
210
- // Directory reference like .agent-loop/patterns/
211
- if (relPath === "patterns/" || relPath === "patterns") {
212
- return `](${platformDir}/)`;
213
- }
214
- return `](${fullPath})`;
215
- });
216
-
217
- // Rewrite inline backtick references: `.agent-loop/patterns.md` → platform path
218
- result = result.replace(/`\.agent-loop\/([^`]+)`/g, (_match, relPath) => {
219
- const fullPath = `.agent-loop/${relPath}`;
220
- if (pathMap[fullPath]) {
221
- return `\`${pathMap[fullPath]}\``;
222
- }
223
- if (relPath.startsWith("patterns/") || relPath === "patterns") {
224
- return `\`${platformDir}/\``;
225
- }
226
- return `\`${fullPath}\``;
227
- });
228
-
229
- // Rewrite the intro line
230
- result = result.replace(
231
- /Routes into `\.agent-loop\/`/,
232
- `Routes into \`${platformDir}/\``,
233
- );
234
-
235
- return result;
236
- }
237
-
238
- // ---------------------------------------------------------------------------
239
- // Environment placeholder replacement
240
- // ---------------------------------------------------------------------------
241
-
242
- function applyStacks(content, stacks) {
243
- if (!stacks || stacks.length === 0) return content;
244
-
245
- // Aggregate across all stacks
246
- const allLanguages = [...new Set(stacks.flatMap(s => s.languages))];
247
- const allFrameworks = [...new Set(stacks.flatMap(s => s.frameworks))];
248
- const testRunners = stacks.map(s => s.testRunner).filter(Boolean);
249
- const typeCheckers = stacks.map(s => s.typeChecker).filter(Boolean);
250
- const linters = stacks.map(s => s.linter).filter(Boolean);
251
- const packageManagers = [...new Set(stacks.map(s => s.packageManager).filter(Boolean))];
252
-
253
- // Build stack table for AGENTS.md
254
- const stackRows = stacks.map(s =>
255
- `| ${s.name}${s.path ? ` (\`${s.path}\`)` : ""} | ${s.languages.join(", ")} | ${s.frameworks.join(", ")} |`
256
- ).join("\n");
257
- const stackTable = `| Stack | Languages | Frameworks |\n|-------|-----------|------------|\n${stackRows}`;
258
-
259
- const replacements = {
260
- "{typecheck command}": typeCheckers.join(" && ") || "{typecheck command}",
261
- "{lint command}": linters.join(" && ") || "{lint command}",
262
- "{test command}": testRunners.join(" && ") || "{test command}",
263
- "{targeted test command}": testRunners[0] ? `${testRunners[0]} {path}` : "{targeted test command}",
264
- "{install command}": packageManagers.map(pm => `${pm} install`).join(" && ") || "{install command}",
265
- };
266
-
267
- let result = content;
268
-
269
- // Replace the entire Layer/Stack table section (handles \r\n line endings)
270
- result = result.replace(
271
- /\| Layer \| Stack \|\r?\n\|[-|]+\|\r?\n\| Backend \|[^\r\n]*\|\r?\n\| Frontend \|[^\r\n]*\|\r?\n\| Infra \|[^\r\n]*\|/,
272
- stackTable,
273
- );
274
-
275
- for (const [placeholder, value] of Object.entries(replacements)) {
276
- result = result.replaceAll(placeholder, value);
277
- }
278
- return result;
279
- }
280
-
281
- // ---------------------------------------------------------------------------
282
- // Platform file generation
283
- // ---------------------------------------------------------------------------
284
-
285
- function generatePlatformFiles(projectPath, platform, stacks) {
286
- const config = PLATFORM_CONFIGS[platform];
287
- const results = [];
288
-
289
- // Generate per-doc files with frontmatter + rewritten links
290
- for (const source of SOURCE_FILES) {
291
- const entry = config[source.id];
292
- if (!entry) continue;
293
-
294
- let content = applyStacks(readTemplate(source.path), stacks);
295
- content = rewriteSpecLinks(content, source.id, platform);
296
- const frontmatter = yamlFrontmatter(entry.fm);
297
- writeOutput(projectPath, entry.target, `${frontmatter}\n\n${content}`);
298
- results.push(` ${entry.target}`);
299
- }
300
-
301
- // Generate condensed entrypoint per platform
302
- const platformDir = Object.values(config)[0].target.split("/").slice(0, -1).join("/");
303
- const summary = readTemplate("protocol-summary.md")
304
- .replace(/`\.agent-loop\/`/, `\`${platformDir}/\``);
305
-
306
- if (platform === "copilot") {
307
- writeOutput(projectPath, ".github/copilot-instructions.md", summary);
308
- results.push(" .github/copilot-instructions.md");
309
- } else if (platform === "cursor") {
310
- const fm = yamlFrontmatter({ description: "SyncLoop protocol summary and guardrails", alwaysApply: true });
311
- writeOutput(projectPath, ".cursor/rules/00-protocol.md", `${fm}\n\n${summary}`);
312
- results.push(" .cursor/rules/00-protocol.md");
313
- } else if (platform === "claude") {
314
- writeOutput(projectPath, "CLAUDE.md", summary);
315
- results.push(" CLAUDE.md");
316
- }
317
-
318
- return results;
319
- }
320
-
321
- // ---------------------------------------------------------------------------
322
- // Public API
323
- // ---------------------------------------------------------------------------
324
-
325
- export function init(projectPath, target, stacks) {
326
- const results = [];
327
-
328
- // 1. Copy .agent-loop/ canonical source — only for "all" (multi-platform needs the shared source)
329
- if (target === "all") {
330
- const agentLoopSrc = join(TEMPLATE_DIR, ".agent-loop");
331
- const agentLoopDest = join(projectPath, ".agent-loop");
332
- cpSync(agentLoopSrc, agentLoopDest, { recursive: true });
333
-
334
- // Pre-fill stack placeholders in canonical source files
335
- if (stacks && stacks.length > 0) {
336
- for (const source of SOURCE_FILES) {
337
- const destFile = join(projectPath, source.path);
338
- try {
339
- const content = readFileSync(destFile, "utf-8");
340
- const updated = applyStacks(content, stacks);
341
- if (updated !== content) writeFileSync(destFile, updated, "utf-8");
342
- } catch { /* skip if file doesn't exist */ }
343
- }
344
- }
345
- results.push(".agent-loop/ (canonical source)");
346
- }
347
-
348
- // AGENTS.md — always generated as root entrypoint
349
- // For single-platform targets, rewrite .agent-loop/ links to platform paths
350
- const effectivePlatform = target === "all" ? "all" : target;
351
- let agentsMd = readTemplate("AGENTS.md");
352
- if (stacks?.length) agentsMd = applyStacks(agentsMd, stacks);
353
- agentsMd = rewriteAgentsLinks(agentsMd, effectivePlatform);
354
- writeOutput(projectPath, "AGENTS.md", agentsMd);
355
- results.push("AGENTS.md (cross-platform entrypoint)");
356
-
357
- // 2. Generate platform-specific files
358
- const targets = target === "all" ? ["copilot", "cursor", "claude"] : [target];
359
- for (const t of targets) {
360
- results.push(`\n[${t}]`);
361
- results.push(...generatePlatformFiles(projectPath, t, stacks));
362
- }
363
-
364
- return results;
365
- }
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ mkdirSync,
5
+ cpSync,
6
+ existsSync,
7
+ readdirSync,
8
+ } from "node:fs";
9
+ import { join, dirname, resolve, posix } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const TEMPLATE_DIR = join(__dirname, "template");
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function readTemplate(relativePath) {
20
+ return readFileSync(join(TEMPLATE_DIR, relativePath), "utf-8");
21
+ }
22
+
23
+ function toPosixPath(value) {
24
+ return value.replace(/\\/g, "/");
25
+ }
26
+
27
+ function pathDir(value) {
28
+ const dir = posix.dirname(value);
29
+ return dir === "." ? "" : dir;
30
+ }
31
+
32
+ function splitHash(link) {
33
+ const idx = link.indexOf("#");
34
+ if (idx === -1) return { pathPart: link, hash: "" };
35
+ return {
36
+ pathPart: link.slice(0, idx),
37
+ hash: link.slice(idx),
38
+ };
39
+ }
40
+
41
+ function isExternalLink(link) {
42
+ return /^([a-z]+:|#)/i.test(link);
43
+ }
44
+
45
+ function rewriteMarkdownLinks(content, transform) {
46
+ let inFence = false;
47
+ return content
48
+ .split("\n")
49
+ .map((line) => {
50
+ if (line.trimStart().startsWith("```")) {
51
+ inFence = !inFence;
52
+ return line;
53
+ }
54
+ if (inFence) return line;
55
+ return line.replace(/\]\(([^)]+)\)/g, (_match, linkPath) => `](${transform(linkPath)})`);
56
+ })
57
+ .join("\n");
58
+ }
59
+
60
+ function readJsonSafe(path) {
61
+ try {
62
+ return JSON.parse(readFileSync(path, "utf-8"));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function detectPackageManager(dirPath) {
69
+ if (existsSync(join(dirPath, "pnpm-lock.yaml"))) return "pnpm";
70
+ if (existsSync(join(dirPath, "yarn.lock"))) return "yarn";
71
+ if (existsSync(join(dirPath, "package-lock.json"))) return "npm";
72
+ if (existsSync(join(dirPath, "uv.lock"))) return "uv";
73
+ return "npm";
74
+ }
75
+
76
+ function runCommandPrefix(packageManager) {
77
+ if (packageManager === "npm") return "npm run";
78
+ if (packageManager === "pnpm") return "pnpm";
79
+ if (packageManager === "yarn") return "yarn";
80
+ return `${packageManager} run`;
81
+ }
82
+
83
+ function detectNodeFrameworks(pkg) {
84
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
85
+ const frameworks = [];
86
+ const known = [
87
+ ["next", "Next.js"],
88
+ ["react", "React"],
89
+ ["vite", "Vite"],
90
+ ["vue", "Vue"],
91
+ ["svelte", "Svelte"],
92
+ ["tailwindcss", "TailwindCSS"],
93
+ ["express", "Express"],
94
+ ["fastify", "Fastify"],
95
+ ["@nestjs/core", "NestJS"],
96
+ ["@modelcontextprotocol/sdk", "MCP SDK"],
97
+ ["@tanstack/react-query", "TanStack Query"],
98
+ ["zustand", "Zustand"],
99
+ ];
100
+ for (const [dep, name] of known) {
101
+ if (deps[dep]) frameworks.push(name);
102
+ }
103
+ return frameworks;
104
+ }
105
+
106
+ function detectNodeStack(projectPath, relativePath = "") {
107
+ const stackRoot = join(projectPath, relativePath);
108
+ const pkgPath = join(stackRoot, "package.json");
109
+ if (!existsSync(pkgPath)) return null;
110
+
111
+ const pkg = readJsonSafe(pkgPath) ?? {};
112
+ const scripts = pkg.scripts ?? {};
113
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
114
+ const packageManager = detectPackageManager(stackRoot);
115
+ const runPrefix = runCommandPrefix(packageManager);
116
+ const frameworks = detectNodeFrameworks(pkg);
117
+ const usesTypeScript = Boolean(deps.typescript || existsSync(join(stackRoot, "tsconfig.json")));
118
+ const languages = usesTypeScript ? ["TypeScript", "JavaScript"] : ["JavaScript"];
119
+
120
+ const stackName = relativePath ? posix.basename(toPosixPath(relativePath)) : "app";
121
+
122
+ return {
123
+ name: stackName,
124
+ languages,
125
+ frameworks: frameworks.length > 0 ? frameworks : ["Node.js"],
126
+ testRunner: scripts.test ? `${runPrefix} test` : undefined,
127
+ typeChecker: scripts.typecheck ? `${runPrefix} typecheck` : (usesTypeScript ? "tsc --noEmit" : undefined),
128
+ linter: scripts.lint ? `${runPrefix} lint` : undefined,
129
+ packageManager,
130
+ path: relativePath || undefined,
131
+ };
132
+ }
133
+
134
+ function detectPythonStack(projectPath, relativePath = "") {
135
+ const stackRoot = join(projectPath, relativePath);
136
+ const pyprojectPath = join(stackRoot, "pyproject.toml");
137
+ const requirementsPath = join(stackRoot, "requirements.txt");
138
+ if (!existsSync(pyprojectPath) && !existsSync(requirementsPath)) return null;
139
+
140
+ const pyproject = existsSync(pyprojectPath) ? readFileSync(pyprojectPath, "utf-8").toLowerCase() : "";
141
+ const reqs = existsSync(requirementsPath) ? readFileSync(requirementsPath, "utf-8").toLowerCase() : "";
142
+ const merged = `${pyproject}\n${reqs}`;
143
+
144
+ const frameworks = [];
145
+ if (merged.includes("fastapi")) frameworks.push("FastAPI");
146
+ if (merged.includes("django")) frameworks.push("Django");
147
+ if (merged.includes("flask")) frameworks.push("Flask");
148
+ if (merged.includes("langgraph")) frameworks.push("LangGraph");
149
+ if (merged.includes("pydantic")) frameworks.push("Pydantic");
150
+
151
+ const stackName = relativePath ? posix.basename(toPosixPath(relativePath)) : "app";
152
+ const hasPyright = merged.includes("pyright");
153
+ const hasMypy = merged.includes("mypy");
154
+ const hasRuff = merged.includes("ruff");
155
+ const hasPytest = merged.includes("pytest");
156
+
157
+ return {
158
+ name: stackName,
159
+ languages: ["Python"],
160
+ frameworks: frameworks.length > 0 ? frameworks : ["Python"],
161
+ testRunner: hasPytest ? "pytest" : undefined,
162
+ typeChecker: hasPyright ? "pyright" : (hasMypy ? "mypy" : undefined),
163
+ linter: hasRuff ? "ruff check ." : undefined,
164
+ path: relativePath || undefined,
165
+ };
166
+ }
167
+
168
+ export function detectStacks(projectPath) {
169
+ const root = resolve(projectPath);
170
+ const candidates = [""];
171
+
172
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
173
+ if (!entry.isDirectory()) continue;
174
+ if (entry.name.startsWith(".")) continue;
175
+ if (entry.name === "node_modules") continue;
176
+ candidates.push(entry.name);
177
+ }
178
+
179
+ const stacks = [];
180
+ for (const rel of candidates) {
181
+ const nodeStack = detectNodeStack(root, rel);
182
+ if (nodeStack) stacks.push(nodeStack);
183
+ const pythonStack = detectPythonStack(root, rel);
184
+ if (pythonStack) stacks.push(pythonStack);
185
+ }
186
+
187
+ const deduped = [];
188
+ const seen = new Set();
189
+ for (const stack of stacks) {
190
+ const key = `${stack.name}:${stack.path ?? ""}:${stack.languages.join(",")}`;
191
+ if (seen.has(key)) continue;
192
+ seen.add(key);
193
+ deduped.push(stack);
194
+ }
195
+
196
+ if (deduped.length === 0) {
197
+ return [{
198
+ name: "app",
199
+ languages: ["Unknown"],
200
+ frameworks: ["Unknown"],
201
+ }];
202
+ }
203
+
204
+ return deduped;
205
+ }
206
+
207
+ function writeOutput(projectPath, relativePath, content, options = {}) {
208
+ const { dryRun = false, overwrite = true } = options;
209
+ const fullPath = join(projectPath, relativePath);
210
+ const alreadyExists = existsSync(fullPath);
211
+
212
+ if (alreadyExists && !overwrite) {
213
+ return { path: relativePath, status: "skipped" };
214
+ }
215
+
216
+ if (!dryRun) {
217
+ mkdirSync(dirname(fullPath), { recursive: true });
218
+ writeFileSync(fullPath, content, "utf-8");
219
+ }
220
+
221
+ if (dryRun) {
222
+ return { path: relativePath, status: alreadyExists ? "would-overwrite" : "would-create" };
223
+ }
224
+ return { path: relativePath, status: alreadyExists ? "overwritten" : "created" };
225
+ }
226
+
227
+ function formatWriteResult(result) {
228
+ if (result.status === "skipped") return `${result.path} (skipped: exists)`;
229
+ if (result.status.startsWith("would-")) return `${result.path} (dry-run)`;
230
+ return result.path;
231
+ }
232
+
233
+ function yamlFrontmatter(fields) {
234
+ const lines = ["---"];
235
+ for (const [key, value] of Object.entries(fields)) {
236
+ if (Array.isArray(value)) {
237
+ lines.push(`${key}:`);
238
+ for (const item of value) {
239
+ lines.push(` - "${item}"`);
240
+ }
241
+ } else if (typeof value === "boolean") {
242
+ lines.push(`${key}: ${value}`);
243
+ } else {
244
+ lines.push(`${key}: "${value}"`);
245
+ }
246
+ }
247
+ lines.push("---");
248
+ return lines.join("\n");
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Source file list (maps to src/template/.agent-loop/)
253
+ // ---------------------------------------------------------------------------
254
+
255
+ const SOURCE_FILES = [
256
+ { id: "reasoning-kernel", path: ".agent-loop/reasoning-kernel.md" },
257
+ { id: "feedback", path: ".agent-loop/feedback.md" },
258
+ { id: "validate-env", path: ".agent-loop/validate-env.md" },
259
+ { id: "validate-n", path: ".agent-loop/validate-n.md" },
260
+ { id: "patterns", path: ".agent-loop/patterns.md" },
261
+ { id: "glossary", path: ".agent-loop/glossary.md" },
262
+ { id: "code-patterns", path: ".agent-loop/patterns/code-patterns.md" },
263
+ { id: "testing-guide", path: ".agent-loop/patterns/testing-guide.md" },
264
+ { id: "refactoring-workflow", path: ".agent-loop/patterns/refactoring-workflow.md" },
265
+ { id: "api-standards", path: ".agent-loop/patterns/api-standards.md" },
266
+ ];
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Platform configs
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const COPILOT = {
273
+ "reasoning-kernel": { target: ".github/instructions/reasoning-kernel.instructions.md", fm: { name: "SyncLoop: Reasoning Kernel", description: "7-stage agent reasoning loop with context clearage", applyTo: "**/*" } },
274
+ "feedback": { target: ".github/instructions/feedback.instructions.md", fm: { name: "SyncLoop: Feedback Loop", description: "Failure diagnosis, patch protocol, branch pruning", applyTo: "**/*" } },
275
+ "validate-env": { target: ".github/instructions/validate-env.instructions.md", fm: { name: "SyncLoop: Validate Environment", description: "NFR gates: types, tests, layers, complexity", applyTo: "**/*" } },
276
+ "validate-n": { target: ".github/instructions/validate-n.instructions.md", fm: { name: "SyncLoop: Validate Neighbors", description: "Shape, boundary, bridge checks", applyTo: "**/*" } },
277
+ "patterns": { target: ".github/instructions/patterns.instructions.md", fm: { name: "SyncLoop: Pattern Registry", description: "Pattern routing and learned patterns", applyTo: "**/*" } },
278
+ "glossary": { target: ".github/instructions/glossary.instructions.md", fm: { name: "SyncLoop: Glossary", description: "Canonical terminology", applyTo: "**/*" } },
279
+ "code-patterns": { target: ".github/instructions/code-patterns.instructions.md", fm: { name: "SyncLoop: Code Patterns", description: "P1-P11 implementation patterns", applyTo: "{src,app,lib}/**/*.{ts,py,js,jsx,tsx}" } },
280
+ "testing-guide": { target: ".github/instructions/testing-guide.instructions.md", fm: { name: "SyncLoop: Testing Guide", description: "Test patterns and strategies", applyTo: "{tests,test,__tests__}/**/*" } },
281
+ "refactoring-workflow": { target: ".github/instructions/refactoring-workflow.instructions.md", fm: { name: "SyncLoop: Refactoring Workflow", description: "4-phase refactoring checklist", applyTo: "**/*" } },
282
+ "api-standards": { target: ".github/instructions/api-standards.instructions.md", fm: { name: "SyncLoop: API Standards", description: "Boundary contracts and API conventions", applyTo: "{routes,routers,controllers,api}/**/*" } },
283
+ };
284
+
285
+ const CURSOR = {
286
+ "reasoning-kernel": { target: ".cursor/rules/01-reasoning-kernel.md", fm: { description: "7-stage agent reasoning loop with context clearage and transitions", alwaysApply: true } },
287
+ "feedback": { target: ".cursor/rules/02-feedback.md", fm: { description: "Failure diagnosis, patch protocol, micro-loop, branch pruning", alwaysApply: true } },
288
+ "validate-env": { target: ".cursor/rules/03-validate-env.md", fm: { description: "Stage 1 NFR gates: types, tests, layers, complexity, debug hygiene", alwaysApply: true } },
289
+ "validate-n": { target: ".cursor/rules/04-validate-n.md", fm: { description: "Stage 2 checks: shapes, boundaries, bridges", alwaysApply: true } },
290
+ "patterns": { target: ".cursor/rules/05-patterns.md", fm: { description: "Pattern routing index and learned patterns", alwaysApply: true } },
291
+ "glossary": { target: ".cursor/rules/06-glossary.md", fm: { description: "Canonical domain terminology and naming rules", alwaysApply: true } },
292
+ "code-patterns": { target: ".cursor/rules/07-code-patterns.md", fm: { description: "P1-P11 implementation patterns for layered code", globs: "{src,app,lib}/**/*.{ts,py,js,jsx,tsx}" } },
293
+ "testing-guide": { target: ".cursor/rules/08-testing-guide.md", fm: { description: "Test patterns, fixtures, mocks, strategies", globs: "{tests,test,__tests__}/**/*" } },
294
+ "refactoring-workflow": { target: ".cursor/rules/09-refactoring-workflow.md", fm: { description: "4-phase refactoring checklist for safe restructuring", alwaysApply: false } },
295
+ "api-standards": { target: ".cursor/rules/10-api-standards.md", fm: { description: "Boundary contracts, typed models, error envelopes", globs: "{routes,routers,controllers,api}/**/*" } },
296
+ };
297
+
298
+ const CLAUDE = {
299
+ "reasoning-kernel": { target: ".claude/rules/reasoning-kernel.md", fm: { paths: ["**/*"] } },
300
+ "feedback": { target: ".claude/rules/feedback.md", fm: { paths: ["**/*"] } },
301
+ "validate-env": { target: ".claude/rules/validate-env.md", fm: { paths: ["**/*"] } },
302
+ "validate-n": { target: ".claude/rules/validate-n.md", fm: { paths: ["**/*"] } },
303
+ "patterns": { target: ".claude/rules/patterns.md", fm: { paths: ["**/*"] } },
304
+ "glossary": { target: ".claude/rules/glossary.md", fm: { paths: ["**/*"] } },
305
+ "code-patterns": { target: ".claude/rules/code-patterns.md", fm: { paths: ["src/**", "app/**", "lib/**"] } },
306
+ "testing-guide": { target: ".claude/rules/testing-guide.md", fm: { paths: ["tests/**", "test/**", "__tests__/**"] } },
307
+ "refactoring-workflow": { target: ".claude/rules/refactoring-workflow.md", fm: { paths: ["**/*"] } },
308
+ "api-standards": { target: ".claude/rules/api-standards.md", fm: { paths: ["**/routes/**", "**/api/**", "**/controllers/**"] } },
309
+ };
310
+
311
+ const PLATFORM_CONFIGS = { copilot: COPILOT, cursor: CURSOR, claude: CLAUDE };
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Link rewriting
315
+ // ---------------------------------------------------------------------------
316
+
317
+ const CANONICAL_TO_ID = {};
318
+ for (const source of SOURCE_FILES) {
319
+ CANONICAL_TO_ID[source.path.replace(".agent-loop/", "")] = source.id;
320
+ }
321
+
322
+ function buildTargetMap(platform) {
323
+ const config = PLATFORM_CONFIGS[platform];
324
+ const map = {};
325
+ for (const [id, entry] of Object.entries(config)) {
326
+ map[id] = toPosixPath(entry.target);
327
+ }
328
+ return map;
329
+ }
330
+
331
+ function resolveCanonicalLink(linkPath, sourceId) {
332
+ const source = SOURCE_FILES.find((item) => item.id === sourceId);
333
+ if (!source) return linkPath;
334
+
335
+ const sourceRelPath = source.path.replace(".agent-loop/", "");
336
+ const sourceDir = pathDir(sourceRelPath);
337
+ const normalized = posix.normalize(posix.join(sourceDir || ".", linkPath));
338
+ return normalized.replace(/^\.\//, "");
339
+ }
340
+
341
+ function rewriteSpecLinks(content, sourceId, platform) {
342
+ const targetMap = buildTargetMap(platform);
343
+ const currentTargetPath = targetMap[sourceId];
344
+ const currentDir = pathDir(currentTargetPath);
345
+
346
+ return rewriteMarkdownLinks(content, (linkPath) => {
347
+ if (isExternalLink(linkPath)) return linkPath;
348
+
349
+ const { pathPart, hash } = splitHash(linkPath);
350
+ if (!pathPart) return linkPath;
351
+
352
+ const canonical = resolveCanonicalLink(pathPart, sourceId);
353
+
354
+ if (
355
+ pathPart === "../AGENTS.md" ||
356
+ canonical === "../AGENTS.md" ||
357
+ canonical === "AGENTS.md"
358
+ ) {
359
+ const rel = posix.relative(currentDir || ".", "AGENTS.md") || "AGENTS.md";
360
+ return `${rel}${hash}`;
361
+ }
362
+
363
+ if (
364
+ pathPart === "../README.md" ||
365
+ canonical === "../README.md"
366
+ ) {
367
+ const rel = posix.relative(currentDir || ".", "README.md") || "README.md";
368
+ return `${rel}${hash}`;
369
+ }
370
+
371
+ if (pathPart === "patterns/" || pathPart === "patterns") {
372
+ const targetPath = targetMap.patterns;
373
+ if (!targetPath) return linkPath;
374
+ const rel = posix.relative(currentDir || ".", targetPath) || posix.basename(targetPath);
375
+ return `${rel}${hash}`;
376
+ }
377
+
378
+ const docId = CANONICAL_TO_ID[canonical];
379
+ if (!docId) return linkPath;
380
+
381
+ const targetPath = targetMap[docId];
382
+ if (!targetPath) return linkPath;
383
+
384
+ const rel = posix.relative(currentDir || ".", targetPath) || posix.basename(targetPath);
385
+ return `${rel}${hash}`;
386
+ });
387
+ }
388
+
389
+ function rewriteAgentsLinks(content, platform) {
390
+ if (platform === "all") return content;
391
+
392
+ const config = PLATFORM_CONFIGS[platform];
393
+ const pathMap = {};
394
+ for (const source of SOURCE_FILES) {
395
+ const entry = config[source.id];
396
+ if (entry) pathMap[source.path] = entry.target;
397
+ }
398
+
399
+ const platformDir = Object.values(config)[0].target.split("/").slice(0, -1).join("/");
400
+ let result = content;
401
+
402
+ result = result.replace(/\]\(\.agent-loop\/([^)]+)\)/g, (_match, relPath) => {
403
+ const fullPath = `.agent-loop/${relPath}`;
404
+ if (pathMap[fullPath]) return `](${pathMap[fullPath]})`;
405
+ if (relPath === "patterns/" || relPath === "patterns") return `](${platformDir}/)`;
406
+ return `](${fullPath})`;
407
+ });
408
+
409
+ result = result.replace(/`\.agent-loop\/([^`]+)`/g, (_match, relPath) => {
410
+ const fullPath = `.agent-loop/${relPath}`;
411
+ if (pathMap[fullPath]) return `\`${pathMap[fullPath]}\``;
412
+ if (relPath.startsWith("patterns/") || relPath === "patterns") return `\`${platformDir}/\``;
413
+ return `\`${fullPath}\``;
414
+ });
415
+
416
+ result = result.replace(
417
+ /Routes into `\.agent-loop\/`/,
418
+ `Routes into \`${platformDir}/\``,
419
+ );
420
+ return result;
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Environment placeholder replacement
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function applyStacks(content, stacks) {
428
+ if (!stacks || stacks.length === 0) return content;
429
+
430
+ const testRunners = stacks.map((stack) => stack.testRunner).filter(Boolean);
431
+ const typeCheckers = stacks.map((stack) => stack.typeChecker).filter(Boolean);
432
+ const linters = stacks.map((stack) => stack.linter).filter(Boolean);
433
+ const packageManagers = [...new Set(stacks.map((stack) => stack.packageManager).filter(Boolean))];
434
+
435
+ const stackRows = stacks
436
+ .map((stack) => `| ${stack.name}${stack.path ? ` (\`${stack.path}\`)` : ""} | ${stack.languages.join(", ")} | ${stack.frameworks.join(", ")} |`)
437
+ .join("\n");
438
+ const stackTable = `| Stack | Languages | Frameworks |\n|-------|-----------|------------|\n${stackRows}`;
439
+
440
+ const replacements = {
441
+ "{typecheck command}": typeCheckers.join(" && ") || "{typecheck command}",
442
+ "{lint command}": linters.join(" && ") || "{lint command}",
443
+ "{test command}": testRunners.join(" && ") || "{test command}",
444
+ "{targeted test command}": testRunners[0] ? `${testRunners[0]} {path}` : "{targeted test command}",
445
+ "{install command}": packageManagers.map((pm) => `${pm} install`).join(" && ") || "{install command}",
446
+ };
447
+
448
+ let result = content;
449
+
450
+ const legacyTableRegex = /\| Layer \| Stack \|\r?\n\|[-|]+\|\r?\n\| Backend \|[^\r\n]*\|\r?\n\| Frontend \|[^\r\n]*\|\r?\n\| Infra \|[^\r\n]*\|/;
451
+ const generatedTableRegex = /\| Stack \| Languages \| Frameworks \|\r?\n\|[-|]+\|(?:\r?\n\|[^\r\n]*\|)+/;
452
+ result = result.replace(legacyTableRegex, stackTable);
453
+ result = result.replace(generatedTableRegex, stackTable);
454
+
455
+ for (const [placeholder, value] of Object.entries(replacements)) {
456
+ result = result.replaceAll(placeholder, value);
457
+ }
458
+
459
+ return result;
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Platform file generation
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function generatePlatformFiles(projectPath, platform, stacks, options) {
467
+ const config = PLATFORM_CONFIGS[platform];
468
+ const results = [];
469
+
470
+ for (const source of SOURCE_FILES) {
471
+ const entry = config[source.id];
472
+ if (!entry) continue;
473
+
474
+ let content = applyStacks(readTemplate(source.path), stacks);
475
+ content = rewriteSpecLinks(content, source.id, platform);
476
+ const frontmatter = yamlFrontmatter(entry.fm);
477
+ const writeResult = writeOutput(projectPath, entry.target, `${frontmatter}\n\n${content}`, options);
478
+ results.push(` ${formatWriteResult(writeResult)}`);
479
+ }
480
+
481
+ const platformDir = Object.values(config)[0].target.split("/").slice(0, -1).join("/");
482
+ const summary = readTemplate("protocol-summary.md")
483
+ .replace(/`\.agent-loop\/`/, `\`${platformDir}/\``);
484
+
485
+ if (platform === "copilot") {
486
+ const writeResult = writeOutput(projectPath, ".github/copilot-instructions.md", summary, options);
487
+ results.push(` ${formatWriteResult(writeResult)}`);
488
+ } else if (platform === "cursor") {
489
+ const frontmatter = yamlFrontmatter({ description: "SyncLoop protocol summary and guardrails", alwaysApply: true });
490
+ const writeResult = writeOutput(projectPath, ".cursor/rules/00-protocol.md", `${frontmatter}\n\n${summary}`, options);
491
+ results.push(` ${formatWriteResult(writeResult)}`);
492
+ } else if (platform === "claude") {
493
+ const writeResult = writeOutput(projectPath, "CLAUDE.md", summary, options);
494
+ results.push(` ${formatWriteResult(writeResult)}`);
495
+ }
496
+
497
+ return results;
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Public API
502
+ // ---------------------------------------------------------------------------
503
+
504
+ export function init(projectPath, target = "all", stacks = [], options = {}) {
505
+ const root = resolve(projectPath);
506
+ const effectiveStacks = stacks?.length ? stacks : detectStacks(root);
507
+ const mergedOptions = {
508
+ dryRun: Boolean(options.dryRun),
509
+ overwrite: options.overwrite ?? true,
510
+ };
511
+
512
+ const validTargets = ["copilot", "cursor", "claude", "all"];
513
+ if (!validTargets.includes(target)) {
514
+ throw new Error(`Unknown target "${target}". Use one of: ${validTargets.join(", ")}`);
515
+ }
516
+
517
+ const results = [];
518
+
519
+ if (target === "all") {
520
+ const src = join(TEMPLATE_DIR, ".agent-loop");
521
+ const dest = join(root, ".agent-loop");
522
+
523
+ if (!mergedOptions.dryRun) {
524
+ cpSync(src, dest, {
525
+ recursive: true,
526
+ force: mergedOptions.overwrite,
527
+ errorOnExist: false,
528
+ });
529
+
530
+ if (effectiveStacks.length > 0) {
531
+ for (const source of SOURCE_FILES) {
532
+ const destFile = join(root, source.path);
533
+ if (!mergedOptions.overwrite && existsSync(destFile)) continue;
534
+ try {
535
+ const current = readFileSync(destFile, "utf-8");
536
+ const updated = applyStacks(current, effectiveStacks);
537
+ if (updated !== current) writeFileSync(destFile, updated, "utf-8");
538
+ } catch {
539
+ // Ignore files that are missing after copy.
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ results.push(`.agent-loop/ (canonical source${mergedOptions.dryRun ? ", dry-run" : ""})`);
546
+ }
547
+
548
+ const agentsContent = rewriteAgentsLinks(
549
+ applyStacks(readTemplate("AGENTS.md"), effectiveStacks),
550
+ target === "all" ? "all" : target,
551
+ );
552
+ const agentsResult = writeOutput(root, "AGENTS.md", agentsContent, mergedOptions);
553
+ results.push(`AGENTS.md (cross-platform entrypoint: ${formatWriteResult(agentsResult)})`);
554
+
555
+ const targets = target === "all" ? ["copilot", "cursor", "claude"] : [target];
556
+ for (const currentTarget of targets) {
557
+ results.push(`\n[${currentTarget}]`);
558
+ results.push(...generatePlatformFiles(root, currentTarget, effectiveStacks, mergedOptions));
559
+ }
560
+
561
+ return {
562
+ projectPath: root,
563
+ target,
564
+ dryRun: mergedOptions.dryRun,
565
+ overwrite: mergedOptions.overwrite,
566
+ stacks: effectiveStacks,
567
+ results,
568
+ };
569
+ }