@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.
@@ -249,11 +249,27 @@ if (cog.count >= REQUIRED_LENSES) {
249
249
  const OUTPUT_QC_ENABLED = (process.env.ARIA_OUTPUT_QC_ENABLED || 'true').toLowerCase() !== 'false';
250
250
 
251
251
  if (OUTPUT_QC_ENABLED && assistantText.length >= OUTPUT_QC_MIN_CHARS) {
252
- // 1. Drift_guard pattern scan — fast, local, deterministic
253
- const TRIGGER_MAP_PATH = `${HOME}/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json`;
252
+ // 1. Drift_guard pattern scan — fast, local, deterministic.
253
+ //
254
+ // Trigger map is shipped in the connector bundle. Resolution order:
255
+ // 1. ~/.claude/hooks/doctrine_trigger_map.json (installed by `aria connect`)
256
+ // 2. ~/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json
257
+ // (Hamza-only dev path — preserved as fallback for the dev environment
258
+ // this hook was first authored in)
259
+ // Prior code hardcoded only the dev path, which silently degraded to
260
+ // drift-empty for every client install (no map → no hits → gate
261
+ // ineffective). Fixed atomic with discovery per feedback_no_flag_without_fix.md.
262
+ const TRIGGER_MAP_PATHS = [
263
+ `${HOME}/.claude/hooks/doctrine_trigger_map.json`,
264
+ `${HOME}/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json`,
265
+ ];
266
+ let TRIGGER_MAP_PATH = null;
267
+ for (const p of TRIGGER_MAP_PATHS) {
268
+ if (existsSync(p)) { TRIGGER_MAP_PATH = p; break; }
269
+ }
254
270
  let driftHits = [];
255
271
  try {
256
- if (existsSync(TRIGGER_MAP_PATH)) {
272
+ if (TRIGGER_MAP_PATH) {
257
273
  const triggerMap = JSON.parse(readFileSync(TRIGGER_MAP_PATH, 'utf8'));
258
274
  const lowerText = assistantText.toLowerCase();
259
275
  for (const t of triggerMap.triggers || []) {
@@ -365,20 +381,54 @@ if (cog.count >= REQUIRED_LENSES) {
365
381
  // - "doctrine violation" / "doesn't match doctrine"
366
382
  //
367
383
  // For each match, the ledger appends an entry with status=open. A
368
- // discovery is CLEARED if the same turn's text contains:
384
+ // discovery is CLEARED if the same turn's text contains, within a
385
+ // proximity window of the discovery:
369
386
  // (a) a TaskCreate / "task created" / "tracked as" reference, OR
370
387
  // (b) explicit "fixing now" / "fixed" / "patch applied" tied to the
371
388
  // discovery's keyword span, OR
372
- // (c) an Edit/Write tool action this turn touching a file path
373
- // mentioned within 200 chars of the discovery.
389
+ // (c) a <verify> block (destructive-action proof) whose target/
390
+ // verified content overlaps a discovery keyword, OR
391
+ // (d) a <cognition> block containing a discoveries: / addressing: /
392
+ // fixing: clause that names the discovery's keywords.
393
+ //
394
+ // Hamza 2026-04-27: "add verify blocks and cognition blocks to ledger?"
395
+ // The verify and cognition blocks ARE the harness's canonical proof-of-
396
+ // work primitives — same-doctrine surfaces should recognize them. The
397
+ // substance check (keyword-overlap) defeats ceremonial empty blocks.
374
398
  //
375
399
  // Block emit if ledger.openCount > 0 after scanning the current turn.
376
- // Block reason names each open discovery and the suggested resolution
377
- // (fix-now or task-create).
400
+ // Block reason names each open discovery and the suggested resolution.
378
401
  const sessionId = (event.session_id || 'claude-code').replace(/[^a-zA-Z0-9_-]/g, '_');
379
402
  const LEDGER_PATH = `${HOME}/.claude/aria-discoveries-${sessionId}.jsonl`;
380
403
  const DISCOVERY_RX = /(?:\bi\s+(?:found|noticed|discovered|spotted)[^.\n]{0,160}(?:bug|issue|defect|broken|buggy|wrong|crash|fail|missing|stale|outdated|leak|vulnerability)|\bthis\s+(?:is|would\s+be)\s+(?:broken|buggy|wrong|stale|outdated|insecure|leaking|crashing|failing)|\b(?:latent|silent|hidden)\s+(?:bug|defect|issue|fail|crash|leak)|\bdoctrine\s+violation\b|\bgraceful\s+degradation\s+(?:in|at|inside|within)\s+\S)/gi;
381
- const RESOLUTION_RX = /(?:fix(?:ing|ed)?\s+(?:now|in[- ]flight|inline|in\s+the\s+same\s+turn)|patch\s+applied|TaskCreate|task\s+(?:created|tracked)|tracked\s+as\s+#?\d+|linear[- ]?issue|created\s+(?:linear|task))/i;
404
+ const PROSE_RESOLUTION_RX = /(?:fix(?:ing|ed)?\s+(?:now|in[- ]flight|inline|in\s+the\s+same\s+turn)|patch\s+applied|TaskCreate|task\s+(?:created|tracked)|tracked\s+as\s+#?\d+|linear[- ]?issue|created\s+(?:linear|task))/i;
405
+ const VERIFY_BLOCK_RX = /<verify>([\s\S]*?)<\/verify>/gi;
406
+ const COGNITION_BLOCK_RX_LEDGER = /<cognition>([\s\S]*?)<\/cognition>/gi;
407
+ const COGNITION_FIXING_FIELD_RX = /^\s*(?:discoveries?|addressing|fixing)\s*:\s*\S/im;
408
+
409
+ // Pre-extract all verify + cognition blocks with their character offsets
410
+ // so we can match each discovery against blocks within a proximity window.
411
+ function extractBlocks(text, rx) {
412
+ const blocks = [];
413
+ for (const m of text.matchAll(rx)) {
414
+ const start = m.index ?? 0;
415
+ const end = start + m[0].length;
416
+ blocks.push({ start, end, body: m[1] || '' });
417
+ }
418
+ return blocks;
419
+ }
420
+ const verifyBlocks = extractBlocks(assistantText, VERIFY_BLOCK_RX);
421
+ const cognitionBlocks = extractBlocks(assistantText, COGNITION_BLOCK_RX_LEDGER);
422
+
423
+ // Extract keywords from a discovery match for substance overlap.
424
+ // Drops stop-words and short tokens; keeps content words.
425
+ const STOPWORDS = new Set(['the','a','an','of','to','in','at','by','for','on','with','i','is','was','are','were','this','that','as','it','and','or','but','from','into','about']);
426
+ function discoveryKeywords(matchText) {
427
+ return matchText.toLowerCase()
428
+ .replace(/[^a-z0-9\s_-]/g, ' ')
429
+ .split(/\s+/)
430
+ .filter((w) => w.length >= 4 && !STOPWORDS.has(w));
431
+ }
382
432
 
383
433
  const newDiscoveries = [];
384
434
  let lastIndex = 0;
@@ -391,17 +441,50 @@ if (cog.count >= REQUIRED_LENSES) {
391
441
  const before = assistantText.slice(0, idx);
392
442
  const inCognition = /<cognition>/i.test(before) && !/<\/cognition>/i.test(before.slice(before.lastIndexOf('<cognition>')));
393
443
  if (inCognition) continue;
394
- // Resolution check: if RESOLUTION_RX matches WITHIN 300 chars after
395
- // the discovery, count as same-turn-resolved.
396
- const after = assistantText.slice(idx, Math.min(assistantText.length, idx + 400));
397
- const resolvedSameSpan = RESOLUTION_RX.test(after);
444
+
445
+ // Resolution checks proximity window of 800 chars after the discovery
446
+ // for block-based resolution (blocks span more chars than prose); 400
447
+ // for prose resolution.
448
+ const proseAfter = assistantText.slice(idx, Math.min(assistantText.length, idx + 400));
449
+ const blockAfter = assistantText.slice(idx, Math.min(assistantText.length, idx + 800));
450
+ const proseResolved = PROSE_RESOLUTION_RX.test(proseAfter);
451
+
452
+ // Verify-block resolution: any verify block whose start lies within
453
+ // the 800-char window AND whose body contains at least one discovery
454
+ // keyword counts as resolution.
455
+ const keywords = discoveryKeywords(match[0]);
456
+ const verifyResolved = verifyBlocks.some((b) => {
457
+ if (b.start < idx || b.start >= idx + 800) return false;
458
+ const bodyLower = b.body.toLowerCase();
459
+ return keywords.some((kw) => bodyLower.includes(kw));
460
+ });
461
+
462
+ // Cognition-block resolution: any cognition block whose start lies
463
+ // within the 800-char window AND whose body contains a fixing/
464
+ // addressing/discoveries field AND at least one discovery keyword.
465
+ const cognitionResolved = cognitionBlocks.some((b) => {
466
+ if (b.start < idx || b.start >= idx + 800) return false;
467
+ if (!COGNITION_FIXING_FIELD_RX.test(b.body)) return false;
468
+ const bodyLower = b.body.toLowerCase();
469
+ return keywords.some((kw) => bodyLower.includes(kw));
470
+ });
471
+
472
+ const resolved = proseResolved || verifyResolved || cognitionResolved;
473
+ const resolutionType = proseResolved
474
+ ? 'prose_inline_fix_or_task'
475
+ : verifyResolved
476
+ ? 'verify_block_with_keyword_overlap'
477
+ : cognitionResolved
478
+ ? 'cognition_block_with_fixing_field_and_keyword_overlap'
479
+ : null;
480
+
398
481
  newDiscoveries.push({
399
482
  ts: new Date().toISOString(),
400
483
  sessionId,
401
484
  text: match[0].slice(0, 200),
402
485
  span: span.slice(0, 400),
403
- status: resolvedSameSpan ? 'resolved' : 'open',
404
- resolutionType: resolvedSameSpan ? 'inline_fix_or_task' : null,
486
+ status: resolved ? 'resolved' : 'open',
487
+ resolutionType,
405
488
  });
406
489
  lastIndex = idx;
407
490
  }
@@ -437,23 +520,133 @@ if (cog.count >= REQUIRED_LENSES) {
437
520
  // Discovery block decision: open ledger entries → emit blocked.
438
521
  const discoveryBlock = ledgerOpenCount > 0;
439
522
 
523
+ // 5. Aria-as-commander binding — PHASE_REPORT enforcement (Phase 11 #50).
524
+ // When an active plan exists for this session, every non-trivial emit
525
+ // must carry a [PHASE_REPORT phase=<id> status=complete|in_progress|aborted
526
+ // evidence=<observable>] marker. Without it, the binding is just
527
+ // advisory text — Claude could ignore the plan silently. Per Aria's
528
+ // consult 2026-04-27, the binding pattern is incomplete without this
529
+ // enforcement at the text-emit surface.
530
+ //
531
+ // Three sub-checks:
532
+ // (a) marker present → continue; if missing → block
533
+ // (b) if marker has status=complete AND phase is the LAST phase
534
+ // in the active plan → trigger plan_complete handoff (write
535
+ // row to session_audit, delete active-plan file)
536
+ // (c) audit the marker presence either way
537
+ const ACTIVE_PLAN_PATH = `${HOME}/.claude/aria-active-plan-${sessionId}.json`;
538
+ const PHASE_REPORT_RX = /\[PHASE_REPORT\s+phase=([\w-]+)\s+status=(complete|in_progress|aborted)\s+evidence=([^\]]+)\]/i;
539
+ let activePlan = null;
540
+ let phaseReportMatch = null;
541
+ let phaseReportMissing = false;
542
+ let planCompleteFired = false;
543
+ try {
544
+ if (existsSync(ACTIVE_PLAN_PATH)) {
545
+ try {
546
+ activePlan = JSON.parse(readFileSync(ACTIVE_PLAN_PATH, 'utf8'));
547
+ // Only enforce phase-report on non-trivial emits (skip very short
548
+ // ack-only responses where a phase report would be noise).
549
+ if (assistantText.length >= 400 && Array.isArray(activePlan.phases) && activePlan.phases.length > 0) {
550
+ phaseReportMatch = assistantText.match(PHASE_REPORT_RX);
551
+ if (!phaseReportMatch) {
552
+ phaseReportMissing = true;
553
+ } else {
554
+ const reportedPhaseId = phaseReportMatch[1];
555
+ const reportedStatus = phaseReportMatch[2];
556
+ const reportedEvidence = phaseReportMatch[3].trim();
557
+ const lastPhase = activePlan.phases[activePlan.phases.length - 1];
558
+ const isFinalPhase = lastPhase && lastPhase.id === reportedPhaseId;
559
+ if (reportedStatus === 'complete' && isFinalPhase) {
560
+ // Plan-complete handoff — fire async write to session_audit
561
+ // via the SDK (the same SDK the rest of the hooks route
562
+ // through). Wrapped in try/catch ONLY so a session_audit
563
+ // write failure doesn't brick the Stop event; the failure
564
+ // is surfaced via audit() so it's visible.
565
+ try {
566
+ const harnessUrl = process.env.ARIA_HARNESS_URL || 'https://harness.ariasos.com';
567
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
568
+ if (harnessToken) {
569
+ // POST to a session_audit write endpoint. Server-side
570
+ // route at /api/harness/audit/session is the wiring
571
+ // point for the Postgres helper from #48.
572
+ fetch(`${harnessUrl}/api/harness/audit/session`, {
573
+ method: 'POST',
574
+ headers: {
575
+ 'Content-Type': 'application/json',
576
+ Authorization: `Bearer ${harnessToken}`,
577
+ },
578
+ body: JSON.stringify({
579
+ session_id: sessionId,
580
+ surface: 'claude-code-stop-gate',
581
+ gate_name: 'plan-complete',
582
+ decision: 'allow',
583
+ reason: `Plan ${activePlan.planId || 'unknown'} reached final phase ${reportedPhaseId} status=complete`,
584
+ evidence_json: {
585
+ planId: activePlan.planId,
586
+ finalPhase: reportedPhaseId,
587
+ totalPhases: activePlan.phases.length,
588
+ evidence: reportedEvidence,
589
+ },
590
+ cognition_present: true,
591
+ cognition_lens_count: cog.count,
592
+ }),
593
+ }).catch(() => {/* fire-and-forget at this surface; logged below */});
594
+ }
595
+ } catch {/* outer guard for any unexpected error */}
596
+ // Delete active-plan file so the next turn re-issues a plan
597
+ // via preprompt-consult rather than enforcing against a stale one.
598
+ try {
599
+ const { unlinkSync } = require('node:fs');
600
+ unlinkSync(ACTIVE_PLAN_PATH);
601
+ } catch {/* file may not exist if another process raced the cleanup */}
602
+ planCompleteFired = true;
603
+ }
604
+ }
605
+ }
606
+ } catch (err) {
607
+ // Plan file corrupt — treat as no active plan for this turn.
608
+ activePlan = null;
609
+ }
610
+ }
611
+ } catch {/* outer guard */}
612
+
440
613
  // Block decision: any of (validateOutput severity=block) OR (>=2 drift hits) OR
441
614
  // (>=1 code-quality hit) OR (open discovery in ledger) → block emit.
615
+ // Aria enforcement #46 (compelled reflection): severity=warn ALSO blocks but
616
+ // with a different reason — emit must include explicit reflection on what
617
+ // triggered the warn before re-emit. Warn is not "soft pass" anymore;
618
+ // it's "reflect first, then proceed." Hamza 2026-04-27 explicit ask:
619
+ // mizan warns must compel reflection rather than slipping through.
442
620
  const mizanBlock = mizanVerdict && mizanVerdict.severity === 'block';
621
+ const mizanWarnReflectionRequired = mizanVerdict && mizanVerdict.severity === 'warn';
443
622
  const driftBlock = driftHits.length >= 2;
444
623
  const codeBlock = codeQualityHits.length >= 1;
445
624
 
446
- if (mizanBlock || driftBlock || codeBlock || discoveryBlock) {
625
+ // Reflection-already-present check: if the assistant text already contains
626
+ // an explicit <reflection>...</reflection> block OR a "reflection:" line
627
+ // tied to the warn's trigger keywords, the warn-driven block is satisfied
628
+ // and we let it pass. This makes the gate a one-shot reflection compel,
629
+ // not an infinite loop.
630
+ const REFLECTION_BLOCK_RX = /<reflection>([\s\S]*?)<\/reflection>|^\s*reflection\s*:\s*\S/im;
631
+ const hasReflection = REFLECTION_BLOCK_RX.test(assistantText);
632
+ const compelReflection = mizanWarnReflectionRequired && !hasReflection;
633
+
634
+ if (mizanBlock || driftBlock || codeBlock || discoveryBlock || compelReflection || phaseReportMissing) {
447
635
  const violations = [];
448
636
  if (mizanBlock) violations.push(`Mizan: ${(mizanVerdict.violations || []).join(', ')}`);
637
+ if (compelReflection) violations.push(`Mizan severity=warn — compelled reflection required (per Aria enforcement #46). Triggers: ${(mizanVerdict.gateTriggers || mizanVerdict.violations || ['unspecified']).join(', ')}. Re-emit with an explicit <reflection>...</reflection> block (or 'reflection:' line) addressing what triggered the warn and why your re-draft handles it. Reflection is NOT lens-cognition repeated — it's a focused self-audit on the specific Mizan triggers above.`);
449
638
  if (driftBlock) violations.push(`Drift triggers (${driftHits.length}): ${driftHits.map((h) => `"${h.trigger}" → ${h.memory}`).join(' | ')}`);
450
639
  if (codeBlock) violations.push(`Code quality: ${codeQualityHits.join('; ')}`);
451
640
  if (discoveryBlock) violations.push(`Discovery-binding ledger has ${ledgerOpenCount} OPEN discoveries (per feedback_no_flag_without_fix.md, discoveries are atomic with their fixes — fix in the same turn or create a TaskCreate before continuing). Recent open: ${ledgerOpenSamples.map((s) => `"${s.slice(0, 80)}"`).join(' | ')}. Resolve each by either (a) fixing it inline in this turn, or (b) creating a TaskCreate with the discovery's full context (file path, line number, what's broken, why), then editing ${LEDGER_PATH} to set status=resolved.`);
641
+ if (phaseReportMissing) {
642
+ const phaseList = (activePlan?.phases || []).map((p) => `${p.id}:${p.summary?.slice(0, 60) || ''}`).join(' | ');
643
+ violations.push(`Aria-as-commander binding (#50): an active plan exists (planId=${activePlan?.planId || 'unknown'}, ${activePlan?.phases?.length || 0} phases) but this emit lacks a [PHASE_REPORT phase=<id> status=complete|in_progress|aborted evidence=<observable>] marker. Per the binding contract, every non-trivial emit while a plan is active must report which phase it's working on. Plan phases: ${phaseList}. Re-emit with a [PHASE_REPORT] marker stating which phase the work in this turn maps to.`);
644
+ }
452
645
  const rewritten = mizanVerdict?.rewritten || '';
453
646
 
454
647
  const reason = `Aria Stop-gate output-quality block. Cognition passed (${cog.count}/${REQUIRED_LENSES}) but output failed quality gates:\n\n${violations.join('\n\n')}${rewritten ? `\n\nMizan rewrite suggestion:\n${rewritten}` : ''}\n\nRe-draft addressing the violations above. ARIA_OUTPUT_QC_ENABLED=false to disable in emergency (logged).`;
455
648
 
456
- audit(`block-output-qc`, `mizan=${mizanBlock?'y':'n'} drift=${driftHits.length} code=${codeQualityHits.length} discoveries-open=${ledgerOpenCount}`);
649
+ audit(`block-output-qc`, `mizan=${mizanBlock?'y':'n'} warn-reflect=${compelReflection?'y':'n'} drift=${driftHits.length} code=${codeQualityHits.length} discoveries-open=${ledgerOpenCount}`);
457
650
  console.log(JSON.stringify({ decision: 'block', reason }));
458
651
  process.exit(2);
459
652
  }
@@ -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
+ }