@aria_asi/cli 0.2.24 → 0.2.26

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,101 @@
1
+ #!/usr/bin/env node
2
+ // aria-discovery-record.mjs — invoked by sub-agents via Bash to append findings
3
+ // to their session ledger. Usage:
4
+ // echo '{"text":"defect found...","kind":"missing-export","refs":["file.ts:42"]}' | node ~/.claude/hooks/aria-discovery-record.mjs
5
+ //
6
+ // Tier-aware: client tier writes to /var/lib/aria-licensee/{jti}/aria-discoveries-{session}.jsonl,
7
+ // owner tier writes to ~/.claude/aria-discoveries-{session}.jsonl.
8
+ //
9
+ // Session ID resolution priority:
10
+ // 1. CLAUDE_SESSION_ID env (set by Claude Code in the sub-agent process)
11
+ // 2. ARIA_SESSION_ID env (set by spawnSubAgent caller)
12
+ // 3. ARIA_SUB_SESSION_ID env (explicit sub-session override)
13
+ // 4. Derived from handoff parentSessionId suffixed with "-sub" to ensure
14
+ // the sub-agent ledger file differs from the parent file so ledger-merge
15
+ // picks it up (merge skips files whose path == parentLedgerPath).
16
+ //
17
+ // Doctrine: feedback_no_flag_without_fix.md — findings recorded, not deferred.
18
+ // feedback_implementation_coupled_cognition.md — write IS the impl.
19
+
20
+ import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
21
+ import { dirname } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+
24
+ const HOME = homedir();
25
+ const LICENSE_PATH = `${HOME}/.aria/license.json`;
26
+ const HANDOFF_PATH = `${HOME}/.claude/aria-agent-harness-handoff.json`;
27
+
28
+ let input = '';
29
+ for await (const chunk of process.stdin) input += chunk;
30
+ let entry;
31
+ try { entry = JSON.parse(input); } catch {
32
+ process.stderr.write('discovery-record: invalid JSON on stdin\n');
33
+ process.exit(1);
34
+ }
35
+
36
+ if (!entry.text || typeof entry.text !== 'string' || entry.text.length < 4) {
37
+ process.stderr.write('discovery-record: text required (>=4 chars)\n');
38
+ process.exit(1);
39
+ }
40
+
41
+ // Detect tier + resolve JTI
42
+ let isClientTier = false;
43
+ let jti = null;
44
+ try {
45
+ if (existsSync(LICENSE_PATH)) {
46
+ const lic = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
47
+ jti = lic.jti ?? null;
48
+ isClientTier = Boolean(jti);
49
+ }
50
+ } catch { /* non-fatal — treat as owner tier */ }
51
+
52
+ // Session ID: prefer env var set by Claude Code, fall back to handoff derivation.
53
+ // IMPORTANT: sub-agents MUST NOT use parentSessionId directly — that produces
54
+ // the same ledger path as the parent, which aria-agent-ledger-merge.mjs skips.
55
+ // We use CLAUDE_SESSION_ID (the sub-agent's own session) or suffix the parent
56
+ // session with "-sub" so the file is distinct and mergeable.
57
+ let sessionId =
58
+ process.env.CLAUDE_SESSION_ID ||
59
+ process.env.ARIA_SESSION_ID ||
60
+ process.env.ARIA_SUB_SESSION_ID ||
61
+ null;
62
+
63
+ if (!sessionId) {
64
+ try {
65
+ if (existsSync(HANDOFF_PATH)) {
66
+ const handoff = JSON.parse(readFileSync(HANDOFF_PATH, 'utf8'));
67
+ const parentSession = handoff.parentSessionId;
68
+ // Derive a distinct sub-agent session so merge picks up this file.
69
+ sessionId = parentSession ? `${parentSession}-sub` : 'sub-unknown';
70
+ }
71
+ } catch { /* non-fatal */ }
72
+ }
73
+ sessionId = sessionId || 'sub-unknown';
74
+ const safeSession = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
75
+
76
+ // Resolve ledger path (tier-scoped)
77
+ const ledgerPath = isClientTier
78
+ ? `/var/lib/aria-licensee/${jti}/aria-discoveries-${safeSession}.jsonl`
79
+ : `${HOME}/.claude/aria-discoveries-${safeSession}.jsonl`;
80
+
81
+ // Compose the row
82
+ const row = {
83
+ at: new Date().toISOString(),
84
+ session: sessionId,
85
+ kind: entry.kind || 'observation',
86
+ text: entry.text,
87
+ refs: Array.isArray(entry.refs) ? entry.refs : [],
88
+ evidence: entry.evidence || null,
89
+ source: entry.source || 'sub-agent',
90
+ resolution_status: entry.resolution_status || 'open',
91
+ };
92
+
93
+ try {
94
+ mkdirSync(dirname(ledgerPath), { recursive: true });
95
+ appendFileSync(ledgerPath, JSON.stringify(row) + '\n');
96
+ process.stdout.write(JSON.stringify({ ok: true, ledger: ledgerPath, at: row.at }) + '\n');
97
+ } catch (err) {
98
+ process.stderr.write(`discovery-record: write failed: ${err.message}\n`);
99
+ process.exit(2);
100
+ }
101
+ process.exit(0);
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // aria-outcome-record.mjs — PostToolUse hook that records action outcomes
3
+ // to /api/harness/outcome-record (Sonnet H's #76 endpoint). Composes with
4
+ // aria-discovery-record + the regression sweeper to make the outcome ledger
5
+ // actually accumulate rows.
6
+ //
7
+ // Fires on every Bash|Edit|Write|NotebookEdit tool completion.
8
+ // Fire-and-forget: HTTP failures are swallowed — never blocks the tool pipeline.
9
+ //
10
+ // Tier-aware: reads license.json for client-tier token; falls back to env vars
11
+ // for owner tier. Never carries master token in client-tier POST bodies.
12
+ //
13
+ // Doctrine: feedback_no_flag_without_fix.md — outcomes recorded, not deferred.
14
+ // feedback_implementation_coupled_cognition.md — POST IS the impl.
15
+
16
+ import { readFileSync, existsSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+
19
+ const HOME = homedir();
20
+ const LICENSE_PATH = `${HOME}/.aria/license.json`;
21
+
22
+ let input = '';
23
+ for await (const chunk of process.stdin) input += chunk;
24
+ let event;
25
+ try { event = JSON.parse(input); } catch { process.exit(0); }
26
+
27
+ const toolName = event.tool_name || event.toolName || '';
28
+ if (!['Bash', 'Edit', 'Write', 'NotebookEdit'].includes(toolName)) process.exit(0);
29
+
30
+ // Derive action_kind + action_target
31
+ let actionKind, actionTarget;
32
+ if (toolName === 'Bash') {
33
+ actionKind = 'bash';
34
+ const cmd = event.tool_input?.command || '';
35
+ actionTarget = (cmd.split(/\s+/)[0] || 'unknown').slice(0, 100);
36
+ } else {
37
+ actionKind = 'edit';
38
+ actionTarget = (event.tool_input?.file_path || event.tool_input?.path || 'unknown').slice(0, 200);
39
+ }
40
+
41
+ // Tier-aware auth: client license.json overrides env vars
42
+ const harnessUrl = process.env.ARIA_HARNESS_URL || 'http://192.168.4.25:30080';
43
+ let harnessToken = process.env.ARIA_HARNESS_TOKEN || process.env.ARIA_API_KEY || '';
44
+ let isClientTier = false;
45
+ try {
46
+ if (existsSync(LICENSE_PATH)) {
47
+ const lic = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
48
+ if (lic.jti) {
49
+ isClientTier = true;
50
+ // Client tier: use their license token, never master token
51
+ harnessToken = lic.token || lic.license || harnessToken;
52
+ }
53
+ }
54
+ } catch { /* non-fatal — fall back to env */ }
55
+
56
+ const sessionId = event.session_id || event.sessionId || 'unknown';
57
+ const success = !event.tool_response?.error && event.tool_response?.type !== 'error';
58
+
59
+ // Fire-and-forget POST to outcome-record — never blocks, never throws
60
+ fetch(`${harnessUrl}/api/harness/outcome-record`, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ 'Authorization': `Bearer ${harnessToken}`,
65
+ },
66
+ body: JSON.stringify({
67
+ sessionId,
68
+ actionKind,
69
+ actionTarget,
70
+ actionShape: {
71
+ tool: toolName,
72
+ success,
73
+ isClientTier,
74
+ },
75
+ }),
76
+ }).catch(() => {/* fire-and-forget — HTTP failures are silently dropped */});
77
+
78
+ // Exit immediately — don't await the fetch; this is a PostToolUse hook
79
+ // and must not add meaningful latency to the tool pipeline.
80
+ process.exit(0);
@@ -38,6 +38,7 @@
38
38
 
