sneakoscope 0.9.12 → 0.9.13

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 (39) hide show
  1. package/README.md +8 -2
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +142 -2
  5. package/package.json +4 -2
  6. package/src/cli/command-registry.mjs +3 -3
  7. package/src/cli/feature-commands.mjs +42 -11
  8. package/src/cli/install-helpers.mjs +14 -7
  9. package/src/cli/legacy-main.mjs +5 -4
  10. package/src/commands/codex-app.mjs +30 -0
  11. package/src/commands/codex-lb.mjs +18 -2
  12. package/src/commands/db.mjs +6 -0
  13. package/src/commands/doctor.mjs +46 -0
  14. package/src/commands/proof.mjs +38 -2
  15. package/src/commands/wiki.mjs +53 -2
  16. package/src/core/codex-lb-circuit.mjs +45 -1
  17. package/src/core/db-safety.mjs +17 -2
  18. package/src/core/feature-fixtures.mjs +39 -1
  19. package/src/core/feature-registry.mjs +87 -4
  20. package/src/core/fsx.mjs +1 -1
  21. package/src/core/hooks-runtime.mjs +12 -3
  22. package/src/core/pipeline.mjs +18 -0
  23. package/src/core/proof/evidence-collector.mjs +7 -0
  24. package/src/core/proof/proof-reader.mjs +11 -0
  25. package/src/core/proof/proof-redaction.test-helper.mjs +9 -0
  26. package/src/core/proof/route-adapter.mjs +74 -0
  27. package/src/core/proof/route-proof-gate.mjs +33 -0
  28. package/src/core/proof/route-proof-policy.mjs +96 -0
  29. package/src/core/proof/selftest-proof-fixtures.mjs +54 -0
  30. package/src/core/proof/validation.mjs +1 -0
  31. package/src/core/rust-accelerator.mjs +29 -7
  32. package/src/core/version.mjs +1 -1
  33. package/src/core/wiki-image/callout-parser.mjs +16 -0
  34. package/src/core/wiki-image/computer-use-ledger.mjs +38 -0
  35. package/src/core/wiki-image/image-relation.mjs +2 -0
  36. package/src/core/wiki-image/image-voxel-ledger.mjs +43 -0
  37. package/src/core/wiki-image/proof-linker.mjs +12 -0
  38. package/src/core/wiki-image/validation.mjs +16 -5
  39. package/src/core/wiki-image/visual-anchor.mjs +14 -1
