@asobacloud/nehanda 0.8.0 → 0.9.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Nehanda Command-Line Interface (`nehanda-cli`)
2
2
 
3
- An agentic terminal REPL and single-process engine built for energy systems research and software development. `nehanda-cli` connects directly to our flagship [Nehanda v3](https://huggingface.co/asoba/nehanda-v3-27b), normalizes multi-OEM energy telemetry through the ODSE protocol, and enforces a governed development workflow across the full research-to-production lifecycle. It also connects to local or cloud-based Ollama models, LM Studio instances, or any OpenAI-compatible API.
3
+ An agentic terminal REPL and single-process engine built for energy systems research and software development. `nehanda-cli` works with Ollama, LM Studio, OpenRouter, Anthropic Claude, ZhipuAI, or any OpenAI-compatible API — local or cloud. It normalizes multi-OEM energy telemetry through the ODSE protocol and enforces a governed development workflow across the full research-to-production lifecycle.
4
4
 
5
5
  Every conversation turn, tool call, phase transition, and permission check is stored in a local, queryable SQLite database you own, ensuring complete transcripts exist for auditing and debugging.
6
6
 
@@ -94,7 +94,7 @@ Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the hos
94
94
 
95
95
  * **Dynamic Tool Rescue (`[TOOL_CALL]`):** Native support for endpoints that strip OpenAI tool schemas (such as `nehandaMlProxy`). The engine dynamically injects active tool schemas directly into system prompts as `[TOOL_CALL]` blocks, parsing and executing tools locally without server-side function-calling support. Uses `[TOOL_CALL]` delimiters instead of `<tool_call>` XML to prevent vLLM's `--tool-call-parser qwen3_xml` stop-token interception.
96
96
 
97
- * **Deterministic SDLC Workflow:** Enforces a 6-phase state machine (`idle` → `plan` → `implement` → `test` → `verify` → `done`) to prevent unapproved code changes, hallucinated test passes, or unverified implementations.
97
+ * **Deterministic SDLC Workflow:** Enforces a 7-phase state machine (`idle` → `explore` → `planning` → `test` → `implement` → `test` → `verify` → `done`) following the AGENTS.md TDD mandate. Transitions are gated: `planning → implement` is blocked until an approved plan exists and at least one RED behavioral test is recorded; `test → verify` requires all tests passing and safety checkers clean.
98
98
 
99
99
  * **Interactive Safety Interventions:** When execution checkers flag a command — opening local sockets, inspecting HIL testbed hardware paths, or writing outside the working directory — the engine raises a structured `{ status, reason, risk_score, proposed_remediation }` payload rather than crashing. In the TUI, blocked operations surface a `BlockConfirmMenu` (`[A] Approve for Session | [S] Run in Laya Sandbox | [C] Cancel`) wired directly into the Ink event loop. Session approvals are cached so the same operation is not re-prompted within a session. Headless pipe mode auto-denies without prompting.
100
100
 
@@ -102,7 +102,7 @@ Local Ollama / LM Studio: pick that provider in `/model`, accept or edit the hos
102
102
 
103
103
  * **Runtime Visibility:** `RuntimeHealthMonitor` probes active inference endpoints (1s HEAD request) and checks Laya sidecar liveness before each session. The TUI status header shows active model, endpoint health and latency, sidecar PID, and context usage percentage — so researchers have immediate visual confirmation of whether the target backend is an internal local host or an external cloud endpoint before uploading grid telemetry.
104
104
 
105
- * **Process Legibility:** Every tool call is visible while it runs. Tool-start lines show argument summaries (`⚙ Read(ui.mjs)`, `⚙ Bash(git status)`), a live spinner with plain-English verbs tracks activity between outputs (`⠋ Analyzing…`, `⠙ Reading ui.mjs…`), and tool results carry honest context suffixes (`✓ Read: … (N lines)`, `✓ Bash: … (240ms)`, `✓ Write: … (N chars)`) — never fabricated data. A dim `esc to cancel` hint stays available while a turn runs, and result previews are capped at 120 characters so raw output never floods the screen. Backed by `summarizeToolArgs`, `verbForTool`, `formatToolResultSuffix`, and `TOOL_VERBS` in `lib/ui.mjs`, with a live spinner channel (`bridge.updateSpinner`) in the Ink TUI.
105
+ * **Process Legibility:** Every tool call is visible while it runs. Tool-start lines show argument summaries (`⚙ Read(ui.mjs)`, `⚙ Bash(git status)`), a live spinner with plain-English verbs tracks activity between outputs (`⠋ Analyzing…`, `⠙ Reading ui.mjs…`), and tool results carry honest context suffixes (`✓ Read: … (N lines)`, `✓ Bash: … (240ms)`, `✓ Write: … (N chars)`) — never fabricated data. When a reasoning model (Qwen3, Gemma) is thinking, the spinner shows live token progress (`⠋ Thinking (42 tokens)…`) rather than a frozen screen. Pressing Esc immediately updates the spinner to `Cancelling…` before the in-flight request is torn down. A dim `esc to cancel` hint stays available while a turn runs, and result previews are capped at 120 characters so raw output never floods the screen. Backed by `summarizeToolArgs`, `verbForTool`, `formatToolResultSuffix`, and `TOOL_VERBS` in `lib/ui.mjs`, with a live spinner channel (`bridge.updateSpinner`) in the Ink TUI.
106
106
 
107
107
  * **Interactive TUI & Pipe Support:** Rich Ink-based TUI (`bin/nehanda-ui.mjs`) for interactive development sessions, with headless pipe-mode support (`bin/agent.mjs`) for acceptance testing and automation.
108
108
 
@@ -430,19 +430,23 @@ Numeric values are auto-converted. Changes persist in the local settings databas
430
430
 
431
431
  ## SDLC Workflow
432
432
 
433
- The engine enforces state transitions across six distinct phases:
433
+ The engine enforces state transitions across seven distinct phases, aligned with the AGENTS.md TDD mandate:
434
434
 
435
- 1. **`idle`**: Discovery and triage. Mutating file tools are physically masked out. Safety and analysis tools are available on-demand.
435
+ 1. **`idle`**: Discovery and triage. Mutating file tools are available. Safety and analysis tools are available on-demand.
436
436
 
437
- 2. **`plan`**: Model formulates success criteria and implementation steps (`EnterPlanMode`). No tools available.
437
+ 2. **`explore`**: Codebase exploration only. `lib/explore.mjs` drives this phase — the model uses Glob, Grep, and Read to aggregate file summaries before entering planning. Ends when the model outputs `Exploration complete.`
438
438
 
439
- 3. **`implement`**: Code changes applied using file editing and shell execution (`ExitPlanMode`).
439
+ 3. **`planning`**: Model formulates a plan using exploration summaries (`EnterPlanMode`). Mutating tools are blocked until a plan is approved. No code changes permitted.
440
440
 
441
- 4. **`test`**: Automated test generation and execution (`SubmitImplementation`). **After tests pass, all tools marked `mandatory_in: ["test"]` must be run.** The system prompt enforces this — the model cannot declare success until all mandatory safety checkers pass.
441
+ 4. **`test`**: Behavioral test creation and RED confirmation. The model writes tests and runs them to confirm they fail for the diagnosed reason. **Gated:** `planning → implement` is blocked until at least one RED test result is recorded here.
442
442
 
443
- 5. **`verify`**: Inspection of test outputs and coverage verification (`SubmitTest`).
443
+ 5. **`implement`**: Code changes applied using file editing and shell execution (`ExitPlanMode`). Only reachable after a confirmed RED test exists and a plan is approved.
444
444
 
445
- 6. **`done`**: Final sign-off and git commit creation.
445
+ 6. **`test`**: Automated test execution to verify GREEN (`SubmitImplementation`). **All tools marked `mandatory_in: ["test"]` must run and pass** before this phase can advance — the system prompt enforces this.
446
+
447
+ 7. **`verify`**: Inspection of test outputs and coverage verification (`SubmitTest`). **Gated:** requires all test results passing and at least one safety checker event recorded.
448
+
449
+ 8. **`done`**: Final sign-off and git commit creation.
446
450
 
447
451
  ---
448
452
 
@@ -494,10 +498,37 @@ node tests/unit/gap4_pinned_compaction_behavioral.mjs
494
498
  node tests/unit/ui_legibility_behavioral.mjs
495
499
  ```
496
500
 
501
+ Run the Ollama, spinner, and Laya behavioral suites:
502
+
503
+ ```bash
504
+ node tests/unit/test-ollama-llm-call-behavioral.mjs
505
+ node tests/unit/test-ollama-permission-prompt-behavioral.mjs
506
+ node tests/unit/laya_warm_load_behavioral.mjs
507
+ node tests/unit/issue22_query_context_behavioral.mjs
508
+ ```
509
+
497
510
  ---
498
511
 
499
512
  ## Changelog
500
513
 
514
+ ### 0.9.0
515
+
516
+ - **AGENTS.md SDLC workflow + local issue tracking (`lib/workflow.mjs`, `lib/issues.mjs`, `lib/explore.mjs`):** The orchestrator now enforces the full E-P-C-V-D workflow defined in AGENTS.md — Explore, Plan, Confirm, Code, Validate. A local `.issues/` directory tracks open and closed issues with structured metadata (observed behavior, diagnosis, acceptance criteria). `lib/explore.mjs` drives the EXPLORE phase, aggregating file summaries before entering PLANNING. Closes #15.
517
+
518
+ - **Generic agent config restored to `~/.config/nehanda` (`lib/settings.mjs`, `scripts/postinstall.mjs`):** Removed the `.aimee` dependency and all references to the legacy agent registry. Agent configuration now lives exclusively at `~/.config/nehanda/agents.json`, consistent with the documented config file locations. Closes #16.
519
+
520
+ - **Working directory in welcome banner and footer (`bin/nehanda-ui.mjs`):** The TUI banner displayed on startup and the status footer both now show the current working directory, giving researchers immediate confirmation of which project root is active before issuing any commands. Closes #17.
521
+
522
+ - **Spinner stabilization: AskPrompt, spitfire suppression, null-safe `askHuman` (`bin/nehanda-ui.mjs`):** Fixed three related spinner regressions: (1) the spinner was visible and cycling behind interactive `io.ask` permission prompts, obscuring the question; (2) rapid `bridge.updateSpinner` calls between tool results caused visible label flicker (spitfire); (3) `askHuman` threw on a null `io` reference in headless contexts. Closes #18.
523
+
524
+ - **Ollama LLM call path: behavioral coverage + documentation (`lib/orchestrate.mjs`, `tests/unit/test-ollama-llm-call-behavioral.mjs`, `docs/MAP.md`):** Full behavioral RED/GREEN test suite for the Ollama call path — `detectOllamaToolSupport` probe logic (capabilities, template, modelfile fields, HTTP error fail-open), native vs. manual tool injection dispatch, `Authorization: Bearer ollama` header, `OLLAMA_BASE_URL` env var resolution, and `buildSystemPrompt` injection format. `docs/MAP.md` documents the full call path contract. Closes #19.
525
+
526
+ - **Turn-start and probe spinner stabilization (`bin/nehanda-ui.mjs`, `lib/orchestrate.mjs`):** Prior to this fix, the spinner had no label during the gap between turn submission and the first model event — cycling generic `TOOL_VERBS` while Laya intent classification and the Ollama `detectOllamaToolSupport` probe (up to 3 s) ran silently. Fixed by pre-seeding `bridge.updateSpinner('Analyzing')` immediately on submit and `io.spinner.start('Analyzing')` at `runSdlcOpenAICompat` entry. Behavioral coverage in `tests/unit/test-ollama-permission-prompt-behavioral.mjs`. Closes #20.
527
+
528
+ - **Laya sidecar startup warming (`lib/laya_sidecar.mjs`):** `ensureLayaReady()` now calls `loadLaya()` (`method: 'load'`) instead of `pingLaya()` (`method: 'ping'`). The `ping` path responded immediately without loading model weights, leaving the first user-turn `classifyUserIntent` call to cold-load the 322M-parameter checkpoint mid-turn (200–400 ms stall). `load` invokes `_ensure_loaded()` in `laya_worker.py`, priming both `noul` and `choice` criteria into MPS/CPU RAM before the first turn. Behavioral coverage in `tests/unit/laya_warm_load_behavioral.mjs`. Closes #21.
529
+
530
+ - **Grounded query context, Ollama `think:false`, reasoning token streaming, instant Esc cancel (`lib/orchestrate.mjs`, `lib/openaiCompat.mjs`, `bin/nehanda-ui.mjs`):** Four improvements to reduce reasoning model overhead and improve cancellation UX: (1) `buildSystemPrompt` appends an explicit `[GROUNDED FACTS]` block (date, cwd, platform, shell) to every phase prompt so models never spend tokens debating their own awareness; (2) `streamOpenAIChatCompletion` now parses `delta.reasoning` and `delta.reasoning_content` SSE fields and updates the spinner live — `⠋ Thinking (42 tokens)…` — rather than sitting frozen during the thinking phase; (3) a `think: false` option is forwarded to the Ollama API request body when `model_config.thinking === false`, disabling chain-of-thought for supported models; (4) `detectOllamaToolSupport` now accepts `rt.signal` composed via `AbortSignal.any([signal, AbortSignal.timeout(3000)])` so Esc cancels the capability probe immediately rather than waiting the full 3 s timeout, and `bridge.abortCurrent` updates the spinner to `Cancelling…` before invoking `controller.abort()`. Behavioral coverage in `tests/unit/issue22_query_context_behavioral.mjs`. Closes #22.
531
+
501
532
  ### 0.8.0
502
533
 
503
534
  - **Full `/help` + explicit MCP env unset (`lib/slashCommands.mjs`, `bin/nehanda-ui.mjs`):** `/help` lists every command and subcommand (`/key` variants, full `/mcp` surface). Omitting a VALUE no longer silently clears env vars — use `/mcp env unset <server> <NAME>` (or `clear`). Autocomplete stays top-level unless you type `/mcp` or `/key`. Behavioral coverage in `tests/unit/help_commands_behavioral.mjs`. Closes #9.
@@ -541,7 +572,7 @@ node tests/unit/ui_legibility_behavioral.mjs
541
572
  ### 0.2.0
542
573
  - Jev-Mem memory architecture (System-1 Laya control plane)
543
574
  - Dynamic Tool Rescue (`[TOOL_CALL]` delimiter path)
544
- - Deterministic 6-phase SDLC workflow
575
+ - Deterministic SDLC workflow (expanded to 7 phases in 0.9.0)
545
576
  - Multi-provider switching
546
577
 
547
578
  ---
package/bin/agent.mjs CHANGED
@@ -1081,7 +1081,10 @@ function resolveModelArg(arg) {
1081
1081
 
1082
1082
  // First, try exact match in hardcoded WIRE map (backward compatibility)
1083
1083
  if (arg.includes('/')) {
1084
- const [p, m] = arg.split('/', 2)
1084
+ // Split on the FIRST "/" only — the model name may itself contain slashes (#12)
1085
+ const slashIdx = arg.indexOf('/')
1086
+ const p = arg.slice(0, slashIdx)
1087
+ const m = arg.slice(slashIdx + 1)
1085
1088
 
1086
1089
  // Validate provider and model_id are non-empty
1087
1090
  if (!p || !p.trim()) {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // nehanda-ui — Ink terminal UI for ona-code
3
3
  // Connects directly to nehanda-ml.asoba.co via the in-process ona-code engine.
4
- // No aimee-server, no HTTP daemon, no cross-repo resolution.
4
+ // Standalone terminal UI, no external HTTP daemon, no cross-repo resolution.
5
5
 
6
6
  if (process.stdout.isTTY) {
7
7
  process.stdout.write('\x1b[2J\x1b[3J\x1b[H')
@@ -28,7 +28,14 @@ import { applyMcpConfig, loadMcpConfig, writeGlobalMcpConfig, readGlobalMcpConfi
28
28
  import { getMcpServer, closeAllMcpServers, listMcpServers, mcpListResources } from '../lib/mcp.mjs'
29
29
  import { invalidateMcpToolCache } from '../lib/tools.mjs'
30
30
  import { saveOpenAiCompatKey, resolveOpenAiCompatKey, saveSecureCredentials, resolveZhipuApiKey } from '../lib/auth.mjs'
31
- import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix } from '../lib/ui.mjs'
31
+ import { TOOL_VERBS, verbForTool, summarizeToolArgs, formatToolResultSuffix, maskSecretAnswer } from '../lib/ui.mjs'
32
+ import {
33
+ PICKER_THRESHOLD,
34
+ createAskIdSequencer,
35
+ groupModelsByVendor,
36
+ filterModelItems,
37
+ visibleWindow,
38
+ } from '../lib/modelPicker.mjs'
32
39
  import Database from 'better-sqlite3'
33
40
  import { ensureLayaReady } from '../lib/laya_sidecar.mjs'
34
41
 
@@ -46,6 +53,10 @@ const { default: TerminalRenderer }= await import(`${REPO_ROOT}/node_modules/mar
46
53
 
47
54
  const e = React.createElement
48
55
 
56
+ // Per-question id source for AskPrompt React keys (HP-1a, #10). Module scope:
57
+ // ids only need to be unique for the process lifetime.
58
+ const askIdSeq = createAskIdSequencer()
59
+
49
60
  // ── Asoba 3 Visual Identity ───────────────────────────────────
50
61
  const BRAND = {
51
62
  accent: '#455BF1',
@@ -58,9 +69,9 @@ const BRAND = {
58
69
  // ── Config paths ─────────────────────────────────────────────
59
70
  const NEHANDA_DIR = path.join(os.homedir(), '.config', 'nehanda')
60
71
  const NEHANDA_DB = path.join(NEHANDA_DIR, 'ona-session.db')
61
- const AIMEE_DIR = path.join(os.homedir(), '.config', 'aimee')
62
- const AGENTS_JSON = path.join(AIMEE_DIR, 'agents.json')
72
+ const AGENTS_JSON = path.join(NEHANDA_DIR, 'agents.json')
63
73
  const SPLASH_SEEN = path.join(NEHANDA_DIR, '.splash-seen')
74
+ const LAUNCH_CWD = process.cwd()
64
75
 
65
76
  function hasSeenSplash() { try { return fs.existsSync(SPLASH_SEEN) } catch { return false } }
66
77
  function markSplashSeen() {
@@ -83,8 +94,15 @@ function readApiKey() {
83
94
 
84
95
  function writeApiKey(key) {
85
96
  const data = readAgents()
86
- const agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
87
- if (agent) { agent.api_key = key; fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8') }
97
+ let agent = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
98
+ if (!agent) {
99
+ agent = { name: data.default_agent || 'default' }
100
+ if (!Array.isArray(data.agents)) data.agents = []
101
+ data.agents.push(agent)
102
+ data.default_agent = agent.name
103
+ }
104
+ agent.api_key = key
105
+ fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
88
106
  }
89
107
 
90
108
  // ── Session DB bootstrap ─────────────────────────────────────
@@ -213,7 +231,7 @@ function SplashScreen({ onDone }) {
213
231
  e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'press any key to continue…')),
214
232
  )
215
233
  }
216
- function WelcomeBanner({ model, endpoint }) {
234
+ function WelcomeBanner({ model, endpoint, cwd = LAUNCH_CWD }) {
217
235
  let cols = 80; try { cols = process.stdout.columns || 80 } catch {}
218
236
  const bw = Math.min(cols - 2, 80)
219
237
  const lw = Math.floor(bw * 0.42), rw = bw - lw - 3
@@ -225,8 +243,9 @@ function WelcomeBanner({ model, endpoint }) {
225
243
  e(Box, { flexDirection: 'column', width: rw, paddingLeft: 1, paddingY: 1 },
226
244
  e(Text, { bold: true, color: BRAND.accent }, 'Configuration'),
227
245
  e(Box, { marginTop: 1, flexDirection: 'column' },
228
- e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
229
- e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
246
+ e(Text, {}, e(Text, { dimColor: true }, 'Model: '), e(Text, { color: BRAND.lavender, bold: true }, model || '—')),
247
+ e(Text, {}, e(Text, { dimColor: true }, 'Endpoint: '), e(Text, { dimColor: true }, endpoint || '—')),
248
+ e(Text, {}, e(Text, { dimColor: true }, 'Directory: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
230
249
  ),
231
250
  e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, 'Type /help for commands')),
232
251
  ),
@@ -287,6 +306,9 @@ function InputArea({ onSubmit, isLoading }) {
287
306
  }
288
307
  function AskPrompt({ question, onAnswer, onCancel }) {
289
308
  const [value, setValue] = React.useState('')
309
+ // Defense-in-depth for HP-1a (#10): if this instance ever re-renders with a
310
+ // new question (fiber reuse), the typed value must not carry over.
311
+ React.useEffect(() => { setValue('') }, [question])
290
312
  useInput((_input, key) => { if (key.escape) onCancel() })
291
313
  return e(Box, { flexDirection: 'column' },
292
314
  e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, question)),
@@ -294,7 +316,124 @@ function AskPrompt({ question, onAnswer, onCancel }) {
294
316
  e(TextInput, { value, onChange: setValue, onSubmit: React.useCallback(v => onAnswer(v.trim()), [onAnswer]) })),
295
317
  e(Box, { marginLeft: 1 }, e(Text, { dimColor: true }, 'Esc cancel')))
296
318
  }
319
+ // ── Model Picker (large lists, #10 / HP-2) ───────────────────
320
+ // Three views: vendor GROUPS → windowed BROWSE within a vendor →
321
+ // type-to-filter SEARCH across every discovered model.
322
+ // Digits jump rows, '/' opens search, Esc steps back.
323
+ function ModelPicker({ title, items, onSelect, onCancel }) {
324
+ const groups = React.useMemo(() => groupModelsByVendor(items), [items])
325
+ const [view, setView] = React.useState('groups') // 'groups' | 'browse' | 'search'
326
+ const [groupIdx, setGroupIdx] = React.useState(0)
327
+ const [browseIdx, setBrowseIdx] = React.useState(0)
328
+ const [filter, setFilter] = React.useState('')
329
+ const [searchIdx, setSearchIdx] = React.useState(0)
330
+
331
+ const browseModels = view === 'browse' && groups[groupIdx] ? groups[groupIdx].models : []
332
+ const searchResults = React.useMemo(() => filterModelItems(items, filter), [items, filter])
333
+ const prevView = React.useRef('groups')
334
+
335
+ const jump = (setter, list, n) => {
336
+ if (!isNaN(n) && n >= 1 && n <= list.length) setter(n - 1)
337
+ }
338
+
339
+ useInput((input, key) => {
340
+ if (view === 'search') {
341
+ // TextInput owns printable chars + Enter (its onSubmit); arrows pass through.
342
+ if (key.escape) {
343
+ if (filter) { setFilter(''); setSearchIdx(0) }
344
+ else { setView(prevView.current); setSearchIdx(0) }
345
+ } else if (key.upArrow) { setSearchIdx(i => (i - 1 + searchResults.length) % searchResults.length) }
346
+ else if (key.downArrow) { setSearchIdx(i => (i + 1) % searchResults.length) }
347
+ return
348
+ }
349
+ if (key.escape) {
350
+ if (view === 'browse') setView('groups')
351
+ else onCancel()
352
+ return
353
+ }
354
+ if (input === '/') { prevView.current = view; setView('search'); setFilter(''); setSearchIdx(0); return }
355
+ if (view === 'groups') {
356
+ if (key.upArrow) { setGroupIdx(i => (i - 1 + groups.length) % groups.length); return }
357
+ if (key.downArrow) { setGroupIdx(i => (i + 1) % groups.length); return }
358
+ if (key.return) {
359
+ const g = groups[groupIdx]
360
+ if (!g) return
361
+ if (g.isCustom) { onSelect(g.models[0]); return }
362
+ setBrowseIdx(0); setView('browse')
363
+ return
364
+ }
365
+ jump(setGroupIdx, groups, parseInt(input, 10))
366
+ } else if (view === 'browse') {
367
+ if (key.upArrow) { setBrowseIdx(i => (i - 1 + browseModels.length) % browseModels.length); return }
368
+ if (key.downArrow) { setBrowseIdx(i => (i + 1) % browseModels.length); return }
369
+ if (key.return) { const m = browseModels[browseIdx]; if (m) onSelect(m); return }
370
+ const win = visibleWindow(browseModels, browseIdx)
371
+ const shown = win.visible
372
+ const n = parseInt(input, 10)
373
+ if (!isNaN(n) && n >= 1 && n <= shown.length) {
374
+ const item = shown[n - 1]
375
+ const abs = browseModels.indexOf(item)
376
+ if (abs !== -1) setBrowseIdx(abs)
377
+ }
378
+ }
379
+ })
380
+
381
+ const row = (item, i, cursor) => e(Box, { key: String(item.id ?? i), flexDirection: 'column' },
382
+ e(Box, {}, e(Text, { color: cursor ? BRAND.accent : undefined }, cursor ? '❯ ' : ' '),
383
+ e(Text, { color: BRAND.lavender }, `${i + 1}. `),
384
+ e(Text, { bold: true, color: cursor ? BRAND.accent : undefined }, item.label)),
385
+ e(Box, { marginLeft: 5 }, e(Text, { dimColor: true }, item.desc)))
386
+ const hiddenHints = win => e(Box, { key: 'hints', flexDirection: 'column' },
387
+ win.hiddenAbove ? e(Text, { dimColor: true }, ` ↑ ${win.hiddenAbove} more above`) : null,
388
+ win.hiddenBelow ? e(Text, { dimColor: true }, ` ↓ ${win.hiddenBelow} more below`) : null)
389
+
390
+ let body
391
+ if (view === 'groups') {
392
+ body = e(Box, { marginTop: 1, flexDirection: 'column' },
393
+ ...groups.map((g, i) => row(g, i, i === groupIdx)),
394
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
395
+ `↑↓/1–${Math.min(9, groups.length)} vendor · Enter open · / search all · Esc cancel`)))
396
+ } else if (view === 'browse') {
397
+ const win = visibleWindow(browseModels, browseIdx)
398
+ body = e(Box, { marginTop: 1, flexDirection: 'column' },
399
+ e(Text, { dimColor: true }, `${groups[groupIdx].label} — ${browseModels.length} models`),
400
+ ...win.visible.map((m, i) => row(m, win.start + i, win.start + i === browseIdx)),
401
+ hiddenHints(win),
402
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
403
+ '↑↓ navigate · Enter select · / search all · Esc back')))
404
+ } else {
405
+ const win = visibleWindow(searchResults, searchIdx)
406
+ const handleSearchSubmit = v => {
407
+ const item = searchResults[searchIdx]
408
+ if (item) onSelect(item)
409
+ }
410
+ body = e(Box, { marginTop: 1, flexDirection: 'column' },
411
+ e(Box, { paddingX: 1 }, e(Text, { color: BRAND.accent }, '/'),
412
+ e(TextInput, { value: filter, onChange: v => { setFilter(v); setSearchIdx(0) }, onSubmit: handleSearchSubmit })),
413
+ searchResults.length
414
+ ? e(Box, { marginTop: 1, flexDirection: 'column' },
415
+ ...win.visible.map((m, i) => row(m, win.start + i, win.start + i === searchIdx)),
416
+ hiddenHints(win))
417
+ : e(Text, { dimColor: true }, ' (no matches)'),
418
+ e(Box, { marginTop: 1 }, e(Text, { dimColor: true },
419
+ 'type to filter · ↑↓ select · Enter confirm · Esc clear/back')))
420
+ }
421
+
422
+ return e(Box, { flexDirection: 'column', marginLeft: 1 },
423
+ e(Text, { bold: true, color: BRAND.accent }, title),
424
+ body)
425
+ }
426
+
427
+ // Wrapper (no hooks): large catalogs (e.g. OpenRouter's hundreds of models)
428
+ // switch to the grouped/filterable picker instead of a flat scroll list (HP-2, #10).
297
429
  function SelectMenu({ title, items, onSelect, onCancel }) {
430
+ if (items.length > PICKER_THRESHOLD) {
431
+ return e(ModelPicker, { title, items, onSelect, onCancel })
432
+ }
433
+ return e(SelectMenuFlat, { title, items, onSelect, onCancel })
434
+ }
435
+
436
+ function SelectMenuFlat({ title, items, onSelect, onCancel }) {
298
437
  const [idx, setIdx] = React.useState(0)
299
438
  useInput((input, key) => {
300
439
  if (key.escape) { onCancel(); return }
@@ -356,13 +495,17 @@ function BlockConfirmMenu({ payload, onDecide }) {
356
495
  e(Text, {}, ' Cancel')))
357
496
  }
358
497
 
359
- function TokenFooter({ calls, model }) {
498
+ function TokenFooter({ calls, model, cwd = LAUNCH_CWD }) {
360
499
  const cols = process.stdout.columns || 80
361
500
  return e(Box, { flexDirection: 'column' },
362
501
  e(Text, { dimColor: true }, '─'.repeat(cols)),
363
502
  e(Box, { flexDirection: 'row', paddingX: 1, justifyContent: 'space-between' },
364
503
  e(Text, { dimColor: true }, `calls ${calls}`),
365
- e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
504
+ e(Box, { flexDirection: 'row' },
505
+ e(Text, {}, e(Text, { dimColor: true }, 'model: '), e(Text, { color: BRAND.lavender }, model || '—')),
506
+ e(Text, { dimColor: true }, ' '),
507
+ e(Text, {}, e(Text, { dimColor: true }, 'dir: '), e(Text, { dimColor: true }, cwd || LAUNCH_CWD)),
508
+ ),
366
509
  e(Text, { dimColor: true }, '? /help ^C exit')))
367
510
  }
368
511
 
@@ -384,7 +527,8 @@ function App({ bridge }) {
384
527
  const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
385
528
  return { model: ag?.model || 'nehanda-rag-synthesis-27b', endpoint: ag?.endpoint || 'https://nehanda-ml.asoba.co/v1' }
386
529
  })
387
- const lastCtrlCRef = React.useRef(0)
530
+ const lastCtrlCRef = React.useRef(0)
531
+ const lastSpinLabelRef = React.useRef(null) // #18: retain last explicit label to prevent spitfire between tool calls
388
532
  const bannerRef = React.useRef({ kind: 'banner' })
389
533
  const historyItems = React.useMemo(() => [bannerRef.current, ...finalized], [finalized])
390
534
 
@@ -396,8 +540,12 @@ function App({ bridge }) {
396
540
  bridge.endAssistant = msg => { setFinalized(p => [...p, msg]); setStreaming(null); setCallCount(c => c + 1) }
397
541
  bridge.clearMessages = () => { setFinalized([]); setStreaming(null); setClearGen(g => g + 1) }
398
542
  bridge.setLoading = v => setIsLoading(v)
399
- bridge.updateSpinner = text => setSpinLabel(text)
400
- bridge.ask = q => new Promise(resolve => setAskState({ question: q, resolve }))
543
+ bridge.updateSpinner = text => { if (text !== null) lastSpinLabelRef.current = text; setSpinLabel(text) }
544
+ // HP-1a (#10): unique id per question — AskPrompt is keyed by it, so every
545
+ // new wizard question remounts with an EMPTY input (no key carry-over).
546
+ // opts.secret → the persisted transcript line is masked (HP-1b).
547
+ bridge.ask = (q, opts) => new Promise(resolve =>
548
+ setAskState({ id: askIdSeq.next(), question: q, secret: !!(opts && opts.secret), resolve }))
401
549
  bridge.select = (t,i) => new Promise(resolve => setSelectState({ title: t, items: i, resolve }))
402
550
  bridge.confirmBlock = payload => new Promise(resolve => setBlockState({ payload, resolve }))
403
551
  bridge.exit = () => { exit() }
@@ -409,7 +557,10 @@ function App({ bridge }) {
409
557
  }, [bridge, exit])
410
558
 
411
559
  const handleAnswer = React.useCallback(answer => {
412
- if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: askState.question + ' ' + answer }]); askState.resolve(answer) }
560
+ // HP-1b (#10): secret answers persist masked to transcript history; the
561
+ // wizard still receives the raw answer via resolve().
562
+ const shown = askState?.secret ? maskSecretAnswer(answer) : answer
563
+ if (askState?.resolve) { setFinalized(p => [...p, { role: 'system', text: `${askState.question} ${shown}`.trim() }]); askState.resolve(answer) }
413
564
  setAskState(null)