39
39
  import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
40
40
  import { dirname } from 'node:path';
41
+ import { homedir } from 'node:os';
41
42
 
42
43
  const HOME = process.env.HOME || '/tmp';
43
44
  const LOG = `${HOME}/.claude/aria-pre-tool-gate.log`;
@@ -1004,6 +1005,89 @@ No env-var disable path — gates are unconditional from the gated process per H
1004
1005
  process.exit(2);
1005
1006
  }
1006
1007
 
1008
+ // ── Sub-agent packet-citation check (Layer 4 — #84) ─────────────────────────
1009
+ //
1010
+ // When running inside a sub-agent process (detected by a non-stale handoff file
1011
+ // existing AND harnessPacketPath is set in that handoff), require the cognition
1012
+ // lens content to CITE THE PACKET — at least one of:
1013
+ // - A doctrine rule ID (e.g. "doctrine_first", "no_demos", "workaround_vs_path_fix")
1014
+ // - An axiom name (e.g. "truth_over_deception", "no_harm", "sacred_trust", "reflection_before_action")
1015
+ // - A frame primitive (e.g. "Fitrah", "Tafakkur", "Tadabbur", "Ilham", "Mizan", "Hikma", "Nur", "Wahi", "Firasah")
1016
+ // - A memory class reference (e.g. "feedback_*.md", "project_*.md", "reference_*.md")
1017
+ //
1018
+ // Owner-tier sessions (ownerTier.hamza === true AND no jti) are EXEMPT —
1019
+ // they are the source of truth for the packet itself.
1020
+ //
1021
+ // Fail-soft detection: handoff read errors silently skip this check.
1022
+ (function checkSubAgentPacketCitation() {
1023
+ const _HOME = process.env.HOME || '/tmp';
1024
+ // Try owner-tier handoff path first, then client-tier paths.
1025
+ const HANDOFF_TTL_MS = 5 * 60 * 1000;
1026
+ // Probe known handoff paths.
1027
+ const candidatePaths = [
1028
+ `${_HOME}/.claude/aria-agent-harness-handoff.json`,
1029
+ ];
1030
+ // Also check /var/lib/aria-licensee if that dir exists (client-tier).
1031
+ try {
1032
+ const licPath = `${_HOME}/.aria/license.json`;
1033
+ if (existsSync(licPath)) {
1034
+ const lic = JSON.parse(readFileSync(licPath, 'utf8'));
1035
+ if (lic.jti) {
1036
+ candidatePaths.push(`/var/lib/aria-licensee/${lic.jti}/handoff.json`);
1037
+ }
1038
+ }
1039
+ } catch { /* non-fatal */ }
1040
+
1041
+ let handoff = null;
1042
+ for (const hp of candidatePaths) {
1043
+ if (!existsSync(hp)) continue;
1044
+ try {
1045
+ const raw = JSON.parse(readFileSync(hp, 'utf8'));
1046
+ const ageMs = Date.now() - new Date(raw.writtenAt || 0).getTime();
1047
+ if (ageMs > HANDOFF_TTL_MS) continue; // stale handoff — not a sub-agent context
1048
+ if (!raw.harnessPacketPath) continue; // no packet path written → identity-only handoff, skip check
1049
+ handoff = raw;
1050
+ break;
1051
+ } catch { /* malformed — skip */ }
1052
+ }
1053
+
1054
+ if (!handoff) return; // not a sub-agent context, or handoff has no packetPath
1055
+
1056
+ // Owner-tier exemption: if ownerTier.hamza === true AND no jti → source of truth
1057
+ const ownerExempt = (handoff.ownerTier?.hamza === true) && !handoff.ownerTier?.jti;
1058
+ if (ownerExempt) return;
1059
+
1060
+ // Now verify that the cognition block cites at least one packet substrate token.
1061
+ // Tokens accepted (case-insensitive):
1062
+ // • Doctrine rule IDs from feedback_*/project_*/reference_* filenames
1063
+ // • Axiom names: truth_over_deception, no_harm, sacred_trust, reflection_before_action
1064
+ // • Frame primitives: Fitrah, Tafakkur, Tadabbur, Ilham, Mizan, Hikma, Nur, Wahi, Firasah
1065
+ // • Memory class patterns: feedback_*.md, project_*.md, reference_*.md
1066
+ const PACKET_CITE_RX = /\b(?:feedback_[a-z0-9_]+\.md|project_[a-z0-9_]+\.md|reference_[a-z0-9_]+\.md|doctrine_first|no_demos|workaround_vs_path_fix|no_flag_without_fix|implementation_coupled_cognition|session_starts_with_linear|gates_enforce_form_not_substance|truth_over_deception|no_harm|sacred_trust|reflection_before_action|power_obligates_service|fitrah|tafakkur|tadabbur|ilham|mizan|hikma|nur|wahi|firasah|harness\s*packet)\b/i;
1067
+
1068
+ const cogText = cogBlockBody || unionText;
1069
+ const hasCite = PACKET_CITE_RX.test(cogText) || PACKET_CITE_RX.test(cmd);
1070
+
1071
+ if (!hasCite) {
1072
+ const packetRef = handoff.harnessPacketPath;
1073
+ const reason = `Sub-agent cognition cites no packet substrate — lens content must reference at least one axiom/frame/memory/doctrine from the harness packet at ${packetRef}.
1074
+
1075
+ Accepted citations (case-insensitive, any ONE suffices):
1076
+ • Doctrine rule IDs: doctrine_first, no_demos, workaround_vs_path_fix, no_flag_without_fix, etc.
1077
+ • Axioms: truth_over_deception, no_harm, sacred_trust, reflection_before_action, power_obligates_service
1078
+ • Frame primitives: Fitrah, Tafakkur, Tadabbur, Ilham, Mizan, Hikma, Nur, Wahi, Firasah
1079
+ • Memory class refs: feedback_*.md, project_*.md, reference_*.md
1080
+
1081
+ The harness packet is at: ${packetRef}
1082
+ Read it first (it is in your environment as ARIA_HARNESS_PACKET_PATH), then reference it in your cognition block.
1083
+
1084
+ Cognition-theater rejected per feedback_gates_enforce_form_not_substance.md.`;
1085
+ audit(`block-subagent-no-packet-cite ${toolName.toLowerCase()}`, cmdPreview);
1086
+ console.log(JSON.stringify({ decision: 'block', reason }));
1087
+ process.exit(2);
1088
+ }
1089
+ })();
1090
+
1007
1091
  // ── arch_facts gate (architectural violation scan) ────────────────────────
