nubos-pilot 0.5.3 → 0.5.4

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.
@@ -44,6 +44,7 @@ const COMMANDS = [
44
44
  { name: 'commit', category: 'Utility', description: 'Atomic git commit wrapper with gitignore-guard' },
45
45
  { name: 'config-get', category: 'Utility', description: 'Read value from .nubos-pilot/config.json by dotted key path' },
46
46
  { name: 'lang-directive', category: 'Utility', description: 'Print workflow language directive from config.response_language (SSOT)' },
47
+ { name: 'text-mode', category: 'Utility', description: 'Print whether text mode is active (config.workflow.text_mode ∨ CLAUDECODE)' },
47
48
  { name: 'generate-slug', category: 'Utility', description: 'Slugify text via lib/layout.cjs.slugify' },
48
49
  { name: 'stats', category: 'Utility', description: 'Aggregated project stats (roadmap + STATE + git + metrics JSON shape)' },
49
50
 
@@ -5,6 +5,7 @@ const { NubosPilotError, atomicWriteFileSync, withFileLock } = require('../../li
5
5
  const { getPhase } = require('../../lib/roadmap.cjs');
6
6
  const layout = require('../../lib/layout.cjs');
7
7
  const { parseVerificationMd, milestoneVerificationPath } = require('../../lib/verify.cjs');
8
+ const textMode = require('../../lib/text-mode.cjs');
8
9
 
9
10
  const BEGIN_MARKER = '// >>> np:add-tests begin';
10
11
  const END_MARKER = '// <<< np:add-tests end';
@@ -138,6 +139,7 @@ function run(args, ctx) {
138
139
  const mNum = _validateMilestoneArg(list[1]);
139
140
  const target = _resolveTestTarget(mNum, cwd);
140
141
  const { passes, skips, verification_path } = _loadCases(mNum, cwd);
142
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
141
143
  const payload = {
142
144
  _workflow: 'add-tests',
143
145
  milestone: mNum,
@@ -146,6 +148,8 @@ function run(args, ctx) {
146
148
  verification_path,
147
149
  pass_cases: passes,
148
150
  skip_cases: skips,
151
+ text_mode: tmDetail.enabled,
152
+ text_mode_source: tmDetail.source,
149
153
  };
150
154
  stdout.write(JSON.stringify(payload, null, 2));
151
155
  return payload;
@@ -3,6 +3,7 @@ const path = require('node:path');
3
3
 
4
4
  const { projectStateDir, NubosPilotError } = require('../../lib/core.cjs');
5
5
  const { slugify } = require('../../lib/layout.cjs');
6
+ const textMode = require('../../lib/text-mode.cjs');
6
7
 
7
8
  const MAX_DESCRIPTION_LENGTH = 500;
8
9
 
@@ -54,6 +55,7 @@ function _buildPayload(description, cwd) {
54
55
  }
55
56
  const todos_dir_exists = fs.existsSync(todosDir);
56
57
  const state_path = path.join(stateDir, 'STATE.md');
58
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
57
59
  return {
58
60
  _workflow: 'add-todo',
59
61
  commit_docs: true,
@@ -66,6 +68,8 @@ function _buildPayload(description, cwd) {
66
68
  todos_dir_exists,
67
69
  todo_count,
68
70
  state_path,
71
+ text_mode: tmDetail.enabled,
72
+ text_mode_source: tmDetail.source,
69
73
  todos: [],
70
74
  };
71
75
  }
@@ -8,6 +8,7 @@ const crypto = require('node:crypto');
8
8
  const { NubosPilotError, projectStateDir } = require('../../lib/core.cjs');
9
9
  const { getPhase } = require('../../lib/roadmap.cjs');
10
10
  const layout = require('../../lib/layout.cjs');
11
+ const textMode = require('../../lib/text-mode.cjs');
11
12
 
12
13
  const INLINE_THRESHOLD_BYTES = 16 * 1024;
13
14
 
@@ -96,6 +97,8 @@ function run(args, ctx) {
96
97
  const has_context = fs.existsSync(contextPath);
97
98
  const has_milestone_dir = fs.existsSync(milestoneDir);
98
99
 
100
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
101
+
99
102
  const payload = {
100
103
  _workflow: 'discuss-phase',
101
104
  milestone: mNum,
@@ -109,6 +112,8 @@ function run(args, ctx) {
109
112
  requirements: Array.isArray(def.requirements) ? def.requirements : [],
110
113
  success_criteria: Array.isArray(def.success_criteria) ? def.success_criteria : [],
111
114
  mode: flags.assumptions ? 'assumptions' : 'adaptive',
115
+ text_mode: tmDetail.enabled,
116
+ text_mode_source: tmDetail.source,
112
117
  agent_skills: _agentSkills(),
113
118
  };
114
119
 
@@ -7,6 +7,20 @@ const { makeSandbox, seedRoadmapYaml, cleanupAll } =
7
7
  require('../../tests/helpers/fixture.cjs');
8
8
  const subcmd = require('./discuss-phase.cjs');
9
9
 
10
+ function _clearClaudeEnv() {
11
+ const saved = {};
12
+ for (const k of ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']) {
13
+ saved[k] = process.env[k];
14
+ delete process.env[k];
15
+ }
16
+ return () => {
17
+ for (const [k, v] of Object.entries(saved)) {
18
+ if (v === undefined) delete process.env[k];
19
+ else process.env[k] = v;
20
+ }
21
+ };
22
+ }
23
+
10
24
  function _baseRoadmap() {
11
25
  return {
12
26
  schema_version: 1,
@@ -42,24 +56,67 @@ function _captureStdout() {
42
56
  afterEach(cleanupAll);
43
57
 
44
58
  test('DP-1: run(["3"]) on valid milestone returns JSON payload with expected shape', () => {
45
- const sandbox = makeSandbox();
46
- seedRoadmapYaml(sandbox, _baseRoadmap());
47
- const cap = _captureStdout();
48
- subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
49
- const raw = cap.get().trim();
50
- assert.ok(!raw.startsWith('@file:'));
51
- const payload = JSON.parse(raw);
52
- assert.equal(payload.milestone, 3);
53
- assert.equal(payload.milestone_id, 'M003');
54
- assert.ok(payload.milestone_dir.endsWith(path.join('.nubos-pilot', 'milestones', 'M003')));
55
- assert.ok(payload.milestone_context_path.endsWith(path.join('M003', 'M003-CONTEXT.md')));
56
- assert.equal(payload.milestone_name, 'Observability');
57
- assert.equal(payload.has_context, false);
58
- assert.equal(payload.has_milestone_dir, false);
59
- assert.equal(payload.goal, 'Ship structured logging + metrics');
60
- assert.deepEqual(payload.requirements, ['OBS-01']);
61
- assert.ok('agent_skills' in payload);
62
- assert.equal(payload.mode, 'adaptive');
59
+ const restore = _clearClaudeEnv();
60
+ try {
61
+ const sandbox = makeSandbox();
62
+ seedRoadmapYaml(sandbox, _baseRoadmap());
63
+ const cap = _captureStdout();
64
+ subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
65
+ const raw = cap.get().trim();
66
+ assert.ok(!raw.startsWith('@file:'));
67
+ const payload = JSON.parse(raw);
68
+ assert.equal(payload.milestone, 3);
69
+ assert.equal(payload.milestone_id, 'M003');
70
+ assert.ok(payload.milestone_dir.endsWith(path.join('.nubos-pilot', 'milestones', 'M003')));
71
+ assert.ok(payload.milestone_context_path.endsWith(path.join('M003', 'M003-CONTEXT.md')));
72
+ assert.equal(payload.milestone_name, 'Observability');
73
+ assert.equal(payload.has_context, false);
74
+ assert.equal(payload.has_milestone_dir, false);
75
+ assert.equal(payload.goal, 'Ship structured logging + metrics');
76
+ assert.deepEqual(payload.requirements, ['OBS-01']);
77
+ assert.ok('agent_skills' in payload);
78
+ assert.equal(payload.mode, 'adaptive');
79
+ assert.equal(payload.text_mode, false);
80
+ assert.equal(payload.text_mode_source, 'default');
81
+ } finally {
82
+ restore();
83
+ }
84
+ });
85
+
86
+ test('DP-1b: CLAUDECODE=1 sets text_mode=true with runtime source', () => {
87
+ const restore = _clearClaudeEnv();
88
+ try {
89
+ process.env.CLAUDECODE = '1';
90
+ const sandbox = makeSandbox();
91
+ seedRoadmapYaml(sandbox, _baseRoadmap());
92
+ const cap = _captureStdout();
93
+ subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
94
+ const payload = JSON.parse(cap.get().trim());
95
+ assert.equal(payload.text_mode, true);
96
+ assert.equal(payload.text_mode_source, 'runtime');
97
+ } finally {
98
+ restore();
99
+ }
100
+ });
101
+
102
+ test('DP-1c: config workflow.text_mode=false wins over CLAUDECODE', () => {
103
+ const restore = _clearClaudeEnv();
104
+ try {
105
+ process.env.CLAUDECODE = '1';
106
+ const sandbox = makeSandbox();
107
+ seedRoadmapYaml(sandbox, _baseRoadmap());
108
+ fs.writeFileSync(
109
+ path.join(sandbox, '.nubos-pilot', 'config.json'),
110
+ JSON.stringify({ workflow: { text_mode: false } }),
111
+ );
112
+ const cap = _captureStdout();
113
+ subcmd.run(['3'], { cwd: sandbox, stdout: cap.stub });
114
+ const payload = JSON.parse(cap.get().trim());
115
+ assert.equal(payload.text_mode, false);
116
+ assert.equal(payload.text_mode_source, 'config');
117
+ } finally {
118
+ restore();
119
+ }
63
120
  });
64
121
 
65
122
  test('DP-2: run(["nonexistent"]) throws discuss-invalid-phase-arg', () => {
@@ -4,6 +4,7 @@ const path = require('node:path');
4
4
  const { NubosPilotError, atomicWriteFileSync } = require('../../lib/core.cjs');
5
5
  const { scan } = require('../../lib/workspace-scan.cjs');
6
6
  const { workspaceGitInfo } = require('../../lib/git.cjs');
7
+ const textMode = require('../../lib/text-mode.cjs');
7
8
 
8
9
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
9
10
  const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
@@ -98,6 +99,8 @@ function _emitPlan(projectRoot, flags, stdout) {
98
99
 
99
100
  const scanContext = _scanContextFor(projectRoot);
100
101
 
102
+ const tmDetail = textMode.resolveTextModeDetail(projectRoot);
103
+
101
104
  stdout.write(JSON.stringify({
102
105
  mode: 'plan',
103
106
  sub_mode: mode,
@@ -107,6 +110,8 @@ function _emitPlan(projectRoot, flags, stdout) {
107
110
  questions: _grayAreas(),
108
111
  required_fields: REQUIRED_FIELDS,
109
112
  requirements_md_path: path.join(projectRoot, '.nubos-pilot', 'REQUIREMENTS.md'),
113
+ text_mode: tmDetail.enabled,
114
+ text_mode_source: tmDetail.source,
110
115
  }, null, 2));
111
116
  }
112
117
 
@@ -13,6 +13,7 @@ const layout = require('../../lib/layout.cjs');
13
13
  const { getPhase } = require('../../lib/roadmap.cjs');
14
14
  const { extractFrontmatter } = require('../../lib/frontmatter.cjs');
15
15
  const { getAgentSkills } = require('../../lib/agents.cjs');
16
+ const textMode = require('../../lib/text-mode.cjs');
16
17
 
17
18
  const INLINE_THRESHOLD_BYTES = 16 * 1024;
18
19
 
@@ -119,6 +120,8 @@ function _initPayload(mNum, cwd) {
119
120
  tasks,
120
121
  });
121
122
  }
123
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
124
+
122
125
  return {
123
126
  _workflow: 'execute-milestone',
124
127
  milestone: mNum,
@@ -132,6 +135,8 @@ function _initPayload(mNum, cwd) {
132
135
  total_tasks: totalTasks,
133
136
  slice_count: slices.length,
134
137
  executor_tier: 'sonnet',
138
+ text_mode: tmDetail.enabled,
139
+ text_mode_source: tmDetail.source,
135
140
  agent_skills: { executor: _safeSkills('np-executor', cwd) },
136
141
  };
137
142
  }
@@ -13,6 +13,7 @@ const {
13
13
  const { parseRoadmap } = require('../../lib/roadmap.cjs');
14
14
  const { mutateState } = require('../../lib/state.cjs');
15
15
  const layout = require('../../lib/layout.cjs');
16
+ const textMode = require('../../lib/text-mode.cjs');
16
17
 
17
18
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates', 'milestone');
18
19
  const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
@@ -49,9 +50,12 @@ function _emit(stdout, payload) {
49
50
  stdout.write(JSON.stringify(payload, null, 2));
50
51
  }
51
52
 
52
- function _interviewPayload() {
53
+ function _interviewPayload(cwd) {
54
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
53
55
  return {
54
56
  mode: 'interview',
57
+ text_mode: tmDetail.enabled,
58
+ text_mode_source: tmDetail.source,
55
59
  questions: [
56
60
  { key: 'milestone_name', type: 'input',
57
61
  question: 'Milestone name (e.g. "Auth & Basic UI")?' },
@@ -282,7 +286,7 @@ function run(args, ctx) {
282
286
  return;
283
287
  }
284
288
 
285
- _emit(stdout, _interviewPayload());
289
+ _emit(stdout, _interviewPayload(cwd));
286
290
  }
287
291
 
288
292
  module.exports = { run, _interviewPayload };
@@ -10,6 +10,7 @@ const {
10
10
  } = require('../../lib/core.cjs');
11
11
  const { writeState } = require('../../lib/state.cjs');
12
12
  const layout = require('../../lib/layout.cjs');
13
+ const textMode = require('../../lib/text-mode.cjs');
13
14
 
14
15
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
15
16
  const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
@@ -42,9 +43,12 @@ function _todayIso() {
42
43
  return new Date().toISOString().slice(0, 10);
43
44
  }
44
45
 
45
- function _interviewPayload() {
46
+ function _interviewPayload(cwd) {
47
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
46
48
  return {
47
49
  mode: 'interview',
50
+ text_mode: tmDetail.enabled,
51
+ text_mode_source: tmDetail.source,
48
52
  questions: [
49
53
  { key: 'project_name', type: 'input',
50
54
  question: 'Project name?' },
@@ -286,7 +290,7 @@ function run(args, ctx) {
286
290
  return;
287
291
  }
288
292
 
289
- _emit(stdout, _interviewPayload());
293
+ _emit(stdout, _interviewPayload(cwd));
290
294
  }
291
295
 
292
296
  module.exports = { run, _interviewPayload, _slugify };
@@ -14,6 +14,7 @@ const {
14
14
  const layout = require('../../lib/layout.cjs');
15
15
  const { getPhase } = require('../../lib/roadmap.cjs');
16
16
  const { getAgentSkills } = require('../../lib/agents.cjs');
17
+ const textMode = require('../../lib/text-mode.cjs');
17
18
 
18
19
  const INLINE_THRESHOLD_BYTES = 16 * 1024;
19
20
 
@@ -104,6 +105,8 @@ function _initPayload(mNum, cwd) {
104
105
  };
105
106
  });
106
107
 
108
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
109
+
107
110
  return {
108
111
  _workflow: 'plan-milestone',
109
112
  milestone: mNum,
@@ -122,6 +125,8 @@ function _initPayload(mNum, cwd) {
122
125
  existing_slices: sliceStatus,
123
126
  planner_tier: 'opus',
124
127
  checker_tier: 'opus',
128
+ text_mode: tmDetail.enabled,
129
+ text_mode_source: tmDetail.source,
125
130
  agent_skills: {
126
131
  'np-planner': _safeSkills('np-planner', cwd),
127
132
  'np-plan-checker': _safeSkills('np-plan-checker', cwd),
@@ -7,6 +7,7 @@ const crypto = require('node:crypto');
7
7
 
8
8
  const { NubosPilotError, projectStateDir } = require('../../lib/core.cjs');
9
9
  const layout = require('../../lib/layout.cjs');
10
+ const textMode = require('../../lib/text-mode.cjs');
10
11
 
11
12
  const INLINE_THRESHOLD_BYTES = 16 * 1024;
12
13
 
@@ -107,6 +108,8 @@ function run(args, ctx) {
107
108
  };
108
109
  });
109
110
 
111
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
112
+
110
113
  const payload = {
111
114
  _workflow: 'research-phase',
112
115
  milestone: mNum,
@@ -118,6 +121,8 @@ function run(args, ctx) {
118
121
  has_research,
119
122
  slice_research: sliceResearch,
120
123
  tools_available: _toolsAvailable(),
124
+ text_mode: tmDetail.enabled,
125
+ text_mode_source: tmDetail.source,
121
126
  agent_skills: _agentSkills(cwd),
122
127
  };
123
128
  _emit(payload, stdout, cwd);
@@ -4,6 +4,7 @@ const { NubosPilotError } = require('../../lib/core.cjs');
4
4
  const { readState } = require('../../lib/state.cjs');
5
5
  const { readCheckpoint, listCheckpoints } = require('../../lib/checkpoint.cjs');
6
6
  const { TASK_ID_RE } = require('../../lib/tasks.cjs');
7
+ const textMode = require('../../lib/text-mode.cjs');
7
8
 
8
9
  function _safeReadState(cwd) {
9
10
  try { return readState(cwd); } catch { return null; }
@@ -69,6 +70,10 @@ function run(_args, ctx) {
69
70
  };
70
71
  }
71
72
 
73
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
74
+ payload.text_mode = tmDetail.enabled;
75
+ payload.text_mode_source = tmDetail.source;
76
+
72
77
  stdout.write(JSON.stringify(payload));
73
78
  return payload;
74
79
  }
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { resolveTextModeDetail } = require('../../lib/text-mode.cjs');
4
+
5
+ function _usage() {
6
+ return 'Usage:\n np-tools.cjs text-mode [--json]';
7
+ }
8
+
9
+ function _emitError(err, stderr) {
10
+ const code = err && err.name === 'NubosPilotError' ? err.code : 'text-mode-internal-error';
11
+ const message = (err && err.message) || String(err);
12
+ const details = (err && err.details) || null;
13
+ stderr.write(JSON.stringify({ code, message, details }) + '\n');
14
+ }
15
+
16
+ function run(argv, ctx) {
17
+ const context = ctx || {};
18
+ const cwd = context.cwd || process.cwd();
19
+ const stdout = context.stdout || process.stdout;
20
+ const stderr = context.stderr || process.stderr;
21
+ const args = Array.isArray(argv) ? argv.slice() : [];
22
+
23
+ let wantJson = false;
24
+ for (const a of args) {
25
+ if (a === '--json') { wantJson = true; continue; }
26
+ if (a === '-h' || a === '--help') {
27
+ stdout.write(_usage() + '\n');
28
+ return 0;
29
+ }
30
+ stderr.write(JSON.stringify({
31
+ code: 'text-mode-unknown-arg',
32
+ message: 'Unknown argument: ' + a,
33
+ details: { arg: a },
34
+ }) + '\n');
35
+ return 1;
36
+ }
37
+
38
+ try {
39
+ const detail = resolveTextModeDetail(cwd);
40
+ if (wantJson) {
41
+ stdout.write(JSON.stringify(detail) + '\n');
42
+ } else {
43
+ stdout.write(String(detail.enabled) + '\n');
44
+ }
45
+ return 0;
46
+ } catch (err) {
47
+ _emitError(err, stderr);
48
+ return 1;
49
+ }
50
+ }
51
+
52
+ module.exports = { run };
53
+
54
+ if (require.main === module) {
55
+ process.exit(run(process.argv.slice(2)));
56
+ }
@@ -0,0 +1,132 @@
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 subcmd = require('./text-mode.cjs');
10
+
11
+ function _mkSandbox() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-text-mode-cli-'));
13
+ fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
14
+ return dir;
15
+ }
16
+
17
+ function _captureIO() {
18
+ const out = [];
19
+ const err = [];
20
+ return {
21
+ stdout: { write: (s) => { out.push(String(s)); return true; } },
22
+ stderr: { write: (s) => { err.push(String(s)); return true; } },
23
+ stdoutText: () => out.join(''),
24
+ stderrText: () => err.join(''),
25
+ };
26
+ }
27
+
28
+ function _clearClaudeEnv() {
29
+ const saved = {};
30
+ for (const k of ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']) {
31
+ saved[k] = process.env[k];
32
+ delete process.env[k];
33
+ }
34
+ return () => {
35
+ for (const [k, v] of Object.entries(saved)) {
36
+ if (v === undefined) delete process.env[k];
37
+ else process.env[k] = v;
38
+ }
39
+ };
40
+ }
41
+
42
+ test('text-mode CLI: default without config and without Claude env prints "false"', () => {
43
+ const restore = _clearClaudeEnv();
44
+ try {
45
+ const dir = _mkSandbox();
46
+ try {
47
+ const io = _captureIO();
48
+ const rc = subcmd.run([], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
49
+ assert.equal(rc, 0);
50
+ assert.equal(io.stdoutText().trim(), 'false');
51
+ } finally {
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ } finally {
55
+ restore();
56
+ }
57
+ });
58
+
59
+ test('text-mode CLI: CLAUDECODE=1 prints "true"', () => {
60
+ const restore = _clearClaudeEnv();
61
+ try {
62
+ process.env.CLAUDECODE = '1';
63
+ const dir = _mkSandbox();
64
+ try {
65
+ const io = _captureIO();
66
+ const rc = subcmd.run([], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
67
+ assert.equal(rc, 0);
68
+ assert.equal(io.stdoutText().trim(), 'true');
69
+ } finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ } finally {
73
+ restore();
74
+ }
75
+ });
76
+
77
+ test('text-mode CLI: --json emits detail object', () => {
78
+ const restore = _clearClaudeEnv();
79
+ try {
80
+ process.env.CLAUDECODE = '1';
81
+ const dir = _mkSandbox();
82
+ try {
83
+ const io = _captureIO();
84
+ const rc = subcmd.run(['--json'], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
85
+ assert.equal(rc, 0);
86
+ const payload = JSON.parse(io.stdoutText().trim());
87
+ assert.equal(payload.enabled, true);
88
+ assert.equal(payload.source, 'runtime');
89
+ } finally {
90
+ fs.rmSync(dir, { recursive: true, force: true });
91
+ }
92
+ } finally {
93
+ restore();
94
+ }
95
+ });
96
+
97
+ test('text-mode CLI: config workflow.text_mode=false wins over CLAUDECODE', () => {
98
+ const restore = _clearClaudeEnv();
99
+ try {
100
+ process.env.CLAUDECODE = '1';
101
+ const dir = _mkSandbox();
102
+ try {
103
+ fs.writeFileSync(
104
+ path.join(dir, '.nubos-pilot', 'config.json'),
105
+ JSON.stringify({ workflow: { text_mode: false } }),
106
+ );
107
+ const io = _captureIO();
108
+ const rc = subcmd.run(['--json'], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
109
+ assert.equal(rc, 0);
110
+ const payload = JSON.parse(io.stdoutText().trim());
111
+ assert.equal(payload.enabled, false);
112
+ assert.equal(payload.source, 'config');
113
+ } finally {
114
+ fs.rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ } finally {
117
+ restore();
118
+ }
119
+ });
120
+
121
+ test('text-mode CLI: unknown flag exits 1 with structured error', () => {
122
+ const dir = _mkSandbox();
123
+ try {
124
+ const io = _captureIO();
125
+ const rc = subcmd.run(['--wat'], { cwd: dir, stdout: io.stdout, stderr: io.stderr });
126
+ assert.equal(rc, 1);
127
+ const payload = JSON.parse(io.stderrText().trim());
128
+ assert.equal(payload.code, 'text-mode-unknown-arg');
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
@@ -19,6 +19,7 @@ const {
19
19
  milestoneVerificationPath,
20
20
  } = require('../../lib/verify.cjs');
21
21
  const { getAgentSkills } = require('../../lib/agents.cjs');
22
+ const textMode = require('../../lib/text-mode.cjs');
22
23
 
23
24
  const INLINE_THRESHOLD_BYTES = 16 * 1024;
24
25
  const _VALID_SC_STATUSES = new Set(['Pass', 'Fail', 'Defer', 'Pending']);
@@ -90,6 +91,8 @@ function _initPayload(mNum, cwd) {
90
91
  };
91
92
  });
92
93
 
94
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
95
+
93
96
  return {
94
97
  _workflow: 'verify-work',
95
98
  milestone: mNum,
@@ -101,6 +104,8 @@ function _initPayload(mNum, cwd) {
101
104
  verification_path: verificationPath,
102
105
  slice_uat: sliceUat,
103
106
  verifier_tier: 'sonnet',
107
+ text_mode: tmDetail.enabled,
108
+ text_mode_source: tmDetail.source,
104
109
  agent_skills: { verifier: _safeSkills('np-verifier', cwd) },
105
110
  };
106
111
  }
@@ -1,4 +1,9 @@
1
- const { _readOneLine, _parseAnswer, _hasReadlineImplForTests } = require('./_readline.cjs');
1
+ const {
2
+ _readOneLine,
3
+ _parseAnswer,
4
+ _hasReadlineImplForTests,
5
+ askUserReadline,
6
+ } = require('./_readline.cjs');
2
7
  const { NubosPilotError } = require('../core.cjs');
3
8
 
4
9
  function _emitClaudeMarkerBlock({ type, question, options, def }) {
@@ -19,7 +24,10 @@ async function askUser(spec) {
19
24
  const options = spec && spec.options;
20
25
  const def = spec ? spec.default : undefined;
21
26
  const hasTTY = !!process.stdin.isTTY;
22
- if (!hasTTY && !_hasReadlineImplForTests()) {
27
+ if (hasTTY) {
28
+ return askUserReadline({ type, question, options, def });
29
+ }
30
+ if (!_hasReadlineImplForTests()) {
23
31
  if (def !== undefined && def !== null) {
24
32
  return { value: def, source: 'default' };
25
33
  }
@@ -118,6 +118,34 @@ test('claude-adapter: askUser without TTY and without default throws askuser-no-
118
118
  }
119
119
  });
120
120
 
121
+ test('claude-adapter: askUser with TTY stdin uses readline UI, no marker block', async () => {
122
+ const originalIsTTY = process.stdin.isTTY;
123
+ _setReadlineImplForTests(async () => '2');
124
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
125
+ const stderrChunks = [];
126
+ process.stderr.write = (chunk) => { stderrChunks.push(String(chunk)); return true; };
127
+ try {
128
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
129
+ const { val, out } = await captureStdout(() =>
130
+ claude.askUser({ type: 'select', question: 'Q', options: ['A', 'B', 'C'] })
131
+ );
132
+ assert.ok(!/<!-- askUser v1 -->/.test(out),
133
+ 'must not emit marker block when stdin is TTY');
134
+ assert.equal(val.source, 'readline');
135
+ assert.equal(val.value, 'B');
136
+ const stderrJoined = stderrChunks.join('');
137
+ assert.match(stderrJoined, /Q/, 'readline UI should render the question on stderr');
138
+ } finally {
139
+ process.stderr.write = originalStderrWrite;
140
+ _setReadlineImplForTests(null);
141
+ if (originalIsTTY === undefined) {
142
+ delete process.stdin.isTTY;
143
+ } else {
144
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
145
+ }
146
+ }
147
+ });
148
+
121
149
  test('claude-adapter: askUser without TTY but with default returns default', async () => {
122
150
  const originalIsTTY = process.stdin.isTTY;
123
151
  try {
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { findProjectRoot, NubosPilotError } = require('./core.cjs');
6
+
7
+ const DEFAULT_TEXT_MODE = false;
8
+ const CLAUDE_ENV_KEYS = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'];
9
+
10
+ function _coerceBool(raw) {
11
+ if (raw === true || raw === false) return raw;
12
+ if (raw == null) return null;
13
+ const s = String(raw).trim().toLowerCase();
14
+ if (s === 'true' || s === '1' || s === 'yes' || s === 'on') return true;
15
+ if (s === 'false' || s === '0' || s === 'no' || s === 'off') return false;
16
+ return null;
17
+ }
18
+
19
+ function readConfigTextMode(cwd) {
20
+ let root;
21
+ try {
22
+ root = findProjectRoot(cwd || process.cwd());
23
+ } catch (err) {
24
+ if (err && err.code === 'not-in-project') return null;
25
+ throw err;
26
+ }
27
+ const p = path.join(root, '.nubos-pilot', 'config.json');
28
+ if (!fs.existsSync(p)) return null;
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
32
+ } catch (err) {
33
+ throw new NubosPilotError('text-mode-config-parse-error', 'config.json invalid JSON', { cause: err && err.message });
34
+ }
35
+ if (!parsed || typeof parsed !== 'object') return null;
36
+ const workflow = parsed.workflow;
37
+ if (!workflow || typeof workflow !== 'object') return null;
38
+ if (!Object.prototype.hasOwnProperty.call(workflow, 'text_mode')) return null;
39
+ const coerced = _coerceBool(workflow.text_mode);
40
+ return coerced;
41
+ }
42
+
43
+ function detectRuntimeTextMode(env) {
44
+ const source = env || process.env;
45
+ for (const key of CLAUDE_ENV_KEYS) {
46
+ const v = source[key];
47
+ if (v != null && String(v) !== '' && String(v) !== '0' && String(v).toLowerCase() !== 'false') {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function resolveTextMode(cwd, env) {
55
+ const fromConfig = readConfigTextMode(cwd);
56
+ if (fromConfig !== null) return fromConfig;
57
+ if (detectRuntimeTextMode(env)) return true;
58
+ return DEFAULT_TEXT_MODE;
59
+ }
60
+
61
+ function resolveTextModeDetail(cwd, env) {
62
+ const fromConfig = readConfigTextMode(cwd);
63
+ if (fromConfig !== null) {
64
+ return { enabled: fromConfig, source: 'config' };
65
+ }
66
+ if (detectRuntimeTextMode(env)) {
67
+ return { enabled: true, source: 'runtime' };
68
+ }
69
+ return { enabled: DEFAULT_TEXT_MODE, source: 'default' };
70
+ }
71
+
72
+ module.exports = {
73
+ DEFAULT_TEXT_MODE,
74
+ CLAUDE_ENV_KEYS,
75
+ readConfigTextMode,
76
+ detectRuntimeTextMode,
77
+ resolveTextMode,
78
+ resolveTextModeDetail,
79
+ };
@@ -0,0 +1,139 @@
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 tm = require('./text-mode.cjs');
10
+
11
+ function _mkSandbox() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-textmode-'));
13
+ fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
14
+ return dir;
15
+ }
16
+
17
+ function _writeConfig(dir, obj) {
18
+ fs.writeFileSync(path.join(dir, '.nubos-pilot', 'config.json'), JSON.stringify(obj, null, 2));
19
+ }
20
+
21
+ test('text-mode: default without config or runtime env is false', () => {
22
+ const dir = _mkSandbox();
23
+ try {
24
+ assert.equal(tm.resolveTextMode(dir, {}), false);
25
+ const detail = tm.resolveTextModeDetail(dir, {});
26
+ assert.deepEqual(detail, { enabled: false, source: 'default' });
27
+ } finally {
28
+ fs.rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ test('text-mode: CLAUDECODE=1 in env flips default to true', () => {
33
+ const dir = _mkSandbox();
34
+ try {
35
+ assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), true);
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);
47
+ } finally {
48
+ fs.rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ test('text-mode: env value "0" or "false" does not flip', () => {
53
+ const dir = _mkSandbox();
54
+ 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);
58
+ } finally {
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('text-mode: config workflow.text_mode=true wins over absent runtime', () => {
64
+ const dir = _mkSandbox();
65
+ try {
66
+ _writeConfig(dir, { workflow: { text_mode: true } });
67
+ assert.equal(tm.resolveTextMode(dir, {}), true);
68
+ assert.deepEqual(tm.resolveTextModeDetail(dir, {}), { enabled: true, source: 'config' });
69
+ } finally {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('text-mode: config workflow.text_mode=false overrides CLAUDECODE runtime', () => {
75
+ const dir = _mkSandbox();
76
+ try {
77
+ _writeConfig(dir, { workflow: { text_mode: false } });
78
+ assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), false);
79
+ assert.deepEqual(
80
+ tm.resolveTextModeDetail(dir, { CLAUDECODE: '1' }),
81
+ { enabled: false, source: 'config' },
82
+ );
83
+ } finally {
84
+ fs.rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test('text-mode: config workflow.text_mode="true" string coerced to boolean', () => {
89
+ const dir = _mkSandbox();
90
+ try {
91
+ _writeConfig(dir, { workflow: { text_mode: 'true' } });
92
+ assert.equal(tm.resolveTextMode(dir, {}), true);
93
+ _writeConfig(dir, { workflow: { text_mode: '0' } });
94
+ assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), false);
95
+ } finally {
96
+ fs.rmSync(dir, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test('text-mode: config missing workflow.text_mode falls through to runtime detection', () => {
101
+ const dir = _mkSandbox();
102
+ try {
103
+ _writeConfig(dir, { response_language: 'de' });
104
+ assert.equal(tm.resolveTextMode(dir, { CLAUDECODE: '1' }), true);
105
+ assert.equal(tm.resolveTextMode(dir, {}), false);
106
+ } finally {
107
+ fs.rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('text-mode: readConfigTextMode returns null outside project root', () => {
112
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'np-outside-'));
113
+ try {
114
+ assert.equal(tm.readConfigTextMode(outside), null);
115
+ assert.equal(tm.resolveTextMode(outside, {}), false);
116
+ } finally {
117
+ fs.rmSync(outside, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ test('text-mode: readConfigTextMode throws on invalid JSON', () => {
122
+ const dir = _mkSandbox();
123
+ try {
124
+ fs.writeFileSync(path.join(dir, '.nubos-pilot', 'config.json'), '{not json');
125
+ assert.throws(
126
+ () => tm.readConfigTextMode(dir),
127
+ (err) => err && err.code === 'text-mode-config-parse-error',
128
+ );
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
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);
137
+ assert.equal(tm.detectRuntimeTextMode({ OTHER: '1' }), false);
138
+ assert.equal(tm.detectRuntimeTextMode({}), false);
139
+ });
package/np-tools.cjs CHANGED
@@ -46,6 +46,7 @@ const topLevelCommands = {
46
46
  'resolve-model': require('./bin/np-tools/resolve-model.cjs'),
47
47
  'stats': require('./bin/np-tools/stats.cjs'),
48
48
  'lang-directive': require('./bin/np-tools/lang-directive.cjs'),
49
+ 'text-mode': require('./bin/np-tools/text-mode.cjs'),
49
50
  };
50
51
 
51
52
  const THRESHOLD = 16 * 1024;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {
@@ -41,11 +41,16 @@ TODO_PATH="${PENDING_DIR}/${DATE}-${SLUG}.md"
41
41
  ```
42
42
 
43
43
  Extract from init JSON: `commit_docs`, `date`, `timestamp`, `slug`,
44
- `todo_count`, `todos_dir_exists`, `pending_dir`, `state_path`. The
45
- init handler sanitises the slug through `lib/layout.cjs.slugify`
46
- (strips to `[a-z0-9-]` only; filename-injection mitigation) and
47
- validates the description length (<= 500 chars) before any filesystem
48
- write occurs.
44
+ `todo_count`, `todos_dir_exists`, `pending_dir`, `state_path`,
45
+ `text_mode`, `text_mode_source`. The init handler sanitises the slug
46
+ through `lib/layout.cjs.slugify` (strips to `[a-z0-9-]` only;
47
+ filename-injection mitigation) and validates the description length
48
+ (<= 500 chars) before any filesystem write occurs.
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`.
49
54
 
50
55
  ## Create Pending Dir
51
56
 
@@ -32,7 +32,24 @@ conflict — the config is the single source of truth.
32
32
 
33
33
  Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`,
34
34
  `milestone_context_path`, `has_context`, `has_milestone_dir`, `goal`,
35
- `requirements`, `agent_skills`, `mode`.
35
+ `requirements`, `agent_skills`, `mode`, `text_mode`, `text_mode_source`.
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.
36
53
 
37
54
  If the user passed `--assumptions`, route to
38
55
  `workflows/discuss-phase-assumptions.md` and exit this workflow.
@@ -125,30 +142,33 @@ Capture the idea in a "Deferred Ideas" section. Don't lose it, don't act on it.
125
142
  ## Answer Validation
126
143
 
127
144
  <answer_validation>
128
- **IMPORTANT: Answer validation** After every interactive prompt, check the
129
- exit code and the response:
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:**
130
152
  1. If `askuser` exits with structured error `askuser-no-tty` (exit code 1,
131
- stderr JSON with `"code":"askuser-no-tty"`), **skip retry** and fall back
132
- immediately to the plain-text numbered list described below the runtime
133
- cannot prompt interactively in this session.
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).
134
156
  2. If the response is empty or whitespace-only (exit 0 but no value), retry
135
157
  the question once with the same parameters.
136
158
  3. If still empty, present the options as a plain-text numbered list and ask
137
159
  the user to type their choice number.
138
160
  Never proceed with an empty answer.
139
161
 
140
- **Text mode (`workflow.text_mode: true` in config or `--text` flag):**
141
- When text mode is active, **do not use `np-tools.cjs askuser` at all**.
142
- Instead, present every question as a plain-text numbered list and ask the
143
- user to type their choice number. This is required for Claude Code remote
144
- sessions (`/rc` mode) where the Claude App cannot forward TUI menu selections
145
- back to the host.
146
-
147
- Enable text mode:
148
- - Per-session: pass `--text` flag
149
- - Per-project: `np-tools.cjs config-set workflow.text_mode true`
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).
150
169
 
151
- Text mode applies to ALL workflows in the session, not just discuss-phase.
170
+ Text mode applies to ALL workflows that emit `text_mode` in their INIT
171
+ payload, not just discuss-phase.
152
172
  </answer_validation>
153
173
 
154
174
  ## Process
@@ -30,7 +30,13 @@ into every np-executor spawn prompt as a system-level rule so task summaries
30
30
  and checkpoint notes follow the project language. This supersedes any
31
31
  directive in CLAUDE.md managed block.
32
32
 
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`, `agent_skills`.
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
+
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`.
34
40
 
35
41
  `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`).
36
42
 
@@ -62,6 +62,12 @@ 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`.
70
+
65
71
  Payload: three questions — `milestone_name`, `milestone_goal`, `create_req_prefix` (confirm).
66
72
 
67
73
  ## Interview
@@ -117,6 +117,12 @@ user-facing output, and any narrative prose written into PROJECT.md /
117
117
  REQUIREMENTS.md (field names and YAML keys stay canonical English).
118
118
  Supersedes CLAUDE.md.
119
119
 
120
+ **Text-mode routing.** If INIT payload `text_mode == true`, skip every
121
+ `np-tools.cjs askuser` call below and render each question as a plain-text
122
+ prompt in the main chat; collect the user's answer inline and move on.
123
+ Auto-enabled in Claude Code (CLAUDECODE=1); opt-in via
124
+ `.nubos-pilot/config.json` → `workflow.text_mode`.
125
+
120
126
  ```bash
121
127
  ANS_PROJECT_NAME=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{"type":"input","prompt":"Project name?"}')
122
128
  ANS_CORE_VALUE=$(node .nubos-pilot/bin/np-tools.cjs askuser --json '{"type":"input","prompt":"Core value — one sentence that must stay true if everything else fails?"}')
package/workflows/note.md CHANGED
@@ -52,6 +52,17 @@ 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:
56
+
57
+ ```bash
58
+ TEXT_MODE=$(node .nubos-pilot/bin/np-tools.cjs text-mode 2>/dev/null || echo false)
59
+ ```
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`.
65
+
55
66
  ## Compute Paths
56
67
 
57
68
  Scope branching is explicit (T-10-05-02 mitigation + Pitfall 10
@@ -86,7 +86,12 @@ askuser prompts, status updates, and any narrative text the spawned planner
86
86
  or plan-checker subagents emit. Pass `$LANG_DIRECTIVE` into their spawn
87
87
  prompts as a system-level rule. This supersedes any directive in CLAUDE.md.
88
88
 
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`, `agent_skills`.
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
+
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`.
90
95
 
91
96
  `PLAN_ID` and `TASK_ID` default to `${milestone_id}-plan` / `${milestone_id}-planner-run` for the metrics records.
92
97
 
@@ -107,6 +107,11 @@ 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`.
114
+
110
115
  The payload shape:
111
116
 
112
117
  ```json
@@ -24,6 +24,11 @@ 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`.
31
+
27
32
  ## Execution
28
33
 
29
34
  ### status: resume
@@ -56,6 +56,17 @@ sections (summary, highlights, notable events) and any askuser prompts.
56
56
  Task IDs, milestone IDs, commit SHAs, metrics keys, and file paths stay
57
57
  canonical English. Supersedes CLAUDE.md.
58
58
 
59
+ **Text-mode routing.** Resolve once:
60
+
61
+ ```bash
62
+ TEXT_MODE=$(node .nubos-pilot/bin/np-tools.cjs text-mode 2>/dev/null || echo false)
63
+ ```
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`.
69
+
59
70
  The filename format is `YYYY-MM-DDTHHMM-session-report.md` (D-17 —
60
71
  4-char HHMM, no seconds, local time) so reports sort
61
72
  lexicographically by invocation order.
@@ -31,7 +31,12 @@ prompts, and pass it into the np-nyquist-auditor spawn prompt so gap-fill
31
31
  narrative follows the project language. Test IDs, file paths, and canonical
32
32
  field names stay English. Supersedes CLAUDE.md.
33
33
 
34
- Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`, `slice_uat`.
34
+ Parse JSON for: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`, `slice_uat`, `text_mode`, `text_mode_source`.
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`.
35
40
 
36
41
  ```bash
37
42
  MILESTONE_ID=$(echo "$INIT" | jq -r '.milestone_id')
@@ -29,7 +29,13 @@ prose (Pass/Fail findings, root-cause notes) follows the project language.
29
29
  Test-case IDs, file paths, and stack traces stay canonical. Supersedes
30
30
  CLAUDE.md.
31
31
 
32
- Parse: `milestone`, `milestone_id`, `milestone_dir`, `milestone_name`, `success_criteria`, `draft_results`, `verification_path`, `slice_uat`, `verifier_tier`, `agent_skills`.
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
+
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`.
33
39
 
34
40
  ## Pass 1 — verifier agent
35
41