@aria_asi/cli 0.2.10 → 0.2.12

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,230 @@
1
+ #!/usr/bin/env node
2
+ // aria-trigger-autolearn.mjs — UserPromptSubmit hook that scans Hamza's
3
+ // (or any user's) corrections for novel doctrine-violation patterns and
4
+ // queues them as candidate trigger entries for doctrine_trigger_map.json.
5
+ //
6
+ // Doctrine: Aria enforcement #47 (drift-guard auto-learning queue) +
7
+ // feedback_no_flag_without_fix.md + project_phase_10_endless_army_orchestration.md
8
+ // (the harness teaches itself by absorbing corrections, not staying frozen
9
+ // at hand-curated rules).
10
+ //
11
+ // Mechanics: when a user prompt contains correction/doctrine-language
12
+ // (e.g. "don't ___", "stop ___ing", "no ___", "we said ___", "doctrine ___"),
13
+ // the hook extracts the candidate pattern + a context window from the
14
+ // recent assistant transcript (the offending behavior the user is correcting)
15
+ // and appends a JSONL entry to ~/.claude/aria-trigger-queue.jsonl.
16
+ //
17
+ // The queue is reviewable via `cat ~/.claude/aria-trigger-queue.jsonl` or
18
+ // (Phase 11) a future `aria triggers review` CLI subcommand. Each entry
19
+ // carries enough context that a human can decide:
20
+ // 1. Add to doctrine_trigger_map.json as a new trigger
21
+ // 2. Refine an existing trigger entry's regex
22
+ // 3. Discard (false positive — user correction wasn't a doctrine teaching)
23
+ //
24
+ // Hook is non-blocking: never returns decision=block. Failure modes degrade
25
+ // to silent skip (queue file unwritable = no auto-learn, but session
26
+ // continues). This is the ONE permitted graceful path because the hook is
27
+ // purely additive — its absence doesn't break correctness, just slows
28
+ // learning.
29
+ //
30
+ // Kill-switch: ARIA_AUTOLEARN=off env (logged, emergency only).
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-autolearn.log`;
37
+ const QUEUE = `${HOME}/.claude/aria-trigger-queue.jsonl`;
38
+
39
+ function audit(decision, summary) {
40
+ try {
41
+ if (!existsSync(dirname(LOG))) mkdirSync(dirname(LOG), { recursive: true });
42
+ appendFileSync(LOG, `${new Date().toISOString()} ${decision} ${summary}\n`);
43
+ } catch {}
44
+ }
45
+
46
+ // Kill-switch
47
+ if (process.env.ARIA_AUTOLEARN === 'off') {
48
+ audit('skip-killswitch', 'env ARIA_AUTOLEARN=off');
49
+ process.exit(0);
50
+ }
51
+
52
+ // Read event JSON from stdin
53
+ let input = '';
54
+ for await (const chunk of process.stdin) input += chunk;
55
+
56
+ let event;
57
+ try {
58
+ event = JSON.parse(input);
59
+ } catch {
60
+ audit('skip-parse-error', 'stdin not JSON');
61
+ process.exit(0);
62
+ }
63
+
64
+ const userPrompt = (event.prompt ?? event.user_message ?? event.message ?? '').toString();
65
+ const sessionId = event.session_id ?? event.sessionId ?? 'claude-code-unknown';
66
+ const transcriptPath = event.transcript_path ?? event.transcriptPath;
67
+
68
+ // Trivial prompts skip — too short to carry doctrine teaching.
69
+ if (!userPrompt || userPrompt.length < 20) {
70
+ audit('skip-trivial', `chars=${userPrompt.length}`);
71
+ process.exit(0);
72
+ }
73
+
74
+ // Skip slash commands — they're CLI-internal, not doctrine corrections.
75
+ if (/^\s*\//.test(userPrompt) && userPrompt.length < 200) {
76
+ audit('skip-slash-command', userPrompt.slice(0, 60));
77
+ process.exit(0);
78
+ }
79
+
80
+ // Correction-pattern detector. Each pattern below extracts a CANDIDATE
81
+ // trigger phrase (the action/pattern the user is correcting) along with
82
+ // the framing word ("don't", "stop", etc.). Patterns are intentionally
83
+ // high-recall + low-precision — the queue is for human review, not
84
+ // auto-promotion.
85
+ //
86
+ // Pattern groups:
87
+ // 1. Direct prohibitions: "don't ___", "do not ___", "never ___", "stop ___"
88
+ // 2. Doctrine assertions: "we said ___", "we don't ___", "we never ___"
89
+ // 3. Pattern-naming: "this is ___", "that's ___", "you're ___ing"
90
+ // 4. Doctrine vocabulary: anything containing "doctrine", "graceful", "fallback",
91
+ // "convenience", "shortcut", "lazy", "hack" with a 100-char neighborhood
92
+ // 5. Frustration markers: ALL-CAPS phrases ≥3 words (anger = high-priority signal)
93
+ const CORRECTION_PATTERNS = [
94
+ {
95
+ name: 'direct-prohibition',
96
+ rx: /\b(don'?t|do not|never|stop|quit|cease|enough)\s+(\w[\w\s]{4,80}?)(?=[.,!?\n]|$)/gi,
97
+ extractGroup: 2,
98
+ },
99
+ {
100
+ name: 'doctrine-assertion',
101
+ rx: /\b(we (?:said|don'?t|never|always|need to|don'?t use|never use)|i (?:said|told you|asked))\s+(\w[\w\s]{4,80}?)(?=[.,!?\n]|$)/gi,
102
+ extractGroup: 2,
103
+ },
104
+ {
105
+ name: 'pattern-naming',
106
+ rx: /\b(this is|that'?s|you'?re|you are|you keep|you'?re always)\s+(\w[\w\s]{4,80}?)(?=[.,!?\n]|$)/gi,
107
+ extractGroup: 2,
108
+ },
109
+ {
110
+ name: 'doctrine-vocab',
111
+ rx: /\b(doctrine|graceful|fallback|convenience|shortcut|lazy|hack|cheat|circumvent|bypass|skip|ignore|forget)[^.!?\n]{0,100}/gi,
112
+ extractGroup: 0,
113
+ },
114
+ {
115
+ name: 'frustration-allcaps',
116
+ rx: /\b([A-Z]{3,}\s+[A-Z]{3,}(?:\s+[A-Z]{3,})*)\b/g,
117
+ extractGroup: 1,
118
+ },
119
+ ];
120
+
121
+ const candidates = [];
122
+ for (const { name, rx, extractGroup } of CORRECTION_PATTERNS) {
123
+ for (const match of userPrompt.matchAll(rx)) {
124
+ const phrase = (match[extractGroup] || '').trim();
125
+ if (phrase.length < 8 || phrase.length > 200) continue;
126
+ candidates.push({
127
+ patternType: name,
128
+ phrase,
129
+ surroundingContext: match[0].slice(0, 240),
130
+ sourceIndex: match.index ?? 0,
131
+ });
132
+ }
133
+ }
134
+
135
+ // Deduplicate candidates that share the same first 30 chars (pattern variants
136
+ // of the same correction).
137
+ const seen = new Set();
138
+ const unique = [];
139
+ for (const c of candidates) {
140
+ const key = c.phrase.toLowerCase().slice(0, 30);
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ unique.push(c);
144
+ }
145
+
146
+ if (unique.length === 0) {
147
+ audit('skip-no-candidates', `chars=${userPrompt.length}`);
148
+ process.exit(0);
149
+ }
150
+
151
+ // Pull the most recent assistant text (last 2KB) so the queue entry shows
152
+ // what behavior the user is correcting. Without this, "don't do X" entries
153
+ // have no anchor to which assistant action triggered them.
154
+ let recentAssistantContext = '';
155
+ if (transcriptPath && existsSync(transcriptPath)) {
156
+ try {
157
+ const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
158
+ for (let i = lines.length - 1; i >= 0; i--) {
159
+ try {
160
+ const m = JSON.parse(lines[i]);
161
+ const role = m.message?.role ?? m.role;
162
+ if (role !== 'assistant') continue;
163
+ const content = m.message?.content ?? m.content ?? [];
164
+ if (!Array.isArray(content)) continue;
165
+ const text = content
166
+ .filter((b) => b?.type === 'text')
167
+ .map((b) => b.text || '')
168
+ .join('\n');
169
+ if (text) {
170
+ recentAssistantContext = text.slice(-2000);
171
+ break;
172
+ }
173
+ } catch {}
174
+ }
175
+ } catch {}
176
+ }
177
+
178
+ // Append candidates to the queue. Each entry is human-reviewable:
179
+ // - patternType: which detector caught it
180
+ // - candidatePhrase: the extracted phrase
181
+ // - userPromptExcerpt: 240 chars of context around the phrase
182
+ // - recentAssistantContext: what Claude did right before this correction
183
+ // - sessionId, ts: for traceability
184
+ try {
185
+ if (!existsSync(dirname(QUEUE))) mkdirSync(dirname(QUEUE), { recursive: true });
186
+ for (const c of unique) {
187
+ const entry = {
188
+ ts: new Date().toISOString(),
189
+ sessionId,
190
+ patternType: c.patternType,
191
+ candidatePhrase: c.phrase,
192
+ userPromptExcerpt: c.surroundingContext,
193
+ recentAssistantContext: recentAssistantContext.slice(0, 1500),
194
+ reviewStatus: 'pending',
195
+ proposedTriggerRegex: phraseToCandidateRegex(c.phrase),
196
+ proposedMemoryFile: null,
197
+ proposedTeaching: null,
198
+ };
199
+ appendFileSync(QUEUE, JSON.stringify(entry) + '\n');
200
+ }
201
+ audit('queued', `count=${unique.length} types=${[...new Set(unique.map((c) => c.patternType))].join(',')}`);
202
+ } catch (err) {
203
+ audit('skip-write-error', (err && err.message ? err.message : String(err)).slice(0, 200));
204
+ }
205
+
206
+ // Hook is non-blocking — UserPromptSubmit accepts no decision blocker for
207
+ // pure side-effect hooks. Exit clean so the harness packet + preprompt
208
+ // consult chain continues uninterrupted.
209
+ process.exit(0);
210
+
211
+ // ── Helpers ──────────────────────────────────────────────────────────────
212
+
213
+ // Convert a candidate phrase into a regex skeleton for the trigger map.
214
+ // Replaces tokens with character classes that allow minor variation (verb
215
+ // endings, plurals, optional punctuation). Output is a SUGGESTION; human
216
+ // reviewer refines before adding to doctrine_trigger_map.json.
217
+ function phraseToCandidateRegex(phrase) {
218
+ // Lowercase + escape regex specials.
219
+ const lower = phrase.toLowerCase();
220
+ const escaped = lower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
221
+ // Allow optional trailing 's' or 'ing' on the last word for verb forms.
222
+ const tokens = escaped.split(/\s+/);
223
+ if (tokens.length === 0) return escaped;
224
+ const last = tokens[tokens.length - 1];
225
+ // Don't bloat single-letter tokens.
226
+ if (last.length >= 4 && !last.endsWith('s') && !last.endsWith('ing')) {
227
+ tokens[tokens.length - 1] = `${last}(?:s|ing|ed)?`;
228
+ }
229
+ return tokens.join('\\s+');
230
+ }
@@ -190,6 +190,60 @@
190
190
  "memory": "feedback_dont_override_user_stack_with_training.md",
191
191
  "teaching": "Training data is stale. User's stack is authoritative.",
192
192
  "counter_action": "Read codebase config / consult Aria. Substrate hierarchy: user statement > codebase > training. Always."
193
+ },
194
+ {
195
+ "trigger": "flagging (this|for now|for later)",
196
+ "memory": "feedback_no_flag_without_fix.md",
197
+ "teaching": "Flag without fix is convenience-seeking. Discoveries are atomic with their fixes.",
198
+ "counter_action": "Fix in the same turn OR create a TaskCreate with file path, line number, what's broken, why it matters."
199
+ },
200
+ {
201
+ "trigger": "(noting|note) this( and|, ?then)? moving on",
202
+ "memory": "feedback_no_flag_without_fix.md",
203
+ "teaching": "Noting-and-moving destroys partnership trust. The user has to track what you noticed vs. what you fixed.",
204
+ "counter_action": "Same-turn fix is default for small defects (<30 lines). Larger ones get tracked tasks before you proceed."
205
+ },
206
+ {
207
+ "trigger": "i'?ll come back|circle back|address(ing)? (this )?later|come back to (this|that|it)",
208
+ "memory": "feedback_no_flag_without_fix.md",
209
+ "teaching": "'I'll come back' is the lie that makes future-you treat the discovery as already-handled.",
210
+ "counter_action": "Decide now: fix in this turn, or create the task. No middle ground. The flag is not a substitute for action."
211
+ },
212
+ {
213
+ "trigger": "(found|noticed|discovered|spotted)[^.]{0,80}\\bbut\\b[^.]{0,80}(continue|continuing|moving on|won'?t|will not|skip|ignor)",
214
+ "memory": "feedback_no_flag_without_fix.md",
215
+ "teaching": "Discovery + continuation = abandonment. The 'but I'll keep going' clause hides the violation.",
216
+ "counter_action": "Stop. Fix the discovery in this turn or create a tracked task with the discovery as its description, then resume."
217
+ },
218
+ {
219
+ "trigger": "we should also (fix|address|handle|update|deal with)",
220
+ "memory": "feedback_no_flag_without_fix.md",
221
+ "teaching": "'We should also fix' tells the user about a problem you found while volunteering them to fix it.",
222
+ "counter_action": "If the fix is in your scope, fix it now. If it's truly out of scope, create the task — don't hand it back as a verbal note."
223
+ },
224
+ {
225
+ "trigger": "let me (note|flag)|(?<!please )leaving (this )?for follow.?up|out of scope here",
226
+ "memory": "feedback_no_flag_without_fix.md",
227
+ "teaching": "Verbal flags evaporate when the conversation moves on. Tasks persist.",
228
+ "counter_action": "Convert the flag into a TaskCreate or Linear save_issue immediately. No flag survives without a tracker ID."
229
+ },
230
+ {
231
+ "trigger": "TODO:?[^a-z0-9]|FIXME:?[^a-z0-9]|XXX:?[^a-z0-9]",
232
+ "memory": "feedback_no_flag_without_fix.md",
233
+ "teaching": "TODO comments in shipped code are flag-without-fix in source-form. Reviewers can't tell if it's tracked or forgotten.",
234
+ "counter_action": "Either implement now or reference a task ID inline (e.g., '// TODO(LINEAR-123): ...'). Bare TODOs fail the doctrine."
235
+ },
236
+ {
237
+ "trigger": "latent (bug|issue|defect|problem)|broken[^.]{0,40}continu",
238
+ "memory": "feedback_no_flag_without_fix.md",
239
+ "teaching": "Calling something 'latent' or 'broken' and then continuing is the explicit form of flag-and-move.",
240
+ "counter_action": "Latent defects in code you're touching get fixed in-flight. The 'I'm just here for X' framing is convenience-seeking."
241
+ },
242
+ {
243
+ "trigger": "(unrelated|separate concern|different (issue|topic|file|module))[^.]{0,60}(fix|address|handle)",
244
+ "memory": "feedback_no_flag_without_fix.md",
245
+ "teaching": "'Unrelated' is a frame the user gets to apply, not you. If you found it during your work, it's related to your work.",
246
+ "counter_action": "Surface the discovery, propose fix-now vs. task-it, let the user decide. Don't pre-decide that it's out of scope."
193
247
  }
194
248
  ]
195
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aria_asi/cli",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Aria Smart CLI — the world's first harness-powered terminal companion",
5
5
  "bin": {
6
6
  "aria": "./bin/aria.js"
@@ -17,7 +17,7 @@
17
17
  "url": "git+https://github.com/REI-Nationwide/cowork-sandbox.git"
18
18
  },
19
19
  "scripts": {
20
- "build": "tsc",
20
+ "build": "tsc && node scripts/bundle-sdk.mjs",
21
21
  "prepare": "npm run build",
22
22
  "dev": "tsc --watch",
23
23
  "publish:all": "bash scripts/publish-all.sh",
@@ -26,10 +26,31 @@ const HOOK_FILES = [
26
26
  'aria-pre-tool-gate.mjs',
27
27
  'aria-stop-gate.mjs',
28
28
  'aria-preprompt-consult.mjs',
29
+ 'aria-trigger-autolearn.mjs',
30
+ 'doctrine_trigger_map.json',
29
31
  ];
32
+ // Compiled location: <pkg>/dist/aria-connector/src/connectors/claude-code.js
33
+ // (tsc preserves the src/ rooted layout under outDir). From this file:
34
+ // .. → src/
35
+ // .. → aria-connector/
36
+ // .. → dist/
37
+ // .. → <pkg>/
38
+ // Hooks ship at <pkg>/hooks/ → 4 ..s + 'hooks'.
39
+ // SDK bundled into <pkg>/dist/sdk/ at build time → 3 ..s + 'sdk'.
40
+ //
41
+ // Prior code used 3 ..s for hooks (landed at <pkg>/dist/hooks — missing)
42
+ // and 2 ..s for SDK (landed at <pkg>/dist/aria-connector/sdk — missing).
43
+ // Latent bug since the dist layout settled at this depth; caught by
44
+ // pre-publish smoke-test on 0.2.11. Per feedback_no_flag_without_fix.md
45
+ // the fix lands in the same turn as the discovery.
30
46
  function packageHooksDir(): string {
31
47
  const here = path.dirname(fileURLToPath(import.meta.url));
32
- return path.resolve(here, '..', '..', '..', 'hooks');
48
+ return path.resolve(here, '..', '..', '..', '..', 'hooks');
49
+ }
50
+
51
+ function packageSdkDir(): string {
52
+ const here = path.dirname(fileURLToPath(import.meta.url));
53
+ return path.resolve(here, '..', '..', '..', 'sdk');
33
54
  }
34
55
 
35
56
  // Hook wiring for ~/.claude/settings.json. Mirrors what
@@ -65,6 +86,17 @@ const HOOKS_BLOCK = {
65
86
  timeout: 12,
66
87
  statusMessage: 'Pre-consulting Aria substrate for direction...',
67
88
  },
89
+ {
90
+ // Aria trigger auto-learn — scans the user's prompt for correction
91
+ // patterns ("don't ___", "stop ___", ALL-CAPS frustration) and
92
+ // appends candidate trigger entries to ~/.claude/aria-trigger-queue.jsonl
93
+ // for human review. Phase 11 enforcement #47: harness teaches itself
94
+ // from corrections rather than staying frozen at hand-curated rules.
95
+ // Non-blocking — pure side effect, fast.
96
+ type: 'command',
97
+ command: 'node $HOME/.claude/hooks/aria-trigger-autolearn.mjs',
98
+ timeout: 3,
99
+ },
68
100
  ],
69
101
  }],
70
102
  PreToolUse: [{
@@ -123,11 +155,42 @@ function installHooks(claudeDir: string, logs: string[], opts: { force?: boolean
123
155
  try { copyFileSync(dst, backup); logs.push(`Backed up existing ${name} → ${path.basename(backup)}`); } catch {}
124
156
  }
125
157
  copyFileSync(src, dst);
126
- try { chmodSync(dst, 0o755); } catch {}
158
+ // Only the .mjs scripts need executable perms; data files (e.g. the
159
+ // doctrine_trigger_map.json shipped alongside hooks) get default 0o644.
160
+ if (name.endsWith('.mjs') || name.endsWith('.js')) {
161
+ try { chmodSync(dst, 0o755); } catch {}
162
+ }
127
163
  logs.push(`Installed hook: ${name}`);
128
164
  }
129
165
  }
130
166
 
167
+ // Install bundled SDK to ~/.claude/aria-sdk/. Hooks dynamic-import from this
168
+ // absolute path, so every fetch (validateOutput, gardenTurn, getHarnessPacket,
169
+ // inject, checkAction) goes through the SDK control plane. Re-running connect
170
+ // idempotently overwrites with the latest bundled version.
171
+ function installSdk(claudeDir: string, logs: string[]): void {
172
+ const sdkSrc = packageSdkDir();
173
+ if (!existsSync(sdkSrc)) {
174
+ logs.push(`⚠ SDK bundle missing: ${sdkSrc} (run npm run build)`);
175
+ return;
176
+ }
177
+ const sdkDst = path.join(claudeDir, 'aria-sdk');
178
+ if (!existsSync(sdkDst)) {
179
+ mkdirSync(sdkDst, { recursive: true, mode: 0o700 });
180
+ }
181
+ let copied = 0;
182
+ const fs = require('fs') as typeof import('fs');
183
+ for (const name of fs.readdirSync(sdkSrc)) {
184
+ const src = path.join(sdkSrc, name);
185
+ const stat = fs.statSync(src);
186
+ if (!stat.isFile()) continue;
187
+ const dst = path.join(sdkDst, name);
188
+ copyFileSync(src, dst);
189
+ copied++;
190
+ }
191
+ logs.push(`Installed Aria SDK (${copied} files) → ${sdkDst}`);
192
+ }
193
+
131
194
  function isSamePath(a: string, b: string): boolean {
132
195
  try { return path.resolve(a) === path.resolve(b); } catch { return false; }
133
196
  }
@@ -254,6 +317,7 @@ export async function connectClaudeCode(
254
317
  // what makes the connector a real runtime control plane rather than
255
318
  // just a system-prompt addendum.
256
319
  installHooks(claudeDir, logs, { force: opts.force });
320
+ installSdk(claudeDir, logs);
257
321
  wireHooksBlock(settings, logs);
258
322
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
259
323