@eventmodelers/cli 1.0.44 → 1.0.46
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 +4 -1
- package/cli.js +53 -1
- package/package.json +2 -2
- package/shared/build-kit/lib/checks/README.md +59 -0
- package/shared/build-kit/lib/ralph.js +110 -28
- package/shared/build-kit/lib/util/find-slice.cjs +59 -0
- package/shared/build-kit/ralph-claude.js +6 -2
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
- package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
- package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
- package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +122 -0
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/kurrent/templates/root/README.md +46 -0
- package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
- package/stacks/kurrent/templates/root/mvnw +259 -0
- package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
- package/stacks/kurrent/templates/root/pom.xml +152 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
- package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
- package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
- package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +4 -2
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-slicing-event-models/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +22 -0
- package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
- package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
- package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
- package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/node/templates/root/.githooks/pre-commit +11 -0
- package/stacks/node/templates/root/README.md +67 -0
- package/stacks/node/templates/root/package.json +2 -1
- package/stacks/node/templates/root/setup-env.sh +7 -1
- package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
- package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
- package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +101 -0
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/opencqrs/templates/root/README.md +42 -0
- package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
- package/stacks/opencqrs/templates/root/mvnw +259 -0
- package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
- package/stacks/opencqrs/templates/root/pom.xml +139 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
- package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
- package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
- package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
- package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +25 -0
- package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
- package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
- package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
- package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
- package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
- package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
- package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
- package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
- package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
- package/stacks/supabase/templates/root/package.json +2 -1
- package/stacks/supabase/templates/root/setup-env.sh +7 -1
- package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
- package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
- package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
- package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
- package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
- package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
- package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
- package/stacks/umadb/templates/root/README.md +48 -0
- package/stacks/umadb/templates/root/docker-compose.yml +29 -0
- package/stacks/umadb/templates/root/mvnw +259 -0
- package/stacks/umadb/templates/root/mvnw.cmd +149 -0
- package/stacks/umadb/templates/root/pom.xml +151 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
- package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
- package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
- package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
- package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
package/README.md
CHANGED
|
@@ -19,6 +19,9 @@ npx @eventmodelers/cli init --stack node # Node.js / TypeScript
|
|
|
19
19
|
npx @eventmodelers/cli init --stack supabase # Supabase
|
|
20
20
|
npx @eventmodelers/cli init --stack axon # Axon Framework (Java/Kotlin)
|
|
21
21
|
npx @eventmodelers/cli init --stack cratis-csharp # Cratis (.NET/C#)
|
|
22
|
+
npx @eventmodelers/cli init --stack opencqrs # OpenCQRS (Java, EventSourcingDB)
|
|
23
|
+
npx @eventmodelers/cli init --stack umadb # UmaDB (Java)
|
|
24
|
+
npx @eventmodelers/cli init --stack kurrent # Kurrent (Java, KurrentDB)
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
The installer prompts for your API token, Organization ID, and Board ID from [app.eventmodelers.ai/account](https://app.eventmodelers.ai/account), scaffolds the stack into your project, and writes `.eventmodelers/config.json` with your credentials.
|
|
@@ -73,7 +76,7 @@ your-project/
|
|
|
73
76
|
└── CLAUDE.md ← agent instructions
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
The
|
|
79
|
+
The seven backend stacks (`node`, `supabase`, `axon`, `cratis-csharp`, `opencqrs`, `umadb`, `kurrent`) also scaffold a real project skeleton into your project root (`templates/root/`) — source layout, build files, migrations, etc.
|
|
77
80
|
|
|
78
81
|
## Skills
|
|
79
82
|
|
package/cli.js
CHANGED
|
@@ -70,6 +70,27 @@ const STACKS = {
|
|
|
70
70
|
useShared: true,
|
|
71
71
|
needsBoardId: true,
|
|
72
72
|
},
|
|
73
|
+
opencqrs: {
|
|
74
|
+
label: 'OpenCQRS (Java, EventSourcingDB)',
|
|
75
|
+
kitSubdir: 'build-kit',
|
|
76
|
+
kitDirName: '.build-kit',
|
|
77
|
+
useShared: true,
|
|
78
|
+
needsBoardId: true,
|
|
79
|
+
},
|
|
80
|
+
umadb: {
|
|
81
|
+
label: 'UmaDB (Java)',
|
|
82
|
+
kitSubdir: 'build-kit',
|
|
83
|
+
kitDirName: '.build-kit',
|
|
84
|
+
useShared: true,
|
|
85
|
+
needsBoardId: true,
|
|
86
|
+
},
|
|
87
|
+
kurrent: {
|
|
88
|
+
label: 'Kurrent (Java, KurrentDB)',
|
|
89
|
+
kitSubdir: 'build-kit',
|
|
90
|
+
kitDirName: '.build-kit',
|
|
91
|
+
useShared: true,
|
|
92
|
+
needsBoardId: true,
|
|
93
|
+
},
|
|
73
94
|
};
|
|
74
95
|
|
|
75
96
|
// Not a stack — no backend scaffold, just skills + the agent loop. Installed via
|
|
@@ -774,7 +795,10 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
774
795
|
// first-installed kit added (e.g. node_modules/.idea from a build-kit install).
|
|
775
796
|
const gitignoreDest = join(targetDir, '.gitignore');
|
|
776
797
|
const priorGitignore = existsSync(gitignoreDest) ? readFileSync(gitignoreDest, 'utf-8') : null;
|
|
777
|
-
|
|
798
|
+
// .githooks/ (the slice commit-scope guard) is opt-in via `init --hooks` — skipped
|
|
799
|
+
// here and handled explicitly below so a plain `init` never silently changes the
|
|
800
|
+
// project's git hook wiring.
|
|
801
|
+
copyDirContents(rootSrc, targetDir, { skip: ['CLAUDE.md', '.githooks'] });
|
|
778
802
|
if (priorGitignore !== null && existsSync(gitignoreDest)) {
|
|
779
803
|
const incoming = readFileSync(gitignoreDest, 'utf-8');
|
|
780
804
|
const merged = mergeGitignoreLines(priorGitignore, incoming);
|
|
@@ -888,6 +912,31 @@ async function installStack(stackKey, stackCfg, options = {}) {
|
|
|
888
912
|
}
|
|
889
913
|
}
|
|
890
914
|
|
|
915
|
+
// --- 3b. Opt-in slice commit-scope guard (`init --hooks`) ---
|
|
916
|
+
// Skipped from the generic root copy above so a plain `init` never touches git's
|
|
917
|
+
// hook wiring; installed explicitly here only when requested.
|
|
918
|
+
if (options.hooks) {
|
|
919
|
+
const hooksSrc = join(rootSrc, '.githooks');
|
|
920
|
+
if (existsSync(hooksSrc)) {
|
|
921
|
+
copyDirContents(hooksSrc, join(targetDir, '.githooks'));
|
|
922
|
+
const preCommitHook = join(targetDir, '.githooks', 'pre-commit');
|
|
923
|
+
if (existsSync(preCommitHook)) {
|
|
924
|
+
// cpSync doesn't reliably carry over the executable bit across platforms,
|
|
925
|
+
// and git silently skips a non-executable hook.
|
|
926
|
+
try { execSync(`chmod +x "${preCommitHook}"`); } catch {}
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
execSync('git rev-parse --git-dir', { cwd: targetDir, stdio: 'ignore' });
|
|
930
|
+
execSync('git config core.hooksPath .githooks', { cwd: targetDir });
|
|
931
|
+
console.log(' ✓ Installed .githooks/ and set core.hooksPath — commits touching src/slices/ are now scope-guarded');
|
|
932
|
+
} catch {
|
|
933
|
+
console.log(' ✓ Installed .githooks/ — run `git config core.hooksPath .githooks` once this directory is a git repo to activate it');
|
|
934
|
+
}
|
|
935
|
+
} else {
|
|
936
|
+
console.log(' ℹ️ --hooks was given but this stack ships no .githooks/ template — nothing to install');
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
891
940
|
// --- 4. Install kit dependencies ---
|
|
892
941
|
if (existsSync(join(kitDir, 'package.json'))) {
|
|
893
942
|
console.log('📦 Installing kit dependencies...');
|
|
@@ -1539,6 +1588,7 @@ credentialFlags(program
|
|
|
1539
1588
|
.option('--target <name>', `Bridge target framework (${Object.keys(BRIDGE_TARGETS).join(', ')}) — only meaningful with --bridge`)
|
|
1540
1589
|
.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`.')
|
|
1541
1590
|
.option('--build-kit', 'Install a blank build-kit scaffold (.build-kit/ + .claude/skills/build-*/SKILL.md placeholders, all TODO-marked) for a stack not built into this CLI yet — no fixed backend. Mutually exclusive with --stack/--modeling/--bridge.')
|
|
1591
|
+
.option('--hooks', 'Install the slice commit-scope guard (.githooks/pre-commit, running .build-kit/lib/check-commit-scope.cjs) and wire it up via `git config core.hooksPath .githooks` — only meaningful with --stack (build-kit stacks). Off by default.')
|
|
1542
1592
|
.option('--global', 'Install skills into ~/.claude/skills/ instead of the project — available in every project')
|
|
1543
1593
|
.option('-f, --force', 'Re-prompt for credentials even if a config already has everything required — overwrites the existing config.json'))
|
|
1544
1594
|
.action(async (opts, command) => {
|
|
@@ -1628,6 +1678,7 @@ credentialFlags(program
|
|
|
1628
1678
|
force: opts.force,
|
|
1629
1679
|
credentialOverrides: credentialOverridesFromOpts(opts),
|
|
1630
1680
|
templatesSource: join(clonedDir, 'templates'),
|
|
1681
|
+
hooks: opts.hooks,
|
|
1631
1682
|
});
|
|
1632
1683
|
return;
|
|
1633
1684
|
}
|
|
@@ -1639,6 +1690,7 @@ credentialFlags(program
|
|
|
1639
1690
|
global: opts.global,
|
|
1640
1691
|
force: opts.force,
|
|
1641
1692
|
credentialOverrides: credentialOverridesFromOpts(opts),
|
|
1693
|
+
hooks: opts.hooks,
|
|
1642
1694
|
});
|
|
1643
1695
|
});
|
|
1644
1696
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, or modeling-only)",
|
|
3
|
+
"version": "1.0.46",
|
|
4
|
+
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"eventmodelers": "cli.js"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Commit-scope guard checks
|
|
2
|
+
|
|
3
|
+
Every `*.cjs` file in this folder is loaded and run automatically by
|
|
4
|
+
`../check-commit-scope.cjs` against the staged changeset of a slice commit
|
|
5
|
+
(a commit that touches `src/slices/{context}/{slice}/**`). Files run in
|
|
6
|
+
filename sort order — that's why they're numbered.
|
|
7
|
+
|
|
8
|
+
## Adding a check
|
|
9
|
+
|
|
10
|
+
Create a new file here, e.g. `60-my-check.cjs`, exporting:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
name: 'my-check', // short id, prefixed onto any violation it reports
|
|
17
|
+
skipIfAlreadyFailing: false, // optional: true = skip this check once an earlier
|
|
18
|
+
// one already found a violation (use for slow checks,
|
|
19
|
+
// e.g. a build/typecheck — no point running it if the
|
|
20
|
+
// commit is already going to be rejected)
|
|
21
|
+
run(ctx) {
|
|
22
|
+
// Inspect ctx and return an array of violations. No violations → return
|
|
23
|
+
// [] (or undefined/null).
|
|
24
|
+
return [
|
|
25
|
+
{ path: 'src/slices/cart/AddItem/AddItemCommand.ts', reason: 'why this is a problem' },
|
|
26
|
+
];
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `ctx` passed to every check
|
|
32
|
+
|
|
33
|
+
| field | type | meaning |
|
|
34
|
+
|-----------------|-----------------|-----------------------------------------------------------------------|
|
|
35
|
+
| `changes` | `{status, path}[]` | staged files — `status` is git's single-letter code (`A`/`M`/`D`/...) |
|
|
36
|
+
| `touchesSlice` | `boolean` | always `true` — the runner only loads checks once this is true |
|
|
37
|
+
| `repoRoot` | `string` | absolute path to the repo root (`git rev-parse --show-toplevel`) |
|
|
38
|
+
| `SLICE_PATTERN` | `RegExp` | matches a path inside a slice's own folder: `src/slices/{ctx}/{slice}/`|
|
|
39
|
+
|
|
40
|
+
### Return value
|
|
41
|
+
|
|
42
|
+
An array of `{ path, reason }` objects — one per violation. `path` should be
|
|
43
|
+
the offending file (or a synthetic label like `'(slice.json)'` if the problem
|
|
44
|
+
isn't tied to one staged file). `reason` is a short, human-readable sentence
|
|
45
|
+
explaining what's wrong; the runner prefixes it with `[<check name>]`.
|
|
46
|
+
|
|
47
|
+
Return `[]`, `undefined`, or `null` when there's nothing to report. Throwing
|
|
48
|
+
is treated as a violation too (`[<check name>] threw: <message>`), so a check
|
|
49
|
+
doesn't need its own top-level try/catch.
|
|
50
|
+
|
|
51
|
+
### Conventions used by the existing checks
|
|
52
|
+
|
|
53
|
+
- Prefer **not blocking** over a false positive when a check can't determine
|
|
54
|
+
the answer confidently (e.g. slice.json isn't found, or a file's shape isn't
|
|
55
|
+
recognized) — these are heuristics layered on top of the hard path-scope
|
|
56
|
+
rules, not a full compiler/schema validator.
|
|
57
|
+
- If two checks would flag the *same file*, only the first one to run reports
|
|
58
|
+
it (the runner dedupes by path) — so keep genuinely distinct concerns in
|
|
59
|
+
separate files rather than trying to avoid overlap yourself.
|
|
@@ -201,14 +201,28 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
201
201
|
writeFileSync(ctxPath, JSON.stringify({ name: activeCtx }, null, 2), 'utf-8');
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
// Write per-context index.json and per-slice slice.json
|
|
204
|
+
// Write per-context index.json and per-slice slice.json.
|
|
205
|
+
//
|
|
206
|
+
// This endpoint (`/slicedata/slices`) is the CHEAP summary one — `{ id, title, status }`
|
|
207
|
+
// only, no commands/events/specifications/codeGen prompts. Its whole job here is to keep
|
|
208
|
+
// `status` fresh so hasPendingTasks/getFirstPlannedSliceTitle see live transitions; it must
|
|
209
|
+
// NEVER clobber the richer slice.json a full fetch (`/load-slice`, `eventmodelers fetch`,
|
|
210
|
+
// `eventmodelers listen`) already wrote for the same slice. So every write below merges
|
|
211
|
+
// onto whatever's already on disk — spreading the existing object first, the fresh summary
|
|
212
|
+
// fields second — instead of replacing it wholesale.
|
|
205
213
|
for (const [contextSlug, { slices: ctxSlices }] of Object.entries(contexts)) {
|
|
206
214
|
const contextDir = join(slicesDir, contextSlug);
|
|
207
215
|
mkdirSync(contextDir, { recursive: true });
|
|
208
216
|
|
|
217
|
+
const indexPath = join(contextDir, 'index.json');
|
|
218
|
+
const existingIndex = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, 'utf-8')) : { slices: [] };
|
|
219
|
+
const existingById = new Map((existingIndex.slices ?? []).map((e) => [e.id, e]));
|
|
220
|
+
|
|
209
221
|
const indexSlices = ctxSlices.map((s, i) => {
|
|
210
222
|
const folder = (s.title ?? s.id).replaceAll(' ', '').toLowerCase();
|
|
223
|
+
const existing = existingById.get(s.id);
|
|
211
224
|
return {
|
|
225
|
+
...existing,
|
|
212
226
|
id: s.id,
|
|
213
227
|
slice: s.title,
|
|
214
228
|
index: i,
|
|
@@ -216,16 +230,18 @@ async function fetchAndPersistSlices(cfg, kitDir) {
|
|
|
216
230
|
contextSlug,
|
|
217
231
|
folder,
|
|
218
232
|
status: s.status,
|
|
219
|
-
definition: { id: s.id, title: s.title, status: s.status },
|
|
233
|
+
definition: { ...existing?.definition, id: s.id, title: s.title, status: s.status },
|
|
220
234
|
};
|
|
221
235
|
});
|
|
222
|
-
writeFileSync(
|
|
236
|
+
writeFileSync(indexPath, JSON.stringify({ slices: indexSlices }, null, 2), 'utf-8');
|
|
223
237
|
|
|
224
238
|
for (const slice of ctxSlices) {
|
|
225
239
|
const folder = (slice.title ?? slice.id).replaceAll(' ', '').toLowerCase();
|
|
226
240
|
const sliceDir = join(contextDir, folder);
|
|
227
241
|
mkdirSync(sliceDir, { recursive: true });
|
|
228
|
-
|
|
242
|
+
const sliceJsonPath = join(sliceDir, 'slice.json');
|
|
243
|
+
const existingSlice = existsSync(sliceJsonPath) ? JSON.parse(readFileSync(sliceJsonPath, 'utf-8')) : {};
|
|
244
|
+
writeFileSync(sliceJsonPath, JSON.stringify({ ...existingSlice, ...slice }, null, 2), 'utf-8');
|
|
229
245
|
}
|
|
230
246
|
}
|
|
231
247
|
|
|
@@ -265,30 +281,83 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
265
281
|
|
|
266
282
|
const channelName = `board:${cfg.boardId}-slicechanged`;
|
|
267
283
|
const realtime = await createRealtimeAdapter(cfg, realtimeToken);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
284
|
+
|
|
285
|
+
// Shared by the scheduled timer, a CHANNEL_ERROR/TIMED_OUT subscribe status, and a
|
|
286
|
+
// 401 from the alive-ping — whichever notices the token is bad first wins; the rest
|
|
287
|
+
// just await the same in-flight refresh instead of firing duplicate mint requests.
|
|
288
|
+
const ts = () => new Date().toISOString();
|
|
289
|
+
let refreshing = null;
|
|
290
|
+
const refreshToken = (reason) => {
|
|
291
|
+
if (!refreshing) {
|
|
292
|
+
const startedAt = Date.now();
|
|
293
|
+
console.log(`[agent] ${ts()} Refreshing realtime token (reason: ${reason})...`);
|
|
294
|
+
refreshing = (async () => {
|
|
295
|
+
try {
|
|
296
|
+
realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
|
|
297
|
+
await realtime.setAuth(realtimeToken);
|
|
298
|
+
console.log(`[agent] ${ts()} Token refreshed (reason: ${reason}, took ${Date.now() - startedAt}ms)`);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error(`[agent] ${ts()} Token refresh FAILED (reason: ${reason}):`, err);
|
|
301
|
+
throw err;
|
|
302
|
+
} finally {
|
|
303
|
+
refreshing = null;
|
|
275
304
|
}
|
|
305
|
+
})();
|
|
306
|
+
}
|
|
307
|
+
return refreshing;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// A subscribe that errors on a stale token needs a fresh token AND a new join
|
|
311
|
+
// attempt — setAuth alone doesn't re-join a channel that already errored out.
|
|
312
|
+
// Capped so a non-expiry auth failure (e.g. genuinely revoked access) can't turn
|
|
313
|
+
// into a tight resubscribe loop hammering the platform forever.
|
|
314
|
+
let channelErrorStreak = 0;
|
|
315
|
+
const subscribeChannel = () => {
|
|
316
|
+
realtime.subscribe(
|
|
317
|
+
channelName,
|
|
318
|
+
{
|
|
319
|
+
message: (payload) => {
|
|
320
|
+
if (payload === 'Exit') {
|
|
321
|
+
console.log(`[agent] ${ts()} Received "Exit" — shutting down`);
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
'slice:changed': (payload) => handleSliceChanged(payload, cfg, kitDir, queueAllStatuses),
|
|
276
326
|
},
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
327
|
+
async (status) => {
|
|
328
|
+
if (status !== 'CHANNEL_ERROR' && status !== 'TIMED_OUT') {
|
|
329
|
+
if (channelErrorStreak > 0) {
|
|
330
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status} — recovered after ${channelErrorStreak} failed attempt(s)`);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(`[agent] ${ts()} Channel "${channelName}": ${status}`);
|
|
333
|
+
}
|
|
334
|
+
channelErrorStreak = 0;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
channelErrorStreak += 1;
|
|
338
|
+
console.warn(`[agent] ${ts()} Channel "${channelName}": ${status} (attempt ${channelErrorStreak}/5)`);
|
|
339
|
+
if (channelErrorStreak > 5) {
|
|
340
|
+
console.error(`[agent] ${ts()} Channel "${channelName}" failed ${channelErrorStreak} times in a row — giving up until the next scheduled token refresh (every 10min)`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await refreshToken(`channel ${status}`);
|
|
345
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
346
|
+
console.log(`[agent] ${ts()} Resubscribing to channel "${channelName}" (attempt ${channelErrorStreak}/5)...`);
|
|
347
|
+
subscribeChannel();
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.error(`[agent] ${ts()} Token refresh after channel error failed, will not resubscribe this round:`, err);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
subscribeChannel();
|
|
281
355
|
|
|
282
|
-
setInterval(
|
|
283
|
-
|
|
284
|
-
realtimeToken = await retryOn401('getRealtimeToken (refresh)', () => getRealtimeToken(cfg));
|
|
285
|
-
await realtime.setAuth(realtimeToken);
|
|
286
|
-
console.log('[agent] Token refreshed');
|
|
287
|
-
} catch (err) {
|
|
288
|
-
console.error('[agent] Token refresh failed:', err);
|
|
289
|
-
}
|
|
356
|
+
setInterval(() => {
|
|
357
|
+
refreshToken('scheduled 10min refresh').catch((err) => console.error(`[agent] ${ts()} Scheduled token refresh failed:`, err));
|
|
290
358
|
}, 10 * 60 * 1000);
|
|
291
359
|
|
|
360
|
+
let lastPingFailed = false;
|
|
292
361
|
const ping = async () => {
|
|
293
362
|
try {
|
|
294
363
|
const res = await fetch(`${cfg.baseUrl}/api/agent-alive`, {
|
|
@@ -297,9 +366,19 @@ async function startRealtimeAgent(cfg, kitDir, { agentType = 'BUILD', queueAllSt
|
|
|
297
366
|
body: JSON.stringify({ token: cfg.token, board_id: cfg.boardId, agent_type: agentType, agent_id: cfg.agentId }),
|
|
298
367
|
signal: AbortSignal.timeout(10_000),
|
|
299
368
|
});
|
|
300
|
-
if (!res.ok)
|
|
369
|
+
if (!res.ok) {
|
|
370
|
+
console.error(`[agent] ${ts()} Ping failed: ${res.status} ${await res.text().catch(() => '')}`);
|
|
371
|
+
lastPingFailed = true;
|
|
372
|
+
if (res.status === 401) {
|
|
373
|
+
await refreshToken('ping-401').catch((err) => console.error(`[agent] ${ts()} Token refresh after 401 ping failed:`, err));
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (lastPingFailed) console.log(`[agent] ${ts()} Ping recovered`);
|
|
378
|
+
lastPingFailed = false;
|
|
301
379
|
} catch (err) {
|
|
302
|
-
console.error(
|
|
380
|
+
console.error(`[agent] ${ts()} Ping error:`, err);
|
|
381
|
+
lastPingFailed = true;
|
|
303
382
|
}
|
|
304
383
|
};
|
|
305
384
|
await ping();
|
|
@@ -354,10 +433,13 @@ async function runWithRetry(label, fn) {
|
|
|
354
433
|
}
|
|
355
434
|
}
|
|
356
435
|
|
|
357
|
-
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice) {
|
|
436
|
+
async function ralphLoop(kitDir, cfg, onTask, onPlannedSlice, localOnly = false) {
|
|
358
437
|
const promptFile = join(kitDir, 'lib', 'prompt.md');
|
|
359
438
|
const backendPromptFile = join(kitDir, 'lib', 'backend-prompt.md');
|
|
360
|
-
|
|
439
|
+
// --local must mean zero board contact even when .eventmodelers/config.json
|
|
440
|
+
// happens to hold valid credentials — never let a locally-present token flip
|
|
441
|
+
// this back on.
|
|
442
|
+
const credentialed = !localOnly && hasCredentials(cfg);
|
|
361
443
|
let lastIdleCtx;
|
|
362
444
|
|
|
363
445
|
while (true) {
|
|
@@ -410,7 +492,7 @@ export async function startRalph({ kitDir, projectDir, onTask, onPlannedSlice, a
|
|
|
410
492
|
// reaches out to the platform at all.
|
|
411
493
|
if (localOnly || !hasCredentials(local)) {
|
|
412
494
|
console.log(` mode: local-only (no platform sync)${localOnly ? ' — forced by --local' : ''}\n`);
|
|
413
|
-
await ralphLoop(kitDir, local, onTask, onPlannedSlice);
|
|
495
|
+
await ralphLoop(kitDir, local, onTask, onPlannedSlice, localOnly);
|
|
414
496
|
return;
|
|
415
497
|
}
|
|
416
498
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared by checks that need to cross-reference code against the slice's own
|
|
4
|
+
// definition. Not a check itself — no `run(ctx)` interface here.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
function normalize(s) {
|
|
10
|
+
return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Given the {context}/{SliceName} folder names used under src/slices/, find the
|
|
14
|
+
// matching slice.json under .build-kit/.slices/<contextSlug>/<sliceFolder>/. The
|
|
15
|
+
// two sides are slugified differently (see shared/skills/load-slice/SKILL.md —
|
|
16
|
+
// contextSlug is hyphenated, sliceFolder strips all spaces), so this matches on
|
|
17
|
+
// a normalized (lowercase, alphanumeric-only) form instead of exact equality.
|
|
18
|
+
// Returns null — never throws — when zero or more than one candidate matches;
|
|
19
|
+
// callers should treat that as "can't verify" and skip rather than block.
|
|
20
|
+
function findSliceJson(repoRoot, context, sliceName) {
|
|
21
|
+
const root = path.join(repoRoot, '.build-kit', '.slices');
|
|
22
|
+
if (!fs.existsSync(root)) return null;
|
|
23
|
+
|
|
24
|
+
const wantSlice = normalize(sliceName);
|
|
25
|
+
const candidates = [];
|
|
26
|
+
|
|
27
|
+
let contextDirs;
|
|
28
|
+
try {
|
|
29
|
+
contextDirs = fs.readdirSync(root, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const contextDir of contextDirs) {
|
|
35
|
+
if (!contextDir.isDirectory()) continue;
|
|
36
|
+
const contextPath = path.join(root, contextDir.name);
|
|
37
|
+
let sliceDirs;
|
|
38
|
+
try {
|
|
39
|
+
sliceDirs = fs.readdirSync(contextPath, { withFileTypes: true });
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const sliceDir of sliceDirs) {
|
|
44
|
+
if (!sliceDir.isDirectory()) continue;
|
|
45
|
+
if (normalize(sliceDir.name) !== wantSlice) continue;
|
|
46
|
+
const slicePath = path.join(contextPath, sliceDir.name, 'slice.json');
|
|
47
|
+
if (fs.existsSync(slicePath)) candidates.push(slicePath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (candidates.length !== 1) return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(fs.readFileSync(candidates[0], 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { findSliceJson, normalize };
|
|
@@ -11,7 +11,11 @@ const kitDir = dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
const projectDir = process.argv[2] ? resolve(process.argv[2]) : resolve(kitDir, '..');
|
|
12
12
|
|
|
13
13
|
const cfg = loadLocalConfig(kitDir);
|
|
14
|
-
const
|
|
14
|
+
const localOnly = process.env.RALPH_LOCAL === '1';
|
|
15
|
+
// --local must mean zero board contact — never hand Claude live board
|
|
16
|
+
// credentials via the inline header, even if config.json has them, or it'll
|
|
17
|
+
// treat them as already-connected and skip straight to board sync.
|
|
18
|
+
const inlineHeader = !localOnly && cfg.boardId
|
|
15
19
|
? `board=${cfg.boardId} token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl}\n\n`
|
|
16
20
|
: '';
|
|
17
21
|
|
|
@@ -96,7 +100,7 @@ startRalph({
|
|
|
96
100
|
projectDir,
|
|
97
101
|
onTask: runClaude,
|
|
98
102
|
onPlannedSlice: runClaude,
|
|
99
|
-
localOnly
|
|
103
|
+
localOnly,
|
|
100
104
|
}).catch((err) => {
|
|
101
105
|
console.error('[ralph] Fatal:', err);
|
|
102
106
|
process.exit(1);
|