@eventmodelers/cli 0.0.22 → 0.0.24
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 +29 -0
- package/cli.js +202 -22
- package/lib/fetch.js +167 -0
- package/package.json +2 -1
- package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +125 -0
- package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +95 -0
- package/shared/build-kit/lib/ralph.js +41 -22
- package/stacks/bridge/templates/bridge/lib/AGENT.md +47 -0
- package/stacks/bridge/templates/bridge/lib/prompt.md +111 -0
- package/stacks/bridge/templates/bridge/ralph-claude.js +48 -0
- package/stacks/bridge/templates/bridge/ralph-hook.js +103 -0
- package/stacks/bridge/templates/bridge/ralph-ollama.js +43 -0
package/README.md
CHANGED
|
@@ -59,6 +59,30 @@ npx @eventmodelers/cli init-modeling --global
|
|
|
59
59
|
|
|
60
60
|
Everything else (the kit dir, project scaffold, credentials, MCP registration) still targets the current directory as usual — `--global` only changes where skills land.
|
|
61
61
|
|
|
62
|
+
## Bridging to another spec framework
|
|
63
|
+
|
|
64
|
+
If you drive development with a different spec/task framework (Spec Kitty today; more later) instead of build-kit's own code generation, a **bridge** kit keeps that framework's artifacts in sync with the board instead of writing application code:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx @eventmodelers/cli init --bridge --target spec-kitty
|
|
68
|
+
npx @eventmodelers/cli bridge
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`init --bridge` installs a `.bridge-kit/` (mirrors `.build-kit/`'s realtime + task-queue loop) plus only the skills for the chosen `--target` (`shared/bridge/<target>/`) — a `spec-kitty` bridge never installs Kiro's skills, and vice versa. `bridge` starts the loop: on every board slice change (not just "Planned", unlike build-kit), it re-runs `bridge-<target>-specify` to regenerate that framework's spec artifacts from the current `.slices/` export. It doesn't build code and doesn't claim slices. Pass `--ollama` for the local-Ollama runner instead of Claude (same caveat as build-kit's `--ollama`: `lib/ollama-agent.js` is shared as-is).
|
|
72
|
+
|
|
73
|
+
### Overriding the executor with a hook
|
|
74
|
+
|
|
75
|
+
Claude is only the default — some teams don't want an AI agent in this loop at all, e.g. they'd rather just commit + push the board export and let a CI pipeline own the actual translation. `--hook` replaces the AI executor with an arbitrary shell command, run once per batch of slice changes:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx @eventmodelers/cli init --bridge --target spec-kitty --hook "git add .slices && git commit -m sync && git push"
|
|
79
|
+
npx @eventmodelers/cli bridge
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`init --bridge --hook` persists the command to `.bridge-kit/bridge.json` — a plain, **committed** file (unlike `.eventmodelers/config.json`, which is gitignored for credentials) since the hook is project policy meant to be shared by every teammate and CI runner, not per-machine state. `bridge --hook "<command>"` overrides it for a single run without touching that file. Only one executor runs per invocation — `--ollama` and `--hook` are mutually exclusive.
|
|
83
|
+
|
|
84
|
+
The hook command runs with `BRIDGE_TASK_COUNT`, `BRIDGE_SLICE_ID`/`_TITLE`/`_STATUS` (the most recent change in the batch), and `BRIDGE_BATCH_FILE` (path to the full batch as JSON) in its environment. It's invoked once per batch, not once per slice — any change that arrives while the hook is still running is left queued for the next batch rather than dropped.
|
|
85
|
+
|
|
62
86
|
## Claude execution & config resolution
|
|
63
87
|
|
|
64
88
|
During install you can optionally point the agent at a local LLM server (vLLM, Ollama) instead of the default Claude Code endpoint, and/or pin a specific model:
|
|
@@ -201,6 +225,9 @@ npx @eventmodelers/cli run --ollama # same, via local Ollama (ra
|
|
|
201
225
|
npx @eventmodelers/cli run --bash # bash-only loop, no realtime (ralph.sh)
|
|
202
226
|
npx @eventmodelers/cli listen # start the code-export listener (code-export.mjs) from the installed kit dir
|
|
203
227
|
npx @eventmodelers/cli listen --port 4000 # same, on a different port
|
|
228
|
+
npx @eventmodelers/cli fetch # pull full slice detail from every context on the board into <kit-dir>/.slices/
|
|
229
|
+
npx @eventmodelers/cli fetch --slice-id <id> # same, then print just that slice
|
|
230
|
+
npx @eventmodelers/cli fetch --slice-title <title> # same, then print just the slice matching this title
|
|
204
231
|
npx @eventmodelers/cli stacks # list available stacks
|
|
205
232
|
npx @eventmodelers/cli status # check what's installed
|
|
206
233
|
npx @eventmodelers/cli config # print the fully resolved config (file + env), token masked
|
|
@@ -211,6 +238,8 @@ npx @eventmodelers/cli uninstall # remove everything init/ini
|
|
|
211
238
|
|
|
212
239
|
`listen` is the same kind of dispatcher, but for `<kit-dir>/code-export.mjs` — a local HTTP server (port 3001 by default) that the eventmodelers board UI posts slice/screen data to, which then gets written under `<kit-dir>/.slices/`.
|
|
213
240
|
|
|
241
|
+
`fetch` is the pull-based counterpart to `listen`: instead of waiting for the board UI to push data to a running listener, it lists every `MODEL_CONTEXT` node on the board, calls `slicedata?contextId=<id>` for each (full slice detail — commands/events/readmodels/screens/processors/specifications/comments), and writes the same `.slices/<context>/<slice>/slice.json`, `index.json`, and `context.json` layout — useful in CI or any context where nothing is listening on a port. It does not fetch screen images (those only arrive via `listen`'s push). If credentials are missing, it prompts the same way `init-config` does. `--slice-id`/`--slice-title` still fetch and persist everything, then just print the one you asked about.
|
|
242
|
+
|
|
214
243
|
### Uninstall
|
|
215
244
|
|
|
216
245
|
Every `init`/`init-modeling` run writes an install manifest into `<kit-dir>/.eventmodelers/install-manifest.json` recording exactly what it put down. `uninstall` reads that manifest back and removes only:
|
package/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { execSync, spawn } from 'child_process';
|
|
|
18
18
|
import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from 'readline';
|
|
19
19
|
import { homedir } from 'os';
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
|
+
import { runFetch } from './lib/fetch.js';
|
|
21
22
|
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
24
|
const __dirname = dirname(__filename);
|
|
@@ -80,7 +81,32 @@ const MODELING_KIT = {
|
|
|
80
81
|
needsBoardId: false,
|
|
81
82
|
};
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
// Frameworks a bridge install can translate board slices into. Each key needs
|
|
85
|
+
// a matching `bridge-<key>-specify` skill under shared/bridge/ — see
|
|
86
|
+
// stacks/bridge/templates/bridge/lib/prompt.md for how the loop picks it up.
|
|
87
|
+
const BRIDGE_TARGETS = {
|
|
88
|
+
'spec-kitty': { label: 'Spec Kitty' },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Also not a stack — no backend scaffold, just the bridge-*/shared skills +
|
|
92
|
+
// the agent loop. Installed via `init --bridge --target <name>` instead of
|
|
93
|
+
// the `init --stack <name>` picker. useShared:true (unlike modeling-kit): a
|
|
94
|
+
// bridge agent reuses build-kit's cold-spawn/tasks.json engine as-is
|
|
95
|
+
// (lib/ralph.js) — it just reacts to every slice change instead of only
|
|
96
|
+
// "Planned" ones (see queueAllStatuses in lib/ralph.js) and translates
|
|
97
|
+
// instead of building. Its own templates/bridge overlay swaps in
|
|
98
|
+
// bridge-specific prompt.md/AGENT.md and a ralph-claude.js that omits
|
|
99
|
+
// onPlannedSlice entirely — see stacks/bridge/templates/bridge.
|
|
100
|
+
const BRIDGE_KIT = {
|
|
101
|
+
key: 'bridge',
|
|
102
|
+
label: 'Bridge — translate board slices into another spec framework, no backend scaffold',
|
|
103
|
+
kitSubdir: 'bridge',
|
|
104
|
+
kitDirName: '.bridge-kit',
|
|
105
|
+
useShared: true,
|
|
106
|
+
needsBoardId: true,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const KIT_DIR_NAMES = [...new Set([...Object.values(STACKS), MODELING_KIT, BRIDGE_KIT].map((s) => s.kitDirName))];
|
|
84
110
|
|
|
85
111
|
// Same principle Playwright MCP uses per harness: one shared server, but each coding
|
|
86
112
|
// agent has its own registration mechanism. Automate the ones with a real, verified
|
|
@@ -441,17 +467,24 @@ function readJsonSafe(path) {
|
|
|
441
467
|
// Distinguishes this agent process from any other agent pinging the same
|
|
442
468
|
// token/board — e.g. a build-kit and a modeling-kit install in the same project
|
|
443
469
|
// share one root config.json, and without a per-agent id both would upsert the
|
|
444
|
-
// same alive row and race each other.
|
|
445
|
-
// (
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
470
|
+
// same alive row and race each other. The platform already keys the alive-ping
|
|
471
|
+
// on the (agent_type, agent_id) pair, so one shared file works: agentIds is
|
|
472
|
+
// namespaced by agentType inside the project ROOT .eventmodelers/config.json —
|
|
473
|
+
// the same file credentials already live in — instead of each kit dir keeping
|
|
474
|
+
// its own separate config.json (mirrors shared/build-kit/lib/ralph.js's
|
|
475
|
+
// ensureAgentId, duplicated here since this file isn't copied into projects).
|
|
476
|
+
function ensureAgentId(kitDir, agentType) {
|
|
477
|
+
const rootConfigPath = join(dirname(kitDir), '.eventmodelers', 'config.json');
|
|
478
|
+
const rootCfg = readJsonSafe(rootConfigPath);
|
|
479
|
+
rootCfg.agentIds = rootCfg.agentIds || {};
|
|
480
|
+
if (rootCfg.agentIds[agentType]) return rootCfg.agentIds[agentType];
|
|
481
|
+
|
|
482
|
+
const legacyAgentId = readJsonSafe(join(kitDir, '.eventmodelers', 'config.json')).agentId;
|
|
483
|
+
|
|
484
|
+
const agentId = legacyAgentId || randomUUID();
|
|
485
|
+
rootCfg.agentIds[agentType] = agentId;
|
|
486
|
+
mkdirSync(dirname(rootConfigPath), { recursive: true });
|
|
487
|
+
writeFileSync(rootConfigPath, JSON.stringify(rootCfg, null, 2));
|
|
455
488
|
return agentId;
|
|
456
489
|
}
|
|
457
490
|
|
|
@@ -540,6 +573,14 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
540
573
|
// of sync with each other before (e.g. one stack's connect skill silently
|
|
541
574
|
// missing a bugfix another stack's copy had).
|
|
542
575
|
const sharedSkills = join(__dirname, 'shared', 'skills');
|
|
576
|
+
// Adapter skills that translate board slices for another spec framework —
|
|
577
|
+
// one subfolder per target (shared/bridge/spec-kitty/bridge-spec-kitty-*,
|
|
578
|
+
// shared/bridge/kiro/..., etc.), so a bridge install only ever pulls in
|
|
579
|
+
// the target it was actually configured for, not every framework's
|
|
580
|
+
// skills. Only relevant to a bridge install — never copied into the four
|
|
581
|
+
// backend stacks or modeling-kit.
|
|
582
|
+
const isBridge = stackKey === BRIDGE_KIT.key;
|
|
583
|
+
const sharedBridgeSkills = isBridge ? join(__dirname, 'shared', 'bridge', options.target) : null;
|
|
543
584
|
|
|
544
585
|
if (!existsSync(templatesSource)) {
|
|
545
586
|
console.error('❌ Templates directory not found at:', templatesSource);
|
|
@@ -552,6 +593,7 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
552
593
|
const claudeSkillsSrc = join(templatesSource, '.claude', 'skills');
|
|
553
594
|
const installedSkills = [
|
|
554
595
|
...(existsSync(sharedSkills) ? readdirSync(sharedSkills) : []),
|
|
596
|
+
...(isBridge && existsSync(sharedBridgeSkills) ? readdirSync(sharedBridgeSkills) : []),
|
|
555
597
|
...(existsSync(claudeSkillsSrc) ? readdirSync(claudeSkillsSrc) : []),
|
|
556
598
|
];
|
|
557
599
|
let claudeExtras = [];
|
|
@@ -560,11 +602,13 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
560
602
|
const globalSkillsDir = join(homedir(), '.claude', 'skills');
|
|
561
603
|
console.log('📦 Installing skills globally...');
|
|
562
604
|
copyDirContents(sharedSkills, globalSkillsDir);
|
|
605
|
+
if (isBridge) copyDirContents(sharedBridgeSkills, globalSkillsDir);
|
|
563
606
|
copyDirContents(claudeSkillsSrc, globalSkillsDir);
|
|
564
607
|
} else {
|
|
565
608
|
console.log('📦 Installing skills...');
|
|
566
609
|
copyDirContents(join(templatesSource, '.claude'), join(targetDir, '.claude'));
|
|
567
610
|
copyDirContents(sharedSkills, join(targetDir, '.claude', 'skills'));
|
|
611
|
+
if (isBridge) copyDirContents(sharedBridgeSkills, join(targetDir, '.claude', 'skills'));
|
|
568
612
|
claudeExtras = existsSync(join(templatesSource, '.claude'))
|
|
569
613
|
? readdirSync(join(templatesSource, '.claude')).filter((f) => f !== 'skills')
|
|
570
614
|
: [];
|
|
@@ -648,7 +692,11 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
648
692
|
);
|
|
649
693
|
|
|
650
694
|
console.log('\n✅ Done! Start your agent:\n');
|
|
651
|
-
|
|
695
|
+
if (isBridge) {
|
|
696
|
+
console.log(' npx @eventmodelers/cli bridge\n');
|
|
697
|
+
} else {
|
|
698
|
+
console.log(' npx @eventmodelers/cli run (--ollama or --bash for other runners)\n');
|
|
699
|
+
}
|
|
652
700
|
console.log('Connect this project to an MCP client (Claude Code, VS Code, ...):\n');
|
|
653
701
|
console.log(` npx @eventmodelers/cli init-mcp\n`);
|
|
654
702
|
console.log('Expose these skills to other AI agent hosts (Cursor, Windsurf, Gemini CLI, Copilot, Codex CLI, Kiro, ...):\n');
|
|
@@ -846,7 +894,7 @@ async function runModeling(kitDir, projectDir) {
|
|
|
846
894
|
const { createClient } = await import('@supabase/supabase-js');
|
|
847
895
|
|
|
848
896
|
const local = loadLocalConfig(kitDir);
|
|
849
|
-
local.agentId =
|
|
897
|
+
local.agentId = ensureAgentId(kitDir, 'MODELING');
|
|
850
898
|
if (!local.token || !local.organizationId) {
|
|
851
899
|
console.error('❌ --modeling needs platform credentials in .eventmodelers/config.json (token + organizationId) — run `/connect` once or paste your config first.');
|
|
852
900
|
process.exit(1);
|
|
@@ -1075,6 +1123,7 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
|
1075
1123
|
console.error(' Run one of these first:');
|
|
1076
1124
|
console.error(` npx @eventmodelers/cli init --stack <name> (${Object.keys(STACKS).join(', ')})`);
|
|
1077
1125
|
console.error(' npx @eventmodelers/cli init --modeling');
|
|
1126
|
+
console.error(` npx @eventmodelers/cli init --bridge --target <name> (${Object.keys(BRIDGE_TARGETS).join(', ')})`);
|
|
1078
1127
|
process.exit(1);
|
|
1079
1128
|
});
|
|
1080
1129
|
|
|
@@ -1097,19 +1146,25 @@ function credentialOverridesFromOpts(opts) {
|
|
|
1097
1146
|
credentialFlags(program
|
|
1098
1147
|
.command('init')
|
|
1099
1148
|
.alias('install')
|
|
1100
|
-
.description('Scaffold a stack + install the agent kit into the current directory (or --modeling for skills + agent loop only, no backend scaffold)')
|
|
1149
|
+
.description('Scaffold a stack + install the agent kit into the current directory (or --modeling for skills + agent loop only, no backend scaffold; or --bridge to translate board slices into another spec framework)')
|
|
1101
1150
|
.option('--stack <name>', `Stack to install (${Object.keys(STACKS).join(', ')})`)
|
|
1102
|
-
.option('--modeling', 'Install skills + the agent loop only — no backend scaffold. Mutually exclusive with --stack.')
|
|
1151
|
+
.option('--modeling', 'Install skills + the agent loop only — no backend scaffold. Mutually exclusive with --stack/--bridge.')
|
|
1152
|
+
.option('--bridge', 'Install a bridge kit — translates board slices into another spec framework instead of building code. Mutually exclusive with --stack/--modeling. Requires --target.')
|
|
1153
|
+
.option('--target <name>', `Bridge target framework (${Object.keys(BRIDGE_TARGETS).join(', ')}) — only meaningful with --bridge`)
|
|
1154
|
+
.option('--hook <command>', 'Persist a default shell command hook for `bridge` to run per batch of slice changes instead of Claude/Ollama (e.g. commit + push .slices/ for a CI pipeline to pick up) — only meaningful with --bridge. Can also be set per-run with `bridge --hook`.')
|
|
1103
1155
|
.option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
|
|
1104
1156
|
.option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
|
|
1105
1157
|
.action(async (opts, command) => {
|
|
1106
1158
|
const globalOpts = command.optsWithGlobals();
|
|
1107
1159
|
|
|
1108
|
-
if (opts.modeling) {
|
|
1109
|
-
if (opts.stack) {
|
|
1110
|
-
console.error('❌ --modeling and --
|
|
1160
|
+
if (opts.modeling || opts.bridge) {
|
|
1161
|
+
if (opts.stack || (opts.modeling && opts.bridge)) {
|
|
1162
|
+
console.error('❌ --stack, --modeling, and --bridge are mutually exclusive — pick one.');
|
|
1111
1163
|
process.exit(1);
|
|
1112
1164
|
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (opts.modeling) {
|
|
1113
1168
|
await installStack(MODELING_KIT.key, MODELING_KIT, {
|
|
1114
1169
|
configPath: globalOpts.config,
|
|
1115
1170
|
print: globalOpts.print,
|
|
@@ -1120,6 +1175,37 @@ credentialFlags(program
|
|
|
1120
1175
|
return;
|
|
1121
1176
|
}
|
|
1122
1177
|
|
|
1178
|
+
if (opts.bridge) {
|
|
1179
|
+
if (!opts.target) {
|
|
1180
|
+
console.error(`❌ --bridge requires --target (${Object.keys(BRIDGE_TARGETS).join(', ')}).`);
|
|
1181
|
+
process.exit(1);
|
|
1182
|
+
}
|
|
1183
|
+
if (!BRIDGE_TARGETS[opts.target]) {
|
|
1184
|
+
console.error(`❌ Unknown bridge target "${opts.target}". Available: ${Object.keys(BRIDGE_TARGETS).join(', ')}`);
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
await installStack(BRIDGE_KIT.key, BRIDGE_KIT, {
|
|
1188
|
+
configPath: globalOpts.config,
|
|
1189
|
+
print: globalOpts.print,
|
|
1190
|
+
global: opts.global,
|
|
1191
|
+
force: opts.force,
|
|
1192
|
+
credentialOverrides: credentialOverridesFromOpts(opts),
|
|
1193
|
+
target: opts.target,
|
|
1194
|
+
});
|
|
1195
|
+
// Deliberately NOT under .bridge-kit/.eventmodelers/ — that whole name is
|
|
1196
|
+
// gitignored (a bare `.eventmodelers` pattern matches at any depth, since
|
|
1197
|
+
// it protects the root credentials file), so anything written there is
|
|
1198
|
+
// per-machine only. target/hookCommand are project policy — how this repo
|
|
1199
|
+
// reacts to board changes — meant to be committed and shared by every
|
|
1200
|
+
// teammate and CI runner, so they live in a plain sibling file instead.
|
|
1201
|
+
const bridgeConfigPath = join(process.cwd(), BRIDGE_KIT.kitDirName, 'bridge.json');
|
|
1202
|
+
const existingBridgeCfg = readJsonSafe(bridgeConfigPath);
|
|
1203
|
+
mkdirSync(dirname(bridgeConfigPath), { recursive: true });
|
|
1204
|
+
writeFileSync(bridgeConfigPath, JSON.stringify({ ...existingBridgeCfg, target: opts.target, ...(opts.hook ? { hookCommand: opts.hook } : {}) }, null, 2));
|
|
1205
|
+
console.log(` ✓ Bridge target set to "${opts.target}"${opts.hook ? ` with hook: ${opts.hook}` : ''}`);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1123
1209
|
const stackKey = await resolveStack(opts.stack);
|
|
1124
1210
|
await installStack(stackKey, STACKS[stackKey], {
|
|
1125
1211
|
configPath: globalOpts.config,
|
|
@@ -1224,7 +1310,12 @@ program
|
|
|
1224
1310
|
// we resolve each stack's dir independently instead of relying on that order.
|
|
1225
1311
|
const installedKitDirs = findAllInstalledKitDirs(cwd);
|
|
1226
1312
|
const modelingKitDir = installedKitDirs.find((d) => d.endsWith(MODELING_KIT.kitDirName)) ?? null;
|
|
1227
|
-
const
|
|
1313
|
+
const bridgeKitDir = installedKitDirs.find((d) => d.endsWith(BRIDGE_KIT.kitDirName)) ?? null;
|
|
1314
|
+
// A bridge kit is not a build-kit stand-in even though it also reuses
|
|
1315
|
+
// lib/ralph.js — it has its own `eventmodelers bridge` entrypoint (no
|
|
1316
|
+
// onPlannedSlice/--ollama/--bash support), so it's excluded here rather
|
|
1317
|
+
// than falling through to the generic build-kit runner below.
|
|
1318
|
+
const buildKitDir = installedKitDirs.find((d) => d !== modelingKitDir && d !== bridgeKitDir) ?? null;
|
|
1228
1319
|
|
|
1229
1320
|
// No overlap between the two stacks' runtimes: modeling-kit only ever runs the
|
|
1230
1321
|
// warm, direct-dispatch loop (--modeling); build-kit only ever runs the
|
|
@@ -1258,6 +1349,8 @@ program
|
|
|
1258
1349
|
if (!buildKitDir) {
|
|
1259
1350
|
if (modelingKitDir) {
|
|
1260
1351
|
console.error(`❌ A modeling-kit install (${MODELING_KIT.kitDirName}/) only runs via \`eventmodelers run --modeling\` — there is no cold-spawn/tasks.json loop for modeling-only projects.`);
|
|
1352
|
+
} else if (bridgeKitDir) {
|
|
1353
|
+
console.error(`❌ A bridge-kit install (${BRIDGE_KIT.kitDirName}/) only runs via \`eventmodelers bridge\` — it has no --modeling/--ollama/--bash modes.`);
|
|
1261
1354
|
} else {
|
|
1262
1355
|
console.error(`❌ No kit installed in ${cwd} — run \`eventmodelers install\` first.`);
|
|
1263
1356
|
}
|
|
@@ -1291,6 +1384,53 @@ program
|
|
|
1291
1384
|
}
|
|
1292
1385
|
});
|
|
1293
1386
|
|
|
1387
|
+
program
|
|
1388
|
+
.command('bridge')
|
|
1389
|
+
.description('Start the bridge agent loop from the installed .bridge-kit/ — translates board slice changes into another spec framework instead of building code. Claude is the default executor; --ollama or --hook override it.')
|
|
1390
|
+
.option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner')
|
|
1391
|
+
.option('--hook <command>', 'Run this shell command instead of an AI agent for each batch of slice changes (e.g. commit + push .slices/ for a CI pipeline to pick up) — overrides any hook persisted via `init --bridge --hook` for this run only')
|
|
1392
|
+
.action((opts) => {
|
|
1393
|
+
const cwd = process.cwd();
|
|
1394
|
+
const kitDir = findAllInstalledKitDirs(cwd).find((d) => d.endsWith(BRIDGE_KIT.kitDirName)) ?? null;
|
|
1395
|
+
if (!kitDir) {
|
|
1396
|
+
console.error(`❌ No bridge-kit installed in ${cwd} — run \`eventmodelers init --bridge --target <name>\` first.`);
|
|
1397
|
+
process.exit(1);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (opts.ollama && opts.hook) {
|
|
1401
|
+
console.error('❌ --ollama and --hook are mutually exclusive — pick one executor.');
|
|
1402
|
+
process.exit(1);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// A persisted default (from `init --bridge --hook`) lives in bridge.json, a
|
|
1406
|
+
// plain sibling file — NOT under .eventmodelers/, which is gitignored (see
|
|
1407
|
+
// the comment in `init`'s --bridge branch) and would otherwise make this
|
|
1408
|
+
// per-machine instead of a shared, checked-in team/CI convention.
|
|
1409
|
+
const persistedHook = readJsonSafe(join(kitDir, 'bridge.json')).hookCommand;
|
|
1410
|
+
const hookCmd = opts.hook || persistedHook;
|
|
1411
|
+
|
|
1412
|
+
// v1 has no --bash equivalent, unlike `run` — the default/--ollama path
|
|
1413
|
+
// needs an actual agent, not a plain shell script; --hook is the escape
|
|
1414
|
+
// hatch for teams who want a plain shell command instead of an AI agent.
|
|
1415
|
+
const runner = hookCmd ? 'ralph-hook.js' : opts.ollama ? 'ralph-ollama.js' : 'ralph-claude.js';
|
|
1416
|
+
const runnerPath = join(kitDir, runner);
|
|
1417
|
+
if (!existsSync(runnerPath)) {
|
|
1418
|
+
console.error(`❌ ${relative(cwd, runnerPath)} not found.`);
|
|
1419
|
+
process.exit(1);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
console.log(`▶ Starting ${relative(cwd, runnerPath)}${hookCmd ? ` (hook: ${hookCmd})` : ''}...\n`);
|
|
1423
|
+
try {
|
|
1424
|
+
execSync(`node "${runnerPath}"`, {
|
|
1425
|
+
cwd: kitDir,
|
|
1426
|
+
stdio: 'inherit',
|
|
1427
|
+
env: hookCmd ? { ...process.env, BRIDGE_HOOK_CMD: hookCmd } : process.env,
|
|
1428
|
+
});
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
process.exit(err.status || 1);
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1294
1434
|
program
|
|
1295
1435
|
.command('listen')
|
|
1296
1436
|
.description('Start the code-export listener (code-export.mjs) from the installed kit dir — receives slice/screen data pushed from the eventmodelers board UI and writes it into .slices/')
|
|
@@ -1314,6 +1454,44 @@ program
|
|
|
1314
1454
|
}
|
|
1315
1455
|
});
|
|
1316
1456
|
|
|
1457
|
+
program
|
|
1458
|
+
.command('fetch')
|
|
1459
|
+
.description('Pull full slice detail from every context on the board via the slicedata API and write it into .slices/ — the pull-based counterpart to `listen`, without screen images')
|
|
1460
|
+
.option('--slice-id <id>', 'After fetching, print just the slice with this id')
|
|
1461
|
+
.option('--slice-title <title>', 'After fetching, print just the slice with this title (case-insensitive)')
|
|
1462
|
+
.action(async (opts, command) => {
|
|
1463
|
+
const cwd = process.cwd();
|
|
1464
|
+
const kitDir = findInstalledKitDir(cwd);
|
|
1465
|
+
const globalOpts = command.optsWithGlobals();
|
|
1466
|
+
const explicitConfig = globalOpts.config;
|
|
1467
|
+
const effective = loadEffectiveConfig(cwd, kitDir, explicitConfig);
|
|
1468
|
+
let cfg = effective.config;
|
|
1469
|
+
|
|
1470
|
+
const requiredFields = ['organizationId', 'boardId', 'token'];
|
|
1471
|
+
if (requiredFields.some((f) => !cfg[f])) {
|
|
1472
|
+
// Same default (project-root .eventmodelers/config.json, or --config) that
|
|
1473
|
+
// installStack uses — kept identical rather than deriving a path from
|
|
1474
|
+
// `effective`, which can point at a kit-dir-scoped config instead.
|
|
1475
|
+
const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
|
|
1476
|
+
// Same prompt (paste/manual/instructions/skip) `install`/`init-config` use —
|
|
1477
|
+
// reusing it here means `fetch` also works as a first-run credential setup.
|
|
1478
|
+
cfg = await configureCredentials({
|
|
1479
|
+
config: cfg,
|
|
1480
|
+
configPath,
|
|
1481
|
+
targetDir: cwd,
|
|
1482
|
+
requiredFields,
|
|
1483
|
+
boardIdOptional: false,
|
|
1484
|
+
overrides: {},
|
|
1485
|
+
print: globalOpts.print,
|
|
1486
|
+
});
|
|
1487
|
+
if (requiredFields.some((f) => !cfg[f])) {
|
|
1488
|
+
console.error('❌ Still missing token/organizationId/boardId — re-run `eventmodelers fetch` once configured.');
|
|
1489
|
+
process.exit(1);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
await runFetch({ cwd, kitDir, cfg, opts });
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1317
1495
|
program
|
|
1318
1496
|
.command('stacks')
|
|
1319
1497
|
.description('List available stacks (for `init --stack`)')
|
|
@@ -1409,14 +1587,16 @@ program
|
|
|
1409
1587
|
.description('Remove everything init (with or without --modeling) installed: the kit dir, the skills it copied (project-local or ~/.claude/skills with --global), its MCP entry in .claude/settings.json, and any files written by init-agents. Leaves the root project scaffold untouched.')
|
|
1410
1588
|
.option('--build-kit', `Remove ${STACKS.node.kitDirName}/ (the backend-stack kit dir)`)
|
|
1411
1589
|
.option('--modeling-kit', `Remove ${MODELING_KIT.kitDirName}/ (the modeling-only kit dir)`)
|
|
1590
|
+
.option('--bridge-kit', `Remove ${BRIDGE_KIT.kitDirName}/ (the bridge kit dir)`)
|
|
1412
1591
|
.action((opts) => {
|
|
1413
1592
|
const cwd = process.cwd();
|
|
1414
1593
|
let targets;
|
|
1415
1594
|
|
|
1416
|
-
if (opts.buildKit || opts.modelingKit) {
|
|
1595
|
+
if (opts.buildKit || opts.modelingKit || opts.bridgeKit) {
|
|
1417
1596
|
targets = [];
|
|
1418
1597
|
if (opts.buildKit) targets.push(join(cwd, STACKS.node.kitDirName));
|
|
1419
1598
|
if (opts.modelingKit) targets.push(join(cwd, MODELING_KIT.kitDirName));
|
|
1599
|
+
if (opts.bridgeKit) targets.push(join(cwd, BRIDGE_KIT.kitDirName));
|
|
1420
1600
|
targets = targets.filter((p) => existsSync(p));
|
|
1421
1601
|
if (!targets.length) {
|
|
1422
1602
|
console.log('ℹ️ Nothing to remove for the requested option(s).');
|
|
@@ -1429,7 +1609,7 @@ program
|
|
|
1429
1609
|
return;
|
|
1430
1610
|
}
|
|
1431
1611
|
if (targets.length > 1) {
|
|
1432
|
-
console.log('⚠️ Multiple kit dirs found — re-run with --build-kit or --
|
|
1612
|
+
console.log('⚠️ Multiple kit dirs found — re-run with --build-kit, --modeling-kit, and/or --bridge-kit to pick which to remove.');
|
|
1433
1613
|
targets.forEach((t) => console.log(` ${t}`));
|
|
1434
1614
|
return;
|
|
1435
1615
|
}
|
package/lib/fetch.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
|
|
5
|
+
|
|
6
|
+
function readJsonSafe(path) {
|
|
7
|
+
if (!path || !existsSync(path)) return {};
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Mirrors shared/build-kit/code-export.mjs's slugify — kept in sync by hand since
|
|
16
|
+
// that file is copied verbatim into every stack's kit dir and isn't importable here.
|
|
17
|
+
function slugify(text) {
|
|
18
|
+
return text
|
|
19
|
+
.toString()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, '-')
|
|
23
|
+
.replace(/[^\w\-]+/g, '')
|
|
24
|
+
.replace(/\-\-+/g, '-')
|
|
25
|
+
.replace(/^-+/, '')
|
|
26
|
+
.replace(/-+$/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Same folder-naming rule code-export.mjs applies to a slice title when writing
|
|
30
|
+
// .slices/<context>/<folder>/slice.json — kept identical so `fetch` and `listen`
|
|
31
|
+
// produce interchangeable output.
|
|
32
|
+
function sliceFolderName(title) {
|
|
33
|
+
return (title ?? '').replaceAll(' ', '').replaceAll('slice:', '').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Pulls full slice detail from every context on a board and writes it into
|
|
37
|
+
// .slices/, mirroring the layout code-export.mjs's /api/generate handler produces
|
|
38
|
+
// (minus screen images, which only ever arrive via that push-based listener).
|
|
39
|
+
//
|
|
40
|
+
// { cwd, kitDir, cfg: { token, organizationId, boardId, baseUrl }, opts: { sliceId?, sliceTitle? } }
|
|
41
|
+
export async function runFetch({ cwd, kitDir, cfg, opts = {} }) {
|
|
42
|
+
const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
|
|
43
|
+
const headers = { 'x-token': cfg.token, 'x-board-id': cfg.boardId, 'x-user-id': 'cli-fetch' };
|
|
44
|
+
|
|
45
|
+
async function fetchJson(url, what) {
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(url, { headers });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(`❌ Request failed (${what}): ${err.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
console.error(`❌ ${what}: HTTP ${res.status}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`▶ Fetching slices from ${baseUrl} (board ${cfg.boardId})...`);
|
|
61
|
+
|
|
62
|
+
// No dedicated "list contexts" endpoint — MODEL_CONTEXT nodes are the contexts,
|
|
63
|
+
// same as how the connect skill lists CHAPTER nodes for its health check.
|
|
64
|
+
const contextNodes = await fetchJson(
|
|
65
|
+
`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes?type=MODEL_CONTEXT`,
|
|
66
|
+
'nodes?type=MODEL_CONTEXT',
|
|
67
|
+
);
|
|
68
|
+
if (!contextNodes?.length) {
|
|
69
|
+
console.log('ℹ️ No contexts found on this board.');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// /slicedata (buildSliceData) is per-context and returns full slice detail —
|
|
74
|
+
// commands/events/readmodels/screens/processors/specifications/comments — unlike
|
|
75
|
+
// the lightweight /slicedata/slices summary. There's no "all contexts in one
|
|
76
|
+
// call" variant, so fetch each context's full data and merge client-side.
|
|
77
|
+
const allSlices = [];
|
|
78
|
+
for (const node of contextNodes) {
|
|
79
|
+
const contextName = node.meta?.title ?? node.node?.data?.title ?? '';
|
|
80
|
+
// contextId is the normal path (we already have the node id); contextName is
|
|
81
|
+
// the fallback the endpoint itself supports when an id can't be resolved.
|
|
82
|
+
const contextQuery = node.id ? `contextId=${encodeURIComponent(node.id)}` : `contextName=${encodeURIComponent(contextName)}`;
|
|
83
|
+
const { slices } = await fetchJson(
|
|
84
|
+
`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/slicedata?${contextQuery}`,
|
|
85
|
+
`slicedata?${contextQuery}`,
|
|
86
|
+
);
|
|
87
|
+
allSlices.push(...slices);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!allSlices.length) {
|
|
91
|
+
console.log('ℹ️ No slices found on this board.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SLICES_DIR = join(kitDir, '.slices');
|
|
96
|
+
const contextNames = new Set();
|
|
97
|
+
|
|
98
|
+
for (const slice of allSlices) {
|
|
99
|
+
// buildSliceData names this field `context`, not `contextName` (that's the
|
|
100
|
+
// /slicedata/slices summary endpoint's field) — read the one this endpoint sends.
|
|
101
|
+
const contextName = slice.context || 'default';
|
|
102
|
+
contextNames.add(contextName);
|
|
103
|
+
const contextSlug = slugify(contextName) || 'default';
|
|
104
|
+
const baseFolder = join(SLICES_DIR, contextSlug);
|
|
105
|
+
const sliceFolder = sliceFolderName(slice.title);
|
|
106
|
+
mkdirSync(join(baseFolder, sliceFolder), { recursive: true });
|
|
107
|
+
|
|
108
|
+
const sliceData = { ...slice };
|
|
109
|
+
delete sliceData.index;
|
|
110
|
+
writeFileSync(join(baseFolder, sliceFolder, 'slice.json'), JSON.stringify(sliceData, null, 2));
|
|
111
|
+
writeFileSync(join(baseFolder, 'context.json'), JSON.stringify({ name: contextName }, null, 2));
|
|
112
|
+
|
|
113
|
+
const indexFile = join(baseFolder, 'index.json');
|
|
114
|
+
const sliceIndices = readJsonSafe(indexFile);
|
|
115
|
+
if (!Array.isArray(sliceIndices.slices)) sliceIndices.slices = [];
|
|
116
|
+
|
|
117
|
+
const entry = {
|
|
118
|
+
id: slice.id,
|
|
119
|
+
slice: slice.title,
|
|
120
|
+
contextName,
|
|
121
|
+
contextSlug,
|
|
122
|
+
folder: sliceFolder,
|
|
123
|
+
status: slice.status,
|
|
124
|
+
definition: slice,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const existingIdx = sliceIndices.slices.findIndex((it) => it.id === slice.id);
|
|
128
|
+
if (existingIdx === -1) {
|
|
129
|
+
sliceIndices.slices.push(entry);
|
|
130
|
+
} else {
|
|
131
|
+
// Preserve `assigned` — it's local agent-claim state, not something the board tracks.
|
|
132
|
+
sliceIndices.slices[existingIdx] = { ...entry, assigned: sliceIndices.slices[existingIdx].assigned };
|
|
133
|
+
}
|
|
134
|
+
writeFileSync(indexFile, JSON.stringify(sliceIndices, null, 2));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A single shared current_context.json only makes sense when everything fetched
|
|
138
|
+
// belongs to one context — with several, any one choice would be arbitrary, so
|
|
139
|
+
// leave whatever `listen`/a prior fetch already wrote there untouched.
|
|
140
|
+
if (contextNames.size === 1) {
|
|
141
|
+
writeFileSync(join(SLICES_DIR, 'current_context.json'), JSON.stringify({ name: [...contextNames][0] }, null, 2));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(`✅ Fetched ${allSlices.length} slice${allSlices.length === 1 ? '' : 's'} across ${contextNames.size} context${contextNames.size === 1 ? '' : 's'} → ${relative(cwd, SLICES_DIR)}/`);
|
|
145
|
+
|
|
146
|
+
// --slice-id/--slice-title mirror load-slice's Step 4/5 — fetch+persist everything
|
|
147
|
+
// regardless, then just report the one the caller asked about.
|
|
148
|
+
if (opts.sliceId || opts.sliceTitle) {
|
|
149
|
+
const match = opts.sliceId
|
|
150
|
+
? allSlices.find((s) => s.id === opts.sliceId)
|
|
151
|
+
: allSlices.find((s) => (s.title ?? '').toLowerCase() === opts.sliceTitle.toLowerCase());
|
|
152
|
+
|
|
153
|
+
if (!match) {
|
|
154
|
+
console.error(`\n❌ No slice found matching ${opts.sliceId ? `id "${opts.sliceId}"` : `title "${opts.sliceTitle}"`}. Available titles:`);
|
|
155
|
+
allSlices.forEach((s) => console.error(` - ${s.title}`));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const contextSlug = slugify(match.context || 'default') || 'default';
|
|
160
|
+
const sliceFolder = sliceFolderName(match.title);
|
|
161
|
+
console.log('\nRequested slice:');
|
|
162
|
+
console.log(` Title: ${match.title}`);
|
|
163
|
+
console.log(` ID: ${match.id}`);
|
|
164
|
+
console.log(` Status: ${match.status}`);
|
|
165
|
+
console.log(` Folder: ${relative(cwd, join(SLICES_DIR, contextSlug, sliceFolder, 'slice.json'))}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.js",
|
|
11
|
+
"lib",
|
|
11
12
|
"shared",
|
|
12
13
|
"stacks",
|
|
13
14
|
"README.md"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bridge-spec-kitty-specify
|
|
3
|
+
description: "Adapter: generate a Spec Kitty mission spec.md (and auto-evaluate its quality checklist) from an Eventmodelers board's exported event model, instead of running the /spec-kitty.specify interview. Use when the user says \"use the event model as the spec\", \"sync the board to spec-kitty\", \"generate the spec from the event model\", or when a .slices/ export exists and a Spec Kitty mission needs to start or refresh from it."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# bridge-spec-kitty-specify
|
|
7
|
+
|
|
8
|
+
Replaces the human interview in `/spec-kitty.specify` with a direct read of an
|
|
9
|
+
Eventmodelers board export. The event model is the source of truth; `spec.md`
|
|
10
|
+
becomes a **generated, read-only artifact** derived from it. Everything
|
|
11
|
+
downstream of specify (`/spec-kitty.plan`, `/spec-kitty.tasks`,
|
|
12
|
+
implement/review/merge) is untouched — this skill only produces the two
|
|
13
|
+
artifacts that phase gate expects: `spec.md` and `checklists/requirements.md`.
|
|
14
|
+
|
|
15
|
+
Do not use this to hand-author or hand-edit `spec.md` — if the event model is
|
|
16
|
+
wrong, fix it on the board and re-run this skill. A `spec.md` produced here
|
|
17
|
+
that gets manually edited afterward will be silently overwritten on the next
|
|
18
|
+
sync; there is no merge/diff in this version (see Limitations).
|
|
19
|
+
|
|
20
|
+
## Inputs
|
|
21
|
+
|
|
22
|
+
The Eventmodelers `listen` server (`npx @eventmodelers/cli listen`) writes the
|
|
23
|
+
board export locally. Read it directly — do not call the board API:
|
|
24
|
+
|
|
25
|
+
- `.slices/current_context.json` → `{ "name": "<context>" }` — which context
|
|
26
|
+
to ingest if the user didn't name one.
|
|
27
|
+
- `.slices/<context>/index.json` → `{ "slices": [{ id, slice, index, context,
|
|
28
|
+
folder, status, group }] }` — the ordered slice list.
|
|
29
|
+
- `.slices/<context>/context.json` → context-level name/package info.
|
|
30
|
+
- `.slices/<context>/<folder>/slice.json` → full slice detail per the
|
|
31
|
+
[event-modeling-spec](https://github.com/dilgerma/event-modeling-spec)
|
|
32
|
+
contract: `commands[]`, `events[]`, `readModels[]` (projections/queries),
|
|
33
|
+
`screens[]`, `automations[]`/`processors[]`, `specifications[]` (given/when/
|
|
34
|
+
then), plus free-text `description`/`notes`.
|
|
35
|
+
|
|
36
|
+
If `.slices/` doesn't exist yet, stop and tell the user to run
|
|
37
|
+
`npx @eventmodelers/cli listen` (or `init-modeling` first if the kit isn't
|
|
38
|
+
installed) and push the board export before retrying.
|
|
39
|
+
|
|
40
|
+
## Flow
|
|
41
|
+
|
|
42
|
+
1. **Resolve the mission.** If an existing `kitty-specs/<slug>/meta.json`
|
|
43
|
+
already points at this context (check `purpose_tldr`/a stored context
|
|
44
|
+
reference), re-sync into it. Otherwise create a new mission dir the same
|
|
45
|
+
way `/spec-kitty.specify` does: kebab-case name + 8-char ULID suffix,
|
|
46
|
+
`mission_type: software-dev` (this adapter does not introduce a new
|
|
47
|
+
mission type — see project memory on why). Write `meta.json` matching the
|
|
48
|
+
shape in an existing mission (`mission_id`, `mission_slug`, `purpose_tldr`,
|
|
49
|
+
`purpose_context`, `target_branch`, etc.).
|
|
50
|
+
2. **Load every slice** referenced in `index.json` by reading each
|
|
51
|
+
`<folder>/slice.json`. Do not skip slices with empty `specifications` —
|
|
52
|
+
surface them as gaps in the checklist instead (step 4).
|
|
53
|
+
3. **Compose `spec.md`** using the standard Spec Kitty template sections,
|
|
54
|
+
sourced only from what the model actually states — never invent detail
|
|
55
|
+
the board doesn't have:
|
|
56
|
+
- **Purpose** ← `context.json` name/description, or ask the user for a
|
|
57
|
+
one-paragraph purpose if the board has none.
|
|
58
|
+
- **Primary User Story** ← walk the slices in `index.json` order (this is
|
|
59
|
+
already the chronological/timeline order from the board) and narrate
|
|
60
|
+
the SCREEN → COMMAND → EVENT → READMODEL flow across them.
|
|
61
|
+
- **Acceptance Scenarios** ← one bullet per `specifications[]` entry,
|
|
62
|
+
copied straight from its given/when/then — do not paraphrase away
|
|
63
|
+
precision the model already captured.
|
|
64
|
+
- **Edge Cases** ← `specifications[]` entries that describe rejection/
|
|
65
|
+
negative/boundary behavior (reject, invalid, already-exists, etc.).
|
|
66
|
+
- **Domain Language** ← distinct nouns from event/command/read-model field
|
|
67
|
+
names and titles, deduped.
|
|
68
|
+
- **Functional Requirements (FR-###)** ← one row per COMMAND (state-change
|
|
69
|
+
slice) and per AUTOMATION slice; wording from the command/automation
|
|
70
|
+
name + `description`.
|
|
71
|
+
- **Non-Functional Requirements / Constraints** ← only from explicit
|
|
72
|
+
`notes`/`description` content tagged as timing/authorization/volume
|
|
73
|
+
constraints. If the model states none, leave the table empty — do not
|
|
74
|
+
fabricate NFRs to fill it.
|
|
75
|
+
- **Key Entities** ← distinct read-model/event payload shapes.
|
|
76
|
+
- **Interfaces to Other Teams** ← any `TRANSLATION`-type slice or external
|
|
77
|
+
system mentioned in a slice's `description`.
|
|
78
|
+
4. **Evaluate `checklists/requirements.md`** against the ingested model
|
|
79
|
+
instead of a human review pass. Check an item only if the model
|
|
80
|
+
demonstrably satisfies it:
|
|
81
|
+
- "No [NEEDS CLARIFICATION] markers remain" → fails if any slice has an
|
|
82
|
+
empty `specifications[]` or a `description` containing `TODO`/`TBD`.
|
|
83
|
+
- "Requirements are testable and unambiguous" → fails if an FR/NFR row has
|
|
84
|
+
no backing `specifications[]` entry.
|
|
85
|
+
- "Non-functional requirements include measurable thresholds" → fails
|
|
86
|
+
(leave unchecked, don't skip the row) if the NFR table is empty but the
|
|
87
|
+
model contains AUTOMATION slices implying a timing constraint the board
|
|
88
|
+
never made explicit.
|
|
89
|
+
- Leave any check unchecked with a `## Notes` explanation when the event
|
|
90
|
+
model genuinely doesn't provide enough to verify it — this checklist
|
|
91
|
+
gates `/spec-kitty.plan`, so a false pass defeats the point of the gate.
|
|
92
|
+
5. **Hand off.** Once `spec.md` + `checklists/requirements.md` are written,
|
|
93
|
+
tell the user the mission is ready for `/spec-kitty.plan` as normal — do
|
|
94
|
+
not run plan/tasks yourself from this skill.
|
|
95
|
+
|
|
96
|
+
## Re-sync behavior
|
|
97
|
+
|
|
98
|
+
Re-running this skill against the same mission regenerates `spec.md` and
|
|
99
|
+
`checklists/requirements.md` wholesale from the current `.slices/` state.
|
|
100
|
+
There is no field-level diff against the previous generation — if the spec
|
|
101
|
+
needs to reflect board changes, re-run this skill rather than hand-patching
|
|
102
|
+
`spec.md`.
|
|
103
|
+
|
|
104
|
+
## Guardrail
|
|
105
|
+
|
|
106
|
+
If a required section can't be populated from the event model (e.g. no
|
|
107
|
+
Purpose anywhere on the board), ask the user rather than inventing one — an
|
|
108
|
+
invented Purpose defeats the reason this adapter exists (the event model,
|
|
109
|
+
not the LLM, is the source of truth for product intent).
|
|
110
|
+
|
|
111
|
+
## Limitations (by design, v1)
|
|
112
|
+
|
|
113
|
+
- Does not itself generate `tasks/WP##-*.md` from slices — see the companion
|
|
114
|
+
`bridge-spec-kitty-tasks` skill, which enforces a 1:1
|
|
115
|
+
slice-to-WP mapping when `wps.yaml` is written during
|
|
116
|
+
`/spec-kitty.tasks-outline`.
|
|
117
|
+
- Does not introduce a new Spec Kitty mission type. Spec Kitty 3.2.5's
|
|
118
|
+
built-in mission types are loaded from a flat directory inside the
|
|
119
|
+
installed package (`doctrine/missions/mission_types/*.yaml`) via
|
|
120
|
+
`MissionTypeRepository.default()`, and org-pack extension for the
|
|
121
|
+
`mission_types` kind is unwired in this version (fragments only add
|
|
122
|
+
governance-graph metadata, not an executable type). A real native mission
|
|
123
|
+
type would require patching the installed `spec-kitty-cli` package itself.
|
|
124
|
+
This adapter avoids that entirely — it stays inside `mission_type:
|
|
125
|
+
software-dev` and only replaces the specify phase's *inputs*.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bridge-spec-kitty-tasks
|
|
3
|
+
description: "Adapter: enforce a 1:1 mapping between Eventmodelers board slices and Spec Kitty work packages when writing wps.yaml. Use when the user says \"one slice should become one task/WP\", \"generate tasks from the event model\", or right after bridge-spec-kitty-specify, in place of freeform subtask-grouping judgment in /spec-kitty.tasks-outline."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# bridge-spec-kitty-tasks
|
|
7
|
+
|
|
8
|
+
Companion to `bridge-spec-kitty-specify`. That skill turns the event model into
|
|
9
|
+
`spec.md`; this one enforces that each **slice** on the board becomes exactly
|
|
10
|
+
**one work package** — never split across WPs, never merged with another
|
|
11
|
+
slice — when Spec Kitty's task-planning pipeline builds `wps.yaml`.
|
|
12
|
+
|
|
13
|
+
This replaces only step 3–6 of `/spec-kitty.tasks-outline` (deriving
|
|
14
|
+
subtasks and rolling them into WPs). `/spec-kitty.tasks-packages` and
|
|
15
|
+
`/spec-kitty.tasks-finalize` run completely unmodified afterward — they only
|
|
16
|
+
ever consume `wps.yaml`, and don't care where its groupings came from.
|
|
17
|
+
|
|
18
|
+
## Why a hard 1:1 boundary
|
|
19
|
+
|
|
20
|
+
Spec Kitty's default `tasks-outline` groups subtasks into WPs by
|
|
21
|
+
judgment (target 3–7 subtasks, merge small ones, split large ones). That's
|
|
22
|
+
the right default when subtask boundaries come from an LLM reading prose. It
|
|
23
|
+
is the *wrong* default here: the event model already drew the real
|
|
24
|
+
boundaries (each SLICE_BORDER is a reviewable, independently buildable unit
|
|
25
|
+
the board's `Planned → InProgress → Done` slice lifecycle already governs).
|
|
26
|
+
Re-grouping at the WP layer would create a second, competing notion of "unit
|
|
27
|
+
of work" that drifts from the board over time. So this skill trades away
|
|
28
|
+
`tasks-outline`'s sizing flexibility on purpose: WP boundary = slice
|
|
29
|
+
boundary, full stop.
|
|
30
|
+
|
|
31
|
+
## Inputs
|
|
32
|
+
|
|
33
|
+
Same event-model export as `bridge-spec-kitty-specify`:
|
|
34
|
+
`.slices/<context>/index.json` (slice list, board/timeline order) and each
|
|
35
|
+
`.slices/<context>/<folder>/slice.json` (commands, events, readModels,
|
|
36
|
+
automations, `specifications`). Also read, from `feature_dir`:
|
|
37
|
+
`spec.md` (for FR/NFR ids — already generated by `bridge-spec-kitty-specify`) and
|
|
38
|
+
`plan.md` (for the tech-stack/file-layout context needed to fill
|
|
39
|
+
`owned_files`, since the event model doesn't know the codebase's directory
|
|
40
|
+
structure).
|
|
41
|
+
|
|
42
|
+
## Flow
|
|
43
|
+
|
|
44
|
+
1. **One WP per slice, in board order.** Assign `WP01`, `WP02`, ... following
|
|
45
|
+
`index.json`'s order (already the board's chronological/timeline order).
|
|
46
|
+
Do not reorder for "phase grouping" the way default `tasks-outline` does —
|
|
47
|
+
the board order already encodes the intended build sequence.
|
|
48
|
+
2. **Subtasks come from inside the slice, not across slices.** For each
|
|
49
|
+
slice, break its own commands/read-models/screens/automations into T-ids
|
|
50
|
+
(one per command handler, one per read-model endpoint, one per
|
|
51
|
+
screen/automation, one for its tests) — all nested under that slice's one
|
|
52
|
+
WP. Every subtask must trace to exactly one slice; never let a subtask
|
|
53
|
+
span two slices.
|
|
54
|
+
3. **Dependencies from event flow, not guesswork.** WP *B* depends on WP *A*
|
|
55
|
+
iff slice B's commands/queries/automations consume an event that slice
|
|
56
|
+
A's commands/automations produce. Derive this by diffing each slice's
|
|
57
|
+
emitted event names against every other slice's consumed event names —
|
|
58
|
+
this is mechanical, not judgment-based, so don't also apply
|
|
59
|
+
`tasks-outline`'s "Phase 2 typically depends on Phase 1" heuristic on top
|
|
60
|
+
of it.
|
|
61
|
+
4. **`requirement_refs` from the spec's own FR/NFR table.** Match each
|
|
62
|
+
slice's command/read-model/automation names against `spec.md`'s FR/NFR
|
|
63
|
+
rows (written by `bridge-spec-kitty-specify` from these same slices) and cite
|
|
64
|
+
whichever ids trace to this slice. A slice with no matching row means
|
|
65
|
+
`bridge-spec-kitty-specify` needs a re-run, not a fabricated ref here.
|
|
66
|
+
5. **Write `wps.yaml`** using the exact schema `/spec-kitty.tasks-outline`
|
|
67
|
+
expects (`id`, `title`, `dependencies`, `owned_files`, `requirement_refs`,
|
|
68
|
+
`plan_concern_refs` or `cross_cutting: true`, `subtasks`, `prompt_file:
|
|
69
|
+
null`) — so `tasks-packages`/`finalize-tasks` need no awareness that this
|
|
70
|
+
skill (rather than default `tasks-outline`) produced it.
|
|
71
|
+
6. **Hand off.** Tell the user to continue with `/spec-kitty.tasks-packages`
|
|
72
|
+
then `/spec-kitty.tasks-finalize` (or `spec-kitty next`) exactly as normal.
|
|
73
|
+
|
|
74
|
+
## Guardrail: don't fix boundary problems here
|
|
75
|
+
|
|
76
|
+
If a slice's own scope would blow past Spec Kitty's WP sizing ceiling (>10
|
|
77
|
+
subtasks / >700 lines), **do not silently split it into two WPs** — that
|
|
78
|
+
breaks the 1:1 invariant this skill exists to enforce, and produces the
|
|
79
|
+
exact spec/code drift it's meant to prevent. Instead, flag it to the user:
|
|
80
|
+
the slice is too coarse on the board itself, so split the SLICE_BORDER
|
|
81
|
+
there and re-run both `bridge-spec-kitty-specify` and this skill. The event model
|
|
82
|
+
stays the single place slice boundaries get decided.
|
|
83
|
+
|
|
84
|
+
The same holds in the other direction: never merge two small slices into
|
|
85
|
+
one WP for tidiness, even if `tasks-outline`'s default guidance would.
|
|
86
|
+
|
|
87
|
+
## Limitations (v1)
|
|
88
|
+
|
|
89
|
+
- `owned_files`/file-layout ownership still comes from `plan.md`, which is
|
|
90
|
+
still produced by the normal, LLM-authored `/spec-kitty.plan` — the event
|
|
91
|
+
model has no notion of source-file structure, so this isn't something to
|
|
92
|
+
push onto it.
|
|
93
|
+
- Re-running this skill after the board changes regenerates `wps.yaml`
|
|
94
|
+
wholesale from the current slice set, same as `bridge-spec-kitty-specify`'s
|
|
95
|
+
re-sync behavior — no field-level diff against a prior run.
|
|
@@ -109,26 +109,42 @@ function hasCredentials(cfg) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// Distinguishes this agent process from any other agent pinging the same
|
|
112
|
-
// token/board — e.g. a build-kit and a
|
|
112
|
+
// token/board — e.g. a build-kit and a bridge-kit install in the same project
|
|
113
113
|
// share one root config.json, and without a per-agent id both would upsert the
|
|
114
|
-
// same alive row and race each other.
|
|
115
|
-
// (
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// same alive row and race each other. The platform already keys the alive-ping
|
|
115
|
+
// on the (agent_type, agent_id) pair, so one shared file works: agentIds is
|
|
116
|
+
// namespaced by agentType (BUILD/BRIDGE/MODELING/...) inside the project ROOT
|
|
117
|
+
// .eventmodelers/config.json — the same file credentials already live in —
|
|
118
|
+
// instead of each kit dir keeping its own separate config.json. Falls back to
|
|
119
|
+
// a pre-existing kit-local agentId (older installs, before this consolidation)
|
|
120
|
+
// so an upgrade doesn't mint a new identity the platform hasn't seen before.
|
|
121
|
+
function ensureAgentId(kitDir, agentType) {
|
|
122
|
+
const rootConfigPath = join(dirname(kitDir), '.eventmodelers', 'config.json');
|
|
123
|
+
let rootCfg = {};
|
|
124
|
+
if (existsSync(rootConfigPath)) {
|
|
122
125
|
try {
|
|
123
|
-
|
|
126
|
+
rootCfg = JSON.parse(readFileSync(rootConfigPath, 'utf-8'));
|
|
124
127
|
} catch {
|
|
125
|
-
console.warn(`[ralph] Skipping invalid config at ${
|
|
128
|
+
console.warn(`[ralph] Skipping invalid config at ${rootConfigPath}`);
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
rootCfg.agentIds = rootCfg.agentIds || {};
|
|
132
|
+
if (rootCfg.agentIds[agentType]) return rootCfg.agentIds[agentType];
|
|
133
|
+
|
|
134
|
+
const legacyKitConfigPath = join(kitDir, '.eventmodelers', 'config.json');
|
|
135
|
+
let legacyAgentId;
|
|
136
|
+
if (existsSync(legacyKitConfigPath)) {
|
|
137
|
+
try {
|
|
138
|
+
legacyAgentId = JSON.parse(readFileSync(legacyKitConfigPath, 'utf-8')).agentId;
|
|
139
|
+
} catch {
|
|
140
|
+
console.warn(`[ralph] Skipping invalid config at ${legacyKitConfigPath}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const agentId = legacyAgentId || randomUUID();
|
|
145
|
+
rootCfg.agentIds[agentType] = agentId;
|
|
146
|
+
mkdirSync(dirname(rootConfigPath), { recursive: true });
|
|
147
|
+
writeFileSync(rootConfigPath, JSON.stringify(rootCfg, null, 2));
|
|
132
148
|
return agentId;
|
|
133
149
|
}
|
|
134
150
|
|
|
@@ -226,7 +242,7 @@ async function writeTask(payload, kitDir) {
|
|
|
226
242
|
console.log(`[agent] Task written — slice="${payload.sliceTitle}" status="${payload.sliceStatus}"`);
|
|
227
243
|
}
|
|
228
244
|
|
|
229
|
-
async function startRealtimeAgent(cfg, kitDir) {
|
|
245
|
+
async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllStatuses = false } = {}) {
|
|
230
246
|
let realtimeToken = await retryOn401('getRealtimeToken', () => getRealtimeToken(cfg));
|
|
231
247
|
|
|
232
248
|
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
@@ -254,8 +270,11 @@ async function startRealtimeAgent(cfg, kitDir) {
|
|
|
254
270
|
await retryOn401('fetchAndPersistSlices', () => fetchAndPersistSlices(cfg, kitDir)).catch((err) =>
|
|
255
271
|
console.error('[agent] Slice persist error:', err),
|
|
256
272
|
);
|
|
257
|
-
// Planned slices are handled by onPlannedSlice directly — no task needed
|
|
258
|
-
|
|
273
|
+
// Planned slices are handled by onPlannedSlice directly — no task needed.
|
|
274
|
+
// queueAllStatuses opts out of that split entirely (e.g. bridge has no
|
|
275
|
+
// onPlannedSlice consumer, so a lingering Planned slice would otherwise
|
|
276
|
+
// never naturally clear its own trigger — see lib/ralph.js callers).
|
|
277
|
+
if (queueAllStatuses || (payload.sliceStatus || '').toLowerCase() !== 'planned') {
|
|
259
278
|
await writeTask(payload, kitDir).catch((err) => console.error('[agent] writeTask error:', err));
|
|
260
279
|
}
|
|
261
280
|
})
|
|
@@ -276,7 +295,7 @@ async function startRealtimeAgent(cfg, kitDir) {
|
|
|
276
295
|
const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
|
|
277
296
|
method: 'POST',
|
|
278
297
|
headers: { Authorization: `Bearer ${realtimeToken}`, 'Content-Type': 'application/json' },
|
|
279
|
-
body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type:
|
|
298
|
+
body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: agentType, agent_id: cfg.agentId }),
|
|
280
299
|
signal: AbortSignal.timeout(10_000),
|
|
281
300
|
});
|
|
282
301
|
if (!res.ok) console.error(`[agent] Ping failed: ${res.status} ${await res.text().catch(() => '')}`);
|
|
@@ -379,9 +398,9 @@ async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
|
|
|
379
398
|
|
|
380
399
|
export { loadLocalConfig, fetchPlatformConfig, retryOn401, startRealtimeAgent };
|
|
381
400
|
|
|
382
|
-
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice }) {
|
|
401
|
+
export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, agentType = 'BUILD', queueAllStatuses = false }) {
|
|
383
402
|
const local = loadLocalConfig(kitDir);
|
|
384
|
-
local.agentId =
|
|
403
|
+
local.agentId = ensureAgentId(kitDir, agentType);
|
|
385
404
|
|
|
386
405
|
console.log(`Ralph — kit: ${kitDir}`);
|
|
387
406
|
console.log(` project: ${projectDir}`);
|
|
@@ -396,7 +415,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice })
|
|
|
396
415
|
console.log(` org=${cfg.organizationId}, board=${cfg.boardId}, base=${cfg.baseUrl}\n`);
|
|
397
416
|
|
|
398
417
|
await Promise.all([
|
|
399
|
-
startRealtimeAgent(cfg, kitDir),
|
|
418
|
+
startRealtimeAgent(cfg, kitDir, { agentType, queueAllStatuses }),
|
|
400
419
|
ralphLoop(kitDir, cfg, onTask, onPlannedSlice),
|
|
401
420
|
]);
|
|
402
421
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Agent Learnings
|
|
2
|
+
|
|
3
|
+
Patterns and gotchas discovered during task processing. Update this file
|
|
4
|
+
whenever you encounter something reusable.
|
|
5
|
+
|
|
6
|
+
## tasks.json
|
|
7
|
+
|
|
8
|
+
- Tasks are objects with `id`, `createdAt`, and `payload` (a
|
|
9
|
+
`SliceChangedPayload`).
|
|
10
|
+
- Unlike build-kit, every status change is queued (not just non-`Planned`
|
|
11
|
+
ones) — see `queueAllStatuses` in `lib/ralph.js`.
|
|
12
|
+
- After completing a task, remove it from the array entirely — do not add a
|
|
13
|
+
status field.
|
|
14
|
+
- Write `[]` to `tasks.json` if the last task is completed.
|
|
15
|
+
|
|
16
|
+
## SliceChangedPayload fields
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
event always "slice:changed"
|
|
20
|
+
organizationId org UUID or null
|
|
21
|
+
boardId board UUID
|
|
22
|
+
sliceId SLICE_BORDER node UUID
|
|
23
|
+
sliceTitle human-readable slice name (may be null)
|
|
24
|
+
sliceStatus e.g. "Created", "Planned", "InProgress", "Done" (may be null)
|
|
25
|
+
timestamp unix ms when the change was emitted
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Bridge target
|
|
29
|
+
|
|
30
|
+
- The active target framework lives in `.bridge-kit/bridge.json`'s `target`
|
|
31
|
+
field, written at `init --bridge --target <name>` time. This file is
|
|
32
|
+
committed (unlike `.eventmodelers/config.json`, which is gitignored) since
|
|
33
|
+
it's project policy shared by every teammate and CI runner, not a
|
|
34
|
+
per-machine credential.
|
|
35
|
+
- Skill naming convention: `bridge-<target>-specify` (kept in sync every
|
|
36
|
+
task) and `bridge-<target>-tasks` (invoked separately, by that framework's
|
|
37
|
+
own task-planning phase — not from this loop).
|
|
38
|
+
|
|
39
|
+
## Executors
|
|
40
|
+
|
|
41
|
+
- Claude (`ralph-claude.js`, this prompt) is the default. `bridge --ollama`
|
|
42
|
+
swaps in a local model instead — same prompt, different executor.
|
|
43
|
+
- `bridge --hook "<command>"` (or a `hookCommand` persisted in
|
|
44
|
+
`bridge.json`) bypasses this prompt entirely: `ralph-hook.js` runs an
|
|
45
|
+
arbitrary shell command per batch of changes instead of any AI agent — e.g.
|
|
46
|
+
commit + push `.slices/` and let a CI pipeline do the actual translation.
|
|
47
|
+
If you're reading this file, a hook wasn't configured for this run.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Bridge Task Instructions
|
|
2
|
+
|
|
3
|
+
You are an autonomous agent reacting to slice status change events on an
|
|
4
|
+
Eventmodelers board. Unlike a build-kit agent, you do **not** write
|
|
5
|
+
application code — you translate the board's event model into artifacts for
|
|
6
|
+
another spec framework (e.g. Spec Kitty), keeping that framework's files in
|
|
7
|
+
sync with the board as it changes.
|
|
8
|
+
|
|
9
|
+
## Your Loop
|
|
10
|
+
|
|
11
|
+
1. Read `AGENT.md` to load accumulated learnings before doing anything else.
|
|
12
|
+
2. Read `.bridge-kit/tasks.json`.
|
|
13
|
+
3. If `tasks.json` is empty or missing, reply with:
|
|
14
|
+
<promise>IDLE</promise>
|
|
15
|
+
and stop.
|
|
16
|
+
4. Pick the **oldest task** (earliest `createdAt`).
|
|
17
|
+
5. Execute the task — see the Execution section below.
|
|
18
|
+
6. After execution, remove that task from the array and write
|
|
19
|
+
`.bridge-kit/tasks.json` back.
|
|
20
|
+
7. Append a progress entry to `progress.txt` (create if missing).
|
|
21
|
+
8. Update `AGENT.md` with any new reusable learnings discovered this
|
|
22
|
+
iteration.
|
|
23
|
+
9. Reply normally so the next iteration can pick up the next task.
|
|
24
|
+
|
|
25
|
+
## Execution
|
|
26
|
+
|
|
27
|
+
Each task has a single `payload` of type `SliceChangedPayload`:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
{
|
|
31
|
+
event: "slice:changed"
|
|
32
|
+
organizationId: string | null
|
|
33
|
+
boardId: string
|
|
34
|
+
sliceId: string ← SLICE_BORDER node UUID
|
|
35
|
+
sliceTitle: string | null
|
|
36
|
+
sliceStatus: string | null ← e.g. "Planned", "InProgress", "Done"
|
|
37
|
+
timestamp: number
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Unlike a build-kit agent, a bridge agent reacts to **every** status change
|
|
42
|
+
(including `Planned`) — a task is queued regardless of `sliceStatus`. There
|
|
43
|
+
is no separate "claim and build" step, so don't invoke `/update-slice-status`
|
|
44
|
+
here; translation doesn't take ownership of a slice the way building does.
|
|
45
|
+
|
|
46
|
+
### Step 1 — Load credentials
|
|
47
|
+
|
|
48
|
+
Run `/connect` to resolve `TOKEN`, `BOARD_ID`, `ORG_ID`, and `BASE_URL` from
|
|
49
|
+
`.eventmodelers/config.json`.
|
|
50
|
+
|
|
51
|
+
### Step 2 — Resolve the bridge target
|
|
52
|
+
|
|
53
|
+
Read `.bridge-kit/bridge.json`'s `target` field (e.g. `"spec-kitty"`). The
|
|
54
|
+
matching translation skill is named `bridge-<target>-specify`.
|
|
55
|
+
|
|
56
|
+
### Step 3 — Translate
|
|
57
|
+
|
|
58
|
+
Invoke the `bridge-<target>-specify` skill. It reads `.slices/` (written by
|
|
59
|
+
`npx @eventmodelers/cli listen`, which must be running separately — see that
|
|
60
|
+
skill's own guardrail if `.slices/` doesn't exist yet) and regenerates that
|
|
61
|
+
framework's spec artifacts **wholesale** from the current board state. Do
|
|
62
|
+
not hand-author or patch those artifacts yourself — if they're wrong, the
|
|
63
|
+
event model is wrong; fix it on the board and let the next slice change
|
|
64
|
+
re-trigger this skill.
|
|
65
|
+
|
|
66
|
+
Do **not** also invoke `bridge-<target>-tasks` from this loop. That
|
|
67
|
+
companion skill enforces the 1:1 slice-to-work-package mapping during that
|
|
68
|
+
framework's own task-planning phase (e.g. Spec Kitty's
|
|
69
|
+
`/spec-kitty.tasks-outline`) — it's triggered by that framework's own
|
|
70
|
+
tooling when the user reaches that phase, not by every board change.
|
|
71
|
+
|
|
72
|
+
## Updating tasks.json
|
|
73
|
+
|
|
74
|
+
After completing a task, remove it from the array and write the updated
|
|
75
|
+
array back to `.bridge-kit/tasks.json`. If the array is now empty, write
|
|
76
|
+
`[]`.
|
|
77
|
+
|
|
78
|
+
## Progress Report Format
|
|
79
|
+
|
|
80
|
+
APPEND to `progress.txt` (never replace):
|
|
81
|
+
```
|
|
82
|
+
## [ISO timestamp] — Task [task.id]
|
|
83
|
+
|
|
84
|
+
Slice: [sliceTitle] ([sliceId])
|
|
85
|
+
Status change: [sliceStatus]
|
|
86
|
+
|
|
87
|
+
Action taken:
|
|
88
|
+
- [what was translated / regenerated in response to the slice change]
|
|
89
|
+
|
|
90
|
+
Learnings:
|
|
91
|
+
- [any patterns, gotchas, or reusable knowledge discovered]
|
|
92
|
+
---
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Stop Condition
|
|
96
|
+
|
|
97
|
+
If `.bridge-kit/tasks.json` is empty (`[]`) or does not exist, reply with:
|
|
98
|
+
<promise>IDLE</promise>
|
|
99
|
+
|
|
100
|
+
## Updating AGENT.md
|
|
101
|
+
|
|
102
|
+
After completing a task, add any **reusable** learnings to `AGENT.md` —
|
|
103
|
+
patterns, gotchas, or skill behaviour that future iterations should know.
|
|
104
|
+
Only add things that are general and applicable beyond this single task. Do
|
|
105
|
+
not duplicate what is already there.
|
|
106
|
+
|
|
107
|
+
## Important
|
|
108
|
+
|
|
109
|
+
- Process **one task per iteration**.
|
|
110
|
+
- Read `AGENT.md` first — it contains patterns from previous iterations.
|
|
111
|
+
- Always start with `/connect` if credentials are not yet loaded.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge loop using Claude Code as the executor. Unlike build-kit's
|
|
3
|
+
// ralph-claude.js, there is no onPlannedSlice consumer — a bridge agent
|
|
4
|
+
// doesn't "build" a Planned slice, it just translates every slice change as
|
|
5
|
+
// it arrives via tasks.json (see lib/prompt.md and queueAllStatuses below).
|
|
6
|
+
// Usage: node ralph-claude.js [project_dir]
|
|
7
|
+
|
|
8
|
+
import { startRalph, loadLocalConfig } from './lib/ralph.js';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { dirname, resolve } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
|
|
13
|
+
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
15
|
+
|
|
16
|
+
const cfg = loadLocalConfig(kitDir);
|
|
17
|
+
const inlineHeader = cfg.boardId
|
|
18
|
+
? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
19
|
+
: '';
|
|
20
|
+
|
|
21
|
+
const claudeArgs = ['--dangerously-skip-permissions'];
|
|
22
|
+
if (cfg.model) claudeArgs.push('--model', cfg.model);
|
|
23
|
+
const claudeEnv = cfg.anthropicBaseUrl
|
|
24
|
+
? { ...process.env, ANTHROPIC_BASE_URL: cfg.anthropicBaseUrl }
|
|
25
|
+
: process.env;
|
|
26
|
+
|
|
27
|
+
function runClaude(prompt) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const proc = spawn('claude', [...claudeArgs, '-p', inlineHeader + prompt], {
|
|
30
|
+
cwd: projectDir,
|
|
31
|
+
stdio: 'inherit',
|
|
32
|
+
env: claudeEnv,
|
|
33
|
+
});
|
|
34
|
+
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Claude exited ${code}`))));
|
|
35
|
+
proc.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startRalph({
|
|
40
|
+
kitDir,
|
|
41
|
+
projectDir,
|
|
42
|
+
onTask: runClaude,
|
|
43
|
+
agentType: 'BRIDGE',
|
|
44
|
+
queueAllStatuses: true,
|
|
45
|
+
}).catch((err) => {
|
|
46
|
+
console.error('[ralph] Fatal:', err);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge loop that hands each batch of slice changes to an arbitrary external
|
|
3
|
+
// command instead of an AI agent — e.g. commit + push .slices/ and let a CI
|
|
4
|
+
// pipeline take it from there. No Claude/Ollama call happens in this mode.
|
|
5
|
+
//
|
|
6
|
+
// Configure the hook with `bridge --hook "<command>"` (one-off) or persist a
|
|
7
|
+
// default with `init --bridge --target <name> --hook "<command>"`.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node ralph-hook.js [project_dir]
|
|
10
|
+
// BRIDGE_HOOK_CMD="git add .slices && git commit -m sync && git push" node ralph-hook.js
|
|
11
|
+
|
|
12
|
+
import { startRalph } from './lib/ralph.js';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
15
|
+
import { dirname, join, resolve } from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
20
|
+
|
|
21
|
+
// bridge.json (not .eventmodelers/config.json) — a plain, committed sibling
|
|
22
|
+
// file, since target/hookCommand are project policy meant to be shared with
|
|
23
|
+
// every teammate and CI runner, not gitignored per-machine state.
|
|
24
|
+
function loadBridgeConfig() {
|
|
25
|
+
const p = join(kitDir, 'bridge.json');
|
|
26
|
+
if (!existsSync(p)) return {};
|
|
27
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hookCmd = process.env.BRIDGE_HOOK_CMD || loadBridgeConfig().hookCommand;
|
|
31
|
+
if (!hookCmd) {
|
|
32
|
+
console.error('[bridge-hook] No hook command configured.');
|
|
33
|
+
console.error(' Set one for this run: eventmodelers bridge --hook "<command>"');
|
|
34
|
+
console.error(' Or persist a default: eventmodelers init --bridge --target <name> --hook "<command>"');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tasksPath = join(kitDir, 'tasks.json');
|
|
39
|
+
|
|
40
|
+
function readTasks() {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Batched, not one-task-at-a-time: a hook like "commit + push .slices/" acts
|
|
49
|
+
// on the whole current board export in one shot (already fresh — lib/ralph.js
|
|
50
|
+
// re-syncs .slices/ before every task is queued), not on a single slice's
|
|
51
|
+
// translation the way an AI agent does. Only the tasks present at invocation
|
|
52
|
+
// time are cleared afterward — anything the realtime agent queues *while* the
|
|
53
|
+
// hook is still running is left in place for the next tick, so a slice change
|
|
54
|
+
// arriving mid-run is never silently dropped.
|
|
55
|
+
function runHook() {
|
|
56
|
+
const batch = readTasks();
|
|
57
|
+
if (!batch.length) return Promise.resolve();
|
|
58
|
+
|
|
59
|
+
const batchFilePath = join(kitDir, 'last-hook-batch.json');
|
|
60
|
+
mkdirSync(kitDir, { recursive: true });
|
|
61
|
+
writeFileSync(batchFilePath, JSON.stringify(batch, null, 2));
|
|
62
|
+
|
|
63
|
+
const latest = batch[batch.length - 1]?.payload ?? {};
|
|
64
|
+
|
|
65
|
+
return new Promise((resolvePromise, reject) => {
|
|
66
|
+
console.log(`[bridge-hook] Running hook for ${batch.length} change(s): ${hookCmd}`);
|
|
67
|
+
const proc = spawn(hookCmd, {
|
|
68
|
+
cwd: projectDir,
|
|
69
|
+
shell: true,
|
|
70
|
+
stdio: 'inherit',
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
BRIDGE_BATCH_FILE: batchFilePath,
|
|
74
|
+
BRIDGE_TASK_COUNT: String(batch.length),
|
|
75
|
+
BRIDGE_SLICE_ID: latest.sliceId ?? '',
|
|
76
|
+
BRIDGE_SLICE_TITLE: latest.sliceTitle ?? '',
|
|
77
|
+
BRIDGE_SLICE_STATUS: latest.sliceStatus ?? '',
|
|
78
|
+
BRIDGE_BOARD_ID: latest.boardId ?? '',
|
|
79
|
+
BRIDGE_ORGANIZATION_ID: latest.organizationId ?? '',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
proc.on('close', (code) => {
|
|
83
|
+
if (code !== 0) return reject(new Error(`hook exited ${code}`));
|
|
84
|
+
const handledIds = new Set(batch.map((t) => t.id));
|
|
85
|
+
const remaining = readTasks().filter((t) => !handledIds.has(t.id));
|
|
86
|
+
writeFileSync(tasksPath, JSON.stringify(remaining, null, 2), 'utf-8');
|
|
87
|
+
resolvePromise();
|
|
88
|
+
});
|
|
89
|
+
proc.on('error', reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
startRalph({
|
|
94
|
+
kitDir,
|
|
95
|
+
projectDir,
|
|
96
|
+
onTask: runHook,
|
|
97
|
+
// onPlannedSlice omitted — see stacks/bridge/templates/bridge/ralph-claude.js
|
|
98
|
+
agentType: 'BRIDGE',
|
|
99
|
+
queueAllStatuses: true,
|
|
100
|
+
}).catch((err) => {
|
|
101
|
+
console.error('[ralph] Fatal:', err);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge loop using a local Ollama model as the executor. Same caveats as
|
|
3
|
+
// build-kit's ralph-ollama.js — lib/ollama-agent.js is shared as-is (see
|
|
4
|
+
// useShared in cli.js), unmodified for bridge.
|
|
5
|
+
// Run `ollama serve` first.
|
|
6
|
+
// Usage: node ralph-ollama.js [project_dir]
|
|
7
|
+
// OLLAMA_MODEL=qwen3.5:9b node ralph-ollama.js
|
|
8
|
+
// OLLAMA_URL=http://host:11434 node ralph-ollama.js
|
|
9
|
+
|
|
10
|
+
import { startRalph } from './lib/ralph.js';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { dirname, join, resolve } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
17
|
+
const model = process.env.OLLAMA_MODEL || 'qwen3.5:9b';
|
|
18
|
+
|
|
19
|
+
console.log(`[ralph-ollama] model=${model}`);
|
|
20
|
+
|
|
21
|
+
function runOllama() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const proc = spawn('node', [join(kitDir, 'lib', 'ollama-agent.js'), model], {
|
|
24
|
+
cwd: projectDir,
|
|
25
|
+
stdio: 'inherit',
|
|
26
|
+
env: process.env,
|
|
27
|
+
});
|
|
28
|
+
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`ollama-agent exited ${code}`))));
|
|
29
|
+
proc.on('error', reject);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
startRalph({
|
|
34
|
+
kitDir,
|
|
35
|
+
projectDir,
|
|
36
|
+
onTask: runOllama,
|
|
37
|
+
// onPlannedSlice omitted — ollama-agent manages its own task queue
|
|
38
|
+
agentType: 'BRIDGE',
|
|
39
|
+
queueAllStatuses: true,
|
|
40
|
+
}).catch((err) => {
|
|
41
|
+
console.error('[ralph] Fatal:', err);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|