nubos-pilot 0.5.8 → 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.
- package/bin/install.js +3 -7
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/commit.cjs +32 -14
- package/bin/np-tools/commit.test.cjs +30 -3
- package/bin/np-tools/propose-milestones.cjs +461 -0
- package/bin/np-tools/propose-milestones.test.cjs +235 -0
- package/bin/np-tools/template-path.cjs +61 -0
- package/bin/np-tools/template-path.test.cjs +86 -0
- package/lib/config-defaults.cjs +40 -0
- package/lib/git.cjs +11 -7
- package/np-tools.cjs +2 -0
- package/package.json +1 -1
- package/workflows/discuss-phase.md +4 -3
- package/workflows/new-project.md +216 -4
- package/workflows/propose-milestones.md +287 -0
- package/workflows/validate-phase.md +4 -4
package/bin/install.js
CHANGED
|
@@ -17,6 +17,7 @@ const backupMod = require('../lib/install/backup.cjs');
|
|
|
17
17
|
const registryMod = require('../lib/install/runtimes-registry.cjs');
|
|
18
18
|
const runtimeAssetsMod = require('../lib/install/runtime-assets.cjs');
|
|
19
19
|
const languageMod = require('../lib/language.cjs');
|
|
20
|
+
const configDefaults = require('../lib/config-defaults.cjs');
|
|
20
21
|
|
|
21
22
|
const cyan = '\x1b[36m', green = '\x1b[32m', yellow = '\x1b[33m',
|
|
22
23
|
red = '\x1b[31m', blue = '\x1b[38;5;33m',
|
|
@@ -248,16 +249,11 @@ async function _runInitQuestions(detectedRuntime, askUser, flags) {
|
|
|
248
249
|
const model_profile = (await askUser({ type: 'select', question: 'Model-Profile?',
|
|
249
250
|
options: ['frontier', 'quality', 'balanced', 'budget', 'inherit'], default: 'frontier' })).value;
|
|
250
251
|
const response_language = (await askUser({ type: 'input', question: 'Response language (ISO-639 code)?', default: 'en' })).value;
|
|
251
|
-
return {
|
|
252
|
+
return configDefaults.buildInstallConfig({
|
|
252
253
|
runtime, runtimes, scope, mcp: !!f.mcp,
|
|
253
254
|
model_profile,
|
|
254
255
|
response_language,
|
|
255
|
-
|
|
256
|
-
parallelization: true,
|
|
257
|
-
research: true,
|
|
258
|
-
plan_checker: true,
|
|
259
|
-
verifier: true,
|
|
260
|
-
};
|
|
256
|
+
});
|
|
261
257
|
}
|
|
262
258
|
|
|
263
259
|
function _repairCodexConfig() {
|
|
@@ -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' },
|
|
@@ -48,6 +49,7 @@ const COMMANDS = [
|
|
|
48
49
|
{ name: 'generate-slug', category: 'Utility', description: 'Slugify text via lib/layout.cjs.slugify' },
|
|
49
50
|
{ name: 'stats', category: 'Utility', description: 'Aggregated project stats (roadmap + STATE + git + metrics JSON shape)' },
|
|
50
51
|
{ name: 'detect-runtime', category: 'Utility', description: 'Print detected runtime id (claude, codex, gemini, …) — reads config.json ∨ env ∨ default' },
|
|
52
|
+
{ name: 'template-path', category: 'Utility', description: 'Print absolute path to a package-shipped template by name (e.g. VALIDATION, milestone/CONTEXT)' },
|
|
51
53
|
|
|
52
54
|
{ name: 'thread', category: 'Utility', description: 'Cross-session thread CRUD (create/resume under .nubos-pilot/threads/)' },
|
|
53
55
|
{ name: 'session-report', category: 'Utility', description: 'Generate session report from metrics since .last-session pointer' },
|
package/bin/np-tools/commit.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const fs = require('node:fs');
|
|
2
3
|
const path = require('node:path');
|
|
3
|
-
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const { NubosPilotError, findProjectRoot } = require('../../lib/core.cjs');
|
|
4
5
|
const { assertCommittablePaths } = require('../../lib/git.cjs');
|
|
5
6
|
const { resolveCommitArtifacts } = require('../../lib/commit-policy.cjs');
|
|
6
7
|
|
|
@@ -47,19 +48,34 @@ function _parseArgs(argv) {
|
|
|
47
48
|
return { msg, files };
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function
|
|
51
|
-
|
|
51
|
+
function _realpathOrResolve(p) {
|
|
52
|
+
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _normalizeFiles(files, cwd, root) {
|
|
56
|
+
const realRoot = _realpathOrResolve(root);
|
|
57
|
+
return files.map((f) => {
|
|
52
58
|
if (typeof f !== 'string' || f.length === 0) {
|
|
53
59
|
throw new NubosPilotError('commit-invalid-path', 'commit path must be non-empty string', { path: f });
|
|
54
60
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
const abs = path.isAbsolute(f) ? path.resolve(f) : path.resolve(cwd, f);
|
|
62
|
+
const realAbs = _realpathOrResolve(abs);
|
|
63
|
+
const rel = path.relative(realRoot, realAbs);
|
|
64
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
65
|
+
throw new NubosPilotError(
|
|
66
|
+
'commit-path-outside-project',
|
|
67
|
+
'commit path must resolve inside project root',
|
|
68
|
+
{ path: f, root },
|
|
69
|
+
);
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
return rel;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _validateFiles(files) {
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
if (typeof f !== 'string' || f.length === 0) {
|
|
78
|
+
throw new NubosPilotError('commit-invalid-path', 'commit path must be non-empty string', { path: f });
|
|
63
79
|
}
|
|
64
80
|
}
|
|
65
81
|
}
|
|
@@ -87,13 +103,15 @@ function run(argv, ctx) {
|
|
|
87
103
|
stdout.write(JSON.stringify({ committed: false, reason: 'commit_artifacts=false', files }) + '\n');
|
|
88
104
|
return 0;
|
|
89
105
|
}
|
|
90
|
-
const
|
|
106
|
+
const root = findProjectRoot(cwd);
|
|
107
|
+
const normalized = _normalizeFiles(files, cwd, root);
|
|
108
|
+
const committable = assertCommittablePaths(normalized, { cwd: root });
|
|
91
109
|
if (committable.length === 0) {
|
|
92
110
|
throw new NubosPilotError('commit-no-paths', 'commit invoked with no committable paths', { files });
|
|
93
111
|
}
|
|
94
|
-
execFileSync('git', ['add', '--', ...committable], { stdio: 'pipe' });
|
|
95
|
-
execFileSync('git', ['commit', '-m', msg, '--', ...committable], { stdio: 'pipe' });
|
|
96
|
-
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf-8' }).trim();
|
|
112
|
+
execFileSync('git', ['add', '--', ...committable], { cwd: root, stdio: 'pipe' });
|
|
113
|
+
execFileSync('git', ['commit', '-m', msg, '--', ...committable], { cwd: root, stdio: 'pipe' });
|
|
114
|
+
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf-8' }).trim();
|
|
97
115
|
stdout.write(JSON.stringify({ committed: true, sha, files: committable }) + '\n');
|
|
98
116
|
return 0;
|
|
99
117
|
} catch (err) {
|
|
@@ -57,12 +57,39 @@ test('COMMIT-1: happy path commits a single file and prints sha JSON', () => {
|
|
|
57
57
|
assert.match(stdout.toString(), /"committed":\s*true/);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
test('COMMIT-2: path
|
|
60
|
+
test('COMMIT-2: path resolving outside project root rejected with commit-path-outside-project', () => {
|
|
61
|
+
const sb = makeSandbox();
|
|
62
|
+
initGit(sb);
|
|
63
|
+
const stdout = makeSink();
|
|
64
|
+
const stderr = makeSink();
|
|
65
|
+
const code = commitCli.run(['feat: x', '--files', '../outside.txt'], { cwd: sb, stdout, stderr });
|
|
66
|
+
assert.equal(code, 1);
|
|
67
|
+
assert.match(stderr.toString(), /"code":\s*"commit-path-outside-project"/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('COMMIT-2b: absolute path inside project root is accepted and normalized', () => {
|
|
71
|
+
const sb = makeSandbox();
|
|
72
|
+
initGit(sb);
|
|
73
|
+
const absFile = path.join(sb, 'note.md');
|
|
74
|
+
fs.writeFileSync(absFile, 'hi\n');
|
|
75
|
+
const stdout = makeSink();
|
|
76
|
+
const stderr = makeSink();
|
|
77
|
+
const code = commitCli.run(['docs: note', '--files', absFile], { cwd: sb, stdout, stderr });
|
|
78
|
+
assert.equal(code, 0, 'stderr=' + stderr.toString());
|
|
79
|
+
assert.match(stdout.toString(), /"committed":\s*true/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('COMMIT-2c: absolute path outside project root rejected', () => {
|
|
83
|
+
const sb = makeSandbox();
|
|
84
|
+
initGit(sb);
|
|
85
|
+
const outside = path.join(os.tmpdir(), 'np-commit-outside-' + Date.now() + '.txt');
|
|
86
|
+
fs.writeFileSync(outside, 'x');
|
|
61
87
|
const stdout = makeSink();
|
|
62
88
|
const stderr = makeSink();
|
|
63
|
-
const code = commitCli.run(['
|
|
89
|
+
const code = commitCli.run(['docs: x', '--files', outside], { cwd: sb, stdout, stderr });
|
|
90
|
+
try { fs.unlinkSync(outside); } catch {}
|
|
64
91
|
assert.equal(code, 1);
|
|
65
|
-
assert.match(stderr.toString(), /"code":\s*"commit-path-
|
|
92
|
+
assert.match(stderr.toString(), /"code":\s*"commit-path-outside-project"/);
|
|
66
93
|
});
|
|
67
94
|
|
|
68
95
|
test('COMMIT-3: empty message prints usage and exits 1', () => {
|
|
@@ -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 };
|