@asobacloud/nehanda 0.0.0-stage → 0.3.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +57 -0
  3. package/README.md +444 -2
  4. package/bin/agent.mjs +1260 -0
  5. package/bin/nehanda-ui.mjs +824 -0
  6. package/lib/anthropicHeaders.mjs +8 -0
  7. package/lib/app.mjs +313 -0
  8. package/lib/auth.mjs +255 -0
  9. package/lib/bashguard.mjs +159 -0
  10. package/lib/compact.mjs +306 -0
  11. package/lib/energy-middleware.mjs +318 -0
  12. package/lib/hookShapeOrder.mjs +30 -0
  13. package/lib/hookplane.mjs +228 -0
  14. package/lib/instructions.mjs +23 -0
  15. package/lib/jev_read.mjs +216 -0
  16. package/lib/jev_write.mjs +253 -0
  17. package/lib/laya.mjs +296 -0
  18. package/lib/laya_sidecar.mjs +198 -0
  19. package/lib/mcp-config.mjs +189 -0
  20. package/lib/mcp.mjs +169 -0
  21. package/lib/modelConfig.mjs +152 -0
  22. package/lib/modelDiscovery.mjs +205 -0
  23. package/lib/openaiCompat.mjs +147 -0
  24. package/lib/orchestrate.mjs +1163 -0
  25. package/lib/paths.mjs +20 -0
  26. package/lib/permissions.mjs +44 -0
  27. package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
  28. package/lib/scripts/audit-code-integrity.py +437 -0
  29. package/lib/scripts/ensure-laya-env.sh +55 -0
  30. package/lib/scripts/js-safety-checker.cjs +308 -0
  31. package/lib/scripts/laya_worker.py +171 -0
  32. package/lib/scripts/odse-transform.py +97 -0
  33. package/lib/scripts/python-safety-checker.py +197 -0
  34. package/lib/scripts/shell-safety-checker.sh +306 -0
  35. package/lib/settings.mjs +147 -0
  36. package/lib/store.mjs +278 -0
  37. package/lib/team.mjs +69 -0
  38. package/lib/testgen.mjs +146 -0
  39. package/lib/tools/AuditCodeIntegrity.json +30 -0
  40. package/lib/tools/JsSafetyChecker.json +29 -0
  41. package/lib/tools/PythonSafetyChecker.json +29 -0
  42. package/lib/tools/ShellSafetyChecker.json +29 -0
  43. package/lib/tools.mjs +907 -0
  44. package/lib/transcript.mjs +223 -0
  45. package/lib/ui.mjs +286 -0
  46. package/lib/workflow.mjs +91 -0
  47. package/lib/worktree.mjs +56 -0
  48. package/package.json +50 -4
  49. package/schema.sql +181 -0
  50. package/scripts/postinstall.mjs +62 -0
  51. package/scripts/sdlc-acceptance.sh +477 -0
  52. package/scripts/verify-sdlc-hook-order.mjs +26 -0
  53. package/templates/test_e2e.sh +32 -0
  54. package/templates/test_file.sh +14 -0
  55. package/templates/test_hook.sh +27 -0
  56. package/templates/test_http.sh +17 -0
  57. package/templates/test_phase.sh +23 -0
  58. package/templates/test_process.sh +14 -0
  59. package/templates/test_tool.sh +24 -0
  60. package/templates/validate_test.sh +55 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * energy-middleware.mjs
