sneakoscope 0.7.33 → 0.7.37

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.
package/README.md CHANGED
@@ -53,7 +53,7 @@ sks selftest --mock
53
53
  | PPT pipeline | Uses `$PPT` for simple, restrained, information-first HTML/PDF presentation artifacts, first asking delivery context, audience profile, STP strategy, decision context, and 3+ pain-point to solution/aha mappings before source research, design-system work, HTML/PDF export, and render QA. Independent strategy/render/file-write phases run in parallel where inputs allow and are recorded in `ppt-parallel-report.json`; editable source HTML is preserved under `source-html/`, PPT-only temporary build files are cleaned after completion, installed skills/MCPs outside the `$PPT` allowlist are ignored, generated image assets may use `$imagegen` only when sealed in the contract, and `ppt-style-tokens.json` records the design SSOT plus fused source inputs. |
54
54
  | Computer Use fast lane | Uses `$Computer-Use` / `$CU` for UI/browser/visual work that needs maximum speed: skip Team debate and upfront TriWiki loops, use Codex Computer Use directly, then refresh/validate TriWiki and run Honest Mode at final closeout. |
55
55
  | Goal | Provides a fast SKS bridge overlay for Codex native persisted `/goal` create, pause, resume, and clear controls; implementation continues through the selected SKS execution route. |
56
- | TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata, `attention.use_first`, and `attention.hydrate_first`. |
56
+ | TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata, `attention.use_first`, `attention.hydrate_first`, and prompt-bound mistake recall ledgers. |
57
57
  | Context7 | Requires current docs for external packages, APIs, MCPs, SDKs, and framework/runtime behavior when correctness depends on current guidance. |
58
58
  | Design SSOT | Treats `design.md` as the only design decision source of truth. `docs/Design-Sys-Prompt.md` is the builder prompt; getdesign.md, official getdesign docs, and curated DESIGN.md examples from `VoltAgent/awesome-design-md` are source inputs that must be fused into `design.md` or route-local style tokens instead of becoming parallel authorities. |
59
59
  | DB safety | Treats SQL, migrations, Supabase, RLS, and destructive operations as high risk. |
@@ -166,7 +166,7 @@ sks tmux check
166
166
  sks tmux status --once
167
167
  ```
168
168
 
169
- Bare `sks` creates or reuses the default named tmux session for Codex CLI. Use `sks tmux open` when you need explicit `--workspace` / `--session` flags, `sks tmux check` for readiness without launching, and `sks help` for CLI help.
169
+ Bare `sks` creates or reuses the default named tmux session for Codex CLI and attaches to it in an interactive terminal. Use `sks tmux open` when you need explicit `--workspace` / `--session` flags, `sks tmux check` for readiness without launching, and `sks help` for CLI help. Use `--no-attach` or `SKS_TMUX_NO_AUTO_ATTACH=1` when you only want SKS to create/reuse the session and print the manual attach command.
170
170
 
171
171
  Before opening tmux, SKS checks the installed Codex CLI against npm `@openai/codex@latest`. If a newer version exists, it asks `Y/n`; answering `y` updates automatically with `npm i -g @openai/codex@latest` and then opens tmux with the updated Codex CLI.
172
172
 
@@ -177,7 +177,7 @@ sks --mad
177
177
  sks --mad --yes
178
178
  ```
179
179
 
180
- This creates/uses the `sks-mad-high` Codex profile for a one-shot full-access, high-reasoning tmux session with `approval_policy = "on-request"` and `approvals_reviewer = "auto_review"`. It is scoped to that explicit command and does not change normal SKS/DB safety defaults. Repeat launches reuse the same named SKS MAD tmux session.
180
+ This creates/uses the `sks-mad-high` Codex profile for a one-shot full-access, high-reasoning tmux session with `approval_policy = "on-request"` and `approvals_reviewer = "auto_review"`, then attaches to the session in an interactive terminal. It is scoped to that explicit command and does not change normal SKS/DB safety defaults. Repeat launches reuse the same named SKS MAD tmux session.
181
181
 
182
182
  MAD does not disable the pipeline contract: stages, executors, reviewers, and auto-review policy still must not invent unrequested fallback implementation code. If the requested path cannot be implemented, SKS should block with evidence rather than add substitute behavior.
183
183
 
@@ -435,7 +435,7 @@ sks wiki refresh
435
435
  sks wiki validate .sneakoscope/wiki/context-pack.json
