nubos-pilot 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/np-sc-extractor.md +85 -0
- package/bin/install.js +15 -0
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/update-phase-meta.cjs +77 -0
- package/bin/np-tools/update-phase-meta.test.cjs +132 -0
- package/lib/agents.test.cjs +1 -0
- package/lib/roadmap.cjs +107 -0
- package/lib/roadmap.test.cjs +84 -0
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-statusline.js +32 -2
- package/workflows/discuss-phase.md +52 -4
- package/workflows/plan-phase.md +23 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: np-sc-extractor
|
|
3
|
+
description: Derives observable Success Criteria (SC-N) for a milestone from its goal, requirements, and captured decisions; persists them to roadmap.yaml via update-phase-meta. Spawned by /np:discuss-phase after the interview, before plan-phase.
|
|
4
|
+
tier: haiku
|
|
5
|
+
tools: Read, Bash, Grep, Glob
|
|
6
|
+
color: "#10B981"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<role>
|
|
10
|
+
You are the nubos-pilot Success-Criteria extractor. Your sole job: turn a milestone's vision (goal + requirements + interview decisions) into a short, observable, testable list of Success Criteria — and persist that list to `roadmap.yaml` via the `update-phase-meta` CLI helper.
|
|
11
|
+
|
|
12
|
+
You do NOT interview the user. You do NOT edit code. You do NOT re-open scope debates. You read the context that `/np:discuss-phase` has just produced and translate it into SCs.
|
|
13
|
+
</role>
|
|
14
|
+
|
|
15
|
+
<input>
|
|
16
|
+
- `milestone`, `milestone_id`, `milestone_name`, `milestone_dir`
|
|
17
|
+
- `goal`: the milestone's goal string (from `roadmap.yaml`)
|
|
18
|
+
- `requirements`: array of REQ-IDs in scope (from `roadmap.yaml`)
|
|
19
|
+
- `context_path`: path to `<milestone_dir>/<milestone_id>-CONTEXT.md` (just written by the workflow)
|
|
20
|
+
- `requirements_path`: path to `.nubos-pilot/REQUIREMENTS.md` (or `.planning/REQUIREMENTS.md`)
|
|
21
|
+
- `existing_success_criteria`: current `success_criteria[]` from roadmap.yaml (may be empty)
|
|
22
|
+
</input>
|
|
23
|
+
|
|
24
|
+
<required_reading>
|
|
25
|
+
1. `context_path` — the freshly captured decisions (WHAT the milestone locks in).
|
|
26
|
+
2. `requirements_path` — filtered to the REQ-IDs in input; extract the observable behavior hints.
|
|
27
|
+
3. Existing `M<NNN>-ROADMAP.md` and `M<NNN>-META.json` under `milestone_dir` IF they exist — they may already carry SCs a human drafted; prefer those verbatim over re-inventing.
|
|
28
|
+
</required_reading>
|
|
29
|
+
|
|
30
|
+
<extraction_rules>
|
|
31
|
+
1. **Derive, don't invent.** Every SC must trace back to a concrete line in CONTEXT.md, REQUIREMENTS.md, or existing ROADMAP.md. If there is no basis, emit fewer SCs — do not invent requirements.
|
|
32
|
+
2. **Observable.** Each SC must be checkable by a test or demo: "X happens when Y" / "metric Z stays under T". Avoid opinions ("code is clean", "UX feels fast").
|
|
33
|
+
3. **Numbered.** IDs are strictly `SC-1`, `SC-2`, … (no gaps). Start at `SC-1` even if a different ID scheme appears in prose.
|
|
34
|
+
4. **Reuse existing.** If `existing_success_criteria` is non-empty AND the content still matches the goal+requirements, return it unchanged (1:1). Only add/remove when the context materially disagrees.
|
|
35
|
+
5. **Prefer sidecar source of truth.** If `M<NNN>-ROADMAP.md` or `M<NNN>-META.json` list SCs and `roadmap.yaml` does not, migrate them verbatim (fix only ID numbering).
|
|
36
|
+
6. **Between 3 and 15 SCs.** Fewer than 3 = probably missing something; more than 15 = too granular (split the milestone in that case — but do not split here; instead emit a warning in your final message).
|
|
37
|
+
</extraction_rules>
|
|
38
|
+
|
|
39
|
+
<execution_flow>
|
|
40
|
+
|
|
41
|
+
<step name="load">
|
|
42
|
+
Read all `required_reading` files. Missing files: log and continue (e.g. META.json may not exist yet).
|
|
43
|
+
</step>
|
|
44
|
+
|
|
45
|
+
<step name="draft">
|
|
46
|
+
Produce the SC list as a JSON array of `{id, text}` objects. `id` MUST match `/^SC-\d+$/`. `text` is one sentence, observable, testable.
|
|
47
|
+
</step>
|
|
48
|
+
|
|
49
|
+
<step name="persist">
|
|
50
|
+
Call the helper:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
echo '<JSON PATCH>' | node .nubos-pilot/bin/np-tools.cjs update-phase-meta <MILESTONE_NUMBER> --stdin
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Where `<JSON PATCH>` is `{"success_criteria": [...your array...]}`. On success the helper returns `{"ok": true, ...}` — any other output is a failure and must be reported.
|
|
57
|
+
</step>
|
|
58
|
+
|
|
59
|
+
<step name="report">
|
|
60
|
+
Emit a short summary to stdout:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
np-sc-extractor: <N> success criteria persisted to roadmap.yaml (M<NNN>)
|
|
64
|
+
SC-1: <short text>
|
|
65
|
+
SC-2: <short text>
|
|
66
|
+
...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If you had to WARN (>15 SCs, conflicting sources, no basis found), prepend `WARN:` lines before the summary. Never fail silently.
|
|
70
|
+
</step>
|
|
71
|
+
|
|
72
|
+
</execution_flow>
|
|
73
|
+
|
|
74
|
+
<scope_guardrail>
|
|
75
|
+
**Do:**
|
|
76
|
+
- Read CONTEXT.md, REQUIREMENTS.md, existing sidecars.
|
|
77
|
+
- Persist via `update-phase-meta --stdin`.
|
|
78
|
+
- Reuse existing SCs verbatim when they still fit.
|
|
79
|
+
|
|
80
|
+
**Don't:**
|
|
81
|
+
- Ask the user questions (you are non-interactive).
|
|
82
|
+
- Edit CONTEXT.md, ROADMAP.md, META.json, or any implementation file.
|
|
83
|
+
- Invent SCs that have no basis in inputs.
|
|
84
|
+
- Emit more than 15 SCs.
|
|
85
|
+
</scope_guardrail>
|
package/bin/install.js
CHANGED
|
@@ -536,6 +536,21 @@ async function _runInstallLocked(ctx) {
|
|
|
536
536
|
console.error(yellow + ' [codex] repair skipped: ' + (err && err.message) + reset);
|
|
537
537
|
}
|
|
538
538
|
manifestMod.writeManifest(payloadDir, newManifest);
|
|
539
|
+
if (selectedRuntimesEarly.includes('claude')) {
|
|
540
|
+
try {
|
|
541
|
+
const claudeHooks = require('../lib/install/claude-hooks.cjs');
|
|
542
|
+
const res = claudeHooks.installClaudeHooks({
|
|
543
|
+
projectRoot, scope: resolvedScope, which: 'both', force: false,
|
|
544
|
+
});
|
|
545
|
+
console.error(dim + ' [claude-hooks] statusline: ' + res.results.statusline.action
|
|
546
|
+
+ ', ctx-monitor: ' + res.results.ctxMonitor.action + reset);
|
|
547
|
+
if (res.results.statusline.action === 'skipped-existing') {
|
|
548
|
+
console.error(yellow + ' [claude-hooks] foreign statusLine preserved — re-run `install-hooks --force` to overwrite' + reset);
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error(yellow + ' [claude-hooks] skipped: ' + (err && err.message) + reset);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
539
554
|
console.error(green + '✓ Installation abgeschlossen' + reset);
|
|
540
555
|
return { mode, dryRun: false, written: Object.keys(newManifest.files).length,
|
|
541
556
|
backedUp: backupLog.length, deleted: diff.stale.length };
|
|
@@ -50,6 +50,7 @@ const COMMANDS = [
|
|
|
50
50
|
{ name: 'stats', category: 'Utility', description: 'Aggregated project stats (roadmap + STATE + git + metrics JSON shape)' },
|
|
51
51
|
{ name: 'detect-runtime', category: 'Utility', description: 'Print detected runtime id (claude, codex, gemini, …) — reads config.json ∨ env ∨ default' },
|
|
52
52
|
{ name: 'template-path', category: 'Utility', description: 'Print absolute path to a package-shipped template by name (e.g. VALIDATION, milestone/CONTEXT)' },
|
|
53
|
+
{ name: 'update-phase-meta', category: 'Planning', description: 'Update roadmap.yaml phase fields (name/goal/requirements/success_criteria) via JSON patch' },
|
|
53
54
|
|
|
54
55
|
{ name: 'thread', category: 'Utility', description: 'Cross-session thread CRUD (create/resume under .nubos-pilot/threads/)' },
|
|
55
56
|
{ name: 'session-report', category: 'Utility', description: 'Generate session report from metrics since .last-session pointer' },
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const roadmap = require('../../lib/roadmap.cjs');
|
|
5
|
+
|
|
6
|
+
function _parseArgs(args) {
|
|
7
|
+
const out = { milestone: null, json: null, stdin: false };
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const a = args[i];
|
|
10
|
+
if (!a.startsWith('-')) {
|
|
11
|
+
if (out.milestone == null) { out.milestone = a; continue; }
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (a === '--json' || a === '-j') { out.json = args[++i] || null; continue; }
|
|
15
|
+
if (a === '--stdin') { out.stdin = true; continue; }
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _readStdinSync() {
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(0, 'utf-8');
|
|
24
|
+
} catch {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _validateMilestone(raw) {
|
|
30
|
+
if (raw == null) {
|
|
31
|
+
throw new NubosPilotError('update-phase-meta-missing-milestone',
|
|
32
|
+
'milestone number required (e.g. M002 or 2)', {});
|
|
33
|
+
}
|
|
34
|
+
const s = String(raw).trim();
|
|
35
|
+
const m = s.match(/^M?(\d+(?:\.\d+)?)$/i);
|
|
36
|
+
if (!m) {
|
|
37
|
+
throw new NubosPilotError('update-phase-meta-invalid-milestone',
|
|
38
|
+
'milestone must be M<NNN> or <number>', { milestone: raw });
|
|
39
|
+
}
|
|
40
|
+
return m[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(args, opts) {
|
|
44
|
+
const o = opts || {};
|
|
45
|
+
const cwd = o.cwd || process.cwd();
|
|
46
|
+
const stdout = o.stdout || process.stdout;
|
|
47
|
+
const readStdin = typeof o.readStdin === 'function' ? o.readStdin : _readStdinSync;
|
|
48
|
+
const stdinIsTty = o.stdinIsTty != null ? !!o.stdinIsTty : !!process.stdin.isTTY;
|
|
49
|
+
const parsed = _parseArgs(args || []);
|
|
50
|
+
const mNum = _validateMilestone(parsed.milestone);
|
|
51
|
+
|
|
52
|
+
let rawJson = parsed.json;
|
|
53
|
+
if (!rawJson && parsed.stdin) rawJson = readStdin();
|
|
54
|
+
if (!rawJson && !stdinIsTty) rawJson = readStdin();
|
|
55
|
+
if (!rawJson) {
|
|
56
|
+
throw new NubosPilotError('update-phase-meta-missing-json',
|
|
57
|
+
'JSON patch required (via --json, --stdin, or piped stdin)', {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let patch;
|
|
61
|
+
try {
|
|
62
|
+
patch = JSON.parse(rawJson);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new NubosPilotError('update-phase-meta-invalid-json',
|
|
65
|
+
'invalid JSON patch: ' + err.message, { cause: err.message });
|
|
66
|
+
}
|
|
67
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
68
|
+
throw new NubosPilotError('update-phase-meta-invalid-json',
|
|
69
|
+
'JSON patch must be an object', {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = roadmap.updatePhase(mNum, patch, cwd);
|
|
73
|
+
stdout.write(JSON.stringify({ ok: true, milestone: mNum, result }, null, 2) + '\n');
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { run, _parseArgs, _validateMilestone };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const mod = require('./update-phase-meta.cjs');
|
|
10
|
+
const roadmap = require('../../lib/roadmap.cjs');
|
|
11
|
+
|
|
12
|
+
const SEED = [
|
|
13
|
+
'schema_version: 1',
|
|
14
|
+
'milestones:',
|
|
15
|
+
' - id: M001',
|
|
16
|
+
' number: 1',
|
|
17
|
+
' name: First',
|
|
18
|
+
' goal: hello',
|
|
19
|
+
' status: pending',
|
|
20
|
+
' requirements: []',
|
|
21
|
+
' success_criteria: []',
|
|
22
|
+
' slices: []',
|
|
23
|
+
'',
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
26
|
+
function mkSandbox() {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-upm-'));
|
|
28
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
29
|
+
fs.writeFileSync(path.join(dir, '.nubos-pilot', 'roadmap.yaml'), SEED);
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function captureStdout() {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
return {
|
|
36
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
37
|
+
read: () => chunks.join(''),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('UPM-1: _validateMilestone accepts M002, 2, and 2.1', () => {
|
|
42
|
+
assert.equal(mod._validateMilestone('M002'), '002');
|
|
43
|
+
assert.equal(mod._validateMilestone('2'), '2');
|
|
44
|
+
assert.equal(mod._validateMilestone('2.1'), '2.1');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('UPM-2: _validateMilestone rejects garbage', () => {
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => mod._validateMilestone('bogus'),
|
|
50
|
+
(err) => err.code === 'update-phase-meta-invalid-milestone',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('UPM-3: run() writes SCs via --json flag', () => {
|
|
55
|
+
const dir = mkSandbox();
|
|
56
|
+
try {
|
|
57
|
+
const cap = captureStdout();
|
|
58
|
+
const rc = mod.run(['1', '--json', JSON.stringify({
|
|
59
|
+
success_criteria: [{ id: 'SC-1', text: 'log in works' }],
|
|
60
|
+
})], { cwd: dir, stdout: cap.stream, stdinIsTty: true });
|
|
61
|
+
assert.equal(rc, 0);
|
|
62
|
+
const p = roadmap.getPhase(1, dir);
|
|
63
|
+
assert.equal(p.success_criteria.length, 1);
|
|
64
|
+
const out = JSON.parse(cap.read());
|
|
65
|
+
assert.equal(out.ok, true);
|
|
66
|
+
assert.deepEqual(out.result.fields_updated, ['success_criteria']);
|
|
67
|
+
} finally {
|
|
68
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('UPM-4: invalid JSON via --json throws update-phase-meta-invalid-json', () => {
|
|
73
|
+
const dir = mkSandbox();
|
|
74
|
+
try {
|
|
75
|
+
const cap = captureStdout();
|
|
76
|
+
assert.throws(
|
|
77
|
+
() => mod.run(['1', '--json', '{broken'], { cwd: dir, stdout: cap.stream, stdinIsTty: true }),
|
|
78
|
+
(err) => err.code === 'update-phase-meta-invalid-json',
|
|
79
|
+
);
|
|
80
|
+
} finally {
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('UPM-5: JSON-array (not object) patch rejected', () => {
|
|
86
|
+
const dir = mkSandbox();
|
|
87
|
+
try {
|
|
88
|
+
assert.throws(
|
|
89
|
+
() => mod.run(['1', '--json', '[]'], { cwd: dir, stdout: captureStdout().stream, stdinIsTty: true }),
|
|
90
|
+
(err) => err.code === 'update-phase-meta-invalid-json',
|
|
91
|
+
);
|
|
92
|
+
} finally {
|
|
93
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('UPM-6: missing JSON throws update-phase-meta-missing-json', () => {
|
|
98
|
+
const dir = mkSandbox();
|
|
99
|
+
try {
|
|
100
|
+
assert.throws(
|
|
101
|
+
() => mod.run(['1'], { cwd: dir, stdout: captureStdout().stream, stdinIsTty: true }),
|
|
102
|
+
(err) => err.code === 'update-phase-meta-missing-json',
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('UPM-7: missing milestone arg throws update-phase-meta-missing-milestone', () => {
|
|
110
|
+
assert.throws(
|
|
111
|
+
() => mod.run(['--json', '{"goal":"x"}'], { cwd: process.cwd(), stdout: captureStdout().stream }),
|
|
112
|
+
(err) => err.code === 'update-phase-meta-missing-milestone',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('UPM-8: run() reads from piped stdin when stdinIsTty false', () => {
|
|
117
|
+
const dir = mkSandbox();
|
|
118
|
+
try {
|
|
119
|
+
const cap = captureStdout();
|
|
120
|
+
const rc = mod.run(['1'], {
|
|
121
|
+
cwd: dir,
|
|
122
|
+
stdout: cap.stream,
|
|
123
|
+
stdinIsTty: false,
|
|
124
|
+
readStdin: () => JSON.stringify({ requirements: ['REQ-42'] }),
|
|
125
|
+
});
|
|
126
|
+
assert.equal(rc, 0);
|
|
127
|
+
const p = roadmap.getPhase(1, dir);
|
|
128
|
+
assert.deepEqual(p.requirements, ['REQ-42']);
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
package/lib/agents.test.cjs
CHANGED
|
@@ -225,6 +225,7 @@ const NP_AGENTS = [
|
|
|
225
225
|
{ file: 'np-researcher', expected_tier: 'sonnet' },
|
|
226
226
|
{ file: 'np-codebase-documenter', expected_tier: 'sonnet' },
|
|
227
227
|
{ file: 'np-nyquist-auditor', expected_tier: 'haiku' },
|
|
228
|
+
{ file: 'np-sc-extractor', expected_tier: 'haiku' },
|
|
228
229
|
];
|
|
229
230
|
|
|
230
231
|
for (let i = 0; i < NP_AGENTS.length; i += 1) {
|
package/lib/roadmap.cjs
CHANGED
|
@@ -413,6 +413,112 @@ function addBacklogEntry(description, opts) {
|
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
function _normalizeScList(scs) {
|
|
417
|
+
if (!Array.isArray(scs)) {
|
|
418
|
+
throw new NubosPilotError(
|
|
419
|
+
'roadmap-invalid-success-criteria',
|
|
420
|
+
'success_criteria must be an array',
|
|
421
|
+
{ received: typeof scs },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const out = [];
|
|
425
|
+
for (let i = 0; i < scs.length; i++) {
|
|
426
|
+
const sc = scs[i];
|
|
427
|
+
if (typeof sc === 'string') {
|
|
428
|
+
const s = sc.trim();
|
|
429
|
+
if (!s) {
|
|
430
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
431
|
+
'success_criteria[' + i + '] must be non-empty', { index: i });
|
|
432
|
+
}
|
|
433
|
+
out.push(s);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (sc && typeof sc === 'object') {
|
|
437
|
+
const id = typeof sc.id === 'string' ? sc.id.trim() : '';
|
|
438
|
+
const text = typeof sc.text === 'string' ? sc.text.trim() : '';
|
|
439
|
+
if (!id || !/^SC-\d+$/.test(id)) {
|
|
440
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
441
|
+
'success_criteria[' + i + '].id must match /^SC-\\d+$/', { index: i, id: sc.id });
|
|
442
|
+
}
|
|
443
|
+
if (!text) {
|
|
444
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
445
|
+
'success_criteria[' + i + '].text must be non-empty', { index: i });
|
|
446
|
+
}
|
|
447
|
+
out.push({ id, text });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
451
|
+
'success_criteria[' + i + '] must be string or {id,text}', { index: i });
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function _normalizeReqList(reqs) {
|
|
457
|
+
if (!Array.isArray(reqs)) {
|
|
458
|
+
throw new NubosPilotError('roadmap-invalid-requirements',
|
|
459
|
+
'requirements must be an array', { received: typeof reqs });
|
|
460
|
+
}
|
|
461
|
+
return reqs.map((r, i) => {
|
|
462
|
+
if (typeof r !== 'string' || !r.trim()) {
|
|
463
|
+
throw new NubosPilotError('roadmap-invalid-requirements',
|
|
464
|
+
'requirements[' + i + '] must be non-empty string', { index: i });
|
|
465
|
+
}
|
|
466
|
+
return r.trim();
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function updatePhase(n, patch, cwd = process.cwd()) {
|
|
471
|
+
const want = String(n);
|
|
472
|
+
const p = patch || {};
|
|
473
|
+
const allowed = ['name', 'goal', 'requirements', 'success_criteria'];
|
|
474
|
+
const unknown = Object.keys(p).filter((k) => !allowed.includes(k));
|
|
475
|
+
if (unknown.length) {
|
|
476
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
477
|
+
'updatePhase: unknown keys: ' + unknown.join(', '),
|
|
478
|
+
{ unknown, allowed });
|
|
479
|
+
}
|
|
480
|
+
const prepared = {};
|
|
481
|
+
if ('name' in p) {
|
|
482
|
+
if (typeof p.name !== 'string' || !p.name.trim()) {
|
|
483
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
484
|
+
'name must be non-empty string', {});
|
|
485
|
+
}
|
|
486
|
+
prepared.name = p.name.trim();
|
|
487
|
+
}
|
|
488
|
+
if ('goal' in p) {
|
|
489
|
+
if (typeof p.goal !== 'string') {
|
|
490
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
491
|
+
'goal must be string', {});
|
|
492
|
+
}
|
|
493
|
+
prepared.goal = p.goal;
|
|
494
|
+
}
|
|
495
|
+
if ('requirements' in p) prepared.requirements = _normalizeReqList(p.requirements);
|
|
496
|
+
if ('success_criteria' in p) prepared.success_criteria = _normalizeScList(p.success_criteria);
|
|
497
|
+
|
|
498
|
+
return _mutate(cwd, (doc) => {
|
|
499
|
+
let target = null;
|
|
500
|
+
for (const ms of doc.milestones) {
|
|
501
|
+
if (!ms) continue;
|
|
502
|
+
if (Array.isArray(ms.slices) && String(ms.number) === want) { target = ms; break; }
|
|
503
|
+
if (Array.isArray(ms.phases)) {
|
|
504
|
+
const hit = ms.phases.find((ph) => ph && String(ph.number) === want);
|
|
505
|
+
if (hit) { target = hit; break; }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!target) {
|
|
509
|
+
throw new NubosPilotError('phase-not-found',
|
|
510
|
+
'Phase ' + want + ' not found in roadmap.yaml',
|
|
511
|
+
{ requested: want });
|
|
512
|
+
}
|
|
513
|
+
const updated = [];
|
|
514
|
+
for (const k of Object.keys(prepared)) {
|
|
515
|
+
target[k] = prepared[k];
|
|
516
|
+
updated.push(k);
|
|
517
|
+
}
|
|
518
|
+
return { number: want, name: target.name || '', fields_updated: updated };
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
416
522
|
function collapseMilestone(milestoneId, opts) {
|
|
417
523
|
const cwd = (opts && opts.cwd) || process.cwd();
|
|
418
524
|
if (typeof milestoneId !== 'string' || !/^[vV0-9._-]+$/.test(milestoneId)) {
|
|
@@ -446,6 +552,7 @@ module.exports = {
|
|
|
446
552
|
addMilestone,
|
|
447
553
|
addPhase,
|
|
448
554
|
insertPhaseAfter,
|
|
555
|
+
updatePhase,
|
|
449
556
|
addBacklogEntry,
|
|
450
557
|
collapseMilestone,
|
|
451
558
|
};
|
package/lib/roadmap.test.cjs
CHANGED
|
@@ -369,3 +369,87 @@ test('ROAD-COLLAPSE-3: unknown milestoneId throws roadmap-milestone-not-found',
|
|
|
369
369
|
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-milestone-not-found',
|
|
370
370
|
);
|
|
371
371
|
});
|
|
372
|
+
|
|
373
|
+
const WRITE_SEED_TOP_LEVEL = [
|
|
374
|
+
'schema_version: 1',
|
|
375
|
+
'milestones:',
|
|
376
|
+
' - id: M001',
|
|
377
|
+
' number: 1',
|
|
378
|
+
' name: Auth',
|
|
379
|
+
' goal: log users in',
|
|
380
|
+
' status: pending',
|
|
381
|
+
' requirements: []',
|
|
382
|
+
' success_criteria: []',
|
|
383
|
+
' slices: []',
|
|
384
|
+
' - id: M002',
|
|
385
|
+
' number: 2',
|
|
386
|
+
' name: Voice',
|
|
387
|
+
' goal: voice pipeline',
|
|
388
|
+
' status: pending',
|
|
389
|
+
' requirements: []',
|
|
390
|
+
' success_criteria: []',
|
|
391
|
+
' slices: []',
|
|
392
|
+
'',
|
|
393
|
+
].join('\n');
|
|
394
|
+
|
|
395
|
+
test('RM-UPDATE-1: updatePhase writes success_criteria to nested milestone.phases[]', () => {
|
|
396
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
397
|
+
const res = roadmap.updatePhase(1, {
|
|
398
|
+
success_criteria: [{ id: 'SC-1', text: 'scaffold exists' }, { id: 'SC-2', text: 'ADRs committed' }],
|
|
399
|
+
}, sandbox);
|
|
400
|
+
assert.deepEqual(res.fields_updated, ['success_criteria']);
|
|
401
|
+
const p = roadmap.getPhase(1, sandbox);
|
|
402
|
+
assert.equal(p.success_criteria.length, 2);
|
|
403
|
+
assert.equal(p.success_criteria[0].id, 'SC-1');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('RM-UPDATE-2: updatePhase writes to top-level milestone (new-style roadmap)', () => {
|
|
407
|
+
const sandbox = makeSandbox(WRITE_SEED_TOP_LEVEL);
|
|
408
|
+
roadmap.updatePhase(2, {
|
|
409
|
+
success_criteria: ['Speaker ID works', 'Latency < 2s'],
|
|
410
|
+
requirements: ['REQ-01'],
|
|
411
|
+
}, sandbox);
|
|
412
|
+
const p = roadmap.getPhase(2, sandbox);
|
|
413
|
+
assert.deepEqual(p.success_criteria, ['Speaker ID works', 'Latency < 2s']);
|
|
414
|
+
assert.deepEqual(p.requirements, ['REQ-01']);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('RM-UPDATE-3: unknown phase number throws phase-not-found', () => {
|
|
418
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
419
|
+
assert.throws(
|
|
420
|
+
() => roadmap.updatePhase(99, { success_criteria: ['x'] }, sandbox),
|
|
421
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'phase-not-found',
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('RM-UPDATE-4: invalid SC id format throws roadmap-invalid-success-criteria', () => {
|
|
426
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
427
|
+
assert.throws(
|
|
428
|
+
() => roadmap.updatePhase(1, { success_criteria: [{ id: 'BAD', text: 'x' }] }, sandbox),
|
|
429
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-invalid-success-criteria',
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('RM-UPDATE-5: unknown patch key throws roadmap-invalid-patch', () => {
|
|
434
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
435
|
+
assert.throws(
|
|
436
|
+
() => roadmap.updatePhase(1, { status: 'done' }, sandbox),
|
|
437
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-invalid-patch',
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('RM-UPDATE-6: partial patch — only updates given fields', () => {
|
|
442
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
443
|
+
roadmap.updatePhase(1, { goal: 'new goal text' }, sandbox);
|
|
444
|
+
const p = roadmap.getPhase(1, sandbox);
|
|
445
|
+
assert.equal(p.goal, 'new goal text');
|
|
446
|
+
assert.equal(p.success_criteria.length, 0, 'SCs untouched');
|
|
447
|
+
assert.equal(p.name, 'Foundation', 'name untouched');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('RM-UPDATE-7: re-renders ROADMAP.md alongside roadmap.yaml', () => {
|
|
451
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
452
|
+
roadmap.updatePhase(1, { success_criteria: ['check 1'] }, sandbox);
|
|
453
|
+
const md = fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'ROADMAP.md'), 'utf-8');
|
|
454
|
+
assert.ok(md.includes('check 1'), 'ROADMAP.md re-rendered with new SC');
|
|
455
|
+
});
|
package/np-tools.cjs
CHANGED
|
@@ -50,6 +50,7 @@ const topLevelCommands = {
|
|
|
50
50
|
'text-mode': require('./bin/np-tools/text-mode.cjs'),
|
|
51
51
|
'detect-runtime': require('./bin/np-tools/detect-runtime.cjs'),
|
|
52
52
|
'template-path': require('./bin/np-tools/template-path.cjs'),
|
|
53
|
+
'update-phase-meta': require('./bin/np-tools/update-phase-meta.cjs'),
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
const THRESHOLD = 16 * 1024;
|
package/package.json
CHANGED
|
@@ -76,6 +76,36 @@ function renderBar(used, limit) {
|
|
|
76
76
|
return color + bar + '\x1b[0m ' + pct + '%' + suffix;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function terminalWidth() {
|
|
80
|
+
const envCols = Number(process.env.COLUMNS);
|
|
81
|
+
if (Number.isFinite(envCols) && envCols > 0) return envCols;
|
|
82
|
+
if (process.stdout && process.stdout.columns) return process.stdout.columns;
|
|
83
|
+
try {
|
|
84
|
+
const { execSync } = require('node:child_process');
|
|
85
|
+
const out = execSync('tput cols', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
86
|
+
const n = Number(out);
|
|
87
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
88
|
+
} catch {}
|
|
89
|
+
return 120;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function visibleLen(s) {
|
|
93
|
+
const stripped = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
94
|
+
let w = 0;
|
|
95
|
+
for (const ch of stripped) {
|
|
96
|
+
const cp = ch.codePointAt(0);
|
|
97
|
+
if (cp > 0xFFFF) w += 2;
|
|
98
|
+
else w += 1;
|
|
99
|
+
}
|
|
100
|
+
return w;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function centerLine(line) {
|
|
104
|
+
const width = terminalWidth();
|
|
105
|
+
const pad = Math.max(0, Math.floor((width - visibleLen(line)) / 2));
|
|
106
|
+
return ' '.repeat(pad) + line;
|
|
107
|
+
}
|
|
108
|
+
|
|
79
109
|
function writeBridge(payload, usage, limit) {
|
|
80
110
|
const sid = payload && payload.session_id;
|
|
81
111
|
if (!sid) return;
|
|
@@ -97,14 +127,14 @@ function writeBridge(payload, usage, limit) {
|
|
|
97
127
|
const usage = lastUsage(payload && payload.transcript_path);
|
|
98
128
|
const prefix = '\x1b[38;5;33mnubos-pilot\x1b[0m';
|
|
99
129
|
if (!usage) {
|
|
100
|
-
process.stdout.write(prefix);
|
|
130
|
+
process.stdout.write(centerLine(prefix));
|
|
101
131
|
return;
|
|
102
132
|
}
|
|
103
133
|
writeBridge(payload, usage, limit);
|
|
104
134
|
const bar = renderBar(usage.total, limit);
|
|
105
135
|
const modelName = (payload && payload.model && payload.model.display_name) || '';
|
|
106
136
|
const tail = modelName ? ' \x1b[2m' + modelName + '\x1b[0m' : '';
|
|
107
|
-
process.stdout.write(prefix + ' ctx ' + bar + tail);
|
|
137
|
+
process.stdout.write(centerLine(prefix + ' ctx ' + bar + tail));
|
|
108
138
|
})().catch(() => {
|
|
109
139
|
process.stdout.write('\x1b[38;5;33mnubos-pilot\x1b[0m');
|
|
110
140
|
});
|
|
@@ -370,18 +370,66 @@ If the template lacks a key, `render()` throws
|
|
|
370
370
|
`NubosPilotError('template-missing-key', …)` — the workflow must not swallow
|
|
371
371
|
that error. Fix the template or the accumulator, don't mask the failure.
|
|
372
372
|
|
|
373
|
+
### Step 6b: Extract + persist Success Criteria (np-sc-extractor)
|
|
374
|
+
|
|
375
|
+
CONTEXT.md now captures the decisions. Success Criteria in `roadmap.yaml` are still empty for this milestone — downstream `/np:verify-work` reads them from there, so we must persist them now. Spawn the SC-extractor (haiku) to derive observable SCs from goal + requirements + CONTEXT.md + any pre-existing `M<NNN>-ROADMAP.md` / `M<NNN>-META.json` sidecars, and call `update-phase-meta` to write them.
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
SC_START=$(node .nubos-pilot/bin/np-tools.cjs metrics start-timestamp)
|
|
379
|
+
SC_MODEL=$(node .nubos-pilot/bin/np-tools.cjs resolve-model np-sc-extractor --profile balanced)
|
|
380
|
+
|
|
381
|
+
REQS_PATH=".nubos-pilot/REQUIREMENTS.md"
|
|
382
|
+
[[ -f "$REQS_PATH" ]] || REQS_PATH=".planning/REQUIREMENTS.md"
|
|
383
|
+
|
|
384
|
+
EXISTING_SC_JSON=$(node -e '
|
|
385
|
+
const r = require("./lib/roadmap.cjs");
|
|
386
|
+
const p = r.getPhase(process.argv[1]);
|
|
387
|
+
process.stdout.write(JSON.stringify(p.success_criteria || []));
|
|
388
|
+
' "$PHASE")
|
|
389
|
+
|
|
390
|
+
# Spawn agent=np-sc-extractor tier=haiku model=$SC_MODEL milestone=$PHASE
|
|
391
|
+
# input: milestone=$PHASE, milestone_id=$MILESTONE_ID, milestone_dir=$MILESTONE_DIR,
|
|
392
|
+
# context_path=$CONTEXT_PATH, requirements_path=$REQS_PATH,
|
|
393
|
+
# existing_success_criteria=$EXISTING_SC_JSON
|
|
394
|
+
# output: calls `np-tools.cjs update-phase-meta $PHASE --stdin` with
|
|
395
|
+
# {"success_criteria": [{id:"SC-N", text:"..."}, ...]} and prints summary.
|
|
396
|
+
|
|
397
|
+
SC_END=$(node .nubos-pilot/bin/np-tools.cjs metrics end-timestamp)
|
|
398
|
+
node .nubos-pilot/bin/np-tools.cjs metrics record \
|
|
399
|
+
--agent np-sc-extractor --tier haiku --resolved-model "$SC_MODEL" \
|
|
400
|
+
--phase "$PHASE" --plan "${MILESTONE_ID}-sc" --task "${MILESTONE_ID}-sc-extract" \
|
|
401
|
+
--started "$SC_START" --ended "$SC_END" \
|
|
402
|
+
--tokens-in "${TOKENS_IN:-0}" --tokens-out "${TOKENS_OUT:-0}" \
|
|
403
|
+
--retry-count 0 --status ok --runtime "$RUNTIME"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
After the spawn, sanity-check that `success_criteria` is non-empty:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
SC_COUNT=$(node -e '
|
|
410
|
+
const r = require("./lib/roadmap.cjs");
|
|
411
|
+
const p = r.getPhase(process.argv[1]);
|
|
412
|
+
process.stdout.write(String((p.success_criteria || []).length));
|
|
413
|
+
' "$PHASE")
|
|
414
|
+
if [[ "$SC_COUNT" -lt 1 ]]; then
|
|
415
|
+
echo "ERROR: np-sc-extractor produced no success_criteria for $MILESTONE_ID — refusing to continue." >&2
|
|
416
|
+
exit 1
|
|
417
|
+
fi
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A failure here is loud by design: `/np:verify-work` and `/np:validate-phase` depend on a populated `success_criteria[]`. If the extractor cannot derive any, fix the goal/requirements/CONTEXT.md inputs before retrying.
|
|
421
|
+
|
|
373
422
|
### Step 7: Commit respecting config.commit_docs
|
|
374
423
|
|
|
375
424
|
```bash
|
|
376
425
|
COMMIT_DOCS=$(node .nubos-pilot/bin/np-tools.cjs config-get workflow.commit_docs 2>/dev/null || echo "true")
|
|
377
426
|
if [[ "$COMMIT_DOCS" == "true" ]]; then
|
|
378
|
-
git add "$CONTEXT_PATH"
|
|
379
|
-
git commit -m "docs($MILESTONE_ID): capture milestone context"
|
|
427
|
+
git add "$CONTEXT_PATH" .nubos-pilot/roadmap.yaml .nubos-pilot/ROADMAP.md
|
|
428
|
+
git commit -m "docs($MILESTONE_ID): capture milestone context + success criteria"
|
|
380
429
|
fi
|
|
381
430
|
```
|
|
382
431
|
|
|
383
|
-
If `workflow.commit_docs` is false, leave the
|
|
384
|
-
opting into manual commit gating.
|
|
432
|
+
If `workflow.commit_docs` is false, leave both CONTEXT.md and the roadmap edits uncommitted — the user is opting into manual commit gating.
|
|
385
433
|
|
|
386
434
|
### Step 8: Confirm and next steps
|
|
387
435
|
|
package/workflows/plan-phase.md
CHANGED
|
@@ -118,6 +118,29 @@ case "$CHOICE" in
|
|
|
118
118
|
esac
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
### Gate 1b — Empty success_criteria
|
|
122
|
+
|
|
123
|
+
If `success_criteria.length == 0`:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
CHOICE=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{
|
|
127
|
+
"type": "select",
|
|
128
|
+
"header": "No SCs in roadmap.yaml",
|
|
129
|
+
"question": "Milestone has no success_criteria in roadmap.yaml. Downstream /np:verify-work will produce an empty VERIFICATION.md. How to proceed?",
|
|
130
|
+
"options": [
|
|
131
|
+
{"label": "Run /np:discuss-phase first", "description": "Recommended — np-sc-extractor derives SCs from CONTEXT.md + goal + requirements and writes them to roadmap.yaml."},
|
|
132
|
+
{"label": "Continue anyway", "description": "Plan the milestone without SCs; you must back-fill them before /np:verify-work."},
|
|
133
|
+
{"label": "Abort", "description": "Exit without changes."}
|
|
134
|
+
]
|
|
135
|
+
}')
|
|
136
|
+
case "$CHOICE" in
|
|
137
|
+
"Run /np:discuss-phase"*) echo "Run: /np:discuss-phase $PHASE"; exit 0 ;;
|
|
138
|
+
"Abort") exit 0 ;;
|
|
139
|
+
esac
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The planner will still emit a plan without SCs, but you are consciously opting into a known-broken verify-work path. The safer default is always to re-run `/np:discuss-phase` — Step 6b there spawns `np-sc-extractor` which populates `roadmap.yaml` directly.
|
|
143
|
+
|
|
121
144
|
### Gate 2 — Missing slice RESEARCH.md
|
|
122
145
|
|
|
123
146
|
Research is per-slice (`slices/S<NNN>/S<NNN>-RESEARCH.md`). The planner can plan without research, but if the roadmap config requires it, ask. The `--research` flag auto-dispatches `/np:research-phase` before re-entering.
|