nova-spec 1.0.6 → 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/forge.js +32 -7
- package/lib/installer.js +5 -4
- package/lib/migrate-config.js +7 -3
- package/lib/sync.js +113 -22
- package/novaspec/commands/nova-build.md +11 -2
- package/novaspec/commands/nova-review.md +1 -1
- package/novaspec/commands/nova-spec.md +1 -1
- package/novaspec/config.example.yml +11 -3
- package/package.json +1 -1
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');
|
|
@@ -125,7 +126,7 @@ async function init() {
|
|
|
125
126
|
|
|
126
127
|
// Manifest reflects what we just shipped from the package.
|
|
127
128
|
const manifest = generateManifest(PACKAGE_ROOT);
|
|
128
|
-
|
|
129
|
+
writeAtomic(
|
|
129
130
|
path.join(destDir, 'novaspec', MANIFEST_FILE),
|
|
130
131
|
JSON.stringify(manifest, null, 2) + '\n',
|
|
131
132
|
);
|
|
@@ -396,13 +397,13 @@ function writeClaudeSettings(claudeDir) {
|
|
|
396
397
|
settings.hooks.SessionStart.push({ hooks: [novaHook] });
|
|
397
398
|
}
|
|
398
399
|
|
|
399
|
-
|
|
400
|
+
writeAtomic(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
400
401
|
}
|
|
401
402
|
|
|
402
403
|
function writeOpenCodeSettings(opencodeDir) {
|
|
403
404
|
const settingsPath = path.join(opencodeDir, 'settings.local.json');
|
|
404
405
|
if (fs.existsSync(settingsPath)) return;
|
|
405
|
-
|
|
406
|
+
writeAtomic(
|
|
406
407
|
settingsPath,
|
|
407
408
|
JSON.stringify(
|
|
408
409
|
{
|
|
@@ -454,7 +455,7 @@ function writeConfig(destDir, { ticketSystem, jiraConfig, baseBranch, forgeType
|
|
|
454
455
|
` done: ${yamlString(jiraConfig.done_transition_id)}`,
|
|
455
456
|
].join('\n') + '\n';
|
|
456
457
|
|
|
457
|
-
|
|
458
|
+
writeAtomic(configPath, content);
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
function ensureGitignore(destDir) {
|
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
|
@@ -10,7 +10,29 @@ const FRAMEWORK_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
|
10
10
|
// Paths the user owns or that are auto-generated — never tracked or overwritten.
|
|
11
11
|
const NEVER_TRACK = new Set(['novaspec/config.yml', `novaspec/${MANIFEST_FILE}`]);
|
|
12
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
|
+
|
|
13
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
|
+
}
|
|
14
36
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
15
37
|
}
|
|
16
38
|
|
|
@@ -69,9 +91,18 @@ function readManifest(manifestPath) {
|
|
|
69
91
|
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
70
92
|
return { files: data.files || {}, version: data.version };
|
|
71
93
|
} catch (err) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
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: {} };
|
|
75
106
|
}
|
|
76
107
|
}
|
|
77
108
|
|
|
@@ -94,6 +125,11 @@ async function sync(destDir = process.cwd()) {
|
|
|
94
125
|
const removed = [];
|
|
95
126
|
const skippedRemoved = [];
|
|
96
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
|
+
|
|
97
133
|
// Update / create / skip files
|
|
98
134
|
for (const [rel, srcAbs] of Object.entries(sources)) {
|
|
99
135
|
const destAbs = path.join(destDir, rel);
|
|
@@ -114,8 +150,6 @@ async function sync(destDir = process.cwd()) {
|
|
|
114
150
|
// Only overwrite when we KNOW the user didn't touch it: previousShipped
|
|
115
151
|
// must be recorded AND match the current disk hash. If the manifest is
|
|
116
152
|
// missing or doesn't mention this file, treat as user-owned and skip.
|
|
117
|
-
// This preserves edits when the manifest is deleted/corrupt/merged-away,
|
|
118
|
-
// at the cost of needing /nova-diff to apply the new version.
|
|
119
153
|
if (previousShipped && currentHash === previousShipped) {
|
|
120
154
|
fs.copyFileSync(srcAbs, destAbs);
|
|
121
155
|
updated.push(rel);
|
|
@@ -148,11 +182,11 @@ async function sync(destDir = process.cwd()) {
|
|
|
148
182
|
for (const rel of skippedRemoved) {
|
|
149
183
|
if (oldManifest.files[rel]) newManifest.files[rel] = oldManifest.files[rel];
|
|
150
184
|
}
|
|
151
|
-
|
|
185
|
+
writeAtomic(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
|
|
152
186
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
156
190
|
|
|
157
191
|
// Refresh SessionStart hook in any installed runtime
|
|
158
192
|
ensureSessionStartHook(destDir);
|
|
@@ -205,6 +239,12 @@ function ensureSessionStartHook(destDir) {
|
|
|
205
239
|
path.join(destDir, '.opencode', 'settings.local.json'),
|
|
206
240
|
].filter(p => fs.existsSync(path.dirname(p)));
|
|
207
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
|
+
|
|
208
248
|
for (const settingsPath of targets) {
|
|
209
249
|
let settings = {};
|
|
210
250
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -220,24 +260,73 @@ function ensureSessionStartHook(destDir) {
|
|
|
220
260
|
settings.hooks = settings.hooks || {};
|
|
221
261
|
settings.hooks.SessionStart = settings.hooks.SessionStart || [];
|
|
222
262
|
|
|
223
|
-
let updated = false;
|
|
224
263
|
for (const group of settings.hooks.SessionStart) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (group.hooks[i]?.command?.includes(HOOK_MARKER)) {
|
|
228
|
-
if (group.hooks[i].command !== hookCommand) {
|
|
229
|
-
group.hooks[i] = novaHook;
|
|
230
|
-
}
|
|
231
|
-
updated = true;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
264
|
+
if (!Array.isArray(group.hooks)) continue;
|
|
265
|
+
group.hooks = group.hooks.filter((h) => !isNovaHook(h));
|
|
234
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] });
|
|
235
273
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
}
|
|
238
318
|
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
239
321
|
|
|
240
|
-
|
|
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);
|
|
241
330
|
}
|
|
242
331
|
}
|
|
243
332
|
|
|
@@ -248,6 +337,8 @@ module.exports = {
|
|
|
248
337
|
buildHookCommand,
|
|
249
338
|
readManifest,
|
|
250
339
|
collectPackageFiles,
|
|
340
|
+
refreshRuntimeLinks,
|
|
341
|
+
writeAtomic,
|
|
251
342
|
HOOK_MARKER,
|
|
252
343
|
FRAMEWORK_FILES,
|
|
253
344
|
MANIFEST_FILE,
|
|
@@ -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
|
|
|
@@ -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
|