@stonepandastudio/cairn 0.2.0 → 0.3.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 CHANGED
@@ -1,11 +1,10 @@
1
1
  # @stonepandastudio/cairn
2
2
 
3
- Shared AI workflow scaffolding for Stone Panda repos.
3
+ Shared AI workflow scaffolding, kept in sync across several repos.
4
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.
5
+ Each repo carries an `ai/` folder and a `.claude/commands/` folder holding the same
6
+ workflowbrief description plan execute review → QA, with the steps
7
+ mirrored into an issue tracker. Copied between repos by hand, they drift.
9
8
 
10
9
  A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
11
10
  markers — a manifest of what it generated, and a report of where the copies stopped
@@ -13,21 +12,21 @@ agreeing.
13
12
 
14
13
  ```bash
15
14
  npm i -D @stonepandastudio/cairn
16
- npx cairn init --stack backend --tracker jira-server --project-key PROOF --story-type Task
15
+ npx cairn init --stack backend --tracker jira-server --project-key MYPROJ --story-type Task
17
16
  ```
18
17
 
19
18
  ## What v1 does
20
19
 
21
20
  | Command | |
22
21
  |---|---|
23
- | `cairn tracker <cmd>` | Issue tracker client. Replaces the four copied `ai/scripts/jira.js` files |
22
+ | `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
24
23
  | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
25
24
  | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
26
25
 
27
26
  Template rendering — generating the command stubs, `WORKFLOW.md` and the agent role
28
27
  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.
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.
31
30
 
32
31
  ## The two folders
33
32
 
@@ -37,7 +36,7 @@ repo/
37
36
  _cairn/ cairn writes this. nobody hand-edits it.
38
37
  manifest.json what was generated, from which template, at which hash
39
38
  scripts/jira.js vendored shim
40
- ai/ project-owned. cairn seeds it at init and never touches it again.
39
+ ai/ project-owned. cairn never touches it.
41
40
  contexts/ progress/ tasks/ infrastructure/
42
41
  ```
43
42
 
@@ -51,38 +50,43 @@ Generated files are **committed**, not gitignored. Claude Code discovers
51
50
  ## Tracker
52
51
 
53
52
  ```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
53
+ cairn tracker list-statuses MYPROJ
54
+ cairn tracker create-task "Some task title"
55
+ cairn tracker create-subtask MYPROJ-101 "Step 1: first slice"
56
+ cairn tracker set-status MYPROJ-102 "In Progress"
57
+ cairn tracker append-description MYPROJ-101 ./ai/tasks/MYPROJ-101/brief-step-1.md --markup
58
+ cairn tracker close-story MYPROJ-42 # cascades through subtasks first
60
59
  ```
61
60
 
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.
61
+ Providers are `jira-server`, `youtrack` and `none`, chosen per repo via
62
+ `tracker.provider`. `none` is a real provider, not an error case: a repo with no
63
+ tracker makes the workflow steps no-ops rather than every command stub needing an
64
+ "if this repo has a tracker" branch written in prose. YouTrack has no Task/Sub-task
65
+ type split (a `Subtask` link instead), a `Stage` state field rather than a
66
+ transition graph, and markdown-native descriptions.
65
67
 
66
- Three things the four copied scripts did that this does not:
68
+ Three things the copied scripts did that this does not:
67
69
 
68
70
  - **`process.exit` from library code.** Failures throw `TrackerError`; the CLI layer
69
71
  chooses the exit code. That is what makes the client callable from a workflow runner.
70
72
  - **Fail on a re-run.** Transitioning to a status the issue already holds is a
71
73
  successful no-op. `NoTransitionError` exits 0 — re-running a step must be safe.
72
74
  - **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
+ explaining markdown → Jira wiki conversion. It is `toMarkup()` now, under test,
76
+ reachable via `--markup`.
75
77
 
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
+ Credentials come from the repo's `.env` `JIRA_BASE_URL` / `JIRA_USER` /
79
+ `JIRA_PASSWORD` for `jira-server` (Basic auth: Jira Server 8.5.1 predates PATs), or
80
+ `YOUTRACK_URL` / `YOUTRACK_TOKEN` / `YOUTRACK_PROJECT` for `youtrack` (Bearer token,
81
+ admin-read scope for the project and stage-bundle lookups). Anything already in the
78
82
  environment wins over the file.
