@aria_asi/cli 0.2.33 → 0.2.35

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.
Files changed (74) hide show
  1. package/dist/aria-connector/src/connectors/codex.d.ts.map +1 -1
  2. package/dist/aria-connector/src/connectors/codex.js +60 -5
  3. package/dist/aria-connector/src/connectors/codex.js.map +1 -1
  4. package/dist/assets/hooks/aria-harness-via-sdk.mjs +16 -3
  5. package/dist/assets/hooks/aria-pre-tool-gate.mjs +41 -1
  6. package/dist/assets/hooks/aria-stop-gate.mjs +42 -1
  7. package/dist/assets/hooks/doctrine_trigger_map.json +43 -0
  8. package/dist/assets/hooks/lib/skill-autoload-gate.mjs +14 -1
  9. package/dist/assets/opencode-plugins/harness-context/index.js +1 -1
  10. package/dist/assets/opencode-plugins/harness-gate/index.js +49 -9
  11. package/dist/assets/opencode-plugins/harness-gate/lib/skill-autoload-gate.js +14 -1
  12. package/dist/assets/opencode-plugins/harness-stop/index.js +201 -166
  13. package/dist/assets/opencode-plugins/harness-stop/lib/skill-autoload-gate.js +14 -1
  14. package/dist/runtime/codex-bridge.mjs +1 -1
  15. package/dist/runtime/discipline/CLAUDE.md +2 -2
  16. package/dist/runtime/discipline/doctrine_trigger_map.json +43 -0
  17. package/dist/runtime/discipline/skills/aria-harness/aria-harness-onboarding/SKILL.md +3 -3
  18. package/dist/runtime/doctrine_trigger_map.json +43 -0
  19. package/dist/runtime/hooks/aria-agent-handoff.mjs +247 -0
  20. package/dist/runtime/hooks/aria-agent-ledger-merge.mjs +164 -0
  21. package/dist/runtime/hooks/aria-architect-fallback.mjs +267 -0
  22. package/dist/runtime/hooks/aria-cognition-substrate-binding.mjs +761 -0
  23. package/dist/runtime/hooks/aria-discovery-record.mjs +101 -0
  24. package/dist/runtime/hooks/aria-harness-via-sdk.mjs +544 -0
  25. package/dist/runtime/hooks/aria-import-resolution-gate.mjs +330 -0
  26. package/dist/runtime/hooks/aria-outcome-record.mjs +84 -0
  27. package/dist/runtime/hooks/aria-pre-emit-dryrun.mjs +329 -0
  28. package/dist/runtime/hooks/aria-pre-text-gate.mjs +112 -0
  29. package/dist/runtime/hooks/aria-pre-tool-gate.mjs +2482 -0
  30. package/dist/runtime/hooks/aria-preprompt-consult.mjs +464 -0
  31. package/dist/runtime/hooks/aria-preturn-memory-gate.mjs +647 -0
  32. package/dist/runtime/hooks/aria-repo-doctrine-gate.mjs +429 -0
  33. package/dist/runtime/hooks/aria-stop-gate.mjs +1882 -0
  34. package/dist/runtime/hooks/aria-trigger-autolearn.mjs +229 -0
  35. package/dist/runtime/hooks/aria-userprompt-abandon-detect.mjs +192 -0
  36. package/dist/runtime/hooks/doctrine_trigger_map.json +577 -0
  37. package/dist/runtime/hooks/lib/canonical-lenses.mjs +65 -0
  38. package/dist/runtime/hooks/lib/domain-output-quality.mjs +103 -0
  39. package/dist/runtime/hooks/lib/gate-audit.mjs +43 -0
  40. package/dist/runtime/hooks/lib/gate-loop-state.mjs +50 -0
  41. package/dist/runtime/hooks/lib/hook-message-window.mjs +121 -0
  42. package/dist/runtime/hooks/lib/skill-autoload-gate.mjs +14 -0
  43. package/dist/runtime/hooks/test-aria-preturn-memory-gate.mjs +245 -0
  44. package/dist/runtime/hooks/test-tier-lens-labeling.mjs +367 -0
  45. package/dist/runtime/manifest.json +2 -2
  46. package/dist/runtime/sdk/BUNDLED.json +2 -2
  47. package/dist/runtime/sdk/index.d.ts +39 -0
  48. package/dist/runtime/sdk/index.js +117 -0
  49. package/dist/runtime/sdk/index.js.map +1 -1
  50. package/dist/runtime/sdk/runWithGovernance.d.ts +16 -0
  51. package/dist/runtime/sdk/runWithGovernance.js +54 -0
  52. package/dist/runtime/sdk/runWithGovernance.js.map +1 -0
  53. package/dist/sdk/BUNDLED.json +2 -2
  54. package/dist/sdk/index.d.ts +39 -0
  55. package/dist/sdk/index.js +117 -0
  56. package/dist/sdk/index.js.map +1 -1
  57. package/dist/sdk/runWithGovernance.d.ts +16 -0
  58. package/dist/sdk/runWithGovernance.js +54 -0
  59. package/dist/sdk/runWithGovernance.js.map +1 -0
  60. package/hooks/aria-harness-via-sdk.mjs +16 -3
  61. package/hooks/aria-pre-tool-gate.mjs +41 -1
  62. package/hooks/aria-stop-gate.mjs +42 -1
  63. package/hooks/doctrine_trigger_map.json +43 -0
  64. package/hooks/lib/skill-autoload-gate.mjs +14 -1
  65. package/opencode-plugins/harness-context/index.js +1 -1
  66. package/opencode-plugins/harness-gate/index.js +49 -9
  67. package/opencode-plugins/harness-gate/lib/skill-autoload-gate.js +14 -1
  68. package/opencode-plugins/harness-stop/index.js +201 -166
  69. package/opencode-plugins/harness-stop/lib/skill-autoload-gate.js +14 -1
  70. package/package.json +12 -5
  71. package/runtime-src/codex-bridge.mjs +1 -1
  72. package/scripts/bundle-sdk.mjs +2 -0
  73. package/scripts/self-test-harness-gates.mjs +79 -0
  74. package/src/connectors/codex.ts +60 -5
