sneakoscope 0.3.0
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/LICENSE +21 -0
- package/README.md +272 -0
- package/bin/sks.mjs +8 -0
- package/docs/PERFORMANCE.md +39 -0
- package/package.json +46 -0
- package/src/cli/main.mjs +358 -0
- package/src/core/codex-adapter.mjs +49 -0
- package/src/core/db-safety.mjs +347 -0
- package/src/core/decision-contract.mjs +120 -0
- package/src/core/fsx.mjs +328 -0
- package/src/core/hooks-runtime.mjs +110 -0
- package/src/core/hproof.mjs +39 -0
- package/src/core/init.mjs +135 -0
- package/src/core/mission.mjs +56 -0
- package/src/core/no-question-guard.mjs +53 -0
- package/src/core/questions.mjs +99 -0
- package/src/core/retention.mjs +140 -0
- package/src/core/rust-accelerator.mjs +19 -0
- package/src/core/triwiki-attention.mjs +68 -0
package/src/core/fsx.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_VERSION = '0.3.0';
|
|
9
|
+
export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
|
|
10
|
+
export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sha256(input) {
|
|
17
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function randomId(len = 6) {
|
|
21
|
+
return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').slice(0, len);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function exists(p) {
|
|
25
|
+
try { await fsp.access(p); return true; } catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ensureDir(p) {
|
|
29
|
+
await fsp.mkdir(p, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readText(p, fallback = undefined) {
|
|
33
|
+
try { return await fsp.readFile(p, 'utf8'); }
|
|
34
|
+
catch (err) { if (fallback !== undefined) return fallback; throw err; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function writeTextAtomic(p, text) {
|
|
38
|
+
await ensureDir(path.dirname(p));
|
|
39
|
+
const tmp = `${p}.${process.pid}.${randomId(6)}.tmp`;
|
|
40
|
+
const handle = await fsp.open(tmp, 'w');
|
|
41
|
+
try {
|
|
42
|
+
await handle.writeFile(text, 'utf8');
|
|
43
|
+
await handle.sync().catch(() => {});
|
|
44
|
+
} finally {
|
|
45
|
+
await handle.close().catch(() => {});
|
|
46
|
+
}
|
|
47
|
+
await fsp.rename(tmp, p);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readJson(p, fallback = undefined) {
|
|
51
|
+
try { return JSON.parse(await fsp.readFile(p, 'utf8')); }
|
|
52
|
+
catch (err) { if (fallback !== undefined) return fallback; throw err; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function writeJsonAtomic(p, data) {
|
|
56
|
+
await writeTextAtomic(p, `${JSON.stringify(data, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function appendJsonl(p, obj) {
|
|
60
|
+
await ensureDir(path.dirname(p));
|
|
61
|
+
await fsp.appendFile(p, `${JSON.stringify(obj)}\n`, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function appendJsonlBounded(p, obj, maxBytes = 5 * 1024 * 1024) {
|
|
65
|
+
await appendJsonl(p, obj);
|
|
66
|
+
try {
|
|
67
|
+
const st = await fsp.stat(p);
|
|
68
|
+
if (st.size <= maxBytes) return;
|
|
69
|
+
const keep = Math.max(1024, Math.floor(maxBytes / 2));
|
|
70
|
+
const handle = await fsp.open(p, 'r');
|
|
71
|
+
try {
|
|
72
|
+
const start = Math.max(0, st.size - keep);
|
|
73
|
+
const buf = Buffer.alloc(st.size - start);
|
|
74
|
+
await handle.read(buf, 0, buf.length, start);
|
|
75
|
+
const marker = Buffer.from(JSON.stringify({ ts: nowIso(), type: 'log.rotated', kept_tail_bytes: buf.length }) + '\n');
|
|
76
|
+
await writeTextAtomic(p, `${marker.toString('utf8')}${buf.toString('utf8').replace(/^.*?\n/, '')}`);
|
|
77
|
+
} finally {
|
|
78
|
+
await handle.close().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function copyFileIfMissing(src, dest) {
|
|
84
|
+
if (await exists(dest)) return false;
|
|
85
|
+
await ensureDir(path.dirname(dest));
|
|
86
|
+
await fsp.copyFile(src, dest);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function mergeManagedBlock(file, markerName, content) {
|
|
91
|
+
const begin = `<!-- BEGIN ${markerName} -->`;
|
|
92
|
+
const end = `<!-- END ${markerName} -->`;
|
|
93
|
+
const block = `${begin}\n${content.trim()}\n${end}\n`;
|
|
94
|
+
const current = await readText(file, '');
|
|
95
|
+
if (!current.trim()) {
|
|
96
|
+
await writeTextAtomic(file, `${block}\n`);
|
|
97
|
+
return 'created';
|
|
98
|
+
}
|
|
99
|
+
const beginIdx = current.indexOf(begin);
|
|
100
|
+
const endIdx = current.indexOf(end);
|
|
101
|
+
if (beginIdx >= 0 && endIdx >= beginIdx) {
|
|
102
|
+
const afterEnd = endIdx + end.length;
|
|
103
|
+
const next = `${current.slice(0, beginIdx)}${block}${current.slice(afterEnd).replace(/^\n/, '')}`;
|
|
104
|
+
await writeTextAtomic(file, next);
|
|
105
|
+
return 'updated';
|
|
106
|
+
}
|
|
107
|
+
await writeTextAtomic(file, `${current.replace(/\s*$/, '\n\n')}${block}\n`);
|
|
108
|
+
return 'appended';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function packageRoot() {
|
|
112
|
+
return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function cwd() { return process.cwd(); }
|
|
116
|
+
|
|
117
|
+
export async function findUp(start, names) {
|
|
118
|
+
let dir = path.resolve(start);
|
|
119
|
+
const root = path.parse(dir).root;
|
|
120
|
+
while (true) {
|
|
121
|
+
for (const name of names) {
|
|
122
|
+
const candidate = path.join(dir, name);
|
|
123
|
+
if (await exists(candidate)) return candidate;
|
|
124
|
+
}
|
|
125
|
+
if (dir === root) return null;
|
|
126
|
+
dir = path.dirname(dir);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function projectRoot(start = process.cwd()) {
|
|
131
|
+
const resolved = path.resolve(start);
|
|
132
|
+
const sine = await findUp(resolved, ['.sneakoscope', '.dcodex']);
|
|
133
|
+
if (sine) {
|
|
134
|
+
const root = path.dirname(sine);
|
|
135
|
+
if (root !== path.parse(root).root) return root;
|
|
136
|
+
}
|
|
137
|
+
const git = await findUp(resolved, ['.git']);
|
|
138
|
+
if (git) {
|
|
139
|
+
const root = path.dirname(git);
|
|
140
|
+
if (root !== path.parse(root).root) return root;
|
|
141
|
+
}
|
|
142
|
+
return resolved;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function isGitRepo(root = process.cwd()) {
|
|
146
|
+
return exists(path.join(root, '.git'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function rel(root, p) {
|
|
150
|
+
return path.relative(root, p).split(path.sep).join('/');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function listFilesRecursive(dir, opts = {}) {
|
|
154
|
+
const {
|
|
155
|
+
ignore = ['.git', 'node_modules', '.sneakoscope/arenas', '.sneakoscope/tmp'],
|
|
156
|
+
maxFiles = 50000,
|
|
157
|
+
maxDepth = 40
|
|
158
|
+
} = opts;
|
|
159
|
+
const out = [];
|
|
160
|
+
async function walk(d, depth) {
|
|
161
|
+
if (out.length >= maxFiles || depth > maxDepth) return;
|
|
162
|
+
let entries = [];
|
|
163
|
+
try { entries = await fsp.readdir(d, { withFileTypes: true }); } catch { return; }
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
if (out.length >= maxFiles) return;
|
|
166
|
+
const p = path.join(d, e.name);
|
|
167
|
+
const rp = rel(dir, p);
|
|
168
|
+
if (ignore.some((ig) => rp === ig || rp.startsWith(`${ig}/`))) continue;
|
|
169
|
+
if (e.isSymbolicLink?.()) continue;
|
|
170
|
+
if (e.isDirectory()) await walk(p, depth + 1);
|
|
171
|
+
else if (e.isFile()) out.push(p);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await walk(dir, 0);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function which(cmd) {
|
|
179
|
+
const paths = (process.env.PATH || '').split(path.delimiter);
|
|
180
|
+
const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
|
|
181
|
+
for (const p of paths) {
|
|
182
|
+
for (const ext of exts) {
|
|
183
|
+
const candidate = path.join(p, `${cmd}${ext}`);
|
|
184
|
+
if (await exists(candidate)) return candidate;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
class TailBuffer {
|
|
191
|
+
constructor(limitBytes) {
|
|
192
|
+
this.limit = Math.max(1024, limitBytes || DEFAULT_PROCESS_TAIL_BYTES);
|
|
193
|
+
this.parts = [];
|
|
194
|
+
this.bytes = 0;
|
|
195
|
+
this.totalBytes = 0;
|
|
196
|
+
this.truncated = false;
|
|
197
|
+
}
|
|
198
|
+
push(chunk) {
|
|
199
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
200
|
+
this.parts.push(buf);
|
|
201
|
+
this.bytes += buf.length;
|
|
202
|
+
this.totalBytes += buf.length;
|
|
203
|
+
while (this.bytes > this.limit && this.parts.length) {
|
|
204
|
+
const first = this.parts[0];
|
|
205
|
+
const excess = this.bytes - this.limit;
|
|
206
|
+
this.truncated = true;
|
|
207
|
+
if (first.length <= excess) {
|
|
208
|
+
this.parts.shift();
|
|
209
|
+
this.bytes -= first.length;
|
|
210
|
+
} else {
|
|
211
|
+
this.parts[0] = first.subarray(excess);
|
|
212
|
+
this.bytes -= excess;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
text() { return Buffer.concat(this.parts, this.bytes).toString('utf8'); }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function runProcess(command, args, options = {}) {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const tailBytes = options.maxOutputBytes ?? DEFAULT_PROCESS_TAIL_BYTES;
|
|
222
|
+
const stdoutTail = new TailBuffer(tailBytes);
|
|
223
|
+
const stderrTail = new TailBuffer(tailBytes);
|
|
224
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS;
|
|
225
|
+
let killedByTimeout = false;
|
|
226
|
+
let settled = false;
|
|
227
|
+
let stdoutStream = null;
|
|
228
|
+
let stderrStream = null;
|
|
229
|
+
|
|
230
|
+
const child = spawn(command, args, {
|
|
231
|
+
cwd: options.cwd || process.cwd(),
|
|
232
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
233
|
+
shell: false,
|
|
234
|
+
stdio: [options.input !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe']
|
|
235
|
+
});
|
|
236
|
+
if (options.input !== undefined && child.stdin) { child.stdin.end(options.input); }
|
|
237
|
+
|
|
238
|
+
const finish = async (result) => {
|
|
239
|
+
if (settled) return;
|
|
240
|
+
settled = true;
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
try { await stdoutStream?.end?.(); } catch {}
|
|
243
|
+
try { await stderrStream?.end?.(); } catch {}
|
|
244
|
+
resolve(result);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (options.stdoutFile) {
|
|
248
|
+
fs.mkdirSync(path.dirname(options.stdoutFile), { recursive: true });
|
|
249
|
+
stdoutStream = fs.createWriteStream(options.stdoutFile, { flags: 'a' });
|
|
250
|
+
}
|
|
251
|
+
if (options.stderrFile) {
|
|
252
|
+
fs.mkdirSync(path.dirname(options.stderrFile), { recursive: true });
|
|
253
|
+
stderrStream = fs.createWriteStream(options.stderrFile, { flags: 'a' });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
killedByTimeout = true;
|
|
258
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
259
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 1500).unref?.();
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
timer.unref?.();
|
|
262
|
+
|
|
263
|
+
child.stdout.on('data', (d) => {
|
|
264
|
+
stdoutTail.push(d);
|
|
265
|
+
stdoutStream?.write(d);
|
|
266
|
+
options.onStdout?.(d.toString());
|
|
267
|
+
});
|
|
268
|
+
child.stderr.on('data', (d) => {
|
|
269
|
+
stderrTail.push(d);
|
|
270
|
+
stderrStream?.write(d);
|
|
271
|
+
options.onStderr?.(d.toString());
|
|
272
|
+
});
|
|
273
|
+
child.on('error', (err) => finish({
|
|
274
|
+
code: -1,
|
|
275
|
+
stdout: stdoutTail.text(),
|
|
276
|
+
stderr: `${stderrTail.text()}${err.message}`,
|
|
277
|
+
stdoutBytes: stdoutTail.totalBytes,
|
|
278
|
+
stderrBytes: stderrTail.totalBytes,
|
|
279
|
+
truncated: stdoutTail.truncated || stderrTail.truncated,
|
|
280
|
+
timedOut: killedByTimeout
|
|
281
|
+
}));
|
|
282
|
+
child.on('close', (code) => finish({
|
|
283
|
+
code: killedByTimeout ? 124 : code,
|
|
284
|
+
stdout: stdoutTail.text(),
|
|
285
|
+
stderr: stderrTail.text(),
|
|
286
|
+
stdoutBytes: stdoutTail.totalBytes,
|
|
287
|
+
stderrBytes: stderrTail.totalBytes,
|
|
288
|
+
truncated: stdoutTail.truncated || stderrTail.truncated,
|
|
289
|
+
timedOut: killedByTimeout
|
|
290
|
+
}));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function readStdin() {
|
|
295
|
+
return new Promise((resolve) => {
|
|
296
|
+
let data = '';
|
|
297
|
+
process.stdin.setEncoding('utf8');
|
|
298
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
299
|
+
process.stdin.on('end', () => resolve(data));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function tmpdir(prefix = 'sks-') {
|
|
304
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function fileSize(p) {
|
|
308
|
+
try { return (await fsp.stat(p)).size; } catch { return 0; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function dirSize(dir, opts = {}) {
|
|
312
|
+
let total = 0;
|
|
313
|
+
const files = await listFilesRecursive(dir, opts);
|
|
314
|
+
for (const f of files) total += await fileSize(f);
|
|
315
|
+
return total;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function formatBytes(bytes) {
|
|
319
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
320
|
+
let n = Number(bytes) || 0;
|
|
321
|
+
let i = 0;
|
|
322
|
+
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
|
323
|
+
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function rmrf(p) {
|
|
327
|
+
await fsp.rm(p, { recursive: true, force: true });
|
|
328
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { projectRoot, readJson, appendJsonl, readStdin, nowIso, exists } from './fsx.mjs';
|
|
3
|
+
import { containsUserQuestion, looksInteractiveCommand, noQuestionContinuationReason, interactiveCommandReason } from './no-question-guard.mjs';
|
|
4
|
+
import { stateFile, missionDir } from './mission.mjs';
|
|
5
|
+
import { checkDbOperation, dbBlockReason } from './db-safety.mjs';
|
|
6
|
+
|
|
7
|
+
async function loadHookPayload() {
|
|
8
|
+
const raw = await readStdin();
|
|
9
|
+
try { return raw.trim() ? JSON.parse(raw) : {}; } catch { return { raw }; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function loadState(root) {
|
|
13
|
+
return readJson(stateFile(root), {});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRalphRunning(state) {
|
|
17
|
+
return state.mode === 'RALPH' && state.phase === 'RALPH_RUNNING_NO_QUESTIONS';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractLastMessage(payload) {
|
|
21
|
+
return payload.last_assistant_message || payload.assistant_message || payload.message || payload.response || payload.raw || '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractCommand(payload) {
|
|
25
|
+
return payload.command || payload.tool_input?.command || payload.input?.command || payload.tool?.input?.command || '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function hookMain(name) {
|
|
29
|
+
const payload = await loadHookPayload();
|
|
30
|
+
const root = await projectRoot(payload.cwd || process.cwd());
|
|
31
|
+
const state = await loadState(root);
|
|
32
|
+
const ralph = isRalphRunning(state);
|
|
33
|
+
if (name === 'user-prompt-submit') return hookUserPrompt(root, state, payload, ralph);
|
|
34
|
+
if (name === 'pre-tool') return hookPreTool(root, state, payload, ralph);
|
|
35
|
+
if (name === 'post-tool') return hookPostTool(root, state, payload, ralph);
|
|
36
|
+
if (name === 'permission-request') return hookPermission(root, state, payload, ralph);
|
|
37
|
+
if (name === 'stop') return hookStop(root, state, payload, ralph);
|
|
38
|
+
return { continue: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function hookUserPrompt(root, state, payload, ralph) {
|
|
42
|
+
if (!ralph) return { continue: true };
|
|
43
|
+
const id = state.mission_id;
|
|
44
|
+
if (id) await appendJsonl(path.join(missionDir(root, id), 'user_queue.jsonl'), { ts: nowIso(), payload });
|
|
45
|
+
return {
|
|
46
|
+
decision: 'block',
|
|
47
|
+
reason: 'Ralph is running in no-question/no-interruption mode. User prompt has been queued until Ralph completes.'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function hookPreTool(root, state, payload, ralph) {
|
|
52
|
+
const dbDecision = await checkDbOperation(root, state, payload, { duringRalph: ralph });
|
|
53
|
+
if (dbDecision.action === 'block') {
|
|
54
|
+
return { decision: 'block', permissionDecision: 'deny', reason: dbBlockReason(dbDecision) };
|
|
55
|
+
}
|
|
56
|
+
const command = extractCommand(payload);
|
|
57
|
+
if (ralph && looksInteractiveCommand(command)) return { decision: 'block', reason: interactiveCommandReason(command) };
|
|
58
|
+
return { continue: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function hookPostTool(root, state, payload, ralph) {
|
|
62
|
+
const dbDecision = await checkDbOperation(root, state, payload, { duringRalph: ralph });
|
|
63
|
+
if (dbDecision.action === 'block') {
|
|
64
|
+
return { decision: 'block', reason: dbBlockReason(dbDecision) };
|
|
65
|
+
}
|
|
66
|
+
if (!ralph) return { continue: true };
|
|
67
|
+
const failed = payload.exit_code && payload.exit_code !== 0;
|
|
68
|
+
if (failed) {
|
|
69
|
+
return {
|
|
70
|
+
additionalContext: 'Ralph no-question mode is active. Do not ask the user about this tool failure. Apply decision-contract.json fallback ladder, create a fix task, and continue.'
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { continue: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function hookPermission(root, state, payload, ralph) {
|
|
77
|
+
const dbDecision = await checkDbOperation(root, state, payload, { duringRalph: ralph });
|
|
78
|
+
if (dbDecision.action === 'block') {
|
|
79
|
+
return { decision: 'deny', permissionDecision: 'deny', reason: dbBlockReason(dbDecision) };
|
|
80
|
+
}
|
|
81
|
+
if (!ralph) return { continue: true };
|
|
82
|
+
return {
|
|
83
|
+
decision: 'deny',
|
|
84
|
+
permissionDecision: 'deny',
|
|
85
|
+
reason: 'Ralph no-question mode forbids mid-loop approval prompts. Choose a non-approval safe alternative using decision-contract.json.'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function hookStop(root, state, payload, ralph) {
|
|
90
|
+
if (!ralph) return { continue: true };
|
|
91
|
+
const id = state.mission_id;
|
|
92
|
+
const last = extractLastMessage(payload);
|
|
93
|
+
if (containsUserQuestion(last)) return { decision: 'block', reason: noQuestionContinuationReason() };
|
|
94
|
+
if (id) {
|
|
95
|
+
const gatePath = path.join(missionDir(root, id), 'done-gate.json');
|
|
96
|
+
if (await exists(gatePath)) {
|
|
97
|
+
const gate = await readJson(gatePath, {});
|
|
98
|
+
if (gate.passed === true) return { continue: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
decision: 'block',
|
|
103
|
+
reason: 'Ralph is not done. Continue autonomously. Run verifier, fix failing checks, sync wiki/visual artifacts if needed, and write done-gate.json. Do not ask the user.'
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function emitHook(name) {
|
|
108
|
+
const result = await hookMain(name);
|
|
109
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { exists, readJson, writeJsonAtomic, nowIso, fileSize } from './fsx.mjs';
|
|
3
|
+
|
|
4
|
+
export async function evaluateDoneGate(root, missionId) {
|
|
5
|
+
const dir = path.join(root, '.sneakoscope', 'missions', missionId);
|
|
6
|
+
const gatePath = path.join(dir, 'done-gate.json');
|
|
7
|
+
const contractPath = path.join(dir, 'decision-contract.json');
|
|
8
|
+
const contractExists = await exists(contractPath);
|
|
9
|
+
const gate = await readJson(gatePath, {});
|
|
10
|
+
const reasons = [];
|
|
11
|
+
if (!contractExists) reasons.push('decision_contract_missing');
|
|
12
|
+
if (gate.unsupported_critical_claims && gate.unsupported_critical_claims > 0) reasons.push('unsupported_critical_claims_present');
|
|
13
|
+
if (gate.database_safety_violation === true || gate.db_safety_violation === true) reasons.push('database_safety_violation_present');
|
|
14
|
+
if (gate.database_destructive_operation_attempted === true) reasons.push('destructive_database_operation_attempted');
|
|
15
|
+
if (gate.visual_drift === 'high') reasons.push('visual_drift_high');
|
|
16
|
+
if (gate.wiki_drift === 'high') reasons.push('wiki_drift_high');
|
|
17
|
+
if (gate.tests_required === true && !gate.test_evidence_present) reasons.push('test_evidence_missing');
|
|
18
|
+
const dbSafetyLog = path.join(dir, 'db-safety.jsonl');
|
|
19
|
+
if ((await exists(dbSafetyLog)) && (await fileSize(dbSafetyLog)) > 0 && gate.database_safety_reviewed !== true) reasons.push('database_safety_log_requires_review');
|
|
20
|
+
const passed = gate.passed === true && reasons.length === 0;
|
|
21
|
+
const result = { checked_at: nowIso(), passed, reasons, gate };
|
|
22
|
+
await writeJsonAtomic(path.join(dir, 'done-gate.evaluated.json'), result);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultDoneGate() {
|
|
27
|
+
return {
|
|
28
|
+
passed: false,
|
|
29
|
+
unsupported_critical_claims: 0,
|
|
30
|
+
database_safety_violation: false,
|
|
31
|
+
database_destructive_operation_attempted: false,
|
|
32
|
+
database_safety_reviewed: true,
|
|
33
|
+
visual_drift: 'unknown',
|
|
34
|
+
wiki_drift: 'unknown',
|
|
35
|
+
tests_required: true,
|
|
36
|
+
test_evidence_present: false,
|
|
37
|
+
notes: []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ensureDir, writeJsonAtomic, writeTextAtomic, mergeManagedBlock, nowIso, PACKAGE_VERSION, exists } from './fsx.mjs';
|
|
3
|
+
import { DEFAULT_RETENTION_POLICY } from './retention.mjs';
|
|
4
|
+
import { DEFAULT_DB_SAFETY_POLICY } from './db-safety.mjs';
|
|
5
|
+
|
|
6
|
+
const AGENTS_BLOCK = `
|
|
7
|
+
# Sneakoscope Codex Managed Rules
|
|
8
|
+
|
|
9
|
+
This repository uses Sneakoscope Codex.
|
|
10
|
+
|
|
11
|
+
## Ralph No-Question Rule
|
|
12
|
+
|
|
13
|
+
Ralph may ask questions only during prepare. After decision-contract.json is sealed and Ralph run starts, the assistant must not ask the user questions, request confirmation, or present choices. Resolve using the decision ladder.
|
|
14
|
+
|
|
15
|
+
## Performance and Retention
|
|
16
|
+
|
|
17
|
+
Sneakoscope Codex keeps runtime state bounded. Do not write large raw logs into prompts. Store raw outputs in files, keep only tails/summaries in JSON, and allow sks gc to remove old arenas, temp files, and stale mission logs.
|
|
18
|
+
|
|
19
|
+
## Source Priority
|
|
20
|
+
|
|
21
|
+
1. Current code, tests, config
|
|
22
|
+
2. decision-contract.json
|
|
23
|
+
3. vgraph.json
|
|
24
|
+
4. beta.json
|
|
25
|
+
5. LLM Wiki
|
|
26
|
+
6. visual parse
|
|
27
|
+
7. model knowledge only if explicitly allowed
|
|
28
|
+
|
|
29
|
+
## Database Safety
|
|
30
|
+
|
|
31
|
+
Sneakoscope Codex treats database access as high risk. Destructive database operations are never allowed: DROP, TRUNCATE, mass DELETE/UPDATE, reset, push, repair, project deletion, branch reset/merge/delete, RLS disabling, broad grants/revokes, and any operation that could erase or overwrite data. Supabase/Postgres MCP should be read-only and project-scoped by default. Live database writes must not be performed through direct execute_sql; schema changes must be migration-file based and allowed only for local or preview/branch environments by the sealed contract.
|
|
32
|
+
|
|
33
|
+
## Done Means
|
|
34
|
+
|
|
35
|
+
A task is not done until relevant tests are run or justified, unsupported critical claims are zero, database safety violations are zero, visual/wiki drift is low or explicitly accepted, and final output includes evidence.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export async function initProject(root, opts = {}) {
|
|
39
|
+
const created = [];
|
|
40
|
+
const sine = path.join(root, '.sneakoscope');
|
|
41
|
+
const dirs = [
|
|
42
|
+
'.sneakoscope/state', '.sneakoscope/missions', '.sneakoscope/db', '.sneakoscope/bus', '.sneakoscope/hproof', '.sneakoscope/db', '.sneakoscope/memory/q0_raw', '.sneakoscope/memory/q1_evidence', '.sneakoscope/memory/q2_facts', '.sneakoscope/memory/q3_tags', '.sneakoscope/memory/q4_bits', '.sneakoscope/gx/cartridges', '.sneakoscope/model/fingerprints', '.sneakoscope/genome/candidates', '.sneakoscope/trajectories/raw', '.sneakoscope/locks', '.sneakoscope/tmp', '.sneakoscope/arenas', '.sneakoscope/reports', '.codex', '.agents/skills'
|
|
43
|
+
];
|
|
44
|
+
for (const d of dirs) await ensureDir(path.join(root, d));
|
|
45
|
+
|
|
46
|
+
await writeJsonAtomic(path.join(sine, 'manifest.json'), {
|
|
47
|
+
package: 'sneakoscope',
|
|
48
|
+
version: PACKAGE_VERSION,
|
|
49
|
+
initialized_at: nowIso(),
|
|
50
|
+
no_external_tools: true,
|
|
51
|
+
codex_required: true,
|
|
52
|
+
native_runtime_dependencies: 0,
|
|
53
|
+
database_safety: 'destructive_db_operations_denied_always'
|
|
54
|
+
});
|
|
55
|
+
created.push('.sneakoscope/manifest.json');
|
|
56
|
+
|
|
57
|
+
const dbSafetyPath = path.join(sine, 'db-safety.json');
|
|
58
|
+
if (!(await exists(dbSafetyPath)) || opts.force) {
|
|
59
|
+
await writeJsonAtomic(dbSafetyPath, DEFAULT_DB_SAFETY_POLICY);
|
|
60
|
+
created.push('.sneakoscope/db-safety.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const policyPath = path.join(sine, 'policy.json');
|
|
64
|
+
if (!(await exists(policyPath)) || opts.force) {
|
|
65
|
+
await writeJsonAtomic(policyPath, {
|
|
66
|
+
schema_version: 1,
|
|
67
|
+
retention: DEFAULT_RETENTION_POLICY,
|
|
68
|
+
database_safety: DEFAULT_DB_SAFETY_POLICY,
|
|
69
|
+
performance: {
|
|
70
|
+
max_parallel_sessions: 2,
|
|
71
|
+
process_tail_bytes: 262144,
|
|
72
|
+
codex_timeout_ms: 1800000,
|
|
73
|
+
prefer_streaming_logs: true
|
|
74
|
+
},
|
|
75
|
+
package: {
|
|
76
|
+
zero_runtime_dependencies: true,
|
|
77
|
+
rust_default_runtime: false,
|
|
78
|
+
rust_reason: 'Native Rust binaries would increase package size and break npm install portability. Optional Rust source is provided for future acceleration, but default runtime is dependency-free Node.js.'
|
|
79
|
+
},
|
|
80
|
+
database: {
|
|
81
|
+
guardian_enabled: true,
|
|
82
|
+
live_database_mode: 'read_only',
|
|
83
|
+
destructive_operations_allowed: false,
|
|
84
|
+
mcp_write_tools_allowed: false
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
created.push('.sneakoscope/policy.json');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const currentState = path.join(sine, 'state', 'current.json');
|
|
91
|
+
if (!(await exists(currentState)) || opts.force) {
|
|
92
|
+
await writeJsonAtomic(currentState, { mode: 'IDLE', phase: 'IDLE', updated_at: nowIso() });
|
|
93
|
+
created.push('.sneakoscope/state/current.json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await mergeManagedBlock(path.join(root, 'AGENTS.md'), 'Sneakoscope Codex GX MANAGED BLOCK', AGENTS_BLOCK);
|
|
97
|
+
created.push('AGENTS.md managed block');
|
|
98
|
+
|
|
99
|
+
await writeTextAtomic(path.join(root, '.codex', 'config.toml'), `[features]\ncodex_hooks = true\n\n[profiles.sks-ralph]\nmodel = "gpt-5.5"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\nmodel_reasoning_effort = "high"\n\n[profiles.sks-default]\nmodel = "gpt-5.5"\napproval_policy = "on-request"\nsandbox_mode = "workspace-write"\nmodel_reasoning_effort = "medium"\n`);
|
|
100
|
+
created.push('.codex/config.toml');
|
|
101
|
+
|
|
102
|
+
await writeJsonAtomic(path.join(root, '.codex', 'hooks.json'), {
|
|
103
|
+
hooks: {
|
|
104
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'sks hook user-prompt-submit' }] }],
|
|
105
|
+
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'sks hook pre-tool' }] }],
|
|
106
|
+
PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'sks hook post-tool' }] }],
|
|
107
|
+
PermissionRequest: [{ matcher: '*', hooks: [{ type: 'command', command: 'sks hook permission-request' }] }],
|
|
108
|
+
Stop: [{ hooks: [{ type: 'command', command: 'sks hook stop' }] }]
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
created.push('.codex/hooks.json');
|
|
112
|
+
|
|
113
|
+
await installSkills(root);
|
|
114
|
+
created.push('.agents/skills/*');
|
|
115
|
+
return { created };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function installSkills(root) {
|
|
119
|
+
const skills = {
|
|
120
|
+
'ralph-supervisor': `---\nname: ralph-supervisor\ndescription: Run the Ralph no-question loop after a decision contract is sealed.\n---\n\nYou are the Ralph Supervisor.\n\nRules:\n- Never ask the user during Ralph run.\n- Use decision-contract.json and the decision ladder.\n- Continue until done-gate.json passes or safe scope is completed with explicit limitation.\n- Keep outputs bounded. Write raw logs to files and summarize only tails.\n- Database destructive operations are never allowed.\n- Write progress to .sneakoscope mission files.\n`,
|
|
121
|
+
'ralph-resolver': `---\nname: ralph-resolver\ndescription: Resolve newly discovered ambiguity during Ralph using the sealed decision ladder, without asking the user.\n---\n\nResolve ambiguity in this order: seed contract, explicit answers, approved defaults, AGENTS.md, current code/tests, smallest reversible change, defer optional scope. Never ask the user. If database risk is involved, prefer read-only, no-op, local-only migration file, or safe limitation; never run destructive SQL.\n`,
|
|
122
|
+
'hproof-claim-ledger': `---\nname: hproof-claim-ledger\ndescription: Extract atomic claims and classify support status.\n---\n\nEvery factual statement must become an atomic claim. Unsupported critical claims cannot be used for implementation or final answer. Database claims require DB safety evidence.\n`,
|
|
123
|
+
'hproof-evidence-bind': `---\nname: hproof-evidence-bind\ndescription: Bind claims to code, tests, decision contract, vgraph, beta, wiki, or visual parse evidence.\n---\n\nEvidence priority: current code/tests, decision-contract.json, vgraph.json, beta.json, wiki, visual parse, user prompt. Database claims must respect .sneakoscope/db-safety.json.\n`,
|
|
124
|
+
'db-safety-guard': `---\nname: db-safety-guard\ndescription: Enforce Sneakoscope Codex database safety before using SQL, Supabase MCP, Postgres, Prisma, Drizzle, Knex, or migration commands.\n---\n\nRules:\n- Never run DROP, TRUNCATE, mass DELETE/UPDATE, db reset, db push, project deletion, branch reset/merge/delete, or RLS-disabling operations.\n- Supabase MCP must be read-only and project-scoped by default.\n- Live writes through execute_sql are blocked; use migration files and only local/preview branches if explicitly allowed.\n- Production writes are forbidden.\n- If unsure, read-only only.\n`,
|
|
125
|
+
'gx-visual-generate': `---\nname: gx-visual-generate\ndescription: Generate a visual sheet using GPT Image 2 from vgraph.json and beta.json.\n---\n\nUse built-in GPT Image 2 / $imagegen only. Do not use external diagram tools. vgraph.json is source of truth.\n`,
|
|
126
|
+
'gx-visual-read': `---\nname: gx-visual-read\ndescription: Read a Sneakoscope Codex visual sheet and produce parse.json.\n---\n\nExtract nodes, edges, invariants, tests, risks, and uncertainties. Do not infer hidden nodes.\n`,
|
|
127
|
+
'gx-visual-validate': `---\nname: gx-visual-validate\ndescription: Validate visual parse against vgraph.json and beta.json.\n---\n\nIf critical nodes, edges, or invariants are missing, mark validation failed.\n`,
|
|
128
|
+
'turbo-context-pack': `---\nname: turbo-context-pack\ndescription: Build ultra-low-token context packet with Q4 bits, Q3 tags, top-K claims, and minimal evidence.\n---\n\nDefault to Q4/Q3 only. Add Q2 or Q1 only when needed for support or verification.\n`
|
|
129
|
+
};
|
|
130
|
+
for (const [name, content] of Object.entries(skills)) {
|
|
131
|
+
const dir = path.join(root, '.agents', 'skills', name);
|
|
132
|
+
await ensureDir(dir);
|
|
133
|
+
await writeTextAtomic(path.join(dir, 'SKILL.md'), `${content.trim()}\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ensureDir, nowIso, randomId, writeJsonAtomic, appendJsonl, readJson, exists } from './fsx.mjs';
|
|
3
|
+
|
|
4
|
+
export function missionId() {
|
|
5
|
+
const d = new Date();
|
|
6
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
7
|
+
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
8
|
+
return `M-${stamp}-${randomId(4)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sineDir(root) { return path.join(root, '.sneakoscope'); }
|
|
12
|
+
export function missionsDir(root) { return path.join(sineDir(root), 'missions'); }
|
|
13
|
+
export function missionDir(root, id) { return path.join(missionsDir(root), id); }
|
|
14
|
+
export function stateFile(root) { return path.join(sineDir(root), 'state', 'current.json'); }
|
|
15
|
+
|
|
16
|
+
export async function createMission(root, { mode, prompt }) {
|
|
17
|
+
const id = missionId();
|
|
18
|
+
const dir = missionDir(root, id);
|
|
19
|
+
await ensureDir(dir);
|
|
20
|
+
await ensureDir(path.join(dir, 'bus'));
|
|
21
|
+
await ensureDir(path.join(dir, 'ralph'));
|
|
22
|
+
await ensureDir(path.join(dir, 'sessions'));
|
|
23
|
+
const mission = {
|
|
24
|
+
id,
|
|
25
|
+
mode,
|
|
26
|
+
prompt,
|
|
27
|
+
created_at: nowIso(),
|
|
28
|
+
phase: mode === 'ralph' ? 'RALPH_PREPARE' : 'PREPARE',
|
|
29
|
+
questions_allowed: true,
|
|
30
|
+
implementation_allowed: false
|
|
31
|
+
};
|
|
32
|
+
await writeJsonAtomic(path.join(dir, 'mission.json'), mission);
|
|
33
|
+
await appendJsonl(path.join(dir, 'events.jsonl'), { ts: nowIso(), type: 'mission.created', mission: id, mode, prompt });
|
|
34
|
+
await writeJsonAtomic(stateFile(root), { mission_id: id, mode: mode.toUpperCase(), phase: mission.phase, updated_at: nowIso() });
|
|
35
|
+
return { id, dir, mission };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadMission(root, id) {
|
|
39
|
+
const dir = missionDir(root, id);
|
|
40
|
+
const mission = await readJson(path.join(dir, 'mission.json'));
|
|
41
|
+
return { id, dir, mission };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function findLatestMission(root) {
|
|
45
|
+
const dir = missionsDir(root);
|
|
46
|
+
if (!(await exists(dir))) return null;
|
|
47
|
+
const fs = await import('node:fs/promises');
|
|
48
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
49
|
+
const ids = entries.filter((e) => e.isDirectory() && e.name.startsWith('M-')).map((e) => e.name).sort();
|
|
50
|
+
return ids.at(-1) || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function setCurrent(root, patch) {
|
|
54
|
+
const current = await readJson(stateFile(root), {});
|
|
55
|
+
await writeJsonAtomic(stateFile(root), { ...current, ...patch, updated_at: nowIso() });
|
|
56
|
+
}
|