414
565
  }, [askState])
415
566
  const handleAskCancel = React.useCallback(() => { askState?.resolve(null); setAskState(null) }, [askState])
@@ -444,18 +595,25 @@ function App({ bridge }) {
444
595
  return e(Box, { flexDirection: 'column' },
445
596
  e(Static, { items: historyItems, key: clearGen },
446
597
  (item, i) => item.kind === 'banner'
447
- ? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint })
598
+ ? e(WelcomeBanner, { key: 'banner', model: modelInfo.model, endpoint: modelInfo.endpoint, cwd: LAUNCH_CWD })
448
599
  : e(MessageView, { key: String(i), msg: item })),
449
600
  streaming ? e(MessageView, { msg: { role: 'assistant', ...streaming } }) : null,
450
- (isLoading && (!streaming || !streaming.text)) || spinLabel ? e(Spinner, { label: spinLabel || undefined }) : null,
601
+ // #18 fix 1: !askState hides Spinner while AskPrompt is shown (permission prompt visible).
602
+ // #18 fix 2: effectiveSpinLabel uses lastSpinLabelRef fallback when spinLabel=null during
603
+ // active loading, preventing snap-back to a random rotating verb between tool calls.
604
+ (() => {
605
+ const effectiveSpinLabel = spinLabel ?? (isLoading ? lastSpinLabelRef.current : null)
606
+ return (!askState && ((isLoading && (!streaming || !streaming.text)) || effectiveSpinLabel))
607
+ ? e(Spinner, { label: effectiveSpinLabel || undefined }) : null
608
+ })(),
451
609
  askState
