sneakoscope 0.6.70 → 0.6.72
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 +3 -2
- package/package.json +1 -1
- package/src/cli/main.mjs +9 -1
- package/src/core/cmux-ui.mjs +82 -3
- package/src/core/fsx.mjs +1 -1
- package/src/core/hooks-runtime.mjs +83 -5
package/README.md
CHANGED
|
@@ -44,11 +44,12 @@ sks selftest --mock
|
|
|
44
44
|
| Area | What it does |
|
|
45
45
|
| --- | --- |
|
|
46
46
|
| CLI runtime | `sks`, `sks cmux`, and `sks --mad` open Codex CLI in a cmux workspace. |
|
|
47
|
-
| Codex App commands | Installs generated skills so `$Team`, `$DFix`, `$QA-LOOP`, `$Ralph`, `$DB`, `$Wiki`, `$Help`, and related routes are visible in prompt workflows. |
|
|
47
|
+
| Codex App commands | Installs generated skills so `$Team`, `$From-Chat-IMG`, `$DFix`, `$QA-LOOP`, `$Ralph`, `$DB`, `$Wiki`, `$Help`, and related routes are visible in prompt workflows. |
|
|
48
48
|
| Team orchestration | Runs substantial work through ambiguity handling, scouts, TriWiki refresh, debate, runtime task graphs, worker inboxes, implementation, review, cleanup, reflection, and Honest Mode. |
|
|
49
|
+
| From-Chat-IMG | Turns chat screenshots plus original attachments into source-bound work orders, then requires scoped QA evidence before completion. |
|
|
49
50
|
| QA loop | Dogfoods UI/API behavior with safety gates, Browser/Computer evidence, safe fixes, and rechecks. |
|
|
50
51
|
| Ralph | Clarifies once, seals a decision contract, then continues without repeatedly asking the user. |
|
|
51
|
-
| TriWiki | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with `attention.use_first
|
|
52
|
+
| TriWiki voxels | Maintains `.sneakoscope/wiki/context-pack.json` as the context SSOT with coordinate anchors, voxel metadata, `attention.use_first`, and `attention.hydrate_first`. |
|
|
52
53
|
| Context7 | Requires current docs for external packages, APIs, MCPs, SDKs, and framework/runtime behavior when correctness depends on current guidance. |
|
|
53
54
|
| DB safety | Treats SQL, migrations, Supabase, RLS, and destructive operations as high risk. |
|
|
54
55
|
| Release hygiene | Checks versioning, changelog, package contents, tarball size, syntax, selftests, and dry-run publishing. |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sneakoscope",
|
|
3
3
|
"displayName": "ㅅㅋㅅ",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.72",
|
|
5
5
|
"description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Ralph, 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
|
@@ -30,7 +30,7 @@ import { context7Evidence, evaluateStop, recordContext7Evidence, recordSubagentE
|
|
|
30
30
|
import { TEAM_DECOMPOSITION_ARTIFACT, TEAM_GRAPH_ARTIFACT, TEAM_INBOX_DIR, TEAM_RUNTIME_TASKS_ARTIFACT, teamRuntimePlanMetadata, teamRuntimeRequiredArtifacts, validateTeamRuntimeArtifacts, writeTeamRuntimeArtifacts } from '../core/team-dag.mjs';
|
|
31
31
|
import { appendTeamEvent, formatRoleCounts, initTeamLive, normalizeTeamSpec, parseTeamSpecArgs, parseTeamSpecText, readTeamDashboard, readTeamLive, readTeamTranscriptTail, renderTeamAgentLane } from '../core/team-live.mjs';
|
|
32
32
|
import { CODEX_APP_DOCS_URL, codexAppIntegrationStatus, formatCodexAppStatus } from '../core/codex-app.mjs';
|
|
33
|
-
import { CMUX_BREW_COMMAND, CMUX_BREW_UPGRADE_COMMAND, buildCmuxLaunchPlan, buildCmuxNewWorkspaceArgs, cmuxWorkspaceRef, defaultCmuxWorkspaceName, ensureCmuxInstalled, formatCmuxBanner, launchCmuxTeamView, launchCmuxUi, matchingCmuxWorkspaces, parseCmuxWorkspaceList, platformCmuxInstallHint, runCmuxStatus, sanitizeCmuxWorkspaceName, cmuxAvailable } from '../core/cmux-ui.mjs';
|
|
33
|
+
import { CMUX_BREW_COMMAND, CMUX_BREW_UPGRADE_COMMAND, buildCmuxLaunchPlan, buildCmuxNewWorkspaceArgs, cmuxWorkspaceRef, cmuxWorkspaceRefFromText, defaultCmuxWorkspaceName, ensureCmuxInstalled, formatCmuxBanner, launchCmuxTeamView, launchCmuxUi, matchingCmuxWorkspaces, parseCmuxWorkspaceList, platformCmuxInstallHint, readCmuxWorkspaceRecord, runCmuxStatus, sanitizeCmuxWorkspaceName, cmuxAvailable, writeCmuxWorkspaceRecord } from '../core/cmux-ui.mjs';
|
|
34
34
|
import { autoReviewProfileName, autoReviewStatus, autoReviewSummary, enableAutoReview, disableAutoReview, enableMadHighProfile, madHighProfileName } from '../core/auto-review.mjs';
|
|
35
35
|
|
|
36
36
|
const flag = (args, name) => args.includes(name);
|
|
@@ -2378,6 +2378,10 @@ async function selftest() {
|
|
|
2378
2378
|
] }));
|
|
2379
2379
|
const workspaceMatches = matchingCmuxWorkspaces(workspaceList, workspacePlan);
|
|
2380
2380
|
if (workspaceMatches.length !== 1 || cmuxWorkspaceRef(workspaceMatches[0]) !== 'workspace:1') throw new Error('selftest failed: MAD cmux workspace reuse matching did not select the stable workspace');
|
|
2381
|
+
if (cmuxWorkspaceRefFromText('OK workspace:3') !== 'workspace:3') throw new Error('selftest failed: cmux workspace ref parser did not read cmux OK output');
|
|
2382
|
+
await writeCmuxWorkspaceRecord(workspacePlan, { ref: 'workspace:7', name: 'sks-mad-selftest', cwd: tmp });
|
|
2383
|
+
const workspaceRecord = await readCmuxWorkspaceRecord(workspacePlan);
|
|
2384
|
+
if (workspaceRecord?.ref !== 'workspace:7' || workspaceRecord.workspace !== 'sks-mad-selftest') throw new Error('selftest failed: MAD cmux workspace record was not persisted for stable reuse');
|
|
2381
2385
|
const guardBlocked = await checkHarnessModification(tmp, { tool_name: 'apply_patch', command: '*** Update File: .agents/skills/team/SKILL.md\n+tamper\n' });
|
|
2382
2386
|
if (guardBlocked.action !== 'block') throw new Error('selftest failed: harness guard allowed skill tampering');
|
|
2383
2387
|
const setupBlocked = await checkHarnessModification(tmp, { command: 'sks setup --force' });
|
|
@@ -2665,6 +2669,10 @@ async function selftest() {
|
|
|
2665
2669
|
if (honestMissingSummaryResult.code !== 0) throw new Error(`selftest failed: missing-summary honest hook exited ${honestMissingSummaryResult.code}: ${honestMissingSummaryResult.stderr}`);
|
|
2666
2670
|
const honestMissingSummaryJson = JSON.parse(honestMissingSummaryResult.stdout);
|
|
2667
2671
|
if (honestMissingSummaryJson.decision !== 'block' || !String(honestMissingSummaryJson.reason || '').includes('completion summary')) throw new Error('selftest failed: Honest Mode without completion summary was accepted');
|
|
2672
|
+
const honestMissingSummaryRepeatResult = await runProcess(process.execPath, [hookBin, 'hook', 'stop'], { cwd: honestLoopTmp, input: JSON.stringify({ cwd: honestLoopTmp, last_assistant_message: '**솔직모드**\n검증: selftest 통과\n남은 gap: 없음' }), env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
|
|
2673
|
+
if (honestMissingSummaryRepeatResult.code !== 0) throw new Error(`selftest failed: repeated missing-summary honest hook exited ${honestMissingSummaryRepeatResult.code}: ${honestMissingSummaryRepeatResult.stderr}`);
|
|
2674
|
+
const honestMissingSummaryRepeatJson = JSON.parse(honestMissingSummaryRepeatResult.stdout);
|
|
2675
|
+
if (honestMissingSummaryRepeatJson.decision === 'block' || !String(honestMissingSummaryRepeatJson.systemMessage || '').includes('repeat guard')) throw new Error('selftest failed: repeated completion-summary stop prompt was not suppressed');
|
|
2668
2676
|
const honestBlockedAsExpectedResult = await runProcess(process.execPath, [hookBin, 'hook', 'stop'], { cwd: honestLoopTmp, input: JSON.stringify({ cwd: honestLoopTmp, last_assistant_message: '**작업 요약**\nlegacy QA report 차단 확인을 검증했습니다.\n**솔직모드**\n검증: selftest 통과, legacy `qa-report.md` 차단 확인\n제약: registry publish excluded' }), env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 128 * 1024 });
|
|
2669
2677
|
if (honestBlockedAsExpectedResult.code !== 0) throw new Error(`selftest failed: blocked-as-expected honest hook exited ${honestBlockedAsExpectedResult.code}: ${honestBlockedAsExpectedResult.stderr}`);
|
|
2670
2678
|
const honestBlockedAsExpectedJson = JSON.parse(honestBlockedAsExpectedResult.stdout);
|
package/src/core/cmux-ui.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { exists, packageRoot, runProcess, sha256, sksRoot, which } from './fsx.mjs';
|
|
4
|
+
import { exists, nowIso, packageRoot, readJson, runProcess, sha256, sksRoot, which, writeJsonAtomic } from './fsx.mjs';
|
|
5
5
|
import { getCodexInfo } from './codex-adapter.mjs';
|
|
6
6
|
import { codexAppIntegrationStatus, formatCodexAppStatus } from './codex-app.mjs';
|
|
7
7
|
|
|
@@ -74,6 +74,64 @@ export function cmuxWorkspaceRef(workspace = {}) {
|
|
|
74
74
|
return String(workspace.ref || workspace.workspace_ref || workspace.handle || workspace.id || workspace.workspace_id || workspace.uuid || '').trim();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export function cmuxWorkspaceRefFromText(text = '') {
|
|
78
|
+
return String(text || '').match(/\bworkspace:\d+\b/i)?.[0] || String(text || '').match(/[0-9a-f]{8}-[0-9a-f-]{27,}/i)?.[0] || '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cmuxWorkspaceStatePath(plan = {}) {
|
|
82
|
+
return path.join(path.resolve(plan.root || process.cwd()), '.sneakoscope', 'state', 'cmux-workspaces.json');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function cmuxWorkspaceStateKey(plan = {}) {
|
|
86
|
+
const root = path.resolve(plan.root || process.cwd());
|
|
87
|
+
const workspace = sanitizeCmuxWorkspaceName(plan.workspace || defaultCmuxWorkspaceName(root));
|
|
88
|
+
const profile = codexProfileFromArgs(plan.codexArgs);
|
|
89
|
+
return sha256(`${root}\n${workspace}\n${profile || 'default'}`).slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function readCmuxWorkspaceRecord(plan = {}) {
|
|
93
|
+
const state = await readJson(cmuxWorkspaceStatePath(plan), {}).catch(() => ({}));
|
|
94
|
+
const record = state.workspaces?.[cmuxWorkspaceStateKey(plan)] || null;
|
|
95
|
+
return record && typeof record === 'object' ? record : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function writeCmuxWorkspaceRecord(plan = {}, workspace = {}) {
|
|
99
|
+
const statePath = cmuxWorkspaceStatePath(plan);
|
|
100
|
+
const state = await readJson(statePath, {}).catch(() => ({}));
|
|
101
|
+
const root = path.resolve(plan.root || process.cwd());
|
|
102
|
+
const name = workspaceName(workspace) || sanitizeCmuxWorkspaceName(plan.workspace || defaultCmuxWorkspaceName(root));
|
|
103
|
+
const record = {
|
|
104
|
+
workspace: name,
|
|
105
|
+
root,
|
|
106
|
+
ref: cmuxWorkspaceRef(workspace),
|
|
107
|
+
description: workspaceDescription(workspace) || cmuxWorkspaceDescription(plan),
|
|
108
|
+
cwd: workspaceCwd(workspace) || root,
|
|
109
|
+
profile: codexProfileFromArgs(plan.codexArgs) || 'default',
|
|
110
|
+
updated_at: nowIso()
|
|
111
|
+
};
|
|
112
|
+
if (!record.ref) return null;
|
|
113
|
+
const next = {
|
|
114
|
+
schema_version: 1,
|
|
115
|
+
updated_at: record.updated_at,
|
|
116
|
+
workspaces: {
|
|
117
|
+
...(state.workspaces && typeof state.workspaces === 'object' ? state.workspaces : {}),
|
|
118
|
+
[cmuxWorkspaceStateKey(plan)]: record
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
await writeJsonAtomic(statePath, next);
|
|
122
|
+
return record;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function forgetCmuxWorkspaceRecord(plan = {}) {
|
|
126
|
+
const statePath = cmuxWorkspaceStatePath(plan);
|
|
127
|
+
const state = await readJson(statePath, null).catch(() => null);
|
|
128
|
+
if (!state?.workspaces || typeof state.workspaces !== 'object') return;
|
|
129
|
+
const key = cmuxWorkspaceStateKey(plan);
|
|
130
|
+
if (!(key in state.workspaces)) return;
|
|
131
|
+
delete state.workspaces[key];
|
|
132
|
+
await writeJsonAtomic(statePath, { ...state, updated_at: nowIso(), workspaces: state.workspaces });
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
export function shellEscape(value) {
|
|
78
136
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
79
137
|
}
|
|
@@ -280,12 +338,18 @@ export async function launchCmuxUi(args = [], opts = {}) {
|
|
|
280
338
|
return { plan, workspace_reuse: reuse };
|
|
281
339
|
}
|
|
282
340
|
}
|
|
283
|
-
const created = spawnSync(plan.cmux.bin, buildCmuxNewWorkspaceArgs(plan, command), { encoding: 'utf8', stdio:
|
|
341
|
+
const created = spawnSync(plan.cmux.bin, buildCmuxNewWorkspaceArgs(plan, command), { encoding: 'utf8', stdio: 'pipe' });
|
|
342
|
+
if (!args.includes('--quiet')) {
|
|
343
|
+
if (created.stdout) process.stdout.write(created.stdout);
|
|
344
|
+
if (created.stderr) process.stderr.write(created.stderr);
|
|
345
|
+
}
|
|
284
346
|
if (created.status !== 0) {
|
|
285
347
|
process.exitCode = created.status || 1;
|
|
286
|
-
if (created.stderr) process.stderr.write(created.stderr);
|
|
348
|
+
if (args.includes('--quiet') && created.stderr) process.stderr.write(created.stderr);
|
|
287
349
|
return { plan };
|
|
288
350
|
}
|
|
351
|
+
const createdRef = cmuxWorkspaceRefFromText(`${created.stdout || ''}\n${created.stderr || ''}`);
|
|
352
|
+
if (createdRef) await writeCmuxWorkspaceRecord(plan, { ref: createdRef, name: plan.workspace, description: cmuxWorkspaceDescription(plan), cwd: plan.root }).catch(() => null);
|
|
289
353
|
if (args.includes('--no-open')) {
|
|
290
354
|
console.log(`SKS cmux workspace requested: ${plan.workspace}`);
|
|
291
355
|
}
|
|
@@ -293,6 +357,8 @@ export async function launchCmuxUi(args = [], opts = {}) {
|
|
|
293
357
|
}
|
|
294
358
|
|
|
295
359
|
async function reuseExistingCmuxWorkspace(plan = {}, opts = {}) {
|
|
360
|
+
const remembered = await reuseRecordedCmuxWorkspace(plan);
|
|
361
|
+
if (remembered.reused) return remembered;
|
|
296
362
|
const listed = await listCmuxWorkspaces(plan.cmux?.bin);
|
|
297
363
|
if (!listed.ok) return listed;
|
|
298
364
|
const matches = matchingCmuxWorkspaces(listed.workspaces, plan);
|
|
@@ -311,9 +377,22 @@ async function reuseExistingCmuxWorkspace(plan = {}, opts = {}) {
|
|
|
311
377
|
if (close.code === 0) closed += 1;
|
|
312
378
|
}
|
|
313
379
|
}
|
|
380
|
+
await writeCmuxWorkspaceRecord(plan, keep).catch(() => null);
|
|
314
381
|
return { ok: true, reused: true, workspace: keep, workspace_ref: ref, closed_duplicates: closed, total_matches: matches.length };
|
|
315
382
|
}
|
|
316
383
|
|
|
384
|
+
async function reuseRecordedCmuxWorkspace(plan = {}) {
|
|
385
|
+
const record = await readCmuxWorkspaceRecord(plan);
|
|
386
|
+
const ref = cmuxWorkspaceRef(record || {});
|
|
387
|
+
if (!ref) return { ok: true, reused: false };
|
|
388
|
+
const selected = await runProcess(plan.cmux.bin, ['select-workspace', '--workspace', ref], { timeoutMs: 5000, maxOutputBytes: 16 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
|
|
389
|
+
if (selected.code === 0) return { ok: true, reused: true, workspace: record, workspace_ref: ref, remembered: true, closed_duplicates: 0 };
|
|
390
|
+
const error = `${selected.stderr || selected.stdout || 'cmux select-workspace failed'}`.trim();
|
|
391
|
+
if (isRecoverableCmuxSocketError(error)) return { ok: false, error, stale_ref: ref };
|
|
392
|
+
await forgetCmuxWorkspaceRecord(plan).catch(() => null);
|
|
393
|
+
return { ok: true, reused: false, stale_ref: ref, stale_error: error };
|
|
394
|
+
}
|
|
395
|
+
|
|
317
396
|
async function listCmuxWorkspaces(bin) {
|
|
318
397
|
if (!bin) return { ok: false, error: 'cmux CLI not found' };
|
|
319
398
|
const run = await runProcess(bin, ['list-workspaces', '--json'], { timeoutMs: 5000, maxOutputBytes: 128 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
|
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.6.
|
|
8
|
+
export const PACKAGE_VERSION = '0.6.72';
|
|
9
9
|
export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
|
|
10
10
|
export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
|
|
11
11
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { projectRoot, readJson, readText, writeJsonAtomic, appendJsonl, readStdin, nowIso, runProcess, which, PACKAGE_VERSION } from './fsx.mjs';
|
|
2
|
+
import { projectRoot, readJson, readText, writeJsonAtomic, appendJsonl, readStdin, nowIso, runProcess, which, PACKAGE_VERSION, sha256 } from './fsx.mjs';
|
|
3
3
|
import { looksInteractiveCommand, interactiveCommandReason } from './no-question-guard.mjs';
|
|
4
4
|
import { missionDir, setCurrent, stateFile } from './mission.mjs';
|
|
5
5
|
import { checkDbOperation, dbBlockReason, handleMadSksUserConfirmation } from './db-safety.mjs';
|
|
@@ -10,6 +10,10 @@ const TEAM_DIGEST_MAX_EVENTS = 4;
|
|
|
10
10
|
const TEAM_DIGEST_MESSAGE_CHARS = 180;
|
|
11
11
|
const TEAM_DIGEST_CONTEXT_CHARS = 1600;
|
|
12
12
|
const TEAM_DIGEST_SYSTEM_CHARS = 260;
|
|
13
|
+
const STOP_REPEAT_GUARD_ARTIFACT = 'stop-hook-repeat-guard.json';
|
|
14
|
+
const STOP_REPEAT_GUARD_WINDOW_MS = 10 * 60 * 1000;
|
|
15
|
+
const STOP_REPEAT_GUARD_MAX_ENTRIES = 25;
|
|
16
|
+
const DEFAULT_STOP_REPEAT_GUARD_LIMIT = 2;
|
|
13
17
|
|
|
14
18
|
async function loadHookPayload() {
|
|
15
19
|
const raw = await readStdin();
|
|
@@ -188,15 +192,19 @@ async function hookStop(root, state, payload, noQuestion) {
|
|
|
188
192
|
if (!noQuestion) {
|
|
189
193
|
const last = extractLastMessage(payload);
|
|
190
194
|
if (!hasHonestMode(last)) {
|
|
191
|
-
|
|
195
|
+
const reason = 'SKS Honest Mode is required before finishing. Re-check the actual goal, verify evidence/tests, state gaps honestly, and only then provide the final answer. Include a short "SKS Honest Mode" or "솔직모드" section.';
|
|
196
|
+
const repeatDecision = await finalizationRepeatDecision(root, state, payload, reason, 'honest_mode_missing');
|
|
197
|
+
return repeatDecision || {
|
|
192
198
|
decision: 'block',
|
|
193
|
-
reason
|
|
199
|
+
reason
|
|
194
200
|
};
|
|
195
201
|
}
|
|
196
202
|
if (!hasCompletionSummary(last)) {
|
|
197
|
-
|
|
203
|
+
const reason = 'SKS final completion summary is required before finishing. Explain what was done, what changed for the user/repo, what was verified, and any remaining gaps before or alongside SKS Honest Mode.';
|
|
204
|
+
const repeatDecision = await finalizationRepeatDecision(root, state, payload, reason, 'completion_summary_missing');
|
|
205
|
+
return repeatDecision || {
|
|
198
206
|
decision: 'block',
|
|
199
|
-
reason
|
|
207
|
+
reason
|
|
200
208
|
};
|
|
201
209
|
}
|
|
202
210
|
if (shouldLoopBackAfterHonestMode(state) && hasHonestModeUnresolvedGap(last)) {
|
|
@@ -215,6 +223,76 @@ async function hookStop(root, state, payload, noQuestion) {
|
|
|
215
223
|
};
|
|
216
224
|
}
|
|
217
225
|
|
|
226
|
+
async function finalizationRepeatDecision(root, state = {}, payload = {}, reason = '', kind = 'finalization') {
|
|
227
|
+
const now = nowIso();
|
|
228
|
+
const guardPath = path.join(root, '.sneakoscope', 'state', STOP_REPEAT_GUARD_ARTIFACT);
|
|
229
|
+
const previous = await readJson(guardPath, {}).catch(() => ({}));
|
|
230
|
+
const limit = stopRepeatGuardLimit();
|
|
231
|
+
const entries = pruneStopRepeatEntries(previous.entries || {}, now);
|
|
232
|
+
const key = stopRepeatKey(state, payload, reason, kind);
|
|
233
|
+
const prior = entries[key] || {};
|
|
234
|
+
const repeatCount = stopRepeatInWindow(prior, now)
|
|
235
|
+
? Number(prior.repeat_count || 0) + 1
|
|
236
|
+
: 1;
|
|
237
|
+
const record = {
|
|
238
|
+
schema_version: 1,
|
|
239
|
+
updated_at: now,
|
|
240
|
+
window_ms: STOP_REPEAT_GUARD_WINDOW_MS,
|
|
241
|
+
limit,
|
|
242
|
+
entries: {
|
|
243
|
+
...entries,
|
|
244
|
+
[key]: {
|
|
245
|
+
kind,
|
|
246
|
+
route: state.route_command || state.route || state.mode || null,
|
|
247
|
+
mission_id: state.mission_id || null,
|
|
248
|
+
conversation_id: conversationId(payload),
|
|
249
|
+
first_seen: stopRepeatInWindow(prior, now) ? (prior.first_seen || now) : now,
|
|
250
|
+
last_seen: now,
|
|
251
|
+
repeat_count: repeatCount,
|
|
252
|
+
tripped: repeatCount >= limit,
|
|
253
|
+
reason
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
await writeJsonAtomic(guardPath, record).catch(() => null);
|
|
258
|
+
if (repeatCount < limit) return null;
|
|
259
|
+
return {
|
|
260
|
+
continue: true,
|
|
261
|
+
systemMessage: `SKS stop hook repeat guard suppressed repeated ${kind} prompt after ${repeatCount} identical block(s). No completion success is claimed by the hook.`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stopRepeatKey(state = {}, payload = {}, reason = '', kind = '') {
|
|
266
|
+
return sha256(JSON.stringify({
|
|
267
|
+
kind,
|
|
268
|
+
reason,
|
|
269
|
+
conversation_id: conversationId(payload),
|
|
270
|
+
mission_id: state.mission_id || null,
|
|
271
|
+
route: state.route_command || state.route || state.mode || null,
|
|
272
|
+
gate: state.stop_gate || null
|
|
273
|
+
})).slice(0, 24);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function stopRepeatGuardLimit() {
|
|
277
|
+
const raw = Number.parseInt(process.env.SKS_STOP_REPEAT_GUARD_LIMIT || '', 10);
|
|
278
|
+
if (!Number.isFinite(raw)) return DEFAULT_STOP_REPEAT_GUARD_LIMIT;
|
|
279
|
+
return Math.max(1, Math.min(20, raw));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function stopRepeatInWindow(entry = {}, now = nowIso()) {
|
|
283
|
+
const last = Date.parse(entry.last_seen || '');
|
|
284
|
+
const current = Date.parse(now);
|
|
285
|
+
if (!Number.isFinite(last) || !Number.isFinite(current)) return false;
|
|
286
|
+
return current - last <= STOP_REPEAT_GUARD_WINDOW_MS;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function pruneStopRepeatEntries(entries = {}, now = nowIso()) {
|
|
290
|
+
return Object.fromEntries(Object.entries(entries)
|
|
291
|
+
.filter(([, entry]) => stopRepeatInWindow(entry, now))
|
|
292
|
+
.sort((a, b) => Date.parse(b[1]?.last_seen || '') - Date.parse(a[1]?.last_seen || ''))
|
|
293
|
+
.slice(0, STOP_REPEAT_GUARD_MAX_ENTRIES));
|
|
294
|
+
}
|
|
295
|
+
|
|
218
296
|
async function updateCheckContext(root, payload, prompt) {
|
|
219
297
|
if (process.env.SKS_DISABLE_UPDATE_CHECK === '1') return '';
|
|
220
298
|
const statePath = path.join(root, '.sneakoscope', 'state', 'update-check.json');
|