sneakoscope 0.9.11 → 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 (62) hide show
  1. package/README.md +28 -2
  2. package/crates/sks-core/Cargo.lock +7 -0
  3. package/crates/sks-core/Cargo.toml +10 -0
  4. package/crates/sks-core/src/main.rs +202 -0
  5. package/package.json +15 -3
  6. package/src/cli/args.mjs +49 -0
  7. package/src/cli/command-registry.mjs +128 -0
  8. package/src/cli/feature-commands.mjs +112 -6
  9. package/src/cli/install-helpers.mjs +14 -7
  10. package/src/cli/legacy-main.mjs +4147 -0
  11. package/src/cli/main.mjs +7 -4138
  12. package/src/cli/output.mjs +9 -0
  13. package/src/cli/router.mjs +30 -0
  14. package/src/commands/all-features.mjs +6 -0
  15. package/src/commands/codex-app.mjs +30 -0
  16. package/src/commands/codex-lb.mjs +47 -0
  17. package/src/commands/db.mjs +6 -0
  18. package/src/commands/doctor.mjs +46 -0
  19. package/src/commands/features.mjs +6 -0
  20. package/src/commands/help.mjs +77 -0
  21. package/src/commands/hooks.mjs +6 -0
  22. package/src/commands/perf.mjs +91 -0
  23. package/src/commands/proof.mjs +103 -0
  24. package/src/commands/root.mjs +24 -0
  25. package/src/commands/version.mjs +5 -0
  26. package/src/commands/wiki.mjs +95 -0
  27. package/src/core/codex-lb-circuit.mjs +130 -0
  28. package/src/core/db-safety.mjs +18 -3
  29. package/src/core/feature-fixtures.mjs +103 -0
  30. package/src/core/feature-registry.mjs +117 -11
  31. package/src/core/fsx.mjs +1 -1
  32. package/src/core/hooks-runtime.mjs +17 -6
  33. package/src/core/language-preference.mjs +106 -0
  34. package/src/core/pipeline.mjs +24 -0
  35. package/src/core/proof/claim-ledger.mjs +9 -0
  36. package/src/core/proof/command-ledger.mjs +17 -0
  37. package/src/core/proof/evidence-collector.mjs +33 -0
  38. package/src/core/proof/file-change-ledger.mjs +6 -0
  39. package/src/core/proof/proof-reader.mjs +30 -0
  40. package/src/core/proof/proof-redaction.test-helper.mjs +9 -0
  41. package/src/core/proof/proof-schema.mjs +42 -0
  42. package/src/core/proof/proof-writer.mjs +81 -0
  43. package/src/core/proof/route-adapter.mjs +74 -0
  44. package/src/core/proof/route-proof-gate.mjs +33 -0
  45. package/src/core/proof/route-proof-policy.mjs +96 -0
  46. package/src/core/proof/selftest-proof-fixtures.mjs +54 -0
  47. package/src/core/proof/validation.mjs +20 -0
  48. package/src/core/routes.mjs +4 -3
  49. package/src/core/rust-accelerator.mjs +55 -3
  50. package/src/core/secret-redaction.mjs +69 -0
  51. package/src/core/version-manager.mjs +11 -7
  52. package/src/core/version.mjs +1 -0
  53. package/src/core/wiki-image/bbox.mjs +10 -0
  54. package/src/core/wiki-image/callout-parser.mjs +16 -0
  55. package/src/core/wiki-image/computer-use-ledger.mjs +38 -0
  56. package/src/core/wiki-image/image-hash.mjs +42 -0
  57. package/src/core/wiki-image/image-relation.mjs +2 -0
  58. package/src/core/wiki-image/image-voxel-ledger.mjs +147 -0
  59. package/src/core/wiki-image/image-voxel-schema.mjs +16 -0
  60. package/src/core/wiki-image/proof-linker.mjs +12 -0
  61. package/src/core/wiki-image/validation.mjs +53 -0
  62. package/src/core/wiki-image/visual-anchor.mjs +42 -0
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ import { containsPlaintextSecret } from '../secret-redaction.mjs';
2
+ import { COMPLETION_PROOF_SCHEMA, COMPLETION_PROOF_STATUSES } from './proof-schema.mjs';
3
+
4
+ export function validateCompletionProof(proof = {}) {
5
+ const issues = [];
6
+ if (proof.schema !== COMPLETION_PROOF_SCHEMA) issues.push('schema');
7
+ if (!COMPLETION_PROOF_STATUSES.includes(proof.status)) issues.push('status');
8
+ if (!proof.summary || typeof proof.summary !== 'object') issues.push('summary');
9
+ if (!proof.evidence || typeof proof.evidence !== 'object') issues.push('evidence');
10
+ if (!Array.isArray(proof.claims)) issues.push('claims');
11
+ if (!Array.isArray(proof.unverified)) issues.push('unverified');
12
+ if (!Array.isArray(proof.blockers)) issues.push('blockers');
13
+ if (containsPlaintextSecret(proof)) issues.push('plaintext_secret');
14
+ if (proof.status === 'failed') issues.push('proof_failed');
15
+ return {
16
+ ok: issues.length === 0,
17
+ status: issues.length ? 'failed' : proof.status,
18
+ issues
19
+ };
20
+ }
@@ -532,8 +532,8 @@ export const COMMAND_CATALOG = [
532
532
  { name: 'root', usage: 'sks root [--json]', description: 'Show whether SKS is using a project root or the per-user global SKS runtime root.' },
533
533
  { name: 'deps', usage: 'sks deps check|install [tmux|codex|context7|all] [--yes]', description: 'Check or guided-install Node/npm PATH, Codex CLI/App, Context7, Browser tooling, Computer Use, tmux, and Homebrew on macOS.' },
534
534
  { name: 'codex-app', usage: 'sks codex-app [check|open|pat status|remote-control]', description: 'Check Codex App install, PAT-safe status, first-party MCP/plugin readiness, and Codex CLI 0.130.0+ remote-control availability.' },
535
- { name: 'hooks', usage: 'sks hooks explain [--json]', description: 'Explain Codex hook events, config locations, handler support, and SKS hook policies without storing raw payloads.' },
536
- { name: 'codex-lb', usage: 'sks codex-lb status|health|repair|setup --host <domain> --api-key <key>', description: 'Configure, health-check, or repair codex-lb provider auth by writing ~/.codex/config.toml, restoring CODEX_LB_API_KEY env auth from stored or legacy login-cache state, and preserving the shared Codex login cache unless explicitly requested.' },
535
+ { name: 'hooks', usage: 'sks hooks explain|status|trust-report|replay ... [--json]', description: 'Explain Codex hook events, config locations, handler support, replay fixtures, and SKS hook policies without storing raw payloads.' },
536
+ { name: 'codex-lb', usage: 'sks codex-lb status|health|metrics|doctor|circuit|repair|setup ...', description: 'Configure, health-check, repair, and record circuit evidence for codex-lb provider auth without confusing ChatGPT OAuth and proxy keys.' },
537
537
  { name: 'auth', usage: 'sks auth status|health|repair|setup --host <domain> --api-key <key>', description: 'Shortcut for codex-lb provider auth status, health, repair, and setup commands.' },
538
538
  { name: 'openclaw', usage: 'sks openclaw install|path|print [--dir path] [--force] [--json]', description: 'Generate an OpenClaw skill package so OpenClaw agents can discover and use local SKS workflows.' },
539
539
  { name: 'tmux', usage: 'sks | sks tmux open|check|status [--workspace name]', description: 'Open the default SKS tmux runtime with bare sks, or use tmux subcommands for explicit launch/check/status.' },
@@ -563,7 +563,8 @@ export const COMMAND_CATALOG = [
563
563
  { name: 'db', usage: 'sks db policy|scan|mcp-config|classify|check ...', description: 'Inspect and enforce database/Supabase safety policy.' },
564
564
  { name: 'eval', usage: 'sks eval run|compare|thresholds ...', description: 'Run deterministic context-quality and performance evidence checks.' },
565
565
  { name: 'harness', usage: 'sks harness fixture|review [--json]', description: 'Run Harness Growth Factory fixtures for forgetting, skills, experiments, tool taxonomy, permissions, MultiAgentV2, and tmux views.' },
566
- { name: 'perf', usage: 'sks perf run|workflow [--json] [--iterations N] [--intent "task"] [--changed file1,file2]', description: 'Measure structured GPT-5.5/SKS performance budgets, including Proof Field workflow decisions and fast-lane evidence.' },
566
+ { name: 'perf', usage: 'sks perf run|workflow|cold-start [--json] [--iterations N]', description: 'Measure structured GPT-5.5/SKS performance budgets, including cold-start, Proof Field workflow decisions, and fast-lane evidence.' },
567
+ { name: 'proof', usage: 'sks proof show|latest|validate|export|smoke [--json|--md]', description: 'Show, validate, export, or smoke-write the unified Completion Proof Engine surface.' },
567
568
  { name: 'proof-field', usage: 'sks proof-field scan [--json] [--intent "task"] [--changed file1,file2]', description: 'Analyze Potential Proof Field cones, negative-work cache, and fast-lane eligibility for a change set.' },
568
569
  { name: 'skill-dream', usage: 'sks skill-dream status|run|record [--json]', description: 'Track generated-skill usage in lightweight JSON and periodically report keep, merge, prune, and improvement candidates without deleting skills automatically.' },
569
570
  { name: 'code-structure', usage: 'sks code-structure scan [--json]', description: 'Scan handwritten source files for 1000/2000/3000-line structure gates and split-review exceptions.' },
@@ -1,5 +1,8 @@
1
1
  import path from 'node:path';
2
- import { exists, packageRoot, runProcess, which } from './fsx.mjs';
2
+ import { exists, packageRoot, readText, runProcess, which } from './fsx.mjs';
3
+ import { sha256File } from './wiki-image/image-hash.mjs';
4
+ import { validateImageVoxelLedger } from './wiki-image/validation.mjs';
5
+ import { readImageVoxelLedger } from './wiki-image/image-voxel-ledger.mjs';
3
6
 
4
7
  export async function findRustAccelerator() {
5
8
  const env = process.env.SKS_RS_BIN || process.env.DCODEX_RS_BIN;
@@ -11,9 +14,58 @@ export async function findRustAccelerator() {
11
14
  return null;
12
15
  }
13
16
 
17
+ export async function runRustOrFallback(command, args = [], fallbackFn = async () => null) {
18
+ const bin = await findRustAccelerator();
19
+ if (!bin) return normalizeAcceleratorResult(command, { engine: 'js', available: false, result: await fallbackFn() });
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 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
+ }
24
+
25
+ export async function rustImageHash(file) {
26
+ return runRustOrFallback('image-hash', [file], async () => ({ ok: true, engine: 'js', path: file, sha256: await sha256File(file) }));
27
+ }
28
+
29
+ export async function rustVoxelValidate(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
+ });
34
+ }
35
+
36
+ export async function rustSecretScan(file) {
37
+ return runRustOrFallback('secret-scan', [file], async () => {
38
+ const text = await readText(file, '');
39
+ return { ok: !/(CODEX_ACCESS_TOKEN|OPENAI_API_KEY|CODEX_LB_API_KEY|sk-proj-|sk-clb-|github_pat_)/.test(text) };
40
+ });
41
+ }
42
+
14
43
  export async function rustInfo() {
15
44
  const bin = await findRustAccelerator();
16
- if (!bin) return { available: false };
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.' };
17
47
  const result = await runProcess(bin, ['--version'], { timeoutMs: 3000, maxOutputBytes: 20_000 });
18
- return { available: result.code === 0, bin, version: `${result.stdout}${result.stderr}`.trim() };
48
+ return { available: result.code === 0, bin, version: `${result.stdout}${result.stderr}`.trim(), capabilities, packaging: 'source_checkout_or_optional_path' };
49
+ }
50
+
51
+ function parseRustJson(text = '') {
52
+ try { return JSON.parse(text); } catch { return text.trim(); }
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
+ };
19
71
  }