452
- ? e(AskPrompt, { question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
610
+ ? e(AskPrompt, { key: askState.id, question: askState.question, onAnswer: handleAnswer, onCancel: handleAskCancel })
453
611
  : blockState
454
612
  ? e(BlockConfirmMenu, { payload: blockState.payload, onDecide: handleBlock })
455
613
  : selectState
456
614
  ? e(SelectMenu, { title: selectState.title, items: selectState.items, onSelect: handleSelect, onCancel: handleSelectCancel })
457
615
  : e(InputArea, { onSubmit: bridge.onSubmit, isLoading }),
458
- e(TokenFooter, { calls: callCount, model: modelInfo.model }))
616
+ e(TokenFooter, { calls: callCount, model: modelInfo.model, cwd: LAUNCH_CWD }))
459
617
  }
460
618
 
461
619
  // ── Diff executor ─────────────────────────────────────────────
@@ -492,7 +650,7 @@ function stripThink(text) {
492
650
  async function runUiConfigureWizard(bridge, opts = {}) {
493
651
  try {
494
652
  const result = await runConfigureWizard({
495
- ask: q => bridge.ask(q),
653
+ ask: (q, opts) => bridge.ask(q, opts),
496
654
  select: (title, items) => bridge.select(title, items),
497
655
  notify: msg => bridge.addMessage({ role: 'system', text: ` ${msg}` }),
498
656
  resolveExistingKey: async (preset) => {
@@ -508,12 +666,16 @@ async function runUiConfigureWizard(bridge, opts = {}) {
508
666
  },
509
667
  persist: async (cfg) => {
510
668
  const data = readAgents()
511
- const ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
512
- if (ag) {
513
- ag.model = cfg.model
514
- ag.endpoint = cfg.endpoint
515
- if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
669
+ let ag = data.agents?.find(a => a.name === data.default_agent) || data.agents?.[0]
670
+ if (!ag) {
671
+ ag = { name: data.default_agent || 'default' }
672
+ if (!Array.isArray(data.agents)) data.agents = []
673
+ data.agents.push(ag)
674
+ data.default_agent = ag.name
516
675
  }
676
+ ag.model = cfg.model
677
+ ag.endpoint = cfg.endpoint
678
+ if (cfg.provider === 'nehanda' && cfg.apiKey) ag.api_key = cfg.apiKey
517
679
  fs.writeFileSync(AGENTS_JSON, JSON.stringify(data, null, 2), 'utf8')
518
680
  runtimeSettings.model_config = cfg.model_config || modelConfigFromAgent(
519
681
  { model: cfg.model, endpoint: cfg.endpoint },
@@ -589,7 +751,7 @@ async function handleCommand(cmd, bridge) {
589
751
  }
590
752
 
591
753
  if (!sub || !KEY_TO_PRESET[sub]) {
592
- const answer = await bridge.ask('New Nehanda API key:')
754
+ const answer = await bridge.ask('New Nehanda API key:', { secret: true })
593
755
  if (!answer) return
594
756
  try {
595
757
  writeApiKey(answer)
@@ -601,7 +763,7 @@ async function handleCommand(cmd, bridge) {
601
763
 
602
764
  const presetId = KEY_TO_PRESET[sub]
603
765
  const inlineValue = parts.slice(2).join(' ').trim()
604
- const answer = inlineValue || await bridge.ask(`New ${sub} API key:`)
766
+ const answer = inlineValue || await bridge.ask(`New ${sub} API key:`, { secret: true })
605
767
  if (!answer) return
606
768
 
607
769
  try {
@@ -833,8 +995,12 @@ bridge.onSubmit = async (text) => {
833
995
  if (text.startsWith('/')) { await handleCommand(text, bridge); return }
834
996
 
835
997
  bridge.setLoading(true)
998
+ bridge.updateSpinner('Analyzing')
836
999
  const controller = new AbortController()
837
- bridge.abortCurrent = () => controller.abort()
1000
+ bridge.abortCurrent = () => {
1001
+ bridge.updateSpinner('Cancelling…') // #22: immediate UI feedback before abort propagates
1002
+ controller.abort()
1003
+ }
838
1004
  let lastPartial = ''
839
1005
 
840
1006
  try {
@@ -861,7 +1027,7 @@ bridge.onSubmit = async (text) => {
861
1027
  bareMode: false,
862
1028
  // Project instructions sourced from nehanda's own loadInstructions() (Ona.md),
863
1029
  // matching bin/agent.mjs. Does NOT read agents.json exec_system_prompt — that
864
- // field belongs to aimee and was silently reintroducing conflicting tool-call
1030
+ // legacy field was silently reintroducing conflicting tool-call
865
1031
  // syntax (<tool_call><name>...) that fights orchestrate.mjs's [TOOL_CALL] format.
866
1032
  onaInstructions: onaInstructionsContent || null,
867
1033
  // Esc/Ctrl+C abort — must reach fetch / Anthropic stream (issue #7).
@@ -107,7 +107,7 @@ export async function discoverModelsForPreset(preset, opts = {}) {
107
107
  * Interactive configure wizard.
108
108
  *
109
109
  * @param {object} deps
110
- * @param {(q: string) => Promise<string>} deps.ask
110
+ * @param {(q: string, opts?: { secret?: boolean }) => Promise<string>} deps.ask
111
111
  * @param {(title: string, items: object[]) => Promise<object|null>} deps.select
112
112
  * @param {(msg: string) => void} [deps.notify]
113
113
  * @param {(ctx: {preset, host, apiKey}) => Promise<{success, models?, error?}>} [deps.discover]
@@ -155,7 +155,8 @@ export async function runConfigureWizard(deps, opts = {}) {
155
155
  ])
156
156
  if (!choice) return null
157
157
  if (choice.id === 'replace') {
158
- const entered = await ask(`${preset.label} API key:`)
158
+ // {secret:true} → the TUI masks this answer in transcript history (HP-1b, #10)
159
+ const entered = await ask(`${preset.label} API key:`, { secret: true })
159
160
  if (!entered) return null
160
161
  apiKey = entered.trim()
161
162
  keyDirty = true
@@ -163,7 +164,7 @@ export async function runConfigureWizard(deps, opts = {}) {
163
164
  apiKey = existing
164
165
  }
165
166
  } else {
166
- const entered = await ask(`${preset.label} API key:`)
167
+ const entered = await ask(`${preset.label} API key:`, { secret: true })
167
168
  if (!entered) return null
168
169
  apiKey = entered.trim()
169
170
  keyDirty = true
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // Directories to skip entirely during exploration
5
+ const SKIP_DIRS = new Set([
6
+ 'node_modules', '.git', '.issues', 'dist', 'build', 'tmp', '.tmp',
7
+ 'coverage', '.nyc_output', '__pycache__', '.venv', 'venv',
8
+ '.next', '.nuxt', 'out',
9
+ ])
10
+
11
+ // Documentation files receive highest priority (read first)
12
+ const DOC_FILENAMES = new Set([
13
+ 'README.md', 'readme.md', 'README', 'readme',
14
+ 'CONTRIBUTING.md', 'CONTRIBUTING', 'ARCHITECTURE.md', 'ARCHITECTURE',
15
+ 'AGENTS.md', 'GEMINI.md', 'CHANGELOG.md', 'CHANGES.md',
16
+ 'DESIGN.md', 'OVERVIEW.md', 'ONBOARDING.md', 'SETUP.md',
17
+ 'MAP.md', 'RUNBOOK.md', 'OPERATIONS.md', 'SPECIFICATION.md', 'SPEC.md',
18
+ ])
19
+
20
+ // Secondary documentation: anything under docs/ or .docs/
21
+ const DOC_DIRS = new Set(['docs', '.docs', 'documentation', 'doc'])
22
+
23
+ /**
24
+ * Scans a repository directory and returns files in documentation-first order.
25
+ *
26
+ * Priority order:
27
+ * 1. Root-level named documentation files (README.md, CONTRIBUTING.md, etc.)
28
+ * 2. All Markdown files inside docs/ subdirectories
29
+ * 3. Root-level *.md files not already listed
30
+ * 4. Configuration files (package.json, pyproject.toml, etc.)
31
+ * 5. All other source files (alphabetical, depth-first)
32
+ *
33
+ * @param {string} cwd - The repository root to scan
34
+ * @returns {string[]} - Absolute paths in exploration priority order
35
+ */
36
+ export function getDocPrioritizedFiles(cwd) {
37
+ const abs = path.resolve(cwd)
38
+
39
+ // Tier 1: High-priority named root documentation files
40
+ const tier1 = []
41
+ for (const name of DOC_FILENAMES) {
42
+ const f = path.join(abs, name)
43
+ if (fs.existsSync(f) && fs.statSync(f).isFile()) {
44
+ tier1.push(f)
45
+ }
46
+ }
47
+
48
+ // Collect all files in the repo (excluding SKIP_DIRS)
49
+ const allFiles = []
50
+ function walk(dir) {
51
+ let entries
52
+ try {
53
+ entries = fs.readdirSync(dir, { withFileTypes: true })
54
+ } catch {
55
+ return
56
+ }
57
+ for (const entry of entries) {
58
+ if (entry.isDirectory()) {
59
+ if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name))
60
+ } else if (entry.isFile()) {
61
+ allFiles.push(path.join(dir, entry.name))
62
+ }
63
+ }
64
+ }
65
+ walk(abs)
66
+
67
+ const tier1Set = new Set(tier1)
68
+
69
+ // Tier 2: Markdown files inside docs/ directories
70
+ const tier2 = allFiles.filter(f => {
71
+ if (tier1Set.has(f)) return false
72
+ const rel = path.relative(abs, f)
73
+ const parts = rel.split(path.sep)
74
+ return parts.length > 1 && DOC_DIRS.has(parts[0]) && f.endsWith('.md')
75
+ })
76
+
77
+ const tier2Set = new Set(tier2)
78
+
79
+ // Tier 3: Remaining root-level *.md files
80
+ const tier3 = allFiles.filter(f => {
81
+ if (tier1Set.has(f) || tier2Set.has(f)) return false
82
+ const rel = path.relative(abs, f)
83
+ return !rel.includes(path.sep) && f.endsWith('.md')
84
+ })
85
+
86
+ const tier3Set = new Set(tier3)
87
+
88
+ // Tier 4: Config files
89
+ const CONFIG_NAMES = new Set([
90
+ 'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
91
+ 'pyproject.toml', 'setup.py', 'setup.cfg', 'Cargo.toml',
92
+ '.env.example', '.env.sample', 'tsconfig.json', 'jest.config.js',
93
+ 'jest.config.mjs', 'eslint.config.js', '.eslintrc.js', '.eslintrc.json',
94
+ ])
95
+ const tier4 = allFiles.filter(f => {
96
+ if (tier1Set.has(f) || tier2Set.has(f) || tier3Set.has(f)) return false
97
+ const base = path.basename(f)
98
+ return CONFIG_NAMES.has(base)
99
+ })
100
+
101
+ const tier4Set = new Set(tier4)
102
+
103
+ // Tier 5: Everything else
104
+ const tier5 = allFiles.filter(f =>
105
+ !tier1Set.has(f) && !tier2Set.has(f) && !tier3Set.has(f) && !tier4Set.has(f)
106
+ )
107
+
108
+ return [...tier1, ...tier2, ...tier3, ...tier4, ...tier5]
109
+ }