@aria_asi/cli 0.2.37 → 0.2.39

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 (41) hide show
  1. package/README.md +140 -0
  2. package/bin/aria.js +8 -4
  3. package/dist/aria-connector/src/auth.d.ts +1 -0
  4. package/dist/aria-connector/src/auth.d.ts.map +1 -1
  5. package/dist/aria-connector/src/auth.js +26 -1
  6. package/dist/aria-connector/src/auth.js.map +1 -1
  7. package/dist/aria-connector/src/connectors/codex.d.ts.map +1 -1
  8. package/dist/aria-connector/src/connectors/codex.js +0 -42
  9. package/dist/aria-connector/src/connectors/codex.js.map +1 -1
  10. package/dist/aria-connector/src/setup-wizard.d.ts.map +1 -1
  11. package/dist/aria-connector/src/setup-wizard.js +41 -1
  12. package/dist/aria-connector/src/setup-wizard.js.map +1 -1
  13. package/dist/aria-connector/src/types.d.ts +6 -0
  14. package/dist/aria-connector/src/types.d.ts.map +1 -1
  15. package/dist/assets/hooks/aria-pre-tool-gate.mjs +53 -0
  16. package/dist/assets/hooks/aria-pre-tool-use.mjs +75 -0
  17. package/dist/assets/hooks/lib/first-class-coach.mjs +3 -3
  18. package/dist/cli-0.2.38.tgz +0 -0
  19. package/dist/runtime/coach-kernel.mjs +66 -5
  20. package/dist/runtime/gated-ledger.mjs +237 -0
  21. package/dist/runtime/hooks/aria-pre-tool-gate.mjs +53 -0
  22. package/dist/runtime/hooks/aria-pre-tool-use.mjs +75 -0
  23. package/dist/runtime/hooks/lib/first-class-coach.mjs +3 -3
  24. package/dist/runtime/manifest.json +1 -1
  25. package/dist/runtime/quality-enforcer.mjs +257 -0
  26. package/dist/runtime/sdk/BUNDLED.json +1 -1
  27. package/dist/runtime/service.mjs +119 -0
  28. package/dist/sdk/BUNDLED.json +1 -1
  29. package/hooks/aria-pre-tool-gate.mjs +53 -0
  30. package/hooks/aria-pre-tool-use.mjs +75 -0
  31. package/hooks/lib/first-class-coach.mjs +3 -3
  32. package/package.json +1 -1
  33. package/runtime-src/coach-kernel.mjs +66 -5
  34. package/runtime-src/gated-ledger.mjs +237 -0
  35. package/runtime-src/quality-enforcer.mjs +257 -0
  36. package/runtime-src/service.mjs +119 -0
  37. package/scripts/install-client.sh +32 -2
  38. package/src/auth.ts +25 -1
  39. package/src/connectors/codex.ts +0 -42
  40. package/src/setup-wizard.ts +43 -1
  41. package/src/types.ts +6 -0
@@ -127,7 +127,7 @@ function inferRiskClass(highRisk, repairable) {
127
127
  return 'low';
128
128
  }
129
129
 
