@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aria_asi/cli",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Aria Smart CLI — the world's first harness-powered terminal companion",
5
5
  "bin": {
6
6
  "aria": "./bin/aria.js"
@@ -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
- type: 'command',
97
- command: 'node $HOME/.claude/hooks/aria-pre-tool-gate.mjs',
98
- timeout: 5,
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
- try { chmodSync(dst, 0o755); } catch {}
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 {