@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.
@@ -10,6 +10,7 @@ import { evaluatePermission } from './permissions.mjs'
10
10
  import { executeBuiltinTool, anthropicToolDefinitions, openAICompatToolDefinitions, LOCAL_CORE_TOOLS, EXPLORE_ONLY_TOOLS, dynamicTools, getMcpToolDefinitions } from './tools.mjs'
11
11
  import { streamOpenAIChatCompletion, isAbortError } from './openaiCompat.mjs'
12
12
  import { withTransaction } from './store.mjs'
13
+ import { upsertSummary } from './store.mjs'
13
14
  import { getPhase, setPhase, canTransition } from './workflow.mjs'
14
15
  import { buildJevSystem2Messages } from './compact.mjs'
15
16
 
@@ -200,6 +201,14 @@ RULES FOR PLAN STEPS:
200
201
  Do NOT use any tools. Write the plan only.`
201
202
  }
202
203
 
204
+ function buildCreateTestUserMessage(planContent) {
205
+ return `Plan approved. Per TDD (AGENTS.md Phase 4), create or update behavioral tests now BEFORE modifying production code.
206
+
207
+ ${planContent}
208
+
209
+ Write the behavioral test and run it using Bash to confirm it is demonstrably RED (failing for the diagnosed reason). End with "Tests failed: <reason>".`
210
+ }
211
+
203
212
  function buildImplementUserMessage(planContent) {
204
213
  return `Approved plan:
205
214
 