1008
1092
  //
1009
1093
  // Runs after cognition + discovery-binding pass, for Edit/Write/NotebookEdit.
@@ -1182,6 +1266,80 @@ Claude must either: (a) reframe action to fit allowedActions, OR (b) emit [PLAN_
1182
1266
  bindingAuditAppend({ event: 'allow_phase_action', sessionId, planId: plan.planId, phaseId: phase.id, action, target });
1183
1267
  }
1184
1268
 
1269
+ // ── Outcome Ledger regression flag (fail-soft) ───────────────────────────────
1270
+ //
1271
+ // Before allowing, query aria_outcome_ledger for recent regressions on the same
1272
+ // action shape. This is NOT a block — it's a soft-warning surfaced to stderr so
1273
+ // the agent can see the risk and decide whether the root cause has shipped.
1274
+ //
1275
+ // action_kind: 'edit' for Edit/Write/NotebookEdit, 'bash' for Bash
1276
+ // action_target: file path for file tools; first word of bash command otherwise
1277
+ //
1278
+ // Fail-open: network errors, timeouts, or non-200 responses are silently ignored.
1279
+ // The gate does not hardcode timeouts (no-timeouts doctrine); the 3s AbortController
1280
+ // is a DETECTION PROBE — if the endpoint is alive it will respond quickly; if it
1281
+ // is down the catch path fail-opens without blocking the developer.
1282
+ (async function checkOutcomeLedger() {
1283
+ const _harnessUrl = process.env.ARIA_HARNESS_URL || 'http://192.168.4.25:30080';
1284
+ const _harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
1285
+
1286
+ // Derive action_kind and action_target from current tool call
1287
+ let _actionKind = 'bash';
1288
+ let _actionTarget = '';
1289
+ if (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') {
1290
+ _actionKind = 'edit';
1291
+ _actionTarget = filePath;
1292
+ } else if (toolName === 'Bash') {
1293
+ _actionKind = 'bash';
1294
+ // First word of the command (the verb) as the shape key
1295
+ _actionTarget = cmd.trim().split(/\s+/)[0] || '';
1296
+ // If the command touches a specific file path, prefer that as target
1297
+ // (captures edit-like bash operations: sed, awk, tee, etc.)
1298
+ const _fileArgMatch = cmd.match(/\b([\w./~-]+\.[a-z]{2,6})\b/i);
1299
+ if (_fileArgMatch) _actionTarget = _fileArgMatch[1];
1300
+ }
1301
+
1302
+ if (!_actionTarget) return; // nothing useful to query
1303
+
1304
+ try {
1305
+ const _ctl = new AbortController();
1306
+ const _probeTimer = setTimeout(() => _ctl.abort(), 3000);
1307
+ let _resp;
1308
+ try {
1309
+ const _params = new URLSearchParams({ action_kind: _actionKind, action_target: _actionTarget });
1310
+ _resp = await fetch(`${_harnessUrl}/api/harness/outcome-recent-regressions?${_params}`, {
1311
+ method: 'GET',
1312
+ headers: {
1313
+ 'Content-Type': 'application/json',
1314
+ ...(_harnessToken ? { Authorization: `Bearer ${_harnessToken}` } : {}),
1315
+ },
1316
+ signal: _ctl.signal,
1317
+ });
1318
+ } finally {
1319
+ clearTimeout(_probeTimer);
1320
+ }
1321
+
1322
+ if (!_resp || !_resp.ok) return; // endpoint unreachable or error — fail-open
1323
+ const _data = await _resp.json();
1324
+ const _regressions = Array.isArray(_data?.regressions) ? _data.regressions : [];
1325
+
1326
+ if (_regressions.length > 0) {
1327
+ // Soft-warning to stderr — visible in Claude Code's hook output but does NOT block
1328
+ const _lines = _regressions.map(
1329
+ (r) => ` - ${r.action_target} at ${new Date(r.created_at).toISOString()}: ${r.regression_signal || '(no signal text)'}`,
1330
+ ).join('\n');
1331
+ process.stderr.write(
1332
+ `⚠️ Aria Outcome Ledger: this action shape regressed ${_regressions.length} time(s) in the last 60 min:\n` +
1333
+ `${_lines}\n` +
1334
+ `Re-emitting with this risk visible. Proceed only if root cause has shipped since.\n`,
1335
+ );
1336
+ audit(`warn-ledger-regression ${_actionKind} target=${_actionTarget} count=${_regressions.length}`, cmdPreview);
1337
+ }
1338
+ } catch {
1339
+ // Network error, abort, parse failure — fail-open, no warning
1340
+ }
1341
+ })();
1342
+
1185
1343
  // Non-trivial action with cognition AND (binding-allowed OR binding-disabled) — allow.
