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
package/workflows/new-project.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 |
|