436
436
  ```
437
437
 
438
- TriWiki is the long-running context source of truth. It keeps compact high-trust recall in `attention.use_first` and source-hydration targets in `attention.hydrate_first`.
438
+ TriWiki is the long-running context source of truth. It keeps compact high-trust recall in `attention.use_first`, source-hydration targets in `attention.hydrate_first`, and binds relevant prior-mistake claims into the current decision contract when they match the prompt.
439
439
 
440
440
  ## Safety Model
441
441
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.7.33",
4
+ "version": "0.7.37",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
package/src/cli/main.mjs CHANGED
@@ -52,12 +52,13 @@ import { classifyToolError, harnessGrowthReport } from '../core/evaluation.mjs';
52
52
  import { runWorkflowPerfBench, validateWorkflowPerfReport } from '../core/perf-bench.mjs';
53
53
  import { buildProofField, proofFieldFixture, validateProofFieldReport } from '../core/proof-field.mjs';
54
54
  import { recordMistake, writeMistakeMemoryReport } from '../core/mistake-memory.mjs';
55
+ import { MISTAKE_RECALL_ARTIFACT, contractConsumesMistakeRecall } from '../core/mistake-recall.mjs';
55
56
  import { buildPromptContext } from '../core/prompt-context-builder.mjs';
56
57
  import { renderTeamDashboardState, writeTeamDashboardState } from '../core/team-dashboard-renderer.mjs';
57
58
  import { GOAL_WORKFLOW_ARTIFACT } from '../core/goal-workflow.mjs';
58
59
  import { CODEX_APP_DOCS_URL, codexAppIntegrationStatus, formatCodexAppStatus } from '../core/codex-app.mjs';
59
60
  import { OPENCLAW_SKILL_NAME, installOpenClawSkill } from '../core/openclaw.mjs';
60
- import { buildTmuxLaunchPlan, buildTmuxOpenArgs, createTmuxSession, isTmuxShellSession, runTmuxLaunchPlanSyntaxCheck, tmuxReadiness, tmuxStatusKind, defaultTmuxSessionName, formatTmuxBanner, launchTmuxTeamView, launchTmuxUi, platformTmuxInstallHint, runTmuxStatus, sanitizeTmuxSessionName, teamLaneStyle } from '../core/tmux-ui.mjs';
61
+ import { buildTmuxLaunchPlan, buildTmuxOpenArgs, createTmuxSession, isTmuxShellSession, runTmuxLaunchPlanSyntaxCheck, shouldAutoAttachTmux, tmuxReadiness, tmuxStatusKind, defaultTmuxSessionName, formatTmuxBanner, launchTmuxTeamView, launchTmuxUi, platformTmuxInstallHint, runTmuxStatus, sanitizeTmuxSessionName, teamLaneStyle } from '../core/tmux-ui.mjs';
61
62
  import { autoReviewProfileName, autoReviewStatus, autoReviewSummary, enableAutoReview, disableAutoReview, enableMadHighProfile, madHighProfileName } from '../core/auto-review.mjs';
62
63
  import { context7Command } from './context7-command.mjs';
63
64
  import { askPostinstallQuestion, checkContext7, checkRequiredSkills, ensureCodexCliTool, ensureGlobalCodexSkillsDuringInstall, ensureProjectContext7Config, ensureRelatedCliTools, ensureSksCommandDuringInstall, globalCodexSkillsRoot, maybePromptCodexUpdateForLaunch, postinstall, postinstallBootstrapDecision, shouldAutoApproveInstall } from './install-helpers.mjs';
@@ -803,6 +804,11 @@ async function versioning(sub = 'status', args = []) {
803
804
  console.log(`Bump: ${status.bump || 'patch'}`);
804
805
  console.log(`Hook: ${status.hook_installed ? 'installed' : 'missing'}${status.hook_path ? ` ${status.hook_path}` : ''}`);
805
806
  console.log(`Last seen: ${status.last_version || 'none'}`);
807
+ if (status.runtime_drift?.checked) {
808
+ const drift = status.runtime_drift;
809
+ console.log(`Runtime: ${drift.runtime_version || 'unknown'} (${drift.relation || 'unknown'})`);
810
+ if (!drift.ok) console.log(`Warning: source package is ${drift.package_version}, but bare sks resolves to ${drift.runtime_version}. Use node ./bin/sks.mjs in this repo or reinstall/update the global package before trusting runtime behavior.`);
811
+ }
806
812
  if (!status.ok) console.log('Run: sks doctor --fix');
807
813
  return;
808
814
  }
@@ -1662,6 +1668,12 @@ async function effectivePackageVersion() {
1662
1668
  return highestVersion([PACKAGE_VERSION, pkg.version]);
1663
1669
  }
1664
1670
 
1671
+ async function selftestRuntimeVersion() {
1672
+ const source = await safeReadText(path.join(packageRoot(), 'src', 'core', 'fsx.mjs'));
1673
+ const sourceVersion = source.match(/export const PACKAGE_VERSION = ['"]([^'"]+)['"];/)?.[1] || null;
1674
+ return sourceVersion || PACKAGE_VERSION;
1675
+ }
1676
+
1665
1677
  function highestVersion(versions = []) {
1666
1678
  return versions.filter(Boolean).reduce((best, candidate) => compareVersions(candidate, best) > 0 ? candidate : best, '0.0.0');
1667
1679
  }
@@ -1925,6 +1937,10 @@ async function selftest() {
1925
1937
  if (!tmuxSyntax.ok || !tmuxSyntax.command.includes('tmux attach-session -t sks-mad-selftest')) throw new Error('selftest failed: MAD tmux attach plan is not stable by session name');
1926
1938
  const tmuxOpenArgs = buildTmuxOpenArgs(workspacePlan);
1927
1939
  if (tmuxOpenArgs.join(' ') !== 'attach-session -t sks-mad-selftest') throw new Error('selftest failed: MAD tmux attach args are not stable by session name');
1940
+ if (!shouldAutoAttachTmux(['--mad'], {}, { stdin: { isTTY: true }, stdout: { isTTY: true } })) throw new Error('selftest failed: MAD tmux launch does not auto-attach in an interactive terminal');
1941
+ if (shouldAutoAttachTmux(['--mad', '--json'], {}, { stdin: { isTTY: true }, stdout: { isTTY: true } })) throw new Error('selftest failed: MAD tmux json mode should not auto-attach');
1942
+ if (shouldAutoAttachTmux(['--mad', '--no-attach'], {}, { stdin: { isTTY: true }, stdout: { isTTY: true } })) throw new Error('selftest failed: MAD tmux --no-attach should remain print-only');
1943
+ if (shouldAutoAttachTmux(['--mad'], { SKS_TMUX_NO_AUTO_ATTACH: '1' }, { stdin: { isTTY: true }, stdout: { isTTY: true } })) throw new Error('selftest failed: SKS_TMUX_NO_AUTO_ATTACH should disable tmux auto-attach');
1928
1944
  if (!isTmuxShellSession({ TMUX: '/tmp/tmux-501/default,1,0' })) throw new Error('selftest failed: tmux shell session env was not detected');
1929
1945
  if (tmuxStatusKind({ ok: false, bin: null }) !== 'missing') throw new Error('selftest failed: missing tmux was not labeled missing');
1930
1946
  const bareDefault = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs')], {
@@ -2238,7 +2254,8 @@ async function selftest() {
2238
2254
  if (String(hookUpdateCurrentContext).includes('Update SKS now') || String(hookUpdateCurrentContext).includes('Skip update for this conversation')) throw new Error('selftest failed: hook prompted for update even though installed SKS is current');
2239
2255
  const hookUpdateCurrentState = await readJson(path.join(hookUpdateCurrentTmp, '.sneakoscope', 'state', 'update-check.json'), {});
2240
2256
  if (hookUpdateCurrentState.pending_offer) throw new Error('selftest failed: current installed SKS left a pending update offer');
2241
- if (hookUpdateCurrentState.current !== '9.9.9' || hookUpdateCurrentState.runtime_current !== PACKAGE_VERSION || hookUpdateCurrentState.installed_current !== '9.9.9') throw new Error(`selftest failed: hook did not record effective installed SKS version: ${JSON.stringify({ expected: { current: '9.9.9', runtime_current: PACKAGE_VERSION, installed_current: '9.9.9' }, actual: hookUpdateCurrentState })}`);
2257
+ const hookRuntimeExpected = await selftestRuntimeVersion();
2258
+ if (hookUpdateCurrentState.current !== '9.9.9' || hookUpdateCurrentState.runtime_current !== hookRuntimeExpected || hookUpdateCurrentState.installed_current !== '9.9.9') throw new Error(`selftest failed: hook did not record effective installed SKS version: ${JSON.stringify({ expected: { current: '9.9.9', runtime_current: hookRuntimeExpected, installed_current: '9.9.9', loaded_runtime_current: PACKAGE_VERSION }, actual: hookUpdateCurrentState })}`);
2242
2259
  const hookUpdatePendingTmp = tmpdir();
2243
2260
  await initProject(hookUpdatePendingTmp, {});
2244
2261
  await writeJsonAtomic(path.join(hookUpdatePendingTmp, '.sneakoscope', 'state', 'update-check.json'), {
@@ -3014,12 +3031,27 @@ async function selftest() {
3014
3031
  const coord = rgbaToWikiCoord({ r: 12, g: 34, b: 56, a: 255 });
3015
3032
  if (coord.schema !== 'sks.wiki-coordinate.v1' || coord.xyzw.length !== 4) throw new Error('selftest failed: RGBA wiki coordinate conversion');
3016
3033
  await writeTextAtomic(path.join(tmp, '.sneakoscope', 'memory', 'q2_facts', 'selftest.md'), '- claim: Selftest memory claim must be selected before lower-weight mission notes. | id: selftest-memory-priority | source: src/cli/main.mjs | risk: high | status: supported | evidence_count: 3 | required_weight: 1.0 | trust_score: 0.9\n');
3034
+ await writeTextAtomic(path.join(tmp, '.sneakoscope', 'memory', 'q2_facts', 'tail-repeat.md'), [
3035
+ ...Array.from({ length: 60 }, (_, i) => `- claim: Low priority filler memory ${i}. | id: tail-filler-${i} | source: src/cli/main.mjs | risk: low | status: supported | evidence_count: 1 | required_weight: 0.1 | trust_score: 0.5`),
3036
+ '- claim: TriWiki repeated mistake recall must preserve recent high-weight tail lessons. | id: tail-repeat-mistake | source: src/core/mistake-recall.mjs | risk: high | status: supported | freshness: fresh | evidence_count: 4 | required_weight: 1.2 | trust_score: 0.95'
3037
+ ].join('\n'));
3017
3038
  await createMission(tmp, { mode: 'sks', prompt: '모호한 질문은 그만 물어봐야지;; triwiki로 예측해' });
3018
3039
  await createMission(tmp, { mode: 'sks', prompt: 'triwiki에서 자주 요청하는 것들은 카운팅해서 더 우선 참고해줘' });
3040
+ const projectClaims = await projectWikiClaims(tmp);
3041
+ if (!projectClaims.some((claim) => claim.id === 'tail-repeat-mistake')) throw new Error('selftest failed: tail high-weight memory claim was dropped from TriWiki ingestion');
3042
+ const recallPrompt = 'triwiki 반복 실수 방지 개선 selftest';
3043
+ const recallMission = await createMission(tmp, { mode: 'team', prompt: recallPrompt });
3044
+ await writeJsonAtomic(path.join(recallMission.dir, 'required-answers.schema.json'), { prompt: recallPrompt, slots: [{ id: 'GOAL_PRECISE', required: true }, { id: 'ACCEPTANCE_CRITERIA', required: true, type: 'array' }] });
3045
+ await writeJsonAtomic(path.join(recallMission.dir, 'answers.json'), { GOAL_PRECISE: recallPrompt, ACCEPTANCE_CRITERIA: ['repeat mistake memory is consumed'] });
3046
+ const recallSeal = await sealContract(recallMission.dir, { id: recallMission.id, prompt: recallPrompt, mode: 'team' });
3047
+ if (!recallSeal.ok) throw new Error('selftest failed: mistake recall contract did not seal');
3048
+ const recallLedger = await readJson(path.join(recallMission.dir, MISTAKE_RECALL_ARTIFACT), null);
3049
+ if (!recallLedger?.required || !recallLedger.matches?.some((match) => match.id === 'tail-repeat-mistake')) throw new Error('selftest failed: mistake recall did not match tail TriWiki lesson');
3050
+ if (!contractConsumesMistakeRecall(recallSeal.contract, recallLedger).ok) throw new Error('selftest failed: mistake recall was not consumed by decision contract');
3019
3051
  const wikiPack = contextCapsule({
3020
3052
  mission: { id: 'selftest-wiki', coord: { rgba: { r: 48, g: 132, b: 212, a: 240 } } },
3021
3053
  role: 'verifier',
3022
- claims: await projectWikiClaims(tmp),
3054
+ claims: projectClaims,
3023
3055
  q4: { mode: 'selftest' },
3024
3056
  q3: ['sks', 'llm-wiki', 'wiki-coordinate'],
3025
3057
  budget: { maxWikiAnchors: 48, includeTrustSummary: true }
@@ -3070,6 +3102,17 @@ async function selftest() {
3070
3102
  const primingClaim = primingPack.claims?.find((claim) => claim.id === 'positive-recall-guard');
3071
3103
  if (!primingClaim || /elephant|do\s+not/i.test(primingClaim.text || '') || primingClaim.text_policy !== 'positive_recall_negation_suppressed') throw new Error('selftest failed: TriWiki compact recall did not suppress negative priming text');
3072
3104
  if (!primingPack.attention?.hydrate_first?.some((row) => row[0] === 'positive-recall-guard' && String(row[1]).includes('negative_priming'))) throw new Error('selftest failed: negative priming claim was not source-hydration gated');
3105
+ const voxelPromotionPack = contextCapsule({
3106
+ mission: { id: 'voxel-promotion-selftest', coord: { rgba: { r: 70, g: 100, b: 130, a: 255 } } },
3107
+ role: 'worker',
3108
+ claims: [
3109
+ { id: 'voxel-priority-hydrate', text: 'TriWiki memory repeat prevention should hydrate source evidence when priority route layers are high.', authority: 'code', risk: 'low', status: 'supported', freshness: 'fresh', required_weight: 1.25, trust_score: 0.95, coord: { rgba: { r: 70, g: 100, b: 130, a: 255 } } }
3110
+ ],
3111
+ q4: { mode: 'voxel-promotion-selftest' },
3112
+ q3: ['triwiki', 'memory'],
3113
+ budget: { maxClaims: 1, maxWikiAnchors: 1, maxAttentionUse: 1, maxAttentionHydrate: 1 }
3114
+ });
3115
+ if (!voxelPromotionPack.attention?.hydrate_first?.some((row) => row[0] === 'voxel-priority-hydrate' && String(row[1]).startsWith('voxel:priority_route'))) throw new Error('selftest failed: voxel priority route did not promote hydration');
3073
3116
  const dryRunPack = await writeWikiContextPack(tmp, ['--max-anchors', '4'], { dryRun: true });
3074
3117
  if (wikiVoxelRowCount(dryRunPack.pack.wiki) !== 4) throw new Error('selftest failed: dry-run wiki pack did not build voxel rows');
3075
3118
  if (await exists(dryRunPack.file)) throw new Error('selftest failed: wiki refresh dry-run wrote context pack');
@@ -991,7 +991,7 @@ async function memoryWikiClaims(root) {
991
991
  continue;
992
992
  }
993
993
  if (!text.trim()) continue;
994
- const rows = parseMemoryClaimRows(text, relFile).slice(0, 24);
994
+ const rows = selectMemoryClaimRows(parseMemoryClaimRows(text, relFile), 48);
995
995
  let index = 0;
996
996
  for (const row of rows) {
997
997
  const source = row.source || relFile;
@@ -1015,6 +1015,48 @@ async function memoryWikiClaims(root) {
1015
1015
  return claims;
1016
1016
  }
1017
1017
 
1018
+ function selectMemoryClaimRows(rows = [], limit = 48) {
1019
+ const prepared = (rows || []).map((row, index) => ({ row, index, total: rows.length }));
1020
+ if (prepared.length <= limit) return prepared.map((item) => item.row);
1021
+ const picked = new Map();
1022
+ const add = (item) => {
1023
+ if (!item?.row) return;
1024
+ const key = item.row.id || `${item.index}:${item.row.text}`;
1025
+ if (!picked.has(key)) picked.set(key, item);
1026
+ };
1027
+ const required = prepared.filter(({ row }) =>
1028
+ Number(row.required_weight || 0) >= 0.95
1029
+ || Number(row.trust_score || 0) >= 0.9
1030
+ || row.risk === 'critical'
1031
+ || (row.risk === 'high' && Number(row.evidence_count || 0) >= 3)
1032
+ );
1033
+ for (const item of required) add(item);
1034
+ for (const item of prepared.slice(-12)) add(item);
1035
+ const already = new Set([...picked.values()].map((item) => item.index));
1036
+ const scored = prepared
1037
+ .filter((item) => !already.has(item.index))
1038
+ .map((item) => ({ ...item, score: memoryRowPriorityScore(item) }))
1039
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index));
1040
+ for (const item of scored) {
1041
+ if (picked.size >= limit) break;
1042
+ add(item);
1043
+ }
1044
+ return [...picked.values()]
1045
+ .sort((a, b) => a.index - b.index)
1046
+ .slice(0, limit)
1047
+ .map((item) => item.row);
1048
+ }
1049
+
1050
+ function memoryRowPriorityScore({ row, index, total }) {
1051
+ const required = Number(row.required_weight || 0);
1052
+ const trust = Number(row.trust_score || 0);
1053
+ const evidence = Number(row.evidence_count || 0);
1054
+ const recency = total > 1 ? index / (total - 1) : 1;
1055
+ const risk = { low: 0, medium: 0.35, high: 0.9, critical: 1.25 }[row.risk || 'medium'] ?? 0.35;
1056
+ const freshness = { fresh: 0.45, unknown: 0.1, stale: -0.4 }[row.freshness || 'unknown'] ?? 0.1;
1057
+ return required * 8 + trust * 4 + Math.log1p(Math.max(0, evidence)) + recency * 2 + risk + freshness;
1058
+ }
1059
+
1018
1060
  async function listMemoryClaimFiles(base) {
1019
1061
  const out = [];
1020
1062
  async function walk(dir, depth = 0) {
@@ -1065,6 +1107,7 @@ function normalizeMemoryClaimRow(row, relFile) {
1065
1107
  risk: row.risk,
1066
1108
  status: row.status || row.confidence,
1067
1109
  freshness: row.freshness,
1110
+ updated_at: row.updated_at || row.updatedAt || row.created_at || row.createdAt,
1068
1111
  evidence_count: Number.isFinite(Number(row.evidence_count)) ? Number(row.evidence_count) : undefined,
1069
1112
  required_weight: Number.isFinite(Number(row.required_weight)) ? Number(row.required_weight) : undefined,
1070
1113
  trust_score: Number.isFinite(Number(row.trust_score)) ? Number(row.trust_score) : undefined
@@ -1082,6 +1125,7 @@ function normalizeMemoryClaimRow(row, relFile) {
1082
1125
  risk: extractClaimField(clean, 'risk') || 'high',
1083
1126
  status,
1084
1127
  freshness: extractClaimField(clean, 'freshness') || 'fresh',
1128
+ updated_at: extractClaimField(clean, 'updated_at') || extractClaimField(clean, 'updatedAt') || extractClaimField(clean, 'created_at'),
1085
1129
  evidence_count: parseOptionalNumber(extractClaimField(clean, 'evidence_count')),
1086
1130
  required_weight: parseOptionalNumber(extractClaimField(clean, 'required_weight')),
1087
1131
  trust_score: parseOptionalNumber(extractClaimField(clean, 'trust_score'))
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { readJson, writeJsonAtomic, nowIso, sha256 } from './fsx.mjs';
3
3
  import { validateQaLoopAnswers } from './qa-loop.mjs';
4
4
  import { inferAnswersForPrompt } from './questions.mjs';
5
+ import { bindMistakeRecallToAnswers, buildMistakeRecallLedger, mistakeRecallContractSummary, writeMistakeRecallArtifacts } from './mistake-recall.mjs';
5
6
 
6
7
  function isEmptyAnswer(v, slot = {}) {
7
8
  if (v === undefined || v === null) return true;
@@ -38,7 +39,7 @@ export function validateAnswers(schema, answers) {
38
39
  return { ok: errors.length === 0, errors, resolved, totalRequired: schema.slots.filter((s) => s.required).length };
39
40
  }
40
41
 
41
- export function buildDecisionContract({ mission, schema, answers }) {
42
+ export function buildDecisionContract({ mission, schema, answers, mistakeRecall = null }) {
42
43
  const madSks = answers.MAD_SKS_MODE === 'explicit_invocation_only';
43
44
  const defaults = {
44
45
  if_multiple_valid_implementations: 'choose_smallest_reversible_change',
@@ -111,6 +112,7 @@ export function buildDecisionContract({ mission, schema, answers }) {
111
112
  acceptance_criteria: Array.isArray(answers.ACCEPTANCE_CRITERIA) ? answers.ACCEPTANCE_CRITERIA : String(answers.ACCEPTANCE_CRITERIA || '').split('\n').map((x) => x.trim()).filter(Boolean),
112
113
  non_goals: Array.isArray(answers.NON_GOALS) ? answers.NON_GOALS : String(answers.NON_GOALS || '').split('\n').map((x) => x.trim()).filter(Boolean),
113
114
  test_scope: answers.TEST_SCOPE,
115
+ triwiki_mistake_recall: mistakeRecallContractSummary(mistakeRecall),
114
116
  approved_defaults: defaults,
115
117
  decision_ladder: [
116
118
  'seed_contract',
@@ -134,21 +136,41 @@ export async function sealContract(missionDir, mission) {
134
136
  const schema = await readJson(path.join(missionDir, 'required-answers.schema.json'));
135
137
  const explicitAnswers = await readJson(path.join(missionDir, 'answers.json'));
136
138
  const inferred = inferAnswersForPrompt(mission?.prompt || schema?.prompt || '', explicitAnswers);
137
- const answers = {
139
+ const baseAnswers = {
138
140
  ...(schema.inferred_answers || {}),
139
141
  ...inferred.answers,
140
142
  ...explicitAnswers
141
143
  };
144
+ const root = rootFromMissionDir(missionDir);
145
+ const mistakeRecall = await buildMistakeRecallLedger(root, {
146
+ prompt: mission?.prompt || schema?.prompt || '',
147
+ answers: baseAnswers
148
+ });
149
+ const answers = bindMistakeRecallToAnswers(baseAnswers, mistakeRecall);
142
150
  const validation = validateAnswers(schema, answers);
143
151
  if (!validation.ok) return { ok: false, validation };
144
- const contract = buildDecisionContract({ mission, schema, answers });
152
+ const contract = buildDecisionContract({ mission, schema, answers, mistakeRecall });
153
+ const mistakeRecallConsumption = await writeMistakeRecallArtifacts(missionDir, mistakeRecall, contract);
145
154
  await writeJsonAtomic(path.join(missionDir, 'resolved-answers.json'), {
146
155
  explicit_answers: explicitAnswers,
147
156
  inferred_answers: { ...(schema.inferred_answers || {}), ...inferred.answers },
148
157
  inference_notes: { ...(schema.inference_notes || {}), ...inferred.notes },
149
- answers
158
+ answers,
159
+ mistake_recall: {
160
+ artifact: 'mistake-recall-ledger.json',
161
+ consumed: mistakeRecallConsumption.ok,
162
+ required: mistakeRecall.required
163
+ }
150
164
  });
151
165
  await writeJsonAtomic(path.join(missionDir, 'decision-contract.json'), contract);
152
166
  await writeJsonAtomic(path.join(missionDir, 'answer-validation.json'), validation);
153
167
  return { ok: true, validation, contract };
154
168
  }
169
+
170
+ function rootFromMissionDir(missionDir) {
171
+ const resolved = path.resolve(missionDir);
172
+ const parts = resolved.split(path.sep);
173
+ const idx = parts.lastIndexOf('.sneakoscope');
174
+ if (idx > 0) return parts.slice(0, idx).join(path.sep) || path.sep;
175
+ return path.resolve(resolved, '..', '..', '..');
176
+ }
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.7.33';
8
+ export const PACKAGE_VERSION = '0.7.37';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -0,0 +1,277 @@
1
+ import path from 'node:path';
2
+ import fsp from 'node:fs/promises';
3
+ import { nowIso, readJson, sha256, writeJsonAtomic } from './fsx.mjs';
4
+
5
+ export const MISTAKE_RECALL_ARTIFACT = 'mistake-recall-ledger.json';
6
+ export const CLAIM_CONSUMPTION_ARTIFACT = 'claim-consumption-ledger.json';
7
+
8
+ const MISTAKE_CUE_RE = /\b(mistake|repeat|repeated|regression|forget|forgot|stale|drift|ambiguity|clarification|fallback|question|runtime|route|triwiki|wiki|voxel|memory)\b|반복|실수|또\s*|까먹|기억|모호|질문|복셀|검수|개선|정상\s*동작/i;
9
+
10
+ export async function buildMistakeRecallLedger(root, { prompt = '', answers = {}, maxMatches = 8 } = {}) {
11
+ const seedText = `${prompt || ''}\n${JSON.stringify(answers || {})}`;
12
+ const queryTerms = tokenize(seedText);
13
+ const claims = [
14
+ ...(await claimsFromContextPack(root)),
15
+ ...(await claimsFromMemory(root))
16
+ ];
17
+ const scored = claims
18
+ .map((claim) => ({ ...claim, ...scoreClaim(claim, queryTerms, seedText) }))
19
+ .filter((claim) => claim.score >= 2.5 || (claim.mistake_cue && claim.overlap_count > 0))
20
+ .sort((a, b) => (b.score - a.score) || String(a.id).localeCompare(String(b.id)))
21
+ .slice(0, maxMatches);
22
+ return {
23
+ schema_version: 1,
24
+ generated_at: nowIso(),
25
+ prompt_hash: sha256(seedText),
26
+ required: scored.length > 0,
27
+ status: scored.length ? 'matched' : 'no_relevant_mistake_claims',
28
+ query_terms: [...queryTerms].slice(0, 24),
29
+ matches: scored.map((claim) => ({
30
+ id: claim.id,
31
+ text: claim.text,
32
+ source: claim.source,
33
+ file: claim.file || claim.source,
34
+ reason: claim.reason,
35
+ score: Number(claim.score.toFixed(3)),
36
+ overlap_count: claim.overlap_count,
37
+ required_weight: claim.required_weight,
38
+ trust_score: claim.trust_score,
39
+ risk: claim.risk,
40
+ freshness: claim.freshness
41
+ }))
42
+ };
43
+ }
44
+
45
+ export function bindMistakeRecallToAnswers(answers = {}, ledger = {}) {
46
+ if (!ledger?.required || !Array.isArray(ledger.matches) || !ledger.matches.length) return answers;
47
+ const ids = ledger.matches.map((match) => match.id).filter(Boolean);
48
+ const recallLine = `TriWiki mistake recall consumed before implementation: ${ids.join(', ')}`;
49
+ const riskBoundary = Array.isArray(answers.RISK_BOUNDARY)
50
+ ? [...answers.RISK_BOUNDARY]
51
+ : String(answers.RISK_BOUNDARY || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
52
+ if (!riskBoundary.includes(recallLine)) riskBoundary.push(recallLine);
53
+ const acceptance = Array.isArray(answers.ACCEPTANCE_CRITERIA)
54
+ ? [...answers.ACCEPTANCE_CRITERIA]
55
+ : String(answers.ACCEPTANCE_CRITERIA || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
56
+ const acceptanceLine = 'mistake-recall-ledger.json is consumed by the decision contract when relevant TriWiki mistakes are found';
57
+ if (!acceptance.includes(acceptanceLine)) acceptance.push(acceptanceLine);
58
+ return {
59
+ ...answers,
60
+ RISK_BOUNDARY: riskBoundary,
61
+ ACCEPTANCE_CRITERIA: acceptance,
62
+ TRIWIKI_MISTAKE_RECALL_REQUIRED: true,
63
+ TRIWIKI_MISTAKE_RECALL_IDS: ids,
64
+ TRIWIKI_MISTAKE_RECALL_STATUS: ledger.status
65
+ };
66
+ }
67
+
68
+ export function mistakeRecallContractSummary(ledger = {}) {
69
+ if (!ledger?.required) return { required: false, status: ledger?.status || 'not_required', matches: [] };
70
+ return {
71
+ required: true,
72
+ status: ledger.status,
73
+ artifact: MISTAKE_RECALL_ARTIFACT,
74
+ match_count: ledger.matches?.length || 0,
75
+ ids: (ledger.matches || []).map((match) => match.id).filter(Boolean),
76
+ sources: [...new Set((ledger.matches || []).map((match) => match.source).filter(Boolean))].slice(0, 8)
77
+ };
78
+ }
79
+
80
+ export function contractConsumesMistakeRecall(contract = {}, ledger = {}) {
81
+ if (!ledger?.required) return { ok: true, missing: [] };
82
+ const ids = (ledger.matches || []).map((match) => match.id).filter(Boolean);
83
+ if (!ids.length) return { ok: true, missing: [] };
84
+ const contractText = JSON.stringify(contract || {});
85
+ const missing = ids.filter((id) => !contractText.includes(id));
86
+ const summary = contract?.triwiki_mistake_recall;
87
+ if (!summary?.required) missing.push('contract.triwiki_mistake_recall.required');
88
+ if (summary?.artifact !== MISTAKE_RECALL_ARTIFACT) missing.push('contract.triwiki_mistake_recall.artifact');
89
+ return { ok: missing.length === 0, missing };
90
+ }
91
+
92
+ export async function writeMistakeRecallArtifacts(missionDir, ledger = {}, contract = {}) {
93
+ await writeJsonAtomic(path.join(missionDir, MISTAKE_RECALL_ARTIFACT), ledger);
94
+ const consumption = {
95
+ schema_version: 1,
96
+ generated_at: nowIso(),
97
+ artifact: MISTAKE_RECALL_ARTIFACT,
98
+ contract_hash: contract?.sealed_hash || null,
99
+ ...contractConsumesMistakeRecall(contract, ledger)
100
+ };
101
+ await writeJsonAtomic(path.join(missionDir, CLAIM_CONSUMPTION_ARTIFACT), consumption);
102
+ return consumption;
103
+ }
104
+
105
+ export async function mistakeRecallGateStatus(root, state = {}) {
106
+ const id = state?.mission_id;
107
+ if (!id) return { ok: true, missing: [] };
108
+ const dir = path.join(root, '.sneakoscope', 'missions', id);
109
+ const ledger = await readJson(path.join(dir, MISTAKE_RECALL_ARTIFACT), null);
110
+ if (!ledger) return { ok: true, missing: [] };
111
+ if (!ledger.required) return { ok: true, missing: [] };
112
+ const contract = await readJson(path.join(dir, 'decision-contract.json'), null);
113
+ if (!contract) return { ok: false, missing: ['decision-contract.json'] };
114
+ const consumed = contractConsumesMistakeRecall(contract, ledger);
115
+ return {
116
+ ok: consumed.ok,
117
+ missing: consumed.missing || [],
118
+ source: path.join('.sneakoscope', 'missions', id, MISTAKE_RECALL_ARTIFACT)
119
+ };
120
+ }
121
+
122
+ async function claimsFromContextPack(root) {
123
+ const pack = await readJson(path.join(root, '.sneakoscope', 'wiki', 'context-pack.json'), null);
124
+ const claims = [];
125
+ for (const claim of Array.isArray(pack?.claims) ? pack.claims : []) {
126
+ if (!claim?.id || !claim?.text) continue;
127
+ claims.push({
128
+ id: String(claim.id),
129
+ text: String(claim.text).slice(0, 420),
130
+ source: claim.source || '.sneakoscope/wiki/context-pack.json',
131
+ file: claim.source || '.sneakoscope/wiki/context-pack.json',
132
+ authority: claim.authority || 'wiki',
133
+ risk: claim.risk || 'medium',
134
+ freshness: claim.freshness || 'unknown',
135
+ trust_score: numberOrUndefined(claim.trust_score)
136
+ });
137
+ }
138
+ return claims;
139
+ }
140
+
141
+ async function claimsFromMemory(root) {
142
+ const base = path.join(root, '.sneakoscope', 'memory');
143
+ const files = await listClaimFiles(base);
144
+ const rows = [];
145
+ for (const file of files.slice(0, 120)) {
146
+ const rel = path.relative(root, file);
147
+ let text = '';
148
+ try {
149
+ text = await fsp.readFile(file, 'utf8');
150
+ } catch {
151
+ continue;
152
+ }
153
+ rows.push(...parseClaimRows(text, rel));
154
+ }
155
+ return rows;
156
+ }
157
+
158
+ async function listClaimFiles(base) {
159
+ const out = [];
160
+ async function walk(dir, depth = 0) {
161
+ if (depth > 3) return;
162
+ let entries = [];
163
+ try {
164
+ entries = await fsp.readdir(dir, { withFileTypes: true });
165
+ } catch {
166
+ return;
167
+ }
168
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
169
+ const file = path.join(dir, entry.name);
170
+ if (entry.isDirectory()) await walk(file, depth + 1);
171
+ else if (/\.(md|txt|json)$/i.test(entry.name)) out.push(file);
172
+ }
173
+ }
174
+ await walk(base);
175
+ return out;
176
+ }
177
+
178
+ function parseClaimRows(text, relFile) {
179
+ if (/\.json$/i.test(relFile)) {
180
+ try {
181
+ const parsed = JSON.parse(text);
182
+ const rows = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.claims) ? parsed.claims : []);
183
+ return rows.map((row) => normalizeClaim(row, relFile)).filter(Boolean);
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+ return String(text || '').split(/\r?\n/)
189
+ .map((line) => line.trim())
190
+ .filter((line) => line && !line.startsWith('#'))
191
+ .map((line) => normalizeClaim(line.replace(/^[-*]\s*/, ''), relFile))
192
+ .filter(Boolean);
193
+ }
194
+
195
+ function normalizeClaim(row, relFile) {
196
+ if (!row) return null;
197
+ if (typeof row === 'object') {
198
+ const text = String(row.text || row.claim || '').trim();
199
+ if (!text) return null;
200
+ return {
201
+ id: row.id ? String(row.id) : `memory-${slug(relFile)}-${sha256(text).slice(0, 8)}`,
202
+ text: text.slice(0, 420),
203
+ source: row.source || row.file || relFile,
204
+ file: row.file || row.source || relFile,
205
+ risk: row.risk || 'medium',
206
+ freshness: row.freshness || 'unknown',
207
+ required_weight: numberOrUndefined(row.required_weight),
208
+ trust_score: numberOrUndefined(row.trust_score)
209
+ };
210
+ }
211
+ const clean = String(row || '').trim();
212
+ if (!/\bclaim\s*:/i.test(clean)) return null;
213
+ const claimText = clean.replace(/^claim\s*:\s*/i, '').trim();
214
+ return {
215
+ id: extractField(clean, 'id') || `memory-${slug(relFile)}-${sha256(clean).slice(0, 8)}`,
216
+ text: claimText.slice(0, 420),
217
+ source: extractField(clean, 'source') || extractField(clean, 'file') || relFile,
218
+ file: extractField(clean, 'file') || extractField(clean, 'source') || relFile,
219
+ risk: extractField(clean, 'risk') || 'medium',
220
+ freshness: extractField(clean, 'freshness') || 'unknown',
221
+ required_weight: numberOrUndefined(extractField(clean, 'required_weight')),
222
+ trust_score: numberOrUndefined(extractField(clean, 'trust_score'))
223
+ };
224
+ }
225
+
226
+ function scoreClaim(claim, queryTerms, seedText) {
227
+ const hay = `${claim.id || ''} ${claim.text || ''} ${claim.source || ''}`.toLowerCase();
228
+ let overlap = 0;
229
+ for (const term of queryTerms) if (hay.includes(term)) overlap += 1;
230
+ const mistakeCue = MISTAKE_CUE_RE.test(hay);
231
+ const seedMistakeCue = MISTAKE_CUE_RE.test(seedText || '');
232
+ const required = Number(claim.required_weight || 0);
233
+ const trust = Number(claim.trust_score || 0);
234
+ const risk = { low: 0, medium: 0.25, high: 0.75, critical: 1.1 }[claim.risk || 'medium'] ?? 0.25;
235
+ const freshness = { fresh: 0.45, unknown: 0.1, stale: -0.25 }[claim.freshness || 'unknown'] ?? 0.1;
236
+ const score = overlap + required * 3.5 + trust * 1.5 + risk + freshness + (mistakeCue ? 2 : 0) + (seedMistakeCue && mistakeCue ? 1 : 0);
237
+ return {
238
+ score,
239
+ overlap_count: overlap,
240
+ mistake_cue: mistakeCue,
241
+ reason: [
242
+ overlap ? `prompt_overlap:${overlap}` : null,
243
+ mistakeCue ? 'mistake_cue' : null,
244
+ required ? `required_weight:${required}` : null,
245
+ trust ? `trust:${trust}` : null
246
+ ].filter(Boolean).join(',')
247
+ };
248
+ }
249
+
250
+ function tokenize(text) {
251
+ const out = new Set();
252
+ for (const match of String(text || '').toLowerCase().matchAll(/[a-z0-9_/-]{3,}|[가-힣]{2,}/g)) {
253
+ const token = match[0].replace(/^[-_/]+|[-_/]+$/g, '');
254
+ if (token && !STOP_TERMS.has(token)) out.add(token);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ const STOP_TERMS = new Set([
260
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'true', 'false',
261
+ '사용자', '요청', '현재', '코드', '기준', '구현', '작업', '완료', '검증'
262
+ ]);
263
+
264
+ function extractField(text, key) {
265
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
266
+ const match = String(text || '').match(new RegExp(`\\b${escaped}\\s*[:=]\\s*\\\`?([^\\\`|,;]+)`, 'i'));
267
+ return match ? match[1].trim().replace(/[.;)]$/, '') : null;
268
+ }
269
+
270
+ function numberOrUndefined(value) {
271
+ const n = Number(value);
272
+ return Number.isFinite(n) ? n : undefined;
273
+ }
274
+
275
+ function slug(value) {
276
+ return String(value || 'claim').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'claim';
277
+ }
@@ -10,6 +10,7 @@ import { GOAL_WORKFLOW_ARTIFACT, writeGoalWorkflow } from './goal-workflow.mjs';
10
10
  import { writeCodeStructureReport } from './code-structure.mjs';
