sneakoscope 10.5.6 → 10.5.7

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 (42) hide show
  1. package/README.md +1 -1
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/cli/command-manifest-lite.js +3 -3
  5. package/dist/config/skills-manifest.json +1 -1
  6. package/dist/core/agents/agent-message-bus.js +180 -4
  7. package/dist/core/align/code-navigation-align.js +58 -3
  8. package/dist/core/artifact-schemas.js +105 -0
  9. package/dist/core/commands/gc-command.js +58 -0
  10. package/dist/core/commands/wiki-command.js +17 -3
  11. package/dist/core/decisions/cli.js +19 -3
  12. package/dist/core/decisions/config.js +25 -3
  13. package/dist/core/decisions/integration.js +26 -6
  14. package/dist/core/decisions/policy.js +7 -1
  15. package/dist/core/decisions/questions.js +43 -1
  16. package/dist/core/decisions/state.js +6 -2
  17. package/dist/core/decisions/types.js +9 -0
  18. package/dist/core/git-hygiene/git-policy.js +1 -0
  19. package/dist/core/git-hygiene/git-status.js +31 -22
  20. package/dist/core/git-hygiene/shared-memory-publish.js +536 -4
  21. package/dist/core/git-hygiene/validators.js +29 -1
  22. package/dist/core/hooks-runtime/jev-spawn-routing.js +22 -1
  23. package/dist/core/hooks-runtime/jev-turn-plan.js +116 -29
  24. package/dist/core/hooks-runtime/parent-orchestration-gate.js +10 -6
  25. package/dist/core/hooks-runtime/subagent-spawn-policy.js +2 -0
  26. package/dist/core/hooks-runtime.js +81 -5
  27. package/dist/core/memory/jev-memory-intake.js +179 -0
  28. package/dist/core/memory-governor.js +99 -0
  29. package/dist/core/perf/lru-cache.js +50 -1
  30. package/dist/core/pipeline-internals/pipeline-stage-builder.js +358 -0
  31. package/dist/core/pipeline-internals/runtime-core.js +21 -8
  32. package/dist/core/pipeline-internals/runtime-gates.js +22 -0
  33. package/dist/core/router/route-cache.js +38 -2
  34. package/dist/core/subagents/official-subagent-preparation.js +47 -3
  35. package/dist/core/triwiki/context-graph/compiler/cache-key.js +6 -1
  36. package/dist/core/triwiki-attention.js +24 -1
  37. package/dist/core/version.js +1 -1
  38. package/dist/core/wiki-image/image-voxel-ledger.js +84 -57
  39. package/dist/core/wiki-image/route-image-evidence.js +7 -3
  40. package/dist/core/wiki-image/validation.js +20 -12
  41. package/dist/core/wiki-image/visual-anchor.js +5 -1
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  Sneakoscope Codex (`sks`) is an open-source trust layer for Codex CLI and ChatGPT Desktop. It coordinates bounded AI coding agents, records machine-verifiable evidence, preserves project memory, and blocks release claims that are not supported by current tests or artifacts. Search visibility outcomes are measured separately; SKS does not promise rankings or traffic.
18
18
  <!-- END SKS SEARCH VISIBILITY MARKETING -->
19
19
 
20
- Current package: **SKS 10.5.6**. Install the latest stable release from npm.
20
+ Current package: **SKS 10.5.7**. Install the latest stable release from npm.
21
21
 
22
22
  Sneakoscope Codex (`sks`) is a local trust layer for Codex CLI and ChatGPT Desktop. It keeps agent work bounded, preserves project context, records machine-verifiable evidence, and prevents release claims that current tests or artifacts cannot support.
23
23
 
