nubos-pilot 0.3.0 → 0.4.0
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/np-code-fixer.md +22 -4
- package/agents/np-code-reviewer.md +6 -5
- package/agents/np-codebase-documenter.md +176 -0
- package/agents/np-executor.md +54 -7
- package/agents/np-planner.md +1 -0
- package/agents/np-researcher.md +7 -0
- package/bin/np-tools/discuss-project.cjs +377 -0
- package/bin/np-tools/discuss-project.test.cjs +238 -0
- package/bin/np-tools/doctor.cjs +103 -0
- package/bin/np-tools/doctor.test.cjs +112 -0
- package/bin/np-tools/new-project.cjs +6 -0
- package/bin/np-tools/scan-codebase.cjs +204 -0
- package/bin/np-tools/scan-codebase.test.cjs +165 -0
- package/bin/np-tools/update-docs.cjs +216 -0
- package/bin/np-tools/update-docs.test.cjs +130 -0
- package/docs/adr/0007-codebase-docs-layer.md +273 -0
- package/docs/adr/README.md +2 -0
- package/lib/codebase-docs.cjs +450 -0
- package/lib/codebase-docs.test.cjs +266 -0
- package/lib/codebase-manifest.cjs +171 -0
- package/lib/codebase-manifest.test.cjs +156 -0
- package/lib/git.cjs +38 -0
- package/lib/workspace-scan.cjs +290 -0
- package/lib/workspace-scan.test.cjs +212 -0
- package/np-tools.cjs +3 -0
- package/package.json +1 -1
- package/templates/PROJECT.md +26 -4
- package/workflows/discuss-project.md +177 -0
- package/workflows/new-project.md +141 -94
- package/workflows/scan-codebase.md +155 -0
- package/workflows/update-docs.md +132 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { NubosPilotError, atomicWriteFileSync } = require('../../lib/core.cjs');
|
|
5
|
+
const { scan } = require('../../lib/workspace-scan.cjs');
|
|
6
|
+
const { workspaceGitInfo } = require('../../lib/git.cjs');
|
|
7
|
+
|
|
8
|
+
const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
|
|
9
|
+
const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
10
|
+
|
|
11
|
+
const REQUIRED_FIELDS = Object.freeze([
|
|
12
|
+
'project_description',
|
|
13
|
+
'domain_text',
|
|
14
|
+
'target_users_text',
|
|
15
|
+
'non_goals_text',
|
|
16
|
+
'success_criteria_text',
|
|
17
|
+
'strategic_decisions_text',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function _render(raw, vars, name) {
|
|
21
|
+
return raw.replace(PLACEHOLDER_RE, (_m, key) => {
|
|
22
|
+
if (!(key in vars)) {
|
|
23
|
+
throw new NubosPilotError(
|
|
24
|
+
'template-unresolved-var',
|
|
25
|
+
`Undefined placeholder {{${key}}} in template "${name}"`,
|
|
26
|
+
{ template: name, variable: key, available: Object.keys(vars) },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return String(vars[key]);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _parseArgs(args) {
|
|
34
|
+
const flags = {
|
|
35
|
+
cwd: null,
|
|
36
|
+
mode: 'plan',
|
|
37
|
+
answersPath: null,
|
|
38
|
+
bootstrap: false,
|
|
39
|
+
proposedRequirementsPath: null,
|
|
40
|
+
};
|
|
41
|
+
for (let i = 0; i < (args || []).length; i++) {
|
|
42
|
+
const a = args[i];
|
|
43
|
+
if (a === '--cwd') flags.cwd = args[++i];
|
|
44
|
+
else if (a === '--apply') { flags.mode = 'apply'; flags.answersPath = args[++i]; }
|
|
45
|
+
else if (a === '--bootstrap') flags.bootstrap = true;
|
|
46
|
+
else if (a === '--proposed-requirements') flags.proposedRequirementsPath = args[++i];
|
|
47
|
+
}
|
|
48
|
+
return flags;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _scanContextFor(projectRoot) {
|
|
52
|
+
const scanResult = scan({ cwd: projectRoot, batchSize: 1000, gitInfo: workspaceGitInfo });
|
|
53
|
+
return {
|
|
54
|
+
file_count: scanResult.stats.file_count,
|
|
55
|
+
language_distribution: scanResult.language_distribution,
|
|
56
|
+
manifest_paths: Object.keys(scanResult.manifests).sort(),
|
|
57
|
+
doc_paths: Object.keys(scanResult.docs).sort(),
|
|
58
|
+
git: scanResult.git,
|
|
59
|
+
readme_head: scanResult.docs['README.md']
|
|
60
|
+
? (scanResult.docs['README.md'].content || '').split('\n').slice(0, 40).join('\n')
|
|
61
|
+
: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _grayAreas() {
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
key: 'target_users_text',
|
|
69
|
+
question: 'Target users — who uses this and in what context?',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: 'domain_text',
|
|
73
|
+
question: 'Domain / lore / background — what world does this live in? (industry, inspiration, reference systems)',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'project_description',
|
|
77
|
+
question: 'What This Is — 2–3 sentences describing what the product does and who it serves (in your words)',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: 'non_goals_text',
|
|
81
|
+
question: 'Non-Goals — what is this project explicitly NOT? List things that might look in-scope but are out.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'success_criteria_text',
|
|
85
|
+
question: 'Success Criteria — how do you know it worked? Concrete, observable, not vibes.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'strategic_decisions_text',
|
|
89
|
+
question: 'Strategic Decisions — tech choices, constraints you are locking in at the product level (stack, deployment model, data strategy, etc.)',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _emitPlan(projectRoot, flags, stdout) {
|
|
95
|
+
const projectMd = path.join(projectRoot, '.nubos-pilot', 'PROJECT.md');
|
|
96
|
+
const projectExists = fs.existsSync(projectMd);
|
|
97
|
+
const mode = flags.bootstrap || !projectExists ? 'bootstrap' : 'refresh';
|
|
98
|
+
|
|
99
|
+
const scanContext = _scanContextFor(projectRoot);
|
|
100
|
+
|
|
101
|
+
stdout.write(JSON.stringify({
|
|
102
|
+
mode: 'plan',
|
|
103
|
+
sub_mode: mode,
|
|
104
|
+
project_md_exists: projectExists,
|
|
105
|
+
project_md_path: projectMd,
|
|
106
|
+
scan_context: scanContext,
|
|
107
|
+
questions: _grayAreas(),
|
|
108
|
+
required_fields: REQUIRED_FIELDS,
|
|
109
|
+
requirements_md_path: path.join(projectRoot, '.nubos-pilot', 'REQUIREMENTS.md'),
|
|
110
|
+
}, null, 2));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _validateAnswers(answers) {
|
|
114
|
+
for (const key of REQUIRED_FIELDS) {
|
|
115
|
+
if (typeof answers[key] !== 'string' || answers[key].trim() === '') {
|
|
116
|
+
throw new NubosPilotError(
|
|
117
|
+
'discuss-project-missing-field',
|
|
118
|
+
'answers JSON missing field: ' + key,
|
|
119
|
+
{ field: key },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _readExistingProjectMd(projectMd) {
|
|
126
|
+
if (!fs.existsSync(projectMd)) return null;
|
|
127
|
+
try {
|
|
128
|
+
return fs.readFileSync(projectMd, 'utf-8');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
throw new NubosPilotError(
|
|
131
|
+
'discuss-project-project-unreadable',
|
|
132
|
+
'PROJECT.md present but unreadable',
|
|
133
|
+
{ path: projectMd, cause: err && err.code },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _extractExistingField(md, heading) {
|
|
139
|
+
const re = new RegExp('^##\\s+' + heading.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + '\\s*$', 'm');
|
|
140
|
+
const match = md.match(re);
|
|
141
|
+
if (!match) return null;
|
|
142
|
+
const start = match.index + match[0].length;
|
|
143
|
+
const rest = md.slice(start);
|
|
144
|
+
const nextHeading = rest.match(/\n##\s+/);
|
|
145
|
+
const body = nextHeading ? rest.slice(0, nextHeading.index) : rest;
|
|
146
|
+
return body.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _replaceSectionBody(md, heading, newBody) {
|
|
150
|
+
const lines = md.split('\n');
|
|
151
|
+
const headingRe = new RegExp('^##\\s+' + heading.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + '\\s*$');
|
|
152
|
+
const nextHeadingRe = /^##\s+\S/;
|
|
153
|
+
let startIdx = -1;
|
|
154
|
+
for (let i = 0; i < lines.length; i++) {
|
|
155
|
+
if (headingRe.test(lines[i])) { startIdx = i; break; }
|
|
156
|
+
}
|
|
157
|
+
if (startIdx < 0) return null;
|
|
158
|
+
let endIdx = lines.length;
|
|
159
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
160
|
+
if (nextHeadingRe.test(lines[i])) { endIdx = i; break; }
|
|
161
|
+
}
|
|
162
|
+
const before = lines.slice(0, startIdx + 1);
|
|
163
|
+
const after = lines.slice(endIdx);
|
|
164
|
+
return [...before, '', newBody, '', ...after].join('\n');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _applyRefresh(projectRoot, projectMd, answers) {
|
|
168
|
+
const raw = _readExistingProjectMd(projectMd);
|
|
169
|
+
if (!raw) {
|
|
170
|
+
throw new NubosPilotError(
|
|
171
|
+
'discuss-project-cannot-refresh',
|
|
172
|
+
'cannot refresh because PROJECT.md does not exist — run np:new-project first',
|
|
173
|
+
{ path: projectMd },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const updates = [
|
|
178
|
+
{ heading: 'What This Is', body: answers.project_description },
|
|
179
|
+
{ heading: 'Domain', body: answers.domain_text },
|
|
180
|
+
{ heading: 'Target Users', body: answers.target_users_text },
|
|
181
|
+
{ heading: 'Non-Goals', body: answers.non_goals_text },
|
|
182
|
+
{ heading: 'Success Criteria', body: answers.success_criteria_text },
|
|
183
|
+
{ heading: 'Strategic Decisions', body: answers.strategic_decisions_text },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
let out = raw;
|
|
187
|
+
for (const u of updates) {
|
|
188
|
+
const replaced = _replaceSectionBody(out, u.heading, u.body);
|
|
189
|
+
if (replaced != null) {
|
|
190
|
+
out = replaced;
|
|
191
|
+
} else {
|
|
192
|
+
out = out.replace(/\n## Constraints/, '\n## ' + u.heading + '\n\n' + u.body + '\n\n## Constraints');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
197
|
+
out = out.replace(/\*Last updated:[^\n]*\*/g, '*Last updated: ' + now + ' after np:discuss-project*');
|
|
198
|
+
|
|
199
|
+
atomicWriteFileSync(projectMd, out);
|
|
200
|
+
return { mode: 'apply-refresh', project_md_path: projectMd, updated_at: now };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _applyBootstrap(projectRoot, answers) {
|
|
204
|
+
const projectMd = path.join(projectRoot, '.nubos-pilot', 'PROJECT.md');
|
|
205
|
+
const raw = _readExistingProjectMd(projectMd);
|
|
206
|
+
if (!raw) {
|
|
207
|
+
throw new NubosPilotError(
|
|
208
|
+
'discuss-project-bootstrap-requires-project',
|
|
209
|
+
'bootstrap mode requires PROJECT.md to exist (scaffold first via np:new-project)',
|
|
210
|
+
{ path: projectMd },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const result = _applyRefresh(projectRoot, projectMd, answers);
|
|
214
|
+
result.mode = 'apply-bootstrap';
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const REQ_ID_RE = /^REQ-\d{2,}$/;
|
|
219
|
+
const REQ_ID_IN_MD_RE = /\*\*(REQ-\d{2,})\*\*/g;
|
|
220
|
+
|
|
221
|
+
function _extractExistingReqIds(reqMd) {
|
|
222
|
+
const ids = new Set();
|
|
223
|
+
if (typeof reqMd !== 'string') return ids;
|
|
224
|
+
let m;
|
|
225
|
+
while ((m = REQ_ID_IN_MD_RE.exec(reqMd)) !== null) {
|
|
226
|
+
ids.add(m[1]);
|
|
227
|
+
}
|
|
228
|
+
return ids;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateProposedRequirements(proposedReqs, existingIds) {
|
|
232
|
+
if (!Array.isArray(proposedReqs)) {
|
|
233
|
+
throw new NubosPilotError(
|
|
234
|
+
'proposed-reqs-not-array',
|
|
235
|
+
'proposed requirements must be a JSON array',
|
|
236
|
+
{ got: typeof proposedReqs },
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
const valid = [];
|
|
241
|
+
for (let i = 0; i < proposedReqs.length; i++) {
|
|
242
|
+
const req = proposedReqs[i];
|
|
243
|
+
if (!req || typeof req !== 'object') {
|
|
244
|
+
throw new NubosPilotError(
|
|
245
|
+
'proposed-reqs-invalid-entry',
|
|
246
|
+
`entry ${i} is not an object`,
|
|
247
|
+
{ index: i },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (typeof req.id !== 'string' || !REQ_ID_RE.test(req.id)) {
|
|
251
|
+
throw new NubosPilotError(
|
|
252
|
+
'proposed-reqs-invalid-id',
|
|
253
|
+
`entry ${i} has invalid id (expected REQ-NN): ${JSON.stringify(req.id)}`,
|
|
254
|
+
{ index: i, id: req.id },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (typeof req.text !== 'string' || req.text.trim() === '') {
|
|
258
|
+
throw new NubosPilotError(
|
|
259
|
+
'proposed-reqs-empty-text',
|
|
260
|
+
`entry ${i} (${req.id}) has empty text`,
|
|
261
|
+
{ index: i, id: req.id },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (seen.has(req.id)) {
|
|
265
|
+
throw new NubosPilotError(
|
|
266
|
+
'proposed-reqs-duplicate-id',
|
|
267
|
+
`duplicate id in proposed requirements: ${req.id}`,
|
|
268
|
+
{ id: req.id },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (existingIds && existingIds.has(req.id)) {
|
|
272
|
+
throw new NubosPilotError(
|
|
273
|
+
'proposed-reqs-collides-with-existing',
|
|
274
|
+
`id already exists in REQUIREMENTS.md: ${req.id}`,
|
|
275
|
+
{ id: req.id },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
seen.add(req.id);
|
|
279
|
+
valid.push({ id: req.id, text: req.text.trim() });
|
|
280
|
+
}
|
|
281
|
+
return valid;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function _applyProposedRequirements(projectRoot, proposedReqs) {
|
|
285
|
+
if (!Array.isArray(proposedReqs) || proposedReqs.length === 0) return null;
|
|
286
|
+
const reqMdPath = path.join(projectRoot, '.nubos-pilot', 'REQUIREMENTS.md');
|
|
287
|
+
if (!fs.existsSync(reqMdPath)) return null;
|
|
288
|
+
const raw = fs.readFileSync(reqMdPath, 'utf-8');
|
|
289
|
+
const existingIds = _extractExistingReqIds(raw);
|
|
290
|
+
const valid = validateProposedRequirements(proposedReqs, existingIds);
|
|
291
|
+
|
|
292
|
+
const lines = [];
|
|
293
|
+
lines.push('## Proposed (from np:discuss-project)');
|
|
294
|
+
lines.push('');
|
|
295
|
+
lines.push('_Review and promote to Active. Remove this block once reconciled._');
|
|
296
|
+
lines.push('');
|
|
297
|
+
for (const req of valid) {
|
|
298
|
+
lines.push('- **' + req.id + '** — ' + req.text);
|
|
299
|
+
}
|
|
300
|
+
lines.push('');
|
|
301
|
+
const appended = raw + '\n' + lines.join('\n') + '\n';
|
|
302
|
+
atomicWriteFileSync(reqMdPath, appended);
|
|
303
|
+
return reqMdPath;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _apply(projectRoot, flags, stdout) {
|
|
307
|
+
let raw;
|
|
308
|
+
try {
|
|
309
|
+
raw = fs.readFileSync(flags.answersPath, 'utf-8');
|
|
310
|
+
} catch (err) {
|
|
311
|
+
throw new NubosPilotError(
|
|
312
|
+
'discuss-project-answers-unreadable',
|
|
313
|
+
'answers file not readable: ' + flags.answersPath,
|
|
314
|
+
{ path: flags.answersPath, cause: err && err.code },
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
let answers;
|
|
318
|
+
try {
|
|
319
|
+
answers = JSON.parse(raw);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw new NubosPilotError(
|
|
322
|
+
'discuss-project-answers-parse-error',
|
|
323
|
+
'answers file is not valid JSON',
|
|
324
|
+
{ path: flags.answersPath, cause: err && err.message },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
_validateAnswers(answers);
|
|
328
|
+
|
|
329
|
+
const projectMd = path.join(projectRoot, '.nubos-pilot', 'PROJECT.md');
|
|
330
|
+
const shouldBootstrap = flags.bootstrap || (answers._mode === 'bootstrap');
|
|
331
|
+
const result = shouldBootstrap
|
|
332
|
+
? _applyBootstrap(projectRoot, answers)
|
|
333
|
+
: _applyRefresh(projectRoot, projectMd, answers);
|
|
334
|
+
|
|
335
|
+
let reqPath = null;
|
|
336
|
+
if (flags.proposedRequirementsPath) {
|
|
337
|
+
try {
|
|
338
|
+
const reqs = JSON.parse(fs.readFileSync(flags.proposedRequirementsPath, 'utf-8'));
|
|
339
|
+
reqPath = _applyProposedRequirements(projectRoot, reqs);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
throw new NubosPilotError(
|
|
342
|
+
'discuss-project-proposed-reqs-unreadable',
|
|
343
|
+
'proposed requirements file not readable/parseable: ' + flags.proposedRequirementsPath,
|
|
344
|
+
{ path: flags.proposedRequirementsPath, cause: err && err.message },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
stdout.write(JSON.stringify({
|
|
350
|
+
...result,
|
|
351
|
+
requirements_updated: reqPath ? path.relative(projectRoot, reqPath) : null,
|
|
352
|
+
}, null, 2));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function run(args, ctx) {
|
|
356
|
+
const context = ctx || {};
|
|
357
|
+
const stdout = context.stdout || process.stdout;
|
|
358
|
+
const flags = _parseArgs(args);
|
|
359
|
+
const projectRoot = path.resolve(flags.cwd || context.cwd || process.cwd());
|
|
360
|
+
|
|
361
|
+
const stateDir = path.join(projectRoot, '.nubos-pilot');
|
|
362
|
+
if (!fs.existsSync(stateDir)) {
|
|
363
|
+
throw new NubosPilotError(
|
|
364
|
+
'discuss-project-not-initialized',
|
|
365
|
+
'.nubos-pilot/ not found — run np:new-project first',
|
|
366
|
+
{ cwd: projectRoot },
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (flags.mode === 'apply') {
|
|
371
|
+
_apply(projectRoot, flags, stdout);
|
|
372
|
+
} else {
|
|
373
|
+
_emitPlan(projectRoot, flags, stdout);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { run, _parseArgs, REQUIRED_FIELDS, validateProposedRequirements };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
const { test, afterEach } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const newProject = require('./new-project.cjs');
|
|
8
|
+
const subcmd = require('./discuss-project.cjs');
|
|
9
|
+
|
|
10
|
+
const _sandboxes = [];
|
|
11
|
+
|
|
12
|
+
function makeSandbox() {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-dp-'));
|
|
14
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
|
|
15
|
+
_sandboxes.push(dir);
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function scaffold(root) {
|
|
20
|
+
const answersPath = path.join(root, 'ans.json');
|
|
21
|
+
fs.writeFileSync(answersPath, JSON.stringify({
|
|
22
|
+
project_name: 'Demo',
|
|
23
|
+
core_value: 'Ship fast.',
|
|
24
|
+
primary_constraints: 'Node 22',
|
|
25
|
+
first_milestone_name: 'v1.0',
|
|
26
|
+
first_phase_name: 'foundation',
|
|
27
|
+
}));
|
|
28
|
+
newProject.run(['--apply', answersPath], { cwd: root, stdout: captureStdout().stub });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function captureStdout() {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
return {
|
|
34
|
+
stub: { write: (s) => chunks.push(String(s)) },
|
|
35
|
+
json: () => JSON.parse(chunks.join('')),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
while (_sandboxes.length) {
|
|
41
|
+
const dir = _sandboxes.pop();
|
|
42
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('DP-1: throws when .nubos-pilot missing', () => {
|
|
47
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-dp-bare-'));
|
|
48
|
+
_sandboxes.push(dir);
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => subcmd.run([], { cwd: dir, stdout: captureStdout().stub }),
|
|
51
|
+
(err) => err.code === 'discuss-project-not-initialized',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('DP-2: plan mode emits questions, required_fields, scan context', () => {
|
|
56
|
+
const root = makeSandbox();
|
|
57
|
+
scaffold(root);
|
|
58
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'demo' }));
|
|
59
|
+
fs.writeFileSync(path.join(root, 'README.md'), '# Demo\n\nFirst line.\n');
|
|
60
|
+
|
|
61
|
+
const cap = captureStdout();
|
|
62
|
+
subcmd.run([], { cwd: root, stdout: cap.stub });
|
|
63
|
+
const out = cap.json();
|
|
64
|
+
assert.equal(out.mode, 'plan');
|
|
65
|
+
assert.ok(Array.isArray(out.questions));
|
|
66
|
+
assert.ok(out.questions.length >= 5);
|
|
67
|
+
assert.ok(out.required_fields.includes('project_description'));
|
|
68
|
+
assert.ok(out.scan_context);
|
|
69
|
+
assert.ok(out.scan_context.manifest_paths.includes('package.json'));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('DP-3: plan detects bootstrap sub_mode when placeholders still in PROJECT.md', () => {
|
|
73
|
+
const root = makeSandbox();
|
|
74
|
+
scaffold(root);
|
|
75
|
+
|
|
76
|
+
const cap = captureStdout();
|
|
77
|
+
subcmd.run(['--bootstrap'], { cwd: root, stdout: cap.stub });
|
|
78
|
+
const out = cap.json();
|
|
79
|
+
assert.equal(out.sub_mode, 'bootstrap');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('DP-4: apply fills in PROJECT.md sections', () => {
|
|
83
|
+
const root = makeSandbox();
|
|
84
|
+
scaffold(root);
|
|
85
|
+
|
|
86
|
+
const answers = {
|
|
87
|
+
project_description: 'Demo fills holes.',
|
|
88
|
+
domain_text: 'Jedi-era scaffolding.',
|
|
89
|
+
target_users_text: 'Engineers.',
|
|
90
|
+
non_goals_text: 'Not a framework.',
|
|
91
|
+
success_criteria_text: 'Phase 1 ships.',
|
|
92
|
+
strategic_decisions_text: 'Node-only.',
|
|
93
|
+
};
|
|
94
|
+
const answersPath = path.join(root, 'pa.json');
|
|
95
|
+
fs.writeFileSync(answersPath, JSON.stringify(answers));
|
|
96
|
+
|
|
97
|
+
const cap = captureStdout();
|
|
98
|
+
subcmd.run(['--apply', answersPath, '--bootstrap'], { cwd: root, stdout: cap.stub });
|
|
99
|
+
const projectMd = fs.readFileSync(path.join(root, '.nubos-pilot', 'PROJECT.md'), 'utf-8');
|
|
100
|
+
assert.ok(projectMd.includes('Demo fills holes.'));
|
|
101
|
+
assert.ok(projectMd.includes('Jedi-era scaffolding.'));
|
|
102
|
+
assert.ok(projectMd.includes('Engineers.'));
|
|
103
|
+
assert.ok(projectMd.includes('Not a framework.'));
|
|
104
|
+
assert.ok(projectMd.includes('Phase 1 ships.'));
|
|
105
|
+
assert.ok(projectMd.includes('Node-only.'));
|
|
106
|
+
assert.doesNotMatch(projectMd, /_TBD — filled by/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('DP-5: apply refresh updates a filled PROJECT.md', () => {
|
|
110
|
+
const root = makeSandbox();
|
|
111
|
+
scaffold(root);
|
|
112
|
+
|
|
113
|
+
const bootstrap = {
|
|
114
|
+
project_description: 'First take.',
|
|
115
|
+
domain_text: 'd1',
|
|
116
|
+
target_users_text: 'u1',
|
|
117
|
+
non_goals_text: 'n1',
|
|
118
|
+
success_criteria_text: 's1',
|
|
119
|
+
strategic_decisions_text: 'st1',
|
|
120
|
+
};
|
|
121
|
+
const p1 = path.join(root, 'a1.json');
|
|
122
|
+
fs.writeFileSync(p1, JSON.stringify(bootstrap));
|
|
123
|
+
subcmd.run(['--apply', p1, '--bootstrap'], { cwd: root, stdout: captureStdout().stub });
|
|
124
|
+
|
|
125
|
+
const refresh = {
|
|
126
|
+
project_description: 'Second take.',
|
|
127
|
+
domain_text: 'd2',
|
|
128
|
+
target_users_text: 'u2',
|
|
129
|
+
non_goals_text: 'n2',
|
|
130
|
+
success_criteria_text: 's2',
|
|
131
|
+
strategic_decisions_text: 'st2',
|
|
132
|
+
};
|
|
133
|
+
const p2 = path.join(root, 'a2.json');
|
|
134
|
+
fs.writeFileSync(p2, JSON.stringify(refresh));
|
|
135
|
+
subcmd.run(['--apply', p2], { cwd: root, stdout: captureStdout().stub });
|
|
136
|
+
|
|
137
|
+
const projectMd = fs.readFileSync(path.join(root, '.nubos-pilot', 'PROJECT.md'), 'utf-8');
|
|
138
|
+
assert.ok(projectMd.includes('Second take.'));
|
|
139
|
+
assert.ok(!projectMd.includes('First take.'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('DP-6: apply validates required fields', () => {
|
|
143
|
+
const root = makeSandbox();
|
|
144
|
+
scaffold(root);
|
|
145
|
+
const p = path.join(root, 'a.json');
|
|
146
|
+
fs.writeFileSync(p, JSON.stringify({ project_description: 'x' }));
|
|
147
|
+
assert.throws(
|
|
148
|
+
() => subcmd.run(['--apply', p, '--bootstrap'], { cwd: root, stdout: captureStdout().stub }),
|
|
149
|
+
(err) => err.code === 'discuss-project-missing-field',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('DP-7-val: validateProposedRequirements rejects non-array', () => {
|
|
154
|
+
const { validateProposedRequirements } = subcmd;
|
|
155
|
+
assert.throws(
|
|
156
|
+
() => validateProposedRequirements({ not: 'array' }),
|
|
157
|
+
(err) => err.code === 'proposed-reqs-not-array',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('DP-7-val2: validateProposedRequirements rejects invalid id', () => {
|
|
162
|
+
const { validateProposedRequirements } = subcmd;
|
|
163
|
+
assert.throws(
|
|
164
|
+
() => validateProposedRequirements([{ id: 'REQ-1', text: 'ok' }]),
|
|
165
|
+
(err) => err.code === 'proposed-reqs-invalid-id',
|
|
166
|
+
);
|
|
167
|
+
assert.throws(
|
|
168
|
+
() => validateProposedRequirements([{ id: 'foo', text: 'ok' }]),
|
|
169
|
+
(err) => err.code === 'proposed-reqs-invalid-id',
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('DP-7-val3: validateProposedRequirements rejects empty text', () => {
|
|
174
|
+
const { validateProposedRequirements } = subcmd;
|
|
175
|
+
assert.throws(
|
|
176
|
+
() => validateProposedRequirements([{ id: 'REQ-02', text: ' ' }]),
|
|
177
|
+
(err) => err.code === 'proposed-reqs-empty-text',
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('DP-7-val4: validateProposedRequirements rejects duplicates within batch', () => {
|
|
182
|
+
const { validateProposedRequirements } = subcmd;
|
|
183
|
+
assert.throws(
|
|
184
|
+
() => validateProposedRequirements([
|
|
185
|
+
{ id: 'REQ-02', text: 'a' },
|
|
186
|
+
{ id: 'REQ-02', text: 'b' },
|
|
187
|
+
]),
|
|
188
|
+
(err) => err.code === 'proposed-reqs-duplicate-id',
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('DP-7-val5: validateProposedRequirements rejects collision with existing', () => {
|
|
193
|
+
const { validateProposedRequirements } = subcmd;
|
|
194
|
+
const existing = new Set(['REQ-01', 'REQ-02']);
|
|
195
|
+
assert.throws(
|
|
196
|
+
() => validateProposedRequirements([{ id: 'REQ-02', text: 'x' }], existing),
|
|
197
|
+
(err) => err.code === 'proposed-reqs-collides-with-existing',
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('DP-7-val6: validateProposedRequirements trims text and returns valid', () => {
|
|
202
|
+
const { validateProposedRequirements } = subcmd;
|
|
203
|
+
const result = validateProposedRequirements([
|
|
204
|
+
{ id: 'REQ-02', text: ' must persist ' },
|
|
205
|
+
]);
|
|
206
|
+
assert.deepEqual(result, [{ id: 'REQ-02', text: 'must persist' }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('DP-7: proposed requirements are appended to REQUIREMENTS.md', () => {
|
|
210
|
+
const root = makeSandbox();
|
|
211
|
+
scaffold(root);
|
|
212
|
+
const answers = {
|
|
213
|
+
project_description: 'd',
|
|
214
|
+
domain_text: 'd',
|
|
215
|
+
target_users_text: 't',
|
|
216
|
+
non_goals_text: 'n',
|
|
217
|
+
success_criteria_text: 's',
|
|
218
|
+
strategic_decisions_text: 'st',
|
|
219
|
+
};
|
|
220
|
+
const ap = path.join(root, 'a.json');
|
|
221
|
+
fs.writeFileSync(ap, JSON.stringify(answers));
|
|
222
|
+
|
|
223
|
+
const rp = path.join(root, 'r.json');
|
|
224
|
+
fs.writeFileSync(rp, JSON.stringify([
|
|
225
|
+
{ id: 'REQ-02', text: 'must support offline mode' },
|
|
226
|
+
{ id: 'REQ-03', text: 'must persist to markdown' },
|
|
227
|
+
]));
|
|
228
|
+
|
|
229
|
+
subcmd.run(['--apply', ap, '--bootstrap', '--proposed-requirements', rp], {
|
|
230
|
+
cwd: root, stdout: captureStdout().stub,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const reqs = fs.readFileSync(path.join(root, '.nubos-pilot', 'REQUIREMENTS.md'), 'utf-8');
|
|
234
|
+
assert.ok(reqs.includes('## Proposed (from np:discuss-project)'));
|
|
235
|
+
assert.ok(reqs.includes('REQ-02'));
|
|
236
|
+
assert.ok(reqs.includes('must support offline mode'));
|
|
237
|
+
assert.ok(reqs.includes('REQ-03'));
|
|
238
|
+
});
|