@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
package/hooks/aria-stop-gate.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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)
|
|
373
|
-
//
|
|
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
|
|
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
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
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:
|
|
404
|
-
resolutionType
|
|
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
|
-
|
|
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
|
+
}
|