nova-spec 1.0.2 → 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.
package/lib/jira.js ADDED
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const { URL } = require('url');
5
+
6
+ function basicAuth(email, token) {
7
+ return Buffer.from(`${email}:${token}`).toString('base64');
8
+ }
9
+
10
+ function request({ url, method = 'GET', email, token, body = null }) {
11
+ return new Promise((resolve, reject) => {
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(url);
15
+ } catch (_) {
16
+ return reject(new Error(`Invalid URL: ${url}`));
17
+ }
18
+
19
+ const options = {
20
+ method,
21
+ hostname: parsed.hostname,
22
+ port: parsed.port || 443,
23
+ path: parsed.pathname + parsed.search,
24
+ headers: {
25
+ Authorization: `Basic ${basicAuth(email, token)}`,
26
+ Accept: 'application/json',
27
+ },
28
+ };
29
+
30
+ if (body) {
31
+ options.headers['Content-Type'] = 'application/json';
32
+ }
33
+
34
+ const req = https.request(options, (res) => {
35
+ let data = '';
36
+ res.setEncoding('utf8');
37
+ res.on('data', (chunk) => (data += chunk));
38
+ res.on('end', () => {
39
+ const status = res.statusCode || 0;
40
+ if (status >= 400) {
41
+ const error = new Error(`Jira HTTP ${status}: ${data || res.statusMessage}`);
42
+ error.status = status;
43
+ return reject(error);
44
+ }
45
+ if (!data) return resolve(null);
46
+ try {
47
+ resolve(JSON.parse(data));
48
+ } catch (err) {
49
+ reject(new Error(`Invalid JSON from Jira: ${err.message}`));
50
+ }
51
+ });
52
+ });
53
+
54
+ req.on('error', (err) => reject(err));
55
+ if (body) req.write(JSON.stringify(body));
56
+ req.end();
57
+ });
58
+ }
59
+
60
+ async function getIssueAsync({ url, email, token, ticket }) {
61
+ const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}`;
62
+ return request({ url: endpoint, email, token });
63
+ }
64
+
65
+ async function listTransitionsAsync({ url, email, token, ticket }) {
66
+ const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}/transitions`;
67
+ const response = await request({ url: endpoint, email, token });
68
+ return (response && response.transitions) || [];
69
+ }
70
+
71
+ async function transitionAsync({ url, email, token, ticket, transitionId }) {
72
+ const endpoint = `${url.replace(/\/$/, '')}/rest/api/3/issue/${encodeURIComponent(ticket)}/transitions`;
73
+ return request({
74
+ url: endpoint,
75
+ method: 'POST',
76
+ email,
77
+ token,
78
+ body: { transition: { id: String(transitionId) } },
79
+ });
80
+ }
81
+
82
+ function runCli() {
83
+ // CLI mode: node lib/jira.js <command> [args...]
84
+ const [, , cmd, ...args] = process.argv;
85
+ const { JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN } = process.env;
86
+
87
+ if (!JIRA_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) {
88
+ console.error('Missing env vars: JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN');
89
+ process.exit(2);
90
+ }
91
+
92
+ const baseArgs = { url: JIRA_URL, email: JIRA_EMAIL, token: JIRA_API_TOKEN };
93
+ let promise;
94
+
95
+ switch (cmd) {
96
+ case 'get':
97
+ if (!args[0]) {
98
+ console.error('Usage: nova-jira get <TICKET>');
99
+ process.exit(2);
100
+ }
101
+ promise = getIssueAsync({ ...baseArgs, ticket: args[0] });
102
+ break;
103
+ case 'transitions':
104
+ if (!args[0]) {
105
+ console.error('Usage: nova-jira transitions <TICKET>');
106
+ process.exit(2);
107
+ }
108
+ promise = listTransitionsAsync({ ...baseArgs, ticket: args[0] });
109
+ break;
110
+ case 'transition':
111
+ if (!args[0] || !args[1]) {
112
+ console.error('Usage: nova-jira transition <TICKET> <TRANSITION_ID>');
113
+ process.exit(2);
114
+ }
115
+ promise = transitionAsync({ ...baseArgs, ticket: args[0], transitionId: args[1] });
116
+ break;
117
+ default:
118
+ console.error('Commands: get <TICKET> | transitions <TICKET> | transition <TICKET> <ID>');
119
+ process.exit(2);
120
+ }
121
+
122
+ promise
123
+ .then((r) => {
124
+ if (r != null) console.log(JSON.stringify(r, null, 2));
125
+ })
126
+ .catch((err) => {
127
+ console.error(err.message);
128
+ process.exit(err.status === 401 ? 401 : err.status === 404 ? 404 : 1);
129
+ });
130
+ }
131
+
132
+ if (require.main === module) runCli();
133
+
134
+ module.exports = {
135
+ getIssueAsync,
136
+ listTransitionsAsync,
137
+ transitionAsync,
138
+ };
@@ -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,185 +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
- }
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
+ }
84
109
 
85
- copyDirSync(NOVASPEC_SRC, novaspecDest);
110
+ const currentHash = hashFile(destAbs);
111
+ if (currentHash === newStockHash) continue; // already up to date
86
112
 