1186
1344
  audit(`allow-cognition ${toolName.toLowerCase()} lenses=${lensCount} via=${cognitionSource}`, cmdPreview);
1187
1345
  pushDecision('allow', `${toolName.toLowerCase()} with ${lensCount} lenses (${cognitionSource})`);
@@ -46,7 +46,7 @@
46
46
  // Future: signed-grant override mechanism at ~/.aria/owner-overrides/<hook>.json
47
47
  // with HMAC signature using a secret only Hamza holds. Deferred to next session.
48
48
 
49
- import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
49
+ import { readFileSync, appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
50
50
  import { dirname } from 'node:path';
51
51
 
52
52
  const HOME = process.env.HOME || '/tmp';
@@ -702,6 +702,108 @@ if (cog.count >= REQUIRED_LENSES) {
702
702
  }
703
703
  } catch {/* outer guard */}
704
704
 
705
+ // ── Layer C — auto-re-consult on [PLAN_BLOCKER] (#85) ────────────────────
706
+ //
707
+ // When the assistant emits a [PLAN_BLOCKER reason="..."] marker the runtime
708
+ // must fire a two-path replan rather than blocking and waiting for the human:
709
+ //
710
+ // Primary path: POST /api/harness/replan (aria-soul server-side)
711
+ // Fallback path: node aria-architect-fallback.mjs (local sub-agent)
712
+ //
713
+ // Both paths write the fresh BINDING_PLAN to the session-scoped active-plan
714
+ // file so the next turn's pre-tool-gate and stop-gate pick it up.
715
+ //
716
+ // Fail-soft: if both paths fail, we log + emit a clear message asking Hamza
717
+ // to intervene. We do NOT crash the stop-gate — the existing block/allow
718
+ // decision below continues on its own merits.
719
+ const planBlockerMatch = assistantText.match(/\[PLAN_BLOCKER\s+reason="([^"]{10,2000})"\s*\]/);
720
+ if (planBlockerMatch) {
721
+ const planBlockerReason = planBlockerMatch[1];
722
+ audit(`[PLAN_BLOCKER] detected — firing replan: ${planBlockerReason.slice(0, 100)}`);
723
+
724
+ const currentPlanId = activePlan?.planId || 'unknown';
725
+ let planMinted = false;
726
+
727
+ // Primary path: aria-soul /api/harness/replan
728
+ try {
729
+ const harnessUrl = process.env.ARIA_HARNESS_URL || 'https://harness.ariasos.com';
730
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || process.env.ARIA_API_KEY || '';
731
+ const ctl = new AbortController();
732
+ const replanTimeout = setTimeout(() => ctl.abort(), 15000);
733
+ const resp = await fetch(`${harnessUrl}/api/harness/replan`, {
734
+ method: 'POST',
735
+ headers: {
736
+ 'Content-Type': 'application/json',
737
+ 'Authorization': `Bearer ${harnessToken}`,
738
+ },
739
+ body: JSON.stringify({
740
+ reason: planBlockerReason,
741
+ currentPlanId,
742
+ sessionId,
743
+ }),
744
+ signal: ctl.signal,
745
+ });
746
+ clearTimeout(replanTimeout);
747
+ if (resp.ok) {
748
+ const data = await resp.json();
749
+ if (data.ok && data.plan) {
750
+ const freshPlan = {
751
+ ...data.plan,
752
+ mintedAt: new Date().toISOString(),
753
+ mintedBy: 'aria-soul-replan',
754
+ };
755
+ try {
756
+ if (!existsSync(dirname(ACTIVE_PLAN_PATH))) mkdirSync(dirname(ACTIVE_PLAN_PATH), { recursive: true });
757
+ writeFileSync(ACTIVE_PLAN_PATH, JSON.stringify(freshPlan, null, 2));
758
+ } catch (writeErr) {
759
+ audit(`replan-primary-write-err: ${String(writeErr).slice(0, 200)}`);
760
+ }
761
+ planMinted = true;
762
+ audit(`replan-primary-ok planId=${data.plan.planId}`);
763
+ } else {
764
+ audit(`replan-primary-bad-response: ok=${data.ok} error=${(data.error || '').slice(0, 200)}`);
765
+ }
766
+ } else {
767
+ audit(`replan-primary-http-${resp.status}`);
768
+ }
769
+ } catch (err) {
770
+ audit(`replan-primary-failed: ${(err?.message || String(err)).slice(0, 200)}`);
771
+ }
772
+
773
+ // Fallback path: architect-fallback hook (spawned as sub-process)
774
+ if (!planMinted) {
775
+ audit('replan-primary-unreachable — firing architect-fallback');
776
+ try {
777
+ const { spawnSync } = await import('node:child_process');
778
+ const fallbackBin = `${HOME}/.claude/hooks/aria-architect-fallback.mjs`;
779
+ if (existsSync(fallbackBin)) {
780
+ const fallbackResult = spawnSync('node', [fallbackBin], {
781
+ input: JSON.stringify({ reason: planBlockerReason, currentPlanId, sessionId }),
782
+ encoding: 'utf8',
783
+ timeout: 130000,
784
+ });
785
+ if (fallbackResult.status === 0) {
786
+ audit(`architect-fallback-ok: ${(fallbackResult.stdout || '').slice(0, 200)}`);
787
+ planMinted = true;
788
+ } else {
789
+ audit(
790
+ `architect-fallback-failed status=${fallbackResult.status} stderr=${(fallbackResult.stderr || '').slice(0, 200)}`
791
+ );
792
+ }
793
+ } else {
794
+ audit(`architect-fallback-missing: ${fallbackBin} not found`);
795
+ }
796
+ } catch (err) {
797
+ audit(`architect-fallback-threw: ${(err?.message || String(err)).slice(0, 200)}`);
798
+ }
799
+ }
800
+
801
+ if (!planMinted) {
802
+ audit('replan-both-paths-failed — Hamza must intervene');
803
+ // Surface clearly in the block reason below; don't crash the gate.
804
+ }
805
+ }
806
+
705
807
  // Block decision: any of (validateOutput severity=block) OR (>=2 drift hits) OR
