@stonepandastudio/cairn 0.2.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 ADDED
@@ -0,0 +1,158 @@
1
+ # @stonepandastudio/cairn
2
+
3
+ Shared AI workflow scaffolding for Stone Panda repos.
4
+
5
+ Five repos — `glossr-api`, `glossr-cli`, `glossr-frontend`, `snap-proof/backend`,
6
+ `snap-proof/frontend` — each carry an `ai/` folder and a `.claude/commands/` folder
7
+ holding the same workflow: brief → description → plan → execute → review → QA, with
8
+ the steps mirrored into Jira. They were copied between repos by hand and drifted.
9
+
10
+ A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
11
+ markers — a manifest of what it generated, and a report of where the copies stopped
12
+ agreeing.
13
+
14
+ ```bash
15
+ npm i -D @stonepandastudio/cairn
16
+ npx cairn init --stack backend --tracker jira-server --project-key PROOF --story-type Task
17
+ ```
18
+
19
+ ## What v1 does
20
+
21
+ | Command | |
22
+ |---|---|
23
+ | `cairn tracker <cmd>` | Issue tracker client. Replaces the four copied `ai/scripts/jira.js` files |
24
+ | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
25
+ | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
26
+
27
+ Template rendering — generating the command stubs, `WORKFLOW.md` and the agent role
28
+ contracts from one workflow declaration — is v2, and deliberately not in this release.
29
+ v1 swaps the Jira client under five repos; bundling prompt changes into the same
30
+ release would make any regression impossible to attribute.
31
+
32
+ ## The two folders
33
+
34
+ ```
35
+ repo/
36
+ cairn.config.json you edit this. cairn only ever reads it.
37
+ _cairn/ cairn writes this. nobody hand-edits it.
38
+ manifest.json what was generated, from which template, at which hash
39
+ scripts/jira.js vendored shim
40
+ ai/ project-owned. cairn seeds it at init and never touches it again.
41
+ contexts/ progress/ tasks/ infrastructure/
42
+ ```
43
+
44
+ The boundary is the whole design. Hand-edited config living inside a generated
45
+ directory is how generated directories acquire state nobody dares regenerate.
46
+
47
+ Generated files are **committed**, not gitignored. Claude Code discovers
48
+ `.claude/commands/` and agent docs from disk, and a prompt change should show up in
49
+ `git diff` like any other change.
50
+
51
+ ## Tracker
52
+
53
+ ```bash
54
+ cairn tracker list-statuses PROOF
55
+ cairn tracker create-task "Protected image fetch API"
56
+ cairn tracker create-subtask PROOF-217 "Step 1: signed URL issuing"
57
+ cairn tracker set-status PROOF-218 "In Progress"
58
+ cairn tracker append-description PROOF-217 ./ai/tasks/PROOF-217/brief-step-1.md --markup
59
+ cairn tracker close-story GLO-42 # cascades through subtasks first
60
+ ```
61
+
62
+ Providers are `jira-server` and `none`. `none` is a real provider, not an error case:
63
+ `glossr-cli` has no tracker, and the workflow steps become no-ops rather than every
64
+ command stub needing an "if this repo has Jira" branch written in prose.
65
+
66
+ Three things the four copied scripts did that this does not:
67
+
68
+ - **`process.exit` from library code.** Failures throw `TrackerError`; the CLI layer
69
+ chooses the exit code. That is what makes the client callable from a workflow runner.
70
+ - **Fail on a re-run.** Transitioning to a status the issue already holds is a
71
+ successful no-op. `NoTransitionError` exits 0 — re-running a step must be safe.
72
+ - **Ask an agent to convert markup by hand.** `ai/JIRA_MARKUP.md` was a prose document
73
+ in three variants explaining markdown → Jira wiki conversion. It is `toMarkup()` now,
74
+ under test, reachable via `--markup`.
75
+
76
+ Credentials come from the repo's `.env` (`JIRA_BASE_URL`, `JIRA_USER`, `JIRA_PASSWORD`).
77
+ Jira Server 8.5.1 predates PAT support, so auth is Basic. Anything already in the
78
+ environment wins over the file.
79
+
80
+ ### Legacy command names
81
+
82
+ `create-task`, `create-story`, `close-story` and friends are all still accepted, and
83
+ output formats are byte-for-byte what the old scripts printed. Roughly forty prose
84
+ references across five repos name these strings, and the agents parse the output.
85
+ They get renamed in the v2 pass that regenerates those documents.
86
+
87
+ ## Doctor
88
+
89
+ ```bash
90
+ cairn doctor # full table
91
+ cairn doctor --diff ai/WORKFLOW.md # hunks for one file across repos
92
+ cairn doctor --json
93
+ cairn doctor --strict # exit 1 on drift or a hand-edited generated file
94
+ ```
95
+
96
+ It reports two independent axes, and keeping them apart is the point:
97
+
98
+ **Drift** — cross-repo. Do the copies still agree? Raw comparison is noise, so each
99
+ file is normalized first: BOM and CRLF stripped, repo paths and names folded,
100
+ `GLO-18`/`PROOF-210` folded to `{{ISSUE}}`, and declared vars folded to
101
+ `{{placeholder}}`. What survives is drift no template variable explains. Files are
102
+ then clustered by normalized content; the largest cluster becomes the reference.
103
+
104
+ | Status | Meaning |
105
+ |---|---|
106
+ | `IDENTICAL` | byte-identical everywhere |
107
+ | `COSMETIC` | differs only in declared vars — extract as a template, zero merge cost |
108
+ | `DRIFT` | two or more genuinely different variants, with a similarity score |
109
+ | `MISSING` | some repos in the cohort lack it |
110
+ | `STRANDED` | exactly one repo has it — promotion candidate |
111
+
112
+ **Managed** — per-repo, read from `_cairn/manifest.json`. Does what cairn wrote still
113
+ match what is on disk?
114
+
115
+ | State | Meaning |
116
+ |---|---|
117
+ | `MANAGED` | on-disk hash matches what cairn wrote |
118
+ | `MODIFIED` | someone hand-edited a generated file |
119
+ | `OUTDATED` | cairn has newer content for it |
120
+ | `DELETED` | it is gone; `sync` will restore it |
121
+
122
+ A file can be perfectly in sync across repos while being hand-edited away from its
123
+ template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
124
+ overwriting a hand edit is the destructive outcome.
125
+
126
+ ### Workspace config (`repos.json`)
127
+
128
+ - `repos[]` — name, path, stack. Per-repo settings live in each repo's own
129
+ `cairn.config.json`; the inline `vars` blocks are a fallback used only until a repo
130
+ has been through `cairn init`, so migration can happen one repo at a time.
131
+ - `pathAliases` — local → canonical. `glossr-cli` calls its stubs `execute-tests.md`
132
+ and `review-code.md`; aliasing folds them onto the cohort's names instead of
133
+ reporting one `MISSING` and one `STRANDED` for what is the same file.
134
+ - `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
135
+ compares backend-with-backend. `requires: { tracker: true }` skips repos with no
136
+ tracker. A repo with no config has an *unknown* tracker, not an absent one, and is
137
+ never skipped on that basis.
138
+ - Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
139
+ `DATABASE_SCHEMA.md` — is per-project by design and never inspected.
140
+ `ai/INITIAL_PROMPT.md` is explicitly excluded: four variants at 0% shared content
141
+ make it a per-project document, not a drifted template.
142
+
143
+ ## Layout
144
+
145
+ ```
146
+ bin/cairn.js subcommand router
147
+ lib/config.js cairn.config.json + .env loading
148
+ lib/manifest.js generated-file hashing and state
149
+ lib/paint.js ANSI + table rendering
150
+ lib/tracker/ index (registry), cli, jira-server, none
151
+ lib/doctor/ index (analysis + report), scan, normalize, diff
152
+ lib/init.js cairn init
153
+ templates/shims/ vendored shim source
154
+ schema.json JSON Schema for cairn.config.json
155
+ test/run.js dependency-free test runner
156
+ ```
157
+
158
+ No runtime dependencies. Node >= 18. `npm test` runs 34 tests.
package/bin/cairn.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // cairn — shared AI workflow scaffolding for Stone Panda repos.
5
+ //
6
+ // v1 surface:
7
+ // cairn tracker <cmd> issue tracker client (replaces the copied ai/scripts/jira.js)
8
+ // cairn doctor cross-repo drift report
9
+ // cairn init make a repo cairn-managed
10
+ //
11
+ // Template rendering (command stubs, WORKFLOW.md, agent role contracts) is v2 and
12
+ // deliberately absent: this release swaps the Jira client under five repos, and
13
+ // mixing that with prompt changes would make any regression unattributable.
14
+
15
+ const HELP = `cairn — shared AI workflow scaffolding
16
+
17
+ Usage: cairn <command> [options]
18
+
19
+ tracker <cmd> [...] issue tracker client (cairn tracker --help)
20
+ doctor [options] cross-repo drift report (cairn doctor --help)
21
+ init [options] make a repo cairn-managed (cairn init --help)
22
+ version print version
23
+
24
+ Run \`cairn <command> --help\` for command options.
25
+ `;
26
+
27
+ function main() {
28
+ const [command, ...rest] = process.argv.slice(2);
29
+
30
+ switch (command) {
31
+ case 'tracker':
32
+ require('../lib/tracker/cli')(rest);
33
+ return;
34
+
35
+ case 'doctor':
36
+ process.exitCode = require('../lib/doctor').main(rest);
37
+ return;
38
+
39
+ case 'init':
40
+ process.exitCode = require('../lib/init').main(rest);
41
+ return;
42
+
43
+ case 'version':
44
+ case '--version':
45
+ case '-v':
46
+ console.log(require('../package.json').version);
47
+ return;
48
+
49
+ case undefined:
50
+ case 'help':
51
+ case '--help':
52
+ case '-h':
53
+ process.stdout.write(HELP);
54
+ return;
55
+
56
+ default:
57
+ console.error(`Unknown command: ${command}`);
58
+ process.stderr.write(HELP);
59
+ process.exitCode = 2;
60
+ }
61
+ }
62
+
63
+ main();
package/lib/config.js ADDED
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CONFIG_NAME = 'cairn.config.json';
7
+ const CAIRN_DIR = '_cairn';
8
+
9
+ // Boundary rule for the whole framework:
10
+ // cairn.config.json — hand-edited, lives at the repo root, cairn only reads it
11
+ // _cairn/ — cairn writes it, nobody hand-edits it
12
+ // Keeping the editable file out of the generated directory is what stops the
13
+ // generated directory from slowly acquiring hand-maintained state.
14
+
15
+ const DEFAULTS = {
16
+ stack: 'unknown',
17
+ tracker: { provider: 'none' },
18
+ agents: {},
19
+ vars: {},
20
+ };
21
+
22
+ class ConfigError extends Error {}
23
+
24
+ function configPath(repoPath) {
25
+ return path.join(repoPath, CONFIG_NAME);
26
+ }
27
+
28
+ function cairnDir(repoPath) {
29
+ return path.join(repoPath, CAIRN_DIR);
30
+ }
31
+
32
+ // Parse a dotenv-ish file. Deliberately minimal — no export keyword, no
33
+ // interpolation, no multiline values. The four repos' .env files are flat.
34
+ function parseEnv(text) {
35
+ const out = {};
36
+ for (const line of text.split('\n')) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed || trimmed.startsWith('#')) continue;
39
+ const eq = trimmed.indexOf('=');
40
+ if (eq === -1) continue;
41
+ const key = trimmed.slice(0, eq).trim();
42
+ let value = trimmed.slice(eq + 1).trim();
43
+ if (
44
+ (value.startsWith('"') && value.endsWith('"')) ||
45
+ (value.startsWith("'") && value.endsWith("'"))
46
+ ) {
47
+ value = value.slice(1, -1);
48
+ }
49
+ out[key] = value;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // Load .env into process.env without clobbering anything already set, so CI
55
+ // secrets and shell exports always win over the committed-adjacent file.
56
+ function loadEnv(repoPath, relEnv = '.env') {
57
+ const envPath = path.resolve(repoPath, relEnv);
58
+ if (!fs.existsSync(envPath)) return {};
59
+ const parsed = parseEnv(fs.readFileSync(envPath, 'utf8'));
60
+ for (const [key, value] of Object.entries(parsed)) {
61
+ if (process.env[key] === undefined) process.env[key] = value;
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ function normalizeTracker(tracker) {
67
+ const t = { ...(tracker || {}) };
68
+ t.provider = t.provider || 'none';
69
+ t.issueTypes = { story: 'Story', subtask: 'Sub-task', ...(t.issueTypes || {}) };
70
+ t.statuses = { todo: 'To Do', inProgress: 'In Progress', done: 'Done', ...(t.statuses || {}) };
71
+ t.env = t.env || '.env';
72
+ return t;
73
+ }
74
+
75
+ function validate(cfg, source) {
76
+ if (cfg.tracker.provider !== 'none' && !cfg.tracker.projectKey) {
77
+ throw new ConfigError(`${source}: tracker.provider is "${cfg.tracker.provider}" but tracker.projectKey is missing`);
78
+ }
79
+ if (cfg.aiKit) {
80
+ throw new ConfigError(`${source}: "aiKit" is the pre-rename key — rename it to "cairn"`);
81
+ }
82
+ return cfg;
83
+ }
84
+
85
+ // Read a repo's config. `fallbackVars` lets the doctor keep working against
86
+ // repos that have not been migrated yet: repos.json can still carry an inline
87
+ // `vars` block, and it is used only when the repo has no cairn.config.json.
88
+ function loadRepoConfig(repoPath, { fallbackVars = null, required = false } = {}) {
89
+ const file = configPath(repoPath);
90
+ if (!fs.existsSync(file)) {
91
+ if (required) {
92
+ throw new ConfigError(`No ${CONFIG_NAME} in ${repoPath} — run \`cairn init\` there first`);
93
+ }
94
+ return {
95
+ ...DEFAULTS,
96
+ tracker: normalizeTracker(null),
97
+ vars: fallbackVars || {},
98
+ unmanaged: true,
99
+ path: repoPath,
100
+ };
101
+ }
102
+
103
+ let raw;
104
+ try {
105
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'));
106
+ } catch (err) {
107
+ throw new ConfigError(`${file}: invalid JSON — ${err.message}`);
108
+ }
109
+
110
+ const cfg = {
111
+ ...DEFAULTS,
112
+ ...raw,
113
+ tracker: normalizeTracker(raw.tracker),
114
+ agents: raw.agents || {},
115
+ vars: raw.vars || fallbackVars || {},
116
+ unmanaged: false,
117
+ path: repoPath,
118
+ configFile: file,
119
+ };
120
+ return validate(cfg, file);
121
+ }
122
+
123
+ // Walk up from cwd looking for cairn.config.json. Lets `cairn tracker …` be run
124
+ // from anywhere inside a repo, which matters because the agents invoke it from
125
+ // wherever their session happens to be.
126
+ function findRepoRoot(start = process.cwd()) {
127
+ let dir = path.resolve(start);
128
+ for (;;) {
129
+ if (fs.existsSync(path.join(dir, CONFIG_NAME))) return dir;
130
+ const parent = path.dirname(dir);
131
+ if (parent === dir) return null;
132
+ dir = parent;
133
+ }
134
+ }
135
+
136
+ module.exports = {
137
+ CONFIG_NAME,
138
+ CAIRN_DIR,
139
+ ConfigError,
140
+ cairnDir,
141
+ configPath,
142
+ findRepoRoot,
143
+ loadEnv,
144
+ loadRepoConfig,
145
+ parseEnv,
146
+ };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // Line diff utilities with no external dependencies.
4
+ //
5
+ // Full LCS backtracking is O(n*m) in both time and memory, which is fine for the
6
+ // agent/command markdown files this tool compares (a few hundred lines each) but
7
+ // not for anything pathological. Past MAX_CELLS we fall back to a multiset
8
+ // estimate that gets the counts right without producing usable hunks.
9
+
10
+ const MAX_CELLS = 4_000_000;
11
+
12
+ function splitLines(text) {
13
+ const lines = text.split('\n');
14
+ // A trailing newline yields a final empty element that is not a real line.
15
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
16
+ return lines;
17
+ }
18
+
19
+ // Returns an array of ['=' | '-' | '+', line] ops turning `a` into `b`,
20
+ // or null when the input is too large for the LCS table.
21
+ function diffOps(a, b) {
22
+ const n = a.length;
23
+ const m = b.length;
24
+ if ((n + 1) * (m + 1) > MAX_CELLS) return null;
25
+
26
+ const w = m + 1;
27
+ const dp = new Int32Array((n + 1) * w);
28
+ for (let i = n - 1; i >= 0; i--) {
29
+ for (let j = m - 1; j >= 0; j--) {
30
+ dp[i * w + j] =
31
+ a[i] === b[j]
32
+ ? dp[(i + 1) * w + (j + 1)] + 1
33
+ : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
34
+ }
35
+ }
36
+
37
+ const ops = [];
38
+ let i = 0;
39
+ let j = 0;
40
+ while (i < n && j < m) {
41
+ if (a[i] === b[j]) {
42
+ ops.push(['=', a[i]]);
43
+ i++;
44
+ j++;
45
+ } else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) {
46
+ ops.push(['-', a[i]]);
47
+ i++;
48
+ } else {
49
+ ops.push(['+', b[j]]);
50
+ j++;
51
+ }
52
+ }
53
+ while (i < n) ops.push(['-', a[i++]]);
54
+ while (j < m) ops.push(['+', b[j++]]);
55
+ return ops;
56
+ }
57
+
58
+ // Multiset fallback: how many lines of each side have no partner on the other.
59
+ // Ignores ordering, so it under-reports pure moves — acceptable for a size guard.
60
+ function approxStats(a, b) {
61
+ const pool = new Map();
62
+ for (const line of a) pool.set(line, (pool.get(line) || 0) + 1);
63
+ let common = 0;
64
+ for (const line of b) {
65
+ const left = pool.get(line) || 0;
66
+ if (left > 0) {
67
+ pool.set(line, left - 1);
68
+ common++;
69
+ }
70
+ }
71
+ return {
72
+ common,
73
+ removed: a.length - common,
74
+ added: b.length - common,
75
+ approx: true,
76
+ };
77
+ }
78
+
79
+ function diffStats(a, b) {
80
+ const ops = diffOps(a, b);
81
+ if (!ops) return approxStats(a, b);
82
+ let common = 0;
83
+ let added = 0;
84
+ let removed = 0;
85
+ for (const [kind] of ops) {
86
+ if (kind === '=') common++;
87
+ else if (kind === '+') added++;
88
+ else removed++;
89
+ }
90
+ return { common, added, removed, approx: false };
91
+ }
92
+
93
+ // 1.0 means identical, 0.0 means nothing in common. Two empty files are identical.
94
+ function similarity(stats, aLen, bLen) {
95
+ const span = Math.max(aLen, bLen);
96
+ return span === 0 ? 1 : stats.common / span;
97
+ }
98
+
99
+ // Groups ops into unified-diff-style hunks with `context` unchanged lines around
100
+ // each run of changes. Returns [] when there is nothing to show.
101
+ function hunks(ops, context = 2) {
102
+ const changed = ops.map(([kind]) => kind !== '=');
103
+ const keep = new Array(ops.length).fill(false);
104
+ for (let i = 0; i < ops.length; i++) {
105
+ if (!changed[i]) continue;
106
+ for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) {
107
+ keep[j] = true;
108
+ }
109
+ }
110
+
111
+ const out = [];
112
+ let current = null;
113
+ for (let i = 0; i < ops.length; i++) {
114
+ if (keep[i]) {
115
+ if (!current) {
116
+ current = [];
117
+ out.push(current);
118
+ }
119
+ current.push(ops[i]);
120
+ } else {
121
+ current = null;
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ module.exports = { splitLines, diffOps, diffStats, similarity, hunks };