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
|
@@ -0,0 +1,120 @@
|
|
|
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('./session-aggregate.cjs');
|
|
10
|
+
|
|
11
|
+
function mkSandbox(opts) {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sa-'));
|
|
13
|
+
const state = path.join(dir, '.nubos-pilot');
|
|
14
|
+
fs.mkdirSync(path.join(state, 'metrics'), { recursive: true });
|
|
15
|
+
const records = (opts && opts.records) || [];
|
|
16
|
+
const byPhase = {};
|
|
17
|
+
for (const r of records) {
|
|
18
|
+
const key = r.phase || 'meta';
|
|
19
|
+
(byPhase[key] = byPhase[key] || []).push(r);
|
|
20
|
+
}
|
|
21
|
+
for (const key of Object.keys(byPhase)) {
|
|
22
|
+
const fname = key === 'meta' ? 'meta.jsonl' : 'phase-' + key + '.jsonl';
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
path.join(state, 'metrics', fname),
|
|
25
|
+
byPhase[key].map((r) => JSON.stringify(r)).join('\n'),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (opts && opts.pointer) {
|
|
29
|
+
fs.mkdirSync(path.join(state, 'reports'), { recursive: true });
|
|
30
|
+
fs.writeFileSync(path.join(state, 'reports', '.last-session'), opts.pointer);
|
|
31
|
+
}
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function captureStdout() {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
return {
|
|
38
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
39
|
+
read: () => chunks.join(''),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('SA-1: aggregates with no pointer (full project)', async () => {
|
|
44
|
+
const dir = mkSandbox({
|
|
45
|
+
records: [
|
|
46
|
+
{
|
|
47
|
+
schema_version: 2, started_at: '2026-04-10T10:00:00Z', phase: 'P1',
|
|
48
|
+
agent: 'a', tier: 't', resolved_model: 'm',
|
|
49
|
+
plan: 'PL1', task: 'TA1', tokens_in: 10, tokens_out: 20,
|
|
50
|
+
duration_ms: 100, status: 'ok', runtime: 'claude', retry_count: 0,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const cap = captureStdout();
|
|
56
|
+
const rc = await mod.run([], { cwd: dir, stdout: cap.stream });
|
|
57
|
+
assert.equal(rc, 0);
|
|
58
|
+
const out = JSON.parse(cap.read());
|
|
59
|
+
assert.equal(out.record_count, 1);
|
|
60
|
+
assert.equal(out.total_tokens_in, 10);
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('SA-2: --since override honoured', async () => {
|
|
67
|
+
const dir = mkSandbox({
|
|
68
|
+
records: [
|
|
69
|
+
{
|
|
70
|
+
schema_version: 2, started_at: '2026-04-10T10:00:00Z', phase: 'P1',
|
|
71
|
+
agent: 'a', tier: 't', resolved_model: 'm',
|
|
72
|
+
plan: 'PL1', task: 'TA1', tokens_in: 10, tokens_out: 20,
|
|
73
|
+
duration_ms: 100, status: 'ok', runtime: 'claude', retry_count: 0,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
schema_version: 2, started_at: '2026-04-20T10:00:00Z', phase: 'P1',
|
|
77
|
+
agent: 'a', tier: 't', resolved_model: 'm',
|
|
78
|
+
plan: 'PL1', task: 'TA2', tokens_in: 5, tokens_out: 7,
|
|
79
|
+
duration_ms: 50, status: 'ok', runtime: 'claude', retry_count: 0,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const cap = captureStdout();
|
|
85
|
+
await mod.run(['--since', '2026-04-15T00:00:00Z'], { cwd: dir, stdout: cap.stream });
|
|
86
|
+
const out = JSON.parse(cap.read());
|
|
87
|
+
assert.equal(out.record_count, 1);
|
|
88
|
+
assert.equal(out.total_tokens_in, 5);
|
|
89
|
+
} finally {
|
|
90
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('SA-3: existing pointer used when no --since override', async () => {
|
|
95
|
+
const dir = mkSandbox({
|
|
96
|
+
pointer: '2026-04-15T00:00:00Z',
|
|
97
|
+
records: [
|
|
98
|
+
{
|
|
99
|
+
schema_version: 2, started_at: '2026-04-10T10:00:00Z', phase: 'P1',
|
|
100
|
+
agent: 'a', tier: 't', resolved_model: 'm',
|
|
101
|
+
plan: 'PL1', task: 'TA1', tokens_in: 10, tokens_out: 20,
|
|
102
|
+
duration_ms: 100, status: 'ok', runtime: 'claude', retry_count: 0,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
schema_version: 2, started_at: '2026-04-20T10:00:00Z', phase: 'P1',
|
|
106
|
+
agent: 'a', tier: 't', resolved_model: 'm',
|
|
107
|
+
plan: 'PL1', task: 'TA2', tokens_in: 5, tokens_out: 7,
|
|
108
|
+
duration_ms: 50, status: 'ok', runtime: 'claude', retry_count: 0,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
const cap = captureStdout();
|
|
114
|
+
await mod.run([], { cwd: dir, stdout: cap.stream });
|
|
115
|
+
const out = JSON.parse(cap.read());
|
|
116
|
+
assert.equal(out.record_count, 1);
|
|
117
|
+
} finally {
|
|
118
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
atomicWriteFileSync,
|
|
10
|
+
} = require('../../lib/core.cjs');
|
|
11
|
+
|
|
12
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
13
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
14
|
+
|
|
15
|
+
function _parseArgs(args) {
|
|
16
|
+
const rest = [];
|
|
17
|
+
for (const a of args || []) {
|
|
18
|
+
if (!a.startsWith('-')) rest.push(a);
|
|
19
|
+
}
|
|
20
|
+
return { iso: rest[0] || null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _validateIso(raw) {
|
|
24
|
+
if (!raw) {
|
|
25
|
+
throw new NubosPilotError('session-pointer-missing-iso',
|
|
26
|
+
'ISO-8601 UTC timestamp required (e.g. 2026-04-22T12:34:56Z)', {});
|
|
27
|
+
}
|
|
28
|
+
if (!ISO_RE.test(raw)) {
|
|
29
|
+
throw new NubosPilotError('session-pointer-invalid-iso',
|
|
30
|
+
'timestamp must be ISO-8601 UTC (YYYY-MM-DDTHH:MM:SSZ)', { iso: raw });
|
|
31
|
+
}
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _pointerPath(cwd) {
|
|
36
|
+
return path.join(projectStateDir(cwd), 'reports', '.last-session');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function run(args, opts) {
|
|
40
|
+
const o = opts || {};
|
|
41
|
+
const cwd = o.cwd || process.cwd();
|
|
42
|
+
const stdout = o.stdout || process.stdout;
|
|
43
|
+
const parsed = _parseArgs(args || []);
|
|
44
|
+
const iso = _validateIso(parsed.iso);
|
|
45
|
+
|
|
46
|
+
const pointer = _pointerPath(cwd);
|
|
47
|
+
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
48
|
+
|
|
49
|
+
withFileLock(pointer, () => atomicWriteFileSync(pointer, iso), { timeoutMs: LOCK_TIMEOUT_MS });
|
|
50
|
+
|
|
51
|
+
stdout.write(JSON.stringify({ ok: true, pointer, iso }));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { run, _parseArgs, _validateIso, _pointerPath };
|
|
@@ -0,0 +1,62 @@
|
|
|
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('./session-pointer-write.cjs');
|
|
10
|
+
|
|
11
|
+
function mkSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-spw-'));
|
|
13
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function captureStdout() {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
return {
|
|
20
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
21
|
+
read: () => chunks.join(''),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('SPW-1: writes pointer atomically', () => {
|
|
26
|
+
const dir = mkSandbox();
|
|
27
|
+
try {
|
|
28
|
+
const cap = captureStdout();
|
|
29
|
+
const iso = '2026-04-22T12:34:56Z';
|
|
30
|
+
const rc = mod.run([iso], { cwd: dir, stdout: cap.stream });
|
|
31
|
+
assert.equal(rc, 0);
|
|
32
|
+
const written = fs.readFileSync(path.join(dir, '.nubos-pilot', 'reports', '.last-session'), 'utf-8');
|
|
33
|
+
assert.equal(written, iso);
|
|
34
|
+
} finally {
|
|
35
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('SPW-2: missing iso throws', () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => mod.run([], { cwd: '/tmp', stdout: { write: () => {} } }),
|
|
42
|
+
(err) => err.code === 'session-pointer-missing-iso',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('SPW-3: invalid iso throws', () => {
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => mod._validateIso('not-a-date'),
|
|
49
|
+
(err) => err.code === 'session-pointer-invalid-iso',
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('SPW-4: accepts fractional seconds', () => {
|
|
54
|
+
assert.equal(mod._validateIso('2026-04-22T12:34:56.789Z'), '2026-04-22T12:34:56.789Z');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('SPW-5: rejects missing Z suffix', () => {
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => mod._validateIso('2026-04-22T12:34:56'),
|
|
60
|
+
(err) => err.code === 'session-pointer-invalid-iso',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { projectStateDir, NubosPilotError } = require('../../lib/core.cjs');
|
|
5
|
+
|
|
6
|
+
const SUBDIR_RE = /^[a-zA-Z0-9._-][a-zA-Z0-9._/-]*$/;
|
|
7
|
+
|
|
8
|
+
function _parseArgs(args) {
|
|
9
|
+
const out = { subdir: null };
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
const a = args[i];
|
|
12
|
+
if (a === '--subdir' || a === '-s') { out.subdir = args[++i] || null; continue; }
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function _validateSubdir(raw) {
|
|
18
|
+
if (raw == null) return null;
|
|
19
|
+
const s = String(raw);
|
|
20
|
+
if (s.includes('..')) {
|
|
21
|
+
throw new NubosPilotError('state-dir-invalid-subdir',
|
|
22
|
+
'subdir must not contain ".."', { subdir: raw });
|
|
23
|
+
}
|
|
24
|
+
if (path.isAbsolute(s)) {
|
|
25
|
+
throw new NubosPilotError('state-dir-invalid-subdir',
|
|
26
|
+
'subdir must be relative', { subdir: raw });
|
|
27
|
+
}
|
|
28
|
+
if (!SUBDIR_RE.test(s)) {
|
|
29
|
+
throw new NubosPilotError('state-dir-invalid-subdir',
|
|
30
|
+
'subdir contains forbidden characters', { subdir: raw });
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(args, opts) {
|
|
36
|
+
const o = opts || {};
|
|
37
|
+
const cwd = o.cwd || process.cwd();
|
|
38
|
+
const stdout = o.stdout || process.stdout;
|
|
39
|
+
const parsed = _parseArgs(args || []);
|
|
40
|
+
const subdir = _validateSubdir(parsed.subdir);
|
|
41
|
+
const base = projectStateDir(cwd);
|
|
42
|
+
const out = subdir == null ? base : path.join(base, subdir);
|
|
43
|
+
stdout.write(out);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { run, _parseArgs, _validateSubdir };
|
|
@@ -0,0 +1,72 @@
|
|
|
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('./state-dir.cjs');
|
|
10
|
+
|
|
11
|
+
function mkSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sd-'));
|
|
13
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function captureStdout() {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
return {
|
|
20
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
21
|
+
read: () => chunks.join(''),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('SD-1: run() without --subdir returns .nubos-pilot path', () => {
|
|
26
|
+
const dir = mkSandbox();
|
|
27
|
+
try {
|
|
28
|
+
const cap = captureStdout();
|
|
29
|
+
const rc = mod.run([], { cwd: dir, stdout: cap.stream });
|
|
30
|
+
assert.equal(rc, 0);
|
|
31
|
+
assert.equal(cap.read(), path.join(dir, '.nubos-pilot'));
|
|
32
|
+
} finally {
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('SD-2: --subdir notes appends the segment', () => {
|
|
38
|
+
const dir = mkSandbox();
|
|
39
|
+
try {
|
|
40
|
+
const cap = captureStdout();
|
|
41
|
+
const rc = mod.run(['--subdir', 'notes'], { cwd: dir, stdout: cap.stream });
|
|
42
|
+
assert.equal(rc, 0);
|
|
43
|
+
assert.equal(cap.read(), path.join(dir, '.nubos-pilot', 'notes'));
|
|
44
|
+
} finally {
|
|
45
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('SD-3: .. in subdir rejected', () => {
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => mod._validateSubdir('../etc'),
|
|
52
|
+
(err) => err.code === 'state-dir-invalid-subdir',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('SD-4: absolute subdir rejected', () => {
|
|
57
|
+
assert.throws(
|
|
58
|
+
() => mod._validateSubdir('/etc/passwd'),
|
|
59
|
+
(err) => err.code === 'state-dir-invalid-subdir',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('SD-5: special chars rejected', () => {
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => mod._validateSubdir('x$(whoami)'),
|
|
66
|
+
(err) => err.code === 'state-dir-invalid-subdir',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('SD-6: nested slash allowed (threads/open)', () => {
|
|
71
|
+
assert.equal(mod._validateSubdir('threads/open'), 'threads/open');
|
|
72
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const { mutateState } = require('../../lib/state.cjs');
|
|
5
|
+
|
|
6
|
+
const ALLOWED_KEYS = new Set(['pending_todos']);
|
|
7
|
+
|
|
8
|
+
function _parseArgs(args) {
|
|
9
|
+
const rest = [];
|
|
10
|
+
for (const a of args || []) {
|
|
11
|
+
if (!a.startsWith('-')) rest.push(a);
|
|
12
|
+
}
|
|
13
|
+
return { key: rest[0] || null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function run(args, opts) {
|
|
17
|
+
const o = opts || {};
|
|
18
|
+
const cwd = o.cwd || process.cwd();
|
|
19
|
+
const stdout = o.stdout || process.stdout;
|
|
20
|
+
const parsed = _parseArgs(args || []);
|
|
21
|
+
|
|
22
|
+
if (!parsed.key) {
|
|
23
|
+
throw new NubosPilotError('state-incr-missing-key',
|
|
24
|
+
'state counter key required', { allowed: Array.from(ALLOWED_KEYS) });
|
|
25
|
+
}
|
|
26
|
+
if (!ALLOWED_KEYS.has(parsed.key)) {
|
|
27
|
+
throw new NubosPilotError('state-incr-unknown-key',
|
|
28
|
+
'state counter key not in whitelist: ' + parsed.key,
|
|
29
|
+
{ key: parsed.key, allowed: Array.from(ALLOWED_KEYS) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const next = mutateState((doc) => {
|
|
33
|
+
const current = doc.frontmatter[parsed.key];
|
|
34
|
+
const asNumber = typeof current === 'number' && Number.isFinite(current) ? current : 0;
|
|
35
|
+
doc.frontmatter[parsed.key] = asNumber + 1;
|
|
36
|
+
return doc;
|
|
37
|
+
}, cwd);
|
|
38
|
+
|
|
39
|
+
stdout.write(JSON.stringify({ ok: true, key: parsed.key, value: next.frontmatter[parsed.key] }));
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { run, _parseArgs, ALLOWED_KEYS };
|
|
@@ -0,0 +1,100 @@
|
|
|
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('./state-incr.cjs');
|
|
10
|
+
|
|
11
|
+
const STATE_SEED = [
|
|
12
|
+
'---',
|
|
13
|
+
'schema_version: 2',
|
|
14
|
+
'milestone: null',
|
|
15
|
+
'milestone_name: null',
|
|
16
|
+
'current_phase: null',
|
|
17
|
+
'current_plan: null',
|
|
18
|
+
'current_task: null',
|
|
19
|
+
'last_updated: null',
|
|
20
|
+
'progress:',
|
|
21
|
+
' total_phases: 0',
|
|
22
|
+
' completed_phases: 0',
|
|
23
|
+
' total_plans: 0',
|
|
24
|
+
' completed_plans: 0',
|
|
25
|
+
' percent: 0',
|
|
26
|
+
'session:',
|
|
27
|
+
' stopped_at: null',
|
|
28
|
+
' resume_file: null',
|
|
29
|
+
' last_activity: null',
|
|
30
|
+
'---',
|
|
31
|
+
'',
|
|
32
|
+
'# State',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
function mkSandbox(withCounter) {
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-si-'));
|
|
38
|
+
fs.mkdirSync(path.join(dir, '.nubos-pilot'));
|
|
39
|
+
let content = STATE_SEED;
|
|
40
|
+
if (withCounter != null) {
|
|
41
|
+
content = content.replace('schema_version: 2', 'schema_version: 2\npending_todos: ' + withCounter);
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(path.join(dir, '.nubos-pilot', 'STATE.md'), content);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function captureStdout() {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
return {
|
|
50
|
+
stream: { write: (c) => { chunks.push(c); } },
|
|
51
|
+
read: () => chunks.join(''),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('SI-1: first increment sets pending_todos to 1', () => {
|
|
56
|
+
const dir = mkSandbox();
|
|
57
|
+
try {
|
|
58
|
+
const cap = captureStdout();
|
|
59
|
+
const rc = mod.run(['pending_todos'], { cwd: dir, stdout: cap.stream });
|
|
60
|
+
assert.equal(rc, 0);
|
|
61
|
+
const out = JSON.parse(cap.read());
|
|
62
|
+
assert.equal(out.ok, true);
|
|
63
|
+
assert.equal(out.value, 1);
|
|
64
|
+
const written = fs.readFileSync(path.join(dir, '.nubos-pilot', 'STATE.md'), 'utf-8');
|
|
65
|
+
assert.match(written, /pending_todos: 1/);
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('SI-2: increments existing counter', () => {
|
|
72
|
+
const dir = mkSandbox(3);
|
|
73
|
+
try {
|
|
74
|
+
const cap = captureStdout();
|
|
75
|
+
mod.run(['pending_todos'], { cwd: dir, stdout: cap.stream });
|
|
76
|
+
const out = JSON.parse(cap.read());
|
|
77
|
+
assert.equal(out.value, 4);
|
|
78
|
+
} finally {
|
|
79
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('SI-3: unknown key rejected', () => {
|
|
84
|
+
const dir = mkSandbox();
|
|
85
|
+
try {
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => mod.run(['bogus'], { cwd: dir, stdout: { write: () => {} } }),
|
|
88
|
+
(err) => err.code === 'state-incr-unknown-key',
|
|
89
|
+
);
|
|
90
|
+
} finally {
|
|
91
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('SI-4: missing key rejected', () => {
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => mod.run([], { cwd: '/tmp', stdout: { write: () => {} } }),
|
|
98
|
+
(err) => err.code === 'state-incr-missing-key',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { NubosPilotError, atomicWriteFileSync } = require('../../lib/core.cjs');
|
|
6
|
+
const { extractFrontmatter } = require('../../lib/frontmatter.cjs');
|
|
7
|
+
|
|
8
|
+
const FRONTMATTER_ORDER = ['slug', 'status', 'created', 'last_resumed'];
|
|
9
|
+
|
|
10
|
+
function _parseArgs(args) {
|
|
11
|
+
const out = { path: null, today: null };
|
|
12
|
+
const rest = [];
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const a = args[i];
|
|
15
|
+
if (!a.startsWith('-')) { rest.push(a); continue; }
|
|
16
|
+
if (a === '--today' || a === '-t') { out.today = args[++i] || null; continue; }
|
|
17
|
+
}
|
|
18
|
+
if (rest.length) out.path = rest[0];
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _bumpStatus(cur) {
|
|
23
|
+
const s = String(cur || 'OPEN');
|
|
24
|
+
if (s === 'OPEN') return 'IN_PROGRESS';
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _serialize(fm, body) {
|
|
29
|
+
const lines = ['---'];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (const k of FRONTMATTER_ORDER) {
|
|
32
|
+
if (k in fm) { lines.push(k + ': ' + fm[k]); seen.add(k); }
|
|
33
|
+
}
|
|
34
|
+
for (const k of Object.keys(fm)) {
|
|
35
|
+
if (!seen.has(k)) lines.push(k + ': ' + fm[k]);
|
|
36
|
+
}
|
|
37
|
+
lines.push('---');
|
|
38
|
+
return lines.join('\n') + '\n' + body;
|
|
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
|
+
|
|
47
|
+
if (!parsed.path) {
|
|
48
|
+
throw new NubosPilotError('thread-resume-missing-path',
|
|
49
|
+
'thread path required', {});
|
|
50
|
+
}
|
|
51
|
+
const today = parsed.today || new Date().toISOString().slice(0, 10);
|
|
52
|
+
|
|
53
|
+
const resolved = path.resolve(cwd, parsed.path);
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = fs.readFileSync(resolved, 'utf-8');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new NubosPilotError('thread-resume-read-error',
|
|
59
|
+
'cannot read thread file: ' + (err && err.message),
|
|
60
|
+
{ path: resolved, cause: err && err.code });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let parts;
|
|
64
|
+
try {
|
|
65
|
+
parts = extractFrontmatter(raw);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new NubosPilotError('thread-resume-parse-error',
|
|
68
|
+
'thread frontmatter invalid: ' + (err && err.message),
|
|
69
|
+
{ path: resolved, cause: err && err.code });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fm = Object.assign({}, parts.frontmatter);
|
|
73
|
+
fm.status = _bumpStatus(fm.status);
|
|
74
|
+
fm.last_resumed = today;
|
|
75
|
+
|
|
76
|
+
const out = _serialize(fm, parts.body);
|
|
77
|
+
atomicWriteFileSync(resolved, out);
|
|
78
|
+
|
|
79
|
+
stdout.write(JSON.stringify({ ok: true, path: resolved, status: fm.status, last_resumed: fm.last_resumed }));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { run, _parseArgs, _bumpStatus, _serialize, FRONTMATTER_ORDER };
|