@@ -0,0 +1,74 @@
1
+ import path from 'node:path';
2
+ import { collectProofEvidence } from './evidence-collector.mjs';
3
+ import { writeCompletionProof } from './proof-writer.mjs';
4
+ import { normalizeProofRoute, routeRequiresImageVoxelAnchors } from './route-proof-policy.mjs';
5
+
6
+ export async function writeRouteCompletionProof(root, {
7
+ missionId = null,
8
+ route = null,
9
+ status = 'verified_partial',
10
+ gate = null,
11
+ summary = {},
12
+ artifacts = [],
13
+ evidence = {},
14
+ claims = [],
15
+ unverified = [],
16
+ blockers = [],
17
+ nextHumanActions = []
18
+ } = {}) {
19
+ const collected = await collectProofEvidence(root);
20
+ const normalizedRoute = normalizeProofRoute(route);
21
+ const mergedEvidence = {
22
+ ...collected,
23
+ ...evidence,
24
+ route_gate: gate || evidence.route_gate || null,
25
+ artifacts: normalizeArtifacts(root, artifacts)
26
+ };
27
+ const normalizedStatus = normalizeRouteProofStatus(status, {
28
+ route: normalizedRoute,
29
+ evidence: mergedEvidence,
30
+ blockers,
31
+ unverified
32
+ });
33
+ return writeCompletionProof(root, {
34
+ mission_id: missionId,
35
+ route: normalizedRoute,
36
+ status: normalizedStatus,
37
+ summary: {
38
+ files_changed: collected.files?.length || 0,
39
+ commands_run: mergedEvidence.commands?.length || 0,
40
+ tests_passed: 0,
41
+ tests_failed: 0,
42
+ manual_review_required: normalizedStatus !== 'verified',
43
+ ...summary
44
+ },
45
+ evidence: mergedEvidence,
46
+ claims,
47
+ unverified,
48
+ blockers,
49
+ next_human_actions: nextHumanActions
50
+ }, {
51
+ command: {
52
+ cmd: `sks proof route ${missionId || 'latest'}`,
53
+ route: normalizedRoute,
54
+ status: normalizedStatus
55
+ }
56
+ });
57
+ }
58
+
59
+ function normalizeRouteProofStatus(status, { route, evidence, blockers, unverified }) {
60
+ if (blockers?.length) return status === 'failed' ? 'failed' : 'blocked';
61
+ if (status === 'verified' && unverified?.length) return 'verified_partial';
62
+ if (routeRequiresImageVoxelAnchors(route)) {
63
+ const anchors = evidence?.image_voxels?.anchors ?? evidence?.image_voxels?.anchor_count ?? 0;
64
+ if (Number(anchors) <= 0) return status === 'verified' ? 'blocked' : status;
65
+ }
66
+ return status;
67
+ }
68
+
69
+ function normalizeArtifacts(root, artifacts = []) {
70
+ return artifacts.map((artifact) => {
71
+ if (typeof artifact !== 'string') return artifact;
72
+ return path.isAbsolute(artifact) ? path.relative(root, artifact).split(path.sep).join('/') : artifact;
73
+ });
74
+ }
@@ -0,0 +1,33 @@
1
+ import { containsPlaintextSecret } from '../secret-redaction.mjs';
2
+ import { readRouteProof } from './proof-reader.mjs';
3
+ import { validateCompletionProof } from './validation.mjs';
4
+ import { proofStatusBlocks, routeRequiresCompletionProof, routeRequiresImageVoxelAnchors } from './route-proof-policy.mjs';
5
+
6
+ export async function validateRouteCompletionProof(root, { missionId = null, route = null, state = {}, visualClaim = true } = {}) {
7
+ const proofRequired = state.proof_required === true || routeRequiresCompletionProof(route);
8
+ if (!proofRequired) return { ok: true, required: false, status: 'not_required', issues: [] };
9
+ const proof = await readRouteProof(root, missionId);
10
+ if (!proof) {
11
+ return {
12
+ ok: false,
13
+ required: true,
14
+ status: 'blocked',
15
+ issues: ['completion_proof_missing']
16
+ };
17
+ }
18
+ const validation = validateCompletionProof(proof);
19
+ const issues = [...validation.issues];
20
+ if (proofStatusBlocks(proof.status)) issues.push(`proof_status_${proof.status}`);
21
+ if (containsPlaintextSecret(proof)) issues.push('plaintext_secret');
22
+ if (routeRequiresImageVoxelAnchors(route || proof.route, { visualClaim })) {
23
+ const anchors = proof.evidence?.image_voxels?.anchors ?? proof.evidence?.image_voxels?.anchor_count ?? 0;
24
+ if (Number(anchors) <= 0) issues.push('image_voxel_anchors_missing');
25
+ }
26
+ return {
27
+ ok: issues.length === 0,
28
+ required: true,
29
+ status: issues.length ? 'blocked' : proof.status,
30
+ issues,
31
+ proof
32
+ };
33
+ }
@@ -0,0 +1,96 @@
1
+ export const SERIOUS_ROUTE_ALIASES = Object.freeze([
2
+ '$Team',
3
+ '$DFix',
4
+ '$QA-LOOP',
5
+ '$Research',
6
+ '$AutoResearch',
7
+ '$PPT',
8
+ '$Image-UX-Review',
9
+ '$UX-Review',
10
+ '$Visual-Review',
11
+ '$UI-UX-Review',
12
+ '$From-Chat-IMG',
13
+ '$Computer-Use',
14
+ '$CU',
15
+ '$DB',
16
+ '$Wiki',
17
+ '$GX',
18
+ '$Goal',
19
+ '$MAD-SKS',
20
+ 'hproof',
21
+ 'proof-field',
22
+ 'recallpulse'
23
+ ]);
24
+
25
+ export const VISUAL_ROUTE_ALIASES = Object.freeze([
26
+ '$Image-UX-Review',
27
+ '$UX-Review',
28
+ '$Visual-Review',
29
+ '$UI-UX-Review',
30
+ '$From-Chat-IMG',
31
+ '$PPT',
32
+ '$QA-LOOP',
33
+ '$Computer-Use',
34
+ '$CU',
35
+ '$GX'
36
+ ]);
37
+
38
+ const ROUTE_NORMALIZATION = Object.freeze({
39
+ team: '$Team',
40
+ dfix: '$DFix',
41
+ qaloop: '$QA-LOOP',
42
+ 'qa-loop': '$QA-LOOP',
43
+ research: '$Research',
44
+ autoresearch: '$AutoResearch',
45
+ ppt: '$PPT',
46
+ imageuxreview: '$Image-UX-Review',
47
+ 'image-ux-review': '$Image-UX-Review',
48
+ uxreview: '$UX-Review',
49
+ 'ux-review': '$UX-Review',
50
+ visualreview: '$Visual-Review',
51
+ 'visual-review': '$Visual-Review',
52
+ uiuxreview: '$UI-UX-Review',
53
+ 'ui-ux-review': '$UI-UX-Review',
54
+ fromchatimg: '$From-Chat-IMG',
55
+ 'from-chat-img': '$From-Chat-IMG',
56
+ computeruse: '$Computer-Use',
57
+ 'computer-use': '$Computer-Use',
58
+ cu: '$CU',
59
+ db: '$DB',
60
+ wiki: '$Wiki',
61
+ gx: '$GX',
62
+ goal: '$Goal',
63
+ madsks: '$MAD-SKS',
64
+ 'mad-sks': '$MAD-SKS',
65
+ hproof: 'hproof',
66
+ prooffield: 'proof-field',
67
+ 'proof-field': 'proof-field',
68
+ recallpulse: 'recallpulse'
69
+ });
70
+
71
+ export function normalizeProofRoute(route) {
72
+ const raw = String(route || '').trim();
73
+ if (!raw) return null;
74
+ if (SERIOUS_ROUTE_ALIASES.includes(raw) || VISUAL_ROUTE_ALIASES.includes(raw)) return raw;
75
+ const stripped = raw.replace(/^\$/, '').replace(/[^A-Za-z0-9-]+/g, '').toLowerCase();
76
+ return ROUTE_NORMALIZATION[stripped] || raw;
77
+ }
78
+
79
+ export function routeRequiresCompletionProof(route) {
80
+ const normalized = normalizeProofRoute(route);
81
+ return SERIOUS_ROUTE_ALIASES.includes(normalized);
82
+ }
83
+
84
+ export function routeRequiresImageVoxelAnchors(route, opts = {}) {
85
+ const normalized = normalizeProofRoute(route);
86
+ if (opts.visualClaim === false) return false;
87
+ return VISUAL_ROUTE_ALIASES.includes(normalized);
88
+ }
89
+
90
+ export function routeFromState(state = {}) {
91
+ return normalizeProofRoute(state.route_command || state.route || state.mode || state.route_id || state.id);
92
+ }
93
+
94
+ export function proofStatusBlocks(status) {
95
+ return status === 'failed' || status === 'blocked' || status === 'not_verified';
96
+ }
@@ -0,0 +1,54 @@
1
+ import { writeRouteCompletionProof } from './route-adapter.mjs';
2
+
3
+ const CLAIM_TEXT = {
4
+ hard_blocker: 'Hard blocker unblocks incomplete active gate after repeated identical compliance stops.',
5
+ team_gate: 'Team selftest fixture reached Completion Proof gate before reflection validation.',
6
+ subagent_gate: 'Subagent selftest fixture records Completion Proof after subagent evidence.'
7
+ };
8
+
9
+ const DEFAULTS = {
10
+ hard_blocker: {
11
+ artifacts: ['hard-blocker.json', 'compliance-loop-guard.json'],
12
+ gateSource: 'selftest-hard-blocker',
13
+ unverified: ['selftest fixture does not claim a real Team run completed']
14
+ },
15
+ team_gate: {
16
+ artifacts: ['team-gate.json', 'team-session-cleanup.json'],
17
+ gateSource: 'selftest-route-gate',
18
+ unverified: ['selftest fixture does not claim a real Team implementation run completed']
19
+ },
20
+ subagent_gate: {
21
+ artifacts: ['team-gate.json', 'team-session-cleanup.json', 'reflection-gate.json'],
22
+ gateSource: 'selftest-subagent-gate',
23
+ unverified: ['selftest fixture records mocked subagent evidence only']
24
+ }
25
+ };
26
+
27
+ export async function writeSelftestRouteProof(root, {
28
+ missionId,
29
+ route = '$Team',
30
+ kind = 'team_gate',
31
+ artifacts = null,
32
+ gateSource = null,
33
+ unverified = null
34
+ } = {}) {
35
+ const defaults = DEFAULTS[kind] || DEFAULTS.team_gate;
36
+ return writeRouteCompletionProof(root, {
37
+ missionId,
38
+ route,
39
+ status: 'verified_partial',
40
+ gate: { passed: true, source: gateSource || defaults.gateSource },
41
+ summary: { selftest: true, tests_passed: 1, manual_review_required: true },
42
+ artifacts: artifacts || defaults.artifacts,
43
+ evidence: {
44
+ route_gate: { passed: true },
45
+ commands: [{ cmd: 'sks selftest --mock', status: 'verified_partial' }]
46
+ },
47
+ claims: [{
48
+ id: `selftest-${kind.replaceAll('_', '-')}-proof`,
49
+ text: CLAIM_TEXT[kind] || CLAIM_TEXT.team_gate,
50
+ status: 'verified_partial'
51
+ }],
52
+ unverified: unverified || defaults.unverified
53
+ });
54
+ }
@@ -11,6 +11,7 @@ export function validateCompletionProof(proof = {}) {
11
11
  if (!Array.isArray(proof.unverified)) issues.push('unverified');
12
12
  if (!Array.isArray(proof.blockers)) issues.push('blockers');
13
13
  if (containsPlaintextSecret(proof)) issues.push('plaintext_secret');
14
+ if (proof.status === 'failed') issues.push('proof_failed');
14
15
  return {
15
16
  ok: issues.length === 0,
16
17
  status: issues.length ? 'failed' : proof.status,
@@ -16,18 +16,21 @@ export async function findRustAccelerator() {
16
16
 
17
17
  export async function runRustOrFallback(command, args = [], fallbackFn = async () => null) {
18
18
  const bin = await findRustAccelerator();
19
- if (!bin) return { engine: 'js', available: false, result: await fallbackFn() };
19
+ if (!bin) return normalizeAcceleratorResult(command, { engine: 'js', available: false, result: await fallbackFn() });
20
20
  const result = await runProcess(bin, [command, ...args], { timeoutMs: 10000, maxOutputBytes: 1024 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
21
- if (result.code !== 0) return { engine: 'js', available: true, rust_error: result.stderr || result.stdout, result: await fallbackFn() };
22
- return { engine: 'rust', available: true, stdout: result.stdout.trim(), result: parseRustJson(result.stdout) };
21
+ if (result.code !== 0) return normalizeAcceleratorResult(command, { engine: 'js', available: true, rust_error: classifyRustError(command, result.stderr || result.stdout), result: await fallbackFn() });
22
+ return normalizeAcceleratorResult(command, { engine: 'rust', available: true, stdout: result.stdout.trim(), result: parseRustJson(result.stdout) });
23
23
  }
24
24
 
25
25
  export async function rustImageHash(file) {
26
- return runRustOrFallback('image-hash', [file], async () => ({ sha256: await sha256File(file) }));
26
+ return runRustOrFallback('image-hash', [file], async () => ({ ok: true, engine: 'js', path: file, sha256: await sha256File(file) }));
27
27
  }
28
28
 
29
29
  export async function rustVoxelValidate(file) {
30
- return runRustOrFallback('voxel-validate', [file], async () => validateImageVoxelLedger(await readImageVoxelLedger(packageRoot(), file)));
30
+ return runRustOrFallback('voxel-validate', [file], async () => {
31
+ const validation = validateImageVoxelLedger(await readImageVoxelLedger(packageRoot(), file));
32
+ return { ok: validation.ok, engine: 'js', schema: 'sks.image-voxel-ledger.v1', images: validation.summary.images, anchors: validation.summary.anchors, issues: validation.issues };
33
+ });
31
34
  }
32
35
 
33
36
  export async function rustSecretScan(file) {
@@ -39,11 +42,30 @@ export async function rustSecretScan(file) {
39
42
 
40
43
  export async function rustInfo() {
41
44
  const bin = await findRustAccelerator();
42
- if (!bin) return { available: false, packaging: 'source_checkout_or_optional_path', note: 'Rust accelerator available only from source checkout or SKS_RS_BIN until prebuilt packages exist.' };
45
+ const capabilities = ['compact-info', 'jsonl-tail', 'secret-scan', 'image-hash', 'voxel-validate'];
46
+ if (!bin) return { available: false, capabilities, packaging: 'source_checkout_or_optional_path', note: 'Rust accelerator available only from source checkout or SKS_RS_BIN until prebuilt packages exist.' };
43
47
  const result = await runProcess(bin, ['--version'], { timeoutMs: 3000, maxOutputBytes: 20_000 });
44
- return { available: result.code === 0, bin, version: `${result.stdout}${result.stderr}`.trim(), packaging: 'source_checkout_or_optional_path' };
48
+ return { available: result.code === 0, bin, version: `${result.stdout}${result.stderr}`.trim(), capabilities, packaging: 'source_checkout_or_optional_path' };
45
49
  }
46
50
 
47
51
  function parseRustJson(text = '') {
48
52
  try { return JSON.parse(text); } catch { return text.trim(); }
49
53
  }
54
+
55
+ function classifyRustError(command, text = '') {
56
+ const s = String(text || '');
57
+ if (/unknown|Commands:|optional accelerator/i.test(s)) return { kind: 'command_missing', command, message: s };
58
+ return { kind: 'runtime_error', command, message: s };
59
+ }
60
+
61
+ function normalizeAcceleratorResult(command, value) {
62
+ const result = value.result && typeof value.result === 'object' ? value.result : { ok: false, value: value.result };
63
+ return {
64
+ command,
65
+ engine: value.engine,
66
+ available: Boolean(value.available),
67
+ rust_error: value.rust_error || null,
68
+ stdout: value.stdout || null,
69
+ result
70
+ };
71
+ }
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '0.9.12';
1
+ export const PACKAGE_VERSION = '0.9.13';
@@ -0,0 +1,16 @@
1
+ export function parseGeneratedReviewCallouts(ledger = {}) {
2
+ const items = Array.isArray(ledger.callouts)
3
+ ? ledger.callouts
4
+ : Array.isArray(ledger.issues)
5
+ ? ledger.issues
6
+ : [];
7
+ return items.map((item, index) => ({
8
+ id: item.id || `callout-${String(index + 1).padStart(3, '0')}`,
9
+ image_id: item.image_id || item.imageId || ledger.image_id || null,
10
+ bbox: item.bbox || item.box || null,
11
+ label: item.label || item.title || item.issue || `Callout ${index + 1}`,
12
+ source: item.source || ledger.source || 'gpt-image-2-annotated-review',
13
+ evidence_path: item.evidence_path || ledger.path || null,
14
+ trust_score: item.trust_score ?? 0.82
15
+ }));
16
+ }
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { nowIso, packageRoot, readJson } from '../fsx.mjs';
3
+ import { addVisualAnchor, readImageVoxelLedger, writeImageVoxelLedger } from './image-voxel-ledger.mjs';
4
+ import { validateImageVoxelLedger } from './validation.mjs';
5
+
6
+ export async function importComputerUseEvidence(root = packageRoot(), file, opts = {}) {
7
+ const ledger = await readJson(path.resolve(root, file), {});
8
+ const current = await readImageVoxelLedger(root);
9
+ const screens = Array.isArray(ledger.screens) ? ledger.screens : [];
10
+ const images = [
11
+ ...(current.images || []),
12
+ ...screens.map((screen) => ({
13
+ id: screen.id,
14
+ path: screen.path,
15
+ sha256: screen.sha256 || 'fixture',
16
+ width: screen.width,
17
+ height: screen.height,
18
+ source: screen.source || 'codex-computer-use',
19
+ captured_at: screen.captured_at || nowIso()
20
+ }))
21
+ ].filter((image, index, all) => image.id && all.findIndex((entry) => entry.id === image.id) === index);
22
+ let next = await writeImageVoxelLedger(root, { ...current, mission_id: opts.missionId || current.mission_id || null, images });
23
+ for (const action of ledger.actions || []) {
24
+ if (!action.bbox) continue;
25
+ const result = await addVisualAnchor(root, {
26
+ imageId: action.screen_id,
27
+ bbox: action.bbox,
28
+ label: action.target || action.type || 'Computer Use action',
29
+ source: 'codex-computer-use',
30
+ evidencePath: file,
31
+ route: opts.route || '$Computer-Use',
32
+ missionId: opts.missionId
33
+ });
34
+ next = result.ledger;
35
+ }
36
+ const validation = validateImageVoxelLedger(next, { requireAnchors: true, route: opts.route || '$Computer-Use' });
37
+ return { schema: 'sks.computer-use-image-voxel-import.v1', ok: validation.ok, mode: ledger.mode || 'mock', ledger: next, validation };
38
+ }
@@ -0,0 +1,2 @@
1
+ export { createImageRelation } from './visual-anchor.mjs';
2
+ export { addImageRelation } from './image-voxel-ledger.mjs';
@@ -3,6 +3,7 @@ import { ensureDir, exists, nowIso, packageRoot, readJson, writeJsonAtomic } fro
3
3
  import { emptyImageVoxelLedger } from './image-voxel-schema.mjs';
4
4
  import { sha256File, imageDimensions } from './image-hash.mjs';
5
5
  import { validateImageVoxelLedger } from './validation.mjs';
6
+ import { createImageRelation, createVisualAnchor } from './visual-anchor.mjs';
6
7
 
7
8
  export function wikiImageLedgerPath(root = packageRoot()) {
8
9
  return path.join(root, '.sneakoscope', 'wiki', 'image-voxel-ledger.json');
@@ -98,7 +99,49 @@ export async function imageVoxelSummary(root = packageRoot(), ledgerFile = wikiI
98
99
  };
99
100
  }
100
101
 
102
+ export async function addVisualAnchor(root = packageRoot(), input = {}) {
103
+ const ledger = await readImageVoxelLedger(root);
104
+ const image = (ledger.images || []).find((entry) => entry.id === input.imageId);
105
+ const anchor = createVisualAnchor({
106
+ id: input.id || stableAnchorId(input.imageId, input.label, ledger.anchors?.length || 0),
107
+ imageId: input.imageId,
108
+ bbox: input.bbox,
109
+ label: input.label,
110
+ source: input.source || 'manual',
111
+ evidencePath: input.evidencePath || null,
112
+ trustScore: input.trustScore ?? 0.82,
113
+ route: input.route || null,
114
+ claimId: input.claimId || null
115
+ });
116
+ const anchors = [...(ledger.anchors || []).filter((entry) => entry.id !== anchor.id), anchor];
117
+ const next = await writeImageVoxelLedger(root, { ...ledger, mission_id: input.missionId || ledger.mission_id || null, anchors });
118
+ const validation = validateImageVoxelLedger(next, { requireAnchors: true, route: input.route || '$Wiki' });
119
+ return { ok: validation.ok && Boolean(image), anchor, ledger: next, validation: image ? validation : { ...validation, ok: false, issues: [...validation.issues, `missing_image:${input.imageId}`] } };
120
+ }
121
+
122
+ export async function addImageRelation(root = packageRoot(), input = {}) {
123
+ const ledger = await readImageVoxelLedger(root);
124
+ const relation = createImageRelation({
125
+ type: input.type || 'before_after',
126
+ beforeImageId: input.beforeImageId,
127
+ afterImageId: input.afterImageId,
128
+ anchors: input.anchors || [],
129
+ verification: input.verification || 'changed-screen-recheck',
130
+ status: input.status || 'verified_partial'
131
+ });
132
+ const relations = [...(ledger.relations || []), relation];
133
+ const next = await writeImageVoxelLedger(root, { ...ledger, mission_id: input.missionId || ledger.mission_id || null, relations });
134
+ const validation = validateImageVoxelLedger(next, { requireAnchors: true, requireRelations: true, route: input.route || '$Wiki' });
135
+ return { ok: validation.ok, relation, ledger: next, validation };
136
+ }
137
+
101
138
  function stableImageId(rel, sha256) {
102
139
  const base = path.basename(rel).replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'image';
103
140
  return `${base}-${sha256.slice(0, 8)}`;
104
141
  }
142
+
143
+ function stableAnchorId(imageId = 'image', label = 'anchor', index = 0) {
144
+ const image = String(imageId || 'image').replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 40) || 'image';
145
+ const slug = String(label || 'anchor').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'anchor';
146
+ return `${image}-${slug}-${String(index + 1).padStart(3, '0')}`;
147
+ }
@@ -0,0 +1,12 @@
1
+ import { imageVoxelSummary } from './image-voxel-ledger.mjs';
2
+
3
+ export async function imageVoxelProofEvidence(root, ledgerFile) {
4
+ const summary = await imageVoxelSummary(root, ledgerFile);
5
+ return {
6
+ schema: 'sks.image-voxel-proof-link.v1',
7
+ ok: summary.ok,
8
+ evidence: {
9
+ image_voxels: summary
10
+ }
11
+ };
12
+ }
@@ -1,12 +1,14 @@
1
1
  import { IMAGE_VOXEL_LEDGER_SCHEMA } from './image-voxel-schema.mjs';
2
2
  import { validateBbox } from './bbox.mjs';
3
3
 
4
- export function validateImageVoxelLedger(ledger = {}) {
4
+ export function validateImageVoxelLedger(ledger = {}, opts = {}) {
5
5
  const issues = [];
6
6
  if (ledger.schema !== IMAGE_VOXEL_LEDGER_SCHEMA) issues.push('schema');
7
7
  const images = Array.isArray(ledger.images) ? ledger.images : [];
8
8
  const anchors = Array.isArray(ledger.anchors) ? ledger.anchors : [];
9
+ const relations = Array.isArray(ledger.relations) ? ledger.relations : [];
9
10
  const imageById = new Map();
11
+ const anchorById = new Map();
10
12
  for (const image of images) {
11
13
  if (!image.id) issues.push('image_id');
12
14
  if (image.id && imageById.has(image.id)) issues.push(`duplicate_image:${image.id}`);
@@ -15,28 +17,37 @@ export function validateImageVoxelLedger(ledger = {}) {
15
17
  if (!image.sha256) issues.push(`image_sha256:${image.id || 'unknown'}`);
16
18
  if (!Number.isFinite(Number(image.width)) || !Number.isFinite(Number(image.height))) issues.push(`image_dimensions:${image.id || 'unknown'}`);
17
19
  }
20
+ if (opts.requireAnchors && anchors.length === 0) issues.push(`missing_anchors:${opts.route || 'visual-route'}`);
18
21
  for (const anchor of anchors) {
19
22
  if (!anchor.id) issues.push('anchor_id');
23
+ if (anchor.id && anchorById.has(anchor.id)) issues.push(`duplicate_anchor:${anchor.id}`);
24
+ if (anchor.id) anchorById.set(anchor.id, anchor);
20
25
  if (!anchor.image_id || !imageById.has(anchor.image_id)) issues.push(`anchor_image_ref:${anchor.id || 'unknown'}`);
21
26
  if (anchor.bbox) {
22
- const bbox = validateBbox(anchor.bbox, imageById.get(anchor.image_id) || {});
27
+ const image = imageById.get(anchor.image_id) || {};
28
+ if (!Number.isFinite(Number(image.width)) || !Number.isFinite(Number(image.height))) issues.push(`bbox_image_dimensions:${anchor.id || 'unknown'}`);
29
+ const bbox = validateBbox(anchor.bbox, image);
23
30
  for (const issue of bbox.issues) issues.push(`${issue}:${anchor.id || 'unknown'}`);
24
31
  } else {
25
32
  issues.push(`anchor_bbox:${anchor.id || 'unknown'}`);
26
33
  }
27
34
  }
28
- for (const relation of Array.isArray(ledger.relations) ? ledger.relations : []) {
35
+ if (opts.requireRelations && relations.length === 0) issues.push(`missing_relations:${opts.route || 'visual-route'}`);
36
+ for (const relation of relations) {
29
37
  if (relation.before_image_id && !imageById.has(relation.before_image_id)) issues.push(`relation_before:${relation.before_image_id}`);
30
38
  if (relation.after_image_id && !imageById.has(relation.after_image_id)) issues.push(`relation_after:${relation.after_image_id}`);
39
+ for (const anchorId of relation.changed_anchor_ids || relation.anchors || []) {
40
+ if (!anchorById.has(anchorId)) issues.push(`relation_anchor:${anchorId}`);
41
+ }
31
42
  }
32
43
  return {
33
44
  ok: issues.length === 0,
34
- status: issues.length ? 'blocked' : 'verified_partial',
45
+ status: issues.length ? 'blocked' : (anchors.length ? 'verified_partial' : 'not_verified'),
35
46
  issues,
36
47
  summary: {
37
48
  images: images.length,
38
49
  anchors: anchors.length,
39
- relations: Array.isArray(ledger.relations) ? ledger.relations.length : 0
50
+ relations: relations.length
40
51
  }
41
52
  };
42
53
  }
@@ -1,6 +1,6 @@
1
1
  import { rgbaKey, rgbaToWikiCoord } from '../wiki-coordinate.mjs';
2
2
 
3
- export function createVisualAnchor({ id, imageId, bbox, label, source, evidencePath, trustScore = 0.5, rgba = [58, 132, 210, 240] } = {}) {
3
+ export function createVisualAnchor({ id, imageId, bbox, label, source, evidencePath, trustScore = 0.5, rgba = [58, 132, 210, 240], route = null, claimId = null } = {}) {
4
4
  const key = Array.isArray(rgba) ? rgbaKey(rgba) : String(rgba || '3a84d2f0');
5
5
  const rgbaTuple = Array.isArray(rgba)
6
6
  ? rgba
@@ -16,6 +16,8 @@ export function createVisualAnchor({ id, imageId, bbox, label, source, evidenceP
16
16
  evidence_path: evidencePath || null,
17
17
  trust_score: trustScore,
18
18
  trust_band: source || 'visual-anchor',
19
+ route,
20
+ claim_id: claimId,
19
21
  voxel_layers: {
20
22
  sem: 0.7,
21
23
  trust: trustScore,
@@ -27,3 +29,14 @@ export function createVisualAnchor({ id, imageId, bbox, label, source, evidenceP
27
29
  }
28
30
  };
29
31
  }
32
+
33
+ export function createImageRelation({ type = 'before_after', beforeImageId, afterImageId, anchors = [], verification = 'changed-screen-recheck', status = 'verified_partial' } = {}) {
34
+ return {
35
+ type,
36
+ before_image_id: beforeImageId,
37
+ after_image_id: afterImageId,
38
+ changed_anchor_ids: Array.isArray(anchors) ? anchors : String(anchors || '').split(',').map((x) => x.trim()).filter(Boolean),
39
+ verification,
40
+ status
41
+ };
42
+ }