130
- function decisionFromSignals(highRisk, repairable, warnings) {
130
+ function decisionFromSignals(highRisk, repairable, warnings, autoTrigger = []) {
131
131
  if (highRisk.length > 0) {
132
132
  return {
133
133
  decision: 'hard_block',
@@ -136,6 +136,14 @@ function decisionFromSignals(highRisk, repairable, warnings) {
136
136
  reasons: highRisk,
137
137
  };
138
138
  }
139
+ if (autoTrigger.length > 0) {
140
+ return {
141
+ decision: 'auto_trigger_skills',
142
+ permitted: false,
143
+ nextAction: 'auto_load_missing_skills_and_retry',
144
+ reasons: autoTrigger,
145
+ };
146
+ }
139
147
  if (repairable.length > 0) {
140
148
  return {
141
149
  decision: 'repair_once',
@@ -192,12 +200,16 @@ export function normalizeCoachEvent(input = {}) {
192
200
  return record;
193
201
  }
194
202
 
203
+ const DESTRUCTIVE_RX = /\b(?:rm\s+-[rRfF]+\S*|drop\s+(?:table|database|schema|collection|index)|git\s+(?:reset\s+--hard|push\s+--force|push\s+--delete)|sudo\s+|systemctl\s+(?:stop|disable|mask|kill)|kill\s+-[9K]|pkill\s+-[9K]|--no-verify|--no-gpg-sign|kubectl\s+(?:delete|scale\s+--replicas=0|rollout\s+undo)|docker\s+rm\s+-f|chmod\s+777|wget|curl.*\|\s*(?:ba)?sh)\b/i;
204
+ const DEPLOY_RX = /\b(?:kubectl\s+(?:apply|set|rollout|delete|create|replace|scale)|helm\s+(?:upgrade|install|uninstall)|terraform\s+(?:apply|destroy)|docker\s+(?:push|build\s+.*--push))\b/i;
205
+
195
206
  export function evaluateCoachEvent(event = {}) {
196
207
  const normalized = event.phase ? event : normalizeCoachEvent(event);
197
208
  const text = normalized.rawText || normalized.text_preview || '';
198
209
  const highRisk = [];
199
210
  const repairable = [];
200
211
  const warnings = [];
212
+ const autoTrigger = [];
201
213
  const action = String(normalized.action || '').toLowerCase();
202
214
 
203
215
  if (!ALLOWED_PHASES.has(normalized.phase)) {
@@ -207,13 +219,13 @@ export function evaluateCoachEvent(event = {}) {
207
219
  highRisk.push('secret_or_credential_exposure');
208
220
  }
209
221
  if (normalized.phase === 'pre_generation' && normalized.missing_skill_ids.length > 0) {
210
- highRisk.push('required_skill_unavailable_before_generation');
222
+ autoTrigger.push('required_skill_auto_load');
211
223
  }
212
224
  if (TOOL_PHASES.has(normalized.phase) || action) {
213
- if ((action === 'delete' || /\b(?:rm\s+-rf|drop\s+(?:table|database|schema)|git\s+reset\s+--hard)\b/i.test(text)) && normalized.metadata?.approved !== true) {
225
+ if ((action === 'delete' || DESTRUCTIVE_RX.test(text)) && normalized.metadata?.approved !== true) {
214
226
  highRisk.push('unapproved_destructive_action');
215
227
  }
216
- if ((action === 'deploy' || /\b(?:kubectl\s+(?:apply|set|rollout|delete)|helm\s+upgrade|terraform\s+apply|docker\s+push)\b/i.test(text)) && !hasVerifyEvidence(normalized, text)) {
228
+ if ((action === 'deploy' || DEPLOY_RX.test(text)) && !hasVerifyEvidence(normalized, text)) {
217
229
  highRisk.push('unverified_deploy_or_infra_mutation');
218
230
  }
219
231
  }
@@ -229,17 +241,21 @@ export function evaluateCoachEvent(event = {}) {
229
241
  repairable.push('unsupported_completion_or_verification_claim');
230
242
  }
231
243
  }
244
+ if (normalized.missing_skill_ids.length > 0 && !normalized.metadata?.skillsAdvisoryOnly) {
245
+ autoTrigger.push('required_skills_not_loaded');
246
+ }
232
247
  if (normalized.lane.includes('unmanaged') || normalized.metadata?.complianceGuarantee === 'best_effort_only') {
233
248
  warnings.push('unmanaged_direct_provider_best_effort_only');
234
249
  }
235
250
 
236
- const verdict = decisionFromSignals(highRisk, repairable, warnings);
251
+ const verdict = decisionFromSignals(highRisk, repairable, warnings, autoTrigger);
237
252
  return {
238
253
  ...verdict,
239
254
  riskClass: inferRiskClass(highRisk, repairable),
240
255
  highRisk,
241
256
  repairable,
242
257
  warnings,
258
+ autoTrigger,
243
259
  };
244
260
  }
245
261
 
@@ -369,3 +385,48 @@ export function formatCoachClientBlock(recordOrResult = {}) {
369
385
  `Next: ${next}`,
370
386
  ].join('\n');
371
387
  }
388
+
389
+ /**
390
+ * Auto-trigger skill loading when the Coach detects missing skills.
391
+ * Searches the skill registry for each missing skill ID and loads their
392
+ * body text into the turn context so the LLM can apply them.
393
+ */
394
+ export async function triggerMissingSkills(missingSkillIds = [], skillSearchRoots = []) {
395
+ if (!missingSkillIds.length) return { loaded: [], stillMissing: [], loadedBodies: [] };
396
+
397
+ const loaded = [];
398
+ const stillMissing = [];
399
+ const loadedBodies = [];
400
+
401
+ const roots = skillSearchRoots.length > 0 ? skillSearchRoots : [
402
+ join(dirname(fileURLToPath(import.meta.url)), 'discipline', 'skills'),
403
+ join(process.env.HOME || '', '.aria', 'runtime', 'discipline', 'skills'),
404
+ join(process.env.HOME || '', '.claude', 'skills'),
405
+ join(process.env.HOME || '', '.agents', 'skills'),
406
+ ];
407
+
408
+ for (const skillId of missingSkillIds) {
409
+ let found = false;
410
+ for (const root of roots) {
411
+ if (!existsSync(root)) continue;
412
+ try {
413
+ const entries = require('node:fs').readdirSync(root, { recursive: true, withFileTypes: true });
414
+ for (const entry of entries) {
415
+ if (!entry.isFile() || !entry.name.endsWith('.md') && !entry.name.endsWith('SKILL.md')) continue;
416
+ if (entry.name.toLowerCase().includes(skillId.toLowerCase()) ||
417
+ entry.parentPath?.toLowerCase().includes(skillId.toLowerCase())) {
418
+ const body = readFileSync(join(entry.parentPath || root, entry.name), 'utf8');
419
+ loaded.push(skillId);
420
+ loadedBodies.push({ skillId, path: join(entry.parentPath || root, entry.name), body: body.slice(0, 20000) });
421
+ found = true;
422
+ break;
423
+ }
424
+ }
425
+ } catch {}
426
+ if (found) break;
427
+ }
428
+ if (!found) stillMissing.push(skillId);
429
+ }
430
+
431
+ return { loaded, stillMissing, loadedBodies };
432
+ }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gated Ledger — End-to-End Runtime Enforcement
4
+ *
5
+ * Every kernel output, coach decision, completion claim, and doctrine
6
+ * trigger flows through this ledger. Nothing bypasses it.
7
+ *
8
+ * Gates (executed in order):
9
+ * 1. Skill Gate — missing skills → auto-trigger load → retry
10
+ * 2. Template Gate — deterministic/templated output → forced regeneration
11
+ * 3. Coach Gate — pre/post cognition, tool directives enforced
12
+ * 4. Quality Gate — gate labels, minimum substance, collapse text blocked
13
+ * 5. Evidence Gate — completion claims without measured evidence blocked
14
+ * 6. Doctrine Gate — doctrine trigger map enforced
15
+ * 7. Final Output Gate — safe fallback if all else fails
16
+ *
17
+ * Ledger records every gate decision, every enforcement action,
18
+ * every repair attempt, and every final outcome.
19
+ */
20
+
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
22
+ import { createHash, randomUUID } from 'node:crypto';
23
+ import { homedir } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+
26
+ const HOME = homedir();
27
+ const STATE_DIR = join(HOME, '.aria', 'runtime', 'state');
28
+ const GATED_LEDGER_PATH = join(STATE_DIR, 'gated-ledger.jsonl');
29
+ const DOCTRINE_TRIGGER_MAP_PATH = join(STATE_DIR, 'doctrine_trigger_map.json');
30
+
31
+ function ensureDir() {
32
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
33
+ }
34
+
35
+ function appendRecord(record) {
36
+ ensureDir();
37
+ try {
38
+ appendFileSync(GATED_LEDGER_PATH, `${JSON.stringify(record)}\n`, { mode: 0o600 });
39
+ return true;
40
+ } catch { return false; }
41
+ }
42
+
43
+ function hash(text) {
44
+ return createHash('sha256').update(String(text)).digest('hex').slice(0, 16);
45
+ }
46
+
47
+ const GATE_LABEL_RX = /\b(?:personal_mouth_[a-z_]+\b|code_no_tests\b|code_fake_implementation\b|code_type_safety\b|ip_infrastructure\b|8lens_[a-z_]+\b|voice_cold_[a-z_]+\b|harness_output_gate_block\b|auto_fix:\s|personal_mouth_harness_[a-z_]+\b|personal_mouth_unsupported_[a-z_]+\b)/i;
48
+ const COLLAPSE_RX = /I need to pause and reconsider\.?/i;
49
+ const COMPLETION_CLAIM_RX = /\b(?:done|complete|completed|ready|verified|fixed|shipped|production-ready|passing|passed|all phases|all done)\b/i;
50
+ const TEMPLATE_RX = /\b(?:Decision: use Owner Runtime kernels|Sequence: contract, Garden Service snapshot|Repair context loaded|Research context loaded|Action kernel engaged|I'm here with you\.\s*No fixing,\s*no task pressure)\b/i;
51
+ const MINIMUM_CHARS = 50;
52
+
53
+ /** Detect deterministic/templated kernel output */
54
+ function isTemplateOutput(text, kernel) {
55
+ if (!text || text.length < MINIMUM_CHARS) return { isTemplate: true, reason: 'below_minimum_substance' };
56
+ if (TEMPLATE_RX.test(text)) return { isTemplate: true, reason: 'deterministic_kernel_template' };
57
+ return { isTemplate: false, reason: '' };
58
+ }
59
+
60
+ /** Check for completion claims without evidence */
61
+ function hasUnsupportedClaim(text) {
62
+ if (!COMPLETION_CLAIM_RX.test(text)) return { claim: false, reasons: [] };
63
+ const reasons = [];
64
+ if (!/\b(?:exit\s*0|0\s+failures?|passed|status.*ok|200|verified|ledger_record|receiptId|sha256|test.*pass)\b/i.test(text)) {
65
+ reasons.push('completion_claim_without_measured_evidence');
66
+ }
67
+ if (!/<cognition>[\s\S]*?<\/cognition>/i.test(text) && !/<verify>[\s\S]*?<\/verify>/i.test(text)) {
68
+ reasons.push('completion_claim_without_cognition_or_verify');
69
+ }
70
+ return { claim: reasons.length > 0, reasons };
71
+ }
72
+
73
+ /** Check quality */
74
+ function checkQuality(text) {
75
+ const reasons = [];
76
+ if (!text || text.length === 0) reasons.push('empty_output');
77
+ if (GATE_LABEL_RX.test(text)) reasons.push('gate_label_leak');
78
+ if (COLLAPSE_RX.test(text)) reasons.push('collapse_placeholder');
79
+ if (text.trim().length < MINIMUM_CHARS) reasons.push('below_minimum_chars');
80
+ return { passed: reasons.length === 0, reasons };
81
+ }
82
+
83
+ const SAFE_FALLBACKS = {
84
+ emotional_presence: "I'm here. Tell me what's with you right now.",
85
+ architect: "I need more context for a proper architecture answer.",
86
+ repair: "Let me trace the root cause. What's the specific error?",
87
+ action: "Action kernel requires confirmation. What would you like to execute?",
88
+ research: "Let me gather relevant information. What should I research?",
89
+ default: "Let me try again — that last response wasn't right.",
90
+ };
91
+
92
+ /**
93
+ * Enforce all gates on a kernel output.
94
+ * Returns the final safe text and the full enforcement record.
95
+ */
96
+ export async function enforceGates(text, context = {}) {
97
+ const kernel = context.kernel || 'default';
98
+ const sessionId = context.sessionId || 'runtime';
99
+ const gateLog = [];
100
+ const startedAt = Date.now();
101
+ let current = text || '';
102
+ let enforced = false;
103
+ let allPassed = true;
104
+
105
+ // ── Gate 1: Template Detection ────────────────────────────────────
106
+ const templateCheck = isTemplateOutput(current, kernel);
107
+ gateLog.push({
108
+ gate: 'template',
109
+ passed: !templateCheck.isTemplate,
110
+ reason: templateCheck.reason,
111
+ });
112
+ if (templateCheck.isTemplate) {
113
+ enforced = true;
114
+ current = SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
115
+ allPassed = false;
116
+ }
117
+
118
+ // ── Gate 2: Quality ───────────────────────────────────────────────
119
+ const quality = checkQuality(current);
120
+ gateLog.push({
121
+ gate: 'quality',
122
+ passed: quality.passed,
123
+ reasons: quality.reasons,
124
+ });
125
+ if (!quality.passed) {
126
+ enforced = true;
127
+ current = SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
128
+ const requality = checkQuality(current);
129
+ if (!requality.passed) {
130
+ current = "I'm here. The pipeline needs attention. Let me recover.";
131
+ }
132
+ allPassed = false;
133
+ }
134
+
135
+ // ── Gate 3: Completion Claims ─────────────────────────────────────
136
+ const claimCheck = hasUnsupportedClaim(current);
137
+ gateLog.push({
138
+ gate: 'completion_claim',
139
+ passed: !claimCheck.claim,
140
+ reasons: claimCheck.reasons,
141
+ });
142
+ if (claimCheck.claim) {
143
+ enforced = true;
144
+ // Remove the claim language, keep the substance
145
+ current = current.replace(/\b(?:done|complete|completed|ready|verified|fixed|shipped|all phases|all done)\b/gi, 'in progress');
146
+ allPassed = false;
147
+ }
148
+
149
+ // ── Gate 4: Doctrine Trigger ──────────────────────────────────────
150
+ const doctrineHits = checkDoctrineTriggers(current, context);
151
+ gateLog.push({
152
+ gate: 'doctrine',
153
+ passed: doctrineHits.length === 0,
154
+ triggers: doctrineHits,
155
+ });
156
+
157
+ // ── Write Ledger Record ───────────────────────────────────────────
158
+ const record = {
159
+ recordId: randomUUID(),
160
+ sessionId,
161
+ kernel,
162
+ inputTextHash: hash(text),
163
+ finalTextHash: hash(current),
164
+ enforced,
165
+ allPassed,
166
+ gates: gateLog,
167
+ doctrineTriggers: doctrineHits,
168
+ durationMs: Date.now() - startedAt,
169
+ at: new Date().toISOString(),
170
+ };
171
+ appendRecord(record);
172
+
173
+ return {
174
+ finalText: current,
175
+ enforced,
176
+ allPassed,
177
+ gates: gateLog,
178
+ doctrineTriggers: doctrineHits,
179
+ record,
180
+ };
181
+ }
182
+
183
+ /** Load the doctrine trigger map and check text against it */
184
+ function checkDoctrineTriggers(text, context) {
185
+ const triggers = [];
186
+ let map = null;
187
+ try {
188
+ if (existsSync(DOCTRINE_TRIGGER_MAP_PATH)) {
189
+ map = JSON.parse(readFileSync(DOCTRINE_TRIGGER_MAP_PATH, 'utf8'));
190
+ }
191
+ } catch {}
192
+ if (!map || !Array.isArray(map.triggers)) return triggers;
193
+
194
+ for (const trigger of map.triggers) {
195
+ if (!trigger.pattern) continue;
196
+ try {
197
+ const rx = new RegExp(trigger.pattern, 'i');
198
+ if (rx.test(text)) {
199
+ triggers.push({
200
+ trigger: trigger.name || trigger.pattern,
201
+ doctrine: trigger.doctrine || trigger.name,
202
+ severity: trigger.severity || 'warning',
203
+ action: trigger.action || 'log',
204
+ });
205
+ }
206
+ } catch {}
207
+ }
208
+ return triggers;
209
+ }
210
+
211
+ /**
212
+ * Fast inline enforcement — use in streamConversation or any
213
+ * point where a text is about to reach the user surface.
214
+ */
215
+ export function enforceFast(text, kernel = 'default') {
216
+ if (!text || text.length === 0) return SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
217
+ if (GATE_LABEL_RX.test(text)) return SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
218
+ if (COLLAPSE_RX.test(text)) return SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
219
+ if (TEMPLATE_RX.test(text)) return SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default;
220
+ return text;
221
+ }
222
+
223
+ /**
224
+ * Read the gated ledger for monitoring/debugging.
225
+ */
226
+ export function readGatedLedger(limit = 50) {
227
+ ensureDir();
228
+ try {
229
+ if (!existsSync(GATED_LEDGER_PATH)) return [];
230
+ const lines = readFileSync(GATED_LEDGER_PATH, 'utf8').trim().split('\n').filter(Boolean);
231
+ return lines.slice(-limit).map((line) => {
232
+ try { return JSON.parse(line); } catch { return null; }
233
+ }).filter(Boolean);
234
+ } catch {
235
+ return [];
236
+ }
237
+ }
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime Quality Enforcer — First-Class Doctrine Rails
4
+ *
5
+ * Hard-blocks any output that contains internal gate labels, placeholders,
6
+ * or collapse-text. No gate label ever reaches a user surface again.
7
+ *
8
+ * Invariants:
9
+ * 1. HARD regex blocks — catch-all for any internal machinery leaking
10
+ * 2. Minimum substance check — no empty/trivial responses
11
+ * 3. Recovery contract — blocked → rewrite prompt → retry (max 2) → safe fallback
12
+ * 4. Coach kernel notified of every violation for pattern learning
13
+ * 5. Quality violation ledger records every enforcement action
14
+ * 6. Safe fallbacks guaranteed per kernel — deterministic, never empty
15
+ */
16
+
17
+ import { createHash, randomUUID } from 'node:crypto';
18
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ // ── Paths ──────────────────────────────────────────────────────────────────
23
+
24
+ const HOME = homedir();
25
+ const STATE_DIR = join(HOME, '.aria', 'runtime', 'state');
26
+ const QUALITY_LEDGER_PATH = join(STATE_DIR, 'quality-violations.jsonl');
27
+ const COACH_STATE_PATH = join(STATE_DIR, 'coach-state.json');
28
+
29
+ // ── Hard Doctrine Rails ────────────────────────────────────────────────────
30
+
31
+ const HARD_BLOCK_PATTERNS = [
32
+ { pattern: /\bpersonal_mouth_[a-z_]+\b/i, label: 'gate_label:personal_mouth' },
33
+ { pattern: /\bcode_no_tests\b/i, label: 'gate_label:code_no_tests' },
34
+ { pattern: /\bcode_fake_implementation\b/i, label: 'gate_label:fake_impl' },
35
+ { pattern: /\bcode_type_safety\b/i, label: 'gate_label:type_safety' },
36
+ { pattern: /\bip_infrastructure\b/i, label: 'gate_label:ip_leak' },
37
+ { pattern: /\b8lens_[a-z_]+\b/i, label: 'gate_label:8lens' },
38
+ { pattern: /\bvoice_cold_[a-z_]+\b/i, label: 'gate_label:voice_cold' },
39
+ { pattern: /\bharness_output_gate_block\b/i, label: 'gate_label:output_block' },
40
+ { pattern: /\bauto_fix:\s/i, label: 'gate_label:auto_fix' },
41
+ { pattern: /I need to pause and reconsider\.?/i, label: 'gate_label:collapse_placeholder' },
42
+ { pattern: /\bpersonal_mouth_harness_shallow_[a-z_]+\b/i, label: 'gate_label:shallow' },
43
+ { pattern: /\bpersonal_mouth_unsupported_internal_[a-z_]+\b/i, label: 'gate_label:internal_claim' },
44
+ ];
45
+
46
+ const MINIMUM_CHARS = 40;
47
+
48
+ const SAFE_FALLBACKS = {
49
+ emotional_presence: "I'm here. Tell me what's with you right now.",
50
+ architect: "I need more context to give a proper architecture answer. What specific system or decision are you working on?",
51
+ repair: "I can see the issue — let me trace the root cause. Can you share the specific error or surface that's broken?",
52
+ action: "Action kernel received — confirmation required before proceeding. What would you like to execute?",
53
+ research: "Let me gather the relevant information. What specific topic or question should I research?",
54
+ default: "Let me try again — that last response wasn't right. What were you asking about?",
55
+ };
56
+
57
+ // ── Violation Ledger ──────────────────────────────────────────────────────
58
+
59
+ function ensureStateDir() {
60
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
61
+ }
62
+
63
+ function logViolation(violation) {
64
+ ensureStateDir();
65
+ try {
66
+ appendFileSync(QUALITY_LEDGER_PATH, `${JSON.stringify(violation)}\n`, { encoding: 'utf8' });
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function notifyCoach(violation) {
74
+ ensureStateDir();
75
+ try {
76
+ const event = {
77
+ at: new Date().toISOString(),
78
+ type: 'quality_violation',
79
+ violationId: violation.violationId,
80
+ kernel: violation.kernel,
81
+ violation: violation.violation,
82
+ textPreview: violation.textPreview,
83
+ recoveryAttempts: violation.recoveryAttempts,
84
+ finalOutcome: violation.finalOutcome,
85
+ };
86
+ if (existsSync(COACH_STATE_PATH)) {
87
+ // Append to coach state for offline learning
88
+ appendFileSync(COACH_STATE_PATH, `${JSON.stringify(event)}\n`, { encoding: 'utf8' });
89
+ }
90
+ } catch {
91
+ // Non-fatal — coach learning is best-effort
92
+ }
93
+ }
94
+
95
+ function violationHash(text) {
96
+ return createHash('sha256').update(String(text)).digest('hex').slice(0, 16);
97
+ }
98
+
99
+ // ── Core Enforcement ──────────────────────────────────────────────────────
100
+
101
+ export function checkQuality(text) {
102
+ if (typeof text !== 'string' || text.length === 0) {
103
+ return { allowed: false, reasons: ['empty_output'] };
104
+ }
105
+
106
+ const reasons = [];
107
+ for (const { pattern, label } of HARD_BLOCK_PATTERNS) {
108
+ if (pattern.test(text)) {
109
+ reasons.push(label);
110
+ }
111
+ }
112
+
113
+ if (text.trim().length < MINIMUM_CHARS) {
114
+ reasons.push('below_minimum_chars');
115
+ }
116
+
117
+ return { allowed: reasons.length === 0, reasons };
118
+ }
119
+
120
+ export async function enforceQualityWithRecovery(
121
+ text,
122
+ kernel = 'default',
123
+ options = {},
124
+ ) {
125
+ const sessionId = options.sessionId || 'runtime';
126
+ const rewriteFn = options.rewriteFn || null;
127
+ const maxAttempts = Math.min(2, Math.max(0, Number(options.maxRecoveryAttempts || 2)));
128
+
129
+ const initial = checkQuality(text);
130
+ if (initial.allowed) {
131
+ return {
132
+ finalText: text,
133
+ enforced: false,
134
+ attempts: 0,
135
+ violations: [],
136
+ logged: false,
137
+ };
138
+ }
139
+
140
+ const violations = [...initial.reasons];
141
+ let currentText = text;
142
+ let attempts = 0;
143
+ let repaired = false;
144
+
145
+ // Recovery loop: ask the model to repair its own output
146
+ while (attempts < maxAttempts && rewriteFn && !repaired) {
147
+ attempts += 1;
148
+ try {
149
+ const repairedText = await rewriteFn(
150
+ `Your previous response was blocked by quality enforcement for these reasons: ${initial.reasons.join(', ')}. ` +
151
+ `Rewrite the answer to remove all internal labels, gate phrases, and placeholder text. ` +
152
+ `Original context: ${text.slice(0, 200)}`
153
+ );
154
+ const repairCheck = checkQuality(repairedText);
155
+ if (repairCheck.allowed) {
156
+ currentText = repairedText;
157
+ repaired = true;
158
+ break;
159
+ }
160
+ violations.push(...repairCheck.reasons);
161
+ } catch {
162
+ // Recovery attempt failed — continue to next attempt or fallback
163
+ }
164
+ }
165
+
166
+ // Safe fallback if all recovery attempts failed
167
+ const finalText = repaired
168
+ ? currentText
169
+ : (SAFE_FALLBACKS[kernel] || SAFE_FALLBACKS.default);
170
+
171
+ // Log the violation
172
+ const violation = {
173
+ violationId: randomUUID(),
174
+ sessionId,
175
+ kernel,
176
+ violations,
177
+ textPreview: String(text).slice(0, 200),
178
+ textHash: violationHash(text),
179
+ recoveryAttempts: attempts,
180
+ finalOutcome: repaired ? 'repaired' : 'safe_fallback',
181
+ at: new Date().toISOString(),
182
+ };
183
+
184
+ const logged = logViolation(violation);
185
+ notifyCoach(violation);
186
+
187
+ return {
188
+ finalText,
189
+ enforced: true,
190
+ attempts,
191
+ violations,
192
+ logged,
193
+ };
194
+ }
195
+
196
+ // ── Safe Mouth Proxy ──────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Drop-in guard for mounted-runtime provider pipelines.
200
+ * Accepts a providerMeta text and guarantees the finalText is safe.
201
+ */
202
+ export async function guardProviderOutput(providerMeta, kernel, sessionId) {
203
+ const result = await enforceQualityWithRecovery(
204
+ providerMeta.text,
205
+ kernel,
206
+ { sessionId },
207
+ );
208
+ return {
209
+ ...providerMeta,
210
+ text: result.finalText,
211
+ quality: {
212
+ enforced: result.enforced,
213
+ attempts: result.attempts,
214
+ violations: result.violations,
215
+ logged: result.logged,
216
+ },
217
+ };
218
+ }
219
+
220
+ // ── Coach Notification Bridge ─────────────────────────────────────────────
221
+
222
+ /**
223
+ * Notifies the coach kernel of a new quality violation pattern.
224
+ * Called by the quality enforcer after every enforcement action.
225
+ */
226
+ export function getCoachQualitySummary() {
227
+ ensureStateDir();
228
+ try {
229
+ if (!existsSync(QUALITY_LEDGER_PATH)) {
230
+ return { ok: true, violationCount: 0, recentPatterns: [] };
231
+ }
232
+ const lines = require('node:fs').readFileSync(QUALITY_LEDGER_PATH, 'utf8').trim().split('\n').filter(Boolean);
233
+ const violations = lines.map((line) => {
234
+ try { return JSON.parse(line); } catch { return null; }
235
+ }).filter(Boolean);
236
+
237
+ // Count by violation type
238
+ const byType = {};
239
+ for (const v of violations) {
240
+ for (const reason of (v.violations || [])) {
241
+ byType[reason] = (byType[reason] || 0) + 1;
242
+ }
243
+ }
244
+
245
+ return {
246
+ ok: true,
247
+ violationCount: violations.length,
248
+ recentPatterns: Object.entries(byType)
249
+ .sort(([, a], [, b]) => b - a)
250
+ .slice(0, 10)
251
+ .map(([pattern, count]) => ({ pattern, count })),
252
+ lastViolation: violations[violations.length - 1] || null,
253
+ };
254
+ } catch {
255
+ return { ok: false, violationCount: 0, recentPatterns: [] };
256
+ }
257
+ }