79
83
 
80
84
  ### Legacy command names
81
85
 
82
86
  `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.
87
+ output formats are byte-for-byte what the old scripts printed. Existing prose
88
+ references name these strings and the agents parse the output, so they are kept
89
+ until the v2 pass regenerates those documents.
86
90
 
87
91
  ## Doctor
88
92
 
@@ -93,13 +97,15 @@ cairn doctor --json
93
97
  cairn doctor --strict # exit 1 on drift or a hand-edited generated file
94
98
  ```
95
99
 
100
+ Run it from the directory holding `repos.json`, or pass `--config <file>`.
101
+
96
102
  It reports two independent axes, and keeping them apart is the point:
97
103
 
98
104
  **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.
105
+ file is normalized first: BOM and CRLF stripped, repo paths and names folded, issue
106
+ keys folded to `{{ISSUE}}`, and declared vars folded to `{{placeholder}}`. What
107
+ survives is drift no template variable explains. Files are then clustered by
108
+ normalized content; the largest cluster becomes the reference.
103
109
 
104
110
  | Status | Meaning |
105
111
  |---|---|
@@ -116,8 +122,11 @@ match what is on disk?
116
122
  |---|---|
117
123
  | `MANAGED` | on-disk hash matches what cairn wrote |
118
124
  | `MODIFIED` | someone hand-edited a generated file |
119
- | `OUTDATED` | cairn has newer content for it |
120
- | `DELETED` | it is gone; `sync` will restore it |
125
+ | `OUTDATED` | cairn has newer content for it — needs template hashes, wired in v2 |
126
+ | `DELETED` | it is gone; a future `cairn sync` will restore it |
127
+
128
+ v1 reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` and the `sync` that acts
129
+ on these states arrive with the v2 renderer.
121
130
 
122
131
  A file can be perfectly in sync across repos while being hand-edited away from its
123
132
  template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
@@ -128,17 +137,20 @@ overwriting a hand edit is the destructive outcome.
128
137
  - `repos[]` — name, path, stack. Per-repo settings live in each repo's own
129
138
  `cairn.config.json`; the inline `vars` blocks are a fallback used only until a repo
130
139
  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.
140
+ - `pathAliases` — local → canonical. When one repo names a stub `execute-tests.md`
141
+ where the others use `execute-test-plan.md`, aliasing folds them onto the cohort's
142
+ names instead of reporting one `MISSING` and one `STRANDED` for the same file.
134
143
  - `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
135
144
  compares backend-with-backend. `requires: { tracker: true }` skips repos with no
136
145
  tracker. A repo with no config has an *unknown* tracker, not an absent one, and is
137
146
  never skipped on that basis.
138
147
  - Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
139
148
  `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.
149
+ `ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
150
+ it a per-project document, not a drifted template.
151
+
152
+ v2 splits this flat file into a per-project `workspace.json` plus an opt-in hub that
153
+ references the projects for cross-project comparison. See `DESIGN.md`.
142
154
 
143
155
  ## Layout
144
156
 
@@ -147,7 +159,7 @@ bin/cairn.js subcommand router
147
159
  lib/config.js cairn.config.json + .env loading
148
160
  lib/manifest.js generated-file hashing and state
149
161
  lib/paint.js ANSI + table rendering
150
- lib/tracker/ index (registry), cli, jira-server, none
162
+ lib/tracker/ index (registry), cli, jira-server, youtrack, none
151
163
  lib/doctor/ index (analysis + report), scan, normalize, diff
152
164
  lib/init.js cairn init
153
165
  templates/shims/ vendored shim source
@@ -155,4 +167,4 @@ schema.json JSON Schema for cairn.config.json
155
167
  test/run.js dependency-free test runner