11
11
  import { writeMemorySweepReport } from './memory-governor.mjs';
12
12
  import { writeMistakeMemoryReport } from './mistake-memory.mjs';
13
+ import { MISTAKE_RECALL_ARTIFACT, mistakeRecallGateStatus } from './mistake-recall.mjs';
13
14
  import { recordSkillDreamEvent, skillDreamPolicyText, writeSkillForgeReport } from './skill-forge.mjs';
14
15
  import { writeResearchPlan } from './research.mjs';
15
16
  import { PPT_REQUIRED_GATE_FIELDS } from './ppt.mjs';
@@ -993,6 +994,15 @@ export async function projectGateStatus(root, state = {}) {
993
994
  source: active.file ? `.sneakoscope/missions/${id}/${active.file}` : null
994
995
  });
995
996
  }
997
+ const mistakeRecall = await mistakeRecallGateStatus(root, state);
998
+ if (id && (!mistakeRecall.ok || mistakeRecall.source)) {
999
+ gates.push({
1000
+ id: MISTAKE_RECALL_ARTIFACT,
1001
+ ok: mistakeRecall.ok,
1002
+ missing: mistakeRecall.missing || [],
1003
+ source: `.sneakoscope/missions/${id}/${MISTAKE_RECALL_ARTIFACT}`
1004
+ });
1005
+ }
996
1006
  const reflection = await reflectionGateStatus(root, state);
