agent-procedures 0.1.0 → 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/lib/auth.js ADDED
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { Writable } from "node:stream";
5
+ import {
6
+ PROVIDERS,
7
+ PROVIDER_NAMES,
8
+ credentialsFile,
9
+ readCredentials,
10
+ reflexHome,
11
+ resolveCredentials,
12
+ } from "./providers.js";
13
+
14
+ export const USAGE = `Usage:
15
+ npx agent-procedures auth login [--provider <name>] [--model <id>] [--key-stdin]
16
+ npx agent-procedures auth status
17
+
18
+ Providers: ${PROVIDER_NAMES.join(", ")}
19
+
20
+ Interactive by default. For scripts/CI, pipe the key on stdin:
21
+ echo "$OPENAI_API_KEY" | npx agent-procedures auth login --provider openai --key-stdin
22
+
23
+ The groomer also reads ${PROVIDER_NAMES.map((p) => PROVIDERS[p].envVar).join(" / ")}
24
+ directly, so \`auth login\` is optional if one of those is already set.`;
25
+
26
+ function parseArgs(args) {
27
+ const out = { provider: null, model: null, keyStdin: false };
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a === "--provider" && args[i + 1]) out.provider = args[++i];
31
+ else if (a === "--model" && args[i + 1]) out.model = args[++i];
32
+ else if (a === "--key-stdin") out.keyStdin = true;
33
+ else throw new Error(`Unknown argument: ${a}\n\n${USAGE}`);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function ask(question, io) {
39
+ return new Promise((resolve) => {
40
+ const rl = readline.createInterface({ input: io.stdin, output: io.stdout });
41
+ rl.question(question, (answer) => {
42
+ rl.close();
43
+ resolve(answer.trim());
44
+ });
45
+ });
46
+ }
47
+
48
+ // Readline with a muted output stream: line editing still works, nothing is echoed.
49
+ function askHidden(question, io) {
50
+ return new Promise((resolve) => {
51
+ io.stdout.write(question);
52
+ const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
53
+ const rl = readline.createInterface({ input: io.stdin, output: muted, terminal: true });
54
+ rl.question("", (answer) => {
55
+ rl.close();
56
+ io.stdout.write("\n");
57
+ resolve(answer.trim());
58
+ });
59
+ });
60
+ }
61
+
62
+ function readAll(stream) {
63
+ return new Promise((resolve, reject) => {
64
+ let data = "";
65
+ stream.setEncoding("utf8");
66
+ stream.on("data", (c) => (data += c));
67
+ stream.on("end", () => resolve(data.trim()));
68
+ stream.on("error", reject);
69
+ });
70
+ }
71
+
72
+ async function chooseProvider(io) {
73
+ const menu = PROVIDER_NAMES.map((p, i) => ` [${i + 1}] ${PROVIDERS[p].label}`).join("\n");
74
+ const answer = await ask(`Provider:\n${menu}\n> `, io);
75
+ const byIndex = PROVIDER_NAMES[Number(answer) - 1];
76
+ const byName = PROVIDER_NAMES.find((p) => p === answer.toLowerCase());
77
+ const provider = byIndex || byName;
78
+ if (!provider) throw new Error(`Unknown provider "${answer}". Choose one of: ${PROVIDER_NAMES.join(", ")}`);
79
+ return provider;
80
+ }
81
+
82
+ function saveCredentials(provider, apiKey, model) {
83
+ const dir = reflexHome();
84
+ const file = credentialsFile();
85
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
86
+
87
+ let creds = { version: 1, providers: {} };
88
+ try {
89
+ creds = { ...creds, ...(readCredentials() || {}) };
90
+ } catch (e) {
91
+ // corrupt file; start fresh
92
+ }
93
+ creds.providers = creds.providers || {};
94
+ creds.providers[provider] = { apiKey, model };
95
+ creds.defaultProvider = provider;
96
+
97
+ fs.writeFileSync(file, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
98
+ fs.chmodSync(file, 0o600); // mode above only applies on create
99
+ return file;
100
+ }
101
+
102
+ export async function login(args, io = { stdin: process.stdin, stdout: process.stdout }) {
103
+ const opts = parseArgs(args);
104
+ const interactive = Boolean(io.stdin.isTTY) && !opts.keyStdin;
105
+
106
+ let provider = opts.provider;
107
+ if (provider && !PROVIDERS[provider]) {
108
+ throw new Error(`Unsupported provider "${provider}". Choose one of: ${PROVIDER_NAMES.join(", ")}`);
109
+ }
110
+ if (!provider) {
111
+ if (!interactive) throw new Error(`--provider is required when not running interactively.\n\n${USAGE}`);
112
+ provider = await chooseProvider(io);
113
+ }
114
+ const def = PROVIDERS[provider];
115
+
116
+ let apiKey;
117
+ if (opts.keyStdin) {
118
+ apiKey = await readAll(io.stdin);
119
+ } else if (interactive) {
120
+ apiKey = await askHidden(`${def.label} API key (get one at ${def.keysUrl}): `, io);
121
+ } else {
122
+ throw new Error(`No key given. Use --key-stdin when not running interactively.\n\n${USAGE}`);
123
+ }
124
+ if (!apiKey) throw new Error("No key given.");
125
+
126
+ if (!apiKey.startsWith(def.keyPrefix)) {
127
+ io.stdout.write(`Warning: ${def.label} keys usually start with "${def.keyPrefix}". Checking anyway.\n`);
128
+ }
129
+
130
+ const model = opts.model || def.defaultModel;
131
+
132
+ io.stdout.write(`Checking key with ${def.label}... `);
133
+ try {
134
+ await def.verify(apiKey);
135
+ } catch (e) {
136
+ io.stdout.write("failed\n");
137
+ throw new Error(`${def.label} rejected the key: ${e.message}`);
138
+ }
139
+ io.stdout.write("ok\n");
140
+
141
+ const file = saveCredentials(provider, apiKey, model);
142
+ io.stdout.write(`Saved to ${file} (readable only by you)\n`);
143
+ io.stdout.write(`Provider: ${def.label}\nModel: ${model}\n`);
144
+ }
145
+
146
+ function maskKey(key) {
147
+ if (key.length <= 8) return "****";
148
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
149
+ }
150
+
151
+ export function status(io = { stdout: process.stdout }, cwd = process.cwd()) {
152
+ const resolved = resolveCredentials();
153
+ if (!resolved) {
154
+ io.stdout.write(
155
+ `Not configured. Run \`npx agent-procedures auth login\` or set one of: ${PROVIDER_NAMES.map((p) => PROVIDERS[p].envVar).join(", ")}\n`,
156
+ );
157
+ } else {
158
+ const def = PROVIDERS[resolved.provider];
159
+ const from = resolved.source === "env" ? `env ${def.envVar}` : credentialsFile();
160
+ io.stdout.write(`Provider: ${def.label}\nModel: ${resolved.model}\nKey: ${maskKey(resolved.apiKey)} (from ${from})\n`);
161
+ }
162
+
163
+ const log = path.join(cwd, ".reflex", "worker.log");
164
+ if (fs.existsSync(log)) {
165
+ const lines = fs.readFileSync(log, "utf8").trim().split("\n");
166
+ const last = lines[lines.length - 1];
167
+ if (last) io.stdout.write(`Last groomer error: ${last}\n`);
168
+ }
169
+ }
package/lib/engine.js ADDED
@@ -0,0 +1,179 @@
1
+ import {
2
+ appendProcedure,
3
+ appendTrace,
4
+ deleteTrace,
5
+ readProcedures,
6
+ readTrace,
7
+ startTrace,
8
+ } from "./store.js";
9
+
10
+ /**
11
+ * Harness-free hot path.
12
+ *
13
+ * Normalized event (produced by a harness adapter):
14
+ * { type: "prompt", sessionId, promptId, prompt }
15
+ * { type: "tool", sessionId, promptId, tool, target, ok }
16
+ * { type: "stop", sessionId, promptId, interrupted, busy }
17
+ *
18
+ * Returns { context: string | null }. `context` is text the harness should
19
+ * inject into the agent's next turn, if it knows how.
20
+ */
21
+ export function handleEvent(evt, reflexDir, deps = {}) {
22
+ if (!evt || !evt.sessionId || !evt.promptId) return { context: null };
23
+
24
+ if (evt.type === "prompt") return onPrompt(evt, reflexDir);
25
+ if (evt.type === "tool") return onTool(evt, reflexDir);
26
+ if (evt.type === "stop") return onStop(evt, reflexDir, deps);
27
+ return { context: null };
28
+ }
29
+
30
+ export const GROOM_THRESHOLD = 5;
31
+
32
+ // --- prompt: open turn + recall ------------------------------------------
33
+
34
+ function onPrompt(evt, reflexDir) {
35
+ const { sessionId, promptId, prompt } = evt;
36
+ startTrace(reflexDir, sessionId, promptId, { t: "Goal", prompt });
37
+
38
+ const hit = recall(prompt, readProcedures(reflexDir));
39
+ if (!hit) return { context: null };
40
+
41
+ appendTrace(reflexDir, sessionId, promptId, { t: "Recall", hit: hit.node.id });
42
+ return { context: hit.text };
43
+ }
44
+
45
+ function recall(prompt, procs) {
46
+ if (!prompt) return null;
47
+ const p = prompt.toLowerCase();
48
+ const fmt = (steps) => steps.map((s) => `- ${s.t}: ${s.target}`).join("\n");
49
+
50
+ for (const node of procs) {
51
+ if (node.enabled === false || node.parent_id) continue;
52
+
53
+ let match = node.trigger && p.includes(node.trigger.toLowerCase());
54
+ if (!match && Array.isArray(node.aliases)) {
55
+ match = node.aliases.some((a) => p.includes(a.toLowerCase()));
56
+ }
57
+ if (!match) continue;
58
+
59
+ let text = fmt(node.steps);
60
+ const alt = procs.find((e) => e.parent_id === node.id && e.type === "alt" && e.enabled !== false);
61
+ const onFail = procs.find((e) => e.parent_id === node.id && e.type === "on_fail" && e.enabled !== false);
62
+ if (alt) text += "\n\nAlternative path that worked:\n" + fmt(alt.steps);
63
+ if (onFail) text += "\n\nIf it fails, this recovery worked:\n" + fmt(onFail.steps);
64
+
65
+ return {
66
+ node,
67
+ text: `Reflex memory found a procedure for this task:\n\n${text}\n\nFollow these steps instead of figuring it out from scratch.`,
68
+ };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // --- tool: trace ----------------------------------------------------------
74
+
75
+ function onTool(evt, reflexDir) {
76
+ appendTrace(reflexDir, evt.sessionId, evt.promptId, { t: evt.tool, target: evt.target || "", ok: evt.ok });
77
+ return { context: null };
78
+ }
79
+
80
+ // --- stop: judge → gate → synthesize → write ------------------------------
81
+
82
+ function onStop(evt, reflexDir, deps) {
83
+ const { sessionId, promptId } = evt;
84
+ const lines = readTrace(reflexDir, sessionId, promptId);
85
+ if (lines.length === 0) return { context: null };
86
+
87
+ let goal = "";
88
+ let recallHit = null;
89
+ for (const l of lines) {
90
+ if (l.t === "Goal") goal = l.prompt;
91
+ if (l.t === "Recall" && l.hit) recallHit = l.hit;
92
+ }
93
+
94
+ if (judge(lines, evt)) {
95
+ const steps = synthesize(lines);
96
+ if (steps.length > 0) {
97
+ if (!recallHit) {
98
+ appendProcedure(reflexDir, {
99
+ id: Math.random().toString(36).slice(2, 11),
100
+ title: goal,
101
+ trigger: goal,
102
+ steps,
103
+ enabled: true,
104
+ hits: 0,
105
+ last_used_at: new Date().toISOString(),
106
+ created_at: new Date().toISOString(),
107
+ status: "pending_review",
108
+ });
109
+ } else {
110
+ const parent = readProcedures(reflexDir).find((p) => p.id === recallHit);
111
+ const parentSteps = parent ? parent.steps : [];
112
+ const same =
113
+ parentSteps.length === steps.length &&
114
+ parentSteps.every((s, i) => s.t === steps[i].t && s.target === steps[i].target);
115
+ if (!same) {
116
+ appendProcedure(reflexDir, {
117
+ parent_id: recallHit,
118
+ type: "alt",
119
+ steps,
120
+ created_at: new Date().toISOString(),
121
+ status: "pending_review",
122
+ });
123
+ }
124
+ }
125
+
126
+ const pending = readProcedures(reflexDir).filter((p) => p.status === "pending_review").length;
127
+ if (pending >= GROOM_THRESHOLD && deps.spawnGroomer) deps.spawnGroomer();
128
+ }
129
+ }
130
+
131
+ deleteTrace(reflexDir, sessionId, promptId);
132
+ return { context: null };
133
+ }
134
+
135
+ const READ_ONLY_CMDS = ["cat", "ls", "git", "pwd", "head", "tail", "echo", "grep", "find"];
136
+ const PURE_READ_CMDS = ["cat", "ls", "pwd", "head", "tail"];
137
+
138
+ function isMutating(t, target) {
139
+ if (t === "Edit" || t === "Write" || t === "NotebookEdit") return true;
140
+ if (t === "Bash" || t === "bash") {
141
+ const cmd = (target || "").trim();
142
+ if (!cmd) return false;
143
+ const first = cmd.split(" ")[0];
144
+ if (READ_ONLY_CMDS.includes(first)) {
145
+ return first === "git" && /\s(commit|push|add|rm|mv)\b/.test(cmd);
146
+ }
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function judge(lines, evt) {
153
+ const steps = lines.filter((l) => l.t !== "Goal" && l.t !== "Recall");
154
+ if (!steps.some((l) => isMutating(l.t, l.target))) return false;
155
+ if (evt.interrupted || evt.busy) return false;
156
+
157
+ const failed = new Set();
158
+ for (const l of steps) {
159
+ const key = `${l.t}|${l.target}`;
160
+ if (l.ok === false) failed.add(key);
161
+ else if (l.ok === true) failed.delete(key);
162
+ }
163
+ return failed.size === 0;
164
+ }
165
+
166
+ function synthesize(lines) {
167
+ const steps = [];
168
+ for (const l of lines) {
169
+ if (l.t === "Goal" || l.t === "Recall" || l.ok === false) continue;
170
+ if (l.t === "Read" || l.t === "View") continue;
171
+ if (l.t === "Bash" || l.t === "bash") {
172
+ const target = l.target || "";
173
+ if (PURE_READ_CMDS.includes(target.trim().split(" ")[0])) continue;
174
+ if (/^git (status|log|diff)/.test(target)) continue;
175
+ }
176
+ steps.push({ t: l.t, target: l.target });
177
+ }
178
+ return steps;
179
+ }
package/lib/groomer.js ADDED
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PROVIDERS, resolveCredentials } from "./providers.js";
4
+
5
+ // Silent Failure Logging
6
+ function logError(proceduresFile, msg) {
7
+ const reflexDir = path.dirname(proceduresFile);
8
+ const logFile = path.join(reflexDir, "worker.log");
9
+ const timestamp = new Date().toISOString();
10
+ try {
11
+ fs.appendFileSync(logFile, `[${timestamp}] ${msg}\n`, "utf8");
12
+ } catch (e) {
13
+ // nowhere to log, silently fail
14
+ }
15
+ }
16
+
17
+ export async function runGroomer(proceduresFile) {
18
+ let creds;
19
+ try {
20
+ creds = resolveCredentials();
21
+ } catch (e) {
22
+ logError(proceduresFile, e.message);
23
+ return;
24
+ }
25
+ if (!creds) {
26
+ logError(
27
+ proceduresFile,
28
+ "No API key found. Run `npx agent-procedures auth login` or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.",
29
+ );
30
+ return;
31
+ }
32
+ const { provider, apiKey, model } = creds;
33
+
34
+ if (!fs.existsSync(proceduresFile)) return;
35
+
36
+ const lines = fs
37
+ .readFileSync(proceduresFile, "utf8")
38
+ .split("\n")
39
+ .filter(Boolean);
40
+ const nodes = lines
41
+ .map((l) => {
42
+ try {
43
+ return JSON.parse(l);
44
+ } catch (e) {
45
+ return null;
46
+ }
47
+ })
48
+ .filter(Boolean);
49
+
50
+ const pending = nodes.filter((n) => n.status === "pending_review");
51
+ if (pending.length === 0) return;
52
+
53
+ const prompt = `
54
+ Here are ${pending.length} new execution nodes/edges added by a coding agent.
55
+
56
+ <edges>
57
+ ${JSON.stringify(pending, null, 2)}
58
+ </edges>
59
+
60
+ For each item:
61
+ 1) Did the agent verify this worked? (e.g. ran a test, checked a log). If it just wrote code and blindly stopped without checking, it's risky.
62
+ 2) Is this a generic/reusable workflow, or a one-off anomaly?
63
+ If it's unverified and risky, or a pure anomaly, decide to DROP it.
64
+ If it's good, KEEP it.
65
+
66
+ If you KEEP a root node (has no parent_id), generate 5-10 semantic aliases (synonyms) for its 'trigger' text so we can match variations of the user's prompt later.
67
+
68
+ Return ONLY a JSON array of objects with this schema:
69
+ [
70
+ {
71
+ "id": "node_id",
72
+ "action": "KEEP" | "DROP",
73
+ "aliases": ["alias1", "alias2"] // only if KEEP and root node
74
+ }
75
+ ]
76
+ `;
77
+
78
+ let responseText;
79
+ try {
80
+ responseText = await PROVIDERS[provider].complete(apiKey, model, prompt);
81
+ } catch (err) {
82
+ logError(proceduresFile, err.message);
83
+ return;
84
+ }
85
+
86
+ let actions = [];
87
+ try {
88
+ const jsonStr = responseText
89
+ .replace(/```json/g, "")
90
+ .replace(/```/g, "")
91
+ .trim();
92
+ actions = JSON.parse(jsonStr);
93
+ } catch (e) {
94
+ logError(
95
+ proceduresFile,
96
+ "Failed to parse LLM JSON response: " + responseText,
97
+ );
98
+ return;
99
+ }
100
+
101
+ const newNodes = [];
102
+ for (const node of nodes) {
103
+ if (node.status === "pending_review") {
104
+ const actionDef = actions.find((a) => a.id === node.id);
105
+ if (actionDef) {
106
+ if (actionDef.action === "DROP") {
107
+ continue;
108
+ } else {
109
+ delete node.status;
110
+ if (!node.parent_id && actionDef.aliases) {
111
+ node.aliases = actionDef.aliases;
112
+ }
113
+ }
114
+ }
115
+ }
116
+ newNodes.push(node);
117
+ }
118
+
119
+ // Atomic Writes for the Groomer
120
+ const newFileContent =
121
+ newNodes.map((n) => JSON.stringify(n)).join("\n") + "\n";
122
+ const tmpFile = proceduresFile + ".tmp";
123
+ try {
124
+ fs.writeFileSync(tmpFile, newFileContent, "utf8");
125
+ fs.renameSync(tmpFile, proceduresFile);
126
+ } catch (err) {
127
+ logError(proceduresFile, `Failed atomic write: ${err.message}`);
128
+ }
129
+ }
@@ -0,0 +1,75 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Claude Code adapter. Everything Claude-specific lives here.
5
+ // Docs: https://code.claude.com/docs/en/hooks
6
+
7
+ const EVENTS = ["UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "Stop"];
8
+
9
+ export default {
10
+ id: "claude",
11
+
12
+ /** Where the generated hook shim goes, relative to the repo root. */
13
+ hookFile: ".claude/hooks/reflex.mjs",
14
+
15
+ /** Merge our hooks into .claude/settings.json without touching anything else. */
16
+ register(cwd) {
17
+ const settingsPath = path.join(cwd, ".claude", "settings.json");
18
+ let settings = {};
19
+ if (fs.existsSync(settingsPath)) {
20
+ try {
21
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
22
+ } catch (e) {
23
+ console.warn("Failed to parse existing .claude/settings.json, overwriting...");
24
+ }
25
+ }
26
+ settings.hooks = settings.hooks || {};
27
+
28
+ for (const event of EVENTS) {
29
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
30
+ const command = `node "$CLAUDE_PROJECT_DIR/${this.hookFile}" ${event}`;
31
+ const wired = settings.hooks[event].some((g) => (g.hooks || []).some((h) => h.command === command));
32
+ if (!wired) settings.hooks[event].push({ hooks: [{ type: "command", command }] });
33
+ }
34
+
35
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
36
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
37
+ },
38
+
39
+ /** Claude's stdin payload → normalized engine event, or null to ignore. */
40
+ normalize(eventName, payload) {
41
+ const name = payload.hook_event_name || eventName;
42
+ const base = { sessionId: payload.session_id, promptId: payload.prompt_id };
43
+
44
+ if (name === "UserPromptSubmit") {
45
+ return { type: "prompt", ...base, prompt: payload.prompt };
46
+ }
47
+ if (name === "PostToolUse" || name === "PostToolUseFailure") {
48
+ const tool = payload.tool_name;
49
+ const input = payload.tool_input || {};
50
+ let target = "";
51
+ if (tool === "Bash" || tool === "bash") target = input.command || "";
52
+ else if (typeof input === "string") target = input;
53
+ else target = input.target || input.file_path || input.path || "";
54
+ return { type: "tool", ...base, tool, target, ok: name === "PostToolUse" };
55
+ }
56
+ if (name === "Stop") {
57
+ return {
58
+ type: "stop",
59
+ ...base,
60
+ interrupted: payload.stop_hook_active === true,
61
+ busy: Array.isArray(payload.background_tasks) && payload.background_tasks.length > 0,
62
+ };
63
+ }
64
+ return null;
65
+ },
66
+
67
+ /** Engine result → what to print on stdout for Claude. */
68
+ render(evt, result) {
69
+ if (evt.type !== "prompt") return "";
70
+ if (!result.context) return "{}";
71
+ return JSON.stringify({
72
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: result.context },
73
+ });
74
+ },
75
+ };
@@ -0,0 +1,15 @@
1
+ import claude from "./claude.js";
2
+
3
+ // To add a harness: write lib/harnesses/<id>.js with the same shape as claude.js
4
+ // (id, hookFile, register, normalize, render) and list it here.
5
+ export const HARNESSES = { claude };
6
+
7
+ export const DEFAULT_HARNESS = "claude";
8
+
9
+ export function getHarness(id = DEFAULT_HARNESS) {
10
+ const h = HARNESSES[id];
11
+ if (!h) {
12
+ throw new Error(`Unknown harness "${id}". Available: ${Object.keys(HARNESSES).join(", ")}`);
13
+ }
14
+ return h;
15
+ }
package/lib/init.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { DEFAULT_HARNESS, getHarness } from "./harnesses/index.js";
5
+
6
+ const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
3
7
 
4
8
  const DEFAULT_CONFIG = {
5
9
  version: 1,
6
- harness: "cursor",
7
- recall: true,
8
- capture: true,
10
+ harness: "claude",
9
11
  minConfidence: 0.7,
10
12
  store: {
11
13
  procedures: "procedures.jsonl",
@@ -29,9 +31,14 @@ function writeIfMissing(filePath, contents) {
29
31
  return "created";
30
32
  }
31
33
 
34
+ function writeAlways(filePath, contents) {
35
+ fs.writeFileSync(filePath, contents, "utf8");
36
+ return "updated";
37
+ }
38
+
32
39
  function ensureGitignore(cwd) {
33
40
  const gitignorePath = path.join(cwd, ".gitignore");
34
- const entry = ".reflex/";
41
+ const entry = ".reflex/traces/";
35
42
 
36
43
  if (!fs.existsSync(gitignorePath)) {
37
44
  fs.writeFileSync(gitignorePath, `${entry}\n`, "utf8");
@@ -41,7 +48,8 @@ function ensureGitignore(cwd) {
41
48
  const current = fs.readFileSync(gitignorePath, "utf8");
42
49
  const lines = current.split(/\r?\n/);
43
50
  const alreadyIgnored = lines.some(
44
- (line) => line.trim() === ".reflex/" || line.trim() === ".reflex"
51
+ (line) =>
52
+ line.trim() === ".reflex/traces/" || line.trim() === ".reflex/traces",
45
53
  );
46
54
 
47
55
  if (alreadyIgnored) {
@@ -53,18 +61,62 @@ function ensureGitignore(cwd) {
53
61
  return "updated";
54
62
  }
55
63
 
56
- export function init(cwd = process.cwd()) {
64
+ // One bundled file, built by `npm run build` into dist/runtime.js.
65
+ // npx doesn't leave the package behind, so the repo carries this artifact.
66
+ const RUNTIME_SRC = path.join(LIB_DIR, "..", "dist", "runtime.js");
67
+
68
+ function installRuntime(reflexDir) {
69
+ if (!fs.existsSync(RUNTIME_SRC)) {
70
+ throw new Error("Missing dist/runtime.js. Run `npm run build` in the agent-procedures repo first.");
71
+ }
72
+ fs.copyFileSync(RUNTIME_SRC, path.join(reflexDir, "runtime.js"));
73
+ const engineDir = path.join(reflexDir, "engine");
74
+ if (fs.existsSync(engineDir)) fs.rmSync(engineDir, { recursive: true, force: true });
75
+ return "updated";
76
+ }
77
+
78
+ // A few lines that point the harness's hook at the shared engine.
79
+ function writeShim(cwd, harness) {
80
+ const hookPath = path.join(cwd, harness.hookFile);
81
+ const enginePath = path.join(cwd, ".reflex", "runtime.js");
82
+ let rel = path.relative(path.dirname(hookPath), enginePath).split(path.sep).join("/");
83
+ if (!rel.startsWith(".")) rel = "./" + rel;
84
+
85
+ ensureDir(path.dirname(hookPath));
86
+ writeAlways(
87
+ hookPath,
88
+ `#!/usr/bin/env node
89
+ // Generated by agent-procedures init. Re-run init to refresh; do not edit.
90
+ import { run } from "${rel}";
91
+ await run(${JSON.stringify(harness.id)});
92
+ `,
93
+ );
94
+ fs.chmodSync(hookPath, 0o755);
95
+ }
96
+
97
+ export function init(cwd = process.cwd(), { harness: harnessId = DEFAULT_HARNESS } = {}) {
98
+ const harness = getHarness(harnessId);
57
99
  const reflexDir = path.join(cwd, ".reflex");
100
+ const tracesDir = path.join(reflexDir, "traces");
101
+
102
+ ensureDir(reflexDir);
103
+ ensureDir(tracesDir);
104
+
58
105
  const results = {
59
- dir: ensureDir(reflexDir),
106
+ dir: "exists",
60
107
  config: writeIfMissing(
61
108
  path.join(reflexDir, "config.json"),
62
- `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`
109
+ `${JSON.stringify({ ...DEFAULT_CONFIG, harness: harness.id }, null, 2)}\n`,
63
110
  ),
64
111
  procedures: writeIfMissing(path.join(reflexDir, "procedures.jsonl"), ""),
65
112
  runs: writeIfMissing(path.join(reflexDir, "runs.jsonl"), ""),
66
113
  gitignore: ensureGitignore(cwd),
114
+ runtime: installRuntime(reflexDir),
67
115
  };
68
116
 
117
+ writeShim(cwd, harness);
118
+ harness.register(cwd);
119
+ results.hooks = "updated";
120
+
69
121
  return results;
70
122
  }