@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.
- package/README.md +33 -22
- package/bin/cairn.js +5 -0
- package/lib/init.js +10 -0
- package/lib/render/cli.js +106 -0
- package/lib/render/engine.js +148 -0
- package/lib/render/index.js +210 -0
- package/package.json +2 -1
- package/presets/EXTRACTION.md +210 -0
- package/presets/README.md +86 -0
- package/presets/angular/code-guidelines.md +197 -0
- package/presets/angular/slots/architect-discussion-topics.md +10 -0
- package/presets/angular/slots/architect-mandatory-docs.md +5 -0
- package/presets/angular/slots/architect-references.md +4 -0
- package/presets/angular/slots/implementation-reference.md +34 -0
- package/presets/angular/slots/key-patterns.md +11 -0
- package/presets/angular/slots/plan-step-ordering.md +11 -0
- package/presets/angular/slots/review-checklist.md +16 -0
- package/presets/angular/variants/i18n-external-service.md +11 -0
- package/presets/angular/variants/i18n-glossr.md +63 -0
- package/presets/core/AGENTS.md +49 -0
- package/presets/core/README.md +35 -0
- package/presets/core/WORKFLOW.md +56 -0
- package/presets/core/agents/architect.md +269 -0
- package/presets/core/agents/developer.md +145 -0
- package/presets/core/agents/reviewer.md +167 -0
- package/presets/core/commands/_stub.md +7 -0
- package/presets/core/workflow.json +45 -0
- package/presets/drizzle/code-guidelines.md +33 -0
- package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
- package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
- package/presets/drizzle/slots/implementation-reference.md +17 -0
- package/presets/drizzle/slots/key-patterns.md +7 -0
- package/presets/drizzle/slots/review-checklist.md +10 -0
- package/presets/nestjs/code-guidelines.md +273 -0
- package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
- package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
- package/presets/nestjs/slots/architect-references.md +5 -0
- package/presets/nestjs/slots/implementation-reference.md +45 -0
- package/presets/nestjs/slots/key-patterns.md +11 -0
- package/presets/nestjs/slots/plan-step-ordering.md +12 -0
- package/presets/nestjs/slots/review-checklist.md +12 -0
- package/presets/nestjs/variants/validation-class-validator.md +120 -0
- package/presets/nestjs/variants/validation-zod.md +194 -0
- package/presets/nextjs/code-guidelines.md +45 -0
- package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
- package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
- package/presets/nextjs/slots/architect-references.md +6 -0
- package/presets/nextjs/slots/implementation-reference.md +24 -0
- package/presets/nextjs/slots/key-patterns.md +8 -0
- package/presets/nextjs/slots/plan-step-ordering.md +11 -0
- package/presets/nextjs/slots/review-checklist.md +11 -0
- package/presets/react/code-guidelines.md +46 -0
- package/presets/react/slots/architect-discussion-topics.md +5 -0
- package/presets/react/slots/architect-references.md +5 -0
- package/presets/react/slots/implementation-reference.md +26 -0
- package/presets/react/slots/key-patterns.md +8 -0
- package/presets/react/slots/plan-step-ordering.md +9 -0
- package/presets/react/slots/review-checklist.md +10 -0
- package/presets/tailwind/code-guidelines.md +28 -0
- package/presets/tailwind/slots/implementation-reference.md +8 -0
- package/presets/tailwind/slots/key-patterns.md +5 -0
- package/presets/tailwind/slots/review-checklist.md +8 -0
- package/presets/typeorm/code-guidelines.md +329 -0
- package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
- package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
- package/presets/typeorm/slots/implementation-reference.md +19 -0
- package/presets/typeorm/slots/key-patterns.md +8 -0
- 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
|
|
7
|
-
mirrored into an issue tracker.
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
36
|
-
_cairn/
|
|
37
|
-
manifest.json
|
|
38
|
-
scripts/jira.js
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
],
|