@stonepandastudio/cairn 0.4.2 → 0.5.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 (68) hide show
  1. package/README.md +33 -22
  2. package/bin/cairn.js +5 -0
  3. package/lib/init.js +10 -0
  4. package/lib/render/cli.js +106 -0
  5. package/lib/render/engine.js +148 -0
  6. package/lib/render/index.js +210 -0
  7. package/package.json +2 -1
  8. package/presets/EXTRACTION.md +210 -0
  9. package/presets/README.md +86 -0
  10. package/presets/angular/code-guidelines.md +197 -0
  11. package/presets/angular/slots/architect-discussion-topics.md +10 -0
  12. package/presets/angular/slots/architect-mandatory-docs.md +5 -0
  13. package/presets/angular/slots/architect-references.md +4 -0
  14. package/presets/angular/slots/implementation-reference.md +34 -0
  15. package/presets/angular/slots/key-patterns.md +11 -0
  16. package/presets/angular/slots/plan-step-ordering.md +11 -0
  17. package/presets/angular/slots/review-checklist.md +16 -0
  18. package/presets/angular/variants/i18n-external-service.md +11 -0
  19. package/presets/angular/variants/i18n-glossr.md +63 -0
  20. package/presets/core/AGENTS.md +49 -0
  21. package/presets/core/README.md +35 -0
  22. package/presets/core/WORKFLOW.md +56 -0
  23. package/presets/core/agents/architect.md +269 -0
  24. package/presets/core/agents/developer.md +145 -0
  25. package/presets/core/agents/reviewer.md +167 -0
  26. package/presets/core/commands/_stub.md +7 -0
  27. package/presets/core/workflow.json +45 -0
  28. package/presets/drizzle/code-guidelines.md +33 -0
  29. package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
  30. package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
  31. package/presets/drizzle/slots/implementation-reference.md +17 -0
  32. package/presets/drizzle/slots/key-patterns.md +7 -0
  33. package/presets/drizzle/slots/review-checklist.md +10 -0
  34. package/presets/nestjs/code-guidelines.md +273 -0
  35. package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
  36. package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
  37. package/presets/nestjs/slots/architect-references.md +5 -0
  38. package/presets/nestjs/slots/implementation-reference.md +45 -0
  39. package/presets/nestjs/slots/key-patterns.md +11 -0
  40. package/presets/nestjs/slots/plan-step-ordering.md +12 -0
  41. package/presets/nestjs/slots/review-checklist.md +12 -0
  42. package/presets/nestjs/variants/validation-class-validator.md +120 -0
  43. package/presets/nestjs/variants/validation-zod.md +194 -0
  44. package/presets/nextjs/code-guidelines.md +45 -0
  45. package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
  46. package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
  47. package/presets/nextjs/slots/architect-references.md +6 -0
  48. package/presets/nextjs/slots/implementation-reference.md +24 -0
  49. package/presets/nextjs/slots/key-patterns.md +8 -0
  50. package/presets/nextjs/slots/plan-step-ordering.md +11 -0
  51. package/presets/nextjs/slots/review-checklist.md +11 -0
  52. package/presets/react/code-guidelines.md +46 -0
  53. package/presets/react/slots/architect-discussion-topics.md +5 -0
  54. package/presets/react/slots/architect-references.md +5 -0
  55. package/presets/react/slots/implementation-reference.md +26 -0
  56. package/presets/react/slots/key-patterns.md +8 -0
  57. package/presets/react/slots/plan-step-ordering.md +9 -0
  58. package/presets/react/slots/review-checklist.md +10 -0
  59. package/presets/tailwind/code-guidelines.md +28 -0
  60. package/presets/tailwind/slots/implementation-reference.md +8 -0
  61. package/presets/tailwind/slots/key-patterns.md +5 -0
  62. package/presets/tailwind/slots/review-checklist.md +8 -0
  63. package/presets/typeorm/code-guidelines.md +329 -0
  64. package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
  65. package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
  66. package/presets/typeorm/slots/implementation-reference.md +19 -0
  67. package/presets/typeorm/slots/key-patterns.md +8 -0
  68. package/presets/typeorm/slots/review-checklist.md +8 -0
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  Shared AI workflow scaffolding, kept in sync across several repos.
4
4
 
5
5
  Each repo carries an `ai/` folder and a `.claude/commands/` folder holding the same
