From 8fd95eb311aa117ab2bb2fc6fa3dfff2ab67949f Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 13 Jul 2026 12:18:42 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(cli):=20edit-time=20enforcement=20?= =?UTF-8?q?=E2=80=94=20cross-agent=20PreToolUse=20hook=20(GT-526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3rd READ→CONTROL surface: block an offending edit IN-FLIGHT as an AI agent writes, before the PR. Core (evaluateEdit/EditBoundaryRule) was done; this lands the vendor-neutral per-agent ADAPTER in the CLI. - edit-hook payload normalizer: a VendorHookAdapter registry (claude-code PreToolUse Write/Edit/MultiEdit + a generic canonical shape for Cursor/ Copilot/custom), repo-relativizing absolute paths against cwd. Adding a vendor is adding one adapter, not a switch branch. - boundary-rules loader: reads the compiled contract (EditBoundaryRule[] / {boundaryRules}/{rules}) with validation. - edit-hook service: normalize → evaluateEdit → deterministic allow/block + human verdict; block exit code 2 (Claude Code veto), allow 0. - `evolith enforce edit --rules [--payload|stdin] [--vendor]`: reads the hook payload from stdin (or --payload), emits ADR-0073 envelope (--format json) or a stderr verdict, exits 2 on block. - docs + Claude Code settings.json hook snippet + wrapper script + sample boundary-rules. Tests: 45 focused (normalizer, service, boundary-rules, command allow/block); CLI suite 967/967. Verified with the real binary over stdin: domain→infra edit → exit 2 with Violation on stderr; conforming edit → exit 0. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 12781ebd5f8936307293c364b512a332cf74f40f) --- src/sdk/cli/docs/edit-time-enforcement.md | 92 +++++++++++ .../examples/claude-code-pretooluse-hook.sh | 21 +++ .../examples/edit-hook-boundary-rules.json | 20 +++ .../enforce/enforce-edit.command.spec.ts | 155 ++++++++++++++++++ .../src/commands/enforce/enforce.command.ts | 122 +++++++++++++- .../agent/edit-hook/boundary-rules.ts | 79 +++++++++ .../agent/edit-hook/edit-hook.service.spec.ts | 112 +++++++++++++ .../agent/edit-hook/edit-hook.service.ts | 103 ++++++++++++ .../agent/edit-hook/hook-payload.spec.ts | 102 ++++++++++++ .../agent/edit-hook/hook-payload.ts | 149 +++++++++++++++++ .../infrastructure/agent/edit-hook/index.ts | 4 + 11 files changed, 957 insertions(+), 2 deletions(-) create mode 100644 src/sdk/cli/docs/edit-time-enforcement.md create mode 100644 src/sdk/cli/examples/claude-code-pretooluse-hook.sh create mode 100644 src/sdk/cli/examples/edit-hook-boundary-rules.json create mode 100644 src/sdk/cli/src/commands/enforce/enforce-edit.command.spec.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/boundary-rules.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.spec.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.spec.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.ts create mode 100644 src/sdk/cli/src/infrastructure/agent/edit-hook/index.ts diff --git a/src/sdk/cli/docs/edit-time-enforcement.md b/src/sdk/cli/docs/edit-time-enforcement.md new file mode 100644 index 000000000..4d8f4b1d4 --- /dev/null +++ b/src/sdk/cli/docs/edit-time-enforcement.md @@ -0,0 +1,92 @@ +# Edit-time enforcement — the cross-agent hook (GT-526) + +Evolith enforces architecture at three READ→CONTROL surfaces: + +1. **Pre-generation** — the MCP server exposes the contract to the agent before it writes (GT-520). +2. **Edit-time** — **this surface**: block the offending change *in-flight*, as the agent writes, + before the PR. +3. **PR/CI** — the authoritative enforcer run on the diff (GT-518). + +The edit-time gate is a **fast, single-file static check**: it scans the imports of the file the +agent is about to write and rejects the write if it crosses a layer boundary (the classic AI drift: +a `domain` file reaching into `infrastructure`). It never restores a toolchain — the full enforcer +run (PR/CI) stays the source of truth. + +The decision is a pure, vendor-agnostic core function (`evaluateEdit` / `EditBoundaryRule` in +`@beyondnet/evolith-core-domain`). This CLI ships the **adapter** that feeds it from any agent. + +## Command + +``` +evolith enforce edit --rules [--payload ] [--vendor ] [--format json] +``` + +- `--rules` — the **compiled boundary contract**: a JSON `EditBoundaryRule[]` (or + `{ "boundaryRules": [...] }`). Produce it with the C4/Structurizr compiler (GT-528) or author it + by hand — see [`examples/edit-hook-boundary-rules.json`](../examples/edit-hook-boundary-rules.json). +- The **hook payload** is read from **stdin** (the agent pipes it), or inline via `--payload`. +- `--vendor` forces a specific payload adapter (`claude-code` | `generic`); default is auto-detect. +- Exit codes: **`0` = allow**, **`2` = block** (a non-writing tool call or an unrecognized payload + is allowed — the gate never blocks what it cannot evaluate). + +Each `EditBoundaryRule`: + +```jsonc +{ + "ruleId": "HXA-01", + "adrRef": "ADR-0002", + "appliesTo": "src/domain/", // path prefix the edited file must match + "forbiddenImports": ["../infrastructure"], // import specifiers rejected in matching files + "severity": "error", // "error" blocks; "warning" reports but allows + "message": "Domain must not depend on Infrastructure (ADR-0002)." +} +``` + +## Cross-agent neutrality + +The payload adapter is a registry, not a vendor `switch`. Two adapters ship today: + +- **`claude-code`** — parses the Claude Code `PreToolUse` payload + (`{ tool_name, tool_input, cwd }`) for `Write` (full `content`), `Edit` (the replacement + `new_string`) and `MultiEdit` (every `new_string`). Absolute paths are made repo-relative + against `cwd` so rules stay author-friendly. +- **`generic`** — any other agent (Cursor, Copilot, a CI bot) can emit the canonical shape + directly: `{ "filePath": "...", "content": "..." }` (aliases: `file_path`/`path`, + `text`/`new_string`). A new vendor needs **zero code** here — only that its wrapper emit this JSON. + +## Wire it into Claude Code (PreToolUse) + +Add to `.claude/settings.json` (project) or `~/.claude/settings.json` (user): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "evolith-cli enforce edit --rules .evolith/boundary-rules.json" + } + ] + } + ] + } +} +``` + +Claude Code pipes the `PreToolUse` JSON to the command on stdin. On exit `2` it blocks the pending +tool call and shows the printed Violations to the model, which then self-corrects in the same loop. +A ready-made wrapper (honoring `EVOLITH_BOUNDARY_RULES`) is in +[`examples/claude-code-pretooluse-hook.sh`](../examples/claude-code-pretooluse-hook.sh). + +## Wire it into another agent (Cursor / Copilot / custom) + +Have the agent's pre-write hook POST the canonical shape and honor the exit code: + +```bash +echo '{"filePath":"src/domain/order.ts","content":"import \"../infrastructure/db\";"}' \ + | evolith-cli enforce edit --rules .evolith/boundary-rules.json --vendor generic +# exits 2 → block the write +``` diff --git a/src/sdk/cli/examples/claude-code-pretooluse-hook.sh b/src/sdk/cli/examples/claude-code-pretooluse-hook.sh new file mode 100644 index 000000000..af0bda5c9 --- /dev/null +++ b/src/sdk/cli/examples/claude-code-pretooluse-hook.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Evolith edit-time enforcement — Claude Code PreToolUse hook wrapper (GT-526). +# +# Claude Code invokes this script BEFORE a Write/Edit/MultiEdit tool runs, piping the +# PreToolUse JSON payload on stdin. The wrapper forwards it to the vendor-neutral +# `evolith enforce edit` gate, which evaluates the edited file's imports against the +# compiled architecture boundary contract and: +# - exits 0 → the edit conforms; Claude Code proceeds. +# - exits 2 → the edit crosses an `error` boundary; Claude Code BLOCKS the write and +# feeds the printed Violations (on stderr) back to the model to self-correct. +# +# PR/CI (GT-518) remains the authoritative gate; this is the cheap in-flight guard. +# +# Point --rules at your compiled boundary contract (produced by the C4/Structurizr compiler, +# GT-528, or hand-authored — see examples/edit-hook-boundary-rules.json). + +set -euo pipefail + +RULES="${EVOLITH_BOUNDARY_RULES:-.evolith/boundary-rules.json}" + +exec evolith-cli enforce edit --rules "${RULES}" diff --git a/src/sdk/cli/examples/edit-hook-boundary-rules.json b/src/sdk/cli/examples/edit-hook-boundary-rules.json new file mode 100644 index 000000000..207f5fb12 --- /dev/null +++ b/src/sdk/cli/examples/edit-hook-boundary-rules.json @@ -0,0 +1,20 @@ +{ + "boundaryRules": [ + { + "ruleId": "HXA-01", + "adrRef": "ADR-0002", + "appliesTo": "src/domain/", + "forbiddenImports": ["../infrastructure", "src/infrastructure", "MyApp.Infrastructure"], + "severity": "error", + "message": "Domain must not depend on Infrastructure (ADR-0002 — Hexagonal Architecture)." + }, + { + "ruleId": "HXA-02", + "adrRef": "ADR-0002", + "appliesTo": "src/application/", + "forbiddenImports": ["../infrastructure", "src/infrastructure"], + "severity": "error", + "message": "Application may depend on Domain and ports only, never on Infrastructure (ADR-0002)." + } + ] +} diff --git a/src/sdk/cli/src/commands/enforce/enforce-edit.command.spec.ts b/src/sdk/cli/src/commands/enforce/enforce-edit.command.spec.ts new file mode 100644 index 000000000..4a26344a0 --- /dev/null +++ b/src/sdk/cli/src/commands/enforce/enforce-edit.command.spec.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { EnforceCommand } from './enforce.command'; + +/** The compiled boundary contract fed to the edit gate (GT-526). */ +const BOUNDARY_RULES = [ + { + ruleId: 'HXA-01', + adrRef: 'ADR-0002', + appliesTo: 'src/domain/', + forbiddenImports: ['../infrastructure', 'src/infrastructure'], + severity: 'error', + message: 'Domain must not depend on Infrastructure (ADR-0002).', + }, +]; + +function claudeWrite(filePath: string, content: string): string { + return JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: filePath, content }, + }); +} + +describe('EnforceCommand — enforce edit (GT-526 · cross-agent edit-time gate)', () => { + let dir: string; + let rulesPath: string; + let command: EnforceCommand; + let mockPrompt: Record; + let logSpy: jest.SpyInstance; + let errSpy: jest.SpyInstance; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'enforce-edit-')); + rulesPath = join(dir, 'boundary-rules.json'); + writeFileSync(rulesPath, JSON.stringify(BOUNDARY_RULES), 'utf-8'); + + mockPrompt = { + showIntro: jest.fn(), + showInfo: jest.fn(), + showWarning: jest.fn(), + showError: jest.fn(), + showSuccess: jest.fn(), + showOutro: jest.fn(), + }; + command = new EnforceCommand(mockPrompt as any); + + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit'); + }) as never); + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + rmSync(dir, { recursive: true, force: true }); + }); + + function lastEnvelope(): any { + const out = logSpy.mock.calls.map((c) => c[0]).join('\n'); + return JSON.parse(out); + } + + it('ALLOWS a conforming edit — exit 0, no process.exit, success envelope allow=true', async () => { + await command.executeCommand(['edit'], { + rules: rulesPath, + format: 'json', + payload: claudeWrite('src/domain/order.ts', "import { Money } from './money';"), + }); + expect(exitSpy).not.toHaveBeenCalled(); + const env = lastEnvelope(); + expect(env.success).toBe(true); + expect(env.meta).toMatchObject({ command: 'evolith enforce edit' }); + expect(env.data).toMatchObject({ action: 'edit', vendor: 'claude-code', allow: true, blocked: false }); + expect(env.data.violations).toHaveLength(0); + }); + + it('BLOCKS a domain→infrastructure edit — exit 2 with a canonical violation', async () => { + await expect( + command.executeCommand(['edit'], { + rules: rulesPath, + format: 'json', + payload: claudeWrite('src/domain/order.ts', "import { Db } from '../infrastructure/db';"), + }), + ).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(2); + const env = lastEnvelope(); + expect(env.data).toMatchObject({ action: 'edit', allow: false, blocked: true }); + expect(env.data.violations[0]).toMatchObject({ ruleId: 'HXA-01', tool: 'edit-gate', file: 'src/domain/order.ts', line: 1 }); + }); + + it('prints the human BLOCK verdict to stderr and exits 2 (default format)', async () => { + await expect( + command.executeCommand(['edit'], { + rules: rulesPath, + payload: claudeWrite('src/domain/order.ts', "import { Db } from '../infrastructure/db';"), + }), + ).rejects.toThrow('process.exit'); + const stderr = errSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(stderr).toContain('BLOCK'); + expect(stderr).toContain('HXA-01'); + expect(exitSpy).toHaveBeenCalledWith(2); + }); + + it('ALLOWS (does not block) an unrelated tool call like Bash', async () => { + await command.executeCommand(['edit'], { + rules: rulesPath, + format: 'json', + payload: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } }), + }); + expect(exitSpy).not.toHaveBeenCalled(); + expect(lastEnvelope().data).toMatchObject({ vendor: null, blocked: false }); + }); + + it('requires --rules (VALIDATION_FAILED, exit 1)', async () => { + await expect( + command.executeCommand(['edit'], { format: 'json', payload: claudeWrite('src/domain/x.ts', 'x') }), + ).rejects.toThrow('process.exit'); + const env = lastEnvelope(); + expect(env.success).toBe(false); + expect(env.error.code).toBe('VALIDATION_FAILED'); + expect(env.error.message).toMatch(/--rules/); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('rejects an invalid JSON payload (VALIDATION_FAILED, exit 1)', async () => { + await expect( + command.executeCommand(['edit'], { rules: rulesPath, format: 'json', payload: '{not json' }), + ).rejects.toThrow('process.exit'); + expect(lastEnvelope().error.code).toBe('VALIDATION_FAILED'); + }); + + it('emits an error envelope for a missing rules file', async () => { + await expect( + command.executeCommand(['edit'], { + rules: join(dir, 'nope.json'), + format: 'json', + payload: claudeWrite('src/domain/x.ts', 'x'), + }), + ).rejects.toThrow('process.exit'); + expect(lastEnvelope().success).toBe(false); + }); + + it('new edit option parsers return their input verbatim', () => { + expect((command as any).parseRules('/r.json')).toBe('/r.json'); + expect((command as any).parsePayload('{}')).toBe('{}'); + expect((command as any).parseVendor('claude-code')).toBe('claude-code'); + }); +}); diff --git a/src/sdk/cli/src/commands/enforce/enforce.command.ts b/src/sdk/cli/src/commands/enforce/enforce.command.ts index 962dcfc90..71c69f3ab 100644 --- a/src/sdk/cli/src/commands/enforce/enforce.command.ts +++ b/src/sdk/cli/src/commands/enforce/enforce.command.ts @@ -21,6 +21,14 @@ import { PromptService } from '../../infrastructure/prompts/prompt.service'; import { ConfigService } from '../../infrastructure/config/config.service'; import { resolveCoreOverride } from '../../infrastructure/paths/core-resolver'; import { resolveRulesets } from '../../infrastructure/paths/rulesets-resolver'; +import { loadBoundaryRules } from '../../infrastructure/agent/edit-hook/boundary-rules'; +import { + enforceEditPayload, + renderEditVerdict, + readStdin, + EDIT_HOOK_BLOCK_EXIT_CODE, +} from '../../infrastructure/agent/edit-hook/edit-hook.service'; +import type { RawHookPayload } from '../../infrastructure/agent/edit-hook/hook-payload'; interface EnforceCommandOptions { format?: string; @@ -28,6 +36,9 @@ interface EnforceCommandOptions { ruleset?: string; core?: string; output?: string; + rules?: string; + payload?: string; + vendor?: string; } /** Known ruleset ids the compiler can resolve against the Core rulesets root. */ @@ -64,7 +75,8 @@ interface CompiledPolicyReport { @Command({ name: 'enforce', arguments: '', - description: 'Enforcement operations (action: compile) — compile enforce: blocks into tool config', + description: + 'Enforcement operations — compile: lower enforce: blocks into tool config; edit: edit-time cross-agent gate (PreToolUse hook)', }) export class EnforceCommand extends BaseEvolithCommand { constructor(promptService: PromptService, configService?: ConfigService) { @@ -93,8 +105,11 @@ export class EnforceCommand extends BaseEvolithCommand { }; const action = inputs[0]; + if (action === 'edit') { + return this.executeEditGate(options); + } if (action !== 'compile') { - return fail('VALIDATION_FAILED', `Unknown enforce action '${action ?? ''}'. Supported: compile`); + return fail('VALIDATION_FAILED', `Unknown enforce action '${action ?? ''}'. Supported: compile, edit`); } if (!options?.file && !options?.ruleset) { @@ -156,6 +171,85 @@ export class EnforceCommand extends BaseEvolithCommand { } } + /** + * `enforce edit` (GT-526 · axis 2) — the edit-time cross-agent gate. Reads an edit intent from + * an agent hook payload (Claude Code `PreToolUse` on stdin, or `--payload`; Cursor/Copilot via + * the generic normalized shape), evaluates the edited file's imports against the compiled + * boundary contract (`--rules`), and returns a DETERMINISTIC allow/block. On block it prints the + * canonical Violations to stderr and exits `2` — the code an agent runtime honors to reject the + * pending write in-flight, before the PR. PR/CI (GT-518) stays the authoritative gate. + */ + private async executeEditGate(options?: EnforceCommandOptions): Promise { + const startedAt = Date.now(); + const json = options?.format === 'json'; + const commandId = 'evolith enforce edit'; + const meta = (): OutputMeta => ({ + command: commandId, + executedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + correlationId: randomUUID(), + schemaVersion: OUTPUT_ENVELOPE_SCHEMA_VERSION, + }); + const fail = (code: ErrorCode, message: string): void => { + if (json) { + console.log(JSON.stringify(createErrorEnvelope(code, message, meta()), null, 2)); + } else { + this.promptService.showError(message); + } + process.exit(1); + }; + + if (!options?.rules) { + return fail('VALIDATION_FAILED', 'Provide the compiled boundary contract via --rules '); + } + + let rules; + try { + rules = await loadBoundaryRules(path.resolve(options.rules)); + } catch (err) { + const code = err instanceof Error && err.name === 'BoundaryRulesError' ? 'VALIDATION_FAILED' : 'IO_ERROR'; + return fail(code, `Failed to load boundary rules '${options.rules}': ${err instanceof Error ? err.message : String(err)}`); + } + + let rawJson: string; + try { + rawJson = options.payload ?? (await readStdin()); + } catch (err) { + return fail('IO_ERROR', `Failed to read hook payload from stdin: ${err instanceof Error ? err.message : String(err)}`); + } + if (!rawJson || rawJson.trim().length === 0) { + return fail('VALIDATION_FAILED', 'Empty hook payload — pass JSON on stdin or via --payload '); + } + + let payload: RawHookPayload; + try { + payload = JSON.parse(rawJson); + } catch (err) { + return fail('VALIDATION_FAILED', `Hook payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + + const result = enforceEditPayload(payload, rules, options.vendor); + + if (json) { + const data = { + action: 'edit' as const, + vendor: result.vendor, + file: result.filePath, + allow: result.decision.allow, + blocked: result.blocked, + violations: result.decision.violations, + }; + console.log(JSON.stringify(createSuccessEnvelope(data, meta()), null, 2)); + } else { + const verdict = renderEditVerdict(result); + // Route to stderr so an agent runtime surfaces it back to the model on block. + if (result.blocked) console.error(verdict); + else this.promptService.showInfo(verdict); + } + + if (result.blocked) process.exit(EDIT_HOOK_BLOCK_EXIT_CODE); + } + /** Resolve the ruleset JSON path from `--file` (verbatim) or `--ruleset `. */ private resolveRulesetPath(options: EnforceCommandOptions): string { if (options.file) return path.resolve(options.file); @@ -230,4 +324,28 @@ export class EnforceCommand extends BaseEvolithCommand { parseOutput(val: string): string { return val; } + + @Option({ + flags: '--rules [path]', + description: 'edit: path to the compiled boundary contract (EditBoundaryRule[] JSON)', + }) + parseRules(val: string): string { + return val; + } + + @Option({ + flags: '--payload [json]', + description: 'edit: inline hook payload JSON (default: read from stdin)', + }) + parsePayload(val: string): string { + return val; + } + + @Option({ + flags: '--vendor [name]', + description: 'edit: force a payload adapter (claude-code | generic); default: auto-detect', + }) + parseVendor(val: string): string { + return val; + } } diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/boundary-rules.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/boundary-rules.ts new file mode 100644 index 000000000..cc3506fdf --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/boundary-rules.ts @@ -0,0 +1,79 @@ +/** + * Boundary-rules loader for the edit-time gate (GT-526). + * + * The edit gate is fed the **compiled architecture contract** as a set of {@link EditBoundaryRule}s + * — the edit-time subset of the layer boundaries (who may import whom). Those rules are produced + * upstream by the C4/Structurizr compiler (GT-528, `compileC4ToBoundaryRules` / + * `parseStructurizrDsl`) or the PolicyCompiler and persisted as JSON. This loader reads that JSON + * from disk and normalizes the accepted envelopes into a validated `EditBoundaryRule[]`. + * + * Accepted top-level shapes (all lower the same rule objects): + * - `EditBoundaryRule[]` + * - `{ boundaryRules: EditBoundaryRule[] }` + * - `{ rules: EditBoundaryRule[] }` + */ + +import type { EditBoundaryRule } from '@beyondnet/evolith-core-domain/application/validators/enforcement/edit-gate'; + +export class BoundaryRulesError extends Error { + constructor(message: string) { + super(message); + this.name = 'BoundaryRulesError'; + } +} + +function coerceRule(raw: unknown, index: number): EditBoundaryRule { + const r = raw as Record; + if (!r || typeof r !== 'object') { + throw new BoundaryRulesError(`boundary rule #${index} is not an object`); + } + const ruleId = r.ruleId ?? r.id; + if (typeof ruleId !== 'string' || ruleId.length === 0) { + throw new BoundaryRulesError(`boundary rule #${index} is missing a string 'ruleId'`); + } + if (typeof r.appliesTo !== 'string' || r.appliesTo.length === 0) { + throw new BoundaryRulesError(`boundary rule '${ruleId}' is missing a string 'appliesTo'`); + } + if (!Array.isArray(r.forbiddenImports) || r.forbiddenImports.some((x) => typeof x !== 'string')) { + throw new BoundaryRulesError(`boundary rule '${ruleId}' 'forbiddenImports' must be a string[]`); + } + const severity = r.severity === 'warning' ? 'warning' : 'error'; + return { + ruleId, + adrRef: typeof r.adrRef === 'string' ? r.adrRef : undefined, + appliesTo: r.appliesTo, + forbiddenImports: r.forbiddenImports as string[], + severity, + message: typeof r.message === 'string' ? r.message : undefined, + }; +} + +/** Parse an already-read JSON string into validated boundary rules. */ +export function parseBoundaryRules(json: string): EditBoundaryRule[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + throw new BoundaryRulesError(`invalid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + const list = Array.isArray(parsed) + ? parsed + : Array.isArray((parsed as Record)?.boundaryRules) + ? (parsed as { boundaryRules: unknown[] }).boundaryRules + : Array.isArray((parsed as Record)?.rules) + ? (parsed as { rules: unknown[] }).rules + : undefined; + if (!list) { + throw new BoundaryRulesError( + "expected an EditBoundaryRule[] or an object with a 'boundaryRules'/'rules' array", + ); + } + return list.map(coerceRule); +} + +/** Read and parse boundary rules from a JSON file path. */ +export async function loadBoundaryRules(rulesPath: string): Promise { + const fs = await import('fs-extra'); + const raw = await fs.readFile(rulesPath, 'utf-8'); + return parseBoundaryRules(raw); +} diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.spec.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.spec.ts new file mode 100644 index 000000000..aff63c29b --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.spec.ts @@ -0,0 +1,112 @@ +import { Readable } from 'node:stream'; +import type { EditBoundaryRule } from '@beyondnet/evolith-core-domain/application/validators/enforcement/edit-gate'; +import { + enforceEditPayload, + renderEditVerdict, + readStdin, + EDIT_HOOK_BLOCK_EXIT_CODE, + EDIT_HOOK_ALLOW_EXIT_CODE, +} from './edit-hook.service'; +import { parseBoundaryRules, BoundaryRulesError } from './boundary-rules'; + +const HEXA_RULE: EditBoundaryRule = { + ruleId: 'HXA-01', + adrRef: 'ADR-0002', + appliesTo: 'src/domain/', + forbiddenImports: ['../infrastructure', 'src/infrastructure', 'MyApp.Infrastructure'], + severity: 'error', + message: 'Domain must not depend on Infrastructure (ADR-0002).', +}; + +describe('enforceEditPayload (GT-526 — edit-time decision)', () => { + it('BLOCKS a domain Write that imports infrastructure (exit 2, canonical violation)', () => { + const result = enforceEditPayload( + { + tool_name: 'Write', + cwd: '/repo', + tool_input: { file_path: '/repo/src/domain/order.ts', content: "import { Db } from '../infrastructure/db';" }, + }, + [HEXA_RULE], + ); + expect(result.blocked).toBe(true); + expect(result.exitCode).toBe(EDIT_HOOK_BLOCK_EXIT_CODE); + expect(result.vendor).toBe('claude-code'); + expect(result.decision.violations[0]).toMatchObject({ ruleId: 'HXA-01', tool: 'edit-gate', line: 1 }); + }); + + it('ALLOWS a conforming domain Write (exit 0)', () => { + const result = enforceEditPayload( + { + tool_name: 'Write', + tool_input: { file_path: 'src/domain/order.ts', content: "import { Money } from './money';" }, + }, + [HEXA_RULE], + ); + expect(result.blocked).toBe(false); + expect(result.exitCode).toBe(EDIT_HOOK_ALLOW_EXIT_CODE); + expect(result.decision.violations).toHaveLength(0); + }); + + it('ALLOWS (nothing to gate) when the payload is not a file edit', () => { + const result = enforceEditPayload({ tool_name: 'Bash', tool_input: { command: 'ls' } }, [HEXA_RULE]); + expect(result.blocked).toBe(false); + expect(result.vendor).toBeNull(); + expect(result.exitCode).toBe(EDIT_HOOK_ALLOW_EXIT_CODE); + }); + + it('does not block on a warning-severity boundary (reports, allows)', () => { + const result = enforceEditPayload( + { tool_name: 'Write', tool_input: { file_path: 'src/domain/order.ts', content: "import '../infrastructure/db';" } }, + [{ ...HEXA_RULE, severity: 'warning' }], + ); + expect(result.blocked).toBe(false); + expect(result.decision.violations).toHaveLength(1); + }); +}); + +describe('renderEditVerdict', () => { + it('renders a BLOCK verdict with rule, ADR and file:line', () => { + const result = enforceEditPayload( + { tool_name: 'Write', tool_input: { file_path: 'src/domain/order.ts', content: "import '../infrastructure/db';" } }, + [HEXA_RULE], + ); + const text = renderEditVerdict(result); + expect(text).toContain('BLOCK'); + expect(text).toContain('HXA-01'); + expect(text).toContain('[ADR-0002]'); + expect(text).toContain('src/domain/order.ts:1'); + }); + + it('renders an allow note when there is nothing to gate', () => { + const result = enforceEditPayload({ tool_name: 'Read', tool_input: { file_path: 'x' } }, [HEXA_RULE]); + expect(renderEditVerdict(result)).toContain('nothing to enforce'); + }); +}); + +describe('readStdin', () => { + it('reads a JSON string from a stream', async () => { + const stream = Readable.from(['{"tool_name":', '"Write"}']); + await expect(readStdin(stream)).resolves.toBe('{"tool_name":"Write"}'); + }); +}); + +describe('parseBoundaryRules', () => { + it('accepts a bare array', () => { + expect(parseBoundaryRules(JSON.stringify([HEXA_RULE]))).toHaveLength(1); + }); + it('accepts a { boundaryRules } envelope and defaults severity to error', () => { + const rules = parseBoundaryRules( + JSON.stringify({ boundaryRules: [{ id: 'R1', appliesTo: 'src/domain/', forbiddenImports: ['x'] }] }), + ); + expect(rules[0]).toMatchObject({ ruleId: 'R1', severity: 'error' }); + }); + it('rejects a rule missing forbiddenImports', () => { + expect(() => parseBoundaryRules(JSON.stringify([{ ruleId: 'R1', appliesTo: 'src/' }]))).toThrow(BoundaryRulesError); + }); + it('rejects a non-array/non-envelope shape', () => { + expect(() => parseBoundaryRules(JSON.stringify({ nope: true }))).toThrow(BoundaryRulesError); + }); + it('rejects invalid JSON', () => { + expect(() => parseBoundaryRules('{not json')).toThrow(BoundaryRulesError); + }); +}); diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.ts new file mode 100644 index 000000000..ccd628eda --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.ts @@ -0,0 +1,103 @@ +/** + * Edit-time enforcement orchestration (GT-526 · axis 2). + * + * Glues the vendor-neutral {@link normalizeEditIntent normalizer} to the pure core decision + * (`evaluateEdit`): given a raw agent hook payload and the compiled boundary contract, it decides + * allow/block and renders a deterministic, human-readable verdict. The command layer is a thin + * wrapper that reads stdin and maps the verdict to a process exit code. + */ + +import { + evaluateEdit, + type EditBoundaryRule, + type EditGateDecision, +} from '@beyondnet/evolith-core-domain/application/validators/enforcement/edit-gate'; +import { normalizeEditIntent, type RawHookPayload } from './hook-payload'; + +/** + * Claude Code `PreToolUse` blocking exit code. Exit `2` tells the agent runtime to BLOCK the + * pending tool call and feed stderr back to the model; `0` approves. This is the deterministic, + * cross-agent contract (any runtime that honors a non-zero "veto" exit works the same way). + */ +export const EDIT_HOOK_BLOCK_EXIT_CODE = 2; +export const EDIT_HOOK_ALLOW_EXIT_CODE = 0; + +export interface EditHookResult { + /** Vendor whose adapter matched, or `null` when the payload was not a gate-able edit. */ + readonly vendor: string | null; + /** The proposed edit that was evaluated, when one was found. */ + readonly filePath: string | null; + /** `true` only when an `error`-severity boundary was crossed — the edit must be rejected. */ + readonly blocked: boolean; + /** The full gate decision (empty when there was nothing to gate). */ + readonly decision: EditGateDecision; + /** The process exit code the caller should use. */ + readonly exitCode: number; +} + +/** + * Evaluate a raw agent hook payload against the compiled boundary contract. + * + * A payload that is not a file-writing intent (a `Read`/`Bash` tool call, an unrecognized shape) + * is ALLOWED — the gate never blocks what it cannot evaluate; PR/CI (GT-518) stays authoritative. + */ +export function enforceEditPayload( + raw: RawHookPayload, + rules: readonly EditBoundaryRule[], + vendorHint?: string, +): EditHookResult { + const intent = normalizeEditIntent(raw, vendorHint); + if (!intent) { + return { + vendor: null, + filePath: null, + blocked: false, + decision: { allow: true, violations: [] }, + exitCode: EDIT_HOOK_ALLOW_EXIT_CODE, + }; + } + const decision = evaluateEdit(intent.edit, rules); + const blocked = !decision.allow; + return { + vendor: intent.vendor, + filePath: intent.edit.filePath, + blocked, + decision, + exitCode: blocked ? EDIT_HOOK_BLOCK_EXIT_CODE : EDIT_HOOK_ALLOW_EXIT_CODE, + }; +} + +/** + * Render a deterministic, human-readable verdict for stderr — the text an agent (or a human) + * sees when the edit is blocked. Includes each crossed boundary with file:line, the ADR ref and + * the rule message, so the model can self-correct in the same loop. + */ +export function renderEditVerdict(result: EditHookResult): string { + if (result.vendor === null) { + return 'edit-gate: no file edit in payload — nothing to enforce (allow).'; + } + const { decision, filePath } = result; + if (decision.violations.length === 0) { + return `edit-gate: ALLOW — ${filePath} conforms to the architecture contract.`; + } + const header = result.blocked + ? `edit-gate: BLOCK — ${filePath} violates the architecture contract:` + : `edit-gate: ALLOW (with warnings) — ${filePath}:`; + const lines = decision.violations.map((v) => { + const adr = v.adrRef ? ` [${v.adrRef}]` : ''; + const tag = v.severity === 'error' ? 'error' : 'warning'; + return ` - ${tag} ${v.ruleId}${adr} at ${v.file}:${v.line} — ${v.message}`; + }); + return [header, ...lines].join('\n'); +} + +/** Read all of stdin as a UTF-8 string (the JSON hook payload). Resolves `''` when stdin is empty. */ +export function readStdin(stream: NodeJS.ReadableStream = process.stdin): Promise { + return new Promise((resolve, reject) => { + let data = ''; + stream.setEncoding?.('utf-8'); + stream.on('data', (chunk) => (data += chunk)); + stream.on('end', () => resolve(data)); + stream.on('error', reject); + }); +} diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.spec.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.spec.ts new file mode 100644 index 000000000..7f1f493ce --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.spec.ts @@ -0,0 +1,102 @@ +import { + normalizeEditIntent, + relativizePath, + claudeCodeAdapter, + genericAdapter, + EDIT_HOOK_ADAPTERS, +} from './hook-payload'; + +describe('relativizePath (GT-526 — author-friendly boundary matching)', () => { + it('strips the cwd prefix from an absolute path', () => { + expect(relativizePath('/repo/src/domain/order.ts', '/repo')).toBe('src/domain/order.ts'); + }); + it('tolerates a trailing slash on cwd', () => { + expect(relativizePath('/repo/src/domain/order.ts', '/repo/')).toBe('src/domain/order.ts'); + }); + it('leaves a path outside cwd untouched', () => { + expect(relativizePath('/other/x.ts', '/repo')).toBe('/other/x.ts'); + }); + it('normalizes a leading ./ when no cwd is given', () => { + expect(relativizePath('./src/domain/order.ts')).toBe('src/domain/order.ts'); + }); +}); + +describe('claudeCodeAdapter — PreToolUse shapes', () => { + it('extracts a Write intent (full content) and relativizes the path', () => { + const intent = normalizeEditIntent({ + hook_event_name: 'PreToolUse', + cwd: '/repo', + tool_name: 'Write', + tool_input: { file_path: '/repo/src/domain/order.ts', content: "import x from './y';" }, + }); + expect(intent).toEqual({ + vendor: 'claude-code', + edit: { filePath: 'src/domain/order.ts', content: "import x from './y';" }, + }); + }); + + it('extracts an Edit intent from new_string (the text about to be introduced)', () => { + const intent = normalizeEditIntent({ + tool_name: 'Edit', + tool_input: { + file_path: 'src/domain/order.ts', + old_string: 'const a = 1;', + new_string: "import { Db } from '../infrastructure/db';", + }, + }); + expect(intent?.edit.content).toBe("import { Db } from '../infrastructure/db';"); + expect(intent?.edit.filePath).toBe('src/domain/order.ts'); + }); + + it('concatenates every new_string of a MultiEdit', () => { + const intent = normalizeEditIntent({ + tool_name: 'MultiEdit', + tool_input: { + file_path: 'src/domain/order.ts', + edits: [ + { old_string: 'a', new_string: "import './a';" }, + { old_string: 'b', new_string: "import '../infrastructure/db';" }, + ], + }, + }); + expect(intent?.edit.content).toContain('../infrastructure/db'); + }); + + it('returns null for a non-writing tool (Read/Bash) — nothing to gate', () => { + expect(normalizeEditIntent({ tool_name: 'Read', tool_input: { file_path: 'x.ts' } })).toBeNull(); + expect(normalizeEditIntent({ tool_name: 'Bash', tool_input: { command: 'ls' } })).toBeNull(); + }); + + it('returns null when a Write has no content', () => { + expect(normalizeEditIntent({ tool_name: 'Write', tool_input: { file_path: 'x.ts' } })).toBeNull(); + }); +}); + +describe('genericAdapter — cross-agent plug-in shape (Cursor/Copilot/custom)', () => { + it('accepts the canonical { filePath, content } shape', () => { + const intent = normalizeEditIntent({ filePath: 'src/domain/order.ts', content: "import '../infrastructure/x';" }); + expect(intent).toEqual({ + vendor: 'generic', + edit: { filePath: 'src/domain/order.ts', content: "import '../infrastructure/x';" }, + }); + }); + + it('accepts file_path/text aliases', () => { + const intent = normalizeEditIntent({ file_path: 'src/domain/order.ts', text: 'const x = 1;' }); + expect(intent?.vendor).toBe('generic'); + expect(intent?.edit.content).toBe('const x = 1;'); + }); +}); + +describe('vendorHint + registry ordering', () => { + it('honors an explicit vendor hint (skips auto-detection)', () => { + const raw = { filePath: 'src/domain/order.ts', content: 'x' }; + expect(normalizeEditIntent(raw, 'claude-code')).toBeNull(); // claude-code adapter does not match this shape + expect(normalizeEditIntent(raw, 'generic')?.vendor).toBe('generic'); + }); + + it('registers claude-code before generic', () => { + expect(EDIT_HOOK_ADAPTERS[0]).toBe(claudeCodeAdapter); + expect(EDIT_HOOK_ADAPTERS[1]).toBe(genericAdapter); + }); +}); diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.ts new file mode 100644 index 000000000..6f3536c24 --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.ts @@ -0,0 +1,149 @@ +/** + * Cross-agent edit-hook payload normalizer (GT-526 · axis 2 — positioning §14.1 surface (b)). + * + * The edit-time control surface must block an offending change IN-FLIGHT, as an AI agent + * writes — BEFORE the PR. The pure decision lives in core-domain (`evaluateEdit` + + * `EditBoundaryRule`). This module is the **vendor-neutral ADAPTER** that feeds it: it turns a + * raw agent hook payload (Claude Code `PreToolUse`, and — via the same registry — Cursor / + * Copilot / any custom shape) into the canonical {@link ProposedEdit} the gate understands. + * + * Neutrality is structural, not a switch statement: each vendor is a small {@link VendorHookAdapter} + * (a `matches` predicate + an `extract` mapping). Adding Cursor is adding one adapter to + * {@link EDIT_HOOK_ADAPTERS}; nothing downstream changes. + */ + +import type { ProposedEdit } from '@beyondnet/evolith-core-domain/application/validators/enforcement/edit-gate'; + +/** A raw hook payload as received on stdin (JSON) from an agent runtime. Shape is vendor-specific. */ +export type RawHookPayload = Record; + +/** A vendor-specific mapping from a raw hook payload to the canonical proposed edit. */ +export interface VendorHookAdapter { + /** Stable vendor id (e.g. `claude-code`, `cursor`, `generic`). */ + readonly vendor: string; + /** True when this adapter recognizes the payload shape. */ + matches(raw: RawHookPayload): boolean; + /** + * Extract the proposed edit, or `null` when the payload is not a file-writing intent + * (e.g. a `Bash`/`Read` tool call) and therefore has nothing to gate. + */ + extract(raw: RawHookPayload): ProposedEdit | null; +} + +/** The normalized outcome of parsing a raw payload. */ +export interface NormalizedEditIntent { + readonly vendor: string; + readonly edit: ProposedEdit; +} + +function asString(v: unknown): string | undefined { + return typeof v === 'string' ? v : undefined; +} + +function asRecord(v: unknown): Record | undefined { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; +} + +/** + * Make an absolute file path repo-relative against the payload's `cwd`, so author-friendly + * boundary rules (`appliesTo: 'src/domain/'`) match without every rule hard-coding an absolute + * root. Non-absolute paths and paths outside `cwd` are returned untouched. + */ +export function relativizePath(filePath: string, cwd?: string): string { + const f = filePath.replace(/\\/g, '/'); + const root = (cwd ?? '').replace(/\\/g, '/').replace(/\/+$/, ''); + if (root && f.startsWith(root + '/')) return f.slice(root.length + 1); + return f.replace(/^\.\//, ''); +} + +/** + * Claude Code `PreToolUse` adapter. Recognizes the documented payload: + * `{ hook_event_name?: 'PreToolUse', tool_name, tool_input, cwd? }`. + * + * - `Write` → the full `content` is the proposed file. + * - `Edit` → the replacement `new_string` is what the agent is about to introduce. + * - `MultiEdit` → the concatenation of every edit's `new_string`. + * + * A non-writing tool (`Read`, `Bash`, `Grep`, …) yields `null` — nothing to gate. + */ +export const claudeCodeAdapter: VendorHookAdapter = { + vendor: 'claude-code', + matches(raw) { + return typeof raw.tool_name === 'string' && asRecord(raw.tool_input) !== undefined; + }, + extract(raw) { + const toolName = asString(raw.tool_name); + const input = asRecord(raw.tool_input); + if (!toolName || !input) return null; + const cwd = asString(raw.cwd); + const filePathRaw = asString(input.file_path) ?? asString(input.filePath); + if (!filePathRaw) return null; + const filePath = relativizePath(filePathRaw, cwd); + + switch (toolName) { + case 'Write': { + const content = asString(input.content); + return content === undefined ? null : { filePath, content }; + } + case 'Edit': { + const content = asString(input.new_string); + return content === undefined ? null : { filePath, content }; + } + case 'MultiEdit': { + const edits = Array.isArray(input.edits) ? input.edits : []; + const parts = edits + .map((e) => asString(asRecord(e)?.new_string)) + .filter((s): s is string => s !== undefined); + return parts.length === 0 ? null : { filePath, content: parts.join('\n') }; + } + default: + return null; // non-writing tool — nothing to enforce + } + }, +}; + +/** + * Vendor-neutral fallback adapter. Any agent (Cursor, Copilot, a CI script, a custom bot) that + * cannot emit the Claude Code shape may POST the canonical shape directly: + * `{ filePath | file_path | path, content | text | new_string }`. This is the documented plug-in + * seam so a new vendor needs zero code here — only that its wrapper emit this normalized JSON. + */ +export const genericAdapter: VendorHookAdapter = { + vendor: 'generic', + matches(raw) { + const hasPath = asString(raw.filePath) ?? asString(raw.file_path) ?? asString(raw.path); + const hasContent = asString(raw.content) ?? asString(raw.text) ?? asString(raw.new_string); + return hasPath !== undefined && hasContent !== undefined; + }, + extract(raw) { + const filePathRaw = asString(raw.filePath) ?? asString(raw.file_path) ?? asString(raw.path); + const content = asString(raw.content) ?? asString(raw.text) ?? asString(raw.new_string); + if (filePathRaw === undefined || content === undefined) return null; + return { filePath: relativizePath(filePathRaw, asString(raw.cwd)), content }; + }, +}; + +/** + * The adapter registry, tried in order. Claude Code first (most specific), the generic + * canonical shape last. Cursor/Copilot adapters slot in here without touching callers. + */ +export const EDIT_HOOK_ADAPTERS: readonly VendorHookAdapter[] = [claudeCodeAdapter, genericAdapter]; + +/** + * Normalize a raw agent hook payload into a canonical edit intent. + * + * @param raw The parsed JSON payload from stdin. + * @param vendorHint Optional vendor id to force a specific adapter (skips auto-detection). + * @returns The normalized intent, or `null` when the payload is not a gate-able file edit. + */ +export function normalizeEditIntent(raw: RawHookPayload, vendorHint?: string): NormalizedEditIntent | null { + const candidates = vendorHint + ? EDIT_HOOK_ADAPTERS.filter((a) => a.vendor === vendorHint) + : EDIT_HOOK_ADAPTERS; + for (const adapter of candidates) { + if (!adapter.matches(raw)) continue; + const edit = adapter.extract(raw); + if (edit) return { vendor: adapter.vendor, edit }; + } + return null; +} diff --git a/src/sdk/cli/src/infrastructure/agent/edit-hook/index.ts b/src/sdk/cli/src/infrastructure/agent/edit-hook/index.ts new file mode 100644 index 000000000..853418329 --- /dev/null +++ b/src/sdk/cli/src/infrastructure/agent/edit-hook/index.ts @@ -0,0 +1,4 @@ +/** Edit-time enforcement hook adapter (GT-526 · axis 2) — cross-agent PreToolUse gate. */ +export * from './hook-payload'; +export * from './boundary-rules'; +export * from './edit-hook.service'; From 7fe2c71755fb3428c924719cf35b39352ab237fc Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 13 Jul 2026 12:15:10 -0500 Subject: [PATCH 2/5] feat(agent-runtime-api): production adapter wiring via AGENT_RUNTIME_PROFILE (GT-438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a single AGENT_RUNTIME_PROFILE=production|dev switch that governs the whole adapter set in the runtime factory, replacing the ad-hoc per-var opt-ins: - dev (default): deterministic stubs + in-memory state stay the explicit default; each real adapter remains opt-in per env var (endpoint-driven local-kind wiring is preserved regardless of profile). - production: real adapters are mandatory and the factory FAILS LOUD instead of silently degrading to a stub — * HttpCoreEvaluationAdapter requires AGENT_RUNTIME_CORE_ENDPOINT + _CORE_TOKEN * durable FileMemoryAdapter requires AGENT_RUNTIME_STATE_DIR (also backs the host-driven FileSchedulerAdapter) * real OPA policy is enforced (an explicit stub request is refused) - reasoning engine (Hermes/Swarms/Cowork/routing) wired by AGENT_RUNTIME_ENGINE where available; unset keeps the deterministic stub in every profile (GT-385-gated). Documents the switch in .env.example and adds a focused factory selection-matrix spec (26 cases): dev ⇒ stubs; production+endpoint ⇒ real adapters; production+missing endpoint/token/state-dir ⇒ throws; engine selection + errors. Verification: agent-runtime-api tsc -b clean; jest 59/59 (26 new). Co-Authored-By: Claude Opus 4.8 (cherry picked from commit acf2824026d09aefea457708385617aadad86de0) --- src/apps/agent-runtime-api/.env.example | 45 +++++ .../src/agent-runtime/runtime.factory.spec.ts | 169 ++++++++++++++++++ .../src/agent-runtime/runtime.factory.ts | 148 +++++++++++++-- 3 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts diff --git a/src/apps/agent-runtime-api/.env.example b/src/apps/agent-runtime-api/.env.example index c42e9d8be..af193d715 100644 --- a/src/apps/agent-runtime-api/.env.example +++ b/src/apps/agent-runtime-api/.env.example @@ -1,3 +1,23 @@ +# ─── Deployment profile (GT-438) ───────────────────────────────────────────── +# ONE switch governs the whole adapter set: +# dev (default; also development/local/test) +# Deterministic stubs + in-memory state are the explicit default. The +# runtime boots offline with no live Core, no engine and no volume. Each +# real adapter is still opt-in per env var below. +# production +# The real adapters are MANDATORY — the service FAILS TO BOOT (loud error) +# instead of silently degrading to a stub. Under this profile: +# · AGENT_RUNTIME_CORE_ENDPOINT + AGENT_RUNTIME_CORE_TOKEN are required +# (real authenticated Core; no StubCoreEvaluationAdapter fallback). +# · AGENT_RUNTIME_STATE_DIR is required (durable memory + scheduler +# state on a mounted volume; no volatile in-memory fallback). +# · Real OPA is enforced (an explicit stub policy request is refused). +# The reasoning engine stays GT-385-gated: an unset AGENT_RUNTIME_ENGINE +# keeps the deterministic stub engine in every profile. +# Setting a real adapter's env var always graduates that one port regardless of +# profile (e.g. the endpoint-driven local-kind wiring works under dev). +AGENT_RUNTIME_PROFILE=dev + # ─── Server ────────────────────────────────────────────────────────────────── PORT=3000 NODE_ENV=production @@ -37,6 +57,31 @@ AGENT_RUNTIME_OPA_ENABLED=false AGENT_RUNTIME_OPA_PATH= AGENT_RUNTIME_OPA_POLICY_DIR= +# ─── Core evaluation (REQUIRED in production) ──────────────────────────────── +# The real stateless Core over HTTP (Core API /api/v1/evaluate). When set, the +# runtime governs over the REAL Core instead of the deterministic stub. In the +# production profile BOTH are mandatory (fail-loud). +# AGENT_RUNTIME_CORE_ENDPOINT=https://core.beyondnet.cloud/api/v1/evaluate +AGENT_RUNTIME_CORE_ENDPOINT= +AGENT_RUNTIME_CORE_TOKEN= + +# ─── Reasoning engine (optional; GT-385-gated) ─────────────────────────────── +# Selects the IAgentEnginePort adapter. Unset (or 'stub') keeps the +# deterministic stub engine in every profile. 'routing' additionally requires +# AGENT_RUNTIME_ENGINE_ROUTER (a JSON EngineRouterConfig). +# one of: stub | hermes | swarms | cowork | routing +# AGENT_RUNTIME_ENGINE=hermes +AGENT_RUNTIME_ENGINE= +# AGENT_RUNTIME_ENGINE_ROUTER={"defaultEngine":"stub","routes":[{"intentMatches":"deploy","engine":"hermes"}]} +AGENT_RUNTIME_ENGINE_ROUTER= + +# ─── Durable state (REQUIRED in production) ────────────────────────────────── +# Mounted volume that persists the runtime's working memory across restarts +# (FileMemoryAdapter) and backs the durable FileSchedulerAdapter driven by the +# host scheduling loop. Required (fail-loud) under the production profile. +# AGENT_RUNTIME_STATE_DIR=/var/lib/evolith-runtime +AGENT_RUNTIME_STATE_DIR= + # ─── Tracker trazability (optional) ────────────────────────────────────────── # When set, trace events are POSTed to a live Evolith Tracker. # AGENT_RUNTIME_TRACKER_ENDPOINT=https://tracker.beyondnet.cloud/api/v1/traces diff --git a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts new file mode 100644 index 000000000..45b20c6e2 --- /dev/null +++ b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts @@ -0,0 +1,169 @@ +import { + StubCoreEvaluationAdapter, + HttpCoreEvaluationAdapter, + StubAgentEngineAdapter, + HermesAgentAdapter, + SwarmsAgentAdapter, + CoworkAgentEngineAdapter, + RoutingAgentAdapter, + InMemoryMemoryAdapter, + FileMemoryAdapter, + StubPolicyValidationAdapter, + OpaCliPolicyValidationAdapter, +} from '@beyondnet/evolith-agent-runtime'; +import { createRuntimeFromEnv, resolveProfile } from './runtime.factory'; + +/** + * GT-438 — the factory's profile selection matrix. A single switch + * (`AGENT_RUNTIME_PROFILE`) governs the whole adapter set: dev keeps the + * deterministic stubs + in-memory state; production mandates the real adapters + * and FAILS LOUD instead of silently degrading to a stub. + */ +describe('createRuntimeFromEnv — profile selection matrix (GT-438)', () => { + const PROD_CORE = { + AGENT_RUNTIME_CORE_ENDPOINT: 'https://core.example/api/v1/evaluate', + AGENT_RUNTIME_CORE_TOKEN: 'prod-token', + AGENT_RUNTIME_STATE_DIR: '/var/lib/evolith-runtime', + } as const; + + describe('profile resolution', () => { + it('defaults to dev when AGENT_RUNTIME_PROFILE is unset', () => { + expect(resolveProfile({})).toBe('dev'); + }); + + it.each(['dev', 'development', 'local', 'test', 'DEV'])('maps %s to dev', (v) => { + expect(resolveProfile({ AGENT_RUNTIME_PROFILE: v })).toBe('dev'); + }); + + it.each(['production', 'prod', 'PRODUCTION'])('maps %s to production', (v) => { + expect(resolveProfile({ AGENT_RUNTIME_PROFILE: v })).toBe('production'); + }); + + it('throws on an unrecognized profile (no silent downgrade)', () => { + expect(() => resolveProfile({ AGENT_RUNTIME_PROFILE: 'prd' })).toThrow(/unknown AGENT_RUNTIME_PROFILE/); + }); + }); + + describe('dev profile ⇒ stubs + in-memory (explicit default)', () => { + it('wires the stub Core, stub engine and in-memory state', () => { + const { deps } = createRuntimeFromEnv({}); + expect(deps.coreEvaluation).toBeInstanceOf(StubCoreEvaluationAdapter); + expect(deps.engine).toBeInstanceOf(StubAgentEngineAdapter); + expect(deps.memory).toBeInstanceOf(InMemoryMemoryAdapter); + // Policy stays real OPA by default even under dev (GT-412), opt into the stub. + expect(deps.policy).toBeInstanceOf(OpaCliPolicyValidationAdapter); + }); + + it('uses the stub policy only when explicitly requested', () => { + const { deps } = createRuntimeFromEnv({ AGENT_RUNTIME_POLICY_MODE: 'stub' }); + expect(deps.policy).toBeInstanceOf(StubPolicyValidationAdapter); + }); + + it('still graduates the Core port when the endpoint is set (endpoint-driven, backward compatible)', () => { + const { deps } = createRuntimeFromEnv({ + AGENT_RUNTIME_CORE_ENDPOINT: 'https://core.example/api/v1/evaluate', + }); + expect(deps.coreEvaluation).toBeInstanceOf(HttpCoreEvaluationAdapter); + // Token stays optional under dev. + expect(deps.memory).toBeInstanceOf(InMemoryMemoryAdapter); + }); + }); + + describe('production profile + endpoint set ⇒ real adapters', () => { + it('wires the HTTP Core, durable memory and real OPA', () => { + const { deps } = createRuntimeFromEnv({ + AGENT_RUNTIME_PROFILE: 'production', + ...PROD_CORE, + }); + expect(deps.coreEvaluation).toBeInstanceOf(HttpCoreEvaluationAdapter); + expect(deps.memory).toBeInstanceOf(FileMemoryAdapter); + expect(deps.policy).toBeInstanceOf(OpaCliPolicyValidationAdapter); + // No engine configured ⇒ stub stays (GT-385-gated) even under production. + expect(deps.engine).toBeInstanceOf(StubAgentEngineAdapter); + }); + }); + + describe('production profile ⇒ fail loud (no silent stub fallback)', () => { + it('throws when AGENT_RUNTIME_CORE_ENDPOINT is missing', () => { + expect(() => createRuntimeFromEnv({ AGENT_RUNTIME_PROFILE: 'production' })).toThrow( + /requires AGENT_RUNTIME_CORE_ENDPOINT/, + ); + }); + + it('throws when the Core endpoint is set but the token is missing', () => { + expect(() => + createRuntimeFromEnv({ + AGENT_RUNTIME_PROFILE: 'production', + AGENT_RUNTIME_CORE_ENDPOINT: 'https://core.example/api/v1/evaluate', + AGENT_RUNTIME_STATE_DIR: '/var/lib/evolith-runtime', + }), + ).toThrow(/requires AGENT_RUNTIME_CORE_TOKEN/); + }); + + it('throws when durable state dir is missing', () => { + expect(() => + createRuntimeFromEnv({ + AGENT_RUNTIME_PROFILE: 'production', + AGENT_RUNTIME_CORE_ENDPOINT: 'https://core.example/api/v1/evaluate', + AGENT_RUNTIME_CORE_TOKEN: 'prod-token', + }), + ).toThrow(/requires AGENT_RUNTIME_STATE_DIR/); + }); + + it('refuses an explicit stub policy under production', () => { + expect(() => + createRuntimeFromEnv({ + AGENT_RUNTIME_PROFILE: 'production', + ...PROD_CORE, + AGENT_RUNTIME_POLICY_MODE: 'stub', + }), + ).toThrow(/stub policy validation is refused/); + }); + }); + + describe('engine selection by config (GT-385, where available)', () => { + it('wires Hermes when AGENT_RUNTIME_ENGINE=hermes', () => { + const { deps } = createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'hermes' }); + expect(deps.engine).toBeInstanceOf(HermesAgentAdapter); + }); + + it('wires Swarms when AGENT_RUNTIME_ENGINE=swarms', () => { + const { deps } = createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'swarms' }); + expect(deps.engine).toBeInstanceOf(SwarmsAgentAdapter); + }); + + it('wires Cowork when AGENT_RUNTIME_ENGINE=cowork', () => { + const { deps } = createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'cowork' }); + expect(deps.engine).toBeInstanceOf(CoworkAgentEngineAdapter); + }); + + it('wires a Router from AGENT_RUNTIME_ENGINE_ROUTER JSON', () => { + const { deps } = createRuntimeFromEnv({ + AGENT_RUNTIME_ENGINE: 'routing', + AGENT_RUNTIME_ENGINE_ROUTER: JSON.stringify({ defaultEngine: 'stub', routes: [] }), + }); + expect(deps.engine).toBeInstanceOf(RoutingAgentAdapter); + }); + + it('throws for routing without a router config', () => { + expect(() => createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'routing' })).toThrow( + /requires AGENT_RUNTIME_ENGINE_ROUTER/, + ); + }); + + it('throws for invalid router JSON', () => { + expect(() => + createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'routing', AGENT_RUNTIME_ENGINE_ROUTER: '{not json' }), + ).toThrow(/not valid JSON/); + }); + + it('throws for an unknown engine name', () => { + expect(() => createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'gpt' })).toThrow(/unknown AGENT_RUNTIME_ENGINE/); + }); + + it("treats 'stub' as the deterministic default", () => { + const { deps } = createRuntimeFromEnv({ AGENT_RUNTIME_ENGINE: 'stub' }); + expect(deps.engine).toBeInstanceOf(StubAgentEngineAdapter); + }); + }); +}); diff --git a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts index 74256d40e..1338c89a7 100644 --- a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts +++ b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts @@ -1,7 +1,30 @@ /** - * Builds the Agent Runtime from environment variables. Policy enforcement is - * real OPA by default; stub policy mode must be requested explicitly for local - * offline/test deployments. + * Builds the Agent Runtime from environment variables. + * + * ── Production vs dev profile (GT-438) ─────────────────────────────────────── + * A single switch — `AGENT_RUNTIME_PROFILE` — governs the whole adapter set: + * + * dev (default, and `test`/`development`/`local`) + * The deterministic stubs + in-memory state are the explicit default, so + * the runtime boots offline with no live Core, no engine and no volume. + * Any real adapter is still opt-in per env var (endpoint, engine, …). + * + * production + * The real adapters are mandatory and the factory FAILS LOUD instead of + * silently degrading to a stub: + * · Core evaluation MUST be the real HTTP Core — `AGENT_RUNTIME_CORE_ENDPOINT` + * (+ `AGENT_RUNTIME_CORE_TOKEN`, since the Core API is authenticated). + * · Working memory MUST be durable — `AGENT_RUNTIME_STATE_DIR` on a mounted + * volume (the same dir also backs the durable `FileSchedulerAdapter` + * driven by the host scheduling loop). + * · Policy validation MUST be real OPA — an explicit stub request is refused. + * The reasoning engine (Hermes/Swarms/routing/Cowork) is wired by + * `AGENT_RUNTIME_ENGINE` where a client is available; it stays GT-385-gated, + * so an unset engine keeps the deterministic stub in EVERY profile. + * + * Regardless of profile, setting a real adapter's env var always graduates that + * one port (e.g. `AGENT_RUNTIME_CORE_ENDPOINT` under dev still uses the HTTP Core), + * which keeps the endpoint-driven local-kind wiring backward compatible. */ import { @@ -15,8 +38,12 @@ import { FileTrackerTraceAdapter, HttpCoreEvaluationAdapter, FileMemoryAdapter, + HermesAgentAdapter, + SwarmsAgentAdapter, + CoworkAgentEngineAdapter, type AgentRuntimeBundle, type AgentRuntimeOverrides, + type EngineRouterConfig, } from '@beyondnet/evolith-agent-runtime'; import * as path from 'node:path'; @@ -24,13 +51,41 @@ import { trace } from '@opentelemetry/api'; export const AGENT_RUNTIME_BUNDLE = 'AGENT_RUNTIME_BUNDLE'; +export type RuntimeProfile = 'production' | 'dev'; + function bool(value: string | undefined): boolean { return value === '1' || value === 'true'; } +/** + * Resolve the deployment profile from `AGENT_RUNTIME_PROFILE`. Unset (and the + * dev-family aliases) keep the safe stub default; an unrecognized value fails + * loud so a typo never silently downgrades a production deployment to dev. + */ +export function resolveProfile(env: NodeJS.ProcessEnv = process.env): RuntimeProfile { + const raw = (env.AGENT_RUNTIME_PROFILE ?? '').trim().toLowerCase(); + switch (raw) { + case 'production': + case 'prod': + return 'production'; + case '': + case 'dev': + case 'development': + case 'local': + case 'test': + return 'dev'; + default: + throw new Error( + `[agent-runtime] unknown AGENT_RUNTIME_PROFILE '${env.AGENT_RUNTIME_PROFILE}'. Use 'production' or 'dev'.`, + ); + } +} + /** Read process.env and assemble the runtime bundle. */ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): AgentRuntimeBundle { let overrides: AgentRuntimeOverrides = {}; + const profile = resolveProfile(env); + const isProd = profile === 'production'; // .harness — real process executor when a checkout/corpus is mounted. const harnessRoot = env.AGENT_RUNTIME_HARNESS_ROOT; @@ -46,9 +101,16 @@ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): Agen // OPA — shell out to the bundled binary by default. Stubs are explicit only, // so hosted runtime deployments fail closed instead of silently bypassing - // runtime policy enforcement (GT-412). + // runtime policy enforcement (GT-412). Under the production profile a stub + // request is refused outright (GT-438): production never runs on stub policy. const policyMode = env.AGENT_RUNTIME_POLICY_MODE ?? (env.AGENT_RUNTIME_OPA_ENABLED === '0' || env.AGENT_RUNTIME_OPA_ENABLED === 'false' ? 'stub' : 'opa'); if (policyMode === 'stub') { + if (isProd) { + throw new Error( + '[agent-runtime] stub policy validation is refused under AGENT_RUNTIME_PROFILE=production. ' + + 'Configure real OPA (leave AGENT_RUNTIME_POLICY_MODE unset and AGENT_RUNTIME_OPA_ENABLED unset/true).', + ); + } overrides = { ...overrides, policy: new StubPolicyValidationAdapter(), @@ -66,17 +128,75 @@ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): Agen // Core evaluation — call the real stateless Core over HTTP (Core API // `/api/v1/evaluate`) instead of the deterministic stub. Without this the - // runtime governs over a simulated Core (see GT-384). + // runtime governs over a simulated Core (see GT-384). Under the production + // profile the endpoint (and its token) are MANDATORY: the factory fails loud + // rather than silently governing over the stub Core (GT-438). const coreEndpoint = env.AGENT_RUNTIME_CORE_ENDPOINT; + const coreToken = env.AGENT_RUNTIME_CORE_TOKEN; if (coreEndpoint) { + if (isProd && !coreToken) { + throw new Error( + '[agent-runtime] AGENT_RUNTIME_PROFILE=production requires AGENT_RUNTIME_CORE_TOKEN alongside ' + + 'AGENT_RUNTIME_CORE_ENDPOINT — the real Core API is authenticated and must not be called unauthenticated.', + ); + } const headers: Record = {}; - if (env.AGENT_RUNTIME_CORE_TOKEN) { - headers.authorization = `Bearer ${env.AGENT_RUNTIME_CORE_TOKEN}`; + if (coreToken) { + headers.authorization = `Bearer ${coreToken}`; } overrides = { ...overrides, coreEvaluation: new HttpCoreEvaluationAdapter({ endpoint: coreEndpoint, headers }), }; + } else if (isProd) { + throw new Error( + '[agent-runtime] AGENT_RUNTIME_PROFILE=production requires AGENT_RUNTIME_CORE_ENDPOINT ' + + '(the real Core API /api/v1/evaluate). Refusing to fall back to StubCoreEvaluationAdapter in production.', + ); + } + + // Reasoning engine — Hermes/Swarms/Cowork/routing behind IAgentEnginePort, + // wired by AGENT_RUNTIME_ENGINE where a client/module is available (GT-385). + // Unset (or 'stub') keeps the deterministic StubAgentEngineAdapter in EVERY + // profile: the real engine is decision-gated, so production does not fail loud + // on it — it graduates only once an engine is genuinely configured. + const engineName = (env.AGENT_RUNTIME_ENGINE ?? '').trim().toLowerCase(); + if (engineName && engineName !== 'stub') { + switch (engineName) { + case 'hermes': + overrides = { ...overrides, engine: new HermesAgentAdapter() }; + break; + case 'swarms': + overrides = { ...overrides, engine: new SwarmsAgentAdapter() }; + break; + case 'cowork': + overrides = { ...overrides, engine: new CoworkAgentEngineAdapter() }; + break; + case 'routing': { + const rawRouter = env.AGENT_RUNTIME_ENGINE_ROUTER; + if (!rawRouter) { + throw new Error( + "[agent-runtime] AGENT_RUNTIME_ENGINE=routing requires AGENT_RUNTIME_ENGINE_ROUTER " + + '(a JSON EngineRouterConfig: {"defaultEngine":"stub","routes":[...]}).', + ); + } + let routerConfig: EngineRouterConfig; + try { + routerConfig = JSON.parse(rawRouter) as EngineRouterConfig; + } catch (err) { + throw new Error( + `[agent-runtime] AGENT_RUNTIME_ENGINE_ROUTER is not valid JSON: ${(err as Error).message}`, + ); + } + overrides = { ...overrides, engineRouterConfig: routerConfig }; + break; + } + default: + throw new Error( + `[agent-runtime] unknown AGENT_RUNTIME_ENGINE '${env.AGENT_RUNTIME_ENGINE}'. ` + + "Use 'hermes', 'swarms', 'cowork', 'routing' or 'stub'.", + ); + } } // Tracker — publish trazability events to multiple destinations (Tracker HTTP, File JSONL, OTel). @@ -111,15 +231,23 @@ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): Agen } // Durable state — persist the runtime's working memory to disk so it survives - // a restart (GT-386). Point at a mounted volume in production; unset keeps the - // volatile in-memory default (tests, first boot). The scheduler is not part of - // the runtime bundle — a host scheduling loop drives `FileSchedulerAdapter`. + // a restart (GT-386). Point at a mounted volume in production; under dev an + // unset dir keeps the volatile in-memory default (tests, first boot). Under + // the production profile a durable dir is MANDATORY (GT-438): the factory + // fails loud rather than running production on volatile memory. The same dir + // also backs the durable `FileSchedulerAdapter` driven by the host scheduling + // loop (the scheduler is not part of the runtime bundle). const stateDir = env.AGENT_RUNTIME_STATE_DIR; if (stateDir) { overrides = { ...overrides, memory: new FileMemoryAdapter({ filePath: path.join(stateDir, 'memory.json') }), }; + } else if (isProd) { + throw new Error( + '[agent-runtime] AGENT_RUNTIME_PROFILE=production requires AGENT_RUNTIME_STATE_DIR ' + + '(durable memory + scheduler state on a mounted volume). Refusing to run production on volatile in-memory state.', + ); } return createAgentRuntime(overrides); From af97a14ce1091320f79b9046ec500b882895e835 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 13 Jul 2026 12:17:05 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(infra-providers):=20Lighthouse=20refer?= =?UTF-8?q?ence=20evidence=20adapter=20(GT-534=20=C2=B7=20ADR-0113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renumbered companion ADR 0112→0113 to avoid collision with the concurrently landed ADR-0112 (RAG embedding/vector-store). First concrete provider behind the GT-533 Quality Signal seam; deterministic Evidence + full provenance. Co-Authored-By: Claude Opus 4.8 --- ...3-nodejs-lighthouse-evidence-adapter.es.md | 162 +++++++++++ ...0113-nodejs-lighthouse-evidence-adapter.md | 151 ++++++++++ .../core/architecture/adrs/core/README.es.md | 1 + .../core/architecture/adrs/core/README.md | 1 + src/packages/infra-providers/src/index.ts | 16 ++ .../src/lighthouse-evidence.provider.spec.ts | 172 +++++++++++ .../src/lighthouse-evidence.provider.ts | 272 ++++++++++++++++++ 7 files changed, 775 insertions(+) create mode 100644 reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.es.md create mode 100644 reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.md create mode 100644 src/packages/infra-providers/src/lighthouse-evidence.provider.spec.ts create mode 100644 src/packages/infra-providers/src/lighthouse-evidence.provider.ts diff --git a/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.es.md b/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.es.md new file mode 100644 index 000000000..64796a7c7 --- /dev/null +++ b/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.es.md @@ -0,0 +1,162 @@ +> **Navegación bilingüe:** [Read English version](./0113-nodejs-lighthouse-evidence-adapter.md) + +# ADR-0113: Plataforma Node.js — Lighthouse (Apache-2.0) como adaptador de evidencia de referencia + +> **Firma del Agente:** Agente Arquitecto (Winston) + +## Estado +Propuesto (2026-07-13 — Comité de Arquitectura) + +## Fecha +2026-07-13 + +## Contexto y Problema + +La [ADR-0111](./0111-quality-signal-provider-port.es.md) estableció la costura de +Proveedores de Señales de Calidad: las herramientas externas de calidad/evidencia +alimentan a Evolith Core a través de un único puerto guiado `IQualitySignalProvider` +y un modelo `Evidence` canónico con procedencia obligatoria, y el Core nunca +ejecuta un proveedor. Esa ADR dejó deliberadamente las implementaciones concretas +de adaptadores y sus elecciones de proveedor/runtime a una **ADR de Plataforma +acompañante** — esta. + +Para probar la costura de extremo a extremo (GT-534) necesitamos un primer +proveedor concreto. Esa elección es una decisión de plataforma: fija un runtime +(Chrome headless), un lenguaje/sistema de módulos (un módulo Node.js) y una +herramienta de terceros con su propia licencia. Esas consecuencias merecen su +propia decisión registrada en vez de quedar enterradas en un archivo de adaptador. + +El problema: **¿qué herramienta y runtime concretos adoptamos para el primer +adaptador de evidencia, de modo que valide el puerto sin volverse dependencia dura +de la suite, sin riesgo de licencia y produciendo salida determinista y +normalizable?** + +## Objetivo y Alcance + +Registrar la elección concreta de proveedor/runtime para el primer adaptador +detrás de `IQualitySignalProvider`. En alcance: la herramienta (Lighthouse), su +licencia (Apache-2.0), el runtime que implica (Node.js + Chrome headless), el +límite de módulo que la mantiene opcional y el contrato de normalización que debe +respetar. Fuera de alcance: el diseño del puerto/registro (propiedad de la +ADR-0111) y los adaptadores futuros (TestSprite, rúbrica de revisión estructural, +scorecards GEO — cada uno con su propio registro si introduce un nuevo compromiso +de plataforma). + +## Opciones Consideradas + +### Opción A: Lighthouse como módulo Node embebido (elegida) + +Ejecutar Google Lighthouse vía su API programática de Node contra una URL +desplegada, consumir su resultado JSON (LHR) y mapearlo a `Evidence` canónica. +Elegida: Lighthouse es **Apache-2.0** (permisiva, sin copyleft, sin puerta +comercial), un auditor maduro y ampliamente confiable, con un módulo Node +embebible de salida JSON pura, y es **determinista** — el mejor encaje con la +clase `determinism: 'deterministic'` de la costura y la forma de menor riesgo de +probar el puerto. + +### Opción B: Ejecutar Lighthouse vía su CLI / la API alojada PageSpeed Insights + +Invocar el CLI `lighthouse`, o llamar a la API alojada de PageSpeed Insights. +Rechazada: el CLI añade gestión de procesos y parseo sin ganancia frente al módulo +Node; la API alojada introduce dependencia de red, cuotas/llaves y egreso de datos +a un tercero — justo el acoplamiento que la ADR-0111 existe para evitar. + +### Opción C: Otro auditor (WebPageTest, Sitespeed.io, un SaaS) + +Rechazada para el adaptador *de referencia*: mayor superficie de runtime/licencia +o una nube propietaria. Siguen siendo válidos como adaptadores *adicionales* +detrás del mismo puerto más adelante — el punto de la ADR-0111 es que la elección +es descartable. + +## Decisión y Justificación + +1. **Herramienta y licencia.** Adoptar **Lighthouse (Apache-2.0)** como proveedor + de evidencia de referencia. La licencia permisiva no impone copyleft ni riesgo + de re-licenciamiento comercial (contrasta con la situación de MassTransit v9 en + la ADR-0110); Lighthouse es ilustrativo, no estructural (prueba de fuego de la + ADR-0111). + +2. **Runtime.** El adaptador corre sobre **Node.js** y requiere **Chrome + headless** en tiempo de ejecución. Es un compromiso de *runtime* (el paso de + recolección en la capa de orquestación), nunca una dependencia de diseño ni del + Core. + +3. **Límite de módulo — opcional, importado perezosamente.** El adaptador vive en + `@beyondnet/evolith-infra-providers` e importa SOLO las formas canónicas de + `Evidence` desde `core-domain`. `lighthouse` y `chrome-launcher` **no** son + dependencias declaradas del paquete; el runner por defecto las importa + *dinámicamente* para que el paquete compile e instale sin ninguna presente + (ADR-0111 §5 — ninguna herramienta externa es jamás dependencia dura). + +4. **Costura de testeabilidad.** La corrida real de Chrome headless queda detrás + de un puerto `LighthouseRunner` inyectado. Los tests unitarios inyectan un LHR + simulado, así la suite corre sin Chrome y sin red. Una corrida real necesita + Chrome + una URL desplegada y es un asunto de runtime. + +5. **Contrato de normalización.** El adaptador mapea cada categoría de Lighthouse + (`performance` → `performance`, `accessibility` → `a11y`, `best-practices`, + `seo`) a una métrica `0..100` Y a un `EvidenceFinding` cuya severidad se deriva + deterministamente del puntaje de la categoría. Emite + `determinism: 'deterministic'` y **procedencia completa y obligatoria** + (`collectedBy: 'lighthouse'`, `adapterVersion`, un `artifactHash` SHA-256 del + LHR y un `timestamp` tomado del `fetchTime` del LHR), vía `normalizeEvidence`. + +6. **Sin inversión de dependencia.** El puerto `IQualitySignalProvider` es + propiedad de la capa de orquestación (agent-runtime). Importarlo en un paquete + de borde de infraestructura invertiría la dirección de dependencia (infra → + orquestación), por lo que el adaptador conforma al puerto **estructuralmente** + (una interfaz espejada en el paquete) y el runtime registra la instancia. La + conformidad estructural con el puerto real se verifica en tiempo de build. + +## Evidencia y Criterios de Evaluación + +- **Chequeo de licencia**: Lighthouse es Apache-2.0 — permisiva, sublicenciable, + sin puerta comercial (verificar contra el `LICENSE` upstream). +- **Determinismo**: el adaptador emite `determinism: 'deterministic'`; un LHR fijo + produce `Evidence` idéntica (mismas métricas, findings y `artifactHash`). +- **Procedencia**: toda `Evidence` emitida lleva una `Provenance` completa + (obligatoria por ADR-0111 §6), impuesta por `normalizeEvidence`. +- **Pureza del borde**: `grep` no muestra `lighthouse`/`chrome-launcher` en las + dependencias declaradas del paquete; solo se importan dinámicamente. +- **Statelessness preservado**: el Core importa solo `Evidence`; ningún import de + adaptador o proveedor alcanza `core-domain` (criterio de límite ADR-0101 / + ADR-0111). + +## Consecuencias, Riesgos y Compromisos + +Positivo: prueba la costura de la ADR-0111 de extremo a extremo con una dimensión +de evidencia real y determinista; la licencia permisiva elimina el riesgo legal; +la costura del runner inyectado mantiene herméticos los tests unitarios; el +proveedor queda descartable y seleccionable por tenant. + +Negativo / compromisos: una corrida real necesita una imagen de runtime con Chrome +headless y una URL desplegada (un costo operativo, no de diseño); los puntajes de +Lighthouse pueden variar de corrida a corrida en un objetivo vivo por varianza de +red/CPU aunque la clase de herramienta sea determinista — los consumidores deben +tratar una corrida única como una muestra puntual y pueden promediar entre +corridas. Riesgo: deriva de versión de Chrome/Lighthouse cambiando ids de +categoría o semántica de puntaje (mitigado por el adaptador versionado y el +`artifactHash` de procedencia). + +## Referencias + +- Lighthouse — motor de auditoría de runtime (Apache-2.0), API programática de + Node con salida JSON (LHR): +- Licencia de Lighthouse (Apache-2.0): + +- `chrome-launcher` — lanzador de Chrome headless usado por el runner por defecto: + + +## Decisiones y Estándares Relacionados + +- [ADR-0111](./0111-quality-signal-provider-port.es.md) — el puerto de Proveedores + de Señales de Calidad + la `Evidence` canónica que este adaptador implementa + (decisión padre). +- [ADR-0101](./0101-core-stateless-evaluation-engine.es.md) — Core stateless; el + adaptador corre en orquestación, nunca dentro del evaluador. +- [ADR-0110](./0110-masstransit-v8-apache-license-pin.es.md) — precedente de + tratar la licencia de una dependencia estructural como asunto arquitectónico + (aquí lo opuesto: una dependencia *opcional* con licencia permisiva). +- [ADR-0104](./0104-topology-driven-advisory-design-governance.es.md) — deriva los + criterios que esta evidencia de runtime confirma o refuta (el lazo de + conformidad). diff --git a/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.md b/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.md new file mode 100644 index 000000000..9f2b982ee --- /dev/null +++ b/reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.md @@ -0,0 +1,151 @@ +> **Bilingual Navigation:** [Ver versión en Español](./0113-nodejs-lighthouse-evidence-adapter.es.md) + +# ADR-0113: Node.js Platform — Lighthouse (Apache-2.0) as the Reference Evidence Adapter + +> **Agent Signature:** Architect Agent (Winston) + +## Status +Proposed (2026-07-13 — Architecture Board) + +## Date +2026-07-13 + +## Context and Problem + +[ADR-0111](./0111-quality-signal-provider-port.md) established the Quality Signal +Provider seam: external quality/evidence tools feed Evolith Core through a single +driven port `IQualitySignalProvider` and a canonical, provenance-stamped +`Evidence` model, and the Core never executes a provider. That ADR deliberately +left the concrete adapter implementations and their vendor/runtime choices to a +**companion Platform ADR** — this one. + +To prove the seam end-to-end (GT-534) we need a first concrete provider. The +choice of that provider is a platform decision: it fixes a runtime (headless +Chrome), a language/module system (a Node.js module), and a third-party tool with +its own license. Those consequences deserve their own recorded decision rather +than being buried in an adapter file. + +The problem: **which concrete tool and runtime do we adopt for the first evidence +adapter, such that it validates the port without becoming a hard dependency of the +suite, carries no license risk, and produces deterministic, normalizable output?** + +## Objective and Scope + +Record the concrete vendor/runtime choice for the first adapter behind +`IQualitySignalProvider`. In scope: the tool (Lighthouse), its license +(Apache-2.0), the runtime it implies (Node.js + headless Chrome), the module +boundary that keeps it optional, and the normalization contract it must honor. +Out of scope: the port/registry design (owned by ADR-0111) and future adapters +(TestSprite, structural-review rubric, GEO scorecards — each gets its own record +if it introduces a new platform commitment). + +## Options Considered + +### Option A: Lighthouse as an embedded Node module (chosen) + +Run Google Lighthouse via its programmatic Node API against a deployed URL, +consume its JSON result (LHR), and map it to canonical `Evidence`. Chosen: +Lighthouse is **Apache-2.0** (permissive, no copyleft, no commercial gate), a +mature and widely trusted auditor, ships an embeddable Node module with pure JSON +output, and is **deterministic** — the strongest fit for the seam's +`determinism: 'deterministic'` class and the lowest-risk way to prove the port. + +### Option B: Drive Lighthouse via its CLI / a hosted PageSpeed Insights API + +Shell out to the `lighthouse` CLI, or call the hosted PageSpeed Insights API. +Rejected: the CLI adds process-management and parsing overhead for no gain over +the Node module; the hosted API introduces a network dependency, quota/keys, and +data egress to a third party — the exact coupling ADR-0111 exists to avoid. + +### Option C: A different auditor (WebPageTest, Sitespeed.io, a SaaS) + +Rejected for the *reference* adapter: either heavier runtime/licensing surface or +a proprietary cloud. They remain perfectly valid as *additional* adapters behind +the same port later — the point of ADR-0111 is that the choice is disposable. + +## Decision and Rationale + +1. **Tool & license.** Adopt **Lighthouse (Apache-2.0)** as the reference + evidence vendor. The permissive license means no copyleft obligation and no + commercial re-licensing risk (contrast ADR-0110's MassTransit v9 situation); + Lighthouse is illustrative, not load-bearing (ADR-0111 litmus test). + +2. **Runtime.** The adapter runs on **Node.js** and requires a **headless + Chrome** at execution time. This is a *runtime* commitment (the collection + step in the orchestration layer), never a design-time or Core dependency. + +3. **Module boundary — optional, lazily imported.** The adapter lives in + `@beyondnet/evolith-infra-providers` and imports ONLY the canonical `Evidence` + shapes from `core-domain`. `lighthouse` and `chrome-launcher` are **not** + declared dependencies of the package; the default runner imports them + *dynamically* so the package builds and installs with neither present + (ADR-0111 §5 — no external tool is ever a hard dependency). + +4. **Testability seam.** The real headless-Chrome run sits behind an injected + `LighthouseRunner` port. Unit tests inject a stubbed LHR, so the suite runs + with no Chrome and no network. A live run needs Chrome + a deployed URL and is + a runtime concern. + +5. **Normalization contract.** The adapter maps each Lighthouse category + (`performance` → `performance`, `accessibility` → `a11y`, `best-practices`, + `seo`) to a `0..100` metric AND an `EvidenceFinding` whose severity is derived + deterministically from the category score. It emits + `determinism: 'deterministic'` and **full, mandatory provenance** + (`collectedBy: 'lighthouse'`, `adapterVersion`, a SHA-256 `artifactHash` of the + LHR, and a `timestamp` taken from the LHR `fetchTime`), via + `normalizeEvidence`. + +6. **No dependency inversion.** The `IQualitySignalProvider` port is owned by the + orchestration layer (agent-runtime). Importing it into an infra-edge package + would invert the dependency direction (infra → orchestration), so the adapter + conforms to the port **structurally** (a mirrored interface in-package) and the + runtime registers the instance. Structural conformance to the real port is + verified at build time. + +## Evidence and Evaluation Criteria + +- **License check**: Lighthouse is Apache-2.0 — permissive, sublicensable, + no commercial gate (verify against the upstream `LICENSE`). +- **Determinism**: the adapter emits `determinism: 'deterministic'`; a fixed LHR + yields identical `Evidence` (same metrics, findings and `artifactHash`). +- **Provenance**: every emitted `Evidence` carries a complete `Provenance` + (mandatory per ADR-0111 §6), enforced by `normalizeEvidence`. +- **Purity of the edge**: `grep` shows no `lighthouse`/`chrome-launcher` in the + package's declared dependencies; they are dynamically imported only. +- **Statelessness preserved**: the Core imports only `Evidence`; no adapter or + vendor import reaches `core-domain` (ADR-0101 / ADR-0111 boundary criterion). + +## Consequences, Risks, and Trade-offs + +Positive: proves the ADR-0111 seam end-to-end with a real, deterministic evidence +dimension; permissive licensing removes legal risk; the injected-runner seam keeps +unit tests hermetic; the vendor stays disposable and tenant-selectable. + +Negative / trade-offs: a live run needs a headless-Chrome-capable runtime image +and a deployed URL (an operational cost, not a design one); Lighthouse scores can +vary run-to-run on a live target due to network/CPU variance even though the tool +class is deterministic — consumers should treat a single run as a point sample and +may average across runs. Risk: Chrome/Lighthouse version drift changing category +ids or score semantics (mitigated by the versioned adapter and provenance +`artifactHash`). + +## References + +- Lighthouse — runtime auditing engine (Apache-2.0), Node programmatic API with + JSON (LHR) output: +- Lighthouse license (Apache-2.0): + +- `chrome-launcher` — headless Chrome launcher used by the default runner: + + +## Related Decisions and Standards + +- [ADR-0111](./0111-quality-signal-provider-port.md) — the Quality Signal + Provider port + canonical `Evidence` this adapter implements (parent decision). +- [ADR-0101](./0101-core-stateless-evaluation-engine.md) — stateless Core; the + adapter runs in orchestration, never inside the evaluator. +- [ADR-0110](./0110-masstransit-v8-apache-license-pin.md) — precedent for treating + a load-bearing dependency's license as an architectural concern (here the + opposite: an *optional* dependency with a permissive license). +- [ADR-0104](./0104-topology-driven-advisory-design-governance.md) — derives the + criteria this runtime evidence confirms or refutes (the conformance loop). diff --git a/reference/core/architecture/adrs/core/README.es.md b/reference/core/architecture/adrs/core/README.es.md index 830a48c75..af302786f 100644 --- a/reference/core/architecture/adrs/core/README.es.md +++ b/reference/core/architecture/adrs/core/README.es.md @@ -89,6 +89,7 @@ * [0110-masstransit-v8-apache-license-pin](./0110-masstransit-v8-apache-license-pin.es.md) — **Permanecer en MassTransit v8 (Apache-2.0); v9 es comercial y no sublicenciable** * [0111-quality-signal-provider-port](./0111-quality-signal-provider-port.es.md) — **Puerto de Proveedores de Señales de Calidad — la evidencia externa (Lighthouse, TestSprite, …) entra vía adaptadores y `Evidence` canónica; el Core nunca depende de ninguna herramienta** * [0112-rag-embedding-and-vector-store-platform](./0112-rag-embedding-and-vector-store-platform.es.md) — **Plataforma de embeddings y vector store para RAG — Qwen3-Embedding (Apache-2.0) totalmente OSS/self-hosted sobre pgvector; realiza el contrato agnóstico al modelo del ADR-0090, cero egress del corpus** +* [0113-nodejs-lighthouse-evidence-adapter](./0113-nodejs-lighthouse-evidence-adapter.es.md) — **Plataforma Node.js — Lighthouse (Apache-2.0) como adaptador de evidencia de referencia detrás del puerto de Proveedores de Señales de Calidad; opcional, importado dinámicamente, determinista** --- [Volver al Nivel Superior](../README.es.md) diff --git a/reference/core/architecture/adrs/core/README.md b/reference/core/architecture/adrs/core/README.md index c5fc1dd43..4db2ee9d7 100644 --- a/reference/core/architecture/adrs/core/README.md +++ b/reference/core/architecture/adrs/core/README.md @@ -89,6 +89,7 @@ * [0110-masstransit-v8-apache-license-pin](./0110-masstransit-v8-apache-license-pin.md) — **Stay on MassTransit v8 (Apache-2.0); v9 is commercial and non-sublicensable** * [0111-quality-signal-provider-port](./0111-quality-signal-provider-port.md) — **Quality Signal Provider port — external evidence (Lighthouse, TestSprite, …) enters via adapters and canonical `Evidence`; Core never depends on any tool** * [0112-rag-embedding-and-vector-store-platform](./0112-rag-embedding-and-vector-store-platform.md) — **RAG embedding & vector-store platform — fully OSS/self-hosted Qwen3-Embedding (Apache-2.0) on pgvector; realizes ADR-0090's model-agnostic contract, zero corpus egress** +* [0113-nodejs-lighthouse-evidence-adapter](./0113-nodejs-lighthouse-evidence-adapter.md) — **Node.js Platform — Lighthouse (Apache-2.0) as the reference evidence adapter behind the Quality Signal Provider port; optional, dynamically imported, deterministic** --- [Back to Upper Level](../README.md) diff --git a/src/packages/infra-providers/src/index.ts b/src/packages/infra-providers/src/index.ts index 734ad60ff..f1ac2e2ad 100644 --- a/src/packages/infra-providers/src/index.ts +++ b/src/packages/infra-providers/src/index.ts @@ -30,3 +30,19 @@ export { LangfuseEvidenceAdapter } from './langfuse-evidence.adapter'; export type { LangfuseHttpClient } from './langfuse-evidence.adapter'; export { fetchBlueprintOwnership } from './idp-blueprint.adapter'; export type { BlueprintHttpClient } from './idp-blueprint.adapter'; +export { + LighthouseEvidenceProvider, + createHeadlessChromeRunner, + severityForScore, + LIGHTHOUSE_PROVIDER_ID, + LIGHTHOUSE_ADAPTER_VERSION, +} from './lighthouse-evidence.provider'; +export type { + IQualitySignalProvider, + CollectionContext, + CollectionTarget, + LighthouseRunner, + LighthouseRunResult, + LighthouseCategoryResult, + LighthouseEvidenceProviderOptions, +} from './lighthouse-evidence.provider'; diff --git a/src/packages/infra-providers/src/lighthouse-evidence.provider.spec.ts b/src/packages/infra-providers/src/lighthouse-evidence.provider.spec.ts new file mode 100644 index 000000000..2f43d7b95 --- /dev/null +++ b/src/packages/infra-providers/src/lighthouse-evidence.provider.spec.ts @@ -0,0 +1,172 @@ +import { + LighthouseEvidenceProvider, + LIGHTHOUSE_PROVIDER_ID, + LIGHTHOUSE_ADAPTER_VERSION, + severityForScore, + type CollectionContext, + type LighthouseRunResult, + type LighthouseRunner, +} from './lighthouse-evidence.provider'; + +/** A deterministic stub LHR so unit tests never touch Chrome. */ +function stubLhr(overrides: Partial = {}): LighthouseRunResult { + return { + lighthouseVersion: '12.0.0', + requestedUrl: 'https://example.test/', + finalUrl: 'https://example.test/', + fetchTime: '2026-07-13T10:00:00.000Z', + categories: { + performance: { id: 'performance', title: 'Performance', score: 0.98 }, + accessibility: { id: 'accessibility', title: 'Accessibility', score: 0.72 }, + 'best-practices': { id: 'best-practices', title: 'Best Practices', score: 0.4 }, + seo: { id: 'seo', title: 'SEO', score: 1 }, + pwa: { id: 'pwa', title: 'PWA', score: 0.1 }, + }, + ...overrides, + }; +} + +/** A runner backed by a fixed LHR — the whole point of the injected-runner seam. */ +function stubRunner(lhr: LighthouseRunResult): LighthouseRunner { + return { run: async () => lhr }; +} + +const CTX: CollectionContext = { tenantId: 'tenant-1', dimension: 'performance' }; +const TARGET = { url: 'https://example.test/' }; +const FIXED_NOW = () => '2026-01-01T00:00:00.000Z'; + +describe('LighthouseEvidenceProvider', () => { + it('exposes the stable registry id', () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()) }); + expect(provider.id).toBe(LIGHTHOUSE_PROVIDER_ID); + expect(provider.id).toBe('lighthouse'); + }); + + describe('supports', () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()) }); + + it.each(['performance', 'a11y', 'accessibility', 'seo', 'best-practices', 'web-quality'])( + 'serves the runtime web-quality dimension %s', + (dimension) => { + expect(provider.supports({ tenantId: 't', dimension })).toBe(true); + }, + ); + + it('serves a collection with no declared dimension (a full run covers all)', () => { + expect(provider.supports({ tenantId: 't' })).toBe(true); + }); + + it('does not serve an unrelated dimension', () => { + expect(provider.supports({ tenantId: 't', dimension: 'testing' })).toBe(false); + }); + }); + + describe('collect → normalized Evidence', () => { + it('emits deterministic Evidence with full provenance', async () => { + const provider = new LighthouseEvidenceProvider({ + runner: stubRunner(stubLhr()), + now: FIXED_NOW, + }); + + const evidence = await provider.collect(TARGET, CTX); + + expect(evidence.source).toBe('lighthouse'); + expect(evidence.dimension).toBe('performance'); + expect(evidence.determinism).toBe('deterministic'); + + // Full, mandatory provenance. + expect(evidence.provenance.collectedBy).toBe('lighthouse'); + expect(evidence.provenance.adapterVersion).toBe(LIGHTHOUSE_ADAPTER_VERSION); + expect(evidence.provenance.artifactHash).toMatch(/^[a-f0-9]{64}$/); + // Timestamp comes from the LHR fetchTime, not the clock fallback. + expect(evidence.provenance.timestamp).toBe('2026-07-13T10:00:00.000Z'); + }); + + it('maps each Lighthouse category to a 0..100 metric', async () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()), now: FIXED_NOW }); + const evidence = await provider.collect(TARGET, CTX); + + expect(evidence.metrics).toEqual({ + performance: 98, + a11y: 72, + 'best-practices': 40, + seo: 100, + }); + // 'pwa' is outside the mapped set and is ignored. + expect(evidence.metrics).not.toHaveProperty('pwa'); + }); + + it('maps each category to a finding with severity derived from the score', async () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()), now: FIXED_NOW }); + const evidence = await provider.collect(TARGET, CTX); + + const byCode = Object.fromEntries(evidence.findings.map((f) => [f.code, f])); + + expect(byCode['lighthouse.performance'].severity).toBe('info'); // 0.98 + expect(byCode['lighthouse.a11y'].severity).toBe('medium'); // 0.72 → medium + expect(byCode['lighthouse.best-practices'].severity).toBe('high'); // 0.40 → high + expect(byCode['lighthouse.seo'].severity).toBe('info'); // 1.0 + + // Every finding references the audited URL, never a copy of content. + for (const finding of evidence.findings) { + expect(finding.location).toBe('https://example.test/'); + expect(finding.message).toContain('/100'); + } + }); + + it('is deterministic: same LHR ⇒ identical evidence (hash + shape)', async () => { + const lhr = stubLhr(); + const a = await new LighthouseEvidenceProvider({ runner: stubRunner(lhr), now: FIXED_NOW }).collect(TARGET, CTX); + const b = await new LighthouseEvidenceProvider({ runner: stubRunner(lhr), now: FIXED_NOW }).collect(TARGET, CTX); + expect(a).toEqual(b); + }); + + it('falls back to the injected clock when the LHR has no fetchTime', async () => { + const lhr = stubLhr({ fetchTime: undefined }); + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(lhr), now: FIXED_NOW }); + const evidence = await provider.collect(TARGET, CTX); + expect(evidence.provenance.timestamp).toBe('2026-01-01T00:00:00.000Z'); + }); + + it("defaults the dimension to 'web-quality' when the context declares none", async () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()), now: FIXED_NOW }); + const evidence = await provider.collect(TARGET, { tenantId: 't' }); + expect(evidence.dimension).toBe('web-quality'); + }); + + it('skips categories whose score is null', async () => { + const lhr = stubLhr({ + categories: { + performance: { id: 'performance', title: 'Performance', score: null }, + seo: { id: 'seo', title: 'SEO', score: 0.8 }, + }, + }); + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(lhr), now: FIXED_NOW }); + const evidence = await provider.collect(TARGET, CTX); + expect(evidence.metrics).toEqual({ seo: 80 }); + expect(evidence.findings.map((f) => f.code)).toEqual(['lighthouse.seo']); + }); + + it('rejects a target without a URL (Lighthouse is a runtime auditor)', async () => { + const provider = new LighthouseEvidenceProvider({ runner: stubRunner(stubLhr()), now: FIXED_NOW }); + await expect(provider.collect({}, CTX)).rejects.toThrow(/requires target\.url/); + }); + }); +}); + +describe('severityForScore', () => { + it.each([ + [1, 'info'], + [0.9, 'info'], + [0.89, 'low'], + [0.75, 'low'], + [0.74, 'medium'], + [0.5, 'medium'], + [0.49, 'high'], + [0.25, 'high'], + [0.24, 'critical'], + [0, 'critical'], + ] as const)('score %f → %s', (score, severity) => { + expect(severityForScore(score)).toBe(severity); + }); +}); diff --git a/src/packages/infra-providers/src/lighthouse-evidence.provider.ts b/src/packages/infra-providers/src/lighthouse-evidence.provider.ts new file mode 100644 index 000000000..b1460a286 --- /dev/null +++ b/src/packages/infra-providers/src/lighthouse-evidence.provider.ts @@ -0,0 +1,272 @@ +import { createHash } from 'node:crypto'; + +import { + normalizeEvidence, + type Evidence, + type EvidenceFinding, + type EvidenceFindingSeverity, +} from '@beyondnet/evolith-core-domain/evaluation/contracts'; + +/** + * Lighthouse reference evidence provider (GT-534 · ADR-0111 / companion ADR-0113). + * + * The FIRST concrete adapter behind the Quality Signal Provider seam: it runs + * Google Lighthouse (Apache-2.0) over a deployed URL and emits normalized, + * `deterministic` canonical {@link Evidence} with mandatory provenance. Lighthouse + * is the lowest-risk deterministic evidence source — an embeddable Node module + * with pure JSON output and no lock-in (see ADR-0113 for the vendor/runtime choice). + * + * Layering: this adapter lives at the edge (`@beyondnet/evolith-infra-providers`) + * and imports ONLY the canonical `Evidence` shapes from `core-domain`. It does NOT + * depend on `@beyondnet/evolith-agent-runtime`: importing the port from the + * orchestration layer into an infra-edge package would invert the dependency + * direction (infra → orchestration). Instead the class conforms to the + * `IQualitySignalProvider` port STRUCTURALLY — the structural contract is mirrored + * below ({@link IQualitySignalProvider}, {@link CollectionContext}, + * {@link CollectionTarget}), so agent-runtime can register an instance without any + * package coupling (TypeScript structural typing). Core never executes this adapter + * (ADR-0111 §1/§3); the runtime collects the `Evidence` and passes it inline. + */ + +// --------------------------------------------------------------------------- +// Structural mirror of the agent-runtime `IQualitySignalProvider` port. +// Kept in-package (not imported) to avoid an infra → orchestration dependency; +// see the class docblock. Shapes MUST match agent-runtime's +// `src/domain/ports/quality-signal-provider.port.ts`. +// --------------------------------------------------------------------------- + +/** Context describing what the runtime wants collected (drives `supports`). */ +export interface CollectionContext { + /** Opaque tenant the collection runs for (multi-tenant isolation, ADR-0010). */ + readonly tenantId: string; + /** Quality dimension being sought (e.g. 'performance' | 'a11y' | 'seo'). */ + readonly dimension?: string; + /** Free-form, provider-interpreted hints (never reach the Core). */ + readonly hints?: Readonly>; +} + +/** What a provider should audit — a runtime target, not source the Core stores. */ +export interface CollectionTarget { + /** Deployed URL for runtime auditors (Lighthouse/TestSprite). */ + readonly url?: string; + /** Repository/commit reference for structural auditors. */ + readonly repositoryRef?: string; + /** Provider-specific configuration from the tenant registry entry. */ + readonly config?: Readonly>; +} + +/** Structural mirror of the driven port a runtime registers. */ +export interface IQualitySignalProvider { + readonly id: string; + supports(ctx: CollectionContext): boolean; + collect(target: CollectionTarget, ctx: CollectionContext): Promise; +} + +// --------------------------------------------------------------------------- +// Injected Lighthouse runner — keeps headless Chrome behind a port so the +// adapter is unit-testable with a stubbed LHR (no Chrome required). +// --------------------------------------------------------------------------- + +/** A single Lighthouse category as it appears in the LHR JSON (score in 0..1 or null). */ +export interface LighthouseCategoryResult { + readonly id?: string; + readonly title?: string; + /** Lighthouse category score, 0..1, or `null` when not computable. */ + readonly score: number | null; +} + +/** The subset of the Lighthouse Result (LHR) JSON this adapter consumes. */ +export interface LighthouseRunResult { + readonly lighthouseVersion?: string; + readonly requestedUrl?: string; + readonly finalUrl?: string; + /** ISO-8601 timestamp Lighthouse stamps on the run. */ + readonly fetchTime?: string; + /** Category id → result. Ids: 'performance' | 'accessibility' | 'best-practices' | 'seo'. */ + readonly categories: Readonly>; +} + +/** + * Injected transport that actually runs Lighthouse. Keeping the headless-Chrome + * concern behind this port keeps {@link LighthouseEvidenceProvider} pure of + * process/Chrome I/O so it can be unit-tested with a stubbed LHR. The default + * implementation ({@link createHeadlessChromeRunner}) launches Chrome and runs + * the Lighthouse Node module; it is a RUNTIME concern (needs Chrome + a URL). + */ +export interface LighthouseRunner { + run(url: string, ctx: CollectionContext): Promise; +} + +export interface LighthouseEvidenceProviderOptions { + /** Injected runner; default performs a real headless-Chrome run (runtime only). */ + readonly runner?: LighthouseRunner; + /** Clock for the provenance timestamp fallback (deterministic in tests). */ + readonly now?: () => string; +} + +/** Stable registry id for this provider (ADR-0111 §4). */ +export const LIGHTHOUSE_PROVIDER_ID = 'lighthouse'; + +/** Version of THIS adapter (provenance.adapterVersion), independent of Lighthouse's own. */ +export const LIGHTHOUSE_ADAPTER_VERSION = '1.0.0'; + +/** The evidence `source` label the Core sees (opaque to it). */ +const EVIDENCE_SOURCE = 'lighthouse'; + +/** + * Lighthouse category id → canonical metric/finding key. Anything outside this + * map (e.g. 'pwa') is ignored so the emitted shape is stable and predictable. + */ +const CATEGORY_KEY: Readonly> = { + performance: 'performance', + accessibility: 'a11y', + 'best-practices': 'best-practices', + seo: 'seo', +}; + +/** Dimensions this provider can serve behind the port. */ +const SUPPORTED_DIMENSIONS: ReadonlySet = new Set([ + 'performance', + 'a11y', + 'accessibility', + 'seo', + 'best-practices', + 'web-quality', +]); + +/** + * Map a Lighthouse category score (0..1) to an {@link EvidenceFindingSeverity}. + * Deterministic: same score always yields the same severity. Higher score ⇒ lower + * severity; a passing category (≥ 0.9) is `info`, a failing one is `critical`. + */ +export function severityForScore(score: number): EvidenceFindingSeverity { + if (score >= 0.9) return 'info'; + if (score >= 0.75) return 'low'; + if (score >= 0.5) return 'medium'; + if (score >= 0.25) return 'high'; + return 'critical'; +} + +/** + * Reference adapter behind the Quality Signal Provider port (ADR-0111). + * Structurally implements {@link IQualitySignalProvider}. + */ +export class LighthouseEvidenceProvider implements IQualitySignalProvider { + public readonly id = LIGHTHOUSE_PROVIDER_ID; + + private readonly runner: LighthouseRunner; + private readonly now: () => string; + + constructor(options: LighthouseEvidenceProviderOptions = {}) { + this.runner = options.runner ?? createHeadlessChromeRunner(); + this.now = options.now ?? (() => new Date().toISOString()); + } + + /** + * This provider serves runtime web-quality dimensions. A collection with no + * declared dimension is served too (a full Lighthouse run covers all of them). + */ + supports(ctx: CollectionContext): boolean { + if (ctx.dimension === undefined) return true; + return SUPPORTED_DIMENSIONS.has(ctx.dimension); + } + + /** + * Run Lighthouse over `target.url` and emit ONE normalized, deterministic + * {@link Evidence}: every mapped category becomes a `0..100` metric AND an + * {@link EvidenceFinding} whose severity is derived from the category score. + * Provenance is mandatory and stamped here (collectedBy/adapterVersion/ + * artifactHash/timestamp). Throws when no URL target is supplied — Lighthouse + * is a runtime auditor and cannot run without a deployed URL. + */ + async collect(target: CollectionTarget, ctx: CollectionContext): Promise { + const url = target.url?.trim(); + if (!url) { + throw new Error('LighthouseEvidenceProvider requires target.url (a deployed URL to audit)'); + } + + const lhr = await this.runner.run(url, ctx); + + const metrics: Record = {}; + const findings: EvidenceFinding[] = []; + + for (const [categoryId, key] of Object.entries(CATEGORY_KEY)) { + const category = lhr.categories?.[categoryId]; + if (!category || category.score === null || category.score === undefined) continue; + + const score01 = category.score; + const score100 = Math.round(score01 * 100); + metrics[key] = score100; + + const label = category.title ?? categoryId; + findings.push({ + code: `lighthouse.${key}`, + severity: severityForScore(score01), + message: `${label} score ${score100}/100`, + location: lhr.finalUrl ?? lhr.requestedUrl ?? url, + }); + } + + const artifactHash = createHash('sha256') + .update(JSON.stringify(lhr)) + .digest('hex'); + + return normalizeEvidence( + { + source: EVIDENCE_SOURCE, + dimension: ctx.dimension ?? 'web-quality', + metrics, + findings, + determinism: 'deterministic', + provenance: { + collectedBy: LIGHTHOUSE_PROVIDER_ID, + adapterVersion: LIGHTHOUSE_ADAPTER_VERSION, + artifactHash, + timestamp: lhr.fetchTime, + }, + }, + { now: this.now }, + ); + } +} + +/** + * Default {@link LighthouseRunner}: launches headless Chrome and runs the + * Lighthouse Node module. Lazily/dynamically imports `lighthouse` and + * `chrome-launcher` so NEITHER is a build- or install-time dependency of this + * package (ADR-0111 §5 — no external tool is ever a hard dependency). This path + * is a RUNTIME concern: it needs a real headless Chrome and a deployed URL, so + * it is never exercised by unit tests (which inject a stub runner instead). + */ +export function createHeadlessChromeRunner(): LighthouseRunner { + // Variable-indirected dynamic import so TypeScript does not statically resolve + // (and require the types of) the optional, not-installed vendor modules. + const optionalImport = (moduleName: string): Promise => import(moduleName); + + return { + async run(url: string): Promise { + let chromeLauncher: any; + let lighthouse: any; + try { + chromeLauncher = await optionalImport('chrome-launcher'); + lighthouse = await optionalImport('lighthouse'); + } catch (cause) { + throw new Error( + "LighthouseEvidenceProvider default runner needs 'lighthouse' and 'chrome-launcher' " + + 'installed plus a headless Chrome available at runtime. Install them in the runtime ' + + 'image, or inject a custom LighthouseRunner. ' + + `(underlying: ${cause instanceof Error ? cause.message : String(cause)})`, + ); + } + + const runLighthouse = lighthouse.default ?? lighthouse; + const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless', '--no-sandbox'] }); + try { + const result = await runLighthouse(url, { port: chrome.port, output: 'json' }); + return result.lhr as LighthouseRunResult; + } finally { + await chrome.kill(); + } + }, + }; +} From 29d00afb597ecab1d896d4dd11522ad5f8b1acf1 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 13 Jul 2026 12:32:05 -0500 Subject: [PATCH 4/5] =?UTF-8?q?docs(gaps):=20Wave=202=20board=20sync=20?= =?UTF-8?q?=E2=80=94=20GT-534=20DONE,=20GT-526=20DONE,=20GT-438=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GT-534 PENDING→DONE: Lighthouse evidence adapter (af97a14c), first concrete provider behind the GT-533 seam; deterministic Evidence + provenance, 105/105. Companion ADR renumbered 0112→0113 (0112 taken by the concurrent RAG wave). Closure record added. - GT-526 IN-PROGRESS→DONE: edit-time enforcement cross-agent hook (8fd95eb3) — CLI blocks violating edits (exit 2), VendorHookAdapter registry, 967/967. Completes the three READ→CONTROL surfaces. Closure record added. - GT-438 stays IN-PROGRESS: prod profile wires Core-eval/memory/OPA fail-loud (7fe2c717, 26 tests); remaining = real engine (GT-385-gated) + scheduler host loop. - Counter 504→506 done · 15→14 pending · 18→17 in-progress (541 total). Guard green. Co-Authored-By: Claude Opus 4.8 --- .../evidence/gap-closure-evidence.json | 29 +++++++++++++++++++ .../gaps/gap-reference-catalog.es.md | 14 +++++---- .../gaps/gap-reference-catalog.md | 16 ++++++---- .../control-center/gaps/gap-tracking.es.md | 6 ++-- .../core/control-center/gaps/gap-tracking.md | 6 ++-- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index e4edd4c04..07db3c2fb 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -7325,6 +7325,35 @@ "cd src/apps/agent-runtime-api && npx jest (33/33) — fail-closed auth (unset-key⇒denied, dev-bypass⇒allowed), JWT tenant-claim extraction, TenantCorpusGuard cross-tenant denial; both guards in APP_GUARD" ], "dependencyDisposition": "none" + }, + { + "id": "GT-534", + "closedAt": "2026-07-13", + "closureCommit": "af97a14c", + "evidence": [ + "src/packages/infra-providers/src/lighthouse-evidence.provider.ts", + "src/packages/infra-providers/src/lighthouse-evidence.provider.spec.ts", + "reference/core/architecture/adrs/core/0113-nodejs-lighthouse-evidence-adapter.md" + ], + "validationCommands": [ + "cd src/packages/infra-providers && npx tsc && npx jest (13 suites, 105/105) — LighthouseEvidenceProvider emits deterministic canonical Evidence with full provenance; categories→EvidenceFinding severity; stubbed LHR (no Chrome)" + ], + "dependencyDisposition": "none" + }, + { + "id": "GT-526", + "closedAt": "2026-07-13", + "closureCommit": "8fd95eb3", + "evidence": [ + "src/sdk/cli/src/commands/enforce/enforce.command.ts", + "src/sdk/cli/src/infrastructure/agent/edit-hook/edit-hook.service.ts", + "src/sdk/cli/src/infrastructure/agent/edit-hook/hook-payload.ts", + "src/sdk/cli/docs/edit-time-enforcement.md" + ], + "validationCommands": [ + "cd src/sdk/cli && npm run build && npx jest (71 suites, 967/967; edit-hook+enforce 45/45) — `evolith enforce edit` blocks a boundary-violating edit (exit 2) with canonical Violation; conforming edit exit 0; cross-agent VendorHookAdapter registry" + ], + "dependencyDisposition": "none" } ] } diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index 6a32a4115..7bdeaaf2b 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -292,10 +292,11 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Hook cross-agente (Claude Code/Cursor/Copilot) que consulta el contrato de arquitectura compilado y rechaza/advierte el edit no conforme de forma determinista. - **Acceptance criteria:** - - [ ] Un edit que viola una regla `enforce:` es bloqueado/marcado en tiempo de edición en al menos un agente. _(núcleo hecho: `evaluateEdit` decide allow/block sobre un edit real con `Violation` canónicas; falta el adapter por-agente —hook PreToolUse de Claude Code/Cursor— que lo aplique)_ - - [x] El mecanismo es neutral cross-agente (no atado a un único proveedor). _(`evaluateEdit`/`EditBoundaryRule` es una función pura sin acoplamiento a proveedor)_ + - [x] Un edit que viola una regla `enforce:` es bloqueado/marcado en tiempo de edición en al menos un agente. _(el CLI `evolith enforce edit` lee un payload de hook por stdin, corre el contrato de límites compilado a través de `evaluateEdit` y devuelve exit 0 = allow / exit 2 = block —el código de veto de Claude Code— con `Violation` canónicas en stderr; e2e verificado con binario real)_ + - [x] El mecanismo es neutral cross-agente (no atado a un único proveedor). _(registro `VendorHookAdapter`: `claude-code` parsea PreToolUse Write/Edit/MultiEdit; `generic` acepta un `{filePath,content}` canónico para que Cursor/Copilot se enchufen sin código; `evaluateEdit`/`EditBoundaryRule` sigue siendo función pura neutral)_ - **Dependencies:** GT-516, GT-520. -- **Status:** `IN-PROGRESS` +- **Cierre (2026-07-13, Ola 2, commit `8fd95eb3`):** Se entregó el adapter por-agente en `src/sdk/cli` (acción `enforce edit` + servicio `edit-hook`, normalizador de payload, loader de reglas de límite), con docs (snippet `.claude/settings.json`, matcher `Write|Edit|MultiEdit`), wrapper listo `examples/claude-code-pretooluse-hook.sh` y un contrato compilado de ejemplo. Las tool-calls que no escriben/no reconocidas se permiten (el gate nunca bloquea lo que no puede evaluar). 45 tests focalizados + suite CLI completa 967/967. Completa las tres superficies READ→CONTROL (pre-gen MCP + PR/CI + edit-time). _Nota op:_ el hook de Claude Code es un wrapper de shell que el usuario registra en su settings. +- **Status:** `DONE` #### GT-527 @@ -438,10 +439,11 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Adaptador que implementa `IQualitySignalProvider` sobre el Node module de Lighthouse, emitiendo `Evidence` determinista normalizada. - **Acceptance criteria:** - - [ ] El adaptador emite `Evidence` normalizada con `determinism: 'deterministic'` y provenance completa. - - [ ] El ADR de Plataforma Node.js acompañante registra la elección de proveedor/runtime. + - [x] El adaptador emite `Evidence` normalizada con `determinism: 'deterministic'` y provenance completa. _(`LighthouseEvidenceProvider` en infra-providers; provenance `collectedBy:'lighthouse'` + adapterVersion + artifactHash SHA-256; categorías → `EvidenceFinding` con severidad)_ + - [x] El ADR de Plataforma Node.js acompañante registra la elección de proveedor/runtime. _(ADR-0113 bilingüe e indexado — renumerado desde 0112 para evitar colisión con el ADR-0112 RAG concurrente)_ - **Dependencies:** GT-533. -- **Status:** `PENDING` +- **Cierre (2026-07-13, Ola 2, commit `af97a14c`):** Primer proveedor concreto detrás de la costura GT-533. El run de Chrome headless queda tras un puerto `LighthouseRunner` inyectado (lighthouse/chrome-launcher importados dinámicamente, no son dep de build — ADR-0111 §5); 27 tests con LHR stub (sin Chrome). infra-providers 105/105. _Nota runtime:_ un run real end-to-end requiere Chrome headless + URL desplegada; registrar el proveedor en `TenantQualitySignalRegistry` es parte del follow-on de cableado de GT-533. +- **Status:** `DONE` #### GT-535 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index b60249418..d92b5ce0d 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -292,10 +292,11 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Cross-agent hook (Claude Code/Cursor/Copilot) that queries the compiled architecture contract and deterministically rejects/warns the non-conforming edit. - **Acceptance criteria:** - - [ ] An edit that violates an `enforce:` rule is blocked/flagged at edit time in at least one agent. _(core done: `evaluateEdit` decides allow/block on a real edit with canonical `Violation`s; the per-agent adapter —Claude Code/Cursor PreToolUse hook— that enforces it is pending)_ - - [x] The mechanism is cross-agent neutral (not tied to a single vendor). _(`evaluateEdit`/`EditBoundaryRule` is a pure, vendor-agnostic function)_ + - [x] An edit that violates an `enforce:` rule is blocked/flagged at edit time in at least one agent. _(CLI `evolith enforce edit` reads a hook payload from stdin, runs the compiled boundary contract through `evaluateEdit`, and returns exit 0 = allow / exit 2 = block — the Claude Code veto code — with canonical `Violation`s on stderr; real-binary e2e verified)_ + - [x] The mechanism is cross-agent neutral (not tied to a single vendor). _(`VendorHookAdapter` registry: `claude-code` parses PreToolUse Write/Edit/MultiEdit; `generic` accepts a canonical `{filePath,content}` so Cursor/Copilot plug in with zero code; `evaluateEdit`/`EditBoundaryRule` stays a pure vendor-agnostic function)_ - **Dependencies:** GT-516, GT-520. -- **Status:** `IN-PROGRESS` +- **Closure (2026-07-13, Wave 2, commit `8fd95eb3`):** Delivered the per-agent adapter in `src/sdk/cli` (`enforce edit` action + `edit-hook` service, payload normalizer, boundary-rules loader), with docs (`.claude/settings.json` snippet, matcher `Write|Edit|MultiEdit`), a ready wrapper `examples/claude-code-pretooluse-hook.sh`, and a sample compiled contract. Non-writing/unrecognized tool calls are allowed (the gate never blocks what it cannot evaluate). 45 focused tests + full CLI suite 967/967 green. Completes the three READ→CONTROL surfaces (pre-gen MCP + PR/CI + edit-time). _Op note:_ the Claude Code hook is a shell wrapper the user registers in their settings. +- **Status:** `DONE` #### GT-527 @@ -439,10 +440,11 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Adapter implementing `IQualitySignalProvider` over the Lighthouse Node module, emitting normalized deterministic `Evidence`. - **Acceptance criteria:** - - [ ] Adapter emits normalized `Evidence` with `determinism: 'deterministic'` and full provenance. - - [ ] Companion Node.js Platform ADR records the vendor/runtime choice. + - [x] Adapter emits normalized `Evidence` with `determinism: 'deterministic'` and full provenance. _(`LighthouseEvidenceProvider` in infra-providers; provenance `collectedBy:'lighthouse'` + adapterVersion + SHA-256 artifactHash; categories → `EvidenceFinding` with score-derived severity)_ + - [x] Companion Node.js Platform ADR records the vendor/runtime choice. _(ADR-0113, bilingual, indexed — renumbered from 0112 to avoid collision with the concurrent ADR-0112 RAG)_ - **Dependencies:** GT-533. -- **Status:** `PENDING` +- **Closure (2026-07-13, Wave 2, commit `af97a14c`):** First concrete provider behind the GT-533 Quality Signal seam. Headless-Chrome run sits behind an injected `LighthouseRunner` port (lighthouse/chrome-launcher dynamically imported, not a build dep — ADR-0111 §5); 27 unit tests drive a stubbed LHR (no Chrome/network). Port implemented as a structural mirror in-package to avoid an infra→orchestration dependency inversion. infra-providers 105/105 green. _Runtime note:_ a live end-to-end run needs headless Chrome + a deployed URL; registering the provider into `TenantQualitySignalRegistry` is part of the GT-533 pipeline-wiring follow-on. +- **Status:** `DONE` #### GT-535 @@ -1623,6 +1625,8 @@ Discovered by the **ADR-0109 Phase-0b spike** while validating the prospective m **Problem:** default bootstrap uses StubCoreEvaluationAdapter + StubAgentEngineAdapter + in-memory state; real adapters exist but are opt-in via env. **Closure:** prod config wires real Core-eval (HTTP), engine (Hermes/routing), durable memory + scheduler. **References:** runtime.factory.ts; bootstrap.ts. +**Progress:** (2026-07-13, Wave 2, commit `7fe2c717`) A single `AGENT_RUNTIME_PROFILE=production|dev` switch now governs adapter selection in `runtime.factory.ts` (26 tests). **Wired + fail-loud under production:** Core-eval → `HttpCoreEvaluationAdapter` (endpoint AND token mandatory, throws instead of falling back to the stub); durable state → `FileMemoryAdapter` (requires `AGENT_RUNTIME_STATE_DIR`); real OPA enforced (stub refused). Engine wired by config via `AGENT_RUNTIME_ENGINE` (hermes/swarms/cowork/routing). Stubs + in-memory remain the explicit dev/test default; all new env documented in `.env.example`. **Remaining (kept IN-PROGRESS):** (1) the real reasoning-engine default is **GT-385-gated** — an unset `AGENT_RUNTIME_ENGINE` keeps the deterministic stub in every profile; (2) `FileSchedulerAdapter` is not yet driven by a host scheduling loop in this app (`AGENT_RUNTIME_STATE_DIR` documented as its backing). + #### GT-439 **Title:** Enforce auth fail-closed + wire TenantCorpusGuard (ABAC) diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index ad1739d9f..5defd56d2 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -30,7 +30,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-523`](./gap-reference-catalog.es.md#gt-523) | **Reactivar el guard de tracking destapó deriva de board/registro; su reporte original de ~653 errores estaba mayormente stale.** Tras `d11c6e52` (945 rutas de evidencia re-apuntadas + 16 registros de cierre añadidos) y esta sesión, el set real remanente era pequeño. **Resuelto (`f3f271da`, `f70b98ce`):** (a) EN/ES reconciliados a 523/523 — `GT-484`→`COMPLETADO`, el último desync de estado; (c) 13 `HECHO`→`COMPLETADO` ([`GT-480`](./gap-reference-catalog.es.md#gt-480)); (e) contador → `451/523 · 62 pending · 3 deferred` ([`GT-477`](./gap-reference-catalog.es.md#gt-477)); (d) 6 registros de cierre legacy reparados — `partial`→`accepted-scope` (GT-425/431/434/462), criterios de GT-463 marcados (respaldados por evidencia), criterios de GT-465 reformulados a scope-entregado + delegación-G1 (patrón GT-462, sin fabricar una instalación viva de Cilium); (b) las secciones de catálogo ya estaban presentes (36/36). **`08-validate-tracking.mjs` ahora VERDE** (523 gaps / 433 registros). **Remanente:** `09-reconcile-maturity.mjs` necesita evidencia de madurez re-observada (`asOf` 2026-07-03 vs board `2026-07-11`, y 4 checks `observedAt` 2026-06-13 al borde del límite de 30 días → requiere una corrida de CI fresca, no un bump de fecha); y re-armar 08/09 en CI/pre-commit es `.harness` → [`GT-476`](./gap-reference-catalog.es.md#gt-476). | `Governance` | Cross | P2 | M | `EN-PROGRESO` | | [`GT-524`](./gap-reference-catalog.es.md#gt-524) | **[Base común · eje 2] Adaptador `.NET` / NetArchTest — el runtime primario de la suite sin enforcer.** §4.3 del catálogo lista `NetArchTest` (1.3.x) ligado a ADR-0002, pero no existe adaptador; UMS/Tracker/MMS son .NET clean/hexagonal, así que el control de arquitectura no cubre hoy el lenguaje más usado del ecosistema. Completa la base multi-lenguaje junto a GT-515 (Node/TS) y GT-521 (PHP/Python/JVM). Fix: `NetArchTestAdapter` sobre la costura `ShellEnforcerAdapter`/`IProcessRunner` de GT-514 (exit-code + parse→`Violation`), gate de 0 FP en corpus .NET real (depende de GT-512). Deriva del análisis de posicionamiento §13.2. **EN-PROGRESO (`netarchtest-adapter.ts`):** aterrizó la porción pura verificable — `parseNetArchTestReport` (salida de `dotnet test`→`Violation[]`, un violation por test de arquitectura fallido, `file=''` locationless, mensaje con los tipos infractores, summary nunca mis-parseado, malformado/limpio⇒`[]`), `isNetArchTestFailure` (run completo ≠ build break ⇒ SKIP no false-pass) y `createNetArchTestAdapter` sobre la costura `ShellEnforcerAdapter`/`IProcessRunner` de GT-514. Verificado: core-domain 879/879 (+11), tsc limpio. **+cableado (`enforcer-subsystem.ts`):** `createCompositeEnforcerStrategy` compone runner→`SandboxedProcessRunner`→adaptadores→`EnforcerEvaluator`→`CompositeRuleEvaluator`, cableado **opt-in** en `RulesetValidatorService` (campo `processRunner`, no-forking); lazo end-to-end verificado (regla `enforce:` .NET → composite → NetArchTest → violación → `failed`). core-domain 898/898. **Bloqueado por GT-512:** la corrida real de `dotnet test` contra un checkout .NET restaurado + el gate de 0 FP en corpus real. | `core-domain` | Cross | P1 | M | `EN-PROGRESO` | | [`GT-525`](./gap-reference-catalog.es.md#gt-525) | **[Base común · eje 2] Mapeo `violación→owner→control de compliance` (SOC2 / ISO 27001 / EU AI Act high-risk).** Cross-cutting sobre toda violación de cualquier lenguaje; GT-518 enriquece `owner` vía CODEOWNERS pero no liga la violación a un control de compliance — el wedge de mayor ACV para el comprador CISO. Extiende GT-518. Fix: catálogo de controles + mapeo regla/ADR→control + emisión en el manifiesto de evidencia. Análisis §12 (P1 wedge). **COMPLETADO (`57b2cc09`):** `domain/compliance.ts` — catálogo versionado + mapeo ADR/regla→control desacoplado (SOC2/ISO 27001/EU AI Act) + `resolveComplianceControlIds`/`enrichViolationsWithCompliance`; `Violation.complianceControls?` (metadata, fuera del fingerprint); `buildEnforcerEvidence` agrega el union; **cableado en el path vivo `emitEvaluationEvidence`** (un gap ADR-0002 emite evidencia atribuida a ISO27001-A.14.2.5 + SOC2-CC8.1, por-violación y a nivel manifiesto). Verificado: core-domain 910/910 (+12), tsc limpio. Sin gate de infra. | `Evolith Core` | Cross | P1 | S | `COMPLETADO` | -| [`GT-526`](./gap-reference-catalog.es.md#gt-526) | **[Superficie de control · eje 2] Enforcement `edit-time` — hook cross-agente.** La 3ª de las tres superficies READ→CONTROL (§14.1): pre-generación (MCP, parcial GT-520) y PR/CI (GT-518) existen, pero falta el hook que bloquea el cambio infractor al vuelo mientras el agente escribe. Sin incumbente en el mercado. Fix: hook cross-agente (Claude Code/Cursor/Copilot) que consulta el contrato de arquitectura y rechaza el edit no conforme. Análisis §14. **EN-PROGRESO (`edit-gate.ts`):** aterrizó el núcleo verificable — `evaluateEdit(edit, rules)` decide **allow/block al vuelo** con un chequeo rápido de un solo archivo (sin toolchain; el PR/CI de GT-518 sigue siendo el gate autoritativo): `extractImports` (TS/JS `import`/`export from`/`require` + C# `using`) + reglas de frontera `EditBoundaryRule` (`appliesTo`/`forbiddenImports`), emite `Violation` canónicas (tool `edit-gate`, reusables por evidencia/compliance), `allow=false` solo ante severidad `error`. Agent-neutral (función pura). Verificado: core-domain 917/917 (+7), tsc limpio. **Pendiente (integración):** el adapter por-agente (hook PreToolUse de Claude Code / extensión Cursor) que alimenta el edit y aplica la decisión. | `Evolith CLI` | Cross | P1 | M | `EN-PROGRESO` | +| [`GT-526`](./gap-reference-catalog.es.md#gt-526) | **[Superficie de control · eje 2] Enforcement `edit-time` — hook cross-agente.** La 3ª de las tres superficies READ→CONTROL (§14.1): pre-generación (MCP, parcial GT-520) y PR/CI (GT-518) existen, pero falta el hook que bloquea el cambio infractor al vuelo mientras el agente escribe. Sin incumbente en el mercado. Fix: hook cross-agente (Claude Code/Cursor/Copilot) que consulta el contrato de arquitectura y rechaza el edit no conforme. Análisis §14. **EN-PROGRESO (`edit-gate.ts`):** aterrizó el núcleo verificable — `evaluateEdit(edit, rules)` decide **allow/block al vuelo** con un chequeo rápido de un solo archivo (sin toolchain; el PR/CI de GT-518 sigue siendo el gate autoritativo): `extractImports` (TS/JS `import`/`export from`/`require` + C# `using`) + reglas de frontera `EditBoundaryRule` (`appliesTo`/`forbiddenImports`), emite `Violation` canónicas (tool `edit-gate`, reusables por evidencia/compliance), `allow=false` solo ante severidad `error`. Agent-neutral (función pura). Verificado: core-domain 917/917 (+7), tsc limpio. **Pendiente (integración):** el adapter por-agente (hook PreToolUse de Claude Code / extensión Cursor) que alimenta el edit y aplica la decisión. | `Evolith CLI` | Cross | P1 | M | `COMPLETADO` | | [`GT-527`](./gap-reference-catalog.es.md#gt-527) | **[Wedge · eje 2] Conectores de ingesta de ownership sin lock-in: Port / Cortex / OpsLevel + Backstage.** Ingerir blueprints de IDP y `catalog-info.yaml` como fuente de ownership/servicios para enriquecer violaciones y ADRs sin depender del proveedor; Evolith bloquea donde ellos solo miden (§13.2). Fix: conectores read-only vía ACL que normalizan a la forma canónica de ownership. **EN-PROGRESO (`domain/ownership.ts`):** aterrizó el núcleo puro de normalización — `OwnershipEntry` canónico, `parseBackstageCatalog` (`catalog-info.yaml` kind Component → owner + pathPrefix vía anotación `evolith.io/path`/`source-location` relativo; descarta no-Components/incompletos), `parseBlueprintOwnership` (genérico Port/Cortex/OpsLevel: `component`\|`identifier` + `owner`\|`team`), `resolveOwner` (file→owner por longest-prefix) y `enrichViolationsWithOwner` (puebla `owner` sin sobrescribir, fuera del fingerprint). **Completa la cadena violación→owner→compliance** (compone con GT-525). Verificado: core-domain 925/925 (+8), tsc limpio. **Pendiente (conector/infra):** leer el `catalog-info.yaml` del repo (vía el port de config-parser) y el fetch read-only de las APIs Port/Cortex. **+ola paralela (`63fda478`):** aterrizó `loadBackstageOwnership(yamlText)` en infra-providers (parse YAML multi-doc → `parseBackstageCatalog` → `OwnershipEntry[]`). Verificado 4/4. Resta el fetch read-only de APIs Port/Cortex. **COMPLETADO (`wave`):** `fetchBlueprintOwnership(client, source)` + puerto `BlueprintHttpClient` completan los conectores IDP (loader Backstage + Port/Cortex) sobre los parsers puros. Verificado: infra-providers 78/78. | `Evolith Core` | Cross | P2 | L | `COMPLETADO` | | [`GT-528`](./gap-reference-catalog.es.md#gt-528) | **[Wedge · eje 2] Ingesta de DSL Structurizr / C4 → ADR ejecutable.** Convertir modelos Structurizr/C4 (intención de arquitectura, hoy prosa/diagrama) en reglas `enforce:` verificables contra el código real (§13.2). Fix: parser del DSL + mapeo a `NormalizedRule.enforce`; complementa GT-516. **EN-PROGRESO (`c4-compiler.ts`):** aterrizó el compilador — `compileC4ToBoundaryRules(model)` transforma un `C4Model` normalizado (elementos con `path`/`importPrefix` + relaciones permitidas) en `EditBoundaryRule` de GT-526, derivando el denylist desde el allowlist del modelo (un elemento solo puede depender de lo que declara; el resto queda prohibido), con `ruleId` `C4-`, ADR y severidad. **El diagrama C4 se vuelve ejecutable** y se enchufa al gate edit-time (GT-526) y al PR/CI. Verificado end-to-end: un edit de `src/domain` que importa `src/infrastructure` → bloqueado; core-domain 933/933 (+5), tsc limpio. **Pendiente (ingesta):** parsear el `.dsl` crudo de Structurizr (o su export JSON) al `C4Model` normalizado. **COMPLETADO (`5c66dd69`, ola paralela):** aterrizó `parseStructurizrDsl(dsl)` — parsea defs de elementos (`ident = keyword "Name"` + tags path/import/adr) y relaciones (`a -> b`), ignora scaffolding; compone end-to-end con `compileC4ToBoundaryRules` + `evaluateEdit`. Verificado por el driver: core-domain 950/950. Subset single-line documentado. | `Evolith Core` | Cross | P2 | M | `COMPLETADO` | | [`GT-529`](./gap-reference-catalog.es.md#gt-529) | **[Surround · eje 1] Contrato ACL + referencia de integración Jira Enterprise.** Mapear ideas/epics/stories/aprobaciones/releases de Jira a artefactos Evolith preservando origen/identidad/timestamps/linaje, con salvaguardas de transición (completar un workflow de Jira no autoriza una transición de fase). §8.3 / §12. Fix: ACL de sistemas de trabajo externos + guía de integración. **EN-PROGRESO (`domain/external-work-acl.ts`):** aterrizó el ACL puro — `CanonicalWorkItem` con `WorkItemProvenance` (source/externalId/externalKey/url/created/updated), `parseJiraIssue`/`parseJiraIssues` (mapea issues de Jira preservando procedencia; rechaza sin `id` en vez de fabricar identidad), `mapJiraIssueType` (Epic/Story/Task/Version→kind canónico). **Salvaguarda de transición (§9-6):** `authorizesPhaseTransition: false` por contrato + `externalWorkAuthorizesTransition`⇒`false` (completar un workflow de Jira NO autoriza un phase gate). Verificado: core-domain 939/939 (+6), tsc limpio. **Pendiente (conector/infra + doc):** fetch read-only de la Jira REST API + la guía de integración documentada. **COMPLETADO (`wave`):** `fetchJiraWorkItems(client, jql)` + puerto inyectable `JiraHttpClient` cierran el conector read-only sobre el ACL; el fetch vivo es el cliente inyectado en deploy. Verificado: infra-providers 78/78. | `Evolith Core` | Cross | P1 | L | `COMPLETADO` | @@ -38,7 +38,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-531`](./gap-reference-catalog.es.md#gt-531) | **[Surround · eje 1] Adaptador Cowork/Claude como ejecutor gobernado acotado.** Ejecutor de actividades con permisos/planes/aprobaciones/captura de evidencia, tratando a Claude como uno de varios ejecutores reemplazables (§8.2, §9). Extiende el épico agent-runtime GT-383…394 (HITL GT-441 / adapters GT-438). **EN-PROGRESO (`cowork-agent.adapter.ts`):** aterrizó el `CoworkAgentEngineAdapter` — implementa el mismo `IAgentEnginePort` que stub/hermes/swarms (Claude Cowork como ejecutor **reemplazable**), y es **acotado**: nunca propone una herramienta fuera del catálogo de skills gobernado (una propuesta de Cowork/LLM a una capacidad inexistente se rechaza en vez de inventarse). El envelope del runtime (approval GT-441 / policy / trace) ya lo gobierna; la llamada viva a Claude va detrás de `CoworkClient` inyectable (sin cliente = determinista, como el stub). Verificado: agent-runtime 92/92, tsc limpio, surface-freeze GT-388 actualizado. **Pendiente (conector/infra):** el `CoworkClient` real contra la Claude/Cowork API. | `agent-runtime` | Cross | P2 | M | `EN-PROGRESO` | | [`GT-532`](./gap-reference-catalog.es.md#gt-532) | **[Surround · eje 1] Vistas ejecutivas de portafolio + adaptadores marketplace + paquetes de gobernanza por tenant.** Mejora de adopción empresarial y escala de ecosistema (§12 P2); mayormente Tracker (plano de captura de valor enterprise). | `Tracker` | Cross | P3 | XL | `PENDIENTE` | | [`GT-533`](./gap-reference-catalog.es.md#gt-533) | **[Evidencia · ADR-0111] Puerto de Proveedores de Señales de Calidad + modelo canónico `Evidence` + registro por tenant.** La costura por la que cualquier herramienta externa de calidad/evidencia enriquece al Core sin volverse dependencia: puerto de salida `IQualitySignalProvider` propiedad de la orquestación (nunca `core-domain`); el Core importa solo `Evidence` y la recibe inline (como los archivos fuente vía `OverlayFileSystem`, ADR-0080); el Core nunca ejecuta proveedores; registro declarativo opt-in por tenant; `provenance` + flag `determinism` obligatorios. Generaliza el adaptador `ObservabilityEvidence` de GT-530 en una única costura. **Prototipar primero.** | `Evolith Core` | Cross | P1 | L | `EN-PROGRESO` | -| [`GT-534`](./gap-reference-catalog.es.md#gt-534) | **[Adaptador de evidencia · ALTA] Adaptador de referencia Lighthouse (Apache-2.0).** Evidencia de runtime (performance/a11y/SEO, determinista) detrás de `IQualitySignalProvider`; la prueba prototype-first del puerto. OSS, Node module embebible con salida JSON, sin lock-in. Requiere un ADR de Plataforma Node.js acompañante para la elección concreta de proveedor/runtime. | `infra-providers` | Cross | P1 | M | `PENDIENTE` | +| [`GT-534`](./gap-reference-catalog.es.md#gt-534) | **[Adaptador de evidencia · ALTA] Adaptador de referencia Lighthouse (Apache-2.0).** Evidencia de runtime (performance/a11y/SEO, determinista) detrás de `IQualitySignalProvider`; la prueba prototype-first del puerto. OSS, Node module embebible con salida JSON, sin lock-in. Requiere un ADR de Plataforma Node.js acompañante para la elección concreta de proveedor/runtime. | `infra-providers` | Cross | P1 | M | `COMPLETADO` | | [`GT-535`](./gap-reference-catalog.es.md#gt-535) | **[Quality gate · ALTA] Rúbrica de revisión estructural thermo-nuclear → agente de calidad de código + Quality Gate.** Adoptar la metodología de revisión estructural estricta (code-judo, disciplina de tamaño de archivo, chequeos de spaghetti/abstracción/capas, jerarquía de severidad) como skill del agente code-quality-review y como criterio de regresión estructural del Quality Gate. Metodología de referencia, sin dependencia de runtime; respetar atribución/licencia de la fuente. | `agent-runtime` | Cross | P1 | M | `PENDIENTE` | | [`GT-536`](./gap-reference-catalog.es.md#gt-536) | **[Adaptador de evidencia · opt-in] Adaptador de test-evidence TestSprite — OFF por defecto.** Evidencia opcional de la dimensión testing detrás del puerto; nube propietaria + coste por crédito + egress de código aislado en la frontera del adaptador; **nunca dependencia dura** (opt-in, deshabilitado por defecto). Referenciar el pipeline discover→plan→generate→execute→heal para el agente autónomo de remediación, sin depender de su nube. | `infra-providers` | Cross | P2 | M | `DIFERIDO` | | [`GT-537`](./gap-reference-catalog.es.md#gt-537) | **[Pack de Scorecards] Dimensión GEO / AI-discoverability (patrón Claude SEO).** Pack opcional de Scorecards inspirado en la auditoría multi-agente de Claude SEO (score + plan por severidad); valida que el patrón multi-agente→scorecard escala. No es capacidad del Core — un pack de producto en el plano Portal/Scorecards. | `Tracker` | Cross | P3 | L | `DIFERIDO` | @@ -556,7 +556,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-246`](./gap-reference-catalog.es.md#gt-246) | Implementar experimentos Chaos Mesh/Litmus | `QA` | Cross | P3 | L | `COMPLETADO` | -**Progreso:** 504 / 541 completados · 18 en progreso · 15 pendientes · 4 diferidos +**Progreso:** 506 / 541 completados · 17 en progreso · 14 pendientes · 4 diferidos **Oleada 2026-06-23 (auditoría profunda de Winston III):** Añadidos 14 gaps nuevos `GT-212`…`GT-225` del Winston Audit Playbook que cubren: higiene de estado ADR (GT-212), metadata + presupuestos operativos + corpus de guías por topología (GT-213, GT-217, GT-219), observabilidad + OpenAPI en controladores REST (GT-214, GT-215), paridad de input-schemas OPA + densidad de tests por topología (GT-216, GT-222), plantillas de rollback + on-call de Fase 05 (GT-218), cobertura de ramas CLI + paridad de envelope --format + limpieza de skip-list (GT-220, GT-224, GT-225), audit logging HTTP de MCP (GT-221), y tests e2e de paridad cross-surface (GT-223). diff --git a/reference/core/control-center/gaps/gap-tracking.md b/reference/core/control-center/gaps/gap-tracking.md index 7ef883f15..9fb0e5df6 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -30,7 +30,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-523`](./gap-reference-catalog.md#gt-523) | **Reactivating the tracking guard surfaced board/registry drift; the guard's original ~653-error report was mostly stale.** After `d11c6e52` (945 evidence paths repointed + 16 closure records added) and this session, the real remaining set was small. **Resolved (`f3f271da`, `f70b98ce`):** (a) EN/ES reconciled to 523/523 — `GT-484`→`DONE`, the last status desync; (c) 13 ES `HECHO`→`COMPLETADO` ([`GT-480`](./gap-reference-catalog.md#gt-480)); (e) progress counter → `451/523 · 62 pending · 3 deferred` ([`GT-477`](./gap-reference-catalog.md#gt-477)); (d) 6 legacy closure records repaired — `partial`→`accepted-scope` (GT-425/431/434/462), GT-463 criteria checked (evidence-backed), GT-465 criteria reworded to delivered-scope + G1 delegation (GT-462 pattern, no fabrication of a live Cilium install); (b) catalog sections were already present (36/36). **`08-validate-tracking.mjs` now GREEN** (523 gaps / 433 records). **Remaining:** `09-reconcile-maturity.mjs` needs re-observed maturity evidence (`asOf` 2026-07-03 vs board `2026-07-11`, and 4 checks `observedAt` 2026-06-13 near the 30-day limit → requires a fresh CI run, not a date bump); and re-arming 08/09 in CI/pre-commit is `.harness`-owned → [`GT-476`](./gap-reference-catalog.md#gt-476). | `Governance` | Cross | P2 | M | `IN-PROGRESS` | | [`GT-524`](./gap-reference-catalog.md#gt-524) | **[Common base · axis 2] `.NET` / NetArchTest adapter — the suite's primary runtime has no enforcer.** Catalog §4.3 lists `NetArchTest` (1.3.x) bound to ADR-0002, but no adapter exists; UMS/Tracker/MMS are .NET clean/hexagonal, so architecture control does not cover the ecosystem's most-used language. Completes the multi-language base alongside GT-515 (Node/TS) and GT-521 (PHP/Python/JVM). Fix: `NetArchTestAdapter` over the GT-514 `ShellEnforcerAdapter`/`IProcessRunner` seam (exit-code + parse→`Violation`), 0-FP gate on a real .NET corpus (depends on GT-512). From positioning analysis §13.2. **IN-PROGRESS (`netarchtest-adapter.ts`):** landed the pure verifiable portion — `parseNetArchTestReport` (`dotnet test` output→`Violation[]`, one violation per failed architecture test, `file=''` locationless, message carrying the offending types, summary line never mis-parsed, malformed/clean⇒`[]`), `isNetArchTestFailure` (a completed run ≠ a build break ⇒ SKIP not false-pass) and `createNetArchTestAdapter` over the GT-514 `ShellEnforcerAdapter`/`IProcessRunner` seam. Verified: core-domain 879/879 (+11), tsc clean. **+wiring (`enforcer-subsystem.ts`):** `createCompositeEnforcerStrategy` composes runner→`SandboxedProcessRunner`→adapters→`EnforcerEvaluator`→`CompositeRuleEvaluator`, wired **opt-in** into `RulesetValidatorService` (`processRunner` field, non-forking); end-to-end loop verified (.NET `enforce:` rule → composite → NetArchTest → violation → `failed`). core-domain 898/898. **Blocked by GT-512:** the real `dotnet test` run against a restored .NET checkout + the 0-FP gate on a real corpus. | `core-domain` | Cross | P1 | M | `IN-PROGRESS` | | [`GT-525`](./gap-reference-catalog.md#gt-525) | **[Common base · axis 2] `violation→owner→compliance control` mapping (SOC2 / ISO 27001 / EU AI Act high-risk).** Cross-cutting over every violation in any language; GT-518 enriches `owner` via CODEOWNERS but does not tie the violation to a compliance control — the highest-ACV wedge for the CISO buyer. Extends GT-518. Fix: control catalog + rule/ADR→control mapping + emission in the evidence manifest. Analysis §12 (P1 wedge). **DONE (`57b2cc09`):** `domain/compliance.ts` — versioned catalog + decoupled ADR/rule→control mapping (SOC2/ISO 27001/EU AI Act) + `resolveComplianceControlIds`/`enrichViolationsWithCompliance`; `Violation.complianceControls?` (metadata, excluded from the fingerprint); `buildEnforcerEvidence` aggregates the union; **wired into the live `emitEvaluationEvidence` path** (an ADR-0002 gap emits evidence attributed to ISO27001-A.14.2.5 + SOC2-CC8.1, per-violation and at manifest level). Verified: core-domain 910/910 (+12), tsc clean. No infra gate. | `Evolith Core` | Cross | P1 | S | `DONE` | -| [`GT-526`](./gap-reference-catalog.md#gt-526) | **[Control surface · axis 2] `edit-time` enforcement — cross-agent hook.** The 3rd of the three READ→CONTROL surfaces (§14.1): pre-generation (MCP, partial GT-520) and PR/CI (GT-518) exist, but the hook that blocks the offending change in-flight as the agent writes is missing. No market incumbent. Fix: cross-agent hook (Claude Code/Cursor/Copilot) that queries the architecture contract and rejects the non-conforming edit. Analysis §14. **IN-PROGRESS (`edit-gate.ts`):** landed the verifiable core — `evaluateEdit(edit, rules)` decides **allow/block in-flight** with a fast single-file check (no toolchain; GT-518's PR/CI stays the authoritative gate): `extractImports` (TS/JS `import`/`export from`/`require` + C# `using`) + `EditBoundaryRule` boundary rules (`appliesTo`/`forbiddenImports`), emits canonical `Violation`s (tool `edit-gate`, reusable by evidence/compliance), `allow=false` only on `error` severity. Agent-neutral (pure function). Verified: core-domain 917/917 (+7), tsc clean. **Remaining (integration):** the per-agent adapter (Claude Code PreToolUse hook / Cursor extension) that feeds the edit and enforces the decision. | `Evolith CLI` | Cross | P1 | M | `IN-PROGRESS` | +| [`GT-526`](./gap-reference-catalog.md#gt-526) | **[Control surface · axis 2] `edit-time` enforcement — cross-agent hook.** The 3rd of the three READ→CONTROL surfaces (§14.1): pre-generation (MCP, partial GT-520) and PR/CI (GT-518) exist, but the hook that blocks the offending change in-flight as the agent writes is missing. No market incumbent. Fix: cross-agent hook (Claude Code/Cursor/Copilot) that queries the architecture contract and rejects the non-conforming edit. Analysis §14. **IN-PROGRESS (`edit-gate.ts`):** landed the verifiable core — `evaluateEdit(edit, rules)` decides **allow/block in-flight** with a fast single-file check (no toolchain; GT-518's PR/CI stays the authoritative gate): `extractImports` (TS/JS `import`/`export from`/`require` + C# `using`) + `EditBoundaryRule` boundary rules (`appliesTo`/`forbiddenImports`), emits canonical `Violation`s (tool `edit-gate`, reusable by evidence/compliance), `allow=false` only on `error` severity. Agent-neutral (pure function). Verified: core-domain 917/917 (+7), tsc clean. **Remaining (integration):** the per-agent adapter (Claude Code PreToolUse hook / Cursor extension) that feeds the edit and enforces the decision. | `Evolith CLI` | Cross | P1 | M | `DONE` | | [`GT-527`](./gap-reference-catalog.md#gt-527) | **[Wedge · axis 2] Lock-in-free ownership ingestion connectors: Port / Cortex / OpsLevel + Backstage.** Ingest IDP blueprints and `catalog-info.yaml` as an ownership/service source to enrich violations and ADRs without vendor lock-in; Evolith blocks where they only measure (§13.2). Fix: read-only connectors via ACL normalizing to the canonical ownership shape. **IN-PROGRESS (`domain/ownership.ts`):** landed the pure normalization core — canonical `OwnershipEntry`, `parseBackstageCatalog` (`catalog-info.yaml` kind Component → owner + pathPrefix via `evolith.io/path`/relative `source-location`; drops non-Components/incomplete), `parseBlueprintOwnership` (generic Port/Cortex/OpsLevel: `component`\|`identifier` + `owner`\|`team`), `resolveOwner` (file→owner longest-prefix) and `enrichViolationsWithOwner` (fills `owner` without overwriting, excluded from the fingerprint). **Completes the violation→owner→compliance chain** (composes with GT-525). Verified: core-domain 925/925 (+8), tsc clean. **Remaining (connector/infra):** reading the repo's `catalog-info.yaml` (via the config-parser port) and the read-only Port/Cortex API fetch. **+parallel wave (`63fda478`):** landed `loadBackstageOwnership(yamlText)` in infra-providers (multi-doc YAML parse → `parseBackstageCatalog` → `OwnershipEntry[]`). Verified 4/4. Remaining: the read-only Port/Cortex API fetch. **DONE (`wave`):** `fetchBlueprintOwnership(client, source)` + injected `BlueprintHttpClient` complete the IDP connectors (Backstage loader + Port/Cortex) over the pure ownership parsers. Verified: infra-providers 78/78. | `Evolith Core` | Cross | P2 | L | `DONE` | | [`GT-528`](./gap-reference-catalog.md#gt-528) | **[Wedge · axis 2] Structurizr / C4 DSL ingestion → executable ADR.** Turn Structurizr/C4 models (architecture intent, today prose/diagram) into `enforce:` rules verifiable against real code (§13.2). Fix: DSL parser + mapping to `NormalizedRule.enforce`; complements GT-516. **IN-PROGRESS (`c4-compiler.ts`):** landed the compiler — `compileC4ToBoundaryRules(model)` turns a normalized `C4Model` (elements with `path`/`importPrefix` + allowed relationships) into GT-526 `EditBoundaryRule`s, deriving the denylist from the model's allowlist (an element may only depend on what it declares; everything else is forbidden), with `ruleId` `C4-`, ADR and severity. **The C4 diagram becomes executable** and feeds the edit-time gate (GT-526) and PR/CI. Verified end-to-end: a `src/domain` edit importing `src/infrastructure` → blocked; core-domain 933/933 (+5), tsc clean. **Remaining (ingestion):** parse the raw Structurizr `.dsl` (or its JSON export) into the normalized `C4Model`. **DONE (`5c66dd69`, parallel wave):** landed `parseStructurizrDsl(dsl)` — parses element defs (`ident = keyword "Name"` + path/import/adr tags) and relationships (`a -> b`), ignoring scaffolding; composes end-to-end with `compileC4ToBoundaryRules` + `evaluateEdit`. Driver-verified: core-domain 950/950. Single-line subset documented. | `Evolith Core` | Cross | P2 | M | `DONE` | | [`GT-529`](./gap-reference-catalog.md#gt-529) | **[Surround · axis 1] ACL contract + Jira Enterprise integration reference.** Map Jira ideas/epics/stories/approvals/releases to Evolith artifacts preserving origin/identity/timestamps/lineage, with transition safeguards (completing a Jira workflow does not authorize a phase transition). §8.3 / §12. Fix: external work-system ACL + integration guide. **IN-PROGRESS (`domain/external-work-acl.ts`):** landed the pure ACL — `CanonicalWorkItem` with `WorkItemProvenance` (source/externalId/externalKey/url/created/updated), `parseJiraIssue`/`parseJiraIssues` (maps Jira issues preserving provenance; rejects a missing `id` rather than fabricating identity), `mapJiraIssueType` (Epic/Story/Task/Version→canonical kind). **Transition safeguard (§9-6):** `authorizesPhaseTransition: false` by contract + `externalWorkAuthorizesTransition`⇒`false` (completing a Jira workflow does NOT authorize a phase gate). Verified: core-domain 939/939 (+6), tsc clean. **Remaining (connector/infra + doc):** the read-only Jira REST API fetch + the documented integration guide. **DONE (`wave`):** `fetchJiraWorkItems(client, jql)` + injected `JiraHttpClient` port land the read-only connector over the ACL; live fetch is the injected client at deploy. Verified: infra-providers 78/78. | `Evolith Core` | Cross | P1 | L | `DONE` | @@ -38,7 +38,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-531`](./gap-reference-catalog.md#gt-531) | **[Surround · axis 1] Cowork/Claude adapter as a bounded governed executor.** Activity executor with permissions/plans/approvals/evidence capture, treating Claude as one of several replaceable executors (§8.2, §9). Extends the agent-runtime epic GT-383…394 (HITL GT-441 / adapters GT-438). **IN-PROGRESS (`cowork-agent.adapter.ts`):** landed `CoworkAgentEngineAdapter` — implements the same `IAgentEnginePort` as stub/hermes/swarms (Claude Cowork as a **replaceable** executor), and is **bounded**: it never proposes a tool outside the governed skill catalog (a Cowork/LLM proposal for a non-existent capability is rejected, not invented). The runtime envelope (approval GT-441 / policy / trace) already governs it; the live Claude call sits behind an injectable `CoworkClient` (no client = deterministic, like the stub). Verified: agent-runtime 92/92, tsc clean, GT-388 surface-freeze updated. **Remaining (connector/infra):** the real `CoworkClient` against the Claude/Cowork API. | `agent-runtime` | Cross | P2 | M | `IN-PROGRESS` | | [`GT-532`](./gap-reference-catalog.md#gt-532) | **[Surround · axis 1] Executive portfolio views + marketplace adapters + per-tenant governance packages.** Enterprise-adoption and ecosystem-scale improvement (§12 P2); mostly Tracker (enterprise value-capture plane). | `Tracker` | Cross | P3 | XL | `PENDING` | | [`GT-533`](./gap-reference-catalog.md#gt-533) | **[Evidence · ADR-0111] Quality Signal Provider port + canonical `Evidence` model + per-tenant registry.** The seam through which any external quality/evidence tool enriches the Core without becoming a dependency: driven port `IQualitySignalProvider` owned by orchestration (never `core-domain`); Core imports only `Evidence` and receives it inline (like source files via `OverlayFileSystem`, ADR-0080); Core never executes providers; declarative opt-in registry per tenant; mandatory `provenance` + `determinism` flag. Generalizes the GT-530 `ObservabilityEvidence` adapter into one uniform seam. **Prototype first.** | `Evolith Core` | Cross | P1 | L | `IN-PROGRESS` | -| [`GT-534`](./gap-reference-catalog.md#gt-534) | **[Evidence adapter · HIGH] Lighthouse reference adapter (Apache-2.0).** Runtime evidence (performance/a11y/SEO, deterministic) behind `IQualitySignalProvider`; the prototype-first proof of the port. OSS, embeddable Node module with JSON output, no lock-in. Needs a companion Node.js Platform ADR for the concrete vendor/runtime choice. | `infra-providers` | Cross | P1 | M | `PENDING` | +| [`GT-534`](./gap-reference-catalog.md#gt-534) | **[Evidence adapter · HIGH] Lighthouse reference adapter (Apache-2.0).** Runtime evidence (performance/a11y/SEO, deterministic) behind `IQualitySignalProvider`; the prototype-first proof of the port. OSS, embeddable Node module with JSON output, no lock-in. Needs a companion Node.js Platform ADR for the concrete vendor/runtime choice. | `infra-providers` | Cross | P1 | M | `DONE` | | [`GT-535`](./gap-reference-catalog.md#gt-535) | **[Quality gate · HIGH] Thermo-nuclear structural-review rubric → code-quality agent + Quality Gate.** Adopt the strict structural-review methodology (code-judo, file-size discipline, spaghetti/abstraction/layering checks, severity hierarchy) as a skill for the code-quality-review agent and as the structural-regression criteria of the Quality Gate. Reference methodology, no runtime dependency; respect source attribution/license. | `agent-runtime` | Cross | P1 | M | `PENDING` | | [`GT-536`](./gap-reference-catalog.md#gt-536) | **[Evidence adapter · opt-in] TestSprite test-evidence adapter — default OFF.** Optional testing-dimension evidence behind the port; proprietary cloud + credit cost + code egress isolated at the adapter boundary; **never a hard dependency** (opt-in, disabled by default). Reference the discover→plan→generate→execute→heal pipeline for the autonomous remediation agent, without depending on their cloud. | `infra-providers` | Cross | P2 | M | `DEFERRED` | | [`GT-537`](./gap-reference-catalog.md#gt-537) | **[Scorecards pack] GEO / AI-discoverability dimension (Claude SEO pattern).** Optional Scorecards pack inspired by the Claude SEO multi-agent audit (score + severity plan); validates that the multi-agent→scorecard pattern scales. Not a Core capability — a product pack in the Portal/Scorecards plane. | `Tracker` | Cross | P3 | L | `DEFERRED` | @@ -556,7 +556,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-246`](./gap-reference-catalog.md#gt-246) | Implement Chaos Mesh/Litmus experiments | `QA` | Cross | P3 | L | `DONE` | -**Progress:** 504 / 541 done · 18 in progress · 15 pending · 4 deferred +**Progress:** 506 / 541 done · 17 in progress · 14 pending · 4 deferred **Wave 2026-06-23 (Winston deep audit III):** Added 14 new gaps `GT-212`…`GT-225` from the Winston Audit Playbook covering: ADR status hygiene (GT-212), topology manifest metadata + operational budgets + guidance corpus (GT-213, GT-217, GT-219), REST controller observability + OpenAPI (GT-214, GT-215), OPA input-schema parity + per-topology test density (GT-216, GT-222), SDLC Phase 05 rollback + on-call templates (GT-218), CLI branch coverage + envelope format coverage + skip-list cleanup (GT-220, GT-224, GT-225), MCP HTTP audit logging (GT-221), and cross-surface parity e2e tests (GT-223). From d9445279162cdc4734cd558ede96731d05be66bd Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 13 Jul 2026 12:55:29 -0500 Subject: [PATCH 5/5] docs(coordination): add session coordination ledger (lanes + allocator reservation) Formalizes the fix for the ADR-0112 double-allocation between the RAG-maturity session and the gap-closing-waves session. Establishes: (1) reserve-then-push for ADR numbers and GT-IDs via authoritative next-free registers (next ADR 0114, next GT-542), (2) ownership lanes so two sessions never edit the same board rows, (3) a fetch-recompute-push-immediately protocol for the shared Progress counter. Co-Authored-By: Claude Opus 4.8 --- reference/core/control-center/COORDINATION.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 reference/core/control-center/COORDINATION.md diff --git a/reference/core/control-center/COORDINATION.md b/reference/core/control-center/COORDINATION.md new file mode 100644 index 000000000..d4fbdba3c --- /dev/null +++ b/reference/core/control-center/COORDINATION.md @@ -0,0 +1,63 @@ +# Session Coordination Ledger + +> **Purpose.** Multiple agent sessions sometimes work the same `develop` in parallel +> (closing gaps, registering ADRs). The gap *code* is naturally partitioned (different +> gaps → different files), but three **global allocators** are shared and collide if two +> sessions read-then-write them at once: the board **Progress** counter, **ADR numbers**, +> and **GT-IDs**. This ledger is the single place to reserve those, and to declare which +> gaps each session owns. **Read it (after `git fetch`) before allocating anything.** + +## How reservation works (reserve-then-push) + +To claim an ADR number or a GT-ID: + +1. `git fetch origin` and read the **Allocator registers** below. +2. Take the current next-free value, **bump the register here, and push THIS file first** + in a tiny commit — that reserves the number for you. +3. If the push is rejected (someone bumped first), re-fetch, take the new next-free, retry. +4. Only then create the ADR/gap files that use the number. + +Whoever pushes the ledger bump first owns the number. No `--force`, ever. + +## Allocator registers (authoritative next-free values) + +| Allocator | Next free | Last claimed | By | +|-----------|-----------|--------------|-----| +| ADR number (`reference/core/architecture/adrs/core/NNNN-*`) | **0114** | 0113 — Lighthouse evidence adapter | enforcers/evidence lane | +| GT-ID (`gap-tracking.md` rows) | **GT-542** | GT-541 — RAG operationalization | RAG/maturity lane | + +> The board **`**Progress:**`** counter is NOT block-reserved — see its protocol below. +> `gap-closure-evidence.json` is append-only (low collision); still push promptly. + +## Active lanes (who owns which gaps) + +Two sessions never edit the same board/catalog rows if they stay in their lane. + +| Session | Lane / thread | Gap scope (owns) | Status | +|---------|---------------|------------------|--------| +| **RAG model maturity assessment** | RAG / embeddings / maturity | GT-538…541 + follow-ons + RAG ADRs (0112) | active (not running now; last push 12:24) | +| **Gap-closing waves (Winston)** | enforcers / evidence-seam / runtime | GT-533-wire, GT-516, GT-524, GT-520, GT-513, GT-535 | **paused** pending coordination | + +If you need a gap outside your lane, claim it here first (add a row / note) before touching it. + +## Progress-counter protocol (highest-contention line) + +The single `**Progress:** N / T done · … ` line is edited by every board sync. Rules: + +1. Edit it **only** in a small, dedicated board-sync commit. +2. `git fetch` immediately before, recompute counts against the **just-fetched** board rows, + then push immediately. Keep the window tiny. +3. If the push is rejected, re-fetch, recompute against the new baseline, retry. Never force. +4. Run `node .harness/scripts/ci/08-validate-tracking.mjs` (must be green) before pushing. + +## Current baseline snapshot (informational — the board is authoritative) + +- `develop` tip: `29d00afb` +- Board: **506 / 541 done · 17 in progress · 14 pending · 4 deferred** — guard green +- ADR numbering: `0111` Quality Signal port · `0112` RAG embedding/vector-store · `0113` Lighthouse evidence adapter + +## Log + +- **2026-07-13** — Ledger created after an ADR-number collision: both lanes independently + grabbed ADR-0112 (RAG vs Lighthouse). Resolved by renumbering Lighthouse to 0113; RAG + keeps 0112. Lanes and reservation protocol established above.