nova-spec 1.0.2 → 1.0.5
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/AGENTS.md +32 -3
- package/CLAUDE.md +3 -0
- package/INSTALL.md +144 -0
- package/PHILOSOPHY.md +149 -0
- package/README.md +27 -12
- package/lib/cli.js +153 -1
- package/lib/forge.js +63 -0
- package/lib/installer.js +322 -131
- package/lib/jira.js +86 -0
- package/lib/migrate-config.js +59 -0
- package/lib/sync.js +180 -118
- package/novaspec/agents/context-loader.md +1 -1
- package/novaspec/guardrails/nova-installed.sh +21 -0
- package/novaspec/guardrails/proposal-closed.sh +49 -0
- package/novaspec/guardrails/review-checks.sh +115 -0
- package/novaspec/templates/ticket-summary.md +14 -0
- package/package.json +12 -4
- package/novaspec/.nova-manifest.json +0 -30
- package/novaspec/config.yml +0 -23
- package/novaspec/custom/agents/.gitkeep +0 -0
- package/novaspec/custom/commands/.gitkeep +0 -0
- package/novaspec/custom/skills/.gitkeep +0 -0
|
@@ -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,247 @@
|
|
|
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
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
} else {
|
|
23
|
-
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
52
|
-
outdated_customs: [],
|
|
63
|
+
files,
|
|
53
64
|
};
|
|
65
|
+
}
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
fs.
|
|
57
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
110
|
+
const currentHash = hashFile(destAbs);
|
|
111
|
+
if (currentHash === newStockHash) continue; // already up to date
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
const previousShipped = oldManifest.files[rel];
|
|
114
|
+
const isUntouched = !previousShipped || currentHash === previousShipped;
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
123
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
130
|
-
|
|
157
|
+
printReport({ version: newManifest.version, created, updated, skipped, removed, skippedRemoved });
|
|
158
|
+
}
|
|
131
159
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
161
|
-
|
|
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)));
|
|
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 {
|
|
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 =
|
|
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 = {
|
|
239
|
+
module.exports = {
|
|
240
|
+
sync,
|
|
241
|
+
generateManifest,
|
|
242
|
+
ensureSessionStartHook,
|
|
243
|
+
buildHookCommand,
|
|
244
|
+
readManifest,
|
|
245
|
+
collectPackageFiles,
|
|
246
|
+
HOOK_MARKER,
|
|
247
|
+
FRAMEWORK_FILES,
|
|
248
|
+
MANIFEST_FILE,
|
|
249
|
+
};
|
|
@@ -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
|
|
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
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nova-spec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Spec-Driven Development framework for Claude Code and OpenCode",
|
|
5
5
|
"bin": {
|
|
6
6
|
"nova-spec": "bin/nova-spec.js"
|
|
@@ -8,11 +8,19 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
10
10
|
"lib/",
|
|
11
|
-
"novaspec/",
|
|
12
|
-
"
|
|
11
|
+
"novaspec/agents/",
|
|
12
|
+
"novaspec/commands/",
|
|
13
|
+
"novaspec/guardrails/",
|
|
14
|
+
"novaspec/skills/",
|
|
15
|
+
"novaspec/templates/",
|
|
16
|
+
"novaspec/config.example.yml",
|
|
17
|
+
"AGENTS.md",
|
|
18
|
+
"CLAUDE.md",
|
|
19
|
+
"INSTALL.md",
|
|
20
|
+
"PHILOSOPHY.md"
|
|
13
21
|
],
|
|
14
22
|
"scripts": {
|
|
15
|
-
"test": "
|
|
23
|
+
"test": "node test/smoke.test.js"
|
|
16
24
|
},
|
|
17
25
|
"dependencies": {
|
|
18
26
|
"@inquirer/prompts": "^7.0.0"
|