nubos-pilot 0.5.9 → 0.6.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.
@@ -9,6 +9,7 @@ const COMMANDS = [
9
9
  { name: 'plan-milestone', category: 'Planning', description: 'Plan a milestone: scaffolds slices + tasks' },
10
10
  { name: 'new-project', category: 'Planning', description: 'Greenfield project init (PROJECT.md + REQUIREMENTS.md + M001 milestone)' },
11
11
  { name: 'new-milestone', category: 'Planning', description: 'Append a new milestone (M<NNN>) to an existing project' },
12
+ { name: 'propose-milestones', category: 'Planning', description: 'Re-plan all not-yet-done milestones: AI proposes add/update/remove from PROJECT.md + REQUIREMENTS.md' },
12
13
  { name: 'agent-skills', category: 'Planning', description: 'Print agent_skills config for a given subagent' },
13
14
 
14
15
  { name: 'execute-milestone', category: 'Execution', description: 'Wave-based milestone execution — slice by slice, tasks parallel within a slice' },
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const YAML = require('yaml');
6
+
7
+ const {
8
+ NubosPilotError,
9
+ atomicWriteFileSync,
10
+ withFileLock,
11
+ } = require('../../lib/core.cjs');
12
+ const layout = require('../../lib/layout.cjs');
13
+ const { readState } = require('../../lib/state.cjs');
14
+ const textMode = require('../../lib/text-mode.cjs');
15
+
16
+ const TBD_RE = /<!--\s*TBD[^>]*-->/gi;
17
+ const DONE_STATUSES = new Set(['done', 'complete', 'completed']);
18
+
19
+ function _emit(stdout, payload) {
20
+ stdout.write(JSON.stringify(payload, null, 2));
21
+ }
22
+
23
+ function _guardInitialized(root) {
24
+ const projectMd = path.join(root, '.nubos-pilot', 'PROJECT.md');
25
+ if (!fs.existsSync(projectMd)) {
26
+ throw new NubosPilotError(
27
+ 'project-not-initialized',
28
+ 'PROJECT.md not found — run np:new-project first',
29
+ { hint: 'Run np:new-project first', path: projectMd },
30
+ );
31
+ }
32
+ }
33
+
34
+ function _readRoadmap(root) {
35
+ const p = path.join(root, '.nubos-pilot', 'roadmap.yaml');
36
+ if (!fs.existsSync(p)) {
37
+ throw new NubosPilotError(
38
+ 'roadmap-missing',
39
+ 'roadmap.yaml not found',
40
+ { path: p },
41
+ );
42
+ }
43
+ const raw = fs.readFileSync(p, 'utf-8');
44
+ let doc;
45
+ try { doc = YAML.parse(raw); } catch (err) {
46
+ throw new NubosPilotError(
47
+ 'roadmap-parse-error',
48
+ 'roadmap.yaml invalid YAML',
49
+ { path: p, cause: err && err.message },
50
+ );
51
+ }
52
+ if (!doc || !Array.isArray(doc.milestones)) {
53
+ throw new NubosPilotError(
54
+ 'roadmap-parse-error',
55
+ 'roadmap.yaml missing milestones array',
56
+ { path: p },
57
+ );
58
+ }
59
+ return { doc, path: p };
60
+ }
61
+
62
+ function _classifyMilestone(m, root, stateMilestoneId) {
63
+ if (!m || m.id === 'backlog') return null;
64
+ const status = typeof m.status === 'string' ? m.status : 'pending';
65
+ const isDone = DONE_STATUSES.has(status);
66
+ const slices = Array.isArray(m.slices) ? m.slices : [];
67
+ const hasSlices = slices.length > 0;
68
+
69
+ const mNumMatch = typeof m.id === 'string' ? m.id.match(/^M(\d+)$/) : null;
70
+ const mNum = mNumMatch ? Number(mNumMatch[1]) : (typeof m.number === 'number' ? m.number : null);
71
+
72
+ let contextSummary = null;
73
+ let contextHasContent = false;
74
+ if (mNum != null) {
75
+ const ctxPath = layout.milestoneContextPath(mNum, root);
76
+ if (fs.existsSync(ctxPath)) {
77
+ const raw = fs.readFileSync(ctxPath, 'utf-8');
78
+ const tbdSections = (raw.match(TBD_RE) || []).length;
79
+ const contentSections = (raw.match(/^<[a-z_]+>$/gm) || []).length;
80
+ contextHasContent = contentSections > 0 && tbdSections === 0;
81
+ contextSummary = {
82
+ path: ctxPath,
83
+ byte_size: raw.length,
84
+ tbd_sections: tbdSections,
85
+ content_sections: contentSections,
86
+ has_content: contextHasContent,
87
+ };
88
+ }
89
+ }
90
+
91
+ const isActive = stateMilestoneId && m.id === stateMilestoneId;
92
+
93
+ let classification;
94
+ if (isDone) classification = 'completed';
95
+ else if (hasSlices || isActive) classification = 'active';
96
+ else if (contextHasContent) classification = 'discussed';
97
+ else classification = 'empty';
98
+
99
+ return {
100
+ id: m.id,
101
+ number: mNum,
102
+ name: m.name || '',
103
+ goal: typeof m.goal === 'string' ? m.goal : '',
104
+ status,
105
+ classification,
106
+ slice_count: slices.length,
107
+ context: contextSummary,
108
+ touchable: classification === 'empty',
109
+ modification_requires_confirm: classification === 'active' || classification === 'discussed',
110
+ };
111
+ }
112
+
113
+ function _nextMilestoneNumber(doc) {
114
+ let maxNum = 0;
115
+ for (const m of doc.milestones || []) {
116
+ if (!m) continue;
117
+ if (m.id === 'backlog') continue;
118
+ if (typeof m.number === 'number' && Number.isInteger(m.number) && m.number > maxNum) {
119
+ maxNum = m.number;
120
+ }
121
+ if (typeof m.id === 'string') {
122
+ const mm = m.id.match(/^M(\d+)$/);
123
+ if (mm) {
124
+ const n = Number(mm[1]);
125
+ if (Number.isInteger(n) && n > maxNum) maxNum = n;
126
+ }
127
+ }
128
+ }
129
+ return maxNum + 1;
130
+ }
131
+
132
+ function _interviewPayload(cwd) {
133
+ const root = path.resolve(cwd);
134
+ _guardInitialized(root);
135
+ const { doc } = _readRoadmap(root);
136
+
137
+ let stateMilestoneId = null;
138
+ try {
139
+ const st = readState(root);
140
+ stateMilestoneId = st && st.frontmatter && st.frontmatter.milestone || null;
141
+ } catch {
142
+ stateMilestoneId = null;
143
+ }
144
+
145
+ const classified = [];
146
+ for (const m of doc.milestones) {
147
+ const row = _classifyMilestone(m, root, stateMilestoneId);
148
+ if (row) classified.push(row);
149
+ }
150
+
151
+ const projectMd = fs.readFileSync(path.join(root, '.nubos-pilot', 'PROJECT.md'), 'utf-8');
152
+ const reqPath = path.join(root, '.nubos-pilot', 'REQUIREMENTS.md');
153
+ const reqMd = fs.existsSync(reqPath) ? fs.readFileSync(reqPath, 'utf-8') : '';
154
+
155
+ const projectHasTbd = /_TBD — filled by \/np:discuss-project\._/.test(projectMd);
156
+
157
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
158
+
159
+ return {
160
+ _workflow: 'propose-milestones',
161
+ mode: 'interview',
162
+ text_mode: tmDetail.enabled,
163
+ text_mode_source: tmDetail.source,
164
+ project_md_path: path.join(root, '.nubos-pilot', 'PROJECT.md'),
165
+ requirements_md_path: reqPath,
166
+ project_md: projectMd,
167
+ requirements_md: reqMd,
168
+ project_has_tbd: projectHasTbd,
169
+ current_state_milestone: stateMilestoneId,
170
+ milestones: classified,
171
+ next_milestone_number: _nextMilestoneNumber(doc),
172
+ guidance: {
173
+ completed: 'Untouchable — never modify or remove; displayed for context only.',
174
+ active: 'Has slices or is the current state pointer — modifications require explicit per-item confirm.',
175
+ discussed: 'Has non-TBD CONTEXT.md content — modifications require explicit per-item confirm.',
176
+ empty: 'No slices, CONTEXT.md still TBD — freely modifiable or removable.',
177
+ },
178
+ };
179
+ }
180
+
181
+ function _validateOperation(op, idx) {
182
+ if (!op || typeof op !== 'object') {
183
+ throw new NubosPilotError(
184
+ 'invalid-operation',
185
+ 'operation ' + idx + ' is not an object',
186
+ { index: idx },
187
+ );
188
+ }
189
+ const type = op.type;
190
+ if (!['add', 'update', 'remove'].includes(type)) {
191
+ throw new NubosPilotError(
192
+ 'invalid-operation-type',
193
+ 'operation ' + idx + ' has unknown type: ' + String(type),
194
+ { index: idx, type },
195
+ );
196
+ }
197
+ if (type === 'add') {
198
+ if (typeof op.milestone_name !== 'string' || op.milestone_name.trim() === '') {
199
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_name required', { index: idx });
200
+ }
201
+ if (typeof op.milestone_goal !== 'string' || op.milestone_goal.trim() === '') {
202
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_goal required', { index: idx });
203
+ }
204
+ } else {
205
+ if (typeof op.milestone_id !== 'string' || !/^M\d+$/.test(op.milestone_id)) {
206
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '].milestone_id required (format M<NNN>)', { index: idx });
207
+ }
208
+ if (type === 'update') {
209
+ const hasName = typeof op.new_name === 'string' && op.new_name.trim() !== '';
210
+ const hasGoal = typeof op.new_goal === 'string' && op.new_goal.trim() !== '';
211
+ if (!hasName && !hasGoal) {
212
+ throw new NubosPilotError('answers-missing-field', 'op[' + idx + '] update needs new_name or new_goal', { index: idx });
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ function _findMilestone(doc, id) {
219
+ return doc.milestones.find((m) => m && m.id === id);
220
+ }
221
+
222
+ function _assertTouchable(m, opType, confirmForceModify) {
223
+ const status = typeof m.status === 'string' ? m.status : 'pending';
224
+ if (DONE_STATUSES.has(status)) {
225
+ throw new NubosPilotError(
226
+ 'milestone-completed-untouchable',
227
+ 'Milestone ' + m.id + ' is completed (status=' + status + ') — cannot ' + opType,
228
+ { id: m.id, status },
229
+ );
230
+ }
231
+ const slices = Array.isArray(m.slices) ? m.slices : [];
232
+ if (slices.length > 0 && !confirmForceModify) {
233
+ throw new NubosPilotError(
234
+ 'milestone-has-slices',
235
+ 'Milestone ' + m.id + ' has ' + slices.length + ' slice(s); ' + opType + ' requires confirm_force_modify=true',
236
+ { id: m.id, slice_count: slices.length },
237
+ );
238
+ }
239
+ }
240
+
241
+ function _applyAdd(doc, op) {
242
+ const mNum = _nextMilestoneNumber(doc);
243
+ const id = layout.mId(mNum);
244
+ doc.milestones.push({
245
+ id,
246
+ number: mNum,
247
+ name: op.milestone_name,
248
+ goal: op.milestone_goal,
249
+ status: 'pending',
250
+ requirements: [],
251
+ success_criteria: [],
252
+ slices: [],
253
+ });
254
+ return { type: 'add', id, number: mNum, name: op.milestone_name };
255
+ }
256
+
257
+ function _applyUpdate(doc, op) {
258
+ const m = _findMilestone(doc, op.milestone_id);
259
+ if (!m) {
260
+ throw new NubosPilotError('milestone-not-found', 'milestone ' + op.milestone_id + ' not found', { id: op.milestone_id });
261
+ }
262
+ _assertTouchable(m, 'update', op.confirm_force_modify === true);
263
+ const changed = {};
264
+ if (typeof op.new_name === 'string' && op.new_name.trim() !== '') {
265
+ changed.from_name = m.name;
266
+ m.name = op.new_name;
267
+ changed.to_name = m.name;
268
+ }
269
+ if (typeof op.new_goal === 'string' && op.new_goal.trim() !== '') {
270
+ changed.from_goal = m.goal;
271
+ m.goal = op.new_goal;
272
+ changed.to_goal = m.goal;
273
+ }
274
+ return { type: 'update', id: m.id, changed };
275
+ }
276
+
277
+ function _applyRemove(doc, op, root) {
278
+ const idx = doc.milestones.findIndex((m) => m && m.id === op.milestone_id);
279
+ if (idx < 0) {
280
+ throw new NubosPilotError('milestone-not-found', 'milestone ' + op.milestone_id + ' not found', { id: op.milestone_id });
281
+ }
282
+ const m = doc.milestones[idx];
283
+ _assertTouchable(m, 'remove', op.confirm_force_modify === true);
284
+ doc.milestones.splice(idx, 1);
285
+
286
+ let archivedTo = null;
287
+ const mNumMatch = m.id.match(/^M(\d+)$/);
288
+ if (mNumMatch) {
289
+ const mNum = Number(mNumMatch[1]);
290
+ const srcDir = layout.milestoneDir(mNum, root);
291
+ if (fs.existsSync(srcDir)) {
292
+ const archRoot = path.join(root, '.nubos-pilot', 'archive', 'milestones');
293
+ fs.mkdirSync(archRoot, { recursive: true });
294
+ const stamp = new Date().toISOString().slice(0, 10);
295
+ const target = path.join(archRoot, m.id + '-' + stamp);
296
+ fs.renameSync(srcDir, target);
297
+ archivedTo = target;
298
+ }
299
+ }
300
+ return { type: 'remove', id: m.id, archived_to: archivedTo };
301
+ }
302
+
303
+ function _apply(answersPath, cwd, stdout) {
304
+ let raw;
305
+ try { raw = fs.readFileSync(answersPath, 'utf-8'); } catch (err) {
306
+ throw new NubosPilotError(
307
+ 'answers-not-readable',
308
+ 'answers file not readable: ' + answersPath,
309
+ { path: answersPath, cause: err && err.code },
310
+ );
311
+ }
312
+ let answers;
313
+ try { answers = JSON.parse(raw); } catch (err) {
314
+ throw new NubosPilotError(
315
+ 'answers-parse-error',
316
+ 'answers file is not valid JSON',
317
+ { path: answersPath, cause: err && err.message },
318
+ );
319
+ }
320
+ if (!answers || !Array.isArray(answers.operations)) {
321
+ throw new NubosPilotError(
322
+ 'answers-missing-field',
323
+ 'answers.operations must be an array',
324
+ {},
325
+ );
326
+ }
327
+ answers.operations.forEach(_validateOperation);
328
+
329
+ const root = path.resolve(cwd);
330
+ _guardInitialized(root);
331
+
332
+ const roadmapPath = path.join(root, '.nubos-pilot', 'roadmap.yaml');
333
+ const results = withFileLock(roadmapPath, () => {
334
+ const rawYaml = fs.readFileSync(roadmapPath, 'utf-8');
335
+ let doc;
336
+ try { doc = YAML.parse(rawYaml); } catch (err) {
337
+ throw new NubosPilotError('roadmap-parse-error', 'roadmap.yaml invalid YAML', { path: roadmapPath, cause: err && err.message });
338
+ }
339
+ if (!doc || !Array.isArray(doc.milestones)) {
340
+ throw new NubosPilotError('roadmap-parse-error', 'roadmap.yaml missing milestones array', { path: roadmapPath });
341
+ }
342
+
343
+ const out = [];
344
+ for (const op of answers.operations) {
345
+ if (op.type === 'add') out.push(_applyAdd(doc, op));
346
+ else if (op.type === 'update') out.push(_applyUpdate(doc, op));
347
+ else if (op.type === 'remove') out.push(_applyRemove(doc, op, root));
348
+ }
349
+
350
+ atomicWriteFileSync(roadmapPath, YAML.stringify(doc, { indent: 2 }));
351
+
352
+ for (const result of out) {
353
+ if (result.type === 'add') {
354
+ _writeMilestoneArtefacts(root, result.number, result.name, doc);
355
+ }
356
+ }
357
+
358
+ return out;
359
+ });
360
+
361
+ _emit(stdout, {
362
+ mode: 'apply',
363
+ results,
364
+ });
365
+ }
366
+
367
+ function _writeMilestoneArtefacts(root, mNum, name, doc) {
368
+ const { _render, _loadTemplate } = _lazyRenderer();
369
+ const m = doc.milestones.find((x) => x && x.id === layout.mId(mNum));
370
+ const goal = m && m.goal || '';
371
+ layout.createMilestoneDir(mNum, root);
372
+ const mIdStr = layout.mId(mNum);
373
+ const createdDate = new Date().toISOString().slice(0, 10);
374
+ const ctxVars = {
375
+ milestone_id: mIdStr,
376
+ milestone_name: name,
377
+ created_date: createdDate,
378
+ goal_text: goal,
379
+ decisions_text: '<!-- TBD: locked decisions from /np:discuss-phase -->',
380
+ deferred_text: '<!-- TBD: deferred ideas -->',
381
+ domain_text: '<!-- TBD: domain boundary -->',
382
+ canonical_refs_text: '<!-- TBD: canonical references -->',
383
+ };
384
+ const roadmapVars = {
385
+ milestone_id: mIdStr,
386
+ milestone_name: name,
387
+ created_date: createdDate,
388
+ slices_text: '<!-- TBD: slices will be appended by /np:plan-phase ' + mNum + ' -->',
389
+ };
390
+ const metaVars = {
391
+ milestone_id: mIdStr,
392
+ milestone_name: JSON.stringify(name).slice(1, -1),
393
+ status: 'pending',
394
+ created_date: createdDate,
395
+ goal_text_escaped: JSON.stringify(goal).slice(1, -1),
396
+ requirements_json: '[]',
397
+ success_criteria_json: '[]',
398
+ slice_count: 0,
399
+ task_count: 0,
400
+ };
401
+ _writeFile(layout.milestoneContextPath(mNum, root), _render(_loadTemplate('CONTEXT.md'), ctxVars, 'milestone/CONTEXT.md'));
402
+ _writeFile(layout.milestoneRoadmapPath(mNum, root), _render(_loadTemplate('ROADMAP.md'), roadmapVars, 'milestone/ROADMAP.md'));
403
+ _writeFile(layout.milestoneMetaPath(mNum, root), _render(_loadTemplate('META.json'), metaVars, 'milestone/META.json'));
404
+ }
405
+
406
+ function _writeFile(target, content) {
407
+ if (path.basename(target) === 'PROJECT.md') {
408
+ throw new NubosPilotError(
409
+ 'propose-milestones-forbidden-write',
410
+ 'propose-milestones is never allowed to write PROJECT.md (D-29)',
411
+ { path: target },
412
+ );
413
+ }
414
+ atomicWriteFileSync(target, content);
415
+ }
416
+
417
+ function _lazyRenderer() {
418
+ const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates', 'milestone');
419
+ const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
420
+ function _render(raw, vars, templateName) {
421
+ return raw.replace(PLACEHOLDER_RE, (_match, key) => {
422
+ if (!(key in vars)) {
423
+ throw new NubosPilotError(
424
+ 'template-unresolved-var',
425
+ 'Undefined placeholder {{' + key + '}} in template "' + templateName + '"',
426
+ { template: templateName, variable: key, available: Object.keys(vars) },
427
+ );
428
+ }
429
+ return String(vars[key]);
430
+ });
431
+ }
432
+ function _loadTemplate(name) {
433
+ return fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf-8');
434
+ }
435
+ return { _render, _loadTemplate };
436
+ }
437
+
438
+ function run(args, ctx) {
439
+ const context = ctx || {};
440
+ const cwd = context.cwd || process.cwd();
441
+ const stdout = context.stdout || process.stdout;
442
+ const argv = args || [];
443
+
444
+ const applyIdx = argv.indexOf('--apply');
445
+ if (applyIdx >= 0) {
446
+ const answersPath = argv[applyIdx + 1];
447
+ if (!answersPath) {
448
+ throw new NubosPilotError(
449
+ 'missing-apply-path',
450
+ '--apply requires a path to the answers JSON file',
451
+ { args: argv.slice() },
452
+ );
453
+ }
454
+ _apply(answersPath, cwd, stdout);
455
+ return;
456
+ }
457
+
458
+ _emit(stdout, _interviewPayload(cwd));
459
+ }
460
+
461
+ module.exports = { run, _interviewPayload };
@@ -0,0 +1,235 @@
1
+ const { test, afterEach } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const YAML = require('yaml');
7
+
8
+ const newProject = require('./new-project.cjs');
9
+ const newMilestone = require('./new-milestone.cjs');
10
+ const subcmd = require('./propose-milestones.cjs');
11
+
12
+ const _sandboxes = [];
13
+
14
+ function makeSandbox() {
15
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-propose-'));
16
+ _sandboxes.push(root);
17
+ return root;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (_sandboxes.length) {
22
+ const p = _sandboxes.pop();
23
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
24
+ }
25
+ });
26
+
27
+ function _captureStdout() {
28
+ let buf = '';
29
+ const stub = { write: (s) => { buf += s; return true; } };
30
+ return { stub, get: () => buf };
31
+ }
32
+
33
+ function _writeJson(p, data) { fs.writeFileSync(p, JSON.stringify(data), 'utf-8'); return p; }
34
+
35
+ function _seedProject(root) {
36
+ const a = _writeJson(path.join(root, 'np.json'), {
37
+ project_name: 'Demo', core_value: 'ship', primary_constraints: 'node22',
38
+ first_milestone_name: 'Auth', first_phase_name: 'Login',
39
+ });
40
+ newProject.run(['--apply', a], { cwd: root, stdout: _captureStdout().stub });
41
+ }
42
+
43
+ function _addMilestone(root, name, goal) {
44
+ const p = _writeJson(path.join(root, 'ms.json'), { milestone_name: name, milestone_goal: goal, create_req_prefix: false });
45
+ newMilestone.run(['--apply', p], { cwd: root, stdout: _captureStdout().stub });
46
+ }
47
+
48
+ function _runInterview(root) {
49
+ const cap = _captureStdout();
50
+ subcmd.run([], { cwd: root, stdout: cap.stub });
51
+ return JSON.parse(cap.get());
52
+ }
53
+
54
+ function _runApply(root, operations) {
55
+ const p = _writeJson(path.join(root, 'ops.json'), { operations });
56
+ const cap = _captureStdout();
57
+ subcmd.run(['--apply', p], { cwd: root, stdout: cap.stub });
58
+ return JSON.parse(cap.get());
59
+ }
60
+
61
+ function _setStatus(root, id, status) {
62
+ const rmPath = path.join(root, '.nubos-pilot', 'roadmap.yaml');
63
+ const doc = YAML.parse(fs.readFileSync(rmPath, 'utf-8'));
64
+ const m = doc.milestones.find((x) => x && x.id === id);
65
+ m.status = status;
66
+ fs.writeFileSync(rmPath, YAML.stringify(doc, { indent: 2 }));
67
+ }
68
+
69
+ test('PM-1: interview without .nubos-pilot throws project-not-initialized', () => {
70
+ const sandbox = makeSandbox();
71
+ assert.throws(
72
+ () => subcmd.run([], { cwd: sandbox, stdout: _captureStdout().stub }),
73
+ (err) => err.code === 'project-not-initialized',
74
+ );
75
+ });
76
+
77
+ test('PM-2: interview emits classified milestones + project_has_tbd + next_milestone_number', () => {
78
+ const sandbox = makeSandbox();
79
+ _seedProject(sandbox);
80
+ _addMilestone(sandbox, 'Profile', 'ship profile');
81
+ const payload = _runInterview(sandbox);
82
+ assert.equal(payload.mode, 'interview');
83
+ assert.ok(Array.isArray(payload.milestones));
84
+ assert.equal(payload.milestones.length, 2);
85
+ assert.equal(payload.next_milestone_number, 3);
86
+ for (const m of payload.milestones) {
87
+ assert.ok(['completed', 'active', 'discussed', 'empty'].includes(m.classification));
88
+ }
89
+ });
90
+
91
+ test('PM-3: completed milestones are classified untouchable + guard blocks update/remove', () => {
92
+ const sandbox = makeSandbox();
93
+ _seedProject(sandbox);
94
+ _setStatus(sandbox, 'M001', 'done');
95
+ const payload = _runInterview(sandbox);
96
+ const m1 = payload.milestones.find((m) => m.id === 'M001');
97
+ assert.equal(m1.classification, 'completed');
98
+ assert.equal(m1.touchable, false);
99
+
100
+ assert.throws(
101
+ () => _runApply(sandbox, [{ type: 'remove', milestone_id: 'M001' }]),
102
+ (err) => err.code === 'milestone-completed-untouchable',
103
+ );
104
+ assert.throws(
105
+ () => _runApply(sandbox, [{ type: 'update', milestone_id: 'M001', new_goal: 'x' }]),
106
+ (err) => err.code === 'milestone-completed-untouchable',
107
+ );
108
+ });
109
+
110
+ test('PM-4: filled CONTEXT.md (no TBD markers) → discussed classification', () => {
111
+ const sandbox = makeSandbox();
112
+ _seedProject(sandbox);
113
+ // Adding a second milestone moves STATE pointer off M001, so M001 is no longer "active"
114
+ _addMilestone(sandbox, 'Profile', 'ship profile');
115
+ const ctx = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'M001-CONTEXT.md');
116
+ fs.writeFileSync(ctx, '# M001\n<goal>\nShip auth.\n</goal>\n<decisions>\nUse Lucia.\n</decisions>\n');
117
+ const payload = _runInterview(sandbox);
118
+ const m1 = payload.milestones.find((m) => m.id === 'M001');
119
+ assert.equal(m1.classification, 'discussed');
120
+ assert.equal(m1.context.tbd_sections, 0);
121
+ assert.ok(m1.context.has_content);
122
+ });
123
+
124
+ test('PM-5: apply add → appends new milestone + creates M<NNN>/ dir', () => {
125
+ const sandbox = makeSandbox();
126
+ _seedProject(sandbox);
127
+ const result = _runApply(sandbox, [
128
+ { type: 'add', milestone_name: 'Profile', milestone_goal: 'ship profile' },
129
+ ]);
130
+ assert.equal(result.mode, 'apply');
131
+ assert.equal(result.results[0].type, 'add');
132
+ assert.equal(result.results[0].id, 'M002');
133
+ const mDir = path.join(sandbox, '.nubos-pilot', 'milestones', 'M002');
134
+ assert.ok(fs.existsSync(mDir));
135
+ assert.ok(fs.existsSync(path.join(mDir, 'M002-CONTEXT.md')));
136
+ assert.ok(fs.existsSync(path.join(mDir, 'M002-META.json')));
137
+ assert.ok(fs.existsSync(path.join(mDir, 'slices')));
138
+ });
139
+
140
+ test('PM-6: apply update changes name/goal in roadmap without touching CONTEXT.md', () => {
141
+ const sandbox = makeSandbox();
142
+ _seedProject(sandbox);
143
+ const ctxPath = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'M001-CONTEXT.md');
144
+ const ctxBefore = fs.readFileSync(ctxPath);
145
+ _runApply(sandbox, [{ type: 'update', milestone_id: 'M001', new_name: 'Auth & Sessions' }]);
146
+ const rm = YAML.parse(fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'roadmap.yaml'), 'utf-8'));
147
+ const m1 = rm.milestones.find((m) => m.id === 'M001');
148
+ assert.equal(m1.name, 'Auth & Sessions');
149
+ assert.deepEqual(fs.readFileSync(ctxPath), ctxBefore);
150
+ });
151
+
152
+ test('PM-7: apply remove archives milestone dir + drops roadmap entry', () => {
153
+ const sandbox = makeSandbox();
154
+ _seedProject(sandbox);
155
+ _addMilestone(sandbox, 'Profile', 'ship profile');
156
+ const srcDir = path.join(sandbox, '.nubos-pilot', 'milestones', 'M002');
157
+ assert.ok(fs.existsSync(srcDir));
158
+
159
+ _runApply(sandbox, [{ type: 'remove', milestone_id: 'M002' }]);
160
+
161
+ assert.ok(!fs.existsSync(srcDir), 'milestone dir still exists after remove');
162
+ const archRoot = path.join(sandbox, '.nubos-pilot', 'archive', 'milestones');
163
+ assert.ok(fs.existsSync(archRoot));
164
+ const archived = fs.readdirSync(archRoot);
165
+ assert.ok(archived.some((name) => name.startsWith('M002-')));
166
+ const rm = YAML.parse(fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'roadmap.yaml'), 'utf-8'));
167
+ assert.ok(!rm.milestones.some((m) => m && m.id === 'M002'));
168
+ });
169
+
170
+ test('PM-8: milestone with slices blocks modification unless confirm_force_modify=true', () => {
171
+ const sandbox = makeSandbox();
172
+ _seedProject(sandbox);
173
+ const rmPath = path.join(sandbox, '.nubos-pilot', 'roadmap.yaml');
174
+ const doc = YAML.parse(fs.readFileSync(rmPath, 'utf-8'));
175
+ const m1 = doc.milestones.find((m) => m.id === 'M001');
176
+ m1.slices = [{ id: 'S001', name: 'slice', status: 'pending', tasks: [] }];
177
+ fs.writeFileSync(rmPath, YAML.stringify(doc, { indent: 2 }));
178
+
179
+ assert.throws(
180
+ () => _runApply(sandbox, [{ type: 'update', milestone_id: 'M001', new_name: 'x' }]),
181
+ (err) => err.code === 'milestone-has-slices',
182
+ );
183
+
184
+ const result = _runApply(sandbox, [
185
+ { type: 'update', milestone_id: 'M001', new_name: 'x', confirm_force_modify: true },
186
+ ]);
187
+ assert.equal(result.results[0].changed.to_name, 'x');
188
+ });
189
+
190
+ test('PM-9: multi-op batch (add + update + remove) applies all and returns summary', () => {
191
+ const sandbox = makeSandbox();
192
+ _seedProject(sandbox);
193
+ _addMilestone(sandbox, 'Profile', 'ship profile');
194
+ _addMilestone(sandbox, 'Feed', 'ship feed');
195
+
196
+ const result = _runApply(sandbox, [
197
+ { type: 'add', milestone_name: 'Comments', milestone_goal: 'threaded comments' },
198
+ { type: 'update', milestone_id: 'M002', new_goal: 'refined profile goal' },
199
+ { type: 'remove', milestone_id: 'M003' },
200
+ ]);
201
+
202
+ assert.equal(result.results.length, 3);
203
+ const rm = YAML.parse(fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'roadmap.yaml'), 'utf-8'));
204
+ const ids = rm.milestones.filter((m) => m && m.id !== 'backlog').map((m) => m.id);
205
+ assert.deepEqual(ids.sort(), ['M001', 'M002', 'M004']);
206
+ const m2 = rm.milestones.find((m) => m.id === 'M002');
207
+ assert.equal(m2.goal, 'refined profile goal');
208
+ });
209
+
210
+ test('PM-10: --apply does NOT touch PROJECT.md (D-29)', () => {
211
+ const sandbox = makeSandbox();
212
+ _seedProject(sandbox);
213
+ const p = path.join(sandbox, '.nubos-pilot', 'PROJECT.md');
214
+ const before = fs.readFileSync(p);
215
+ _runApply(sandbox, [{ type: 'add', milestone_name: 'X', milestone_goal: 'y' }]);
216
+ assert.deepEqual(fs.readFileSync(p), before);
217
+ });
218
+
219
+ test('PM-11: invalid operation type throws invalid-operation-type', () => {
220
+ const sandbox = makeSandbox();
221
+ _seedProject(sandbox);
222
+ assert.throws(
223
+ () => _runApply(sandbox, [{ type: 'reorder', milestone_id: 'M001' }]),
224
+ (err) => err.code === 'invalid-operation-type',
225
+ );
226
+ });
227
+
228
+ test('PM-12: add without name/goal throws answers-missing-field', () => {
229
+ const sandbox = makeSandbox();
230
+ _seedProject(sandbox);
231
+ assert.throws(
232
+ () => _runApply(sandbox, [{ type: 'add', milestone_name: '', milestone_goal: 'x' }]),
233
+ (err) => err.code === 'answers-missing-field',
234
+ );
235
+ });
package/np-tools.cjs CHANGED
@@ -12,6 +12,7 @@ const initWorkflows = {
12
12
  'new-project': require('./bin/np-tools/new-project.cjs'),
13
13
  'discuss-project': require('./bin/np-tools/discuss-project.cjs'),
14
14
  'new-milestone': require('./bin/np-tools/new-milestone.cjs'),
15
+ 'propose-milestones': require('./bin/np-tools/propose-milestones.cjs'),
15
16
 
16
17
  'execute-milestone': require('./bin/np-tools/execute-milestone.cjs'),
17
18
  'verify-work': require('./bin/np-tools/verify-work.cjs'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "AI-driven planning and execution tool for code projects",
5
5
  "homepage": "https://github.com/Nubos-AI/nubos-pilot",
6
6
  "repository": {
@@ -6,11 +6,12 @@ argument-hint: [--apply <answers.json>]
6
6
 
7
7
  # np:new-project
8
8
 
9
- Initialize a new nubos-pilot project in three phases:
9
+ Initialize a new nubos-pilot project in four phases:
10
10
 
11
11
  1. **Phase 0 — Workspace Scan** (context capture)
12
- 2. **Phase 1 — Bootstrap Interview** (5 structural questions → scaffold)
12
+ 2. **Phase 1 — Bootstrap Interview** (5 structural questions → scaffold with M001)
13
13
  3. **Phase 2 — Project Discovery** (obligatory, chains into `np:discuss-project --bootstrap`)
14
+ 4. **Phase 3 — Additional Milestones** (AI proposes from Discovery, user reviews; appends M002, M003, …)
14
15
 
15
16
  Optionally runs an initial codebase scan at the end when the workspace
16
17
  contains existing source (`np:scan-codebase`). Everything lands under
@@ -55,9 +56,17 @@ This workflow writes:
55
56
  - `.nubos-pilot/roadmap.yaml` (schema_version: 2, first milestone M001 with empty slices[])
56
57
  - `.nubos-pilot/STATE.md`
57
58
  - `.nubos-pilot/milestones/M001/{M001-CONTEXT.md, M001-ROADMAP.md, M001-META.json}`
59
+ - (optional) `.nubos-pilot/milestones/M002/ …` via Phase 3 AI-proposed
60
+ review (or the manual bulk-loop fallback)
58
61
  - (optional) `.nubos-pilot/codebase/` via chained `np:scan-codebase`
59
62
 
60
63
  `np:discuss-project` (Phase 2) chains automatically — not skippable.
64
+ Phase 3 reads populated PROJECT.md + REQUIREMENTS.md, proposes a
65
+ milestone sequence, and lets the user accept / edit / discard before any
66
+ write. Each accepted milestone is delegated to the `new-milestone`
67
+ subcommand, which auto-numbers M<NNN> and honors the D-29 invariant
68
+ (PROJECT.md is never rewritten). Phase 3 skips cleanly when PROJECT.md
69
+ still has `_TBD` placeholders.
61
70
  `np:scan-codebase` chains when the workspace contains >= 1 source file.
62
71
  </downstream_awareness>
63
72
 
@@ -215,7 +224,205 @@ finish later)
215
224
 
216
225
  Record the skip in STATE.md so the next `np:next` reminds the user.
217
226
 
218
- ## Phase 3 (conditional): Initial Codebase Scan
227
+ ## Phase 3: Additional Milestones (AI-proposed, user-reviewed)
228
+
229
+ After Discovery, PROJECT.md and REQUIREMENTS.md are populated — this is
230
+ the richest context the workflow will ever have about the project. Use it:
231
+ the AI proposes a milestone sequence (M002, M003, …) derived from
232
+ Discovery, the user reviews, and accepted milestones are appended via the
233
+ `new-milestone --apply` subcommand. Each appended milestone starts empty
234
+ (`slices: []`) and is discussed/planned later via `/np:discuss-phase <N>`
235
+ and `/np:plan-phase <N>`.
236
+
237
+ ### Step 3.1 — Skip guard
238
+
239
+ Phase 3 depends on populated Discovery content. If PROJECT.md still
240
+ contains `_TBD — filled by /np:discuss-project._` placeholders, skip
241
+ Phase 3 with a clear hint:
242
+
243
+ ```bash
244
+ PROJECT_MD=".nubos-pilot/PROJECT.md"
245
+ if grep -q "_TBD — filled by /np:discuss-project._" "$PROJECT_MD"; then
246
+ echo "Phase 3 skipped — PROJECT.md has unfilled sections. Finish /np:discuss-project, then append milestones via /np:new-milestone."
247
+ MILESTONES_APPENDED=()
248
+ else
249
+ # Step 3.2 onward …
250
+ fi
251
+ ```
252
+
253
+ ### Step 3.2 — Propose milestone sequence
254
+
255
+ Read `.nubos-pilot/PROJECT.md` and `.nubos-pilot/REQUIREMENTS.md` in full.
256
+ Derive a proposed milestone breakdown grounded in Discovery:
257
+
258
+ - **Anchor on Success Criteria** — each major success criterion maps to
259
+ at least one milestone.
260
+ - **Respect Non-Goals** — never propose a milestone that crosses a
261
+ declared Non-Goal.
262
+ - **Honor Strategic Decisions** — reflected in ordering / `depends_on`
263
+ (e.g. "infra before features" → infra milestone first).
264
+ - **Target 3–6 milestones.** Pick a count that matches project scope;
265
+ don't pad, don't hand-wave. A tiny project can legitimately have 0
266
+ additional milestones (M001 is enough); say so explicitly in that case.
267
+ - **One clear goal per milestone**, one sentence, shippable as a unit.
268
+
269
+ Render the proposal in the main chat (NOT via askuser) so the user sees
270
+ the full list at once:
271
+
272
+ ```
273
+ Based on PROJECT.md + REQUIREMENTS.md I propose these milestones
274
+ in addition to M001 — <first_milestone_name>:
275
+
276
+ [1] <name> — <goal>
277
+ [2] <name> — <goal>
278
+ [3] <name> — <goal>
279
+
280
+
281
+ Reasoning:
282
+ - <name 1>: anchored in success criterion "<excerpt>"
283
+ - <name 2>: …
284
+ ```
285
+
286
+ If the AI concludes no further milestones are warranted, state that
287
+ explicitly and skip to Step 3.5 with an empty acceptance list.
288
+
289
+ ### Step 3.3 — User review
290
+
291
+ ```bash
292
+ REVIEW_CHOICE=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
293
+ "type": "select",
294
+ "prompt": "How do you want to proceed with the proposed milestones?",
295
+ "options": [
296
+ "Accept all as proposed",
297
+ "Edit individually (accept/edit/remove per item)",
298
+ "Discard proposal and enter my own list",
299
+ "Keep only M001"
300
+ ]
301
+ }')
302
+ ```
303
+
304
+ Routing:
305
+
306
+ - **Accept all** → `ACCEPTED` = full proposal, unchanged.
307
+ - **Edit individually** → iterate over proposals; for each ask
308
+ `select` with options `Accept`, `Edit name/goal`, `Remove`. On `Edit`,
309
+ two follow-up `input` prompts collect the revised name and goal
310
+ (pre-filled via `prompt` default with the proposed value).
311
+ - **Discard proposal** → fall through to the legacy bulk-loop (Step 3.4b)
312
+ exactly as it existed before Phase-3-AI.
313
+ - **Keep only M001** → `ACCEPTED` = empty, skip to Step 3.5.
314
+
315
+ After any of the first three paths, offer the bulk-loop as an optional
316
+ top-up:
317
+
318
+ ```bash
319
+ ADD_MORE=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
320
+ "type": "confirm",
321
+ "prompt": "Add further milestones manually beyond the reviewed list?",
322
+ "default": false
323
+ }')
324
+ ```
325
+
326
+ ### Step 3.4a — Apply accepted proposals
327
+
328
+ For each entry in `ACCEPTED` (AI-proposed + any user-revised), call
329
+ `new-milestone --apply` with a tmp answers file. Identical contract to
330
+ `/np:new-milestone`:
331
+
332
+ ```bash
333
+ MILESTONES_APPENDED=()
334
+ for idx in "${!ACCEPTED_NAMES[@]}"; do
335
+ MS_ANSWERS=$(mktemp -t np-new-project-ms.XXXXXX)
336
+ node -e '
337
+ const fs = require("fs");
338
+ fs.writeFileSync(process.env.MS_ANSWERS, JSON.stringify({
339
+ milestone_name: process.env.MS_NAME,
340
+ milestone_goal: process.env.MS_GOAL,
341
+ create_req_prefix: false,
342
+ }));
343
+ ' MS_ANSWERS="$MS_ANSWERS" \
344
+ MS_NAME="${ACCEPTED_NAMES[$idx]}" \
345
+ MS_GOAL="${ACCEPTED_GOALS[$idx]}"
346
+
347
+ MS_RESULT=$(node .nubos-pilot/bin/np-tools.cjs init new-milestone --apply "$MS_ANSWERS")
348
+ rm -f "$MS_ANSWERS"
349
+
350
+ MS_ID=$(node -e '
351
+ const r = JSON.parse(process.env.MS_RESULT);
352
+ process.stdout.write(r.milestone_id);
353
+ ' MS_RESULT="$MS_RESULT")
354
+ MILESTONES_APPENDED+=("$MS_ID — ${ACCEPTED_NAMES[$idx]}")
355
+ done
356
+ ```
357
+
358
+ `create_req_prefix` defaults to `false` for AI-proposed milestones —
359
+ Discovery already produced REQUIREMENTS.md sections. If the user wants a
360
+ dedicated REQ block for a specific milestone, `/np:new-milestone` can
361
+ add it later.
362
+
363
+ ### Step 3.4b — Manual bulk-loop (fallback / top-up)
364
+
365
+ Entered from "Discard proposal" or from the `ADD_MORE=true` top-up gate.
366
+ Same behavior as the pre-AI bulk-loop: prompt for `milestone_name`,
367
+ `milestone_goal`, `create_req_prefix`; empty name exits.
368
+
369
+ ```bash
370
+ while :; do
371
+ ANS_MS_NAME=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
372
+ "type": "input",
373
+ "prompt": "Define another milestone now? Enter name or leave empty to finish."
374
+ }')
375
+ [ -z "$ANS_MS_NAME" ] && break
376
+
377
+ ANS_MS_GOAL=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
378
+ "type": "input",
379
+ "prompt": "Milestone goal (one sentence)?"
380
+ }')
381
+ ANS_REQ_PREFIX=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
382
+ "type": "confirm",
383
+ "prompt": "Create a new Requirements section for this milestone?",
384
+ "default": false
385
+ }')
386
+
387
+ MS_ANSWERS=$(mktemp -t np-new-project-ms.XXXXXX)
388
+ node -e '
389
+ const fs = require("fs");
390
+ const prefix = process.env.ANS_REQ_PREFIX;
391
+ fs.writeFileSync(process.env.MS_ANSWERS, JSON.stringify({
392
+ milestone_name: process.env.ANS_MS_NAME,
393
+ milestone_goal: process.env.ANS_MS_GOAL,
394
+ create_req_prefix: prefix === "true" || prefix === "yes" || prefix === "y",
395
+ }));
396
+ ' ANS_MS_NAME="$ANS_MS_NAME" ANS_MS_GOAL="$ANS_MS_GOAL" \
397
+ ANS_REQ_PREFIX="$ANS_REQ_PREFIX" MS_ANSWERS="$MS_ANSWERS"
398
+
399
+ MS_RESULT=$(node .nubos-pilot/bin/np-tools.cjs init new-milestone --apply "$MS_ANSWERS")
400
+ rm -f "$MS_ANSWERS"
401
+
402
+ MS_ID=$(node -e '
403
+ const r = JSON.parse(process.env.MS_RESULT);
404
+ process.stdout.write(r.milestone_id);
405
+ ' MS_RESULT="$MS_RESULT")
406
+ MILESTONES_APPENDED+=("$MS_ID — $ANS_MS_NAME")
407
+ done
408
+ ```
409
+
410
+ ### Step 3.5 — Done
411
+
412
+ Continue to Phase 4 with `MILESTONES_APPENDED` populated (possibly empty).
413
+
414
+ Notes:
415
+ - Every write goes through `np-tools.cjs init new-milestone --apply` —
416
+ identical error surface (`answers-missing-field`, `roadmap-parse-error`).
417
+ Any failure aborts the current loop and surfaces the error; prior
418
+ milestones stay intact (atomic per-milestone).
419
+ - Text-mode routing (`INIT.text_mode == true`) applies to every askuser
420
+ call above — render each prompt inline instead of shelling out.
421
+ - AI-proposed milestones start empty (`slices: []`). The user discusses
422
+ each later via `/np:discuss-phase <N>` and plans via
423
+ `/np:plan-phase <N>`; PROJECT.md is never rewritten (D-29).
424
+
425
+ ## Phase 4 (conditional): Initial Codebase Scan
219
426
 
220
427
  If Phase 0 reported `file_count > 0` with code files (not only manifests
221
428
  and docs), offer to run the initial scan now:
@@ -258,13 +465,18 @@ Created:
258
465
  M001-ROADMAP.md
259
466
  M001-META.json
260
467
  slices/
468
+ .nubos-pilot/milestones/M002/ … (if additional milestones added)
261
469
  .nubos-pilot/codebase/ (if initial scan ran)
262
470
 
263
- Milestone: M001 — <milestone_name>
471
+ Milestones:
472
+ M001 — <milestone_name>
473
+ <each entry from MILESTONES_APPENDED>
264
474
 
265
475
  Next:
266
476
  - /np:discuss-phase 1 to capture decisions for M001
267
477
  - /np:plan-phase 1 to break M001 into slices + tasks
478
+ - /np:discuss-phase <N> / /np:plan-phase <N> for each appended milestone
479
+ (only after M001 ships; earlier milestones first)
268
480
  - /np:update-docs after any code change (agents will do this automatically)
269
481
  ```
270
482
 
@@ -0,0 +1,287 @@
1
+ ---
2
+ command: np:propose-milestones
3
+ description: Re-plan all not-yet-done milestones on an existing project. AI reads PROJECT.md + REQUIREMENTS.md + existing milestone state, proposes add/update/remove operations, user reviews per-item, subcommand applies atomically.
4
+ argument-hint: [--apply <answers.json>]
5
+ ---
6
+
7
+ # np:propose-milestones
8
+
9
+ Re-plan the not-yet-done milestone pipeline of an existing project. Where `/np:new-project` creates the initial scaffold (+ optional AI-proposed milestones in Phase 3) and `/np:new-milestone` appends a single milestone manually, `/np:propose-milestones` re-examines the whole open pipeline against current Discovery content and proposes an updated sequence.
10
+
11
+ ## Philosophy
12
+
13
+ <philosophy>
14
+ Projects drift. Discovery answers get refined, Strategic Decisions shift, Success Criteria sharpen, new constraints emerge — but the milestone pipeline set at project-init rarely gets revisited until someone manually runs `/np:new-milestone` for each new idea. This workflow closes that gap: it re-reads the current PROJECT.md + REQUIREMENTS.md, looks at what each not-yet-done milestone already represents, and proposes a coherent updated pipeline. Completed milestones stay untouched; milestones with real investment (slices, non-TBD CONTEXT.md) require explicit confirmation before modification.
15
+
16
+ Runtime-agnostic: the subcommand is deterministic Node code; the AI proposal happens in the host agent (Claude / Codex / …); the review goes through the askuser gateway (or text-mode inline prompts in Claude Code).
17
+ </philosophy>
18
+
19
+ ## Scope Guardrail
20
+
21
+ <scope_guardrail>
22
+ This workflow ONLY touches:
23
+
24
+ - `.nubos-pilot/roadmap.yaml` (add / modify / remove milestone entries)
25
+ - `.nubos-pilot/ROADMAP.md` (regenerated by downstream render if present)
26
+ - `.nubos-pilot/milestones/M<NNN>/` (new dirs for added milestones; existing dirs move to `.nubos-pilot/archive/milestones/M<NNN>-<date>/` on remove)
27
+
28
+ It NEVER:
29
+
30
+ - writes `.nubos-pilot/PROJECT.md` — D-29 strict invariant, enforced by `_writeFile` guard
31
+ - modifies **completed** milestones (`status: done|complete|completed`) — the subcommand raises `milestone-completed-untouchable`
32
+ - modifies **milestones with slices** without explicit `confirm_force_modify: true` — the subcommand raises `milestone-has-slices`
33
+ - touches CONTEXT.md of existing milestones on update (only `name` / `goal` in roadmap.yaml change; CONTEXT.md is the user's work)
34
+ - runs when PROJECT.md still has `_TBD` placeholders (refuses in Step 2 below)
35
+ </scope_guardrail>
36
+
37
+ ## Downstream Awareness
38
+
39
+ <downstream_awareness>
40
+ Each operation maps to a single atomic change inside one file-lock on `roadmap.yaml`:
41
+
42
+ - `add` → calls the same path as `np:new-milestone --apply`: appends to `roadmap.yaml`, creates `M<NNN>/` dir with CONTEXT.md / ROADMAP.md / META.json skeleton (all TBD), slice subdir.
43
+ - `update` → mutates `name` and/or `goal` of an existing milestone entry in `roadmap.yaml`. CONTEXT.md is never touched; the user's /np:discuss-phase work is preserved.
44
+ - `remove` → drops the milestone entry from `roadmap.yaml` and moves `M<NNN>/` to `.nubos-pilot/archive/milestones/M<NNN>-<YYYY-MM-DD>/` (never hard-deletes).
45
+
46
+ All ops run inside a single `withFileLock(roadmap.yaml, …)` — either every op applies or none does. New milestones always auto-number as `M<next>` (next = max existing + 1).
47
+ </downstream_awareness>
48
+
49
+ ## Guard
50
+
51
+ Refuse when not in an initialized project.
52
+
53
+ ```bash
54
+ if [ ! -f .nubos-pilot/PROJECT.md ]; then
55
+ echo "Error: no .nubos-pilot/PROJECT.md found. Run /np:new-project first."
56
+ exit 1
57
+ fi
58
+ ```
59
+
60
+ The subcommand raises `project-not-initialized` anyway; the shell check gives a cleaner message before the AI analysis starts.
61
+
62
+ ## Single-Call Init
63
+
64
+ ```bash
65
+ LANG_DIRECTIVE=$(node .nubos-pilot/bin/np-tools.cjs lang-directive)
66
+ INIT=$(node .nubos-pilot/bin/np-tools.cjs init propose-milestones)
67
+ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
68
+ ```
69
+
70
+ **Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
71
+ `$LANG_DIRECTIVE` is authoritative. Obey it for askuser prompt texts, AI-facing reasoning shown to the user, and any narrative prose. YAML keys, milestone IDs, and status strings stay canonical English.
72
+
73
+ **Text-mode routing.** If INIT payload `text_mode == true`, skip every `np-tools.cjs askuser` call below and render each question as a plain-text prompt in the main chat; collect the answer inline. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
74
+
75
+ Parse INIT for: `milestones[]` (each with `id`, `name`, `goal`, `status`, `classification`, `slice_count`, `context`, `touchable`, `modification_requires_confirm`), `project_md`, `requirements_md`, `project_has_tbd`, `current_state_milestone`, `next_milestone_number`, `guidance`.
76
+
77
+ ## Step 1: Present current state
78
+
79
+ Render the classification table to the user so context is shared before proposing:
80
+
81
+ ```
82
+ Current milestones:
83
+ M001 — Auth [discussed] (pending, 0 slices, CONTEXT filled)
84
+ M002 — Profile [empty] (pending, 0 slices, CONTEXT TBD)
85
+ M003 — Feed [active] (pending, 0 slices, state pointer)
86
+ M004 — Analytics [completed] (done — untouchable)
87
+
88
+ Legend:
89
+ completed — done; never modified
90
+ active — has slices or is current state; modify only with explicit confirm
91
+ discussed — CONTEXT.md has real content; modify only with explicit confirm
92
+ empty — no slices, CONTEXT still TBD; freely modifiable
93
+ ```
94
+
95
+ ## Step 2: Guard on unfilled Discovery
96
+
97
+ If INIT payload `project_has_tbd == true`, refuse with a clear hint:
98
+
99
+ ```
100
+ PROJECT.md still has _TBD placeholders from /np:discuss-project.
101
+ /np:propose-milestones needs a populated Discovery to produce useful proposals.
102
+
103
+ Run /np:discuss-project first (finish the six discovery sections), then retry.
104
+ ```
105
+
106
+ Exit — do not offer fallback. Bad Discovery in → garbage proposals out; there is no value in a proposal anchored on TBD content.
107
+
108
+ ## Step 3: AI proposal
109
+
110
+ Read the full `project_md` and `requirements_md` from the INIT payload. For each milestone in `milestones[]` that is NOT `classification: completed`, decide independently:
111
+
112
+ 1. **Keep as-is** — name + goal still align with current Discovery. State the single sentence that anchors it (e.g. "Anchored on Success Criterion: <excerpt>").
113
+ 2. **Update** — name or goal need refinement given current Discovery. Propose the new name/goal and say *why* it changed (what in PROJECT.md / REQUIREMENTS.md prompted this).
114
+ 3. **Remove** — the milestone no longer fits (replaced by different scope, crosses a Non-Goal now, or was redundant with another). Say *why*.
115
+
116
+ Then propose any **new** milestones (`add` operations) the current Discovery implies but are missing. Anchor each on a specific Success Criterion or Strategic Decision.
117
+
118
+ Constraints:
119
+ - Respect **Non-Goals** — never propose a milestone that crosses a declared Non-Goal.
120
+ - Honor **Strategic Decisions** — use for ordering (sequence, `depends_on` hints for later /np:plan-phase).
121
+ - Target **3–6 total** not-yet-done milestones after the proposal. Do not pad; a small project may correctly end up with 1–2.
122
+ - **Do not propose changes to milestones classified as `completed`.** They are frozen.
123
+
124
+ Render the proposal as a diff-style list in the main chat:
125
+
126
+ ```
127
+ Proposed changes:
128
+
129
+ KEEP M001 — Auth (still anchored on Success Criterion "secure multi-device sessions")
130
+ UPDATE M002 — Profile → "Profile & Preferences"
131
+ goal: "Ship user profile + preference persistence"
132
+ reason: REQUIREMENTS now includes REQ-07 (preferences) which belongs here
133
+ REMOVE M003 — Feed reason: Non-Goals explicitly excludes social feed; this crossed the line
134
+ ADD Analytics goal: "Ship event tracking + admin dashboard"
135
+ reason: Success Criterion "measure retention" has no home
136
+ ADD Migrations goal: "Ship production DB migration pipeline"
137
+ reason: Strategic Decision "infra before features" implies this early
138
+
139
+ Sensitive items (require your explicit confirm if you accept):
140
+ - UPDATE M002 — classification=discussed (CONTEXT.md already has content)
141
+ ```
142
+
143
+ ## Step 4: User review per item
144
+
145
+ For each proposal, ask one question via `askuser`. The exact prompt depends on the op type and classification:
146
+
147
+ ### Keep proposals (no change)
148
+ No prompt — just listed under "Unchanged" in the summary. The user can override via Step 5 bulk-gate.
149
+
150
+ ### Update / Remove of **touchable** milestones (classification=`empty`)
151
+ ```bash
152
+ ANSWER=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
153
+ "type": "select",
154
+ "prompt": "UPDATE M002 — name: Profile → Profile & Preferences?",
155
+ "options": ["Accept", "Edit (type your own name/goal)", "Skip this item"]
156
+ }')
157
+ ```
158
+
159
+ On `Edit` → two follow-up `input` prompts (new_name, new_goal) with the proposed values as defaults.
160
+
161
+ ### Update / Remove of **non-touchable** milestones (classification=`active` or `discussed`)
162
+ ```bash
163
+ ANSWER=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
164
+ "type": "select",
165
+ "prompt": "⚠ M002 already has CONTEXT.md content. UPDATE anyway?",
166
+ "options": ["Accept (confirm_force_modify=true)", "Edit", "Skip this item"]
167
+ }')
168
+ ```
169
+
170
+ `Accept` must set `confirm_force_modify: true` on the resulting operation — the subcommand will reject the change otherwise. Additionally, if the milestone has `slice_count > 0`:
171
+
172
+ ```bash
173
+ # Extra hard-confirm for milestones with slices
174
+ ANSWER=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
175
+ "type": "confirm",
176
+ "prompt": "M002 has 3 slice(s) with tasks. Modification will still preserve CONTEXT.md and slices, but roadmap name/goal will change. Proceed?",
177
+ "default": false
178
+ }')
179
+ ```
180
+
181
+ ### Add proposals
182
+ ```bash
183
+ ANSWER=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
184
+ "type": "select",
185
+ "prompt": "ADD new milestone: Analytics — Ship event tracking + admin dashboard?",
186
+ "options": ["Accept", "Edit name/goal", "Skip this item"]
187
+ }')
188
+ ```
189
+
190
+ ## Step 5: User bulk-override gate
191
+
192
+ After per-item review, offer two meta-options before apply:
193
+
194
+ ```bash
195
+ FINAL=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
196
+ "type": "select",
197
+ "prompt": "About to apply N operations. Last chance.",
198
+ "options": [
199
+ "Apply all accepted operations",
200
+ "Show diff again (re-render proposal with my edits)",
201
+ "Add another milestone manually before applying",
202
+ "Abort — make no changes"
203
+ ]
204
+ }')
205
+ ```
206
+
207
+ - `Show diff again` → re-render the proposal + user-edits and loop back to Step 4.
208
+ - `Add another milestone manually` → enter the `/np:new-milestone` three-question loop inline (same prompts as the `new-project` Phase 3 bulk loop) and append the collected answers as `add` operations.
209
+ - `Abort` → exit cleanly, no writes.
210
+
211
+ ## Step 6: Apply
212
+
213
+ Build the answers JSON from collected decisions:
214
+
215
+ ```bash
216
+ OPS_JSON=$(mktemp -t np-propose-ops.XXXXXX)
217
+ trap 'rm -f "$OPS_JSON"' EXIT
218
+
219
+ node -e '
220
+ const fs = require("fs");
221
+ fs.writeFileSync(process.env.OPS_JSON, JSON.stringify({
222
+ operations: [
223
+ // each entry:
224
+ // { type: "add", milestone_name, milestone_goal }
225
+ // { type: "update", milestone_id: "M<NNN>", new_name?, new_goal?, confirm_force_modify? }
226
+ // { type: "remove", milestone_id: "M<NNN>", confirm_force_modify? }
227
+ ],
228
+ }));
229
+ ' OPS_JSON="$OPS_JSON"
230
+
231
+ node .nubos-pilot/bin/np-tools.cjs init propose-milestones --apply "$OPS_JSON"
232
+ ```
233
+
234
+ Output JSON:
235
+
236
+ ```jsonc
237
+ {
238
+ "mode": "apply",
239
+ "results": [
240
+ { "type": "add", "id": "M005", "number": 5, "name": "Analytics" },
241
+ { "type": "update", "id": "M002", "changed": { "from_name": "Profile", "to_name": "Profile & Preferences" } },
242
+ { "type": "remove", "id": "M003", "archived_to": ".nubos-pilot/archive/milestones/M003-2026-04-21" }
243
+ ]
244
+ }
245
+ ```
246
+
247
+ ## Step 7: Summary + next steps
248
+
249
+ Render the apply result to the user with concrete next actions:
250
+
251
+ ```
252
+ Applied N operations.
253
+
254
+ Updated pipeline (not-yet-done):
255
+ M001 — Auth
256
+ M002 — Profile & Preferences (updated: name, goal)
257
+ M004 — Analytics (new)
258
+ M005 — Migrations (new)
259
+
260
+ Archived:
261
+ M003 — Feed → .nubos-pilot/archive/milestones/M003-2026-04-21
262
+
263
+ Next:
264
+ - /np:discuss-phase <N> for each new or updated milestone to refresh CONTEXT.md
265
+ - /np:plan-phase <N> to break it into slices + tasks
266
+ ```
267
+
268
+ ## Optional Commit
269
+
270
+ ```bash
271
+ if [ "$(node .nubos-pilot/bin/np-tools.cjs config-get workflow.commit_docs 2>/dev/null)" = "true" ]; then
272
+ git add .nubos-pilot/
273
+ git commit -m "chore: np:propose-milestones re-plan pipeline"
274
+ fi
275
+ ```
276
+
277
+ ## Errors
278
+
279
+ | Code | Trigger | User action |
280
+ |------|---------|-------------|
281
+ | `project-not-initialized` | no `PROJECT.md` in `.nubos-pilot/` | Run `/np:new-project` first |
282
+ | `roadmap-missing` / `roadmap-parse-error` | `roadmap.yaml` missing or malformed | Inspect `.nubos-pilot/roadmap.yaml` |
283
+ | `milestone-completed-untouchable` | update/remove on status=done | Cannot modify shipped milestones; add a new one instead |
284
+ | `milestone-has-slices` | update/remove on milestone with slices without `confirm_force_modify:true` | Either confirm explicitly per-item or first `/np:reset-slice` the affected slices |
285
+ | `milestone-not-found` | update/remove references non-existent M<NNN> | Re-run; use IDs shown in Step 1 |
286
+ | `answers-missing-field` | op missing required field | Fix the op and re-run |
287
+ | `invalid-operation-type` | op type is not add/update/remove | Valid types: add, update, remove |