svamp-cli 0.2.223-canary.2 → 0.2.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/{agentCommands-MGRUIvWB.mjs → agentCommands-BignK4hn.mjs} +5 -5
  2. package/dist/{auth-FvyDIAch.mjs → auth-tP-DHfsI.mjs} +1 -1
  3. package/dist/cli.mjs +61 -61
  4. package/dist/{commands-Cmjb9Je1.mjs → commands-1IupY9qh.mjs} +6 -6
  5. package/dist/{commands-Dm3EQICq.mjs → commands-Bpq8_9OU.mjs} +2 -2
  6. package/dist/{commands-BhFr0kUL.mjs → commands-C1VIwqOH.mjs} +1 -1
  7. package/dist/{commands-_RXnsA1v.mjs → commands-CA8AMvq-.mjs} +1 -1
  8. package/dist/{commands-BiIkCdGB.mjs → commands-DMoXgLUh.mjs} +1 -1
  9. package/dist/{commands-BuecUAk8.mjs → commands-DqvzIDMK.mjs} +13 -1
  10. package/dist/{commands-nlra_-D0.mjs → commands-ez1xdcH5.mjs} +2 -2
  11. package/dist/{fleet-Bi3KIev7.mjs → fleet-B6Rh4mje.mjs} +1 -1
  12. package/dist/{frpc-BpQyeWvI.mjs → frpc-vpwKk0kV.mjs} +1 -1
  13. package/dist/{headlessCli-Be0bYOUB.mjs → headlessCli-C51w8i7T.mjs} +2 -2
  14. package/dist/index.mjs +1 -1
  15. package/dist/package-CzzAMi8h.mjs +63 -0
  16. package/dist/{rpc-CRZWx5YY.mjs → rpc-CcQQrbBO.mjs} +1 -1
  17. package/dist/{rpc-WS0yDVqB.mjs → rpc-D7QFTk_g.mjs} +1 -1
  18. package/dist/{run-8_mnLg6R.mjs → run-BZrPFG_w.mjs} +1 -1
  19. package/dist/{run-DVdqRhtp.mjs → run-C-9ZtEi4.mjs} +77 -33
  20. package/dist/{scheduler-Dx-QrQfP.mjs → scheduler-jlX6Mkxf.mjs} +1 -1
  21. package/dist/{serveCommands-CI2P6buK.mjs → serveCommands-Dhrv-HAD.mjs} +5 -5
  22. package/dist/{serveManager-B-keYGE9.mjs → serveManager-DtBN8E9D.mjs} +2 -2
  23. package/dist/{sideband-BdYb3ezu.mjs → sideband-DxH9bMhv.mjs} +1 -1
  24. package/package.json +3 -3
  25. package/bin/skills/loop/IMPLEMENTATION_PROGRESS.md +0 -49
  26. package/bin/skills/loop/SKILL.md +0 -100
  27. package/bin/skills/loop/bin/channel-core.mjs +0 -161
  28. package/bin/skills/loop/bin/channel-server.mjs +0 -151
  29. package/bin/skills/loop/bin/inject-loop.mjs +0 -90
  30. package/bin/skills/loop/bin/loop-init.mjs +0 -194
  31. package/bin/skills/loop/bin/loop-status.mjs +0 -51
  32. package/bin/skills/loop/bin/precompact.mjs +0 -30
  33. package/bin/skills/loop/bin/routine-core.mjs +0 -126
  34. package/bin/skills/loop/bin/state-fp.mjs +0 -111
  35. package/bin/skills/loop/bin/stop-gate.mjs +0 -349
  36. package/bin/skills/loop/test/test-channel-core.mjs +0 -86
  37. package/bin/skills/loop/test/test-loop-gate.mjs +0 -489
  38. package/bin/skills/loop/test/test-routine-core.mjs +0 -54
  39. package/dist/package-CwGmpH_-.mjs +0 -63
