@eventmodelers/cli 1.0.162 → 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 +250 -163
- 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/connect/SKILL.md +1 -5
- 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/attributes/SKILL.md +1 -17
- 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/examples/SKILL.md +0 -8
- package/stacks/modeling-kit/templates/.claude/skills/stressor-analysis/SKILL.md +0 -2
- package/stacks/modeling-kit/templates/.claude/skills/walkthrough/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/AGENTS.md +0 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -72
- 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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// When the loop spends a turn on the learnings. The curator is an LLM turn that only writes events —
|
|
2
|
+
// `learn --merge` and `learn --retract` — and the loop marks the review done afterwards (`learnings-curated`),
|
|
3
|
+
// whatever came of the turn: a curator that failed or timed out does not make the loop retry in a tight loop,
|
|
4
|
+
// the next review comes after more learnings have piled up.
|
|
5
|
+
|
|
6
|
+
import { appendEvent, readEvents } from './slice-events.js';
|
|
7
|
+
import { fold, needsCuration } from './slice-state.js';
|
|
8
|
+
import { curationRequest } from './slice-learnings.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_THRESHOLD = 10;
|
|
11
|
+
|
|
12
|
+
// Learnings recorded since the last review before a turn is worth it: RALPH_CURATE_AFTER, 0 switches
|
|
13
|
+
// curation off.
|
|
14
|
+
export function curationThreshold(env = process.env) {
|
|
15
|
+
const raw = env.RALPH_CURATE_AFTER;
|
|
16
|
+
if (raw === undefined || raw === '') return DEFAULT_THRESHOLD;
|
|
17
|
+
const n = Number(raw);
|
|
18
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_THRESHOLD;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Runs a curation turn when enough new learnings are waiting and no slice is being built. `onCurate(prompt,
|
|
22
|
+
// { env })` is the runner's agent turn; `env` must reach the agent's process (EVENTMODELERS_CURATING=1 makes
|
|
23
|
+
// `learn` refuse to record new learnings — the curator tidies, it does not add facts). It is passed along
|
|
24
|
+
// instead of set on process.env because the runners build their child environment once, at load time.
|
|
25
|
+
// Returns whether a turn was run.
|
|
26
|
+
export async function curateIfDue({ kitDir, meta, onCurate, threshold = curationThreshold() }) {
|
|
27
|
+
if (!onCurate || !threshold) return false;
|
|
28
|
+
const state = fold(readEvents(kitDir));
|
|
29
|
+
if (!needsCuration(state, threshold)) return false;
|
|
30
|
+
if (Object.values(state.slices).some((s) => s.claim)) return false;
|
|
31
|
+
|
|
32
|
+
console.log(`[ralph] ${state.learnings.filter((l) => !l.curated).length} new learnings — curating them.`);
|
|
33
|
+
try {
|
|
34
|
+
await onCurate(curationRequest(state.learnings), { env: { EVENTMODELERS_CURATING: '1' } });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error('[ralph] The curation turn failed — the learnings stay as they are:', err.message);
|
|
37
|
+
}
|
|
38
|
+
appendEvent(kitDir, { type: 'learnings-curated' }, meta);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The slice-state event store, local to this checkout and never committed:
|
|
2
|
+
// .build-kit/events/<id>.json one standalone JSON file per event
|
|
3
|
+
// .build-kit/events.log append-only index, one event id per line — its order is the event order
|
|
4
|
+
// An event exists once its id is in the index. A file the index doesn't list (a crash between the two
|
|
5
|
+
// writes) is never applied, and an index line without a readable file is skipped. `fold` (slice-state.js)
|
|
6
|
+
// derives all state from readEvents.
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'fs';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
export const EVENTS_DIR = 'events';
|
|
13
|
+
export const INDEX_FILE = 'events.log';
|
|
14
|
+
|
|
15
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
|
+
|
|
17
|
+
// True when the index ends in the middle of a line — a crash during an append left a fragment.
|
|
18
|
+
function endsMidLine(path) {
|
|
19
|
+
if (!existsSync(path)) return false;
|
|
20
|
+
const fd = openSync(path, 'r');
|
|
21
|
+
try {
|
|
22
|
+
const { size } = fstatSync(fd);
|
|
23
|
+
if (size === 0) return false;
|
|
24
|
+
const last = Buffer.alloc(1);
|
|
25
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
26
|
+
return last[0] !== 0x0a;
|
|
27
|
+
} finally {
|
|
28
|
+
closeSync(fd);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Tags label any event (an area, a topic, who it is for). Anything but non-blank strings is dropped, the
|
|
33
|
+
// rest trimmed and de-duplicated; an event without usable tags has no `tags` field at all.
|
|
34
|
+
export function normalizeTags(tags) {
|
|
35
|
+
if (!Array.isArray(tags)) return [];
|
|
36
|
+
return [...new Set(tags.filter((t) => typeof t === 'string').map((t) => t.trim()).filter(Boolean))];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Where an event came from: the agent that wrote it, what kind it is (BUILD or MODELING) and the organization and
|
|
40
|
+
// board it ran against. Only these four keys are kept (never a token or anything else that wandered into the config), blank values
|
|
41
|
+
// dropped; no usable metadata means no `meta` field.
|
|
42
|
+
const META_KEYS = ['agent_id', 'agent_type', 'organization_id', 'board_id'];
|
|
43
|
+
export function normalizeMeta(meta) {
|
|
44
|
+
const clean = {};
|
|
45
|
+
for (const key of META_KEYS) {
|
|
46
|
+
const value = typeof meta?.[key] === 'string' ? meta[key].trim() : '';
|
|
47
|
+
if (value) clean[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return clean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The event file is written whole (temp file, then rename) before its id is indexed, so a reader that
|
|
53
|
+
// finds the id finds a complete file. The index line is one short append-mode write, so lines from
|
|
54
|
+
// concurrent processes never interleave however large the event. After a torn index line the new id
|
|
55
|
+
// starts on a fresh line instead of being glued onto the fragment. Returns the event id.
|
|
56
|
+
export function appendEvent(kitDir, event, meta) {
|
|
57
|
+
const dir = join(kitDir, EVENTS_DIR);
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
const tmp = join(dir, `${id}.tmp`);
|
|
61
|
+
// The caller's metadata is the only metadata: one carried in the event itself is dropped.
|
|
62
|
+
const { tags, meta: _ignored, ...rest } = event;
|
|
63
|
+
const cleanTags = normalizeTags(tags);
|
|
64
|
+
const cleanMeta = normalizeMeta(meta);
|
|
65
|
+
writeFileSync(tmp, JSON.stringify({
|
|
66
|
+
at: new Date().toISOString(),
|
|
67
|
+
...rest,
|
|
68
|
+
...(cleanTags.length ? { tags: cleanTags } : {}),
|
|
69
|
+
...(Object.keys(cleanMeta).length ? { meta: cleanMeta } : {}),
|
|
70
|
+
id,
|
|
71
|
+
}, null, 2), 'utf-8');
|
|
72
|
+
renameSync(tmp, join(dir, `${id}.json`));
|
|
73
|
+
const index = join(kitDir, INDEX_FILE);
|
|
74
|
+
appendFileSync(index, `${endsMidLine(index) ? '\n' : ''}${id}\n`, 'utf-8');
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// All events in index order; with `tag`, only those carrying it.
|
|
79
|
+
export function readEvents(kitDir, { tag } = {}) {
|
|
80
|
+
const index = join(kitDir, INDEX_FILE);
|
|
81
|
+
if (!existsSync(index)) return [];
|
|
82
|
+
const events = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const line of readFileSync(index, 'utf-8').split('\n')) {
|
|
85
|
+
const id = line.trim();
|
|
86
|
+
if (!ID.test(id) || seen.has(id)) continue;
|
|
87
|
+
seen.add(id);
|
|
88
|
+
try {
|
|
89
|
+
events.push(JSON.parse(readFileSync(join(kitDir, EVENTS_DIR, `${id}.json`), 'utf-8')));
|
|
90
|
+
} catch { /* indexed but missing or unreadable — skipped */ }
|
|
91
|
+
}
|
|
92
|
+
return tag === undefined ? events : events.filter((e) => e.tags?.includes(tag));
|
|
93
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The git side of a claim: what state a build starts from, and undoing a failed attempt. Nothing here
|
|
2
|
+
// knows about slice state — the caller passes `base` and `cleanTree` (recorded on the `claimed` event).
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { basename, dirname, join } from 'path';
|
|
7
|
+
|
|
8
|
+
// The kit dir (.build-kit: slices, event store), .eventmodelers (credentials) and progress.txt belong to
|
|
9
|
+
// the loop and the agent's bookkeeping, never to a slice's code — they are outside every check and every
|
|
10
|
+
// discard below.
|
|
11
|
+
function treePathspec(kitDir) {
|
|
12
|
+
return ['.', `:(exclude)${basename(kitDir)}`, ':(exclude).eventmodelers', ':(exclude)progress.txt'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function gitIn(projectDir, ...args) {
|
|
16
|
+
return execFileSync('git', args, { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function currentBranch(projectDir) {
|
|
20
|
+
try { return gitIn(projectDir, 'symbolic-ref', '-q', '--short', 'HEAD').trim(); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// true: clean, false: uncommitted changes, null: not a git repo / git unavailable.
|
|
24
|
+
export function isTreeClean(kitDir) {
|
|
25
|
+
const projectDir = dirname(kitDir);
|
|
26
|
+
if (!existsSync(join(projectDir, '.git'))) return null;
|
|
27
|
+
try {
|
|
28
|
+
return gitIn(projectDir, 'status', '--porcelain', '--', ...treePathspec(kitDir)).trim() === '';
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The git state a claim starts from: the checked-out branch (null when detached), its commit, and the
|
|
35
|
+
// local branches that exist. null when this is not a git repo / git is unavailable.
|
|
36
|
+
export function gitBase(kitDir) {
|
|
37
|
+
const projectDir = dirname(kitDir);
|
|
38
|
+
if (!existsSync(join(projectDir, '.git'))) return null;
|
|
39
|
+
try {
|
|
40
|
+
const branch = gitIn(projectDir, 'symbolic-ref', '-q', '--short', 'HEAD').trim();
|
|
41
|
+
const head = gitIn(projectDir, 'rev-parse', 'HEAD').trim();
|
|
42
|
+
const branches = gitIn(projectDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads').split('\n').filter(Boolean);
|
|
43
|
+
return { branch, head, branches };
|
|
44
|
+
} catch {
|
|
45
|
+
return null; // e.g. detached HEAD or a repo without commits — nothing safe to restore later
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A build that ended without recording an outcome did not finish, and the attempt is undone as if it
|
|
50
|
+
// never happened: uncommitted work is discarded, the branch the claim started on is checked out again
|
|
51
|
+
// and every `feature/*` branch created since is deleted — so the retry starts from the state the
|
|
52
|
+
// claim saw, not on top of a half-done build. Only when the tree was clean when the slice was claimed;
|
|
53
|
+
// otherwise the changes may be a person's own work, and the loop leaves them alone. Commits on the
|
|
54
|
+
// starting branch itself are never rewritten.
|
|
55
|
+
// Returns what was thrown away ({ changed, created, branch }) for the caller to record, or null when
|
|
56
|
+
// nothing was touched.
|
|
57
|
+
export function discardUnfinishedWork(kitDir, planned, cleanTree, base = null) {
|
|
58
|
+
const projectDir = dirname(kitDir);
|
|
59
|
+
if (cleanTree !== true) {
|
|
60
|
+
if (cleanTree === false) console.warn(`[ralph] "${planned.title}" ended unfinished, but the working tree had uncommitted changes before the build — leaving them in place.`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const spec = treePathspec(kitDir);
|
|
65
|
+
const changed = gitIn(projectDir, 'status', '--porcelain', '--', ...spec).trim();
|
|
66
|
+
const branch = currentBranch(projectDir);
|
|
67
|
+
const created = base?.branches
|
|
68
|
+
? gitIn(projectDir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/feature').split('\n')
|
|
69
|
+
.filter((b) => b && !base.branches.includes(b))
|
|
70
|
+
: [];
|
|
71
|
+
const onOtherBranch = Boolean(base?.branch) && branch !== base.branch;
|
|
72
|
+
if (!changed && !created.length && !onOtherBranch) return null;
|
|
73
|
+
|
|
74
|
+
// Scoped to the build's own paths, so the kit dir, .eventmodelers and progress.txt keep any staging.
|
|
75
|
+
gitIn(projectDir, 'reset', '-q', 'HEAD', '--', ...spec);
|
|
76
|
+
gitIn(projectDir, 'checkout', '--', ...spec);
|
|
77
|
+
gitIn(projectDir, 'clean', '-fdq', '--', ...spec);
|
|
78
|
+
if (onOtherBranch) gitIn(projectDir, 'checkout', '-q', base.branch);
|
|
79
|
+
for (const b of created) gitIn(projectDir, 'branch', '-D', b);
|
|
80
|
+
console.log(`[ralph] Discarded the unfinished attempt at "${planned.title}"${created.length ? ` (deleted ${created.join(', ')})` : ''}:\n${changed}`);
|
|
81
|
+
return { changed, created, branch };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[ralph] Could not discard the unfinished work of "${planned.title}":`, err.message);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Learnings as events, and AGENTS.md as their projection (`renderLearnings`). The build agent records a
|
|
2
|
+
// learning with `eventmodelers learn` and withdraws a wrong one with `learn --retract`; nobody edits the
|
|
3
|
+
// file by hand — but when someone does (an older prompt, a pull that merged teammates' lines), the lines
|
|
4
|
+
// are adopted as learnings first, so a regeneration never loses them.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { appendEvent, readEvents } from './slice-events.js';
|
|
9
|
+
import { fold, learningKey, renderLearnings } from './slice-state.js';
|
|
10
|
+
|
|
11
|
+
export const AGENTS_FILE = 'AGENTS.md';
|
|
12
|
+
const SCOPES = ['system', 'repo'];
|
|
13
|
+
|
|
14
|
+
// What an AGENTS.md says as learnings. A `##` (or deeper) heading sets the topic; a top-level `#` is the file's
|
|
15
|
+
// title and no topic. Bullets are learnings, and so are plain lines added after the list has begun (a hand-appended
|
|
16
|
+
// line) — but not the paragraph a file opens with: whatever comes before the first bullet or sub-heading is an
|
|
17
|
+
// introduction. A file with no bullets and no sub-heading at all is a hand-written list and is read line by line.
|
|
18
|
+
// The generated comment and fenced code are never learnings.
|
|
19
|
+
export function parseAgentsFile(text) {
|
|
20
|
+
const items = [];
|
|
21
|
+
let topic;
|
|
22
|
+
let fenced = false;
|
|
23
|
+
let started = false;
|
|
24
|
+
let structured = false;
|
|
25
|
+
for (const raw of text.split('\n')) {
|
|
26
|
+
const line = raw.trim();
|
|
27
|
+
if (line.startsWith('```')) { fenced = !fenced; continue; }
|
|
28
|
+
if (fenced || !line || line.startsWith('<!--')) continue;
|
|
29
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
30
|
+
if (heading) {
|
|
31
|
+
if (heading[1].length > 1) { topic = heading[2].trim() || undefined; started = true; structured = true; }
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const bullet = /^[-*]\s+/.test(line);
|
|
35
|
+
if (bullet) { started = true; structured = true; }
|
|
36
|
+
const learning = line.replace(/^[-*]\s+/, '').trim();
|
|
37
|
+
if (learning) items.push({ topic, text: learning, introduction: !started });
|
|
38
|
+
}
|
|
39
|
+
return items.filter((i) => structured ? !i.introduction : true).map(({ topic: t, text: x }) => ({ topic: t, text: x }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeIfChanged(path, content) {
|
|
43
|
+
if (existsSync(path) && readFileSync(path, 'utf-8') === content) return false;
|
|
44
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
45
|
+
writeFileSync(tmp, content, 'utf-8');
|
|
46
|
+
renameSync(tmp, path);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Writes AGENTS.md from the log, without reading it first. Used right after this module's own events —
|
|
51
|
+
// adopting then would take the line a retraction just removed for a hand edit and bring it back.
|
|
52
|
+
// Nothing is written while there is nothing learned (a project without learnings gets no file).
|
|
53
|
+
function projectLearnings(kitDir, state) {
|
|
54
|
+
const content = renderLearnings(state);
|
|
55
|
+
return content ? writeIfChanged(join(kitDir, AGENTS_FILE), content) : false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The entry point before anything reads learnings: adopts what AGENTS.md says that the log does not know
|
|
59
|
+
// yet, then writes the file from the log. Returns { state, written }.
|
|
60
|
+
export function syncLearnings({ kitDir, meta }) {
|
|
61
|
+
const path = join(kitDir, AGENTS_FILE);
|
|
62
|
+
let state = fold(readEvents(kitDir));
|
|
63
|
+
if (existsSync(path)) {
|
|
64
|
+
const known = new Set(state.learnings.map((l) => learningKey(l.text)));
|
|
65
|
+
let adopted = false;
|
|
66
|
+
for (const { topic, text } of parseAgentsFile(readFileSync(path, 'utf-8'))) {
|
|
67
|
+
const key = learningKey(text);
|
|
68
|
+
if (known.has(key)) continue;
|
|
69
|
+
known.add(key);
|
|
70
|
+
appendEvent(kitDir, { type: 'learning-recorded', text, ...(topic ? { topic } : {}), scope: 'repo', tags: ['adopted'] }, meta);
|
|
71
|
+
adopted = true;
|
|
72
|
+
}
|
|
73
|
+
if (adopted) state = fold(readEvents(kitDir));
|
|
74
|
+
}
|
|
75
|
+
return { state, written: projectLearnings(kitDir, state) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Records one learning. A learning the file already has is not recorded again (returned with
|
|
79
|
+
// duplicate: true), so an agent does not have to diff before it calls.
|
|
80
|
+
export function recordLearning({ kitDir, text, topic, tags, scope = 'repo', slice, meta }) {
|
|
81
|
+
const clean = String(text ?? '').trim();
|
|
82
|
+
if (!clean) throw new Error('the learning is empty');
|
|
83
|
+
if (!SCOPES.includes(scope)) throw new Error(`scope must be ${SCOPES.join(' or ')}, not "${scope}"`);
|
|
84
|
+
const { state } = syncLearnings({ kitDir, meta });
|
|
85
|
+
const existing = state.learnings.find((l) => learningKey(l.text) === learningKey(clean));
|
|
86
|
+
if (existing) return { id: existing.id, duplicate: true };
|
|
87
|
+
const id = appendEvent(kitDir, { type: 'learning-recorded', text: clean, ...(topic?.trim() ? { topic: topic.trim() } : {}), scope, ...(slice ? { slice } : {}), tags }, meta);
|
|
88
|
+
projectLearnings(kitDir, fold(readEvents(kitDir)));
|
|
89
|
+
return { id, duplicate: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A learning by its id or by a unique prefix of it (`learnings --ids` shows the first eight characters).
|
|
93
|
+
function resolveLearning(learnings, id) {
|
|
94
|
+
const wanted = String(id ?? '').trim();
|
|
95
|
+
if (!wanted) throw new Error('name the learning by its id (see `learnings --ids`)');
|
|
96
|
+
const matches = learnings.filter((l) => l.id === wanted || l.id.startsWith(wanted));
|
|
97
|
+
if (!matches.length) throw new Error(`no learning with id "${wanted}"`);
|
|
98
|
+
if (matches.length > 1 && !matches.some((l) => l.id === wanted)) throw new Error(`"${wanted}" matches ${matches.length} learnings — use more of the id`);
|
|
99
|
+
return matches.find((l) => l.id === wanted) ?? matches[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Withdraws a learning by id or by a unique prefix of it; `reason` says why (the curator gives one).
|
|
103
|
+
export function retractLearning({ kitDir, id, reason, meta }) {
|
|
104
|
+
const { state } = syncLearnings({ kitDir, meta });
|
|
105
|
+
const target = resolveLearning(state.learnings, id);
|
|
106
|
+
appendEvent(kitDir, { type: 'learning-retracted', learning: target.id, ...(reason?.trim() ? { reason: reason.trim() } : {}) }, meta);
|
|
107
|
+
projectLearnings(kitDir, fold(readEvents(kitDir)));
|
|
108
|
+
return { id: target.id, text: target.text };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function listLearnings(kitDir) {
|
|
112
|
+
return fold(readEvents(kitDir)).learnings.map(({ id, text, topic, scope, tags }) => ({ id, text, topic, scope, tags }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The curator's merge: two or more learnings that say the same thing become one. The event keeps what
|
|
116
|
+
// the sources said, so a bad merge can be repaired. Sources of different scopes need the scope named.
|
|
117
|
+
export function mergeLearnings({ kitDir, ids, text, topic, scope, tags, reason, meta }) {
|
|
118
|
+
const { state } = syncLearnings({ kitDir, meta });
|
|
119
|
+
const sources = [...new Set((ids ?? []).map((id) => resolveLearning(state.learnings, id)))];
|
|
120
|
+
if (sources.length < 2) throw new Error('a merge needs at least two learnings');
|
|
121
|
+
const clean = String(text ?? '').trim();
|
|
122
|
+
if (!clean) throw new Error('the merged learning is empty');
|
|
123
|
+
if (scope !== undefined && !SCOPES.includes(scope)) throw new Error(`scope must be ${SCOPES.join(' or ')}, not "${scope}"`);
|
|
124
|
+
if (scope === undefined && new Set(sources.map((l) => l.scope)).size > 1) throw new Error('the learnings have different scopes — name the scope of the merged one');
|
|
125
|
+
const id = appendEvent(kitDir, {
|
|
126
|
+
type: 'learning-merged',
|
|
127
|
+
learnings: sources.map((l) => l.id),
|
|
128
|
+
text: clean,
|
|
129
|
+
...(topic?.trim() ? { topic: topic.trim() } : {}),
|
|
130
|
+
...(scope ? { scope } : {}),
|
|
131
|
+
tags,
|
|
132
|
+
...(reason?.trim() ? { reason: reason.trim() } : {}),
|
|
133
|
+
sources: sources.map(({ id, text: was }) => ({ id, text: was })),
|
|
134
|
+
}, meta);
|
|
135
|
+
projectLearnings(kitDir, fold(readEvents(kitDir)));
|
|
136
|
+
return { id };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// What the curator (an LLM turn) is told: the learnings with their ids, and the two things it may do.
|
|
140
|
+
// It changes the list only through those commands — AGENTS.md is regenerated from the events.
|
|
141
|
+
export function curationRequest(learnings) {
|
|
142
|
+
if (!learnings.length) return '';
|
|
143
|
+
const list = learnings.map((l) => ` ${l.id.slice(0, 8)} [${l.topic ?? 'General'}] (${l.scope}) ${l.text}${l.tags?.length ? ` ${l.tags.map((t) => `#${t}`).join(' ')}` : ''}`).join('\n');
|
|
144
|
+
return `You are the curator of this project's learnings. Below is everything recorded so far: id, [topic], (scope), the learning, #tags. \`.build-kit/AGENTS.md\` is generated from this list — never edit that file. You change the list only with these two commands:
|
|
145
|
+
|
|
146
|
+
- Merge learnings that say the same thing into one terse line:
|
|
147
|
+
npx @eventmodelers/cli learn --merge <id,id,…> "<combined text>" --reason "<why they are the same>"
|
|
148
|
+
(add --topic to set the heading, and --scope when the sources have different scopes)
|
|
149
|
+
- Retract a learning that is wrong or obsolete:
|
|
150
|
+
npx @eventmodelers/cli learn --retract <id> --reason "<what you found>"
|
|
151
|
+
|
|
152
|
+
Rules:
|
|
153
|
+
- No new learnings — recording one is refused while you curate. Merge or retract only.
|
|
154
|
+
- Retract only with evidence you have checked: the code, the git log, the slice definitions. Put it in --reason. If you cannot judge a learning, keep it.
|
|
155
|
+
- A merge must not lose information: the combined text says everything its sources said, in one line, and adds nothing.
|
|
156
|
+
- Learnings with scope "system" are about the modeled domain; merge or retract them only when the evidence is in the slices or the code.
|
|
157
|
+
- Leave learnings that are fine alone. Finish with one line saying what you merged and retracted.
|
|
158
|
+
|
|
159
|
+
Learnings:
|
|
160
|
+
${list}
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── learnings a turn states in its reply ────────────────────────────────────────
|
|
165
|
+
// The locked-down modeling agent cannot run `learn` (its Bash is an allowlist, and it takes chat from any
|
|
166
|
+
// collaborator on the board), so it ends a reply with `Learning [<topic>]: <one terse line>` lines and the
|
|
167
|
+
// CLI — trusted code — records them. A reply yields at most five learnings of at most 300 characters:
|
|
168
|
+
// what a board collaborator can talk the agent into saying stays small.
|
|
169
|
+
const MAX_TURN_LEARNINGS = 5;
|
|
170
|
+
const MAX_LEARNING_LENGTH = 300;
|
|
171
|
+
const TURN_LEARNING = /^\s*(?:[-*]\s+)?learning(?:\s*\[([^\]]{1,60})\])?:\s*(.*?)\s*$/i;
|
|
172
|
+
|
|
173
|
+
export function parseTurnLearnings(reply) {
|
|
174
|
+
const found = [];
|
|
175
|
+
let fenced = false;
|
|
176
|
+
for (const line of String(reply ?? '').split('\n')) {
|
|
177
|
+
if (line.trim().startsWith('```')) { fenced = !fenced; continue; }
|
|
178
|
+
if (fenced) continue;
|
|
179
|
+
const m = TURN_LEARNING.exec(line);
|
|
180
|
+
if (!m) continue;
|
|
181
|
+
const text = m[2];
|
|
182
|
+
if (!text || /^none\.?$/i.test(text) || text.length > MAX_LEARNING_LENGTH) continue;
|
|
183
|
+
found.push({ topic: m[1]?.trim() || undefined, text });
|
|
184
|
+
if (found.length === MAX_TURN_LEARNINGS) break;
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Records what a turn's reply states as modeling learnings (scope system: they are about the model).
|
|
190
|
+
// Returns how many were new.
|
|
191
|
+
export function recordTurnLearnings({ kitDir, reply, meta }) {
|
|
192
|
+
let recorded = 0;
|
|
193
|
+
for (const { topic, text } of parseTurnLearnings(reply)) {
|
|
194
|
+
if (!recordLearning({ kitDir, text, topic, scope: 'system', tags: ['modeling'], meta }).duplicate) recorded++;
|
|
195
|
+
}
|
|
196
|
+
return recorded;
|
|
197
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Slice status in `.slices/<ctx>/index.json` and `slice.json` is a read-only view of the event log — the
|
|
2
|
+
// build agent reads those files, but nothing writes status into them except this projection.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { appendEvent, readEvents } from './slice-events.js';
|
|
7
|
+
import { fold } from './slice-state.js';
|
|
8
|
+
|
|
9
|
+
// The loop's status for a slice, falling back to what the board said when the log knows nothing of it.
|
|
10
|
+
export function statusOf(state, slice) {
|
|
11
|
+
return state.slices[slice.id]?.status ?? slice.status;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Records what the board shows. Only changes become events, so a refresh every few seconds does not
|
|
15
|
+
// grow the log. `meta` (agent, organization, board) is stamped on each event. Returns the folded state.
|
|
16
|
+
export function observeBoard(kitDir, boardSlices, meta, current = fold(readEvents(kitDir))) {
|
|
17
|
+
const state = current;
|
|
18
|
+
let changed = false;
|
|
19
|
+
for (const { id, status } of boardSlices) {
|
|
20
|
+
if (!id || !status || state.slices[id]?.lastBoardStatus === status) continue;
|
|
21
|
+
appendEvent(kitDir, { type: 'board-observed', slice: id, status }, meta);
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
return changed ? fold(readEvents(kitDir)) : state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The build agent still records its outcome by editing a slice's status in index.json (until it has a
|
|
28
|
+
// command of its own). While a slice is claimed the projection keeps that file equal to the log, so any
|
|
29
|
+
// difference is the agent's edit — adopted as `agent-recorded`. This runs before every refresh and before
|
|
30
|
+
// settling, or the next projection would write the log's InProgress over the agent's Done.
|
|
31
|
+
// Returns the folded state.
|
|
32
|
+
export function adoptAgentEdits(kitDir, state, meta) {
|
|
33
|
+
const claimed = Object.entries(state.slices).filter(([, s]) => s.claim).map(([id]) => id);
|
|
34
|
+
const slicesDir = join(kitDir, '.slices');
|
|
35
|
+
if (!claimed.length || !existsSync(slicesDir)) return state;
|
|
36
|
+
let adopted = false;
|
|
37
|
+
for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
38
|
+
if (!ctx.isDirectory()) continue;
|
|
39
|
+
let index;
|
|
40
|
+
try { index = JSON.parse(readFileSync(join(slicesDir, ctx.name, 'index.json'), 'utf-8')); } catch { continue; }
|
|
41
|
+
for (const entry of index.slices ?? []) {
|
|
42
|
+
if (!claimed.includes(entry.id) || !entry.status || entry.status === state.slices[entry.id].status) continue;
|
|
43
|
+
appendEvent(kitDir, { type: 'agent-recorded', slice: entry.id, status: entry.status }, meta);
|
|
44
|
+
adopted = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return adopted ? fold(readEvents(kitDir)) : state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeJsonAtomic(path, data) {
|
|
51
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
52
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
53
|
+
renameSync(tmp, path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sets status (and the Blocked note) on one parsed file's object; true when anything changed.
|
|
57
|
+
function apply(target, slice, extra = {}) {
|
|
58
|
+
const want = { status: slice.status, blockedReason: slice.blocked?.reason, blockedAt: slice.blocked?.at, ...extra };
|
|
59
|
+
let changed = false;
|
|
60
|
+
for (const [key, value] of Object.entries(want)) {
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
if (key !== 'status' && key in target) { delete target[key]; changed = true; }
|
|
63
|
+
} else if (JSON.stringify(target[key]) !== JSON.stringify(value)) {
|
|
64
|
+
target[key] = value;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return changed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Writes the derived status into every index.json entry (and its `definition`) and slice.json the log
|
|
72
|
+
// has a status for. Everything else in those files is left alone, and a file is only rewritten when
|
|
73
|
+
// something changed. Returns how many files were written.
|
|
74
|
+
export function projectStatus(kitDir, state) {
|
|
75
|
+
const slicesDir = join(kitDir, '.slices');
|
|
76
|
+
if (!existsSync(slicesDir)) return 0;
|
|
77
|
+
let written = 0;
|
|
78
|
+
for (const ctx of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
79
|
+
if (!ctx.isDirectory()) continue;
|
|
80
|
+
const indexPath = join(slicesDir, ctx.name, 'index.json');
|
|
81
|
+
if (!existsSync(indexPath)) continue;
|
|
82
|
+
let index;
|
|
83
|
+
try { index = JSON.parse(readFileSync(indexPath, 'utf-8')); } catch { continue; }
|
|
84
|
+
let indexChanged = false;
|
|
85
|
+
for (const entry of index.slices ?? []) {
|
|
86
|
+
const slice = state.slices[entry.id];
|
|
87
|
+
if (!slice?.status) continue;
|
|
88
|
+
if (apply(entry, slice)) indexChanged = true;
|
|
89
|
+
if (entry.definition && entry.definition.status !== slice.status) { entry.definition.status = slice.status; indexChanged = true; }
|
|
90
|
+
const slicePath = join(slicesDir, ctx.name, entry.folder ?? '', 'slice.json');
|
|
91
|
+
if (!entry.folder || !existsSync(slicePath)) continue;
|
|
92
|
+
try {
|
|
93
|
+
const data = JSON.parse(readFileSync(slicePath, 'utf-8'));
|
|
94
|
+
// slice.json is what the build agent reads, so it also carries what the last failed attempt tried.
|
|
95
|
+
if (apply(data, slice, { lastAttempt: slice.lastFailure ?? undefined })) { writeJsonAtomic(slicePath, data); written++; }
|
|
96
|
+
} catch { /* an unreadable slice.json is left for the next full fetch to repair */ }
|
|
97
|
+
}
|
|
98
|
+
if (indexChanged) { writeJsonAtomic(indexPath, index); written++; }
|
|
99
|
+
}
|
|
100
|
+
return written;
|
|
101
|
+
}
|