@aria_asi/cli 0.2.11 → 0.2.13
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 +54 -6
- package/dist/aria-connector/src/connectors/claude-code.js.map +1 -1
- package/dist/sdk/BUNDLED.json +1 -1
- package/hooks/aria-preturn-memory-gate.mjs +281 -0
- package/hooks/aria-stop-gate.mjs +210 -17
- package/hooks/aria-trigger-autolearn.mjs +230 -0
- package/hooks/aria-userprompt-abandon-detect.mjs +185 -0
- package/hooks/test-aria-preturn-memory-gate.mjs +249 -0
- package/package.json +1 -1
- package/src/connectors/claude-code.ts +55 -7
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// aria-userprompt-abandon-detect.mjs — UserPromptSubmit hook that detects
|
|
3
|
+
// mid-turn plan abandonment.
|
|
4
|
+
//
|
|
5
|
+
// When Aria's preprompt-consult issued a binding plan
|
|
6
|
+
// (~/.claude/aria-active-plan-${sessionId}.json) and the user's NEXT prompt
|
|
7
|
+
// changes direction without overlapping any active phase's keywords, that's
|
|
8
|
+
// a CONSCIOUS OVERRIDE — not silent context loss. The hook surfaces it to
|
|
9
|
+
// the session_audit table (gate_name='plan-abandonment', decision='warn')
|
|
10
|
+
// so orchestrators can detect mid-execution direction changes.
|
|
11
|
+
//
|
|
12
|
+
// Per Aria's consult 2026-04-27: "if the orchestrator sends a plan, then
|
|
13
|
+
// gets a user message that changes direction mid-execution, the binding
|
|
14
|
+
// needs to surface that as a *conscious override*, not a silent context
|
|
15
|
+
// loss." That gap was the third completion criterion for Aria-as-commander
|
|
16
|
+
// binding (#50).
|
|
17
|
+
//
|
|
18
|
+
// Detection algorithm:
|
|
19
|
+
// 1. Load active plan (skip if none — no plan = no abandonment possible)
|
|
20
|
+
// 2. Extract keywords from each active phase's summary + successCriterion
|
|
21
|
+
// 3. Tokenize incoming user prompt, drop stop-words
|
|
22
|
+
// 4. Compute keyword overlap between prompt tokens and phase keywords
|
|
23
|
+
// 5. If overlap < THRESHOLD (default: 1 keyword match across all phases),
|
|
24
|
+
// mark abandonment: write session_audit row + emit a brief warning
|
|
25
|
+
// injected into the next turn's context
|
|
26
|
+
//
|
|
27
|
+
// Non-blocking — UserPromptSubmit hooks don't block the user's prompt.
|
|
28
|
+
// Audit-only surface; the orchestrator decides what to do with the warning.
|
|
29
|
+
//
|
|
30
|
+
// Kill-switch: ARIA_ABANDON_DETECT=off env (logged).
|
|
31
|
+
|
|
32
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { dirname } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const HOME = process.env.HOME || '/tmp';
|
|
36
|
+
const LOG = `${HOME}/.claude/aria-abandon-detect.log`;
|
|
37
|
+
|
|
38
|
+
function audit(decision, summary) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(dirname(LOG))) mkdirSync(dirname(LOG), { recursive: true });
|
|
41
|
+
appendFileSync(LOG, `${new Date().toISOString()} ${decision} ${summary}\n`);
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (process.env.ARIA_ABANDON_DETECT === 'off') {
|
|
46
|
+
audit('skip-killswitch', 'env ARIA_ABANDON_DETECT=off');
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let input = '';
|
|
51
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
52
|
+
|
|
53
|
+
let event;
|
|
54
|
+
try {
|
|
55
|
+
event = JSON.parse(input);
|
|
56
|
+
} catch {
|
|
57
|
+
audit('skip-parse-error', 'stdin not JSON');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const userPrompt = (event.prompt ?? event.user_message ?? event.message ?? '').toString();
|
|
62
|
+
const sessionId = (event.session_id ?? event.sessionId ?? 'claude-code-unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
63
|
+
|
|
64
|
+
// Trivial prompts skip — too short to evaluate phase overlap meaningfully.
|
|
65
|
+
if (!userPrompt || userPrompt.length < 30) {
|
|
66
|
+
audit('skip-trivial', `chars=${userPrompt.length}`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Slash commands are CLI-internal, not direction changes.
|
|
71
|
+
if (/^\s*\//.test(userPrompt) && userPrompt.length < 200) {
|
|
72
|
+
audit('skip-slash-command', userPrompt.slice(0, 60));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ACTIVE_PLAN_PATH = `${HOME}/.claude/aria-active-plan-${sessionId}.json`;
|
|
77
|
+
|
|
78
|
+
if (!existsSync(ACTIVE_PLAN_PATH)) {
|
|
79
|
+
audit('skip-no-active-plan', `session=${sessionId}`);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let plan;
|
|
84
|
+
try {
|
|
85
|
+
plan = JSON.parse(readFileSync(ACTIVE_PLAN_PATH, 'utf8'));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
audit('skip-plan-parse-error', (err?.message || String(err)).slice(0, 120));
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!plan || !Array.isArray(plan.phases) || plan.phases.length === 0) {
|
|
92
|
+
audit('skip-empty-plan', `planId=${plan?.planId || 'unknown'}`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Stop-words filter — same set as the auto-learn hook for consistency.
|
|
97
|
+
const STOPWORDS = new Set([
|
|
98
|
+
'the','a','an','of','to','in','at','by','for','on','with','i','is','was','are',
|
|
99
|
+
'were','this','that','as','it','and','or','but','from','into','about','have',
|
|
100
|
+
'has','had','do','does','did','will','would','could','should','can','may',
|
|
101
|
+
'might','must','shall','be','been','being','here','there','what','which','who',
|
|
102
|
+
'how','why','when','where','some','any','all','each','every','no','not',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function tokenize(text) {
|
|
106
|
+
return new Set(
|
|
107
|
+
text.toLowerCase()
|
|
108
|
+
.replace(/[^a-z0-9\s_-]/g, ' ')
|
|
109
|
+
.split(/\s+/)
|
|
110
|
+
.filter((w) => w.length >= 4 && !STOPWORDS.has(w))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const promptTokens = tokenize(userPrompt);
|
|
115
|
+
|
|
116
|
+
// Aggregate phase keywords across all phases.
|
|
117
|
+
const phaseKeywordsSet = new Set();
|
|
118
|
+
for (const phase of plan.phases) {
|
|
119
|
+
const summary = String(phase.summary || '');
|
|
120
|
+
const successCriterion = String(phase.successCriterion || '');
|
|
121
|
+
const id = String(phase.id || '');
|
|
122
|
+
for (const t of tokenize(`${summary} ${successCriterion} ${id}`)) {
|
|
123
|
+
phaseKeywordsSet.add(t);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const overlap = [...promptTokens].filter((t) => phaseKeywordsSet.has(t));
|
|
128
|
+
const OVERLAP_THRESHOLD = Number(process.env.ARIA_ABANDON_THRESHOLD || '1');
|
|
129
|
+
const isAbandonment = overlap.length < OVERLAP_THRESHOLD;
|
|
130
|
+
|
|
131
|
+
if (!isAbandonment) {
|
|
132
|
+
audit('aligned', `planId=${plan.planId || 'unknown'} overlap=${overlap.length} prompt-tokens=${promptTokens.size} phase-tokens=${phaseKeywordsSet.size}`);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Abandonment detected — write session_audit row + emit warning context.
|
|
137
|
+
const harnessUrl = process.env.ARIA_HARNESS_URL || 'https://harness.ariasos.com';
|
|
138
|
+
const harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
|
|
139
|
+
|
|
140
|
+
if (harnessToken) {
|
|
141
|
+
// Fire-and-forget POST — don't block the user's turn waiting for audit
|
|
142
|
+
// write. The audit log entry below records whether the request was even
|
|
143
|
+
// attempted, so silence in this block means the audit is missing AND
|
|
144
|
+
// that fact is itself audited locally.
|
|
145
|
+
fetch(`${harnessUrl}/api/harness/audit/session`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: {
|
|
148
|
+
'Content-Type': 'application/json',
|
|
149
|
+
Authorization: `Bearer ${harnessToken}`,
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
session_id: sessionId,
|
|
153
|
+
surface: 'claude-code-userprompt-abandon-detect',
|
|
154
|
+
gate_name: 'plan-abandonment',
|
|
155
|
+
decision: 'warn',
|
|
156
|
+
reason: `Mid-turn plan abandonment detected. Active plan ${plan.planId || 'unknown'} (${plan.phases.length} phases) has ${phaseKeywordsSet.size} aggregate phase-keywords; incoming user prompt (${promptTokens.size} content-tokens) overlaps only ${overlap.length} (threshold=${OVERLAP_THRESHOLD}).`,
|
|
157
|
+
evidence_json: {
|
|
158
|
+
planId: plan.planId,
|
|
159
|
+
promptExcerpt: userPrompt.slice(0, 400),
|
|
160
|
+
overlapTokens: overlap,
|
|
161
|
+
promptTokenCount: promptTokens.size,
|
|
162
|
+
phaseKeywordCount: phaseKeywordsSet.size,
|
|
163
|
+
phaseSummaries: plan.phases.map((p) => ({ id: p.id, summary: String(p.summary || '').slice(0, 120) })),
|
|
164
|
+
threshold: OVERLAP_THRESHOLD,
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
}).catch(() => {/* network-level failure surfaces in audit log below */});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
audit('abandonment-detected', `planId=${plan.planId || 'unknown'} overlap=${overlap.length}/${OVERLAP_THRESHOLD} prompt-tokens=${promptTokens.size} phase-tokens=${phaseKeywordsSet.size}`);
|
|
171
|
+
|
|
172
|
+
// Inject a warning into the next turn's context so the orchestrator (Claude)
|
|
173
|
+
// sees the abandonment signal explicitly rather than treating the new prompt
|
|
174
|
+
// as plan-aligned. The orchestrator can decide to: (a) acknowledge the
|
|
175
|
+
// override + clear the active plan, or (b) ask the user whether to continue
|
|
176
|
+
// the prior plan after this excursion.
|
|
177
|
+
const context = `[ARIA_PLAN_ABANDONMENT_DETECTED — active plan ${plan.planId || 'unknown'} has ${plan.phases.length} phase(s); incoming user prompt overlaps only ${overlap.length} keyword(s) with active phases (threshold=${OVERLAP_THRESHOLD}). This is a CONSCIOUS USER OVERRIDE, not silent context loss. Orchestrator should: (1) acknowledge the direction change to the user, (2) decide whether to clear the active plan or keep it for resumption, (3) write the decision to session_audit. Active phases were: ${plan.phases.map((p) => `${p.id}: ${String(p.summary || '').slice(0, 80)}`).join(' | ')}]`;
|
|
178
|
+
|
|
179
|
+
console.log(JSON.stringify({
|
|
180
|
+
hookSpecificOutput: {
|
|
181
|
+
hookEventName: 'UserPromptSubmit',
|
|
182
|
+
additionalContext: context,
|
|
183
|
+
},
|
|
184
|
+
}));
|
|
185
|
+
process.exit(0);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test: aria-preturn-memory-gate.mjs — Enforcement Layer #49
|
|
3
|
+
//
|
|
4
|
+
// Runs the hook as a child process with simulated stdin events.
|
|
5
|
+
// Asserts correct output shape for both the block and allow paths.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node hooks/test-aria-preturn-memory-gate.mjs
|
|
8
|
+
// Exit code: 0 = all assertions passed, 1 = failure
|
|
9
|
+
|
|
10
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
11
|
+
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const HOOK = path.join(HERE, 'aria-preturn-memory-gate.mjs');
|
|
18
|
+
const HOME = process.env.HOME || '/tmp';
|
|
19
|
+
const CLAUDE_DIR = `${HOME}/.claude`;
|
|
20
|
+
const SESSION_ID = `test-preturn-${Date.now()}`;
|
|
21
|
+
|
|
22
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
|
|
27
|
+
function assert(label, condition, actual) {
|
|
28
|
+
if (condition) {
|
|
29
|
+
console.log(` PASS ${label}`);
|
|
30
|
+
passed++;
|
|
31
|
+
} else {
|
|
32
|
+
console.error(` FAIL ${label}`);
|
|
33
|
+
if (actual !== undefined) console.error(` got: ${JSON.stringify(actual)}`);
|
|
34
|
+
failed++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function turnStatePath(sid) {
|
|
39
|
+
const safe = String(sid).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
40
|
+
return `${CLAUDE_DIR}/aria-turn-state-${safe}.json`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cleanTurnState(sid) {
|
|
44
|
+
const p = turnStatePath(sid);
|
|
45
|
+
if (existsSync(p)) unlinkSync(p);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildEvent(overrides = {}) {
|
|
49
|
+
return {
|
|
50
|
+
tool_name: 'Bash',
|
|
51
|
+
tool_input: { command: 'ls .' },
|
|
52
|
+
session_id: SESSION_ID,
|
|
53
|
+
transcript_path: null,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildTranscriptWith(lines, filePath) {
|
|
59
|
+
const dir = path.dirname(filePath);
|
|
60
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
61
|
+
writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runHook(eventObj, env = {}) {
|
|
65
|
+
const result = spawnSync('node', [HOOK], {
|
|
66
|
+
input: JSON.stringify(eventObj),
|
|
67
|
+
encoding: 'utf-8',
|
|
68
|
+
env: { ...process.env, HOME, ...env },
|
|
69
|
+
});
|
|
70
|
+
let parsed = null;
|
|
71
|
+
try { parsed = JSON.parse(result.stdout.trim()); } catch { /* non-JSON stdout */ }
|
|
72
|
+
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr, parsed };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Setup ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
if (!existsSync(CLAUDE_DIR)) mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
78
|
+
|
|
79
|
+
// ── Test 1: no transcript → gate fires, block + recovery payload ───────
|
|
80
|
+
console.log('\nTest 1: no transcript (no context signals) → block with recovery');
|
|
81
|
+
{
|
|
82
|
+
cleanTurnState(SESSION_ID);
|
|
83
|
+
const event = buildEvent({ transcript_path: null });
|
|
84
|
+
const { exitCode, parsed } = runHook(event);
|
|
85
|
+
|
|
86
|
+
assert('exit code is 2 (block)', exitCode === 2, exitCode);
|
|
87
|
+
assert('decision is "block"', parsed?.decision === 'block', parsed?.decision);
|
|
88
|
+
assert('reason is a non-empty string', typeof parsed?.reason === 'string' && parsed.reason.length > 0, parsed?.reason);
|
|
89
|
+
assert(
|
|
90
|
+
'hookSpecificOutput.recovery.action is "run_context_loader"',
|
|
91
|
+
parsed?.hookSpecificOutput?.recovery?.action === 'run_context_loader',
|
|
92
|
+
parsed?.hookSpecificOutput?.recovery?.action,
|
|
93
|
+
);
|
|
94
|
+
assert(
|
|
95
|
+
'hookSpecificOutput.recovery.target is session ID',
|
|
96
|
+
parsed?.hookSpecificOutput?.recovery?.target === SESSION_ID,
|
|
97
|
+
parsed?.hookSpecificOutput?.recovery?.target,
|
|
98
|
+
);
|
|
99
|
+
assert(
|
|
100
|
+
'hookSpecificOutput.recovery.expectedContext includes "harness_packet"',
|
|
101
|
+
Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
|
|
102
|
+
parsed.hookSpecificOutput.recovery.expectedContext.includes('harness_packet'),
|
|
103
|
+
parsed?.hookSpecificOutput?.recovery?.expectedContext,
|
|
104
|
+
);
|
|
105
|
+
assert(
|
|
106
|
+
'hookSpecificOutput.recovery.expectedContext includes "memory_files"',
|
|
107
|
+
Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
|
|
108
|
+
parsed.hookSpecificOutput.recovery.expectedContext.includes('memory_files'),
|
|
109
|
+
parsed?.hookSpecificOutput?.recovery?.expectedContext,
|
|
110
|
+
);
|
|
111
|
+
assert(
|
|
112
|
+
'hookSpecificOutput.recovery.expectedContext includes "binding_plan"',
|
|
113
|
+
Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
|
|
114
|
+
parsed.hookSpecificOutput.recovery.expectedContext.includes('binding_plan'),
|
|
115
|
+
parsed?.hookSpecificOutput?.recovery?.expectedContext,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Test 2: transcript WITH all three signals → allow ─────────────────
|
|
120
|
+
console.log('\nTest 2: transcript with all three context signals → allow');
|
|
121
|
+
{
|
|
122
|
+
cleanTurnState(SESSION_ID);
|
|
123
|
+
const transcriptFile = path.join(tmpdir(), `test-transcript-preturn-${Date.now()}.jsonl`);
|
|
124
|
+
|
|
125
|
+
const assistantTextWithAllSignals = [
|
|
126
|
+
'🔐 Aria Harness — Session Packet loaded for turn.',
|
|
127
|
+
'[ARIA_DIRECTION] Proceed with implementation of Phase 8 research-first context injection.',
|
|
128
|
+
'<cognition>',
|
|
129
|
+
' nur: Current task is implementing the pre-turn memory gate per feedback_no_graceful_degradation.md and project_aria_as_controller_inversion.md.',
|
|
130
|
+
' mizan: Risk is that blocking without recovery creates dead-letter state. Soft-gate pattern resolves this.',
|
|
131
|
+
' hikma: feedback_no_flag_without_fix.md requires inline fix for any discovery.',
|
|
132
|
+
' tafakkur: The gate must deduplicate within 60s to prevent orchestrator retry loops.',
|
|
133
|
+
'</cognition>',
|
|
134
|
+
].join('\n');
|
|
135
|
+
|
|
136
|
+
buildTranscriptWith(
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
role: 'user',
|
|
140
|
+
message: {
|
|
141
|
+
role: 'user',
|
|
142
|
+
content: [{ type: 'text', text: 'Implement phase 8.' }],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
role: 'assistant',
|
|
147
|
+
message: {
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
content: [{ type: 'text', text: assistantTextWithAllSignals }],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
transcriptFile,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const event = buildEvent({ transcript_path: transcriptFile });
|
|
157
|
+
const { exitCode, parsed } = runHook(event);
|
|
158
|
+
|
|
159
|
+
assert('exit code is 0 (allow)', exitCode === 0, exitCode);
|
|
160
|
+
assert('no block decision in stdout (stdout is empty or non-block)', !parsed || parsed.decision !== 'block', parsed?.decision);
|
|
161
|
+
|
|
162
|
+
// Cleanup
|
|
163
|
+
try { unlinkSync(transcriptFile); } catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Test 3: deduplication — gate already fired within 60s → skip ───────
|
|
167
|
+
console.log('\nTest 3: gate already fired this turn (< 60s ago) → skip (exit 0)');
|
|
168
|
+
{
|
|
169
|
+
// Pre-write a turn-state showing the gate fired 5 seconds ago
|
|
170
|
+
const statePath = turnStatePath(SESSION_ID);
|
|
171
|
+
writeFileSync(statePath, JSON.stringify({ lastTurnGateFiredAt: Date.now() - 5000, lastDecision: 'block', signals: {} }), 'utf-8');
|
|
172
|
+
|
|
173
|
+
const event = buildEvent({ transcript_path: null });
|
|
174
|
+
const { exitCode } = runHook(event);
|
|
175
|
+
|
|
176
|
+
assert('exit code is 0 (dedup skip — no re-fire within 60s)', exitCode === 0, exitCode);
|
|
177
|
+
|
|
178
|
+
cleanTurnState(SESSION_ID);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Test 4: non-action tool (Read) → gate skips entirely ───────────────
|
|
182
|
+
console.log('\nTest 4: non-action tool (Read) → gate skips (exit 0)');
|
|
183
|
+
{
|
|
184
|
+
cleanTurnState(SESSION_ID);
|
|
185
|
+
const event = buildEvent({ tool_name: 'Read', transcript_path: null });
|
|
186
|
+
const { exitCode } = runHook(event);
|
|
187
|
+
assert('exit code is 0 (ungated read tool)', exitCode === 0, exitCode);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Test 5: kill-switch env disables gate ─────────────────────────────
|
|
191
|
+
console.log('\nTest 5: ARIA_PRETURN_MEMORY_GATE=off → bypass (exit 0)');
|
|
192
|
+
{
|
|
193
|
+
cleanTurnState(SESSION_ID);
|
|
194
|
+
const event = buildEvent({ transcript_path: null });
|
|
195
|
+
const { exitCode } = runHook(event, { ARIA_PRETURN_MEMORY_GATE: 'off' });
|
|
196
|
+
assert('exit code is 0 (kill-switch)', exitCode === 0, exitCode);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Test 6: only ONE signal present → still blocks ────────────────────
|
|
200
|
+
console.log('\nTest 6: only harness packet signal (missing direction + memory ref) → block');
|
|
201
|
+
{
|
|
202
|
+
cleanTurnState(SESSION_ID);
|
|
203
|
+
const transcriptFile = path.join(tmpdir(), `test-transcript-preturn-partial-${Date.now()}.jsonl`);
|
|
204
|
+
|
|
205
|
+
const assistantTextPartial = '🔐 Aria Harness — partial context only. No direction. No memory refs.';
|
|
206
|
+
|
|
207
|
+
buildTranscriptWith(
|
|
208
|
+
[
|
|
209
|
+
{
|
|
210
|
+
role: 'user',
|
|
211
|
+
message: {
|
|
212
|
+
role: 'user',
|
|
213
|
+
content: [{ type: 'text', text: 'Do the thing.' }],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
role: 'assistant',
|
|
218
|
+
message: {
|
|
219
|
+
role: 'assistant',
|
|
220
|
+
content: [{ type: 'text', text: assistantTextPartial }],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
transcriptFile,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const event = buildEvent({ transcript_path: transcriptFile });
|
|
228
|
+
const { exitCode, parsed } = runHook(event);
|
|
229
|
+
|
|
230
|
+
assert('exit code is 2 (block on partial signals)', exitCode === 2, exitCode);
|
|
231
|
+
assert('decision is "block"', parsed?.decision === 'block', parsed?.decision);
|
|
232
|
+
assert(
|
|
233
|
+
'reason mentions missing aria_direction',
|
|
234
|
+
typeof parsed?.reason === 'string' && parsed.reason.includes('aria_direction'),
|
|
235
|
+
parsed?.reason?.slice(0, 200),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
try { unlinkSync(transcriptFile); } catch {}
|
|
239
|
+
cleanTurnState(SESSION_ID);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Summary ────────────────────────────────────────────────────────────
|
|
243
|
+
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
|
244
|
+
if (failed > 0) {
|
|
245
|
+
process.exit(1);
|
|
246
|
+
} else {
|
|
247
|
+
console.log('All assertions passed.');
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -24,8 +24,12 @@ import type { AriaConfig } from '../config.js';
|
|
|
24
24
|
const HOOK_FILES = [
|
|
25
25
|
'aria-harness-via-sdk.mjs',
|
|
26
26
|
'aria-pre-tool-gate.mjs',
|
|
27
|
+
'aria-preturn-memory-gate.mjs',
|
|
27
28
|
'aria-stop-gate.mjs',
|
|
28
29
|
'aria-preprompt-consult.mjs',
|
|
30
|
+
'aria-trigger-autolearn.mjs',
|
|
31
|
+
'aria-userprompt-abandon-detect.mjs',
|
|
32
|
+
'doctrine_trigger_map.json',
|
|
29
33
|
];
|
|
30
34
|
// Compiled location: <pkg>/dist/aria-connector/src/connectors/claude-code.js
|
|
31
35
|
// (tsc preserves the src/ rooted layout under outDir). From this file:
|
|
@@ -84,6 +88,29 @@ const HOOKS_BLOCK = {
|
|
|
84
88
|
timeout: 12,
|
|
85
89
|
statusMessage: 'Pre-consulting Aria substrate for direction...',
|
|
86
90
|
},
|
|
91
|
+
{
|
|
92
|
+
// Aria trigger auto-learn — scans the user's prompt for correction
|
|
93
|
+
// patterns ("don't ___", "stop ___", ALL-CAPS frustration) and
|
|
94
|
+
// appends candidate trigger entries to ~/.claude/aria-trigger-queue.jsonl
|
|
95
|
+
// for human review. Phase 11 enforcement #47: harness teaches itself
|
|
96
|
+
// from corrections rather than staying frozen at hand-curated rules.
|
|
97
|
+
// Non-blocking — pure side effect, fast.
|
|
98
|
+
type: 'command',
|
|
99
|
+
command: 'node $HOME/.claude/hooks/aria-trigger-autolearn.mjs',
|
|
100
|
+
timeout: 3,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
// Aria-as-commander mid-turn abandonment detection (Phase 11 #50,
|
|
104
|
+
// criterion 3). When an active plan exists and the incoming user
|
|
105
|
+
// prompt's keyword overlap with active phases falls below threshold,
|
|
106
|
+
// surfaces a CONSCIOUS OVERRIDE signal in additionalContext + writes
|
|
107
|
+
// a session_audit row (gate_name='plan-abandonment', decision='warn').
|
|
108
|
+
// Prevents silent context loss when user changes direction mid-plan.
|
|
109
|
+
// Non-blocking — UserPromptSubmit can't block prompts.
|
|
110
|
+
type: 'command',
|
|
111
|
+
command: 'node $HOME/.claude/hooks/aria-userprompt-abandon-detect.mjs',
|
|
112
|
+
timeout: 4,
|
|
113
|
+
},
|
|
87
114
|
],
|
|
88
115
|
}],
|
|
89
116
|
PreToolUse: [{
|
|
@@ -92,11 +119,28 @@ const HOOKS_BLOCK = {
|
|
|
92
119
|
// / NotebookEdit get the same enforcement so orchestrator-side
|
|
93
120
|
// compliance mirrors what we demand from workers.
|
|
94
121
|
matcher: 'Bash|Edit|Write|NotebookEdit',
|
|
95
|
-
hooks: [
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
hooks: [
|
|
123
|
+
{
|
|
124
|
+
type: 'command',
|
|
125
|
+
command: 'node $HOME/.claude/hooks/aria-pre-tool-gate.mjs',
|
|
126
|
+
timeout: 5,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
// Pre-turn memory consumption gate (Enforcement Layer #49).
|
|
130
|
+
// Fires after the cognition gate passes. Checks the first action
|
|
131
|
+
// of each turn for ALL THREE context-loading signals:
|
|
132
|
+
// 1. 🔐 Aria Harness header (harness packet injected)
|
|
133
|
+
// 2. [ARIA_DIRECTION] / [ARIA_BINDING_PLAN] (preprompt-consult fired)
|
|
134
|
+
// 3. feedback_*.md / project_*.md memory reference (memory consumed)
|
|
135
|
+
// On miss: blocks with structured recovery JSON so the orchestrator
|
|
136
|
+
// can run the context-loader and retry — no dead-letter state.
|
|
137
|
+
// Turn-deduplication via ~/.claude/aria-turn-state-${sessionId}.json
|
|
138
|
+
// prevents retry loops (60s window).
|
|
139
|
+
type: 'command',
|
|
140
|
+
command: 'node $HOME/.claude/hooks/aria-preturn-memory-gate.mjs',
|
|
141
|
+
timeout: 5,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
100
144
|
}],
|
|
101
145
|
Stop: [{
|
|
102
146
|
// Stop gate — text-decision boundary. Reflexive replies fail this
|
|
@@ -142,7 +186,11 @@ function installHooks(claudeDir: string, logs: string[], opts: { force?: boolean
|
|
|
142
186
|
try { copyFileSync(dst, backup); logs.push(`Backed up existing ${name} → ${path.basename(backup)}`); } catch {}
|
|
143
187
|
}
|
|
144
188
|
copyFileSync(src, dst);
|
|
145
|
-
|
|
189
|
+
// Only the .mjs scripts need executable perms; data files (e.g. the
|
|
190
|
+
// doctrine_trigger_map.json shipped alongside hooks) get default 0o644.
|
|
191
|
+
if (name.endsWith('.mjs') || name.endsWith('.js')) {
|
|
192
|
+
try { chmodSync(dst, 0o755); } catch {}
|
|
193
|
+
}
|
|
146
194
|
logs.push(`Installed hook: ${name}`);
|
|
147
195
|
}
|
|
148
196
|
}
|
|
@@ -220,7 +268,7 @@ function wireHooksBlock(settings: Record<string, unknown>, logs: string[]): void
|
|
|
220
268
|
if (!settings.$schema) {
|
|
221
269
|
settings.$schema = 'https://json.schemastore.org/claude-code-settings.json';
|
|
222
270
|
}
|
|
223
|
-
logs.push(`Wired hooks into settings.json (${mergedEvents} events: SessionStart, UserPromptSubmit, PreToolUse, Stop) — merge-safe, preserves third-party entries`);
|
|
271
|
+
logs.push(`Wired hooks into settings.json (${mergedEvents} events: SessionStart, UserPromptSubmit, PreToolUse [cognition+memory-gate], Stop) — merge-safe, preserves third-party entries`);
|
|
224
272
|
}
|
|
225
273
|
|
|
226
274
|
function buildAriaSystemBlock(config: AriaConfig): string {
|