@aria_asi/cli 0.2.33 → 0.2.34

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 +47 -0
  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 +47 -0
@@ -0,0 +1,65 @@
1
+ export const LENS_NAMES_OLDER = ['nur', 'mizan', 'hikma', 'tafakkur', 'tadabbur', 'ilham', 'wahi', 'firasah'];
2
+ export const LENS_NAMES_NEWER = ['zahir', 'batin', 'sabab', 'hikmah', 'aqibah', 'ilham', 'meta', 'fitrah'];
3
+
4
+ export const PRIMARY_OWNER_LENS_NAMES = LENS_NAMES_OLDER;
5
+ // Readability must not rewrite the lens semantics. Client surfaces may need
6
+ // friendlier prose inside each lens, but the visible lens labels themselves
7
+ // stay canonical so the meaning is preserved.
8
+ export const PRIMARY_CLIENT_LENS_NAMES = LENS_NAMES_OLDER;
9
+
10
+ export const ALL_LENS_NAMES = [...new Set([
11
+ ...LENS_NAMES_OLDER,
12
+ ...LENS_NAMES_NEWER,
13
+ ])];
14
+
15
+ export function lensNamesForTier(isOwner) {
16
+ return isOwner ? PRIMARY_OWNER_LENS_NAMES : PRIMARY_CLIENT_LENS_NAMES;
17
+ }
18
+
19
+ export function canonicalLensCorrectionText() {
20
+ return [
21
+ `Owner canonical lenses: ${LENS_NAMES_OLDER.join(', ')}.`,
22
+ `Owner mapped counterparts that must remain represented in phase reasoning and receipts: ${LENS_NAMES_NEWER.join(', ')}.`,
23
+ `Canonical lens labels are preserved on every surface. Readability comes from the explanatory prose inside each lens, never from renaming the lens itself.`,
24
+ ].join(' ');
25
+ }
26
+
27
+ export function extractLensTexts(cognitionInner, lensNames = ALL_LENS_NAMES) {
28
+ const out = {};
29
+ for (const lens of lensNames) {
30
+ const otherLensPattern = lensNames.join('|');
31
+ const lensRx = new RegExp(
32
+ `\\b${lens}\\s*:\\s*([^\\n]*(?:\\n(?!\\s*(?:${otherLensPattern})\\s*:|<\\/cognition>)[^\\n]*)*)`,
33
+ 'i',
34
+ );
35
+ const match = cognitionInner.match(lensRx);
36
+ if (match) out[lens] = (match[1] || '').trim();
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export function detectCognitionLenses(text, {
42
+ minChars = 0,
43
+ placeholderRx = null,
44
+ cognitionBlockRx = /<cognition>([\s\S]*?)<\/cognition>/i,
45
+ lensNames = ALL_LENS_NAMES,
46
+ } = {}) {
47
+ if (!text) return { count: 0, names: [], matchedSet: null };
48
+ const block = text.match(cognitionBlockRx);
49
+ const searchSpace = block ? block[1] : text;
50
+ const lensTexts = extractLensTexts(searchSpace, lensNames);
51
+ const names = [];
52
+ for (const lens of lensNames) {
53
+ const content = (lensTexts[lens] || '').trim();
54
+ if (!content) continue;
55
+ if (minChars > 0 && content.length < minChars) continue;
56
+ if (placeholderRx && placeholderRx.test(content)) continue;
57
+ names.push(lens);
58
+ }
59
+
60
+ const matchedSet =
61
+ [LENS_NAMES_OLDER, LENS_NAMES_NEWER]
62
+ .find((candidateSet) => candidateSet.every((name) => names.includes(name))) || null;
63
+
64
+ return { count: names.length, names, matchedSet };
65
+ }
@@ -0,0 +1,103 @@
1
+ // Domain-aware output QA for Aria stop gates.
2
+ // Deterministic local checks complement remote Mizan; they do not replace it.
3
+
4
+ const DOMAIN_RULES = [
5
+ {
6
+ domain: 'code',
7
+ signal: /```|\b(?:function|class|interface|type|import|export|npm test|typecheck|eslint|jest|vitest|tsx?|jsx?|\.ts|\.tsx|\.js|\.py)\b/i,
8
+ },
9
+ {
10
+ domain: 'ui',
11
+ signal: /\b(?:ui|ux|frontend|react|component|page|screen|modal|form|button|layout|tailwind|css|html|mobile|responsive|accessib|aria-label|keyboard|focus state|dark mode)\b/i,
12
+ },
13
+ {
14
+ domain: 'beauty',
15
+ signal: /\b(?:beauty|beautiful|polish|visual|aesthetic|elegant|layout|typography|spacing|hierarchy|composition|brand|design language|modern|clean)\b/i,
16
+ },
17
+ {
18
+ domain: 'security',
19
+ signal: /\b(?:security|auth|token|secret|credential|password|jwt|oauth|csrf|xss|sql injection|permission|role|cors|sanitize|encrypt|webhook signature)\b/i,
20
+ },
21
+ {
22
+ domain: 'ops',
23
+ signal: /\b(?:deploy|rollout|rollback|kubernetes|kubectl|docker|image|pod|health check|slo|alert|log|metric|trace|env var|migration|release)\b/i,
24
+ },
25
+ {
26
+ domain: 'product',
27
+ signal: /\b(?:user flow|customer|workflow|conversion|pricing|onboarding|checkout|activation|retention|business|persona|job-to-be-done|acceptance criteria)\b/i,
28
+ },
29
+ {
30
+ domain: 'writing',
31
+ signal: /\b(?:summary|explain|docs|readme|copy|email|post|article|message|final answer|status report|release notes)\b/i,
32
+ },
33
+ ];
34
+
35
+ function hasAny(text, patterns) {
36
+ return patterns.some((pattern) => pattern.test(text));
37
+ }
38
+
39
+ function lineHits(text, rx, label) {
40
+ const hits = [];
41
+ const lines = String(text || '').split('\n');
42
+ for (let i = 0; i < lines.length; i++) {
43
+ if (rx.test(lines[i])) hits.push(`${label} at line ${i + 1}`);
44
+ }
45
+ return hits;
46
+ }
47
+
48
+ export function extractCodeBlocks(text) {
49
+ return [...String(text || '').matchAll(/```[a-z0-9_-]*\n([\s\S]*?)```/gi)].map((m) => m[1] || '');
50
+ }
51
+
52
+ export function analyzeDomainOutputQuality(text, options = {}) {
53
+ const source = String(text || '');
54
+ const lower = source.toLowerCase();
55
+ const codeBlocks = options.codeBlocks || extractCodeBlocks(source);
56
+ const domains = DOMAIN_RULES.filter((rule) => rule.signal.test(source)).map((rule) => rule.domain);
57
+ const blockers = [];
58
+ const warnings = [];
59
+
60
+ if (domains.includes('code')) {
61
+ for (const hit of lineHits(source, /\b(?:TODO|FIXME|XXX|implementation pending|not implemented|coming soon|placeholder)\b/i, 'code placeholder semantics')) blockers.push(hit);
62
+ for (const block of codeBlocks) {
63
+ if (/@ts-expect-error|@ts-ignore/.test(block)) blockers.push('code: type suppression instead of fixing the type contract');
64
+ if (/catch\s*\([^)]*\)\s*\{\s*(?:return\s+(?:''|""|null|undefined|\[\]|\{\})|\}\s*$|\/\/[^\n]*$)/m.test(block)) blockers.push('code: silent or empty catch block hides runtime failure');
65
+ if (/console\.log\(/.test(block) && !/\/\/\s*(?:debug|log)/i.test(block)) warnings.push('code: console.log appears in shipped code without debug intent');
66
+ }
67
+ }
68
+
69
+ if (domains.includes('ui')) {
70
+ if (!hasAny(source, [/\b(?:mobile|responsive|breakpoint|small screen|desktop)\b/i])) blockers.push('ui: UI/design output must address desktop and mobile responsiveness');
71
+ if (!hasAny(source, [/\b(?:accessib|aria-|keyboard|focus|screen reader|semantic html|label)\b/i])) blockers.push('ui: UI/design output must address accessibility, focus, labels, or semantic structure');
72
+ if (/\b(?:button|input|form|modal|menu|dialog)\b/i.test(source) && !/\b(?:focus|keyboard|aria-|label|escape|tab order)\b/i.test(source)) blockers.push('ui: interactive elements need keyboard/focus/accessibility behavior');
73
+ }
74
+
75
+ if (domains.includes('beauty')) {
76
+ if (/\b(?:clean|modern|beautiful|polished|nice|sleek)\b/i.test(source) && !hasAny(source, [/\b(?:spacing|typography|contrast|hierarchy|rhythm|composition|palette|motion|density|visual language)\b/i])) blockers.push('beauty: aesthetic claim lacks concrete visual language such as spacing, typography, contrast, hierarchy, palette, or composition');
77
+ }
78
+
79
+ if (domains.includes('security')) {
80
+ if (/\b(?:token|secret|credential|password|api key|jwt)\b/i.test(source) && !/\b(?:redact|mask|env|secret store|do not log|rotate|least privilege)\b/i.test(source)) blockers.push('security: secrets/tokens require redaction, env/secret-store handling, no logging, or rotation guidance');
81
+ if (/\b(?:auth|permission|role|admin|tenant)\b/i.test(source) && !/\b(?:authorize|authorization|least privilege|tenant isolation|deny by default|role check)\b/i.test(source)) warnings.push('security: auth/permission output should state authorization and isolation checks');
82
+ }
83
+
84
+ if (domains.includes('ops')) {
85
+ if (/\b(?:deploy|rollout|release|migration|kubectl|docker push)\b/i.test(source) && !/\b(?:rollback|health|smoke|verify|readiness|observability|monitor)\b/i.test(source)) blockers.push('ops: deploy/release output must include rollback plus health/smoke/readiness verification');
86
+ if (/\b(?:env var|config|secret)\b/i.test(source) && !/\b(?:scope|owner|runtime|restart|reload|secret)\b/i.test(source)) warnings.push('ops: runtime config changes should name scope and reload/restart expectations');
87
+ }
88
+
89
+ if (domains.includes('product')) {
90
+ if (!/\b(?:user|customer|operator|admin|persona|workflow|acceptance criteria|success metric|job-to-be-done)\b/i.test(source)) warnings.push('product: product output should bind to a user/workflow and success criterion');
91
+ }
92
+
93
+ if (domains.includes('writing')) {
94
+ if (/\b(?:done|fixed|verified|published|deployed|passed|complete)\b/i.test(lower) && !/\b(?:verified|observed|passed|evidence|output|registry|status|unverified|not verified)\b/i.test(lower)) blockers.push('writing: completion claim needs observed evidence or explicit unverified boundary');
95
+ if (/\b(?:should work|probably|presumably|i assume)\b/i.test(source)) blockers.push('writing: uncertainty must be stated as an evidence boundary, not an assumption');
96
+ }
97
+
98
+ return {
99
+ domains: [...new Set(domains)],
100
+ blockers: [...new Set(blockers)],
101
+ warnings: [...new Set(warnings)],
102
+ };
103
+ }
@@ -0,0 +1,43 @@
1
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ export function inferDecisionFromEvent(event) {
5
+ if (/^block/i.test(event)) return 'block';
6
+ if (/^warn/i.test(event)) return 'warn';
7
+ return 'allow';
8
+ }
9
+
10
+ export function appendGateAudit({
11
+ auditPath,
12
+ legacyLogPath = null,
13
+ gate,
14
+ event,
15
+ decision = null,
16
+ summary = '',
17
+ data = {},
18
+ }) {
19
+ const ts = new Date().toISOString();
20
+ const resolvedDecision = decision || inferDecisionFromEvent(event);
21
+
22
+ try {
23
+ if (!existsSync(dirname(auditPath))) mkdirSync(dirname(auditPath), { recursive: true });
24
+ appendFileSync(
25
+ auditPath,
26
+ JSON.stringify({
27
+ ts,
28
+ gate,
29
+ decision: resolvedDecision,
30
+ event,
31
+ summary,
32
+ ...data,
33
+ }) + '\n',
34
+ );
35
+ } catch {}
36
+
37
+ if (legacyLogPath) {
38
+ try {
39
+ if (!existsSync(dirname(legacyLogPath))) mkdirSync(dirname(legacyLogPath), { recursive: true });
40
+ appendFileSync(legacyLogPath, `${ts} ${event} ${summary}\n`);
41
+ } catch {}
42
+ }
43
+ }
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ const MAX_RECORDS = 300;
5
+
6
+ export function registerGateBlock({
7
+ gate,
8
+ sessionId,
9
+ signature,
10
+ statePath,
11
+ threshold = 2,
12
+ windowMs = 10 * 60 * 1000,
13
+ }) {
14
+ const now = Date.now();
15
+ let rows = [];
16
+ try {
17
+ if (existsSync(statePath)) {
18
+ const raw = JSON.parse(readFileSync(statePath, 'utf8'));
19
+ if (Array.isArray(raw)) rows = raw;
20
+ }
21
+ } catch {}
22
+
23
+ const freshRows = rows.filter((row) =>
24
+ row &&
25
+ typeof row.ts === 'number' &&
26
+ typeof row.sessionId === 'string' &&
27
+ typeof row.gate === 'string' &&
28
+ typeof row.signature === 'string' &&
29
+ (now - row.ts) <= windowMs,
30
+ );
31
+
32
+ const priorMatches = freshRows.filter((row) =>
33
+ row.sessionId === sessionId &&
34
+ row.gate === gate &&
35
+ row.signature === signature,
36
+ );
37
+
38
+ freshRows.push({ ts: now, sessionId, gate, signature });
39
+
40
+ try {
41
+ mkdirSync(dirname(statePath), { recursive: true });
42
+ writeFileSync(statePath, JSON.stringify(freshRows.slice(-MAX_RECORDS), null, 2) + '\n');
43
+ } catch {}
44
+
45
+ return {
46
+ loopDetected: priorMatches.length >= threshold,
47
+ priorCount: priorMatches.length,
48
+ totalCount: priorMatches.length + 1,
49
+ };
50
+ }
@@ -0,0 +1,121 @@
1
+ function withGlobalRegex(regex) {
2
+ if (!(regex instanceof RegExp)) return null;
3
+ const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
4
+ return new RegExp(regex.source, flags);
5
+ }
6
+
7
+ export function normalizeRole(entry) {
8
+ if (entry?.message?.role) return entry.message.role;
9
+ if (entry?.role) return entry.role;
10
+ if (entry?.type === 'assistant' || entry?.type === 'user') return entry.type;
11
+ return null;
12
+ }
13
+
14
+ export function normalizeContent(entry) {
15
+ return entry?.message?.content ?? entry?.content ?? [];
16
+ }
17
+
18
+ export function extractTextFromContent(content) {
19
+ if (Array.isArray(content)) {
20
+ return content
21
+ .filter((block) => block && block.type === 'text')
22
+ .map((block) => (typeof block.text === 'string' ? block.text : ''))
23
+ .filter(Boolean)
24
+ .join('\n');
25
+ }
26
+ return typeof content === 'string' ? content : '';
27
+ }
28
+
29
+ export function isToolResultOnlyContent(content) {
30
+ return (
31
+ Array.isArray(content) &&
32
+ content.length > 0 &&
33
+ content.every((block) => block && block.type === 'tool_result')
34
+ );
35
+ }
36
+
37
+ export function isMostlySystemReminder(text, systemReminderRx, threshold = 0.6) {
38
+ if (!text || !(systemReminderRx instanceof RegExp)) return false;
39
+ const reminderRx = withGlobalRegex(systemReminderRx);
40
+ if (!reminderRx) return false;
41
+ const matches = text.match(reminderRx) || [];
42
+ if (matches.length === 0) return false;
43
+ const reminderChars = matches.reduce((sum, match) => sum + match.length, 0);
44
+ return reminderChars / Math.max(1, text.length) >= threshold;
45
+ }
46
+
47
+ export function collectRecentAssistantTextsFromMessages(messages, {
48
+ compactSummaryRx = null,
49
+ hardLookbackCap = 50,
50
+ systemReminderRx = null,
51
+ systemReminderThreshold = 0.6,
52
+ userBoundariesToCross = 1,
53
+ } = {}) {
54
+ if (!Array.isArray(messages) || messages.length === 0) return [];
55
+ const recentAssistantTexts = [];
56
+ let scanned = 0;
57
+ let userBoundariesCrossed = 0;
58
+
59
+ for (let i = messages.length - 1; i >= 0 && scanned < hardLookbackCap; i--) {
60
+ const entry = messages[i];
61
+ const role = normalizeRole(entry);
62
+ const content = normalizeContent(entry);
63
+ if (role === 'user') {
64
+ if (isToolResultOnlyContent(content)) continue;
65
+ const textContent = extractTextFromContent(content);
66
+ if (isMostlySystemReminder(textContent, systemReminderRx, systemReminderThreshold)) {
67
+ continue;
68
+ }
69
+ userBoundariesCrossed++;
70
+ if (userBoundariesCrossed > userBoundariesToCross) break;
71
+ continue;
72
+ }
73
+ if (role !== 'assistant') continue;
74
+ scanned++;
75
+ const text = extractTextFromContent(content);
76
+ if (!text) continue;
77
+ if (compactSummaryRx instanceof RegExp && compactSummaryRx.test(text) && text.length > 4000) {
78
+ continue;
79
+ }
80
+ recentAssistantTexts.push(text);
81
+ }
82
+
83
+ return recentAssistantTexts.reverse();
84
+ }
85
+
86
+ export function collectTurnWindowFromMessages(messages, {
87
+ systemReminderRx = null,
88
+ systemReminderThreshold = 0.6,
89
+ } = {}) {
90
+ if (!Array.isArray(messages) || messages.length === 0) {
91
+ return { assistantText: '', lastUserMessage: '' };
92
+ }
93
+
94
+ const textChunks = [];
95
+ let lastUserMessage = '';
96
+
97
+ for (let i = messages.length - 1; i >= 0; i--) {
98
+ const entry = messages[i];
99
+ const role = normalizeRole(entry);
100
+ const content = normalizeContent(entry);
101
+
102
+ if (role === 'user') {
103
+ if (isToolResultOnlyContent(content)) continue;
104
+ const textContent = extractTextFromContent(content);
105
+ if (isMostlySystemReminder(textContent, systemReminderRx, systemReminderThreshold)) {
106
+ continue;
107
+ }
108
+ lastUserMessage = textContent || lastUserMessage;
109
+ break;
110
+ }
111
+
112
+ if (role !== 'assistant') continue;
113
+ const text = extractTextFromContent(content);
114
+ if (text) textChunks.push(text);
115
+ }
116
+
117
+ return {
118
+ assistantText: textChunks.reverse().join('\n\n'),
119
+ lastUserMessage,
120
+ };
121
+ }
@@ -0,0 +1,14 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ const RECEIPT_ROOT = process.env.ARIA_SKILL_RECEIPT_DIR || join(tmpdir(), 'aria-skill-receipts');
5
+ const ALIASES = new Map([['deploy', 'aria-harness-deploy'], ['output', 'aria-harness-output-discipline'], ['repo', 'aria-repo-doctrine'], ['forge', 'aria-forge-guardrails']]);
6
+ const RX = { deploy: /deploy-service\.sh|kubectl\s+(?:apply|set|rollout|delete|scale)|helm\s+upgrade|terraform\s+apply|docker\s+push/i, mutationTool: /^(?:edit|write|notebookedit|patch|apply_patch)$/i, mutation: /apply_patch|write file|edit file|modify|delete file|migration|handler|route|runtime|hook|plugin|\btest\b|smoke script/i, strip: /remove|delete|strip|drop|omit|disable|bypass|skip|stub|mock|fake|placeholder|temporary|quick scaffold|band-aid/i, readiness: /production-ready|ready for production|works in general|general readiness|client packages?|npm packages?|SDKs?|runtimes?|harnesses?|release-ready|ship-ready/i, narrow: /single flow|one flow|narrow e2e|covered flow|specific path|widget flow/i, completion: /done|complete|completed|ready|verified|fixed|shipped|implemented|production-ready/i, badProof: /but|except|caveat|remaining|not yet|still|separate|later|blocked|skipped|unresolved|follow-up|failed|failing|error|red|not run|could not verify|untested|no proof|missing proof|without proof/i, advisory: /non-blocking|warning only|warn only|advisory|fall through|falls through|fail open|soft fail|logged and continue|quality gate warning/i, success: /(?:verified|passed|success|successful|green|ok)\s*[:=\-].{0,120}(?:npm|node|playwright|jest|vitest|build|test|lint|typecheck|curl|kubectl|self-test|e2e|probe|smoke)|(?:npm|node|playwright|jest|vitest|build|test|lint|typecheck|curl|kubectl).{0,120}(?:passed|success|successful|green|exit\s*0)/i, resubmit: /re-?submission|resubmit/i, rewrite: /re-?write|rewrite|fix/i, retest: /re-?test|retest|rerun/i, aria: /ARIA console|Aria console|\/chat|aria-pipeline-mcp|aria_chat|escalat(?:e|ion).{0,80}ARIA/i };
7
+ function normalizeSkillName(skill) { return ALIASES.get(String(skill || '').trim()) || String(skill || '').trim(); }
8
+ function sessionDir(sessionId) { return join(RECEIPT_ROOT, encodeURIComponent(String(sessionId || 'unknown'))); }
9
+ function readReceiptSkills(sessionId) { const dir = sessionDir(sessionId); if (!existsSync(dir)) return new Set(); const skills = new Set(); for (const name of readdirSync(dir)) { if (!name.endsWith('.json')) continue; try { const receipt = JSON.parse(readFileSync(join(dir, name), 'utf8')); if (receipt?.skill) skills.add(normalizeSkillName(receipt.skill)); } catch {} } return skills; }
10
+ function readInlineSkills(text) { const skills = new Set(); const value = String(text || ''); for (const match of value.matchAll(/<skill_content\s+name=["']([^"']+)["']/gi)) skills.add(normalizeSkillName(match[1])); return skills; }
11
+ export function recordSkillLoaded({ sessionId, skill, surface = 'unknown', metadata = {} } = {}) { const normalized = normalizeSkillName(skill); if (!normalized) throw new Error('recordSkillLoaded requires a skill name'); const dir = sessionDir(sessionId); mkdirSync(dir, { recursive: true }); const receipt = { skill: normalized, surface, metadata, recordedAt: new Date().toISOString() }; writeFileSync(join(dir, `${encodeURIComponent(normalized)}.json`), `${JSON.stringify(receipt, null, 2)}\n`); return receipt; }
12
+ export function classifyRequiredSkills({ text = '', action = '', toolName = '', filePath = '', isDeploy = false, isMutation = false, isOutputCloseout = false } = {}) { const combined = [text, action, toolName, filePath].filter(Boolean).join('\n'); const required = new Set(); const reasons = []; const recoveryMissing = []; if (isDeploy || RX.deploy.test(combined)) { required.add('aria-harness-deploy'); required.add('aria-forge-guardrails'); reasons.push('deploy/shared-infrastructure action requires fail-closed deploy and forge guardrails'); } if (isMutation || RX.mutationTool.test(toolName)) { required.add('aria-repo-doctrine'); reasons.push('repository/runtime mutation requires repo doctrine'); } if (RX.strip.test(combined)) { required.add('aria-harness-no-stripping'); reasons.push('strip/remove/bypass language requires no-stripping gate'); } if (isOutputCloseout && RX.completion.test(combined)) { required.add('aria-harness-output-discipline'); reasons.push('owner-facing completion/readiness claim requires output discipline'); if (!RX.success.test(combined)) recoveryMissing.push('successful proof from a concrete command/probe'); } if (RX.readiness.test(combined)) { required.add('architecture-decision'); required.add('testing-strategy'); required.add('aria-forge-guardrails'); required.add('aria-harness-output-discipline'); reasons.push('broad production/package/SDK/runtime readiness claim requires architecture, testing, and forge guardrails'); } if (RX.readiness.test(combined) && RX.narrow.test(combined)) { required.add('testing-strategy'); required.add('aria-forge-guardrails'); reasons.push('narrow e2e proof cannot support broad readiness claim without readiness-matrix discipline'); } if (RX.completion.test(combined) && RX.badProof.test(combined)) { required.add('aria-harness-output-discipline'); required.add('aria-forge-guardrails'); reasons.push('completion claim with unresolved or failed proof requires recovery cycle'); if (!RX.resubmit.test(combined)) recoveryMissing.push('re-submission'); if (!RX.rewrite.test(combined)) recoveryMissing.push('re-write'); if (!RX.retest.test(combined)) recoveryMissing.push('re-test'); if (!RX.aria.test(combined)) recoveryMissing.push('ARIA console escalation'); } if (RX.advisory.test(combined)) { required.add('aria-forge-guardrails'); required.add('aria-harness-output-discipline'); reasons.push('advisory/fail-open gate language requires fail-closed hardening discipline'); } return { requiredSkills: [...required].sort(), reasons, recoveryMissing }; }
13
+ export function evaluateSkillGate(options = {}) { const classified = classifyRequiredSkills(options); const text = [options.text, options.action].filter(Boolean).join('\n'); const loaded = new Set([...readReceiptSkills(options.sessionId), ...readInlineSkills(text)]); const missingSkills = classified.requiredSkills.filter((skill) => !loaded.has(skill)); const recoveryMissing = classified.recoveryMissing || []; return { ok: missingSkills.length === 0 && recoveryMissing.length === 0, surface: options.surface || 'unknown', sessionId: options.sessionId || 'unknown', requiredSkills: classified.requiredSkills, loadedSkills: [...loaded].sort(), missingSkills, recoveryMissing, reasons: classified.reasons, autoLoadAvailable: options.autoLoadAvailable === true }; }
14
+ export function formatSkillGateBlock(result = {}) { const missing = Array.isArray(result.missingSkills) ? result.missingSkills : []; const recovery = Array.isArray(result.recoveryMissing) ? result.recoveryMissing : []; const reasons = Array.isArray(result.reasons) ? result.reasons : []; return ['=== ARIA SKILL AUTOLOAD GATE BLOCK ===', `surface: ${result.surface || 'unknown'}`, `missing_skills: ${missing.length ? missing.join(', ') : '(none)'}`, `missing_recovery_cycle: ${recovery.length ? recovery.join(', ') : '(none)'}`, `required_skills: ${(result.requiredSkills || []).join(', ') || '(none)'}`, reasons.length ? `reasons: ${reasons.join(' | ')}` : 'reasons: no classifier reason recorded', 'counter_action: re-submit, re-write, re-test, and escalate through ARIA console until successful proof exists. Do not downgrade this to an advisory warning.'].join('\n'); }
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ // Test: aria-preturn-memory-gate.mjs — Enforcement Layer #49
3
+ //
4
+ // Runs the hook as a child process with simulated stdin events.
5
+ // Asserts correct output shape for both the block and allow paths.
6
+ //
7
+ // Usage: node hooks/test-aria-preturn-memory-gate.mjs
8
+ // Exit code: 0 = all assertions passed, 1 = failure
9
+
10
+ import { spawnSync, execSync } from 'node:child_process';
11
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const HOOK = path.join(HERE, 'aria-preturn-memory-gate.mjs');
18
+ const HOME = process.env.HOME || '/tmp';
19
+ const CLAUDE_DIR = `${HOME}/.claude`;
20
+ const SESSION_ID = `test-preturn-${Date.now()}`;
21
+
22
+ // ── Helpers ────────────────────────────────────────────────────────────
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+
27
+ function assert(label, condition, actual) {
28
+ if (condition) {
29
+ console.log(` PASS ${label}`);
30
+ passed++;
31
+ } else {
32
+ console.error(` FAIL ${label}`);
33
+ if (actual !== undefined) console.error(` got: ${JSON.stringify(actual)}`);
34
+ failed++;
35
+ }
36
+ }
37
+
38
+ function turnStatePath(sid) {
39
+ const safe = String(sid).replace(/[^a-zA-Z0-9_-]/g, '_');
40
+ return `${CLAUDE_DIR}/aria-turn-state-${safe}.json`;
41
+ }
42
+
43
+ function cleanTurnState(sid) {
44
+ const p = turnStatePath(sid);
45
+ if (existsSync(p)) unlinkSync(p);
46
+ }
47
+
48
+ function buildEvent(overrides = {}) {
49
+ return {
50
+ tool_name: 'Bash',
51
+ tool_input: { command: 'ls .' },
52
+ session_id: SESSION_ID,
53
+ transcript_path: null,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function buildTranscriptWith(lines, filePath) {
59
+ const dir = path.dirname(filePath);
60
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
+ writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf-8');
62
+ }
63
+
64
+ function runHook(eventObj, env = {}) {
65
+ const result = spawnSync('node', [HOOK], {
66
+ input: JSON.stringify(eventObj),
67
+ encoding: 'utf-8',
68
+ env: { ...process.env, HOME, ...env },
69
+ });
70
+ let parsed = null;
71
+ try { parsed = JSON.parse(result.stdout.trim()); } catch { /* non-JSON stdout */ }
72
+ return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr, parsed };
73
+ }
74
+
75
+ // ── Setup ──────────────────────────────────────────────────────────────
76
+
77
+ if (!existsSync(CLAUDE_DIR)) mkdirSync(CLAUDE_DIR, { recursive: true });
78
+
79
+ // ── Test 1: no transcript → gate fires, block + recovery payload ───────
80
+ console.log('\nTest 1: no transcript (no context signals) → block with recovery');
81
+ {
82
+ cleanTurnState(SESSION_ID);
83
+ const event = buildEvent({ transcript_path: null });
84
+ const { exitCode, parsed } = runHook(event);
85
+
86
+ assert('exit code is 2 (block)', exitCode === 2, exitCode);
87
+ assert('decision is "block"', parsed?.decision === 'block', parsed?.decision);
88
+ assert('reason is a non-empty string', typeof parsed?.reason === 'string' && parsed.reason.length > 0, parsed?.reason);
89
+ assert(
90
+ 'hookSpecificOutput.recovery.action is "run_context_loader"',
91
+ parsed?.hookSpecificOutput?.recovery?.action === 'run_context_loader',
92
+ parsed?.hookSpecificOutput?.recovery?.action,
93
+ );
94
+ assert(
95
+ 'hookSpecificOutput.recovery.target is session ID',
96
+ parsed?.hookSpecificOutput?.recovery?.target === SESSION_ID,
97
+ parsed?.hookSpecificOutput?.recovery?.target,
98
+ );
99
+ assert(
100
+ 'hookSpecificOutput.recovery.expectedContext includes "harness_packet"',
101
+ Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
102
+ parsed.hookSpecificOutput.recovery.expectedContext.includes('harness_packet'),
103
+ parsed?.hookSpecificOutput?.recovery?.expectedContext,
104
+ );
105
+ assert(
106
+ 'hookSpecificOutput.recovery.expectedContext includes "memory_files"',
107
+ Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
108
+ parsed.hookSpecificOutput.recovery.expectedContext.includes('memory_files'),
109
+ parsed?.hookSpecificOutput?.recovery?.expectedContext,
110
+ );
111
+ assert(
112
+ 'hookSpecificOutput.recovery.expectedContext includes "binding_plan"',
113
+ Array.isArray(parsed?.hookSpecificOutput?.recovery?.expectedContext) &&
114
+ parsed.hookSpecificOutput.recovery.expectedContext.includes('binding_plan'),
115
+ parsed?.hookSpecificOutput?.recovery?.expectedContext,
116
+ );
117
+ }
118
+
119
+ // ── Test 2: transcript WITH all three signals → allow ─────────────────
120
+ console.log('\nTest 2: transcript with all three context signals → allow');
121
+ {
122
+ cleanTurnState(SESSION_ID);
123
+ const transcriptFile = path.join(tmpdir(), `test-transcript-preturn-${Date.now()}.jsonl`);
124
+
125
+ const assistantTextWithAllSignals = [
126
+ '🔐 Aria Harness — Session Packet loaded for turn.',
127
+ '[ARIA_DIRECTION] Proceed with implementation of Phase 8 research-first context injection.',
128
+ '<cognition>',
129
+ ' nur: Current task is implementing the pre-turn memory gate per feedback_no_graceful_degradation.md and project_aria_as_controller_inversion.md.',
130
+ ' mizan: Risk is that blocking without recovery creates dead-letter state. Soft-gate pattern resolves this.',
131
+ ' hikma: feedback_no_flag_without_fix.md requires inline fix for any discovery.',
132
+ ' tafakkur: The gate must deduplicate within 60s to prevent orchestrator retry loops.',
133
+ '</cognition>',
134
+ ].join('\n');
135
+
136
+ buildTranscriptWith(
137
+ [
138
+ {
139
+ role: 'user',
140
+ message: {
141
+ role: 'user',
142
+ content: [{ type: 'text', text: 'Implement phase 8.' }],
143
+ },
144
+ },
145
+ {
146
+ role: 'assistant',
147
+ message: {
148
+ role: 'assistant',
149
+ content: [{ type: 'text', text: assistantTextWithAllSignals }],
150
+ },
151
+ },
152
+ ],
153
+ transcriptFile,
154
+ );
155
+
156
+ const event = buildEvent({ transcript_path: transcriptFile });
157
+ const { exitCode, parsed } = runHook(event);
158
+
159
+ assert('exit code is 0 (allow)', exitCode === 0, exitCode);
160
+ assert('no block decision in stdout (stdout is empty or non-block)', !parsed || parsed.decision !== 'block', parsed?.decision);
161
+
162
+ // Cleanup
163
+ try { unlinkSync(transcriptFile); } catch {}
164
+ }
165
+
166
+ // ── Test 3: deduplication — gate already fired within 60s → skip ───────
167
+ console.log('\nTest 3: gate already fired this turn (< 60s ago) → skip (exit 0)');
168
+ {
169
+ // Pre-write a turn-state showing the gate fired 5 seconds ago
170
+ const statePath = turnStatePath(SESSION_ID);
171
+ writeFileSync(statePath, JSON.stringify({ lastTurnGateFiredAt: Date.now() - 5000, lastDecision: 'block', signals: {} }), 'utf-8');
172
+
173
+ const event = buildEvent({ transcript_path: null });
174
+ const { exitCode } = runHook(event);
175
+
176
+ assert('exit code is 0 (dedup skip — no re-fire within 60s)', exitCode === 0, exitCode);
177
+
178
+ cleanTurnState(SESSION_ID);
179
+ }
180
+
181
+ // ── Test 4: non-action tool (Read) → gate skips entirely ───────────────
182
+ console.log('\nTest 4: non-action tool (Read) → gate skips (exit 0)');
183
+ {
184
+ cleanTurnState(SESSION_ID);
185
+ const event = buildEvent({ tool_name: 'Read', transcript_path: null });
186
+ const { exitCode } = runHook(event);
187
+ assert('exit code is 0 (ungated read tool)', exitCode === 0, exitCode);
188
+ }
189
+
190
+ // ── Test 5: REMOVED 2026-04-27 — env-var kill-switch stripped per Hamza
191
+ // directive ("those should've been my choice to give you to turn off
192
+ // not free for you to access"). Gates are unconditional from the gated
193
+ // process. Disable = remove hook entry from ~/.claude/settings.json.
194
+
195
+ // ── Test 6: only ONE signal present → still blocks ────────────────────
196
+ console.log('\nTest 6: only harness packet signal (missing direction + memory ref) → block');
197
+ {
198
+ cleanTurnState(SESSION_ID);
199
+ const transcriptFile = path.join(tmpdir(), `test-transcript-preturn-partial-${Date.now()}.jsonl`);
200
+
201
+ const assistantTextPartial = '🔐 Aria Harness — partial context only. No direction. No memory refs.';
202
+
203
+ buildTranscriptWith(
204
+ [
205
+ {
206
+ role: 'user',
207
+ message: {
208
+ role: 'user',
209
+ content: [{ type: 'text', text: 'Do the thing.' }],
210
+ },
211
+ },
212
+ {
213
+ role: 'assistant',
214
+ message: {
215
+ role: 'assistant',
216
+ content: [{ type: 'text', text: assistantTextPartial }],
217
+ },
218
+ },
219
+ ],
220
+ transcriptFile,
221
+ );
222
+
223
+ const event = buildEvent({ transcript_path: transcriptFile });
224
+ const { exitCode, parsed } = runHook(event);
225
+
226
+ assert('exit code is 2 (block on partial signals)', exitCode === 2, exitCode);
227
+ assert('decision is "block"', parsed?.decision === 'block', parsed?.decision);
228
+ assert(
229
+ 'reason mentions missing aria_direction',
230
+ typeof parsed?.reason === 'string' && parsed.reason.includes('aria_direction'),
231
+ parsed?.reason?.slice(0, 200),
232
+ );
233
+
234
+ try { unlinkSync(transcriptFile); } catch {}
235
+ cleanTurnState(SESSION_ID);
236
+ }
237
+
238
+ // ── Summary ────────────────────────────────────────────────────────────
239
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
240
+ if (failed > 0) {
241
+ process.exit(1);
242
+ } else {
243
+ console.log('All assertions passed.');
244
+ process.exit(0);
245
+ }