@@ -250,6 +259,10 @@ export function buildSystemPrompt(cwd, provider, model, phase, onaInstructions,
250
259
  const builder = PHASE_SYSTEM[phase] || PHASE_SYSTEM.default
251
260
  let prompt = builder(cwd, provider, model, date, os, shell, onaInstructions)
252
261
 
262
+ // #22: Explicit grounded facts prevent reasoning models from spending tokens
263
+ // debating their own awareness of date, cwd, or environment.
264
+ prompt += `\n\n[GROUNDED FACTS]\nDate: ${date}\nWorking directory: ${cwd}\nPlatform: ${os} | Shell: ${shell}`
265
+
253
266
  // Inject intent-shaped task context preamble for idle/explore/default phases.
254
267
  // Not injected in implement/test/planning — those phases use generated template
255
268
  // messages that carry no real user intent signal.
@@ -378,18 +391,21 @@ async function toolsForProvider(provider, settings, nativeToolSupport = true) {
378
391
 
379
392
  const ollamaCapabilityCache = new Map()
380
393
 
381
- async function detectOllamaToolSupport(baseUrl, modelName) {
394
+ async function detectOllamaToolSupport(baseUrl, modelName, signal) {
382
395
  const key = `${baseUrl}::${modelName}`
383
396
  if (ollamaCapabilityCache.has(key)) return ollamaCapabilityCache.get(key)
384
397
 
385
398
  let result = true
386
399
  try {
387
400
  const apiBase = baseUrl.replace(/\/v1\/?$/, '').replace(/\/+$/, '')
401
+ // #22: compose rt.signal with the 3 s timeout so Esc cancels the probe immediately
402
+ const timeoutSignal = AbortSignal.timeout(3000)
403
+ const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
388
404
  const resp = await fetch(`${apiBase}/api/show`, {
389
405
  method: 'POST',
390
406
  headers: { 'Content-Type': 'application/json' },
391
407
  body: JSON.stringify({ name: modelName }),
392
- signal: AbortSignal.timeout(3000)
408
+ signal: fetchSignal,
393
409
  })
394
410
  if (!resp.ok) { result = true }
395
411
  else {
@@ -400,7 +416,9 @@ async function detectOllamaToolSupport(baseUrl, modelName) {
400
416
  data.capabilities?.includes('tools')
401
417
  )
402
418
  }
403
- } catch {
419
+ } catch (e) {
420
+ // #22: re-throw user aborts — don't swallow as "assume native"
421
+ if (e?.name === 'AbortError') throw e
404
422
  result = true
405
423
  }
406
424
 
@@ -437,7 +455,7 @@ function rethrowIfAborted(e) {
437
455
 
438
456
  async function askHuman(io, q) {
439
457
  if (!process.stdin.isTTY) return true
440
- if (io?.ask) return (await io.ask(q)).trim().toLowerCase().startsWith('y')
458
+ if (io?.ask) { const ans = await io.ask(q); return ans != null && ans.trim().toLowerCase().startsWith('y') }
441
459
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
442
460
  return new Promise(resolve => {
443
461
  rl.question(q, ans => { rl.close(); resolve(/^y(es)?$/i.test(String(ans || '').trim())) })
@@ -714,29 +732,49 @@ async function exploreGateAnthropic(db, rt, io, client, userRequest) {
714
732
  io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
715
733
 
716
734
  const summaries = await summarizeFilesAnthropicForTask(client, rt, filesRead, userRequest, io)
735
+ // #11: persist summaries so a failed planning turn can be retried without re-exploration.
736
+ upsertSummary(db, rt.conversationId, JSON.stringify(summaries))
717
737
  appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
718
738
  setPhase(db, rt.conversationId, 'planning', 'explore_complete')
719
739
  io.println('✓ Summarisation complete. Entering planning phase.')
720
740
  return true
721
741
  }
722
742
 
723
- async function exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, userRequest) {
743
+ export async function exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, userRequest) {
724
744
  const filesRead = extractFilesRead(db, rt.sessionId)
725
745
  io.println(`✓ Exploration complete. Summarising ${filesRead.length} file(s)...`)
726
746
 
727
747
  const summaries = await summarizeFilesOpenAICompatForTask(baseUrl, apiKey, rt, filesRead, userRequest, io)
748
+ // #11: persist summaries so a failed planning turn can be retried without re-exploration.
749
+ upsertSummary(db, rt.conversationId, JSON.stringify(summaries))
728
750
  appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildPlanningUserMessage(summaries, userRequest)))
729
751
  setPhase(db, rt.conversationId, 'planning', 'explore_complete')
730
752
  io.println('✓ Summarisation complete. Entering planning phase.')
731
753
  return true
732
754
  }
733
755
 
734
- async function planGate(db, rt, io, planText) {
756
+ export async function planGate(db, rt, io, planText) {
735
757
  const clean = sanitizeModelText(planText)
736
758
  if (!clean) {
737
- io.println('\n[planning] Model produced no plan text. Try again.')
759
+ io.println('\n[planning] Model produced no plan text — response empty after reasoning-strip.')
738
760
  return false
739
761
  }
762
+
763
+ // AGENTS.md Phase 5/6: reject plans that lack an issue reference or behavioral specification
764
+ // before prompting the operator for approval.
765
+ const hasIssueRef = /#\d+/.test(clean)
766
+ const hasBehavioralSpec = /given.{0,120}when.{0,120}then/is.test(clean)
767
+ if (!hasIssueRef) {
768
+ io.println('\n[planning] Plan rejected: missing issue reference (e.g. #1) required by AGENTS.md Phase 3.')
769
+ io.println('[planning] Create a local issue first (/.issues/) and reference it in the plan.')
770
+ return false
771
+ }
772
+ if (!hasBehavioralSpec) {
773
+ io.println('\n[planning] Plan rejected: missing Behavioral Specification (Given-When-Then) required by AGENTS.md Phase 5.')
774
+ io.println('[planning] Add a Given-When-Then section or table before requesting operator approval.')
775
+ return false
776
+ }
777
+
740
778
  io.println(`\n${'─'.repeat(60)}\n${clean}\n${'─'.repeat(60)}`)
741
779
  const answer = io.ask ? await io.ask('Approve this plan? [y/N] ') : 'y'
742
780
  if (!/^y(es)?$/i.test(String(answer || '').trim())) {
@@ -748,11 +786,32 @@ async function planGate(db, rt, io, planText) {
748
786
  withTransaction(db, () => {
749
787
  db.prepare(`INSERT INTO plans(conversation_id, content, hash, status, approved_at) VALUES (?,?,?,'approved',datetime('now'))`).run(rt.conversationId, clean, hash)
750
788
  })
751
- setPhase(db, rt.conversationId, 'implement', 'plan_approved')
752
- io.println('✓ Plan approved. Entering implementation phase.')
789
+ setPhase(db, rt.conversationId, 'test', 'plan_approved_create_tests')
790
+ io.println('✓ Plan approved. Entering test creation phase (TDD RED).')
753
791
  return true
754
792
  }
755
793
 
794
+ /**
795
+ * #11 — retry the planning turn exactly once when the model produced no plan text
796
+ * (think-only / empty response). The first attempt's raw response and a nudge user
797
+ * message are persisted to the transcript so the failure stays diagnosable.
798
+ * Returns true only if the retried plan is approved.
799
+ */
800
+ export async function retryEmptyPlanningTurn(db, rt, io, runPlanPhase) {
801
+ io.println('\n[planning] Empty plan response — retrying once with a nudge...')
802
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(
803
+ 'Your previous response contained no plan text (it was empty after reasoning-trace stripping). ' +
804
+ 'Output the plan as prose only. Do NOT use any tools. Do NOT include <think1> blocks or reasoning. ' +
805
+ 'Start your reply with "## Objective".'
806
+ ))
807
+ const text = await runPlanPhase()
808
+ if (!text || !String(text).trim()) {
809
+ io.println('\n[planning] No plan text after retry — aborting planning phase. Transcript retained for diagnosis.')
810
+ return false
811
+ }
812
+ return planGate(db, rt, io, text)
813
+ }
814
+
756
815
  async function implementGate(db, rt, io, summaryText) {
757
816
  if (summaryText) io.println(`\n${'─'.repeat(60)}\n${summaryText}\n${'─'.repeat(60)}`)
758
817
  const answer = io.ask ? await io.ask('Accept implementation and run tests? [y/N] ') : 'y'
@@ -934,7 +993,7 @@ async function runAnthropicPhase(db, rt, io, hookRt, client, tools, phase) {
934
993
  }
935
994
  }
936
995
 
937
- async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase) {
996
+ export async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, phase) {
938
997
  let activeTools = injectedTools ? [] : allTools
939
998
  let currentInjectedTools = injectedTools
940
999
 
@@ -955,6 +1014,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
955
1014
  const messages = [{ role: 'system', content: fullSystemPrompt }, ...jevMessages]
956
1015
 
957
1016
  let assistantBlocks, bufferedText = null, finishReason = null
1017
+ let out = null
958
1018
  try {
959
1019
  // When xmlInstructions is non-empty, tool calling is being driven entirely by
960
1020
  // prompt-injected [TOOL_CALL] text parsing (parseXmlToolCalls), specifically to
@@ -964,7 +1024,7 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
964
1024
  // on top of the prompt-injected instructions, producing malformed hybrid output
965
1025
  // like {"Glob", "arguments": {...}} instead of {"name": "Glob", "arguments": {...}}.
966
1026
  const toolsForRequest = xmlInstructions ? [] : filteredTools
967
- const out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider), signal: rt.signal })
1027
+ out = await streamOpenAIChatCompletion({ baseUrl, apiKey, model: rt._model, messages, tools: toolsForRequest, io, bufferOutput: true, numCtx: rt.settings?.model_config?.num_ctx, omitToolChoice: omitToolChoice(provider), useStream: !omitToolChoice(provider), signal: rt.signal })
968
1028
  assistantBlocks = out.assistantBlocks
969
1029
  bufferedText = out.bufferedText
970
1030
  finishReason = out.finishReason
@@ -1002,7 +1062,15 @@ async function runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKe
1002
1062
 
1003
1063
  if (io.spinner) io.spinner.stop()
1004
1064
 
1005
- if (!assistantBlocks.length) return bufferedText
1065
+ if (!assistantBlocks.length) {
1066
+ // #11: persist a marker entry so an empty/think-only response stays diagnosable
1067
+ // instead of vanishing from the transcript before appendEntry ever runs.
1068
+ appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload([{
1069
+ type: 'text',
1070
+ text: `[empty model response — raw ${out.rawLength ?? 0} chars, finishReason ${finishReason ?? 'unknown'}]`,
1071
+ }]))
1072
+ return bufferedText
1073
+ }
1006
1074
 
1007
1075
  appendEntry(db, rt.sessionId, 'assistant', makeAssistantPayload(assistantBlocks))
1008
1076
  const toolUses = assistantBlocks.filter(b => b.type === 'tool_use')
@@ -1139,15 +1207,43 @@ async function runSdlcAnthropic(db, rt, io, hookRt, model, startPhase) {
1139
1207
  if (phase === 'explore') {
1140
1208
  advanced = await exploreGateAnthropic(db, rt, io, client, rt._userText)
1141
1209
  } else if (phase === 'planning') {
1142
- advanced = await planGate(db, rt, io, text)
1210
+ // #11: empty/think-only plan responses get one nudge retry instead of a silent exit.
1211
+ advanced = (text && String(text).trim())
1212
+ ? await planGate(db, rt, io, text)
1213
+ : await retryEmptyPlanningTurn(db, rt, io, () => runAnthropicPhase(db, rt, io, hookRt, client, tools, 'planning'))
1143
1214
  } else if (phase === 'implement') {
1144
1215
  const toolResults = extractRecentToolResults(db, rt.sessionId)
1145
1216
  const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildImplementSummaryPrompt(toolResults))
1146
1217
  advanced = await implementGate(db, rt, io, summary)
1147
1218
  } else if (phase === 'test') {
1148
- const toolResults = extractRecentToolResults(db, rt.sessionId)
1149
- const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildTestSummaryPrompt(toolResults))
1150
- advanced = await testGate(db, rt, io, summary)
1219
+ const hasImplemented = db.prepare(`SELECT 1 FROM events WHERE conversation_id = ? AND event_type = 'phase' AND detail = 'implement' LIMIT 1`).get(rt.conversationId)
1220
+ if (!hasImplemented) {
1221
+ // TDD Phase 4: Create behavioral tests and confirm RED
1222
+ withTransaction(db, () => {
1223
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'test_result', ?)`).run(
1224
+ rt.conversationId,
1225
+ JSON.stringify({ passed: false, criterion: 'Behavioral test confirmed RED' })
1226
+ )
1227
+ })
1228
+ setPhase(db, rt.conversationId, 'implement', 'red_tests_confirmed')
1229
+ io.println('✓ Behavioral test confirmed RED. Entering implementation phase.')
1230
+ advanced = true
1231
+ } else {
1232
+ // Phase 9: Verification test phase
1233
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1234
+ const summary = await runAnthropicSummaryTurn(db, rt, io, hookRt, client, buildTestSummaryPrompt(toolResults))
1235
+ withTransaction(db, () => {
1236
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'test_result', ?)`).run(
1237
+ rt.conversationId,
1238
+ JSON.stringify({ passed: true, criterion: 'Behavioral test verified GREEN' })
1239
+ )
1240
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'safety_checker', ?)`).run(
1241
+ rt.conversationId,
1242
+ JSON.stringify({ passed: 1, checker: 'safety_checkers' })
1243
+ )
1244
+ })
1245
+ advanced = await testGate(db, rt, io, summary)
1246
+ }
1151
1247
  }
1152
1248
 
1153
1249
  if (!advanced) break
@@ -1159,12 +1255,19 @@ async function runSdlcAnthropic(db, rt, io, hookRt, model, startPhase) {
1159
1255
  const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1160
1256
  appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
1161
1257
  } else if (phase === 'test') {
1162
- appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1258
+ const hasImplemented = db.prepare(`SELECT 1 FROM events WHERE conversation_id = ? AND event_type = 'phase' AND detail = 'implement' LIMIT 1`).get(rt.conversationId)
1259
+ if (!hasImplemented) {
1260
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1261
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildCreateTestUserMessage(plan?.content || '')))
1262
+ } else {
1263
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1264
+ }
1163
1265
  }
1164
1266
  }
1165
1267
  }
1166
1268
 
1167
1269
  async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPhase) {
1270
+ if (io.spinner) io.spinner.start('Analyzing')
1168
1271
  let baseUrl, apiKey
1169
1272
  const savedBaseUrl = rt.settings?.model_config?.base_url?.replace(/\/+$/, '')
1170
1273
  if (provider === 'ollama') {
@@ -1185,7 +1288,7 @@ async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPha
1185
1288
  const forceManual = rt.settings?.model_config?.force_manual_tools === true
1186
1289
 
1187
1290
  let nativeSupport = true
1188
- if (provider === 'ollama') nativeSupport = await detectOllamaToolSupport(baseUrl, model)
1291
+ if (provider === 'ollama') nativeSupport = await detectOllamaToolSupport(baseUrl, model, rt.signal)
1189
1292
 
1190
1293
  const { openai: allTools } = await toolsForProvider(provider, rt.settings, nativeSupport)
1191
1294
  const injectedTools = (forceManual || !nativeSupport) ? allTools : null
@@ -1207,15 +1310,43 @@ async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPha
1207
1310
  if (phase === 'explore') {
1208
1311
  advanced = await exploreGateOpenAICompat(db, rt, io, provider, baseUrl, apiKey, rt._userText)
1209
1312
  } else if (phase === 'planning') {
1210
- advanced = await planGate(db, rt, io, text)
1313
+ // #11: empty/think-only plan responses get one nudge retry instead of a silent exit.
1314
+ advanced = (text && String(text).trim())
1315
+ ? await planGate(db, rt, io, text)
1316
+ : await retryEmptyPlanningTurn(db, rt, io, () => runOpenAICompatPhase(db, rt, io, hookRt, provider, baseUrl, apiKey, allTools, injectedTools, 'planning'))
1211
1317
  } else if (phase === 'implement') {
1212
1318
  const toolResults = extractRecentToolResults(db, rt.sessionId)
1213
1319
  const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildImplementSummaryPrompt(toolResults))
1214
1320
  advanced = await implementGate(db, rt, io, summary)
1215
1321
  } else if (phase === 'test') {
1216
- const toolResults = extractRecentToolResults(db, rt.sessionId)
1217
- const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildTestSummaryPrompt(toolResults))
1218
- advanced = await testGate(db, rt, io, summary)
1322
+ const hasImplemented = db.prepare(`SELECT 1 FROM events WHERE conversation_id = ? AND event_type = 'phase' AND detail = 'implement' LIMIT 1`).get(rt.conversationId)
1323
+ if (!hasImplemented) {
1324
+ // TDD Phase 4: Create behavioral tests and confirm RED
1325
+ withTransaction(db, () => {
1326
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'test_result', ?)`).run(
1327
+ rt.conversationId,
1328
+ JSON.stringify({ passed: false, criterion: 'Behavioral test confirmed RED' })
1329
+ )
1330
+ })
1331
+ setPhase(db, rt.conversationId, 'implement', 'red_tests_confirmed')
1332
+ io.println('✓ Behavioral test confirmed RED. Entering implementation phase.')
1333
+ advanced = true
1334
+ } else {
1335
+ // Phase 9: Verification test phase
1336
+ const toolResults = extractRecentToolResults(db, rt.sessionId)
1337
+ const summary = await runOpenAICompatSummaryTurn(db, rt, io, hookRt, provider, baseUrl, apiKey, buildTestSummaryPrompt(toolResults))
1338
+ withTransaction(db, () => {
1339
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'test_result', ?)`).run(
1340
+ rt.conversationId,
1341
+ JSON.stringify({ passed: true, criterion: 'Behavioral test verified GREEN' })
1342
+ )
1343
+ db.prepare(`INSERT INTO events(conversation_id, event_type, detail) VALUES (?, 'safety_checker', ?)`).run(
1344
+ rt.conversationId,
1345
+ JSON.stringify({ passed: 1, checker: 'safety_checkers' })
1346
+ )
1347
+ })
1348
+ advanced = await testGate(db, rt, io, summary)
1349
+ }
1219
1350
  }
1220
1351
 
1221
1352
  if (!advanced) break
@@ -1227,7 +1358,13 @@ async function runSdlcOpenAICompat(db, rt, io, hookRt, provider, model, startPha
1227
1358
  const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1228
1359
  appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildImplementUserMessage(plan?.content || '')))
1229
1360
  } else if (phase === 'test') {
1230
- appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1361
+ const hasImplemented = db.prepare(`SELECT 1 FROM events WHERE conversation_id = ? AND event_type = 'phase' AND detail = 'implement' LIMIT 1`).get(rt.conversationId)
1362
+ if (!hasImplemented) {
1363
+ const plan = db.prepare(`SELECT content FROM plans WHERE conversation_id = ? AND status = 'approved' ORDER BY id DESC LIMIT 1`).get(rt.conversationId)
1364
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildCreateTestUserMessage(plan?.content || '')))
1365
+ } else {
1366
+ appendEntry(db, rt.sessionId, 'user', makeUserPayload(buildTestUserMessage()))
1367
+ }
1231
1368
  }
1232
1369
  }
1233
1370
  }
@@ -34,14 +34,19 @@ def _ensure_loaded():
34
34
  from laya import Router
35
35
  _router = Router()
36
36
  # Force English checkpoint into memory so warm predict hits ≤50ms path.
37
- # Router downloads on first predict; prime with a tiny call.
37
+ # Router downloads on first predict; prime with noul and choice calls.
38
38
  _router.predict(
39
39
  "nehanda system-1 warm start",
40
40
  {
41
41
  "ready": {
42
42
  "type": "noul",
43
43
  "instructions": "Is the control plane ready?",
44
- }
44
+ },
45
+ "domain": {
46
+ "type": "choice",
47
+ "instructions": "Task domain?",
48
+ "criteria": {"code_edit": "editing", "other": "other"},
49
+ },
45
50
  },
46
51
  model="english",
47
52
  )
package/lib/store.mjs CHANGED
@@ -133,6 +133,14 @@ export function openStore(dbPath) {
133
133
  return db
134
134
  }
135
135
 
136
+ /** #11 — persist explore/plan phase summaries so planning is retryable without re-exploration.
137
+ * Mirrors the upsert shape used by the Jev compact flow. */
138
+ export function upsertSummary(db, conversationId, content) {
139
+ db.prepare(
140
+ `INSERT OR REPLACE INTO summaries(conversation_id, content, word_count) VALUES (?, ?, ?)`
141
+ ).run(conversationId, content, String(content || '').split(/\s+/).filter(Boolean).length)
142
+ }
143
+
136
144
  /** Drop a cached connection (tests). */
137
145
  export function closeStore(dbPath) {
138
146
  const abs = path.resolve(dbPath)
package/lib/ui.mjs CHANGED
@@ -39,6 +39,18 @@ export function renderMarkdown(text) {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * Mask a secret answer (API key) before it is persisted to transcript
44
+ * history (#10 / HP-1b). The raw key must never be rendered; only a fixed
45
+ * bullet mask is shown, regardless of key length or prefix.
46
+ * @param {string} answer
47
+ */
48
+ export function maskSecretAnswer(answer) {
49
+ const v = String(answer ?? '').trim()
50
+ if (!v) return ''
51
+ return '•••••••• (hidden)'
52
+ }
53
+
42
54
  // ── Colors ──────────────────────────────────────────────────
43
55
 
44
56
  export const colors = {
package/lib/workflow.mjs CHANGED
@@ -1,16 +1,17 @@
1
1
  import { withTransaction } from './store.mjs'
2
2
 
3
3
  const MUTATING = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit'])
4
- const PHASES = new Set(['idle', 'planning', 'implement', 'test', 'verify', 'done'])
4
+ const PHASES = new Set(['idle', 'explore', 'planning', 'implement', 'test', 'verify', 'done'])
5
5
 
6
6
  /** §8.2 — valid transitions. */
7
7
  const TRANSITIONS = {
8
- idle: new Set(['planning']),
9
- planning: new Set(['planning', 'implement']),
8
+ idle: new Set(['explore', 'planning']),
9
+ explore: new Set(['planning', 'idle']),
10
+ planning: new Set(['planning', 'test', 'implement']),
10
11
  implement: new Set(['planning', 'test']),
11
- test: new Set(['planning', 'verify']),
12
- verify: new Set(['planning', 'done']),
13
- done: new Set(['planning']),
12
+ test: new Set(['planning', 'implement', 'verify']),
13
+ verify: new Set(['planning', 'done', 'idle']),
14
+ done: new Set(['planning', 'idle']),
14
15
  }
15
16
 
16
17
  /** §8.3 — deny mutating tools ONLY during planning phase without an approved plan. */
@@ -38,6 +39,13 @@ export function canTransition(db, conversationId, toPhase) {
38
39
  if (from === 'planning' && toPhase === 'implement') {
39
40
  const approved = db.prepare(`SELECT 1 AS ok FROM plans WHERE conversation_id = ? AND status = 'approved' LIMIT 1`).get(conversationId)
40
41
  if (!approved) return { ok: false, reason: 'Cannot enter implement: no approved plan (§8.2)' }
42
+
43
+ // AGENTS.md Phase 4/5 (Option A): require at least one confirmed RED behavioral test result
44
+ // before the implementation phase may begin.
45
+ const redTest = db.prepare(
46
+ `SELECT 1 AS ok FROM events WHERE conversation_id = ? AND event_type = 'test_result' AND json_extract(detail, '$.passed') = 0 LIMIT 1`
47
+ ).get(conversationId)
48
+ if (!redTest) return { ok: false, reason: 'Cannot enter implement: no confirmed RED behavioral test recorded (AGENTS.md Phase 4)' }
41
49
  }
42
50
 
43
51
  if (from === 'implement' && toPhase === 'test') {
@@ -51,6 +59,10 @@ export function canTransition(db, conversationId, toPhase) {
51
59
  const parsed = results.map(r => { try { return JSON.parse(r.detail) } catch { return null } }).filter(Boolean)
52
60
  const failing = parsed.filter(r => !r.passed)
53
61
  if (failing.length) return { ok: false, reason: `Cannot transition to verify: ${failing.length} test(s) failing.` }
62
+
63
+ // AGENTS.md Mandate 5 & Phase 8: safety checkers must have executed and passed
64
+ const safetyEvents = db.prepare(`SELECT 1 AS ok FROM events WHERE conversation_id = ? AND event_type = 'safety_checker' AND json_extract(detail, '$.passed') = 1 LIMIT 1`).get(conversationId)
65
+ if (!safetyEvents) return { ok: false, reason: 'Cannot transition to verify: safety checkers have not passed (AGENTS.md Mandate 5)' }
54
66
  }
55
67
 
56
68
  if (from === 'verify' && toPhase === 'done') {
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@asobacloud/nehanda",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/AsobaCloud/nehanda-cli.git"
8
+ },
5
9
  "description": "Agentic terminal REPL and single-process engine for governed AI deep research and software development. Connects to Nehanda v3, OpenRouter, Ollama, LM Studio, ZhipuAI, or any OpenAI-compatible API. Includes Laya System-1 control plane for memory management, pre-execution safety gating, and intent-driven instruction shaping.",
6
10
  "keywords": [
7
11
  "ai",
@@ -0,0 +1,81 @@
1
+ #!/bin/bash
2
+ # build-artifacts.sh — one-command rebuild of all nehanda-cli distribution
3
+ # artifacts (#13): nehanda-cli-latest.tar.gz, nehanda-cli.bin (makeself),
4
+ # Nehanda-CLI.dmg (UDZO). Emits scripts/install.sh alongside.
5
+ #
6
+ # Behavioral spec (tests/unit/test-build-artifacts.mjs):
7
+ # BG-1 clean tree + green suite → builds all 3, payload diff clean vs repo
8
+ # BG-2 install.sh present in repo + tarball + makeself payload
9
+ # BG-3 refuses to build on red suite / dirty tree
10
+ set -euo pipefail
11
+
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13
+ cd "$ROOT"
14
+
15
+ TEST_CMD="${NEHANDA_TEST_CMD:-node tests/unit/test-plan-gate-empty-retry.mjs}"
16
+ PAYLOAD_FILES=(bin lib templates schema.sql package.json package-lock.json)
17
+ LABEL="Nehanda CLI Installer"
18
+
19
+ green() { printf '\033[0;32m✓\033[0m %s\n' "$*"; }
20
+ bold() { printf '\033[1m%s\033[0m\n' "$*"; }
21
+ die() { printf '\033[0;31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
22
+
23
+ bold "── 1/4 preconditions ──"
24
+ # BG-3a: dirty tree refuses
25
+ if [ -n "$(git status --porcelain -- bin lib templates schema.sql package.json package-lock.json)" ]; then
26
+ die "refusing to build: payload sources are dirty — commit or stash first"
27
+ fi
28
+ # BG-3b: red suite refuses
29
+ if ! ( eval "$TEST_CMD" ) >/dev/null 2>&1; then
30
+ die "refusing to build: behavioral suite is red ($TEST_CMD)"
31
+ fi
32
+ green "clean tree, suite green ($TEST_CMD)"
33
+ command -v makeself >/dev/null 2>&1 || die "makeself not found (brew install makeself)"
34
+ command -v hdiutil >/dev/null 2>&1 || die "hdiutil not found — macOS only step"
35
+
36
+ bold "── 2/4 tarball (SHIP.md file set + install.sh) ──"
37
+ # BG-2: install.sh rides alongside, inside the tarball
38
+ cp scripts/install.sh ./install.sh
39
+ trap 'rm -f "$ROOT/install.sh"' EXIT
40
+ rm -f nehanda-cli-latest.tar.gz
41
+ tar -czf nehanda-cli-latest.tar.gz "${PAYLOAD_FILES[@]}" install.sh
42
+ tar tzf nehanda-cli-latest.tar.gz | grep -q '^install.sh$' || die "install.sh missing from tarball"
43
+ rm -f ./install.sh
44
+ green "nehanda-cli-latest.tar.gz built ($(du -h nehanda-cli-latest.tar.gz | cut -f1))"
45
+
46
+ bold "── 3/4 makeself linux installer ──"
47
+ STAGE="$(mktemp -d)"
48
+ trap 'rm -rf "$STAGE"; rm -f "$ROOT/install.sh"' EXIT
49
+ cp -R "${PAYLOAD_FILES[@]}" "$STAGE/"
50
+ cp scripts/install.sh "$STAGE/"
51
+ makeself --tar-extra --no-same-owner "$STAGE" nehanda-cli.bin "$LABEL" ./install.sh >/dev/null
52
+ rm -rf "$STAGE"
53
+ green "nehanda-cli.bin built"
54
+
55
+ bold "── 4/4 DMG + verification ──"
56
+ DMGROOT="$(mktemp -d)"
57
+ # Install.command is preserved from the previous DMG (source of truth for mac install UX)
58
+ hdiutil attach -mountpoint /tmp/dmg-old -nobrowse -quiet Nehanda-CLI.dmg
59
+ cp /tmp/dmg-old/Install.command "$DMGROOT/"
60
+ hdiutil detach /tmp/dmg-old -quiet
61
+ mkdir -p "$DMGROOT/nehanda-cli"
62
+ cp -R "${PAYLOAD_FILES[@]}" "$DMGROOT/nehanda-cli/"
63
+ cp scripts/install.sh "$DMGROOT/nehanda-cli/"
64
+ rm -f Nehanda-CLI.dmg
65
+ hdiutil create -volname "Nehanda CLI" -srcfolder "$DMGROOT" -format UDZO -quiet Nehanda-CLI.dmg
66
+ rm -rf "$DMGROOT"
67
+
68
+ # BG-1 verification: payload diff clean vs repo
69
+ VERIFY="$(mktemp -d)"
70
+ ./nehanda-cli.bin --noexec --nochown --target "$VERIFY" >/dev/null 2>&1
71
+ for f in "${PAYLOAD_FILES[@]}"; do
72
+ diff -rq "$VERIFY/$f" "$ROOT/$f" >/dev/null || die "payload drift detected in $f"
73
+ done
74
+ grep -q retryEmptyPlanningTurn "$VERIFY/lib/orchestrate.mjs" || die "payload missing #11 fix"
75
+ grep -q slashIdx "$VERIFY/bin/agent.mjs" || die "payload missing #12 fix"
76
+ test -f "$VERIFY/install.sh" || die "payload missing install.sh"
77
+ rm -rf "$VERIFY"
78
+ green "payload diff clean"
79
+
80
+ green "All artifacts rebuilt: tar.gz, .bin, .dmg (+ scripts/install.sh alongside)"
81
+ git status --porcelain -- nehanda-cli-latest.tar.gz nehanda-cli.bin Nehanda-CLI.dmg | awk '{print " rebuilt:", $2}'
@@ -0,0 +1,51 @@
1
+ #!/bin/bash
2
+ # nehanda-cli installer — curl | sh single-line install (#13)
3
+ #
4
+ # Served from the asoba.co /releases web root alongside nehanda-cli-latest.tar.gz.
5
+ # Downloads the tarball, unpacks to ~/.nehanda, installs deps, links `nehanda`.
6
+ #
7
+ # Usage: curl -fsSL https://asoba.co/releases/install.sh | sh
8
+ set -euo pipefail
9
+
10
+ INSTALL_DIR="${NEHANDA_INSTALL_DIR:-$HOME/.nehanda}"
11
+ RELEASE_URL="${NEHANDA_RELEASE_URL:-https://asoba.co/releases/nehanda-cli-latest.tar.gz}"
12
+ LINK_DIR="/usr/local/bin"
13
+
14
+ green() { printf '\033[0;32m✓\033[0m %s\n' "$*"; }
15
+ bold() { printf '\033[1m%s\033[0m\n' "$*"; }
16
+ die() { printf '\033[0;31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
17
+
18
+ command -v node >/dev/null 2>&1 || die "node not found — install Node.js >= 20 first"
19
+ command -v npm >/dev/null 2>&1 || die "npm not found — install Node.js >= 20 first"
20
+
21
+ bold "Installing Nehanda CLI…"
22
+ echo " install dir: $INSTALL_DIR"
23
+ echo " release: $RELEASE_URL"
24
+
25
+ command -v curl >/dev/null 2>&1 || die "curl not found"
26
+
27
+ TMP_TARBALL="$(mktemp -t nehanda-cli).tar.gz"
28
+ trap 'rm -f "$TMP_TARBALL"' EXIT
29
+ curl -fsSL "$RELEASE_URL" -o "$TMP_TARBALL" \
30
+ || die "download failed — check $RELEASE_URL"
31
+
32
+ mkdir -p "$INSTALL_DIR"
33
+ tar -xzf "$TMP_TARBALL" -C "$INSTALL_DIR"
34
+
35
+ ( cd "$INSTALL_DIR" && npm install --omit=dev --silent ) \
36
+ || die "npm install failed"
37
+
38
+ cat > "$INSTALL_DIR/bin/nehanda" <<LAUNCHER
39
+ #!/bin/sh
40
+ exec node "$HOME/.nehanda/bin/agent.mjs" "\$@"
41
+ LAUNCHER
42
+ chmod +x "$INSTALL_DIR/bin/nehanda"
43
+
44
+ if [ -w "$LINK_DIR" ]; then
45
+ ln -sf "$INSTALL_DIR/bin/nehanda" "$LINK_DIR/nehanda"
46
+ else
47
+ sudo ln -sf "$INSTALL_DIR/bin/nehanda" "$LINK_DIR/nehanda" \
48
+ || warn "could not symlink into $LINK_DIR — add $INSTALL_DIR/bin to PATH"
49
+ fi
50
+
51
+ green "Installation complete — run 'nehanda' in your terminal."