706
808
  // (>=1 code-quality hit) OR (open discovery in ledger) → block emit.
707
809
  // Aria enforcement #46 (compelled reflection): severity=warn ALSO blocks but
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Aria Harness Context Plugin for OpenCode.
3
+ *
4
+ * Injects the live harness packet into OpenCode's system prompt on every
5
+ * session start. Routes through the canonical @aria/harness-http-client SDK
6
+ * via the inject-context.mjs script that ships alongside this plugin.
7
+ *
8
+ * Distribution: this dir is installed by `aria connect` (via connectors/
9
+ * opencode.ts) into `~/.opencode/plugins/harness-context/`. The plugin's
10
+ * absolute install path is wired into ~/.opencode/config.json's `plugin`
11
+ * (singular) array. OpenCode loads it on session start.
12
+ *
13
+ * inject-context.mjs is resolved RELATIVE TO THIS FILE (via import.meta.url),
14
+ * so the same install layout works on every machine — no $HOME or repo-path
15
+ * assumptions.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
24
+ const INJECT_SCRIPT = join(PLUGIN_DIR, 'inject-context.mjs');
25
+
26
+ function getHarnessContext() {
27
+ if (!existsSync(INJECT_SCRIPT)) {
28
+ process.stderr.write(`[harness-context] inject-context.mjs missing at ${INJECT_SCRIPT}\n`);
29
+ return null;
30
+ }
31
+ try {
32
+ // execFileSync — argv-safe (no shell injection surface even though the
33
+ // path is internal). 15s ceiling matches OpenCode's tolerance for
34
+ // session-start blocking. Stdout is the prepend-able context; stderr
35
+ // is the diagnostic surface.
36
+ const output = execFileSync(process.execPath, [INJECT_SCRIPT], {
37
+ encoding: 'utf-8',
38
+ timeout: 15000,
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ env: process.env,
41
+ });
42
+ return output;
43
+ } catch (err) {
44
+ process.stderr.write(`[harness-context] inject-context failed: ${err && err.message ? err.message : String(err)}\n`);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export default async function HarnessContextPlugin(ctx) {
50
+ const context = getHarnessContext();
51
+ if (context) {
52
+ try {
53
+ ctx.system?.prepend?.(context.slice(0, 8000));
54
+ } catch (_) {
55
+ // ctx.system shape varies across OpenCode versions; if prepend isn't
56
+ // available we silently no-op rather than crash plugin load.
57
+ }
58
+ }
59
+ return {};
60
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // Aria harness context injector — bundled alongside the harness-context
3
+ // OpenCode plugin. Resolved relative to the plugin (via import.meta.url),
4
+ // so the install layout is portable across machines.
5
+ //
6
+ // Contract with the plugin:
7
+ // - stdin: ignored
8
+ // - stdout: text to prepend to OpenCode's system prompt
9
+ // - exit 0 on success; non-zero treated as failure (plugin returns null)
10
+ //
11
+ // SDK resolution: prefers ~/.claude/aria-sdk/index.js (the SDK installed by
12
+ // `aria connect`'s claude-code path — clients with both connectors share it).
13
+ // Cache fast-path uses ~/.claude/.aria-harness-last-packet.json so OpenCode
14
+ // session-start doesn't block on a slow consult endpoint.
15
+
16
+ import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const HOME = homedir();
21
+ const LICENSE_PATH = join(HOME, '.aria', 'license.json');
22
+ const OWNER_TOKEN_PATH = join(HOME, '.aria', 'owner-token');
23
+ const SDK_PATH = join(HOME, '.claude', 'aria-sdk', 'index.js');
24
+ const PACKET_CACHE_PATH = join(HOME, '.claude', '.aria-harness-last-packet.json');
25
+ const PACKET_CACHE_TTL_SEC = Number(process.env.ARIA_HARNESS_CACHE_TTL_SEC || '60');
26
+
27
+ function fail(reason) {
28
+ process.stderr.write(`[inject-context] ${reason}\n`);
29
+ process.exit(1);
30
+ }
31
+
32
+ function resolveApiKey() {
33
+ if (process.env.ARIA_API_KEY) return process.env.ARIA_API_KEY;
34
+ if (process.env.ARIA_HARNESS_TOKEN) return process.env.ARIA_HARNESS_TOKEN;
35
+ if (process.env.ARIA_MASTER_TOKEN) return process.env.ARIA_MASTER_TOKEN;
36
+ if (existsSync(OWNER_TOKEN_PATH)) {
37
+ try {
38
+ const v = readFileSync(OWNER_TOKEN_PATH, 'utf8').trim();
39
+ if (v) return v;
40
+ } catch {}
41
+ }
42
+ if (existsSync(LICENSE_PATH)) {
43
+ try {
44
+ const j = JSON.parse(readFileSync(LICENSE_PATH, 'utf8'));
45
+ if (j.token) return j.token;
46
+ } catch {}
47
+ }
48
+ return '';
49
+ }
50
+
51
+ function resolveBaseUrl() {
52
+ if (process.env.ARIA_HARNESS_BASE_URL) return process.env.ARIA_HARNESS_BASE_URL.replace(/\/+$/, '');
53
+ if (process.env.ARIA_SOUL_URL) return process.env.ARIA_SOUL_URL.replace(/\/+$/, '');
54
+ return 'http://localhost:30080';
55
+ }
56
+
57
+ function loadCachedPacket() {
58
+ try {
59
+ if (!existsSync(PACKET_CACHE_PATH)) return null;
60
+ const ageSec = (Date.now() - statSync(PACKET_CACHE_PATH).mtimeMs) / 1000;
61
+ if (ageSec > PACKET_CACHE_TTL_SEC) return null;
62
+ return JSON.parse(readFileSync(PACKET_CACHE_PATH, 'utf8'));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function formatPacketAsContext(packet) {
69
+ if (typeof packet === 'string') return packet;
70
+ if (packet && typeof packet === 'object') {
71
+ if (packet.packet && typeof packet.packet === 'object') {
72
+ return JSON.stringify(packet.packet, null, 2);
73
+ }
74
+ if (packet.harness && typeof packet.harness === 'string') return packet.harness;
75
+ return JSON.stringify(packet, null, 2);
76
+ }
77
+ return '';
78
+ }
79
+
80
+ async function fetchViaSdk(baseUrl, apiKey) {
81
+ if (!existsSync(SDK_PATH)) {
82
+ fail(`SDK not found at ${SDK_PATH} — run \`aria connect\` to install`);
83
+ }
84
+ const mod = await import(SDK_PATH);
85
+ const Client = mod.HTTPHarnessClient;
86
+ if (!Client) fail('SDK loaded but HTTPHarnessClient not exported — bundle is broken');
87
+ const client = new Client({ baseUrl, apiKey, workspaceRoot: process.cwd() });
88
+ return client.getHarnessPacket({
89
+ stage: 'session-start',
90
+ actor: 'opencode',
91
+ system: 'opencode',
92
+ platform: 'opencode',
93
+ });
94
+ }
95
+
96
+ async function main() {
97
+ // Cache fast-path first — don't block OpenCode startup on a slow consult.
98
+ const cached = loadCachedPacket();
99
+ if (cached) {
100
+ process.stdout.write(formatPacketAsContext(cached));
101
+ return;
102
+ }
103
+
104
+ const apiKey = resolveApiKey();
105
+ if (!apiKey) {
106
+ fail('no API key — set ARIA_API_KEY or run `aria login`');
107
+ }
108
+ const baseUrl = resolveBaseUrl();
109
+
110
+ try {
111
+ const packet = await fetchViaSdk(baseUrl, apiKey);
112
+ process.stdout.write(formatPacketAsContext(packet));
113
+ } catch (err) {
114
+ fail(`SDK fetch failed: ${err && err.message ? err.message : String(err)}`);
115
+ }
116
+ }
117
+
118
+ main().catch((err) => {
119
+ fail(`unexpected: ${err && err.message ? err.message : String(err)}`);
120
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "harness-context",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ }
9
+ }