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 +83 -45
- package/lib/forge.js +32 -7
- package/lib/installer.js +32 -5
- package/lib/jira.js +9 -1
- package/lib/migrate-config.js +7 -3
- package/lib/sync.js +121 -25
- package/novaspec/agents/context-loader.md +13 -3
- package/novaspec/agents/nova-review-agent.md +28 -5
- package/novaspec/commands/nova-build.md +11 -2
- package/novaspec/commands/nova-diff.md +50 -34
- package/novaspec/commands/nova-plan.md +15 -1
- package/novaspec/commands/nova-review.md +1 -1
- package/novaspec/commands/nova-spec.md +1 -1
- package/novaspec/commands/nova-start.md +36 -8
- package/novaspec/commands/nova-wrap.md +20 -5
- package/novaspec/config.example.yml +11 -3
- package/novaspec/guardrails/checklist.md +21 -2
- package/novaspec/guardrails/nova-installed.sh +0 -0
- package/novaspec/skills/jira-integration/SKILL.md +46 -61
- package/package.json +11 -4
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
|
-
|
|
43
|
-
|
|
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(
|
|
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({ ...
|
|
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({ ...
|
|
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({ ...
|
|
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)
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
const email =
|
|
137
|
-
let token =
|
|
174
|
+
const jiraCfg = cfg.jira || {};
|
|
175
|
+
const { url, email } = jiraCfg;
|
|
176
|
+
let token = jiraCfg.token;
|
|
138
177
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
return { url, email, token };
|
|
200
|
+
return { ok: true, url, email, token };
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
function readForgeConfig() {
|
|
151
|
-
const
|
|
204
|
+
const cfg = loadConfig();
|
|
152
205
|
const defaults = { type: 'auto', cli: 'auto' };
|
|
153
|
-
if (!
|
|
154
|
-
const
|
|
206
|
+
if (!cfg || cfg.__parseError) return defaults;
|
|
207
|
+
const forge = cfg.forge || {};
|
|
155
208
|
return {
|
|
156
|
-
type:
|
|
157
|
-
cli:
|
|
209
|
+
type: forge.type || 'auto',
|
|
210
|
+
cli: forge.cli || 'auto',
|
|
158
211
|
};
|
|
159
212
|
}
|
|
160
213
|
|
|
161
|
-
|
|
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
|
-
|
|
14
|
+
// Try `origin` first (the common case).
|
|
7
15
|
try {
|
|
8
|
-
remote = execSync('git remote get-url origin', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/lib/migrate-config.js
CHANGED
|
@@ -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*"?([^"
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
process.exit
|
|
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
|
-
|
|
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
|
-
|
|
185
|
+
writeAtomic(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
|
|
149
186
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
-
|
|
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
|
|
3
|
-
argument-hint: <
|
|
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.
|
|
7
|
-
|
|
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
|
-
##
|
|
10
|
-
|
|
11
|
-
### 1. Resolve paths
|
|
10
|
+
## When this is invoked
|
|
12
11
|
|
|
13
|
-
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
Nothing to diff.
|
|
17
|
+
→ /nova-diff <path>
|
|
20
18
|
```
|
|
21
|
-
Stop here.
|
|
22
19
|
|
|
23
|
-
|
|
20
|
+
For example: `/nova-diff novaspec/templates/pr-body.md`.
|
|
21
|
+
|
|
22
|
+
## Steps
|
|
23
|
+
|
|
24
|
+
### 1. Resolve the path
|
|
24
25
|
|
|
25
|
-
|
|
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
|
|
29
|
+
Verify the local file exists. If not:
|
|
28
30
|
```
|
|
29
|
-
|
|
30
|
-
No upstream changes since your override was created.
|
|
31
|
+
No local file at "<path>". Nothing to diff.
|
|
31
32
|
```
|
|
32
|
-
Stop
|
|
33
|
+
Stop.
|
|
33
34
|
|
|
34
|
-
###
|
|
35
|
+
### 2. Locate the upstream (package) version
|
|
35
36
|
|
|
36
37
|
Run:
|
|
38
|
+
|
|
37
39
|
```bash
|
|
38
|
-
|
|
40
|
+
npx nova-spec source "$ARGUMENTS"
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
50
|
-
[M] Merge manually — I'll
|
|
51
|
-
[R] Replace with
|
|
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
|
|
69
|
+
Wait for an explicit selection.
|
|
55
70
|
|
|
56
|
-
- **[K]**:
|
|
57
|
-
- **[M]**:
|
|
58
|
-
- **[R]**:
|
|
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
|
|
63
|
-
-
|
|
64
|
-
-
|
|
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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Start the nova-spec flow from a
|
|
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` → `
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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:
|
|
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}
|
|
24
|
-
done_transition_id: "41"
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
16
|
-
project: PROJ
|
|
17
|
-
email: you@
|
|
18
|
-
token: ${JIRA_API_TOKEN}
|
|
19
|
-
done_transition_id: "41"
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
Output is JSON. Extract: `key`, `fields.summary`, `fields.status.name`,
|
|
40
|
+
`fields.description`, `fields.assignee.displayName`.
|
|
45
41
|
|
|
46
|
-
### List
|
|
42
|
+
### List transitions for a ticket
|
|
47
43
|
|
|
48
44
|
```bash
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
##
|
|
74
|
+
## Rules
|
|
93
75
|
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
-
|
|
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.
|
|
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
|
}
|