@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.
Files changed (80) hide show
  1. package/README.md +27 -9
  2. package/cli.js +369 -36
  3. package/lib/build-kits.js +23 -0
  4. package/lib/scaffold-model.js +71 -0
  5. package/lib/scaffold.js +224 -0
  6. package/lib/supervise.js +60 -0
  7. package/lib/ui-baseline.js +123 -0
  8. package/package.json +1 -1
  9. package/shared/build-kit/lib/build-log-share.js +149 -0
  10. package/shared/build-kit/lib/events-view.js +18 -3
  11. package/shared/build-kit/lib/ralph.js +27 -16
  12. package/shared/build-kit/lib/slice-events.js +42 -2
  13. package/shared/build-kit/lib/slice-info.js +52 -0
  14. package/shared/build-kit/lib/slice-run.js +2 -2
  15. package/shared/build-kit/lib/slice-state.js +6 -4
  16. package/shared/scaffold/LICENSE-em-scaffold +21 -0
  17. package/shared/scaffold/supabase/generate.mjs +290 -0
  18. package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
  19. package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
  20. package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
  21. package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
  22. package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
  23. package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
  24. package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
  25. package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
  26. package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
  27. package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
  28. package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
  29. package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
  30. package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
  31. package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
  32. package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
  33. package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
  34. package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
  35. package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
  36. package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
  37. package/shared/skills/ui-contract/SKILL.md +6 -6
  38. package/shared/skills/update-slice-status/SKILL.md +1 -1
  39. package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
  40. package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
  41. package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
  42. package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
  43. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
  44. package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
  45. package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
  46. package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
  47. package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
  48. package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
  49. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
  50. package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
  51. package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
  52. package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
  53. package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
  54. package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
  55. package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
  56. package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
  57. package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
  58. package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
  59. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
  60. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
  61. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
  62. package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
  63. package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
  64. package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
  65. package/stacks/react/templates/build-kit/lib/kit.json +1 -1
  66. package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
  67. package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
  68. package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
  69. package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
  70. package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
  71. package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
  72. package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
  73. package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
  74. package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
  75. package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
  76. package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
  77. package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
  78. package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
  79. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
  80. 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).toLowerCase();
277
- const plannedCtx = Object.keys(contexts).find(c => contexts[c].slices.some(s => (s.status || '').toLowerCase() === pickup));
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 Handover for UI kits) are handled by
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 || '').toLowerCase() !== readPickupStatus(kitDir).toLowerCase()) {
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": "Handover" } for the UI kits, which build what a backend kit handed over);
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).toLowerCase();
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 || '').toLowerCase() === pickup && !skipIds.has(s.id));
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).toLowerCase();
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 || '').toLowerCase() === pickup) ids.add(s.id);
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 `Handover` instead of `Done`: this run's
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 Handover) ignore it.
724
- const handover = planned && pickupStatus.toLowerCase() !== 'handover' ? `\nHandover: ${resolveHandover(cfg) ? 'on' : 'off'}\n` : '';
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. Returns the event id.
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; with `tag`, only those carrying it.
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.toLowerCase() !== pickupStatus.toLowerCase()) {
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
- const same = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
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
- // Handover for UI kits); any other status means it moved. Any status but Blocked lifts a block.
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 Handover for UI kits). Actions: discard {base, skip}, push {status}, release {reason} —
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.