nubos-pilot 0.6.0 → 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 +83 -0
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/discuss-phase.test.cjs +3 -3
- package/bin/np-tools/text-mode.test.cjs +9 -6
- 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/install/claude-hooks.cjs +195 -0
- package/lib/install/claude-hooks.test.cjs +163 -0
- package/lib/roadmap.cjs +107 -0
- package/lib/roadmap.test.cjs +84 -0
- package/lib/text-mode.cjs +1 -1
- package/lib/text-mode.test.cjs +11 -22
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-ctx-monitor.js +94 -0
- package/templates/claude/payload/hooks/np-statusline.js +140 -0
- package/workflows/add-todo.md +4 -4
- package/workflows/discuss-phase.md +76 -44
- package/workflows/discuss-project.md +5 -0
- package/workflows/execute-phase.md +4 -5
- package/workflows/new-milestone.md +4 -5
- package/workflows/new-project.md +4 -5
- package/workflows/note.md +5 -5
- package/workflows/plan-phase.md +27 -4
- package/workflows/propose-milestones.md +4 -1
- package/workflows/research-phase.md +4 -4
- package/workflows/resume-work.md +4 -4
- package/workflows/session-report.md +4 -4
- package/workflows/validate-phase.md +4 -4
- package/workflows/verify-work.md +4 -5
|
@@ -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 };
|
|
@@ -683,6 +698,10 @@ async function main() {
|
|
|
683
698
|
const doctor = require('./np-tools/doctor.cjs');
|
|
684
699
|
return await doctor.run(rest.slice(1), { cwd, stdout: process.stdout });
|
|
685
700
|
}
|
|
701
|
+
case 'install-hooks':
|
|
702
|
+
return await runInstallHooks({ cwd, args: rest.slice(1) });
|
|
703
|
+
case 'uninstall-hooks':
|
|
704
|
+
return await runUninstallHooks({ cwd, args: rest.slice(1) });
|
|
686
705
|
default:
|
|
687
706
|
process.stderr.write(
|
|
688
707
|
red + 'Unbekanntes Subcommand: ' + sub + reset + '\n',
|
|
@@ -692,6 +711,70 @@ async function main() {
|
|
|
692
711
|
}
|
|
693
712
|
}
|
|
694
713
|
|
|
714
|
+
function _parseHookFlags(args) {
|
|
715
|
+
const flags = { scope: null, which: 'both', force: false, dryRun: false };
|
|
716
|
+
for (let i = 0; i < args.length; i++) {
|
|
717
|
+
const a = args[i];
|
|
718
|
+
if (a === '--scope' || a === '-s') { flags.scope = args[++i] || null; continue; }
|
|
719
|
+
if (a.startsWith('--scope=')) { flags.scope = a.slice('--scope='.length); continue; }
|
|
720
|
+
if (a === '--statusline-only') { flags.which = 'statusline'; continue; }
|
|
721
|
+
if (a === '--ctx-monitor-only') { flags.which = 'ctx-monitor'; continue; }
|
|
722
|
+
if (a === '--force' || a === '-f') { flags.force = true; continue; }
|
|
723
|
+
if (a === '--dry-run') { flags.dryRun = true; continue; }
|
|
724
|
+
}
|
|
725
|
+
if (flags.scope && !VALID_SCOPES.includes(flags.scope)) {
|
|
726
|
+
throw new NubosPilotError('invalid-flag',
|
|
727
|
+
'--scope must be one of: ' + VALID_SCOPES.join(', '),
|
|
728
|
+
{ flag: '--scope', got: flags.scope });
|
|
729
|
+
}
|
|
730
|
+
return flags;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function runInstallHooks(opts) {
|
|
734
|
+
const o = opts || {};
|
|
735
|
+
const projectRoot = o.projectRoot || o.cwd || process.cwd();
|
|
736
|
+
const flags = _parseHookFlags(o.args || []);
|
|
737
|
+
const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
|
|
738
|
+
const claudeHooks = require('../lib/install/claude-hooks.cjs');
|
|
739
|
+
const res = claudeHooks.installClaudeHooks({
|
|
740
|
+
projectRoot, scope, which: flags.which, force: flags.force, dryRun: flags.dryRun,
|
|
741
|
+
});
|
|
742
|
+
if (res.dryRun) {
|
|
743
|
+
process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
|
|
744
|
+
return res;
|
|
745
|
+
}
|
|
746
|
+
console.error(green + '✓ Claude Code hooks geschrieben → ' + res.path + reset);
|
|
747
|
+
if (res.results.statusline) {
|
|
748
|
+
console.error(dim + ' statusline: ' + res.results.statusline.action
|
|
749
|
+
+ (res.results.statusline.existingCommand ? ' (existing: ' + res.results.statusline.existingCommand + ')' : '')
|
|
750
|
+
+ reset);
|
|
751
|
+
}
|
|
752
|
+
if (res.results.ctxMonitor) {
|
|
753
|
+
console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
|
|
754
|
+
}
|
|
755
|
+
if (res.results.statusline && res.results.statusline.action === 'skipped-existing') {
|
|
756
|
+
console.error(yellow + ' [statusline] existing non-nubos statusLine preserved. Pass --force to overwrite.' + reset);
|
|
757
|
+
}
|
|
758
|
+
return res;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function runUninstallHooks(opts) {
|
|
762
|
+
const o = opts || {};
|
|
763
|
+
const projectRoot = o.projectRoot || o.cwd || process.cwd();
|
|
764
|
+
const flags = _parseHookFlags(o.args || []);
|
|
765
|
+
const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
|
|
766
|
+
const claudeHooks = require('../lib/install/claude-hooks.cjs');
|
|
767
|
+
const res = claudeHooks.uninstallClaudeHooks({ projectRoot, scope, dryRun: flags.dryRun });
|
|
768
|
+
if (res.dryRun) {
|
|
769
|
+
process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
|
|
770
|
+
return res;
|
|
771
|
+
}
|
|
772
|
+
console.error(green + '✓ Claude Code hooks entfernt ← ' + res.path + reset);
|
|
773
|
+
console.error(dim + ' statusline: ' + res.results.statusline.action + reset);
|
|
774
|
+
console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
|
|
775
|
+
return res;
|
|
776
|
+
}
|
|
777
|
+
|
|
695
778
|
if (require.main === module) {
|
|
696
779
|
main().catch((err) => {
|
|
697
780
|
if (err && err.code) {
|
|
@@ -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' },
|
|
@@ -83,7 +83,7 @@ test('DP-1: run(["3"]) on valid milestone returns JSON payload with expected sha
|
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test('DP-1b: CLAUDECODE=1
|
|
86
|
+
test('DP-1b: CLAUDECODE=1 no longer flips text_mode (Claude Code uses AskUserQuestion)', () => {
|
|
87
87
|
const restore = _clearClaudeEnv();
|
|
88
88
|
try {
|
|
89
89
|
process.env.CLAUDECODE = '1';
|
|
@@ -92,8 +92,8 @@ test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
|
|
|
92
92
|
const cap = _captureStdout();
|
|
93
93
|
subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
|
|
94
94
|
const payload = JSON.parse(cap.get().trim());
|
|
95
|
-
assert.equal(payload.text_mode,
|
|
96
|
-
assert.equal(payload.text_mode_source, '
|
|
95
|
+
assert.equal(payload.text_mode, false);
|
|
96
|
+
assert.equal(payload.text_mode_source, 'default');
|
|
97
97
|
} finally {
|
|
98
98
|
restore();
|
|
99
99
|
}
|
|
@@ -56,7 +56,7 @@ test('text-mode CLI: default without config and without Claude env prints "false
|
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
|
|
59
|
+
test('text-mode CLI: CLAUDECODE=1 no longer prints "true" (AskUserQuestion path)', () => {
|
|
60
60
|
const restore = _clearClaudeEnv();
|
|
61
61
|
try {
|
|
62
62
|
process.env.CLAUDECODE = '1';
|
|
@@ -65,7 +65,7 @@ test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
|
|
|
65
65
|
const io = _captureIO();
|
|
66
66
|
const rc = subcmd.run([], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
|
|
67
67
|
assert.equal(rc, 0);
|
|
68
|
-
assert.equal(io.stdoutText().trim(), '
|
|
68
|
+
assert.equal(io.stdoutText().trim(), 'false');
|
|
69
69
|
} finally {
|
|
70
70
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
71
71
|
}
|
|
@@ -74,18 +74,21 @@ test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('text-mode CLI: --json emits detail object', () => {
|
|
77
|
+
test('text-mode CLI: --json emits detail object with config source', () => {
|
|
78
78
|
const restore = _clearClaudeEnv();
|
|
79
79
|
try {
|
|
80
|
-
process.env.CLAUDECODE = '1';
|
|
81
80
|
const dir = _mkSandbox();
|
|
82
81
|
try {
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(dir, '.nubos-pilot', 'config.json'),
|
|
84
|
+
JSON.stringify({ workflow: { text_mode: true } }),
|
|
85
|
+
);
|
|
83
86
|
const io = _captureIO();
|
|
84
87
|
const rc = subcmd.run(['--json'], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
|
|
85
88
|
assert.equal(rc, 0);
|
|
86
89
|
const payload = JSON.parse(io.stdoutText().trim());
|
|
87
90
|
assert.equal(payload.enabled, true);
|
|
88
|
-
assert.equal(payload.source, '
|
|
91
|
+
assert.equal(payload.source, 'config');
|
|
89
92
|
} finally {
|
|
90
93
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
94
|
}
|
|
@@ -94,7 +97,7 @@ test('text-mode CLI: --json emits detail object', () => {
|
|
|
94
97
|
}
|
|
95
98
|
});
|
|
96
99
|
|
|
97
|
-
test('text-mode CLI: config workflow.text_mode=false
|
|
100
|
+
test('text-mode CLI: config workflow.text_mode=false stays false even with CLAUDECODE', () => {
|
|
98
101
|
const restore = _clearClaudeEnv();
|
|
99
102
|
try {
|
|
100
103
|
process.env.CLAUDECODE = '1';
|
|
@@ -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) {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
|
|
7
|
+
const { atomicWriteFileSync, NubosPilotError } = require('../core.cjs');
|
|
8
|
+
|
|
9
|
+
const STATUSLINE_REL = '.claude/nubos-pilot/hooks/np-statusline.js';
|
|
10
|
+
const CTX_MONITOR_REL = '.claude/nubos-pilot/hooks/np-ctx-monitor.js';
|
|
11
|
+
const NP_STATUSLINE_MARKER = 'np-statusline.js';
|
|
12
|
+
const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.js';
|
|
13
|
+
|
|
14
|
+
function _settingsPath(scope, projectRoot) {
|
|
15
|
+
if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
|
|
16
|
+
return path.join(projectRoot, '.claude', 'settings.local.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _readJsonSafe(p) {
|
|
20
|
+
if (!fs.existsSync(p)) return {};
|
|
21
|
+
let raw;
|
|
22
|
+
try { raw = fs.readFileSync(p, 'utf-8'); } catch { return {}; }
|
|
23
|
+
try { return JSON.parse(raw); } catch (err) {
|
|
24
|
+
throw new NubosPilotError(
|
|
25
|
+
'claude-settings-invalid-json',
|
|
26
|
+
'Cannot parse Claude settings: ' + p + ' — ' + err.message,
|
|
27
|
+
{ path: p },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _hookCommand(rel, scope, projectRoot) {
|
|
33
|
+
if (scope === 'global') {
|
|
34
|
+
return 'node "' + path.join('$HOME', rel) + '"';
|
|
35
|
+
}
|
|
36
|
+
return 'node "' + path.join(projectRoot, rel) + '"';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _containsNpHook(entry, marker) {
|
|
40
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
41
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
42
|
+
for (const h of hooks) {
|
|
43
|
+
if (h && typeof h.command === 'string' && h.command.includes(marker)) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _installStatusLine(settings, cmd, force) {
|
|
49
|
+
const existing = settings.statusLine;
|
|
50
|
+
if (existing && typeof existing === 'object' && existing.command) {
|
|
51
|
+
if (String(existing.command).includes(NP_STATUSLINE_MARKER)) {
|
|
52
|
+
settings.statusLine = { type: 'command', command: cmd };
|
|
53
|
+
return { action: 'updated', existed: true };
|
|
54
|
+
}
|
|
55
|
+
if (!force) {
|
|
56
|
+
return { action: 'skipped-existing', existed: true, existingCommand: existing.command };
|
|
57
|
+
}
|
|
58
|
+
settings.statusLine = { type: 'command', command: cmd };
|
|
59
|
+
return { action: 'overwrote', existed: true };
|
|
60
|
+
}
|
|
61
|
+
settings.statusLine = { type: 'command', command: cmd };
|
|
62
|
+
return { action: 'installed', existed: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _installPostToolUse(settings, cmd) {
|
|
66
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
67
|
+
if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
|
|
68
|
+
const list = settings.hooks.PostToolUse;
|
|
69
|
+
for (const entry of list) {
|
|
70
|
+
if (_containsNpHook(entry, NP_CTX_MONITOR_MARKER)) {
|
|
71
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
72
|
+
for (const h of hooks) {
|
|
73
|
+
if (h && typeof h.command === 'string' && h.command.includes(NP_CTX_MONITOR_MARKER)) {
|
|
74
|
+
h.command = cmd;
|
|
75
|
+
h.type = 'command';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { action: 'updated' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
list.push({
|
|
82
|
+
matcher: '.*',
|
|
83
|
+
hooks: [{ type: 'command', command: cmd }],
|
|
84
|
+
});
|
|
85
|
+
return { action: 'installed' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _removeStatusLine(settings) {
|
|
89
|
+
const existing = settings.statusLine;
|
|
90
|
+
if (existing && typeof existing === 'object'
|
|
91
|
+
&& typeof existing.command === 'string'
|
|
92
|
+
&& existing.command.includes(NP_STATUSLINE_MARKER)) {
|
|
93
|
+
delete settings.statusLine;
|
|
94
|
+
return { action: 'removed' };
|
|
95
|
+
}
|
|
96
|
+
return { action: 'not-ours' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _removePostToolUse(settings) {
|
|
100
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.PostToolUse)) return { action: 'absent' };
|
|
101
|
+
const filtered = [];
|
|
102
|
+
for (const entry of settings.hooks.PostToolUse) {
|
|
103
|
+
if (_containsNpHook(entry, NP_CTX_MONITOR_MARKER)) {
|
|
104
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
105
|
+
const keptHooks = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_CTX_MONITOR_MARKER)));
|
|
106
|
+
if (keptHooks.length > 0) {
|
|
107
|
+
filtered.push(Object.assign({}, entry, { hooks: keptHooks }));
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
filtered.push(entry);
|
|
112
|
+
}
|
|
113
|
+
settings.hooks.PostToolUse = filtered;
|
|
114
|
+
if (filtered.length === 0) delete settings.hooks.PostToolUse;
|
|
115
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
116
|
+
return { action: 'removed' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function installClaudeHooks(opts) {
|
|
120
|
+
const o = opts || {};
|
|
121
|
+
const projectRoot = o.projectRoot || process.cwd();
|
|
122
|
+
const scope = o.scope === 'global' ? 'global' : 'local';
|
|
123
|
+
const force = !!o.force;
|
|
124
|
+
const which = o.which || 'both';
|
|
125
|
+
const settingsPath = _settingsPath(scope, projectRoot);
|
|
126
|
+
|
|
127
|
+
const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
|
|
128
|
+
const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
|
|
129
|
+
|
|
130
|
+
const statuslineAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, STATUSLINE_REL);
|
|
131
|
+
const ctxMonitorAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, CTX_MONITOR_REL);
|
|
132
|
+
|
|
133
|
+
if (which === 'statusline' || which === 'both') {
|
|
134
|
+
if (!fs.existsSync(statuslineAbs)) {
|
|
135
|
+
throw new NubosPilotError(
|
|
136
|
+
'claude-hooks-script-missing',
|
|
137
|
+
'Statusline hook script not found: ' + statuslineAbs + '. Run `npx nubos-pilot` install first.',
|
|
138
|
+
{ script: statuslineAbs },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (which === 'ctx-monitor' || which === 'both') {
|
|
143
|
+
if (!fs.existsSync(ctxMonitorAbs)) {
|
|
144
|
+
throw new NubosPilotError(
|
|
145
|
+
'claude-hooks-script-missing',
|
|
146
|
+
'Ctx-monitor hook script not found: ' + ctxMonitorAbs,
|
|
147
|
+
{ script: ctxMonitorAbs },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const settings = _readJsonSafe(settingsPath);
|
|
153
|
+
const results = {};
|
|
154
|
+
|
|
155
|
+
if (which === 'statusline' || which === 'both') {
|
|
156
|
+
results.statusline = _installStatusLine(settings, statuslineCmd, force);
|
|
157
|
+
}
|
|
158
|
+
if (which === 'ctx-monitor' || which === 'both') {
|
|
159
|
+
results.ctxMonitor = _installPostToolUse(settings, ctxMonitorCmd);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
163
|
+
|
|
164
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
165
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
166
|
+
return { dryRun: false, path: settingsPath, results };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function uninstallClaudeHooks(opts) {
|
|
170
|
+
const o = opts || {};
|
|
171
|
+
const projectRoot = o.projectRoot || process.cwd();
|
|
172
|
+
const scope = o.scope === 'global' ? 'global' : 'local';
|
|
173
|
+
const settingsPath = _settingsPath(scope, projectRoot);
|
|
174
|
+
if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' } } };
|
|
175
|
+
|
|
176
|
+
const settings = _readJsonSafe(settingsPath);
|
|
177
|
+
const results = {
|
|
178
|
+
statusline: _removeStatusLine(settings),
|
|
179
|
+
ctxMonitor: _removePostToolUse(settings),
|
|
180
|
+
};
|
|
181
|
+
if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
|
|
182
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
183
|
+
return { dryRun: false, path: settingsPath, results };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
installClaudeHooks,
|
|
188
|
+
uninstallClaudeHooks,
|
|
189
|
+
STATUSLINE_REL,
|
|
190
|
+
CTX_MONITOR_REL,
|
|
191
|
+
NP_STATUSLINE_MARKER,
|
|
192
|
+
NP_CTX_MONITOR_MARKER,
|
|
193
|
+
_settingsPath,
|
|
194
|
+
_hookCommand,
|
|
195
|
+
};
|