3
+ *
4
+ * ODSE energy normalization middleware for nehanda-cli.
5
+ *
6
+ * Intercepts MCP tool results containing energy production data and
7
+ * transforms them through the `odse` Python package (ona-protocol)
8
+ * before the model sees the payload.
9
+ *
10
+ * Flow:
11
+ * 1. Detect OEM brand from MCP server name (e.g. "energy-huawei" → "huawei")
12
+ * 2. If name doesn't resolve → fingerprint the payload fields
13
+ * 3. If brand identified → spawn odse-transform.py, return normalized ODS-E JSON
14
+ * 4. If no brand match → return payload unchanged (pass-through)
15
+ *
16
+ * Server naming convention (recommended in mcp.json):
17
+ * "energy-huawei", "energy-solaredge", "energy-sungrow", etc.
18
+ * Any prefix before the first hyphen-separated OEM key is stripped.
19
+ *
20
+ * No configuration required — drop a new energy MCP server into mcp.json
21
+ * following the naming convention and normalization happens automatically.
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process'
25
+ import path from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
29
+ const TRANSFORM_SCRIPT = path.join(__dirname, 'scripts', 'odse-transform.py')
30
+ const TRANSFORM_TIMEOUT_MS = 30_000
31
+
32
+ // ─── All known ODSE source keys (from odse.transformer._get_transformer) ─────
33
+
34
+ const KNOWN_OEMS = new Set([
35
+ 'huawei', 'fusionsolar',
36
+ 'enphase', 'envoy',
37
+ 'solarman', 'logger',
38
+ 'solaredge',
39
+ 'fronius',
40
+ 'switch',
41
+ 'solaxcloud', 'solax',
42
+ 'fimer', 'auroravision',
43
+ 'sma',
44
+ 'solis', 'soliscloud',
45
+ 'sungrow', 'isolarcloud',
46
+ 'higeco',
47
+ 'eskom', 'eskom_portal', 'eskom_amr', 'nrs049',
48
+ 'sungrow_bess', 'powertitan',
49
+ 'byd', 'byd_bess',
50
+ 'vestas',
51
+ 'siemens_gamesa', 'sgre',
52
+ 'nordex',
53
+ 'terraco',
54
+ 'csv', 'generic_csv', 'generic',
55
+ ])
56
+
57
+ // Canonical source key for known aliases — maps anything → the key odse accepts
58
+ const OEM_ALIASES = {
59
+ fusionsolar: 'huawei',
60
+ envoy: 'enphase',
61
+ logger: 'solarman',
62
+ auroravision: 'fimer',
63
+ solax: 'solaxcloud',
64
+ soliscloud: 'solis',
65
+ isolarcloud: 'sungrow',
66
+ eskom_portal: 'eskom',
67
+ eskom_amr: 'eskom-amr',
68
+ nrs049: 'eskom-amr',
69
+ powertitan: 'sungrow-bess',
70
+ byd_bess: 'byd-bess',
71
+ sgre: 'siemens-gamesa',
72
+ generic_csv: 'generic',
73
+ csv: 'generic',
74
+ }
75
+
76
+ function canonicalOem(key) {
77
+ const k = key.toLowerCase().replace(/-/g, '_')
78
+ return OEM_ALIASES[k] || (KNOWN_OEMS.has(k.replace(/_/g, '-')) ? k.replace(/_/g, '-') : null)
79
+ || (KNOWN_OEMS.has(k) ? k : null)
80
+ }
81
+
82
+ // ─── Name-based OEM detection ─────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Attempt to extract an OEM key from the MCP server name.
86
+ * Handles patterns like:
87
+ * "energy-huawei" → "huawei"
88
+ * "mcp-solaredge" → "solaredge"
89
+ * "power-sungrow-v2" → "sungrow"
90
+ * "huawei" → "huawei" (bare name)
91
+ *
92
+ * @param {string} serverName
93
+ * @returns {string|null} canonical OEM key or null
94
+ */
95
+ export function detectOemFromServerName(serverName) {
96
+ if (!serverName) return null
97
+ const parts = serverName.toLowerCase().split(/[-_]/)
98
+ // Try each segment (and pairs) as an OEM key, skipping generic prefixes
99
+ const SKIP = new Set(['energy', 'mcp', 'power', 'solar', 'api', 'v1', 'v2', 'v3', 'prod', 'dev'])
100
+ for (const part of parts) {
101
+ if (SKIP.has(part)) continue
102
+ const oem = canonicalOem(part)
103
+ if (oem) return oem
104
+ }
105
+ return null
106
+ }
107
+
108
+ // ─── Payload fingerprinting ───────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Field-signature fingerprints derived from ona-protocol transform YAMLs.
112
+ * Each entry: { oem: string, test: (parsed: any) → boolean }
113
+ * Ordered from most-specific to least-specific.
114
+ */
115
+ const FINGERPRINTS = [
116
+ // Huawei FusionSolar CSV: has "inverter_state" or "run_state" columns
117
+ {
118
+ oem: 'huawei',
119
+ test: p =>
120
+ typeof p === 'string'
121
+ ? /inverter.?state|run.?state|fusionsolar/i.test(p)
122
+ : (Array.isArray(p) ? p[0] : p)?.inverter_state !== undefined,
123
+ },
124
+ // Enphase Envoy JSON array: { end_at, wh_del, devices_reporting }
125
+ {
126
+ oem: 'enphase',
127
+ test: p => {
128
+ const first = Array.isArray(p) ? p[0] : null
129
+ return first && 'end_at' in first && 'wh_del' in first
130
+ },
131
+ },
132
+ // SolarEdge: { data: { telemetries: [...] } } or { energy: { values: [...] } }
133
+ {
134
+ oem: 'solaredge',
135
+ test: p =>
136
+ p?.data?.telemetries !== undefined ||
137
+ p?.data?.powerDetails !== undefined ||
138
+ (typeof p === 'string' && /solaredge|L1Data|totalActivePower/i.test(p)),
139
+ },
140
+ // Fronius Solar API: { Head: { Timestamp }, Body: { Data: { Site } } }
141
+ {
142
+ oem: 'fronius',
143
+ test: p => p?.Head?.Timestamp !== undefined && p?.Body?.Data !== undefined,
144
+ },
145
+ // Sungrow iSolarCloud: has stationCode or device_type fields
146
+ {
147
+ oem: 'sungrow',
148
+ test: p =>
149
+ p?.stationCode !== undefined ||
150
+ p?.device_type !== undefined ||
151
+ (typeof p === 'string' && /isolarcloud|sungrow/i.test(p)),
152
+ },
153
+ // SMA: { records: [{ normalized: { active_power_w, status_code } }] }
154
+ {
155
+ oem: 'sma',
156
+ test: p => {
157
+ const first = Array.isArray(p?.records) ? p.records[0] : null
158
+ return first?.normalized?.active_power_w !== undefined && first?.normalized?.status_code !== undefined
159
+ },
160
+ },
161
+ // SolaxCloud: { success, result: { acpower, inverterStatus } }
162
+ {
163
+ oem: 'solaxcloud',
164
+ test: p =>
165
+ p?.result?.acpower !== undefined ||
166
+ p?.result?.inverterStatus !== undefined,
167
+ },
168
+ // Fimer AuroraVision: { series: [{ date, energy, unit }] }
169
+ {
170
+ oem: 'fimer',
171
+ test: p => Array.isArray(p?.series) && p.series[0]?.energy !== undefined && p.series[0]?.date !== undefined,
172
+ },
173
+ // Solarman logger CSV: "Update Time" + "Generation(kWh)" columns
174
+ {
175
+ oem: 'solarman',
176
+ test: p =>
177
+ typeof p === 'string' && /Update.?Time|Generation.*kWh|Device.?State/i.test(p),
178
+ },
179
+ // Vestas: CSV with wind turbine fields
180
+ {
181
+ oem: 'vestas',
182
+ test: p =>
183
+ typeof p === 'string' && /rotor_rpm|nacelle_position|wind_speed.*turbine_state/i.test(p),
184
+ },
185
+ // Siemens Gamesa: CSV with metmast + bearing_temp
186
+ {
187
+ oem: 'siemens-gamesa',
188
+ test: p =>
189
+ typeof p === 'string' && /wind_speed_metmast|bearing_temp|availability_status/i.test(p),
190
+ },
191
+ // Nordex: CSV with blade_angle + transformer_temp
192
+ {
193
+ oem: 'nordex',
194
+ test: p =>
195
+ typeof p === 'string' && /blade_angle|transformer_temp|turbine_status/i.test(p),
196
+ },
197
+ // BYD BESS: has soc or state_of_charge
198
+ {
199
+ oem: 'byd-bess',
200
+ test: p =>
201
+ p?.soc !== undefined || p?.state_of_charge !== undefined ||
202
+ (typeof p === 'string' && /state.?of.?charge|byd/i.test(p)),
203
+ },
204
+ // Terraco historian: { data: [{ timestamp, values: { "SITE.ActivePower": ... } }] }
205
+ {
206
+ oem: 'terraco',
207
+ test: p => {
208
+ const first = Array.isArray(p?.data) ? p.data[0] : null
209
+ if (!first?.values) return false
210
+ return Object.keys(first.values).some(k => /\.\w+/.test(k))
211
+ },
212
+ },
213
+ // Higeco: records with connectionStatus + powerStatus
214
+ {
215
+ oem: 'higeco',
216
+ test: p => {
217
+ const first = Array.isArray(p?.records) ? p.records[0] : null
218
+ return first?.normalized?.connectionStatus !== undefined
219
+ },
220
+ },
221
+ ]
222
+
223
+ /**
224
+ * Attempt to identify an OEM from the raw payload string via field fingerprinting.
225
+ *
226
+ * @param {string} content raw MCP tool result content
227
+ * @returns {string|null} canonical OEM key or null
228
+ */
229
+ export function detectOemFromPayload(content) {
230
+ if (!content || typeof content !== 'string') return null
231
+
232
+ // Try to parse as JSON for object-based fingerprints
233
+ let parsed = null
234
+ try { parsed = JSON.parse(content) } catch { /* not JSON — use raw string */ }
235
+
236
+ const subject = parsed ?? content
237
+
238
+ for (const { oem, test } of FINGERPRINTS) {
239
+ try {
240
+ if (test(subject)) return oem
241
+ } catch { /* fingerprint threw — skip */ }
242
+ }
243
+ return null
244
+ }
245
+
246
+ // ─── Transform dispatch ───────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Run the raw payload through odse-transform.py for the given OEM source.
250
+ * Returns normalized ODS-E JSON string on success, null on failure (caller
251
+ * should fall back to the original content).
252
+ *
253
+ * @param {string} source ODSE source key
254
+ * @param {string} payload raw payload string
255
+ * @param {object} opts { assetId?, timezone? }
256
+ * @returns {string|null}
257
+ */
258
+ function runOdseTransform(source, payload, opts = {}) {
259
+ const args = ['python3', TRANSFORM_SCRIPT, '--source', source]
260
+ if (opts.assetId) args.push('--asset-id', opts.assetId)
261
+ if (opts.timezone) args.push('--timezone', opts.timezone)
262
+
263
+ const result = spawnSync(args[0], args.slice(1), {
264
+ input: payload,
265
+ encoding: 'utf8',
266
+ timeout: TRANSFORM_TIMEOUT_MS,
267
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
268
+ })
269
+
270
+ if (result.error) return null // spawn failed (python3 not found, etc.)
271
+ if (result.status === 2) return null // import/usage error — log and skip
272
+ if (result.status === 1) return null // unknown source — pass through
273
+ if (result.status !== 0) return null
274
+
275
+ const out = (result.stdout || '').trim()
276
+ if (!out) return null
277
+ return out
278
+ }
279
+
280
+ // ─── Public API ───────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Apply ODSE energy normalization to an MCP tool result.
284
+ *
285
+ * If the server name or payload fingerprint identifies a known OEM inverter
286
+ * brand, the raw content is transformed to ODS-E normalized JSON.
287
+ * Otherwise the content is returned unchanged.
288
+ *
289
+ * @param {string} serverName MCP server name (from mcp__<server>__<tool>)
290
+ * @param {string} content raw tool result content string
291
+ * @param {object} opts optional hints: { assetId?, timezone? }
292
+ * @returns {{ content: string, normalized: boolean, oem: string|null }}
293
+ */
294
+ export async function applyEnergyNormalization(serverName, content, opts = {}) {
295
+ if (!content || typeof content !== 'string') {
296
+ return { content, normalized: false, oem: null }
297
+ }
298
+
299
+ // Step 1: try name-based detection
300
+ let oem = detectOemFromServerName(serverName)
301
+
302
+ // Step 2: fall back to payload fingerprinting
303
+ if (!oem) oem = detectOemFromPayload(content)
304
+
305
+ // Step 3: no match — pass through unchanged
306
+ if (!oem) return { content, normalized: false, oem: null }
307
+
308
+ // Step 4: run transform
309
+ const normalized = runOdseTransform(oem, content, opts)
310
+ if (!normalized) {
311
+ // Transform failed — return original, don't break the model turn
312
+ return { content, normalized: false, oem }
313
+ }
314
+
315
+ // Wrap with a header so the model knows this was normalized
316
+ const header = `[ODS-E normalized · source: ${oem}]\n`
317
+ return { content: header + normalized, normalized: true, oem }
318
+ }
@@ -0,0 +1,30 @@
1
+ /** CLEAN_ROOM_SPEC.md Appendix A — HookInputSchema union order (normative). */
2
+ export const HOOK_INPUT_SCHEMA_UNION_ORDER = [
3
+ 'PreToolUse',
4
+ 'PostToolUse',
5
+ 'PostToolUseFailure',
6
+ 'PermissionDenied',
7
+ 'Notification',
8
+ 'UserPromptSubmit',
9
+ 'SessionStart',
10
+ 'SessionEnd',
11
+ 'Stop',
12
+ 'StopFailure',
13
+ 'SubagentStart',
14
+ 'SubagentStop',
15
+ 'PreCompact',
16
+ 'PostCompact',
17
+ 'PermissionRequest',
18
+ 'Setup',
19
+ 'TeammateIdle',
20
+ 'TaskCreated',
21
+ 'TaskCompleted',
22
+ 'Elicitation',
23
+ 'ElicitationResult',
24
+ 'ConfigChange',
25
+ 'InstructionsLoaded',
26
+ 'WorktreeCreate',
27
+ 'WorktreeRemove',
28
+ 'CwdChanged',
29
+ 'FileChanged',
30
+ ]
@@ -0,0 +1,228 @@
1
+ import fs from 'node:fs'
2
+ import { spawn } from 'node:child_process'
3
+ import { withTransaction } from './store.mjs'
4
+
5
+ const MAX_CAPTURE = 4194304
6
+ const TRUNC_MARK = '\n[SDLC_OUTPUT_TRUNCATED]\n'
7
+
8
+ function hookTimeoutMs(eventName) {
9
+ if (eventName === 'SessionEnd') {
10
+ const v = Number(process.env.SDLC_SESSIONEND_HOOK_TIMEOUT_MS)
11
+ return Number.isFinite(v) && v > 0 ? v : 1500
12
+ }
13
+ const v = Number(process.env.SDLC_HOOK_TIMEOUT_MS)
14
+ return Number.isFinite(v) && v > 0 ? v : 600000
15
+ }
16
+
17
+ /** §5.1 — matcher against event-specific field. */
18
+ export function matcherMatches(hookEventName, matcher, ctx) {
19
+ const m = matcher == null || matcher === '' ? '' : String(matcher)
20
+ if (m === '' || m === '*') return true
21
+
22
+ const toolish = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest', 'PermissionDenied'])
23
+ if (toolish.has(hookEventName)) return matchValue(m, ctx.tool_name || '')
24
+ if (hookEventName === 'SessionStart') return matchValue(m, ctx.source || '')
25
+ if (hookEventName === 'Setup' || hookEventName === 'PreCompact' || hookEventName === 'PostCompact') return matchValue(m, ctx.trigger || '')
26
+ if (hookEventName === 'Notification') return matchValue(m, ctx.notification_type || '')
27
+ if (hookEventName === 'SessionEnd') return matchValue(m, ctx.reason || '')
28
+ if (hookEventName === 'StopFailure') return matchValue(m, String(ctx.error || ''))
29
+ if (hookEventName === 'SubagentStart' || hookEventName === 'SubagentStop') return matchValue(m, ctx.agent_type || '')
30
+ if (hookEventName === 'Elicitation' || hookEventName === 'ElicitationResult') return matchValue(m, ctx.mcp_server_name || '')
31
+ if (hookEventName === 'ConfigChange') return matchValue(m, ctx.source || '')
32
+ if (hookEventName === 'InstructionsLoaded') return matchValue(m, ctx.load_reason || '')
33
+ if (hookEventName === 'FileChanged') {
34
+ const base = ctx.file_path ? ctx.file_path.split(/[/\\]/).pop() : ''
35
+ return matchValue(m, base)
36
+ }
37
+ return true
38
+ }
39
+
40
+ function matchValue(matcher, value) {
41
+ if (/^[a-zA-Z0-9_|]+$/.test(matcher) && matcher.includes('|')) {
42
+ return matcher.split('|').includes(value)
43
+ }
44
+ try { return new RegExp(matcher).test(value) } catch { return false }
45
+ }
46
+
47
+ function baseHookInput(hook_event_name, rt) {
48
+ const o = {
49
+ hook_event_name,
50
+ session_id: rt.sessionId,
51
+ conversation_id: rt.conversationId,
52
+ runtime_db_path: rt.runtimeDbPath,
53
+ cwd: rt.cwd,
54
+ }
55
+ if (rt.permissionMode != null) o.permission_mode = rt.permissionMode
56
+ if (rt.agentId != null) o.agent_id = rt.agentId
57
+ if (rt.agentType != null) o.agent_type = rt.agentType
58
+ return o
59
+ }
60
+
61
+ /** §5.2 — dedup: collapse adjacent identical (hook_event, matcher, command, shell, if_condition) keeping first. */
62
+ function hooksForEvent(settings, eventName) {
63
+ const raw = settings?.hooks
64
+ if (!Array.isArray(raw)) return []
65
+ const filtered = raw.filter(h => h && h.hook_event_name === eventName)
66
+ const deduped = []
67
+ for (const h of filtered) {
68
+ const key = `${h.hook_event_name}|${h.matcher ?? ''}|${h.command ?? ''}|${h.shell ?? 'bash'}|${h.if_condition ?? ''}`
69
+ if (deduped.length && deduped[deduped.length - 1]._dedupKey === key) continue
70
+ deduped.push({ ...h, _dedupKey: key })
71
+ }
72
+ return deduped
73
+ }
74
+
75
+ /** §5.3 — sequential hooks; §5.6 blocking merge; §5.8 async rejection. */
76
+ export async function runHooks(db, rt, eventName, eventFields) {
77
+ if (process.env.SDLC_DISABLE_ALL_HOOKS === '1') return { ok: true, preTool: null, userPrompt: null }
78
+
79
+ const list = hooksForEvent(rt.settings, eventName)
80
+ let ordinal = 0
81
+ const preAgg = { permission: 'unset', blocks: [], skipRest: false }
82
+ let userPromptCtx = null
83
+
84
+ for (const h of list) {
85
+ if (preAgg.skipRest) {
86
+ persistSkipped(db, rt, eventName, ordinal, h, eventFields, 'prior_block_or_deny')
87
+ ordinal++
88
+ continue
89
+ }
90
+ if (!matcherMatches(eventName, h.matcher, { ...rt, ...eventFields })) continue
91
+
92
+ const shell = h.shell === 'powershell' || h.shell === 'sh' ? h.shell : 'bash'
93
+ const stdinObj = { ...baseHookInput(eventName, rt), ...eventFields }
94
+ const inputJson = JSON.stringify(stdinObj)
95
+ const started = new Date().toISOString()
96
+
97
+ const { exitCode, stdout, stderr } = await runHookCommand(shell, h.command, stdinObj, rt.cwd, rt.runtimeDbPath, hookTimeoutMs(eventName))
98
+ const completed = new Date().toISOString()
99
+
100
+ withTransaction(db, () => {
101
+ db.prepare(
102
+ `INSERT INTO hook_invocations(session_id, conversation_id, hook_event, hook_ordinal, matcher, command, tool_use_id, tool_name, input_json, exit_code, stdout_text, stderr_text, started_at, completed_at, skipped_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
103
+ ).run(rt.sessionId, rt.conversationId, eventName, ordinal, String(h.matcher ?? ''), String(h.command ?? ''), eventFields.tool_use_id ?? null, eventFields.tool_name ?? null, inputJson, exitCode, stdout, stderr, started, completed, null)
104
+ })
105
+ ordinal++
106
+
107
+ const parsed = parseHookStdout(stdout)
108
+ if (parsed.asyncInvalid) console.error('[SDLC] hook rejected async:true control (§5.8)')
109
+
110
+ if (eventName === 'PreToolUse') {
111
+ mergePreToolUse(preAgg, parsed, exitCode, stderr, ordinal - 1)
112
+ if (preAgg.skipRest) continue
113
+ } else if (eventName === 'UserPromptSubmit' && exitCode === 2) {
114
+ userPromptCtx = { blocked: true, stderr, ordinal: ordinal - 1 }
115
+ break
116
+ }
117
+ }
118
+
119
+ if (eventName === 'PreToolUse') {
120
+ const denied = preAgg.permission === 'deny' || preAgg.blocks.length > 0
121
+ let message = ''
122
+ if (preAgg.blocks.length) {
123
+ message = preAgg.blocks.sort((a, b) => a.ordinal - b.ordinal).map(b => `[${b.ordinal}] ${b.stderr}`).join('\n')
124
+ }
125
+ return {
126
+ ok: !denied,
127
+ preTool: denied ? { denied: true, message } : { denied: false, hookAsk: preAgg.permission === 'ask' },
128
+ userPrompt: null,
129
+ }
130
+ }
131
+
132
+ if (eventName === 'UserPromptSubmit') {
133
+ return { ok: !userPromptCtx?.blocked, preTool: null, userPrompt: userPromptCtx }
134
+ }
135
+
136
+ return { ok: true, preTool: null, userPrompt: null }
137
+ }
138
+
139
+ function persistSkipped(db, rt, eventName, ordinal, h, eventFields, reason) {
140
+ const stdinObj = { ...baseHookInput(eventName, rt), ...eventFields }
141
+ withTransaction(db, () => {
142
+ db.prepare(
143
+ `INSERT INTO hook_invocations(session_id, conversation_id, hook_event, hook_ordinal, matcher, command, tool_use_id, tool_name, input_json, exit_code, stdout_text, stderr_text, started_at, completed_at, skipped_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
144
+ ).run(rt.sessionId, rt.conversationId, eventName, ordinal, String(h.matcher ?? ''), String(h.command ?? ''), eventFields.tool_use_id ?? null, eventFields.tool_name ?? null, JSON.stringify(stdinObj), null, null, null, new Date().toISOString(), null, reason)
145
+ })
146
+ }
147
+
148
+ function mergePreToolUse(agg, parsed, exitCode, stderr, ord) {
149
+ const dec = parsed.hookSpecificOutput?.permissionDecision
150
+ if (dec === 'deny' || dec === 'ask' || dec === 'allow') {
151
+ const rank = { unset: 0, allow: 1, ask: 2, deny: 3 }
152
+ if ((rank[dec] ?? 0) > (rank[agg.permission] ?? 0)) agg.permission = dec
153
+ }
154
+ if (exitCode === 2) agg.blocks.push({ ordinal: ord, stderr: stderr || '' })
155
+ if (agg.permission === 'deny' || agg.blocks.length > 0) agg.skipRest = true
156
+ }
157
+
158
+ function parseHookStdout(stdout) {
159
+ const t = (stdout || '').trim()
160
+ if (!t.startsWith('{')) return {}
161
+ try {
162
+ const j = JSON.parse(t)
163
+ if (j && j.async === true) return { asyncInvalid: true }
164
+ return j
165
+ } catch { return {} }
166
+ }
167
+
168
+ function resolveBash() {
169
+ if (fs.existsSync('/bin/bash')) return '/bin/bash'
170
+ if (fs.existsSync('/usr/bin/bash')) return '/usr/bin/bash'
171
+ return 'bash'
172
+ }
173
+
174
+ async function runHookCommand(shell, commandString, stdinObj, cwd, runtimeDbPath, timeoutMs) {
175
+ const payload = JSON.stringify(stdinObj) + '\n'
176
+ if (!fs.existsSync(cwd)) return { exitCode: null, stdout: '', stderr: `hook cwd does not exist: ${cwd}` }
177
+
178
+ const env = { ...process.env, AGENT_SDLC_DB: runtimeDbPath, SDLC_HOOK: '1', CLAUDE_PROJECT_DIR: cwd }
179
+ if (!env.LANG && !env.LC_ALL) env.LANG = 'C.UTF-8'
180
+
181
+ let child
182
+ try {
183
+ if (shell === 'bash') child = spawn(resolveBash(), ['-lc', commandString], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
184
+ else if (shell === 'sh') child = spawn('/bin/sh', ['-c', commandString], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
185
+ else child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-Command', commandString], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
186
+ } catch (e) {
187
+ return { exitCode: null, stdout: '', stderr: String(e?.message || e) }
188
+ }
189
+
190
+ const stdoutP = cappedRead(child.stdout, MAX_CAPTURE)
191
+ const stderrP = cappedRead(child.stderr, MAX_CAPTURE)
192
+ child.stdin.write(payload, 'utf8')
193
+ child.stdin.end()
194
+
195
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, timeoutMs)
196
+ let exitCode = null
197
+ try {
198
+ exitCode = await new Promise((resolve) => {
199
+ child.on('error', () => resolve(null))
200
+ child.on('close', c => resolve(c))
201
+ })
202
+ } finally { clearTimeout(timer) }
203
+
204
+ return { exitCode, stdout: await stdoutP, stderr: await stderrP }
205
+ }
206
+
207
+ function cappedRead(stream, maxBytes) {
208
+ return new Promise(resolve => {
209
+ let total = 0; const chunks = []; let truncated = false
210
+ stream.on('data', buf => {
211
+ if (truncated) return
212
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
213
+ if (total + b.length <= maxBytes) { chunks.push(b); total += b.length }
214
+ else {
215
+ const rest = maxBytes - total
216
+ if (rest > 0) chunks.push(b.subarray(0, rest))
217
+ total = maxBytes; truncated = true
218
+ try { stream.destroy() } catch {}
219
+ }
220
+ })
221
+ stream.on('end', () => {
222
+ let s = Buffer.concat(chunks).toString('utf8')
223
+ if (truncated) s += TRUNC_MARK
224
+ resolve(s)
225
+ })
226
+ stream.on('error', () => resolve(Buffer.concat(chunks).toString('utf8') + TRUNC_MARK))
227
+ })
228
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /** Search for Ona.md in cwd and parent directories. Returns content or empty string. */
5
+ export function loadInstructions(cwd) {
6
+ const names = ['Ona.md', '.ona/Ona.md', 'ONA.md', '.ona/ONA.md']
7
+ let dir = cwd
8
+ const visited = new Set()
9
+ while (dir && !visited.has(dir)) {
10
+ visited.add(dir)
11
+ for (const name of names) {
12
+ const full = path.join(dir, name)
13
+ if (fs.existsSync(full)) {
14
+ try { return { content: fs.readFileSync(full, 'utf8'), path: full } }
15
+ catch { /* unreadable */ }
16
+ }
17
+ }
18
+ const parent = path.dirname(dir)
19
+ if (parent === dir) break
20
+ dir = parent
21
+ }
22
+ return { content: '', path: null }
23
+ }