nova-spec 1.0.1 → 1.0.4

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.
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ // Each migration is { from: 'X.Y', to: 'X.Y', apply: (text) => text }.
6
+ // They run in order; each one transforms the YAML text. Idempotent — applying
7
+ // twice produces the same result. Detection is heuristic (presence/absence of
8
+ // keys), not a version field, so old configs without `_schema:` migrate cleanly.
9
+ const MIGRATIONS = [
10
+ {
11
+ name: 'add-forge-section',
12
+ detect: (text) => !/^forge:/m.test(text),
13
+ apply: (text) => {
14
+ const block = [
15
+ '',
16
+ 'forge:',
17
+ ' type: auto # auto | github | gitlab',
18
+ ' cli: auto # auto | gh | glab',
19
+ ].join('\n');
20
+ return text.trimEnd() + '\n' + block + '\n';
21
+ },
22
+ },
23
+ {
24
+ name: 'rename-done-transition-id',
25
+ detect: (text) => /^\s*done_transition_id:/m.test(text) && !/^\s*transitions:/m.test(text),
26
+ apply: (text) => {
27
+ // Move done_transition_id under jira.transitions.done while keeping the
28
+ // legacy key as fallback so older skills don't break mid-migration.
29
+ return text.replace(
30
+ /^(\s*)done_transition_id:\s*"?([^"\n]+)"?\s*$/m,
31
+ (_, indent, value) =>
32
+ `${indent}done_transition_id: "${value}"\n${indent}transitions:\n${indent} done: "${value}"`,
33
+ );
34
+ },
35
+ },
36
+ ];
37
+
38
+ function migrateConfig(configPath) {
39
+ if (!fs.existsSync(configPath)) return { applied: [] };
40
+
41
+ const original = fs.readFileSync(configPath, 'utf8');
42
+ let current = original;
43
+ const applied = [];
44
+
45
+ for (const migration of MIGRATIONS) {
46
+ if (migration.detect(current)) {
47
+ current = migration.apply(current);
48
+ applied.push(migration.name);
49
+ }
50
+ }
51
+
52
+ if (current !== original) {
53
+ fs.writeFileSync(configPath, current);
54
+ }
55
+
56
+ return { applied };
57
+ }
58
+
59
+ module.exports = { migrateConfig, MIGRATIONS };
package/lib/sync.js CHANGED
@@ -3,145 +3,246 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
+ const os = require('os');
6
7
 
7
8
  const MANIFEST_FILE = '.nova-manifest.json';
9
+ const HOOK_MARKER = '# nova-spec auto-sync';
10
+ const FRAMEWORK_FILES = ['AGENTS.md', 'CLAUDE.md'];
11
+ // Paths the user owns or that are auto-generated — never tracked or overwritten.
12
+ const NEVER_TRACK = new Set(['novaspec/config.yml', `novaspec/${MANIFEST_FILE}`]);
8
13
 
9
14
  function hashFile(filePath) {
10
- const content = fs.readFileSync(filePath);
11
- return crypto.createHash('md5').update(content).digest('hex');
15
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
12
16
  }
13
17
 