@@ -259,7 +259,7 @@ dependencies = [
259
259
 
260
260
  [[package]]
261
261
  name = "sks-core"
262
- version = "10.5.6"
262
+ version = "10.5.7"
263
263
  dependencies = [
264
264
  "globset",
265
265
  "grep-matcher",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sks-core"
3
- version = "10.5.6"
3
+ version = "10.5.7"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -70,7 +70,7 @@ const COMMAND_MANIFEST_LITE_BASE = [
70
70
  { name: 'gx', summary: 'Render/validate GX cartridges', maturity: 'labs' },
71
71
  { name: 'eval', summary: 'Run eval reports', maturity: 'labs' },
72
72
  { name: 'wiki', summary: 'Manage TriWiki and image voxel ledgers', maturity: 'beta', skipMigrationGate: true, allowedDuringActiveRoute: true, diagnostic: true },
73
- { name: 'memory', summary: 'Project TriWiki memory into managed AGENTS.md blocks or run memory GC', maturity: 'beta' },
73
+ { name: 'memory', summary: 'Project TriWiki memory, explicit Jev promotion, digest-bound forgetting, or bounded recall', maturity: 'beta' },
74
74
  { name: 'gc', summary: 'Compact/prune runtime state', maturity: 'labs', skipMigrationGate: true, allowedDuringActiveRoute: true, diagnostic: true },
75
75
  { name: 'stats', summary: 'Show storage stats', maturity: 'labs', readonly: true, skipMigrationGate: true, allowedDuringActiveRoute: true, diagnostic: true },
76
76
  { name: 'mcp-server', summary: 'Run a stdio MCP server exposing SKS commands as tools for MCP-capable agent hosts', maturity: 'beta', skipMigrationGate: true, allowedDuringActiveRoute: true },
@@ -373,8 +373,8 @@ const COMMAND_HELP_LITE = {
373
373
  description: 'Build, refresh, publish shared shards, rebuild ignored indexes, validate, and attach wrongness-memory context to RGBA/trig LLM Wiki packs with attention.use_first and attention.hydrate_first for compact recall plus source hydration.'
374
374
  },
375
375
  memory: {
376
- usage: 'sks memory build [--json] | sks memory gc [--dry-run]',
377
- description: 'Project TriWiki context-pack memory into managed AGENTS.md blocks or run bounded memory cleanup.'
376
+ usage: 'sks memory build [--json] | sks memory gc [--dry-run] | sks memory promote --mission <id> --yes [--json] | sks memory forget --memory-id <id> --previous-digest <digest> [--json] | sks memory recall [--query <text>] [--high-risk] [--json]',
377
+ description: 'Project TriWiki context-pack memory, explicitly promote a Jev mission intake, write a digest-bound tombstone, or read bounded canonical memory.'
378
378
  },
379
379
  naruto: {
380
380
  usage: 'sks naruto run "task" [--agents N] [--max-threads N] [--trusted-project] [--json] | sks naruto status|subagents|proof [latest|M-...] [--json] | sks naruto execution status [--json] | sks naruto execution set --mode auto|current-session|standalone [--restart] [--json] | sks naruto parent-summary --mission M-... --stdin [--json]',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": "sks.skills-manifest.v1",
3
- "package_version": "10.5.6",
3
+ "package_version": "10.5.7",
4
4
  "skills": [
5
5
  {
6
6
  "canonical_name": "sks",
@@ -1,7 +1,153 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
- import { appendJsonl, nowIso } from '../fsx.js';
3
+ import { appendJsonl, nowIso, sha256 } from '../fsx.js';
4
4
  import { appendAgentLedgerEvent } from './agent-central-ledger.js';
5
+ export const SIBLING_MESSAGE_MAX_BYTES = 4_096;
6
+ export const SIBLING_MESSAGE_MAX_COUNT = 8;
7
+ export const SIBLING_MESSAGE_MAX_REPLIES = 4;
8
+ export const SIBLING_MESSAGE_MAX_TTL_MS = 300_000;
9
+ export const SIBLING_MESSAGE_MAX_HOPS = 1;
10
+ export const SIBLING_MESSAGE_KINDS = Object.freeze(['need_sibling_analysis', 'evidence_request', 'handoff_clarification', 'conflict_notice']);
11
+ const SIBLING_RELAY_LOCKS = new Map();
12
+ async function withSiblingRelayLock(root, fn) {
13
+ const previous = SIBLING_RELAY_LOCKS.get(root) || Promise.resolve();
14
+ const next = previous.catch(() => undefined).then(fn);
15
+ SIBLING_RELAY_LOCKS.set(root, next.catch(() => undefined));
16
+ return next;
17
+ }
18
+ export function buildSiblingMessage(input) {
19
+ const created = input.created_at || nowIso();
20
+ const expires = input.expires_at || new Date(Date.parse(created) + 120_000).toISOString();
21
+ const body = String(input.body || '').trim();
22
+ const bodyBytes = Buffer.byteLength(body, 'utf8');
23
+ if (bodyBytes === 0 || bodyBytes > SIBLING_MESSAGE_MAX_BYTES)
24
+ throw new Error('sibling_message_body_size');
25
+ if (/(?:spawn|merge|publish|align|apply|permission|elevat|credential|secret|api[ _-]?key|password|token|session.?id)/i.test(body)) {
26
+ throw new Error('sibling_message_action_or_sensitive_content');
27
+ }
28
+ const ttlMs = Date.parse(expires) - Date.parse(created);
29
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > SIBLING_MESSAGE_MAX_TTL_MS)
30
+ throw new Error('sibling_message_ttl');
31
+ const message = {
32
+ schema: 'sks.sibling-message.v1',
33
+ message_id: String(input.message_id),
34
+ sender_task_id: String(input.sender_task_id),
35
+ recipient_task_id: String(input.recipient_task_id),
36
+ mission_id: String(input.mission_id),
37
+ plan_id: String(input.plan_id),
38
+ correlation_task_key: String(input.correlation_task_key),
39
+ kind: input.kind,
40
+ body_digest: sha256(body),
41
+ artifact_refs: [...new Set((input.artifact_refs || []).map(String))].filter((ref) => !ref.includes('..') && !/[\\\0]/.test(ref)).slice(0, 16),
42
+ reply_to: input.reply_to || null,
43
+ created_at: created,
44
+ expires_at: expires,
45
+ hop_count: Math.max(0, Math.floor(input.hop_count || 0)),
46
+ status: 'pending'
47
+ };
48
+ const validation = validateSiblingMessage(message);
49
+ if (!validation.ok)
50
+ throw new Error(`sibling_message_invalid:${validation.issues.join('|')}`);
51
+ return message;
52
+ }
53
+ export function validateSiblingMessage(value, now = Date.now(), options = {}) {
54
+ const row = value;
55
+ const issues = [];
56
+ if (!row || row.schema !== 'sks.sibling-message.v1')
57
+ issues.push('schema');
58
+ for (const key of ['message_id', 'sender_task_id', 'recipient_task_id', 'mission_id', 'plan_id', 'correlation_task_key', 'body_digest'])
59
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(String(row?.[key] || '')))
60
+ issues.push(key);
61
+ if (!SIBLING_MESSAGE_KINDS.includes(row?.kind))
62
+ issues.push('kind');
63
+ if (!Array.isArray(row?.artifact_refs) || row.artifact_refs.length > 16)
64
+ issues.push('artifact_refs');
65
+ if (Number(row?.hop_count) !== 0 && Number(row?.hop_count) !== SIBLING_MESSAGE_MAX_HOPS)
66
+ issues.push('hop_count');
67
+ if (!Number.isFinite(Date.parse(String(row?.created_at || ''))) || !Number.isFinite(Date.parse(String(row?.expires_at || ''))))
68
+ issues.push('time');
69
+ const ttlMs = Date.parse(String(row?.expires_at || '')) - Date.parse(String(row?.created_at || ''));
70
+ if (Date.parse(String(row?.expires_at || '')) <= now)
71
+ issues.push('expired');
72
+ if (ttlMs <= 0 || ttlMs > (options.maxTtlMs || SIBLING_MESSAGE_MAX_TTL_MS))
73
+ issues.push('ttl');
74
+ if (options.expectedPlanId && row?.plan_id !== options.expectedPlanId)
75
+ issues.push('cross_plan');
76
+ if (options.allowedRecipients && !options.allowedRecipients.includes(String(row?.recipient_task_id || '')))
77
+ issues.push('recipient');
78
+ if (options.mainTaskId && row?.kind === 'need_sibling_analysis' && row?.recipient_task_id !== options.mainTaskId)
79
+ issues.push('decomposition_recipient');
80
+ if (typeof row?.status !== 'string' || !['pending', 'replied', 'resolved', 'denied', 'expired'].includes(row.status))
81
+ issues.push('status');
82
+ return { ok: issues.length === 0, issues };
83
+ }
84
+ export async function appendSiblingMessage(root, input, options = {}) {
85
+ return withSiblingRelayLock(root, async () => {
86
+ const row = buildSiblingMessage(input);
87
+ const now = options.now || Date.now();
88
+ if (options.maxBytes !== undefined && Buffer.byteLength(String(input.body || '').trim(), 'utf8') > Math.min(SIBLING_MESSAGE_MAX_BYTES, Math.max(1, Math.floor(options.maxBytes))))
89
+ throw new Error('sibling_message_size_cap');
90
+ if (row.sender_task_id === row.recipient_task_id)
91
+ throw new Error('sibling_message_self_recipient');
92
+ if (options.planId && row.plan_id !== options.planId)
93
+ throw new Error('sibling_message_cross_plan');
94
+ if (options.parentTaskId && (options.senderParentTaskId !== options.parentTaskId || options.recipientParentTaskId !== options.parentTaskId)) {
95
+ throw new Error('sibling_message_parent_scope');
96
+ }
97
+ const allowedRecipients = options.allowedRecipients ? [...new Set(options.allowedRecipients.map(String))] : null;
98
+ if (allowedRecipients && !allowedRecipients.includes(row.recipient_task_id))
99
+ throw new Error('sibling_message_recipient_denied');
100
+ if (options.mainTaskId && row.kind === 'need_sibling_analysis' && row.recipient_task_id !== options.mainTaskId)
101
+ throw new Error('sibling_message_decomposition_main_only');
102
+ const requiredCapability = options.requiredCapability || 'sibling_discussion';
103
+ for (const side of [options.senderCapabilities, options.recipientCapabilities]) {
104
+ if (side && !side.includes(requiredCapability))
105
+ throw new Error('sibling_message_capability_denied');
106
+ }
107
+ const validation = validateSiblingMessage(row, now, {
108
+ ...(options.planId === undefined ? {} : { expectedPlanId: options.planId }),
109
+ ...(allowedRecipients === null ? {} : { allowedRecipients }),
110
+ ...(options.maxTtlMs === undefined ? {} : { maxTtlMs: options.maxTtlMs }),
111
+ ...(options.mainTaskId === undefined ? {} : { mainTaskId: options.mainTaskId })
112
+ });
113
+ if (!validation.ok)
114
+ throw new Error(`sibling_message_invalid:${validation.issues.join('|')}`);
115
+ const existing = await readRawAgentMessages(root);
116
+ const duplicate = existing.find((entry) => entry?.message_id === row.message_id && entry?.plan_id === row.plan_id);
117
+ if (duplicate)
118
+ return { ...row, duplicate: true, bus_entry: normalizeAgentMessageBusEntry(duplicate, row.mission_id) };
119
+ const planCount = existing.filter((entry) => entry?.plan_id === row.plan_id && entry?.mission_id === row.mission_id).length;
120
+ if (planCount >= Math.min(SIBLING_MESSAGE_MAX_COUNT, Math.max(1, Math.floor(options.maxCount || SIBLING_MESSAGE_MAX_COUNT))))
121
+ throw new Error('sibling_message_count_cap');
122
+ if (row.reply_to) {
123
+ const parent = existing.find((entry) => entry?.message_id === row.reply_to && entry?.plan_id === row.plan_id);
124
+ if (!parent)
125
+ throw new Error('sibling_message_reply_target_missing');
126
+ const replies = existing.filter((entry) => entry?.plan_id === row.plan_id && entry?.reply_to === row.reply_to).length;
127
+ if (replies >= Math.min(SIBLING_MESSAGE_MAX_REPLIES, Math.max(1, Math.floor(options.maxReplies || SIBLING_MESSAGE_MAX_REPLIES))))
128
+ throw new Error('sibling_message_reply_cap');
129
+ }
130
+ const entry = await appendAgentMessage(root, {
131
+ from: row.sender_task_id,
132
+ session_id: row.sender_task_id,
133
+ to: row.recipient_task_id,
134
+ body: `body_digest:${row.body_digest}`,
135
+ type: row.kind,
136
+ mission_id: row.mission_id,
137
+ worker_id: row.sender_task_id,
138
+ event_type: 'status',
139
+ artifact_paths: row.artifact_refs,
140
+ message_id: row.message_id,
141
+ plan_id: row.plan_id,
142
+ correlation_task_key: row.correlation_task_key,
143
+ reply_to: row.reply_to,
144
+ expires_at: row.expires_at,
145
+ hop_count: row.hop_count,
146
+ body_digest: row.body_digest
147
+ });
148
+ return { ...row, duplicate: false, bus_entry: entry };
149
+ });
150
+ }
5
151
  export async function appendAgentMessage(root, message) {
6
152
  const eventType = normalizeAgentMessageEventType(message.event_type || message.type);
7
153
  const entry = {
@@ -18,10 +164,21 @@ export async function appendAgentMessage(root, message) {
18
164
  from: message.from,
19
165
  to: message.to || 'orchestrator',
20
166
  type: message.type || 'note',
21
- body: message.body
167
+ body: message.body,
168
+ ...(message.message_id === undefined ? {} : { message_id: message.message_id }),
169
+ ...(message.plan_id === undefined ? {} : { plan_id: message.plan_id }),
170
+ ...(message.correlation_task_key === undefined ? {} : { correlation_task_key: message.correlation_task_key }),
171
+ ...(message.reply_to === undefined ? {} : { reply_to: message.reply_to }),
172
+ ...(message.expires_at === undefined ? {} : { expires_at: message.expires_at }),
173
+ ...(message.hop_count === undefined ? {} : { hop_count: message.hop_count }),
174
+ ...(message.body_digest === undefined ? {} : { body_digest: message.body_digest })
22
175
  };
23
176
  await appendJsonl(path.join(root, 'agent-messages.jsonl'), entry);
24
- await appendAgentLedgerEvent(root, { agent_id: message.from, session_id: message.session_id, event_type: 'message_appended', payload: { to: entry.to, type: entry.type } });
177
+ await appendAgentLedgerEvent(root, { agent_id: message.from, session_id: message.session_id, event_type: 'message_appended', payload: {
178
+ to: entry.to, type: entry.type, message_id: entry.message_id || null, plan_id: entry.plan_id || null,
179
+ correlation_task_key: entry.correlation_task_key || null, reply_to: entry.reply_to || null,
180
+ expires_at: entry.expires_at || null, hop_count: entry.hop_count ?? null, body_digest: entry.body_digest || null
181
+ } });
25
182
  return entry;
26
183
  }
27
184
  export async function readAgentMessageBus(root, missionId, opts = {}) {
@@ -74,9 +231,28 @@ function normalizeAgentMessageBusEntry(value, missionId) {
74
231
  from: value.from == null ? undefined : String(value.from),
75
232
  to: value.to == null ? undefined : String(value.to),
76
233
  type: value.type == null ? undefined : String(value.type),
77
- body: value.body == null ? undefined : String(value.body)
234
+ body: value.body == null ? undefined : String(value.body),
235
+ message_id: value.message_id == null ? undefined : String(value.message_id),
236
+ plan_id: value.plan_id == null ? undefined : String(value.plan_id),
237
+ correlation_task_key: value.correlation_task_key == null ? undefined : String(value.correlation_task_key),
238
+ reply_to: value.reply_to == null ? value.reply_to : String(value.reply_to),
239
+ expires_at: value.expires_at == null ? undefined : String(value.expires_at),
240
+ hop_count: value.hop_count == null ? undefined : Number(value.hop_count),
241
+ body_digest: value.body_digest == null ? undefined : String(value.body_digest)
78
242
  };
79
243
  }
244
+ async function readRawAgentMessages(root) {
245
+ const file = path.join(root, 'agent-messages.jsonl');
246
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
247
+ return text.split(/\n+/).filter(Boolean).flatMap((line) => {
248
+ try {
249
+ return [JSON.parse(line)];
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ });
255
+ }
80
256
  function normalizeAgentMessageEventType(value) {
81
257
  const text = String(value || '').toLowerCase();
82
258
  if (text === 'worker_completed' || text === 'completed' || text === 'done')
@@ -17,6 +17,7 @@ import { walkCodeInventory } from '../triwiki/context-graph/extractors/code/inve
17
17
  import { codeInventoryInputHashes } from '../triwiki/code-navigation-policy.js';
18
18
  import { clearContextGraphSnapshotCache, clearWorkspaceContextIndex } from '../triwiki/context-graph/query/index.js';
19
19
  import { withTriWikiStateLock } from '../triwiki/triwiki-cleanup.js';
20
+ import { preserveCanonicalMemory, rebuildSharedIndexes } from '../git-hygiene/shared-memory-publish.js';
20
21
  import { publishArchitectureMapToStage } from '../triwiki/context-graph/store/architecture-map-store.js';
21
22
  import { alignPendingRoot, alignPendingWiki, alignSourceFingerprint, projectAlignCodePack, publishAlignContextIndex } from './align-context-index.js';
22
23
  import { ALIGN_LEDGER_ARTIFACT, ALIGN_OUTPUT_ARTIFACTS, ALIGN_PLAN_ARTIFACT, ALIGN_STAGING_ROOT_REL, buildInitialAlignLedger, refreshAlignGate } from './align-route.js';
@@ -43,7 +44,7 @@ if (new Set(ALIGN_OUTPUT_ARTIFACTS).size !== ALIGN_OUTPUT_ARTIFACTS.length) {
43
44
  }
44
45
  const ALIGN_REPLACED_SURFACES = Object.freeze([
45
46
  { key: 'wiki', rel: '.sneakoscope/wiki' },
46
- { key: 'memory', rel: '.sneakoscope/memory' },
47
+ { key: 'legacy_memory_runtime', rel: '.sneakoscope/memory' },
47
48
  { key: 'context_graph_cache', rel: '.sneakoscope/cache/context-graph' },
48
49
  { key: 'code_pack_freshness_cache', rel: '.sneakoscope/cache/code-pack-head-freshness.json' },
49
50
  { key: 'generated_agents_projection', rel: '.sneakoscope/context/AGENTS.generated.md' },
@@ -167,7 +168,15 @@ async function validateStaging(input) {
167
168
  if (!packValidation.ok || !isCodePackProjectionBoundToSnapshot(input.snapshot.snapshotHash, pack))
168
169
  return false;
169
170
  const contextPack = await readJson(path.join(input.stageWiki, 'context-pack.json'), null);
170
- return validateCodeNavigationContextPack(contextPack, input.root).ok;
171
+ if (!validateCodeNavigationContextPack(contextPack, input.root).ok)
172
+ return false;
173
+ for (const [relative, expected] of Object.entries(input.canonicalHashes || {})) {
174
+ const file = path.join(input.stageWiki, relative);
175
+ const stat = await fsp.stat(file).catch(() => null);
176
+ if (!stat?.isFile() || await fileSha256(file) !== expected)
177
+ return false;
178
+ }
179
+ return true;
171
180
  }
172
181
  async function runLocked(root, dir, missionId, plan, ledger, beforeFinalSourceCas) {
173
182
  const startedAt = Date.now();
@@ -177,6 +186,28 @@ async function runLocked(root, dir, missionId, plan, ledger, beforeFinalSourceCa
177
186
  if (staleStage?.isSymbolicLink())
178
187
  throw new Error('code_navigation_staging_symlink_refused');
179
188
  if (staleStage) {
189
+ const entries = await fsp.readdir(stageBase, { withFileTypes: true });
190
+ for (const entry of entries) {
191
+ if (!entry.isDirectory() || entry.isSymbolicLink())
192
+ throw new Error('align_recovery_stage_invalid');
193
+ const journal = await readJson(path.join(stageBase, entry.name, 'swap-journal.json'), null);
194
+ const priorRoot = path.join(stageBase, entry.name, 'previous');
195
+ for (const surface of ALIGN_REPLACED_SURFACES) {
196
+ const prior = path.join(priorRoot, surface.key);
197
+ if (!(await lstatOrNull(prior)))
198
+ continue;
199
+ if ((await lstatOrNull(prior))?.isSymbolicLink())
200
+ throw new Error('align_recovery_symlink');
201
+ const active = path.join(root, surface.rel);
202
+ if (!await lstatOrNull(active) && journal?.phase === 'prior_moved') {
203
+ await ensureDir(path.dirname(active));
204
+ await guardedRename(guard, prior, active);
205
+ }
206
+ else if (await lstatOrNull(prior)) {
207
+ await guardedRm(guard, prior, { recursive: true, force: true });
208
+ }
209
+ }
210
+ }
180
211
  await guardedRm(guard, stageBase, { recursive: true, force: true });
181
212
  if (await lstatOrNull(stageBase))
182
213
  throw new Error('code_navigation_stale_staging_not_removed');
@@ -311,8 +342,11 @@ async function runLocked(root, dir, missionId, plan, ledger, beforeFinalSourceCa
311
342
  await writeJsonAtomic(path.join(stageWiki, 'code-pack.json'), pack);
312
343
  await writeJsonAtomic(path.join(stageWiki, 'context-pack.json'), contextPack);
313
344
  await publishArchitectureMapToStage(stageWiki, snapshot, { root, missionId });
345
+ const canonicalHashes = await preserveCanonicalMemory(root, stageWiki);
346
+ await rebuildSharedIndexes(pendingRoot);
347
+ ledger.publication.canonical_memory_sha256 = canonicalHashes;
314
348
  ledger.publication.staged = true;
315
- ledger.validation.staged_readback = await validateStaging({ stageWiki, root, snapshot, pack, contextPack, manifest });
349
+ ledger.validation.staged_readback = await validateStaging({ stageWiki, root, snapshot, pack, contextPack, manifest, canonicalHashes });
316
350
  if (!ledger.validation.staged_readback)
317
351
  throw new Error('code_navigation_staging_validation_failed');
318
352
  const prior = await inspectPriorSurfaces(root);
@@ -321,15 +355,36 @@ async function runLocked(root, dir, missionId, plan, ledger, beforeFinalSourceCa
321
355
  ledger.input_state.prior_state_digest = prior.length
322
356
  ? crypto.createHash('sha256').update(prior.map((surface) => surface.descriptor).sort().join('\n')).digest('hex')
323
357
  : null;
358
+ await writeJsonAtomic(path.join(stageRoot, 'swap-journal.json'), {
359
+ schema: 'sks.align-swap-journal.v1',
360
+ phase: 'prepared',
361
+ mission_id: missionId,
362
+ active_surfaces: prior.map((surface) => surface.rel).sort(),
363
+ created_at: nowIso()
364
+ });
324
365
  for (const surface of prior) {
325
366
  const temporary = path.join(previousRoot, surface.key);
326
367
  await ensureDir(path.dirname(temporary));
327
368
  await guardedRename(guard, surface.absolute, temporary);
328
369
  movedPrior.push({ surface, temporary });
329
370
  }
371
+ await writeJsonAtomic(path.join(stageRoot, 'swap-journal.json'), {
372
+ schema: 'sks.align-swap-journal.v1',
373
+ phase: 'prior_moved',
374
+ mission_id: missionId,
375
+ active_surfaces: prior.map((surface) => surface.rel).sort(),
376
+ created_at: nowIso()
377
+ });
330
378
  await ensureDir(path.dirname(activeWiki));
331
379
  await guardedRename(guard, stageWiki, activeWiki);
332
380
  promoted = true;
381
+ await writeJsonAtomic(path.join(stageRoot, 'swap-journal.json'), {
382
+ schema: 'sks.align-swap-journal.v1',
383
+ phase: 'promoted',
384
+ mission_id: missionId,
385
+ active_surfaces: prior.map((surface) => surface.rel).sort(),
386
+ created_at: nowIso()
387
+ });
333
388
  clearContextGraphSnapshotCache();
334
389
  clearWorkspaceContextIndex(root);
335
390
  ledger.publication.transactional_directory_replaced = true;
@@ -1,5 +1,110 @@
1
1
  import path from 'node:path';
2
2
  import { exists, nowIso, readJson, writeJsonAtomic } from './fsx.js';
3
+ import { MEMORY_DISPOSITIONS, MEMORY_POLICY_REVISION } from './decisions/types.js';
4
+ import { containsPlaintextSecret } from './secret-redaction.js';
5
+ import { stableDigest } from './decisions/state.js';
6
+ export const JEV_MEMORY_LIMITS = Object.freeze({ textChars: 512, evidence: 16, images: 8, entryBytes: 8192, fileBytes: 131072, entries: 16 });
7
+ export const MEMORY_HASH = /^[a-f0-9]{64}$/;
8
+ export const MEMORY_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/;
9
+ export function memoryRelativePath(value) {
10
+ return typeof value === 'string' && value.length > 0 && value.length <= 320
11
+ && !value.includes('\\') && !value.includes('\0') && !path.posix.isAbsolute(value)
12
+ && !/^[A-Za-z]:/.test(value) && !value.split('/').some((part) => part === '..' || part === '.' || !part);
13
+ }
14
+ export function validateJevMemoryIntake(data) {
15
+ const row = data;
16
+ const errors = [];
17
+ if (!row || row.schema !== 'sks.jev-memory-intake.v1')
18
+ return validationResult('sks.jev-memory-intake.v1', ['schema']);
19
+ const allowed = ['schema', 'memory_id', 'idempotency_key', 'workflow_run_id', 'candidate_digest', 'mission_id', 'turn_id', 'session_id_hash', 'disposition', 'scope', 'text_redacted', 'text_hash', 'source_kind', 'source_digest', 'evidence_refs', 'evidence_hashes', 'context_graph_hash', 'jev_decision', 'image_voxel_refs', 'lifecycle_state', 'ttl', 'created_at', 'policy_revision', 'redaction_status', 'promotion_status'];
20
+ if (Object.keys(row).some((key) => !allowed.includes(key)))
21
+ errors.push('unknown_field');
22
+ for (const key of ['memory_id', 'idempotency_key', 'candidate_digest', 'session_id_hash', 'text_hash', 'source_digest', 'context_graph_hash'])
23
+ if (typeof row[key] !== 'string' || !MEMORY_HASH.test(row[key]))
24
+ errors.push(key);
25
+ for (const key of ['mission_id', 'turn_id', 'workflow_run_id'])
26
+ if (typeof row[key] !== 'string' || !MEMORY_ID.test(row[key]))
27
+ errors.push(key);
28
+ if (!MEMORY_DISPOSITIONS.includes(row.disposition) || ['sensitive_no_store', 'ephemeral_turn', 'keep_baseline', 'needs_confirmation'].includes(row.disposition))
29
+ errors.push('disposition_not_storable');
30
+ if (!['mission', 'project', 'user', 'route'].includes(row.scope))
31
+ errors.push('scope');
32
+ if (!['user_prompt', 'mission_artifact', 'tool_evidence', 'image_evidence'].includes(row.source_kind))
33
+ errors.push('source_kind');
34
+ if (typeof row.text_redacted !== 'string' || !row.text_redacted.trim() || row.text_redacted.length > JEV_MEMORY_LIMITS.textChars)
35
+ errors.push('text_bound');
36
+ if (!Array.isArray(row.evidence_refs) || row.evidence_refs.length > JEV_MEMORY_LIMITS.evidence || row.evidence_refs.some((ref) => !memoryRelativePath(ref)))
37
+ errors.push('evidence_refs');
38
+ if (!row.evidence_hashes || typeof row.evidence_hashes !== 'object' || Array.isArray(row.evidence_hashes)
39
+ || Object.keys(row.evidence_hashes).length > JEV_MEMORY_LIMITS.evidence
40
+ || Object.entries(row.evidence_hashes).some(([ref, hash]) => !memoryRelativePath(ref) || !MEMORY_HASH.test(hash) || !row.evidence_refs?.includes(ref))
41
+ || row.evidence_refs?.some(ref => !row.evidence_hashes?.[ref]))
42
+ errors.push('evidence_hashes');
43
+ if (!Array.isArray(row.image_voxel_refs) || row.image_voxel_refs.length > JEV_MEMORY_LIMITS.images)
44
+ errors.push('image_voxel_refs');
45
+ else
46
+ for (const ref of row.image_voxel_refs) {
47
+ if (!ref || typeof ref !== 'object') {
48
+ errors.push('image_ref');
49
+ continue;
50
+ }
51
+ if (!MEMORY_ID.test(ref.image_id || '') || !MEMORY_ID.test(ref.anchor_id || '') || !MEMORY_HASH.test(ref.sha256 || '') || !ref.relation_id || ref.relation_id.length > 96)
52
+ errors.push('image_ref_identity');
53
+ if (!Number.isInteger(ref.width) || ref.width <= 0 || !Number.isInteger(ref.height) || ref.height <= 0 || !Array.isArray(ref.bbox) || ref.bbox.length !== 4 || !ref.bbox.every(Number.isFinite) || ref.bbox[0] < 0 || ref.bbox[1] < 0 || ref.bbox[2] <= 0 || ref.bbox[3] <= 0 || ref.bbox[0] + ref.bbox[2] > ref.width || ref.bbox[1] + ref.bbox[3] > ref.height)
54
+ errors.push('image_ref_bbox');
55
+ }
56
+ if (!['ACTIVE', 'PINNED', 'DORMANT', 'STALE', 'CONFLICTED', 'QUARANTINED', 'DELETE_CANDIDATE', 'DELETED'].includes(row.lifecycle_state))
57
+ errors.push('lifecycle');
58
+ const zeroTtl = ['ephemeral_turn', 'sensitive_no_store', 'needs_confirmation', 'keep_baseline'].includes(row.disposition);
59
+ if (!Number.isSafeInteger(row.ttl) || row.ttl < 0 || row.ttl > 365 * 86400 || (!zeroTtl && row.ttl === 0) || (zeroTtl && row.ttl !== 0))
60
+ errors.push('ttl');
61
+ if (typeof row.created_at !== 'string' || !Number.isFinite(Date.parse(row.created_at)) || !row.created_at.endsWith('Z'))
62
+ errors.push('created_at');
63
+ if (row.policy_revision !== MEMORY_POLICY_REVISION)
64
+ errors.push('policy_revision');
65
+ if (row.redaction_status?.status !== 'pass' || !MEMORY_HASH.test(row.redaction_status?.rule_digest || ''))
66
+ errors.push('redaction_status');
67
+ if (row.jev_decision !== null && (!MEMORY_HASH.test(row.jev_decision?.digest || '') || Object.keys(row.jev_decision).length !== 1))
68
+ errors.push('jev_decision');
69
+ if (!['staged', 'needs_confirmation', 'promoted', 'rejected', 'tombstoned'].includes(row.promotion_status))
70
+ errors.push('promotion_status');
71
+ if (Buffer.byteLength(JSON.stringify(row)) > JEV_MEMORY_LIMITS.entryBytes)
72
+ errors.push('entry_bytes');
73
+ if (containsPlaintextSecret(row) || /data:image\/|[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|(?:\/Users\/|\/home\/)[^/\s]+/.test(row.text_redacted || ''))
74
+ errors.push('sensitive_content');
75
+ if (row.text_hash !== stableDigest(row.text_redacted))
76
+ errors.push('text_hash_mismatch');
77
+ if (row.memory_id !== stableDigest({ text: row.text_hash, scope: row.scope, source: row.source_digest }))
78
+ errors.push('memory_id_mismatch');
79
+ if (row.idempotency_key !== stableDigest({ workflow: row.workflow_run_id, turn: row.turn_id, candidate: row.candidate_digest }))
80
+ errors.push('idempotency_key_mismatch');
81
+ return validationResult('sks.jev-memory-intake.v1', errors);
82
+ }
83
+ export function validateCanonicalMemoryMetadata(value) {
84
+ const row = value;
85
+ const errors = row ? [...validateJevMemoryIntake(row.intake).errors] : ['memory_metadata'];
86
+ if (row)
87
+ for (const key of ['effective_trust', 'evidence_score', 'priority']) {
88
+ if (typeof row[key] !== 'number' || !Number.isFinite(row[key]) || row[key] < 0 || row[key] > 1)
89
+ errors.push(key);
90
+ }
91
+ if (row && Object.keys(row).some(key => !['intake', 'effective_trust', 'evidence_score', 'priority'].includes(key)))
92
+ errors.push('unknown_memory_metadata');
93
+ return validationResult('sks.canonical-memory-metadata.v1', errors);
94
+ }
95
+ export function validateMemoryTombstone(data) {
96
+ const row = data;
97
+ const errors = [];
98
+ if (!row || row.schema !== 'sks.memory-tombstone.v1')
99
+ return validationResult('sks.memory-tombstone.v1', ['schema']);
100
+ if (!MEMORY_HASH.test(row.memory_id) || !MEMORY_HASH.test(row.previous_digest) || row.id !== row.memory_id)
101
+ errors.push('identity');
102
+ if (!['explicit_forget', 'policy_quarantine'].includes(row.reason) || !Number.isFinite(Date.parse(row.created_at)))
103
+ errors.push('reason_or_time');
104
+ if (Object.keys(row).length !== 6 || containsPlaintextSecret(row))
105
+ errors.push('unexpected_content');
106
+ return validationResult('sks.memory-tombstone.v1', errors);
107
+ }
3
108
  export const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'forensic_vision', 'recovery']);
4
109
  export const WORK_ORDER_STATUSES = new Set(['pending', 'in_progress', 'implemented', 'verified', 'blocked']);
5
110
  export const ARTIFACT_FILES = {
@@ -4,6 +4,61 @@ import { flag } from './command-utils.js';
4
4
  import { compileMistakeRules } from '../verification/mistake-rule-compiler.js';
5
5
  export async function memoryCommand(sub, args = []) {
6
6
  const action = String(sub || '').toLowerCase();
7
+ if (['promote', 'forget', 'recall'].includes(action)) {
8
+ const root = await sksRoot();
9
+ const json = flag(args, '--json');
10
+ if (action === 'promote') {
11
+ const missionId = String(readOption(args, '--mission', '') || '').trim();
12
+ if (!missionId || !flag(args, '--yes')) {
13
+ const blocked = { schema: 'sks.jev-memory-promotion.v1', ok: false, promoted: [], rejected: ['explicit_mission_and_yes_required'] };
14
+ if (json)
15
+ console.log(JSON.stringify(blocked, null, 2));
16
+ else {
17
+ console.log('Memory promotion blocked: pass --mission <id> --yes.');
18
+ process.exitCode = 1;
19
+ }
20
+ return blocked;
21
+ }
22
+ const { promoteJevMemoryIntake } = await import('../git-hygiene/shared-memory-publish.js');
23
+ const result = await promoteJevMemoryIntake(root, missionId, { explicit: true, requireConfirmation: true });
24
+ if (json)
25
+ console.log(JSON.stringify(result, null, 2));
26
+ else
27
+ console.log(result.ok ? `Memory promotion: ${result.promoted.length} record(s)` : `Memory promotion blocked: ${result.rejected.join(', ')}`);
28
+ if (!result.ok)
29
+ process.exitCode = 1;
30
+ return result;
31
+ }
32
+ if (action === 'forget') {
33
+ const memoryId = String(readOption(args, '--memory-id', positionalMemoryId(args)) || '').trim();
34
+ const previousDigest = String(readOption(args, '--previous-digest', '') || '').trim();
35
+ if (!memoryId || !previousDigest) {
36
+ const blocked = { schema: 'sks.memory-tombstone.v1', ok: false, blockers: ['memory_id_and_previous_digest_required'] };
37
+ if (json)
38
+ console.log(JSON.stringify(blocked, null, 2));
39
+ else {
40
+ console.log('Memory forget blocked: pass --memory-id <id> --previous-digest <digest>.');
41
+ process.exitCode = 1;
42
+ }
43
+ return blocked;
44
+ }
45
+ const { writeJevMemoryTombstone } = await import('../git-hygiene/shared-memory-publish.js');
46
+ const result = await writeJevMemoryTombstone(root, { memoryId, previousDigest });
47
+ const output = { ...result, ok: true };
48
+ if (json)
49
+ console.log(JSON.stringify(output, null, 2));
50
+ else
51
+ console.log(`Memory tombstone written: ${result.path}`);
52
+ return output;
53
+ }
54
+ const { readSharedMemoryOverlay } = await import('../git-hygiene/shared-memory-publish.js');
55
+ const result = await readSharedMemoryOverlay(root, { query: String(readOption(args, '--query', '') || ''), highRisk: flag(args, '--high-risk') });
56
+ if (json)
57
+ console.log(JSON.stringify(result, null, 2));
58
+ else
59
+ console.log(`Memory recall: ${result.available ? result.items.length : `unavailable (${result.reason || 'unknown'})`}`);
60
+ return result;
61
+ }
7
62
  if (['build', 'project', 'agents', 'agents-md'].includes(action)) {
8
63
  const root = await projectRoot();
9
64
  const json = flag(args, '--json');
@@ -106,6 +161,9 @@ function readOption(args = [], name, fallback = null) {
106
161
  const prefixed = args.find((arg) => String(arg).startsWith(`${name}=`));
107
162
  return prefixed ? String(prefixed).slice(name.length + 1) : fallback;
108
163
  }
164
+ function positionalMemoryId(args = []) {
165
+ return (args || []).find((arg) => !String(arg).startsWith('--')) || '';
166
+ }
109
167
  export async function statsCommand(args = []) {
110
168
  const root = await sksRoot();
111
169
  const full = flag(args, '--full');
@@ -14,7 +14,7 @@ import { validateImageVoxelLedger } from '../wiki-image/validation.js';
14
14
  import { maybeFinalizeRoute } from '../proof/auto-finalize.js';
15
15
  import { wikiWrongnessCommand } from '../triwiki-wrongness/wrongness-cli.js';
16
16
  import { recordImageWrongnessFromValidation } from '../triwiki-wrongness/image-wrongness.js';
17
- import { publishSharedMemory, rebuildSharedIndexes, sharedMemorySummary, validateSharedMemory } from '../git-hygiene/shared-memory-publish.js';
17
+ import { promoteJevMemoryIntake, publishSharedMemory, rebuildSharedIndexes, sharedMemorySummary, validateSharedMemory } from '../git-hygiene/shared-memory-publish.js';
18
18
  import { codePackPath, isCodePackProjectionBoundToSnapshot } from '../triwiki/code-pack.js';
19
19
  import { runContextGraphLint } from '../triwiki/context-graph/lint/index.js';
20
20
  import { contextIndexFreshness } from '../triwiki/context-graph/store/index-freshness.js';
@@ -57,14 +57,28 @@ export async function wikiCommand(sub, args = []) {
57
57
  if (sub === 'publish') {
58
58
  const root = await sksRoot();
59
59
  if (!flag(args, '--shared'))
60
- throw new Error('Usage: sks wiki publish latest --shared [--redact] [--json]');
60
+ throw new Error('Usage: sks wiki publish latest --shared [--redact] [--jev-memory --mission <id>] [--json]');
61
61
  const target = positionalArgs(args)[0] || 'latest';
62
62
  if (target !== 'latest')
63
63
  throw new Error('Usage: sks wiki publish latest --shared [--redact] [--json]');
64
+ let promotion = null;
65
+ const missionId = readFlagValue(args, '--mission', '');
66
+ if (flag(args, '--jev-memory')) {
67
+ if (!missionId)
68
+ throw new Error('Usage: --jev-memory requires --mission <id>');
69
+ promotion = await promoteJevMemoryIntake(root, missionId, { explicit: true, requireConfirmation: true });
70
+ if (!promotion.ok) {
71
+ process.exitCode = 2;
72
+ if (flag(args, '--json'))
73
+ return console.log(JSON.stringify({ promotion }, null, 2));
74
+ console.log(`Jev memory promotion: blocked (${promotion.rejected.join(', ')})`);
75
+ return;
76
+ }
77
+ }
64
78
  const result = await publishSharedMemory(root, { target: 'wiki', redact: flag(args, '--redact') });
65
79
  process.exitCode = result.ok ? 0 : 2;
66
80
  if (flag(args, '--json'))
67
- return console.log(JSON.stringify(result, null, 2));
81
+ return console.log(JSON.stringify({ ...result, ...(promotion ? { promotion } : {}) }, null, 2));
68
82
  console.log(`Shared wiki publish: ${result.ok ? 'ok' : 'blocked'}`);
69
83
  console.log(`Written: ${result.written.length}`);
70
84
  for (const blocker of result.blockers)