@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.
- package/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +444 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- 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
|
+
}
|