@@ -0,0 +1,69 @@
1
+ const SECRET_ENV_NAMES = [
2
+ 'CODEX_ACCESS_TOKEN',
3
+ 'OPENAI_API_KEY',
4
+ 'CODEX_LB_API_KEY',
5
+ 'ANTHROPIC_API_KEY',
6
+ 'GITHUB_TOKEN',
7
+ 'GITHUB_PAT'
8
+ ];
9
+
10
+ const SECRET_PATTERNS = [
11
+ /\bsk-proj-[A-Za-z0-9_-]{12,}\b/g,
12
+ /\bsk-[A-Za-z0-9_-]{20,}\b/g,
13
+ /\bsk-clb-[A-Za-z0-9_-]{8,}\b/g,
14
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
15
+ /\bghp_[A-Za-z0-9_]{20,}\b/g,
16
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/gi,
17
+ /\b(?:access[_-]?token|api[_-]?key|secret|password|token)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{12,}["']?/gi
18
+ ];
19
+
20
+ export const REDACTION_MARKER = '[redacted]';
21
+
22
+ export function redactSecrets(value, env = process.env) {
23
+ if (value == null) return value;
24
+ if (typeof value === 'string') return redactString(value, env);
25
+ if (Array.isArray(value)) return value.map((item) => redactSecrets(item, env));
26
+ if (typeof value === 'object') {
27
+ const out = {};
28
+ for (const [key, candidate] of Object.entries(value)) {
29
+ out[key] = secretKeyName(key) ? REDACTION_MARKER : redactSecrets(candidate, env);
30
+ }
31
+ return out;
32
+ }
33
+ return value;
34
+ }
35
+
36
+ export function redactString(input = '', env = process.env) {
37
+ let out = String(input);
38
+ for (const name of SECRET_ENV_NAMES) {
39
+ const raw = env?.[name];
40
+ if (raw && raw.length >= 4) out = out.split(raw).join(REDACTION_MARKER);
41
+ out = out.replace(new RegExp(`(${name}\\s*[:=]\\s*)[^\\s"',}]+`, 'gi'), `$1${REDACTION_MARKER}`);
42
+ }
43
+ for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, (match) => redactKeyValue(match));
44
+ return out;
45
+ }
46
+
47
+ export function containsPlaintextSecret(value, env = process.env) {
48
+ const text = typeof value === 'string' ? value : JSON.stringify(value || {});
49
+ for (const name of SECRET_ENV_NAMES) {
50
+ const raw = env?.[name];
51
+ if (raw && raw.length >= 4 && text.includes(raw)) return true;
52
+ }
53
+ return SECRET_PATTERNS.some((pattern) => {
54
+ pattern.lastIndex = 0;
55
+ return pattern.test(text);
56
+ });
57
+ }
58
+
59
+ function secretKeyName(key = '') {
60
+ return /(?:access[_-]?token|api[_-]?key|secret|password|token)$/i.test(String(key || ''))
61
+ || SECRET_ENV_NAMES.includes(String(key || '').toUpperCase());
62
+ }
63
+
64
+ function redactKeyValue(match) {
65
+ const keyValue = String(match).match(/^([^:=]+[:=]\s*)/);
66
+ if (keyValue) return `${keyValue[1]}${REDACTION_MARKER}`;
67
+ if (/^Bearer\s+/i.test(match)) return `Bearer ${REDACTION_MARKER}`;
68
+ return REDACTION_MARKER;
69
+ }
@@ -308,13 +308,17 @@ async function syncPackageLockVersions(root, version) {
308
308
  }
