nubos-pilot 0.6.2 → 0.6.3
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-nyquist-auditor.md +1 -1
- package/agents/np-sc-extractor.md +1 -1
- package/bin/np-tools/_commands.cjs +8 -1
- package/bin/np-tools/phase-meta.cjs +102 -0
- package/bin/np-tools/phase-meta.test.cjs +134 -0
- package/bin/np-tools/render-template.cjs +56 -0
- package/bin/np-tools/render-template.test.cjs +113 -0
- package/bin/np-tools/research-phase.cjs +27 -5
- package/bin/np-tools/research-phase.test.cjs +53 -4
- package/bin/np-tools/session-aggregate.cjs +56 -0
- package/bin/np-tools/session-aggregate.test.cjs +120 -0
- package/bin/np-tools/session-pointer-write.cjs +55 -0
- package/bin/np-tools/session-pointer-write.test.cjs +62 -0
- package/bin/np-tools/state-dir.cjs +47 -0
- package/bin/np-tools/state-dir.test.cjs +72 -0
- package/bin/np-tools/state-incr.cjs +43 -0
- package/bin/np-tools/state-incr.test.cjs +100 -0
- package/bin/np-tools/thread-resume.cjs +83 -0
- package/bin/np-tools/thread-resume.test.cjs +134 -0
- package/bin/np-tools/workspace-scan.cjs +49 -0
- package/bin/np-tools/workspace-scan.test.cjs +77 -0
- package/lib/config-defaults.cjs +8 -1
- package/lib/roadmap-render.cjs +2 -12
- package/lib/roadmap.cjs +2 -12
- package/np-tools.cjs +8 -0
- package/package.json +1 -1
- package/templates/claude/payload/README.md +0 -2
- package/workflows/add-todo.md +1 -1
- package/workflows/discuss-phase.md +3 -19
- package/workflows/new-project.md +1 -14
- package/workflows/note.md +1 -1
- package/workflows/session-report.md +7 -24
- package/workflows/thread.md +3 -27
|
@@ -45,7 +45,7 @@ Before auditing, load:
|
|
|
45
45
|
<execution_flow>
|
|
46
46
|
|
|
47
47
|
<step name="load_requirements">
|
|
48
|
-
Filter `.
|
|
48
|
+
Filter `.nubos-pilot/REQUIREMENTS.md` to the phase's `requirements[]` list supplied in input.
|
|
49
49
|
|
|
50
50
|
Also extract requirement-ID references from each slice's `S<NNN>-PLAN.md` and each task's `T<NNNN>-PLAN.md` frontmatter `requirements:` + `must_haves:` blocks — they often imply requirement coverage without explicit REQ-ID mapping; capture those as additional observation targets.
|
|
51
51
|
|
|
@@ -17,7 +17,7 @@ You do NOT interview the user. You do NOT edit code. You do NOT re-open scope de
|
|
|
17
17
|
- `goal`: the milestone's goal string (from `roadmap.yaml`)
|
|
18
18
|
- `requirements`: array of REQ-IDs in scope (from `roadmap.yaml`)
|
|
19
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`
|
|
20
|
+
- `requirements_path`: path to `.nubos-pilot/REQUIREMENTS.md`
|
|
21
21
|
- `existing_success_criteria`: current `success_criteria[]` from roadmap.yaml (may be empty)
|
|
22
22
|
</input>
|
|
23
23
|
|
|
@@ -51,9 +51,16 @@ const COMMANDS = [
|
|
|
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
53
|
{ name: 'update-phase-meta', category: 'Planning', description: 'Update roadmap.yaml phase fields (name/goal/requirements/success_criteria) via JSON patch' },
|
|
54
|
+
{ name: 'phase-meta', category: 'Planning', description: 'Read roadmap.yaml phase fields as JSON (supports --field NAME and --length for arrays)' },
|
|
55
|
+
{ name: 'state-dir', category: 'Utility', description: 'Print project-state directory (.nubos-pilot) or a validated subdir via --subdir NAME' },
|
|
56
|
+
{ name: 'render-template', category: 'Utility', description: 'Render a shipped template by name with --vars JSON (or --vars-file PATH)' },
|
|
57
|
+
{ name: 'thread-resume', category: 'Utility', description: 'Bump a thread markdown on resume (status OPEN→IN_PROGRESS, refresh last_resumed) via atomic write' },
|
|
58
|
+
{ name: 'state-incr', category: 'Capture', description: 'Increment a whitelisted STATE.md counter (e.g. pending_todos) under withFileLock' },
|
|
54
59
|
|
|
55
60
|
{ name: 'thread', category: 'Utility', description: 'Cross-session thread CRUD (create/resume under .nubos-pilot/threads/)' },
|
|
56
|
-
{ name: 'session-
|
|
61
|
+
{ name: 'session-aggregate', category: 'Utility', description: 'Aggregate session metrics under withFileLock; reads pointer .last-session unless --since overrides' },
|
|
62
|
+
{ name: 'session-pointer-write', category: 'Utility', description: 'Atomic write of .nubos-pilot/reports/.last-session under withFileLock (ISO-8601 UTC)' },
|
|
63
|
+
{ name: 'workspace-scan', category: 'Install', description: 'Scan a workspace and emit inventory JSON (full result or --summary shape for /np:new-project)' },
|
|
57
64
|
{ name: 'cleanup', category: 'Utility', description: 'Archive completed milestones to .nubos-pilot/archive/v<X.Y>/' },
|
|
58
65
|
];
|
|
59
66
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const roadmap = require('../../lib/roadmap.cjs');
|
|
5
|
+
|
|
6
|
+
const ALLOWED_FIELDS = new Set([
|
|
7
|
+
'number',
|
|
8
|
+
'id',
|
|
9
|
+
'name',
|
|
10
|
+
'goal',
|
|
11
|
+
'requirements',
|
|
12
|
+
'success_criteria',
|
|
13
|
+
'depends_on',
|
|
14
|
+
'status',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function _parseArgs(args) {
|
|
18
|
+
const out = { milestone: null, field: null, length: false };
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
const a = args[i];
|
|
21
|
+
if (!a.startsWith('-')) {
|
|
22
|
+
if (out.milestone == null) out.milestone = a;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (a === '--field' || a === '-f') { out.field = args[++i] || null; continue; }
|
|
26
|
+
if (a === '--length' || a === '-l') { out.length = true; continue; }
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _validateMilestone(raw) {
|
|
32
|
+
if (raw == null) {
|
|
33
|
+
throw new NubosPilotError('phase-meta-missing-milestone',
|
|
34
|
+
'milestone number required (e.g. M002 or 2)', {});
|
|
35
|
+
}
|
|
36
|
+
const s = String(raw).trim();
|
|
37
|
+
const m = s.match(/^M?(\d+(?:\.\d+)?)$/i);
|
|
38
|
+
if (!m) {
|
|
39
|
+
throw new NubosPilotError('phase-meta-invalid-milestone',
|
|
40
|
+
'milestone must be M<NNN> or <number>', { milestone: raw });
|
|
41
|
+
}
|
|
42
|
+
return m[1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _project(phase) {
|
|
46
|
+
return {
|
|
47
|
+
number: phase.number,
|
|
48
|
+
id: phase.id || null,
|
|
49
|
+
name: phase.name || '',
|
|
50
|
+
goal: phase.goal || '',
|
|
51
|
+
requirements: Array.isArray(phase.requirements) ? phase.requirements : [],
|
|
52
|
+
success_criteria: Array.isArray(phase.success_criteria) ? phase.success_criteria : [],
|
|
53
|
+
depends_on: phase.depends_on || null,
|
|
54
|
+
status: phase.status || null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function run(args, opts) {
|
|
59
|
+
const o = opts || {};
|
|
60
|
+
const cwd = o.cwd || process.cwd();
|
|
61
|
+
const stdout = o.stdout || process.stdout;
|
|
62
|
+
const parsed = _parseArgs(args || []);
|
|
63
|
+
const mNum = _validateMilestone(parsed.milestone);
|
|
64
|
+
|
|
65
|
+
let def;
|
|
66
|
+
try {
|
|
67
|
+
def = roadmap.getPhase(mNum, cwd);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err && err.code === 'phase-not-found') {
|
|
70
|
+
throw new NubosPilotError('phase-meta-not-found',
|
|
71
|
+
'Milestone ' + mNum + ' not found in roadmap.yaml', { milestone: mNum });
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const projected = _project(def);
|
|
77
|
+
|
|
78
|
+
if (parsed.field) {
|
|
79
|
+
if (!ALLOWED_FIELDS.has(parsed.field)) {
|
|
80
|
+
throw new NubosPilotError('phase-meta-unknown-field',
|
|
81
|
+
'unknown field: ' + parsed.field,
|
|
82
|
+
{ field: parsed.field, allowed: Array.from(ALLOWED_FIELDS) });
|
|
83
|
+
}
|
|
84
|
+
const value = projected[parsed.field];
|
|
85
|
+
if (parsed.length) {
|
|
86
|
+
if (!Array.isArray(value)) {
|
|
87
|
+
throw new NubosPilotError('phase-meta-length-non-array',
|
|
88
|
+
'--length requires an array field; ' + parsed.field + ' is not an array',
|
|
89
|
+
{ field: parsed.field });
|
|
90
|
+
}
|
|
91
|
+
stdout.write(String(value.length));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
stdout.write(JSON.stringify(value));
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stdout.write(JSON.stringify(projected));
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { run, _parseArgs, _validateMilestone, ALLOWED_FIELDS };
|
|
@@ -0,0 +1,134 @@
|
|
|
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('./phase-meta.cjs');
|
|
10
|
+
|
|
11
|
+
const SEED = [
|
|
12
|
+
'schema_version: 1',
|
|
13
|
+
'milestones:',
|
|
14
|
+
' - id: M001',
|
|
15
|
+
' number: 1',
|
|
16
|
+
' name: First',
|
|
17
|
+
' goal: hello',
|
|
18
|
+
' status: pending',
|
|
19
|
+
' requirements: [UTIL-01, UTIL-02]',
|
|
20
|
+
' success_criteria:',
|
|
21
|
+
' - {id: SC-1, text: logs in}',
|
|
22
|
+
' - {id: SC-2, text: logs out}',
|
|
23
|
+
' slices: []',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
function mkSandbox() {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-pm-'));
|
|
29
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
30
|
+
fs.writeFileSync(path.join(dir, '.nubos-pilot', 'roadmap.yaml'), SEED);
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function captureStdout() {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
return {
|
|
37
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
38
|
+
read: () => chunks.join(''),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('PM-1: _validateMilestone accepts M002, 2, and 2.1', () => {
|
|
43
|
+
assert.equal(mod._validateMilestone('M002'), '002');
|
|
44
|
+
assert.equal(mod._validateMilestone('2'), '2');
|
|
45
|
+
assert.equal(mod._validateMilestone('2.1'), '2.1');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('PM-2: _validateMilestone rejects garbage', () => {
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => mod._validateMilestone('bogus'),
|
|
51
|
+
(err) => err.code === 'phase-meta-invalid-milestone',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('PM-3: run() without --field emits full projected JSON', () => {
|
|
56
|
+
const dir = mkSandbox();
|
|
57
|
+
try {
|
|
58
|
+
const cap = captureStdout();
|
|
59
|
+
const rc = mod.run(['1'], { cwd: dir, stdout: cap.stream });
|
|
60
|
+
assert.equal(rc, 0);
|
|
61
|
+
const out = JSON.parse(cap.read());
|
|
62
|
+
assert.equal(out.name, 'First');
|
|
63
|
+
assert.equal(out.goal, 'hello');
|
|
64
|
+
assert.deepEqual(out.requirements, ['UTIL-01', 'UTIL-02']);
|
|
65
|
+
assert.equal(out.success_criteria.length, 2);
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('PM-4: --field success_criteria emits just that array as JSON', () => {
|
|
72
|
+
const dir = mkSandbox();
|
|
73
|
+
try {
|
|
74
|
+
const cap = captureStdout();
|
|
75
|
+
const rc = mod.run(['1', '--field', 'success_criteria'], { cwd: dir, stdout: cap.stream });
|
|
76
|
+
assert.equal(rc, 0);
|
|
77
|
+
const arr = JSON.parse(cap.read());
|
|
78
|
+
assert.equal(arr.length, 2);
|
|
79
|
+
assert.equal(arr[0].id, 'SC-1');
|
|
80
|
+
} finally {
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('PM-5: --field success_criteria --length emits count', () => {
|
|
86
|
+
const dir = mkSandbox();
|
|
87
|
+
try {
|
|
88
|
+
const cap = captureStdout();
|
|
89
|
+
const rc = mod.run(['1', '--field', 'success_criteria', '--length'], { cwd: dir, stdout: cap.stream });
|
|
90
|
+
assert.equal(rc, 0);
|
|
91
|
+
assert.equal(cap.read(), '2');
|
|
92
|
+
} finally {
|
|
93
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('PM-6: --length on non-array field throws', () => {
|
|
98
|
+
const dir = mkSandbox();
|
|
99
|
+
try {
|
|
100
|
+
const cap = captureStdout();
|
|
101
|
+
assert.throws(
|
|
102
|
+
() => mod.run(['1', '--field', 'name', '--length'], { cwd: dir, stdout: cap.stream }),
|
|
103
|
+
(err) => err.code === 'phase-meta-length-non-array',
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('PM-7: unknown field throws', () => {
|
|
111
|
+
const dir = mkSandbox();
|
|
112
|
+
try {
|
|
113
|
+
const cap = captureStdout();
|
|
114
|
+
assert.throws(
|
|
115
|
+
() => mod.run(['1', '--field', 'bogus'], { cwd: dir, stdout: cap.stream }),
|
|
116
|
+
(err) => err.code === 'phase-meta-unknown-field',
|
|
117
|
+
);
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('PM-8: missing milestone in roadmap throws phase-meta-not-found', () => {
|
|
124
|
+
const dir = mkSandbox();
|
|
125
|
+
try {
|
|
126
|
+
const cap = captureStdout();
|
|
127
|
+
assert.throws(
|
|
128
|
+
() => mod.run(['99'], { cwd: dir, stdout: cap.stream }),
|
|
129
|
+
(err) => err.code === 'phase-meta-not-found',
|
|
130
|
+
);
|
|
131
|
+
} finally {
|
|
132
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const { loadTemplate } = require('../../lib/template.cjs');
|
|
5
|
+
|
|
6
|
+
function _parseArgs(args) {
|
|
7
|
+
const out = { name: null, varsJson: null, varsFile: null };
|
|
8
|
+
const rest = [];
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const a = args[i];
|
|
11
|
+
if (!a.startsWith('-')) { rest.push(a); continue; }
|
|
12
|
+
if (a === '--vars' || a === '-v') { out.varsJson = args[++i] || null; continue; }
|
|
13
|
+
if (a === '--vars-file' || a === '-V') { out.varsFile = args[++i] || null; continue; }
|
|
14
|
+
}
|
|
15
|
+
if (rest.length) out.name = rest[0];
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _readVars(parsed) {
|
|
20
|
+
let raw = parsed.varsJson;
|
|
21
|
+
if (!raw && parsed.varsFile) {
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
raw = fs.readFileSync(parsed.varsFile, 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
if (!raw) {
|
|
26
|
+
throw new NubosPilotError('render-template-missing-vars',
|
|
27
|
+
'vars JSON required (via --vars or --vars-file)', {});
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const obj = JSON.parse(raw);
|
|
31
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
32
|
+
throw new Error('vars must be a JSON object');
|
|
33
|
+
}
|
|
34
|
+
return obj;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new NubosPilotError('render-template-invalid-vars',
|
|
37
|
+
'invalid vars JSON: ' + err.message, { cause: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function run(args, opts) {
|
|
42
|
+
const o = opts || {};
|
|
43
|
+
const cwd = o.cwd || process.cwd();
|
|
44
|
+
const stdout = o.stdout || process.stdout;
|
|
45
|
+
const parsed = _parseArgs(args || []);
|
|
46
|
+
if (!parsed.name) {
|
|
47
|
+
throw new NubosPilotError('render-template-missing-name',
|
|
48
|
+
'template name required (e.g. milestone/CONTEXT)', {});
|
|
49
|
+
}
|
|
50
|
+
const vars = _readVars(parsed);
|
|
51
|
+
const rendered = loadTemplate(parsed.name, vars, cwd);
|
|
52
|
+
stdout.write(rendered);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { run, _parseArgs, _readVars };
|
|
@@ -0,0 +1,113 @@
|
|
|
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('./render-template.cjs');
|
|
10
|
+
|
|
11
|
+
function mkSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-rt-'));
|
|
13
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
14
|
+
const tplDir = path.join(dir, '.nubos-pilot', 'templates', 'milestone');
|
|
15
|
+
fs.mkdirSync(tplDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(tplDir, 'CONTEXT.md'), '# {{title}}\n\nGoal: {{goal}}\n');
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function captureStdout() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
return {
|
|
23
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
24
|
+
read: () => chunks.join(''),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('RT-1: renders template with --vars', () => {
|
|
29
|
+
const dir = mkSandbox();
|
|
30
|
+
try {
|
|
31
|
+
const cap = captureStdout();
|
|
32
|
+
const rc = mod.run([
|
|
33
|
+
'milestone/CONTEXT',
|
|
34
|
+
'--vars', JSON.stringify({ title: 'Auth', goal: 'Log in works' }),
|
|
35
|
+
], { cwd: dir, stdout: cap.stream });
|
|
36
|
+
assert.equal(rc, 0);
|
|
37
|
+
assert.equal(cap.read(), '# Auth\n\nGoal: Log in works\n');
|
|
38
|
+
} finally {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('RT-2: --vars-file also works', () => {
|
|
44
|
+
const dir = mkSandbox();
|
|
45
|
+
try {
|
|
46
|
+
const varsPath = path.join(dir, 'vars.json');
|
|
47
|
+
fs.writeFileSync(varsPath, JSON.stringify({ title: 'X', goal: 'Y' }));
|
|
48
|
+
const cap = captureStdout();
|
|
49
|
+
const rc = mod.run([
|
|
50
|
+
'milestone/CONTEXT',
|
|
51
|
+
'--vars-file', varsPath,
|
|
52
|
+
], { cwd: dir, stdout: cap.stream });
|
|
53
|
+
assert.equal(rc, 0);
|
|
54
|
+
assert.equal(cap.read(), '# X\n\nGoal: Y\n');
|
|
55
|
+
} finally {
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('RT-3: missing name throws', () => {
|
|
61
|
+
assert.throws(
|
|
62
|
+
() => mod.run(['--vars', '{}'], { cwd: '/tmp', stdout: { write: () => {} } }),
|
|
63
|
+
(err) => err.code === 'render-template-missing-name',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('RT-4: missing vars throws', () => {
|
|
68
|
+
const dir = mkSandbox();
|
|
69
|
+
try {
|
|
70
|
+
assert.throws(
|
|
71
|
+
() => mod.run(['milestone/CONTEXT'], { cwd: dir, stdout: { write: () => {} } }),
|
|
72
|
+
(err) => err.code === 'render-template-missing-vars',
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('RT-5: invalid JSON vars throws', () => {
|
|
80
|
+
const dir = mkSandbox();
|
|
81
|
+
try {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => mod.run(['milestone/CONTEXT', '--vars', '{broken'], { cwd: dir, stdout: { write: () => {} } }),
|
|
84
|
+
(err) => err.code === 'render-template-invalid-vars',
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('RT-6: non-object vars (array) rejected', () => {
|
|
92
|
+
const dir = mkSandbox();
|
|
93
|
+
try {
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => mod.run(['milestone/CONTEXT', '--vars', '[]'], { cwd: dir, stdout: { write: () => {} } }),
|
|
96
|
+
(err) => err.code === 'render-template-invalid-vars',
|
|
97
|
+
);
|
|
98
|
+
} finally {
|
|
99
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('RT-7: missing placeholder in vars throws template-unresolved-var', () => {
|
|
104
|
+
const dir = mkSandbox();
|
|
105
|
+
try {
|
|
106
|
+
assert.throws(
|
|
107
|
+
() => mod.run(['milestone/CONTEXT', '--vars', '{"title":"X"}'], { cwd: dir, stdout: { write: () => {} } }),
|
|
108
|
+
(err) => err.code === 'template-unresolved-var',
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
@@ -8,6 +8,7 @@ const crypto = require('node:crypto');
|
|
|
8
8
|
const { NubosPilotError, projectStateDir } = require('../../lib/core.cjs');
|
|
9
9
|
const layout = require('../../lib/layout.cjs');
|
|
10
10
|
const textMode = require('../../lib/text-mode.cjs');
|
|
11
|
+
const { DEFAULT_RESEARCH_TOOLS } = require('../../lib/config-defaults.cjs');
|
|
11
12
|
|
|
12
13
|
const INLINE_THRESHOLD_BYTES = 16 * 1024;
|
|
13
14
|
|
|
@@ -46,10 +47,31 @@ function _readMilestoneDef(cwd, mNum) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function
|
|
50
|
+
function _readConfigResearchTools(cwd) {
|
|
51
|
+
try {
|
|
52
|
+
const configPath = path.join(projectStateDir(cwd), 'config.json');
|
|
53
|
+
if (!fs.existsSync(configPath)) return {};
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
55
|
+
const wf = parsed && parsed.workflow;
|
|
56
|
+
const rt = wf && wf.research_tools;
|
|
57
|
+
return rt && typeof rt === 'object' ? rt : {};
|
|
58
|
+
} catch (_err) {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _resolveToolFlag(envValue, configValue, defaultValue) {
|
|
64
|
+
if (envValue === '1' || envValue === 'true') return true;
|
|
65
|
+
if (envValue === '0' || envValue === 'false') return false;
|
|
66
|
+
if (typeof configValue === 'boolean') return configValue;
|
|
67
|
+
return defaultValue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _toolsAvailable(cwd) {
|
|
71
|
+
const cfg = _readConfigResearchTools(cwd);
|
|
50
72
|
return {
|
|
51
|
-
WebFetch: process.env.NP_TOOLS_WEBFETCH
|
|
52
|
-
Context7: process.env.NP_TOOLS_CONTEXT7
|
|
73
|
+
WebFetch: _resolveToolFlag(process.env.NP_TOOLS_WEBFETCH, cfg.WebFetch, DEFAULT_RESEARCH_TOOLS.WebFetch),
|
|
74
|
+
Context7: _resolveToolFlag(process.env.NP_TOOLS_CONTEXT7, cfg.Context7, DEFAULT_RESEARCH_TOOLS.Context7),
|
|
53
75
|
};
|
|
54
76
|
}
|
|
55
77
|
|
|
@@ -120,7 +142,7 @@ function run(args, ctx) {
|
|
|
120
142
|
requirements: Array.isArray(def.requirements) ? def.requirements.slice() : [],
|
|
121
143
|
has_research,
|
|
122
144
|
slice_research: sliceResearch,
|
|
123
|
-
tools_available: _toolsAvailable(),
|
|
145
|
+
tools_available: _toolsAvailable(cwd),
|
|
124
146
|
text_mode: tmDetail.enabled,
|
|
125
147
|
text_mode_source: tmDetail.source,
|
|
126
148
|
agent_skills: _agentSkills(cwd),
|
|
@@ -129,4 +151,4 @@ function run(args, ctx) {
|
|
|
129
151
|
return payload;
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
module.exports = { run, INLINE_THRESHOLD_BYTES, _parseMilestoneArg };
|
|
154
|
+
module.exports = { run, INLINE_THRESHOLD_BYTES, _parseMilestoneArg, _toolsAvailable, _resolveToolFlag };
|
|
@@ -82,18 +82,18 @@ test('RP-2: has_research=true iff {milestone_dir}/{milestone_id}-RESEARCH.md exi
|
|
|
82
82
|
assert.equal(payload.has_research, true);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
test('RP-3: tools_available defaults to {
|
|
85
|
+
test('RP-3: tools_available defaults to {true,true} when env vars + config absent (optimistic default)', () => {
|
|
86
86
|
const sandbox = makeSandbox();
|
|
87
87
|
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
88
88
|
_clearEnv();
|
|
89
89
|
const cap = _captureStdout();
|
|
90
90
|
subcmd.run(['5'], { cwd: sandbox, stdout: cap.stub });
|
|
91
91
|
const payload = JSON.parse(cap.get().trim());
|
|
92
|
-
assert.equal(payload.tools_available.WebFetch,
|
|
93
|
-
assert.equal(payload.tools_available.Context7,
|
|
92
|
+
assert.equal(payload.tools_available.WebFetch, true);
|
|
93
|
+
assert.equal(payload.tools_available.Context7, true);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
test('RP-4: NP_TOOLS_WEBFETCH=1 and NP_TOOLS_CONTEXT7=1
|
|
96
|
+
test('RP-4: NP_TOOLS_WEBFETCH=1 and NP_TOOLS_CONTEXT7=1 keep both true', () => {
|
|
97
97
|
const sandbox = makeSandbox();
|
|
98
98
|
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
99
99
|
process.env.NP_TOOLS_WEBFETCH = '1';
|
|
@@ -105,6 +105,55 @@ test('RP-4: NP_TOOLS_WEBFETCH=1 and NP_TOOLS_CONTEXT7=1 flip both booleans', ()
|
|
|
105
105
|
assert.equal(payload.tools_available.Context7, true);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test('RP-4b: NP_TOOLS_WEBFETCH=0 and NP_TOOLS_CONTEXT7=0 flip both booleans to false', () => {
|
|
109
|
+
const sandbox = makeSandbox();
|
|
110
|
+
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
111
|
+
process.env.NP_TOOLS_WEBFETCH = '0';
|
|
112
|
+
process.env.NP_TOOLS_CONTEXT7 = '0';
|
|
113
|
+
const cap = _captureStdout();
|
|
114
|
+
subcmd.run(['5'], { cwd: sandbox, stdout: cap.stub });
|
|
115
|
+
const payload = JSON.parse(cap.get().trim());
|
|
116
|
+
assert.equal(payload.tools_available.WebFetch, false);
|
|
117
|
+
assert.equal(payload.tools_available.Context7, false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('RP-4c: config.workflow.research_tools overrides default when env absent', () => {
|
|
121
|
+
const sandbox = makeSandbox();
|
|
122
|
+
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
123
|
+
const configPath = path.join(sandbox, '.nubos-pilot', 'config.json');
|
|
124
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
125
|
+
workflow: { research_tools: { WebFetch: false, Context7: true } },
|
|
126
|
+
}));
|
|
127
|
+
_clearEnv();
|
|
128
|
+
const cap = _captureStdout();
|
|
129
|
+
subcmd.run(['5'], { cwd: sandbox, stdout: cap.stub });
|
|
130
|
+
const payload = JSON.parse(cap.get().trim());
|
|
131
|
+
assert.equal(payload.tools_available.WebFetch, false);
|
|
132
|
+
assert.equal(payload.tools_available.Context7, true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('RP-4d: env var wins over config (env=1 overrides config=false)', () => {
|
|
136
|
+
const sandbox = makeSandbox();
|
|
137
|
+
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
138
|
+
const configPath = path.join(sandbox, '.nubos-pilot', 'config.json');
|
|
139
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
140
|
+
workflow: { research_tools: { WebFetch: false, Context7: false } },
|
|
141
|
+
}));
|
|
142
|
+
process.env.NP_TOOLS_WEBFETCH = '1';
|
|
143
|
+
const cap = _captureStdout();
|
|
144
|
+
subcmd.run(['5'], { cwd: sandbox, stdout: cap.stub });
|
|
145
|
+
const payload = JSON.parse(cap.get().trim());
|
|
146
|
+
assert.equal(payload.tools_available.WebFetch, true);
|
|
147
|
+
assert.equal(payload.tools_available.Context7, false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('RP-4e: _resolveToolFlag: "true"/"false" strings handled', () => {
|
|
151
|
+
assert.equal(subcmd._resolveToolFlag('true', false, false), true);
|
|
152
|
+
assert.equal(subcmd._resolveToolFlag('false', true, true), false);
|
|
153
|
+
assert.equal(subcmd._resolveToolFlag(undefined, undefined, true), true);
|
|
154
|
+
assert.equal(subcmd._resolveToolFlag(undefined, false, true), false);
|
|
155
|
+
});
|
|
156
|
+
|
|
108
157
|
test('RP-5: missing phase number throws research-phase-not-found', () => {
|
|
109
158
|
const sandbox = makeSandbox();
|
|
110
159
|
seedRoadmapYaml(sandbox, _baseRoadmap());
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const {
|
|
6
|
+
NubosPilotError,
|
|
7
|
+
projectStateDir,
|
|
8
|
+
withFileLock,
|
|
9
|
+
} = require('../../lib/core.cjs');
|
|
10
|
+
const { aggregateSession } = require('../../lib/metrics-aggregate.cjs');
|
|
11
|
+
|
|
12
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
13
|
+
|
|
14
|
+
function _parseArgs(args) {
|
|
15
|
+
const out = { since: null };
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const a = args[i];
|
|
18
|
+
if (a === '--since' || a === '-s') { out.since = args[++i] || null; continue; }
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _pointerPath(cwd) {
|
|
24
|
+
return path.join(projectStateDir(cwd), 'reports', '.last-session');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function run(args, opts) {
|
|
28
|
+
const o = opts || {};
|
|
29
|
+
const cwd = o.cwd || process.cwd();
|
|
30
|
+
const stdout = o.stdout || process.stdout;
|
|
31
|
+
const parsed = _parseArgs(args || []);
|
|
32
|
+
|
|
33
|
+
const pointer = _pointerPath(cwd);
|
|
34
|
+
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
35
|
+
|
|
36
|
+
let summary;
|
|
37
|
+
try {
|
|
38
|
+
summary = await withFileLock(pointer, async () => {
|
|
39
|
+
let since = parsed.since || '';
|
|
40
|
+
if (!since && fs.existsSync(pointer)) {
|
|
41
|
+
since = fs.readFileSync(pointer, 'utf-8').trim();
|
|
42
|
+
}
|
|
43
|
+
return aggregateSession(since || null, { cwd });
|
|
44
|
+
}, { timeoutMs: LOCK_TIMEOUT_MS });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err && err.name === 'NubosPilotError') throw err;
|
|
47
|
+
throw new NubosPilotError('session-aggregate-failed',
|
|
48
|
+
'aggregate failed: ' + (err && err.message),
|
|
49
|
+
{ cause: err && err.message });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stdout.write(JSON.stringify(summary));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { run, _parseArgs, _pointerPath, LOCK_TIMEOUT_MS };
|