997
1007
  if (reflectionRequiredForState(state)) {
998
1008
  gates.push({
@@ -1032,6 +1042,10 @@ export async function evaluateStop(root, state, payload, opts = {}) {
1032
1042
  if (state?.subagents_required && !(await hasSubagentEvidence(root, state))) {
1033
1043
  return complianceBlock(root, state, `SKS ${state.route_command || state.mode || 'route'} requires subagent execution evidence before completion. Spawn worker/reviewer subagents for disjoint code-changing work, or record explicit evidence that subagents were unavailable or unsafe to split.`, { gate: 'subagent-evidence' });
1034
1044
  }
1045
+ const mistakeRecall = await mistakeRecallGateStatus(root, state);
1046
+ if (!mistakeRecall.ok) {
1047
+ return complianceBlock(root, state, `SKS ${state.route_command || state.mode || 'route'} found relevant TriWiki mistake memory that is not bound to the decision contract. Re-run pipeline answer or seal the contract so ${MISTAKE_RECALL_ARTIFACT} is consumed before finishing.`, { gate: MISTAKE_RECALL_ARTIFACT, missing: mistakeRecall.missing });
1048
+ }
1035
1049
  if (opts.noQuestion) {
1036
1050
  if (containsUserQuestion(last)) return complianceBlock(root, state, noQuestionContinuationReason(), { gate: 'no-question' });
1037
1051
  const gate = await passedActiveGate(root, state);
@@ -230,14 +230,24 @@ export function inferAnswersForPrompt(prompt, explicitAnswers = {}) {
230
230
  const authWork = /로그인|auth|session|token|인증/.test(lower);
231
231
  const prioritySignalWork = /화|짜증|답답|;;|!!|강력|기억|우선|자주|반복|카운팅|count|frequency|frequent|priority|weight/.test(lower);
232
232
  const cliSurfaceWork = /\b(cli|command|route|usage|help|sks)\b|명령|커맨드|사용법/.test(lower);
233
+ const explicitRouteWork = /^\s*\$(?:research|team|goal|dfix|ppt|qa-loop|wiki|db|gx|computer-use|cu|autoresearch|sks|answer|help)\b/i.test(String(prompt || ''));
234
+ const triwikiAuditWork = /(triwiki|tri\s*wiki|wiki|복셀|voxel)/.test(lower)
235
+ && /(검수|연구|개선|정상|동작|작동|반복|실수|mistake|repeat|audit|inspect|prevent|방지)/.test(lower);
233
236
  const chatCaptureWork = hasFromChatImgSignal(text)
234
237
  && /(chat|conversation|message|messenger|kakao|screenshot|capture|채팅|대화|메신저|카톡|캡처|스크린샷)/i.test(text)
235
238
  && /(image|photo|attachment|attached|이미지|사진|첨부)/i.test(text)
236
239
  && /(client|customer|request|change|modify|fix|match|ocr|extract|text|고객사|클라이언트|요청|수정|변경|매칭|추출|글자|텍스트)/i.test(text);
237
- const kind = versionWork ? 'version' : chatCaptureWork ? 'chat_capture' : prioritySignalWork ? 'priority' : questionGateWork ? 'questions' : installWork ? 'install' : null;
240
+ const effectivePrioritySignalWork = prioritySignalWork
241
+ && !explicitRouteWork
242
+ && !triwikiAuditWork
243
+ && !versionWork
244
+ && !presentationWork
245
+ && !chatCaptureWork;
246
+ const kind = versionWork ? 'version' : chatCaptureWork ? 'chat_capture' : triwikiAuditWork ? 'triwiki_audit' : effectivePrioritySignalWork ? 'priority' : questionGateWork ? 'questions' : installWork ? 'install' : null;
238
247
  const goals = {
239
248
  version: version ? `sneakoscope 버전을 ${version}로 올린다` : 'sneakoscope 버전을 다음 patch 버전으로 올린다',
240
249
  chat_capture: 'From-Chat-IMG로 채팅 요구사항과 첨부 원본 이미지를 매칭해 고객사 작업 지시서를 만들고 반영한다',
250
+ triwiki_audit: 'TriWiki가 반복 실수를 막는지 검수하고, 실패 경로를 코드와 검증으로 개선한다',
241
251
  priority: '강한 불만과 반복 요청을 TriWiki 우선순위 신호로 기록한다',
242
252
  questions: '예측 가능한 답은 추론하고 실제 모호한 항목만 질문한다',
243
253
  presentation: '청중과 STP 전략에 맞는 HTML 기반 발표자료/PDF 산출물을 만든다',
@@ -246,6 +256,7 @@ export function inferAnswersForPrompt(prompt, explicitAnswers = {}) {
246
256
  const criteria = {
247
257
  version: [version ? `version refs are ${version}` : 'version refs advance consistently', 'publish:dry gate passes', 'npm publish is not run'],
248
258
  chat_capture: ['From-Chat-IMG activates chat-image intake only here', 'all visible chat requirements are listed before implementation', `${FROM_CHAT_IMG_COVERAGE_ARTIFACT} maps every customer request, screenshot region, and attachment to work-order item(s)`, `${FROM_CHAT_IMG_CHECKLIST_ARTIFACT} is updated as each request, image match, work item, scoped QA-LOOP, and verification step is completed`, `${FROM_CHAT_IMG_TEMP_TRIWIKI_ARTIFACT} records temporary TriWiki-backed session context with retention metadata`, `${FROM_CHAT_IMG_QA_LOOP_ARTIFACT} proves QA-LOOP ran over the exact customer-request work-order range after implementation`, 'unresolved_items is empty before Team completion', 'scoped_qa_loop_completed is true with zero unresolved QA findings', 'Codex Computer Use visual inspection strengthens matches when available; no Playwright or browser automation substitute is allowed', CODEX_COMPUTER_USE_ONLY_POLICY, 'client requests follow normal SKS gates and verification'],
259
+ triwiki_audit: ['TriWiki ingestion, voxel attention, and contract consumption paths are inspected against current code', 'repeat-mistake prevention gaps are fixed in the relevant code path or blocked with evidence', 'regression coverage proves fresh/high-weight mistake memory can influence future missions', 'final status separates supported behavior from anything still unverified'],
249
260
  priority: ['strong feedback raises required_weight', 'request topics are counted in wiki packs', 'future inference uses priority signals'],
250
261
  questions: ['predictable answers are inferred', 'partial answers can seal contracts', 'only unresolved changing slots remain visible'],
251
262
  presentation: ['audience profile and STP strategy are explicit before artifact creation', 'target pain points map to proposed solution moments', 'decision context and likely objections are sealed before storyboarding', 'presentation format, device, and delivery context are fixed before design work'],
@@ -241,6 +241,28 @@ export function buildTmuxOpenArgs(plan = {}) {
241
241
  return ['attach-session', '-t', sanitizeTmuxSessionName(plan.session || plan.workspace || defaultTmuxSessionName(plan.root))];
242
242
  }
243
243
 
244
+ export function shouldAutoAttachTmux(args = [], env = process.env, streams = {}) {
245
+ const stdin = streams.stdin || process.stdin;
246
+ const stdout = streams.stdout || process.stdout;
247
+ if (args.includes('--json') || args.includes('--status-only') || args.includes('--quiet') || args.includes('--no-attach')) return false;
248
+ if (String(env.SKS_TMUX_NO_AUTO_ATTACH || '') === '1') return false;
249
+ return Boolean(stdin?.isTTY && stdout?.isTTY);
250
+ }
251
+
252
+ function attachTmuxSession(plan = {}, args = [], opts = {}) {
253
+ const session = sanitizeTmuxSessionName(plan.session || plan.workspace || defaultTmuxSessionName(plan.root));
254
+ const tmuxBin = plan.tmux?.bin || 'tmux';
255
+ const attachArgs = isTmuxShellSession(opts.env || process.env) ? ['switch-client', '-t', session] : buildTmuxOpenArgs({ ...plan, session });
256
+ console.log(`Attaching: ${[path.basename(tmuxBin), ...attachArgs].join(' ')}`);
257
+ const attached = spawnSync(tmuxBin, attachArgs, { stdio: 'inherit' });
258
+ return {
259
+ ok: attached.status === 0,
260
+ status: attached.status,
261
+ signal: attached.signal || null,
262
+ command: [tmuxBin, ...attachArgs].join(' ')
263
+ };
264
+ }
265
+
244
266
  export function runTmuxLaunchPlanSyntaxCheck(plan = {}) {
245
267
  const args = buildTmuxOpenArgs(plan);
246
268
  return {
@@ -292,7 +314,16 @@ export async function launchTmuxUi(args = [], opts = {}) {
292
314
  else console.log(`tmux: not created (${created.stderr || 'tmux failed'})`);
293
315
  if (created.ok) console.log(`Attach: ${created.attach_command}`);
294
316
  }
295
- return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created };
317
+ let attached = null;
318
+ if (created.ok && shouldAutoAttachTmux(args)) {
319
+ attached = attachTmuxSession({ ...plan, session: created.session || plan.session }, args);
320
+ if (!attached.ok) {
321
+ const status = attached.signal || (attached.status ?? 'unknown');
322
+ console.error(`SKS tmux attach failed (${status}). Run manually: ${created.attach_command}`);
323
+ process.exitCode = attached.status || 1;
324
+ }
325
+ }
326
+ return { plan, created: Boolean(created.ok), session: created.session || plan.session, opened: created, attached };
296
327
  }
297
328
 
298
329
  function printTmuxLaunchBlocked(plan, opts = {}) {
@@ -1,4 +1,4 @@
1
- import { buildWikiCoordinateIndex, compactWikiCoordinateIndex, normalizeWikiCoord, wikiCoordSimilarity } from './wiki-coordinate.mjs';
1
+ import { WIKI_VOXEL_LAYERS, buildWikiCoordinateIndex, compactWikiCoordinateIndex, normalizeWikiCoord, wikiCoordSimilarity } from './wiki-coordinate.mjs';
2
2
 
3
3
  const TAU = 2 * Math.PI;
4
4
 
@@ -183,6 +183,51 @@ function hydrateReason(claim = {}) {
183
183
  return '';
184
184
  }
185
185
 
186
+ function voxelHydrateCandidates(wiki = {}, anchors = new Map(), max = 4) {
187
+ const overlay = wiki.vx || wiki.voxel_overlay;
188
+ const rows = Array.isArray(overlay?.v) ? overlay.v : [];
189
+ const layers = Array.isArray(overlay?.l) ? overlay.l : WIKI_VOXEL_LAYERS;
190
+ const idx = Object.fromEntries(layers.map((layer, index) => [layer, index]));
191
+ return rows
192
+ .map((row) => {
193
+ const id = row?.[1];
194
+ const values = Array.isArray(row?.[2]) ? row[2] : [];
195
+ const anchor = anchors.get(id);
196
+ if (!id || !anchor) return null;
197
+ const prio = Number(values[idx.prio] || 0);
198
+ const conflict = Number(values[idx.conflict] || 0);
199
+ const route = Number(values[idx.route] || 0);
200
+ const trust = Number(values[idx.trust] || 0);
201
+ const fresh = Number(values[idx.fresh] || 0);
202
+ let reason = '';
203
+ if (conflict >= 0.35) reason = `voxel:conflict:${conflict.toFixed(2)}`;
204
+ else if (prio >= 0.92 && route >= 0.4) reason = `voxel:priority_route:${prio.toFixed(2)}`;
205
+ else if (prio >= 0.75 && fresh <= 0.25) reason = `voxel:stale_priority:${prio.toFixed(2)}`;
206
+ if (!reason) return null;
207
+ return {
208
+ id,
209
+ anchor,
210
+ reason,
211
+ score: conflict * 3 + prio * 2 + route + (1 - trust) * 0.8 + (1 - fresh) * 0.5
212
+ };
213
+ })
214
+ .filter(Boolean)
215
+ .sort((a, b) => (b.score - a.score) || String(a.id).localeCompare(String(b.id)))
216
+ .slice(0, max);
217
+ }
218
+
219
+ function uniqueAttentionRows(rows = [], max = 4) {
220
+ const out = [];
221
+ const seen = new Set();
222
+ for (const row of rows) {
223
+ if (!Array.isArray(row) || !row[0] || seen.has(row[0])) continue;
224
+ seen.add(row[0]);
225
+ out.push(row);
226
+ if (out.length >= max) break;
227
+ }
228
+ return out;
229
+ }
230
+
186
231
  export function buildTriWikiAttention({ selected = [], wiki = {}, role = 'worker', maxUse = 4, maxHydrate = 4 } = {}) {
187
232
  const anchors = attentionAnchorMap(wiki);
188
233
  const ranked = [...(selected || [])]
@@ -199,11 +244,18 @@ export function buildTriWikiAttention({ selected = [], wiki = {}, role = 'worker
199
244
  .filter((claim) => trustAction(claim) === 'use')
200
245
  .slice(0, maxUse)
201
246
  .map((claim) => attentionRow(claim, anchors.get(claim.id)));
202
- const hydrateFirst = ranked
247
+ const selectedHydrateRows = ranked
203
248
  .map((claim) => ({ claim, reason: hydrateReason(claim) }))
204
249
  .filter((item) => item.reason)
205
- .slice(0, maxHydrate)
206
250
  .map((item) => attentionRow(item.claim, anchors.get(item.claim.id), item.reason));
251
+ const negativeHydrateRows = selectedHydrateRows.filter((row) => String(row[1] || '').includes('negative_priming'));
252
+ const voxelRows = voxelHydrateCandidates(wiki, anchors, maxHydrate)
253
+ .map((item) => attentionRow({ id: item.id }, item.anchor, item.reason));
254
+ const hydrateFirst = uniqueAttentionRows([
255
+ ...negativeHydrateRows,
256
+ ...voxelRows,
257
+ ...selectedHydrateRows
258
+ ], maxHydrate);
207
259
  return {
208
260
  mode: 'aggressive_triwiki_active_recall',
209
261
  use_first: useFirst,
@@ -29,8 +29,9 @@ export async function versioningStatus(root) {
29
29
  const hookInstalled = hookText.includes(`BEGIN ${VERSION_HOOK_MARKER}`);
30
30
  const policy = await versionPolicy(root);
31
31
  const state = await readJson(path.join(git.common_dir, VERSION_STATE_FILE), {});
32
+ const runtimeDrift = await runtimeDriftStatus(root, version);
32
33
  return {
33
- ok: !policy.enabled || hookInstalled || !version,
34
+ ok: (!policy.enabled || hookInstalled || !version) && runtimeDrift.ok,
34
35
  enabled: Boolean(policy.enabled && version),
35
36
  package_version: version,
36
37
  bump: policy.bump,
@@ -38,10 +39,41 @@ export async function versioningStatus(root) {
38
39
  hook_path: git.hook_path,
39
40
  state_path: path.join(git.common_dir, VERSION_STATE_FILE),
40
41
  last_version: state.last_version || null,
42
+ runtime_drift: runtimeDrift,
41
43
  reason: version ? null : 'package_json_version_missing'
42
44
  };
43
45
  }
44
46
 
47
+ async function runtimeDriftStatus(root, packageVersion) {
48
+ if (!packageVersion || process.env.SKS_RUNTIME_DRIFT_CHECK === '0') {
49
+ return { ok: true, checked: false, reason: packageVersion ? 'disabled' : 'package_json_version_missing' };
50
+ }
51
+ const result = await runProcess('sks', ['--version'], {
52
+ cwd: root,
53
+ timeoutMs: 5000,
54
+ maxOutputBytes: 16 * 1024,
55
+ env: { SKS_RUNTIME_DRIFT_CHECK: '0' }
56
+ });
57
+ if (result.code !== 0) {
58
+ return { ok: true, checked: false, reason: 'sks_binary_unavailable', stderr: result.stderr?.trim() || null };
59
+ }
60
+ const match = String(result.stdout || '').match(/(\d+\.\d+\.\d+)/);
61
+ const runtimeVersion = match?.[1] || null;
62
+ const runtime = parseSemver(runtimeVersion);
63
+ const source = parseSemver(packageVersion);
64
+ if (!runtime || !source) {
65
+ return { ok: true, checked: true, reason: 'version_parse_unavailable', runtime_version: runtimeVersion, package_version: packageVersion };
66
+ }
67
+ const comparison = compareSemver(runtime, source);
68
+ return {
69
+ ok: comparison >= 0,
70
+ checked: true,
71
+ runtime_version: runtimeVersion,
72
+ package_version: packageVersion,
73
+ relation: comparison === 0 ? 'same' : (comparison > 0 ? 'runtime_newer' : 'runtime_older')
74
+ };
75
+ }
76
+
45
77
  export async function runVersionPreCommit(root, opts = {}) {
46
78
  if (process.env.SKS_DISABLE_VERSIONING === '1') return { ok: true, skipped: true, reason: 'SKS_DISABLE_VERSIONING=1' };
47
79
  const policy = await versionPolicy(root);