14
- function walkDir(dir, base = dir) {
15
- const results = {};
16
- if (!fs.existsSync(dir)) return results;
18
+ function collectPackageFiles(packageRoot) {
19
+ // Returns a flat map of relPath -> absSrcPath for every file we would ship.
20
+ const out = {};
21
+ const novaspec = path.join(packageRoot, 'novaspec');
22
+ walk(novaspec, packageRoot, out);
23
+ for (const f of FRAMEWORK_FILES) {
24
+ const abs = path.join(packageRoot, f);
25
+ if (fs.existsSync(abs)) out[f] = abs;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function walk(dir, base, out) {
31
+ if (!fs.existsSync(dir)) return;
17
32
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
18
- const fullPath = path.join(dir, entry.name);
19
- const relPath = path.relative(base, fullPath);
33
+ if (entry.isSymbolicLink()) continue;
34
+ if (entry.name === 'node_modules') continue;
35
+ if (entry.name === MANIFEST_FILE) continue;
36
+ if (entry.name === '.gitkeep') continue;
37
+
38
+ const abs = path.join(dir, entry.name);
39
+ const rel = path.relative(base, abs).replace(/\\/g, '/');
40
+
41
+ if (NEVER_TRACK.has(rel)) continue;
42
+
20
43
  if (entry.isDirectory()) {
21
- Object.assign(results, walkDir(fullPath, base));
22
- } else {
23
- results[relPath] = hashFile(fullPath);
44
+ walk(abs, base, out);
45
+ } else if (entry.isFile()) {
46
+ out[rel] = abs;
24
47
  }
25
48
  }
26
- return results;
27
49
  }
28
50
 
29
- function generateManifest(novaspecDir) {
30
- const coreDir = novaspecDir;
31
- const hashes = {};
32
-
33
- for (const section of ['commands', 'skills', 'agents']) {
34
- const sectionDir = path.join(coreDir, section);
35
- if (!fs.existsSync(sectionDir)) continue;
36
- for (const entry of fs.readdirSync(sectionDir, { withFileTypes: true })) {
37
- if (!entry.isDirectory() && !entry.name.endsWith('.md')) continue;
38
- const name = entry.name.replace('.md', '');
39
- const fullPath = path.join(sectionDir, entry.name);
40
- if (entry.isDirectory()) {
41
- hashes[`${section}/${name}`] = walkDir(fullPath);
42
- } else {
43
- hashes[`${section}/${name}`] = hashFile(fullPath);
44
- }
45
- }
51
+ function generateManifest(packageRoot) {
52
+ // Hashes everything WE ship, not what's on the consumer's disk. The manifest
53
+ // records "what we last delivered to this user", so the next sync can detect
54
+ // local edits via hash-compare.
55
+ const files = {};
56
+ const sources = collectPackageFiles(packageRoot);
57
+ for (const [rel, abs] of Object.entries(sources)) {
58
+ files[rel] = hashFile(abs);
46
59
  }
47
-
48
- const manifest = {
60
+ return {
49
61
  version: require('../package.json').version,
50
62
  generated_at: new Date().toISOString(),
51
- hashes,
52
- outdated_customs: [],
63
+ files,
53
64
  };
65
+ }
54
66
 
55
- const manifestPath = path.join(novaspecDir, MANIFEST_FILE);
56
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
57
- return manifest;
67
+ function readManifest(manifestPath) {
68
+ if (!fs.existsSync(manifestPath)) return { files: {} };
69
+ try {
70
+ const data = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
71
+ return { files: data.files || {}, version: data.version };
72
+ } 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);
76
+ }
58
77
  }
59
78
 
60
79
  async function sync(destDir = process.cwd()) {
61
80
  const novaspecDest = path.join(destDir, 'novaspec');
62
- const manifestPath = path.join(novaspecDest, MANIFEST_FILE);
63
81
 
64
82
  if (!fs.existsSync(novaspecDest)) {
65
83
  console.error(' ✗ nova-spec not installed in this directory. Run: npx nova-spec init');
66
84
  process.exit(1);
67
85
  }
68
86
 
69
- // Read existing manifest
70
- const oldManifest = fs.existsSync(manifestPath)
71
- ? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
72
- : { hashes: {} };
73
-
74
- // Update core files from package
75
87
  const PACKAGE_ROOT = path.join(__dirname, '..');
76
- const NOVASPEC_SRC = path.join(PACKAGE_ROOT, 'novaspec');
77
-
78
- // Backup config.yml
79
- const configPath = path.join(novaspecDest, 'config.yml');
80
- let configBackup = null;
81
- if (fs.existsSync(configPath)) {
82
- configBackup = fs.readFileSync(configPath, 'utf8');
83
- }
84
-
85
- copyDirSync(NOVASPEC_SRC, novaspecDest);
86
-
87
- // Restore config
88
- if (configBackup) fs.writeFileSync(configPath, configBackup);
89
-
90
- // Regenerate manifest with new hashes
91
- const newManifest = generateManifest(novaspecDest);
92
-
93
- // Check custom overrides
94
- const customDir = path.join(novaspecDest, 'custom');
95
- const outdated = [];
96
-
97
- if (fs.existsSync(customDir)) {
98
- for (const section of ['commands', 'skills', 'agents']) {
99
- const customSection = path.join(customDir, section);
100
- if (!fs.existsSync(customSection)) continue;
101
- for (const entry of fs.readdirSync(customSection, { withFileTypes: true })) {
102
- const name = entry.name.replace('.md', '');
103
- const key = `${section}/${name}`;
104
- const oldHash = oldManifest.hashes[key];
105
- const newHash = newManifest.hashes[key];
106
- if (oldHash && newHash && JSON.stringify(oldHash) !== JSON.stringify(newHash)) {
107
- outdated.push(name);
108
- }
109
- }
88
+ const manifestPath = path.join(novaspecDest, MANIFEST_FILE);
89
+ const oldManifest = readManifest(manifestPath);
90
+
91
+ const sources = collectPackageFiles(PACKAGE_ROOT);
92
+ const created = [];
93
+ const updated = [];
94
+ const skipped = [];
95
+ const removed = [];
96
+ const skippedRemoved = [];
97
+
98
+ // Update / create / skip files
99
+ for (const [rel, srcAbs] of Object.entries(sources)) {
100
+ const destAbs = path.join(destDir, rel);
101
+ const newStockHash = hashFile(srcAbs);
102
+
103
+ if (!fs.existsSync(destAbs)) {
104
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
105
+ fs.copyFileSync(srcAbs, destAbs);
106
+ created.push(rel);
107
+ continue;
108
+ }
109
+
110
+ const currentHash = hashFile(destAbs);
111
+ if (currentHash === newStockHash) continue; // already up to date
112
+
113
+ const previousShipped = oldManifest.files[rel];
114
+ const isUntouched = !previousShipped || currentHash === previousShipped;
115
+
116
+ if (isUntouched) {
117
+ fs.copyFileSync(srcAbs, destAbs);
118
+ updated.push(rel);
119
+ } else {
120
+ skipped.push(rel);
121
+ }
122
+ }
123
+
124
+ // Detect upstream removals
125
+ for (const rel of Object.keys(oldManifest.files)) {
126
+ if (sources[rel]) continue; // still shipped
127
+ const destAbs = path.join(destDir, rel);
128
+ if (!fs.existsSync(destAbs)) continue; // already gone
129
+
130
+ const currentHash = hashFile(destAbs);
131
+ if (currentHash === oldManifest.files[rel]) {
132
+ fs.unlinkSync(destAbs);
133
+ removed.push(rel);
134
+ } else {
135
+ skippedRemoved.push(rel);
110
136
  }
111
137
  }
112
138
 
113
- // Save outdated list in manifest
114
- newManifest.outdated_customs = outdated;
139
+ // Build new manifest: stock hashes for everything shipped, but keep the OLD
140
+ // hash for files we skipped (so future syncs still detect their modification).
141
+ const newManifest = generateManifest(PACKAGE_ROOT);
142
+ for (const rel of skipped) {
143
+ if (oldManifest.files[rel]) newManifest.files[rel] = oldManifest.files[rel];
144
+ }
145
+ for (const rel of skippedRemoved) {
146
+ if (oldManifest.files[rel]) newManifest.files[rel] = oldManifest.files[rel];
147
+ }
115
148
  fs.writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
116
149
 
117
- // Report
118
- console.log('\n ✓ nova-spec core updated to v' + newManifest.version + '\n');
150
+ // Migrate config.yml (idempotent)
151
+ const { migrateConfig } = require('./migrate-config.js');
152
+ migrateConfig(path.join(novaspecDest, 'config.yml'));
119
153
 
120
- if (outdated.length > 0) {
121
- console.log(' ⚠️ Custom overrides with upstream changes:');
122
- for (const name of outdated) {
123
- console.log(` - ${name} run /nova-diff ${name} to review`);
124
- }
125
- } else {
126
- console.log(' ✓ No custom overrides affected.');
154
+ // Refresh SessionStart hook in any installed runtime
155
+ ensureSessionStartHook(destDir);
156
+
157
+ printReport({ version: newManifest.version, created, updated, skipped, removed, skippedRemoved });
158
+ }
159
+
160
+ function printReport({ version, created, updated, skipped, removed, skippedRemoved }) {
161
+ console.log(`\n ✓ nova-spec synced to v${version}\n`);
162
+
163
+ if (created.length) {
164
+ console.log(` + ${created.length} new file(s):`);
165
+ for (const f of created) console.log(` ${f}`);
166
+ }
167
+ if (updated.length) {
168
+ console.log(` ↻ ${updated.length} file(s) updated (untouched locally):`);
169
+ for (const f of updated) console.log(` ${f}`);
170
+ }
171
+ if (skipped.length) {
172
+ console.log(` ⚠ ${skipped.length} file(s) NOT updated (you have local edits):`);
173
+ for (const f of skipped) console.log(` ${f} → /nova-diff ${f}`);
174
+ }
175
+ if (removed.length) {
176
+ console.log(` − ${removed.length} file(s) removed upstream:`);
177
+ for (const f of removed) console.log(` ${f}`);
178
+ }
179
+ if (skippedRemoved.length) {
180
+ console.log(` ⚠ ${skippedRemoved.length} file(s) removed upstream but kept (you edited them):`);
181
+ for (const f of skippedRemoved) console.log(` ${f}`);
182
+ }
183
+ if (!created.length && !updated.length && !skipped.length && !removed.length && !skippedRemoved.length) {
184
+ console.log(' ✓ Already up to date.');
127
185
  }
128
186
  console.log('');
129
187
  }
130
188
 
131
- function copyDirSync(src, dest) {
132
- fs.mkdirSync(dest, { recursive: true });
133
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
134
- if (entry.name === 'config.yml') continue;
135
- if (entry.name === 'custom') continue; // never overwrite custom/
136
- if (entry.name === MANIFEST_FILE) continue;
137
- const srcPath = path.join(src, entry.name);
138
- const destPath = path.join(dest, entry.name);
139
- if (entry.isDirectory()) {
140
- copyDirSync(srcPath, destPath);
141
- } else {
142
- fs.copyFileSync(srcPath, destPath);
189
+ 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}`;
192
+ }
193
+
194
+ function ensureSessionStartHook(destDir) {
195
+ const hookCommand = buildHookCommand();
196
+ const novaHook = { type: 'command', command: hookCommand, timeout: 30 };
197
+
198
+ const targets = [
199
+ path.join(destDir, '.claude', 'settings.local.json'),
200
+ path.join(destDir, '.opencode', 'settings.local.json'),
201
+ ].filter(p => fs.existsSync(path.dirname(p)));
202
+
203
+ for (const settingsPath of targets) {
204
+ let settings = {};
205
+ if (fs.existsSync(settingsPath)) {
206
+ try {
207
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
208
+ } catch (err) {
209
+ console.error(` ⚠ Could not parse ${settingsPath}: ${err.message}`);
210
+ console.error(' Skipping hook update for this file. Fix the JSON and re-run sync.');
211
+ continue;
212
+ }
143
213
  }
214
+
215
+ settings.hooks = settings.hooks || {};
216
+ settings.hooks.SessionStart = settings.hooks.SessionStart || [];
217
+
218
+ let updated = false;
219
+ 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
+ }
229
+ }
230
+
231
+ if (!updated) {
232
+ settings.hooks.SessionStart.push({ hooks: [novaHook] });
233
+ }
234
+
235
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
144
236
  }
145
237
  }
146
238
 
147
- module.exports = { sync, generateManifest };
239
+ module.exports = {
240
+ sync,
241
+ generateManifest,
242
+ ensureSessionStartHook,
243
+ buildHookCommand,
244
+ readManifest,
245
+ collectPackageFiles,
246
+ HOOK_MARKER,
247
+ FRAMEWORK_FILES,
248
+ };
@@ -27,7 +27,7 @@ If `context/` doesn't exist, return:
27
27
  ## Loaded context
28
28
  **Services**: not documented (context/ missing)
29
29
  **Decisions**: none
30
- **Gaps**: context/ structure not initialized — run install.sh
30
+ **Gaps**: context/ structure not initialized — run `npx nova-spec init`
31
31
  **Questions**: none
32
32
  ```
33
33
  And stop.
@@ -12,7 +12,7 @@ which custom overrides may need attention.
12
12
  Execute in the terminal:
13
13
 
14
14
  ```bash
15
- npx nova-spec sync
15
+ npx nova-spec@latest sync
16
16
  ```
17
17
 
18
18
  Show the output to the user as-is.
@@ -43,4 +43,4 @@ If everything is clean:
43
43
 
44
44
  - Don't modify any custom files.
45
45
  - Don't auto-merge or apply changes.
46
- - If `npx nova-spec sync` fails, show the error and stop.
46
+ - If `npx nova-spec@latest sync` fails, show the error and stop.
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # Guardrail: verify nova-spec is properly installed in this project.
3
+ #
4
+ # Usage: bash novaspec/guardrails/nova-installed.sh
5
+ # Exits 0 if installed, 1 if not.
6
+
7
+ set -euo pipefail
8
+
9
+ ok=1
10
+
11
+ if [ ! -f "novaspec/config.yml" ]; then
12
+ echo "✗ novaspec/config.yml not found — run: npx nova-spec init" >&2
13
+ ok=0
14
+ fi
15
+
16
+ if [ ! -d "context/" ]; then
17
+ echo "✗ context/ directory missing — run: npx nova-spec init" >&2
18
+ ok=0
19
+ fi
20
+
21
+ [ "$ok" -eq 1 ]
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # Guardrail: verify the proposal has no open requirements.
3
+ #
4
+ # Usage: bash novaspec/guardrails/proposal-closed.sh <ticket-id>
5
+ # Exits 0 if proposal is closed, 1 if it has open markers, 2 if missing.
6
+
7
+ set -euo pipefail
8
+
9
+ if [ -z "${1:-}" ]; then
10
+ echo "Usage: $0 <ticket-id>" >&2
11
+ exit 2
12
+ fi
13
+
14
+ ticket="$1"
15
+ proposal="context/changes/active/${ticket}/proposal.md"
16
+
17
+ if [ ! -f "$proposal" ]; then
18
+ echo "✗ Proposal not found: $proposal" >&2
19
+ exit 2
20
+ fi
21
+
22
+ # Patterns that indicate unresolved questions or placeholders.
23
+ # Word-boundary checks avoid matching e.g. "TODOS" inside a real word.
24
+ patterns=(
25
+ '\bTBD\b'
26
+ '\bTODO\b'
27
+ '\bFIXME\b'
28
+ '\?\?\?'
29
+ '<placeholder>'
30
+ '\[ \] decision'
31
+ )
32
+
33
+ found=0
34
+ for pat in "${patterns[@]}"; do
35
+ if grep -nE "$pat" "$proposal" >/dev/null 2>&1; then
36
+ if [ $found -eq 0 ]; then
37
+ echo "✗ Proposal has open markers — close them before /nova-plan:"
38
+ found=1
39
+ fi
40
+ grep -nE "$pat" "$proposal" | sed 's/^/ /'
41
+ fi
42
+ done
43
+
44
+ if [ $found -eq 1 ]; then
45
+ exit 1
46
+ fi
47
+
48
+ echo "✓ Proposal is closed."
49
+ exit 0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # Guardrail: deterministic pre-review checks.
3
+ #
4
+ # Usage: bash novaspec/guardrails/review-checks.sh <ticket-id> [base-branch]
5
+ #
6
+ # Runs (in order):
7
+ # 1. diff is non-empty (committed + working tree)
8
+ # 2. every "Files to touch" entry from tasks.md appears in the diff
9
+ # 3. lint clean (if `npm run lint` / `pnpm lint` / `yarn lint` exists)
10
+ # 4. tests pass (if `npm test` / `pnpm test` / `yarn test` exists)
11
+ #
12
+ # Exits 0 if every applicable check passes, 1 if any blocking check fails,
13
+ # 2 on usage error. Skipped checks (e.g. no lint script) are reported but
14
+ # don't fail.
15
+
16
+ set -uo pipefail
17
+
18
+ if [ -z "${1:-}" ]; then
19
+ echo "Usage: $0 <ticket-id> [base-branch]" >&2
20
+ exit 2
21
+ fi
22
+
23
+ ticket="$1"
24
+ base="${2:-main}"
25
+ fail=0
26
+
27
+ # 1. Diff non-empty
28
+ diff_total="$(git diff "$base"...HEAD 2>/dev/null; git diff HEAD 2>/dev/null)"
29
+ if [ -z "$diff_total" ]; then
30
+ echo "✗ Empty diff: no committed or staged changes against $base."
31
+ fail=1
32
+ else
33
+ echo "✓ Diff is non-empty."
34
+ fi
35
+
36
+ # 2. Files to touch
37
+ tasks="context/changes/active/${ticket}/tasks.md"
38
+ if [ -f "$tasks" ]; then
39
+ # Pull every path under a "Files to touch" section. We look for lines that
40
+ # look like list items containing a path-ish token, but only between that
41
+ # heading and the next heading.
42
+ declared="$(awk '
43
+ /^#+ +Files to touch/i, /^#+ +/ {
44
+ if (NR > 1 && /^#+ +/ && !/^#+ +Files to touch/i) next_section=1
45
+ if (next_section) next
46
+ if (match($0, /[`"]?([\.a-zA-Z0-9_\/-]+\.[a-zA-Z0-9]+)[`"]?/, m)) print m[1]
47
+ }
48
+ ' "$tasks" | sort -u)"
49
+
50
+ if [ -n "$declared" ]; then
51
+ missing=""
52
+ while IFS= read -r f; do
53
+ [ -z "$f" ] && continue
54
+ if ! git diff "$base"...HEAD --name-only | grep -qxF "$f" \
55
+ && ! git diff HEAD --name-only | grep -qxF "$f"; then
56
+ missing+=" - $f"$'\n'
57
+ fi
58
+ done <<< "$declared"
59
+
60
+ if [ -n "$missing" ]; then
61
+ echo "✗ Files declared in tasks.md but missing from diff:"
62
+ printf '%s' "$missing"
63
+ fail=1
64
+ else
65
+ echo "✓ All declared files present in diff."
66
+ fi
67
+ else
68
+ echo "ℹ︎ tasks.md has no 'Files to touch' section — skipping declared-files check."
69
+ fi
70
+ else
71
+ echo "ℹ︎ No tasks.md (quick-fix path) — skipping declared-files check."
72
+ fi
73
+
74
+ # 3 + 4. Lint and test
75
+ detect_pm() {
76
+ if [ -f pnpm-lock.yaml ]; then echo pnpm
77
+ elif [ -f yarn.lock ]; then echo yarn
78
+ elif [ -f package-lock.json ] || [ -f package.json ]; then echo npm
79
+ else echo ''
80
+ fi
81
+ }
82
+
83
+ has_script() {
84
+ local pm="$1" script="$2"
85
+ if [ ! -f package.json ]; then return 1; fi
86
+ node -e "process.exit(((require('./package.json').scripts||{})['$script'])?0:1)" 2>/dev/null
87
+ }
88
+
89
+ pm="$(detect_pm)"
90
+
91
+ if [ -n "$pm" ] && has_script "$pm" lint; then
92
+ echo "Running $pm run lint…"
93
+ if "$pm" run lint --silent >/dev/null 2>&1; then
94
+ echo "✓ Lint clean."
95
+ else
96
+ echo "✗ Lint failed. Run \`$pm run lint\` and fix before review."
97
+ fail=1
98
+ fi
99
+ else
100
+ echo "ℹ︎ No lint script — skipping."
101
+ fi
102
+
103
+ if [ -n "$pm" ] && has_script "$pm" test; then
104
+ echo "Running $pm test…"
105
+ if "$pm" test --silent >/dev/null 2>&1; then
106
+ echo "✓ Tests pass."
107
+ else
108
+ echo "✗ Tests failed. Run \`$pm test\` and fix before review."
109
+ fail=1
110
+ fi
111
+ else
112
+ echo "ℹ︎ No test script — skipping."
113
+ fi
114
+
115
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,14 @@
1
+ ## Ticket: <TICKET-ID> — "<title>"
2
+
3
+ Classification : <quick-fix | feature | architecture> (<estimated effort>)
4
+ Affected services : <svc-1> ✓, <svc-2> ✓
5
+ Branch created : <type>/<TICKET>-<slug> (from <base>)
6
+
7
+ Loaded context:
8
+ Services : <names with ✓ if `context/services/<name>.md` exists, ✗ otherwise>
9
+ Decisions read: <list of decision filenames, or "none">
10
+ Gotchas read : <list, or "none">
11
+ Gaps : <list of missing context, or "none">
12
+ Questions : <open questions for the user, or "none">
13
+
14
+ Next step: <next command, e.g. `/nova-spec` or `/nova-build`>
package/package.json CHANGED
@@ -1,18 +1,25 @@
1
1
  {
2
2
  "name": "nova-spec",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Spec-Driven Development framework for Claude Code and OpenCode",
5
5
  "bin": {
6
- "nova-spec": "./bin/nova-spec.js"
6
+ "nova-spec": "bin/nova-spec.js"
7
7
  },
8
8
  "files": [
9
9
  "bin/",
10
10
  "lib/",
11
- "novaspec/",
12
- "AGENTS.md"
11
+ "novaspec/agents/",
12
+ "novaspec/commands/",
13
+ "novaspec/guardrails/",
14
+ "novaspec/skills/",
15
+ "novaspec/templates/",
16
+ "novaspec/config.example.yml",
17
+ "AGENTS.md",
18
+ "INSTALL.md",
19
+ "PHILOSOPHY.md"
13
20
  ],
14
21
  "scripts": {
15
- "test": "echo \"No automated tests — use smoke test in CLAUDE.md\""
22
+ "test": "node test/smoke.test.js"
16
23
  },
17
24
  "dependencies": {
18
25
  "@inquirer/prompts": "^7.0.0"
@@ -23,7 +30,7 @@
23
30
  "license": "MIT",
24
31
  "repository": {
25
32
  "type": "git",
26
- "url": "https://github.com/Adansuku/nova-spec"
33
+ "url": "git+https://github.com/Adansuku/nova-spec.git"
27
34
  },
28
35
  "keywords": [
29
36
  "claude-code",