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 +4 -4
- package/package.json +1 -1
- package/src/cli/main.mjs +46 -3
- package/src/cli/maintenance-commands.mjs +45 -1
- package/src/core/decision-contract.mjs +26 -4
- package/src/core/fsx.mjs +1 -1
- package/src/core/mistake-recall.mjs +277 -0
- package/src/core/pipeline.mjs +14 -0
- package/src/core/questions.mjs +12 -1
- package/src/core/tmux-ui.mjs +32 -1
- package/src/core/triwiki-attention.mjs +55 -3
- package/src/core/version-manager.mjs +33 -1
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`,
|
|
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"
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/core/pipeline.mjs
CHANGED
|
@@ -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);
|
package/src/core/questions.mjs
CHANGED
|
@@ -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
|
|
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'],
|
package/src/core/tmux-ui.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|