309
309
 
310
310
  async function syncSourcePackageVersion(root, version) {
311
- const file = path.join(root, 'src', 'core', 'fsx.mjs');
312
- const text = await readFileMaybe(file);
313
- if (!text) return { files: [], relative_files: [] };
314
- const next = text.replace(/export const PACKAGE_VERSION = ['"][^'"]+['"];/, `export const PACKAGE_VERSION = '${version}';`);
315
- if (next === text) return { files: [], relative_files: [] };
316
- await writeTextAtomic(file, next);
317
- return { files: [file], relative_files: [path.relative(root, file)] };
311
+ const files = [];
312
+ for (const rel of ['src/core/fsx.mjs', 'src/core/version.mjs']) {
313
+ const file = path.join(root, rel);
314
+ const text = await readFileMaybe(file);
315
+ if (!text) continue;
316
+ const next = text.replace(/export const PACKAGE_VERSION = ['"][^'"]+['"];/, `export const PACKAGE_VERSION = '${version}';`);
317
+ if (next === text) continue;
318
+ await writeTextAtomic(file, next);
319
+ files.push(file);
320
+ }
321
+ return { files, relative_files: files.map((file) => path.relative(root, file)) };
318
322
  }
319
323
 
320
324
  async function syncChangelogVersionSection(root, version) {
@@ -0,0 +1 @@
1
+ export const PACKAGE_VERSION = '0.9.13';
@@ -0,0 +1,10 @@
1
+ export function validateBbox(bbox, image = {}) {
2
+ const issues = [];
3
+ if (!Array.isArray(bbox) || bbox.length !== 4) return { ok: false, issues: ['bbox_shape'] };
4
+ const [x, y, width, height] = bbox.map(Number);
5
+ if (![x, y, width, height].every(Number.isFinite)) issues.push('bbox_number');
6
+ if (x < 0 || y < 0 || width <= 0 || height <= 0) issues.push('bbox_positive');
7
+ if (Number.isFinite(Number(image.width)) && x + width > Number(image.width)) issues.push('bbox_width_out_of_bounds');
8
+ if (Number.isFinite(Number(image.height)) && y + height > Number(image.height)) issues.push('bbox_height_out_of_bounds');
9
+ return { ok: issues.length === 0, issues };
10
+ }
@@ -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,42 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ export async function sha256File(file) {
5
+ return new Promise((resolve, reject) => {
6
+ const hash = createHash('sha256');
7
+ const input = fs.createReadStream(file);
8
+ input.on('error', reject);
9
+ input.on('data', (chunk) => hash.update(chunk));
10
+ input.on('end', () => resolve(hash.digest('hex')));
11
+ });
12
+ }
13
+
14
+ export async function imageDimensions(file) {
15
+ const handle = await fs.promises.open(file, 'r');
16
+ try {
17
+ const header = Buffer.alloc(32);
18
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
19
+ if (bytesRead >= 24 && header.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
20
+ return { width: header.readUInt32BE(16), height: header.readUInt32BE(20), format: 'png' };
21
+ }
22
+ if (bytesRead >= 10 && header[0] === 0xff && header[1] === 0xd8) return jpegDimensions(file);
23
+ return { width: null, height: null, format: 'unknown' };
24
+ } finally {
25
+ await handle.close().catch(() => {});
26
+ }
27
+ }
28
+
29
+ async function jpegDimensions(file) {
30
+ const buf = await fs.promises.readFile(file);
31
+ let offset = 2;
32
+ while (offset < buf.length) {
33
+ if (buf[offset] !== 0xff) break;
34
+ const marker = buf[offset + 1];
35
+ const length = buf.readUInt16BE(offset + 2);
36
+ if (marker >= 0xc0 && marker <= 0xc3) {
37
+ return { width: buf.readUInt16BE(offset + 7), height: buf.readUInt16BE(offset + 5), format: 'jpeg' };
38
+ }
39
+ offset += 2 + length;
40
+ }
41
+ return { width: null, height: null, format: 'jpeg' };
42
+ }
@@ -0,0 +1,2 @@
1
+ export { createImageRelation } from './visual-anchor.mjs';
2
+ export { addImageRelation } from './image-voxel-ledger.mjs';
@@ -0,0 +1,147 @@
1
+ import path from 'node:path';
2
+ import { ensureDir, exists, nowIso, packageRoot, readJson, writeJsonAtomic } from '../fsx.mjs';
3
+ import { emptyImageVoxelLedger } from './image-voxel-schema.mjs';
4
+ import { sha256File, imageDimensions } from './image-hash.mjs';
5
+ import { validateImageVoxelLedger } from './validation.mjs';
6
+ import { createImageRelation, createVisualAnchor } from './visual-anchor.mjs';
7
+
8
+ export function wikiImageLedgerPath(root = packageRoot()) {
9
+ return path.join(root, '.sneakoscope', 'wiki', 'image-voxel-ledger.json');
10
+ }
11
+
12
+ export function wikiImageAssetsPath(root = packageRoot()) {
13
+ return path.join(root, '.sneakoscope', 'wiki', 'image-assets.json');
14
+ }
15
+
16
+ export function wikiVisualAnchorsPath(root = packageRoot()) {
17
+ return path.join(root, '.sneakoscope', 'wiki', 'visual-anchors.json');
18
+ }
19
+
20
+ export function missionImageLedgerPath(root = packageRoot(), missionId) {
21
+ return path.join(root, '.sneakoscope', 'missions', missionId, 'image-voxel-ledger.json');
22
+ }
23
+
24
+ export function missionVisualAnchorsPath(root = packageRoot(), missionId) {
25
+ return path.join(root, '.sneakoscope', 'missions', missionId, 'visual-anchors.json');
26
+ }
27
+
28
+ export async function readImageVoxelLedger(root = packageRoot(), file = wikiImageLedgerPath(root)) {
29
+ if (!await exists(file)) return emptyImageVoxelLedger();
30
+ return readJson(file);
31
+ }
32
+
33
+ export async function writeImageVoxelLedger(root = packageRoot(), ledger = emptyImageVoxelLedger()) {
34
+ await ensureDir(path.dirname(wikiImageLedgerPath(root)));
35
+ const normalized = { ...emptyImageVoxelLedger(), ...ledger, generated_at: nowIso() };
36
+ await writeJsonAtomic(wikiImageLedgerPath(root), normalized);
37
+ await writeJsonAtomic(wikiImageAssetsPath(root), {
38
+ schema: 'sks.image-assets.v1',
39
+ version: normalized.version,
40
+ generated_at: normalized.generated_at,
41
+ images: normalized.images
42
+ });
43
+ await writeJsonAtomic(wikiVisualAnchorsPath(root), {
44
+ schema: 'sks.visual-anchors.v1',
45
+ version: normalized.version,
46
+ generated_at: normalized.generated_at,
47
+ anchors: normalized.anchors
48
+ });
49
+ if (normalized.mission_id) {
50
+ const missionDir = path.dirname(missionImageLedgerPath(root, normalized.mission_id));
51
+ await ensureDir(missionDir);
52
+ await writeJsonAtomic(missionImageLedgerPath(root, normalized.mission_id), normalized);
53
+ await writeJsonAtomic(missionVisualAnchorsPath(root, normalized.mission_id), {
54
+ schema: 'sks.visual-anchors.v1',
55
+ version: normalized.version,
56
+ generated_at: normalized.generated_at,
57
+ anchors: normalized.anchors
58
+ });
59
+ }
60
+ return normalized;
61
+ }
62
+
63
+ export async function ingestImage(root = packageRoot(), imagePath, opts = {}) {
64
+ if (!imagePath) throw new Error('image path required');
65
+ const absolute = path.resolve(root, imagePath);
66
+ const dims = await imageDimensions(absolute);
67
+ const sha256 = await sha256File(absolute);
68
+ const ledger = await readImageVoxelLedger(root);
69
+ const rel = path.relative(root, absolute).split(path.sep).join('/');
70
+ const id = opts.id || stableImageId(rel, sha256);
71
+ const image = {
72
+ id,
73
+ path: rel,
74
+ sha256,
75
+ width: dims.width,
76
+ height: dims.height,
77
+ format: dims.format,
78
+ source: opts.source || 'manual',
79
+ captured_at: opts.capturedAt || nowIso()
80
+ };
81
+ const images = [...(ledger.images || []).filter((entry) => entry.id !== id), image];
82
+ const next = await writeImageVoxelLedger(root, { ...ledger, mission_id: opts.missionId || ledger.mission_id || null, images });
83
+ const validation = validateImageVoxelLedger(next);
84
+ return { ok: validation.ok, image, ledger: next, validation };
85
+ }
86
+
87
+ export async function imageVoxelSummary(root = packageRoot(), ledgerFile = wikiImageLedgerPath(root)) {
88
+ const ledger = await readImageVoxelLedger(root, ledgerFile);
89
+ const validation = validateImageVoxelLedger(ledger);
90
+ return {
91
+ schema: 'sks.image-voxel-summary.v1',
92
+ status: validation.status,
93
+ ok: validation.ok,
94
+ images: ledger.images?.length || 0,
95
+ anchors: ledger.anchors?.length || 0,
96
+ anchor_count: ledger.anchors?.length || 0,
97
+ relations: ledger.relations?.length || 0,
98
+ issues: validation.issues
99
+ };
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
+
138
+ function stableImageId(rel, sha256) {
139
+ const base = path.basename(rel).replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'image';
140
+ return `${base}-${sha256.slice(0, 8)}`;
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,16 @@
1
+ import { PACKAGE_VERSION, nowIso } from '../fsx.mjs';
2
+
3
+ export const IMAGE_VOXEL_LEDGER_SCHEMA = 'sks.image-voxel-ledger.v1';
4
+
5
+ export function emptyImageVoxelLedger(overrides = {}) {
6
+ return {
7
+ schema: IMAGE_VOXEL_LEDGER_SCHEMA,
8
+ version: PACKAGE_VERSION,
9
+ generated_at: nowIso(),
10
+ mission_id: null,
11
+ images: [],
12
+ anchors: [],
13
+ relations: [],
14
+ ...overrides
15
+ };
16
+ }
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ import { IMAGE_VOXEL_LEDGER_SCHEMA } from './image-voxel-schema.mjs';
2
+ import { validateBbox } from './bbox.mjs';
3
+
4
+ export function validateImageVoxelLedger(ledger = {}, opts = {}) {
5
+ const issues = [];
6
+ if (ledger.schema !== IMAGE_VOXEL_LEDGER_SCHEMA) issues.push('schema');
7
+ const images = Array.isArray(ledger.images) ? ledger.images : [];
8
+ const anchors = Array.isArray(ledger.anchors) ? ledger.anchors : [];
9
+ const relations = Array.isArray(ledger.relations) ? ledger.relations : [];
10
+ const imageById = new Map();
11
+ const anchorById = new Map();
12
+ for (const image of images) {
13
+ if (!image.id) issues.push('image_id');
14
+ if (image.id && imageById.has(image.id)) issues.push(`duplicate_image:${image.id}`);
15
+ if (image.id) imageById.set(image.id, image);
16
+ if (!image.path) issues.push(`image_path:${image.id || 'unknown'}`);
17
+ if (!image.sha256) issues.push(`image_sha256:${image.id || 'unknown'}`);
18
+ if (!Number.isFinite(Number(image.width)) || !Number.isFinite(Number(image.height))) issues.push(`image_dimensions:${image.id || 'unknown'}`);
19
+ }
20
+ if (opts.requireAnchors && anchors.length === 0) issues.push(`missing_anchors:${opts.route || 'visual-route'}`);
21
+ for (const anchor of anchors) {
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);
25
+ if (!anchor.image_id || !imageById.has(anchor.image_id)) issues.push(`anchor_image_ref:${anchor.id || 'unknown'}`);
26
+ if (anchor.bbox) {
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);
30
+ for (const issue of bbox.issues) issues.push(`${issue}:${anchor.id || 'unknown'}`);
31
+ } else {
32
+ issues.push(`anchor_bbox:${anchor.id || 'unknown'}`);
33
+ }
34
+ }
35
+ if (opts.requireRelations && relations.length === 0) issues.push(`missing_relations:${opts.route || 'visual-route'}`);
36
+ for (const relation of relations) {
37
+ if (relation.before_image_id && !imageById.has(relation.before_image_id)) issues.push(`relation_before:${relation.before_image_id}`);
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
+ }
42
+ }
43
+ return {
44
+ ok: issues.length === 0,
45
+ status: issues.length ? 'blocked' : (anchors.length ? 'verified_partial' : 'not_verified'),
46
+ issues,
47
+ summary: {
48
+ images: images.length,
49
+ anchors: anchors.length,
50
+ relations: relations.length
51
+ }
52
+ };
53
+ }