@eventmodelers/cli 1.0.163 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -27
- package/RELEASE_NOTES.md +20 -2
- package/cli.js +461 -225
- package/lib/adapters/spec-kitty-adapter.js +3 -7
- package/lib/global-agent-id.js +45 -0
- package/lib/modeling-local-ai.js +4 -2
- package/package.json +1 -1
- package/shared/build-kit/README.md +4 -19
- package/shared/build-kit/lib/agent-lock.js +75 -0
- package/shared/build-kit/lib/agent-name.js +27 -0
- package/shared/build-kit/lib/event-prune.js +58 -0
- package/shared/build-kit/lib/events-view.js +121 -0
- package/shared/build-kit/lib/local-ai-agent.js +14 -26
- package/shared/build-kit/lib/mcp-register.js +17 -1
- package/shared/build-kit/lib/prompt-journal.js +191 -0
- package/shared/build-kit/lib/prompt-replay.js +35 -0
- package/shared/build-kit/lib/ralph.js +201 -156
- package/shared/build-kit/lib/slice-curation.js +40 -0
- package/shared/build-kit/lib/slice-events.js +93 -0
- package/shared/build-kit/lib/slice-git.js +86 -0
- package/shared/build-kit/lib/slice-learnings.js +197 -0
- package/shared/build-kit/lib/slice-projection.js +101 -0
- package/shared/build-kit/lib/slice-run.js +181 -0
- package/shared/build-kit/lib/slice-state.js +182 -0
- package/shared/build-kit/lib/slice-tasks.js +35 -0
- package/shared/build-kit/ralph-claude.js +4 -2
- package/shared/build-kit/ralph-exec.js +4 -2
- package/shared/build-kit/ralph-local-ai.js +5 -3
- package/shared/skills/learn-eventmodelers-api/SKILL.md +13 -0
- package/shared/skills/request-feedback/SKILL.md +1 -2
- package/shared/skills/ui-contract/SKILL.md +5 -3
- package/stacks/axon/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/axon/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/blank/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/blank/templates/build-kit/lib/prompt.md +19 -42
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
- package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
- package/stacks/classic/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
- package/stacks/classic/templates/build-kit/lib/prompt.md +19 -42
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +3 -4
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +9 -24
- package/stacks/modeling-kit/templates/kit/README.md +1 -1
- package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +3 -4
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
- package/stacks/react/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/react/templates/build-kit/lib/prompt.md +19 -42
- package/stacks/supabase/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +2 -2
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +2 -2
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +19 -42
- package/stacks/umadb/templates/build-kit/CLAUDE.md +3 -3
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/umadb/templates/build-kit/lib/prompt.md +18 -41
- package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
- package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
- package/shared/build-kit/lib/agent.sh +0 -20
- package/shared/build-kit/ralph.sh +0 -111
- package/shared/build-kit/realtime-agent.js +0 -20
- package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
- package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
- package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
- package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
- package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
- package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
|
@@ -14,11 +14,8 @@
|
|
|
14
14
|
// adapter's whole job is producing a good brief and calling `intake`, nothing
|
|
15
15
|
// more.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// it directly for a one-shot sync; `eventmodelers bridge` (via
|
|
20
|
-
// ralph-static.js) calls the same run() from a copy the bridge-kit installer
|
|
21
|
-
// places at .bridge-kit/adapters/ — see installStack in cli.js.
|
|
17
|
+
// Its one entry point is `eventmodelers fetch --spec-kitty`, a one-shot sync. (A standing bridge loop that
|
|
18
|
+
// ran the same run() from a copy in .bridge-kit/ is retired.)
|
|
22
19
|
//
|
|
23
20
|
// Exports run({ cfg, projectDir, contextName }).
|
|
24
21
|
|
|
@@ -43,8 +40,7 @@ function slugify(text) {
|
|
|
43
40
|
|
|
44
41
|
// Mirrors lib/fetch.js's per-context call rather than importing runFetch
|
|
45
42
|
// directly — this fetches raw JSON only, it never touches .slices/ (fetch.js
|
|
46
|
-
// owns writing that)
|
|
47
|
-
// inside an installed .bridge-kit/ with no access to lib/fetch.js at all.
|
|
43
|
+
// owns writing that).
|
|
48
44
|
async function fetchFullSliceData(cfg, contextName) {
|
|
49
45
|
const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
|
|
50
46
|
const url = `${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?contextName=${encodeURIComponent(contextName)}`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A run on the global install keeps one agent id per board and repo — "repo" being the directory the run is
|
|
2
|
+
// started in (its git root, or the directory itself outside git). It is the same agent on every start: its chat
|
|
3
|
+
// sessions, its star on the board and the prompts it holds all stay addressed to it. The ids live in one small
|
|
4
|
+
// file next to the per-board credentials; nothing is written into the repo.
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from 'child_process';
|
|
7
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import { dirname } from 'path';
|
|
10
|
+
|
|
11
|
+
function real(path) {
|
|
12
|
+
try { return realpathSync(path); } catch { return path; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The git root of `dir`, else `dir` — both with symlinks resolved, so two paths to one repo are one repo.
|
|
16
|
+
export function repoKey(dir) {
|
|
17
|
+
const start = real(dir);
|
|
18
|
+
try {
|
|
19
|
+
const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: start, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
20
|
+
return top ? real(top) : start;
|
|
21
|
+
} catch {
|
|
22
|
+
return start;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readIds(file) {
|
|
27
|
+
try {
|
|
28
|
+
const ids = JSON.parse(readFileSync(file, 'utf-8'));
|
|
29
|
+
return ids && typeof ids === 'object' && !Array.isArray(ids) ? ids : {};
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stableGlobalAgentId({ file, boardId, dir, mint = randomUUID }) {
|
|
36
|
+
const key = `${boardId}|${repoKey(dir)}`;
|
|
37
|
+
const ids = readIds(file);
|
|
38
|
+
if (typeof ids[key] === 'string' && ids[key]) return ids[key];
|
|
39
|
+
const id = mint();
|
|
40
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
41
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
42
|
+
writeFileSync(tmp, JSON.stringify({ ...ids, [key]: id }, null, 2), 'utf-8');
|
|
43
|
+
renameSync(tmp, file);
|
|
44
|
+
return id;
|
|
45
|
+
}
|
package/lib/modeling-local-ai.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The build kit's own local runner (shared/build-kit/lib/local-ai-agent.js) is a
|
|
4
4
|
// template copied into user projects, so it owns its whole lifecycle: it reads
|
|
5
|
-
// config.json off disk, pulls one task
|
|
5
|
+
// config.json off disk, pulls one task off the queue and exits. This one is the
|
|
6
6
|
// same idea mounted the other way round — the modeling loop already owns the prompt
|
|
7
7
|
// queue, the standalone board-change lane and the idle review, and only ever needed
|
|
8
8
|
// something to hand a turn's text to. So this exports a runner with one method,
|
|
@@ -175,7 +175,7 @@ function systemPrompt({ boardId, organizationId, standalone }) {
|
|
|
175
175
|
].join('\n');
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false }) {
|
|
178
|
+
export function createModelingLocalAiRunner({ cfg, target, log, verbose = false, standalone = false, onToolUse = () => () => {} }) {
|
|
179
179
|
const t = resolveLocalAiTarget({ target, localAi: cfg.localAi || {} });
|
|
180
180
|
let tools = null; // the MCP tool set, fetched once per session
|
|
181
181
|
|
|
@@ -269,6 +269,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
|
|
|
269
269
|
: rawArgs;
|
|
270
270
|
|
|
271
271
|
log(verbose ? `→ ${name}(${JSON.stringify(args).slice(0, 120)})` : `→ ${name}`);
|
|
272
|
+
const finish = onToolUse(name, args);
|
|
272
273
|
|
|
273
274
|
let toolResult;
|
|
274
275
|
try {
|
|
@@ -276,6 +277,7 @@ export function createModelingLocalAiRunner({ cfg, target, log, verbose = false,
|
|
|
276
277
|
} catch (err) {
|
|
277
278
|
toolResult = { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
278
279
|
}
|
|
280
|
+
finish({ isError: !!toolResult.isError });
|
|
279
281
|
if (verbose) log(` ${JSON.stringify(toolResult).slice(0, 160)}`);
|
|
280
282
|
|
|
281
283
|
messages.push({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,8 +27,6 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
27
27
|
| `ralph-claude.js` | Runs the full loop using Claude Code as the executor |
|
|
28
28
|
| `ralph-local-ai.js` | Runs the full loop using a local/self-hosted model (Ollama, vLLM, LM Studio, llama.cpp) |
|
|
29
29
|
| `ralph-exec.js` | Runs the full loop handing each prompt to an external agent command (Codex CLI, OpenCode, …) |
|
|
30
|
-
| `ralph.sh` | Shell-based loop — alternative to the JS entry points |
|
|
31
|
-
| `realtime-agent.js` | Standalone realtime agent — only needed to run it in a separate terminal |
|
|
32
30
|
|
|
33
31
|
**Internals** (`lib/`):
|
|
34
32
|
|
|
@@ -36,16 +34,15 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
36
34
|
|------|---------|
|
|
37
35
|
| `lib/ralph.js` | Shared library — realtime agent + loop logic; imported by the entry points |
|
|
38
36
|
| `lib/local-ai-agent.js` | Local-AI executor — called by `ralph-local-ai.js`, can also run manually |
|
|
39
|
-
| `lib/agent.sh` | Thin shell wrapper around `claude` — called by `ralph.sh` |
|
|
40
37
|
| `lib/prompt.md` | Phase 1 prompt: tells Claude how to load a slice from the board |
|
|
41
38
|
| `lib/backend-prompt.md` | Phase 2 prompt: tells Claude how to build a planned slice |
|
|
42
39
|
| `lib/AGENT.md` | Agent instructions included in Claude's context |
|
|
43
40
|
|
|
44
41
|
## How it works
|
|
45
42
|
|
|
46
|
-
**Phase 1** — triggered when
|
|
47
|
-
- The realtime agent
|
|
48
|
-
- The loop
|
|
43
|
+
**Phase 1** — triggered when the task queue has entries (the queue is the event log in `.build-kit/events/`):
|
|
44
|
+
- The realtime agent queues a task (`task-queued`) each time a `slice:changed` event arrives from the board; a newer change to the same slice replaces its queued task
|
|
45
|
+
- The loop hands the oldest one to Claude (or a local model) with `prompt.md` and completes it (`task-done`) when the turn ends
|
|
49
46
|
- Claude loads the slice data and updates `.slices/`
|
|
50
47
|
|
|
51
48
|
**Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
|
|
@@ -55,18 +52,6 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
55
52
|
|
|
56
53
|
Both phases run in a continuous loop with a 3-second idle sleep. The realtime agent runs concurrently in the same process.
|
|
57
54
|
|
|
58
|
-
## Running the realtime agent separately
|
|
59
|
-
|
|
60
|
-
If you want the board subscription in one terminal and the Claude loop in another:
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
# Terminal 1 — realtime agent only
|
|
64
|
-
node .build-kit/realtime-agent.js
|
|
65
|
-
|
|
66
|
-
# Terminal 2 — loop only (poll tasks.json without the realtime subscription)
|
|
67
|
-
.build-kit/ralph.sh
|
|
68
|
-
```
|
|
69
|
-
|
|
70
55
|
## Local-AI configuration
|
|
71
56
|
|
|
72
57
|
`ralph-local-ai.js` drives any local or self-hosted model that can do tool calling.
|
|
@@ -78,7 +63,7 @@ Claude (`ralph-claude.js`) stays the default runner — this is opt-in.
|
|
|
78
63
|
Planned. That is by design, not a bug:
|
|
79
64
|
|
|
80
65
|
- **Planned slices are never handed to it.** `ralph-local-ai.js` gives `startRalph` no
|
|
81
|
-
`onPlannedSlice`, so the loop only feeds it
|
|
66
|
+
`onPlannedSlice`, so the loop only feeds it queued tasks.
|
|
82
67
|
- **It has no coding tools.** `lib/local-ai-agent.js` exposes only the board MCP tools:
|
|
83
68
|
no file read/write, no shell, no git, no test run.
|
|
84
69
|
- **Its system prompt forbids file and shell access.** Such a request is answered with
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// One agent id, one live process. An agent keeps its stable id (see ensureAgentId); a second process that would
|
|
2
|
+
// share it — two `run`s in one project, two standalone runs from one repo — gets a fresh id instead. Sharing one
|
|
3
|
+
// would make them fight over a single heartbeat row and a single chat, and the second one's start-up recovery
|
|
4
|
+
// would hand back the first one's prompts.
|
|
5
|
+
//
|
|
6
|
+
// The id is claimed with an exclusive lock file (`<id>.lock`, holding the pid) that the process removes on exit. It
|
|
7
|
+
// is written complete under a private name and then hard-linked into place, so a competitor never finds it empty
|
|
8
|
+
// and mistakes a lock being taken for one left over.
|
|
9
|
+
// A lock left by a process that is gone — a crash, kill -9 — is taken over. A lock problem never stops an agent:
|
|
10
|
+
// if locking is impossible the stable id is simply kept.
|
|
11
|
+
|
|
12
|
+
import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { isProcessAlive } from './slice-run.js';
|
|
16
|
+
|
|
17
|
+
const answers = new Map(); // lock file path → the answer given in this process, so asking twice is one decision
|
|
18
|
+
const owned = new Set(); // lock files this process holds
|
|
19
|
+
let exitHooked = false;
|
|
20
|
+
|
|
21
|
+
function release() {
|
|
22
|
+
for (const path of owned) {
|
|
23
|
+
try {
|
|
24
|
+
if (JSON.parse(readFileSync(path, 'utf-8')).pid === process.pid) rmSync(path, { force: true });
|
|
25
|
+
} catch { /* already gone */ }
|
|
26
|
+
}
|
|
27
|
+
owned.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// true: the lock is ours; false: a live process of another pid holds it.
|
|
31
|
+
function lock(path, pid, isAlive) {
|
|
32
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const mine = `${path}.${pid}.${randomUUID()}.tmp`;
|
|
35
|
+
writeFileSync(mine, JSON.stringify({ pid, startedAt: new Date().toISOString() }));
|
|
36
|
+
try {
|
|
37
|
+
linkSync(mine, path); // EEXIST when someone holds it — atomically, and never half written
|
|
38
|
+
} finally {
|
|
39
|
+
rmSync(mine, { force: true });
|
|
40
|
+
}
|
|
41
|
+
owned.add(path);
|
|
42
|
+
if (!exitHooked) { exitHooked = true; process.on('exit', release); }
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code !== 'EEXIST') return true; // cannot lock here (permissions, odd filesystem): do not get in the way
|
|
46
|
+
let holder = null;
|
|
47
|
+
try { holder = JSON.parse(readFileSync(path, 'utf-8')).pid; } catch { /* unreadable: left over */ }
|
|
48
|
+
if (holder === pid) return true;
|
|
49
|
+
if (holder !== null && isAlive(holder)) return false;
|
|
50
|
+
rmSync(path, { force: true }); // left over by a process that is gone — take it over
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `stableId` is the id this agent keeps. Returns { agentId, fellBack }: the stable id when this process got the
|
|
57
|
+
// lock, else a fresh one. Decided once per process and lock directory.
|
|
58
|
+
export function acquireAgentId({ lockDir, stableId, mint = randomUUID, isAlive = isProcessAlive, pid = process.pid }) {
|
|
59
|
+
const path = join(lockDir, `${stableId}.lock`);
|
|
60
|
+
if (answers.has(path)) return answers.get(path);
|
|
61
|
+
let taken = true;
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(lockDir, { recursive: true });
|
|
64
|
+
taken = lock(path, pid, isAlive);
|
|
65
|
+
} catch { /* cannot lock here: keep the stable id */ }
|
|
66
|
+
const answer = taken ? { agentId: stableId, fellBack: false } : { agentId: mint(), fellBack: true };
|
|
67
|
+
answers.set(path, answer);
|
|
68
|
+
return answer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Releases this process's locks and forgets what it decided — for tests, and a clean shutdown.
|
|
72
|
+
export function forgetAgentLocks() {
|
|
73
|
+
release();
|
|
74
|
+
answers.clear();
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The name an agent shows on the board when nobody gave it one: the repository it runs in and what it is
|
|
2
|
+
// ("cart-service (build)"). Derived on every start, so it is stable for as long as the repo is, and never
|
|
3
|
+
// written into the project's config — `agentName` there (init --name) and `run --name` still win.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { basename } from 'path';
|
|
7
|
+
|
|
8
|
+
// `git@github.com:acme/cart-service.git`, `https://host/acme/cart-service/`, `ssh://git@host:2222/a/b.git` →
|
|
9
|
+
// the last path segment without `.git`.
|
|
10
|
+
function repoNameFromRemote(url) {
|
|
11
|
+
const last = url.trim().replace(/\/+$/, '').split(/[/:]/).pop() ?? '';
|
|
12
|
+
return last.replace(/\.git$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defaultAgentName(projectDir, agentType = 'BUILD') {
|
|
16
|
+
let repo = '';
|
|
17
|
+
try {
|
|
18
|
+
repo = repoNameFromRemote(execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
19
|
+
} catch { /* no remote, or not a git repo */ }
|
|
20
|
+
return `${repo || basename(projectDir)} (${agentType.toLowerCase()})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A second agent in the same repo gets a fresh id (see agent-lock.js); its default name says which instance it is,
|
|
24
|
+
// so the board does not show two rows of the same name.
|
|
25
|
+
export function withInstanceSuffix(name, agentId) {
|
|
26
|
+
return `${name} · ${String(agentId).slice(0, 4)}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pruning the event log. The log only grows, and most of it is the prompt journal — a received/finished pair per
|
|
2
|
+
// board prompt, plus every tool call of a `--verbose` run — which nothing reads once the prompt is finished. What
|
|
3
|
+
// the state is derived from (claims, settles, learnings, the task queue, prompts still open) is never touched.
|
|
4
|
+
// Before anything is deleted the state is folded from what would be left and compared with the state folded from
|
|
5
|
+
// the whole log; a difference aborts the prune.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
8
|
+
import { isDeepStrictEqual } from 'util';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { EVENTS_DIR, INDEX_FILE, readEvents } from './slice-events.js';
|
|
11
|
+
import { fold } from './slice-state.js';
|
|
12
|
+
|
|
13
|
+
export const PRUNE_EVERY = 1000;
|
|
14
|
+
const ASKED_FILE = '.prune-asked'; // inside events/, which readEvents never lists and the kit's git rules already cover
|
|
15
|
+
|
|
16
|
+
const JOURNAL_TYPES = new Set(['prompt-received', 'prompt-finished', 'prompt-action', 'tool-call']);
|
|
17
|
+
|
|
18
|
+
// The events of prompts that have finished (and of nothing else).
|
|
19
|
+
export function prunableEvents(events) {
|
|
20
|
+
const finished = new Set(events.filter((e) => e?.type === 'prompt-finished').map((e) => e.prompt));
|
|
21
|
+
return events.filter((e) => e && JOURNAL_TYPES.has(e.type) && finished.has(e.type === 'prompt-received' ? e.prompt?.id : e.prompt));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Deletes them: their files, then their lines from the index (rewritten whole and renamed into place). Returns
|
|
25
|
+
// { removed, kept }; removes nothing when there is nothing to remove, and throws without touching the log when
|
|
26
|
+
// the state folded from the rest would differ.
|
|
27
|
+
export function pruneLog(kitDir) {
|
|
28
|
+
const events = readEvents(kitDir);
|
|
29
|
+
const doomed = new Set(prunableEvents(events).map((e) => e.id));
|
|
30
|
+
if (!doomed.size) return { removed: 0, kept: events.length };
|
|
31
|
+
const rest = events.filter((e) => !doomed.has(e.id));
|
|
32
|
+
if (!isDeepStrictEqual(fold(rest), fold(events))) throw new Error('pruning would change the state the log folds to — nothing was deleted');
|
|
33
|
+
|
|
34
|
+
for (const id of doomed) rmSync(join(kitDir, EVENTS_DIR, `${id}.json`), { force: true });
|
|
35
|
+
const index = join(kitDir, INDEX_FILE);
|
|
36
|
+
const lines = readFileSync(index, 'utf-8').split('\n').filter((l) => l.trim() && !doomed.has(l.trim()));
|
|
37
|
+
const tmp = `${index}.tmp`;
|
|
38
|
+
writeFileSync(tmp, lines.length ? `${lines.join('\n')}\n` : '', 'utf-8');
|
|
39
|
+
renameSync(tmp, index);
|
|
40
|
+
return { removed: doomed.size, kept: rest.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Asked when the log has reached a multiple of PRUNE_EVERY it has not been asked at yet — 1000, then 2000, … — so a
|
|
44
|
+
// "no" is not repeated on every start. `remember` records the size the answer was given at.
|
|
45
|
+
const askedPath = (kitDir) => join(kitDir, EVENTS_DIR, ASKED_FILE);
|
|
46
|
+
const bucket = (n) => Math.floor(n / PRUNE_EVERY);
|
|
47
|
+
|
|
48
|
+
export function shouldOfferPrune(kitDir, count) {
|
|
49
|
+
if (bucket(count) < 1) return false;
|
|
50
|
+
let asked = 0;
|
|
51
|
+
try { asked = Number(readFileSync(askedPath(kitDir), 'utf-8')) || 0; } catch { /* never asked */ }
|
|
52
|
+
return bucket(count) > bucket(asked);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function rememberPruneAnswer(kitDir, count) {
|
|
56
|
+
if (!existsSync(join(kitDir, EVENTS_DIR))) mkdirSync(join(kitDir, EVENTS_DIR), { recursive: true });
|
|
57
|
+
writeFileSync(askedPath(kitDir), String(count), 'utf-8');
|
|
58
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The slice-state event log, decoded for a terminal (`eventmodelers events`): one plain-text line per event, the
|
|
2
|
+
// filters that go with it, and a summary of the state the log folds to. Colour is the command's business.
|
|
3
|
+
|
|
4
|
+
const short = (id) => (id ? String(id).slice(0, 8) : '');
|
|
5
|
+
const oneLine = (text, max) => {
|
|
6
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
7
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
8
|
+
};
|
|
9
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
10
|
+
|
|
11
|
+
// slice id → title, from what the log itself knows (a claim records the slice's title).
|
|
12
|
+
export function sliceTitles(events) {
|
|
13
|
+
const titles = {};
|
|
14
|
+
for (const e of events) if (e.type === 'claimed' && e.slice && e.title) titles[e.slice] = e.title;
|
|
15
|
+
return titles;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// How long a call took, and that it failed — nothing for an event without a duration.
|
|
19
|
+
const took = (e) => (typeof e.durationMs === 'number' ? ` (${e.durationMs < 1000 ? `${e.durationMs}ms` : `${(e.durationMs / 1000).toFixed(1)}s`}${e.error ? ', failed' : ''})` : '');
|
|
20
|
+
|
|
21
|
+
// [subject, detail] — what the event is about, and what it says.
|
|
22
|
+
function describe(e) {
|
|
23
|
+
const tags = e.tags?.length ? ` ${e.tags.map((t) => `#${t}`).join(' ')}` : '';
|
|
24
|
+
switch (e.type) {
|
|
25
|
+
case 'board-observed': case 'agent-recorded': case 'settled':
|
|
26
|
+
return [e.slice, e.status ?? ''];
|
|
27
|
+
case 'claimed': {
|
|
28
|
+
const tree = e.cleanTree === true ? 'clean tree' : e.cleanTree === false ? 'dirty tree' : 'not a git tree';
|
|
29
|
+
return [e.slice, `pid ${e.pid}, ${tree}${e.base ? `, ${e.base.branch}@${String(e.base.head).slice(0, 8)}` : ''}${e.ticketNumber ? `, ticket ${e.ticketNumber}` : ''}${e.assignee ? `, assignee ${e.assignee}` : ''}`];
|
|
30
|
+
}
|
|
31
|
+
case 'claim-confirmed':
|
|
32
|
+
return [e.slice, ''];
|
|
33
|
+
case 'released':
|
|
34
|
+
return [e.slice, e.reason ?? ''];
|
|
35
|
+
case 'attempt-failed': {
|
|
36
|
+
const files = String(e.changed ?? '').split('\n').filter((l) => l.trim()).length;
|
|
37
|
+
const parts = [e.reason, e.status ? `back to ${e.status}` : '', files ? plural(files, 'file') : '', e.branch ?? ''].filter(Boolean);
|
|
38
|
+
return [e.slice, `${parts.join(', ')}${e.lastReply ? `: ${oneLine(e.lastReply, 80)}` : ''}`];
|
|
39
|
+
}
|
|
40
|
+
case 'blocked':
|
|
41
|
+
return [e.slice, oneLine(e.reason, 120)];
|
|
42
|
+
case 'task-queued':
|
|
43
|
+
return [e.payload?.sliceId, e.payload?.sliceStatus ?? ''];
|
|
44
|
+
case 'task-done':
|
|
45
|
+
return [e.task, ''];
|
|
46
|
+
case 'learning-recorded':
|
|
47
|
+
return ['', `[${e.topic ?? 'General'}] ${oneLine(e.text, 160)} (${e.scope ?? 'repo'}${tags})`];
|
|
48
|
+
case 'learning-retracted':
|
|
49
|
+
return [e.learning, e.reason ? oneLine(e.reason, 120) : ''];
|
|
50
|
+
case 'learning-merged':
|
|
51
|
+
return ['', `${plural(e.sources?.length ?? e.learnings?.length ?? 0, 'learning')} → ${oneLine(e.text, 120)}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
|
|
52
|
+
case 'learnings-curated':
|
|
53
|
+
return ['', ''];
|
|
54
|
+
case 'prompt-received':
|
|
55
|
+
return [e.prompt?.id, `"${oneLine(e.prompt?.text, 80)}"${e.prompt?.priority ? ', priority' : ''}, pid ${e.pid}`];
|
|
56
|
+
case 'prompt-action':
|
|
57
|
+
return [e.prompt, `${e.action ?? ''}${e.detail ? ` ${oneLine(e.detail, 140)}` : ''}${took(e)}`];
|
|
58
|
+
case 'tool-call':
|
|
59
|
+
return [e.prompt, `${oneLine(e.detail ?? e.tool, 140)}${took(e)}`];
|
|
60
|
+
case 'prompt-finished':
|
|
61
|
+
return [e.prompt, `${e.outcome ?? ''}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
|
|
62
|
+
default: {
|
|
63
|
+
const { type, id, at, meta, slice, ...rest } = e;
|
|
64
|
+
return [slice, oneLine(JSON.stringify(rest), 160)];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One line: date and time (UTC, as logged), type, subject (a slice with its title when known), detail, and the first four characters of the
|
|
70
|
+
// agent that wrote it with its type (build or modeling) — a log shared by several agents says who did what.
|
|
71
|
+
export function formatEvent(e, { titles = {} } = {}) {
|
|
72
|
+
const time = typeof e.at === 'string' && e.at.length >= 19 ? `${e.at.slice(0, 10)} ${e.at.slice(11, 19)}` : '-'.repeat(19);
|
|
73
|
+
const [rawSubject, detail] = describe(e);
|
|
74
|
+
const title = titles[rawSubject];
|
|
75
|
+
const subject = rawSubject ? `${short(rawSubject)}${title ? ` "${title}"` : ''}` : '';
|
|
76
|
+
const agent = [e.meta?.agent_id ? String(e.meta.agent_id).slice(0, 4) : '', String(e.meta?.agent_type ?? '').toLowerCase()].filter(Boolean).join(' ');
|
|
77
|
+
return [time, String(e.type).padEnd(18), subject, detail, agent].filter((p, i) => i < 2 || p).join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What an event carries beyond its line, indented under it: every field but the ones the line already shows
|
|
81
|
+
// (type, id, time, who). Empty when there is nothing more to say.
|
|
82
|
+
export function formatPayload(e) {
|
|
83
|
+
const { type, id, at, meta, ...rest } = e;
|
|
84
|
+
if (!Object.keys(rest).length) return '';
|
|
85
|
+
return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
|
|
89
|
+
// the log; last keeps the final N that remain.
|
|
90
|
+
export function filterEvents(events, { types, slice, tag, board, last } = {}, titles = {}) {
|
|
91
|
+
let out = events;
|
|
92
|
+
if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
|
|
93
|
+
if (types?.length) out = out.filter((e) => types.includes(e.type));
|
|
94
|
+
if (slice) {
|
|
95
|
+
const wanted = slice.toLowerCase();
|
|
96
|
+
out = out.filter((e) => {
|
|
97
|
+
const id = e.slice ?? e.payload?.sliceId;
|
|
98
|
+
return !!id && (String(id).toLowerCase().startsWith(wanted) || String(titles[id] ?? '').toLowerCase().includes(wanted));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (tag) out = out.filter((e) => e.tags?.includes(tag));
|
|
102
|
+
return last ? out.slice(-last) : out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What the log folds to: slices (status, holder, attempts), the queue, prompts in hand, learnings.
|
|
106
|
+
export function summarize(state, titles = {}) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const slices = Object.entries(state.slices);
|
|
109
|
+
if (!slices.length) lines.push('no slices in the log');
|
|
110
|
+
for (const [id, s] of slices) {
|
|
111
|
+
const claim = s.claim ? ` claimed by pid ${s.claim.pid}${s.claim.confirmed ? '' : ' (unconfirmed)'}` : '';
|
|
112
|
+
const name = titles[id] ?? s.claim?.title;
|
|
113
|
+
const title = name ? ` "${name}"` : '';
|
|
114
|
+
const attempts = s.attempts ? ` ${plural(s.attempts, 'failed attempt')}` : '';
|
|
115
|
+
const blocked = s.blocked ? ` blocked: ${oneLine(s.blocked.reason, 80)}` : '';
|
|
116
|
+
lines.push(`${short(id)}${title} ${s.status ?? 'unknown'}${claim}${attempts}${blocked}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(`${plural(state.tasks.length, 'task')} waiting · ${plural(state.prompts.length, 'prompt')} open · ${plural(state.learnings.length, 'learning')}`);
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
// ollama — Ollama's native POST /api/chat
|
|
7
7
|
// openai — the OpenAI-compatible POST /v1/chat/completions that vLLM, LM Studio,
|
|
8
8
|
// llama.cpp-server, TGI, SGLang (and hosted gateways) all speak
|
|
9
|
-
// Everything above the transport — the MCP tool loop, the
|
|
9
|
+
// Everything above the transport — the MCP tool loop, the
|
|
10
10
|
// security prompt — is identical for both, which is why this is one file and not
|
|
11
11
|
// one kit per vendor.
|
|
12
12
|
//
|
|
13
13
|
// Usage: node local-ai-agent.js [model]
|
|
14
14
|
// LOCAL_AI_TARGET=vllm node local-ai-agent.js
|
|
15
15
|
// LOCAL_AI_URL=http://gpu-box:8000/v1 LOCAL_AI_MODEL=Qwen/Qwen3-8B node local-ai-agent.js
|
|
16
|
-
//
|
|
16
|
+
// The run loop (lib/ralph.js) picks the oldest queued task and hands it over as the prompt on stdin — one task
|
|
17
|
+
// per run, completed by the loop when this process exits cleanly. There is no queue file to read.
|
|
17
18
|
|
|
18
|
-
import { readFileSync
|
|
19
|
+
import { readFileSync } from 'fs';
|
|
19
20
|
import { resolve, dirname } from 'path';
|
|
20
21
|
import { fileURLToPath } from 'url';
|
|
21
22
|
|
|
@@ -254,28 +255,15 @@ async function runAgent(userPrompt, boardId) {
|
|
|
254
255
|
return 'Max tool iterations reached.';
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
async function
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const blocked = tasks.filter(t => t.blocked === true || t.blockedBy?.length > 0);
|
|
263
|
-
if (blocked.length > 0) {
|
|
264
|
-
console.error(`[local-ai] removing ${blocked.length} blocked task(s): ${blocked.map(t => t.id).join(', ')}`);
|
|
265
|
-
tasks = tasks.filter(t => !blocked.includes(t));
|
|
266
|
-
writeFileSync(tasksPath, JSON.stringify(tasks, null, 2));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const task = tasks[0];
|
|
270
|
-
if (!task) return;
|
|
271
|
-
|
|
272
|
-
console.error(`[local-ai] task=${task.id} prompts=${task.prompts.length}`);
|
|
273
|
-
|
|
274
|
-
for (const p of task.prompts) {
|
|
275
|
-
console.log(await runAgent(p.prompt, p.board_id || defaultBoardId));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
writeFileSync(tasksPath, JSON.stringify(tasks.slice(1), null, 2));
|
|
258
|
+
async function readStdin() {
|
|
259
|
+
let text = '';
|
|
260
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
261
|
+
return text;
|
|
279
262
|
}
|
|
280
263
|
|
|
281
|
-
await
|
|
264
|
+
const prompt = await readStdin();
|
|
265
|
+
if (!prompt.trim()) {
|
|
266
|
+
console.error('[local-ai] no prompt on stdin — this agent is started by the run loop with the task as its prompt');
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
console.log(await runAgent(prompt, defaultBoardId));
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// loop (ralph-exec.js).
|
|
4
4
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
6
7
|
import { homedir } from 'os';
|
|
7
8
|
import { dirname, join } from 'path';
|
|
8
9
|
|
|
@@ -12,6 +13,18 @@ export function shellQuote(s) {
|
|
|
12
13
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// Older OpenCode releases have no `run --standalone`; passing it makes `opencode run` print its
|
|
17
|
+
// usage and exit 1, so every turn fails. Ask the installed binary once. Overridable for tests.
|
|
18
|
+
let standaloneSupported = null;
|
|
19
|
+
export function opencodeSupportsStandalone() {
|
|
20
|
+
if (standaloneSupported === null) {
|
|
21
|
+
const r = spawnSync('opencode', ['run', '--help'], { encoding: 'utf8' });
|
|
22
|
+
standaloneSupported = /--standalone\b/.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
23
|
+
}
|
|
24
|
+
return standaloneSupported;
|
|
25
|
+
}
|
|
26
|
+
export function setOpencodeStandaloneSupport(value) { standaloneSupported = value; }
|
|
27
|
+
|
|
15
28
|
// Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
|
|
16
29
|
// that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
|
|
17
30
|
// the caller says what to add by hand.
|
|
@@ -50,7 +63,10 @@ const MCP_REGISTRATIONS = {
|
|
|
50
63
|
// A plain `opencode run` hands the turn to opencode's shared background service, which was
|
|
51
64
|
// started with some other env: {env:EVENTMODELERS_TOKEN} resolves to nothing there and the
|
|
52
65
|
// server refuses every call. --standalone runs a private server that inherits this one's env.
|
|
53
|
-
|
|
66
|
+
// A binary without the flag runs the turn in-process already, so the flag is dropped, not added.
|
|
67
|
+
const supported = opencodeSupportsStandalone();
|
|
68
|
+
const standalone = !supported ? command.replace(/\s--standalone\b/, '')
|
|
69
|
+
: /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
|
|
54
70
|
return { command: standalone, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
|
|
55
71
|
},
|
|
56
72
|
gemini: (command, projectDir, url) => {
|