@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 +42 -11
- package/bin/agent.mjs +4 -1
- package/bin/nehanda-ui.mjs +195 -29
- package/lib/configureProvider.mjs +4 -3
- package/lib/explore.mjs +109 -0
- package/lib/issues.mjs +144 -0
- package/lib/laya_sidecar.mjs +11 -5
- package/lib/modelPicker.mjs +124 -0
- package/lib/openaiCompat.mjs +17 -1
- package/lib/orchestrate.mjs +160 -23
- package/lib/scripts/laya_worker.py +7 -2
- package/lib/store.mjs +8 -0
- package/lib/ui.mjs +12 -0
- package/lib/workflow.mjs +18 -6
- package/package.json +5 -1
- package/scripts/build-artifacts.sh +81 -0
- package/scripts/install.sh +51 -0
package/lib/orchestrate.mjs
CHANGED
|
@@ -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:
|
|
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)
|
|
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
|
|
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, '
|
|
752
|
-
io.println('✓ Plan approved. Entering
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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."
|