87
- // Restore config
88
- if (configBackup) fs.writeFileSync(configPath, configBackup);
113
+ const previousShipped = oldManifest.files[rel];
114
+ const isUntouched = !previousShipped || currentHash === previousShipped;
89
115
 
90
- // Update AGENTS.md and CLAUDE.md (always overwrite — these are framework files)
91
- for (const file of ['AGENTS.md', 'CLAUDE.md']) {
92
- const src = path.join(PACKAGE_ROOT, file);
93
- const dest = path.join(destDir, file);
94
- if (fs.existsSync(src) && fs.existsSync(dest)) {
95
- fs.copyFileSync(src, dest);
116
+ if (isUntouched) {
117
+ fs.copyFileSync(srcAbs, destAbs);
118
+ updated.push(rel);
119
+ } else {
120
+ skipped.push(rel);
96
121
  }
97
122
  }
98
123
 
99
- // Regenerate manifest with new hashes
100
- const newManifest = generateManifest(novaspecDest);
101
-
102
- // Check custom overrides
103
- const customDir = path.join(novaspecDest, 'custom');
104
- const outdated = [];
105
-
106
- if (fs.existsSync(customDir)) {
107
- for (const section of ['commands', 'skills', 'agents']) {
108
- const customSection = path.join(customDir, section);
109
- if (!fs.existsSync(customSection)) continue;
110
- for (const entry of fs.readdirSync(customSection, { withFileTypes: true })) {
111
- const name = entry.name.replace('.md', '');
112
- const key = `${section}/${name}`;
113
- const oldHash = oldManifest.hashes[key];
114
- const newHash = newManifest.hashes[key];
115
- if (oldHash && newHash && JSON.stringify(oldHash) !== JSON.stringify(newHash)) {
116
- outdated.push(name);
117
- }
118
- }
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);
119
136
  }
120
137
  }
121
138
 
122
- // Save outdated list in manifest
123
- 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
+ }
124
148
  fs.writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2) + '\n');
125
149
 
126
- // Ensure SessionStart hook is present (idempotent)
150
+ // Migrate config.yml (idempotent)
151
+ const { migrateConfig } = require('./migrate-config.js');
152
+ migrateConfig(path.join(novaspecDest, 'config.yml'));
153
+
154
+ // Refresh SessionStart hook in any installed runtime
127
155
  ensureSessionStartHook(destDir);
128
156
 
129
- // Report
130
- console.log('\n ✓ nova-spec core updated to v' + newManifest.version + '\n');
157
+ printReport({ version: newManifest.version, created, updated, skipped, removed, skippedRemoved });
158
+ }
131
159
 
132
- if (outdated.length > 0) {
133
- console.log(' ⚠️ Custom overrides with upstream changes:');
134
- for (const name of outdated) {
135
- console.log(` - ${name} → run /nova-diff ${name} to review`);
136
- }
137
- } else {
138
- console.log(' ✓ No custom overrides affected.');
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.');
139
185
  }
140
186
  console.log('');
141
187
  }
142
188
 
143
- function copyDirSync(src, dest) {
144
- fs.mkdirSync(dest, { recursive: true });
145
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
146
- if (entry.name === 'config.yml') continue;
147
- if (entry.name === 'custom') continue; // never overwrite custom/
148
- if (entry.name === MANIFEST_FILE) continue;
149
- const srcPath = path.join(src, entry.name);
150
- const destPath = path.join(dest, entry.name);
151
- if (entry.isDirectory()) {
152
- copyDirSync(srcPath, destPath);
153
- } else {
154
- fs.copyFileSync(srcPath, destPath);
155
- }
156
- }
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}`;
157
192
  }
158
193
 
159
194
  function ensureSessionStartHook(destDir) {
160
- const hook = {
161
- type: 'command',
162
- command: 'npx nova-spec@latest sync 2>/dev/null || true',
163
- timeout: 30,
164
- };
195
+ const hookCommand = buildHookCommand();
196
+ const novaHook = { type: 'command', command: hookCommand, timeout: 30 };
165
197
 
166
198
  const targets = [
167
199
  path.join(destDir, '.claude', 'settings.local.json'),
168
200
  path.join(destDir, '.opencode', 'settings.local.json'),
169
- ].filter(p => fs.existsSync(path.dirname(p))); // only if runtime dir exists
201
+ ].filter(p => fs.existsSync(path.dirname(p)));
170
202
 
171
203
  for (const settingsPath of targets) {
172
204
  let settings = {};
173
205
  if (fs.existsSync(settingsPath)) {
174
- try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch (_) {}
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
+ }
175
213
  }
176
214
 
177
- const existing = settings.hooks?.SessionStart?.[0]?.hooks?.[0];
178
- if (existing?.command === hook.command) continue; // already up to date
179
-
180
- // Overwrite if missing or using outdated command (e.g. without @latest)
181
215
  settings.hooks = settings.hooks || {};
182
- settings.hooks.SessionStart = [{ hooks: [hook] }];
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
+
183
235
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
184
236
  }
185
237
  }
186
238
 
187
- 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.
@@ -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