@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
package/lib/store.mjs
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import Database from 'better-sqlite3'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const SCHEMA_PATH = path.resolve(__dirname, '..', 'schema.sql')
|
|
8
|
+
|
|
9
|
+
/** Current schema version after Jev-Mem tables/columns. */
|
|
10
|
+
export const SCHEMA_VERSION = 2
|
|
11
|
+
|
|
12
|
+
const byPath = new Map()
|
|
13
|
+
|
|
14
|
+
const MEMORY_JEV_COLUMNS = [
|
|
15
|
+
['session_id', 'TEXT'],
|
|
16
|
+
['conversation_id', 'TEXT'],
|
|
17
|
+
['provenance', 'TEXT'],
|
|
18
|
+
['entities_json', 'TEXT'],
|
|
19
|
+
['t_episodic', 'REAL DEFAULT 0'],
|
|
20
|
+
['t_semantic', 'REAL DEFAULT 0'],
|
|
21
|
+
['t_procedural', 'REAL DEFAULT 0'],
|
|
22
|
+
['t_preference', 'REAL DEFAULT 0'],
|
|
23
|
+
['source_entry_id', 'INTEGER'],
|
|
24
|
+
['observation_ts', 'INTEGER'],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function tableExists(db, name) {
|
|
28
|
+
const row = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name)
|
|
29
|
+
return Boolean(row)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function columnNames(db, table) {
|
|
33
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Apply incremental migrations for existing DBs that already have schema_version < SCHEMA_VERSION. */
|
|
37
|
+
export function migrateStore(db) {
|
|
38
|
+
const row = db.prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`).get()
|
|
39
|
+
let version = row ? Number(row.value) : 0
|
|
40
|
+
if (!Number.isFinite(version)) version = 0
|
|
41
|
+
|
|
42
|
+
if (version < 2) {
|
|
43
|
+
// Extend memories with Jev-Mem node fields (CREATE IF NOT EXISTS already ran from schema.sql
|
|
44
|
+
// for fresh DBs; ALTER covers DBs created under v1).
|
|
45
|
+
if (tableExists(db, 'memories')) {
|
|
46
|
+
const cols = new Set(columnNames(db, 'memories'))
|
|
47
|
+
for (const [name, decl] of MEMORY_JEV_COLUMNS) {
|
|
48
|
+
if (!cols.has(name)) {
|
|
49
|
+
db.exec(`ALTER TABLE memories ADD COLUMN ${name} ${decl}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS memory_edges (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
src_id TEXT NOT NULL,
|
|
58
|
+
dst_id TEXT NOT NULL,
|
|
59
|
+
relation TEXT NOT NULL CHECK (relation IN ('semantic', 'temporal', 'causal', 'entity')),
|
|
60
|
+
weight REAL NOT NULL DEFAULT 0,
|
|
61
|
+
meta_json TEXT,
|
|
62
|
+
created_at INTEGER NOT NULL,
|
|
63
|
+
UNIQUE(src_id, dst_id, relation)
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_memory_edges_src ON memory_edges(src_id, relation);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_memory_edges_dst ON memory_edges(dst_id, relation);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, observation_ts);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS laya_jev_state (
|
|
70
|
+
session_id TEXT PRIMARY KEY,
|
|
71
|
+
task_status TEXT NOT NULL DEFAULT 'investigating'
|
|
72
|
+
CHECK (task_status IN (
|
|
73
|
+
'investigating', 'modifying_code', 'awaiting_user',
|
|
74
|
+
'verifying', 'blocked', 'idle'
|
|
75
|
+
)),
|
|
76
|
+
file_target TEXT,
|
|
77
|
+
escalation_risk INTEGER NOT NULL DEFAULT 0 CHECK (escalation_risk IN (0, 1)),
|
|
78
|
+
confidence_score REAL NOT NULL DEFAULT 0,
|
|
79
|
+
state_json TEXT,
|
|
80
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
81
|
+
);
|
|
82
|
+
`)
|
|
83
|
+
|
|
84
|
+
db.prepare(
|
|
85
|
+
`INSERT INTO schema_meta(key,value) VALUES ('schema_version', ?)
|
|
86
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
87
|
+
).run(String(SCHEMA_VERSION))
|
|
88
|
+
version = SCHEMA_VERSION
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return version
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** §4.8 — single writer connection per DB path; pragmas on open. */
|
|
95
|
+
export function openStore(dbPath) {
|
|
96
|
+
const abs = path.resolve(dbPath)
|
|
97
|
+
if (byPath.has(abs)) return byPath.get(abs)
|
|
98
|
+
const dir = path.dirname(abs)
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
100
|
+
const db = new Database(abs)
|
|
101
|
+
db.pragma('foreign_keys = ON')
|
|
102
|
+
db.pragma('journal_mode = WAL')
|
|
103
|
+
db.pragma('busy_timeout = 30000')
|
|
104
|
+
const ddl = fs.readFileSync(SCHEMA_PATH, 'utf8')
|
|
105
|
+
db.exec(ddl)
|
|
106
|
+
const row = db.prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`).get()
|
|
107
|
+
if (!row) {
|
|
108
|
+
db.prepare(`INSERT INTO schema_meta(key,value) VALUES ('schema_version','1')`).run()
|
|
109
|
+
}
|
|
110
|
+
migrateStore(db)
|
|
111
|
+
byPath.set(abs, db)
|
|
112
|
+
return db
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drop a cached connection (tests). */
|
|
116
|
+
export function closeStore(dbPath) {
|
|
117
|
+
const abs = path.resolve(dbPath)
|
|
118
|
+
const db = byPath.get(abs)
|
|
119
|
+
if (db) {
|
|
120
|
+
db.close()
|
|
121
|
+
byPath.delete(abs)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Run fn inside a single immediate transaction (writer serialization). */
|
|
126
|
+
export function withTransaction(db, fn) {
|
|
127
|
+
return db.transaction(fn)()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TASK_STATUSES = new Set([
|
|
131
|
+
'investigating', 'modifying_code', 'awaiting_user', 'verifying', 'blocked', 'idle',
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
/** Idempotent upsert of System-1 canonical session state. */
|
|
135
|
+
export function upsertLayaJevState(db, sessionId, state) {
|
|
136
|
+
const taskStatus = TASK_STATUSES.has(state.task_status) ? state.task_status : 'investigating'
|
|
137
|
+
const escalation = state.escalation_risk ? 1 : 0
|
|
138
|
+
const confidence = Number.isFinite(state.confidence_score) ? state.confidence_score : 0
|
|
139
|
+
const stateJson = state.state_json != null
|
|
140
|
+
? (typeof state.state_json === 'string' ? state.state_json : JSON.stringify(state.state_json))
|
|
141
|
+
: JSON.stringify({
|
|
142
|
+
task_status: taskStatus,
|
|
143
|
+
file_target: state.file_target ?? null,
|
|
144
|
+
escalation_risk: Boolean(escalation),
|
|
145
|
+
confidence_score: confidence,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
db.prepare(`
|
|
149
|
+
INSERT INTO laya_jev_state(session_id, task_status, file_target, escalation_risk, confidence_score, state_json, updated_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
151
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
152
|
+
task_status = excluded.task_status,
|
|
153
|
+
file_target = excluded.file_target,
|
|
154
|
+
escalation_risk = excluded.escalation_risk,
|
|
155
|
+
confidence_score = excluded.confidence_score,
|
|
156
|
+
state_json = excluded.state_json,
|
|
157
|
+
updated_at = datetime('now')
|
|
158
|
+
`).run(
|
|
159
|
+
sessionId,
|
|
160
|
+
taskStatus,
|
|
161
|
+
state.file_target ?? null,
|
|
162
|
+
escalation,
|
|
163
|
+
confidence,
|
|
164
|
+
stateJson,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return getLayaJevState(db, sessionId)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getLayaJevState(db, sessionId) {
|
|
171
|
+
return db.prepare(`SELECT * FROM laya_jev_state WHERE session_id = ?`).get(sessionId) || null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Insert or replace a canonical memory node; always preserves content (no discard-on-write). */
|
|
175
|
+
export function upsertMemoryNode(db, node) {
|
|
176
|
+
const now = node.updated_at ?? Date.now()
|
|
177
|
+
const created = node.created_at ?? now
|
|
178
|
+
db.prepare(`
|
|
179
|
+
INSERT INTO memories(
|
|
180
|
+
id, type, title, content, keywords, anticipated_queries, concept_tags, project_scope,
|
|
181
|
+
correction_count, created_at, updated_at, last_accessed, access_count, attention_score,
|
|
182
|
+
session_id, conversation_id, provenance, entities_json,
|
|
183
|
+
t_episodic, t_semantic, t_procedural, t_preference, source_entry_id, observation_ts
|
|
184
|
+
) VALUES (
|
|
185
|
+
@id, @type, @title, @content, @keywords, @anticipated_queries, @concept_tags, @project_scope,
|
|
186
|
+
@correction_count, @created_at, @updated_at, @last_accessed, @access_count, @attention_score,
|
|
187
|
+
@session_id, @conversation_id, @provenance, @entities_json,
|
|
188
|
+
@t_episodic, @t_semantic, @t_procedural, @t_preference, @source_entry_id, @observation_ts
|
|
189
|
+
)
|
|
190
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
191
|
+
title = excluded.title,
|
|
192
|
+
content = excluded.content,
|
|
193
|
+
keywords = excluded.keywords,
|
|
194
|
+
concept_tags = excluded.concept_tags,
|
|
195
|
+
updated_at = excluded.updated_at,
|
|
196
|
+
entities_json = excluded.entities_json,
|
|
197
|
+
t_episodic = excluded.t_episodic,
|
|
198
|
+
t_semantic = excluded.t_semantic,
|
|
199
|
+
t_procedural = excluded.t_procedural,
|
|
200
|
+
t_preference = excluded.t_preference,
|
|
201
|
+
attention_score = excluded.attention_score
|
|
202
|
+
`).run({
|
|
203
|
+
id: node.id,
|
|
204
|
+
type: node.type || 'jev_observation',
|
|
205
|
+
title: node.title || '',
|
|
206
|
+
content: node.content ?? '',
|
|
207
|
+
keywords: node.keywords ?? null,
|
|
208
|
+
anticipated_queries: node.anticipated_queries ?? null,
|
|
209
|
+
concept_tags: node.concept_tags ?? null,
|
|
210
|
+
project_scope: node.project_scope ?? null,
|
|
211
|
+
correction_count: node.correction_count ?? 1,
|
|
212
|
+
created_at: created,
|
|
213
|
+
updated_at: now,
|
|
214
|
+
last_accessed: node.last_accessed ?? null,
|
|
215
|
+
access_count: node.access_count ?? 0,
|
|
216
|
+
attention_score: node.attention_score ?? 0.5,
|
|
217
|
+
session_id: node.session_id ?? null,
|
|
218
|
+
conversation_id: node.conversation_id ?? null,
|
|
219
|
+
provenance: node.provenance ?? null,
|
|
220
|
+
entities_json: node.entities_json
|
|
221
|
+
? (typeof node.entities_json === 'string' ? node.entities_json : JSON.stringify(node.entities_json))
|
|
222
|
+
: null,
|
|
223
|
+
t_episodic: node.t_episodic ?? 0,
|
|
224
|
+
t_semantic: node.t_semantic ?? 0,
|
|
225
|
+
t_procedural: node.t_procedural ?? 0,
|
|
226
|
+
t_preference: node.t_preference ?? 0,
|
|
227
|
+
source_entry_id: node.source_entry_id ?? null,
|
|
228
|
+
observation_ts: node.observation_ts ?? created,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Keep FTS in sync (delete+insert; FTS5 content tables are separate).
|
|
232
|
+
try {
|
|
233
|
+
db.prepare(`DELETE FROM memories_fts WHERE rowid = (SELECT rowid FROM memories WHERE id = ?)`).run(node.id)
|
|
234
|
+
} catch { /* fresh or missing fts row */ }
|
|
235
|
+
try {
|
|
236
|
+
db.prepare(`
|
|
237
|
+
INSERT INTO memories_fts(rowid, title, content, keywords, anticipated_queries)
|
|
238
|
+
SELECT rowid, title, content, keywords, anticipated_queries FROM memories WHERE id = ?
|
|
239
|
+
`).run(node.id)
|
|
240
|
+
} catch { /* FTS optional for unit tests without virtual table quirks */ }
|
|
241
|
+
|
|
242
|
+
return db.prepare(`SELECT * FROM memories WHERE id = ?`).get(node.id)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Insert/update a typed relation edge when weight ≥ threshold (caller decides). */
|
|
246
|
+
export function upsertMemoryEdge(db, { src_id, dst_id, relation, weight, meta_json = null }) {
|
|
247
|
+
const allowed = new Set(['semantic', 'temporal', 'causal', 'entity'])
|
|
248
|
+
if (!allowed.has(relation)) throw new Error(`invalid relation: ${relation}`)
|
|
249
|
+
const now = Date.now()
|
|
250
|
+
const meta = meta_json == null
|
|
251
|
+
? null
|
|
252
|
+
: (typeof meta_json === 'string' ? meta_json : JSON.stringify(meta_json))
|
|
253
|
+
db.prepare(`
|
|
254
|
+
INSERT INTO memory_edges(src_id, dst_id, relation, weight, meta_json, created_at)
|
|
255
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
256
|
+
ON CONFLICT(src_id, dst_id, relation) DO UPDATE SET
|
|
257
|
+
weight = excluded.weight,
|
|
258
|
+
meta_json = excluded.meta_json
|
|
259
|
+
`).run(src_id, dst_id, relation, weight, meta, now)
|
|
260
|
+
return db.prepare(
|
|
261
|
+
`SELECT * FROM memory_edges WHERE src_id = ? AND dst_id = ? AND relation = ?`,
|
|
262
|
+
).get(src_id, dst_id, relation)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function listMemoryEdges(db, nodeId, relation = null) {
|
|
266
|
+
if (relation) {
|
|
267
|
+
return db.prepare(`
|
|
268
|
+
SELECT * FROM memory_edges
|
|
269
|
+
WHERE (src_id = ? OR dst_id = ?) AND relation = ?
|
|
270
|
+
ORDER BY weight DESC
|
|
271
|
+
`).all(nodeId, nodeId, relation)
|
|
272
|
+
}
|
|
273
|
+
return db.prepare(`
|
|
274
|
+
SELECT * FROM memory_edges
|
|
275
|
+
WHERE src_id = ? OR dst_id = ?
|
|
276
|
+
ORDER BY weight DESC
|
|
277
|
+
`).all(nodeId, nodeId)
|
|
278
|
+
}
|
package/lib/team.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { onaHome } from './paths.mjs'
|
|
4
|
+
|
|
5
|
+
const TEAMS_DIR = path.join(onaHome(), 'teams')
|
|
6
|
+
|
|
7
|
+
function teamFilePath(name) {
|
|
8
|
+
return path.join(TEAMS_DIR, `${name.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createTeam(name, leadSessionId) {
|
|
12
|
+
fs.mkdirSync(TEAMS_DIR, { recursive: true })
|
|
13
|
+
const team = {
|
|
14
|
+
name,
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
leadSessionId,
|
|
17
|
+
members: [],
|
|
18
|
+
}
|
|
19
|
+
fs.writeFileSync(teamFilePath(name), JSON.stringify(team, null, 2), 'utf8')
|
|
20
|
+
return team
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getTeam(name) {
|
|
24
|
+
const p = teamFilePath(name)
|
|
25
|
+
if (!fs.existsSync(p)) return null
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listTeams() {
|
|
30
|
+
if (!fs.existsSync(TEAMS_DIR)) return []
|
|
31
|
+
return fs.readdirSync(TEAMS_DIR)
|
|
32
|
+
.filter(f => f.endsWith('.json'))
|
|
33
|
+
.map(f => {
|
|
34
|
+
try { return JSON.parse(fs.readFileSync(path.join(TEAMS_DIR, f), 'utf8')) }
|
|
35
|
+
catch { return null }
|
|
36
|
+
})
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addTeammate(teamName, memberName, sessionId) {
|
|
41
|
+
const team = getTeam(teamName)
|
|
42
|
+
if (!team) return null
|
|
43
|
+
const agentId = `${memberName}@${teamName}`
|
|
44
|
+
const member = {
|
|
45
|
+
agentId,
|
|
46
|
+
name: memberName,
|
|
47
|
+
isActive: true,
|
|
48
|
+
sessionId,
|
|
49
|
+
joinedAt: Date.now(),
|
|
50
|
+
}
|
|
51
|
+
team.members.push(member)
|
|
52
|
+
fs.writeFileSync(teamFilePath(teamName), JSON.stringify(team, null, 2), 'utf8')
|
|
53
|
+
return member
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setTeammateIdle(teamName, agentId) {
|
|
57
|
+
const team = getTeam(teamName)
|
|
58
|
+
if (!team) return false
|
|
59
|
+
const member = team.members.find(m => m.agentId === agentId)
|
|
60
|
+
if (!member) return false
|
|
61
|
+
member.isActive = false
|
|
62
|
+
fs.writeFileSync(teamFilePath(teamName), JSON.stringify(team, null, 2), 'utf8')
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deleteTeam(name) {
|
|
67
|
+
const p = teamFilePath(name)
|
|
68
|
+
try { fs.unlinkSync(p); return true } catch { return false }
|
|
69
|
+
}
|
package/lib/testgen.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { withTransaction } from './store.mjs'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
|
|
9
|
+
const TAG_RE = /\[template:\s*(\w+)\]/
|
|
10
|
+
const GENERATED_DIR = path.resolve(__dirname, '..', '.generated-tests')
|
|
11
|
+
|
|
12
|
+
/** Extract success criteria with their template tags from plan content. */
|
|
13
|
+
export function extractCriteriaWithTags(planContent) {
|
|
14
|
+
const lines = planContent.split('\n')
|
|
15
|
+
let inCriteria = false
|
|
16
|
+
const criteria = []
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (/^##\s*Success\s*Criteria/i.test(line)) { inCriteria = true; continue }
|
|
19
|
+
if (inCriteria && /^##\s/.test(line)) break
|
|
20
|
+
if (inCriteria && line.trim().match(/^\d+\.|^-/)) {
|
|
21
|
+
const m = line.match(TAG_RE)
|
|
22
|
+
if (m) {
|
|
23
|
+
const text = line.trim().replace(TAG_RE, '').replace(/^\d+\.\s*|^-\s*/, '').trim()
|
|
24
|
+
criteria.push({ text, template: m[1] })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return criteria
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate and run tests from plan criteria using templates.
|
|
33
|
+
* For now, generates simple structural tests from the template + criterion.
|
|
34
|
+
* The LLM-based slot filling is a future enhancement — this version creates
|
|
35
|
+
* runnable tests by pattern-matching on the criterion text.
|
|
36
|
+
*/
|
|
37
|
+
export async function generateAndRunTests(db, rt, planContent, log) {
|
|
38
|
+
const criteria = extractCriteriaWithTags(planContent)
|
|
39
|
+
if (!criteria.length) { log('No tagged criteria found in plan.'); return [] }
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(GENERATED_DIR, { recursive: true })
|
|
42
|
+
const results = []
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < criteria.length; i++) {
|
|
45
|
+
const { text, template } = criteria[i]
|
|
46
|
+
const templatePath = path.join(TEMPLATES_DIR, `test_${template}.sh`)
|
|
47
|
+
if (!fs.existsSync(templatePath)) {
|
|
48
|
+
log(` ✗ No template file for: ${template}`)
|
|
49
|
+
results.push({ criterion: text, passed: false, output: `Missing template: ${template}` })
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const templateContent = fs.readFileSync(templatePath, 'utf8')
|
|
54
|
+
const testFile = path.join(GENERATED_DIR, `test_${i}_${template}.sh`)
|
|
55
|
+
|
|
56
|
+
// Fill template slots with criterion-derived content
|
|
57
|
+
const filled = fillTemplate(templateContent, text, template, rt)
|
|
58
|
+
fs.writeFileSync(testFile, filled, { mode: 0o755 })
|
|
59
|
+
|
|
60
|
+
// Validate
|
|
61
|
+
const validate = spawnSync('bash', [path.join(TEMPLATES_DIR, 'validate_test.sh'), testFile], { encoding: 'utf8', timeout: 10_000 })
|
|
62
|
+
if (validate.status !== 0) {
|
|
63
|
+
log(` ✗ Validation failed: ${text.slice(0, 60)}`)
|
|
64
|
+
results.push({ criterion: text, passed: false, output: validate.stderr || validate.stdout })
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Run
|
|
69
|
+
const dbPath = rt.runtimeDbPath || process.env.AGENT_SDLC_DB
|
|
70
|
+
const run = spawnSync('bash', [testFile], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
timeout: 60_000,
|
|
73
|
+
env: { ...process.env, AGENT_SDLC_DB: dbPath },
|
|
74
|
+
})
|
|
75
|
+
const passed = run.status === 0
|
|
76
|
+
const output = ((run.stdout || '') + (run.stderr || '')).trim()
|
|
77
|
+
results.push({ criterion: text, passed, output })
|
|
78
|
+
|
|
79
|
+
// Persist result
|
|
80
|
+
withTransaction(db, () => {
|
|
81
|
+
db.prepare(`INSERT INTO events(conversation_id, session_id, event_type, detail) VALUES (?,?,?,?)`).run(
|
|
82
|
+
rt.conversationId, rt.sessionId, 'test_result',
|
|
83
|
+
JSON.stringify({ criterion: text, template, passed, output: output.slice(0, 2000) })
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fill template slots based on criterion text and template type.
|
|
93
|
+
* This is a structural fill — it creates a runnable test that exercises
|
|
94
|
+
* the product through its CLI. For full LLM-based slot filling, the
|
|
95
|
+
* /test command would call the model with the template + criterion.
|
|
96
|
+
*/
|
|
97
|
+
function fillTemplate(templateContent, criterion, templateType, rt) {
|
|
98
|
+
const dbPath = rt.runtimeDbPath || process.env.AGENT_SDLC_DB || '/tmp/sdlc_test_$$.db'
|
|
99
|
+
const onaPath = path.resolve(__dirname, '..', 'bin', 'agent.mjs')
|
|
100
|
+
const onaCmd = `node ${onaPath}`
|
|
101
|
+
|
|
102
|
+
let filled = templateContent
|
|
103
|
+
.replace(/^# PLAN_REQ:.*$/m, `# PLAN_REQ: ${criterion}`)
|
|
104
|
+
|
|
105
|
+
// Remove comment markers from the example code in templates so they're executable
|
|
106
|
+
// Replace placeholder sections with actual test code based on template type
|
|
107
|
+
if (templateType === 'tool_contract') {
|
|
108
|
+
filled = fillToolContractTemplate(filled, criterion, onaCmd, dbPath)
|
|
109
|
+
} else if (templateType === 'phase_transition') {
|
|
110
|
+
filled = fillPhaseTransitionTemplate(filled, criterion, onaCmd, dbPath)
|
|
111
|
+
} else if (templateType === 'hook_contract') {
|
|
112
|
+
filled = fillHookContractTemplate(filled, criterion, onaCmd, dbPath)
|
|
113
|
+
} else if (templateType === 'e2e_workflow') {
|
|
114
|
+
filled = fillE2ETemplate(filled, criterion, onaCmd, dbPath)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return filled
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fillToolContractTemplate(template, criterion, onaCmd, dbPath) {
|
|
121
|
+
return template
|
|
122
|
+
.replace(/# ══ SETUP ══\n[\s\S]*?(?=# ══ EXERCISE ══)/, `# ══ SETUP ══\necho "test content" > /tmp/sdlc_test_file.txt\n\n`)
|
|
123
|
+
.replace(/# ══ EXERCISE ══\n[\s\S]*?(?=# ══ ASSERT ══)/, `# ══ EXERCISE ══\n${onaCmd} --eval '{"tool":"Read","input":{"file_path":"/tmp/sdlc_test_file.txt"}}'\n\n`)
|
|
124
|
+
.replace(/# ══ ASSERT ══[\s\S]*$/, `# ══ ASSERT ══\nRESULT=$(sqlite3 "$AGENT_SDLC_DB" "SELECT payload_json FROM transcript_entries WHERE entry_type='tool_result' ORDER BY sequence DESC LIMIT 1")\necho "$RESULT" | grep '"is_error":false' || { echo "FAIL: ${criterion}"; exit 1; }\necho "PASS: ${criterion}"\n`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fillPhaseTransitionTemplate(template, criterion, onaCmd, dbPath) {
|
|
128
|
+
return template
|
|
129
|
+
.replace(/# ══ SETUP ══\n[\s\S]*?(?=# ══ EXERCISE ══)/, `# ══ SETUP ══\n${onaCmd} --init-db\nsqlite3 "$AGENT_SDLC_DB" "INSERT INTO conversations(id, project_dir, phase) VALUES ('test-conv', '/tmp', 'idle')"\n\n`)
|
|
130
|
+
.replace(/# ══ EXERCISE ══\n[\s\S]*?(?=# ══ ASSERT ══)/, `# ══ EXERCISE ══\n${onaCmd} --transition planning --conversation test-conv 2>&1 || true\n\n`)
|
|
131
|
+
.replace(/# ══ ASSERT ══[\s\S]*$/, `# ══ ASSERT ══\nPHASE=$(sqlite3 "$AGENT_SDLC_DB" "SELECT phase FROM conversations WHERE id='test-conv'")\ntest "$PHASE" = "planning" || { echo "FAIL: ${criterion}"; exit 1; }\necho "PASS: ${criterion}"\n`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fillHookContractTemplate(template, criterion, onaCmd, dbPath) {
|
|
135
|
+
return template
|
|
136
|
+
.replace(/# ══ SETUP ══\n[\s\S]*?(?=# ══ EXERCISE ══)/, `# ══ SETUP ══\n${onaCmd} --init-db\nsqlite3 "$AGENT_SDLC_DB" "INSERT OR REPLACE INTO settings_snapshot(scope, json, updated_at) VALUES ('effective', '{\\\"hooks\\\":[{\\\"hook_event_name\\\":\\\"PreToolUse\\\",\\\"matcher\\\":\\\"Bash\\\",\\\"command\\\":\\\"exit 2\\\"}]}', datetime('now'))"\n\n`)
|
|
137
|
+
.replace(/# ══ EXERCISE ══\n[\s\S]*?(?=# ══ ASSERT ══)/, `# ══ EXERCISE ══\n${onaCmd} --eval '{"tool":"Bash","input":{"command":"echo hi"}}' || true\n\n`)
|
|
138
|
+
.replace(/# ══ ASSERT ══[\s\S]*$/, `# ══ ASSERT ══\nROW=$(sqlite3 "$AGENT_SDLC_DB" "SELECT exit_code FROM hook_invocations WHERE hook_event='PreToolUse' ORDER BY id DESC LIMIT 1")\ntest "$ROW" = "2" || { echo "FAIL: ${criterion}"; exit 1; }\necho "PASS: ${criterion}"\n`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fillE2ETemplate(template, criterion, onaCmd, dbPath) {
|
|
142
|
+
return template
|
|
143
|
+
.replace(/# ══ SETUP ══\n[\s\S]*?(?=# ══ EXERCISE ══)/, `# ══ SETUP ══\n${onaCmd} --init-db\n\n`)
|
|
144
|
+
.replace(/# ══ EXERCISE ══\n[\s\S]*?(?=# ══ ASSERT ══)/, `# ══ EXERCISE ══\n# E2E workflow test — verify DB was initialized\n${onaCmd} --init-db\n\n`)
|
|
145
|
+
.replace(/# ══ ASSERT ══[\s\S]*$/, `# ══ ASSERT ══\nTABLES=$(sqlite3 "$AGENT_SDLC_DB" "SELECT count(*) FROM sqlite_master WHERE type='table'")\ntest "$TABLES" -ge 13 || { echo "FAIL: ${criterion}"; exit 1; }\necho "PASS: ${criterion}"\n`)
|
|
146
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "AuditCodeIntegrity",
|
|
3
|
+
"description": "Run the code integrity auditor (lifecycle teardown parity, mock-theater test detection, naming invariants, swallowed exceptions) against a file or directory. Returns structured JSON findings.",
|
|
4
|
+
"input_schema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"target": { "type": "string", "description": "File or directory to audit (default: cwd)" },
|
|
8
|
+
"timeout": { "type": "integer", "description": "Timeout in ms (max 600000)" }
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"phases": {
|
|
12
|
+
"explore_only": true,
|
|
13
|
+
"planning_blocked": false,
|
|
14
|
+
"mandatory_in": ["test"]
|
|
15
|
+
},
|
|
16
|
+
"prompt": {
|
|
17
|
+
"mandatory_instruction": "Run AuditCodeIntegrity on the workspace to check for lifecycle teardown parity, mock-theater tests, naming invariants, and swallowed exceptions.",
|
|
18
|
+
"available_hint": "Audits code integrity (lifecycle parity, mock-theater tests, naming invariants, error handling)"
|
|
19
|
+
},
|
|
20
|
+
"execution": {
|
|
21
|
+
"runtime": "python3",
|
|
22
|
+
"script": "lib/scripts/audit-code-integrity.py",
|
|
23
|
+
"args": [
|
|
24
|
+
"--target", "{{target}}",
|
|
25
|
+
"--format", "json"
|
|
26
|
+
],
|
|
27
|
+
"default_timeout": 120000,
|
|
28
|
+
"max_timeout": 600000
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "JsSafetyChecker",
|
|
3
|
+
"description": "Run the JavaScript safety checker (AST-based duplicate function, duplicate HTML ID, syntax errors) against a file or directory. Returns findings.",
|
|
4
|
+
"input_schema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"target": { "type": "string", "description": "Target directory or file to scan (default: cwd)" },
|
|
8
|
+
"timeout": { "type": "integer", "description": "Timeout in ms (max 600000)" }
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"phases": {
|
|
12
|
+
"explore_only": true,
|
|
13
|
+
"planning_blocked": false,
|
|
14
|
+
"mandatory_in": ["test"]
|
|
15
|
+
},
|
|
16
|
+
"prompt": {
|
|
17
|
+
"mandatory_instruction": "Run JsSafetyChecker on JavaScript and HTML files in the workspace to validate syntax, duplicate functions, and duplicate IDs.",
|
|
18
|
+
"available_hint": "Validates JS/HTML for syntax errors, duplicate functions, and duplicate element IDs"
|
|
19
|
+
},
|
|
20
|
+
"execution": {
|
|
21
|
+
"runtime": "node",
|
|
22
|
+
"script": "lib/scripts/js-safety-checker.cjs",
|
|
23
|
+
"args": [
|
|
24
|
+
"{{target}}"
|
|
25
|
+
],
|
|
26
|
+
"default_timeout": 120000,
|
|
27
|
+
"max_timeout": 600000
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "PythonSafetyChecker",
|
|
3
|
+
"description": "Run the Python safety checker (bandit security issues, ruff style/lint issues, AST mutable default arg checks) against Python files. Returns findings.",
|
|
4
|
+
"input_schema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"target": { "type": "string", "description": "Target directory or file to scan (default: cwd)" },
|
|
8
|
+
"timeout": { "type": "integer", "description": "Timeout in ms (max 600000)" }
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"phases": {
|
|
12
|
+
"explore_only": true,
|
|
13
|
+
"planning_blocked": false,
|
|
14
|
+
"mandatory_in": ["test"]
|
|
15
|
+
},
|
|
16
|
+
"prompt": {
|
|
17
|
+
"mandatory_instruction": "Run PythonSafetyChecker on Python files in the workspace to validate for security issues, lint errors, and unsafe patterns.",
|
|
18
|
+
"available_hint": "Validates Python files for security (bandit), lint (ruff), and unsafe patterns (eval, hardcoded credentials)"
|
|
19
|
+
},
|
|
20
|
+
"execution": {
|
|
21
|
+
"runtime": "python3",
|
|
22
|
+
"script": "lib/scripts/python-safety-checker.py",
|
|
23
|
+
"args": [
|
|
24
|
+
"{{target}}"
|
|
25
|
+
],
|
|
26
|
+
"default_timeout": 120000,
|
|
27
|
+
"max_timeout": 600000
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ShellSafetyChecker",
|
|
3
|
+
"description": "Run the shell safety checker (missing set -euo pipefail, background job silent failure risk, hardcoded credentials) against shell scripts. Returns findings.",
|
|
4
|
+
"input_schema": {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"target": { "type": "string", "description": "Target directory or file to scan (default: cwd)" },
|
|
8
|
+
"timeout": { "type": "integer", "description": "Timeout in ms (max 600000)" }
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"phases": {
|
|
12
|
+
"explore_only": true,
|
|
13
|
+
"planning_blocked": false,
|
|
14
|
+
"mandatory_in": ["test"]
|
|
15
|
+
},
|
|
16
|
+
"prompt": {
|
|
17
|
+
"mandatory_instruction": "Run ShellSafetyChecker on shell scripts in the workspace to validate strict mode, silent failure risks, and security issues.",
|
|
18
|
+
"available_hint": "Validates shell scripts for safety (strict mode, credentials, background job risks)"
|
|
19
|
+
},
|
|
20
|
+
"execution": {
|
|
21
|
+
"runtime": "bash",
|
|
22
|
+
"script": "lib/scripts/shell-safety-checker.sh",
|
|
23
|
+
"args": [
|
|
24
|
+
"{{target}}"
|
|
25
|
+
],
|
|
26
|
+
"default_timeout": 120000,
|
|
27
|
+
"max_timeout": 600000
|
|
28
|
+
}
|
|
29
|
+
}
|