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 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');
@@ -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
- fs.writeFileSync(
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
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
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
- fs.writeFileSync(
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
- fs.writeFileSync(configPath, content);
458
+ writeAtomic(configPath, content);
458
459
  }
459
460
 
460
461
  function ensureGitignore(destDir) {
@@ -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
@@ -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
- console.error(` ✗ Could not parse ${manifestPath}: ${err.message}`);
73
- console.error(' Aborting sync to avoid clobbering project state.');
74
- 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: {} };
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
- fs.writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
185
+ writeAtomic(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
152
186
 
153
- // Migrate config.yml (idempotent)
154
- const { migrateConfig } = require('./migrate-config.js');
155
- 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);
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
- group.hooks = group.hooks || [];
226
- for (let i = 0; i < group.hooks.length; i++) {
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
- if (!updated) {
237
- 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
+ }
238
318
  }
319
+ }
320
+ }
239
321
 
240
- 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);
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
 
@@ -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
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nova-spec",
3
- "version": "1.0.6",
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"