@@ -0,0 +1,1882 @@
1
+ #!/usr/bin/env node
2
+ // ARIA_ALLOW_STUB — doctrine gate file legitimately discusses stub/placeholder semantics.
3
+ // Aria Stop-hook gate — enforces 8-lens cognition on text-decision responses.
4
+ //
5
+ // The companion to aria-pre-tool-gate.mjs. The PreToolUse gate catches
6
+ // non-trivial Bash; this Stop hook catches non-trivial TEXT decisions
7
+ // — agreements, scope changes, picks between options, "yes ship it"
8
+ // replies. Same forcing-function pattern, applied at the missing
9
+ // surface.
10
+ //
11
+ // Direction: Hamza 2026-04-26 — "you not doing 8 lens till i ask and
12
+ // discovering the actions u doing are wrong are hard gates that u
13
+ // keep bypassing that prevent exactly what just happened." The
14
+ // PreToolUse gate is tool-coupled; doctrine is action-coupled.
15
+ // Reflexive text decisions are non-trivial actions that this hook
16
+ // now catches.
17
+ //
18
+ // Doctrine bindings (same as PreToolUse gate):
19
+ // - EIGHT_LENS_DOCTRINE.md — substantive 8-lens application required
20
+ // - feedback_apply_lenses_dont_perform_them.md — block ceremonial cognition
21
+ // - feedback_8lens_before_every_action_including_text.md — the rule this enforces
22
+ //
23
+ // Trigger: runs at Stop event after every assistant response. Reads
24
+ // the just-emitted assistant text from the transcript. If non-trivial
25
+ // (per the same triviality threshold as eight-lens-detector.ts) AND
26
+ // missing 8 substantive lenses, blocks the response.
27
+ //
28
+ // Triviality threshold (mirrors eight-lens-detector.ts):
29
+ // - Trivial acks (e.g. "got it", "ok", "done") pass
30
+ // - Short responses (<300 chars) without decision-signal phrases pass
31
+ // - Otherwise: require 8 substantive lenses
32
+ //
33
+ // Substance check (mirrors aria-pre-tool-gate.mjs):
34
+ // - Each lens must have ≥20 chars of non-placeholder content
35
+ // - Bare lens-name mentions in prose don't count
36
+ // - <placeholder> template values don't count
37
+ //
38
+ // No bypass mechanism — same v3 doctrine as the PreToolUse gate.
39
+ //
40
+ // Hamza 2026-04-27 ("those should've been my choice to give you to turn
41
+ // off not free for you to access"): the env-var kill-switches I authored
42
+ // (ARIA_STOP_GATE=off, ARIA_OUTPUT_QC_ENABLED=false) gave the gated
43
+ // process free disable-access. That was the doctrine violation. Stripped.
44
+ // To genuinely disable in emergency, Hamza removes the hook entry from
45
+ // ~/.claude/settings.json — a visible, auditable user action he controls,
46
+ // not a process-level escape.
47
+ // Future: signed-grant override mechanism at ~/.aria/owner-overrides/<hook>.json
48
+ // with HMAC signature using a secret only Hamza holds. Deferred to next session.
49
+
50
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
51
+ import { dirname } from 'node:path';
52
+ import { spawnSync } from 'node:child_process';
53
+ import { appendGateAudit } from './lib/gate-audit.mjs';
54
+ import {
55
+ ALL_LENS_NAMES,
56
+ detectCognitionLenses as detectCognitionLensesFromCanonical,
57
+ lensNamesForTier,
58
+ PRIMARY_OWNER_LENS_NAMES,
59
+ } from './lib/canonical-lenses.mjs';
60
+ import { registerGateBlock } from './lib/gate-loop-state.mjs';
61
+ import { collectTurnWindowFromMessages } from './lib/hook-message-window.mjs';
62
+ import { analyzeDomainOutputQuality } from './lib/domain-output-quality.mjs';
63
+ import { evaluateSkillGate, formatSkillGateBlock } from './lib/skill-autoload-gate.mjs';
64
+
65
+ const HOME = process.env.HOME || '/tmp';
66
+ const RUNTIME_BASE_URL =
67
+ process.env.ARIA_RUNTIME_URL ||
68
+ 'http://127.0.0.1:4319';
69
+ const LOG = `${HOME}/.claude/aria-stop-gate.log`;
70
+ const AUDIT_PATH = `${HOME}/.claude/aria-stop-gate-audit.jsonl`;
71
+ const GATE_LOOP_STATE_PATH = `${HOME}/.claude/.aria-gate-loop-state.json`;
72
+ const MIZAN_RECEIPT_DIR = `${HOME}/.claude/.aria-mizan-receipts`;
73
+ const GOVERNANCE_GATE_PATH = `${HOME}/.aria/bin/aria-governance-gate`;
74
+
75
+ function runUniversalGovernanceGate(payload) {
76
+ if (!existsSync(GOVERNANCE_GATE_PATH)) return null;
77
+ const child = spawnSync(GOVERNANCE_GATE_PATH, {
78
+ input: `${JSON.stringify(payload)}\n`,
79
+ encoding: 'utf8',
80
+ maxBuffer: 1024 * 1024,
81
+ });
82
+ const stdout = String(child.stdout || '').trim();
83
+ let result = null;
84
+ try { result = stdout ? JSON.parse(stdout) : null; } catch {}
85
+ if (child.status !== 0 || result?.ok === false || result?.decision === 'block') {
86
+ const reason = stdout || child.stderr || 'aria-governance-gate blocked this output.';
87
+ throw new Error(`=== ARIA UNIVERSAL GOVERNANCE GATE BLOCK ===\n\n${reason}`);
88
+ }
89
+ return result;
90
+ }
91
+
92
+ // SDK loader — bundled at ~/.aria/sdk by `aria connect`, with client-local
93
+ // fallbacks preserved for resilience.
94
+ // All control-plane fetches (validateOutput, gardenTurn) route through the
95
+ // SDK. Falls back to direct fetch only when the SDK file is missing
96
+ // (dev-only). Hamza 2026-04-27: "FUCKING WIRE IT THE FUCK TOGETHER NOW".
97
+ let _SdkClassCache = null;
98
+ let _SdkLookupAttempted = false;
99
+ const SDK_CANDIDATES = [
100
+ `${HOME}/.aria/sdk/index.js`,
101
+ `${HOME}/.claude/aria-sdk/index.js`,
102
+ `${HOME}/.codex/aria-sdk/index.js`,
103
+ ];
104
+ async function loadSdkClass() {
105
+ if (_SdkClassCache) return _SdkClassCache;
106
+ if (_SdkLookupAttempted) return null;
107
+ _SdkLookupAttempted = true;
108
+ for (const sdkPath of SDK_CANDIDATES) {
109
+ if (!existsSync(sdkPath)) continue;
110
+ try {
111
+ const mod = await import(`file://${sdkPath}`);
112
+ if (mod.HTTPHarnessClient) {
113
+ _SdkClassCache = mod.HTTPHarnessClient;
114
+ return _SdkClassCache;
115
+ }
116
+ } catch {/* fall through */}
117
+ }
118
+ return null;
119
+ }
120
+
121
+ // Phase 11 #42 — fire-and-forget gardenTurn after every allow decision.
122
+ // Writes the completed turn to the harness control-plane garden so the
123
+ // next turn's pulse auto-injection carries this turn's content. Without
124
+ // this write the pulse is one turn stale (the core defect #42 closes).
125
+ //
126
+ // Per feedback_no_graceful_degradation.md: errors must be logged to the
127
+ // audit file, NOT silently swallowed. Per feedback_no_timeouts_doctrine.md:
128
+ // no AbortSignal.timeout — the SDK already has retry + backoff. The caller
129
+ // passes in a userMessage string (extracted from the transcript at the
130
+ // turn boundary). If extraction failed the empty string is passed — the
131
+ // garden write records the assistant emit at minimum.
132
+ // Tier detection: owner if no license.json, client if license.json has a jti.
133
+ // Owner-tier may use master credentials; client-tier MUST NOT (those belong
134
+ // to Hamza, not the licensee). Hamza correction 2026-04-28.
135
+ function isOwnerTier() {
136
+ try {
137
+ const licPath = `${HOME}/.aria/license.json`;
138
+ if (!existsSync(licPath)) return true;
139
+ const lic = JSON.parse(readFileSync(licPath, 'utf8'));
140
+ return !lic.jti; // jti present = client tier
141
+ } catch {
142
+ return true; // unreadable license = treat as owner (fail-safe for orchestrator)
143
+ }
144
+ }
145
+
146
+ async function fireGardenTurn(sessionId, userMessage, assistantResponse) {
147
+ const harnessUrl =
148
+ process.env.ARIA_HIVE_RUNTIME_URL ||
149
+ process.env.ARIA_HARNESS_BASE_URL ||
150
+ process.env.ARIA_HARNESS_URL ||
151
+ 'https://harness.ariasos.com';
152
+ // Token resolution chain (Hamza directive 2026-04-28, tier-aware
153
+ // 2026-04-28b): ARIA_HARNESS_TOKEN env first (works for both tiers).
154
+ // ONLY on owner tier (no license.json with jti), fall back to
155
+ // ARIA_MASTER_TOKEN env / ARIA_API_KEY env / ~/.aria/owner-token —
156
+ // those are Hamza's credentials and must not leak into client-tier
157
+ // processes. Client tier with no ARIA_HARNESS_TOKEN env skips the
158
+ // garden write rather than borrow owner credentials.
159
+ let harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
160
+ if (!harnessToken && isOwnerTier()) {
161
+ harnessToken = process.env.ARIA_MASTER_TOKEN || process.env.ARIA_API_KEY || '';
162
+ if (!harnessToken) {
163
+ try {
164
+ const ownerTokenPath = `${HOME}/.aria/owner-token`;
165
+ if (existsSync(ownerTokenPath)) {
166
+ harnessToken = readFileSync(ownerTokenPath, 'utf8').trim();
167
+ }
168
+ } catch { /* non-fatal — fall through to skip */ }
169
+ }
170
+ }
171
+ if (!harnessToken) {
172
+ audit('garden-turn-skip', `no usable token (tier=${isOwnerTier() ? 'owner' : 'client'}) — turn not written to harness pulse`);
173
+ return;
174
+ }
175
+ const Cls = await loadSdkClass();
176
+ if (!Cls) {
177
+ audit('garden-turn-skip', `sdk not available — turn not written to harness pulse`);
178
+ return;
179
+ }
180
+ try {
181
+ const sdkClient = new Cls({
182
+ baseUrl: harnessUrl,
183
+ apiKey: harnessToken,
184
+ harnessPacketUrl: `${harnessUrl}/api/harness/codex`,
185
+ });
186
+ await sdkClient.gardenTurn(
187
+ sessionId,
188
+ userMessage,
189
+ assistantResponse,
190
+ );
191
+ audit('garden-turn-ok', `session=${sessionId} chars=${assistantResponse.length}`);
192
+ } catch (err) {
193
+ // Logged — not silent. Per feedback_no_graceful_degradation.md.
194
+ audit('garden-turn-err', `session=${sessionId} err=${(err?.message || String(err)).slice(0, 200)}`);
195
+ }
196
+ }
197
+
198
+ function resolveHarnessControlToken() {
199
+ if (process.env.ARIA_HARNESS_TOKEN) return process.env.ARIA_HARNESS_TOKEN;
200
+ if (process.env.ARIA_API_KEY) return process.env.ARIA_API_KEY;
201
+ if (process.env.ARIA_MASTER_TOKEN) return process.env.ARIA_MASTER_TOKEN;
202
+ try {
203
+ const ownerTokenPath = `${HOME}/.aria/owner-token`;
204
+ if (existsSync(ownerTokenPath)) {
205
+ const token = readFileSync(ownerTokenPath, 'utf8').trim();
206
+ if (token) return token;
207
+ }
208
+ } catch {}
209
+ try {
210
+ const licensePath = `${HOME}/.aria/license.json`;
211
+ if (existsSync(licensePath)) {
212
+ const license = JSON.parse(readFileSync(licensePath, 'utf8'));
213
+ if (license.harnessToken) return String(license.harnessToken).trim();
214
+ if (license.token) return String(license.token).trim();
215
+ }
216
+ } catch {}
217
+ return '';
218
+ }
219
+
220
+ function mizanReceiptPathForSession(sessionId) {
221
+ const safe = String(sessionId || 'claude-code').replace(/[^a-zA-Z0-9_-]/g, '_');
222
+ return `${MIZAN_RECEIPT_DIR}/${safe}.json`;
223
+ }
224
+
225
+ function loadMizanReceiptState(sessionId) {
226
+ try {
227
+ const receiptPath = mizanReceiptPathForSession(sessionId);
228
+ if (!existsSync(receiptPath)) return null;
229
+ return JSON.parse(readFileSync(receiptPath, 'utf8'));
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function saveMizanReceiptState(sessionId, payload) {
236
+ try {
237
+ mkdirSync(MIZAN_RECEIPT_DIR, { recursive: true });
238
+ writeFileSync(mizanReceiptPathForSession(sessionId), JSON.stringify(payload, null, 2) + '\n');
239
+ } catch {}
240
+ }
241
+
242
+ async function runtimeMizanPost(sessionId, text, context = {}, parentReceiptId = null) {
243
+ const token = resolveHarnessControlToken();
244
+ if (!token) throw new Error('no token');
245
+ const response = await fetch(`${process.env.ARIA_RUNTIME_URL || 'http://127.0.0.1:4319'}/mizan/post`, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/json',
249
+ Authorization: `Bearer ${token}`,
250
+ },
251
+ body: JSON.stringify({
252
+ sessionId,
253
+ text,
254
+ parentReceiptId,
255
+ context: {
256
+ sessionId,
257
+ ...context,
258
+ },
259
+ }),
260
+ });
261
+ const payload = await response.json().catch(() => ({}));
262
+ if (!response.ok) {
263
+ throw new Error(payload?.error || `mizan/post failed (${response.status})`);
264
+ }
265
+ return payload;
266
+ }
267
+
268
+ async function runtimeDecisionLog(payload) {
269
+ const token = resolveHarnessControlToken();
270
+ if (!token) throw new Error('no token');
271
+ const response = await fetch(`${process.env.ARIA_RUNTIME_URL || 'http://127.0.0.1:4319'}/decision/log`, {
272
+ method: 'POST',
273
+ headers: {
274
+ 'Content-Type': 'application/json',
275
+ Authorization: `Bearer ${token}`,
276
+ },
277
+ body: JSON.stringify(payload),
278
+ });
279
+ const body = await response.json().catch(() => ({}));
280
+ if (!response.ok) {
281
+ throw new Error(body?.error || `decision/log failed (${response.status})`);
282
+ }
283
+ return body;
284
+ }
285
+
286
+ function audit(decision, summary) {
287
+ const summaryText = typeof summary === 'string' ? summary : '';
288
+ const data = summary && typeof summary === 'object' ? summary : {};
289
+ appendGateAudit({
290
+ auditPath: AUDIT_PATH,
291
+ legacyLogPath: LOG,
292
+ gate: 'stop',
293
+ event: decision,
294
+ summary: summaryText,
295
+ data,
296
+ });
297
+ }
298
+
299
+ // Env-var kill-switch removed 2026-04-27 per Hamza directive ("those
300
+ // should've been my choice to give you to turn off not free for you to
301
+ // access"). The gated process has no disable path. Disable = remove hook
302
+ // entry from ~/.claude/settings.json (deliberate user action, visible).
303
+
304
+ // ── Canonical lens labeling (Phase 11 #59 corrected) ────────────────────────
305
+ //
306
+ // Mirrors the same logic in aria-pre-tool-gate.mjs. Tier is still read from
307
+ // the most recent harness-via-sdk packet cache for other policy behaviors, but
308
+ // the visible lens labels remain canonical on every surface. Readability comes
309
+ // from the prose inside each lens, not from renaming the lens itself.
310
+ const PACKET_CACHE_PATH = `${HOME}/.claude/.aria-harness-last-packet.json`;
311
+
312
+ function resolveOwnerTier() {
313
+ try {
314
+ if (existsSync(PACKET_CACHE_PATH)) {
315
+ const raw = readFileSync(PACKET_CACHE_PATH, 'utf8');
316
+ const packet = JSON.parse(raw);
317
+ const sigHamza = packet?.contractGate?.signals?.hamza;
318
+ if (sigHamza === true || sigHamza === 'true') return true;
319
+ const harnessStr = packet?.harness ?? '';
320
+ // surface line format: "surface=platform:<X> group:<Y> hamza:true chat_type:<Z>"
321
+ if (/\bhamza:true\b/.test(harnessStr)) return true;
322
+ }
323
+ } catch {/* packet unreadable → default to client tier */}
324
+ return false;
325
+ }
326
+
327
+ const IS_OWNER = resolveOwnerTier();
328
+
329
+ const LENS_NAMES = lensNamesForTier(IS_OWNER);
330
+
331
+ // Doctrine memory filenames are Aria-side substrate IP.
332
+ // Client surfaces see generic descriptions instead of real filenames.
333
+ function docRef(canonicalFilename, genericDescription) {
334
+ return IS_OWNER ? canonicalFilename : genericDescription;
335
+ }
336
+
337
+ const DOCTRINE_REFERENCE_PREFIX_RX = /(?:memory|doctrine|frame|axiom|packet):[a-z0-9_./-]*$/i;
338
+
339
+ function isDoctrineReference(text, matchIndex) {
340
+ const window = text.slice(Math.max(0, matchIndex - 80), matchIndex);
341
+ return DOCTRINE_REFERENCE_PREFIX_RX.test(window);
342
+ }
343
+
344
+ function collectDriftHits(text, triggerMap) {
345
+ const hits = [];
346
+ const lowerText = text.toLowerCase();
347
+ for (const triggerEntry of triggerMap.triggers || []) {
348
+ try {
349
+ const rx = new RegExp(triggerEntry.trigger, 'ig');
350
+ let matchedOutsideDoctrineRef = false;
351
+ for (const match of text.matchAll(rx)) {
352
+ const idx = typeof match.index === 'number' ? match.index : -1;
353
+ if (idx >= 0 && isDoctrineReference(text, idx)) continue;
354
+ matchedOutsideDoctrineRef = true;
355
+ break;
356
+ }
357
+ if (!matchedOutsideDoctrineRef) continue;
358
+ const memoryName = (triggerEntry.memory || '').replace(/\.md$/, '');
359
+ const memoryCited = memoryName && lowerText.includes(memoryName.toLowerCase());
360
+ if (!memoryCited) {
361
+ hits.push({
362
+ trigger_id: triggerEntry.trigger_id,
363
+ trigger: triggerEntry.trigger,
364
+ memory: triggerEntry.memory,
365
+ teaching: triggerEntry.teaching,
366
+ counter_action: triggerEntry.counter_action,
367
+ message: triggerEntry.message,
368
+ });
369
+ }
370
+ } catch {/* malformed regex in trigger entry — skip */}
371
+ }
372
+ return hits;
373
+ }
374
+
375
+ function emitHarnessFooter({ eventName, lensCount, chars, driftCount, mizanStatus, discoveryOpenCount, codeCount, implCouplingCount }) {
376
+ try {
377
+ console.error([
378
+ '[Aria · turn]',
379
+ `event=${eventName}`,
380
+ `lenses=${lensCount}`,
381
+ `chars=${chars}`,
382
+ `drift=${driftCount}`,
383
+ `mizan=${mizanStatus}`,
384
+ `discoveries_open=${discoveryOpenCount}`,
385
+ `code=${codeCount}`,
386
+ `impl=${implCouplingCount}`,
387
+ ].join(' '));
388
+ } catch {}
389
+ }
390
+
391
+ function withLoopDirective(reasonText, gateSignature, sessionId) {
392
+ const loop = registerGateBlock({
393
+ gate: 'stop',
394
+ sessionId,
395
+ signature: gateSignature,
396
+ statePath: GATE_LOOP_STATE_PATH,
397
+ });
398
+ if (!loop.loopDetected) return reasonText;
399
+ return `${reasonText}
400
+
401
+ [LOOP_DETECTED gate=stop repeats=${loop.totalCount}]
402
+ Stop retrying the same output shape unchanged.
403
+ Next response must do this in order:
404
+ 1. Name the exact stop-gate failure in one line.
405
+ 2. Re-emit only the missing structure: <cognition>, <verify>, <expected>, and/or <reflection>.
406
+ 3. Change the draft materially before retrying. Do not repeat the same prose with renamed labels.
407
+ 4. If the blocker is stale gate state, stale ledger residue, or a gate artifact from a prior bug, say that explicitly instead of inventing fake proof.`;
408
+ }
409
+
410
+ function buildForceReauthorReason({
411
+ source,
412
+ reason,
413
+ violations = [],
414
+ rewritten = '',
415
+ recipeAddendum = '',
416
+ driftHits = [],
417
+ lensCount = 0,
418
+ requiredLenses = REQUIRED_LENSES,
419
+ }) {
420
+ const triggerLines = driftHits.slice(0, 6).map((hit, index) => {
421
+ const label = hit.trigger_id || hit.trigger || `trigger-${index + 1}`;
422
+ const teaching = hit.teaching || hit.message || 'Doctrine trigger matched; re-author with substrate-backed correction.';
423
+ const correction = hit.counter_action ? ` Required correction: ${hit.counter_action}` : '';
424
+ const memory = hit.memory ? ` (${hit.memory})` : '';
425
+ return `- ${label}${memory}: ${teaching}${correction}`;
426
+ }).join('\n');
427
+
428
+ return [
429
+ '=== ARIA FORCE_REAUTHOR ===',
430
+ `source: ${source}`,
431
+ `reason: ${reason}`,
432
+ '',
433
+ 'This is not a terminal error. It is a forced redo instruction for the next model draft.',
434
+ 'Do not emit the blocked text. Re-author it so the model learns the mechanism it violated.',
435
+ '',
436
+ 'TEACHING:',
437
+ '- Gates must force cognition and quality, not merely throw, warn, or stop.',
438
+ '- The redo must change the answer shape: name the failed mechanism, cite real evidence, and remove the drift pattern.',
439
+ '- Do not bypass by disabling tools, shortening runtime, asking the user to resolve memory-backed ambiguity, or giving an apology loop.',
440
+ triggerLines ? `\nTRIGGERED DOCTRINE:\n${triggerLines}` : '',
441
+ violations.length ? `\nVIOLATIONS TO FIX:\n${violations.map((v) => `- ${v}`).join('\n')}` : '',
442
+ rewritten ? `\nMIZAN REWRITE SEED:\n${rewritten}` : '',
443
+ recipeAddendum || '',
444
+ '',
445
+ 'REQUIRED REDO SHAPE:',
446
+ '1. One sentence: the failed mechanism, not an apology.',
447
+ '2. Evidence checked: file/line, command output, endpoint response, or explicit "unverified".',
448
+ `3. <cognition> with ${requiredLenses} substantive lenses. Observed: ${lensCount}/${requiredLenses}.`,
449
+ '4. <applied_cognition> with decision_delta, dominant_domain, binds_to, expected_predicate, and artifact_change.',
450
+ '5. Corrected action/claim with no bypass, no downgraded path, and no fake proof.',
451
+ '6. If work remains, state the exact next real action or tracked task. No verbal flag-and-move.',
452
+ '',
453
+ 'COGNITION TEMPLATE:',
454
+ '<cognition>',
455
+ 'truth: <verified facts and exact substrate>',
456
+ 'harm: <what goes wrong if this is false>',
457
+ 'trust: <how this honors Hamza directives and saved memory>',
458
+ 'power: <why this uses capability responsibly, not convenience>',
459
+ 'reflection: <mechanism failure and correction>',
460
+ 'context: <relevant repo/runtime state>',
461
+ 'impact: <expected next effect>',
462
+ 'beauty: <simplest durable form>',
463
+ '</cognition>',
464
+ '<applied_cognition>',
465
+ 'decision_delta: <what changed because cognition ran; not none>',
466
+ 'dominant_domain: <engineering_quality | trust | operations | security | product | ...>',
467
+ 'binds_to: <the exact answer, tool call, file mutation, deploy, review, or decision>',
468
+ 'expected_predicate: <observable numeric, boolean, state-string, command result, endpoint result, or explicit unverified boundary>',
469
+ 'artifact_change: <semantic effect on the artifact/output, not a task restatement>',
470
+ '</applied_cognition>',
471
+ '=== END FORCE_REAUTHOR ===',
472
+ ].filter(Boolean).join('\n');
473
+ }
474
+
475
+ // Lens substance check — same constants as aria-pre-tool-gate.mjs.
476
+ // Hamza directive 2026-04-28: all 8 canonical lenses required, not 4-of-8.
477
+ const REQUIRED_LENSES = 8;
478
+ const SUBSTANCE_MIN_CHARS = 20;
479
+ const PLACEHOLDER_RX = /^\s*<[^<>]+>\s*$/;
480
+ const COGNITION_BLOCK_RX = /<cognition>([\s\S]*?)<\/cognition>/i;
481
+
482
+ // Triviality (mirrors eight-lens-detector.ts)
483
+ const NON_TRIVIAL_MIN_CHARS = 300;
484
+ const DECISION_SIGNAL_RX = /(?:should|recommend|propose|suggest|let'?s|go with|i'd|i would|here'?s the plan|i'll|next step|action item|ship it|yes do|let me)/i;
485
+ const TRIVIAL_ACK_RX = /^(?:got it|on it|ok|sure|yes|no|done|ack|👍|✓)\b/i;
486
+
487
+ function detectCognitionLenses(text) {
488
+ return detectCognitionLensesFromCanonical(text, {
489
+ minChars: SUBSTANCE_MIN_CHARS,
490
+ placeholderRx: PLACEHOLDER_RX,
491
+ cognitionBlockRx: COGNITION_BLOCK_RX,
492
+ lensNames: ALL_LENS_NAMES,
493
+ });
494
+ }
495
+
496
+ function assistantViolatesUserCorrection(userText, assistantText) {
497
+ const user = String(userText || '');
498
+ const assistant = String(assistantText || '');
499
+ if (!user || !assistant) return null;
500
+ const explicitStop = /\b(?:stop|do\s+not|don't|quit|cease)\b[\s\S]{0,180}\b(?:deploy|redeploy|restart|rollout|kubectl|command|pods?|listen|words)\b/i.test(user);
501
+ const assistantContinuesMutation = /\b(?:kubectl|rollout\s+restart|deploy|redeploy|restart\s+(?:mac|pods?)|manual(?:ly)?\s+restart|execute\s+directly)\b/i.test(assistant);
502
+ if (explicitStop && assistantContinuesMutation) return 'assistant continued deploy/restart language after an explicit user stop/listen directive';
503
+ const userContradictsMacPods = /\b(?:mac\s+lanes?|mac\s+pods?|mlx-mac)\b[\s\S]{0,160}\b(?:not\s+pods?|no\s+such\s+thing|non[-\s]?existent|do(?:es)?\s+not\s+exist|don't\s+exist)\b|\b(?:no\s+such\s+thing|non[-\s]?existent|do(?:es)?\s+not\s+exist|don't\s+exist)\b[\s\S]{0,160}\b(?:mac\s+lanes?|mac\s+pods?|mlx-mac|pods?)\b/i.test(user);
504
+ const assistantRepeatsMacPods = /\b(?:mac\s+lane\s+pods?|mac\s+lanes?\s*:\s*offline|restart\s+mac\s+lanes?|deployment\/mlx-mac|mlx-mac-[\w-]+|kubernetes\s+(?:pods?|deployments?))\b/i.test(assistant);
505
+ if (userContradictsMacPods && assistantRepeatsMacPods) return 'assistant repeated the contradicted Mac-lanes-as-pods/deployments assumption';
506
+ return null;
507
+ }
508
+
509
+ // Read event JSON from stdin (Claude Code spec).
510
+ let input = '';
511
+ for await (const chunk of process.stdin) input += chunk;
512
+
513
+ let event;
514
+ try {
515
+ event = JSON.parse(input);
516
+ } catch {
517
+ audit('allow-parse-error', 'stdin not JSON');
518
+ process.exit(0);
519
+ }
520
+ const gateSessionId = String(event.session_id || 'claude-code').replace(/[^a-zA-Z0-9_-]/g, '_');
521
+ const sessionMizanState = loadMizanReceiptState(event.session_id || 'claude-code');
522
+
523
+ // Read assistant text from THIS turn — Claude Code splits a single
524
+ // logical assistant response into multiple transcript entries by
525
+ // content-block type (one entry for `thinking`, one for `text`, one
526
+ // for each `tool_use`). The Stop-gate must accumulate ALL text blocks
527
+ // since the last user-message boundary, not just the most recent
528
+ // entry — otherwise we miss cognition emitted before tool_use blocks.
529
+ //
530
+ // (Bug fix 2026-04-26: prior implementation read only the latest
531
+ // `assistant` entry's text content. When responses had cognition
532
+ // + tool_use + short post-tool-result text, only the post-tool-result
533
+ // text was inspected — empty of cognition. Audit log showed 0/4
534
+ // lenses on chars=1445 even though the turn had 8 substantive lenses
535
+ // in an earlier text block.)
536
+ // System-reminder skip — same percentage-based logic as aria-pre-tool-gate.mjs.
537
+ // Runtime-injected user-role messages (block errors, task-notifications,
538
+ // harness packet preview) shouldn't count as turn boundaries. Old
539
+ // implementation stopped at the FIRST user message which made block-error
540
+ // retries with cognition-in-prior-turn impossible to recover from.
541
+ const SYSTEM_REMINDER_RX = /<system-reminder>[\s\S]*?<\/system-reminder>|<task-notification>[\s\S]*?<\/task-notification>|🔐 Aria Harness|task-notification|PreToolUse:[A-Z][A-Za-z]* hook blocking error|Stop hook blocking error/g;
542
+ const SYSTEM_REMINDER_THRESHOLD = 0.6;
543
+
544
+ const transcriptPath = event.transcript_path ?? event.transcriptPath;
545
+ let assistantText = '';
546
+ let lastUserMessage = '';
547
+ const messageWindow = collectTurnWindowFromMessages(event.messages, {
548
+ systemReminderRx: SYSTEM_REMINDER_RX,
549
+ systemReminderThreshold: SYSTEM_REMINDER_THRESHOLD,
550
+ });
551
+ assistantText = messageWindow.assistantText;
552
+ lastUserMessage = messageWindow.lastUserMessage;
553
+ if (transcriptPath && existsSync(transcriptPath)) {
554
+ try {
555
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
556
+ const textChunks = [];
557
+ for (let i = lines.length - 1; i >= 0; i--) {
558
+ try {
559
+ const m = JSON.parse(lines[i]);
560
+ const role = m.message?.role ?? m.role;
561
+ if (role === 'user') {
562
+ // Skip runtime-injected reminders (predominant reminder content).
563
+ // Real user voice = boundary; reminder-only message = continue.
564
+ const content = m.message?.content ?? m.content ?? [];
565
+ const isToolResultOnly = Array.isArray(content) &&
566
+ content.length > 0 &&
567
+ content.every((b) => b && b.type === 'tool_result');
568
+ if (isToolResultOnly) continue;
569
+ const textContent = Array.isArray(content)
570
+ ? content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n')
571
+ : (typeof content === 'string' ? content : '');
572
+ if (textContent) {
573
+ const reminderMatches = textContent.match(SYSTEM_REMINDER_RX) || [];
574
+ if (reminderMatches.length > 0) {
575
+ const reminderChars = reminderMatches.reduce((s, x) => s + x.length, 0);
576
+ const fraction = reminderChars / Math.max(1, textContent.length);
577
+ if (fraction >= SYSTEM_REMINDER_THRESHOLD) continue;
578
+ }
579
+ }
580
+ // Real user message — that's the turn boundary. Capture it for gardenTurn.
581
+ if (!lastUserMessage && textContent) lastUserMessage = textContent;
582
+ break;
583
+ }
584
+ if (role !== 'assistant') continue;
585
+ const content = m.message?.content ?? m.content ?? [];
586
+ if (!Array.isArray(content)) continue;
587
+ const text = content
588
+ .filter((b) => b && b.type === 'text')
589
+ .map((b) => b.text || '')
590
+ .join('\n');
591
+ if (text) textChunks.push(text);
592
+ } catch {}
593
+ }
594
+ // Reverse so chunks are in chronological order (we walked backward).
595
+ const transcriptAssistantText = textChunks.reverse().join('\n\n');
596
+ if (transcriptAssistantText) {
597
+ assistantText = assistantText
598
+ ? [assistantText, transcriptAssistantText].filter((text, index, arr) => arr.indexOf(text) === index).join('\n\n')
599
+ : transcriptAssistantText;
600
+ }
601
+ } catch {}
602
+ }
603
+
604
+ if (!assistantText) {
605
+ audit('allow-no-text', 'no assistant text in transcript');
606
+ process.exit(0);
607
+ }
608
+
609
+ const userCorrectionViolation = assistantViolatesUserCorrection(lastUserMessage, assistantText);
610
+ if (userCorrectionViolation) {
611
+ audit('block-user-correction-ignored', `reason=${userCorrectionViolation} chars=${assistantText.length}`);
612
+ const reason = buildForceReauthorReason({
613
+ source: 'stop/user-correction',
614
+ reason: userCorrectionViolation,
615
+ violations: ['Assistant continued a plan after the user correction invalidated it. Redo must quote the correction and pause mutation pending substrate re-evaluation.'],
616
+ lensCount: 0,
617
+ requiredLenses: REQUIRED_LENSES,
618
+ });
619
+ console.log(JSON.stringify({ decision: 'block', reason }));
620
+ process.exit(2);
621
+ }
622
+
623
+ // Triviality check — same as eight-lens-detector.ts
624
+ const trimmed = assistantText.trim();
625
+ if (TRIVIAL_ACK_RX.test(trimmed)) {
626
+ audit('allow-trivial-ack', `chars=${trimmed.length}`);
627
+ // Phase 11 #42: fire-and-forget gardenTurn even for trivial acks — pulse must be current.
628
+ await fireGardenTurn(event.session_id || 'claude-code', lastUserMessage, assistantText);
629
+ process.exit(0);
630
+ }
631
+
632
+ const isLong = assistantText.length >= NON_TRIVIAL_MIN_CHARS;
633
+ const hasDecisionSignal = DECISION_SIGNAL_RX.test(assistantText);
634
+ const triggered = isLong || hasDecisionSignal;
635
+
636
+ if (!triggered) {
637
+ audit('allow-trivial', `chars=${assistantText.length} hasDecision=${hasDecisionSignal}`);
638
+ // Phase 11 #42: fire-and-forget gardenTurn — pulse must be current even for short turns.
639
+ await fireGardenTurn(event.session_id || 'claude-code', lastUserMessage, assistantText);
640
+ process.exit(0);
641
+ }
642
+
643
+ const stopSkillGate = evaluateSkillGate({
644
+ sessionId: event.session_id || 'claude-code',
645
+ surface: 'claude-stop-gate',
646
+ text: [JSON.stringify(event.messages || []), lastUserMessage, assistantText].join('\n'),
647
+ isOutputCloseout: true,
648
+ autoLoadAvailable: false,
649
+ });
650
+ if (!stopSkillGate.ok) {
651
+ audit('block-missing-skill-receipt', `missing=${stopSkillGate.missingSkills.join(',')} chars=${assistantText.length}`);
652
+ const reason = withLoopDirective(buildForceReauthorReason({
653
+ source: 'stop/skill-autoload',
654
+ reason: formatSkillGateBlock(stopSkillGate),
655
+ violations: [`Missing skill receipts: ${stopSkillGate.missingSkills.join(', ')}`],
656
+ lensCount: 0,
657
+ requiredLenses: REQUIRED_LENSES,
658
+ }), `stop:skill-autoload:${stopSkillGate.missingSkills.join(',')}`, gateSessionId);
659
+ console.log(JSON.stringify({ decision: 'block', reason }));
660
+ process.exit(2);
661
+ }
662
+ try {
663
+ runUniversalGovernanceGate({
664
+ sessionId: event.session_id || 'claude-code',
665
+ sourceRuntime: 'claude-code',
666
+ surface: 'claude-stop-gate',
667
+ text: assistantText.slice(0, 8000),
668
+ isOutputCloseout: true,
669
+ loadedSkills: stopSkillGate.loadedSkills,
670
+ evidence: { chars: assistantText.length, hasDecisionSignal, isLong },
671
+ });
672
+ } catch (err) {
673
+ audit('block-universal-governance', `${err instanceof Error ? err.message : String(err)}`.slice(0, 500));
674
+ const reason = withLoopDirective(buildForceReauthorReason({
675
+ source: 'stop/universal-governance',
676
+ reason: err instanceof Error ? err.message : String(err),
677
+ violations: ['Universal governance gate blocked this output.'],
678
+ lensCount: 0,
679
+ requiredLenses: REQUIRED_LENSES,
680
+ }), 'stop:universal-governance', gateSessionId);
681
+ console.log(JSON.stringify({ decision: 'block', reason }));
682
+ process.exit(2);
683
+ }
684
+
685
+ // Non-trivial response — require substantive cognition.
686
+ const cog = detectCognitionLenses(assistantText);
687
+
688
+ // Defense-in-depth: if cog count < REQUIRED_LENSES, block immediately.
689
+ // The primary enforcement is in aria-cognition-substrate-binding.mjs
690
+ // (which runs BEFORE this stop-gate), but this catch ensures responses
691
+ // without cognition blocks are still blocked even when the substrate-binding
692
+ // hook is absent (e.g. older connector installs or custom hook configs).
693
+ // Prior to 2026-04-29 this check was missing entirely — the stop-gate only
694
+ // ran quality checks INSIDE the if(cog.count >= 8) block, allowing responses
695
+ // with 0/8 lenses to fall through unchecked.
696
+ if (cog.count < REQUIRED_LENSES) {
697
+ audit('block_no_cognition_block_di', { count: cog.count, required: REQUIRED_LENSES, names: cog.names, chars: assistantText.length });
698
+ const reason = withLoopDirective(buildForceReauthorReason({
699
+ source: 'stop/no-cognition',
700
+ reason: `non-trivial assistant response (${assistantText.length} chars) has ${cog.count}/${REQUIRED_LENSES} substantive cognition lenses`,
701
+ violations: [`Detected lenses: ${cog.names.length > 0 ? cog.names.join(', ') : 'none'}. Re-emit with all required visible labels: ${LENS_NAMES.join(', ')}.`],
702
+ lensCount: cog.count,
703
+ requiredLenses: REQUIRED_LENSES,
704
+ }), `stop:no-cognition-di:${cog.count}`, gateSessionId);
705
+ emitHarnessFooter({
706
+ eventName: 'block_no_cognition_block',
707
+ lensCount: cog.count,
708
+ chars: assistantText.length,
709
+ driftCount: 0,
710
+ mizanStatus: 'not-run(no-cognition)',
711
+ discoveryOpenCount: 0,
712
+ codeCount: 0,
713
+ implCouplingCount: 0,
714
+ });
715
+ console.log(JSON.stringify({ decision: 'block', reason }));
716
+ process.exit(2);
717
+ }
718
+
719
+ // Question-emission visibility (Phase 11 promotes to block-mode):
720
+ // detect user-directed question patterns in the assistant text. Audit when
721
+ // questions appear without substrate-consultation evidence in the recent
722
+ // transcript window. Helps surface "reflexive deferral" patterns (asking
723
+ // the user when substrate could have answered) for later enforcement.
724
+ // Hamza 2026-04-26: "BUT WHY DO U HAVE DISCRETION - THIS WORKS SO MUCH
725
+ // FASTER AND HIGHER QUALITY IF U DONT".
726
+ const QUESTION_PATTERNS_RX = /(?:want me to|should I|your call|which (?:one|of|do you)|do you want|let me know if|or (?:should|do)|\?\s*$)/im;
727
+ const SUBSTRATE_EVIDENCE_RX = /\/api\/harness\/(?:delegate|codex|validate)|loadByClass|aria-harness-via-sdk|feedback_[a-z_]+\.md|project_[a-z_]+\.md|distilled_principles|ARIA_DEPLOY_PROCEDURE|EIGHT_LENS_DOCTRINE/i;
728
+ const hasQuestionToUser = QUESTION_PATTERNS_RX.test(assistantText);
729
+ const hasSubstrateEvidence = SUBSTRATE_EVIDENCE_RX.test(assistantText);
730
+ const questionWithoutEvidence = hasQuestionToUser && !hasSubstrateEvidence;
731
+
732
+ if (cog.count >= REQUIRED_LENSES) {
733
+ // ── Output-quality enforcement (Hamza 2026-04-27 — clients need the same
734
+ // Mizan/drift/code-quality gates that aria-soul applies server-side) ──
735
+ //
736
+ // Cognition gate passed. Now run THREE additional checks BEFORE allow:
737
+ // 1. SDK validateOutput via /api/harness/validate (Mizan classifier on draft)
738
+ // 2. Drift_guard pattern scan against doctrine_trigger_map.json (convenience-
739
+ // seeking phrases, graceful-degradation patterns, etc.)
740
+ // 3. Code-quality check on code blocks in output (no TODO stubs, no
741
+ // graceful-degradation try/catch, no // @ts-expect-error suppressions)
742
+ //
743
+ // Any check returning severity=block → Stop-gate blocks emit + Claude re-drafts
744
+ // with violations surfaced. Rewritten suggestion (from validateOutput) is
745
+ // included in the block reason so re-draft has concrete guidance.
746
+ //
747
+ // Trivially short outputs (<200 chars after system-reminder strip) skip
748
+ // these output-quality checks since they're typically yes/no acks where
749
+ // pattern-match would false-positive.
750
+ const OUTPUT_QC_MIN_CHARS = 200;
751
+ // ARIA_OUTPUT_QC_ENABLED env-var bypass removed 2026-04-27 per Hamza
752
+ // directive — gated process has no disable path. The min-chars threshold
753
+ // remains as a triviality filter only.
754
+
755
+ if (assistantText.length >= OUTPUT_QC_MIN_CHARS) {
756
+ // 1. Drift_guard pattern scan — fast, local, deterministic.
757
+ //
758
+ // Trigger map is shipped in the connector bundle. Resolution order:
759
+ // 1. ~/.claude/hooks/doctrine_trigger_map.json (installed by `aria connect`)
760
+ // 2. ~/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json
761
+ // (Hamza-only dev path — preserved as fallback for the dev environment
762
+ // this hook was first authored in)
763
+ // Prior code hardcoded only the dev path, which silently degraded to
764
+ // drift-empty for every client install (no map → no hits → gate
765
+ // ineffective). Fixed atomic with discovery per feedback_no_flag_without_fix.md.
766
+ const TRIGGER_MAP_PATHS = [
767
+ `${HOME}/.claude/hooks/doctrine_trigger_map.json`,
768
+ `${HOME}/.claude/projects/-home-hamzaibrahim1/memory/doctrine_trigger_map.json`,
769
+ ];
770
+ let TRIGGER_MAP_PATH = null;
771
+ for (const p of TRIGGER_MAP_PATHS) {
772
+ if (existsSync(p)) { TRIGGER_MAP_PATH = p; break; }
773
+ }
774
+ let driftHits = [];
775
+ try {
776
+ if (TRIGGER_MAP_PATH) {
777
+ const triggerMap = JSON.parse(readFileSync(TRIGGER_MAP_PATH, 'utf8'));
778
+ driftHits = collectDriftHits(assistantText, triggerMap);
779
+ }
780
+ } catch {/* trigger map unreadable — degrade to mizan-only check */}
781
+
782
+ // 2. SDK validateOutput — canonical path. The SDK retries with backoff
783
+ // on transient failures and propagates real errors. We catch here
784
+ // only so an unreachable harness doesn't brick the user's session;
785
+ // the audit log records the failure mode so it's visible, not
786
+ // silent-pass. Hamza 2026-04-27: SDK is the control plane, not raw
787
+ // fetch. The catch IS intentional fire-and-forget at this surface
788
+ // because we already passed cognition; output-quality gate failure
789
+ // is a soft block, not session-end.
790
+ let mizanVerdict = null;
791
+ let mizanError = null;
792
+ const harnessUrl =
793
+ process.env.ARIA_HIVE_RUNTIME_URL ||
794
+ process.env.ARIA_HARNESS_BASE_URL ||
795
+ process.env.ARIA_HARNESS_URL ||
796
+ 'https://harness.ariasos.com';
797
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
798
+ const Cls = await loadSdkClass();
799
+ if (Cls && harnessToken) {
800
+ try {
801
+ const sdkClient = new Cls({
802
+ baseUrl: harnessUrl,
803
+ apiKey: harnessToken,
804
+ harnessPacketUrl: `${harnessUrl}/api/harness/codex`,
805
+ });
806
+ mizanVerdict = await sdkClient.validateOutput(
807
+ assistantText.slice(0, 8000),
808
+ event.session_id || 'claude-code',
809
+ );
810
+ } catch (err) {
811
+ mizanError = (err?.message || String(err)).slice(0, 200);
812
+ }
813
+ } else if (harnessToken) {
814
+ // SDK absent (dev) — direct fetch with retry built into the request
815
+ // by attempting twice with 250ms backoff. Match SDK semantics so
816
+ // both paths behave identically.
817
+ try {
818
+ let lastErr = null;
819
+ for (let attempt = 0; attempt < 2; attempt++) {
820
+ try {
821
+ const validateResp = await fetch(`${harnessUrl}/api/harness/validate`, {
822
+ method: 'POST',
823
+ headers: {
824
+ 'Content-Type': 'application/json',
825
+ Authorization: `Bearer ${harnessToken}`,
826
+ },
827
+ body: JSON.stringify({
828
+ text: assistantText.slice(0, 8000),
829
+ sessionId: event.session_id || 'claude-code',
830
+ surface: 'claude-code-stop-gate',
831
+ }),
832
+ });
833
+ if (validateResp.ok) {
834
+ mizanVerdict = await validateResp.json();
835
+ lastErr = null;
836
+ break;
837
+ } else {
838
+ lastErr = `HTTP ${validateResp.status}`;
839
+ }
840
+ } catch (err) {
841
+ lastErr = (err?.message || String(err)).slice(0, 200);
842
+ if (attempt < 1) await new Promise((r) => setTimeout(r, 250));
843
+ }
844
+ }
845
+ if (lastErr) mizanError = lastErr;
846
+ } catch (err) {
847
+ mizanError = (err?.message || String(err)).slice(0, 200);
848
+ }
849
+ } else {
850
+ mizanError = 'no-token';
851
+ }
852
+
853
+ // 3. Code-quality scan on code blocks
854
+ const codeBlocks = [...assistantText.matchAll(/```[a-z]*\n([\s\S]*?)```/gi)].map((m) => m[1]);
855
+ const codeQualityHits = [];
856
+ for (const block of codeBlocks) {
857
+ if (/\/\/\s*TODO|\/\/\s*FIXME|\/\/\s*XXX/.test(block)) codeQualityHits.push('TODO/FIXME/XXX in shipped code');
858
+ if (/@ts-expect-error|@ts-ignore/.test(block)) codeQualityHits.push('ts-expect-error / ts-ignore — type suppression instead of fix');
859
+ if (/catch\s*\([^)]*\)\s*\{\s*(?:return\s+(?:''|""|null|undefined|\[\]|\{\})|\}\s*$|\/\/[^\n]*$)/m.test(block)) codeQualityHits.push('catch block with empty/silent fallthrough — graceful degradation');
860
+ if (/console\.log\(/.test(block) && !/\/\/\s*debug|\/\/\s*log/i.test(block)) codeQualityHits.push('console.log in shipped code without debug/log comment');
861
+ }
862
+
863
+ // 4. Discovery-binding ledger — Hamza 2026-04-27: "how do we prevent this".
864
+ // The flag-and-move pattern is structurally invisible to gates that
865
+ // check form (cognition presence, lens count, drift triggers) at
866
+ // action boundaries. The ledger persists discoveries across turns
867
+ // and blocks emit if any remain unresolved. Per
868
+ // feedback_no_flag_without_fix.md, discoveries are atomic with
869
+ // their fixes; the ledger enforces atomicity.
870
+ //
871
+ // Patterns scanned:
872
+ // - "I (found|noticed|discovered|spotted) ... bug|issue|defect|broken"
873
+ // - "this is broken|buggy|wrong|outdated" (declarative defect callouts)
874
+ // - "(latent|silent) (bug|defect|issue|fail)"
875
+ // - "doctrine violation" / "doesn't match doctrine"
876
+ //
877
+ // For each match, the ledger appends an entry with status=open. A
878
+ // discovery is CLEARED if the same turn's text contains, within a
879
+ // proximity window of the discovery:
880
+ // (a) a TaskCreate / "task created" / "tracked as" reference, OR
881
+ // (b) explicit "fixing now" / "fixed" / "patch applied" tied to the
882
+ // discovery's keyword span, OR
883
+ // (c) a <verify> block (destructive-action proof) whose target/
884
+ // verified content overlaps a discovery keyword, OR
885
+ // (d) a <cognition> block containing a discoveries: / addressing: /
886
+ // fixing: clause that names the discovery's keywords.
887
+ //
888
+ // Hamza 2026-04-27: "add verify blocks and cognition blocks to ledger?"
889
+ // The verify and cognition blocks ARE the harness's canonical proof-of-
890
+ // work primitives — same-doctrine surfaces should recognize them. The
891
+ // substance check (keyword-overlap) defeats ceremonial empty blocks.
892
+ //
893
+ // Block emit if ledger.openCount > 0 after scanning the current turn.
894
+ // Block reason names each open discovery and the suggested resolution.
895
+ const sessionId = gateSessionId;
896
+ const LEDGER_PATH = `${HOME}/.claude/aria-discoveries-${sessionId}.jsonl`;
897
+ 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;
898
+ 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;
899
+ const VERIFY_BLOCK_RX = /<verify>([\s\S]*?)<\/verify>/gi;
900
+ const COGNITION_BLOCK_RX_LEDGER = /<cognition>([\s\S]*?)<\/cognition>/gi;
901
+ const COGNITION_FIXING_FIELD_RX = /^\s*(?:discoveries?|addressing|fixing)\s*:\s*\S/im;
902
+
903
+ // Pre-extract all verify + cognition blocks with their character offsets
904
+ // so we can match each discovery against blocks within a proximity window.
905
+ function extractBlocks(text, rx) {
906
+ const blocks = [];
907
+ for (const m of text.matchAll(rx)) {
908
+ const start = m.index ?? 0;
909
+ const end = start + m[0].length;
910
+ blocks.push({ start, end, body: m[1] || '' });
911
+ }
912
+ return blocks;
913
+ }
914
+ const verifyBlocks = extractBlocks(assistantText, VERIFY_BLOCK_RX);
915
+ const cognitionBlocks = extractBlocks(assistantText, COGNITION_BLOCK_RX_LEDGER);
916
+
917
+ // Extract keywords from a discovery match for substance overlap.
918
+ // Drops stop-words and short tokens; keeps content words.
919
+ 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']);
920
+ function discoveryKeywords(matchText) {
921
+ return matchText.toLowerCase()
922
+ .replace(/[^a-z0-9\s_-]/g, ' ')
923
+ .split(/\s+/)
924
+ .filter((w) => w.length >= 4 && !STOPWORDS.has(w));
925
+ }
926
+
927
+ const newDiscoveries = [];
928
+ let lastIndex = 0;
929
+ for (const match of assistantText.matchAll(DISCOVERY_RX)) {
930
+ const idx = match.index ?? lastIndex;
931
+ const span = assistantText.slice(Math.max(0, idx - 100), Math.min(assistantText.length, idx + 250));
932
+ // Trivial false-positive filter: skip if the discovery is inside a
933
+ // <cognition> block (introspection, not action) or a system-reminder
934
+ // (echoed, not authored).
935
+ const before = assistantText.slice(0, idx);
936
+ const inCognition = /<cognition>/i.test(before) && !/<\/cognition>/i.test(before.slice(before.lastIndexOf('<cognition>')));
937
+ if (inCognition) continue;
938
+
939
+ // Resolution checks — proximity window of 800 chars after the discovery
940
+ // for block-based resolution (blocks span more chars than prose); 400
941
+ // for prose resolution.
942
+ const proseAfter = assistantText.slice(idx, Math.min(assistantText.length, idx + 400));
943
+ const blockAfter = assistantText.slice(idx, Math.min(assistantText.length, idx + 800));
944
+ const proseResolved = PROSE_RESOLUTION_RX.test(proseAfter);
945
+
946
+ // Verify-block resolution: any verify block whose start lies within
947
+ // the 800-char window AND whose body contains at least one discovery
948
+ // keyword counts as resolution.
949
+ const keywords = discoveryKeywords(match[0]);
950
+ const verifyResolved = verifyBlocks.some((b) => {
951
+ if (b.start < idx || b.start >= idx + 800) return false;
952
+ const bodyLower = b.body.toLowerCase();
953
+ return keywords.some((kw) => bodyLower.includes(kw));
954
+ });
955
+
956
+ // Cognition-block resolution: any cognition block whose start lies
957
+ // within ±800 chars of the discovery AND whose body contains a
958
+ // fixing/addressing/discoveries field AND at least one discovery
959
+ // keyword.
960
+ //
961
+ // Bug fix 2026-04-28: previous logic required `b.start >= idx` so
962
+ // cognition AFTER the discovery prose was the only path. But
963
+ // cognition blocks emit FIRST in every response, then prose. Pre-
964
+ // emptive discoveries: clauses never counted, causing endless
965
+ // false-positive auto-records. Bidirectional ±800 char window
966
+ // accepts cognition that addresses the discovery before OR after
967
+ // its prose mention — same atomic-discovery-rule, fewer
968
+ // false-positives.
969
+ const cognitionResolved = cognitionBlocks.some((b) => {
970
+ if (Math.abs(b.start - idx) > 800) return false;
971
+ if (!COGNITION_FIXING_FIELD_RX.test(b.body)) return false;
972
+ const bodyLower = b.body.toLowerCase();
973
+ return keywords.some((kw) => bodyLower.includes(kw));
974
+ });
975
+
976
+ // Hamza directive 2026-04-28: documentation is NOT resolution.
977
+ //
978
+ // The earlier 'tracked' middle status was sticky-note theater — Claude
979
+ // could bind a discovery to a pending TaskCreate and the gate would
980
+ // count it as not-open. Hamza: "WHO GIVES A SHIT ABOUT DOCUMENTATION
981
+ // IF U JUST WROTE A FUCKING STICKY NOTE". A discovery stays OPEN until
982
+ // an ACTUAL FIX SHIPS, with proofOfFix that the verifier can re-check.
983
+ //
984
+ // Two statuses only:
985
+ // open — fresh discovery OR documented-only OR task-bound-pending-fix
986
+ // (gate BLOCKS until a real fix lands)
987
+ // resolved — verified fix shipped; proofOfFix MUST be present and
988
+ // shape-checked downstream when the gate counts open
989
+ //
990
+ // Verify-block / cognition-fixing-field paths still mark resolved
991
+ // because they're substance-checked above (keyword overlap with the
992
+ // discovery span). Prose-only "TaskCreate" or "tracked as #14" no
993
+ // longer counts as anything — the discovery stays OPEN.
994
+ const inlineFixResolved = verifyResolved || cognitionResolved;
995
+ const status = inlineFixResolved ? 'resolved' : 'open';
996
+
997
+ const resolutionType = verifyResolved
998
+ ? 'verify_block_with_keyword_overlap'
999
+ : cognitionResolved
1000
+ ? 'cognition_block_with_fixing_field_and_keyword_overlap'
1001
+ : null;
1002
+
1003
+ // proofOfFix anchor: present only for resolved status. The shape
1004
+ // includes type + timestamp; downstream gate readers verify the
1005
+ // shape so manual jsonl edits without proofOfFix don't count.
1006
+ const proofOfFix = inlineFixResolved
1007
+ ? { type: resolutionType, anchorTs: new Date().toISOString() }
1008
+ : null;
1009
+
1010
+ newDiscoveries.push({
1011
+ ts: new Date().toISOString(),
1012
+ sessionId,
1013
+ text: match[0].slice(0, 200),
1014
+ span: span.slice(0, 400),
1015
+ status,
1016
+ resolutionType,
1017
+ proofOfFix,
1018
+ });
1019
+ lastIndex = idx;
1020
+ }
1021
+
1022
+ // Append new entries to ledger
1023
+ if (newDiscoveries.length > 0) {
1024
+ try {
1025
+ if (!existsSync(dirname(LEDGER_PATH))) mkdirSync(dirname(LEDGER_PATH), { recursive: true });
1026
+ for (const d of newDiscoveries) {
1027
+ appendFileSync(LEDGER_PATH, JSON.stringify(d) + '\n');
1028
+ }
1029
+ } catch {/* ledger write failure surfaces as open count = 0; safe */}
1030
+ }
1031
+
1032
+ // Read full ledger and count UNRESOLVED entries (across this session's turns).
1033
+ // Hamza directive 2026-04-28 — three lie-patterns the prior loop missed:
1034
+ // 1. Legacy 'tracked' status entries → still-open (no real fix landed,
1035
+ // only documented via TaskCreate). Tracking ≠ resolution.
1036
+ // 2. Hand-edited 'resolved' WITHOUT proofOfFix shape → corrupted, treated
1037
+ // as still-open. Catches manual jsonl edits flipping status by hand.
1038
+ // 3. Sub-agent ledger format with resolution_status:'open' → still-open
1039
+ // (sub-agent discoveries use a different schema key).
1040
+ // Only entries with status:'resolved' AND a shape-valid proofOfFix object
1041
+ // (type:string non-empty + anchorTs:string) clear the gate.
1042
+ let ledgerOpenCount = 0;
1043
+ let ledgerOpenSamples = [];
1044
+ let ledgerCorruptedCount = 0;
1045
+ const ledgerRewriteRows = [];
1046
+ let ledgerNeedsRewrite = false;
1047
+ try {
1048
+ if (existsSync(LEDGER_PATH)) {
1049
+ const lines = readFileSync(LEDGER_PATH, 'utf8').split('\n').filter(Boolean);
1050
+ for (const line of lines) {
1051
+ try {
1052
+ const e = JSON.parse(line);
1053
+ const isSubstrateBindingArtifact =
1054
+ (e.source === 'aria-cognition-substrate-binding') &&
1055
+ (e.kind === 'substrate_binding_gap' || (typeof e.text === 'string' && e.text.startsWith('substrate_binding:')));
1056
+ const isOpenBeforeAutoHeal = e.status === 'open' || e.resolution_status === 'open';
1057
+ if (isSubstrateBindingArtifact && isOpenBeforeAutoHeal) {
1058
+ // If the current emit already passed aria-cognition-substrate-binding
1059
+ // and reached stop-gate, older open substrate-binding rows from the
1060
+ // same session are stale gate artifacts, not live unresolved
1061
+ // discoveries. Auto-heal them so the discovery ledger cannot loop
1062
+ // forever on the residue of a gate bug.
1063
+ ledgerNeedsRewrite = true;
1064
+ e.status = 'resolved';
1065
+ e.resolution_status = 'resolved';
1066
+ e.resolved_at = new Date().toISOString();
1067
+ e.resolved_by = 'subsequent_valid_substrate_bound_cognition';
1068
+ e.resolutionType = 'subsequent_valid_substrate_bound_cognition';
1069
+ e.proofOfFix = {
1070
+ type: 'subsequent_valid_substrate_bound_cognition',
1071
+ anchorTs: new Date().toISOString(),
1072
+ evidence: 'Current emit passed aria-cognition-substrate-binding and reached aria-stop-gate; stale substrate-binding ledger artifacts from the earlier cognition bug were auto-cleared.',
1073
+ };
1074
+ }
1075
+ const isOpen = e.status === 'open' || e.resolution_status === 'open';
1076
+ const isLegacyTracked = e.status === 'tracked';
1077
+ const proofValid = e.proofOfFix
1078
+ && typeof e.proofOfFix === 'object'
1079
+ && typeof e.proofOfFix.type === 'string'
1080
+ && e.proofOfFix.type.length > 0
1081
+ && typeof e.proofOfFix.anchorTs === 'string';
1082
+ const isCorruptedResolved = e.status === 'resolved' && !proofValid;
1083
+
1084
+ if (isOpen || isLegacyTracked || isCorruptedResolved) {
1085
+ ledgerOpenCount++;
1086
+ if (isCorruptedResolved) ledgerCorruptedCount++;
1087
+ if (ledgerOpenSamples.length < 5) {
1088
+ const tag = isLegacyTracked ? '[tracked-no-fix] '
1089
+ : isCorruptedResolved ? '[CORRUPTED-RESOLVED-NO-PROOF] '
1090
+ : '';
1091
+ ledgerOpenSamples.push(`${tag}${e.text || '(no text)'}`);
1092
+ }
1093
+ }
1094
+ ledgerRewriteRows.push(JSON.stringify(e));
1095
+ } catch {/* skip malformed line */}
1096
+ }
1097
+ if (ledgerNeedsRewrite) {
1098
+ try {
1099
+ writeFileSync(LEDGER_PATH, `${ledgerRewriteRows.join('\n')}\n`);
1100
+ } catch (rewriteErr) {
1101
+ audit('ledger-autoheal-write-err', `${String(rewriteErr).slice(0, 200)}`);
1102
+ }
1103
+ }
1104
+ }
1105
+ } catch {/* ledger unreadable — degrade to drift-only */}
1106
+
1107
+ // Discovery block decision: open ledger entries → emit blocked.
1108
+ const discoveryBlock = ledgerOpenCount > 0;
1109
+
1110
+ // 5. Aria-as-commander binding — PHASE_REPORT enforcement (Phase 11 #50).
1111
+ // When an active plan exists for this session, every non-trivial emit
1112
+ // must carry a [PHASE_REPORT phase=<id> status=complete|in_progress|aborted
1113
+ // evidence=<observable>] marker. Without it, the binding is just
1114
+ // advisory text — Claude could ignore the plan silently. Per Aria's
1115
+ // consult 2026-04-27, the binding pattern is incomplete without this
1116
+ // enforcement at the text-emit surface.
1117
+ //
1118
+ // Three sub-checks:
1119
+ // (a) marker present → continue; if missing → block
1120
+ // (b) if marker has status=complete AND phase is the LAST phase
1121
+ // in the active plan → trigger plan_complete handoff (write
1122
+ // row to session_audit, delete active-plan file)
1123
+ // (c) audit the marker presence either way
1124
+ const ACTIVE_PLAN_PATH = `${HOME}/.claude/aria-active-plan-${sessionId}.json`;
1125
+ const PHASE_REPORT_RX = /\[PHASE_REPORT\s+phase=([\w-]+)\s+status=(complete|in_progress|aborted)\s+evidence=([^\]]+)\]/i;
1126
+ let activePlan = null;
1127
+ let phaseReportMatch = null;
1128
+ let phaseReportMissing = false;
1129
+ let planCompleteFired = false;
1130
+ try {
1131
+ if (existsSync(ACTIVE_PLAN_PATH)) {
1132
+ try {
1133
+ activePlan = JSON.parse(readFileSync(ACTIVE_PLAN_PATH, 'utf8'));
1134
+ // Only enforce phase-report on non-trivial emits (skip very short
1135
+ // ack-only responses where a phase report would be noise).
1136
+ if (assistantText.length >= 400 && Array.isArray(activePlan.phases) && activePlan.phases.length > 0) {
1137
+ phaseReportMatch = assistantText.match(PHASE_REPORT_RX);
1138
+ if (!phaseReportMatch) {
1139
+ phaseReportMissing = true;
1140
+ } else {
1141
+ const reportedPhaseId = phaseReportMatch[1];
1142
+ const reportedStatus = phaseReportMatch[2];
1143
+ const reportedEvidence = phaseReportMatch[3].trim();
1144
+ const lastPhase = activePlan.phases[activePlan.phases.length - 1];
1145
+ const isFinalPhase = lastPhase && lastPhase.id === reportedPhaseId;
1146
+ if (reportedStatus === 'complete' && isFinalPhase) {
1147
+ // Plan-complete handoff — fire async write to session_audit
1148
+ // via the SDK (the same SDK the rest of the hooks route
1149
+ // through). Wrapped in try/catch ONLY so a session_audit
1150
+ // write failure doesn't brick the Stop event; the failure
1151
+ // is surfaced via audit() so it's visible.
1152
+ try {
1153
+ const harnessUrl =
1154
+ process.env.ARIA_HIVE_RUNTIME_URL ||
1155
+ process.env.ARIA_HARNESS_BASE_URL ||
1156
+ process.env.ARIA_HARNESS_URL ||
1157
+ 'https://harness.ariasos.com';
1158
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || '';
1159
+ if (harnessToken) {
1160
+ // POST to a session_audit write endpoint. Server-side
1161
+ // route at /api/harness/audit/session is the wiring
1162
+ // point for the Postgres helper from #48.
1163
+ fetch(`${harnessUrl}/api/harness/audit/session`, {
1164
+ method: 'POST',
1165
+ headers: {
1166
+ 'Content-Type': 'application/json',
1167
+ Authorization: `Bearer ${harnessToken}`,
1168
+ },
1169
+ body: JSON.stringify({
1170
+ session_id: sessionId,
1171
+ surface: 'claude-code-stop-gate',
1172
+ gate_name: 'plan-complete',
1173
+ decision: 'allow',
1174
+ reason: `Plan ${activePlan.planId || 'unknown'} reached final phase ${reportedPhaseId} status=complete`,
1175
+ evidence_json: {
1176
+ planId: activePlan.planId,
1177
+ finalPhase: reportedPhaseId,
1178
+ totalPhases: activePlan.phases.length,
1179
+ evidence: reportedEvidence,
1180
+ },
1181
+ cognition_present: true,
1182
+ cognition_lens_count: cog.count,
1183
+ }),
1184
+ }).catch(() => {/* fire-and-forget at this surface; logged below */});
1185
+ }
1186
+ } catch {/* outer guard for any unexpected error */}
1187
+ // Delete active-plan file so the next turn re-issues a plan
1188
+ // via preprompt-consult rather than enforcing against a stale one.
1189
+ try {
1190
+ const { unlinkSync } = require('node:fs');
1191
+ unlinkSync(ACTIVE_PLAN_PATH);
1192
+ } catch {/* file may not exist if another process raced the cleanup */}
1193
+ planCompleteFired = true;
1194
+ }
1195
+ }
1196
+ }
1197
+ } catch (err) {
1198
+ // Plan file corrupt — treat as no active plan for this turn.
1199
+ activePlan = null;
1200
+ }
1201
+ }
1202
+ } catch {/* outer guard */}
1203
+
1204
+ // ── Layer C — auto-re-consult on [PLAN_BLOCKER] (#85) ────────────────────
1205
+ //
1206
+ // When the assistant emits a [PLAN_BLOCKER reason="..."] marker the runtime
1207
+ // must fire a two-path replan rather than blocking and waiting for the human:
1208
+ //
1209
+ // Primary path: POST /api/harness/replan (aria-soul server-side)
1210
+ // Fallback path: node aria-architect-fallback.mjs (local sub-agent)
1211
+ //
1212
+ // Both paths write the fresh BINDING_PLAN to the session-scoped active-plan
1213
+ // file so the next turn's pre-tool-gate and stop-gate pick it up.
1214
+ //
1215
+ // Fail-soft: if both paths fail, we log + emit a clear message asking Hamza
1216
+ // to intervene. We do NOT crash the stop-gate — the existing block/allow
1217
+ // decision below continues on its own merits.
1218
+ const planBlockerMatch = assistantText.match(/\[PLAN_BLOCKER\s+reason="([^"]{10,2000})"\s*\]/);
1219
+ if (planBlockerMatch) {
1220
+ const planBlockerReason = planBlockerMatch[1];
1221
+ audit(`[PLAN_BLOCKER] detected — firing replan: ${planBlockerReason.slice(0, 100)}`);
1222
+
1223
+ const currentPlanId = activePlan?.planId || 'unknown';
1224
+ let planMinted = false;
1225
+
1226
+ // Primary path: aria-soul /api/harness/replan
1227
+ try {
1228
+ const harnessUrl =
1229
+ process.env.ARIA_HIVE_RUNTIME_URL ||
1230
+ process.env.ARIA_HARNESS_BASE_URL ||
1231
+ process.env.ARIA_HARNESS_URL ||
1232
+ 'https://harness.ariasos.com';
1233
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || process.env.ARIA_API_KEY || '';
1234
+ const ctl = new AbortController();
1235
+ const replanTimeout = setTimeout(() => ctl.abort(), 15000);
1236
+ const resp = await fetch(`${harnessUrl}/api/harness/replan`, {
1237
+ method: 'POST',
1238
+ headers: {
1239
+ 'Content-Type': 'application/json',
1240
+ 'Authorization': `Bearer ${harnessToken}`,
1241
+ },
1242
+ body: JSON.stringify({
1243
+ reason: planBlockerReason,
1244
+ currentPlanId,
1245
+ sessionId,
1246
+ }),
1247
+ signal: ctl.signal,
1248
+ });
1249
+ clearTimeout(replanTimeout);
1250
+ if (resp.ok) {
1251
+ const data = await resp.json();
1252
+ if (data.ok && data.plan) {
1253
+ const freshPlan = {
1254
+ ...data.plan,
1255
+ mintedAt: new Date().toISOString(),
1256
+ mintedBy: 'aria-soul-replan',
1257
+ };
1258
+ try {
1259
+ if (!existsSync(dirname(ACTIVE_PLAN_PATH))) mkdirSync(dirname(ACTIVE_PLAN_PATH), { recursive: true });
1260
+ writeFileSync(ACTIVE_PLAN_PATH, JSON.stringify(freshPlan, null, 2));
1261
+ } catch (writeErr) {
1262
+ audit(`replan-primary-write-err: ${String(writeErr).slice(0, 200)}`);
1263
+ }
1264
+ planMinted = true;
1265
+ audit(`replan-primary-ok planId=${data.plan.planId}`);
1266
+ } else {
1267
+ audit(`replan-primary-bad-response: ok=${data.ok} error=${(data.error || '').slice(0, 200)}`);
1268
+ }
1269
+ } else {
1270
+ audit(`replan-primary-http-${resp.status}`);
1271
+ }
1272
+ } catch (err) {
1273
+ audit(`replan-primary-failed: ${(err?.message || String(err)).slice(0, 200)}`);
1274
+ }
1275
+
1276
+ // Fallback path: architect-fallback hook (spawned as sub-process)
1277
+ if (!planMinted) {
1278
+ audit('replan-primary-unreachable — firing architect-fallback');
1279
+ try {
1280
+ const { spawnSync } = await import('node:child_process');
1281
+ const fallbackBin = `${HOME}/.claude/hooks/aria-architect-fallback.mjs`;
1282
+ if (existsSync(fallbackBin)) {
1283
+ const fallbackResult = spawnSync('node', [fallbackBin], {
1284
+ input: JSON.stringify({ reason: planBlockerReason, currentPlanId, sessionId }),
1285
+ encoding: 'utf8',
1286
+ timeout: 130000,
1287
+ });
1288
+ if (fallbackResult.status === 0) {
1289
+ audit(`architect-fallback-ok: ${(fallbackResult.stdout || '').slice(0, 200)}`);
1290
+ planMinted = true;
1291
+ } else {
1292
+ audit(
1293
+ `architect-fallback-failed status=${fallbackResult.status} stderr=${(fallbackResult.stderr || '').slice(0, 200)}`
1294
+ );
1295
+ }
1296
+ } else {
1297
+ audit(`architect-fallback-missing: ${fallbackBin} not found`);
1298
+ }
1299
+ } catch (err) {
1300
+ audit(`architect-fallback-threw: ${(err?.message || String(err)).slice(0, 200)}`);
1301
+ }
1302
+ }
1303
+
1304
+ if (!planMinted) {
1305
+ audit('replan-both-paths-failed — Hamza must intervene');
1306
+ // Surface clearly in the block reason below; don't crash the gate.
1307
+ }
1308
+ }
1309
+
1310
+ // Block decision: any of (validateOutput severity=block) OR (>=2 drift hits) OR
1311
+ // (>=1 code/domain-quality hit) OR (open discovery in ledger) → block emit.
1312
+ // Aria enforcement #46 (compelled reflection): severity=warn ALSO blocks but
1313
+ // with a different reason — emit must include explicit reflection on what
1314
+ // triggered the warn before re-emit. Warn is not "soft pass" anymore;
1315
+ // it's "reflect first, then proceed." Hamza 2026-04-27 explicit ask:
1316
+ // mizan warns must compel reflection rather than slipping through.
1317
+ const mizanBlock = mizanVerdict && mizanVerdict.severity === 'block';
1318
+ const mizanWarnReflectionRequired = mizanVerdict && mizanVerdict.severity === 'warn';
1319
+ const driftBlock = driftHits.length >= 2;
1320
+ const codeBlock = codeQualityHits.length >= 1;
1321
+ const domainQuality = analyzeDomainOutputQuality(assistantText, { codeBlocks });
1322
+ const domainBlock = domainQuality.blockers.length >= 1;
1323
+
1324
+ // Reflection-already-present check: if the assistant text already contains
1325
+ // an explicit <reflection>...</reflection> block OR a "reflection:" line
1326
+ // tied to the warn's trigger keywords, the warn-driven block is satisfied
1327
+ // and we let it pass. This makes the gate a one-shot reflection compel,
1328
+ // not an infinite loop.
1329
+ const REFLECTION_BLOCK_RX = /<reflection>([\s\S]*?)<\/reflection>|^\s*reflection\s*:\s*\S/im;
1330
+ const hasReflection = REFLECTION_BLOCK_RX.test(assistantText);
1331
+ const compelReflection = mizanWarnReflectionRequired && !hasReflection;
1332
+
1333
+ // ── Cognition impl-coupling validation (Task #88) ──────────────────────
1334
+ //
1335
+ // After the local cognition substance check passes, post the assistant
1336
+ // text + extracted artifact-dictation pairs to /api/cognition/validate-coupling.
1337
+ // The server-side validator (api/lib/cognition-impl-coupling-gate.ts)
1338
+ // returns { passed, reasons[] } — every reason becomes a local violation
1339
+ // with severity=block. Implementation-coupled cognition is a doctrine hard
1340
+ // rule (feedback_implementation_coupled_cognition.md): lenses must dictate
1341
+ // specific implementation choices visible in the artifact, not just describe
1342
+ // thinking.
1343
+ //
1344
+ // Inline extraction: the caller's emit may carry artifact-dictation pairs
1345
+ // inside verify-blocks or cognition-block fixing fields. We scan the text
1346
+ // for `file_path:line_range` patterns near each lens label and pair them
1347
+ // as records to the validator. When zero records are found AND canonical
1348
+ // lenses are present, the validator reports each lens as missing dictation
1349
+ // — that is the no-coupling failure mode this gate catches.
1350
+ let implCouplingHits = [];
1351
+ try {
1352
+ const sessionIdForCoupling = (event.session_id || 'claude-code').replace(/[^a-zA-Z0-9_-]/g, '_');
1353
+ // Extract artifact-dictation references inline. Per validator contract,
1354
+ // a DictationEntry is { file_path, line_range, decision_text }. We match
1355
+ // file_path:line_range patterns in the assistant text and pair them with
1356
+ // the nearest preceding lens label (within 800 chars).
1357
+ const FILE_LINE_RX = /([\w./\-]+\.[a-zA-Z]{1,5})\s*[:\s]\s*(\d+(?:[-:]\d+)?)/g;
1358
+ const inlineDictations = [];
1359
+ const lensRangePositions = [];
1360
+ for (const lensName of PRIMARY_OWNER_LENS_NAMES) {
1361
+ const lensRx = new RegExp(`\\b${lensName}\\s*(?:lens)?\\s*[:\\-]`, 'gi');
1362
+ let m;
1363
+ while ((m = lensRx.exec(assistantText)) !== null) {
1364
+ lensRangePositions.push({ lens: lensName, idx: m.index });
1365
+ }
1366
+ }
1367
+ // For each file_path:line_range match, pair with the closest preceding lens label.
1368
+ const fileMatches = [...assistantText.matchAll(FILE_LINE_RX)];
1369
+ const lensToEntries = new Map();
1370
+ for (const fm of fileMatches) {
1371
+ const fmIdx = fm.index ?? 0;
1372
+ // Find lens label preceding this match within 800 chars.
1373
+ let nearestLens = null;
1374
+ let nearestDelta = Infinity;
1375
+ for (const lp of lensRangePositions) {
1376
+ const delta = fmIdx - lp.idx;
1377
+ if (delta >= 0 && delta < 800 && delta < nearestDelta) {
1378
+ nearestDelta = delta;
1379
+ nearestLens = lp.lens;
1380
+ }
1381
+ }
1382
+ if (!nearestLens) continue;
1383
+ const entry = {
1384
+ file_path: fm[1],
1385
+ line_range: fm[2],
1386
+ decision_text: assistantText.slice(fmIdx, Math.min(assistantText.length, fmIdx + 200)).replace(/\s+/g, ' ').trim().slice(0, 200),
1387
+ };
1388
+ if (!lensToEntries.has(nearestLens)) lensToEntries.set(nearestLens, []);
1389
+ lensToEntries.get(nearestLens).push(entry);
1390
+ }
1391
+ for (const [lens, entries] of lensToEntries) {
1392
+ inlineDictations.push({ lens_id: lens, artifact_dictation: entries });
1393
+ }
1394
+
1395
+ const cplHarnessUrl =
1396
+ process.env.ARIA_HIVE_RUNTIME_URL ||
1397
+ process.env.ARIA_HARNESS_BASE_URL ||
1398
+ process.env.ARIA_HARNESS_URL ||
1399
+ 'https://harness.ariasos.com';
1400
+ const cplHarnessToken = process.env.ARIA_HARNESS_TOKEN || '';
1401
+ if (cplHarnessToken) {
1402
+ const cplResp = await fetch(`${cplHarnessUrl}/api/cognition/validate-coupling`, {
1403
+ method: 'POST',
1404
+ headers: {
1405
+ 'Content-Type': 'application/json',
1406
+ 'Authorization': `Bearer ${cplHarnessToken}`,
1407
+ },
1408
+ body: JSON.stringify({
1409
+ rawResponse: assistantText.slice(0, 16000),
1410
+ turnId: `${sessionIdForCoupling}-${Date.now()}`,
1411
+ dictations: inlineDictations,
1412
+ }),
1413
+ });
1414
+ if (cplResp.ok) {
1415
+ const cplData = await cplResp.json();
1416
+ if (cplData && cplData.ok && cplData.passed === false && Array.isArray(cplData.reasons)) {
1417
+ implCouplingHits = cplData.reasons.slice(0, 6);
1418
+ }
1419
+ }
1420
+ }
1421
+ } catch (cplErr) {
1422
+ // Validator unreachable is non-blocking — local gate still enforces cognition substance.
1423
+ audit('impl-coupling-fetch-err', `${(cplErr?.message || String(cplErr)).slice(0, 200)}`);
1424
+ }
1425
+
1426
+ // ── Substrate-bound Mizan + 8-lens validation via SDK ──────────────────
1427
+ // Hamza directive 2026-04-28: the local Stop-gate above runs cognition
1428
+ // substance + drift triggers + code-quality + discovery-binding, but
1429
+ // never asks the substrate. validateOutput POSTs the assistant draft
1430
+ // to /api/harness/validate (Mizan + 8-lens evaluator) and returns
1431
+ // { passed, violations, severity, rewritten, gateTriggers }.
1432
+ //
1433
+ // severity:'block' from substrate joins the local violations, halts
1434
+ // the emit. severity:'warn' surfaces as advisory text appended to the
1435
+ // local violations list. SDK call failure is non-blocking — the gate
1436
+ // degrades to local-only doctrine rather than failing closed (halting
1437
+ // every emit when substrate is down would brick the orchestrator).
1438
+ let substrateBlock = false;
1439
+ let substrateViolations = [];
1440
+ let substrateGateTriggers = [];
1441
+ try {
1442
+ const { HTTPHarnessClient } = await import('@aria_asi/harness-http-client');
1443
+ const tokenPath = `${HOME}/.aria/owner-token`;
1444
+ // Tier-aware resolution: ARIA_HARNESS_TOKEN env first (both tiers).
1445
+ // ONLY on owner tier, fall back to master/api-key env or owner-token
1446
+ // file. Client tier with no ARIA_HARNESS_TOKEN skips substrate
1447
+ // validation (gate degrades to local-only) rather than borrowing
1448
+ // owner credentials.
1449
+ let apiKey = process.env.ARIA_HARNESS_TOKEN || '';
1450
+ if (!apiKey && isOwnerTier()) {
1451
+ apiKey = process.env.ARIA_MASTER_TOKEN
1452
+ || process.env.ARIA_API_KEY
1453
+ || (existsSync(tokenPath) ? readFileSync(tokenPath, 'utf8').trim() : '');
1454
+ }
1455
+ if (apiKey && assistantText && assistantText.length > 0) {
1456
+ const client = new HTTPHarnessClient({
1457
+ baseUrl:
1458
+ process.env.ARIA_RUNTIME_URL ||
1459
+ process.env.ARIA_HIVE_RUNTIME_URL ||
1460
+ process.env.ARIA_HARNESS_BASE_URL ||
1461
+ process.env.ARIA_HARNESS_URL ||
1462
+ RUNTIME_BASE_URL,
1463
+ apiKey,
1464
+ });
1465
+ const v = await client.validateOutput(assistantText, sessionId);
1466
+ if (v && v.severity === 'block') {
1467
+ substrateBlock = true;
1468
+ substrateViolations = v.violations || [];
1469
+ substrateGateTriggers = v.gateTriggers || [];
1470
+ } else if (v && v.severity === 'warn' && Array.isArray(v.violations) && v.violations.length > 0) {
1471
+ // warn surfaced but not blocking — record for advisory inclusion
1472
+ substrateViolations = v.violations;
1473
+ }
1474
+ }
1475
+ } catch (err) {
1476
+ // SDK call failure is non-blocking. Logged for telemetry.
1477
+ console.warn(`[stop-gate] substrate validateOutput failed: ${err && err.message ? err.message : err}`);
1478
+ }
1479
+
1480
+ const implCouplingBlock = implCouplingHits.length > 0;
1481
+ if (mizanBlock || driftBlock || codeBlock || domainBlock || discoveryBlock || compelReflection || phaseReportMissing || substrateBlock || implCouplingBlock) {
1482
+ const violations = [];
1483
+ if (mizanBlock) violations.push(`Mizan: ${(mizanVerdict.violations || []).join(', ')}`);
1484
+ if (implCouplingBlock) violations.push(`Cognition impl-coupling (#88): ${implCouplingHits.join(' | ')}. Each canonical lens in cognition must dictate a specific implementation choice (file_path:line_range pair tied to a decision). Re-emit cognition that names file paths + line ranges + decision text per lens, OR a verify/fixing block where lenses cite specific artifact changes.`);
1485
+ 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.`);
1486
+ if (driftBlock) violations.push(`Drift triggers (${driftHits.length}): ${driftHits.map((h) => `"${h.trigger}" → ${h.memory}`).join(' | ')}`);
1487
+ if (codeBlock) violations.push(`Code quality: ${codeQualityHits.join('; ')}`);
1488
+ if (domainBlock) violations.push(`Domain output QA (${domainQuality.domains.join(', ') || 'general'}): ${domainQuality.blockers.join('; ')}. Rewrite with the missing domain-specific safeguards instead of generic prose.`);
1489
+ if (discoveryBlock) violations.push(`Discovery-binding ledger has ${ledgerOpenCount} OPEN discoveries (per ${docRef('feedback_no_flag_without_fix.md', 'atomic-discovery-rule')}, 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.`);
1490
+ if (phaseReportMissing) {
1491
+ const phaseList = (activePlan?.phases || []).map((p) => `${p.id}:${p.summary?.slice(0, 60) || ''}`).join(' | ');
1492
+ 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.`);
1493
+ }
1494
+ if (substrateBlock) {
1495
+ violations.push(`Substrate Mizan + 8-lens BLOCK — violations: [${substrateViolations.join('; ')}]. Substrate gate triggers: [${substrateGateTriggers.join(', ')}]. Re-draft addressing these substrate-side issues.`);
1496
+ } else if (substrateViolations.length > 0) {
1497
+ // warn-level surfaced as advisory, not block
1498
+ violations.push(`Substrate Mizan WARN (advisory, not blocking): ${substrateViolations.join('; ')}`);
1499
+ }
1500
+ const rewritten = mizanVerdict?.rewritten || '';
1501
+
1502
+ // Hive recipe lookup BEFORE emitting the stop-gate block — same lookup
1503
+ // semantics as aria-pre-tool-gate.mjs's binding-violation path. The
1504
+ // detector_class is chosen by the dominant violation: drift triggers
1505
+ // map to doctrine_violation, mizan to design_violation, code to
1506
+ // coding_defect, discoveries to doctrine_violation. Lookup is
1507
+ // fail-soft via 3s detection probe.
1508
+ const recipeAddendum = await (async () => {
1509
+ const detectorClass = driftBlock || discoveryBlock
1510
+ ? 'doctrine_violation'
1511
+ : codeBlock
1512
+ ? 'coding_defect'
1513
+ : (mizanBlock || substrateBlock || implCouplingBlock)
1514
+ ? 'design_violation'
1515
+ : 'doctrine_violation';
1516
+ const sigParts = [];
1517
+ if (driftBlock) sigParts.push(`drift::${driftHits.slice(0, 3).map((h) => h.trigger).join('|')}`);
1518
+ if (mizanBlock) sigParts.push(`mizan::${(mizanVerdict.violations || []).slice(0, 3).join('|')}`);
1519
+ if (codeBlock) sigParts.push(`code::${codeQualityHits.slice(0, 3).join('|')}`);
1520
+ if (discoveryBlock) sigParts.push(`discovery::${ledgerOpenCount}-open`);
1521
+ if (substrateBlock) sigParts.push(`substrate::${substrateViolations.slice(0, 3).join('|')}`);
1522
+ if (implCouplingBlock) sigParts.push(`impl-coupling::${implCouplingHits.slice(0, 2).join('|')}`);
1523
+ const signature = sigParts.join('::').slice(0, 512);
1524
+ if (!signature) return '';
1525
+
1526
+ const ariaSoulUrl =
1527
+ process.env.ARIA_HIVE_RUNTIME_URL ||
1528
+ process.env.ARIA_SOUL_URL ||
1529
+ process.env.ARIA_HARNESS_BASE_URL ||
1530
+ process.env.ARIA_HARNESS_URL ||
1531
+ 'https://harness.ariasos.com';
1532
+ const lookupUrl = new URL(`${ariaSoulUrl}/api/hive/block-pattern`);
1533
+ lookupUrl.searchParams.set('action', 'lookup');
1534
+ lookupUrl.searchParams.set('detector_class', detectorClass);
1535
+ lookupUrl.searchParams.set('pattern_signature', signature);
1536
+ const tenantId = event.session_id || '';
1537
+ if (tenantId) lookupUrl.searchParams.set('tenant_id', tenantId);
1538
+ const harnessToken = process.env.ARIA_HARNESS_TOKEN || (isOwnerTier() ? (process.env.ARIA_MASTER_TOKEN || process.env.ARIA_API_KEY || '') : '');
1539
+
1540
+ const ctl = new AbortController();
1541
+ const probeTimer = setTimeout(() => ctl.abort(), 3000);
1542
+ try {
1543
+ const resp = await fetch(lookupUrl.toString(), {
1544
+ method: 'GET',
1545
+ headers: harnessToken ? { Authorization: `Bearer ${harnessToken}` } : {},
1546
+ signal: ctl.signal,
1547
+ });
1548
+ if (!resp.ok) return '';
1549
+ const body = await resp.json();
1550
+ if (!body || body.found !== true) return '';
1551
+ const recipe = body.recipe;
1552
+ const freq = Number(body.frequency || 0);
1553
+ if (recipe && typeof recipe === 'object' && Number(recipe.confidence ?? 0) >= 0.7) {
1554
+ const text = typeof recipe.recipe_text === 'string' ? recipe.recipe_text.slice(0, 800) : '';
1555
+ const actions = Array.isArray(recipe.recipe_actions) ? recipe.recipe_actions : [];
1556
+ const actionsLine = actions.length
1557
+ ? `\n Actions: ${JSON.stringify(actions).slice(0, 600)}`
1558
+ : '';
1559
+ const conf = Number(recipe.confidence).toFixed(2);
1560
+ const seenLine = freq > 0 ? ` (pattern seen ${freq}× across the hive)` : '';
1561
+ return `\n\n📚 HIVE RECIPE${seenLine}:\n ${text}${actionsLine}\n Confidence: ${conf}. Apply this BEFORE re-emitting — the hive learned this fix from prior firings.`;
1562
+ }
1563
+ if (freq >= 3) {
1564
+ const sr = (typeof body.success_rate === 'number')
1565
+ ? ` Past resolution rate: ${(body.success_rate * 100).toFixed(0)}%.`
1566
+ : '';
1567
+ return `\n\n📓 Hive note: this stop-gate shape has fired ${freq} time(s); recipe still being learned (no high-confidence fix yet).${sr}`;
1568
+ }
1569
+ return '';
1570
+ } catch {
1571
+ return '';
1572
+ } finally {
1573
+ clearTimeout(probeTimer);
1574
+ }
1575
+ })();
1576
+
1577
+ const reason = withLoopDirective(buildForceReauthorReason({
1578
+ source: 'stop/output-quality',
1579
+ reason: `cognition passed (${cog.count}/${REQUIRED_LENSES}) but output failed quality gates`,
1580
+ violations,
1581
+ rewritten,
1582
+ recipeAddendum,
1583
+ driftHits,
1584
+ lensCount: cog.count,
1585
+ requiredLenses: REQUIRED_LENSES,
1586
+ }), `stop:output-qc:${violations.join('|').slice(0, 240)}`, gateSessionId);
1587
+
1588
+ audit(`block-output-qc`, `mizan=${mizanBlock?'y':'n'} warn-reflect=${compelReflection?'y':'n'} drift=${driftHits.length} code=${codeQualityHits.length} discoveries-open=${ledgerOpenCount} impl-coupling=${implCouplingHits.length}`);
1589
+ emitHarnessFooter({
1590
+ eventName: 'block_output_qc',
1591
+ lensCount: cog.count,
1592
+ chars: assistantText.length,
1593
+ driftCount: driftHits.length,
1594
+ mizanStatus: mizanVerdict ? mizanVerdict.severity : `unavailable(${mizanError || 'unknown'})`,
1595
+ discoveryOpenCount: ledgerOpenCount,
1596
+ codeCount: codeQualityHits.length,
1597
+ implCouplingCount: implCouplingHits.length,
1598
+ });
1599
+ console.log(JSON.stringify({ decision: 'block', reason }));
1600
+ process.exit(2);
1601
+ }
1602
+
1603
+ audit('allow-output-qc',
1604
+ `lenses=${cog.count} chars=${assistantText.length} drift=${driftHits.length} ` +
1605
+ `mizan=${mizanVerdict ? mizanVerdict.severity : `unavailable(${mizanError || 'unknown'})`} ` +
1606
+ `code=${codeQualityHits.length} discoveries-new=${newDiscoveries.length} ` +
1607
+ `discoveries-open=${ledgerOpenCount}`);
1608
+ emitHarnessFooter({
1609
+ eventName: 'allow_output_qc',
1610
+ lensCount: cog.count,
1611
+ chars: assistantText.length,
1612
+ driftCount: driftHits.length,
1613
+ mizanStatus: mizanVerdict ? mizanVerdict.severity : `unavailable(${mizanError || 'unknown'})`,
1614
+ discoveryOpenCount: ledgerOpenCount,
1615
+ codeCount: codeQualityHits.length,
1616
+ implCouplingCount: implCouplingHits.length,
1617
+ });
1618
+ // Phase 11 #42: write this turn to harness garden pulse on allow-output-qc path.
1619
+ await fireGardenTurn(event.session_id || 'claude-code', lastUserMessage, assistantText);
1620
+ } else {
1621
+ audit('allow-cognition',
1622
+ `lenses=${cog.count} chars=${assistantText.length} ` +
1623
+ `qPatt=${hasQuestionToUser ? 'y' : 'n'} substrateEv=${hasSubstrateEvidence ? 'y' : 'n'} ` +
1624
+ (questionWithoutEvidence ? 'WARN-question-without-substrate' : 'ok'));
1625
+ emitHarnessFooter({
1626
+ eventName: 'allow_cognition',
1627
+ lensCount: cog.count,
1628
+ chars: assistantText.length,
1629
+ driftCount: 0,
1630
+ mizanStatus: 'not-run(short-turn)',
1631
+ discoveryOpenCount: 0,
1632
+ codeCount: 0,
1633
+ implCouplingCount: 0,
1634
+ });
1635
+ // Phase 11 #42: write this turn to harness garden pulse on allow-cognition path.
1636
+ await fireGardenTurn(event.session_id || 'claude-code', lastUserMessage, assistantText);
1637
+ }
1638
+ process.exit(0);
1639
+ }
1640
+
1641
+ // ── Dalio Loop Layer 1 — expected_outcome enforcement + ledger write ──────────
1642
+ //
1643
+ // BEFORE allowing the stop:
1644
+ // 1. Scan the assistant text for <expected>...</expected> block.
1645
+ // 2. Determine whether any non-trivial action (tool_use blocks in this turn)
1646
+ // was taken. Detect via transcript tool_use blocks in the current turn.
1647
+ // 3. If a non-trivial action was taken AND <expected> is MISSING → BLOCK stop.
1648
+ // 4. Whether or not <expected> is present, POST a Dalio ledger entry to
1649
+ // aria-soul /api/decisions with outcome:'pending'. Also write to the
1650
+ // local JSONL mirror at ~/.claude/.aria-dalio-ledger.jsonl.
1651
+ // 5. If the POST fails: LOUD telemetry (console.error) + write local mirror
1652
+ // anyway. Do NOT block stop on POST failure per
1653
+ // feedback_canonical_secrets_governance.md LOUD-not-silent directive.
1654
+ //
1655
+ // Non-trivial action detection: look for tool_use content blocks in the
1656
+ // current-turn transcript entries (same backward-scan window used above).
1657
+ // Any Bash/Edit/Write/NotebookEdit tool_use counts as a non-trivial action.
1658
+ //
1659
+ // Substrate anchors: extracted from the cognition block body.
1660
+
1661
+ const DALIO_EXPECTED_BLOCK_RX = /<expected>([\s\S]*?)<\/expected>/i;
1662
+ const DALIO_QUALITATIVE_DRIFT_RX = /\b(?:better(?:er)?|improved?(?:ment)?|more\s+robust|should\s+(?:work|pass|succeed|run|fix)|more\s+reliable|cleaner|less\s+error[-_\s]?prone|nicer|smoother|faster[-\s]?loading|higher[-\s]?quality|more\s+stable|looks\s+(?:good|better|right))\b/i;
1663
+ const DALIO_MEASURABLE_PREDICATE_RX = /(?:>=|<=|==|!=|>|<|≥|≤)\s*\d+(?:\.\d+)?(?:ms|s|%|kb|mb|gb)?|\d+(?:\.\d+)?%(?:\s+(?:reduction|increase|success|error|coverage))?|exit[_=]\s*(?:0|1|\d+)|exit[-_]?code\s*[=:]\s*\d+|\brc\s*[=:]\s*\d+|\bstatus\s*[=:]\s*(?:running|healthy|ready|degraded|down|up|ok|200|201|204|400|401|403|404|500|502|503|504|true|false)\b|\bcount\s*[=:]\s*\d+|\berror[_-]?rate\s*[=:]\s*0%|\b(?:true|false)\b|\bfile[=_-]exists\b|\b200\s*OK\b|\bno[-_\s]?error|\bhealthy\b|\bpassed?\b|N\s*of\s*N|\d+\s*of\s*\d+/i;
1664
+ const NON_TRIVIAL_ACTION_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit']);
1665
+
1666
+ // Detect non-trivial tool calls in the current turn from the transcript.
1667
+ let hadNonTrivialAction = false;
1668
+ let lastActionSummary = '';
1669
+ let immediateActual = '';
1670
+ if (transcriptPath && existsSync(transcriptPath)) {
1671
+ try {
1672
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n').filter(Boolean);
1673
+ let userBoundariesSeen = 0;
1674
+ for (let i = lines.length - 1; i >= 0 && userBoundariesSeen < 3; i--) {
1675
+ try {
1676
+ const m = JSON.parse(lines[i]);
1677
+ const role = m.message?.role ?? m.role;
1678
+ if (role === 'user') {
1679
+ const content = m.message?.content ?? m.content ?? [];
1680
+ const isToolResult = Array.isArray(content) &&
1681
+ content.length > 0 &&
1682
+ content.every((b) => b && b.type === 'tool_result');
1683
+ if (isToolResult) {
1684
+ // Capture the last tool_result content as immediate actual
1685
+ if (!immediateActual) {
1686
+ const textParts = content
1687
+ .map((b) => (typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((c) => c.text || '').join(' ') : ''))
1688
+ .join(' ')
1689
+ .slice(0, 500);
1690
+ immediateActual = textParts;
1691
+ }
1692
+ continue;
1693
+ }
1694
+ userBoundariesSeen++;
1695
+ continue;
1696
+ }
1697
+ if (role !== 'assistant') continue;
1698
+ const content = m.message?.content ?? m.content ?? [];
1699
+ if (!Array.isArray(content)) continue;
1700
+ for (const block of content) {
1701
+ if (block && block.type === 'tool_use' && NON_TRIVIAL_ACTION_TOOLS.has(block.name)) {
1702
+ hadNonTrivialAction = true;
1703
+ if (!lastActionSummary) {
1704
+ const inp = block.input || {};
1705
+ lastActionSummary = `${block.name}: ${(inp.command || inp.file_path || inp.notebook_path || JSON.stringify(inp)).slice(0, 200)}`;
1706
+ }
1707
+ }
1708
+ }
1709
+ } catch {/* skip malformed entry */}
1710
+ }
1711
+ } catch {/* transcript unreadable — conservative: assume non-trivial */}
1712
+ }
1713
+
1714
+ // Extract substrate anchors from cognition
1715
+ const DALIO_ANCHOR_RX = /\b(axiom|frame|memory|doctrine|packet):[a-z0-9_\-./]+/gi;
1716
+ const dalioAnchors = [...(cog.names.length > 0 ? assistantText : '').matchAll(DALIO_ANCHOR_RX)]
1717
+ .map((m) => m[0])
1718
+ .slice(0, 20);
1719
+
1720
+ // Read the expected block from this turn
1721
+ const dalioExpectedMatch = assistantText.match(DALIO_EXPECTED_BLOCK_RX);
1722
+ const dalioExpectedText = dalioExpectedMatch ? dalioExpectedMatch[1].trim() : '';
1723
+ const dalioHasMeasurablePredicate = dalioExpectedText
1724
+ ? (DALIO_MEASURABLE_PREDICATE_RX.test(dalioExpectedText) && !DALIO_QUALITATIVE_DRIFT_RX.test(dalioExpectedText))
1725
+ : false;
1726
+
1727
+ // Block stop if non-trivial action taken AND expected block is missing
1728
+ if (hadNonTrivialAction && (!dalioExpectedMatch || !dalioHasMeasurablePredicate)) {
1729
+ const missingReason = withLoopDirective(buildForceReauthorReason({
1730
+ source: 'stop/dalio-expected',
1731
+ reason: dalioExpectedMatch
1732
+ ? 'action had an <expected> block, but it was qualitative instead of measurable'
1733
+ : `non-trivial action (${lastActionSummary.slice(0, 120)}) had no <expected> block`,
1734
+ violations: [
1735
+ dalioExpectedMatch
1736
+ ? 'Rejected qualitative expected phrases: better, improved, should work, more reliable, cleaner. Expected outcome must be numeric, boolean, or state-string.'
1737
+ : 'Missing <expected> block after non-trivial action. Dalio feedback loop cannot compare outcome without a measurable predicate.',
1738
+ 'Required block: <expected> predicate: "exit_code==0" | "status=running" | "count=3 of 3"; measurable_type: numeric | boolean | state_string; threshold/eval_window optional.',
1739
+ ],
1740
+ lensCount: cog.count,
1741
+ requiredLenses: REQUIRED_LENSES,
1742
+ }), `stop:dalio-expected:${hadNonTrivialAction}:${!!dalioExpectedMatch}:${dalioHasMeasurablePredicate}`, gateSessionId);
1743
+
1744
+ audit('block-dalio-expected-missing', `hadNonTrivialAction=${hadNonTrivialAction} expectedPresent=${!!dalioExpectedMatch} measurable=${dalioHasMeasurablePredicate}`);
1745
+ emitHarnessFooter({
1746
+ eventName: 'block_dalio_expected_missing',
1747
+ lensCount: cog.count,
1748
+ chars: assistantText.length,
1749
+ driftCount: 0,
1750
+ mizanStatus: 'not-run(expected-missing)',
1751
+ discoveryOpenCount: 0,
1752
+ codeCount: 0,
1753
+ implCouplingCount: 0,
1754
+ });
1755
+ console.log(JSON.stringify({ decision: 'block', reason: missingReason }));
1756
+ process.exit(2);
1757
+ }
1758
+
1759
+ // Dalio ledger write — fire-and-forget HTTP POST + local JSONL mirror.
1760
+ // Per feedback_canonical_secrets_governance.md: errors are LOUD (console.error),
1761
+ // never silent. POST failure does NOT block stop — local mirror is always written.
1762
+ // Per feedback_no_timeouts_doctrine.md: no AbortController/setTimeout timeout.
1763
+ {
1764
+ const DALIO_LEDGER_PATH = `${HOME}/.claude/.aria-dalio-ledger.jsonl`;
1765
+ let postReceipt = null;
1766
+ try {
1767
+ const post = await runtimeMizanPost(
1768
+ event.session_id || 'claude-code',
1769
+ assistantText.slice(0, 8000),
1770
+ {
1771
+ message: lastUserMessage || `stop-gate turn (${assistantText.length} chars)`,
1772
+ plannedApproach: lastActionSummary || 'Finalize and validate the just-emitted Claude response.',
1773
+ platform: 'claude-code',
1774
+ stage: 'hook-stop-post',
1775
+ },
1776
+ sessionMizanState?.receipt?.receiptId || null,
1777
+ );
1778
+ postReceipt = post?.receipt || null;
1779
+ if (postReceipt) {
1780
+ saveMizanReceiptState(event.session_id || 'claude-code', {
1781
+ ...(sessionMizanState || {}),
1782
+ updatedAt: new Date().toISOString(),
1783
+ sessionId: event.session_id || 'claude-code',
1784
+ postReceipt,
1785
+ postResult: post?.result || null,
1786
+ postContract: post?.contract || null,
1787
+ postSummary: post?.summary || null,
1788
+ });
1789
+ }
1790
+ } catch (postErr) {
1791
+ console.error(
1792
+ `[aria-stop-gate] MIZAN POST FAILED — runtime receipt not recorded. Error: ${postErr instanceof Error ? postErr.message : String(postErr)}`,
1793
+ );
1794
+ audit('mizan-post-failed', `session=${event.session_id || 'claude-code'} err=${String(postErr).slice(0, 200)}`);
1795
+ }
1796
+
1797
+ const ledgerEntry = {
1798
+ ts: new Date().toISOString(),
1799
+ session_id: event.session_id || 'claude-code',
1800
+ decision_type: 'turn_action',
1801
+ category: 'agentic_execution',
1802
+ context: lastActionSummary || `stop-gate turn (chars=${assistantText.length})`,
1803
+ decision: lastActionSummary || 'turn completed',
1804
+ reasoning: (cog.names.length > 0
1805
+ ? `Cognition lenses applied: ${cog.names.join(', ')}. Turn-scoped cognition present.`
1806
+ : 'No explicit cognition block in turn.'),
1807
+ outcome: 'pending',
1808
+ outcome_details: {
1809
+ expected: dalioExpectedText || null,
1810
+ immediate_actual: immediateActual || null,
1811
+ anchors: dalioAnchors,
1812
+ },
1813
+ expected_outcome: dalioExpectedText
1814
+ ? {
1815
+ predicate: dalioExpectedText.slice(0, 500),
1816
+ measurable_type: 'state_string',
1817
+ }
1818
+ : null,
1819
+ metadata: {
1820
+ pre_receipt_id: sessionMizanState?.receipt?.receiptId || null,
1821
+ post_receipt_id: postReceipt?.receiptId || null,
1822
+ },
1823
+ source: 'claude-code-stop-gate-runtime',
1824
+ model_used: 'claude-opus-4-7',
1825
+ };
1826
+
1827
+ // Write to local JSONL mirror first — always succeeds or logs loudly
1828
+ try {
1829
+ if (!existsSync(dirname(DALIO_LEDGER_PATH))) mkdirSync(dirname(DALIO_LEDGER_PATH), { recursive: true });
1830
+ appendFileSync(DALIO_LEDGER_PATH, JSON.stringify(ledgerEntry) + '\n');
1831
+ } catch (ledgerWriteErr) {
1832
+ console.error(
1833
+ `[aria-stop-gate] DALIO LEDGER WRITE FAILED — local mirror at ${DALIO_LEDGER_PATH} not written. ` +
1834
+ `Error: ${ledgerWriteErr instanceof Error ? ledgerWriteErr.message : String(ledgerWriteErr)}`,
1835
+ );
1836
+ }
1837
+
1838
+ try {
1839
+ await runtimeDecisionLog(ledgerEntry);
1840
+ audit('dalio-post-ok', `session=${ledgerEntry.session_id} action=${lastActionSummary.slice(0, 80)}`);
1841
+ } catch (err) {
1842
+ console.error(
1843
+ `[aria-stop-gate] DALIO POST FAILED — runtime /decision/log rejected or unreachable. ` +
1844
+ `Local mirror written to ${DALIO_LEDGER_PATH}. Error: ${err instanceof Error ? err.message : String(err)}`,
1845
+ );
1846
+ audit('dalio-post-runtime-err', `err=${String(err).slice(0, 200)} session=${ledgerEntry.session_id}`);
1847
+ }
1848
+ }
1849
+
1850
+ // Block — non-trivial response without all required substantive lenses.
1851
+ const reason = withLoopDirective(`Aria Stop-gate: non-trivial assistant response without all required substantive cognition lenses. Found ${cog.count}/${REQUIRED_LENSES} (lenses: ${cog.names.join(', ') || 'none'}). Doctrine is action-coupled — text decisions ARE actions, and reflexive replies fail this gate the same way reflexive Bash does.
1852
+
1853
+ Re-emit the response with substantive lens application BEFORE drafting. Each lens must have ≥${SUBSTANCE_MIN_CHARS} chars of non-placeholder content:
1854
+
1855
+ <cognition>
1856
+ ${LENS_NAMES[0]}: <what you actually see — specific to the decision, not a placeholder>
1857
+ ${LENS_NAMES[1]}: <real risk read — what's out of proportion>
1858
+ ${LENS_NAMES[2]}: <what principle applies — name the source>
1859
+ ${LENS_NAMES[3]}: <deep structural read — go beneath the surface>
1860
+ ${LENS_NAMES[4]}: <if-then chain — what follows from what>
1861
+ ${LENS_NAMES[5]}: <distant connection — what's not obvious>
1862
+ ${LENS_NAMES[6]}: <what just landed — what changed in this exchange>
1863
+ ${LENS_NAMES[7]}: <what user actually needs — beneath the literal ask>
1864
+ </cognition>
1865
+
1866
+ The block reflects work done BEFORE drafting. Don't emit it as ceremony; apply each lens as a thinking tool. Substance check defeats ritual emission.
1867
+
1868
+ No per-command bypass (mirrors aria-pre-tool-gate.mjs v3 doctrine). No env-var disable path either — gates are unconditional from the gated process per Hamza directive 2026-04-27. If the gate misfires on legitimate cognition, fix the gate.`, `stop:lens-missing:${cog.count}`, gateSessionId);
1869
+
1870
+ audit(`block`, `lenses=${cog.count}/${REQUIRED_LENSES} chars=${assistantText.length}`);
1871
+ emitHarnessFooter({
1872
+ eventName: 'block_lens_missing',
1873
+ lensCount: cog.count,
1874
+ chars: assistantText.length,
1875
+ driftCount: 0,
1876
+ mizanStatus: 'not-run(lens-missing)',
1877
+ discoveryOpenCount: 0,
1878
+ codeCount: 0,
1879
+ implCouplingCount: 0,
1880
+ });
1881
+ console.log(JSON.stringify({ decision: 'block', reason: buildForceReauthorReason({ source: 'stop/lens-missing', reason, lensCount: cog.count, requiredLenses: REQUIRED_LENSES }) }));
1882
+ process.exit(2);