@treenity/mods 3.0.2 → 3.0.3

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.
@@ -0,0 +1,2 @@
1
+ import './types';
2
+ import './view';
@@ -0,0 +1,492 @@
1
+ // Guardian — extensible tool policy for AI agents.
2
+ // Cascade: agent ai.policy → global ai.policy (/agents/guardian) → hardcoded fallback.
3
+ // Escalation: creates ai.approval node → Promise resolution via pendingPermissions.
4
+
5
+ import { pendingPermissions, type PermissionMeta, type PermissionRule, resolvePermission } from '#metatron/permissions';
6
+ import { MetatronConfig } from '#metatron/types';
7
+ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';
8
+ import { createNode, getComponent } from '@treenity/core';
9
+ import { registerType, setComponent } from '@treenity/core/comp';
10
+ import { matchesAny } from '@treenity/core/glob';
11
+ import type { Tree } from '@treenity/core/tree';
12
+ import { AiPolicy } from './types';
13
+
14
+ // ── Approval type — lives in /agents/approvals/{id} ──
15
+
16
+ export class AiApproval {
17
+ agentPath = '';
18
+ agentRole = '';
19
+ tool = '';
20
+ /** @format textarea */
21
+ input = '';
22
+ status: 'pending' | 'approved' | 'denied' = 'pending';
23
+ reason = '';
24
+ createdAt = 0;
25
+ resolvedAt = 0;
26
+
27
+ /** @description Approve this tool usage */
28
+ approve(data?: {
29
+ /** Remember this decision for future calls */
30
+ remember?: 'agent' | 'global'
31
+ }) {
32
+ if (this.status !== 'pending') throw new Error('already resolved');
33
+ this.status = 'approved';
34
+ this.resolvedAt = Date.now();
35
+ const id = (this as any).$path?.split('/').pop();
36
+ if (id) resolvePermission(id, true, {
37
+ tool: this.tool,
38
+ input: this.input,
39
+ agentPath: this.agentPath,
40
+ scope: data?.remember,
41
+ });
42
+ }
43
+
44
+ /** @description Deny this tool usage */
45
+ deny(data?: {
46
+ /** Remember this decision for future calls */
47
+ remember?: 'agent' | 'global'
48
+ }) {
49
+ if (this.status !== 'pending') throw new Error('already resolved');
50
+ this.status = 'denied';
51
+ this.resolvedAt = Date.now();
52
+ const id = (this as any).$path?.split('/').pop();
53
+ if (id) resolvePermission(id, false, {
54
+ tool: this.tool,
55
+ input: this.input,
56
+ agentPath: this.agentPath,
57
+ scope: data?.remember,
58
+ });
59
+ }
60
+ }
61
+
62
+ registerType('ai.approval', AiApproval);
63
+
64
+ // ── ToolPolicy shape (runtime, with RegExp) ──
65
+
66
+ export type ToolPolicy = {
67
+ allow: string[];
68
+ deny: string[];
69
+ escalate: string[];
70
+ };
71
+
72
+ // ── Minimal fallback — read-only, everything else escalated ──
73
+
74
+ const FALLBACK_POLICY: ToolPolicy = {
75
+ allow: [
76
+ 'mcp__treenity__get_node', 'mcp__treenity__list_children',
77
+ 'mcp__treenity__catalog', 'mcp__treenity__describe_type',
78
+ 'mcp__treenity__search_types',
79
+ ],
80
+ deny: [],
81
+ escalate: ['mcp__treenity__set_node', 'mcp__treenity__execute', 'mcp__treenity__remove_node'],
82
+ };
83
+
84
+ // ── Convert ai.policy node data → runtime ToolPolicy ──
85
+
86
+ function policyFromNode(p: AiPolicy): ToolPolicy {
87
+ return {
88
+ allow: [...p.allow],
89
+ deny: [...p.deny],
90
+ escalate: [...p.escalate],
91
+ };
92
+ }
93
+
94
+ /** Merge two policies: b overrides a (more specific wins) */
95
+ function mergePolicies(base: ToolPolicy, override: ToolPolicy): ToolPolicy {
96
+ const allow = [...new Set([...base.allow, ...override.allow])];
97
+ const deny = [...new Set([...base.deny, ...override.deny])];
98
+ const escalate = [...new Set([...base.escalate, ...override.escalate])];
99
+ // Remove from escalate/deny if explicitly allowed in override
100
+ const cleanEscalate = escalate.filter(t => !override.allow.includes(t));
101
+ const cleanDeny = deny.filter(t => !override.allow.includes(t));
102
+ return {
103
+ allow,
104
+ deny: cleanDeny,
105
+ escalate: cleanEscalate,
106
+ };
107
+ }
108
+
109
+ const GUARDIAN_PATH = '/agents/guardian';
110
+
111
+ /** Resolve policy cascade: agent → global → fallback */
112
+ async function resolvePolicy(store: Tree, agentPath: string): Promise<ToolPolicy> {
113
+ const hardcoded = FALLBACK_POLICY;
114
+
115
+ // Global policy from /agents/guardian
116
+ let base = hardcoded;
117
+ try {
118
+ const guardianNode = await store.get(GUARDIAN_PATH);
119
+ if (guardianNode) {
120
+ const globalPolicy = getComponent(guardianNode, AiPolicy);
121
+ if (globalPolicy && (globalPolicy.allow.length || globalPolicy.deny.length || globalPolicy.escalate.length)) {
122
+ base = mergePolicies(hardcoded, policyFromNode(globalPolicy));
123
+ }
124
+ }
125
+ } catch { /* no guardian node yet */ }
126
+
127
+ // Agent-level policy
128
+ try {
129
+ const agentNode = await store.get(agentPath);
130
+ if (agentNode) {
131
+ const agentPolicy = getComponent(agentNode, AiPolicy);
132
+ if (agentPolicy && (agentPolicy.allow.length || agentPolicy.deny.length || agentPolicy.escalate.length)) {
133
+ return mergePolicies(base, policyFromNode(agentPolicy));
134
+ }
135
+ }
136
+ } catch { /* agent has no policy component */ }
137
+
138
+ return base;
139
+ }
140
+
141
+ // ── Build metatron-compatible PermissionRule[] ──
142
+
143
+ /** Static fallback rules for SDK upfront hints. Real enforcement in canUseTool. */
144
+ export function buildPermissionRules(_role: string): PermissionRule[] {
145
+ const policy = FALLBACK_POLICY;
146
+ const rules: PermissionRule[] = [];
147
+
148
+ for (const tool of policy.deny) rules.push({ tool, pathPattern: '', policy: 'deny' });
149
+ for (const tool of policy.escalate) rules.push({ tool, pathPattern: '', policy: 'ask-once' });
150
+ for (const tool of policy.allow) rules.push({ tool, pathPattern: '', policy: 'allow' });
151
+
152
+ return rules;
153
+ }
154
+
155
+
156
+ // ── Always-deny bash patterns ──
157
+
158
+ const DANGEROUS_BASH = [
159
+ /rm\s+-rf/, /push\s+--force/, /reset\s+--hard/, /--no-verify/,
160
+ /curl\b.*\|\s*(?:ba)?sh/, /wget\b.*\|\s*(?:ba)?sh/, // pipe-to-shell
161
+ /\beval\s+/, // arbitrary eval
162
+ /chmod\s+777/, /chmod\s+\+s/, // permission escalation
163
+ /\bdd\s+.*of=\/dev\//, /\bmkfs\b/, // disk destruction
164
+ ];
165
+
166
+ // ── Split bash command by operators, respecting quotes ──
167
+
168
+ export function splitBashParts(cmd: string): string[] {
169
+ const parts: string[] = [];
170
+ let cur = '';
171
+ let inSingle = false;
172
+ let inDouble = false;
173
+ let escaped = false;
174
+
175
+ for (let i = 0; i < cmd.length; i++) {
176
+ const ch = cmd[i];
177
+
178
+ if (escaped) { cur += ch; escaped = false; continue; }
179
+ if (ch === '\\' && !inSingle) { cur += ch; escaped = true; continue; }
180
+ if (ch === "'" && !inDouble) { cur += ch; inSingle = !inSingle; continue; }
181
+ if (ch === '"' && !inSingle) { cur += ch; inDouble = !inDouble; continue; }
182
+
183
+ if (!inSingle && !inDouble) {
184
+ if (ch === '|' && cmd[i + 1] === '|') { parts.push(cur); cur = ''; i++; continue; }
185
+ if (ch === '|') { parts.push(cur); cur = ''; continue; }
186
+ if (ch === '&' && cmd[i + 1] === '&') { parts.push(cur); cur = ''; i++; continue; }
187
+ if (ch === ';') { parts.push(cur); cur = ''; continue; }
188
+ }
189
+
190
+ cur += ch;
191
+ }
192
+ if (cur) parts.push(cur);
193
+ return parts.map(p => p.trim()).filter(Boolean);
194
+ }
195
+
196
+ // ── Escalation via tree nodes + Promise resolution ──
197
+
198
+ const APPROVAL_TIMEOUT = 60 * 60 * 1000; // 1 hour
199
+
200
+ export async function requestApproval(
201
+ store: Tree,
202
+ opts: { agentPath: string; role: string; tool: string; input: string; reason: string },
203
+ ): Promise<boolean> {
204
+ const id = `a-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
205
+ const path = `/agents/approvals/${id}`;
206
+
207
+ await store.set(createNode(path, 'ai.approval', {
208
+ agentPath: opts.agentPath,
209
+ agentRole: opts.role,
210
+ tool: opts.tool,
211
+ input: opts.input.slice(0, 1000),
212
+ status: 'pending',
213
+ reason: opts.reason,
214
+ createdAt: Date.now(),
215
+ }));
216
+
217
+ console.log(`[guardian] escalation: ${opts.role} wants ${opts.tool} → ${path}`);
218
+
219
+ return new Promise<boolean>((resolve) => {
220
+ const timer = setTimeout(() => {
221
+ pendingPermissions.delete(id);
222
+ console.log(`[guardian] escalation timed out: ${path}`);
223
+ resolve(false);
224
+ }, APPROVAL_TIMEOUT);
225
+
226
+ pendingPermissions.set(id, async (allow: boolean, meta?: PermissionMeta) => {
227
+ clearTimeout(timer);
228
+
229
+ // "Remember" — persist rule to tree policy
230
+ if (meta?.scope && meta.tool) {
231
+ try {
232
+ await rememberRule(store, meta.tool, meta.input ?? '', allow, meta.agentPath ?? '', meta.scope);
233
+ } catch (err) {
234
+ console.error(`[guardian] failed to persist rule: ${err}`);
235
+ }
236
+ }
237
+
238
+ resolve(allow);
239
+ });
240
+ });
241
+ }
242
+
243
+ /** Write a persistent rule to agent or global policy (with OCC retry) */
244
+ async function rememberRule(store: Tree, tool: string, _input: string, allow: boolean, agentPath: string, scope: string) {
245
+ const targetPath = scope === 'agent' ? agentPath : GUARDIAN_PATH;
246
+ const MAX_RETRIES = 3;
247
+
248
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
249
+ const node = await store.get(targetPath);
250
+ if (!node) return;
251
+
252
+ const policy = getComponent(node, AiPolicy) ?? Object.assign(new AiPolicy(), { $type: 'ai.policy' });
253
+
254
+ if (allow) {
255
+ if (!policy.allow.includes(tool)) policy.allow.push(tool);
256
+ policy.deny = policy.deny.filter(d => d !== tool);
257
+ policy.escalate = policy.escalate.filter(e => e !== tool);
258
+ } else {
259
+ if (!policy.deny.includes(tool)) policy.deny.push(tool);
260
+ policy.allow = policy.allow.filter(a => a !== tool);
261
+ policy.escalate = policy.escalate.filter(e => e !== tool);
262
+ }
263
+
264
+ setComponent(node, AiPolicy, policy);
265
+ try {
266
+ await store.set(node);
267
+ console.log(`[guardian] remembered: ${allow ? 'allow' : 'deny'} ${tool} → ${targetPath}`);
268
+ return;
269
+ } catch (err) {
270
+ if (err instanceof Error && err.message.startsWith('OptimisticConcurrencyError') && attempt < MAX_RETRIES - 1) {
271
+ console.warn(`[guardian] OCC conflict on ${targetPath}, retry ${attempt + 1}`);
272
+ continue;
273
+ }
274
+ throw err;
275
+ }
276
+ }
277
+ }
278
+
279
+ // ── Startup reconciliation ──
280
+
281
+ /** Result of reconciliation — agents that should be resumed */
282
+ export type ResumableAgent = {
283
+ agentPath: string;
284
+ taskPath: string;
285
+ };
286
+
287
+ export async function reconcileOnStartup(store: Tree): Promise<ResumableAgent[]> {
288
+ const resumable: ResumableAgent[] = [];
289
+
290
+ // Expire orphaned approvals
291
+ try {
292
+ const { items } = await store.getChildren('/agents/approvals');
293
+ for (const approval of items) {
294
+ if (approval.$type !== 'ai.approval' || approval.status !== 'pending') continue;
295
+ await store.set({ ...approval, status: 'denied' as const, reason: 'expired: server restart', resolvedAt: Date.now() });
296
+ console.log(`[guardian] expired orphaned approval: ${approval.$path}`);
297
+ }
298
+ } catch { /* no approvals dir */ }
299
+
300
+ // Reconcile agents — resume those with sessionId, reset the rest
301
+ try {
302
+ const { items } = await store.getChildren('/agents');
303
+ const resumablePaths: string[] = [];
304
+
305
+ for (const node of items) {
306
+ if (node.$type !== 'ai.agent') continue;
307
+ if (node.status !== 'working' && node.status !== 'blocked') continue;
308
+
309
+ // Check if agent has a session to resume
310
+ const config = getComponent(node, MetatronConfig);
311
+ const hasSession = config && typeof config.sessionId === 'string' && config.sessionId.length > 0;
312
+ const taskPath = typeof node.currentTask === 'string' ? node.currentTask : '';
313
+
314
+ if (hasSession && taskPath) {
315
+ // Agent can resume — keep working status, collect for re-launch
316
+ resumable.push({ agentPath: node.$path, taskPath });
317
+ resumablePaths.push(node.$path);
318
+ console.log(`[guardian] resumable agent: ${node.$path} → ${taskPath} (session ${config.sessionId.slice(0, 8)}...)`);
319
+ } else {
320
+ // No session — reset to idle
321
+ await store.set({ ...node, status: 'idle', currentTask: '', taskRef: '' });
322
+ console.log(`[guardian] reset stuck agent: ${node.$path}`);
323
+
324
+ // Reset stuck metatron.tasks under this agent
325
+ try {
326
+ const { items: tasks } = await store.getChildren(`${node.$path}/tasks`);
327
+ for (const task of tasks) {
328
+ if (task.$type !== 'metatron.task' || task.status !== 'running') continue;
329
+ await store.set({ ...task, status: 'error', result: 'interrupted: server restart' });
330
+ console.log(`[guardian] reset stuck agent task: ${task.$path}`);
331
+ }
332
+ } catch { /* no tasks dir yet */ }
333
+ }
334
+ }
335
+
336
+ // Update pool — keep resumable agents in active, clear the rest
337
+ const poolNode = await store.get('/agents');
338
+ if (poolNode && poolNode.$type === 'ai.pool') {
339
+ await store.set({ ...poolNode, active: resumablePaths, queue: [] });
340
+ if (resumablePaths.length) {
341
+ console.log(`[guardian] pool active: [${resumablePaths.join(', ')}]`);
342
+ } else {
343
+ console.log(`[guardian] cleared pool active/queue`);
344
+ }
345
+ }
346
+ } catch { /* */ }
347
+
348
+ // Reset stuck task aiStatus — but skip tasks being resumed
349
+ const resumedTaskPaths = new Set(resumable.map(r => r.taskPath));
350
+ try {
351
+ const { items } = await store.getChildren('/board/data');
352
+ for (const task of items) {
353
+ if (task.$type !== 'board.task') continue;
354
+ if (resumedTaskPaths.has(task.$path)) continue;
355
+ if (typeof task.aiStatus === 'string' && task.aiStatus) {
356
+ await store.set({ ...task, aiStatus: '' });
357
+ console.log(`[guardian] cleared aiStatus on: ${task.$path}`);
358
+ }
359
+ }
360
+ } catch { /* no board */ }
361
+
362
+ return resumable;
363
+ }
364
+
365
+ // ── canUseTool callback for Agent SDK ──
366
+
367
+ export function createCanUseTool(
368
+ role: string,
369
+ agentPath: string,
370
+ store?: Tree,
371
+ ) {
372
+ const allow = (): PermissionResult => ({ behavior: 'allow' });
373
+ const deny = (message: string): PermissionResult => ({ behavior: 'deny', message });
374
+
375
+ // Cache resolved policy (per agent run)
376
+ let cachedPolicy: ToolPolicy | null = null;
377
+
378
+ return async (
379
+ toolName: string,
380
+ input: Record<string, unknown>,
381
+ ): Promise<PermissionResult> => {
382
+
383
+ // Lazy-resolve policy from tree on first call
384
+ if (!cachedPolicy) {
385
+ cachedPolicy = store
386
+ ? await resolvePolicy(store, agentPath)
387
+ : FALLBACK_POLICY;
388
+ }
389
+ const policy = cachedPolicy;
390
+
391
+ // Deny-list check first (bare tool name)
392
+ if (matchesAny(policy.deny, toolName)) {
393
+ return deny(`${role}: denied: ${toolName}`);
394
+ }
395
+
396
+ // Bash → split by pipes/operators, check each sub-command independently
397
+ if (toolName === 'Bash') {
398
+ const cmd = typeof input.command === 'string' ? input.command.trim() : '';
399
+
400
+ // C14: normalize backslash escapes before safety check — prevents bypass via `git\ reset\ --hard`
401
+ const normalized = cmd.replace(/\\(.)/g, '$1');
402
+
403
+ // Hardcoded safety net — check full command (both raw and normalized)
404
+ for (const pattern of DANGEROUS_BASH) {
405
+ if (pattern.test(cmd) || pattern.test(normalized)) return deny(`blocked: ${cmd.slice(0, 60)}`);
406
+ }
407
+
408
+ // Split by newlines first, then by pipes/operators
409
+ const lines = cmd.split(/\n/).map(l => l.trim()).filter(Boolean);
410
+
411
+ // Check each line against safety net (belt-and-suspenders with full-string check above)
412
+ for (const line of lines) {
413
+ const normLine = line.replace(/\\(.)/g, '$1');
414
+ for (const pattern of DANGEROUS_BASH) {
415
+ if (pattern.test(line) || pattern.test(normLine)) return deny(`blocked: ${line.slice(0, 60)}`);
416
+ }
417
+ }
418
+
419
+ // Split into sub-commands and check each
420
+ const parts = lines.length > 0 ? lines.flatMap(l => splitBashParts(l)) : [];
421
+ const effectiveNames = parts.map(p => `Bash:${p}`);
422
+
423
+ // Any sub-command denied → deny entire command
424
+ for (const eName of effectiveNames) {
425
+ if (matchesAny(policy.deny, eName)) {
426
+ return deny(`${role}: denied: ${eName}`);
427
+ }
428
+ }
429
+
430
+ // Escalate BEFORE allow: explicit escalate beats wildcard allow
431
+ const escalated = effectiveNames.filter(e => matchesAny(policy.escalate, e));
432
+ if (escalated.length > 0) {
433
+ if (store) {
434
+ const approved = await requestApproval(store, {
435
+ agentPath, role, tool: escalated[0], input: cmd.slice(0, 200),
436
+ reason: 'requires approval',
437
+ });
438
+ return approved ? allow() : deny('denied by human');
439
+ }
440
+ return deny(`${role}: escalated but no store: ${escalated[0]}`);
441
+ }
442
+
443
+ // All sub-commands must be allowed — if any isn't, escalate as unknown
444
+ const notAllowed = effectiveNames.filter(e => !matchesAny(policy.allow, e));
445
+ if (notAllowed.length === 0 && effectiveNames.length > 0) {
446
+ return allow();
447
+ }
448
+
449
+ // Unknown sub-commands → escalate
450
+ const unknownName = notAllowed[0] ?? `Bash:${cmd}`;
451
+ if (store) {
452
+ const approved = await requestApproval(store, {
453
+ agentPath, role, tool: unknownName, input: cmd.slice(0, 200),
454
+ reason: 'unknown tool',
455
+ });
456
+ return approved ? allow() : deny('denied by human');
457
+ }
458
+ return deny(`${role}: not allowed: ${unknownName}`);
459
+ }
460
+
461
+ // Non-Bash tools — deny → escalate → allow → unknown
462
+ // Escalate BEFORE allow: explicit "requires approval" beats wildcard allow.
463
+ if (matchesAny(policy.deny, toolName)) {
464
+ return deny(`${role}: denied: ${toolName}`);
465
+ }
466
+ if (matchesAny(policy.escalate, toolName)) {
467
+ if (store) {
468
+ const inputStr = JSON.stringify(input).slice(0, 500);
469
+ const approved = await requestApproval(store, {
470
+ agentPath, role, tool: toolName, input: inputStr,
471
+ reason: 'requires approval',
472
+ });
473
+ return approved ? allow() : deny('denied by human');
474
+ }
475
+ return deny(`${role}: escalated but no store: ${toolName}`);
476
+ }
477
+ if (matchesAny(policy.allow, toolName)) {
478
+ return allow();
479
+ }
480
+
481
+ // Unknown tool — escalate to human
482
+ if (store) {
483
+ const inputStr = JSON.stringify(input).slice(0, 500);
484
+ const approved = await requestApproval(store, {
485
+ agentPath, role, tool: toolName, input: inputStr,
486
+ reason: 'unknown tool',
487
+ });
488
+ return approved ? allow() : deny('denied by human');
489
+ }
490
+ return deny(`${role}: not allowed: ${toolName}`);
491
+ };
492
+ }
package/agent/seed.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Agent Office seed — /agents pool + agents + guardian policies
2
+
3
+ import { type NodeData } from '@treenity/core';
4
+ import { registerPrefab } from '@treenity/core/mod';
5
+
6
+ registerPrefab('agent', 'seed', [
7
+ // Pool node — orchestrator service lives here
8
+ { $path: 'agents', $type: 'ai.pool',
9
+ maxConcurrent: 2, active: [], queue: [] },
10
+
11
+ // Guardian — global base policy (applies to ALL agents)
12
+ // Read-only by default. Destructive ops denied. Writes require approval.
13
+ { $path: 'agents/guardian', $type: 'dir',
14
+ policy: {
15
+ $type: 'ai.policy',
16
+ allow: [
17
+ 'mcp__treenity__get_node', 'mcp__treenity__list_children',
18
+ 'mcp__treenity__catalog', 'mcp__treenity__describe_type',
19
+ 'mcp__treenity__search_types', 'mcp__treenity__compile_view',
20
+ ],
21
+ deny: [
22
+ 'mcp__treenity__remove_node',
23
+ 'Bash:git checkout *', 'Bash:git checkout -- *',
24
+ 'Bash:git reset --hard*', 'Bash:git push --force*', 'Bash:git clean*',
25
+ 'Bash:rm -rf *', 'Bash:rm -r *', 'Bash:cat *.env*',
26
+ ],
27
+ escalate: [
28
+ 'mcp__treenity__set_node', 'mcp__treenity__execute', 'mcp__treenity__deploy_prefab',
29
+ 'Bash:git add *', 'Bash:git commit *', 'Bash:git push *',
30
+ 'Bash:sed *', 'Bash:mv *', 'Bash:cp *',
31
+ ],
32
+ },
33
+ },
34
+
35
+ // Approvals queue
36
+ { $path: 'agents/approvals', $type: 'ai.approvals' },
37
+
38
+ // ── QA agent (ECS: ai.agent + metatron.config) ──
39
+ { $path: 'agents/qa', $type: 'ai.agent',
40
+ role: 'qa',
41
+ status: 'idle',
42
+ currentTask: '',
43
+ taskRef: '',
44
+ lastRunAt: 0,
45
+ totalTokens: 0,
46
+ // LLM runtime config — metatron.config component (D29)
47
+ config: {
48
+ $type: 'metatron.config',
49
+ model: 'claude-opus-4-6',
50
+ systemPrompt: `You are a QA agent for the Treenity project.
51
+ Your job: run tests, check for errors, verify code quality.
52
+
53
+ ## QA Checklist
54
+ 1. Run \`npm test\` and report results (use Bash tool)
55
+ 2. Check for TypeScript errors
56
+ 3. Report any failing tests with details
57
+ 4. Summarize: PASS (all green) or FAIL (with specifics)
58
+
59
+ Be concise. Facts only.`,
60
+ sessionId: '',
61
+ },
62
+ // Agent-level policy: only ALLOW overrides (deny/escalate inherited from global guardian)
63
+ policy: {
64
+ $type: 'ai.policy',
65
+ allow: ['Bash:npm *', 'Bash:ls *', 'Bash:cat *', 'Bash:git status*', 'Bash:git diff*', 'Bash:git log*'],
66
+ deny: [],
67
+ escalate: [],
68
+ },
69
+ },
70
+ { $path: 'agents/qa/tasks', $type: 'dir' },
71
+
72
+ // Autostart — orchestrator starts on server boot
73
+ { $path: '/sys/autostart/agents', $type: 'ref', $ref: '/agents' },
74
+ ] as NodeData[]);
@@ -0,0 +1,4 @@
1
+ import './types';
2
+ import './guardian';
3
+ import './service';
4
+ import './seed';