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.
@@ -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
  };
@@ -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 = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'];
8
+ const CLAUDE_ENV_KEYS = [];
9
9
 
10
10
  function _coerceBool(raw) {
11
11
  if (raw === true || raw === false) return raw;
@@ -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 in env flips default to true', () => {
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' }), true);
35
+ assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), false);
36
36
  const detail = tm.resolveTextModeDetail(dir, { CLAUDECODE: '1' });
37
- assert.deepEqual(detail, { enabled: true, source: 'runtime' });
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: env value "0" or "false" does not flip', () => {
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, { CLAUDECODE: '0' }), false);
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 overrides CLAUDECODE runtime', () => {
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 runtime detection', () => {
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' }), true);
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 honors multiple env keys', () => {
135
- assert.equal(tm.detectRuntimeTextMode({ CLAUDECODE: '1' }), true);
136
- assert.equal(tm.detectRuntimeTextMode({ CLAUDE_CODE_ENTRYPOINT: 'cli' }), true);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "AI-driven planning and execution tool for code projects",
5
5
  "homepage": "https://github.com/Nubos-AI/nubos-pilot",
6
6
  "repository": {
@@ -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); });