@stonepandastudio/cairn 0.4.2 → 0.6.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 (72) hide show
  1. package/README.md +60 -23
  2. package/bin/cairn.js +13 -5
  3. package/lib/doctor/index.js +6 -1
  4. package/lib/init.js +15 -1
  5. package/lib/manifest.js +33 -0
  6. package/lib/render/cli.js +116 -0
  7. package/lib/render/engine.js +148 -0
  8. package/lib/render/index.js +220 -0
  9. package/lib/sync/cli.js +197 -0
  10. package/lib/sync/index.js +249 -0
  11. package/package.json +2 -1
  12. package/presets/EXTRACTION.md +210 -0
  13. package/presets/README.md +86 -0
  14. package/presets/angular/code-guidelines.md +197 -0
  15. package/presets/angular/slots/architect-discussion-topics.md +10 -0
  16. package/presets/angular/slots/architect-mandatory-docs.md +5 -0
  17. package/presets/angular/slots/architect-references.md +4 -0
  18. package/presets/angular/slots/implementation-reference.md +34 -0
  19. package/presets/angular/slots/key-patterns.md +11 -0
  20. package/presets/angular/slots/plan-step-ordering.md +11 -0
  21. package/presets/angular/slots/review-checklist.md +16 -0
  22. package/presets/angular/variants/i18n-external-service.md +11 -0
  23. package/presets/angular/variants/i18n-glossr.md +63 -0
  24. package/presets/core/AGENTS.md +49 -0
  25. package/presets/core/README.md +35 -0
  26. package/presets/core/WORKFLOW.md +56 -0
  27. package/presets/core/agents/architect.md +269 -0
  28. package/presets/core/agents/developer.md +145 -0
  29. package/presets/core/agents/reviewer.md +167 -0
  30. package/presets/core/commands/_stub.md +7 -0
  31. package/presets/core/workflow.json +45 -0
  32. package/presets/drizzle/code-guidelines.md +33 -0
  33. package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
  34. package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
  35. package/presets/drizzle/slots/implementation-reference.md +17 -0
  36. package/presets/drizzle/slots/key-patterns.md +7 -0
  37. package/presets/drizzle/slots/review-checklist.md +10 -0
  38. package/presets/nestjs/code-guidelines.md +273 -0
  39. package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
  40. package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
  41. package/presets/nestjs/slots/architect-references.md +5 -0
  42. package/presets/nestjs/slots/implementation-reference.md +45 -0
  43. package/presets/nestjs/slots/key-patterns.md +11 -0
  44. package/presets/nestjs/slots/plan-step-ordering.md +12 -0
  45. package/presets/nestjs/slots/review-checklist.md +12 -0
  46. package/presets/nestjs/variants/validation-class-validator.md +120 -0
  47. package/presets/nestjs/variants/validation-zod.md +194 -0
  48. package/presets/nextjs/code-guidelines.md +45 -0
  49. package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
  50. package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
  51. package/presets/nextjs/slots/architect-references.md +6 -0
  52. package/presets/nextjs/slots/implementation-reference.md +24 -0
  53. package/presets/nextjs/slots/key-patterns.md +8 -0
  54. package/presets/nextjs/slots/plan-step-ordering.md +11 -0
  55. package/presets/nextjs/slots/review-checklist.md +11 -0
  56. package/presets/react/code-guidelines.md +46 -0
  57. package/presets/react/slots/architect-discussion-topics.md +5 -0
  58. package/presets/react/slots/architect-references.md +5 -0
  59. package/presets/react/slots/implementation-reference.md +26 -0
  60. package/presets/react/slots/key-patterns.md +8 -0
  61. package/presets/react/slots/plan-step-ordering.md +9 -0
  62. package/presets/react/slots/review-checklist.md +10 -0
  63. package/presets/tailwind/code-guidelines.md +28 -0
  64. package/presets/tailwind/slots/implementation-reference.md +8 -0
  65. package/presets/tailwind/slots/key-patterns.md +5 -0
  66. package/presets/tailwind/slots/review-checklist.md +8 -0
  67. package/presets/typeorm/code-guidelines.md +329 -0
  68. package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
  69. package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
  70. package/presets/typeorm/slots/implementation-reference.md +19 -0
  71. package/presets/typeorm/slots/key-patterns.md +8 -0
  72. package/presets/typeorm/slots/review-checklist.md +8 -0
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ // cairn render — turn the bundled `presets/` into a repo's `ai/` scaffolding.
4
+ //
5
+ // Greenfield only: it writes files that do not exist, records them in the
6
+ // manifest, and leaves anything already on disk alone (reporting it). The
7
+ // three-way merge that would let it re-render over local edits is `cairn sync`,
8
+ // which is not built yet — for a fresh repo there is nothing to merge.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const { render, stripHeaderComment } = require('./engine');
14
+ const { hashContent } = require('../manifest');
15
+
16
+ const PRIMARY_ONLY_SLOTS = new Set(['plan-step-ordering', 'architect-references']);
17
+
18
+ const DEFAULT_WORKFLOW = {
19
+ steps: [
20
+ { id: 'brief', fn: 'create_brief', command: 'create-brief', role: 'architect', oneLine: 'creates the task brief scaffold' },
21
+ { id: 'describe', fn: 'enrich_description', command: 'enrich-description', role: 'architect', oneLine: 'iterative discussion, then writes the enriched description' },
22
+ { id: 'plan', fn: 'plan_task', command: 'plan-task', role: 'architect', oneLine: 'produces the implementation plan and context-doc updates' },
23
+ { id: 'execute', fn: 'execute_plan', command: 'execute-plan', role: 'developer', oneLine: 'implements the plan' },
24
+ { id: 'review', fn: 'code_review', command: 'code-review', role: 'reviewer', oneLine: 'checks the result against the plan and the guidelines' },
25
+ ],
26
+ };
27
+
28
+ const ROLE_MODEL = { architect: 'Sonnet', developer: 'Haiku', reviewer: 'Sonnet' };
29
+
30
+ function cap(s) {
31
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
32
+ }
33
+
34
+ function trackerContext(t = {}) {
35
+ const provider = t.provider || 'none';
36
+ const remote = provider === 'jira-server' || provider === 'youtrack';
37
+ return {
38
+ provider,
39
+ remote,
40
+ providerLabel: { 'jira-server': 'Jira', youtrack: 'YouTrack', none: 'no tracker' }[provider] || provider,
41
+ exampleKey: t.projectKey || (remote ? 'PROJ' : 'TASK'),
42
+ slug: provider === 'jira-server' ? 'jira' : provider === 'youtrack' ? 'youtrack' : 'tracker',
43
+ subtaskMarker: provider === 'youtrack' ? 'youtrack-subtask' : 'jira-subtask',
44
+ reviewStage: (t.statuses && t.statuses.done) || 'Review',
45
+ parentTerm: remote ? 'parent issue' : 'task',
46
+ childTerm: remote ? 'child issue' : 'step',
47
+ };
48
+ }
49
+
50
+ function loadWorkflow(config, presetsDir) {
51
+ if (config.workflow && Array.isArray(config.workflow.steps)) return config.workflow;
52
+ const file = path.join(presetsDir, 'core', 'workflow.json');
53
+ if (fs.existsSync(file)) {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ } catch {
57
+ /* fall through to the built-in default */
58
+ }
59
+ }
60
+ return DEFAULT_WORKFLOW;
61
+ }
62
+
63
+ function buildContext(config, presetsDir) {
64
+ const stack = config.stack && config.stack.length ? config.stack : ['unknown'];
65
+ const tracker = trackerContext(config.tracker);
66
+ const workflow = loadWorkflow(config, presetsDir);
67
+
68
+ const agents = config.agents || {};
69
+ const roleName = (role) =>
70
+ (agents[role] && agents[role].name) || `${cap(stack[0])}${cap(role)}Agent`;
71
+ const roleDoc = (role) => (agents[role] && agents[role].doc) || `ai/agents/${role}.md`;
72
+
73
+ workflow.steps.forEach((s) => {
74
+ s.agentName = roleName(s.role);
75
+ s.agentDoc = roleDoc(s.role);
76
+ s.model = ROLE_MODEL[s.role] || null;
77
+ s.trackerAction = tracker.remote ? s.trackerAction || 'sync — see the rows below' : 'n/a';
78
+ s.tracker = tracker.remote && s.role === 'architect' ? true : null;
79
+ });
80
+
81
+ return {
82
+ stack,
83
+ tracker,
84
+ workflow,
85
+ model: config.model || 'claude-sonnet-5',
86
+ architectAgent: roleName('architect'),
87
+ developerAgent: roleName('developer'),
88
+ reviewerAgent: roleName('reviewer'),
89
+ project: {
90
+ assistantName:
91
+ (config.vars && config.vars.assistantName) || `${cap(stack[0])} Dev Assistant`,
92
+ },
93
+ agents: ['architect', 'developer', 'reviewer'].map((r) => ({ name: roleName(r), doc: roleDoc(r) })),
94
+ ...(config.vars || {}),
95
+ };
96
+ }
97
+
98
+ function makeResolvePartial(stack, presetsDir) {
99
+ return (name) => {
100
+ const m = name.match(/^stack\/(.+)$/);
101
+ if (!m) return null;
102
+ const slot = m[1];
103
+ const entries = PRIMARY_ONLY_SLOTS.has(slot) ? stack.slice(0, 1) : stack;
104
+ const parts = [];
105
+ for (const entry of entries) {
106
+ const file = path.join(presetsDir, entry, 'slots', `${slot}.md`);
107
+ if (fs.existsSync(file)) parts.push(stripHeaderComment(fs.readFileSync(file, 'utf8')).trim());
108
+ }
109
+ if (!parts.length) return null;
110
+ // Table-row slots (every part starts with `|`) concatenate line-to-line;
111
+ // prose slots need a blank line between them.
112
+ const sep = parts.every((p) => p.startsWith('|')) ? '\n' : '\n\n';
113
+ return parts.join(sep);
114
+ };
115
+ }
116
+
117
+ function readPreset(presetsDir, rel) {
118
+ const file = path.join(presetsDir, rel);
119
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
120
+ }
121
+
122
+ // Concatenate `<entry>/code-guidelines.md` for every stack entry that ships one.
123
+ function codeGuidelines(stack, presetsDir, ctx, resolvePartial) {
124
+ const blocks = [];
125
+ for (const entry of stack) {
126
+ const raw = readPreset(presetsDir, `${entry}/code-guidelines.md`);
127
+ if (raw) blocks.push(render(raw, ctx, resolvePartial).trim());
128
+ }
129
+ return blocks.length ? blocks.join('\n\n---\n\n') + '\n' : null;
130
+ }
131
+
132
+ // Every file the presets would produce for this config, as
133
+ // [[rel, content, source, note?]] — before any greenfield or manifest guard.
134
+ // `planRender` filters this for `cairn render`; `cairn sync` diffs each entry
135
+ // against the repo's manifest + `_cairn/base/`.
136
+ function renderTargets({ config, presetsDir }) {
137
+ const ctx = buildContext(config, presetsDir);
138
+ const resolvePartial = makeResolvePartial(ctx.stack, presetsDir);
139
+
140
+ const targets = [];
141
+
142
+ const stub = readPreset(presetsDir, 'core/commands/_stub.md');
143
+ if (stub) {
144
+ for (const step of ctx.workflow.steps) {
145
+ targets.push([
146
+ `.claude/commands/${step.command}.md`,
147
+ render(stub, { ...ctx, step }, resolvePartial),
148
+ 'core/commands/_stub.md',
149
+ ]);
150
+ }
151
+ }
152
+
153
+ const stepFor = (role) => ctx.workflow.steps.find((s) => s.role === role) || {};
154
+ for (const role of ['architect', 'developer', 'reviewer']) {
155
+ const raw = readPreset(presetsDir, `core/agents/${role}.md`);
156
+ if (raw) {
157
+ targets.push([
158
+ `ai/agents/${role}.md`,
159
+ render(raw, { ...ctx, step: stepFor(role) }, resolvePartial),
160
+ `core/agents/${role}.md`,
161
+ ]);
162
+ }
163
+ }
164
+
165
+ for (const [rel, src] of [
166
+ ['ai/AGENTS.md', 'core/AGENTS.md'],
167
+ ['ai/WORKFLOW.md', 'core/WORKFLOW.md'],
168
+ ]) {
169
+ const raw = readPreset(presetsDir, src);
170
+ if (raw) targets.push([rel, render(raw, ctx, resolvePartial), src]);
171
+ }
172
+
173
+ const guidelines = codeGuidelines(ctx.stack, presetsDir, ctx, resolvePartial);
174
+ if (guidelines) {
175
+ targets.push([
176
+ 'ai/infrastructure/code-guidelines.md',
177
+ guidelines,
178
+ 'presets:<stack>/code-guidelines.md',
179
+ 'seed — fork it; `cairn sync` will three-way merge later changes',
180
+ ]);
181
+ }
182
+
183
+ return { targets, context: ctx };
184
+ }
185
+
186
+ // Returns { planned: [[rel, content, source, note?]], kept: [{ rel, reason }] }.
187
+ // `manifest` + `repoPath` drive the greenfield guard: a file cairn has not
188
+ // written is never touched, and one it wrote but a human has since edited is
189
+ // kept and reported (the re-render that respects local edits is `cairn sync`).
190
+ function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = null }) {
191
+ const { targets, context: ctx } = renderTargets({ config, presetsDir });
192
+
193
+ const planned = [];
194
+ const kept = [];
195
+ for (const [rel, content, source, note] of targets) {
196
+ const entry = manifest.files[rel];
197
+ const abs = repoPath ? path.join(repoPath, rel) : null;
198
+ const onDisk = abs && fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
199
+
200
+ if (entry && onDisk !== null && hashContent(onDisk) !== entry.hash) {
201
+ kept.push({ rel, reason: 'hand-edited since cairn wrote it' });
202
+ continue;
203
+ }
204
+ if (entry && onDisk !== null && hashContent(content) === entry.hash) continue; // current, on disk
205
+ if (entry && onDisk !== null) {
206
+ kept.push({ rel, reason: 'the template changed — `cairn sync` will merge it' });
207
+ continue;
208
+ }
209
+ if (!entry && onDisk !== null) {
210
+ kept.push({ rel, reason: 'already on disk, not written by cairn' });
211
+ continue;
212
+ }
213
+ // Not on disk: brand new, or a manifested file that was deleted — (re)write it.
214
+ planned.push([rel, content, source, entry ? 'restoring' : note]);
215
+ }
216
+
217
+ return { planned, kept, context: ctx };
218
+ }
219
+
220
+ module.exports = { planRender, renderTargets, buildContext, trackerContext, DEFAULT_WORKFLOW };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
7
+ const {
8
+ readManifest,
9
+ record,
10
+ writeManifest,
11
+ writeBase,
12
+ manifestPath,
13
+ } = require('../manifest');
14
+ const { makePaint } = require('../paint');
15
+ const { planSync, OUTCOME } = require('./index');
16
+
17
+ const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
18
+
19
+ const HELP = `cairn sync — propagate preset changes into an already-rendered repo
20
+
21
+ Usage: cairn sync [options]
22
+
23
+ --repo <path> repo to sync (default: the repo cairn.config.json is in)
24
+ --dry-run print the 2x2 outcome per file, write nothing
25
+ --adopt-base for files rendered before cairn tracked a merge base and
26
+ hand-edited since: accept the current file as the base
27
+ (you lose the ability to merge upstream changes to it)
28
+ --no-color disable ANSI colour
29
+
30
+ Runs a three-way merge per managed file: base (what the template rendered last
31
+ time, in _cairn/base/), ours (what is on disk), theirs (what the current presets
32
+ render). Clean results are written; a conflict is written with git markers and
33
+ exits non-zero. Nothing is ever silently overwritten.
34
+ `;
35
+
36
+ const COLOR = {
37
+ [OUTCOME.UNCHANGED]: 'dim',
38
+ [OUTCOME.MODIFIED]: 'yellow',
39
+ [OUTCOME.CREATED]: 'green',
40
+ [OUTCOME.FAST_FORWARD]: 'green',
41
+ [OUTCOME.MERGED]: 'green',
42
+ [OUTCOME.RESTORED]: 'green',
43
+ [OUTCOME.CONFLICT]: 'red',
44
+ [OUTCOME.ORPHANED]: 'magenta',
45
+ [OUTCOME.NO_BASE]: 'red',
46
+ };
47
+
48
+ function parseArgs(argv) {
49
+ const args = { repo: null, dryRun: false, adoptBase: false, color: process.stdout.isTTY };
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--repo') args.repo = argv[++i];
53
+ else if (a === '--dry-run') args.dryRun = true;
54
+ else if (a === '--adopt-base') args.adoptBase = true;
55
+ else if (a === '--no-color') args.color = false;
56
+ else if (a === '-h' || a === '--help') args.help = true;
57
+ else {
58
+ console.error(`Unknown argument: ${a}`);
59
+ return { error: 2 };
60
+ }
61
+ }
62
+ return args;
63
+ }
64
+
65
+ // Apply a plan to disk. Returns the count of files left in conflict.
66
+ function applyPlan(repoPath, manifest, actions, paint) {
67
+ let conflicts = 0;
68
+ let changed = false;
69
+ for (const act of actions) {
70
+ if (act.write !== undefined) {
71
+ const abs = path.join(repoPath, act.rel);
72
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
73
+ fs.writeFileSync(abs, act.write, 'utf8');
74
+ }
75
+ // record() rebuilds the entry, so it drops any stale `conflict` flag; set the
76
+ // fresh one back afterwards.
77
+ if (act.write !== undefined && act.source !== undefined) {
78
+ record(manifest, act.rel, act.source, act.write);
79
+ changed = true;
80
+ }
81
+ if (act.base !== undefined) writeBase(repoPath, act.rel, act.base);
82
+ if (act.conflict) {
83
+ manifest.files[act.rel].conflict = true;
84
+ conflicts++;
85
+ changed = true;
86
+ } else if (act.clearConflict && manifest.files[act.rel] && manifest.files[act.rel].conflict) {
87
+ delete manifest.files[act.rel].conflict;
88
+ changed = true;
89
+ }
90
+ printAction(act, paint);
91
+ }
92
+ return { conflicts, changed };
93
+ }
94
+
95
+ function printAction(act, paint) {
96
+ const tag = paint[COLOR[act.outcome] || 'dim'](act.outcome.padEnd(12));
97
+ const detail = act.detail ? paint.dim(` ${act.detail}`) : '';
98
+ console.log(` ${tag} ${act.rel}${detail}`);
99
+ }
100
+
101
+ function main(argv = process.argv.slice(2)) {
102
+ const args = parseArgs(argv);
103
+ if (args.error) return args.error;
104
+ if (args.help) {
105
+ process.stdout.write(HELP);
106
+ return 0;
107
+ }
108
+
109
+ const paint = makePaint(args.color);
110
+ const start = args.repo ? path.resolve(args.repo) : process.cwd();
111
+ const repoPath = args.repo ? start : findRepoRoot(start);
112
+ if (!repoPath || !fs.existsSync(path.join(repoPath, 'cairn.config.json'))) {
113
+ console.error('No cairn.config.json found — run `cairn init` first');
114
+ return 2;
115
+ }
116
+
117
+ const manifest = readManifest(repoPath);
118
+ if (!manifest || !Object.keys(manifest.files).length) {
119
+ console.error('Nothing to sync — this repo has no cairn manifest. Run `cairn render` first.');
120
+ return 2;
121
+ }
122
+
123
+ let plan;
124
+ try {
125
+ const config = loadRepoConfig(repoPath, { required: true });
126
+ plan = planSync({ config, presetsDir: PRESETS_DIR, manifest, repoPath, adoptBase: args.adoptBase });
127
+ } catch (e) {
128
+ if (e instanceof ConfigError) {
129
+ console.error(e.message);
130
+ return 2;
131
+ }
132
+ throw e;
133
+ }
134
+
135
+ if (args.dryRun) {
136
+ console.log(paint.bold(`cairn sync --dry-run ${repoPath}`));
137
+ for (const act of plan.actions) printAction(act, paint);
138
+ const conflicts = plan.actions.filter((a) => a.outcome === OUTCOME.CONFLICT).length;
139
+ const noBase = plan.actions.filter((a) => a.outcome === OUTCOME.NO_BASE).length;
140
+ console.log('');
141
+ console.log(summary(plan.actions, paint));
142
+ return conflicts || noBase ? 3 : 0;
143
+ }
144
+
145
+ console.log(paint.bold(`cairn sync ${repoPath}`));
146
+ const { conflicts, changed } = applyPlan(repoPath, manifest, plan.actions, paint);
147
+ const noBase = plan.actions.filter((a) => a.outcome === OUTCOME.NO_BASE).length;
148
+
149
+ if (changed) {
150
+ writeManifest(repoPath, manifest);
151
+ console.log(
152
+ ` ${paint.green('wrote')} ${path
153
+ .relative(repoPath, manifestPath(repoPath))
154
+ .replace(/\\/g, '/')}`,
155
+ );
156
+ }
157
+
158
+ console.log('');
159
+ console.log(summary(plan.actions, paint));
160
+
161
+ if (conflicts) {
162
+ console.log('');
163
+ console.log(
164
+ paint.red(` ${conflicts} file(s) left with conflict markers`) +
165
+ paint.dim(' — resolve them, then run `cairn sync` again'),
166
+ );
167
+ }
168
+ if (noBase) {
169
+ console.log(
170
+ paint.red(` ${noBase} file(s) have no merge base`) +
171
+ paint.dim(' — re-run with --adopt-base once you have reviewed them'),
172
+ );
173
+ }
174
+ return conflicts || noBase ? 3 : 0;
175
+ }
176
+
177
+ function summary(actions, paint) {
178
+ const counts = {};
179
+ for (const a of actions) counts[a.outcome] = (counts[a.outcome] || 0) + 1;
180
+ const order = [
181
+ OUTCOME.CREATED,
182
+ OUTCOME.FAST_FORWARD,
183
+ OUTCOME.MERGED,
184
+ OUTCOME.RESTORED,
185
+ OUTCOME.MODIFIED,
186
+ OUTCOME.UNCHANGED,
187
+ OUTCOME.ORPHANED,
188
+ OUTCOME.NO_BASE,
189
+ OUTCOME.CONFLICT,
190
+ ];
191
+ const parts = order
192
+ .filter((k) => counts[k])
193
+ .map((k) => paint[COLOR[k] || 'dim'](`${k} ${counts[k]}`));
194
+ return paint.bold('Summary') + '\n ' + (parts.join(' ') || paint.dim('nothing managed'));
195
+ }
196
+
197
+ module.exports = { main, applyPlan, HELP, PRESETS_DIR };
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ // cairn sync — propagate preset changes into a repo that has already rendered.
4
+ //
5
+ // `cairn render` is greenfield: it writes files that do not exist and never
6
+ // touches one again. `cairn sync` is the upgrade path. For every managed file it
7
+ // holds three versions —
8
+ //
9
+ // base what the template rendered last time (_cairn/base/<rel>)
10
+ // ours what is on disk now (the repo's edits)
11
+ // theirs what the current presets render
12
+ //
13
+ // — and runs a 2x2:
14
+ //
15
+ // template unchanged template changed
16
+ // local unchanged no-op fast-forward (write theirs)
17
+ // local changed keep local (MODIFIED) three-way merge
18
+ //
19
+ // Three cells are automatic. The bottom-right shells out to `git merge-file`;
20
+ // a clean merge is applied, a conflicted one is written with markers and stops
21
+ // the command with a non-zero exit. Nothing is ever silently overwritten.
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const { spawnSync } = require('child_process');
27
+
28
+ const { hashContent, readBase } = require('../manifest');
29
+ const render = require('../render');
30
+
31
+ const SHIM_SOURCE = 'shims/jira.js';
32
+
33
+ // Outcomes, most-benign first. CONFLICT and NO_BASE are the non-zero exits.
34
+ const OUTCOME = {
35
+ UNCHANGED: 'unchanged',
36
+ MODIFIED: 'modified',
37
+ CREATED: 'created',
38
+ FAST_FORWARD: 'fast-forward',
39
+ MERGED: 'merged',
40
+ RESTORED: 'restored',
41
+ CONFLICT: 'CONFLICT',
42
+ ORPHANED: 'orphaned',
43
+ NO_BASE: 'no base',
44
+ };
45
+
46
+ function templatesDir() {
47
+ return path.join(__dirname, '..', '..', 'templates');
48
+ }
49
+
50
+ // `theirs` for a manifest entry: the preset-rendered file when the presets still
51
+ // emit that path, the vendored template for a tracker shim, or null when neither
52
+ // applies any more (the stack changed and this preset dropped out).
53
+ function resolveTheirs(entry, rel, targetsByRel) {
54
+ const hit = targetsByRel.get(rel);
55
+ if (hit) return { content: hit.content, source: hit.source };
56
+ if (entry.source === SHIM_SOURCE) {
57
+ const file = path.join(templatesDir(), SHIM_SOURCE);
58
+ if (fs.existsSync(file)) return { content: fs.readFileSync(file, 'utf8'), source: SHIM_SOURCE };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // git merge-file --diff3, ours/base/theirs on disk, merged text on stdout. Exit
64
+ // code is the conflict count (0 = clean); a negative code means git failed.
65
+ function gitMergeFile(ours, base, theirs) {
66
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-merge-'));
67
+ const write = (name, body) => {
68
+ const p = path.join(dir, name);
69
+ fs.writeFileSync(p, body, 'utf8');
70
+ return p;
71
+ };
72
+ try {
73
+ const res = spawnSync(
74
+ 'git',
75
+ [
76
+ 'merge-file',
77
+ '-p',
78
+ '--diff3',
79
+ '-L',
80
+ 'ours (your edits)',
81
+ '-L',
82
+ 'base (cairn wrote this)',
83
+ '-L',
84
+ 'theirs (new template)',
85
+ write('ours', ours),
86
+ write('base', base),
87
+ write('theirs', theirs),
88
+ ],
89
+ { encoding: 'utf8' },
90
+ );
91
+ if (res.error && res.error.code === 'ENOENT') {
92
+ return { ok: false, reason: 'git is not on PATH — cairn sync needs it for the merge step' };
93
+ }
94
+ if (typeof res.status !== 'number' || res.status < 0) {
95
+ return { ok: false, reason: (res.stderr || 'git merge-file failed').trim() };
96
+ }
97
+ return { ok: true, merged: res.stdout, conflicts: res.status };
98
+ } finally {
99
+ fs.rmSync(dir, { recursive: true, force: true });
100
+ }
101
+ }
102
+
103
+ // One decision per managed file. Returns an action describing what the caller
104
+ // should write; it performs no IO of its own beyond reading ours/base.
105
+ function planFile(rel, entry, repoPath, targetsByRel, { adoptBase }) {
106
+ const theirs = resolveTheirs(entry, rel, targetsByRel);
107
+ const absOurs = path.join(repoPath, rel);
108
+ const ours = fs.existsSync(absOurs) ? fs.readFileSync(absOurs, 'utf8') : null;
109
+
110
+ if (!theirs) {
111
+ return { rel, outcome: OUTCOME.ORPHANED, detail: 'the presets no longer emit this path' };
112
+ }
113
+
114
+ if (ours === null) {
115
+ // Deleted locally — nothing to preserve, put the current render back.
116
+ return {
117
+ rel,
118
+ outcome: OUTCOME.RESTORED,
119
+ detail: 'was deleted',
120
+ write: theirs.content,
121
+ base: theirs.content,
122
+ source: theirs.source,
123
+ };
124
+ }
125
+
126
+ let base = readBase(repoPath, rel);
127
+ let seededBase = false;
128
+ if (base === null) {
129
+ if (hashContent(ours) === entry.hash) {
130
+ // Untouched since cairn wrote it — the on-disk bytes are a faithful base.
131
+ base = ours;
132
+ seededBase = true;
133
+ } else if (adoptBase) {
134
+ base = ours;
135
+ seededBase = true;
136
+ } else {
137
+ return {
138
+ rel,
139
+ outcome: OUTCOME.NO_BASE,
140
+ detail: 'rendered before base tracking and edited since — pass --adopt-base or reconcile by hand',
141
+ };
142
+ }
143
+ }
144
+
145
+ const hOurs = hashContent(ours);
146
+ const hBase = hashContent(base);
147
+ const hTheirs = hashContent(theirs.content);
148
+ const localChanged = hOurs !== hBase;
149
+ const templateChanged = hTheirs !== hBase;
150
+ const hasMarkers = /^<{7} /m.test(ours) && /^>{7} /m.test(ours);
151
+
152
+ if (!templateChanged) {
153
+ // The template has already been folded into the base — anything left is a
154
+ // local edit. Clear a stale conflict flag once the markers are gone.
155
+ const unresolved = entry.conflict && hasMarkers;
156
+ return {
157
+ rel,
158
+ outcome: localChanged ? OUTCOME.MODIFIED : OUTCOME.UNCHANGED,
159
+ detail: unresolved
160
+ ? 'conflict markers still in the file — resolve them'
161
+ : localChanged
162
+ ? 'local edits, template steady — kept'
163
+ : null,
164
+ // Persist a base we only inferred this run, so the next sync is cheap.
165
+ base: seededBase ? base : undefined,
166
+ clearConflict: entry.conflict && !unresolved,
167
+ };
168
+ }
169
+
170
+ if (!localChanged) {
171
+ return {
172
+ rel,
173
+ outcome: OUTCOME.FAST_FORWARD,
174
+ detail: `template moved ${diffMagnitude(base, theirs.content)}`,
175
+ write: theirs.content,
176
+ base: theirs.content,
177
+ source: theirs.source,
178
+ clearConflict: true,
179
+ };
180
+ }
181
+
182
+ const merge = gitMergeFile(ours, base, theirs.content);
183
+ if (!merge.ok) {
184
+ return { rel, outcome: OUTCOME.CONFLICT, detail: merge.reason };
185
+ }
186
+ if (merge.conflicts === 0) {
187
+ return {
188
+ rel,
189
+ outcome: OUTCOME.MERGED,
190
+ detail: 'local edits + template changes combined cleanly',
191
+ write: merge.merged,
192
+ base: theirs.content,
193
+ source: theirs.source,
194
+ clearConflict: true,
195
+ };
196
+ }
197
+ // Write the markers, and advance the base to `theirs` so the next run treats
198
+ // whatever the human leaves behind as a plain local edit rather than merging
199
+ // the same template change a second time.
200
+ return {
201
+ rel,
202
+ outcome: OUTCOME.CONFLICT,
203
+ detail: `${merge.conflicts} conflict${merge.conflicts === 1 ? '' : 's'} — markers written, resolve then re-run`,
204
+ write: merge.merged,
205
+ base: theirs.content,
206
+ source: theirs.source,
207
+ conflict: true,
208
+ };
209
+ }
210
+
211
+ function diffMagnitude(a, b) {
212
+ const la = a.split('\n').length;
213
+ const lb = b.split('\n').length;
214
+ const d = lb - la;
215
+ if (d === 0) return '(same length)';
216
+ return d > 0 ? `(+${d} lines)` : `(${d} lines)`;
217
+ }
218
+
219
+ // Plan a sync for the whole repo. Pure except for reading ours/base and invoking
220
+ // `git merge-file`; the caller writes the files and the manifest.
221
+ function planSync({ config, presetsDir, manifest, repoPath, adoptBase = false }) {
222
+ const { targets } = render.renderTargets({ config, presetsDir });
223
+ const targetsByRel = new Map(targets.map(([rel, content, source]) => [rel, { content, source }]));
224
+
225
+ const actions = [];
226
+ for (const [rel, entry] of Object.entries(manifest.files)) {
227
+ actions.push(planFile(rel, entry, repoPath, targetsByRel, { adoptBase }));
228
+ }
229
+
230
+ // Brand-new preset files: a manifested repo that predates a preset addition.
231
+ // Same greenfield guard as `cairn render` — write it only if nothing is there.
232
+ for (const [rel, content, source] of targets) {
233
+ if (manifest.files[rel]) continue;
234
+ if (fs.existsSync(path.join(repoPath, rel))) continue;
235
+ actions.push({
236
+ rel,
237
+ outcome: OUTCOME.CREATED,
238
+ detail: 'new preset file',
239
+ write: content,
240
+ base: content,
241
+ source,
242
+ });
243
+ }
244
+
245
+ actions.sort((a, b) => a.rel.localeCompare(b.rel));
246
+ return { actions };
247
+ }
248
+
249
+ module.exports = { planSync, planFile, gitMergeFile, OUTCOME };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "bin/",
25
25
  "lib/",
26
26
  "templates/",
27
+ "presets/",
27
28
  "schema.json",
28
29
  "README.md"
29
30
  ],