6
- workflow — brief → description → plan → execute → review → QA, with the steps
7
- mirrored into an issue tracker. Copied between repos by hand, they drift.
6
+ workflow — brief → description → plan → execute → review, with the steps optionally
7
+ mirrored into an issue tracker. Hand-copied between repos, they drift. cairn renders
8
+ them from one set of presets and reports where existing copies disagree.
8
9
 
9
10
  A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
10
11
  markers — a manifest of what it generated, and a report of where the copies stopped
@@ -15,33 +16,40 @@ npm i -D @stonepandastudio/cairn
15
16
  npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ --story-type Task
16
17
  ```
17
18
 
18
- ## What v1 does
19
+ ## What it does
19
20
 
20
21
  | Command | |
21
22
  |---|---|
22
23
  | `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
23
24
  | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
24
25
  | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
26
+ | `cairn render` | Scaffolds `ai/` + `.claude/commands/` from the bundled presets, from the repo's stack + workflow |
25
27
 
26
- Template rendering generating the command stubs, `WORKFLOW.md` and the agent role
27
- contracts from one workflow declaration is v2, and deliberately not in this release.
28
- v1 swaps the tracker client under the consuming repos; bundling prompt changes into
29
- the same release would make any regression impossible to attribute.
28
+ `cairn render` is **greenfield only**: it writes files that do not exist, records them
29
+ in the manifest, and leaves anything already on disk alone (reporting it). The
30
+ three-way merge that would re-render over local edits `cairn sync` is not built
31
+ yet; for a fresh repo there is nothing to merge.
30
32
 
31
33
  ## The two folders
32
34
 
33
35
  ```
34
36
  repo/
35
- cairn.config.json you edit this. cairn only ever reads it.
36
- _cairn/ cairn writes this. nobody hand-edits it.
37
- manifest.json what was generated, from which template, at which hash
38
- scripts/jira.js vendored shim
39
- ai/ project-owned. cairn never touches it.
40
- contexts/ progress/ tasks/ infrastructure/
37
+ cairn.config.json you edit this. cairn only ever reads it.
38
+ _cairn/ cairn writes this. nobody hand-edits it.
39
+ manifest.json what was generated, from which template, at which hash
40
+ scripts/jira.js vendored tracker shim
41
+ .claude/commands/ rendered from the workflow — one stub per step
42
+ ai/
43
+ agents/ rendered from presets/core + your stack's slots
44
+ AGENTS.md WORKFLOW.md rendered
45
+ infrastructure/code-guidelines.md rendered once as a seed — then it is yours
46
+ contexts/ tasks/ yours, never written by cairn
41
47
  ```
42
48
 
43
- The boundary is the whole design. Hand-edited config living inside a generated
44
- directory is how generated directories acquire state nobody dares regenerate.
49
+ `cairn render` seeds the rendered files once and records them in the manifest; it
50
+ never rewrites a file you have edited (that is `cairn sync`, not built yet). The
51
+ boundary is the whole design: hand-edited config living inside a generated directory
52
+ is how generated directories acquire state nobody dares regenerate.
45
53
 
46
54
  Generated files are **committed**, not gitignored. Claude Code discovers
47
55
  `.claude/commands/` and agent docs from disk, and a prompt change should show up in
@@ -88,7 +96,7 @@ environment wins over the file. If the repo's own app reads those same names, se
88
96
  `create-task`, `create-story`, `close-story` and friends are all still accepted, and
89
97
  output formats are byte-for-byte what the old scripts printed. Existing prose
90
98
  references name these strings and the agents parse the output, so they are kept
91
- until the v2 pass regenerates those documents.
99
+ until a render pass regenerates those documents from the presets.
92
100
 
93
101
  ## Doctor
94
102
 
@@ -124,11 +132,12 @@ match what is on disk?
124
132
  |---|---|
125
133
  | `MANAGED` | on-disk hash matches what cairn wrote |
126
134
  | `MODIFIED` | someone hand-edited a generated file |
127
- | `OUTDATED` | cairn has newer content for it — needs template hashes, wired in v2 |
135
+ | `OUTDATED` | cairn has newer content for it — needs template hashes; comes with `cairn sync` |
128
136
  | `DELETED` | it is gone; a future `cairn sync` will restore it |
129
137
 
130
- v1 reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` and the `sync` that acts
131
- on these states arrive with the v2 renderer.
138
+ Today the doctor reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` (needs the
139
+ template hashes threaded through) and `cairn sync` (the three-way merge that acts on
140
+ these states) are still to come — `cairn render` is greenfield-only for now.
132
141
 