156
168
  ```
157
169
 
158
- No runtime dependencies. Node >= 18. `npm test` runs 34 tests.
170
+ No runtime dependencies. Node >= 18. `npm test` runs 46 tests.
package/lib/init.js CHANGED
@@ -89,8 +89,11 @@ function buildConfig(args) {
89
89
  };
90
90
  if (args.tracker !== 'none') {
91
91
  cfg.tracker.projectKey = args.projectKey;
92
- cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
93
92
  cfg.tracker.env = '.env';
93
+ // issueTypes is a Jira concept — YouTrack has no Task/Sub-task split.
94
+ if (args.tracker === 'jira-server') {
95
+ cfg.tracker.issueTypes = { story: args.storyType, subtask: args.subtaskType };
96
+ }
94
97
  }
95
98
  cfg.agents = {};
96
99
  return cfg;
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { loadEnv, loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
4
4
  const { createJiraServer, TrackerError, NoTransitionError } = require('./jira-server');
5
+ const { createYouTrack } = require('./youtrack');
5
6
  const { createNone } = require('./none');
6
7
 
7
8
  // Provider registry. Adding Linear or GitHub Issues later means adding one entry
@@ -9,6 +10,7 @@ const { createNone } = require('./none');
9
10
  // sees the shared interface.
10
11
  const PROVIDERS = {
11
12
  'jira-server': createJiraServer,
13
+ youtrack: createYouTrack,
12
14
  none: createNone,
13
15
  };
14
16
 
@@ -241,11 +241,35 @@ function createJiraServer(config = {}, env = process.env) {
241
241
  // Markdown -> Jira wiki markup. This used to be a per-repo prose document
242
242
  // (`ai/JIRA_MARKUP.md`, three variants) telling agents to convert by hand.
243
243
  // Conversion is deterministic, so it belongs in code where it can't drift.
244
+ // The conversion table it implements lives in those repos' `ai/JIRA_MARKUP.md`
245
+ // and `ai/TASK_TRACKER_MARKUP.md` — headings, bold/italic/strikethrough, inline
246
+ // and fenced code, links, blockquotes, nested lists, rules, and pipe tables.
247
+ function toMarkupInline(s) {
248
+ // Jira uses `*` for bold and `_` for italic. One pass handles `**b**` and
249
+ // `*i*`: group 1 captures which marker it was, the \s guards stop a bullet
250
+ // line ("* item") being read as emphasis, and the non-greedy body keeps
251
+ // `**b** and *i*` from matching as a single span.
252
+ s = s.replace(/(\*\*?)(?!\s)([^*\n]+?)(?<!\s)\1/g, (_, m, t) =>
253
+ m === '**' ? `*${t}*` : `_${t}_`,
254
+ );
255
+ s = s.replace(/~~([^~]+)~~/g, '-$1-');
256
+ s = s.replace(/`([^`]+)`/g, '{{$1}}');
257
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
258
+ return s;
259
+ }
260
+
244
261
  function toMarkup(markdown) {
245
262
  const lines = String(markdown).replace(/\r\n?/g, '\n').split('\n');
246
263
  const out = [];
247
264
  let inFence = false;
248
- for (const line of lines) {
265
+
266
+ const ROW = /^\s*\|(.+)\|\s*$/;
267
+ const isSep = (l) => l !== undefined && l.includes('|') && l.includes('-') && /^[\s|:-]+$/.test(l.trim());
268
+ const cells = (l) => l.match(ROW)[1].split('|').map((c) => toMarkupInline(c.trim()));
269
+
270
+ for (let i = 0; i < lines.length; i++) {
271
+ const line = lines[i];
272
+
249
273
  const fence = line.match(/^```(\w*)\s*$/);
250
274
  if (fence) {
251
275
  out.push(inFence ? '{code}' : fence[1] ? `{code:${fence[1]}}` : '{code}');
@@ -256,15 +280,25 @@ function createJiraServer(config = {}, env = process.env) {
256
280
  out.push(line);
257
281
  continue;
258
282
  }
283
+
284
+ // Pipe table: a row followed by a `|---|---|` separator. Header cells take
285
+ // `||`, body cells `|`, and the separator row is dropped (Jira has none).
286
+ if (ROW.test(line) && isSep(lines[i + 1])) {
287
+ out.push(`||${cells(line).join('||')}||`);
288
+ i++; // consume the separator
289
+ while (ROW.test(lines[i + 1]) && !isSep(lines[i + 1])) {
290
+ out.push(`|${cells(lines[++i]).join('|')}|`);
291
+ }
292
+ continue;
293
+ }
294
+
259
295
  let s = line;
260
296
  s = s.replace(/^(#{1,6})\s+(.*)$/, (_, h, rest) => `h${h.length}. ${rest}`);
297
+ s = s.replace(/^\s*>\s?(.*)$/, 'bq. $1');
261
298
  s = s.replace(/^(\s*)[-*]\s+/, (_, indent) => '*'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
262
299
  s = s.replace(/^(\s*)\d+\.\s+/, (_, indent) => '#'.repeat(Math.floor(indent.length / 2) + 1) + ' ');
263
- s = s.replace(/\*\*([^*]+)\*\*/g, '*$1*');
264
- s = s.replace(/(^|[^*\w])_([^_]+)_(?=[^*\w]|$)/g, '$1_$2_');
265
- s = s.replace(/`([^`]+)`/g, '{{$1}}');
266
- s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[$1|$2]');
267
300
  s = s.replace(/^---+$/, '----');
301
+ if (s !== '----') s = toMarkupInline(s);
268
302
  out.push(s);
269
303
  }
270
304
  return out.join('\n');
@@ -0,0 +1,317 @@
1
+ 'use strict';
2
+
3
+ // YouTrack REST client.
4
+ //
5
+ // Ported from the hand-copied `ai/scripts/youtrack.js` in the snap-proof repos,
6
+ // which those repos ran behind a `task-tracker.js` dispatcher alongside the Jira
7
+ // client. Same shape as jira-server.js: a factory returning the shared Tracker
8
+ // interface, nothing calls process.exit, failures throw TrackerError.
9
+ //
10
+ // YouTrack differs from Jira in three ways this file absorbs:
11
+ // - no Task/Sub-task issue-type split — a sub-issue is a plain issue joined to
12
+ // its parent by a "Subtask" issue link (OUTWARD = "parent for").
13
+ // - the workflow field is a "Stage" state bundle, not a transition graph, so
14
+ // any value in the bundle is reachable directly and NoTransitionError almost
15
+ // never fires. An unknown stage name is a TrackerError, not a no-op.
16
+ // - descriptions are markdown-native, so toMarkup is a passthrough.
17
+ //
18
+ // Auth is a permanent token via the Bearer header (YOUTRACK_TOKEN). The project
19
+ // and stage-bundle lookups hit /api/admin/* — the token needs admin-read scope.
20
+
21
+ const { TrackerError, NoTransitionError } = require('./jira-server');
22
+
23
+ const REQUIRED_ENV = ['YOUTRACK_URL', 'YOUTRACK_TOKEN'];
24
+ const STAGE_FIELD = 'Stage';
25
+ const ASSIGNEE_FIELD = 'Assignee';
26
+
27
+ function createYouTrack(config = {}, env = process.env) {
28
+ const baseUrl = (config.baseUrl || env.YOUTRACK_URL || '').replace(/\/+$/, '');
29
+ const token = config.token || env.YOUTRACK_TOKEN;
30
+ const projectKey = config.projectKey || env.YOUTRACK_PROJECT;
31
+
32
+ function assertConfig() {
33
+ const missing = REQUIRED_ENV.filter((k, i) => ![baseUrl, token][i]);
34
+ if (missing.length) {
35
+ throw new TrackerError(
36
+ `Missing YouTrack credentials: ${missing.join(', ')}. Set them in the repo's .env or the environment.`,
37
+ );
38
+ }
39
+ if (!projectKey) {
40
+ throw new TrackerError(
41
+ 'YouTrack project is not set — pass tracker.projectKey in cairn.config.json or YOUTRACK_PROJECT in .env',
42
+ );
43
+ }
44
+ }
45
+
46
+ async function api(pathname, options = {}) {
47
+ assertConfig();
48
+ let res;
49
+ try {
50
+ res = await fetch(`${baseUrl}${pathname}`, {
51
+ ...options,
52
+ headers: {
53
+ Authorization: `Bearer ${token}`,
54
+ 'Content-Type': 'application/json',
55
+ Accept: 'application/json',
56
+ ...(options.headers || {}),
57
+ },
58
+ });
59
+ } catch (err) {
60
+ throw new TrackerError(`Cannot reach YouTrack at ${baseUrl}: ${err.message}`);
61
+ }
62
+ const text = await res.text();
63
+ let body = null;
64
+ if (text) {
65
+ try {
66
+ body = JSON.parse(text);
67
+ } catch {
68
+ body = text;
69
+ }
70
+ }
71
+ if (!res.ok) {
72
+ const detail =
73
+ body && (body.error_description || body.error)
74
+ ? body.error_description || body.error
75
+ : typeof body === 'string'
76
+ ? body.slice(0, 400)
77
+ : JSON.stringify(body);
78
+ throw new TrackerError(`YouTrack ${res.status} on ${pathname}: ${detail}`, {
79
+ status: res.status,
80
+ body,
81
+ });
82
+ }
83
+ return body;
84
+ }
85
+
86
+ // --- internal lookups (admin scope) --------------------------------------
87
+
88
+ async function resolveProjectId() {
89
+ const projects = await api('/api/admin/projects?fields=id,shortName');
90
+ const p = (projects || []).find((pr) => pr.shortName === projectKey);
91
+ if (!p) throw new TrackerError(`YouTrack project "${projectKey}" not found`);
92
+ return p.id;
93
+ }
94
+
95
+ async function resolveStageBundleId(shortName = projectKey) {
96
+ const fields = await api(
97
+ `/api/admin/projects/${shortName}/customFields?fields=field(name),bundle(id)`,
98
+ );
99
+ const stage = (fields || []).find((f) => f.field && f.field.name === STAGE_FIELD);
100
+ if (!stage || !stage.bundle) {
101
+ throw new TrackerError(`No "${STAGE_FIELD}" field on YouTrack project ${shortName}`);
102
+ }
103
+ return stage.bundle.id;
104
+ }
105
+
106
+ async function stageNames() {
107
+ const bundleId = await resolveStageBundleId();
108
+ const bundle = await api(
109
+ `/api/admin/customFieldSettings/bundles/state/${bundleId}?fields=values(name,isResolved)`,
110
+ );
111
+ return (bundle.values || []).map((v) => ({ name: v.name, resolved: !!v.isResolved }));
112
+ }
113
+
114
+ async function issueLinks(key) {
115
+ return api(`/api/issues/${key}/links?fields=direction,linkType(name),issues(idReadable)`);
116
+ }
117
+
118
+ async function childKeys(key) {
119
+ const links = await issueLinks(key);
120
+ const sub = (links || []).find(
121
+ (l) => l.linkType && l.linkType.name === 'Subtask' && l.direction === 'OUTWARD',
122
+ );
123
+ return sub ? sub.issues.map((i) => i.idReadable) : [];
124
+ }
125
+
126
+ async function parentKeyOf(key) {
127
+ const links = await issueLinks(key);
128
+ const sub = (links || []).find(
129
+ (l) => l.linkType && l.linkType.name === 'Subtask' && l.direction === 'INWARD',
130
+ );
131
+ return sub && sub.issues.length ? sub.issues[0].idReadable : null;
132
+ }
133
+
134
+ // --- interface ---------------------------------------------------------
135
+
136
+ async function listProjects() {
137
+ const projects = await api('/api/admin/projects?fields=shortName,name');
138
+ return (projects || []).map((p) => ({ key: p.shortName, name: p.name }));
139
+ }
140
+
141
+ // One "Stage" bundle rather than a per-type status list, so a single group.
142
+ async function listStatuses() {
143
+ const values = await stageNames();
144
+ return [{ type: STAGE_FIELD, statuses: values.map((v) => v.name) }];
145
+ }
146
+
147
+ // `type` is accepted for interface parity and ignored — YouTrack has no split.
148
+ async function createIssue({ summary, description = '' } = {}) {
149
+ if (!summary) throw new TrackerError('createIssue requires a summary');
150
+ const projectId = await resolveProjectId();
151
+ const body = await api('/api/issues?fields=idReadable', {
152
+ method: 'POST',
153
+ body: JSON.stringify({ project: { id: projectId }, summary, description }),
154
+ });
155
+ return body.idReadable;
156
+ }
157
+
158
+ async function createSubIssue({ parentKey, summary, description = '' } = {}) {
159
+ if (!parentKey) throw new TrackerError('createSubIssue requires a parentKey');
160
+ if (!summary) throw new TrackerError('createSubIssue requires a summary');
161
+ const projectId = await resolveProjectId();
162
+ const created = await api('/api/issues?fields=id,idReadable', {
163
+ method: 'POST',
164
+ body: JSON.stringify({ project: { id: projectId }, summary, description }),
165
+ });
166
+ // Join to the parent with a Subtask link via the command API.
167
+ await api('/api/commands', {
168
+ method: 'POST',
169
+ body: JSON.stringify({ issues: [{ id: created.id }], query: `subtask of ${parentKey}` }),
170
+ });
171
+ return created.idReadable;
172
+ }
173
+
174
+ async function get(key) {
175
+ const data = await api(
176
+ `/api/issues/${key}?fields=idReadable,summary,description,customFields(name,value(name,login))`,
177
+ );
178
+ const fields = data.customFields || [];
179
+ const stage = fields.find((f) => f.name === STAGE_FIELD);
180
+ const assignee = fields.find((f) => f.name === ASSIGNEE_FIELD);
181
+ const [parent, subtasks] = await Promise.all([parentKeyOf(key), childKeys(key)]);
182
+ return {
183
+ key: data.idReadable,
184
+ summary: data.summary,
185
+ description: data.description,
186
+ status: (stage && stage.value && stage.value.name) || null,
187
+ assignee: (assignee && assignee.value && assignee.value.login) || null,
188
+ parent,
189
+ subtasks,
190
+ };
191
+ }
192
+
193
+ async function setFields(key, fields = {}) {
194
+ const payload = {};
195
+ if (fields.summary !== undefined) payload.summary = fields.summary;
196
+ if (fields.description !== undefined) payload.description = fields.description;
197
+ if (Object.keys(payload).length === 0) {
198
+ throw new TrackerError('setFields requires at least one of: summary, description');
199
+ }
200
+ await api(`/api/issues/${key}?fields=idReadable`, {
201
+ method: 'POST',
202
+ body: JSON.stringify(payload),
203
+ });
204
+ return key;
205
+ }
206
+
207
+ async function appendDescription(key, text) {
208
+ if (!text) throw new TrackerError('appendDescription requires text');
209
+ const current = await api(`/api/issues/${key}?fields=description`);
210
+ const existing = (current && current.description) || '';
211
+ const updated = existing ? `${existing}\n\n${text}` : text;
212
+ await api(`/api/issues/${key}?fields=idReadable`, {
213
+ method: 'POST',
214
+ body: JSON.stringify({ description: updated }),
215
+ });
216
+ return updated;
217
+ }
218
+
219
+ async function currentStage(key) {
220
+ const data = await api(`/api/issues/${key}?fields=customFields(name,value(name))`);
221
+ const f = (data.customFields || []).find((x) => x.name === STAGE_FIELD);
222
+ return (f && f.value && f.value.name) || null;
223
+ }
224
+
225
+ // Returns { key, from, to, changed }. Already at the target stage is a success
226
+ // with changed:false — re-running a workflow step must be safe. An unknown
227
+ // stage name throws TrackerError (with the valid names), not NoTransitionError:
228
+ // YouTrack has no transition graph, so this is a bad argument, not a dead end.
229
+ async function setStatus(key, statusName) {
230
+ if (!statusName) throw new TrackerError('setStatus requires a status name');
231
+ const from = await currentStage(key);
232
+ if (from && from.toLowerCase() === statusName.toLowerCase()) {
233
+ return { key, from, to: from, changed: false };
234
+ }
235
+ try {
236
+ await api(`/api/issues/${key}?fields=idReadable`, {
237
+ method: 'POST',
238
+ body: JSON.stringify({
239
+ customFields: [
240
+ { name: STAGE_FIELD, $type: 'StateIssueCustomField', value: { name: statusName } },
241
+ ],
242
+ }),
243
+ });
244
+ } catch (err) {
245
+ let names = [];
246
+ try {
247
+ names = (await stageNames()).map((v) => v.name);
248
+ } catch {
249
+ /* keep the original error if the bundle lookup also fails */
250
+ }
251
+ throw new TrackerError(
252
+ `Could not set ${key} ${STAGE_FIELD} to "${statusName}"` +
253
+ (names.length ? ` — valid: ${names.join(', ')}` : `: ${err.message}`),
254
+ );
255
+ }
256
+ return { key, from, to: statusName, changed: true };
257
+ }
258
+
259
+ // Move an issue and its subtasks. Subtasks first. A subtask that can't be
260
+ // moved is collected, not thrown, so one stuck child doesn't strand the rest.
261
+ async function closeIssue(key, statusName = 'Done') {
262
+ const issue = await get(key);
263
+ const results = [];
264
+ for (const subKey of issue.subtasks) {
265
+ try {
266
+ results.push({ ...(await setStatus(subKey, statusName)), ok: true });
267
+ } catch (err) {
268
+ if (!(err instanceof TrackerError)) throw err;
269
+ results.push({ key: subKey, ok: false, reason: err.message });
270
+ }
271
+ }
272
+ try {
273
+ results.push({ ...(await setStatus(key, statusName)), ok: true });
274
+ } catch (err) {
275
+ if (!(err instanceof TrackerError)) throw err;
276
+ results.push({ key, ok: false, reason: err.message });
277
+ }
278
+ return { key, status: statusName, results, failed: results.filter((r) => !r.ok).length };
279
+ }
280
+
281
+ async function setAssignee(key, username) {
282
+ if (!username) throw new TrackerError('setAssignee requires a username');
283
+ await api(`/api/issues/${key}?fields=idReadable`, {
284
+ method: 'POST',
285
+ body: JSON.stringify({
286
+ customFields: [
287
+ { name: ASSIGNEE_FIELD, $type: 'SingleUserIssueCustomField', value: { login: username } },
288
+ ],
289
+ }),
290
+ });
291
+ return { key, assignee: username };
292
+ }
293
+
294
+ // YouTrack renders markdown natively — nothing to convert.
295
+ function toMarkup(markdown) {
296
+ return markdown;
297
+ }
298
+
299
+ return {
300
+ name: 'youtrack',
301
+ projectKey,
302
+ issueTypes: {},
303
+ listProjects,
304
+ listStatuses,
305
+ createIssue,
306
+ createSubIssue,
307
+ get,
308
+ setFields,
309
+ appendDescription,
310
+ setStatus,
311
+ closeIssue,
312
+ setAssignee,
313
+ toMarkup,
314
+ };
315
+ }
316
+
317
+ module.exports = { createYouTrack, TrackerError, NoTransitionError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
package/schema.json CHANGED
@@ -22,16 +22,17 @@
22
22
  "properties": {
23
23
  "provider": {
24
24
  "type": "string",
25
- "enum": ["jira-server", "none"],
25
+ "enum": ["jira-server", "youtrack", "none"],
26
26
  "default": "none"
27
27
  },
28
28
  "projectKey": {
29
29
  "type": "string",
30
- "description": "Required unless provider is \"none\".",
30
+ "description": "Required unless provider is \"none\". For youtrack, falls back to YOUTRACK_PROJECT in .env.",
31
31
  "examples": ["PROOF", "GLO"]
32
32
  },
33
33
  "issueTypes": {
34
34
  "type": "object",
35
+ "description": "jira-server only. YouTrack has no Task/Sub-task type split.",
35
36
  "additionalProperties": false,
36
37
  "properties": {
37
38
  "story": { "type": "string", "default": "Story" },