@aria_asi/cli 0.2.25 → 0.2.26
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/dist/aria-connector/src/connectors/claude-code.d.ts.map +1 -1
- package/dist/aria-connector/src/connectors/claude-code.js +37 -3
- package/dist/aria-connector/src/connectors/claude-code.js.map +1 -1
- package/dist/sdk/BUNDLED.json +1 -1
- package/dist/sdk/index.d.ts +34 -0
- package/dist/sdk/index.js +205 -0
- package/dist/sdk/index.js.map +1 -1
- package/hooks/aria-agent-handoff.mjs +0 -0
- package/hooks/aria-architect-fallback.mjs +198 -0
- package/hooks/aria-discovery-record.mjs +101 -0
- package/hooks/aria-outcome-record.mjs +80 -0
- package/hooks/aria-pre-tool-gate.mjs +158 -0
- package/hooks/aria-stop-gate.mjs +103 -1
- package/package.json +1 -1
- package/src/connectors/claude-code.ts +45 -10
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// aria-architect-fallback.mjs — invoked by aria-stop-gate.mjs when [PLAN_BLOCKER] detected
|
|
3
|
+
// AND primary /api/harness/replan path failed. Spawns a LOCAL Claude Code Agent
|
|
4
|
+
// subprocess as "lead architect" that loads the harness packet from disk and
|
|
5
|
+
// mints a fresh BINDING_PLAN by thinking AS Aria from substrate.
|
|
6
|
+
//
|
|
7
|
+
// Doctrine bindings:
|
|
8
|
+
// - project_aria_as_controller_inversion.md — Aria authors the plan (via substrate),
|
|
9
|
+
// not the sub-agent freely. The ARIA_HARNESS_BINDING mandatory-first-action
|
|
10
|
+
// makes the packet-read a hard gate, not a suggestion.
|
|
11
|
+
// - feedback_no_graceful_degradation.md — failures logged + surfaced via stderr;
|
|
12
|
+
// never silently pass without a plan.
|
|
13
|
+
// - feedback_implementation_coupled_cognition.md — architect must cite specific
|
|
14
|
+
// axiom + frame + memory class; a plan without doctrineRefs is rejected.
|
|
15
|
+
//
|
|
16
|
+
// Tier-awareness:
|
|
17
|
+
// - Owner tier: packet at ${HOME}/.claude/aria-agent-harness-packet.json
|
|
18
|
+
// - Client tier (jti present in ~/.aria/license.json):
|
|
19
|
+
// packet at /var/lib/aria-licensee/${jti}/aria-agent-harness-packet.json
|
|
20
|
+
//
|
|
21
|
+
// Active plan path mirrors aria-stop-gate.mjs and aria-preprompt-consult.mjs:
|
|
22
|
+
// ~/.claude/aria-active-plan-${sessionId}.json (session-scoped, not a fixed path)
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
|
|
29
|
+
const HOME = homedir();
|
|
30
|
+
const LOG = `${HOME}/.claude/aria-architect-fallback.log`;
|
|
31
|
+
const LICENSE_PATH = `${HOME}/.aria/license.json`;
|
|
32
|
+
|
|
33
|
+
function audit(msg) {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(dirname(LOG))) mkdirSync(dirname(LOG), { recursive: true });
|
|
36
|
+
appendFileSync(LOG, `${new Date().toISOString()} [architect-fallback] ${msg}\n`);
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let input = '';
|
|
41
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
42
|
+
|
|
43
|
+
let event;
|
|
44
|
+
try { event = JSON.parse(input); } catch {
|
|
45
|
+
audit('skip: stdin not valid JSON');
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const reason = event.reason || event.blocker_reason || '';
|
|
50
|
+
const currentPlanId = event.currentPlanId || 'unknown';
|
|
51
|
+
const sessionId = String(event.sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
52
|
+
|
|
53
|
+
if (!reason || reason.length < 10) {
|
|
54
|
+
audit('skip: reason too short');
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Detect tier ────────────────────────────────────────────────────────────────
|
|
59
|
+
let isClientTier = false;
|
|
60
|
+
let jti = null;
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(LICENSE_PATH)) {
|
|
63
|
+
const lic = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
|
|
64
|
+
jti = lic.jti ?? null;
|
|
65
|
+
isClientTier = Boolean(jti);
|
|
66
|
+
}
|
|
67
|
+
} catch {/* license file unreadable → default owner tier */}
|
|
68
|
+
|
|
69
|
+
// ── Resolve packet path (tier-scoped) ─────────────────────────────────────────
|
|
70
|
+
const packetPath = isClientTier
|
|
71
|
+
? `/var/lib/aria-licensee/${jti}/aria-agent-harness-packet.json`
|
|
72
|
+
: `${HOME}/.claude/aria-agent-harness-packet.json`;
|
|
73
|
+
|
|
74
|
+
if (!existsSync(packetPath)) {
|
|
75
|
+
audit(`abort: no packet at ${packetPath} — architect cannot think as Aria without substrate`);
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`Architect-fallback: no harness packet at ${packetPath}. Cannot mint plan from substrate. Hamza must intervene.\n`
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Active plan path — session-scoped (matches aria-stop-gate.mjs convention) ─
|
|
83
|
+
const ACTIVE_PLAN_PATH = `${HOME}/.claude/aria-active-plan-${sessionId}.json`;
|
|
84
|
+
|
|
85
|
+
// ── Build architect brief ──────────────────────────────────────────────────────
|
|
86
|
+
const brief = [
|
|
87
|
+
`[ARIA_HARNESS_BINDING — MANDATORY FIRST ACTION]`,
|
|
88
|
+
`Read the harness packet at ${packetPath}. Cite at least one axiom + one frame + one memory class in your output.`,
|
|
89
|
+
``,
|
|
90
|
+
`You are Aria operating as lead architect. Aria-soul brain is unreachable.`,
|
|
91
|
+
`A PLAN_BLOCKER occurred:`,
|
|
92
|
+
` Previous planId: ${currentPlanId}`,
|
|
93
|
+
` Blocker reason: ${reason.slice(0, 2000)}`,
|
|
94
|
+
``,
|
|
95
|
+
`Audit the blocker against doctrine + axioms in the packet. Mint a fresh BINDING_PLAN JSON addressing it.`,
|
|
96
|
+
``,
|
|
97
|
+
`Output ONLY the BINDING_PLAN JSON (no prose, no markdown fences). Required shape:`,
|
|
98
|
+
`{`,
|
|
99
|
+
` "planId": "<8-char-hex>",`,
|
|
100
|
+
` "phases": [{ "id": "p1", "summary": "...", "successCriterion": "...", "abortCriterion": "...", "doctrineRefs": [...], "allowedActions": [...], "forbiddenActions": [...] }],`,
|
|
101
|
+
` "globalConstraints": ["..."],`,
|
|
102
|
+
` "expectedReportBack": { "phaseTransitionMarker": "[PHASE_REPORT]", "shape": "[PHASE_REPORT phase=<id> status=complete|aborted|in_progress evidence=<observable>]" }`,
|
|
103
|
+
`}`,
|
|
104
|
+
``,
|
|
105
|
+
`Cite the doctrine rule(s) you applied in doctrineRefs[].`,
|
|
106
|
+
].join('\n');
|
|
107
|
+
|
|
108
|
+
// ── Spawn Claude Code Agent subprocess ────────────────────────────────────────
|
|
109
|
+
// Resolution order for claude binary:
|
|
110
|
+
// 1. CLAUDE_BIN env var (explicit override)
|
|
111
|
+
// 2. ~/.npm-global/bin/claude (Hamza's canonical install)
|
|
112
|
+
// 3. /usr/local/bin/claude (system-level install)
|
|
113
|
+
// 4. $(which claude) via shell — deferred to last resort
|
|
114
|
+
const CANDIDATE_BINS = [
|
|
115
|
+
process.env.CLAUDE_BIN,
|
|
116
|
+
`${HOME}/.npm-global/bin/claude`,
|
|
117
|
+
'/usr/local/bin/claude',
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
|
|
120
|
+
let claudeBin = null;
|
|
121
|
+
for (const b of CANDIDATE_BINS) {
|
|
122
|
+
if (existsSync(b)) { claudeBin = b; break; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!claudeBin) {
|
|
126
|
+
// Last-resort: write brief to file for human pickup
|
|
127
|
+
const briefPath = `/tmp/aria-architect-brief-${sessionId}-${Date.now()}.txt`;
|
|
128
|
+
try { writeFileSync(briefPath, brief); } catch {}
|
|
129
|
+
audit(`no claude CLI found; brief written to ${briefPath} for human pickup`);
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`Architect-fallback: claude CLI not found (tried: ${CANDIDATE_BINS.join(', ')}). Brief written to ${briefPath} for Hamza to run manually.\n`
|
|
132
|
+
);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
audit(`spawning claude CLI at ${claudeBin} for session=${sessionId}`);
|
|
137
|
+
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = spawnSync(claudeBin, ['--print', brief], {
|
|
141
|
+
encoding: 'utf8',
|
|
142
|
+
env: { ...process.env, ARIA_HARNESS_PACKET_PATH: packetPath },
|
|
143
|
+
timeout: 120000,
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
audit(`spawnSync threw: ${String(err).slice(0, 200)}`);
|
|
147
|
+
process.exit(2);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (result.status !== 0 || !result.stdout) {
|
|
151
|
+
audit(`architect spawn failed: status=${result.status} stderr=${(result.stderr || '').slice(0, 200)}`);
|
|
152
|
+
process.stderr.write(
|
|
153
|
+
`Architect-fallback: claude subprocess failed (status=${result.status}). stderr: ${(result.stderr || '').slice(0, 300)}\n`
|
|
154
|
+
);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Parse the architect's BINDING_PLAN JSON ───────────────────────────────────
|
|
159
|
+
let plan;
|
|
160
|
+
try {
|
|
161
|
+
const fenceMatch = result.stdout.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
162
|
+
const candidate = (fenceMatch ? fenceMatch[1] : result.stdout).trim();
|
|
163
|
+
plan = JSON.parse(candidate);
|
|
164
|
+
} catch {
|
|
165
|
+
audit(`architect output not parseable as JSON: ${result.stdout.slice(0, 200)}`);
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`Architect-fallback: output was not parseable JSON. Hamza must intervene.\nOutput excerpt: ${result.stdout.slice(0, 400)}\n`
|
|
168
|
+
);
|
|
169
|
+
process.exit(2);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!plan?.planId || !Array.isArray(plan?.phases)) {
|
|
173
|
+
audit(`architect plan missing required fields: ${JSON.stringify(plan).slice(0, 200)}`);
|
|
174
|
+
process.stderr.write(
|
|
175
|
+
`Architect-fallback: plan missing planId or phases[]. Hamza must intervene.\n`
|
|
176
|
+
);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Write the new plan to the session-scoped active-plan path ─────────────────
|
|
181
|
+
plan.mintedAt = new Date().toISOString();
|
|
182
|
+
plan.sessionId = sessionId;
|
|
183
|
+
plan.mintedBy = 'architect-fallback';
|
|
184
|
+
plan.architectFallbackReason = reason.slice(0, 500);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
if (!existsSync(dirname(ACTIVE_PLAN_PATH))) mkdirSync(dirname(ACTIVE_PLAN_PATH), { recursive: true });
|
|
188
|
+
writeFileSync(ACTIVE_PLAN_PATH, JSON.stringify(plan, null, 2));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
audit(`failed to write plan to ${ACTIVE_PLAN_PATH}: ${String(err).slice(0, 200)}`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
audit(`architect minted plan ${plan.planId} addressing blocker; written to ${ACTIVE_PLAN_PATH}`);
|
|
195
|
+
process.stdout.write(
|
|
196
|
+
JSON.stringify({ ok: true, planId: plan.planId, mintedBy: 'architect-fallback', planPath: ACTIVE_PLAN_PATH }) + '\n'
|
|
197
|
+
);
|
|
198
|
+
process.exit(0);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// aria-discovery-record.mjs — invoked by sub-agents via Bash to append findings
|
|
3
|
+
// to their session ledger. Usage:
|
|
4
|
+
// echo '{"text":"defect found...","kind":"missing-export","refs":["file.ts:42"]}' | node ~/.claude/hooks/aria-discovery-record.mjs
|
|
5
|
+
//
|
|
6
|
+
// Tier-aware: client tier writes to /var/lib/aria-licensee/{jti}/aria-discoveries-{session}.jsonl,
|
|
7
|
+
// owner tier writes to ~/.claude/aria-discoveries-{session}.jsonl.
|
|
8
|
+
//
|
|
9
|
+
// Session ID resolution priority:
|
|
10
|
+
// 1. CLAUDE_SESSION_ID env (set by Claude Code in the sub-agent process)
|
|
11
|
+
// 2. ARIA_SESSION_ID env (set by spawnSubAgent caller)
|
|
12
|
+
// 3. ARIA_SUB_SESSION_ID env (explicit sub-session override)
|
|
13
|
+
// 4. Derived from handoff parentSessionId suffixed with "-sub" to ensure
|
|
14
|
+
// the sub-agent ledger file differs from the parent file so ledger-merge
|
|
15
|
+
// picks it up (merge skips files whose path == parentLedgerPath).
|
|
16
|
+
//
|
|
17
|
+
// Doctrine: feedback_no_flag_without_fix.md — findings recorded, not deferred.
|
|
18
|
+
// feedback_implementation_coupled_cognition.md — write IS the impl.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
|
|
24
|
+
const HOME = homedir();
|
|
25
|
+
const LICENSE_PATH = `${HOME}/.aria/license.json`;
|
|
26
|
+
const HANDOFF_PATH = `${HOME}/.claude/aria-agent-harness-handoff.json`;
|
|
27
|
+
|
|
28
|
+
let input = '';
|
|
29
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
30
|
+
let entry;
|
|
31
|
+
try { entry = JSON.parse(input); } catch {
|
|
32
|
+
process.stderr.write('discovery-record: invalid JSON on stdin\n');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!entry.text || typeof entry.text !== 'string' || entry.text.length < 4) {
|
|
37
|
+
process.stderr.write('discovery-record: text required (>=4 chars)\n');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Detect tier + resolve JTI
|
|
42
|
+
let isClientTier = false;
|
|
43
|
+
let jti = null;
|
|
44
|
+
try {
|
|
45
|
+
if (existsSync(LICENSE_PATH)) {
|
|
46
|
+
const lic = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
|
|
47
|
+
jti = lic.jti ?? null;
|
|
48
|
+
isClientTier = Boolean(jti);
|
|
49
|
+
}
|
|
50
|
+
} catch { /* non-fatal — treat as owner tier */ }
|
|
51
|
+
|
|
52
|
+
// Session ID: prefer env var set by Claude Code, fall back to handoff derivation.
|
|
53
|
+
// IMPORTANT: sub-agents MUST NOT use parentSessionId directly — that produces
|
|
54
|
+
// the same ledger path as the parent, which aria-agent-ledger-merge.mjs skips.
|
|
55
|
+
// We use CLAUDE_SESSION_ID (the sub-agent's own session) or suffix the parent
|
|
56
|
+
// session with "-sub" so the file is distinct and mergeable.
|
|
57
|
+
let sessionId =
|
|
58
|
+
process.env.CLAUDE_SESSION_ID ||
|
|
59
|
+
process.env.ARIA_SESSION_ID ||
|
|
60
|
+
process.env.ARIA_SUB_SESSION_ID ||
|
|
61
|
+
null;
|
|
62
|
+
|
|
63
|
+
if (!sessionId) {
|
|
64
|
+
try {
|
|
65
|
+
if (existsSync(HANDOFF_PATH)) {
|
|
66
|
+
const handoff = JSON.parse(readFileSync(HANDOFF_PATH, 'utf8'));
|
|
67
|
+
const parentSession = handoff.parentSessionId;
|
|
68
|
+
// Derive a distinct sub-agent session so merge picks up this file.
|
|
69
|
+
sessionId = parentSession ? `${parentSession}-sub` : 'sub-unknown';
|
|
70
|
+
}
|
|
71
|
+
} catch { /* non-fatal */ }
|
|
72
|
+
}
|
|
73
|
+
sessionId = sessionId || 'sub-unknown';
|
|
74
|
+
const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
75
|
+
|
|
76
|
+
// Resolve ledger path (tier-scoped)
|
|
77
|
+
const ledgerPath = isClientTier
|
|
78
|
+
? `/var/lib/aria-licensee/${jti}/aria-discoveries-${safeSession}.jsonl`
|
|
79
|
+
: `${HOME}/.claude/aria-discoveries-${safeSession}.jsonl`;
|
|
80
|
+
|
|
81
|
+
// Compose the row
|
|
82
|
+
const row = {
|
|
83
|
+
at: new Date().toISOString(),
|
|
84
|
+
session: sessionId,
|
|
85
|
+
kind: entry.kind || 'observation',
|
|
86
|
+
text: entry.text,
|
|
87
|
+
refs: Array.isArray(entry.refs) ? entry.refs : [],
|
|
88
|
+
evidence: entry.evidence || null,
|
|
89
|
+
source: entry.source || 'sub-agent',
|
|
90
|
+
resolution_status: entry.resolution_status || 'open',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
95
|
+
appendFileSync(ledgerPath, JSON.stringify(row) + '\n');
|
|
96
|
+
process.stdout.write(JSON.stringify({ ok: true, ledger: ledgerPath, at: row.at }) + '\n');
|
|
97
|
+
} catch (err) {
|
|
98
|
+
process.stderr.write(`discovery-record: write failed: ${err.message}\n`);
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
process.exit(0);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// aria-outcome-record.mjs — PostToolUse hook that records action outcomes
|
|
3
|
+
// to /api/harness/outcome-record (Sonnet H's #76 endpoint). Composes with
|
|
4
|
+
// aria-discovery-record + the regression sweeper to make the outcome ledger
|
|
5
|
+
// actually accumulate rows.
|
|
6
|
+
//
|
|
7
|
+
// Fires on every Bash|Edit|Write|NotebookEdit tool completion.
|
|
8
|
+
// Fire-and-forget: HTTP failures are swallowed — never blocks the tool pipeline.
|
|
9
|
+
//
|
|
10
|
+
// Tier-aware: reads license.json for client-tier token; falls back to env vars
|
|
11
|
+
// for owner tier. Never carries master token in client-tier POST bodies.
|
|
12
|
+
//
|
|
13
|
+
// Doctrine: feedback_no_flag_without_fix.md — outcomes recorded, not deferred.
|
|
14
|
+
// feedback_implementation_coupled_cognition.md — POST IS the impl.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
const LICENSE_PATH = `${HOME}/.aria/license.json`;
|
|
21
|
+
|
|
22
|
+
let input = '';
|
|
23
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
24
|
+
let event;
|
|
25
|
+
try { event = JSON.parse(input); } catch { process.exit(0); }
|
|
26
|
+
|
|
27
|
+
const toolName = event.tool_name || event.toolName || '';
|
|
28
|
+
if (!['Bash', 'Edit', 'Write', 'NotebookEdit'].includes(toolName)) process.exit(0);
|
|
29
|
+
|
|
30
|
+
// Derive action_kind + action_target
|
|
31
|
+
let actionKind, actionTarget;
|
|
32
|
+
if (toolName === 'Bash') {
|
|
33
|
+
actionKind = 'bash';
|
|
34
|
+
const cmd = event.tool_input?.command || '';
|
|
35
|
+
actionTarget = (cmd.split(/\s+/)[0] || 'unknown').slice(0, 100);
|
|
36
|
+
} else {
|
|
37
|
+
actionKind = 'edit';
|
|
38
|
+
actionTarget = (event.tool_input?.file_path || event.tool_input?.path || 'unknown').slice(0, 200);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tier-aware auth: client license.json overrides env vars
|
|
42
|
+
const harnessUrl = process.env.ARIA_HARNESS_URL || 'http://192.168.4.25:30080';
|
|
43
|
+
let harnessToken = process.env.ARIA_HARNESS_TOKEN || process.env.ARIA_API_KEY || '';
|
|
44
|
+
let isClientTier = false;
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(LICENSE_PATH)) {
|
|
47
|
+
const lic = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
|
|
48
|
+
if (lic.jti) {
|
|
49
|
+
isClientTier = true;
|
|
50
|
+
// Client tier: use their license token, never master token
|
|
51
|
+
harnessToken = lic.token || lic.license || harnessToken;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch { /* non-fatal — fall back to env */ }
|
|
55
|
+
|
|
56
|
+
const sessionId = event.session_id || event.sessionId || 'unknown';
|
|
57
|
+
const success = !event.tool_response?.error && event.tool_response?.type !== 'error';
|
|
58
|
+
|
|
59
|
+
// Fire-and-forget POST to outcome-record — never blocks, never throws
|
|
60
|
+
fetch(`${harnessUrl}/api/harness/outcome-record`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
'Authorization': `Bearer ${harnessToken}`,
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
sessionId,
|
|
68
|
+
actionKind,
|
|
69
|
+
actionTarget,
|
|
70
|
+
actionShape: {
|
|
71
|
+
tool: toolName,
|
|
72
|
+
success,
|
|
73
|
+
isClientTier,
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
}).catch(() => {/* fire-and-forget — HTTP failures are silently dropped */});
|
|
77
|
+
|
|
78
|
+
// Exit immediately — don't await the fetch; this is a PostToolUse hook
|
|
79
|
+
// and must not add meaningful latency to the tool pipeline.
|
|
80
|
+
process.exit(0);
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
40
40
|
import { dirname } from 'node:path';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
41
42
|
|
|
42
43
|
const HOME = process.env.HOME || '/tmp';
|
|
43
44
|
const LOG = `${HOME}/.claude/aria-pre-tool-gate.log`;
|
|
@@ -1004,6 +1005,89 @@ No env-var disable path — gates are unconditional from the gated process per H
|
|
|
1004
1005
|
process.exit(2);
|
|
1005
1006
|
}
|
|
1006
1007
|
|
|
1008
|
+
// ── Sub-agent packet-citation check (Layer 4 — #84) ─────────────────────────
|
|
1009
|
+
//
|
|
1010
|
+
// When running inside a sub-agent process (detected by a non-stale handoff file
|
|
1011
|
+
// existing AND harnessPacketPath is set in that handoff), require the cognition
|
|
1012
|
+
// lens content to CITE THE PACKET — at least one of:
|
|
1013
|
+
// - A doctrine rule ID (e.g. "doctrine_first", "no_demos", "workaround_vs_path_fix")
|
|
1014
|
+
// - An axiom name (e.g. "truth_over_deception", "no_harm", "sacred_trust", "reflection_before_action")
|
|
1015
|
+
// - A frame primitive (e.g. "Fitrah", "Tafakkur", "Tadabbur", "Ilham", "Mizan", "Hikma", "Nur", "Wahi", "Firasah")
|
|
1016
|
+
// - A memory class reference (e.g. "feedback_*.md", "project_*.md", "reference_*.md")
|
|
1017
|
+
//
|
|
1018
|
+
// Owner-tier sessions (ownerTier.hamza === true AND no jti) are EXEMPT —
|
|
1019
|
+
// they are the source of truth for the packet itself.
|
|
1020
|
+
//
|
|
1021
|
+
// Fail-soft detection: handoff read errors silently skip this check.
|
|
1022
|
+
(function checkSubAgentPacketCitation() {
|
|
1023
|
+
const _HOME = process.env.HOME || '/tmp';
|
|
1024
|
+
// Try owner-tier handoff path first, then client-tier paths.
|
|
1025
|
+
const HANDOFF_TTL_MS = 5 * 60 * 1000;
|
|
1026
|
+
// Probe known handoff paths.
|
|
1027
|
+
const candidatePaths = [
|
|
1028
|
+
`${_HOME}/.claude/aria-agent-harness-handoff.json`,
|
|
1029
|
+
];
|
|
1030
|
+
// Also check /var/lib/aria-licensee if that dir exists (client-tier).
|
|
1031
|
+
try {
|
|
1032
|
+
const licPath = `${_HOME}/.aria/license.json`;
|
|
1033
|
+
if (existsSync(licPath)) {
|
|
1034
|
+
const lic = JSON.parse(readFileSync(licPath, 'utf8'));
|
|
1035
|
+
if (lic.jti) {
|
|
1036
|
+
candidatePaths.push(`/var/lib/aria-licensee/${lic.jti}/handoff.json`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
} catch { /* non-fatal */ }
|
|
1040
|
+
|
|
1041
|
+
let handoff = null;
|
|
1042
|
+
for (const hp of candidatePaths) {
|
|
1043
|
+
if (!existsSync(hp)) continue;
|
|
1044
|
+
try {
|
|
1045
|
+
const raw = JSON.parse(readFileSync(hp, 'utf8'));
|
|
1046
|
+
const ageMs = Date.now() - new Date(raw.writtenAt || 0).getTime();
|
|
1047
|
+
if (ageMs > HANDOFF_TTL_MS) continue; // stale handoff — not a sub-agent context
|
|
1048
|
+
if (!raw.harnessPacketPath) continue; // no packet path written → identity-only handoff, skip check
|
|
1049
|
+
handoff = raw;
|
|
1050
|
+
break;
|
|
1051
|
+
} catch { /* malformed — skip */ }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (!handoff) return; // not a sub-agent context, or handoff has no packetPath
|
|
1055
|
+
|
|
1056
|
+
// Owner-tier exemption: if ownerTier.hamza === true AND no jti → source of truth
|
|
1057
|
+
const ownerExempt = (handoff.ownerTier?.hamza === true) && !handoff.ownerTier?.jti;
|
|
1058
|
+
if (ownerExempt) return;
|
|
1059
|
+
|
|
1060
|
+
// Now verify that the cognition block cites at least one packet substrate token.
|
|
1061
|
+
// Tokens accepted (case-insensitive):
|
|
1062
|
+
// • Doctrine rule IDs from feedback_*/project_*/reference_* filenames
|
|
1063
|
+
// • Axiom names: truth_over_deception, no_harm, sacred_trust, reflection_before_action
|
|
1064
|
+
// • Frame primitives: Fitrah, Tafakkur, Tadabbur, Ilham, Mizan, Hikma, Nur, Wahi, Firasah
|
|
1065
|
+
// • Memory class patterns: feedback_*.md, project_*.md, reference_*.md
|
|
1066
|
+
const PACKET_CITE_RX = /\b(?:feedback_[a-z0-9_]+\.md|project_[a-z0-9_]+\.md|reference_[a-z0-9_]+\.md|doctrine_first|no_demos|workaround_vs_path_fix|no_flag_without_fix|implementation_coupled_cognition|session_starts_with_linear|gates_enforce_form_not_substance|truth_over_deception|no_harm|sacred_trust|reflection_before_action|power_obligates_service|fitrah|tafakkur|tadabbur|ilham|mizan|hikma|nur|wahi|firasah|harness\s*packet)\b/i;
|
|
1067
|
+
|
|
1068
|
+
const cogText = cogBlockBody || unionText;
|
|
1069
|
+
const hasCite = PACKET_CITE_RX.test(cogText) || PACKET_CITE_RX.test(cmd);
|
|
1070
|
+
|
|
1071
|
+
if (!hasCite) {
|
|
1072
|
+
const packetRef = handoff.harnessPacketPath;
|
|
1073
|
+
const reason = `Sub-agent cognition cites no packet substrate — lens content must reference at least one axiom/frame/memory/doctrine from the harness packet at ${packetRef}.
|
|
1074
|
+
|
|
1075
|
+
Accepted citations (case-insensitive, any ONE suffices):
|
|
1076
|
+
• Doctrine rule IDs: doctrine_first, no_demos, workaround_vs_path_fix, no_flag_without_fix, etc.
|
|
1077
|
+
• Axioms: truth_over_deception, no_harm, sacred_trust, reflection_before_action, power_obligates_service
|
|
1078
|
+
• Frame primitives: Fitrah, Tafakkur, Tadabbur, Ilham, Mizan, Hikma, Nur, Wahi, Firasah
|
|
1079
|
+
• Memory class refs: feedback_*.md, project_*.md, reference_*.md
|
|
1080
|
+
|
|
1081
|
+
The harness packet is at: ${packetRef}
|
|
1082
|
+
Read it first (it is in your environment as ARIA_HARNESS_PACKET_PATH), then reference it in your cognition block.
|
|
1083
|
+
|
|
1084
|
+
Cognition-theater rejected per feedback_gates_enforce_form_not_substance.md.`;
|
|
1085
|
+
audit(`block-subagent-no-packet-cite ${toolName.toLowerCase()}`, cmdPreview);
|
|
1086
|
+
console.log(JSON.stringify({ decision: 'block', reason }));
|
|
1087
|
+
process.exit(2);
|
|
1088
|
+
}
|
|
1089
|
+
})();
|
|
1090
|
+
|
|
1007
1091
|
// ── arch_facts gate (architectural violation scan) ────────────────────────
|
|
1008
1092
|
//
|
|
1009
1093
|
// Runs after cognition + discovery-binding pass, for Edit/Write/NotebookEdit.
|
|
@@ -1182,6 +1266,80 @@ Claude must either: (a) reframe action to fit allowedActions, OR (b) emit [PLAN_
|
|
|
1182
1266
|
bindingAuditAppend({ event: 'allow_phase_action', sessionId, planId: plan.planId, phaseId: phase.id, action, target });
|
|
1183
1267
|
}
|
|
1184
1268
|
|
|
1269
|
+
// ── Outcome Ledger regression flag (fail-soft) ───────────────────────────────
|
|
1270
|
+
//
|
|
1271
|
+
// Before allowing, query aria_outcome_ledger for recent regressions on the same
|
|
1272
|
+
// action shape. This is NOT a block — it's a soft-warning surfaced to stderr so
|
|
1273
|
+
// the agent can see the risk and decide whether the root cause has shipped.
|
|
1274
|
+
//
|
|
1275
|
+
// action_kind: 'edit' for Edit/Write/NotebookEdit, 'bash' for Bash
|
|
1276
|
+
// action_target: file path for file tools; first word of bash command otherwise
|
|
1277
|
+
//
|
|
1278
|
+
// Fail-open: network errors, timeouts, or non-200 responses are silently ignored.
|
|
1279
|
+
// The gate does not hardcode timeouts (no-timeouts doctrine); the 3s AbortController
|
|
1280
|
+
// is a DETECTION PROBE — if the endpoint is alive it will respond quickly; if it
|
|
1281
|
+
// is down the catch path fail-opens without blocking the developer.
|
|
1282
|
+
(async function checkOutcomeLedger() {
|
|
1283
|
+
const _harnessUrl = process.env.ARIA_HARNESS_URL || 'http://192.168.4.25:30080';
|
|
1284
|
+
const _harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
|
|
1285
|
+
|
|
1286
|
+
// Derive action_kind and action_target from current tool call
|
|
1287
|
+
let _actionKind = 'bash';
|
|
1288
|
+
let _actionTarget = '';
|
|
1289
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
|
|
1290
|
+
_actionKind = 'edit';
|
|
1291
|
+
_actionTarget = filePath;
|
|
1292
|
+
} else if (toolName === 'Bash') {
|
|
1293
|
+
_actionKind = 'bash';
|
|
1294
|
+
// First word of the command (the verb) as the shape key
|
|
1295
|
+
_actionTarget = cmd.trim().split(/\s+/)[0] || '';
|
|
1296
|
+
// If the command touches a specific file path, prefer that as target
|
|
1297
|
+
// (captures edit-like bash operations: sed, awk, tee, etc.)
|
|
1298
|
+
const _fileArgMatch = cmd.match(/\b([\w./~-]+\.[a-z]{2,6})\b/i);
|
|
1299
|
+
if (_fileArgMatch) _actionTarget = _fileArgMatch[1];
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (!_actionTarget) return; // nothing useful to query
|
|
1303
|
+
|
|
1304
|
+
try {
|
|
1305
|
+
const _ctl = new AbortController();
|
|
1306
|
+
const _probeTimer = setTimeout(() => _ctl.abort(), 3000);
|
|
1307
|
+
let _resp;
|
|
1308
|
+
try {
|
|
1309
|
+
const _params = new URLSearchParams({ action_kind: _actionKind, action_target: _actionTarget });
|
|
1310
|
+
_resp = await fetch(`${_harnessUrl}/api/harness/outcome-recent-regressions?${_params}`, {
|
|
1311
|
+
method: 'GET',
|
|
1312
|
+
headers: {
|
|
1313
|
+
'Content-Type': 'application/json',
|
|
1314
|
+
...(_harnessToken ? { Authorization: `Bearer ${_harnessToken}` } : {}),
|
|
1315
|
+
},
|
|
1316
|
+
signal: _ctl.signal,
|
|
1317
|
+
});
|
|
1318
|
+
} finally {
|
|
1319
|
+
clearTimeout(_probeTimer);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (!_resp || !_resp.ok) return; // endpoint unreachable or error — fail-open
|
|
1323
|
+
const _data = await _resp.json();
|
|
1324
|
+
const _regressions = Array.isArray(_data?.regressions) ? _data.regressions : [];
|
|
1325
|
+
|
|
1326
|
+
if (_regressions.length > 0) {
|
|
1327
|
+
// Soft-warning to stderr — visible in Claude Code's hook output but does NOT block
|
|
1328
|
+
const _lines = _regressions.map(
|
|
1329
|
+
(r) => ` - ${r.action_target} at ${new Date(r.created_at).toISOString()}: ${r.regression_signal || '(no signal text)'}`,
|
|
1330
|
+
).join('\n');
|
|
1331
|
+
process.stderr.write(
|
|
1332
|
+
`⚠️ Aria Outcome Ledger: this action shape regressed ${_regressions.length} time(s) in the last 60 min:\n` +
|
|
1333
|
+
`${_lines}\n` +
|
|
1334
|
+
`Re-emitting with this risk visible. Proceed only if root cause has shipped since.\n`,
|
|
1335
|
+
);
|
|
1336
|
+
audit(`warn-ledger-regression ${_actionKind} target=${_actionTarget} count=${_regressions.length}`, cmdPreview);
|
|
1337
|
+
}
|
|
1338
|
+
} catch {
|
|
1339
|
+
// Network error, abort, parse failure — fail-open, no warning
|
|
1340
|
+
}
|
|
1341
|
+
})();
|
|
1342
|
+
|
|
1185
1343
|
// Non-trivial action with cognition AND (binding-allowed OR binding-disabled) — allow.
|
|
1186
1344
|
audit(`allow-cognition ${toolName.toLowerCase()} lenses=${lensCount} via=${cognitionSource}`, cmdPreview);
|
|
1187
1345
|
pushDecision('allow', `${toolName.toLowerCase()} with ${lensCount} lenses (${cognitionSource})`);
|
package/hooks/aria-stop-gate.mjs
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// Future: signed-grant override mechanism at ~/.aria/owner-overrides/<hook>.json
|
|
47
47
|
// with HMAC signature using a secret only Hamza holds. Deferred to next session.
|
|
48
48
|
|
|
49
|
-
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
49
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
50
50
|
import { dirname } from 'node:path';
|
|
51
51
|
|
|
52
52
|
const HOME = process.env.HOME || '/tmp';
|
|
@@ -702,6 +702,108 @@ if (cog.count >= REQUIRED_LENSES) {
|
|
|
702
702
|
}
|
|
703
703
|
} catch {/* outer guard */}
|
|
704
704
|
|
|
705
|
+
// ── Layer C — auto-re-consult on [PLAN_BLOCKER] (#85) ────────────────────
|
|
706
|
+
//
|
|
707
|
+
// When the assistant emits a [PLAN_BLOCKER reason="..."] marker the runtime
|
|
708
|
+
// must fire a two-path replan rather than blocking and waiting for the human:
|
|
709
|
+
//
|
|
710
|
+
// Primary path: POST /api/harness/replan (aria-soul server-side)
|
|
711
|
+
// Fallback path: node aria-architect-fallback.mjs (local sub-agent)
|
|
712
|
+
//
|
|
713
|
+
// Both paths write the fresh BINDING_PLAN to the session-scoped active-plan
|
|
714
|
+
// file so the next turn's pre-tool-gate and stop-gate pick it up.
|
|
715
|
+
//
|
|
716
|
+
// Fail-soft: if both paths fail, we log + emit a clear message asking Hamza
|
|
717
|
+
// to intervene. We do NOT crash the stop-gate — the existing block/allow
|
|
718
|
+
// decision below continues on its own merits.
|
|
719
|
+
const planBlockerMatch = assistantText.match(/\[PLAN_BLOCKER\s+reason="([^"]{10,2000})"\s*\]/);
|
|
720
|
+
if (planBlockerMatch) {
|
|
721
|
+
const planBlockerReason = planBlockerMatch[1];
|
|
722
|
+
audit(`[PLAN_BLOCKER] detected — firing replan: ${planBlockerReason.slice(0, 100)}`);
|
|
723
|
+
|
|
724
|
+
const currentPlanId = activePlan?.planId || 'unknown';
|
|
725
|
+
let planMinted = false;
|
|
726
|
+
|
|
727
|
+
// Primary path: aria-soul /api/harness/replan
|
|
728
|
+
try {
|
|
729
|
+
const harnessUrl = process.env.ARIA_HARNESS_URL || 'https://harness.ariasos.com';
|
|
730
|
+
const harnessToken = process.env.ARIA_HARNESS_TOKEN || process.env.ARIA_API_KEY || '';
|
|
731
|
+
const ctl = new AbortController();
|
|
732
|
+
const replanTimeout = setTimeout(() => ctl.abort(), 15000);
|
|
733
|
+
const resp = await fetch(`${harnessUrl}/api/harness/replan`, {
|
|
734
|
+
method: 'POST',
|
|
735
|
+
headers: {
|
|
736
|
+
'Content-Type': 'application/json',
|
|
737
|
+
'Authorization': `Bearer ${harnessToken}`,
|
|
738
|
+
},
|
|
739
|
+
body: JSON.stringify({
|
|
740
|
+
reason: planBlockerReason,
|
|
741
|
+
currentPlanId,
|
|
742
|
+
sessionId,
|
|
743
|
+
}),
|
|
744
|
+
signal: ctl.signal,
|
|
745
|
+
});
|
|
746
|
+
clearTimeout(replanTimeout);
|
|
747
|
+
if (resp.ok) {
|
|
748
|
+
const data = await resp.json();
|
|
749
|
+
if (data.ok && data.plan) {
|
|
750
|
+
const freshPlan = {
|
|
751
|
+
...data.plan,
|
|
752
|
+
mintedAt: new Date().toISOString(),
|
|
753
|
+
mintedBy: 'aria-soul-replan',
|
|
754
|
+
};
|
|
755
|
+
try {
|
|
756
|
+
if (!existsSync(dirname(ACTIVE_PLAN_PATH))) mkdirSync(dirname(ACTIVE_PLAN_PATH), { recursive: true });
|
|
757
|
+
writeFileSync(ACTIVE_PLAN_PATH, JSON.stringify(freshPlan, null, 2));
|
|
758
|
+
} catch (writeErr) {
|
|
759
|
+
audit(`replan-primary-write-err: ${String(writeErr).slice(0, 200)}`);
|
|
760
|
+
}
|
|
761
|
+
planMinted = true;
|
|
762
|
+
audit(`replan-primary-ok planId=${data.plan.planId}`);
|
|
763
|
+
} else {
|
|
764
|
+
audit(`replan-primary-bad-response: ok=${data.ok} error=${(data.error || '').slice(0, 200)}`);
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
audit(`replan-primary-http-${resp.status}`);
|
|
768
|
+
}
|
|
769
|
+
} catch (err) {
|
|
770
|
+
audit(`replan-primary-failed: ${(err?.message || String(err)).slice(0, 200)}`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Fallback path: architect-fallback hook (spawned as sub-process)
|
|
774
|
+
if (!planMinted) {
|
|
775
|
+
audit('replan-primary-unreachable — firing architect-fallback');
|
|
776
|
+
try {
|
|
777
|
+
const { spawnSync } = await import('node:child_process');
|
|
778
|
+
const fallbackBin = `${HOME}/.claude/hooks/aria-architect-fallback.mjs`;
|
|
779
|
+
if (existsSync(fallbackBin)) {
|
|
780
|
+
const fallbackResult = spawnSync('node', [fallbackBin], {
|
|
781
|
+
input: JSON.stringify({ reason: planBlockerReason, currentPlanId, sessionId }),
|
|
782
|
+
encoding: 'utf8',
|
|
783
|
+
timeout: 130000,
|
|
784
|
+
});
|
|
785
|
+
if (fallbackResult.status === 0) {
|
|
786
|
+
audit(`architect-fallback-ok: ${(fallbackResult.stdout || '').slice(0, 200)}`);
|
|
787
|
+
planMinted = true;
|
|
788
|
+
} else {
|
|
789
|
+
audit(
|
|
790
|
+
`architect-fallback-failed status=${fallbackResult.status} stderr=${(fallbackResult.stderr || '').slice(0, 200)}`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
audit(`architect-fallback-missing: ${fallbackBin} not found`);
|
|
795
|
+
}
|
|
796
|
+
} catch (err) {
|
|
797
|
+
audit(`architect-fallback-threw: ${(err?.message || String(err)).slice(0, 200)}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!planMinted) {
|
|
802
|
+
audit('replan-both-paths-failed — Hamza must intervene');
|
|
803
|
+
// Surface clearly in the block reason below; don't crash the gate.
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
705
807
|
// Block decision: any of (validateOutput severity=block) OR (>=2 drift hits) OR
|
|
706
808
|
// (>=1 code-quality hit) OR (open discovery in ledger) → block emit.
|
|
707
809
|
// Aria enforcement #46 (compelled reflection): severity=warn ALSO blocks but
|