133
142
  A file can be perfectly in sync across repos while being hand-edited away from its
134
143
  template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
@@ -152,8 +161,8 @@ overwriting a hand edit is the destructive outcome.
152
161
  `ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
153
162
  it a per-project document, not a drifted template.
154
163
 
155
- v2 splits this flat file into a per-project `workspace.json` plus an opt-in hub that
156
- references the projects for cross-project comparison. See `DESIGN.md`.
164
+ A later release splits this flat file into a per-project `workspace.json` plus an
165
+ opt-in hub that references the projects for cross-project comparison. See `DESIGN.md`.
157
166
 
158
167
  ## Layout
159
168
 
@@ -164,10 +173,12 @@ lib/manifest.js generated-file hashing and state
164
173
  lib/paint.js ANSI + table rendering
165
174
  lib/tracker/ index (registry), cli, jira-server, youtrack, none
166
175
  lib/doctor/ index (analysis + report), scan, normalize, diff
176
+ lib/render/ engine (a small template engine), index (driver), cli
167
177
  lib/init.js cairn init
168
178
  templates/shims/ vendored shim source
179
+ presets/ core/ + framework/ORM/styling presets rendered by `cairn render`
169
180
  schema.json JSON Schema for cairn.config.json
170
181
  test/run.js dependency-free test runner
171
182
  ```
172
183
 
173
- No runtime dependencies. Node >= 18. `npm test` runs 56 tests.
184
+ No runtime dependencies. Node >= 18. `npm test` runs 61 tests.
package/bin/cairn.js CHANGED
@@ -19,6 +19,7 @@ Usage: cairn <command> [options]
19
19
  tracker <cmd> [...] issue tracker client (cairn tracker --help)
20
20
  doctor [options] cross-repo drift report (cairn doctor --help)
21
21
  init [options] make a repo cairn-managed (cairn init --help)
22
+ render [options] scaffold ai/ from the bundled presets (cairn render --help)
22
23
  version print version
23
24
 
24
25
  Run \`cairn <command> --help\` for command options.
@@ -40,6 +41,10 @@ function main() {
40
41
  process.exitCode = require('../lib/init').main(rest);
41
42
  return;
42
43
 
44
+ case 'render':
45
+ process.exitCode = require('../lib/render/cli').main(rest);
46
+ return;
47
+
43
48
  case 'version':
44
49
  case '--version':
45
50
  case '-v':
package/lib/init.js CHANGED
@@ -58,6 +58,7 @@ Usage: cairn init [options]
58
58
  (only when the repo's app reads JIRA_*/YOUTRACK_* itself)
59
59
  --no-shim do not vendor or replace the ai/scripts/*.js client shims
60
60
  --force overwrite an existing cairn.config.json
61
+ --render also scaffold ai/ from the presets after init
61
62
  --force-shims also replace an ai/scripts/*.js shim that was hand-edited
62
63
  since cairn wrote it (kept untouched otherwise)
63
64
  --dry-run print what would be written, write nothing
@@ -75,6 +76,7 @@ function parseArgs(argv) {
75
76
  shim: true,
76
77
  force: false,
77
78
  forceShims: false,
79
+ render: false,
78
80
  dryRun: false,
79
81
  color: process.stdout.isTTY,
80
82
  };
@@ -90,6 +92,7 @@ function parseArgs(argv) {
90
92
  else if (a === '--no-shim') args.shim = false;
91
93
  else if (a === '--force') args.force = true;
92
94
  else if (a === '--force-shims') args.forceShims = args.force = true;
95
+ else if (a === '--render') args.render = true;
93
96
  else if (a === '--dry-run') args.dryRun = true;
94
97
  else if (a === '--no-color') args.color = false;
95
98
  else if (a === '-h' || a === '--help') args.help = true;
@@ -233,6 +236,13 @@ function main(argv = process.argv.slice(2)) {
233
236
  console.log(paint.dim(`\n Next: ensure .env has ${envVars.join(', ')}, then run`));
234
237
  console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
235
238
  }
239
+
240
+ if (args.render) {
241
+ console.log('');
242
+ require('./render/cli').renderInto(repoPath, { paint });
243
+ } else {
244
+ console.log(paint.dim(`\n Next: \`cairn render\` to scaffold ai/ from the presets`));
245
+ }
236
246
  return 0;
237
247
  }