@@ -1,194 +0,0 @@
1
- #!/usr/bin/env node
2
- // loop-init.mjs — project the loop config into Claude Code's native files.
3
- // Generates, in a target project dir, a SESSION-SCOPED loop home:
4
- // .svamp/<sessionId>/loop/{LOOP.md,loop.config.json,loop-state.json,bin/*}
5
- // .claude/settings.json hooks (Stop gate + LOOP.md injection),
6
- // and (optional) .claude/agents/loop-evaluator.md.
7
- // Session-scoping keeps sibling sessions in the same working dir from colliding.
8
- // Usage:
9
- // node loop-init.mjs <dir> --task "..." [--criteria "..."] [--oracle "cmd"]
10
- // [--max N] [--evaluator on|off] [--model NAME] [--loop-file LOOP.md]
11
- import { mkdirSync, writeFileSync, copyFileSync, readFileSync, existsSync, chmodSync, rmSync } from 'node:fs';
12
- import { dirname, join, resolve } from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
-
15
- const HERE = dirname(fileURLToPath(import.meta.url));
16
-
17
- function parseArgs(argv) {
18
- const a = { _: [] };
19
- for (let i = 0; i < argv.length; i++) {
20
- const t = argv[i];
21
- if (t.startsWith('--')) { a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; }
22
- else a._.push(t);
23
- }
24
- return a;
25
- }
26
- const args = parseArgs(process.argv.slice(2));
27
- const dir = resolve(args._[0] || process.cwd());
28
- const loopFile = args['loop-file'] || 'LOOP.md';
29
- const oracle = typeof args.oracle === 'string' ? args.oracle : null;
30
- // #0156: default max_iterations to 10000 — a high but FINITE default suitable for long-running tasks,
31
- // configurable both here (--max N) and AFTER the loop starts (the limit-only modify-in-place path:
32
- // `svamp session loop <id> --max N` / the Issues-page loop banner). An explicit --max overrides it;
33
- // clearing it (∞, governed by a token-rate budget) is still available via the UI.
34
- const max = args.max ? Number(args.max) : 10000;
35
- const evaluatorOn = args.evaluator !== 'off';
36
- const model = typeof args.model === 'string' ? args.model : null;
37
- const taskProvided = typeof args.task === 'string';
38
- const task = taskProvided ? args.task : '(describe the task here)';
39
- const criteria = typeof args.criteria === 'string' ? args.criteria : null;
40
- // Owning session id, stamped into loop-state.json so the daemon can scope
41
- // "loop active" (auto-approve / AskUserQuestion auto-dismiss / loop resume) to the
42
- // session that started the loop instead of every session sharing this directory.
43
- const sessionId = typeof args.session === 'string' ? args.session
44
- : (typeof process.env.SVAMP_SESSION_ID === 'string' && process.env.SVAMP_SESSION_ID) ? process.env.SVAMP_SESSION_ID
45
- : null;
46
- // #0166: hooks-only mode — install the gate scripts + MERGE the hooks into settings.json, but DON'T
47
- // create a loop (no config/state/LOOP.md/evaluator). Run at SESSION SPAWN so the Stop-gate hook is
48
- // loaded before any loop exists; the hooks no-op while no loop is active (stop-gate: `!cfg` → allow).
49
- // This is what makes loop start/stop/config-update SEAMLESS — the daemon never restarts Claude to load
50
- // the hook. Idempotent + safe to call on every spawn (it does NOT touch an existing loop's state).
51
- const hooksOnly = args['hooks-only'] === true || args['hooks-only'] === 'true';
52
-
53
- // Session-scoped loop home so sessions sharing a working dir never collide:
54
- // <dir>/.svamp/<sessionId>/loop/ (falls back to <dir>/.svamp/loop/ with no session).
55
- // The Claude Code hook DEFINITIONS still live in <dir>/.claude/settings.json (that is
56
- // Claude Code's own config), but the loop's state + memory now live under .svamp.
57
- const loopDir = sessionId ? join(dir, '.svamp', sessionId, 'loop') : join(dir, '.svamp', 'loop');
58
- const binDir = join(loopDir, 'bin');
59
- mkdirSync(binDir, { recursive: true });
60
- mkdirSync(join(dir, '.claude', 'agents'), { recursive: true });
61
-
62
- // Re-arm cleanly: a NEW loop must NOT inherit the PREVIOUS loop's evaluator verdict or
63
- // history. A verdict is task+instance specific, but its freshness fingerprint only covers
64
- // the work tree (state-fp excludes LOOP.md + .svamp/). So a stale {verdict:done, state_fp}
65
- // left by a finished loop would FALSE-CLOSE a fresh loop at iteration 0 whenever the tree
66
- // is unchanged between loops. Always clear them so a new loop requires its own fresh verdict.
67
- // #0166: hooks-only NEVER re-arms — it must not disturb an existing loop's verdict/history/state.
68
- if (!hooksOnly) {
69
- rmSync(join(loopDir, 'evaluator-verdict.json'), { force: true });
70
- rmSync(join(loopDir, 'history.jsonl'), { force: true });
71
- }
72
-
73
- // 1. Copy hook scripts so the project is self-contained.
74
- for (const f of ['state-fp.mjs', 'stop-gate.mjs', 'inject-loop.mjs', 'loop-status.mjs', 'precompact.mjs']) {
75
- const dest = join(binDir, f);
76
- copyFileSync(join(HERE, f), dest);
77
- try { chmodSync(dest, 0o755); } catch {}
78
- }
79
-
80
- // #0166: steps 2-4 CREATE/refresh the loop — skipped in hooks-only mode (the spawn pre-install only
81
- // installs the dormant gate). The copy-scripts (1) + hooks (5) below always run.
82
- if (!hooksOnly) {
83
- // 2. loop.config.json
84
- const config = {
85
- loop_file: loopFile,
86
- // The repo/working root — copied hook scripts (which live under .svamp/<sid>/loop/bin
87
- // and resolve their own dir relatively) read this to run the oracle + fingerprint the
88
- // work product, since their depth no longer encodes the project root.
89
- project_dir: dir,
90
- // The success contract — the "until" the loop gate runs against. Read by the daemon
91
- // to populate the loop verdict event (legacy wire type: supervision:verdict).
92
- ...(criteria ? { criteria: criteria.trim() } : {}),
93
- oracle: oracle ? { command: oracle, timeout_sec: 600 } : null,
94
- evaluator: { enabled: evaluatorOn, model },
95
- max_iterations: max,
96
- budget: (args['max-runtime'] || args['max-tokens'] || args['max-tokens-per-hour']) ? {
97
- ...(args['max-runtime'] ? { max_runtime_sec: Number(args['max-runtime']) } : {}),
98
- ...(args['max-tokens'] ? { max_tokens: Number(args['max-tokens']) } : {}),
99
- // #0156: a ROLLING token-rate cap (tokens per the last hour) — the primary limiter for a
100
- // long-running loop. The gate sums only the last hour's transcript usage and stalls if over.
101
- ...(args['max-tokens-per-hour'] ? { max_tokens_per_hour: Number(args['max-tokens-per-hour']) } : {}),
102
- } : undefined,
103
- };
104
- writeFileSync(join(loopDir, 'loop.config.json'), JSON.stringify(config, null, 2));
105
-
106
- // 3. loop-state.json (machine state, daemon/gate-owned)
107
- writeFileSync(join(loopDir, 'loop-state.json'), JSON.stringify({
108
- active: true, iteration: 0, phase: 'running', started_at: new Date().toISOString(),
109
- ...(sessionId ? { session_id: sessionId } : {}),
110
- }, null, 2));
111
-
112
- // 4. LOOP.md (agent + human editable) — lives inside the session-scoped loop dir
113
- // so two sessions in one working dir keep separate memory. Write it when a task is
114
- // provided (a fresh loop-start must NOT inherit a previous loop's task left over in the
115
- // same session dir), or when none exists yet. Only a hand-authored LOOP.md with NO
116
- // --task passed is preserved (standalone "I pre-wrote the task" case).
117
- const loopPath = join(loopDir, loopFile);
118
- if (taskProvided || !existsSync(loopPath)) {
119
- writeFileSync(loopPath, `# Loop Task
120
-
121
- ## Task
122
- ${task}
123
-
124
- ## Success criteria
125
- ${criteria
126
- ? criteria.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (l.startsWith('-') ? l : `- ${l}`)).join('\n')
127
- : '- (define objective success criteria)'}
128
- ${oracle ? `- The oracle passes: \`${oracle}\`` : ''}
129
- - An independent evaluator confirms the work is genuinely complete.
130
-
131
- ## Plan
132
- - (the agent fills this in)
133
-
134
- ## Progress
135
- - (the agent appends iteration notes here; this is durable memory)
136
- `);
137
- }
138
- } // end if(!hooksOnly): loop creation (config/state/LOOP.md)
139
-
140
- // 5. .claude/settings.json hooks — ALWAYS install/refresh the gate so it's loaded at the next Claude
141
- // start (pre-installed at spawn via hooks-only, so loop start/stop/config never needs a restart).
142
- // #0166: MERGE, don't clobber — preserve any user-defined hooks for these events; only replace OUR
143
- // own prior entry (matched by the loop script name) so re-running can't duplicate it.
144
- const settingsPath = join(dir, '.claude', 'settings.json');
145
- let settings = {};
146
- if (existsSync(settingsPath)) { try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {} }
147
- const node = process.execPath;
148
- const cmd = (script) => `"${node}" "${join(binDir, script)}"`;
149
- settings.hooks = settings.hooks || {};
150
- const mergeHook = (event, script) => {
151
- const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
152
- // Drop any prior group that points at one of OUR loop scripts (so we refresh, never duplicate),
153
- // keeping all user-defined groups for this event intact.
154
- const others = existing.filter((g) => !(Array.isArray(g?.hooks) && g.hooks.some(
155
- (h) => typeof h?.command === 'string' && /loop[\\/](bin[\\/])?(inject-loop|stop-gate|precompact)\.mjs/.test(h.command))));
156
- settings.hooks[event] = [...others, { hooks: [{ type: 'command', command: cmd(script) }] }];
157
- };
158
- mergeHook('SessionStart', 'inject-loop.mjs');
159
- mergeHook('UserPromptSubmit', 'inject-loop.mjs');
160
- mergeHook('Stop', 'stop-gate.mjs');
161
- mergeHook('PreCompact', 'precompact.mjs');
162
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
163
-
164
- // 6. Evaluator agent (materialized) — optional (skipped in hooks-only: no loop yet)
165
- if (!hooksOnly && evaluatorOn) {
166
- const fm = ['---', 'name: loop-evaluator',
167
- 'description: Skeptical independent reviewer that decides if a loop task is genuinely complete.',
168
- 'tools: Read, Bash, Grep, Glob', ...(model ? [`model: ${model}`] : []), '---', ''].join('\n');
169
- writeFileSync(join(dir, '.claude', 'agents', 'loop-evaluator.md'), fm +
170
- `You are an INDEPENDENT, skeptical reviewer. You did NOT write the code under review.
171
-
172
- You are given: the task/goal (from LOOP.md), the current diff, and the oracle output.
173
- Decide whether the work is GENUINELY and COMPLETELY done — default to "continue" on any doubt.
174
-
175
- Check:
176
- - Does it actually satisfy every success criterion in LOOP.md (not just look plausible)?
177
- - Does the oracle genuinely pass, and does it actually cover the requirement?
178
- - Edge cases, stubbed/faked functionality, regressions.
179
- - If this loop works an ISSUE BACKLOG: is EACH issue closed this round genuinely resolved by the real change? A green oracle only means "no open issues", not that each closed issue actually works — reject "done" if any was closed without real resolution.
180
- - HOLISTIC, from the USER's perspective: re-read the ORIGINAL request behind each closed issue (not just its triaged title) and the OVERALL project goal, and confirm we delivered what the user actually wanted end-to-end — INCLUDING that the change was built / published / deployed wherever the request implied it, not merely committed. Reject "done" if the project goal isn't genuinely met from the user's point of view, even when no issues remain open.
181
-
182
- Return ONLY a JSON object:
183
- {"verdict":"done"|"continue","reason":"<concise>","guidance":"<what to fix if continue>"}
184
- Be strict. A false "done" is far worse than one more iteration.
185
- `);
186
- }
187
-
188
- console.log(`✅ loop initialised in ${dir}
189
- loop dir : ${loopDir}
190
- task file : ${join(loopDir, loopFile)}
191
- oracle : ${oracle || '(none)'}
192
- evaluator : ${evaluatorOn ? 'on' + (model ? ` (${model})` : '') : 'off'}
193
- max iters : ${max}
194
- hooks : SessionStart, UserPromptSubmit, Stop (.claude/settings.json)`);
@@ -1,51 +0,0 @@
1
- #!/usr/bin/env node
2
- // loop-status.mjs — show a loop's current state + per-iteration history timeline.
3
- // Usage: node loop-status.mjs [project-dir] [--session <id>] [--json] [-n <count>]
4
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
5
- import { join, resolve } from 'node:path';
6
-
7
- const args = process.argv.slice(2);
8
- const json = args.includes('--json');
9
- const nIdx = args.indexOf('-n');
10
- const limit = nIdx !== -1 ? Number(args[nIdx + 1]) : 20;
11
- const sIdx = args.indexOf('--session');
12
- const sessionId = sIdx !== -1 ? args[sIdx + 1] : (process.env.SVAMP_SESSION_ID || null);
13
- const dir = resolve(args.find((a) => !a.startsWith('-') && a !== String(limit) && a !== sessionId) || process.cwd());
14
-
15
- // Find the loop home: explicit session → .svamp/<sid>/loop; else any session-scoped
16
- // loop that has state; else .svamp/loop; else legacy .claude/loop.
17
- function pickLoopDir(root, sid) {
18
- const cands = [];
19
- if (sid) cands.push(join(root, '.svamp', sid, 'loop'));
20
- try { for (const e of readdirSync(join(root, '.svamp'), { withFileTypes: true })) if (e.isDirectory() && e.name !== sid) cands.push(join(root, '.svamp', e.name, 'loop')); } catch {}
21
- cands.push(join(root, '.svamp', 'loop'), join(root, '.claude', 'loop'));
22
- for (const c of cands) if (existsSync(join(c, 'loop-state.json'))) return c;
23
- return sid ? join(root, '.svamp', sid, 'loop') : cands[cands.length - 1];
24
- }
25
- const LOOP_DIR = pickLoopDir(dir, sessionId);
26
-
27
- const readJSON = (p, f) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return f; } };
28
- const state = readJSON(join(LOOP_DIR, 'loop.config.json'), null)
29
- ? readJSON(join(LOOP_DIR, 'loop-state.json'), {})
30
- : null;
31
-
32
- let history = [];
33
- try {
34
- history = readFileSync(join(LOOP_DIR, 'history.jsonl'), 'utf8')
35
- .split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
36
- } catch {}
37
-
38
- if (json) {
39
- console.log(JSON.stringify({ state, history: history.slice(-limit) }, null, 2));
40
- } else if (!state) {
41
- console.log('No loop configured in', dir);
42
- } else {
43
- const icon = { done: '✅', gave_up: '🛑', continue: '🔄', running: '🔄' };
44
- console.log(`Loop in ${dir}`);
45
- console.log(` phase: ${state.phase || '?'} | iteration: ${state.iteration ?? 0}${state.gave_up_reason ? ` | gave up: ${state.gave_up_reason}` : ''}`);
46
- if (state.last_oracle) console.log(` oracle: ${String(state.last_oracle).split('\n')[0]}`);
47
- console.log(` history (last ${Math.min(limit, history.length)} of ${history.length}):`);
48
- for (const h of history.slice(-limit)) {
49
- console.log(` ${icon[h.decision] || '·'} iter ${h.iteration} [${h.decision}] ${h.ts}${h.reason ? ' — ' + h.reason : ''}`);
50
- }
51
- }
@@ -1,30 +0,0 @@
1
- #!/usr/bin/env node
2
- // precompact.mjs — Claude Code `PreCompact` hook. Records a compaction event in
3
- // the loop's history timeline (so the monitor shows context resets) and reminds
4
- // the agent that LOOP.md is the durable handoff across compaction.
5
- import { readFileSync, appendFileSync } from 'node:fs';
6
- import { dirname, join, resolve } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const HERE = dirname(fileURLToPath(import.meta.url));
10
- // Resolve the loop home from the per-process env (shared-settings safe); fallback to
11
- // the parent of this copied bin/ dir.
12
- const SID = process.env.SVAMP_SESSION_ID || null;
13
- const PROJECT_ENV = process.env.CLAUDE_PROJECT_DIR || null;
14
- const LOOP_DIR = (SID && PROJECT_ENV) ? join(PROJECT_ENV, '.svamp', SID, 'loop') : resolve(HERE, '..');
15
-
16
- let active = false;
17
- try { active = JSON.parse(readFileSync(join(LOOP_DIR, 'loop-state.json'), 'utf8')).active !== false; } catch {}
18
- if (!active) process.exit(0);
19
-
20
- let iteration = 0;
21
- try { iteration = JSON.parse(readFileSync(join(LOOP_DIR, 'loop-state.json'), 'utf8')).iteration || 0; } catch {}
22
- try {
23
- appendFileSync(join(LOOP_DIR, 'history.jsonl'),
24
- JSON.stringify({ ts: new Date().toISOString(), iteration, decision: 'compaction' }) + '\n');
25
- } catch {}
26
-
27
- // Context is about to be compacted — LOOP.md (auto-injected on the next turn) is
28
- // the durable handoff. Nudge the agent to ensure its progress is captured there.
29
- process.stdout.write('Context is being compacted. LOOP.md is your durable memory across this reset — ensure your latest progress, plan, and remaining work are written there before continuing.');
30
- process.exit(0);
@@ -1,126 +0,0 @@
1
- #!/usr/bin/env node
2
- // routine-core.mjs — dependency-free primitives for the routine layer:
3
- // * cron matching + next-fire (standard 5-field cron)
4
- // * payload templating (${body.x}/${query.x}/${now}) for trigger -> action
5
- // * routine spec validation
6
- // No external deps (mirrors the loop gate). Pure + unit-testable.
7
-
8
- // ---- cron ---------------------------------------------------------------
9
- const FIELD_RANGES = [
10
- [0, 59], // minute
11
- [0, 23], // hour
12
- [1, 31], // day of month
13
- [1, 12], // month
14
- [0, 6], // day of week (0=Sun)
15
- ];
16
-
17
- function parseField(token, [min, max]) {
18
- const set = new Set();
19
- for (const part of token.split(',')) {
20
- let m;
21
- if (part === '*') { for (let i = min; i <= max; i++) set.add(i); }
22
- else if ((m = part.match(/^\*\/(\d+)$/))) { const s = +m[1]; for (let i = min; i <= max; i += s) set.add(i); }
23
- else if ((m = part.match(/^(\d+)-(\d+)\/(\d+)$/))) { const [, a, b, s] = m; for (let i = +a; i <= +b; i += +s) set.add(i); }
24
- else if ((m = part.match(/^(\d+)-(\d+)$/))) { const [, a, b] = m; for (let i = +a; i <= +b; i++) set.add(i); }
25
- else if ((m = part.match(/^(\d+)$/))) { set.add(+m[1]); }
26
- else throw new Error(`invalid cron field: "${token}"`);
27
- }
28
- for (const v of set) if (v < min || v > max) throw new Error(`cron value ${v} out of range [${min},${max}]`);
29
- return set;
30
- }
31
-
32
- export function parseCron(expr) {
33
- const fields = String(expr).trim().split(/\s+/);
34
- if (fields.length !== 5) throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
35
- const [minute, hour, dom, month, dow] = fields.map((f, i) => parseField(f, FIELD_RANGES[i]));
36
- const domRestricted = fields[2] !== '*';
37
- const dowRestricted = fields[4] !== '*';
38
- return { minute, hour, dom, month, dow, domRestricted, dowRestricted };
39
- }
40
-
41
- /** Does `date` (minute resolution) match the cron expr? */
42
- export function cronMatches(expr, date) {
43
- const c = typeof expr === 'string' ? parseCron(expr) : expr;
44
- if (!c.minute.has(date.getMinutes())) return false;
45
- if (!c.hour.has(date.getHours())) return false;
46
- if (!c.month.has(date.getMonth() + 1)) return false;
47
- const domOk = c.dom.has(date.getDate());
48
- const dowOk = c.dow.has(date.getDay());
49
- // Standard cron: when BOTH dom and dow are restricted, match is OR; else AND.
50
- if (c.domRestricted && c.dowRestricted) return domOk || dowOk;
51
- return domOk && dowOk;
52
- }
53
-
54
- /** First firing strictly after `from` (defaults to now-ish; caller passes Date). */
55
- // Return a Date whose LOCAL fields equal the wall-clock time in `tz`, so
56
- // cronMatches (which reads local getHours/getMinutes/...) effectively matches
57
- // against that timezone. Falls back to the input on any error.
58
- export function inZone(date, tz) {
59
- if (!tz) return date;
60
- try {
61
- const p = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false,
62
- year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
63
- .formatToParts(date).reduce((o, x) => { o[x.type] = x.value; return o; }, {});
64
- return new Date(+p.year, +p.month - 1, +p.day, +(p.hour === '24' ? 0 : p.hour), +p.minute);
65
- } catch { return date; }
66
- }
67
-
68
- export function nextFire(expr, from, tz) {
69
- const c = parseCron(expr);
70
- const d = new Date(from.getTime());
71
- d.setSeconds(0, 0);
72
- d.setMinutes(d.getMinutes() + 1); // strictly after
73
- const limit = 366 * 24 * 60; // scan up to ~1 year of minutes
74
- for (let i = 0; i < limit; i++) {
75
- if (cronMatches(c, tz ? inZone(d, tz) : d)) return new Date(d.getTime());
76
- d.setMinutes(d.getMinutes() + 1);
77
- }
78
- return null;
79
- }
80
-
81
- // ---- templating ---------------------------------------------------------
82
- function resolvePath(ctx, path) {
83
- return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), ctx);
84
- }
85
- /** Replace ${a.b.c} from ctx; missing -> empty string. No code eval. */
86
- export function renderTemplate(template, ctx) {
87
- return String(template).replace(/\$\{([\w.$]+)\}/g, (_, p) => {
88
- const v = resolvePath(ctx, p);
89
- return v == null ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
90
- });
91
- }
92
-
93
- // ---- spec validation ----------------------------------------------------
94
- const TRIGGER_TYPES = ['manual', 'schedule', 'webhook', 'api'];
95
- const ACTION_KINDS = ['message', 'loop'];
96
- const OVERLAP = ['queue', 'skip', 'replace'];
97
-
98
- export function validateRoutine(r) {
99
- const errs = [];
100
- if (!r || typeof r !== 'object') return ['routine must be an object'];
101
- if (!r.session_id) errs.push('session_id required');
102
- if (!r.name) errs.push('name required');
103
- const t = r.trigger;
104
- if (!t || !TRIGGER_TYPES.includes(t.type)) errs.push(`trigger.type must be one of ${TRIGGER_TYPES.join('|')}`);
105
- if (t?.type === 'schedule') {
106
- try { parseCron(t.cron); } catch (e) { errs.push(`trigger.cron: ${e.message}`); }
107
- if (t.missed && !['catchup', 'skip'].includes(t.missed)) errs.push('trigger.missed must be catchup|skip');
108
- }
109
- const a = r.action;
110
- if (!a || !ACTION_KINDS.includes(a.kind)) errs.push(`action.kind must be one of ${ACTION_KINDS.join('|')}`);
111
- if (a?.kind === 'message' && !a.template) errs.push('action.template required for message action');
112
- if (a?.kind === 'loop' && !a.loop && !a.task_template) errs.push('action.loop or action.task_template required for loop action');
113
- if (r.overlap && !OVERLAP.includes(r.overlap)) errs.push(`overlap must be one of ${OVERLAP.join('|')}`);
114
- // Security: a public (keyless) webhook + loop action = unauthenticated task
115
- // injection into the session. Require a key for loop actions.
116
- if ((t?.type === 'webhook' || t?.type === 'api') && t.public && a?.kind === 'loop')
117
- errs.push('a public webhook/api may not use a loop action (unauthenticated task injection) — use a message action or require a key');
118
- return errs;
119
- }
120
-
121
- if (import.meta.url === `file://${process.argv[1]}`) {
122
- const [cmd, ...rest] = process.argv.slice(2);
123
- if (cmd === 'next') console.log(nextFire(rest.join(' '), new Date())?.toISOString() || 'no fire');
124
- else if (cmd === 'matches') console.log(cronMatches(rest.slice(0, 5).join(' '), new Date()));
125
- else console.log('usage: routine-core.mjs next "<cron>" | matches "<cron>"');
126
- }
@@ -1,111 +0,0 @@
1
- #!/usr/bin/env node
2
- // state-fp.mjs — deterministic fingerprint of a project's working-tree state.
3
- // Used to tie an evaluator verdict to the exact code it judged: if the actor
4
- // edits anything after the evaluator ran, the fingerprint changes and the
5
- // stale verdict is rejected by the Stop gate.
6
- import { execFileSync } from 'node:child_process';
7
- import { createHash } from 'node:crypto';
8
- import { readFileSync, statSync, readdirSync, readlinkSync } from 'node:fs';
9
- import { join } from 'node:path';
10
-
11
- function git(dir, args) {
12
- try {
13
- return execFileSync('git', ['-C', dir, ...args], {
14
- encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
15
- stdio: ['ignore', 'pipe', 'ignore'], // swallow "not a git repository" noise
16
- });
17
- } catch {
18
- return '';
19
- }
20
- }
21
-
22
- // Paths excluded from the fingerprint: the loop's own bookkeeping AND its
23
- // memory file (LOOP.md). The fingerprint must track the WORK PRODUCT only —
24
- // LOOP.md/state/verdict change every iteration and would otherwise make every
25
- // verdict look stale (the agent updates LOOP.md progress as it works).
26
- function excludedPaths(dir) {
27
- // The loop's bookkeeping + memory now live under .svamp/<sid>/loop/ (and legacy
28
- // loops under .claude/loop/). Both — plus a root LOOP.md a legacy loop may have
29
- // left — are excluded so the fingerprint tracks the WORK PRODUCT only.
30
- return { loopFile: 'LOOP.md', prefixes: ['.svamp/', '.claude/loop/'] };
31
- }
32
-
33
- const WALK_SKIP = new Set(['.git', 'node_modules', '.svamp', '.expo', 'dist', 'build']);
34
-
35
- /** Fallback for non-git (or no-HEAD) dirs: hash all files on disk so the
36
- * fingerprint actually reflects content. Without this, git plumbing returns
37
- * empty strings and every state hashes identically — accepting stale verdicts. */
38
- function walkFingerprint(dir, isExcluded) {
39
- const h = createHash('sha256');
40
- h.update('WALK\0');
41
- const files = [];
42
- const links = [];
43
- const walk = (abs, rel) => {
44
- let entries; try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
45
- for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
46
- const childRel = rel ? `${rel}/${e.name}` : e.name;
47
- if (WALK_SKIP.has(e.name) || isExcluded(childRel)) continue;
48
- const childAbs = join(abs, e.name);
49
- // Symlinks: hash the link TARGET (don't follow — avoids cycles/escape) so a
50
- // work product reached via a symlink still changes the fingerprint.
51
- if (e.isSymbolicLink()) { try { links.push([childRel, readlinkSync(childAbs)]); } catch { links.push([childRel, 'unreadable']); } }
52
- else if (e.isDirectory()) walk(childAbs, childRel);
53
- else if (e.isFile()) files.push([childRel, childAbs]);
54
- }
55
- };
56
- walk(dir, '');
57
- for (const [rel, abs] of files.sort((a, b) => a[0].localeCompare(b[0]))) {
58
- let part = rel + ':';
59
- try {
60
- const st = statSync(abs);
61
- part += st.size < 4 * 1024 * 1024
62
- ? st.size + ':' + createHash('sha256').update(readFileSync(abs)).digest('hex')
63
- : st.size + ':skip';
64
- } catch { part += 'missing'; }
65
- h.update(part + '\0');
66
- }
67
- h.update('SYMLINKS\0');
68
- for (const [rel, target] of links.sort((a, b) => a[0].localeCompare(b[0]))) h.update(rel + ':->' + target + '\0');
69
- return h.digest('hex');
70
- }
71
-
72
- /** Fingerprint = HEAD + tracked diff + untracked file contents (size+sha).
73
- * Falls back to a filesystem walk when the dir is not a git repo / has no HEAD. */
74
- export function stateFingerprint(dir) {
75
- const { loopFile, prefixes } = excludedPaths(dir);
76
- const isExcluded = (p) => p === loopFile || prefixes.some((pre) => p.startsWith(pre));
77
- const head = git(dir, ['rev-parse', 'HEAD']).trim();
78
- if (!head) return walkFingerprint(dir, isExcluded); // non-git / no commit yet
79
- // Exclude bookkeeping/memory from the tracked diff too (in case they're committed).
80
- const diff = git(dir, ['-c', 'core.quotepath=false', 'diff', 'HEAD', '--',
81
- '.', `:(exclude)${loopFile}`, ':(exclude).claude/loop', ':(exclude).svamp']);
82
- // KNOWN LIMITATION (review #8): --exclude-standard omits gitignored files, so a
83
- // loop whose work product lands in a gitignored path (e.g. dist/) won't change
84
- // the fingerprint. Acceptable since work products are normally tracked; loops
85
- // targeting gitignored output should commit/track it or run in a non-git dir.
86
- const untrackedList = git(dir, ['ls-files', '--others', '--exclude-standard'])
87
- .split('\n').map((s) => s.trim()).filter(Boolean)
88
- .filter((p) => !isExcluded(p))
89
- .sort();
90
- const h = createHash('sha256');
91
- h.update('HEAD\0' + head + '\0DIFF\0' + diff + '\0UNTRACKED\0');
92
- for (const rel of untrackedList) {
93
- const abs = join(dir, rel);
94
- let part = rel + ':';
95
- try {
96
- const st = statSync(abs);
97
- if (st.isFile() && st.size < 4 * 1024 * 1024) {
98
- part += st.size + ':' + createHash('sha256').update(readFileSync(abs)).digest('hex');
99
- } else {
100
- part += st.size + ':skip';
101
- }
102
- } catch { part += 'missing'; }
103
- h.update(part + '\0');
104
- }
105
- return h.digest('hex');
106
- }
107
-
108
- if (import.meta.url === `file://${process.argv[1]}`) {
109
- const dir = process.argv[2] || process.cwd();
110
- process.stdout.write(stateFingerprint(dir) + '\n');
111
- }