nubos-pilot 0.5.4 → 0.5.6
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-plan-checker.md +1 -0
- package/agents/np-planner.md +14 -3
- package/bin/install.js +6 -0
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/commit-task.cjs +14 -4
- package/bin/np-tools/commit-task.test.cjs +37 -0
- package/bin/np-tools/detect-runtime.cjs +24 -0
- package/bin/np-tools/detect-runtime.test.cjs +47 -0
- package/bin/np-tools/plan-milestone.cjs +69 -3
- package/bin/np-tools/plan-milestone.test.cjs +146 -0
- package/bin/np-tools/stats.cjs +77 -5
- package/bin/np-tools/stats.test.cjs +83 -1
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/workflows/execute-phase.md +1 -1
- package/workflows/plan-phase.md +1 -1
- package/workflows/research-phase.md +1 -1
- package/workflows/validate-phase.md +1 -1
|
@@ -89,6 +89,7 @@ Run each dimension below; for every failure, emit one finding using the matching
|
|
|
89
89
|
- Every `<task>` MUST have `id="M<NNN>-S<NNN>-T<NNNN>"` matching the enclosing slice (milestone and slice numbers must agree with the file path). Mismatch → `broken-dependency`.
|
|
90
90
|
- Missing `depends_on`, `wave`, or `tier` attribute on the opening `<task>` tag → the scaffolder will drop it. Emit `fake-promotion-trigger` with a message telling the planner which task is missing which attribute.
|
|
91
91
|
- `wave="<N>"` should equal the slice's S-number (e.g. S002 → wave="2"). Mismatch is a soft finding (`fake-promotion-trigger`).
|
|
92
|
+
- **Task numbering restarts per slice.** Inside each `S<NNN>-PLAN.md`, the task IDs MUST start at `T0001` and increment contiguously (`T0001, T0002, …`). Counter that continues across slices (e.g. `S002` starting at `T0002` because `S001` used `T0001`) → `broken-dependency` with `target: S<NNN>-PLAN.md task <n>` and a message naming the expected vs. observed T-number. Gaps (`T0001, T0003`) are the same finding.
|
|
92
93
|
|
|
93
94
|
### Dimension 7: Nyquist Coverage Annotation
|
|
94
95
|
|
package/agents/np-planner.md
CHANGED
|
@@ -216,20 +216,22 @@ If any check fails, fix before returning. Plan-checker will catch what you miss,
|
|
|
216
216
|
|
|
217
217
|
Inside each `S<NNN>-PLAN.md`, every `<task>` tag MUST have these four attributes on the opening tag:
|
|
218
218
|
|
|
219
|
-
- `id="M<NNN>-S<NNN>-T<NNNN>"` — full-id, e.g. `id="M001-S001-T0001"`. Milestone 3 digits, slice 3 digits, task **4 digits**.
|
|
219
|
+
- `id="M<NNN>-S<NNN>-T<NNNN>"` — full-id, e.g. `id="M001-S001-T0001"`. Milestone 3 digits, slice 3 digits, task **4 digits**. **Task numbering restarts at `T0001` inside every slice.** The first task of `S002` is `M<NNN>-S002-T0001`, the first task of `S003` is `M<NNN>-S003-T0001`. Tasks within a slice run `T0001, T0002, T0003, …` without gaps. Never continue the counter across slices (`S001-T0001, S002-T0002` is wrong — it must be `S001-T0001, S002-T0001`).
|
|
220
220
|
- `depends_on="<id>[,<id>...]"` — comma-separated predecessor task full-ids, or empty string `""`. Must only reference tasks in **earlier slices** (cross-slice forward deps) or be empty (intra-slice tasks are implicitly parallel, never serial).
|
|
221
221
|
- `wave="<N>"` — integer equal to the slice number. For S001 use `wave="1"`, for S002 use `wave="2"`, etc.
|
|
222
222
|
- `tier="<haiku|sonnet|opus>"` — executor tier, picks the model via resolve-model.
|
|
223
223
|
|
|
224
224
|
The scaffolder (`_extractTasksFromSlicePlan` in `bin/np-tools/plan-milestone.cjs`) reads ONLY these opening-tag attributes. Without them, zero task files are scaffolded and execute-phase has nothing to dispatch.
|
|
225
225
|
|
|
226
|
+
Inside the body, every `<task>` MUST also contain a `<files_modified>` block listing the files the executor will touch (one per line or comma-separated). An empty or missing `<files_modified>` block produces `files_modified: []` in task frontmatter, which causes `commit-task` to fail (`commit-task-no-files`) unless the executor reported touched files via `checkpoint touch` as a runtime fallback. Plans MUST declare intent up-front; relying on the checkpoint fallback is a last-resort safety net.
|
|
227
|
+
|
|
226
228
|
Correct example for `slices/S001/S001-PLAN.md`:
|
|
227
229
|
|
|
228
230
|
```
|
|
229
231
|
<tasks>
|
|
230
232
|
<task id="M001-S001-T0001" depends_on="" wave="1" tier="sonnet">
|
|
231
233
|
<name>Seed login form</name>
|
|
232
|
-
<
|
|
234
|
+
<files_modified>src/auth/LoginForm.tsx</files_modified>
|
|
233
235
|
<read_first>
|
|
234
236
|
- src/auth/AuthProvider.tsx
|
|
235
237
|
</read_first>
|
|
@@ -257,7 +259,16 @@ Create `LoginForm.tsx` with email + password inputs. Wire it to the
|
|
|
257
259
|
</tasks>
|
|
258
260
|
```
|
|
259
261
|
|
|
260
|
-
Note both tasks have `depends_on=""` — they're in the same slice and run in parallel. If `T0002` truly needs `T0001` first, move `T0002` into a new slice `S002` and
|
|
262
|
+
Note both tasks have `depends_on=""` — they're in the same slice and run in parallel. If `T0002` truly needs `T0001` first, move `T0002` into a new slice `S002` and renumber it to `T0001` — each slice owns its own task counter:
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
<task id="M001-S002-T0001" depends_on="M001-S001-T0001" wave="2" tier="sonnet">
|
|
266
|
+
<name>Use login handler in session flow</name>
|
|
267
|
+
...
|
|
268
|
+
</task>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The cross-slice dep `M001-S001-T0001` flows forward (S001 → S002); the new task is `T0001` of S002, not `T0003`.
|
|
261
272
|
</task_format>
|
|
262
273
|
|
|
263
274
|
<tooling_conventions>
|
package/bin/install.js
CHANGED
|
@@ -663,6 +663,12 @@ function _runUninstallLocked(projectRoot) {
|
|
|
663
663
|
|
|
664
664
|
async function main() {
|
|
665
665
|
const rawArgs = process.argv.slice(2);
|
|
666
|
+
if (rawArgs.includes('--version') || rawArgs.includes('-v')) {
|
|
667
|
+
let version = '0.0.0';
|
|
668
|
+
try { version = String(require('../package.json').version || '0.0.0'); } catch {}
|
|
669
|
+
process.stdout.write(version + '\n');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
666
672
|
const { flags, rest } = parseInstallFlags(rawArgs);
|
|
667
673
|
const sub = rest[0];
|
|
668
674
|
const cwd = process.cwd();
|
|
@@ -47,6 +47,7 @@ const COMMANDS = [
|
|
|
47
47
|
{ name: 'text-mode', category: 'Utility', description: 'Print whether text mode is active (config.workflow.text_mode ∨ CLAUDECODE)' },
|
|
48
48
|
{ name: 'generate-slug', category: 'Utility', description: 'Slugify text via lib/layout.cjs.slugify' },
|
|
49
49
|
{ name: 'stats', category: 'Utility', description: 'Aggregated project stats (roadmap + STATE + git + metrics JSON shape)' },
|
|
50
|
+
{ name: 'detect-runtime', category: 'Utility', description: 'Print detected runtime id (claude, codex, gemini, …) — reads config.json ∨ env ∨ default' },
|
|
50
51
|
|
|
51
52
|
{ name: 'thread', category: 'Utility', description: 'Cross-session thread CRUD (create/resume under .nubos-pilot/threads/)' },
|
|
52
53
|
{ name: 'session-report', category: 'Utility', description: 'Generate session report from metrics since .last-session pointer' },
|
|
@@ -7,7 +7,7 @@ const { TASK_ID_RE, setTaskStatus } = require('../../lib/tasks.cjs');
|
|
|
7
7
|
const layout = require('../../lib/layout.cjs');
|
|
8
8
|
const git = require('../../lib/git.cjs');
|
|
9
9
|
const { commitTask, findCommitByTaskId } = git;
|
|
10
|
-
const { deleteCheckpoint } = require('../../lib/checkpoint.cjs');
|
|
10
|
+
const { deleteCheckpoint, readCheckpoint } = require('../../lib/checkpoint.cjs');
|
|
11
11
|
|
|
12
12
|
function _resolveTaskFile(taskId, cwd) {
|
|
13
13
|
const parsed = layout.parseTaskFullId(taskId);
|
|
@@ -70,11 +70,21 @@ function run(args, ctx) {
|
|
|
70
70
|
const { filePath } = _resolveTaskFile(taskId, cwd);
|
|
71
71
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
72
72
|
const { frontmatter, body } = extractFrontmatter(raw);
|
|
73
|
-
const
|
|
73
|
+
const declared = Array.isArray(frontmatter.files_modified) ? frontmatter.files_modified : [];
|
|
74
|
+
let files = declared.slice();
|
|
75
|
+
let filesSource = 'frontmatter';
|
|
76
|
+
if (files.length === 0) {
|
|
77
|
+
const cp = readCheckpoint(taskId, cwd);
|
|
78
|
+
const touched = cp && Array.isArray(cp.files_touched) ? cp.files_touched : [];
|
|
79
|
+
if (touched.length > 0) {
|
|
80
|
+
files = touched.slice();
|
|
81
|
+
filesSource = 'checkpoint';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
74
84
|
if (files.length === 0) {
|
|
75
85
|
throw new NubosPilotError(
|
|
76
86
|
'commit-task-no-files',
|
|
77
|
-
'Task ' + taskId + ' has empty files_modified',
|
|
87
|
+
'Task ' + taskId + ' has empty files_modified and no files_touched in checkpoint',
|
|
78
88
|
{ taskId },
|
|
79
89
|
);
|
|
80
90
|
}
|
|
@@ -93,7 +103,7 @@ function run(args, ctx) {
|
|
|
93
103
|
process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
const payload = { ok: true, task_id: taskId, sha, files: safeFiles };
|
|
106
|
+
const payload = { ok: true, task_id: taskId, sha, files: safeFiles, files_source: filesSource };
|
|
97
107
|
stdout.write(JSON.stringify(payload));
|
|
98
108
|
return payload;
|
|
99
109
|
}
|
|
@@ -158,3 +158,40 @@ test('CT-5: commit-task unknown task id → task-not-found', () => {
|
|
|
158
158
|
(err) => err && err.code === 'commit-task-not-found',
|
|
159
159
|
);
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
test('CT-6: empty files_modified falls back to checkpoint.files_touched', () => {
|
|
163
|
+
const root = makeRepo();
|
|
164
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0010', []);
|
|
165
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
166
|
+
fs.writeFileSync(path.join(root, 'src', 'b.ts'), 'export const b = 2;\n', 'utf-8');
|
|
167
|
+
const cpPath = path.join(root, '.nubos-pilot', 'checkpoints', 'M006-S001-T0010.json');
|
|
168
|
+
fs.mkdirSync(path.dirname(cpPath), { recursive: true });
|
|
169
|
+
fs.writeFileSync(cpPath, JSON.stringify({
|
|
170
|
+
schema_version: 1,
|
|
171
|
+
task_id: 'M006-S001-T0010',
|
|
172
|
+
status: 'pre-commit',
|
|
173
|
+
files_touched: ['src/b.ts'],
|
|
174
|
+
}), 'utf-8');
|
|
175
|
+
const prev = process.cwd();
|
|
176
|
+
process.chdir(root);
|
|
177
|
+
const cap = _capture();
|
|
178
|
+
try {
|
|
179
|
+
subcmd.run(['M006-S001-T0010'], { cwd: root, stdout: cap.stub });
|
|
180
|
+
} finally {
|
|
181
|
+
process.chdir(prev);
|
|
182
|
+
}
|
|
183
|
+
const payload = JSON.parse(cap.get());
|
|
184
|
+
assert.equal(payload.ok, true);
|
|
185
|
+
assert.equal(payload.files_source, 'checkpoint');
|
|
186
|
+
assert.deepEqual(payload.files, ['src/b.ts']);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('CT-7: empty files_modified AND no checkpoint → commit-task-no-files', () => {
|
|
190
|
+
const root = makeRepo();
|
|
191
|
+
seedPlanAndTask(root, '06-01', 'M006-S001-T0011', []);
|
|
192
|
+
const cap = _capture();
|
|
193
|
+
assert.throws(
|
|
194
|
+
() => subcmd.run(['M006-S001-T0011'], { cwd: root, stdout: cap.stub }),
|
|
195
|
+
(err) => err && err.code === 'commit-task-no-files',
|
|
196
|
+
);
|
|
197
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { detect } = require('../../lib/runtime/index.cjs');
|
|
4
|
+
|
|
5
|
+
function run(argv, ctx) {
|
|
6
|
+
const context = ctx || {};
|
|
7
|
+
const cwd = context.cwd || process.cwd();
|
|
8
|
+
const stdout = context.stdout || process.stdout;
|
|
9
|
+
const args = Array.isArray(argv) ? argv.slice() : [];
|
|
10
|
+
const result = detect({ cwd });
|
|
11
|
+
if (args.includes('--json')) {
|
|
12
|
+
stdout.write(JSON.stringify(result) + '\n');
|
|
13
|
+
} else {
|
|
14
|
+
stdout.write(result.runtime + '\n');
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { run };
|
|
20
|
+
|
|
21
|
+
if (require.main === module) {
|
|
22
|
+
const code = run(process.argv.slice(2));
|
|
23
|
+
if (typeof code === 'number' && code !== 0) process.exit(code);
|
|
24
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { test } = require('node:test');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const { Writable } = require('node:stream');
|
|
7
|
+
|
|
8
|
+
const cli = require('./detect-runtime.cjs');
|
|
9
|
+
|
|
10
|
+
function makeSink() {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
const w = new Writable({
|
|
13
|
+
write(chunk, _enc, cb) { chunks.push(chunk); cb(); },
|
|
14
|
+
});
|
|
15
|
+
w.toString = () => Buffer.concat(chunks.map((c) => Buffer.isBuffer(c) ? c : Buffer.from(String(c)))).toString('utf-8');
|
|
16
|
+
return w;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeSandbox(runtime) {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-detect-rt-'));
|
|
21
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
|
|
22
|
+
if (runtime) {
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
path.join(root, '.nubos-pilot', 'config.json'),
|
|
25
|
+
JSON.stringify({ runtime, runtime_source: 'config' }),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('detect-runtime: reads runtime from .nubos-pilot/config.json', () => {
|
|
32
|
+
const sb = makeSandbox('gemini');
|
|
33
|
+
const stdout = makeSink();
|
|
34
|
+
const code = cli.run([], { cwd: sb, stdout });
|
|
35
|
+
assert.equal(code, 0);
|
|
36
|
+
assert.equal(stdout.toString().trim(), 'gemini');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('detect-runtime --json emits {runtime, source}', () => {
|
|
40
|
+
const sb = makeSandbox('codex');
|
|
41
|
+
const stdout = makeSink();
|
|
42
|
+
const code = cli.run(['--json'], { cwd: sb, stdout });
|
|
43
|
+
assert.equal(code, 0);
|
|
44
|
+
const parsed = JSON.parse(stdout.toString());
|
|
45
|
+
assert.equal(parsed.runtime, 'codex');
|
|
46
|
+
assert.ok(parsed.source);
|
|
47
|
+
});
|
|
@@ -163,12 +163,65 @@ function _extractTasksFromSlicePlan(planPath) {
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
function _escapeRegex(s) {
|
|
167
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _buildMilestoneRemap(mNum, cwd) {
|
|
171
|
+
const slices = layout.listSlices(mNum, cwd);
|
|
172
|
+
const remap = new Map();
|
|
173
|
+
for (const s of slices) {
|
|
174
|
+
const planPath = layout.slicePlanPath(mNum, s.number, cwd);
|
|
175
|
+
if (!fs.existsSync(planPath)) continue;
|
|
176
|
+
const tasks = _extractTasksFromSlicePlan(planPath);
|
|
177
|
+
tasks.forEach((t, idx) => {
|
|
178
|
+
const oldId = t.id;
|
|
179
|
+
if (!oldId) return;
|
|
180
|
+
const newId = layout.taskFullId(mNum, s.number, idx + 1);
|
|
181
|
+
if (oldId !== newId) remap.set(oldId, newId);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return remap;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _rewriteSlicePlanIds(planPath, remap) {
|
|
188
|
+
if (remap.size === 0) return false;
|
|
189
|
+
const raw = fs.readFileSync(planPath, 'utf-8');
|
|
190
|
+
const keys = [...remap.keys()].sort((a, b) => b.length - a.length);
|
|
191
|
+
const re = new RegExp('\\b(' + keys.map(_escapeRegex).join('|') + ')\\b', 'g');
|
|
192
|
+
const next = raw.replace(re, (m) => remap.get(m) || m);
|
|
193
|
+
if (next === raw) return false;
|
|
194
|
+
atomicWriteFileSync(planPath, next);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _normalizeMilestoneTaskIds(mNum, cwd) {
|
|
199
|
+
const remap = _buildMilestoneRemap(mNum, cwd);
|
|
200
|
+
if (remap.size === 0) return { changed: false, remap: {} };
|
|
201
|
+
const slices = layout.listSlices(mNum, cwd);
|
|
202
|
+
for (const s of slices) {
|
|
203
|
+
const planPath = layout.slicePlanPath(mNum, s.number, cwd);
|
|
204
|
+
if (!fs.existsSync(planPath)) continue;
|
|
205
|
+
_rewriteSlicePlanIds(planPath, remap);
|
|
206
|
+
}
|
|
207
|
+
return { changed: true, remap: Object.fromEntries(remap) };
|
|
208
|
+
}
|
|
209
|
+
|
|
166
210
|
function _extractInnerTag(body, tag) {
|
|
167
211
|
const re = new RegExp('<' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + tag + '>', 'i');
|
|
168
212
|
const m = body.match(re);
|
|
169
213
|
return m ? m[1].trim() : '';
|
|
170
214
|
}
|
|
171
215
|
|
|
216
|
+
function _parseFilesList(body) {
|
|
217
|
+
const raw = _extractInnerTag(body, 'files_modified') || _extractInnerTag(body, 'files');
|
|
218
|
+
if (!raw) return [];
|
|
219
|
+
return raw
|
|
220
|
+
.split(/[,\n]/)
|
|
221
|
+
.map((s) => s.trim().replace(/^[-*]\s+/, '').trim())
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
}
|
|
224
|
+
|
|
172
225
|
function _filesYaml(files) {
|
|
173
226
|
if (!files.length) return '[]';
|
|
174
227
|
return '\n' + files.map((f) => ' - ' + JSON.stringify(f)).join('\n');
|
|
@@ -184,8 +237,7 @@ function _renderTaskPlanMd(task, mNum, sNum) {
|
|
|
184
237
|
const parsed = fullId.match(/T(\d{4,})$/);
|
|
185
238
|
const taskNum = parsed ? Number(parsed[1]) : 0;
|
|
186
239
|
const name = _extractInnerTag(task.body, 'name') || fullId;
|
|
187
|
-
const
|
|
188
|
-
const filesList = files ? files.split(/[,\n]/).map((s) => s.trim()).filter(Boolean) : [];
|
|
240
|
+
const filesList = _parseFilesList(task.body);
|
|
189
241
|
const readFirst = _extractInnerTag(task.body, 'read_first');
|
|
190
242
|
const action = _extractInnerTag(task.body, 'action');
|
|
191
243
|
const verify = _extractInnerTag(task.body, 'verify');
|
|
@@ -258,6 +310,13 @@ function _scaffoldSliceTasks(mNum, sNum, cwd) {
|
|
|
258
310
|
if (!fs.existsSync(planPath)) {
|
|
259
311
|
return { scaffolded: [], reason: 'no-slice-plan', slice: layout.sliceFullId(mNum, sNum) };
|
|
260
312
|
}
|
|
313
|
+
const sliceRemap = new Map();
|
|
314
|
+
const preTasks = _extractTasksFromSlicePlan(planPath);
|
|
315
|
+
preTasks.forEach((t, idx) => {
|
|
316
|
+
const newId = layout.taskFullId(mNum, sNum, idx + 1);
|
|
317
|
+
if (t.id && t.id !== newId) sliceRemap.set(t.id, newId);
|
|
318
|
+
});
|
|
319
|
+
_rewriteSlicePlanIds(planPath, sliceRemap);
|
|
261
320
|
const tasks = _extractTasksFromSlicePlan(planPath);
|
|
262
321
|
if (tasks.length === 0) {
|
|
263
322
|
return { scaffolded: [], reason: 'no-tasks-in-slice-plan', slice: layout.sliceFullId(mNum, sNum) };
|
|
@@ -293,12 +352,19 @@ function _scaffoldAllTasks(mNum, cwd) {
|
|
|
293
352
|
if (slices.length === 0) {
|
|
294
353
|
return { scaffolded: [], reason: 'no-slices', milestone: layout.mId(mNum) };
|
|
295
354
|
}
|
|
355
|
+
const normalized = _normalizeMilestoneTaskIds(mNum, cwd);
|
|
296
356
|
const per = [];
|
|
297
357
|
for (const s of slices) {
|
|
298
358
|
per.push(_scaffoldSliceTasks(mNum, s.number, cwd));
|
|
299
359
|
}
|
|
300
360
|
const total = per.reduce((acc, p) => acc + (p.task_count || 0), 0);
|
|
301
|
-
return {
|
|
361
|
+
return {
|
|
362
|
+
scaffolded: per,
|
|
363
|
+
reason: 'ok',
|
|
364
|
+
milestone: layout.mId(mNum),
|
|
365
|
+
total_tasks: total,
|
|
366
|
+
normalized_ids: normalized.changed ? normalized.remap : {},
|
|
367
|
+
};
|
|
302
368
|
}
|
|
303
369
|
|
|
304
370
|
function _createMilestoneDir(mNum, cwd) {
|
|
@@ -207,3 +207,149 @@ test('PM-9: unknown verb throws', () => {
|
|
|
207
207
|
(err) => err && err.code === 'plan-milestone-unknown-verb',
|
|
208
208
|
);
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
test('PM-10: scaffold-all-tasks renumbers task ids per slice when planner numbered globally', () => {
|
|
212
|
+
const sandbox = makeSandbox();
|
|
213
|
+
seedRoadmapYaml(sandbox, _roadmap());
|
|
214
|
+
seedMilestoneDir(sandbox, 1, {});
|
|
215
|
+
const plan1 = [
|
|
216
|
+
'---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
|
|
217
|
+
'<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
|
|
218
|
+
'<task id="M001-S001-T0002" wave="1" tier="sonnet" depends_on=""><name>B</name><action>A</action><done>D</done></task>',
|
|
219
|
+
'<task id="M001-S001-T0003" wave="1" tier="sonnet" depends_on=""><name>C</name><action>A</action><done>D</done></task>',
|
|
220
|
+
].join('\n');
|
|
221
|
+
const plan2 = [
|
|
222
|
+
'---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
|
|
223
|
+
'<task id="M001-S002-T0004" wave="2" tier="sonnet" depends_on="M001-S001-T0003"><name>D</name><action>A</action><done>D</done></task>',
|
|
224
|
+
'<task id="M001-S002-T0005" wave="2" tier="sonnet" depends_on=""><name>E</name><action>A</action><done>D</done></task>',
|
|
225
|
+
].join('\n');
|
|
226
|
+
const plan3 = [
|
|
227
|
+
'---', 'slice: "M001-S003"', 'milestone: "M001"', '---', '',
|
|
228
|
+
'<task id="M001-S003-T0006" wave="3" tier="sonnet" depends_on="M001-S002-T0004"><name>F</name><action>A</action><done>D</done></task>',
|
|
229
|
+
'<task id="M001-S003-T0007" wave="3" tier="sonnet" depends_on="M001-S002-T0005,M001-S001-T0001"><name>G</name><action>A</action><done>D</done></task>',
|
|
230
|
+
].join('\n');
|
|
231
|
+
seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': plan1 });
|
|
232
|
+
seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
|
|
233
|
+
seedSliceDir(sandbox, 1, 3, { 'S003-PLAN.md': plan3 });
|
|
234
|
+
|
|
235
|
+
const cap = _capture();
|
|
236
|
+
subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: cap.stub });
|
|
237
|
+
const out = JSON.parse(cap.get().trim());
|
|
238
|
+
assert.equal(out.reason, 'ok');
|
|
239
|
+
assert.equal(out.total_tasks, 7);
|
|
240
|
+
|
|
241
|
+
const tasksBase = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices');
|
|
242
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0001')));
|
|
243
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0002')));
|
|
244
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S001', 'tasks', 'T0003')));
|
|
245
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0001')));
|
|
246
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0002')));
|
|
247
|
+
assert.ok(!fs.existsSync(path.join(tasksBase, 'S002', 'tasks', 'T0004')));
|
|
248
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0001')));
|
|
249
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0002')));
|
|
250
|
+
assert.ok(!fs.existsSync(path.join(tasksBase, 'S003', 'tasks', 'T0006')));
|
|
251
|
+
|
|
252
|
+
const s2t1 = fs.readFileSync(path.join(tasksBase, 'S002', 'tasks', 'T0001', 'T0001-PLAN.md'), 'utf-8');
|
|
253
|
+
assert.match(s2t1, /id: "M001-S002-T0001"/);
|
|
254
|
+
assert.match(s2t1, /- "M001-S001-T0003"/);
|
|
255
|
+
|
|
256
|
+
const s3t1 = fs.readFileSync(path.join(tasksBase, 'S003', 'tasks', 'T0001', 'T0001-PLAN.md'), 'utf-8');
|
|
257
|
+
assert.match(s3t1, /id: "M001-S003-T0001"/);
|
|
258
|
+
assert.match(s3t1, /- "M001-S002-T0001"/);
|
|
259
|
+
|
|
260
|
+
const s3t2 = fs.readFileSync(path.join(tasksBase, 'S003', 'tasks', 'T0002', 'T0002-PLAN.md'), 'utf-8');
|
|
261
|
+
assert.match(s3t2, /id: "M001-S003-T0002"/);
|
|
262
|
+
assert.match(s3t2, /- "M001-S002-T0002"/);
|
|
263
|
+
assert.match(s3t2, /- "M001-S001-T0001"/);
|
|
264
|
+
|
|
265
|
+
const s2PlanRaw = fs.readFileSync(path.join(tasksBase, 'S002', 'S002-PLAN.md'), 'utf-8');
|
|
266
|
+
assert.match(s2PlanRaw, /id="M001-S002-T0001"/);
|
|
267
|
+
assert.match(s2PlanRaw, /id="M001-S002-T0002"/);
|
|
268
|
+
assert.doesNotMatch(s2PlanRaw, /T0004/);
|
|
269
|
+
assert.doesNotMatch(s2PlanRaw, /T0005/);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(out.normalized_ids['M001-S002-T0004'], 'M001-S002-T0001');
|
|
272
|
+
assert.deepEqual(out.normalized_ids['M001-S002-T0005'], 'M001-S002-T0002');
|
|
273
|
+
assert.deepEqual(out.normalized_ids['M001-S003-T0006'], 'M001-S003-T0001');
|
|
274
|
+
assert.deepEqual(out.normalized_ids['M001-S003-T0007'], 'M001-S003-T0002');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('PM-11: scaffold-all-tasks is idempotent — already-normalized ids stay unchanged', () => {
|
|
278
|
+
const sandbox = makeSandbox();
|
|
279
|
+
seedRoadmapYaml(sandbox, _roadmap());
|
|
280
|
+
seedMilestoneDir(sandbox, 1, {});
|
|
281
|
+
const plan1 = [
|
|
282
|
+
'---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
|
|
283
|
+
'<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
|
|
284
|
+
].join('\n');
|
|
285
|
+
const plan2 = [
|
|
286
|
+
'---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
|
|
287
|
+
'<task id="M001-S002-T0001" wave="2" tier="sonnet" depends_on="M001-S001-T0001"><name>B</name><action>A</action><done>D</done></task>',
|
|
288
|
+
].join('\n');
|
|
289
|
+
seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': plan1 });
|
|
290
|
+
seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
|
|
291
|
+
|
|
292
|
+
subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: _capture().stub });
|
|
293
|
+
const cap = _capture();
|
|
294
|
+
subcmd.run(['scaffold-all-tasks', '1'], { cwd: sandbox, stdout: cap.stub });
|
|
295
|
+
const out = JSON.parse(cap.get().trim());
|
|
296
|
+
assert.deepEqual(out.normalized_ids, {});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('PM-13: scaffold accepts <files_modified> tag and strips bullet prefixes', () => {
|
|
300
|
+
const sandbox = makeSandbox();
|
|
301
|
+
seedRoadmapYaml(sandbox, _roadmap());
|
|
302
|
+
seedMilestoneDir(sandbox, 1, {});
|
|
303
|
+
const slicePlan = [
|
|
304
|
+
'---', 'slice: "M001-S001"', 'milestone: "M001"', '---', '',
|
|
305
|
+
'<task id="M001-S001-T0001" wave="1" tier="sonnet" depends_on="">',
|
|
306
|
+
' <name>Multi-file task</name>',
|
|
307
|
+
' <files_modified>',
|
|
308
|
+
' - src/a.ts',
|
|
309
|
+
' - src/b.ts',
|
|
310
|
+
' - src/c.ts',
|
|
311
|
+
' </files_modified>',
|
|
312
|
+
' <action>Touch three files.</action>',
|
|
313
|
+
' <done>Done.</done>',
|
|
314
|
+
'</task>',
|
|
315
|
+
].join('\n');
|
|
316
|
+
seedSliceDir(sandbox, 1, 1, { 'S001-PLAN.md': slicePlan });
|
|
317
|
+
subcmd.run(['scaffold-slice-tasks', '1', '1'], { cwd: sandbox, stdout: _capture().stub });
|
|
318
|
+
const body = fs.readFileSync(
|
|
319
|
+
path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S001', 'tasks', 'T0001', 'T0001-PLAN.md'),
|
|
320
|
+
'utf-8',
|
|
321
|
+
);
|
|
322
|
+
assert.match(body, /- "src\/a.ts"/);
|
|
323
|
+
assert.match(body, /- "src\/b.ts"/);
|
|
324
|
+
assert.match(body, /- "src\/c.ts"/);
|
|
325
|
+
assert.doesNotMatch(body, /- "- /);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('PM-12: scaffold-slice-tasks renumbers its own slice when ids do not start at T0001', () => {
|
|
329
|
+
const sandbox = makeSandbox();
|
|
330
|
+
seedRoadmapYaml(sandbox, _roadmap());
|
|
331
|
+
seedMilestoneDir(sandbox, 1, {});
|
|
332
|
+
const plan2 = [
|
|
333
|
+
'---', 'slice: "M001-S002"', 'milestone: "M001"', '---', '',
|
|
334
|
+
'<task id="M001-S002-T0004" wave="2" tier="sonnet" depends_on=""><name>A</name><action>A</action><done>D</done></task>',
|
|
335
|
+
'<task id="M001-S002-T0005" wave="2" tier="sonnet" depends_on=""><name>B</name><action>A</action><done>D</done></task>',
|
|
336
|
+
].join('\n');
|
|
337
|
+
seedSliceDir(sandbox, 1, 2, { 'S002-PLAN.md': plan2 });
|
|
338
|
+
|
|
339
|
+
const cap = _capture();
|
|
340
|
+
subcmd.run(['scaffold-slice-tasks', '1', '2'], { cwd: sandbox, stdout: cap.stub });
|
|
341
|
+
const out = JSON.parse(cap.get().trim());
|
|
342
|
+
assert.equal(out.task_count, 2);
|
|
343
|
+
|
|
344
|
+
const tasksBase = path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S002', 'tasks');
|
|
345
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'T0001')));
|
|
346
|
+
assert.ok(fs.existsSync(path.join(tasksBase, 'T0002')));
|
|
347
|
+
assert.ok(!fs.existsSync(path.join(tasksBase, 'T0004')));
|
|
348
|
+
|
|
349
|
+
const planRaw = fs.readFileSync(
|
|
350
|
+
path.join(sandbox, '.nubos-pilot', 'milestones', 'M001', 'slices', 'S002', 'S002-PLAN.md'),
|
|
351
|
+
'utf-8',
|
|
352
|
+
);
|
|
353
|
+
assert.match(planRaw, /id="M001-S002-T0001"/);
|
|
354
|
+
assert.doesNotMatch(planRaw, /T0004/);
|
|
355
|
+
});
|
package/bin/np-tools/stats.cjs
CHANGED
|
@@ -1,14 +1,78 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
1
2
|
const path = require('node:path');
|
|
2
3
|
const { execFileSync } = require('node:child_process');
|
|
3
4
|
const { NubosPilotError, findProjectRoot } = require('../../lib/core.cjs');
|
|
4
5
|
const { parseRoadmap } = require('../../lib/roadmap.cjs');
|
|
5
6
|
const { readState } = require('../../lib/state.cjs');
|
|
6
7
|
const { aggregatePhase } = require('../../lib/metrics-aggregate.cjs');
|
|
8
|
+
const { extractFrontmatter } = require('../../lib/frontmatter.cjs');
|
|
9
|
+
const layout = require('../../lib/layout.cjs');
|
|
7
10
|
|
|
8
|
-
const SCHEMA_VERSION =
|
|
11
|
+
const SCHEMA_VERSION = 2;
|
|
9
12
|
|
|
10
13
|
function _usage() {
|
|
11
|
-
return 'Usage:\n np-tools.cjs stats json';
|
|
14
|
+
return 'Usage:\n np-tools.cjs stats json\n np-tools.cjs stats bar';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _percent(num, den) {
|
|
18
|
+
if (!den || den <= 0) return 0;
|
|
19
|
+
return Math.min(100, Math.round((num / den) * 100));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _taskStatus(planPath) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(planPath, 'utf-8');
|
|
25
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
26
|
+
return frontmatter && typeof frontmatter.status === 'string'
|
|
27
|
+
? frontmatter.status : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _collectTaskAndSliceStats(cwd) {
|
|
34
|
+
let tasksTotal = 0;
|
|
35
|
+
let tasksComplete = 0;
|
|
36
|
+
let slicesTotal = 0;
|
|
37
|
+
let slicesComplete = 0;
|
|
38
|
+
const milestones = layout.listMilestones(cwd);
|
|
39
|
+
for (const m of milestones) {
|
|
40
|
+
const slices = layout.listSlices(m.number, cwd);
|
|
41
|
+
for (const s of slices) {
|
|
42
|
+
slicesTotal += 1;
|
|
43
|
+
const tasks = layout.listTasks(m.number, s.number, cwd);
|
|
44
|
+
if (tasks.length === 0) continue;
|
|
45
|
+
let doneInSlice = 0;
|
|
46
|
+
for (const t of tasks) {
|
|
47
|
+
if (!fs.existsSync(t.plan_path)) continue;
|
|
48
|
+
tasksTotal += 1;
|
|
49
|
+
if (_taskStatus(t.plan_path) === 'done') {
|
|
50
|
+
tasksComplete += 1;
|
|
51
|
+
doneInSlice += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (doneInSlice === tasks.length && tasks.length > 0) slicesComplete += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
tasks: {
|
|
59
|
+
total: tasksTotal,
|
|
60
|
+
complete: tasksComplete,
|
|
61
|
+
percent: _percent(tasksComplete, tasksTotal),
|
|
62
|
+
},
|
|
63
|
+
slices: {
|
|
64
|
+
total: slicesTotal,
|
|
65
|
+
complete: slicesComplete,
|
|
66
|
+
percent: _percent(slicesComplete, slicesTotal),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _renderBar(label, percent, width) {
|
|
72
|
+
const w = Math.max(4, Math.min(60, width || 24));
|
|
73
|
+
const filled = Math.round((percent / 100) * w);
|
|
74
|
+
const bar = '█'.repeat(filled) + '░'.repeat(w - filled);
|
|
75
|
+
return label + ' [' + bar + '] ' + String(percent).padStart(3, ' ') + '%';
|
|
12
76
|
}
|
|
13
77
|
|
|
14
78
|
function _emitError(err, stderr) {
|
|
@@ -81,7 +145,8 @@ async function _buildStats(cwd) {
|
|
|
81
145
|
plansTotal += ph.plans_total;
|
|
82
146
|
plansComplete += ph.plans_complete;
|
|
83
147
|
}
|
|
84
|
-
const percent =
|
|
148
|
+
const percent = _percent(plansComplete, plansTotal);
|
|
149
|
+
const fs_progress = _collectTaskAndSliceStats(useCwd);
|
|
85
150
|
let lastActivity = null;
|
|
86
151
|
try {
|
|
87
152
|
const st = readState(useCwd);
|
|
@@ -108,6 +173,8 @@ async function _buildStats(cwd) {
|
|
|
108
173
|
plans_total: plansTotal,
|
|
109
174
|
plans_complete: plansComplete,
|
|
110
175
|
percent,
|
|
176
|
+
tasks: fs_progress.tasks,
|
|
177
|
+
slices: fs_progress.slices,
|
|
111
178
|
git,
|
|
112
179
|
last_activity: lastActivity,
|
|
113
180
|
metrics_by_phase,
|
|
@@ -121,7 +188,7 @@ async function run(argv, ctx) {
|
|
|
121
188
|
const stderr = context.stderr || process.stderr;
|
|
122
189
|
const args = Array.isArray(argv) ? argv.slice() : [];
|
|
123
190
|
const sub = args.shift();
|
|
124
|
-
if (sub !== 'json') {
|
|
191
|
+
if (sub !== 'json' && sub !== 'bar') {
|
|
125
192
|
stderr.write(_usage() + '\n');
|
|
126
193
|
return 1;
|
|
127
194
|
}
|
|
@@ -133,6 +200,11 @@ async function run(argv, ctx) {
|
|
|
133
200
|
}
|
|
134
201
|
try {
|
|
135
202
|
const out = await _buildStats(cwd);
|
|
203
|
+
if (sub === 'bar') {
|
|
204
|
+
stdout.write(_renderBar('Tasks ', out.tasks.percent) + ' (' + out.tasks.complete + '/' + out.tasks.total + ')\n');
|
|
205
|
+
stdout.write(_renderBar('Slices', out.slices.percent) + ' (' + out.slices.complete + '/' + out.slices.total + ')\n');
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
136
208
|
stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
137
209
|
return 0;
|
|
138
210
|
} catch (err) {
|
|
@@ -141,7 +213,7 @@ async function run(argv, ctx) {
|
|
|
141
213
|
}
|
|
142
214
|
}
|
|
143
215
|
|
|
144
|
-
module.exports = { run, _buildStats, _collectPhases, _milestoneEntry };
|
|
216
|
+
module.exports = { run, _buildStats, _collectPhases, _milestoneEntry, _collectTaskAndSliceStats, _renderBar };
|
|
145
217
|
|
|
146
218
|
if (require.main === module) {
|
|
147
219
|
run(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
|
|
@@ -87,17 +87,99 @@ test('STATS-1: stats json emits schema_version + phases + git + metrics_by_phase
|
|
|
87
87
|
const code = await statsCli.run(['json'], { cwd: sb, stdout, stderr });
|
|
88
88
|
assert.equal(code, 0, 'stderr=' + stderr.toString());
|
|
89
89
|
const parsed = JSON.parse(stdout.toString());
|
|
90
|
-
assert.equal(parsed.schema_version,
|
|
90
|
+
assert.equal(parsed.schema_version, 2);
|
|
91
91
|
assert.ok(parsed.milestone);
|
|
92
92
|
assert.equal(parsed.phases.length, 2);
|
|
93
93
|
assert.equal(parsed.plans_total, 2);
|
|
94
94
|
assert.equal(parsed.plans_complete, 1);
|
|
95
95
|
assert.equal(parsed.percent, 50);
|
|
96
|
+
assert.ok(parsed.tasks);
|
|
97
|
+
assert.equal(typeof parsed.tasks.total, 'number');
|
|
98
|
+
assert.equal(typeof parsed.tasks.complete, 'number');
|
|
99
|
+
assert.equal(typeof parsed.tasks.percent, 'number');
|
|
100
|
+
assert.ok(parsed.slices);
|
|
101
|
+
assert.equal(typeof parsed.slices.total, 'number');
|
|
102
|
+
assert.equal(typeof parsed.slices.complete, 'number');
|
|
103
|
+
assert.equal(typeof parsed.slices.percent, 'number');
|
|
96
104
|
assert.ok(parsed.git);
|
|
97
105
|
assert.ok(typeof parsed.git.commits === 'number');
|
|
98
106
|
assert.ok(parsed.metrics_by_phase);
|
|
99
107
|
});
|
|
100
108
|
|
|
109
|
+
function writeTaskPlan(root, mNum, sNum, tNum, status) {
|
|
110
|
+
const mid = 'M' + String(mNum).padStart(3, '0');
|
|
111
|
+
const sid = 'S' + String(sNum).padStart(3, '0');
|
|
112
|
+
const tid = 'T' + String(tNum).padStart(4, '0');
|
|
113
|
+
const dir = path.join(root, '.nubos-pilot', 'milestones', mid, 'slices', sid, 'tasks', tid);
|
|
114
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
115
|
+
const fm = [
|
|
116
|
+
'---',
|
|
117
|
+
`id: ${mid}-${sid}-${tid}`,
|
|
118
|
+
`slice: ${mid}-${sid}`,
|
|
119
|
+
`milestone: ${mid}`,
|
|
120
|
+
'type: execute',
|
|
121
|
+
`status: ${status}`,
|
|
122
|
+
'tier: sonnet',
|
|
123
|
+
'owner: executor',
|
|
124
|
+
`wave: ${sNum}`,
|
|
125
|
+
'depends_on: []',
|
|
126
|
+
'files_modified: []',
|
|
127
|
+
'autonomous: true',
|
|
128
|
+
'must_haves: {}',
|
|
129
|
+
'---',
|
|
130
|
+
'',
|
|
131
|
+
`# ${mid}-${sid}-${tid}`,
|
|
132
|
+
'',
|
|
133
|
+
].join('\n');
|
|
134
|
+
fs.writeFileSync(path.join(dir, tid + '-PLAN.md'), fm);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test('STATS-4: tasks + slices percent reflect filesystem task status', async () => {
|
|
138
|
+
const sb = makeSandbox(DEMO_YAML, DEMO_STATE);
|
|
139
|
+
writeTaskPlan(sb, 1, 1, 1, 'done');
|
|
140
|
+
writeTaskPlan(sb, 1, 1, 2, 'done');
|
|
141
|
+
writeTaskPlan(sb, 1, 2, 1, 'done');
|
|
142
|
+
writeTaskPlan(sb, 1, 2, 2, 'pending');
|
|
143
|
+
writeTaskPlan(sb, 1, 3, 1, 'pending');
|
|
144
|
+
const stdout = makeSink();
|
|
145
|
+
const stderr = makeSink();
|
|
146
|
+
const code = await statsCli.run(['json'], { cwd: sb, stdout, stderr });
|
|
147
|
+
assert.equal(code, 0, 'stderr=' + stderr.toString());
|
|
148
|
+
const parsed = JSON.parse(stdout.toString());
|
|
149
|
+
assert.equal(parsed.tasks.total, 5);
|
|
150
|
+
assert.equal(parsed.tasks.complete, 3);
|
|
151
|
+
assert.equal(parsed.tasks.percent, 60);
|
|
152
|
+
assert.equal(parsed.slices.total, 3);
|
|
153
|
+
assert.equal(parsed.slices.complete, 1, 'only S001 has all tasks done');
|
|
154
|
+
assert.equal(parsed.slices.percent, 33);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('STATS-5: tasks + slices are 0 when nothing scaffolded', async () => {
|
|
158
|
+
const sb = makeSandbox(DEMO_YAML, DEMO_STATE);
|
|
159
|
+
const stdout = makeSink();
|
|
160
|
+
const stderr = makeSink();
|
|
161
|
+
await statsCli.run(['json'], { cwd: sb, stdout, stderr });
|
|
162
|
+
const parsed = JSON.parse(stdout.toString());
|
|
163
|
+
assert.equal(parsed.tasks.total, 0);
|
|
164
|
+
assert.equal(parsed.tasks.percent, 0);
|
|
165
|
+
assert.equal(parsed.slices.total, 0);
|
|
166
|
+
assert.equal(parsed.slices.percent, 0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('STATS-6: stats bar renders two progress rows on stdout', async () => {
|
|
170
|
+
const sb = makeSandbox(DEMO_YAML, DEMO_STATE);
|
|
171
|
+
writeTaskPlan(sb, 1, 1, 1, 'done');
|
|
172
|
+
writeTaskPlan(sb, 1, 1, 2, 'pending');
|
|
173
|
+
const stdout = makeSink();
|
|
174
|
+
const stderr = makeSink();
|
|
175
|
+
const code = await statsCli.run(['bar'], { cwd: sb, stdout, stderr });
|
|
176
|
+
assert.equal(code, 0, 'stderr=' + stderr.toString());
|
|
177
|
+
const out = stdout.toString();
|
|
178
|
+
assert.match(out, /Tasks .*\d+%/);
|
|
179
|
+
assert.match(out, /Slices.*\d+%/);
|
|
180
|
+
assert.match(out, /1\/2/);
|
|
181
|
+
});
|
|
182
|
+
|
|
101
183
|
test('STATS-2: unknown subcommand prints usage', async () => {
|
|
102
184
|
const sb = makeSandbox(DEMO_YAML, DEMO_STATE);
|
|
103
185
|
const stdout = makeSink();
|
package/np-tools.cjs
CHANGED
|
@@ -47,6 +47,7 @@ const topLevelCommands = {
|
|
|
47
47
|
'stats': require('./bin/np-tools/stats.cjs'),
|
|
48
48
|
'lang-directive': require('./bin/np-tools/lang-directive.cjs'),
|
|
49
49
|
'text-mode': require('./bin/np-tools/text-mode.cjs'),
|
|
50
|
+
'detect-runtime': require('./bin/np-tools/detect-runtime.cjs'),
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
const THRESHOLD = 16 * 1024;
|
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@ LANG_DIRECTIVE=$(node .nubos-pilot/bin/np-tools.cjs lang-directive)
|
|
|
20
20
|
INIT=$(node .nubos-pilot/bin/np-tools.cjs init execute-milestone init "$PHASE")
|
|
21
21
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
|
22
22
|
AGENT_SKILLS_EXECUTOR=$(node .nubos-pilot/bin/np-tools.cjs agent-skills executor 2>/dev/null)
|
|
23
|
-
RUNTIME=$(node -
|
|
23
|
+
RUNTIME=$(node .nubos-pilot/bin/np-tools.cjs detect-runtime)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
**Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
|
package/workflows/plan-phase.md
CHANGED
|
@@ -77,7 +77,7 @@ INIT=$(node .nubos-pilot/bin/np-tools.cjs init plan-milestone init "$PHASE")
|
|
|
77
77
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
|
78
78
|
AGENT_SKILLS_PLANNER=$(node .nubos-pilot/bin/np-tools.cjs agent-skills planner 2>/dev/null)
|
|
79
79
|
AGENT_SKILLS_CHECKER=$(node .nubos-pilot/bin/np-tools.cjs agent-skills plan-checker 2>/dev/null)
|
|
80
|
-
RUNTIME=$(node -
|
|
80
|
+
RUNTIME=$(node .nubos-pilot/bin/np-tools.cjs detect-runtime)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
**Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
|
|
@@ -95,7 +95,7 @@ payload; larger payloads are written to a tmp file and referenced via
|
|
|
95
95
|
LANG_DIRECTIVE=$(node .nubos-pilot/bin/np-tools.cjs lang-directive)
|
|
96
96
|
INIT=$(node .nubos-pilot/bin/np-tools.cjs init research-phase "$PHASE")
|
|
97
97
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
|
98
|
-
RUNTIME=$(node -
|
|
98
|
+
RUNTIME=$(node .nubos-pilot/bin/np-tools.cjs detect-runtime)
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
**Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
|
|
@@ -22,7 +22,7 @@ fi
|
|
|
22
22
|
LANG_DIRECTIVE=$(node .nubos-pilot/bin/np-tools.cjs lang-directive)
|
|
23
23
|
INIT=$(node .nubos-pilot/bin/np-tools.cjs init verify-work "$PHASE")
|
|
24
24
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
|
25
|
-
RUNTIME=$(node -
|
|
25
|
+
RUNTIME=$(node .nubos-pilot/bin/np-tools.cjs detect-runtime)
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
**Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
|