nubos-pilot 0.6.0 → 0.6.1

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/bin/install.js CHANGED
@@ -683,6 +683,10 @@ async function main() {
683
683
  const doctor = require('./np-tools/doctor.cjs');
684
684
  return await doctor.run(rest.slice(1), { cwd, stdout: process.stdout });
685
685
  }
686
+ case 'install-hooks':
687
+ return await runInstallHooks({ cwd, args: rest.slice(1) });
688
+ case 'uninstall-hooks':
689
+ return await runUninstallHooks({ cwd, args: rest.slice(1) });
686
690
  default:
687
691
  process.stderr.write(
688
692
  red + 'Unbekanntes Subcommand: ' + sub + reset + '\n',
@@ -692,6 +696,70 @@ async function main() {
692
696
  }
693
697
  }
694
698
 
699
+ function _parseHookFlags(args) {
700
+ const flags = { scope: null, which: 'both', force: false, dryRun: false };
701
+ for (let i = 0; i < args.length; i++) {
702
+ const a = args[i];
703
+ if (a === '--scope' || a === '-s') { flags.scope = args[++i] || null; continue; }
704
+ if (a.startsWith('--scope=')) { flags.scope = a.slice('--scope='.length); continue; }
705
+ if (a === '--statusline-only') { flags.which = 'statusline'; continue; }
706
+ if (a === '--ctx-monitor-only') { flags.which = 'ctx-monitor'; continue; }
707
+ if (a === '--force' || a === '-f') { flags.force = true; continue; }
708
+ if (a === '--dry-run') { flags.dryRun = true; continue; }
709
+ }
710
+ if (flags.scope && !VALID_SCOPES.includes(flags.scope)) {
711
+ throw new NubosPilotError('invalid-flag',
712
+ '--scope must be one of: ' + VALID_SCOPES.join(', '),
713
+ { flag: '--scope', got: flags.scope });
714
+ }
715
+ return flags;
716
+ }
717
+
718
+ async function runInstallHooks(opts) {
719
+ const o = opts || {};
720
+ const projectRoot = o.projectRoot || o.cwd || process.cwd();
721
+ const flags = _parseHookFlags(o.args || []);
722
+ const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
723
+ const claudeHooks = require('../lib/install/claude-hooks.cjs');
724
+ const res = claudeHooks.installClaudeHooks({
725
+ projectRoot, scope, which: flags.which, force: flags.force, dryRun: flags.dryRun,
726
+ });
727
+ if (res.dryRun) {
728
+ process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
729
+ return res;
730
+ }
731
+ console.error(green + '✓ Claude Code hooks geschrieben → ' + res.path + reset);
732
+ if (res.results.statusline) {
733
+ console.error(dim + ' statusline: ' + res.results.statusline.action
734
+ + (res.results.statusline.existingCommand ? ' (existing: ' + res.results.statusline.existingCommand + ')' : '')
735
+ + reset);
736
+ }
737
+ if (res.results.ctxMonitor) {
738
+ console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
739
+ }
740
+ if (res.results.statusline && res.results.statusline.action === 'skipped-existing') {
741
+ console.error(yellow + ' [statusline] existing non-nubos statusLine preserved. Pass --force to overwrite.' + reset);
742
+ }
743
+ return res;
744
+ }
745
+
746
+ async function runUninstallHooks(opts) {
747
+ const o = opts || {};
748
+ const projectRoot = o.projectRoot || o.cwd || process.cwd();
749
+ const flags = _parseHookFlags(o.args || []);
750
+ const scope = flags.scope || _readExistingScope(projectRoot) || 'local';
751
+ const claudeHooks = require('../lib/install/claude-hooks.cjs');
752
+ const res = claudeHooks.uninstallClaudeHooks({ projectRoot, scope, dryRun: flags.dryRun });
753
+ if (res.dryRun) {
754
+ process.stdout.write(JSON.stringify({ dryRun: true, path: res.path, results: res.results }, null, 2) + '\n');
755
+ return res;
756
+ }
757
+ console.error(green + '✓ Claude Code hooks entfernt ← ' + res.path + reset);
758
+ console.error(dim + ' statusline: ' + res.results.statusline.action + reset);
759
+ console.error(dim + ' ctx-monitor: ' + res.results.ctxMonitor.action + reset);
760
+ return res;
761
+ }
762
+
695
763
  if (require.main === module) {
696
764
  main().catch((err) => {
697
765
  if (err && err.code) {
@@ -83,7 +83,7 @@ test('DP-1: run(["3"]) on valid milestone returns JSON payload with expected sha
83
83
  }
84
84
  });
85
85
 
86
- test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
86
+ test('DP-1b: CLAUDECODE=1 no longer flips text_mode (Claude Code uses AskUserQuestion)', () => {
87
87
  const restore = _clearClaudeEnv();
88
88
  try {
89
89
  process.env.CLAUDECODE = '1';
@@ -92,8 +92,8 @@ test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
92
92
  const cap = _captureStdout();
93
93
  subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
94
94
  const payload = JSON.parse(cap.get().trim());
95
- assert.equal(payload.text_mode, true);
96
- assert.equal(payload.text_mode_source, 'runtime');
95
+ assert.equal(payload.text_mode, false);
96
+ assert.equal(payload.text_mode_source, 'default');
97
97
  } finally {
98
98
  restore();
99
99
  }
@@ -56,7 +56,7 @@ test('text-mode CLI: default without config and without Claude env prints "false
56
56
  }
57
57
  });
58
58
 
59
- test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
59
+ test('text-mode CLI: CLAUDECODE=1 no longer prints "true" (AskUserQuestion path)', () => {
60
60
  const restore = _clearClaudeEnv();
61
61
  try {
62
62
  process.env.CLAUDECODE = '1';
@@ -65,7 +65,7 @@ test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
65
65
  const io = _captureIO();
66
66
  const rc = subcmd.run([], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
67
67
  assert.equal(rc, 0);
68
- assert.equal(io.stdoutText().trim(), 'true');
68
+ assert.equal(io.stdoutText().trim(), 'false');
69
69
  } finally {
70
70
  fs.rmSync(dir, { recursive: true, force: true });
71
71
  }
@@ -74,18 +74,21 @@ test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
74
74
  }
75
75
  });
76
76
 
77
- test('text-mode CLI: --json emits detail object', () => {
77
+ test('text-mode CLI: --json emits detail object with config source', () => {
78
78
  const restore = _clearClaudeEnv();
79
79
  try {
80
- process.env.CLAUDECODE = '1';
81
80
  const dir = _mkSandbox();
82
81
  try {
82
+ fs.writeFileSync(
83
+ path.join(dir, '.nubos-pilot', 'config.json'),
84
+ JSON.stringify({ workflow: { text_mode: true } }),
85
+ );
83
86
  const io = _captureIO();
84
87
  const rc = subcmd.run(['--json'], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
85
88
  assert.equal(rc, 0);
86
89
  const payload = JSON.parse(io.stdoutText().trim());
87
90
  assert.equal(payload.enabled, true);
88
- assert.equal(payload.source, 'runtime');
91
+ assert.equal(payload.source, 'config');
89
92
  } finally {
90
93
  fs.rmSync(dir, { recursive: true, force: true });
91
94
  }
@@ -94,7 +97,7 @@ test('text-mode CLI: --json emits detail object', () => {
94
97
  }
95
98
  });
96
99
 
97
- test('text-mode CLI: config workflow.text_mode=false wins over CLAUDECODE', () => {
100
+ test('text-mode CLI: config workflow.text_mode=false stays false even with CLAUDECODE', () => {
98
101
  const restore = _clearClaudeEnv();
99
102
  try {
100
103
  process.env.CLAUDECODE = '1';
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+
7
+ const { atomicWriteFileSync, NubosPilotError } = require('../core.cjs');
8
+
9
+ const STATUSLINE_REL = '.claude/nubos-pilot/hooks/np-statusline.js';
10
+ const CTX_MONITOR_REL = '.claude/nubos-pilot/hooks/np-ctx-monitor.js';
11
+ const NP_STATUSLINE_MARKER = 'np-statusline.js';
12
+ const NP_CTX_MONITOR_MARKER = 'np-ctx-monitor.js';
13
+
14
+ function _settingsPath(scope, projectRoot) {
15
+ if (scope === 'global') return path.join(os.homedir(), '.claude', 'settings.json');
16
+ return path.join(projectRoot, '.claude', 'settings.local.json');
17
+ }
18
+
19
+ function _readJsonSafe(p) {
20
+ if (!fs.existsSync(p)) return {};
21
+ let raw;
22
+ try { raw = fs.readFileSync(p, 'utf-8'); } catch { return {}; }
23
+ try { return JSON.parse(raw); } catch (err) {
24
+ throw new NubosPilotError(
25
+ 'claude-settings-invalid-json',
26
+ 'Cannot parse Claude settings: ' + p + ' — ' + err.message,
27
+ { path: p },
28
+ );
29
+ }
30
+ }
31
+
32
+ function _hookCommand(rel, scope, projectRoot) {
33
+ if (scope === 'global') {
34
+ return 'node "' + path.join('$HOME', rel) + '"';
35
+ }
36
+ return 'node "' + path.join(projectRoot, rel) + '"';
37
+ }
38
+
39
+ function _containsNpHook(entry, marker) {
40
+ if (!entry || typeof entry !== 'object') return false;
41
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
42
+ for (const h of hooks) {
43
+ if (h && typeof h.command === 'string' && h.command.includes(marker)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function _installStatusLine(settings, cmd, force) {
49
+ const existing = settings.statusLine;
50
+ if (existing && typeof existing === 'object' && existing.command) {
51
+ if (String(existing.command).includes(NP_STATUSLINE_MARKER)) {
52
+ settings.statusLine = { type: 'command', command: cmd };
53
+ return { action: 'updated', existed: true };
54
+ }
55
+ if (!force) {
56
+ return { action: 'skipped-existing', existed: true, existingCommand: existing.command };
57
+ }
58
+ settings.statusLine = { type: 'command', command: cmd };
59
+ return { action: 'overwrote', existed: true };
60
+ }
61
+ settings.statusLine = { type: 'command', command: cmd };
62
+ return { action: 'installed', existed: false };
63
+ }
64
+
65
+ function _installPostToolUse(settings, cmd) {
66
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
67
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
68
+ const list = settings.hooks.PostToolUse;
69
+ for (const entry of list) {
70
+ if (_containsNpHook(entry, NP_CTX_MONITOR_MARKER)) {
71
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
72
+ for (const h of hooks) {
73
+ if (h && typeof h.command === 'string' && h.command.includes(NP_CTX_MONITOR_MARKER)) {
74
+ h.command = cmd;
75
+ h.type = 'command';
76
+ }
77
+ }
78
+ return { action: 'updated' };
79
+ }
80
+ }
81
+ list.push({
82
+ matcher: '.*',
83
+ hooks: [{ type: 'command', command: cmd }],
84
+ });
85
+ return { action: 'installed' };
86
+ }
87
+
88
+ function _removeStatusLine(settings) {
89
+ const existing = settings.statusLine;
90
+ if (existing && typeof existing === 'object'
91
+ && typeof existing.command === 'string'
92
+ && existing.command.includes(NP_STATUSLINE_MARKER)) {
93
+ delete settings.statusLine;
94
+ return { action: 'removed' };
95
+ }
96
+ return { action: 'not-ours' };
97
+ }
98
+
99
+ function _removePostToolUse(settings) {
100
+ if (!settings.hooks || !Array.isArray(settings.hooks.PostToolUse)) return { action: 'absent' };
101
+ const filtered = [];
102
+ for (const entry of settings.hooks.PostToolUse) {
103
+ if (_containsNpHook(entry, NP_CTX_MONITOR_MARKER)) {
104
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
105
+ const keptHooks = hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes(NP_CTX_MONITOR_MARKER)));
106
+ if (keptHooks.length > 0) {
107
+ filtered.push(Object.assign({}, entry, { hooks: keptHooks }));
108
+ }
109
+ continue;
110
+ }
111
+ filtered.push(entry);
112
+ }
113
+ settings.hooks.PostToolUse = filtered;
114
+ if (filtered.length === 0) delete settings.hooks.PostToolUse;
115
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
116
+ return { action: 'removed' };
117
+ }
118
+
119
+ function installClaudeHooks(opts) {
120
+ const o = opts || {};
121
+ const projectRoot = o.projectRoot || process.cwd();
122
+ const scope = o.scope === 'global' ? 'global' : 'local';
123
+ const force = !!o.force;
124
+ const which = o.which || 'both';
125
+ const settingsPath = _settingsPath(scope, projectRoot);
126
+
127
+ const statuslineCmd = _hookCommand(STATUSLINE_REL, scope, projectRoot);
128
+ const ctxMonitorCmd = _hookCommand(CTX_MONITOR_REL, scope, projectRoot);
129
+
130
+ const statuslineAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, STATUSLINE_REL);
131
+ const ctxMonitorAbs = path.join(scope === 'global' ? os.homedir() : projectRoot, CTX_MONITOR_REL);
132
+
133
+ if (which === 'statusline' || which === 'both') {
134
+ if (!fs.existsSync(statuslineAbs)) {
135
+ throw new NubosPilotError(
136
+ 'claude-hooks-script-missing',
137
+ 'Statusline hook script not found: ' + statuslineAbs + '. Run `npx nubos-pilot` install first.',
138
+ { script: statuslineAbs },
139
+ );
140
+ }
141
+ }
142
+ if (which === 'ctx-monitor' || which === 'both') {
143
+ if (!fs.existsSync(ctxMonitorAbs)) {
144
+ throw new NubosPilotError(
145
+ 'claude-hooks-script-missing',
146
+ 'Ctx-monitor hook script not found: ' + ctxMonitorAbs,
147
+ { script: ctxMonitorAbs },
148
+ );
149
+ }
150
+ }
151
+
152
+ const settings = _readJsonSafe(settingsPath);
153
+ const results = {};
154
+
155
+ if (which === 'statusline' || which === 'both') {
156
+ results.statusline = _installStatusLine(settings, statuslineCmd, force);
157
+ }
158
+ if (which === 'ctx-monitor' || which === 'both') {
159
+ results.ctxMonitor = _installPostToolUse(settings, ctxMonitorCmd);
160
+ }
161
+
162
+ if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
163
+
164
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
165
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
166
+ return { dryRun: false, path: settingsPath, results };
167
+ }
168
+
169
+ function uninstallClaudeHooks(opts) {
170
+ const o = opts || {};
171
+ const projectRoot = o.projectRoot || process.cwd();
172
+ const scope = o.scope === 'global' ? 'global' : 'local';
173
+ const settingsPath = _settingsPath(scope, projectRoot);
174
+ if (!fs.existsSync(settingsPath)) return { path: settingsPath, results: { statusline: { action: 'absent' }, ctxMonitor: { action: 'absent' } } };
175
+
176
+ const settings = _readJsonSafe(settingsPath);
177
+ const results = {
178
+ statusline: _removeStatusLine(settings),
179
+ ctxMonitor: _removePostToolUse(settings),
180
+ };
181
+ if (o.dryRun) return { dryRun: true, path: settingsPath, results, settings };
182
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
183
+ return { dryRun: false, path: settingsPath, results };
184
+ }
185
+
186
+ module.exports = {
187
+ installClaudeHooks,
188
+ uninstallClaudeHooks,
189
+ STATUSLINE_REL,
190
+ CTX_MONITOR_REL,
191
+ NP_STATUSLINE_MARKER,
192
+ NP_CTX_MONITOR_MARKER,
193
+ _settingsPath,
194
+ _hookCommand,
195
+ };
@@ -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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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); });
@@ -0,0 +1,110 @@
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 BAR_WIDTH = 10;
9
+ const AUTOCOMPACT_BUFFER = 0.835;
10
+ const DEFAULT_WINDOW = 200_000;
11
+ const EXTENDED_WINDOW = 1_000_000;
12
+
13
+ function readStdinJson() {
14
+ return new Promise((resolve) => {
15
+ if (process.stdin.isTTY) return resolve({});
16
+ let buf = '';
17
+ process.stdin.setEncoding('utf-8');
18
+ const timer = setTimeout(() => {
19
+ try { process.stdin.removeAllListeners(); } catch {}
20
+ resolve(safeParse(buf));
21
+ }, 500);
22
+ process.stdin.on('data', (chunk) => { buf += chunk; });
23
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(safeParse(buf)); });
24
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(safeParse(buf)); });
25
+ });
26
+ }
27
+
28
+ function safeParse(s) {
29
+ try { return s ? JSON.parse(s) : {}; } catch { return {}; }
30
+ }
31
+
32
+ function modelWindow(payload) {
33
+ const id = String((payload && payload.model && payload.model.id) || '');
34
+ const name = String((payload && payload.model && payload.model.display_name) || '');
35
+ const s = (id + ' ' + name).toLowerCase();
36
+ if (s.includes('[1m]') || /\b1m\b/.test(s) || s.includes('1-m') || s.includes('1000k')) {
37
+ return EXTENDED_WINDOW;
38
+ }
39
+ return DEFAULT_WINDOW;
40
+ }
41
+
42
+ function lastUsage(transcriptPath) {
43
+ if (!transcriptPath) return null;
44
+ let raw;
45
+ try { raw = fs.readFileSync(transcriptPath, 'utf-8'); } catch { return null; }
46
+ const lines = raw.split('\n');
47
+ for (let i = lines.length - 1; i >= 0; i--) {
48
+ const line = lines[i];
49
+ if (!line) continue;
50
+ let obj;
51
+ try { obj = JSON.parse(line); } catch { continue; }
52
+ const usage = obj && obj.message && obj.message.usage;
53
+ if (!usage || typeof usage !== 'object') continue;
54
+ const input = Number(usage.input_tokens || 0);
55
+ const cacheCreation = Number(usage.cache_creation_input_tokens || 0);
56
+ const cacheRead = Number(usage.cache_read_input_tokens || 0);
57
+ const output = Number(usage.output_tokens || 0);
58
+ const total = input + cacheCreation + cacheRead + output;
59
+ if (!Number.isFinite(total) || total <= 0) continue;
60
+ return { input, cacheCreation, cacheRead, output, total };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function renderBar(used, limit) {
66
+ const fraction = Math.max(0, Math.min(1, used / limit));
67
+ const ofUsable = Math.max(0, Math.min(1, fraction / AUTOCOMPACT_BUFFER));
68
+ const pct = Math.round(ofUsable * 100);
69
+ const filled = Math.round(ofUsable * BAR_WIDTH);
70
+ const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
71
+ let color = '\x1b[32m';
72
+ let suffix = '';
73
+ if (pct >= 80) { color = '\x1b[31m'; suffix = ' 💀'; }
74
+ else if (pct >= 65) { color = '\x1b[38;5;208m'; }
75
+ else if (pct >= 50) { color = '\x1b[33m'; }
76
+ return color + bar + '\x1b[0m ' + pct + '%' + suffix;
77
+ }
78
+
79
+ function writeBridge(payload, usage, limit) {
80
+ const sid = payload && payload.session_id;
81
+ if (!sid) return;
82
+ const bridgePath = path.join(os.tmpdir(), 'claude-ctx-' + String(sid).replace(/[^a-zA-Z0-9._-]/g, '_') + '.json');
83
+ const data = {
84
+ session_id: sid,
85
+ used: usage.total,
86
+ limit: limit,
87
+ usable_remaining_pct: Math.max(0, Math.round(((AUTOCOMPACT_BUFFER * limit) - usage.total) / (AUTOCOMPACT_BUFFER * limit) * 100)),
88
+ updated_at: new Date().toISOString(),
89
+ };
90
+ try { fs.writeFileSync(bridgePath, JSON.stringify(data)); } catch {}
91
+ }
92
+
93
+ (async () => {
94
+ let payload = {};
95
+ try { payload = await readStdinJson(); } catch { payload = {}; }
96
+ const limit = modelWindow(payload);
97
+ const usage = lastUsage(payload && payload.transcript_path);
98
+ const prefix = '\x1b[38;5;33mnubos-pilot\x1b[0m';
99
+ if (!usage) {
100
+ process.stdout.write(prefix);
101
+ return;
102
+ }
103
+ writeBridge(payload, usage, limit);
104
+ const bar = renderBar(usage.total, limit);
105
+ const modelName = (payload && payload.model && payload.model.display_name) || '';
106
+ const tail = modelName ? ' \x1b[2m' + modelName + '\x1b[0m' : '';
107
+ process.stdout.write(prefix + ' ctx ' + bar + tail);
108
+ })().catch(() => {
109
+ process.stdout.write('\x1b[38;5;33mnubos-pilot\x1b[0m');
110
+ });
@@ -47,10 +47,10 @@ through `lib/layout.cjs.slugify` (strips to `[a-z0-9-]` only;
47
47
  filename-injection mitigation) and validates the description length
48
48
  (<= 500 chars) before any filesystem write occurs.
49
49
 
50
- **Text-mode routing.** If `text_mode == true`, skip every `np-tools.cjs askuser`
51
- call below and render questions as plain-text numbered lists in the main
52
- chat. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via
53
- `.nubos-pilot/config.json` `workflow.text_mode`.
50
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
51
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
52
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
53
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
54
54
 
55
55
  ## Create Pending Dir
56
56
 
@@ -34,22 +34,20 @@ Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`,
34
34
  `milestone_context_path`, `has_context`, `has_milestone_dir`, `goal`,
35
35
  `requirements`, `agent_skills`, `mode`, `text_mode`, `text_mode_source`.
36
36
 
37
- **Text-mode routing (SSOT = INIT payload).** If `text_mode` is `true`, do
38
- **not** shell out to `np-tools.cjs askuser` for any prompt in this workflow.
39
- Present every question inline as a plain-text numbered list and wait for the
40
- user's reply in the main chat. This is the correct path whenever:
41
-
42
- - `text_mode_source == "runtime"` → Claude Code Bash has no TTY and cannot
43
- forward interactive menu selections; the askuser marker-block protocol
44
- never completes.
45
- - `text_mode_source == "config"` → the user explicitly opted into text mode
46
- via `.nubos-pilot/config.json` → `workflow.text_mode`.
47
-
48
- When text mode is active, skip every `node .nubos-pilot/bin/np-tools.cjs
49
- askuser …` block below and substitute the plain-text equivalent. Collect the
50
- answers from the user's reply, then proceed to the next step as normal. The
51
- rest of the workflow (validation, canonical-ref accumulation, template
52
- render, commit) is unchanged.
37
+ **Askuser routing (SSOT = INIT payload).** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a **spec**, not a literal command. Pick the path once at Initialize:
38
+
39
+ - **Claude Code runtime** (you are running inside Claude Code — the `AskUserQuestion` tool is available to you): **do not** shell out to `np-tools.cjs askuser`. Parse the JSON spec inside each askuser block and call the native `AskUserQuestion` tool directly with one question entry:
40
+ - `type: "select"` `{ question, header, multiSelect: false, options: [{label, description}...] }`
41
+ - `type: "multiselect"` → `{ question, header, multiSelect: true, options: [{label, description}...] }`
42
+ - `type: "confirm"` → single question with `options: [{label: "Yes"}, {label: "No"}]`, `multiSelect: false`
43
+ - `type: "input"` → ask as a plain free-form question in the chat; the user replies inline
44
+ Use a short `header` (≤12 chars) that labels the category, e.g. `"Discuss"`, `"Scope"`, `"Overwrite?"`. This is the default path and gives the user a real selection menu.
45
+
46
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render every question inline as a plain-text numbered list; the user replies with the number. This path is opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
47
+
48
+ - **Other runtime with TTY** (Codex, Gemini, …): run the shell `node .nubos-pilot/bin/np-tools.cjs askuser --json '…'` block verbatim.
49
+
50
+ `text_mode_source` in the INIT payload (`config` / `default`) is informational only it does not change the routing above.
53
51
 
54
52
  If the user passed `--assumptions`, route to
55
53
  `workflows/discuss-phase-assumptions.md` and exit this workflow.
@@ -142,33 +140,19 @@ Capture the idea in a "Deferred Ideas" section. Don't lose it, don't act on it.
142
140
  ## Answer Validation
143
141
 
144
142
  <answer_validation>
145
- **Routing decision (made once at Initialize):** If INIT payload
146
- `text_mode == true`, skip every `np-tools.cjs askuser` call in this workflow
147
- and use plain-text numbered lists in the main chat instead. The
148
- `text_mode_source` field (`runtime` / `config` / `default`) tells you why
149
- text mode is active it is informational only and does not change behavior.
150
-
151
- **When text_mode is false and askuser is used per-prompt validation:**
152
- 1. If `askuser` exits with structured error `askuser-no-tty` (exit code 1,
153
- stderr JSON with `"code":"askuser-no-tty"`), that means the runtime
154
- detection missed something; **skip retry** and treat the remainder of the
155
- workflow as text-mode (plain-text numbered lists).
156
- 2. If the response is empty or whitespace-only (exit 0 but no value), retry
157
- the question once with the same parameters.
158
- 3. If still empty, present the options as a plain-text numbered list and ask
159
- the user to type their choice number.
143
+ **Routing was decided at Initialize** (see "Askuser routing" section above). This section documents per-prompt validation only.
144
+
145
+ **Claude Code path (`AskUserQuestion` tool):** the tool guarantees a non-empty selection; no validation needed.
146
+
147
+ **Shell askuser path (other runtimes with TTY):**
148
+ 1. If `askuser` exits with structured error `askuser-no-tty` (exit code 1, stderr JSON with `"code":"askuser-no-tty"`), that means the runtime detection missed something; **skip retry** and treat the remainder of the workflow as text-mode (plain-text numbered lists).
149
+ 2. If the response is empty or whitespace-only (exit 0 but no value), retry the question once with the same parameters.
150
+ 3. If still empty, present the options as a plain-text numbered list and ask the user to type their choice number.
160
151
  Never proceed with an empty answer.
161
152
 
162
- **Enable text mode:**
163
- - Auto-detected: any Claude Code session (`CLAUDECODE=1` /
164
- `CLAUDE_CODE_ENTRYPOINT` set) — default behavior, no user action needed.
165
- - Opt-in per project: set `workflow.text_mode: true` in
166
- `.nubos-pilot/config.json`.
167
- - Opt-out per project: set `workflow.text_mode: false` in
168
- `.nubos-pilot/config.json` (overrides runtime detection).
153
+ **Text-mode (numbered-list path):** user reply must parse as a valid index (1-N) for select/multiselect, `y/n` for confirm, or any non-empty string for input. Re-ask on invalid input.
169
154
 
170
- Text mode applies to ALL workflows that emit `text_mode` in their INIT
171
- payload, not just discuss-phase.
155
+ **Enable text mode** (force the numbered-list path regardless of runtime): set `workflow.text_mode: true` in `.nubos-pilot/config.json`. Useful for remote-control setups or runtimes where neither `AskUserQuestion` nor TTY stdin are reliable.
172
156
  </answer_validation>
173
157
 
174
158
  ## Process
@@ -61,6 +61,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
61
61
  narrative status updates, and the prose written into PROJECT.md sections.
62
62
  Supersedes CLAUDE.md managed block.
63
63
 
64
+ **Askuser routing.** The "Use `np-tools.cjs askuser` for every prompt" rule below is SC-5 gateway enforcement — the JSON spec must pass through np-tools for logging/validation. Pick the presentation path:
65
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
66
+ - **`text_mode == true`** (INIT payload): skip shell askuser calls and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
67
+ - **Other runtime with TTY** (Codex, Gemini, …): execute `node .nubos-pilot/bin/np-tools.cjs askuser --json '…'` directly.
68
+
64
69
  Parse: `mode`, `sub_mode` (`bootstrap` or `refresh`), `project_md_exists`,
65
70
  `scan_context`, `questions[]`, `required_fields[]`.
66
71
 
@@ -32,11 +32,10 @@ directive in CLAUDE.md managed block.
32
32
 
33
33
  Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `waves[]` (each with `wave` (= slice number), `slice_id`, `slice_full_id`, `slice_dir`, `tasks[]`), `total_tasks`, `slice_count`, `executor_tier`, `text_mode`, `text_mode_source`, `agent_skills`.
34
34
 
35
- **Text-mode routing.** If `text_mode == true`, skip every `np-tools.cjs askuser`
36
- call below (including the orphan-checkpoint and empty-milestone prompts)
37
- and render the options as a plain-text numbered list in the main chat.
38
- Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via
39
- `.nubos-pilot/config.json` → `workflow.text_mode`.
35
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below (including the orphan-checkpoint and empty-milestone prompts) is a spec, not a literal command. Pick the path once at Initialize:
36
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
37
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
38
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
40
39
 
41
40
  `PLAN_ID` is iterated per slice as `${milestone_id}-${slice_id}` (e.g. `M001-S001`). `TASK_ID` is iterated from each slice's `tasks[]` (e.g. `M001-S001-T0001`).
42
41
 
@@ -62,11 +62,10 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
62
62
  user-facing output, and any prose written into milestone artefacts (YAML
63
63
  keys, IDs, and identifiers stay canonical English). Supersedes CLAUDE.md.
64
64
 
65
- **Text-mode routing.** If INIT payload `text_mode == true`, skip every
66
- `np-tools.cjs askuser` call below and render each question as a plain-text
67
- prompt in the main chat; collect the answer inline. Auto-enabled in Claude
68
- Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json`
69
- `workflow.text_mode`.
65
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
66
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
67
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
68
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
70
69
 
71
70
  Payload: three questions — `milestone_name`, `milestone_goal`, `create_req_prefix` (confirm).
72
71
 
@@ -126,11 +126,10 @@ user-facing output, and any narrative prose written into PROJECT.md /
126
126
  REQUIREMENTS.md (field names and YAML keys stay canonical English).
127
127
  Supersedes CLAUDE.md.
128
128
 
129
- **Text-mode routing.** If INIT payload `text_mode == true`, skip every
130
- `np-tools.cjs askuser` call below and render each question as a plain-text
131
- prompt in the main chat; collect the user's answer inline and move on.
132
- Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via
133
- `.nubos-pilot/config.json` → `workflow.text_mode`.
129
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
130
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
131
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
132
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
134
133
 
135
134
  ```bash
136
135
  ANS_PROJECT_NAME=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{"type":"input","prompt":"Project name?"}')
package/workflows/note.md CHANGED
@@ -52,16 +52,16 @@ into `$TEXT`. Empty text after stripping is an error — there is no
52
52
  `list` or `promote` subcommand here (deferred to a future
53
53
  capture-management plan).
54
54
 
55
- **Text-mode routing.** Resolve once at the start:
55
+ **Askuser routing.** Resolve once at the start:
56
56
 
57
57
  ```bash
58
58
  TEXT_MODE=$(node .nubos-pilot/bin/np-tools.cjs text-mode 2>/dev/null || echo false)
59
59
  ```
60
60
 
61
- If `$TEXT_MODE == "true"`, skip every `np-tools.cjs askuser` call below and
62
- render questions as plain-text numbered lists in the main chat. Auto-enabled
63
- in Claude Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json` →
64
- `workflow.text_mode`.
61
+ Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path:
62
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
63
+ - **`$TEXT_MODE == "true"`**: skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
64
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
65
65
 
66
66
  ## Compute Paths
67
67
 
@@ -88,10 +88,10 @@ prompts as a system-level rule. This supersedes any directive in CLAUDE.md.
88
88
 
89
89
  Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_context_path`, `milestone_roadmap_path`, `milestone_meta_path`, `name`, `goal`, `requirements`, `success_criteria`, `has_context`, `has_roadmap`, `has_meta`, `existing_slices[]`, `planner_tier`, `checker_tier`, `text_mode`, `text_mode_source`, `agent_skills`.
90
90
 
91
- **Text-mode routing.** If `text_mode == true`, skip every `np-tools.cjs askuser`
92
- call below and present questions as plain-text numbered lists in the main
93
- chat. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in per-project via
94
- `.nubos-pilot/config.json` `workflow.text_mode`.
91
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
92
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
93
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
94
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
95
95
 
96
96
  `PLAN_ID` and `TASK_ID` default to `${milestone_id}-plan` / `${milestone_id}-planner-run` for the metrics records.
97
97
 
@@ -70,7 +70,10 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
70
70
  **Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
71
71
  `$LANG_DIRECTIVE` is authoritative. Obey it for askuser prompt texts, AI-facing reasoning shown to the user, and any narrative prose. YAML keys, milestone IDs, and status strings stay canonical English.
72
72
 
73
- **Text-mode routing.** If INIT payload `text_mode == true`, skip every `np-tools.cjs askuser` call below and render each question as a plain-text prompt in the main chat; collect the answer inline. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
73
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
74
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
75
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
76
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
74
77
 
75
78
  Parse INIT for: `milestones[]` (each with `id`, `name`, `goal`, `status`, `classification`, `slice_count`, `context`, `touchable`, `modification_requires_confirm`), `project_md`, `requirements_md`, `project_has_tbd`, `current_state_milestone`, `next_milestone_number`, `guidance`.
76
79
 
@@ -107,10 +107,10 @@ project language. This supersedes CLAUDE.md.
107
107
  `RUNTIME` is resolved once here and reused by the metrics-record call at the
108
108
  researcher spawn site (Step 4) per D-06 workflow-writer pattern.
109
109
 
110
- **Text-mode routing.** If `text_mode == true` in the payload below, skip every
111
- `np-tools.cjs askuser` call in this workflow and render questions as
112
- plain-text numbered lists in the main chat. Auto-enabled in Claude Code
113
- (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json` `workflow.text_mode`.
110
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
111
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
112
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` `workflow.text_mode`.
113
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
114
114
 
115
115
  The payload shape:
116
116
 
@@ -24,10 +24,10 @@ askuser prompts. When spawning the np-executor to continue a checkpoint,
24
24
  pass `$LANG_DIRECTIVE` into the spawn prompt so resumed task summaries
25
25
  follow the project language. Supersedes CLAUDE.md.
26
26
 
27
- **Text-mode routing.** If INIT payload `text_mode == true`, skip every
28
- `np-tools.cjs askuser` call below and render prompts as plain-text numbered
29
- lists in the main chat. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in
30
- via `.nubos-pilot/config.json` `workflow.text_mode`.
27
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
28
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
29
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` `workflow.text_mode`.
30
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
31
31
 
32
32
  ## Execution
33
33
 
@@ -62,10 +62,10 @@ canonical English. Supersedes CLAUDE.md.
62
62
  TEXT_MODE=$(node .nubos-pilot/bin/np-tools.cjs text-mode 2>/dev/null || echo false)
63
63
  ```
64
64
 
65
- If `$TEXT_MODE == "true"`, skip every `np-tools.cjs askuser` call below and
66
- render questions as plain-text numbered lists in the main chat. Auto-enabled
67
- in Claude Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json` →
68
- `workflow.text_mode`.
65
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
66
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
67
+ - **`$TEXT_MODE == "true"`** (from the check above, or INIT payload `text_mode == true`): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
68
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
69
69
 
70
70
  The filename format is `YYYY-MM-DDTHHMM-session-report.md` (D-17 —
71
71
  4-char HHMM, no seconds, local time) so reports sort
@@ -33,10 +33,10 @@ field names stay English. Supersedes CLAUDE.md.
33
33
 
34
34
  Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`, `slice_uat`, `text_mode`, `text_mode_source`.
35
35
 
36
- **Text-mode routing.** If `text_mode == true`, skip every `np-tools.cjs askuser`
37
- call in this workflow and render options as plain-text numbered lists in
38
- the main chat. Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via
39
- `.nubos-pilot/config.json` `workflow.text_mode`.
36
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below is a spec, not a literal command. Pick the path once at Initialize:
37
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` → `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
38
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` → `workflow.text_mode`.
39
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
40
40
 
41
41
  ```bash
42
42
  MILESTONE_ID=$(echo "$INIT" | jq -r '.milestone_id')
@@ -31,11 +31,10 @@ CLAUDE.md.
31
31
 
32
32
  Parse: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`, `success_criteria`, `draft_results`, `verification_path`, `slice_uat`, `verifier_tier`, `text_mode`, `text_mode_source`, `agent_skills`.
33
33
 
34
- **Text-mode routing.** If `text_mode == true`, skip every `np-tools.cjs askuser`
35
- call below (including the Pass-2 `needs_user_confirm` gate) and render the
36
- options as a plain-text numbered list in the main chat. Auto-enabled in
37
- Claude Code (CLAUDECODE=1); opt-in via `.nubos-pilot/config.json`
38
- `workflow.text_mode`.
34
+ **Askuser routing.** Every `node .nubos-pilot/bin/np-tools.cjs askuser …` block below (including the Pass-2 `needs_user_confirm` gate) is a spec, not a literal command. Pick the path once at Initialize:
35
+ - **Claude Code** (native `AskUserQuestion` tool is available): parse the JSON spec and call `AskUserQuestion` directly. `select` `multiSelect: false`; `multiselect` → `multiSelect: true`; `confirm` → `options: [{label: "Yes"}, {label: "No"}]`; `input` → ask free-form in chat. Use a short `header` (≤12 chars).
36
+ - **`text_mode == true`** (INIT payload): skip every askuser block and render questions as plain-text numbered lists. Opt-in via `.nubos-pilot/config.json` `workflow.text_mode`.
37
+ - **Other runtime with TTY** (Codex, Gemini, …): execute the shell `askuser` block verbatim.
39
38
 
40
39
  ## Pass 1 — verifier agent
41
40