@milpa/comal 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +213 -55
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +38 -1
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,211 @@
1
+ // House operations for visual acceptance. Agent operations inspect, draft and
2
+ // verify; only human operations (audience 'human', no tool permission, cli/tui
3
+ // surfaces) can approve references, confirm criteria or revoke them.
4
+ import { createHash } from 'node:crypto';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { comalEnvironment } from '../compat.mjs';
8
+ import { IMAGE_LIMIT } from '../vision.mjs';
9
+ import { launchChrome, defaultChromeBinary } from './chrome.mjs';
10
+ import { freezePage } from './fixtures.mjs';
11
+ import { openLedger, defaultKeyDir } from './ledger.mjs';
12
+ import { normalizeCriterion } from './measure.mjs';
13
+ import { componentInventory, listComponents, loadComponent, pairKey, readReference, referencePaths, setupDigest, setupManifest, VISUAL_DIR } from './registry.mjs';
14
+ import { verifyComponent, writeReferenceDraft, captureMaquette } from './verify.mjs';
15
+ import { composite } from './report.mjs';
16
+
17
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
18
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
19
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
20
+ const ID = { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,62}$' };
21
+ const DIGEST = { type: 'string', pattern: '^[a-f0-9]{12,64}$' };
22
+ const schema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
23
+ const READ = { workspace: 'read', external: 'none', process: 'none', authority: 'none' };
24
+ const BROWSER = { workspace: 'write', external: 'read', process: 'spawn', authority: 'none' };
25
+ const HUMAN = { workspace: 'none', external: 'none', process: 'none', authority: 'visual_acceptance' };
26
+ const fail = (message, code = 'VISUAL_OPERATION_REFUSED') => Object.assign(new Error(message), { code });
27
+
28
+ function imageBlock(bytes) {
29
+ if (!bytes || comalEnvironment().COMAL_VISION === 'off' || bytes.length > IMAGE_LIMIT) return null;
30
+ return { type: 'image', mimeType: 'image/png', data: Buffer.from(bytes).toString('base64') };
31
+ }
32
+ const publicResult = ({ captures, ...rest }) => rest;
33
+ function requireHuman(options) { if (options.actor?.kind !== 'human') throw fail('Sólo el humano puede aprobar, confirmar o revocar en la aceptación visual; usa /visual.', 'HOUSE_REFUSED'); }
34
+
35
+ const short = digest => digest ? digest.slice(0, 12) : null;
36
+ const criterionDigest = criterion => sha256(JSON.stringify(canonical(criterion)));
37
+ // The human approves what status shows: digest prefixes bind each approval to those exact bytes.
38
+ function digestMatches(prefix, digest) { return typeof prefix === 'string' && /^[a-f0-9]{12,64}$/.test(prefix) && typeof digest === 'string' && digest.startsWith(prefix); }
39
+ // Files added, removed or changed between the approved setup manifest and now.
40
+ function setupChanges(approved, current) {
41
+ if (!approved?.fixtures) return null;
42
+ const changes = [];
43
+ if (approved.spec_sha256 !== current.spec_sha256) changes.push('spec');
44
+ for (const id of new Set([...Object.keys(approved.fixtures), ...Object.keys(current.fixtures)])) {
45
+ const before = approved.fixtures[id] || {}, after = current.fixtures[id] || {};
46
+ if (!approved.fixtures[id]) { changes.push(`fixture ${id}: nuevo`); continue; }
47
+ if (!current.fixtures[id]) { changes.push(`fixture ${id}: eliminado`); continue; }
48
+ for (const file of new Set([...Object.keys(before), ...Object.keys(after)])) if (before[file] !== after[file]) changes.push(`fixture ${id}/${file}: ${!before[file] ? 'nuevo' : !after[file] ? 'eliminado' : 'modificado'}`);
49
+ }
50
+ return changes.slice(0, 50);
51
+ }
52
+
53
+ async function status(cwd, stateDir, keyDir, component) {
54
+ if (!component) {
55
+ const ids = await listComponents(cwd);
56
+ return { components: ids, hint: ids.length ? 'VisualStatus {component} muestra pares, referencias y criterios.' : `No hay componentes: crea ${VISUAL_DIR}/components/<id>.json (ver docs/visual-acceptance.md).` };
57
+ }
58
+ const inventory = await componentInventory(cwd, component), effective = await (await openLedger({ stateDir, keyDir })).effective(component);
59
+ const setup = await setupDigest(cwd, inventory.spec), manifest = await setupManifest(cwd, inventory.spec);
60
+ const setupState = !effective.setup ? 'not_approved' : effective.setup.digest === setup ? 'approved' : 'changed_after_approval';
61
+ const pairs = inventory.pairs.map(pair => {
62
+ const approval = effective.references[pair.key];
63
+ const state = !pair.reference ? 'missing' : pair.reference.error ? 'invalid' : !approval ? 'draft' : approval.digest === pair.reference.digest ? 'approved' : 'changed_after_approval';
64
+ return { key: pair.key, fixture: pair.fixture, state: pair.state, kind: pair.kind, mandatory: pair.mandatory, reference: state, mode: pair.reference?.mode ?? null,
65
+ ...(pair.reference?.meta ? { tolerance: pair.reference.meta.tolerance, file: pair.reference.file, digest: short(pair.reference.digest) } : {}),
66
+ ...(pair.reference?.error ? { error: pair.reference.error } : {}),
67
+ ...(state === 'draft' || state === 'changed_after_approval' ? { human_action: `/visual approve ${component} ${pair.fixture} ${pair.state} ${short(pair.reference.digest)}` } : {}) };
68
+ });
69
+ const confirmed = effective.criteria.map(c => c.id);
70
+ return { component, extension: inventory.spec.extension, root: inventory.spec.root,
71
+ setup: { state: setupState, digest: short(setup), fixtures: inventory.spec.fixtures.map(f => ({ id: f.id, kind: f.kind, mandatory: f.mandatory, ...(f.url ? { url: f.url } : { files: Object.keys(manifest.fixtures[f.id] || {}).length }) })),
72
+ ...(setupState === 'changed_after_approval' ? { changes_since_approval: setupChanges(effective.setup.payload?.manifest, manifest) } : {}),
73
+ ...(setupState !== 'approved' ? { human_action: `/visual approve-setup ${component} ${short(setup)}` } : {}) },
74
+ pairs,
75
+ criteria: { confirmed, pending: inventory.proposedCriteria.filter(c => !confirmed.includes(c.id)).map(c => ({ ...c, digest: short(criterionDigest(c)), human_action: `/visual confirm ${component} ${c.id} ${short(criterionDigest(c))}` })), proposal_errors: inventory.proposalErrors },
76
+ ledger_invalid_entries: effective.invalid_entries };
77
+ }
78
+
79
+ export function visualOperations({ cwd, stateDir, keyDir = defaultKeyDir(), judge, chromeBinary = defaultChromeBinary() }) {
80
+ const ledger = () => openLedger({ stateDir, keyDir });
81
+ return [
82
+ { name: 'VisualStatus', description: 'Visual acceptance registry: components under visual/components, their fixture x state pairs, which references are approved by the human, confirmed vs pending criteria. Read only.',
83
+ input_schema: schema({ component: ID }, []), effects: READ,
84
+ run: async input => JSON.stringify(await status(cwd, stateDir, keyDir, input.component)) },
85
+ { name: 'VisualBoard', description: 'Human view of the visual acceptance registry (/visual status).', audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: READ,
86
+ input_schema: schema({ component: ID }, []), run: async (input, options) => { requireHuman(options); return JSON.stringify(await status(cwd, stateDir, keyDir, input.component)); } },
87
+ { name: 'VisualCapture', description: 'Inspection only: the house loads the current extension build in a fresh Chrome profile, reaches one fixture/state of a component and returns the capture (pixels reach you as an image) plus measurements of proposed criteria. Never approves anything.',
88
+ input_schema: schema({ component: ID, fixture: ID, state: ID }, ['component', 'fixture', 'state']), effects: BROWSER,
89
+ run: async (input, options) => {
90
+ const key = pairKey(input.fixture, input.state);
91
+ const result = await verifyComponent({ cwd, stateDir, keyDir, component: input.component, mode: 'inspection', pairs: [key], signal: options.signal, chromeBinary });
92
+ if (!result.pairs.length) throw fail(`El componente ${input.component} no tiene el par ${key}.`);
93
+ const shot = result.captures.get(key), reference = await readReference(cwd, input.component, key);
94
+ const image = imageBlock(reference?.png ? composite([reference.png, shot.capture]) : shot.capture);
95
+ return JSON.stringify({ ...publicResult(result), image_layout: reference?.png ? 'reference | capture' : 'capture', ...(image ? { image } : {}), scope: 'inspection_not_acceptance' });
96
+ } },
97
+ { name: 'VisualVerify', description: 'Visual ACCEPTANCE of a component, run by the house on the current extension build: every fixture x state pair is captured in a fresh profile and compared against the human-approved reference (pixels in exact mode, an independent vision judge in mockup mode) plus confirmed criteria. status passed|failed|blocked. Use it as the WorkPlan check of UI tasks: {operation:"VisualVerify",arguments:{component},assertions:[{kind:"json_equals",pointer:"/status",value:"passed"}]}.',
98
+ input_schema: schema({ component: ID }, ['component']), effects: BROWSER,
99
+ run: async (input, options) => {
100
+ const result = await verifyComponent({ cwd, stateDir, keyDir, component: input.component, mode: 'acceptance', judge, signal: options.signal, chromeBinary });
101
+ for (const file of result.observed_files) options.observe?.({ kind: 'file_read', file_path: file.file_path, sha256: file.sha256 });
102
+ if (result.status === 'passed') await (await ledger()).recordAccepted({ component: input.component, build_digest: result.build_digest, setup_digest: result.setup_digest,
103
+ references_digest: result.references_digest, criteria_digest: result.criteria_digest, run_id: result.run_id, session_id: options.sessionId ?? null, invocation_id: options.invocation_id ?? null, at: new Date().toISOString() });
104
+ for (const pair of result.pairs) {
105
+ const refs = referencePaths(cwd, input.component, pair.key);
106
+ for (const file of [refs.png, refs.meta]) { const bytes = await fs.readFile(file).catch(() => null); if (bytes) options.observe?.({ kind: 'file_read', file_path: path.relative(cwd, file), sha256: sha256(bytes) }); }
107
+ }
108
+ const ordered = [...result.pairs].sort((a, b) => (a.verdict === 'pass') - (b.verdict === 'pass') || b.mandatory - a.mandatory);
109
+ const images = [];
110
+ for (const pair of ordered) {
111
+ if (images.length >= 2 || !pair.images.composite) continue;
112
+ const block = imageBlock(await fs.readFile(path.join(cwd, pair.images.composite)));
113
+ if (block) images.push({ pair: pair.key, layout: pair.images.diff ? 'reference | capture | diff' : pair.images.reference ? 'reference | capture' : 'capture', image: block });
114
+ }
115
+ const next = result.status === 'passed' ? 'Aceptación visual aprobada para este build. Entrega el reporte al humano para su aceptación final.'
116
+ : result.status === 'failed' ? 'Corrige la extensión según los aspectos que fallan y vuelve a correr VisualVerify. No edites referencias aprobadas.'
117
+ : 'Bloqueado: resuelve el motivo (referencias o criterios sin aprobar, entorno distinto, revisor sin visión). El humano aprueba con /visual.';
118
+ return JSON.stringify({ ...publicResult(result), images, next_action: next });
119
+ } },
120
+ { name: 'VisualFreeze', description: 'Record a live https page as a frozen fixture under visual/fixtures/<fixture> (DOM without scripts, CSS and resources) so it replays offline at its own origin. Does not overwrite an existing fixture.',
121
+ input_schema: schema({ url: { type: 'string', minLength: 9, maxLength: 2000, pattern: '^https://' }, fixture: ID }, ['url', 'fixture']), effects: BROWSER,
122
+ run: async (input, options) => {
123
+ const dir = path.join(cwd, VISUAL_DIR, 'fixtures', input.fixture);
124
+ if (await fs.stat(dir).catch(() => null)) throw fail(`Ya existe ${path.relative(cwd, dir)}; elige otro id de fixture.`);
125
+ const session = await launchChrome({ binary: chromeBinary, viewport: { width: 1280, height: 800, deviceScaleFactor: 1 }, signal: options.signal });
126
+ try {
127
+ const fixture = await freezePage({ session, url: input.url, dir, id: input.fixture, signal: options.signal });
128
+ return JSON.stringify({ fixture: { ...fixture, dir: path.relative(cwd, dir) }, next_action: `Agrégalo al componente como {"id":"${input.fixture}","kind":"frozen"}.` });
129
+ } finally { await session.close(); }
130
+ } },
131
+ { name: 'VisualReferenceDraft', description: 'Propose a reference image for one fixture/state: source "capture" uses the current extension build, source "maquette" renders a static HTML maquette (inside the workspace) at the fixture origin without the extension. It is only a draft until the human approves it with /visual approve. The maquette replaces the whole page, so draw the page as it should look with the component open; files of the fixture are served at the same origin. Keep maquettes under visual/maquettes/<component>/<fixture>--<state>.html and pass that path as maquette (required with source "maquette"). mode "mockup" is judged by an independent visual reviewer (structure, colour, size, spacing, position); "exact" compares pixels with the reference.',
132
+ input_schema: schema({ component: ID, fixture: ID, state: ID, mode: { enum: ['exact', 'mockup'] }, source: { enum: ['capture', 'maquette'] }, maquette: { type: 'string', minLength: 1, maxLength: 1000 } }, ['component', 'fixture', 'state', 'mode', 'source']), effects: BROWSER,
133
+ run: async (input, options) => {
134
+ const key = pairKey(input.fixture, input.state), spec = await loadComponent(cwd, input.component);
135
+ if (!spec.states.some(s => s.id === input.state && s.fixtures.includes(input.fixture))) throw fail(`El componente ${input.component} no tiene el par ${key}.`);
136
+ let shot;
137
+ if (input.source === 'maquette') {
138
+ if (!input.maquette) throw fail('source maquette requiere maquette (ruta del HTML).');
139
+ shot = await captureMaquette({ cwd, stateDir, component: input.component, fixture: input.fixture, file: input.maquette, chromeBinary, signal: options.signal });
140
+ } else {
141
+ if (input.maquette) throw fail('maquette sólo aplica con source maquette.');
142
+ const result = await verifyComponent({ cwd, stateDir, keyDir, component: input.component, mode: 'inspection', pairs: [key], signal: options.signal, chromeBinary });
143
+ if (result.pairs[0]?.reason && /No se llegó|no está montado/.test(result.pairs[0].reason)) throw fail(`No se puede proponer la captura: ${result.pairs[0].reason}`);
144
+ shot = result.captures.get(key);
145
+ }
146
+ const written = await writeReferenceDraft({ cwd, component: input.component, fixture: input.fixture, state: input.state, mode: input.mode, png: shot.capture, fingerprint: shot.fingerprint, source: input.source });
147
+ for (const file of [written.png, written.meta]) options.observe?.({ kind: 'file_read', file_path: file, sha256: sha256(await fs.readFile(path.join(cwd, file))) });
148
+ const image = imageBlock(shot.capture);
149
+ return JSON.stringify({ draft: written, mode: input.mode, approved: false, ...(image ? { image } : {}), human_action: `/visual approve ${input.component} ${input.fixture} ${input.state} ${short(written.digest)}` });
150
+ } },
151
+ { name: 'VisualApproveSetup', description: 'Human approval of the component configuration (spec file and every local/frozen fixture) shown by /visual status.', audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: HUMAN,
152
+ input_schema: schema({ component: ID, digest: DIGEST }, ['component', 'digest']),
153
+ run: async (input, options) => {
154
+ requireHuman(options);
155
+ const spec = await loadComponent(cwd, input.component), digest = await setupDigest(cwd, spec), manifest = await setupManifest(cwd, spec);
156
+ if (!digestMatches(input.digest, digest)) throw fail(`La configuración de ${input.component} no coincide con el digest ${input.digest} (actual ${short(digest)}); revisa /visual status antes de aprobar.`);
157
+ const entry = await (await ledger()).append({ kind: 'setup', component: input.component, key: 'setup', digest, payload: { fixtures: spec.fixtures.map(f => ({ id: f.id, kind: f.kind, mandatory: f.mandatory })), states: spec.states.map(s => s.id), root: spec.root, extension: spec.extension, manifest },
158
+ approver: options.actor.id, session_id: options.sessionId, invocation_id: options.invocation_id });
159
+ return JSON.stringify({ approved_setup: { component: input.component, digest, at: entry.at } });
160
+ } },
161
+ { name: 'VisualApprove', description: 'Human approval of the current reference image of one fixture/state, bound to the digest shown by /visual status.', audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: HUMAN,
162
+ input_schema: schema({ component: ID, fixture: ID, state: ID, digest: DIGEST }, ['component', 'fixture', 'state', 'digest']),
163
+ run: async (input, options) => {
164
+ requireHuman(options);
165
+ await loadComponent(cwd, input.component);
166
+ const key = pairKey(input.fixture, input.state), reference = await readReference(cwd, input.component, key);
167
+ if (!reference) throw fail(`No existe la referencia ${path.relative(cwd, referencePaths(cwd, input.component, key).png)} con su .json.`);
168
+ if (reference.error) throw fail(`Referencia inválida: ${reference.error}`);
169
+ if (!digestMatches(input.digest, reference.digest)) throw fail(`La referencia ${key} cambió desde que la viste (digest actual ${short(reference.digest)}); revísala de nuevo antes de aprobar.`);
170
+ const entry = await (await ledger()).append({ kind: 'reference', component: input.component, key, digest: reference.digest,
171
+ payload: { mode: reference.mode, tolerance: reference.meta.tolerance, png_sha256: reference.png_sha256, meta_sha256: reference.meta_sha256, file: reference.file }, approver: options.actor.id, session_id: options.sessionId, invocation_id: options.invocation_id });
172
+ return JSON.stringify({ approved: { component: input.component, key, mode: reference.mode, tolerance: reference.meta.tolerance, file: reference.file, digest: reference.digest, at: entry.at } });
173
+ } },
174
+ { name: 'VisualConfirm', description: 'Human confirmation of one proposed criterion from visual/criteria/<component>.json, bound to the digest shown by /visual status; from now on it is a permanent criterion of that component.', audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: HUMAN,
175
+ input_schema: schema({ component: ID, criterion: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }, digest: DIGEST }, ['component', 'criterion', 'digest']),
176
+ run: async (input, options) => {
177
+ requireHuman(options);
178
+ const { proposedCriteria: criteria, proposalErrors: errors } = await componentInventory(cwd, input.component), found = criteria.find(c => c.id === input.criterion);
179
+ if (!found) throw fail(`No hay un criterio propuesto ${input.criterion} válido en ${VISUAL_DIR}/criteria/${input.component}.json.${errors.length ? ' Errores: ' + errors.join('; ') : ''}`);
180
+ const criterion = normalizeCriterion(found), digest = criterionDigest(criterion);
181
+ if (!digestMatches(input.digest, digest)) throw fail(`El criterio ${criterion.id} cambió desde que lo viste (digest actual ${short(digest)}); revísalo de nuevo antes de confirmar.`);
182
+ const entry = await (await ledger()).append({ kind: 'criterion', component: input.component, key: criterion.id, digest, payload: criterion,
183
+ approver: options.actor.id, session_id: options.sessionId, invocation_id: options.invocation_id });
184
+ return JSON.stringify({ confirmed: { component: input.component, criterion, at: entry.at } });
185
+ } },
186
+ { name: 'VisualRevoke', description: 'Human revocation of the approved configuration, an approved reference or a confirmed criterion of a component.', audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: HUMAN,
187
+ input_schema: schema({ component: ID, kind: { enum: ['setup', 'reference', 'criterion'] }, key: { type: 'string', minLength: 1, maxLength: 200 } }, ['component', 'kind', 'key']),
188
+ run: async (input, options) => {
189
+ requireHuman(options);
190
+ const effective = await (await ledger()).effective(input.component);
191
+ const present = input.kind === 'setup' ? (input.key === 'setup' ? effective.setup : null) : input.kind === 'reference' ? effective.references[input.key] : effective.criteria_records[input.key];
192
+ if (!present) throw fail(`No hay ${input.kind === 'setup' ? 'una configuración aprobada' : input.kind === 'reference' ? 'una referencia aprobada' : 'un criterio confirmado'} ${input.key} en ${input.component}.`);
193
+ const entry = await (await ledger()).append({ kind: 'revoke', component: input.component, key: `${input.kind}:${input.key}`, digest: present.digest, payload: {},
194
+ approver: options.actor.id, session_id: options.sessionId, invocation_id: options.invocation_id });
195
+ // Revoking the setup retires the component from the close guard's drift rule.
196
+ if (input.kind === 'setup') await (await ledger()).forgetAccepted(input.component);
197
+ return JSON.stringify({ revoked: { component: input.component, kind: input.kind, key: input.key, at: entry.at } });
198
+ } },
199
+ ];
200
+ }
201
+
202
+ /** Parse "/visual ..." arguments into [operation, input]. */
203
+ export function parseVisualCommand(arg) {
204
+ const words = String(arg || '').trim().split(/\s+/).filter(Boolean), [action = 'status', ...rest] = words;
205
+ if (action === 'status' && rest.length <= 1) return ['VisualBoard', rest[0] ? { component: rest[0] } : {}];
206
+ if (action === 'approve-setup' && rest.length === 2) return ['VisualApproveSetup', { component: rest[0], digest: rest[1] }];
207
+ if (action === 'approve' && rest.length === 4) return ['VisualApprove', { component: rest[0], fixture: rest[1], state: rest[2], digest: rest[3] }];
208
+ if (action === 'confirm' && rest.length === 3) return ['VisualConfirm', { component: rest[0], criterion: rest[1], digest: rest[2] }];
209
+ if (action === 'revoke' && rest.length === 3 && ['setup', 'reference', 'criterion'].includes(rest[1])) return ['VisualRevoke', { component: rest[0], kind: rest[1], key: rest[2] }];
210
+ throw fail('Uso: /visual [status [C]] | approve-setup C DIGEST | approve C FIXTURE ESTADO DIGEST | confirm C CRITERIO DIGEST | revoke C setup|reference|criterion CLAVE (DIGEST: el prefijo que muestra /visual status)', 'VISUAL_COMMAND_INVALID');
211
+ }
@@ -0,0 +1,96 @@
1
+ // Exact-mode pixel comparison. Perceptual YIQ colour distance with a threshold,
2
+ // plus antialiasing detection so font smoothing at one-pixel edges is the only
3
+ // tolerated difference by default. Images are RGBA8 ({width, height, data}).
4
+
5
+ export const DEFAULT_EXACT_TOLERANCE = Object.freeze({ threshold: 0.1, includeAntialiasing: false, maxDiffPixels: 0, maxDiffRatio: 0 });
6
+ const MAX_YIQ_DELTA = 35215;
7
+
8
+ export function normalizeTolerance(value) {
9
+ if (value === undefined || value === null) return DEFAULT_EXACT_TOLERANCE;
10
+ const bad = message => Object.assign(new Error(`Tolerancia inválida: ${message}`), { code: 'VISUAL_TOLERANCE_INVALID' });
11
+ if (typeof value !== 'object' || Array.isArray(value)) throw bad('debe ser un objeto');
12
+ for (const key of Object.keys(value)) if (!(key in DEFAULT_EXACT_TOLERANCE)) throw bad(`campo desconocido ${key}`);
13
+ const t = { ...DEFAULT_EXACT_TOLERANCE, ...value };
14
+ if (typeof t.threshold !== 'number' || !(t.threshold >= 0 && t.threshold <= 1)) throw bad('threshold entre 0 y 1');
15
+ if (typeof t.includeAntialiasing !== 'boolean') throw bad('includeAntialiasing booleano');
16
+ if (!Number.isSafeInteger(t.maxDiffPixels) || t.maxDiffPixels < 0) throw bad('maxDiffPixels entero >= 0');
17
+ if (typeof t.maxDiffRatio !== 'number' || !(t.maxDiffRatio >= 0 && t.maxDiffRatio <= 1)) throw bad('maxDiffRatio entre 0 y 1');
18
+ return Object.freeze(t);
19
+ }
20
+
21
+ // Colour channels blended over white, as a viewer would see a transparent pixel.
22
+ function blended(data, i) {
23
+ const a = data[i + 3] / 255;
24
+ return [255 + (data[i] - 255) * a, 255 + (data[i + 1] - 255) * a, 255 + (data[i + 2] - 255) * a];
25
+ }
26
+ const yOf = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
27
+ const iOf = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
28
+ const qOf = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
29
+
30
+ // Signed perceptual distance: positive when the second pixel is lighter.
31
+ function colorDelta(a, ia, b, ib, yOnly = false) {
32
+ const [r1, g1, b1] = blended(a, ia), [r2, g2, b2] = blended(b, ib);
33
+ const y = yOf(r1, g1, b1) - yOf(r2, g2, b2);
34
+ if (yOnly) return y;
35
+ const i = iOf(r1, g1, b1) - iOf(r2, g2, b2), q = qOf(r1, g1, b1) - qOf(r2, g2, b2);
36
+ const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
37
+ return y > 0 ? -delta : delta;
38
+ }
39
+
40
+ // True when (x, y) has many identical neighbours (part of a flat area).
41
+ function flatNeighbours(img, x, y) {
42
+ let same = 0; const i = (y * img.width + x) * 4;
43
+ for (let ny = Math.max(0, y - 1); ny <= Math.min(img.height - 1, y + 1); ny++)
44
+ for (let nx = Math.max(0, x - 1); nx <= Math.min(img.width - 1, x + 1); nx++) {
45
+ if (nx === x && ny === y) continue;
46
+ const j = (ny * img.width + nx) * 4;
47
+ if (img.data[i] === img.data[j] && img.data[i + 1] === img.data[j + 1] && img.data[i + 2] === img.data[j + 2] && img.data[i + 3] === img.data[j + 3]) same++;
48
+ if (same > 2) return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ // A pixel is antialiased when, in one image, it sits between a darkest and a
54
+ // brightest neighbour and either of those belongs to a flat area in both images.
55
+ function antialiased(img, x, y, other) {
56
+ let zeroes = x === 0 || x === img.width - 1 || y === 0 || y === img.height - 1 ? 1 : 0;
57
+ let min = 0, max = 0, minX = -1, minY = -1, maxX = -1, maxY = -1;
58
+ const i = (y * img.width + x) * 4;
59
+ for (let ny = Math.max(0, y - 1); ny <= Math.min(img.height - 1, y + 1); ny++)
60
+ for (let nx = Math.max(0, x - 1); nx <= Math.min(img.width - 1, x + 1); nx++) {
61
+ if (nx === x && ny === y) continue;
62
+ const delta = colorDelta(img.data, i, img.data, (ny * img.width + nx) * 4, true);
63
+ if (delta === 0) { if (++zeroes > 2) return false; }
64
+ else if (delta < min) { min = delta; minX = nx; minY = ny; }
65
+ else if (delta > max) { max = delta; maxX = nx; maxY = ny; }
66
+ }
67
+ if (min === 0 || max === 0) return false;
68
+ return (flatNeighbours(img, minX, minY) && flatNeighbours(other, minX, minY)) || (flatNeighbours(img, maxX, maxY) && flatNeighbours(other, maxX, maxY));
69
+ }
70
+
71
+ export function comparePixels(reference, actual, toleranceInput) {
72
+ const tolerance = normalizeTolerance(toleranceInput);
73
+ if (reference.width !== actual.width || reference.height !== actual.height)
74
+ return { passed: false, reason: 'dimension_mismatch', reference_size: { width: reference.width, height: reference.height },
75
+ actual_size: { width: actual.width, height: actual.height }, mismatched: null, antialiased: null, ratio: null, bounds: null, diff: null, tolerance };
76
+ const { width, height } = reference, total = width * height, maxDelta = MAX_YIQ_DELTA * tolerance.threshold * tolerance.threshold;
77
+ const diff = new Uint8Array(total * 4);
78
+ let mismatched = 0, aa = 0, minX = width, minY = height, maxX = -1, maxY = -1;
79
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
80
+ const i = (y * width + x) * 4, delta = Math.abs(colorDelta(reference.data, i, actual.data, i));
81
+ let colour;
82
+ if (delta > maxDelta) {
83
+ if (!tolerance.includeAntialiasing && (antialiased(reference, x, y, actual) || antialiased(actual, x, y, reference))) { aa++; colour = [255, 255, 0, 255]; }
84
+ else { mismatched++; colour = [255, 0, 0, 255]; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; }
85
+ } else {
86
+ // Faded grayscale of the reference keeps the layout readable under the marks.
87
+ const [r, g, b] = blended(reference.data, i), v = 255 + (yOf(r, g, b) - 255) * 0.1;
88
+ colour = [v, v, v, 255];
89
+ }
90
+ diff.set(colour, i);
91
+ }
92
+ const ratio = mismatched / total;
93
+ return { passed: mismatched <= tolerance.maxDiffPixels || ratio <= tolerance.maxDiffRatio && tolerance.maxDiffRatio > 0,
94
+ reason: null, mismatched, antialiased: aa, ratio, bounds: maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
95
+ diff: { width, height, data: diff }, tolerance };
96
+ }
@@ -0,0 +1,103 @@
1
+ // Minimal PNG codec for visual acceptance: 8-bit, non-interlaced grayscale, RGB,
2
+ // gray+alpha and RGBA, which covers Chrome captures and common image tools.
3
+ // Decoded images are always RGBA8 ({width, height, data: Uint8Array}).
4
+ import { deflateSync, inflateSync } from 'node:zlib';
5
+
6
+ const SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');
7
+ export const PNG_MAX_PIXELS = 40_000_000;
8
+ const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
9
+
10
+ const CRC_TABLE = (() => {
11
+ const table = new Uint32Array(256);
12
+ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; }
13
+ return table;
14
+ })();
15
+ function crc32(buffer) {
16
+ let crc = 0xffffffff;
17
+ for (let i = 0; i < buffer.length; i++) crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
18
+ return (crc ^ 0xffffffff) >>> 0;
19
+ }
20
+ const fail = message => Object.assign(new Error(`PNG inválido: ${message}`), { code: 'VISUAL_PNG_INVALID' });
21
+
22
+ export function pngDimensions(bytes) {
23
+ return bytes?.length >= 24 && SIGNATURE.equals(bytes.subarray(0, 8)) && bytes.subarray(12, 16).toString('latin1') === 'IHDR'
24
+ ? { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) } : null;
25
+ }
26
+
27
+ export function decodePng(input) {
28
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
29
+ if (bytes.length < 8 || !SIGNATURE.equals(bytes.subarray(0, 8))) throw fail('firma ausente');
30
+ let offset = 8, header = null; const idat = [];
31
+ while (offset < bytes.length) {
32
+ if (offset + 12 > bytes.length) throw fail('chunk truncado');
33
+ const length = bytes.readUInt32BE(offset), type = bytes.subarray(offset + 4, offset + 8).toString('latin1');
34
+ const end = offset + 12 + length;
35
+ if (end > bytes.length) throw fail('chunk truncado');
36
+ const body = bytes.subarray(offset + 8, offset + 8 + length);
37
+ if (crc32(bytes.subarray(offset + 4, offset + 8 + length)) !== bytes.readUInt32BE(offset + 8 + length)) throw fail(`CRC de ${type}`);
38
+ if (type === 'IHDR') {
39
+ if (length !== 13) throw fail('IHDR');
40
+ header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], compression: body[10], filter: body[11], interlace: body[12] };
41
+ if (!header.width || !header.height) throw fail('dimensiones vacías');
42
+ if (header.width * header.height > PNG_MAX_PIXELS) throw Object.assign(new Error(`PNG excede ${PNG_MAX_PIXELS} pixels.`), { code: 'VISUAL_PNG_TOO_LARGE' });
43
+ if (header.depth !== 8 || !(header.color in CHANNELS)) throw fail('sólo se admiten 8 bits gris, RGB, gris+alfa o RGBA (sin paleta)');
44
+ if (header.compression !== 0 || header.filter !== 0 || header.interlace !== 0) throw fail('sólo se admiten imágenes no entrelazadas');
45
+ } else if (type === 'IDAT') idat.push(body);
46
+ else if (type === 'IEND') break;
47
+ offset = end;
48
+ }
49
+ if (!header) throw fail('IHDR ausente');
50
+ if (!idat.length) throw fail('IDAT ausente');
51
+ const channels = CHANNELS[header.color], stride = header.width * channels;
52
+ let raw;
53
+ try { raw = inflateSync(Buffer.concat(idat), { maxOutputLength: (stride + 1) * header.height + 1 }); }
54
+ catch { throw fail('datos comprimidos'); }
55
+ if (raw.length !== (stride + 1) * header.height) throw fail('tamaño de datos');
56
+ const pixels = new Uint8Array(stride * header.height);
57
+ for (let y = 0; y < header.height; y++) {
58
+ const filter = raw[y * (stride + 1)], src = y * (stride + 1) + 1, dst = y * stride;
59
+ for (let i = 0; i < stride; i++) {
60
+ const a = i >= channels ? pixels[dst + i - channels] : 0, b = y ? pixels[dst - stride + i] : 0, c = y && i >= channels ? pixels[dst - stride + i - channels] : 0;
61
+ let predictor;
62
+ if (filter === 0) predictor = 0; else if (filter === 1) predictor = a; else if (filter === 2) predictor = b;
63
+ else if (filter === 3) predictor = (a + b) >> 1;
64
+ else if (filter === 4) { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); predictor = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
65
+ else throw fail(`filtro ${filter}`);
66
+ pixels[dst + i] = (raw[src + i] + predictor) & 255;
67
+ }
68
+ }
69
+ const data = new Uint8Array(header.width * header.height * 4);
70
+ for (let i = 0, j = 0; i < header.width * header.height; i++, j += channels) {
71
+ const o = i * 4;
72
+ if (channels === 4) { data[o] = pixels[j]; data[o + 1] = pixels[j + 1]; data[o + 2] = pixels[j + 2]; data[o + 3] = pixels[j + 3]; }
73
+ else if (channels === 3) { data[o] = pixels[j]; data[o + 1] = pixels[j + 1]; data[o + 2] = pixels[j + 2]; data[o + 3] = 255; }
74
+ else if (channels === 2) { data[o] = data[o + 1] = data[o + 2] = pixels[j]; data[o + 3] = pixels[j + 1]; }
75
+ else { data[o] = data[o + 1] = data[o + 2] = pixels[j]; data[o + 3] = 255; }
76
+ }
77
+ return { width: header.width, height: header.height, data };
78
+ }
79
+
80
+ function pngChunk(type, body) {
81
+ const length = Buffer.alloc(4); length.writeUInt32BE(body.length);
82
+ const typed = Buffer.concat([Buffer.from(type, 'latin1'), body]);
83
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(typed));
84
+ return Buffer.concat([length, typed, crc]);
85
+ }
86
+
87
+ export function encodePng({ width, height, data }) {
88
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) throw fail('dimensiones');
89
+ if (data.length !== width * height * 4) throw fail('los datos no son RGBA del tamaño declarado');
90
+ const stride = width * 4, raw = Buffer.alloc((stride + 1) * height);
91
+ for (let y = 0; y < height; y++) { raw[y * (stride + 1)] = 0; Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1); }
92
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
93
+ return Buffer.concat([SIGNATURE, pngChunk('IHDR', ihdr), pngChunk('IDAT', deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0))]);
94
+ }
95
+
96
+ /** Crop an RGBA image to an integer rectangle fully inside it. */
97
+ export function cropImage(image, { x, y, width, height }) {
98
+ if (![x, y, width, height].every(Number.isSafeInteger) || x < 0 || y < 0 || width < 1 || height < 1 || x + width > image.width || y + height > image.height)
99
+ throw Object.assign(new Error('Recorte fuera de la imagen.'), { code: 'VISUAL_CROP_OUTSIDE' });
100
+ const data = new Uint8Array(width * height * 4);
101
+ for (let row = 0; row < height; row++) data.set(image.data.subarray(((y + row) * image.width + x) * 4, ((y + row) * image.width + x + width) * 4), row * width * 4);
102
+ return { width, height, data };
103
+ }