local-executor 0.1.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 +301 -0
- package/dist/cli.js +2606 -0
- package/package.json +69 -0
- package/skill/adapters/claude-code/SKILL.md +45 -0
- package/skill/adapters/codex/AGENTS.block.md +13 -0
- package/skill/adapters/codex/SKILL.md +6 -0
- package/skill/adapters/cursor/local-executor.mdc +19 -0
- package/skill/adapters/windsurf/local-executor.md +18 -0
- package/skill/core/PIPELINE.md +109 -0
- package/skill/core/audit-prompt.md +51 -0
- package/skill/core/executor-system-prompt.md +34 -0
- package/skill/core/handoff-template.md +65 -0
- package/skill/core/modern-practices.md +61 -0
- package/skill/runtime/check_local.mjs +56 -0
- package/skill/runtime/config.json +10 -0
- package/skill/runtime/run_executor.mjs +225 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Send a task packet to the local Ollama model and collect the code it returns.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node run_executor.mjs --packet packet.md --out response.md [--apply] [--root .] [--model TAG] [--json]
|
|
7
|
+
*
|
|
8
|
+
* The executor is told (via ../core/executor-system-prompt.md) to answer ONLY
|
|
9
|
+
* with fenced code blocks whose info string names the target file:
|
|
10
|
+
*
|
|
11
|
+
* ```python path=src/utils.py
|
|
12
|
+
* ...
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* With --apply, each block is written under --root (a .bak copy is made first).
|
|
16
|
+
* Without --apply, blocks are only saved to --out for the planner to inspect.
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 ok 2 Ollama/network error 3 no code blocks returned
|
|
20
|
+
* 1 usage 4 executor declared it cannot do the task (EXECUTOR_CANNOT.md)
|
|
21
|
+
*/
|
|
22
|
+
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
23
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const config = JSON.parse(await readFile(join(here, "config.json"), "utf8"));
|
|
28
|
+
const systemPrompt = await readFile(join(here, "..", "core", "executor-system-prompt.md"), "utf8");
|
|
29
|
+
|
|
30
|
+
const BLOCK_RE = /```([\w+#.-]*)[ \t]+path=(\S+)[ \t]*\r?\n([\s\S]*?)\r?\n?```/g;
|
|
31
|
+
const CANNOT_FILE = "EXECUTOR_CANNOT.md";
|
|
32
|
+
|
|
33
|
+
function usage(code) {
|
|
34
|
+
console.error(
|
|
35
|
+
"usage: run_executor.mjs --packet <file> --out <file> [--apply] [--root <dir>] [--model <tag>] [--json]",
|
|
36
|
+
);
|
|
37
|
+
process.exit(code);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const args = { apply: false, root: ".", model: config.model, json: false };
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === "--apply") args.apply = true;
|
|
45
|
+
else if (a === "--json") args.json = true;
|
|
46
|
+
else if (a === "--packet") args.packet = argv[++i];
|
|
47
|
+
else if (a === "--out") args.out = argv[++i];
|
|
48
|
+
else if (a === "--root") args.root = argv[++i];
|
|
49
|
+
else if (a === "--model") args.model = argv[++i];
|
|
50
|
+
else if (a === "-h" || a === "--help") usage(0);
|
|
51
|
+
else {
|
|
52
|
+
console.error(`unknown argument: ${a}`);
|
|
53
|
+
usage(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!args.packet || !args.out) usage(1);
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function callOllama(model, packet) {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timer = setTimeout(() => controller.abort(), (config.timeout_seconds ?? 600) * 1000);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${config.ollama_url}/api/chat`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
model,
|
|
70
|
+
stream: false,
|
|
71
|
+
keep_alive: config.keep_alive ?? "30m",
|
|
72
|
+
// Qwen 3.x and Gemma 4 support a "thinking" mode. Off by default: the
|
|
73
|
+
// executor should spend its tokens on code, not deliberation.
|
|
74
|
+
think: config.think ?? false,
|
|
75
|
+
options: {
|
|
76
|
+
temperature: config.temperature ?? 0.1,
|
|
77
|
+
num_ctx: config.num_ctx ?? 16384,
|
|
78
|
+
},
|
|
79
|
+
messages: [
|
|
80
|
+
{ role: "system", content: systemPrompt },
|
|
81
|
+
{ role: "user", content: packet },
|
|
82
|
+
],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) throw new Error(`Ollama HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
return {
|
|
88
|
+
content: data.message?.content ?? "",
|
|
89
|
+
evalCount: data.eval_count ?? 0,
|
|
90
|
+
evalDurationMs: Math.round((data.eval_duration ?? 0) / 1e6),
|
|
91
|
+
promptEvalCount: data.prompt_eval_count ?? 0,
|
|
92
|
+
};
|
|
93
|
+
} finally {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Strip any <think>…</think> reasoning a model might emit despite think:false. */
|
|
99
|
+
export function stripThinking(text) {
|
|
100
|
+
return text.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseBlocks(text) {
|
|
104
|
+
return [...text.matchAll(BLOCK_RE)].map((m) => ({
|
|
105
|
+
lang: m[1] || "",
|
|
106
|
+
path: m[2],
|
|
107
|
+
code: m[3].endsWith("\n") ? m[3] : `${m[3]}\n`,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Refuse absolute paths and anything that escapes the root. Returns the resolved target. */
|
|
112
|
+
export function safeTarget(root, relPath) {
|
|
113
|
+
const cleaned = normalize(relPath).replace(/^[\\/]+/, "");
|
|
114
|
+
if (isAbsolute(relPath) || /^[a-zA-Z]:/.test(relPath)) {
|
|
115
|
+
throw new Error(`refusing absolute path from executor: ${relPath}`);
|
|
116
|
+
}
|
|
117
|
+
const target = resolve(root, cleaned);
|
|
118
|
+
const rel = relative(resolve(root), target);
|
|
119
|
+
if (rel.startsWith("..") || rel.split(sep).includes("..")) {
|
|
120
|
+
throw new Error(`refusing path outside root: ${relPath}`);
|
|
121
|
+
}
|
|
122
|
+
return target;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function exists(p) {
|
|
126
|
+
try {
|
|
127
|
+
await access(p);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function main() {
|
|
135
|
+
const args = parseArgs(process.argv.slice(2));
|
|
136
|
+
const packet = await readFile(args.packet, "utf8");
|
|
137
|
+
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = await callOllama(args.model, packet);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`EXECUTOR ERROR: ${err.message}`);
|
|
143
|
+
console.error(
|
|
144
|
+
`Is Ollama running at ${config.ollama_url}? Try: node "${join(here, "check_local.mjs")}"`,
|
|
145
|
+
);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const response = stripThinking(result.content);
|
|
150
|
+
await mkdir(dirname(resolve(args.out)), { recursive: true });
|
|
151
|
+
await writeFile(args.out, response);
|
|
152
|
+
|
|
153
|
+
const blocks = parseBlocks(response);
|
|
154
|
+
const tps =
|
|
155
|
+
result.evalDurationMs > 0
|
|
156
|
+
? Math.round((result.evalCount / (result.evalDurationMs / 1000)) * 10) / 10
|
|
157
|
+
: null;
|
|
158
|
+
|
|
159
|
+
if (blocks.length === 0) {
|
|
160
|
+
console.error(`EXECUTOR RETURNED NO CODE BLOCKS — see ${args.out}`);
|
|
161
|
+
if (args.json)
|
|
162
|
+
console.log(JSON.stringify({ ok: false, reason: "no_blocks", files: [], tokensPerSec: tps }));
|
|
163
|
+
return 3;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const cannot = blocks.find((b) => b.path === CANNOT_FILE);
|
|
167
|
+
if (cannot) {
|
|
168
|
+
console.error("EXECUTOR CANNOT DO THIS TASK:");
|
|
169
|
+
console.error(cannot.code.trim());
|
|
170
|
+
if (args.json)
|
|
171
|
+
console.log(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
ok: false,
|
|
174
|
+
reason: "cannot",
|
|
175
|
+
message: cannot.code.trim(),
|
|
176
|
+
tokensPerSec: tps,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
return 4;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const written = [];
|
|
183
|
+
if (args.apply) {
|
|
184
|
+
for (const b of blocks) {
|
|
185
|
+
let target;
|
|
186
|
+
try {
|
|
187
|
+
target = safeTarget(args.root, b.path);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`SKIPPED: ${err.message}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
await mkdir(dirname(target), { recursive: true });
|
|
193
|
+
if (await exists(target)) await copyFile(target, `${target}.bak`);
|
|
194
|
+
await writeFile(target, b.code);
|
|
195
|
+
written.push(target);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (args.json) {
|
|
200
|
+
console.log(
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
ok: true,
|
|
203
|
+
files: blocks.map((b) => b.path),
|
|
204
|
+
written,
|
|
205
|
+
model: args.model,
|
|
206
|
+
evalCount: result.evalCount,
|
|
207
|
+
promptEvalCount: result.promptEvalCount,
|
|
208
|
+
tokensPerSec: tps,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
console.log(
|
|
213
|
+
`Executor (${args.model}) returned ${blocks.length} file(s)${tps ? ` at ${tps} tok/s` : ""}:`,
|
|
214
|
+
);
|
|
215
|
+
for (const b of blocks) console.log(` - ${b.path}`);
|
|
216
|
+
for (const t of written) console.log(` wrote ${t}`);
|
|
217
|
+
if (!args.apply) console.log(`Not applied (no --apply). Raw response: ${args.out}`);
|
|
218
|
+
}
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Only run when executed directly, so tests can import the pure helpers.
|
|
223
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
224
|
+
process.exit(await main());
|
|
225
|
+
}
|