@eventmodelers/cli 2.0.1 → 2.0.3
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 +27 -9
- package/cli.js +369 -36
- package/lib/build-kits.js +23 -0
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/build-log-share.js +149 -0
- package/shared/build-kit/lib/events-view.js +18 -3
- package/shared/build-kit/lib/ralph.js +27 -16
- package/shared/build-kit/lib/slice-events.js +42 -2
- package/shared/build-kit/lib/slice-info.js +52 -0
- package/shared/build-kit/lib/slice-run.js +2 -2
- package/shared/build-kit/lib/slice-state.js +6 -4
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
- package/shared/skills/ui-contract/SKILL.md +6 -6
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
- package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
- package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/umadb/templates/build-kit/lib/prompt.md +24 -0
|
@@ -51,6 +51,8 @@ function describe(e) {
|
|
|
51
51
|
return ['', `${plural(e.sources?.length ?? e.learnings?.length ?? 0, 'learning')} → ${oneLine(e.text, 120)}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
|
|
52
52
|
case 'learnings-curated':
|
|
53
53
|
return ['', ''];
|
|
54
|
+
case 'info-recorded':
|
|
55
|
+
return [e.slice, `[${e.kind ?? 'note'}] ${oneLine(e.text, 160)}`];
|
|
54
56
|
case 'prompt-received':
|
|
55
57
|
return [e.prompt?.id, `"${oneLine(e.prompt?.text, 80)}"${e.prompt?.priority ? ', priority' : ''}, pid ${e.pid}`];
|
|
56
58
|
case 'prompt-action':
|
|
@@ -85,10 +87,23 @@ export function formatPayload(e) {
|
|
|
85
87
|
return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// The kit's first look at each slice: its first `board-observed` event. A fresh kit records every slice already on
|
|
91
|
+
// the board this way, which says nothing happened — the ids to leave out of the default view of the log.
|
|
92
|
+
export function baselineIds(events) {
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const ids = new Set();
|
|
95
|
+
for (const e of events) {
|
|
96
|
+
if (e.type !== 'board-observed' || !e.slice || seen.has(e.slice)) continue;
|
|
97
|
+
seen.add(e.slice);
|
|
98
|
+
ids.add(e.id);
|
|
99
|
+
}
|
|
100
|
+
return ids;
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
// 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;
|
|
104
|
+
// the log; omit is a set of event ids to leave out (see baselineIds); last keeps the final N that remain.
|
|
105
|
+
export function filterEvents(events, { types, slice, tag, board, last, omit } = {}, titles = {}) {
|
|
106
|
+
let out = omit?.size ? events.filter((e) => !omit.has(e.id)) : events;
|
|
92
107
|
if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
|
|
93
108
|
if (types?.length) out = out.filter((e) => types.includes(e.type));
|
|
94
109
|
if (slice) {
|
|
@@ -15,11 +15,12 @@ import { randomUUID } from 'crypto';
|
|
|
15
15
|
import { createRealtimeAdapter, keepSubscribed } from './adapters/realtime-adapter.js';
|
|
16
16
|
import { backoffMs, sleep } from './backoff.js';
|
|
17
17
|
import { adoptAgentEdits, observeBoard, projectStatus, statusOf } from './slice-projection.js';
|
|
18
|
-
import { fold } from './slice-state.js';
|
|
18
|
+
import { fold, statusKey } from './slice-state.js';
|
|
19
19
|
import { readEvents } from './slice-events.js';
|
|
20
20
|
import { claimSlice, recordEvent, settleSlice, settleLeftoverClaims } from './slice-run.js';
|
|
21
21
|
import { isTreeClean } from './slice-git.js';
|
|
22
22
|
import { syncLearnings } from './slice-learnings.js';
|
|
23
|
+
import { createBuildLogShare, kitTypeOf, SYNC_STATE_FILE } from './build-log-share.js';
|
|
23
24
|
import { curateIfDue } from './slice-curation.js';
|
|
24
25
|
import { queueTask, workNextTask } from './slice-tasks.js';
|
|
25
26
|
import { defaultAgentName, withInstanceSuffix } from './agent-name.js';
|
|
@@ -273,8 +274,8 @@ export async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
273
274
|
if (!activeCtx || !contexts[activeCtx]) {
|
|
274
275
|
// First run (or the current context disappeared): seed with a context that
|
|
275
276
|
// has planned work, else the first one. This is the ONLY place we choose it.
|
|
276
|
-
const pickup = readPickupStatus(kitDir)
|
|
277
|
-
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status
|
|
277
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
278
|
+
const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => statusKey(s.status) === pickup));
|
|
278
279
|
activeCtx = plannedCtx || Object.keys(contexts)[0] || 'default';
|
|
279
280
|
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
280
281
|
}
|
|
@@ -377,9 +378,9 @@ async function handleSliceChanged(payload, cfg, kitDir) {
|
|
|
377
378
|
await fetchAndPersistSlices(cfg, kitDir).catch((err) =>
|
|
378
379
|
console.error('[agent] Slice persist error:', err),
|
|
379
380
|
);
|
|
380
|
-
// Slices in the kit's pickup status (Planned, or
|
|
381
|
+
// Slices in the kit's pickup status (Planned, or Waiting for UI kits) are handled by
|
|
381
382
|
// onPlannedSlice directly — no task needed.
|
|
382
|
-
if ((payload.sliceStatus
|
|
383
|
+
if (statusKey(payload.sliceStatus) !== statusKey(readPickupStatus(kitDir))) {
|
|
383
384
|
try {
|
|
384
385
|
queueTask({ kitDir, payload, meta: eventMeta(cfg) });
|
|
385
386
|
console.log(`[agent] Task queued — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
@@ -490,15 +491,19 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', createAdap
|
|
|
490
491
|
// ── Ralph loop ────────────────────────────────────────────────────────────────
|
|
491
492
|
|
|
492
493
|
// The slice status this kit claims work from. A stack declares it in `lib/kit.json`
|
|
493
|
-
// ({ "pickupStatus": "
|
|
494
|
+
// ({ "pickupStatus": "Waiting" } for the UI kits, which build what a backend kit handed over);
|
|
494
495
|
// absent means "Planned", the backend default. `run --status-trigger <status>` (RALPH_STATUS_TRIGGER)
|
|
495
496
|
// overrides either for that run.
|
|
497
|
+
// `Handover` was renamed `Waiting`; a kit.json or --status-trigger written before that still says it,
|
|
498
|
+
// and a board served by an older platform still reports it.
|
|
499
|
+
const canonicalStatus = (s) => (statusKey(s) === 'waiting' ? 'Waiting' : s);
|
|
500
|
+
|
|
496
501
|
export function readPickupStatus(kitDir) {
|
|
497
502
|
const override = (process.env.RALPH_STATUS_TRIGGER || '').trim();
|
|
498
|
-
if (override) return override;
|
|
503
|
+
if (override) return canonicalStatus(override);
|
|
499
504
|
try {
|
|
500
505
|
const s = JSON.parse(readFileSync(join(kitDir, 'lib', 'kit.json'), 'utf-8')).pickupStatus;
|
|
501
|
-
if (typeof s === 'string' && s.trim()) return s.trim();
|
|
506
|
+
if (typeof s === 'string' && s.trim()) return canonicalStatus(s.trim());
|
|
502
507
|
} catch {}
|
|
503
508
|
return 'Planned';
|
|
504
509
|
}
|
|
@@ -533,14 +538,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
533
538
|
try {
|
|
534
539
|
others = readdirSync(slicesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
535
540
|
} catch {}
|
|
536
|
-
const pickup = readPickupStatus(kitDir)
|
|
541
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
537
542
|
const candidates = [...(currentCtx ? [currentCtx] : []), ...others.filter((c) => c !== currentCtx)];
|
|
538
543
|
for (const ctx of candidates) {
|
|
539
544
|
const indexPath = join(slicesDir, ctx, 'index.json');
|
|
540
545
|
if (!existsSync(indexPath)) continue;
|
|
541
546
|
try {
|
|
542
547
|
const { slices } = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
543
|
-
const planned = slices && slices.find((s) => (s.status
|
|
548
|
+
const planned = slices && slices.find((s) => statusKey(s.status) === pickup && !skipIds.has(s.id));
|
|
544
549
|
if (!planned) continue;
|
|
545
550
|
if (ctx !== currentCtx) {
|
|
546
551
|
writeFileSync(join(slicesDir, 'current_context.json'), JSON.stringify({ name: ctx }, null, 2), 'utf-8');
|
|
@@ -555,14 +560,14 @@ function getFirstPlannedSlice(kitDir, skipIds = new Set()) {
|
|
|
555
560
|
// Ids of every slice currently waiting for pickup, across all contexts.
|
|
556
561
|
function listPlannedIds(kitDir) {
|
|
557
562
|
const slicesDir = join(kitDir, '.slices');
|
|
558
|
-
const pickup = readPickupStatus(kitDir)
|
|
563
|
+
const pickup = statusKey(readPickupStatus(kitDir));
|
|
559
564
|
const ids = new Set();
|
|
560
565
|
try {
|
|
561
566
|
for (const d of readdirSync(slicesDir, { withFileTypes: true })) {
|
|
562
567
|
if (!d.isDirectory()) continue;
|
|
563
568
|
try {
|
|
564
569
|
const { slices } = JSON.parse(readFileSync(join(slicesDir, d.name, 'index.json'), 'utf-8'));
|
|
565
|
-
for (const s of slices ?? []) if ((s.status
|
|
570
|
+
for (const s of slices ?? []) if (statusKey(s.status) === pickup) ids.add(s.id);
|
|
566
571
|
} catch {}
|
|
567
572
|
}
|
|
568
573
|
} catch {}
|
|
@@ -666,7 +671,7 @@ async function runWithRetry(label, fn) {
|
|
|
666
671
|
// alone, the agent read it as background, answered "what would you like me to help with?" with
|
|
667
672
|
// no tool calls, and a Planned slice stayed Planned until the stuck-slice guard blocked it. So
|
|
668
673
|
// every turn ends with the concrete request — here, once, for every runner.
|
|
669
|
-
// Whether a backend kit finishes UI-bearing slices at `
|
|
674
|
+
// Whether a backend kit finishes UI-bearing slices at `Waiting` instead of `Done`: this run's
|
|
670
675
|
// `run --handover` (RALPH_HANDOVER) over the project's `handover` setting in config.json.
|
|
671
676
|
export function resolveHandover(cfg) {
|
|
672
677
|
return process.env.RALPH_HANDOVER === '1' || cfg?.handover === true;
|
|
@@ -720,8 +725,8 @@ function taskRequest(planned = null, cfg = null, pickupStatus = 'Planned', queue
|
|
|
720
725
|
? `build the ${pickupStatus} slice "${planned.title}" (id=${planned.id}, context="${planned.ctx}")`
|
|
721
726
|
: `process this queued task (id=${queued?.id}) — the slice change that queued it: ${JSON.stringify(queued?.payload ?? {})}`;
|
|
722
727
|
// Stated explicitly so the agent never has to work it out from config.json: this run's effective
|
|
723
|
-
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup
|
|
724
|
-
const handover = planned && pickupStatus
|
|
728
|
+
// value, which is what the `ui-contract` skill (Step 4) follows. UI kits (pickup Waiting) ignore it.
|
|
729
|
+
const handover = planned && statusKey(pickupStatus) !== 'waiting' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
|
|
725
730
|
return `\n\n---\n\nYour task now: ${task}, following the instructions above. Start immediately.\n${handover}`;
|
|
726
731
|
}
|
|
727
732
|
|
|
@@ -735,7 +740,7 @@ function ensureLearningsGitRules(kitDir) {
|
|
|
735
740
|
const projectDir = dirname(kitDir);
|
|
736
741
|
if (!existsSync(join(projectDir, '.git'))) return;
|
|
737
742
|
const rules = [
|
|
738
|
-
['info/exclude', [`${basename(kitDir)}/events/`, `${basename(kitDir)}/events.log`]],
|
|
743
|
+
['info/exclude', [`${basename(kitDir)}/events/`, `${basename(kitDir)}/events.log`, `${basename(kitDir)}/events.seq`, `${basename(kitDir)}/${SYNC_STATE_FILE}`]],
|
|
739
744
|
['info/attributes', [`${basename(kitDir)}/AGENTS.md merge=union`]],
|
|
740
745
|
];
|
|
741
746
|
for (const [name, lines] of rules) {
|
|
@@ -765,6 +770,10 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false,
|
|
|
765
770
|
const board = credentialed ? createBoard(cfg) : null;
|
|
766
771
|
const meta = eventMeta(cfg);
|
|
767
772
|
ensureLearningsGitRules(kitDir);
|
|
773
|
+
// Sharing the event log with the board (`run --sync`): needs the board, so never with --local.
|
|
774
|
+
const share = createBuildLogShare({ kitDir, cfg, meta, agentType: cfg?.agentType ?? 'BUILD', kitType: kitTypeOf(readPickupStatus(kitDir)), sync: process.env.RALPH_SYNC === '1', enabled: credentialed, log: (line) => console.log(`[ralph] ${line}`) });
|
|
775
|
+
if (process.env.RALPH_SYNC === '1' && !credentialed) console.log('[ralph] --sync needs board credentials — the build log stays local.');
|
|
776
|
+
share.start();
|
|
768
777
|
let lastIdleCtx;
|
|
769
778
|
// The build agent's final reply on the most recent planned-slice turn — quoted in the Blocked note.
|
|
770
779
|
let lastReply = null;
|
|
@@ -827,6 +836,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false,
|
|
|
827
836
|
}
|
|
828
837
|
}
|
|
829
838
|
lastReply = null;
|
|
839
|
+
if (planned.id) share.iterationStarted(planned);
|
|
830
840
|
const built = await runWithRetry(`onPlannedSlice: building slice "${planned.title}"...`, async () => { lastReply = await onPlannedSlice(prompt, {
|
|
831
841
|
sliceId: planned.id,
|
|
832
842
|
sliceTitle: planned.title,
|
|
@@ -838,6 +848,7 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false,
|
|
|
838
848
|
}); });
|
|
839
849
|
if (built) console.log(`[ralph] Slice build complete — waiting for next slice`);
|
|
840
850
|
if (planned.id) await settleSlice({ kitDir, planned, board, meta, pickupStatus: readPickupStatus(kitDir), details: { built, lastReply } });
|
|
851
|
+
if (planned.id) await share.iterationClosed(planned, built ? 'ok' : 'error');
|
|
841
852
|
if (credentialed) await fetchAndPersistSlices(cfg, kitDir).catch(() => {});
|
|
842
853
|
didWork = true;
|
|
843
854
|
}
|
|
@@ -11,6 +11,7 @@ import { join } from 'path';
|
|
|
11
11
|
|
|
12
12
|
export const EVENTS_DIR = 'events';
|
|
13
13
|
export const INDEX_FILE = 'events.log';
|
|
14
|
+
export const SEQ_FILE = 'events.seq';
|
|
14
15
|
|
|
15
16
|
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
17
|
|
|
@@ -49,10 +50,41 @@ export function normalizeMeta(meta) {
|
|
|
49
50
|
return clean;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// The last sequence number handed out is remembered next to the index (SEQ_FILE), so numbers keep climbing
|
|
54
|
+
// when events are pruned or the log is rebuilt. A kit that has no such file yet (a log written before events
|
|
55
|
+
// were numbered) continues from its newest event: its own `seq`, or how many events precede it. Two processes
|
|
56
|
+
// appending in the same instant can draw the same number — nothing enforces uniqueness, here or on the server:
|
|
57
|
+
// the index order decides, and readEvents renumbers so a reader still sees a strictly increasing sequence.
|
|
58
|
+
function newestEventSeq(kitDir) {
|
|
59
|
+
const index = join(kitDir, INDEX_FILE);
|
|
60
|
+
if (!existsSync(index)) return 0;
|
|
61
|
+
const ids = readFileSync(index, 'utf-8').split('\n').map((l) => l.trim()).filter((l) => ID.test(l));
|
|
62
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
63
|
+
try {
|
|
64
|
+
const { seq } = JSON.parse(readFileSync(join(kitDir, EVENTS_DIR, `${ids[i]}.json`), 'utf-8'));
|
|
65
|
+
return Number.isInteger(seq) ? seq : i + 1;
|
|
66
|
+
} catch { /* unreadable — look at the one before */ }
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Takes the next number and remembers it.
|
|
72
|
+
function nextSeq(kitDir) {
|
|
73
|
+
const file = join(kitDir, SEQ_FILE);
|
|
74
|
+
let remembered = 0;
|
|
75
|
+
try { remembered = Number.parseInt(readFileSync(file, 'utf-8'), 10) || 0; } catch { /* none yet */ }
|
|
76
|
+
const seq = Math.max(remembered, newestEventSeq(kitDir)) + 1;
|
|
77
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
78
|
+
writeFileSync(tmp, String(seq), 'utf-8');
|
|
79
|
+
renameSync(tmp, file);
|
|
80
|
+
return seq;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
// The event file is written whole (temp file, then rename) before its id is indexed, so a reader that
|
|
53
84
|
// finds the id finds a complete file. The index line is one short append-mode write, so lines from
|
|
54
85
|
// 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.
|
|
86
|
+
// starts on a fresh line instead of being glued onto the fragment. Every event gets the next sequence number
|
|
87
|
+
// (`seq`), which — not the timestamp — defines the order. Returns the event id.
|
|
56
88
|
export function appendEvent(kitDir, event, meta) {
|
|
57
89
|
const dir = join(kitDir, EVENTS_DIR);
|
|
58
90
|
mkdirSync(dir, { recursive: true });
|
|
@@ -67,6 +99,7 @@ export function appendEvent(kitDir, event, meta) {
|
|
|
67
99
|
...rest,
|
|
68
100
|
...(cleanTags.length ? { tags: cleanTags } : {}),
|
|
69
101
|
...(Object.keys(cleanMeta).length ? { meta: cleanMeta } : {}),
|
|
102
|
+
seq: nextSeq(kitDir),
|
|
70
103
|
id,
|
|
71
104
|
}, null, 2), 'utf-8');
|
|
72
105
|
renameSync(tmp, join(dir, `${id}.json`));
|
|
@@ -75,7 +108,7 @@ export function appendEvent(kitDir, event, meta) {
|
|
|
75
108
|
return id;
|
|
76
109
|
}
|
|
77
110
|
|
|
78
|
-
// All events in index order
|
|
111
|
+
// All events in index order, each with its `seq`; with `tag`, only those carrying it.
|
|
79
112
|
export function readEvents(kitDir, { tag } = {}) {
|
|
80
113
|
const index = join(kitDir, INDEX_FILE);
|
|
81
114
|
if (!existsSync(index)) return [];
|
|
@@ -89,5 +122,12 @@ export function readEvents(kitDir, { tag } = {}) {
|
|
|
89
122
|
events.push(JSON.parse(readFileSync(join(kitDir, EVENTS_DIR, `${id}.json`), 'utf-8')));
|
|
90
123
|
} catch { /* indexed but missing or unreadable — skipped */ }
|
|
91
124
|
}
|
|
125
|
+
// Strictly increasing in index order: an event from before numbering, or one that drew the same number as its
|
|
126
|
+
// predecessor, takes the next free one.
|
|
127
|
+
let prev = 0;
|
|
128
|
+
for (const e of events) {
|
|
129
|
+
if (!Number.isInteger(e.seq) || e.seq <= prev) e.seq = prev + 1;
|
|
130
|
+
prev = e.seq;
|
|
131
|
+
}
|
|
92
132
|
return tag === undefined ? events : events.filter((e) => e.tags?.includes(tag));
|
|
93
133
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Info events: what an agent reports that is not a reusable learning — "I can't change X because Y", a
|
|
2
|
+
// decision it took, a note for whoever reads the board. A learning is true of the codebase or the model for
|
|
3
|
+
// every later build (slice-learnings.js); an info is about this slice, now. Infos feed no projection — the
|
|
4
|
+
// event is the record — and are also posted as a comment on the slice, so the board shows them.
|
|
5
|
+
|
|
6
|
+
import { appendEvent } from './slice-events.js';
|
|
7
|
+
|
|
8
|
+
export const INFO_KINDS = ['limitation', 'decision', 'note'];
|
|
9
|
+
const MAX_INFO_LENGTH = 500;
|
|
10
|
+
|
|
11
|
+
export function recordInfo({ kitDir, text, kind = 'note', slice, tags, meta }) {
|
|
12
|
+
const clean = String(text ?? '').trim();
|
|
13
|
+
if (!clean) throw new Error('the info is empty');
|
|
14
|
+
if (!INFO_KINDS.includes(kind)) throw new Error(`kind must be ${INFO_KINDS.join(', ')}, not "${kind}"`);
|
|
15
|
+
const id = appendEvent(kitDir, { type: 'info-recorded', kind, text: clean.slice(0, MAX_INFO_LENGTH), ...(slice ? { slice } : {}), tags }, meta);
|
|
16
|
+
return { id, text: clean.slice(0, MAX_INFO_LENGTH), kind };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The comment the board shows for an info.
|
|
20
|
+
export function infoComment({ kind, text }) {
|
|
21
|
+
return `[${kind}] ${text}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── infos a turn states in its reply ────────────────────────────────────────────
|
|
25
|
+
// Same contract as learnings: the locked-down modeling agent ends a reply with `Info [<kind>]: <line>` and
|
|
26
|
+
// the CLI records it. At most five per reply, each capped, so what a board collaborator can talk the agent
|
|
27
|
+
// into saying stays small.
|
|
28
|
+
const MAX_TURN_INFOS = 5;
|
|
29
|
+
const TURN_INFO = /^\s*(?:[-*]\s+)?info(?:\s*\[([^\]]{1,20})\])?:\s*(.*?)\s*$/i;
|
|
30
|
+
|
|
31
|
+
export function parseTurnInfos(reply) {
|
|
32
|
+
const found = [];
|
|
33
|
+
let fenced = false;
|
|
34
|
+
for (const line of String(reply ?? '').split('\n')) {
|
|
35
|
+
if (line.trim().startsWith('```')) { fenced = !fenced; continue; }
|
|
36
|
+
if (fenced) continue;
|
|
37
|
+
const m = TURN_INFO.exec(line);
|
|
38
|
+
if (!m) continue;
|
|
39
|
+
const text = m[2];
|
|
40
|
+
if (!text || /^none\.?$/i.test(text) || text.length > MAX_INFO_LENGTH) continue;
|
|
41
|
+
const kind = m[1]?.trim().toLowerCase();
|
|
42
|
+
found.push({ kind: INFO_KINDS.includes(kind) ? kind : 'note', text });
|
|
43
|
+
if (found.length === MAX_TURN_INFOS) break;
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordTurnInfos({ kitDir, reply, meta }) {
|
|
49
|
+
const infos = parseTurnInfos(reply);
|
|
50
|
+
for (const { kind, text } of infos) recordInfo({ kitDir, text, kind, tags: ['modeling'], meta });
|
|
51
|
+
return infos.length;
|
|
52
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// no board (--local): nothing is pushed or read, the log alone decides.
|
|
7
7
|
|
|
8
8
|
import { appendEvent, readEvents } from './slice-events.js';
|
|
9
|
-
import { fold, decideSettle, decideRecovery } from './slice-state.js';
|
|
9
|
+
import { fold, decideSettle, decideRecovery, statusKey } from './slice-state.js';
|
|
10
10
|
import { adoptAgentEdits, projectStatus } from './slice-projection.js';
|
|
11
11
|
import { discardUnfinishedWork, gitBase } from './slice-git.js';
|
|
12
12
|
|
|
@@ -71,7 +71,7 @@ export async function claimSlice({ kitDir, planned, board, meta, cleanTree, pid
|
|
|
71
71
|
console.error(`[ralph] Could not read the board before claiming "${planned.title}":`, err.message);
|
|
72
72
|
return 'lost';
|
|
73
73
|
}
|
|
74
|
-
if (current && current
|
|
74
|
+
if (current && statusKey(current) !== statusKey(pickupStatus)) {
|
|
75
75
|
console.log(`[ralph] "${planned.title}" is ${current} on the board, not ${pickupStatus} — someone else got there first.`);
|
|
76
76
|
recordEvent(kitDir, { type: 'board-observed', slice: planned.id, status: current }, meta);
|
|
77
77
|
return 'lost';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Events (one writer per type): board-observed, claimed, claim-confirmed, agent-recorded, settled,
|
|
6
6
|
// released, attempt-failed, blocked, learning-recorded, learning-retracted, learning-merged, learnings-curated,
|
|
7
|
-
// task-queued, task-done, prompt-received, prompt-finished. Unknown types are ignored, so a
|
|
7
|
+
// info-recorded (read by nothing here — it is the record), task-queued, task-done, prompt-received, prompt-finished. Unknown types are ignored, so a
|
|
8
8
|
// newer loop's log is readable.
|
|
9
9
|
|
|
10
10
|
const emptySlice = () => ({
|
|
@@ -18,7 +18,9 @@ const emptySlice = () => ({
|
|
|
18
18
|
blocked: null,
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// `Handover` is the old name of `Waiting`: boards and files written before the rename still hold it.
|
|
22
|
+
export const statusKey = (s) => { const k = (s || '').toLowerCase(); return k === 'handover' ? 'waiting' : k; };
|
|
23
|
+
const same = (a, b) => typeof a === 'string' && typeof b === 'string' && statusKey(a) === statusKey(b);
|
|
22
24
|
|
|
23
25
|
const apply = {
|
|
24
26
|
// While a claim is open the board's view is recorded but does not touch `status`: a refresh that
|
|
@@ -29,7 +31,7 @@ const apply = {
|
|
|
29
31
|
if (s.claim) return;
|
|
30
32
|
s.status = e.status;
|
|
31
33
|
// Attempts count builds that failed to move the slice off the status it was put back to (Planned, or
|
|
32
|
-
//
|
|
34
|
+
// Waiting for UI kits); any other status means it moved. Any status but Blocked lifts a block.
|
|
33
35
|
if (!same(e.status, s.retryStatus ?? 'Planned')) { s.attempts = 0; s.lastFailure = null; }
|
|
34
36
|
if (!same(e.status, 'Blocked')) s.blocked = null;
|
|
35
37
|
},
|
|
@@ -150,7 +152,7 @@ export function renderLearnings(state) {
|
|
|
150
152
|
|
|
151
153
|
// What to do with a claimed slice once its build is over. `boardStatus` is the board's current status
|
|
152
154
|
// for it (null when unknown or there is no board); `pickupStatus` is what an unfinished slice goes back
|
|
153
|
-
// to (Planned, or
|
|
155
|
+
// to (Planned, or Waiting for UI kits). Actions: discard {base, skip}, push {status}, release {reason} —
|
|
154
156
|
// the loop appends `settled` after a successful push and `released` for a release. A failed push appends
|
|
155
157
|
// nothing, so the claim stays open and the next iteration settles it again.
|
|
156
158
|
export function decideSettle(state, sliceId, { boardStatus = null, pickupStatus = 'Planned' } = {}) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adrian Mowat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|