238
248
 
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
7
+ const { readManifest, emptyManifest, record, writeManifest, manifestPath } = require('../manifest');
8
+ const { makePaint } = require('../paint');
9
+ const { planRender } = require('./index');
10
+
11
+ const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
12
+
13
+ const HELP = `cairn render — scaffold a repo's ai/ from the bundled presets
14
+
15
+ Usage: cairn render [options]
16
+
17
+ --repo <path> repo to render into (default: the repo cairn.config.json is in)
18
+ --dry-run print what would be written, write nothing
19
+ --no-color disable ANSI colour
20
+
21
+ Reads the repo's cairn.config.json (run \`cairn init\` first) — the stack list,
22
+ tracker, and any agent overrides. Greenfield only: it writes files that do not
23
+ exist and records them in the manifest; anything already on disk is left alone.
24
+ `;
25
+
26
+ function parseArgs(argv) {
27
+ const args = { repo: null, dryRun: false, color: process.stdout.isTTY };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === '--repo') args.repo = argv[++i];
31
+ else if (a === '--dry-run') args.dryRun = true;
32
+ else if (a === '--no-color') args.color = false;
33
+ else if (a === '-h' || a === '--help') args.help = true;
34
+ else {
35
+ console.error(`Unknown argument: ${a}`);
36
+ return { error: 2 };
37
+ }
38
+ }
39
+ return args;
40
+ }
41
+
42
+ // Shared by `cairn render` and `cairn init --render`.
43
+ function renderInto(repoPath, { dryRun = false, paint } = {}) {
44
+ const config = loadRepoConfig(repoPath, { required: true });
45
+ const manifest = readManifest(repoPath) || emptyManifest();
46
+ paint = paint || makePaint(false);
47
+
48
+ const { planned, kept } = planRender({ config, presetsDir: PRESETS_DIR, manifest, repoPath });
49
+
50
+ if (dryRun) {
51
+ console.log(paint.bold(`cairn render --dry-run ${repoPath}`));
52
+ for (const [rel, , , note] of planned) {
53
+ console.log(` ${paint.green('would write')} ${rel}${note ? paint.dim(` (${note})`) : ''}`);
54
+ }
55
+ for (const k of kept) console.log(paint.yellow(` would keep ${k.rel} (${k.reason})`));
56
+ if (!planned.length && !kept.length) console.log(paint.dim(' nothing to render'));
57
+ return 0;
58
+ }
59
+
60
+ let wrote = 0;
61
+ for (const [rel, content, source] of planned) {
62
+ const abs = path.join(repoPath, rel);
63
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
64
+ fs.writeFileSync(abs, content, 'utf8');
65
+ record(manifest, rel, source, content);
66
+ console.log(` ${paint.green('wrote')} ${rel}`);
67
+ wrote++;
68
+ }
69
+ if (wrote) {
70
+ writeManifest(repoPath, manifest);
71
+ console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
72
+ }
73
+ for (const k of kept) console.log(paint.yellow(` kept ${k.rel} (${k.reason})`));
74
+ if (!wrote && !kept.length) console.log(paint.dim(' nothing to render — all current'));
75
+ return 0;
76
+ }
77
+
78
+ function main(argv = process.argv.slice(2)) {
79
+ const args = parseArgs(argv);
80
+ if (args.error) return args.error;
81
+ if (args.help) {
82
+ process.stdout.write(HELP);
83
+ return 0;
84
+ }
85
+
86
+ const start = args.repo ? path.resolve(args.repo) : process.cwd();
87
+ const repoPath = args.repo ? start : findRepoRoot(start);
88
+ if (!repoPath || !fs.existsSync(path.join(repoPath, 'cairn.config.json'))) {
89
+ console.error(`No cairn.config.json found — run \`cairn init\` first`);
90
+ return 2;
91
+ }
92
+
93
+ const paint = makePaint(args.color);
94
+ try {
95
+ if (!args.dryRun) console.log(paint.bold(`cairn render ${repoPath}`));
96
+ return renderInto(repoPath, { dryRun: args.dryRun, paint });
97
+ } catch (e) {
98
+ if (e instanceof ConfigError) {
99
+ console.error(e.message);
100
+ return 2;
101
+ }
102
+ throw e;
103
+ }
104
+ }
105
+
106
+ module.exports = { main, renderInto, HELP, PRESETS_DIR };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // A very small template engine — the subset of Handlebars the preset files use
4
+ // and nothing more. Pure: no fs, no cairn knowledge. The driver in ./index.js
5
+ // supplies the context and the `{{> stack/… }}` partial resolver.
6
+ //
7
+ // Supported:
8
+ // {{ path }} substitution, not HTML-escaped (output is markdown)
9
+ // {{ this.x }} `this` and dotted paths, plus @index / @index_1 / @first / @last inside #each
10
+ // {{#if path}}…{{/if}} with optional {{else}}
11
+ // {{#each path}}…{{/each}}
12
+ // {{> stack/name }} resolved by the caller-supplied resolvePartial(name, ctx)
13
+ // {{! comment }} dropped
14
+ // a leading <!-- cairn preset: … --> or <!-- cairn:… --> block is dropped
15
+
16
+ const TOKEN = /(\{\{!(?:(?!\}\})[\s\S])*\}\}|\{\{\{[^}]*\}\}\}|\{\{[#/>]?\s*[^}]*?\}\})/;
17
+
18
+ // Drop cairn's own editorial comments — `<!-- cairn preset: … -->` /
19
+ // `{{! … }}` — wherever they sit in a template. They document the preset for a
20
+ // human reading `presets/` and must not reach the rendered output. Then tidy the
21
+ // whitespace a block tag on its own line would otherwise leave behind (Mustache's
22
+ // "standalone" rule), and collapse the runs of blank lines that removing a
23
+ // comment block opens up.
24
+ function stripHeaderComment(src) {
25
+ return String(src)
26
+ .replace(/^/, '')
27
+ .replace(/[ \t]*<!--\s*cairn[:\s][\s\S]*?-->[ \t]*\n?/g, '')
28
+ .replace(/^[ \t]*\{\{!(?:(?!\}\})[\s\S])*\}\}[ \t]*\r?\n/gm, '')
29
+ .replace(/\{\{!(?:(?!\}\})[\s\S])*\}\}/g, '')
30
+ .replace(/^[ \t]*(\{\{[#/][^}]*\}\}|\{\{\s*else\s*\}\})[ \t]*\r?\n/gm, '$1')
31
+ .replace(/\n{3,}/g, '\n\n')
32
+ .replace(/^\n+/, '');
33
+ }
34
+
35
+ function tokenize(src) {
36
+ return src
37
+ .split(TOKEN)
38
+ .filter((s) => s !== '' && s !== undefined)
39
+ .map((s) => {
40
+ const m = s.match(/^\{\{([#/>!]?)\s*([\s\S]*?)\s*\}\}$/);
41
+ if (!m) return { t: 'text', v: s };
42
+ const [, sigil, body] = m;
43
+ if (sigil === '!') return { t: 'comment' };
44
+ if (sigil === '>') return { t: 'partial', v: body.trim() };
45
+ if (sigil === '#') {
46
+ const [kw, arg] = body.split(/\s+/, 2);
47
+ return { t: 'open', kw, arg };
48
+ }
49
+ if (sigil === '/') return { t: 'close', kw: body.trim() };
50
+ if (body === 'else') return { t: 'else' };
51
+ return { t: 'var', v: body.trim() };
52
+ });
53
+ }
54
+
55
+ // Build a nested node tree from the flat token list.
56
+ function parse(tokens) {
57
+ const root = { kw: 'root', body: [], else: null };
58
+ const stack = [root];
59
+ let target = 'body';
60
+
61
+ for (const tok of tokens) {
62
+ const top = stack[stack.length - 1];
63
+ const sink = target === 'else' ? top.else : top.body;
64
+
65
+ if (tok.t === 'comment') continue;
66
+ if (tok.t === 'text') sink.push({ t: 'text', v: tok.v });
67
+ else if (tok.t === 'var') sink.push({ t: 'var', v: tok.v });
68
+ else if (tok.t === 'partial') sink.push({ t: 'partial', v: tok.v });
69
+ else if (tok.t === 'open') {
70
+ const node = { t: 'block', kw: tok.kw, arg: tok.arg, body: [], else: [] };
71
+ sink.push(node);
72
+ stack.push(node);
73
+ target = 'body';
74
+ } else if (tok.t === 'else') {
75
+ target = 'else';
76
+ } else if (tok.t === 'close') {
77
+ if (stack.length < 2 || top.kw !== tok.kw) {
78
+ throw new Error(`template: unbalanced {{/${tok.kw}}}`);
79
+ }
80
+ stack.pop();
81
+ target = 'body';
82
+ }
83
+ }
84
+ if (stack.length !== 1) throw new Error(`template: unclosed {{#${stack[stack.length - 1].kw}}}`);
85
+ return root.body;
86
+ }
87
+
88
+ function lookup(ctx, path) {
89
+ if (path === 'this') return ctx.this;
90
+ if (path.startsWith('@')) return ctx['@'] ? ctx['@'][path] : undefined;
91
+ let cur = path.startsWith('this.') ? ctx.this : ctx;
92
+ for (const key of path.replace(/^this\./, '').split('.')) {
93
+ if (cur == null) return undefined;
94
+ cur = cur[key];
95
+ }
96
+ return cur;
97
+ }
98
+
99
+ function truthy(v) {
100
+ return Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false && v !== '';
101
+ }
102
+
103
+ function renderNodes(nodes, ctx, resolvePartial) {
104
+ let out = '';
105
+ for (const node of nodes) {
106
+ if (node.t === 'text') {
107
+ out += node.v;
108
+ } else if (node.t === 'var') {
109
+ const v = lookup(ctx, node.v);
110
+ out += v === undefined || v === null ? '' : String(v);
111
+ } else if (node.t === 'partial') {
112
+ const resolved = resolvePartial(node.v, ctx);
113
+ out += resolved == null ? '' : renderNodes(parse(tokenize(stripHeaderComment(resolved))), ctx, resolvePartial);
114
+ } else if (node.t === 'block' && node.kw === 'if') {
115
+ const branch = truthy(lookup(ctx, node.arg)) ? node.body : node.else;
116
+ out += renderNodes(branch, ctx, resolvePartial);
117
+ } else if (node.t === 'block' && node.kw === 'each') {
118
+ const list = lookup(ctx, node.arg);
119
+ if (Array.isArray(list)) {
120
+ list.forEach((item, i) => {
121
+ const child = {
122
+ ...ctx,
123
+ this: item,
124
+ '@': {
125
+ '@index': i,
126
+ '@index_1': i + 1,
127
+ '@first': i === 0,
128
+ '@last': i === list.length - 1,
129
+ },
130
+ };
131
+ out += renderNodes(node.body, child, resolvePartial);
132
+ });
133
+ } else if (node.else.length) {
134
+ out += renderNodes(node.else, ctx, resolvePartial);
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // Render `src` against `ctx`. `resolvePartial(name, ctx)` returns the raw text for
142
+ // a `{{> name }}` include, or null to emit nothing.
143
+ function render(src, ctx = {}, resolvePartial = () => null) {
144
+ const clean = stripHeaderComment(String(src));
145
+ return renderNodes(parse(tokenize(clean)), ctx, resolvePartial);
146
+ }
147
+
148
+ module.exports = { render, stripHeaderComment };
@@ -0,0 +1,210 @@
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
+ // Returns { planned: [[rel, content, source, note?]], kept: [{ rel, reason }] }.
133
+ // `manifest` + `repoPath` drive the greenfield guard: a file cairn has not
134
+ // written is never touched, and one it wrote but a human has since edited is
135
+ // kept and reported (the re-render that respects local edits is `cairn sync`).
136
+ function planRender({ config, presetsDir, manifest = { files: {} }, repoPath = null }) {
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 will not re-render over your edits',
180
+ ]);
181
+ }
182
+
183
+ const planned = [];
184
+ const kept = [];
185
+ for (const [rel, content, source, note] of targets) {
186
+ const entry = manifest.files[rel];
187
+ const abs = repoPath ? path.join(repoPath, rel) : null;
188
+ const onDisk = abs && fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
189
+
190
+ if (entry && onDisk !== null && hashContent(onDisk) !== entry.hash) {
191
+ kept.push({ rel, reason: 'hand-edited since cairn wrote it' });
192
+ continue;
193
+ }
194
+ if (entry && onDisk !== null && hashContent(content) === entry.hash) continue; // current, on disk
195
+ if (entry && onDisk !== null) {
196
+ kept.push({ rel, reason: 'the template changed — `cairn sync` will merge it' });
197
+ continue;
198
+ }
199
+ if (!entry && onDisk !== null) {
200
+ kept.push({ rel, reason: 'already on disk, not written by cairn' });
201
+ continue;
202
+ }
203
+ // Not on disk: brand new, or a manifested file that was deleted — (re)write it.
204
+ planned.push([rel, content, source, entry ? 'restoring' : note]);
205
+ }
206
+
207
+ return { planned, kept, context: ctx };
208
+ }
209
+
210
+ module.exports = { planRender, buildContext, trackerContext, DEFAULT_WORKFLOW };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.4.2",
3
+ "version": "0.5.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
  ],