nova-spec 1.0.5 → 1.0.7

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/lib/cli.js CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const yaml = require('js-yaml');
5
6
  const { init } = require('./installer.js');
6
7
  const { sync } = require('./sync.js');
7
8
  const jira = require('./jira.js');
8
9
 
10
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
11
+
9
12
  async function run() {
10
13
  const command = process.argv[2];
11
14
 
@@ -39,8 +42,15 @@ function runSource(args) {
39
42
  console.error('Usage: nova-spec source <relative-path>');
40
43
  process.exit(2);
41
44
  }
42
- const PACKAGE_ROOT = path.join(__dirname, '..');
43
- const abs = path.join(PACKAGE_ROOT, relPath);
45
+
46
+ // Resolve and CONTAIN within PACKAGE_ROOT. Rejects ../ traversal so prompt
47
+ // injection through $ARGUMENTS can't read arbitrary files via /nova-diff.
48
+ const abs = path.resolve(PACKAGE_ROOT, relPath);
49
+ const rootWithSep = PACKAGE_ROOT + path.sep;
50
+ if (abs !== PACKAGE_ROOT && !abs.startsWith(rootWithSep)) {
51
+ console.error(`✗ Path escapes the nova-spec package: ${relPath}`);
52
+ process.exit(1);
53
+ }
44
54
  if (!fs.existsSync(abs)) {
45
55
  console.error(`✗ ${relPath} is not part of the nova-spec package.`);
46
56
  process.exit(1);
@@ -56,31 +66,34 @@ async function runJira(args) {
56
66
  }
57
67
 
58
68
  const config = readJiraConfig();
59
- if (!config) {
60
- console.error('Jira not configured. Run `npx nova-spec init` and enable Jira.');
69
+ if (!config.ok) {
70
+ console.error(`${config.error}`);
71
+ if (config.hint) console.error(` ${config.hint}`);
61
72
  process.exit(1);
62
73
  }
63
74
 
64
75
  try {
65
76
  let result;
77
+ const baseArgs = { url: config.url, email: config.email, token: config.token };
66
78
  if (subcmd === 'get') {
67
79
  if (!rest[0]) throw new Error('Usage: nova-spec jira get <TICKET>');
68
- result = await jira.getIssueAsync({ ...config, ticket: rest[0] });
80
+ result = await jira.getIssueAsync({ ...baseArgs, ticket: rest[0] });
69
81
  } else if (subcmd === 'transitions') {
70
82
  if (!rest[0]) throw new Error('Usage: nova-spec jira transitions <TICKET>');
71
- result = await jira.listTransitionsAsync({ ...config, ticket: rest[0] });
83
+ result = await jira.listTransitionsAsync({ ...baseArgs, ticket: rest[0] });
72
84
  } else if (subcmd === 'transition') {
73
85
  if (!rest[0] || !rest[1]) throw new Error('Usage: nova-spec jira transition <TICKET> <ID>');
74
- result = await jira.transitionAsync({ ...config, ticket: rest[0], transitionId: rest[1] });
86
+ result = await jira.transitionAsync({ ...baseArgs, ticket: rest[0], transitionId: rest[1] });
75
87
  } else {
76
88
  throw new Error(`Unknown jira subcommand: ${subcmd}`);
77
89
  }
78
90
  if (result != null) console.log(JSON.stringify(result, null, 2));
79
91
  } catch (err) {
80
92
  if (err.status === 401) {
81
- console.error(' ✗ Jira 401: invalid credentials. Regenerate JIRA_API_TOKEN.');
93
+ console.error(' ✗ Jira 401: invalid credentials. Regenerate JIRA_API_TOKEN at');
94
+ console.error(' https://id.atlassian.com/manage-profile/security/api-tokens');
82
95
  } else if (err.status === 404) {
83
- console.error(` ✗ Jira 404: ticket not found.`);
96
+ console.error(` ✗ Jira 404: ${rest[0] || 'ticket'} not found.`);
84
97
  } else {
85
98
  console.error(` ✗ ${err.message}`);
86
99
  }
@@ -89,13 +102,17 @@ async function runJira(args) {
89
102
  }
90
103
 
91
104
  async function runForge(args) {
92
- const { detectForge, buildPrCommand, reviewTerm, checkCliAvailable } = require('./forge.js');
105
+ const { detectForge, buildPrCommand, reviewTerm, checkCliAvailable, pickCli } = require('./forge.js');
93
106
  const [subcmd, ...rest] = args;
94
107
 
95
108
  if (subcmd === 'detect') {
96
109
  const f = detectForge();
97
- if (f) console.log(f);
98
- else process.exit(1);
110
+ if (f) {
111
+ console.log(f);
112
+ } else {
113
+ console.error('✗ No git remote `origin` or unsupported forge.');
114
+ process.exit(1);
115
+ }
99
116
  return;
100
117
  }
101
118
 
@@ -107,6 +124,12 @@ async function runForge(args) {
107
124
  process.exit(2);
108
125
  }
109
126
  const cli = config.cli !== 'auto' ? config.cli : null;
127
+ const resolvedCli = pickCli(forge, cli);
128
+ if (resolvedCli && !checkCliAvailable(resolvedCli)) {
129
+ console.error(` ✗ ${resolvedCli} is not installed or not on PATH.`);
130
+ console.error(` Install: ${resolvedCli === 'gh' ? 'https://cli.github.com/' : 'https://gitlab.com/gitlab-org/cli'}`);
131
+ process.exit(127);
132
+ }
110
133
  const [title, body, base] = rest;
111
134
  if (!title || !body || !base) {
112
135
  console.error('Usage: nova-spec forge pr-command <title> <body> <base>');
@@ -127,50 +150,65 @@ async function runForge(args) {
127
150
  process.exit(2);
128
151
  }
129
152
 
130
- function readJiraConfig() {
153
+ // Parse novaspec/config.yml into a plain JS object. Returns null if missing.
154
+ // Returns null + warns if YAML is malformed (don't crash callers).
155
+ function loadConfig() {
131
156
  const configPath = path.join(process.cwd(), 'novaspec', 'config.yml');
132
157
  if (!fs.existsSync(configPath)) return null;
133
- const text = fs.readFileSync(configPath, 'utf8');
158
+ try {
159
+ return yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
160
+ } catch (err) {
161
+ return { __parseError: err.message };
162
+ }
163
+ }
164
+
165
+ function readJiraConfig() {
166
+ const cfg = loadConfig();
167
+ if (!cfg) {
168
+ return { ok: false, error: 'novaspec/config.yml not found.', hint: 'Run: npx nova-spec init' };
169
+ }
170
+ if (cfg.__parseError) {
171
+ return { ok: false, error: `novaspec/config.yml has invalid YAML: ${cfg.__parseError}` };
172
+ }
134
173
 
135
- const url = extractYamlScalar(text, 'jira', 'url');
136
- const email = extractYamlScalar(text, 'jira', 'email');
137
- let token = extractYamlScalar(text, 'jira', 'token');
174
+ const jiraCfg = cfg.jira || {};
175
+ const { url, email } = jiraCfg;
176
+ let token = jiraCfg.token;
138
177
 
139
- if (token && /^\$\{[A-Z_]+\}$/.test(token)) {
140
- const envName = token.slice(2, -1);
141
- token = process.env[envName];
142
- } else if (token === '${JIRA_API_TOKEN}' || !token) {
143
- token = process.env.JIRA_API_TOKEN;
178
+ // Resolve ${ENV_VAR} reference
179
+ if (typeof token === 'string') {
180
+ const m = token.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
181
+ if (m) token = process.env[m[1]];
182
+ }
183
+ if (!token) token = process.env.JIRA_API_TOKEN;
184
+
185
+ if (!url || !email) {
186
+ return {
187
+ ok: false,
188
+ error: 'Jira not configured.',
189
+ hint: 'Set jira.url and jira.email in novaspec/config.yml, or run: npx nova-spec init',
190
+ };
191
+ }
192
+ if (!token) {
193
+ return {
194
+ ok: false,
195
+ error: 'JIRA_API_TOKEN env var is not set.',
196
+ hint: 'Export it in your shell rc. Get one at https://id.atlassian.com/manage-profile/security/api-tokens',
197
+ };
144
198
  }
145
199
 
146
- if (!url || !email || !token) return null;
147
- return { url, email, token };
200
+ return { ok: true, url, email, token };
148
201
  }
149
202
 
150
203
  function readForgeConfig() {
151
- const configPath = path.join(process.cwd(), 'novaspec', 'config.yml');
204
+ const cfg = loadConfig();
152
205
  const defaults = { type: 'auto', cli: 'auto' };
153
- if (!fs.existsSync(configPath)) return defaults;
154
- const text = fs.readFileSync(configPath, 'utf8');
206
+ if (!cfg || cfg.__parseError) return defaults;
207
+ const forge = cfg.forge || {};
155
208
  return {
156
- type: extractYamlScalar(text, 'forge', 'type') || 'auto',
157
- cli: extractYamlScalar(text, 'forge', 'cli') || 'auto',
209
+ type: forge.type || 'auto',
210
+ cli: forge.cli || 'auto',
158
211
  };
159
212
  }
160
213
 
161
- function extractYamlScalar(text, parent, key) {
162
- // Minimal YAML reader for `parent: \n key: value` blocks. Strips quotes.
163
- const re = new RegExp(`^${parent}:\\s*$([\\s\\S]*?)(?=^\\S|\\Z)`, 'm');
164
- const block = text.match(re);
165
- if (!block) return null;
166
- const lineRe = new RegExp(`^\\s+${key}:\\s*(.*)$`, 'm');
167
- const match = block[1].match(lineRe);
168
- if (!match) return null;
169
- let value = match[1].trim();
170
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
171
- value = value.slice(1, -1);
172
- }
173
- return value;
174
- }
175
-
176
- module.exports = { run };
214
+ module.exports = { run, loadConfig, readJiraConfig, readForgeConfig };
package/lib/forge.js CHANGED
@@ -2,20 +2,45 @@
2
2
 
3
3
  const { execSync } = require('child_process');
4
4
 
5
+ function classifyRemote(remote) {
6
+ if (!remote) return null;
7
+ if (/github\.com[:/]/i.test(remote)) return 'github';
8
+ if (/gitlab[.-][\w.-]+[:/]|gitlab\.com[:/]/i.test(remote)) return 'gitlab';
9
+ if (/bitbucket\.org[:/]/i.test(remote)) return 'bitbucket';
10
+ return null;
11
+ }
12
+
5
13
  function detectForge(cwd = process.cwd()) {
6
- let remote = '';
14
+ // Try `origin` first (the common case).
7
15
  try {
8
- remote = execSync('git remote get-url origin', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
16
+ const remote = execSync('git remote get-url origin', {
17
+ cwd,
18
+ stdio: ['ignore', 'pipe', 'ignore'],
19
+ })
9
20
  .toString()
10
21
  .trim();
22
+ const hit = classifyRemote(remote);
23
+ if (hit) return hit;
11
24
  } catch (_) {
12
- return null;
25
+ // fall through to multi-remote scan
26
+ }
27
+
28
+ // Fallback: walk every remote URL. Useful for fork workflows where the
29
+ // primary remote is named `upstream` or `gh` instead of `origin`.
30
+ try {
31
+ const lines = execSync('git remote -v', { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
32
+ .toString()
33
+ .split('\n');
34
+ for (const line of lines) {
35
+ const m = line.match(/^\S+\s+(\S+)\s+\((fetch|push)\)$/);
36
+ if (!m) continue;
37
+ const hit = classifyRemote(m[1]);
38
+ if (hit) return hit;
39
+ }
40
+ } catch (_) {
41
+ /* not a git repo or git not available */
13
42
  }
14
43
 
15
- if (!remote) return null;
16
- if (/github\.com[:/]/i.test(remote)) return 'github';
17
- if (/gitlab[.-][\w.-]+[:/]|gitlab\.com[:/]/i.test(remote)) return 'gitlab';
18
- if (/bitbucket\.org[:/]/i.test(remote)) return 'bitbucket';
19
44
  return null;
20
45
  }
21
46
 
package/lib/installer.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  buildHookCommand,
9
9
  HOOK_MARKER,
10
10
  MANIFEST_FILE,
11
+ writeAtomic,
11
12
  } = require('./sync.js');
12
13
  const { detectForge } = require('./forge.js');
13
14
  const { listTransitionsAsync } = require('./jira.js');
@@ -20,6 +21,33 @@ const CLAUDE_MD_SRC = path.join(PACKAGE_ROOT, 'CLAUDE.md');
20
21
  async function init() {
21
22
  console.log('\n nova-spec installer\n ───────────────────\n');
22
23
 
24
+ // Detect an existing installation in the current directory or globally.
25
+ // Re-running `init` from scratch would clobber the user's customizations,
26
+ // because the wizard path uses copyTree which doesn't hash-compare like
27
+ // sync does. So we refuse and route to sync instead.
28
+ const home = process.env.HOME || process.env.USERPROFILE;
29
+ const existingHere = fs.existsSync(path.join(process.cwd(), 'novaspec', MANIFEST_FILE));
30
+ const existingGlobal =
31
+ home && fs.existsSync(path.join(home, '.claude', 'novaspec', MANIFEST_FILE));
32
+
33
+ if (existingHere || existingGlobal) {
34
+ const where = existingHere ? `here (${process.cwd()})` : `globally (${home}/.claude)`;
35
+ console.log(` ⚠ nova-spec is already installed ${where}.`);
36
+ console.log(' Running init again would overwrite local customizations.');
37
+ console.log();
38
+ const ok = await confirm({
39
+ message: 'Run `sync` instead (preserves your edits via hash-compare)?',
40
+ default: true,
41
+ });
42
+ if (!ok) {
43
+ console.log(' Cancelled. To reinstall from scratch, remove `novaspec/` manually first.');
44
+ return;
45
+ }
46
+ const { sync } = require('./sync.js');
47
+ await sync(existingHere ? process.cwd() : path.join(home, '.claude'));
48
+ return;
49
+ }
50
+
23
51
  const scope = await select({
24
52
  message: 'Where do you want to install nova-spec?',
25
53
  choices: [
@@ -29,7 +57,6 @@ async function init() {
29
57
  ],
30
58
  });
31
59
 
32
- const home = process.env.HOME || process.env.USERPROFILE;
33
60
  if (scope === 'global' && !home) {
34
61
  console.error(' ✗ HOME / USERPROFILE not set; cannot resolve global install path.');
35
62
  process.exit(1);
@@ -99,7 +126,7 @@ async function init() {
99
126
 
100
127
  // Manifest reflects what we just shipped from the package.
101
128
  const manifest = generateManifest(PACKAGE_ROOT);
102
- fs.writeFileSync(
129
+ writeAtomic(
103
130
  path.join(destDir, 'novaspec', MANIFEST_FILE),
104
131
  JSON.stringify(manifest, null, 2) + '\n',
105
132
  );
@@ -370,13 +397,13 @@ function writeClaudeSettings(claudeDir) {
370
397
  settings.hooks.SessionStart.push({ hooks: [novaHook] });
371
398
  }
372
399
 
373
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
400
+ writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n');
374
401
  }
375
402
 
376
403
  function writeOpenCodeSettings(opencodeDir) {
377
404
  const settingsPath = path.join(opencodeDir, 'settings.local.json');
378
405
  if (fs.existsSync(settingsPath)) return;
379
- fs.writeFileSync(
406
+ writeAtomic(
380
407
  settingsPath,
381
408
  JSON.stringify(
382
409
  {
@@ -428,7 +455,7 @@ function writeConfig(destDir, { ticketSystem, jiraConfig, baseBranch, forgeType
428
455
  ` done: ${yamlString(jiraConfig.done_transition_id)}`,
429
456
  ].join('\n') + '\n';
430
457
 
431
- fs.writeFileSync(configPath, content);
458
+ writeAtomic(configPath, content);
432
459
  }
433
460
 
434
461
  function ensureGitignore(destDir) {
package/lib/jira.js CHANGED
@@ -31,6 +31,8 @@ function request({ url, method = 'GET', email, token, body = null }) {
31
31
  options.headers['Content-Type'] = 'application/json';
32
32
  }
33
33
 
34
+ options.timeout = 15000;
35
+
34
36
  const req = https.request(options, (res) => {
35
37
  let data = '';
36
38
  res.setEncoding('utf8');
@@ -38,7 +40,10 @@ function request({ url, method = 'GET', email, token, body = null }) {
38
40
  res.on('end', () => {
39
41
  const status = res.statusCode || 0;
40
42
  if (status >= 400) {
41
- const error = new Error(`Jira HTTP ${status}: ${data || res.statusMessage}`);
43
+ // Never include the response body in the message — it can contain
44
+ // request metadata (echoed headers from misconfigured proxies / WAFs)
45
+ // that leaks Authorization. Surface only status + reason phrase.
46
+ const error = new Error(`Jira HTTP ${status}: ${res.statusMessage || 'request failed'}`);
42
47
  error.status = status;
43
48
  return reject(error);
44
49
  }
@@ -51,6 +56,9 @@ function request({ url, method = 'GET', email, token, body = null }) {
51
56
  });
52
57
  });
53
58
 
59
+ req.on('timeout', () => {
60
+ req.destroy(new Error('Jira request timed out (15s)'));
61
+ });
54
62
  req.on('error', (err) => reject(err));
55
63
  if (body) req.write(JSON.stringify(body));
56
64
  req.end();
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const { writeAtomic } = require('./sync.js');
4
5
 
5
6
  // Each migration is { from: 'X.Y', to: 'X.Y', apply: (text) => text }.
6
7
  // They run in order; each one transforms the YAML text. Idempotent — applying
@@ -26,10 +27,13 @@ const MIGRATIONS = [
26
27
  apply: (text) => {
27
28
  // Move done_transition_id under jira.transitions.done while keeping the
28
29
  // legacy key as fallback so older skills don't break mid-migration.
30
+ // The value capture is intentionally non-greedy and strips trailing
31
+ // whitespace + an optional inline `# comment` so users who hand-edited
32
+ // the file with annotations don't get their comment merged into the value.
29
33
  return text.replace(
30
- /^(\s*)done_transition_id:\s*"?([^"\n]+)"?\s*$/m,
34
+ /^(\s*)done_transition_id:\s*"?([^"#\n]+?)"?\s*(?:#[^\n]*)?$/m,
31
35
  (_, indent, value) =>
32
- `${indent}done_transition_id: "${value}"\n${indent}transitions:\n${indent} done: "${value}"`,
36
+ `${indent}done_transition_id: "${value.trim()}"\n${indent}transitions:\n${indent} done: "${value.trim()}"`,
33
37
  );
34
38
  },
35
39
  },
@@ -50,7 +54,7 @@ function migrateConfig(configPath) {
50
54
  }
51
55
 
52
56
  if (current !== original) {
53
- fs.writeFileSync(configPath, current);
57
+ writeAtomic(configPath, current);
54
58
  }
55
59
 
56
60
  return { applied };
package/lib/sync.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
- const os = require('os');
7
6
 
8
7
  const MANIFEST_FILE = '.nova-manifest.json';
9
8
  const HOOK_MARKER = '# nova-spec auto-sync';
@@ -11,7 +10,29 @@ const FRAMEWORK_FILES = ['AGENTS.md', 'CLAUDE.md'];
11
10
  // Paths the user owns or that are auto-generated — never tracked or overwritten.
12
11
  const NEVER_TRACK = new Set(['novaspec/config.yml', `novaspec/${MANIFEST_FILE}`]);
13
12
 
13
+ // Write content to a file via tmp + rename, so partial writes (SIGKILL,
14
+ // disk-full mid-write) never leave a half-written file behind. Critical for
15
+ // the manifest and settings.local.json — corrupted JSON there kills the
16
+ // framework or the IDE startup.
17
+ function writeAtomic(filePath, content) {
18
+ const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
19
+ fs.writeFileSync(tmp, content);
20
+ try {
21
+ fs.renameSync(tmp, filePath);
22
+ } catch (err) {
23
+ try { fs.unlinkSync(tmp); } catch (_) {}
24
+ throw err;
25
+ }
26
+ }
27
+
14
28
  function hashFile(filePath) {
29
+ // Refuse to follow symlinks. If the user replaced a tracked file with a
30
+ // symlink, hash the link target STRING (not the resolved file) so the
31
+ // change is detected as "modified" and the framework leaves it alone.
32
+ const stat = fs.lstatSync(filePath);
33
+ if (stat.isSymbolicLink()) {
34
+ return crypto.createHash('sha256').update(fs.readlinkSync(filePath)).digest('hex');
35
+ }
15
36
  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
16
37
  }
17
38
 
@@ -70,9 +91,18 @@ function readManifest(manifestPath) {
70
91
  const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
71
92
  return { files: data.files || {}, version: data.version };
72
93
  } catch (err) {
73
- console.error(` ✗ Could not parse ${manifestPath}: ${err.message}`);
74
- console.error(' Aborting sync to avoid clobbering project state.');
75
- process.exit(1);
94
+ // Corrupt manifest back it up and continue with empty. Combined with the
95
+ // conservative sync rule (no previousShipped SKIP), this preserves every
96
+ // local file. Replaces a hard process.exit that left users stuck.
97
+ const backup = `${manifestPath}.corrupt.${Date.now()}`;
98
+ try {
99
+ fs.renameSync(manifestPath, backup);
100
+ console.warn(` ⚠ Manifest was corrupt; backed up to ${path.basename(backup)} and continuing.`);
101
+ console.warn(' Until the manifest is rebuilt, sync will skip every file that differs from the package.');
102
+ } catch (renameErr) {
103
+ console.warn(` ⚠ Manifest is corrupt and could not be backed up: ${renameErr.message}`);
104
+ }
105
+ return { files: {} };
76
106
  }
77
107
  }
78
108
 
@@ -95,6 +125,11 @@ async function sync(destDir = process.cwd()) {
95
125
  const removed = [];
96
126
  const skippedRemoved = [];
97
127
 
128
+ // Migrate config.yml BEFORE writing the manifest, so a crash here doesn't
129
+ // leave the manifest claiming "new version applied" while config is stale.
130
+ const { migrateConfig } = require('./migrate-config.js');
131
+ migrateConfig(path.join(novaspecDest, 'config.yml'));
132
+
98
133
  // Update / create / skip files
99
134
  for (const [rel, srcAbs] of Object.entries(sources)) {
100
135
  const destAbs = path.join(destDir, rel);
@@ -111,9 +146,11 @@ async function sync(destDir = process.cwd()) {
111
146
  if (currentHash === newStockHash) continue; // already up to date
112
147
 
113
148
  const previousShipped = oldManifest.files[rel];
114
- const isUntouched = !previousShipped || currentHash === previousShipped;
115
149
 
116
- if (isUntouched) {
150
+ // Only overwrite when we KNOW the user didn't touch it: previousShipped
151
+ // must be recorded AND match the current disk hash. If the manifest is
152
+ // missing or doesn't mention this file, treat as user-owned and skip.
153
+ if (previousShipped && currentHash === previousShipped) {
117
154
  fs.copyFileSync(srcAbs, destAbs);
118
155
  updated.push(rel);
119
156
  } else {
@@ -145,11 +182,11 @@ async function sync(destDir = process.cwd()) {
145
182
  for (const rel of skippedRemoved) {
146
183
  if (oldManifest.files[rel]) newManifest.files[rel] = oldManifest.files[rel];
147
184
  }
148
- fs.writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
185
+ writeAtomic(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
149
186
 
150
- // Migrate config.yml (idempotent)
151
- const { migrateConfig } = require('./migrate-config.js');
152
- migrateConfig(path.join(novaspecDest, 'config.yml'));
187
+ // Refresh runtime dirs: re-link .claude/{commands,skills,agents} if a previous
188
+ // install on Windows fell back to copy. Idempotent — no-op when already linked.
189
+ refreshRuntimeLinks(destDir);
153
190
 
154
191
  // Refresh SessionStart hook in any installed runtime
155
192
  ensureSessionStartHook(destDir);
@@ -187,8 +224,10 @@ function printReport({ version, created, updated, skipped, removed, skippedRemov
187
224
  }
188
225
 
189
226
  function buildHookCommand() {
190
- const logPath = path.join(os.homedir(), '.nova-spec.log');
191
- return `npx nova-spec@latest sync >> ${logPath} 2>&1 || true ${HOOK_MARKER}`;
227
+ // Use $HOME so the shell resolves it at run time. Avoids baking the
228
+ // installer's home dir into the consumer's settings and survives paths
229
+ // with spaces / apostrophes (common on macOS and Windows usernames).
230
+ return `npx nova-spec@latest sync >> "$HOME/.nova-spec.log" 2>&1 || true ${HOOK_MARKER}`;
192
231
  }
193
232
 
194
233
  function ensureSessionStartHook(destDir) {
@@ -200,6 +239,12 @@ function ensureSessionStartHook(destDir) {
200
239
  path.join(destDir, '.opencode', 'settings.local.json'),
201
240
  ].filter(p => fs.existsSync(path.dirname(p)));
202
241
 
242
+ // Any hook command running `npx nova-spec[@<tag>] sync` is OURS, even if
243
+ // it doesn't carry the modern marker (older installs from before v1.0.2).
244
+ // Strip all of them and append one canonical entry — guaranteed dedupe.
245
+ const isNovaHook = (h) =>
246
+ h && typeof h.command === 'string' && /\bnpx\s+nova-spec(@\S+)?\s+sync\b/.test(h.command);
247
+
203
248
  for (const settingsPath of targets) {
204
249
  let settings = {};
205
250
  if (fs.existsSync(settingsPath)) {
@@ -215,24 +260,73 @@ function ensureSessionStartHook(destDir) {
215
260
  settings.hooks = settings.hooks || {};
216
261
  settings.hooks.SessionStart = settings.hooks.SessionStart || [];
217
262
 
218
- let updated = false;
219
263
  for (const group of settings.hooks.SessionStart) {
220
- group.hooks = group.hooks || [];
221
- for (let i = 0; i < group.hooks.length; i++) {
222
- if (group.hooks[i]?.command?.includes(HOOK_MARKER)) {
223
- if (group.hooks[i].command !== hookCommand) {
224
- group.hooks[i] = novaHook;
225
- }
226
- updated = true;
227
- }
228
- }
264
+ if (!Array.isArray(group.hooks)) continue;
265
+ group.hooks = group.hooks.filter((h) => !isNovaHook(h));
229
266
  }
267
+ // Drop groups whose hooks array is now empty
268
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
269
+ (g) => Array.isArray(g.hooks) && g.hooks.length > 0,
270
+ );
271
+
272
+ settings.hooks.SessionStart.push({ hooks: [novaHook] });
230
273
 
231
- if (!updated) {
232
- settings.hooks.SessionStart.push({ hooks: [novaHook] });
274
+ writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n');
275
+ }
276
+ }
277
+
278
+ function refreshRuntimeLinks(destDir) {
279
+ // Re-establish symlinks under .claude/ and .opencode/ if a previous install
280
+ // copied them instead (Windows EPERM fallback). On systems where symlinks
281
+ // are permitted this is a no-op; on Windows without Developer Mode it
282
+ // refreshes the copy with the latest content.
283
+ const novaspecDir = path.join(destDir, 'novaspec');
284
+ if (!fs.existsSync(novaspecDir)) return;
285
+
286
+ const runtimeDirs = ['.claude', '.opencode']
287
+ .map((d) => path.join(destDir, d))
288
+ .filter((d) => fs.existsSync(d));
289
+
290
+ for (const rt of runtimeDirs) {
291
+ for (const name of ['commands', 'skills', 'agents']) {
292
+ const link = path.join(rt, name);
293
+ const target = path.relative(rt, path.join(novaspecDir, name));
294
+
295
+ let isLink = false;
296
+ try {
297
+ isLink = fs.lstatSync(link).isSymbolicLink();
298
+ } catch (_) {
299
+ /* doesn't exist — fall through to create */
300
+ }
301
+
302
+ if (isLink) continue; // already linked, nothing to do
303
+
304
+ // It's a directory of copied files (Windows fallback) or missing.
305
+ // Try to make it a symlink; if that fails, refresh the copy.
306
+ fs.rmSync(link, { recursive: true, force: true });
307
+ const symlinkType = process.platform === 'win32' ? 'junction' : null;
308
+ try {
309
+ if (symlinkType) fs.symlinkSync(target, link, symlinkType);
310
+ else fs.symlinkSync(target, link);
311
+ } catch (err) {
312
+ if (err.code === 'EPERM' || err.code === 'EACCES') {
313
+ copyTreeShallow(path.join(novaspecDir, name), link);
314
+ } else {
315
+ throw err;
316
+ }
317
+ }
233
318
  }
319
+ }
320
+ }
234
321
 
235
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
322
+ function copyTreeShallow(src, dest) {
323
+ fs.mkdirSync(dest, { recursive: true });
324
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
325
+ if (entry.isSymbolicLink()) continue;
326
+ const s = path.join(src, entry.name);
327
+ const d = path.join(dest, entry.name);
328
+ if (entry.isDirectory()) copyTreeShallow(s, d);
329
+ else fs.copyFileSync(s, d);
236
330
  }
237
331
  }
238
332
 
@@ -243,6 +337,8 @@ module.exports = {
243
337
  buildHookCommand,
244
338
  readManifest,
245
339
  collectPackageFiles,
340
+ refreshRuntimeLinks,
341
+ writeAtomic,
246
342
  HOOK_MARKER,
247
343
  FRAMEWORK_FILES,
248
344
  MANIFEST_FILE,
@@ -32,26 +32,36 @@ If `context/` doesn't exist, return:
32
32
  ```
33
33
  And stop.
34
34
 
35
- ### 2. Read each service
35
+ ### 2. Read stack and conventions (always)
36
+
37
+ Always read these two files when they exist (they're scaffolded by `npx nova-spec init`):
38
+ - `context/stack.md` — tech stack + key dependencies
39
+ - `context/conventions.md` — code style + patterns to follow / avoid
40
+
41
+ If either is missing, note it as a gap but continue.
42
+
43
+ ### 3. Read each service
36
44
 
37
45
  For each service in `$ARGUMENTS`:
38
46
  - Read `context/services/<service>.md` if it exists.
39
47
  - If it doesn't exist, note it as a gap.
40
48
 
41
- ### 3. Pick relevant decisions and gotchas
49
+ ### 4. Pick relevant decisions and gotchas
42
50
 
43
51
  - `ls context/decisions/` (no `-R`, doesn't enter `archived/`).
44
52
  - `ls context/gotchas/`.
45
53
  - Pick 3-5 files from each whose name is relevant to the ticket's scope or affected services. Don't force connections.
46
54
  - Read the chosen ones.
47
55
 
48
- ### 4. Return summary
56
+ ### 5. Return summary
49
57
 
50
58
  Return exactly this structure, without extra text:
51
59
 
52
60
  ```
53
61
  ## Loaded context
54
62
 
63
+ **Stack**: <✓ loaded | ✗ missing>
64
+ **Conventions**: <✓ loaded | ✗ missing>
55
65
  **Services**: <list with ✓ if services/<svc>.md exists, ✗ otherwise>
56
66
  **Decisions read**: <list of files or "none">
57
67
  **Gotchas read**: <list of files or "none">
@@ -16,17 +16,40 @@ the user. Don't make commits. Don't modify code.
16
16
  - `context/changes/active/<ticket-id>/proposal.md`
17
17
  - `context/changes/active/<ticket-id>/tasks.md`
18
18
  - Read live decisions in `context/decisions/` (all relevant ones, **without entering `archived/`**)
19
- - Get the full diff by combining:
20
- - Committed changes on the branch: `git diff <branch.base>...HEAD`
21
- - Uncommitted changes (working tree + staged): `git diff HEAD`
22
- - Read `novaspec/config.yml → branch.base` to determine the base branch (default: `main`)
23
- - If both diffs are empty, warn: "⚠️ Empty diff: no changes on the branch or in the working tree."
19
+ - Read `novaspec/config.yml → branch.base` to determine the base branch (default: `main`)
24
20
 
25
21
  If any artifact is missing, stop with:
26
22
  ```
27
23
  ⛔ Review aborted: missing <file>. Run the corresponding step first.
28
24
  ```
29
25
 
26
+ ### 1b. Run deterministic checks (BLOCKING)
27
+
28
+ Before any LLM-based review, run the deterministic guardrail:
29
+
30
+ ```bash
31
+ bash novaspec/guardrails/review-checks.sh <ticket-id> <base-branch>
32
+ ```
33
+
34
+ This runs (in order): diff non-empty, all `## Files to touch` declared in
35
+ `tasks.md` actually appear in the diff, `npm run lint` if defined, `npm test`
36
+ if defined.
37
+
38
+ If the script exits non-zero, **mark the verdict as `✗ Needs fixes`
39
+ immediately** and write the script's output verbatim into `review.md` under
40
+ a `## Pre-review checks` section. Do NOT proceed to the 4-axis LLM review —
41
+ the verdict is already negative. Skip straight to Step 3 (write the report)
42
+ then Step 4 (terminate).
43
+
44
+ ### 1c. Get the full diff
45
+
46
+ Combine:
47
+ - Committed changes on the branch: `git diff <branch.base>...HEAD`
48
+ - Uncommitted changes (working tree + staged): `git diff HEAD`
49
+
50
+ (If `review-checks.sh` already failed at "Diff non-empty", the verdict is
51
+ already locked — skip the diff fetch.)
52
+
30
53
  ### 2. Review across 4 axes
31
54
 
32
55
  **Spec compliance**
@@ -6,7 +6,7 @@ You execute `tasks.md` in order, task by task.
6
6
 
7
7
  ## Guardrail
8
8
 
9
- `checklist.md` → 1, 3 (branch-pattern, tasks-exist)
9
+ `checklist.md` → 0, 1, 2, 3 (nova-installed, branch-pattern, proposal-exists, tasks-exist)
10
10
 
11
11
  ## Precondition
12
12
 
@@ -54,10 +54,19 @@ Show the user:
54
54
  **If tasks remain**: continue with the next one without asking permission.
55
55
 
56
56
  **Stop only if**:
57
- - There's a blocker (error, unhandled exception)
57
+ - There's a blocker (error, unhandled exception, test fails you can't fix)
58
58
  - There's an open decision in the spec
59
59
  - You have a question only the user can answer
60
60
 
61
+ **When you stop on a failed task**, before stopping:
62
+ 1. Mark the failing task in `tasks.md` as `- [!]` (instead of `- [x]` or `- [ ]`)
63
+ 2. Append a one-line note next to it explaining why
64
+ 3. Tell the user what failed and what they need to decide
65
+
66
+ This way, when the user later re-runs `/nova-build`, the framework picks
67
+ up at the failed task (not from the first `- [ ]` after it) and the user
68
+ can see at a glance what blocked the flow.
69
+
61
70
  **If it was the last one**:
62
71
  > "All complete. Run `/nova-review`."
63
72
 
@@ -1,64 +1,80 @@
1
1
  ---
2
- description: Show differences between your custom override and the current core version
3
- argument-hint: <skill|command|agent name>
2
+ description: Show what changed upstream for a framework file you've edited locally
3
+ argument-hint: <relative-path-from-repo-root>
4
4
  ---
5
5
 
6
- You are a **read-only** command. Show what changed between the user's custom
7
- override and the upstream core version for `$ARGUMENTS`.
6
+ You are a **read-only** command. You compare the user's local version of a
7
+ framework file against the version shipped by the installed nova-spec
8
+ package, so they can decide whether to merge upstream changes.
8
9
 
9
- ## Steps
10
-
11
- ### 1. Resolve paths
10
+ ## When this is invoked
12
11
 
13
- - Custom path: `novaspec/custom/<type>/$ARGUMENTS/` (check `skills/`, `commands/`, `agents/` in order)
14
- - Core path: `novaspec/<type>/$ARGUMENTS/`
12
+ `npx nova-spec sync` reports files with local edits under the heading
13
+ `⚠ N file(s) NOT updated (you have local edits)` and points the user at
14
+ this command via:
15
15
 
16
- If the custom path doesn't exist:
17
16
  ```
18
- No custom override found for "$ARGUMENTS".
19
- Nothing to diff.
17
+ /nova-diff <path>
20
18
  ```
21
- Stop here.
22
19
 
23
- ### 2. Check manifest
20
+ For example: `/nova-diff novaspec/templates/pr-body.md`.
21
+
22
+ ## Steps
23
+
24
+ ### 1. Resolve the path
24
25
 
25
- Read `novaspec/.nova-manifest.json`. Look for `$ARGUMENTS` in `outdated_customs`.
26
+ `$ARGUMENTS` is a path relative to the repo root, e.g.
27
+ `novaspec/templates/pr-body.md` or `AGENTS.md`.
26
28
 
27
- If not in `outdated_customs`:
29
+ Verify the local file exists. If not:
28
30
  ```
29
- Your custom "$ARGUMENTS" matches the current core version.
30
- No upstream changes since your override was created.
31
+ No local file at "<path>". Nothing to diff.
31
32
  ```
32
- Stop here.
33
+ Stop.
33
34
 
34
- ### 3. Show diff
35
+ ### 2. Locate the upstream (package) version
35
36
 
36
37
  Run:
38
+
37
39
  ```bash
38
- diff -u novaspec/<type>/$ARGUMENTS/SKILL.md novaspec/custom/<type>/$ARGUMENTS/SKILL.md
40
+ npx nova-spec source "$ARGUMENTS"
39
41
  ```
40
42
 
41
- Present the diff clearly, highlighting:
42
- - Lines added in your custom version (your changes)
43
- - Lines changed in the new core version that your custom doesn't have
43
+ This prints the absolute path to the file **inside the installed nova-spec
44
+ package** (somewhere like `~/.npm/_npx/<hash>/node_modules/nova-spec/<path>`).
45
+ Exit code 1 + `✗ ... is not part of the nova-spec package.` means the path
46
+ is not a framework file — tell the user it isn't tracked by nova-spec.
47
+
48
+ ### 3. Show the diff
49
+
50
+ ```bash
51
+ diff -u "<package-path>" "<local-path>"
52
+ ```
53
+
54
+ Read both files and present the diff clearly:
55
+ - Lines marked `+` in YOUR copy are your edits (keep these unless you want to revert).
56
+ - Lines marked `+` in the package copy are upstream changes (consider merging).
44
57
 
45
58
  ### 4. Decision prompt
46
59
 
60
+ Ask the user:
61
+
47
62
  ```
48
63
  Options:
49
- [K] Keep your version — ignore upstream changes
50
- [M] Merge manually — I'll open both files for you to edit
51
- [R] Replace with core — discard your custom, use new core version
64
+ [K] Keep your version — ignore the upstream change (no-op)
65
+ [M] Merge manually — I'll print both paths so you can edit
66
+ [R] Replace with the package version — discard your local edits
52
67
  ```
53
68
 
54
- Wait for user selection.
69
+ Wait for an explicit selection.
55
70
 
56
- - **[K]**: Update manifest to mark as reviewed. No file changes.
57
- - **[M]**: Show both file paths and remind user to run `/nova-sync` after merging.
58
- - **[R]**: Delete `novaspec/custom/<type>/$ARGUMENTS/` and confirm.
71
+ - **[K]**: do nothing. Next `/nova-sync` will skip the file again with the same warning.
72
+ - **[M]**: print both file paths and remind the user that after they merge, sync may still flag the file as edited (until its hash again matches the shipped version).
73
+ - **[R]**: copy the package version over the local file. **Ask for confirmation before overwriting.** After replacement, the next sync will treat it as up to date.
59
74
 
60
75
  ## Rules
61
76
 
62
- - Never auto-apply changes.
63
- - Always wait for explicit user decision.
64
- - For [R], ask for confirmation before deleting.
77
+ - Never auto-apply. Always wait for explicit selection.
78
+ - For `[R]`, double-confirm before overwriting — destructive.
79
+ - If the user passes a path that includes `..`, the `source` CLI will
80
+ refuse it. Trust that boundary check; don't try to bypass it.
@@ -6,7 +6,21 @@ You translate the spec into an executable plan and tasks.
6
6
 
7
7
  ## Guardrail
8
8
 
9
- `checklist.md` → 1, 2 (branch-pattern, proposal-exists)
9
+ `checklist.md` → 0, 1, 2, 7 (nova-installed, branch-pattern, proposal-exists, proposal-closed)
10
+
11
+ ### Run guardrail #7 before drafting tasks
12
+
13
+ ```bash
14
+ bash novaspec/guardrails/proposal-closed.sh <ticket-id>
15
+ ```
16
+
17
+ This greps `proposal.md` for `TBD`, `TODO`, `FIXME`, `???`, `<placeholder>`,
18
+ `[ ] decision`. If it exits non-zero, **stop immediately** with the script's
19
+ output. Tell the user:
20
+
21
+ > "Proposal has open markers. Re-run `/nova-spec` to close them before planning."
22
+
23
+ Do NOT generate `tasks.md` from an unclosed proposal.
10
24
 
11
25
  ## Precondition
12
26
 
@@ -6,7 +6,7 @@ Final reviewer before closing the ticket.
6
6
 
7
7
  ## Guardrail
8
8
 
9
- `checklist.md` → 1, 4 (branch-pattern, all-tasks-done)
9
+ `checklist.md` → 0, 1, 2, 4 (nova-installed, branch-pattern, proposal-exists, all-tasks-done)
10
10
 
11
11
  ## Steps
12
12
 
@@ -6,7 +6,7 @@ You are responsible for generating the technical spec of the current ticket.
6
6
 
7
7
  ## Guardrail
8
8
 
9
- `checklist.md` → 1 (branch-pattern)
9
+ `checklist.md` → 0, 1 (nova-installed, branch-pattern)
10
10
 
11
11
  ## Precondition
12
12
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Start the nova-spec flow from a Jira ticket
2
+ description: Start the nova-spec flow from a ticket
3
3
  argument-hint: <TICKET-ID>
4
4
  ---
5
5
 
@@ -10,17 +10,45 @@ The user has passed the ticket: **$ARGUMENTS**
10
10
  Your job is to set the stage before any spec or code is written.
11
11
  Don't implement anything. Don't propose a spec. Just orchestrate.
12
12
 
13
+ ## Guardrail
14
+
15
+ `checklist.md` → 0 (nova-installed)
16
+
13
17
  ## Steps
14
18
 
15
19
  ### 1. Get the ticket
16
20
 
17
- Read `novaspec/config.yml` → `jira.skill`.
18
- - If it has a value, invoke that skill to fetch the ticket.
19
- - If it's empty or missing, ask the user to paste:
20
- - title
21
- - description
22
- - acceptance criteria
23
- - relevant comments
21
+ Read `novaspec/config.yml` → `ticket_system`. It's one of:
22
+ - `jira` fetch the ticket from Jira via the `jira-integration` skill
23
+ - `none` (or missing key) no tracker; user pastes content
24
+
25
+ #### If `ticket_system: jira`
26
+
27
+ Validate `$ARGUMENTS` matches `[A-Z][A-Z0-9]+-[0-9]+` (e.g. `PROJ-123`).
28
+ If not, refuse and ask for a properly-formatted ticket key.
29
+
30
+ Invoke the `jira-integration` skill, which runs `npx nova-spec jira get <TICKET>`. Error handling by exit code:
31
+
32
+ - **Exit 401** — invalid credentials. Tell the user:
33
+ > "Jira returned 401 Unauthorized. Regenerate your API token at https://id.atlassian.com/manage-profile/security/api-tokens and update `JIRA_API_TOKEN`."
34
+ Do NOT retry. Stop.
35
+ - **Exit 404** — ticket not found:
36
+ > "Jira returned 404 for `<TICKET>`. Check the key and `jira.project` in `novaspec/config.yml`."
37
+ Stop.
38
+ - **Network / timeout / other** — fall back to manual paste:
39
+ > "Couldn't reach Jira. Paste the ticket title, description, AC, and relevant comments and I'll continue."
40
+
41
+ #### If `ticket_system: none` (or missing)
42
+
43
+ Skip the ticket-key format check — `$ARGUMENTS` is a free-form identifier.
44
+
45
+ Ask the user to paste:
46
+ - title
47
+ - description
48
+ - acceptance criteria
49
+ - relevant comments
50
+
51
+ Don't make up ticket content.
24
52
 
25
53
  ### 2. Classify the ticket
26
54
 
@@ -7,7 +7,7 @@ This is the step that feeds architectural memory.
7
7
 
8
8
  ## Guardrail
9
9
 
10
- `checklist.md` → 1, 5, 6 (branch-pattern, review-approved, old-decision-archived)
10
+ `checklist.md` → 0, 1, 5, 6 (nova-installed, branch-pattern, review-approved, old-decision-archived)
11
11
 
12
12
  ## Precondition
13
13
 
@@ -52,15 +52,28 @@ Default: don't write. Most tickets don't generate a gotcha.
52
52
  Use the structure of `novaspec/templates/commit.md` as a template.
53
53
  If there are many changes, propose grouping into logical commits.
54
54
 
55
- ### 6. Create PR
55
+ ### 6. Create PR / MR (forge-agnostic)
56
56
 
57
57
  Resolve the base branch the same way `/nova-start` does:
58
58
  - Read `branch.base` from `novaspec/config.yml`.
59
59
  - If the key is missing, try `develop`; if that doesn't exist either, ask
60
60
  the user and recommend setting `branch.base` in `novaspec/config.yml`.
61
61
 
62
- Create the PR with `gh pr create --base <resolved-base> --title "<title>"
63
- --body "<description>"`.
62
+ **Do NOT hardcode `gh`.** Ask the CLI to build the right command for the
63
+ forge (`gh pr create ...` for GitHub, `glab mr create ...` for GitLab):
64
+
65
+ ```bash
66
+ npx nova-spec forge pr-command "<TICKET-ID>: <title>" "<description>" "<base>"
67
+ ```
68
+
69
+ The CLI also verifies the forge binary is installed; if it isn't, it exits
70
+ with code 127 and a clear error. Show the user the command, get confirmation,
71
+ then execute it.
72
+
73
+ For user-facing messages use the right vocabulary (PR vs MR):
74
+ ```bash
75
+ TERM=$(npx nova-spec forge term)
76
+ ```
64
77
 
65
78
  **Title**: `<TICKET-ID>: <title>`
66
79
 
@@ -68,7 +81,9 @@ Create the PR with `gh pr create --base <resolved-base> --title "<title>"
68
81
 
69
82
  ### 7. Close the ticket in Jira
70
83
 
71
- If `novaspec/config.yml` has `jira.skill` set, invoke the `jira-integration` skill to transition the ticket to "Done".
84
+ If `novaspec/config.yml` has `jira.skill` set (and `ticket_system: jira`), invoke the `jira-integration` skill to transition the ticket to "Done".
85
+
86
+ The transition ID to use is `jira.transitions.done` from `novaspec/config.yml`. If that key is missing, fall back to the legacy `jira.done_transition_id`. The skill (or `npx nova-spec jira transition <TICKET> <id>` directly) handles the API call.
72
87
 
73
88
  Confirm to the user: "Ticket <TICKET-ID> marked as Done in Jira ✓"
74
89
 
@@ -15,10 +15,18 @@ branch:
15
15
  ticket_case: upper # upper | lower
16
16
  base: main # base branch of the flow
17
17
 
18
+ forge:
19
+ type: auto # auto | github | gitlab | none
20
+ cli: auto # auto | gh | glab
21
+
22
+ ticket_system: jira # jira | none
23
+
18
24
  jira:
19
- skill: "" # set to "jira-integration" to enable Jira
25
+ skill: jira-integration # set to "" to disable Jira
20
26
  url: https://your-workspace.atlassian.net
21
27
  project: PROJ
22
28
  email: you@example.com
23
- token: ${JIRA_API_TOKEN} # create at id.atlassian.com/manage-profile/security/api-tokens
24
- done_transition_id: "41" # find via GET /rest/api/3/issue/<TICKET>/transitions
29
+ token: ${JIRA_API_TOKEN} # create at id.atlassian.com/manage-profile/security/api-tokens
30
+ done_transition_id: "41" # legacy, kept for backward-compat fallback
31
+ transitions:
32
+ done: "41" # structured form — read first by /nova-wrap
@@ -1,10 +1,23 @@
1
1
  # Guardrails — Checklist
2
2
 
3
- **Execution order: 1 → 2 → 3 → 4 → 5 → 6**
3
+ **Execution order: 0 → 1 → 2 → 7 → 3 → 4 → 5 → 6**
4
+
5
+ ## 0. nova-installed
6
+ Verify nova-spec is properly installed in this project.
7
+ - Run `bash novaspec/guardrails/nova-installed.sh`.
8
+ - Checks that `novaspec/config.yml` and `context/` exist.
9
+ - ⛔ **Stop.** Run `npx nova-spec init` first.
4
10
 
5
11
  ## 1. branch-pattern
6
12
  Verify the active ticket branch. Extract `<ticket-id>` from the current git branch.
7
- - Must follow the pattern `(feature|fix|arch)/<TICKET>-<slug>` from `novaspec/config.yml`.
13
+ - Read `branch.types` from `novaspec/config.yml` to know which prefixes are
14
+ valid for this project. The defaults shipped by the installer are:
15
+ `feature, fix, arch, bugfix, hotfix, docs, refactor, chore`.
16
+ - The branch must follow `<type>/<TICKET>-<slug>` where `<type>` is any of
17
+ the configured types (matched against the **values** in `branch.types`,
18
+ e.g. `documentation: docs` → `docs` is valid).
19
+ - If `ticket_system: none` in config, the `<TICKET>` part can be any
20
+ identifier the user chose (no enforced format).
8
21
  - ⛔ **Stop.** Run `/nova-start <TICKET>` first.
9
22
 
10
23
  ## 2. proposal-exists
@@ -32,3 +45,9 @@ Verify the review was approved.
32
45
  Validate that superseded decisions are archived.
33
46
  - Files in `context/decisions/*.md` with `> Supersedes: X.md` imply that `X.md` lives in `context/decisions/archived/`, not at the root.
34
47
  - ⛔ **Stop.** Move the file to `archived/` with `git mv` and retry.
48
+
49
+ ## 7. proposal-closed
50
+ Deterministic check: the proposal has no unresolved markers.
51
+ - Run `bash novaspec/guardrails/proposal-closed.sh <ticket-id>`.
52
+ - The script greps for `TBD`, `TODO`, `FIXME`, `???`, `<placeholder>`, `[ ] decision`.
53
+ - ⛔ **Stop.** Re-run `/nova-spec` and close the open requirements before `/nova-plan`.
File without changes
@@ -1,96 +1,81 @@
1
1
  ---
2
2
  name: jira-integration
3
- description: Read and create tasks in Jira via the REST API. Uses the project's config.yml (`jira` section).
3
+ description: Read and transition Jira tickets via the deterministic `npx nova-spec jira` CLI. The CLI handles auth so tokens never appear in shell commands.
4
4
  ---
5
5
 
6
6
  # Jira Integration
7
7
 
8
- Read and create issues in Jira using Atlassian's REST API v3.
8
+ This skill is a thin wrapper around the `npx nova-spec jira` subcommand,
9
+ which is a small Node-based HTTP client. **Never build `curl` calls by hand**
10
+ — the CLI keeps the `JIRA_API_TOKEN` inside the Node process and never
11
+ inlines it on a command line.
9
12
 
10
- ## Required config in config.yml
13
+ ## Required config in `novaspec/config.yml`
11
14
 
12
15
  ```yaml
16
+ ticket_system: jira
17
+
13
18
  jira:
14
19
  skill: jira-integration
15
- url: https://your-org.atlassian.net # no trailing slash
16
- project: PROJ # default project key
17
- email: you@email.com
18
- token: ${JIRA_API_TOKEN} # reference to env variable
19
- done_transition_id: "41" # find it via GET /rest/api/3/issue/<TICKET>/transitions
20
+ url: https://your-workspace.atlassian.net # no trailing slash
21
+ project: PROJ # default project key
22
+ email: you@example.com # tied to the token
23
+ token: ${JIRA_API_TOKEN} # env-var reference
24
+ done_transition_id: "41" # legacy, kept for fallback
25
+ transitions:
26
+ done: "41" # preferred form, used by /nova-wrap
20
27
  ```
21
28
 
22
29
  Get the token at: https://id.atlassian.com/manage-profile/security/api-tokens
23
30
 
24
- ## How to use this skill
25
-
26
- When the user asks to read or create Jira tasks:
27
-
28
- 1. **Read the config**: read the project's `config.yml` and extract the `jira` section.
29
- 2. **Resolve the token**: if the value starts with `${`, read the corresponding env variable (e.g. `$JIRA_API_TOKEN`).
30
- 3. **Build Basic Auth credentials**: `base64(email:token)`.
31
- 4. **Call the API** with `curl` for the requested operation.
32
-
33
31
  ## Operations
34
32
 
35
33
  ### Read a ticket
36
34
 
37
35
  ```bash
38
- curl -s \
39
- -H "Authorization: Basic <BASE64>" \
40
- -H "Accept: application/json" \
41
- "https://<url>/rest/api/3/issue/<TICKET_KEY>"
36
+ npx nova-spec jira get <TICKET-KEY>
42
37
  ```
43
38
 
44
- Show the user: key, summary, status (status.name), description, assignee.
39
+ Output is JSON. Extract: `key`, `fields.summary`, `fields.status.name`,
40
+ `fields.description`, `fields.assignee.displayName`.
45
41
 
46
- ### List tickets in a project (recent open ones)
42
+ ### List transitions for a ticket
47
43
 
48
44
  ```bash
49
- curl -s \
50
- -H "Authorization: Basic <BASE64>" \
51
- -H "Accept: application/json" \
52
- "https://<url>/rest/api/3/search?jql=project=<PROJECT>+AND+statusCategory!=Done+ORDER+BY+created+DESC&maxResults=20&fields=summary,status,assignee,priority"
45
+ npx nova-spec jira transitions <TICKET-KEY>
53
46
  ```
54
47
 
55
- ### Create a ticket
48
+ Use this when `transitions.done` is missing or wrong: it tells you which
49
+ transitions are reachable from the current ticket status, and their IDs.
50
+
51
+ ### Transition a ticket (close as Done from `/nova-wrap`)
56
52
 
57
53
  ```bash
58
- curl -s -X POST \
59
- -H "Authorization: Basic <BASE64>" \
60
- -H "Content-Type: application/json" \
61
- -H "Accept: application/json" \
62
- "https://<url>/rest/api/3/issue" \
63
- -d '{
64
- "fields": {
65
- "project": { "key": "<PROJECT>" },
66
- "summary": "<TITLE>",
67
- "description": {
68
- "type": "doc", "version": 1,
69
- "content": [{"type": "paragraph", "content": [{"type": "text", "text": "<DESCRIPTION>"}]}]
70
- },
71
- "issuetype": { "name": "<TYPE>" }
72
- }
73
- }'
54
+ npx nova-spec jira transition <TICKET-KEY> <TRANSITION-ID>
74
55
  ```
75
56
 
76
- ### Transition ticket to Done
57
+ Read `<TRANSITION-ID>` from `novaspec/config.yml` → `jira.transitions.done`.
58
+ Fall back to `jira.done_transition_id` if the structured form is missing.
77
59
 
78
- ```bash
79
- AUTH=$(echo -n "<email>:<token>" | base64)
80
- curl -s -X POST \
81
- -H "Authorization: Basic $AUTH" \
82
- -H "Content-Type: application/json" \
83
- "https://<url>/rest/api/3/issue/<TICKET-ID>/transitions" \
84
- -d "{\"transition\": {\"id\": \"<done_transition_id>\"}}"
85
- ```
60
+ ## Error handling
86
61
 
87
- Use `done_transition_id` from `config.yml`. To find your transition IDs:
88
- ```bash
89
- curl -s -H "Authorization: Basic <BASE64>" "https://<url>/rest/api/3/issue/<ANY-TICKET>/transitions"
90
- ```
62
+ The CLI exits with:
63
+
64
+ | Code | Meaning | What to do |
65
+ |---|---|---|
66
+ | 0 | Success | Continue |
67
+ | 1 | Generic error (network, parse) | Surface the message; offer manual fallback |
68
+ | 2 | Usage error (missing arg) | Fix the command and retry |
69
+ | 401 | Invalid credentials | Tell user to regenerate `JIRA_API_TOKEN` — **do NOT retry** |
70
+ | 404 | Ticket not found | Confirm the key with the user |
71
+
72
+ When the CLI prints `✗ Jira 401`, never retry — credentials are wrong.
91
73
 
92
- ## Notes
74
+ ## Rules
93
75
 
94
- - If `token` is not in config.yml, ask the user to set it.
95
- - If the project is not specified in the request, use the `project` from config.yml.
96
- - On HTTP errors (401, 403, 404), show Jira's error message but never expose the token.
76
+ - **Never** paste the token into a `curl` command. The CLI reads it from
77
+ `JIRA_API_TOKEN` env var via the `${JIRA_API_TOKEN}` reference in `config.yml`.
78
+ - If `JIRA_API_TOKEN` is missing, the CLI fails fast with `✗ JIRA_API_TOKEN env var is not set.`
79
+ - For debugging only (and never with a real token visible), you can build
80
+ Basic Auth via `AUTH=$(printf '%s' "$JIRA_EMAIL:$JIRA_API_TOKEN" | base64)` —
81
+ but the CLI is always preferable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova-spec",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Spec-Driven Development framework for Claude Code and OpenCode",
5
5
  "bin": {
6
6
  "nova-spec": "bin/nova-spec.js"
@@ -20,10 +20,14 @@
20
20
  "PHILOSOPHY.md"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/smoke.test.js"
23
+ "test": "node test/smoke.test.js",
24
+ "docs:dev": "vitepress dev docs",
25
+ "docs:build": "vitepress build docs",
26
+ "docs:preview": "vitepress preview docs"
24
27
  },
25
28
  "dependencies": {
26
- "@inquirer/prompts": "^7.0.0"
29
+ "@inquirer/prompts": "^7.0.0",
30
+ "js-yaml": "^4.1.1"
27
31
  },
28
32
  "engines": {
29
33
  "node": ">=18"
@@ -40,5 +44,8 @@
40
44
  "spec-driven-development",
41
45
  "jira",
42
46
  "workflow"
43
- ]
47
+ ],
48
+ "devDependencies": {
49
+ "vitepress": "^1.6.4"
50
+ }
44
51
  }