nubos-pilot 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/np-sc-extractor.md +85 -0
- package/bin/install.js +83 -0
- package/bin/np-tools/_commands.cjs +1 -0
- package/bin/np-tools/discuss-phase.test.cjs +3 -3
- package/bin/np-tools/text-mode.test.cjs +9 -6
- package/bin/np-tools/update-phase-meta.cjs +77 -0
- package/bin/np-tools/update-phase-meta.test.cjs +132 -0
- package/lib/agents.test.cjs +1 -0
- package/lib/install/claude-hooks.cjs +195 -0
- package/lib/install/claude-hooks.test.cjs +163 -0
- package/lib/roadmap.cjs +107 -0
- package/lib/roadmap.test.cjs +84 -0
- package/lib/text-mode.cjs +1 -1
- package/lib/text-mode.test.cjs +11 -22
- package/np-tools.cjs +1 -0
- package/package.json +1 -1
- package/templates/claude/payload/hooks/np-ctx-monitor.js +94 -0
- package/templates/claude/payload/hooks/np-statusline.js +140 -0
- package/workflows/add-todo.md +4 -4
- package/workflows/discuss-phase.md +76 -44
- package/workflows/discuss-project.md +5 -0
- package/workflows/execute-phase.md +4 -5
- package/workflows/new-milestone.md +4 -5
- package/workflows/new-project.md +4 -5
- package/workflows/note.md +5 -5
- package/workflows/plan-phase.md +27 -4
- package/workflows/propose-milestones.md +4 -1
- package/workflows/research-phase.md +4 -4
- package/workflows/resume-work.md +4 -4
- package/workflows/session-report.md +4 -4
- package/workflows/validate-phase.md +4 -4
- package/workflows/verify-work.md +4 -5
|
@@ -0,0 +1,163 @@
|
|
|
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 path = require('node:path');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
|
|
9
|
+
const mod = require('./claude-hooks.cjs');
|
|
10
|
+
|
|
11
|
+
function _mkSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-'));
|
|
13
|
+
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
|
14
|
+
fs.mkdirSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks'), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks', 'np-statusline.js'), '// stub\n');
|
|
16
|
+
fs.writeFileSync(path.join(dir, '.claude', 'nubos-pilot', 'hooks', 'np-ctx-monitor.js'), '// stub\n');
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('claude-hooks: fresh install writes both hooks to local settings', () => {
|
|
21
|
+
const dir = _mkSandbox();
|
|
22
|
+
try {
|
|
23
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
24
|
+
assert.equal(res.dryRun, false);
|
|
25
|
+
assert.equal(res.results.statusline.action, 'installed');
|
|
26
|
+
assert.equal(res.results.ctxMonitor.action, 'installed');
|
|
27
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
28
|
+
assert.equal(settings.statusLine.type, 'command');
|
|
29
|
+
assert.ok(settings.statusLine.command.includes('np-statusline.js'));
|
|
30
|
+
assert.ok(Array.isArray(settings.hooks.PostToolUse));
|
|
31
|
+
assert.equal(settings.hooks.PostToolUse[0].matcher, '.*');
|
|
32
|
+
assert.ok(settings.hooks.PostToolUse[0].hooks[0].command.includes('np-ctx-monitor.js'));
|
|
33
|
+
} finally {
|
|
34
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('claude-hooks: existing foreign statusLine is preserved without force', () => {
|
|
39
|
+
const dir = _mkSandbox();
|
|
40
|
+
try {
|
|
41
|
+
const settingsPath = path.join(dir, '.claude', 'settings.local.json');
|
|
42
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
43
|
+
statusLine: { type: 'command', command: 'echo my-custom-bar' },
|
|
44
|
+
}));
|
|
45
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
46
|
+
assert.equal(res.results.statusline.action, 'skipped-existing');
|
|
47
|
+
assert.equal(res.results.statusline.existingCommand, 'echo my-custom-bar');
|
|
48
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
49
|
+
assert.equal(settings.statusLine.command, 'echo my-custom-bar');
|
|
50
|
+
} finally {
|
|
51
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('claude-hooks: --force overwrites foreign statusLine', () => {
|
|
56
|
+
const dir = _mkSandbox();
|
|
57
|
+
try {
|
|
58
|
+
const settingsPath = path.join(dir, '.claude', 'settings.local.json');
|
|
59
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
60
|
+
statusLine: { type: 'command', command: 'echo other' },
|
|
61
|
+
}));
|
|
62
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', force: true });
|
|
63
|
+
assert.equal(res.results.statusline.action, 'overwrote');
|
|
64
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
65
|
+
assert.ok(settings.statusLine.command.includes('np-statusline.js'));
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('claude-hooks: re-install is idempotent (updates nubos-pilot hook path)', () => {
|
|
72
|
+
const dir = _mkSandbox();
|
|
73
|
+
try {
|
|
74
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
75
|
+
const res2 = mod.installClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
76
|
+
assert.equal(res2.results.statusline.action, 'updated');
|
|
77
|
+
assert.equal(res2.results.ctxMonitor.action, 'updated');
|
|
78
|
+
const settings = JSON.parse(fs.readFileSync(res2.path, 'utf-8'));
|
|
79
|
+
assert.equal(settings.hooks.PostToolUse.length, 1);
|
|
80
|
+
} finally {
|
|
81
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('claude-hooks: preserves unrelated PostToolUse hooks', () => {
|
|
86
|
+
const dir = _mkSandbox();
|
|
87
|
+
try {
|
|
88
|
+
const settingsPath = path.join(dir, '.claude', 'settings.local.json');
|
|
89
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
90
|
+
hooks: {
|
|
91
|
+
PostToolUse: [
|
|
92
|
+
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo other-hook' }] },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'ctx-monitor' });
|
|
97
|
+
assert.equal(res.results.ctxMonitor.action, 'installed');
|
|
98
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
99
|
+
assert.equal(settings.hooks.PostToolUse.length, 2);
|
|
100
|
+
assert.equal(settings.hooks.PostToolUse[0].matcher, 'Bash');
|
|
101
|
+
assert.ok(settings.hooks.PostToolUse[1].hooks[0].command.includes('np-ctx-monitor.js'));
|
|
102
|
+
} finally {
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('claude-hooks: uninstall removes only our entries', () => {
|
|
108
|
+
const dir = _mkSandbox();
|
|
109
|
+
try {
|
|
110
|
+
const settingsPath = path.join(dir, '.claude', 'settings.local.json');
|
|
111
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
112
|
+
statusLine: { type: 'command', command: 'echo custom' },
|
|
113
|
+
hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo foreign' }] }] },
|
|
114
|
+
}));
|
|
115
|
+
mod.installClaudeHooks({ projectRoot: dir, scope: 'local', which: 'ctx-monitor' });
|
|
116
|
+
const res = mod.uninstallClaudeHooks({ projectRoot: dir, scope: 'local' });
|
|
117
|
+
assert.equal(res.results.ctxMonitor.action, 'removed');
|
|
118
|
+
assert.equal(res.results.statusline.action, 'not-ours');
|
|
119
|
+
const settings = JSON.parse(fs.readFileSync(res.path, 'utf-8'));
|
|
120
|
+
assert.equal(settings.statusLine.command, 'echo custom');
|
|
121
|
+
assert.equal(settings.hooks.PostToolUse.length, 1);
|
|
122
|
+
assert.equal(settings.hooks.PostToolUse[0].matcher, 'Bash');
|
|
123
|
+
} finally {
|
|
124
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('claude-hooks: missing hook script throws structured error', () => {
|
|
129
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-claude-hooks-no-scripts-'));
|
|
130
|
+
try {
|
|
131
|
+
assert.throws(
|
|
132
|
+
() => mod.installClaudeHooks({ projectRoot: dir, scope: 'local' }),
|
|
133
|
+
(err) => err && err.code === 'claude-hooks-script-missing',
|
|
134
|
+
);
|
|
135
|
+
} finally {
|
|
136
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('claude-hooks: dryRun returns planned settings without writing', () => {
|
|
141
|
+
const dir = _mkSandbox();
|
|
142
|
+
try {
|
|
143
|
+
const res = mod.installClaudeHooks({ projectRoot: dir, scope: 'local', dryRun: true });
|
|
144
|
+
assert.equal(res.dryRun, true);
|
|
145
|
+
assert.ok(res.settings.statusLine);
|
|
146
|
+
assert.equal(fs.existsSync(res.path), false);
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('claude-hooks: invalid JSON in settings yields structured error', () => {
|
|
153
|
+
const dir = _mkSandbox();
|
|
154
|
+
try {
|
|
155
|
+
fs.writeFileSync(path.join(dir, '.claude', 'settings.local.json'), '{broken');
|
|
156
|
+
assert.throws(
|
|
157
|
+
() => mod.installClaudeHooks({ projectRoot: dir, scope: 'local' }),
|
|
158
|
+
(err) => err && err.code === 'claude-settings-invalid-json',
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
});
|
package/lib/roadmap.cjs
CHANGED
|
@@ -413,6 +413,112 @@ function addBacklogEntry(description, opts) {
|
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
function _normalizeScList(scs) {
|
|
417
|
+
if (!Array.isArray(scs)) {
|
|
418
|
+
throw new NubosPilotError(
|
|
419
|
+
'roadmap-invalid-success-criteria',
|
|
420
|
+
'success_criteria must be an array',
|
|
421
|
+
{ received: typeof scs },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const out = [];
|
|
425
|
+
for (let i = 0; i < scs.length; i++) {
|
|
426
|
+
const sc = scs[i];
|
|
427
|
+
if (typeof sc === 'string') {
|
|
428
|
+
const s = sc.trim();
|
|
429
|
+
if (!s) {
|
|
430
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
431
|
+
'success_criteria[' + i + '] must be non-empty', { index: i });
|
|
432
|
+
}
|
|
433
|
+
out.push(s);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (sc && typeof sc === 'object') {
|
|
437
|
+
const id = typeof sc.id === 'string' ? sc.id.trim() : '';
|
|
438
|
+
const text = typeof sc.text === 'string' ? sc.text.trim() : '';
|
|
439
|
+
if (!id || !/^SC-\d+$/.test(id)) {
|
|
440
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
441
|
+
'success_criteria[' + i + '].id must match /^SC-\\d+$/', { index: i, id: sc.id });
|
|
442
|
+
}
|
|
443
|
+
if (!text) {
|
|
444
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
445
|
+
'success_criteria[' + i + '].text must be non-empty', { index: i });
|
|
446
|
+
}
|
|
447
|
+
out.push({ id, text });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
throw new NubosPilotError('roadmap-invalid-success-criteria',
|
|
451
|
+
'success_criteria[' + i + '] must be string or {id,text}', { index: i });
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function _normalizeReqList(reqs) {
|
|
457
|
+
if (!Array.isArray(reqs)) {
|
|
458
|
+
throw new NubosPilotError('roadmap-invalid-requirements',
|
|
459
|
+
'requirements must be an array', { received: typeof reqs });
|
|
460
|
+
}
|
|
461
|
+
return reqs.map((r, i) => {
|
|
462
|
+
if (typeof r !== 'string' || !r.trim()) {
|
|
463
|
+
throw new NubosPilotError('roadmap-invalid-requirements',
|
|
464
|
+
'requirements[' + i + '] must be non-empty string', { index: i });
|
|
465
|
+
}
|
|
466
|
+
return r.trim();
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function updatePhase(n, patch, cwd = process.cwd()) {
|
|
471
|
+
const want = String(n);
|
|
472
|
+
const p = patch || {};
|
|
473
|
+
const allowed = ['name', 'goal', 'requirements', 'success_criteria'];
|
|
474
|
+
const unknown = Object.keys(p).filter((k) => !allowed.includes(k));
|
|
475
|
+
if (unknown.length) {
|
|
476
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
477
|
+
'updatePhase: unknown keys: ' + unknown.join(', '),
|
|
478
|
+
{ unknown, allowed });
|
|
479
|
+
}
|
|
480
|
+
const prepared = {};
|
|
481
|
+
if ('name' in p) {
|
|
482
|
+
if (typeof p.name !== 'string' || !p.name.trim()) {
|
|
483
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
484
|
+
'name must be non-empty string', {});
|
|
485
|
+
}
|
|
486
|
+
prepared.name = p.name.trim();
|
|
487
|
+
}
|
|
488
|
+
if ('goal' in p) {
|
|
489
|
+
if (typeof p.goal !== 'string') {
|
|
490
|
+
throw new NubosPilotError('roadmap-invalid-patch',
|
|
491
|
+
'goal must be string', {});
|
|
492
|
+
}
|
|
493
|
+
prepared.goal = p.goal;
|
|
494
|
+
}
|
|
495
|
+
if ('requirements' in p) prepared.requirements = _normalizeReqList(p.requirements);
|
|
496
|
+
if ('success_criteria' in p) prepared.success_criteria = _normalizeScList(p.success_criteria);
|
|
497
|
+
|
|
498
|
+
return _mutate(cwd, (doc) => {
|
|
499
|
+
let target = null;
|
|
500
|
+
for (const ms of doc.milestones) {
|
|
501
|
+
if (!ms) continue;
|
|
502
|
+
if (Array.isArray(ms.slices) && String(ms.number) === want) { target = ms; break; }
|
|
503
|
+
if (Array.isArray(ms.phases)) {
|
|
504
|
+
const hit = ms.phases.find((ph) => ph && String(ph.number) === want);
|
|
505
|
+
if (hit) { target = hit; break; }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!target) {
|
|
509
|
+
throw new NubosPilotError('phase-not-found',
|
|
510
|
+
'Phase ' + want + ' not found in roadmap.yaml',
|
|
511
|
+
{ requested: want });
|
|
512
|
+
}
|
|
513
|
+
const updated = [];
|
|
514
|
+
for (const k of Object.keys(prepared)) {
|
|
515
|
+
target[k] = prepared[k];
|
|
516
|
+
updated.push(k);
|
|
517
|
+
}
|
|
518
|
+
return { number: want, name: target.name || '', fields_updated: updated };
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
416
522
|
function collapseMilestone(milestoneId, opts) {
|
|
417
523
|
const cwd = (opts && opts.cwd) || process.cwd();
|
|
418
524
|
if (typeof milestoneId !== 'string' || !/^[vV0-9._-]+$/.test(milestoneId)) {
|
|
@@ -446,6 +552,7 @@ module.exports = {
|
|
|
446
552
|
addMilestone,
|
|
447
553
|
addPhase,
|
|
448
554
|
insertPhaseAfter,
|
|
555
|
+
updatePhase,
|
|
449
556
|
addBacklogEntry,
|
|
450
557
|
collapseMilestone,
|
|
451
558
|
};
|
package/lib/roadmap.test.cjs
CHANGED
|
@@ -369,3 +369,87 @@ test('ROAD-COLLAPSE-3: unknown milestoneId throws roadmap-milestone-not-found',
|
|
|
369
369
|
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-milestone-not-found',
|
|
370
370
|
);
|
|
371
371
|
});
|
|
372
|
+
|
|
373
|
+
const WRITE_SEED_TOP_LEVEL = [
|
|
374
|
+
'schema_version: 1',
|
|
375
|
+
'milestones:',
|
|
376
|
+
' - id: M001',
|
|
377
|
+
' number: 1',
|
|
378
|
+
' name: Auth',
|
|
379
|
+
' goal: log users in',
|
|
380
|
+
' status: pending',
|
|
381
|
+
' requirements: []',
|
|
382
|
+
' success_criteria: []',
|
|
383
|
+
' slices: []',
|
|
384
|
+
' - id: M002',
|
|
385
|
+
' number: 2',
|
|
386
|
+
' name: Voice',
|
|
387
|
+
' goal: voice pipeline',
|
|
388
|
+
' status: pending',
|
|
389
|
+
' requirements: []',
|
|
390
|
+
' success_criteria: []',
|
|
391
|
+
' slices: []',
|
|
392
|
+
'',
|
|
393
|
+
].join('\n');
|
|
394
|
+
|
|
395
|
+
test('RM-UPDATE-1: updatePhase writes success_criteria to nested milestone.phases[]', () => {
|
|
396
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
397
|
+
const res = roadmap.updatePhase(1, {
|
|
398
|
+
success_criteria: [{ id: 'SC-1', text: 'scaffold exists' }, { id: 'SC-2', text: 'ADRs committed' }],
|
|
399
|
+
}, sandbox);
|
|
400
|
+
assert.deepEqual(res.fields_updated, ['success_criteria']);
|
|
401
|
+
const p = roadmap.getPhase(1, sandbox);
|
|
402
|
+
assert.equal(p.success_criteria.length, 2);
|
|
403
|
+
assert.equal(p.success_criteria[0].id, 'SC-1');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('RM-UPDATE-2: updatePhase writes to top-level milestone (new-style roadmap)', () => {
|
|
407
|
+
const sandbox = makeSandbox(WRITE_SEED_TOP_LEVEL);
|
|
408
|
+
roadmap.updatePhase(2, {
|
|
409
|
+
success_criteria: ['Speaker ID works', 'Latency < 2s'],
|
|
410
|
+
requirements: ['REQ-01'],
|
|
411
|
+
}, sandbox);
|
|
412
|
+
const p = roadmap.getPhase(2, sandbox);
|
|
413
|
+
assert.deepEqual(p.success_criteria, ['Speaker ID works', 'Latency < 2s']);
|
|
414
|
+
assert.deepEqual(p.requirements, ['REQ-01']);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('RM-UPDATE-3: unknown phase number throws phase-not-found', () => {
|
|
418
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
419
|
+
assert.throws(
|
|
420
|
+
() => roadmap.updatePhase(99, { success_criteria: ['x'] }, sandbox),
|
|
421
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'phase-not-found',
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('RM-UPDATE-4: invalid SC id format throws roadmap-invalid-success-criteria', () => {
|
|
426
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
427
|
+
assert.throws(
|
|
428
|
+
() => roadmap.updatePhase(1, { success_criteria: [{ id: 'BAD', text: 'x' }] }, sandbox),
|
|
429
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-invalid-success-criteria',
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('RM-UPDATE-5: unknown patch key throws roadmap-invalid-patch', () => {
|
|
434
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
435
|
+
assert.throws(
|
|
436
|
+
() => roadmap.updatePhase(1, { status: 'done' }, sandbox),
|
|
437
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'roadmap-invalid-patch',
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('RM-UPDATE-6: partial patch — only updates given fields', () => {
|
|
442
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
443
|
+
roadmap.updatePhase(1, { goal: 'new goal text' }, sandbox);
|
|
444
|
+
const p = roadmap.getPhase(1, sandbox);
|
|
445
|
+
assert.equal(p.goal, 'new goal text');
|
|
446
|
+
assert.equal(p.success_criteria.length, 0, 'SCs untouched');
|
|
447
|
+
assert.equal(p.name, 'Foundation', 'name untouched');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('RM-UPDATE-7: re-renders ROADMAP.md alongside roadmap.yaml', () => {
|
|
451
|
+
const sandbox = makeSandbox(WRITE_SEED);
|
|
452
|
+
roadmap.updatePhase(1, { success_criteria: ['check 1'] }, sandbox);
|
|
453
|
+
const md = fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'ROADMAP.md'), 'utf-8');
|
|
454
|
+
assert.ok(md.includes('check 1'), 'ROADMAP.md re-rendered with new SC');
|
|
455
|
+
});
|
package/lib/text-mode.cjs
CHANGED
|
@@ -5,7 +5,7 @@ const path = require('node:path');
|
|
|
5
5
|
const { findProjectRoot, NubosPilotError } = require('./core.cjs');
|
|
6
6
|
|
|
7
7
|
const DEFAULT_TEXT_MODE = false;
|
|
8
|
-
const CLAUDE_ENV_KEYS = [
|
|
8
|
+
const CLAUDE_ENV_KEYS = [];
|
|
9
9
|
|
|
10
10
|
function _coerceBool(raw) {
|
|
11
11
|
if (raw === true || raw === false) return raw;
|
package/lib/text-mode.test.cjs
CHANGED
|
@@ -29,32 +29,21 @@ test('text-mode: default without config or runtime env is false', () => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test('text-mode: CLAUDECODE=1
|
|
32
|
+
test('text-mode: CLAUDECODE=1 no longer flips to true (Claude Code uses AskUserQuestion)', () => {
|
|
33
33
|
const dir = _mkSandbox();
|
|
34
34
|
try {
|
|
35
|
-
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }),
|
|
35
|
+
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), false);
|
|
36
36
|
const detail = tm.resolveTextModeDetail(dir, { CLAUDECODE: '1' });
|
|
37
|
-
assert.deepEqual(detail, { enabled:
|
|
38
|
-
} finally {
|
|
39
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('text-mode: CLAUDE_CODE_ENTRYPOINT non-empty flips to true', () => {
|
|
44
|
-
const dir = _mkSandbox();
|
|
45
|
-
try {
|
|
46
|
-
assert.equal(tm.resolveTextMode(dir, { CLAUDE_CODE_ENTRYPOINT: 'cli' }), true);
|
|
37
|
+
assert.deepEqual(detail, { enabled: false, source: 'default' });
|
|
47
38
|
} finally {
|
|
48
39
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
49
40
|
}
|
|
50
41
|
});
|
|
51
42
|
|
|
52
|
-
test('text-mode:
|
|
43
|
+
test('text-mode: CLAUDE_CODE_ENTRYPOINT no longer flips to true', () => {
|
|
53
44
|
const dir = _mkSandbox();
|
|
54
45
|
try {
|
|
55
|
-
assert.equal(tm.resolveTextMode(dir, {
|
|
56
|
-
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: 'false' }), false);
|
|
57
|
-
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '' }), false);
|
|
46
|
+
assert.equal(tm.resolveTextMode(dir, { CLAUDE_CODE_ENTRYPOINT: 'cli' }), false);
|
|
58
47
|
} finally {
|
|
59
48
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
60
49
|
}
|
|
@@ -71,7 +60,7 @@ test('text-mode: config workflow.text_mode=true wins over absent runtime', () =>
|
|
|
71
60
|
}
|
|
72
61
|
});
|
|
73
62
|
|
|
74
|
-
test('text-mode: config workflow.text_mode=false
|
|
63
|
+
test('text-mode: config workflow.text_mode=false stays false under CLAUDECODE env', () => {
|
|
75
64
|
const dir = _mkSandbox();
|
|
76
65
|
try {
|
|
77
66
|
_writeConfig(dir, { workflow: { text_mode: false } });
|
|
@@ -97,11 +86,11 @@ test('text-mode: config workflow.text_mode="true" string coerced to boolean', ()
|
|
|
97
86
|
}
|
|
98
87
|
});
|
|
99
88
|
|
|
100
|
-
test('text-mode: config missing workflow.text_mode falls through to
|
|
89
|
+
test('text-mode: config missing workflow.text_mode falls through to default (false)', () => {
|
|
101
90
|
const dir = _mkSandbox();
|
|
102
91
|
try {
|
|
103
92
|
_writeConfig(dir, { response_language: 'de' });
|
|
104
|
-
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }),
|
|
93
|
+
assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), false);
|
|
105
94
|
assert.equal(tm.resolveTextMode(dir, {}), false);
|
|
106
95
|
} finally {
|
|
107
96
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -131,9 +120,9 @@ test('text-mode: readConfigTextMode throws on invalid JSON', () => {
|
|
|
131
120
|
}
|
|
132
121
|
});
|
|
133
122
|
|
|
134
|
-
test('text-mode: detectRuntimeTextMode
|
|
135
|
-
assert.equal(tm.detectRuntimeTextMode({ CLAUDECODE: '1' }),
|
|
136
|
-
assert.equal(tm.detectRuntimeTextMode({ CLAUDE_CODE_ENTRYPOINT: 'cli' }),
|
|
123
|
+
test('text-mode: detectRuntimeTextMode returns false for all envs (no runtime auto-flip anymore)', () => {
|
|
124
|
+
assert.equal(tm.detectRuntimeTextMode({ CLAUDECODE: '1' }), false);
|
|
125
|
+
assert.equal(tm.detectRuntimeTextMode({ CLAUDE_CODE_ENTRYPOINT: 'cli' }), false);
|
|
137
126
|
assert.equal(tm.detectRuntimeTextMode({ OTHER: '1' }), false);
|
|
138
127
|
assert.equal(tm.detectRuntimeTextMode({}), false);
|
|
139
128
|
});
|
package/np-tools.cjs
CHANGED
|
@@ -50,6 +50,7 @@ const topLevelCommands = {
|
|
|
50
50
|
'text-mode': require('./bin/np-tools/text-mode.cjs'),
|
|
51
51
|
'detect-runtime': require('./bin/np-tools/detect-runtime.cjs'),
|
|
52
52
|
'template-path': require('./bin/np-tools/template-path.cjs'),
|
|
53
|
+
'update-phase-meta': require('./bin/np-tools/update-phase-meta.cjs'),
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
const THRESHOLD = 16 * 1024;
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
|
|
8
|
+
const WARN_THRESHOLD = 35;
|
|
9
|
+
const CRITICAL_THRESHOLD = 25;
|
|
10
|
+
const DEBOUNCE_TOOLS = 5;
|
|
11
|
+
|
|
12
|
+
function readStdinJson() {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
if (process.stdin.isTTY) return resolve({});
|
|
15
|
+
let buf = '';
|
|
16
|
+
process.stdin.setEncoding('utf-8');
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
try { process.stdin.removeAllListeners(); } catch {}
|
|
19
|
+
resolve(safeParse(buf));
|
|
20
|
+
}, 500);
|
|
21
|
+
process.stdin.on('data', (c) => { buf += c; });
|
|
22
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(safeParse(buf)); });
|
|
23
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(safeParse(buf)); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeParse(s) {
|
|
28
|
+
try { return s ? JSON.parse(s) : {}; } catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function severityFor(remaining) {
|
|
32
|
+
if (remaining <= CRITICAL_THRESHOLD) return 'critical';
|
|
33
|
+
if (remaining <= WARN_THRESHOLD) return 'warning';
|
|
34
|
+
return 'normal';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizeSid(sid) {
|
|
38
|
+
return String(sid || '').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
(async () => {
|
|
42
|
+
let payload = {};
|
|
43
|
+
try { payload = await readStdinJson(); } catch { payload = {}; }
|
|
44
|
+
const sid = payload && payload.session_id;
|
|
45
|
+
if (!sid) { process.exit(0); return; }
|
|
46
|
+
const safeSid = sanitizeSid(sid);
|
|
47
|
+
const bridgePath = path.join(os.tmpdir(), 'claude-ctx-' + safeSid + '.json');
|
|
48
|
+
if (!fs.existsSync(bridgePath)) { process.exit(0); return; }
|
|
49
|
+
|
|
50
|
+
let bridge;
|
|
51
|
+
try { bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf-8')); } catch { process.exit(0); return; }
|
|
52
|
+
const remaining = Number(bridge && bridge.usable_remaining_pct);
|
|
53
|
+
if (!Number.isFinite(remaining)) { process.exit(0); return; }
|
|
54
|
+
|
|
55
|
+
const statePath = path.join(os.tmpdir(), 'claude-ctx-warn-' + safeSid + '.json');
|
|
56
|
+
let state = { tool_count: 0, last_warn_at: -999, last_severity: 'normal' };
|
|
57
|
+
try {
|
|
58
|
+
const existing = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
59
|
+
state = Object.assign(state, existing);
|
|
60
|
+
} catch {}
|
|
61
|
+
state.tool_count = Number(state.tool_count || 0) + 1;
|
|
62
|
+
|
|
63
|
+
const sev = severityFor(remaining);
|
|
64
|
+
const escalated = sev !== 'normal' && sev !== state.last_severity && state.last_severity !== 'critical';
|
|
65
|
+
const firstWarn = state.last_severity === 'normal' && sev !== 'normal';
|
|
66
|
+
const enoughGap = (state.tool_count - Number(state.last_warn_at || -999)) >= DEBOUNCE_TOOLS;
|
|
67
|
+
|
|
68
|
+
if (sev === 'normal') {
|
|
69
|
+
state.last_severity = 'normal';
|
|
70
|
+
try { fs.writeFileSync(statePath, JSON.stringify(state)); } catch {}
|
|
71
|
+
process.exit(0); return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!firstWarn && !escalated && !enoughGap) {
|
|
75
|
+
try { fs.writeFileSync(statePath, JSON.stringify(state)); } catch {}
|
|
76
|
+
process.exit(0); return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
state.last_warn_at = state.tool_count;
|
|
80
|
+
state.last_severity = sev;
|
|
81
|
+
try { fs.writeFileSync(statePath, JSON.stringify(state)); } catch {}
|
|
82
|
+
|
|
83
|
+
const msg = sev === 'critical'
|
|
84
|
+
? 'CONTEXT CRITICAL: only ' + remaining + '% of usable context remaining. Stop taking new work — save state now with /np:pause-work before autocompact triggers.'
|
|
85
|
+
: 'CONTEXT LOW: ' + remaining + '% of usable context remaining. Wrap up the current task and consider /np:pause-work soon.';
|
|
86
|
+
|
|
87
|
+
const output = {
|
|
88
|
+
hookSpecificOutput: {
|
|
89
|
+
hookEventName: 'PostToolUse',
|
|
90
|
+
additionalContext: '[nubos-pilot ctx-monitor] ' + msg,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
process.stdout.write(JSON.stringify(output));
|
|
94
|
+
})().catch(() => { process.exit(0); });
|