Compare commits

...

290 Commits

Author SHA1 Message Date
xiaoju c8e42c838b fix(daemon): harden state persistence, ReadonlyArray triggers
1. writeState: atomic write via temp file + rename
2. readState: distinguish missing file vs corrupt JSON (warn on error)
3. executeCompute: write disk before updating memory state
4. SenseInfo.triggers: ReadonlyArray<string>
5. CLAUDE.md: added Sense State Persistence docs
6. .knowledge/: updated architecture, sense, worker-isolation,
   storage-layer, cli, coding-conventions for stateful sense

Fixes #313
2026-05-01 12:08:59 +00:00
xiaomo eb7de9954f Merge pull request 'refactor: Stateful Sense (RFC #308)' (#312) from refactor/308-stateful-sense into main 2026-05-01 10:20:46 +00:00
xiaoju fc7fc9158c docs: update all docs/conventions for stateful sense, remove stale refs
Phase 4 of RFC #308: Stateful Sense refactor.

- CLAUDE.md: updated diagram, tables, examples (no more Signal)
- Cleaned stale Signal Bus / DrizzleDB / _signals / retention refs
  across READMEs, .cursor rules, copilot instructions, .knowledge
- Removed drizzle-orm from core package.json (no longer used)
- Updated pnpm-lock.yaml

Refs #308
2026-05-01 10:09:01 +00:00
xiaoju be1f86044e refactor(cli): migrate senses and CLI to stateful pattern
Phase 3 of RFC #308: Stateful Sense refactor.

- Examples (cpu-usage, nerve-health) use initialState + compute(state)
- CLI create/init templates generate stateful sense (no SQLite/Drizzle)
- Removed: sense query, sense schema commands (no more per-sense SQLite)
- Removed: sense-sqlite.ts, schema templates, migration templates
- Updated all CLI tests for new sense structure

Refs #308, closes #311
2026-05-01 09:58:37 +00:00
xiaoju 5e054facb2 refactor(daemon): stateful sense engine — JSON state, remove Signal Bus
Phase 2 of RFC #308: Stateful Sense refactor.

- SenseRuntime uses JSON file persistence instead of SQLite/Drizzle
- Sense compute now receives state and returns { state, workflow }
- IPC: replaced SignalMessage with ComputeResultMessage
- Removed Signal Bus entirely (on[] now uses reverse-index in scheduler)
- sense-scheduler.onSenseCompleted() triggers dependent senses
- kernel no longer constructs Signal objects or calls routeSenseComputeOutput
- Removed drizzle-orm dependency from daemon package

Refs #308, closes #310
2026-05-01 09:50:46 +00:00
xiaoju e789c7bb34 refactor(core): stateful sense types — remove Signal, add initialState
Phase 1 of RFC #308: Stateful Sense refactor.

- SenseComputeFn<S> now takes state and returns { state, workflow }
- SenseModule<S> exports compute + initialState (no more table)
- Removed: Signal type, ComputeResult, RoutedSenseOutput,
  routeSenseComputeOutput, retention/DEFAULT_SENSE_SIGNAL_RETENTION
- Updated isSenseInfo (removed lastSignalTimestamp)

Refs #308, closes #309
2026-05-01 09:43:13 +00:00
xiaoju 06b91c2e63 test(workflow-utils): add error path tests for createLlmAdapter
Cover non-ok HTTP response (500) and network failure (ECONNREFUSED).
Per review feedback from 星月 🌙 on PR #278.
2026-04-30 15:04:46 +00:00
xiaoju b7200ce51c docs(workflow-meta): document verb-first workflow naming
Add Workflow Naming to CLAUDE.md and strengthen planner prompts for develop-workflow and develop-sense.

Fixes #285

Made-with: Cursor
2026-04-30 15:04:46 +00:00
xingyue 3eab2e29f5 Merge pull request 'chore: dead code cleanup — unused exports, stale docs, deprecated functions' (#304) from chore/dead-code-cleanup into main 2026-04-30 14:47:25 +00:00
xingyue 10c4cf4148 Merge pull request 'feat(cli): nerve agent inject cursor — Phase 4 of RFC #289' (#303) from feat/agent-inject-cursor into main 2026-04-30 14:41:03 +00:00
tuanzi 5db80c99a0 feat(cli): nerve agent inject cursor — generate .cursorrules
Phase 4 of #289:
- Add cursor target to nerve agent inject/remove/status
- Generate .cursorrules from latest SKILL.md content (aligned with PR #301):
  - Parameterless SenseComputeFn compute signature
  - createRole four-tuple for LLM integration
  - Flat roles/<role>.ts layout
  - Sense persistence pitfall
  - nerve agent CLI docs including cursor commands
- Cursor-specific tips (@file, @Folder, @Terminal references)
- Version tracking via .nerve-version + HTML comment
- --path flag for cursor (project-local, not global like hermes)

Closes #299
Ref: #289
2026-04-30 14:38:19 +00:00
xiaomo 49f3d91d1b chore: dead code cleanup — remove unused exports and fix stale docs
- Delete createEchoAgent (daemon, never referenced)
- Delete isDryRun (workflow-utils, deprecated, always false)
- Delete KNOWN_AGENT_ADAPTER_IDS (core, never referenced)
- Remove parseDurationStringToMs, labelSenseTrigger from core public API
- Remove spawnSafe re-export from workflow-utils
- Fix core/README.md stale API names
- Clean stale hermes-options.ts comment

Closes #302
2026-04-30 14:29:45 +00:00
xingyue 4d75c8683f Merge pull request 'docs(cli): sync Hermes SKILL.md with flat workspace and runtime types' (#301) from fix/298-update-hermes-skill into main 2026-04-30 14:20:36 +00:00
xingyue 99b0e58fb6 Merge pull request 'chore: RFC-006 Phase 4 cleanup — delete worker-fork-support.ts' (#300) from chore/rfc-006-cleanup into main 2026-04-30 14:19:44 +00:00
xiaomo a1b1d5eaf1 chore: RFC-006 Phase 4 cleanup — delete worker-fork-support.ts
- Move formatChildExitSummary/formatCapturedStderrTail to worker-runtime.ts
- Move ignoreSessionBroadcastSignals to new worker-signals.ts
- Delete worker-fork-support.ts (teeCapturedStderr no longer used)
- Update .knowledge/worker-isolation.md and architecture.md for WorkerRuntime
- All 167 tests pass, biome check clean

Closes #283
2026-04-30 14:17:16 +00:00
xiaoju 1849789c02 docs(cli): sync Hermes SKILL with workspace AGENT and runtime types
Update sense compute docs to SenseComputeFn (no DB/peers). Document AGENT.md, flat roles, moderator/build helpers, createRole + createLlmAdapter, verb-first naming, nerve agent commands, and root npm/pnpm build.

Fixes #298

Made-with: Cursor
2026-04-30 14:15:14 +00:00
xingyue 7ce46e7735 Merge pull request 'RFC-006 Phase 3: Migrate workflow-manager process logic to WorkerRuntime' (#295) from refactor/rfc-006-workflow-runtime into main 2026-04-30 14:14:05 +00:00
xiaomo 0455f928f5 fix: address review feedback (星月) for Phase 3
1. sendToWorker: IPC send failure now marks thread as failed + dequeues next
2. crashLimitBlocked Set: prevents new startWorkflow from bypassing crash limit
3. "respawning" log skipped when crash limit is active
4. logWorkflowEvent payload: unknown | null (project convention, not ?:)
2026-04-30 14:10:06 +00:00
xingyue 11cedfb5a5 Merge pull request 'refactor(cli): dynamic version for nerve agent — Phase 3 of RFC #289' (#297) from feat/agent-inject-phase3 into main 2026-04-30 14:08:03 +00:00
tuanzi ed1bc4e25f refactor(cli): read CLI version from package.json instead of hardcoding
Phase 3 of #289:
- Replace hardcoded CLI_VERSION constant with dynamic package.json read
- Cache version to avoid repeated fs reads
- Verified: npm pack includes skills/, version detection works correctly

Ref: #289, #296
2026-04-30 14:04:14 +00:00
xiaomo dc4454d23e refactor: migrate workflow-manager process logic to WorkerRuntime (RFC-006 Phase 3)
- workflow-manager.ts: 792 → 498 lines, no more fork/ChildProcess/crash counting
- Extract pure functions to workflow-manager-support.ts (256 lines)
- WorkerRuntime gains: onCrashLimitReached callback, WorkerDrainOpts for per-call timeout
- worker-pool.ts updated for new evict/drain signature (null opts)
- All 167 daemon tests pass

Closes #282
2026-04-30 13:56:43 +00:00
xingyue 7c256620c5 Merge pull request 'feat(cli): nerve agent inject/update/remove/status — Phase 2 of RFC #289' (#294) from feat/agent-inject-phase2 into main 2026-04-30 13:53:39 +00:00
tuanzi 14898e1827 feat(cli): add nerve agent inject/update/remove/status commands
Phase 2 of #289:
- nerve agent inject hermes [--profile <name>]
- nerve agent update (updates all injected skills)
- nerve agent remove hermes [--profile <name>]
- nerve agent status (version check across profiles)
- Include skills/ in npm package files

Ref: #289, #293
2026-04-30 13:46:55 +00:00
xingyue 082d2e72f2 Merge pull request 'refactor: align develop prompts and .knowledge with flat workspace' (#288) from refactor/287-align-prompts-knowledge into main 2026-04-30 13:46:33 +00:00
xingyue fbf63e0266 Merge pull request 'RFC-006 Phase 2: Migrate SenseWorkerPool to WorkerRuntime' (#292) from refactor/rfc-006-worker-runtime into main 2026-04-30 13:44:56 +00:00
xingyue 7d89e8ab61 Merge pull request 'feat(cli): add hermes nerve skill — Phase 1 of RFC #289' (#291) from feat/agent-inject-phase1 into main 2026-04-30 13:42:05 +00:00
xiaomo e67ddc58d8 fix: address review feedback (星月)
1. trySendSync: wrap child.send in try/catch — IPC race between connected check and send
2. gracefulStop: same try/catch for shutdown send
3. Remove crashTimestamps reset on ready — crash window detection was being bypassed
2026-04-30 13:41:31 +00:00
xiaoju 06b1e3d785 refactor(cli,workflow-meta): scaffold AGENT.md on init; align develop prompts
Generate AGENT.md at ~/.uncaged-nerve root during nerve init (layout, verb-first workflows, createRole four-tuple, root build, coding style). Role prompts instruct agents to use cat AGENT.md instead of node_modules nerve-skills paths.

E2E init test asserts AGENT.md. Retain .knowledge workflow/adapter updates and flat single-file roles guidance from the branch.

Fixes #287

Made-with: Cursor
2026-04-30 13:41:24 +00:00
tuanzi f828ebc28b fix: align testing issue commands + add moderator sync pitfall
Address review from 星月:
- Update testing issue #290 to match actual CLI commands
- Add pitfall: moderator must be sync (not async)
- knowledge commands confirmed real (exist in codebase)

Ref: #290
2026-04-30 13:38:56 +00:00
tuanzi 809a11afe3 feat(cli): add hermes nerve skill (Phase 1 of #289)
Add SKILL.md for Hermes Agent covering:
- Core concepts (Sense → Signal → Workflow → Log)
- Complete CLI reference
- Sense development guide with examples
- Workflow development guide with examples
- Daily operation patterns
- nerve.yaml config reference

Ref: #289, #290
2026-04-30 13:36:03 +00:00
xiaomo 4dffcb636b fix: resolve 2 failing tests after WorkerRuntime migration
- Add trySendSync() for synchronous send when worker is ready+connected
- sendCompute uses sync path first, async fallback for cold start
- Add forwardStderr, allowRespawn, hasDisconnectedChild, onReady(key,msg)
- Tests: add connected:true to mocks, flush async fork microtasks
- All 167 daemon tests pass
2026-04-30 13:34:10 +00:00
xiaomo c34ec46416 feat(daemon): WorkerRuntime — generic message-routed process manager (closes #280)
RFC-006 Phase 1: ManagedWorker state machine + WorkerRuntime<K> with
cold start, crash respawn, drain/evict, graceful shutdown.
8 test cases covering all lifecycle scenarios.
2026-04-30 13:09:19 +00:00
xingyue d2bb0275dc Merge pull request 'feat(workflow-utils): add createLlmAdapter AgentFn factory' (#278) from refactor/277-llm-adapter-four-tuple into main 2026-04-30 12:51:29 +00:00
xiaoju 005739f6bc chore(workflow-utils): remove deprecated role factory exports
Remove createCursorRole, createHermesRole, createLlmRole, createReActRole
from public API — all superseded by createRole(adapter, prompt, schema, extract).
Source files retained as internal implementation.

Also remove unused type exports: CliPromptFn, CursorRole*, HermesRole*,
LlmPromptFn, LlmRole*, ReActRole*, ReActTool. Keep LlmMessage and
MetaExtractConfig (used internally).

Refs #277
2026-04-30 12:44:30 +00:00
xiaoju fbe1cc8eba feat(workflow-utils): add createLlmAdapter AgentFn factory
Single-turn chat via chatCompletionText: system from createRole prompt, user from ctx.start.content.

Fixes #277

Made-with: Cursor
2026-04-30 12:38:00 +00:00
xiaomo ba286a2f27 Merge pull request 'refactor(cli): single-package workspace init and root dist build' (#276) from refactor/274-single-package-workspace into main 2026-04-30 11:24:19 +00:00
xiaoju c98e14e9e6 refactor(cli): single-package workspace init and root dist build (#274)
Init templates match ~/.uncaged-nerve: scripts/build.mjs writes dist/senses/*/index.js and dist/workflows/*/index.js; drop @uncaged/nerve-skills from generated package.json; refresh Cursor skills rule copy.

Sense worker sends full compute result on signal IPC so the kernel can route workflow triggers; update e2e harness paths (migrations under senses/, noop under dist/workflows).

Fixes #274

Made-with: Cursor
2026-04-30 10:17:44 +00:00
xiaomo 011345e114 Merge pull request 'refactor(core): consolidate file structure — 22 files → 6' (#275) from refactor/core-file-consolidation into main 2026-04-30 09:21:01 +00:00
xiaoju d9c86c49ae refactor(daemon): load sense/workflow bundles from dist/ directory
Workspace build output moved from senses/<name>/index.js and
workflows/<name>/dist/index.js to dist/senses/<name>/index.js
and dist/workflows/<name>/index.js.

Refs #274
小橘 <xiaoju@shazhou.work>
2026-04-30 09:16:25 +00:00
xiaomo 0d78df89b1 refactor(core): consolidate file structure — 22 files → 6 (closes #273) 2026-04-30 09:15:18 +00:00
xiaomo 0140cdd952 Merge pull request 'refactor: RFC-005 — Separate Agent and Role types' (#272) from refactor/rfc-005-phase-1 into main 2026-04-30 08:29:12 +00:00
xiaomo bfadfffd40 fix: move isDryRun to value export (not type-only) 2026-04-30 08:27:07 +00:00
xiaomo e6093c35db docs: update knowledge cards for RFC-005 (ThreadContext, AgentFn) 2026-04-30 08:09:05 +00:00
xiaomo de8c7c5150 fix: address review — revert unrelated sense-worker change, restore isDryRun as deprecated 2026-04-30 08:00:46 +00:00
xiaomo f799cee51f refactor(cli,docs): RFC-005 Phase 4 — update templates, tests, docs (closes #271) 2026-04-30 07:24:11 +00:00
xiaomo d13b59e787 refactor(daemon): RFC-005 Phase 3 — workflow-worker uses ThreadContext (closes #270) 2026-04-30 07:10:58 +00:00
xiaomo 975f15c66d refactor(workflow-utils): RFC-005 Phase 2 — adapt to ThreadContext, new AgentFn signature (closes #269) 2026-04-30 06:59:15 +00:00
xiaomo 3e51335d91 refactor(core): RFC-005 Phase 1 — ThreadContext, AgentFn, Role signature (closes #268) 2026-04-30 06:54:03 +00:00
xiaoju 9c832b0e21 docs(knowledge): update cards via knowledge-extraction workflow (5q/round)
7 cards updated, 4 new cards added. Topics: signal-routing,
worker-isolation, storage-layer, adapter-isolation, sense contracts,
workflow runtime enforcement, coding conventions details.

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:29 +00:00
xiaoju 2387b73141 fix(daemon): remove stale exports openPeerDb, loadComputeFn from index
These functions were renamed/removed from sense-runtime.ts but index.ts
still re-exported them, causing rslib build to fail (no JS output).

小橘 <xiaoju@shazhou.work>
2026-04-30 05:56:24 +00:00
xiaoju 8824421f26 docs: remove Reflex concept from architecture docs and CLAUDE.md
Reflex was folded into Sense config (interval/on) and ComputeResult
(workflow trigger). Two extension points now: Sense + Workflow.

— 小橘 🍊(NEKO Team)
2026-04-30 00:43:04 +00:00
xiaoju b27a6aced8 feat: sense compute returns ComputeResult<T> with workflow trigger support
- SenseComputeFn returns ComputeResult<T> = null | { signal, workflow }
- sense-runtime persists result.signal, not result itself
- sense-worker sends workflow trigger message when workflow is non-null
- New SenseWorkflowTriggerMessage in IPC protocol
- Knowledge card updated to match

— 小橘 🍊(NEKO Team)
2026-04-30 00:37:16 +00:00
xiaoju bfd8fe729a docs: update sense knowledge card to match pure compute API
- No args (no db, no peers, no signal)
- Runtime handles db.insert
- Export { compute, table }

— 小橘 🍊(NEKO Team)
2026-04-30 00:28:23 +00:00
xiaoju 748df10e6a fix: remove AbortSignal from SenseComputeFn
Compute is truly zero-arg now: () => Promise<T | null>.
Runtime handles timeout via Promise.race, sense doesn't need signal.

— 小橘 🍊(NEKO Team)
2026-04-30 00:22:57 +00:00
xiaoju 3ef9cfcb27 Merge pull request 'refactor: pure sense compute — no db, no peers' (#265) from refactor/pure-sense-compute into main 2026-04-30 00:11:57 +00:00
xiaoju 8c9adf08c5 refactor: pure sense compute — no db, no peers
SenseComputeFn is now (signal: AbortSignal) => Promise<T | null>.
sense-runtime handles db.insert when compute returns non-null.
Senses export { compute, table } — SenseModule type added to core.

Closes #264
Refs #260

— 小橘 🍊(NEKO Team)
2026-04-30 00:07:49 +00:00
scottwei 08e8020cb6 Merge pull request 'feat: add sense contract types to nerve-core' (#263) from feat/sense-contract into main
Reviewed-on: #263
2026-04-29 23:44:01 +00:00
xiaoju e287e07dab feat: add sense contract types to @uncaged/nerve-core
Export SenseComputeFn, SenseComputeOptions, SenseBlobStore,
SensePeerMap — formalizing the compute function signature that
senses must implement.

Closes #262
Refs #260

— 小橘 🍊(NEKO Team)
2026-04-29 15:11:49 +00:00
xiaomo 239dfffb28 Merge pull request 'feat: add @uncaged/nerve-workflow-meta package' (#259) from feat/workflow-meta-package into main 2026-04-29 14:48:57 +00:00
xiaoju 6ccb33bf40 feat: add @uncaged/nerve-workflow-meta package
Extract develop-sense and develop-workflow meta workflows into a
shared package. Reviewer and committer roles imported from their
respective packages.

Refs RFC-004 Phase 2

— 小橘 🍊(NEKO Team)
2026-04-29 14:47:12 +00:00
xiaomo 0c95a9d716 Merge pull request 'feat: add @uncaged/nerve-role-reviewer package' (#258) from feat/role-reviewer-package into main 2026-04-29 14:33:45 +00:00
xiaoju aa64ea86ca feat: add @uncaged/nerve-role-reviewer package
Extract shared reviewer role (diff analysis, convention checking) into
a reusable package. Identical logic currently duplicated across
develop-sense and develop-workflow workspaces.

Refs RFC-004 Phase 1
2026-04-29 14:31:36 +00:00
xiaomo 431627019a Merge pull request 'feat: add @uncaged/nerve-role-committer package (RFC-004 Phase 1)' (#257) from feat/rfc004-role-committer into main 2026-04-29 14:16:11 +00:00
xiaoju 915584ff11 feat: add @uncaged/nerve-role-committer package (RFC-004 Phase 1)
First shared role package. Extracts workspace committer into a
reusable package with decorator chain (withDryRun + onFail).

Also fixes workflow-utils test types (StartStep shape, TestMeta constraint).
2026-04-29 14:07:26 +00:00
xiaoju 3644cce2c8 Merge pull request 'docs: RFC-004 package architecture' (#256) from docs/rfc-004-package-architecture into main 2026-04-29 14:03:02 +00:00
xiaoju 9855eba894 docs: RFC-004 package architecture — shareable workflows, roles & senses 2026-04-29 13:29:53 +00:00
scottwei 554933d43b Merge pull request 'feat(workflow-utils): add withDryRun role wrapper' (#255) from feat/254-with-dry-run into main
Reviewed-on: #255
2026-04-29 13:28:22 +00:00
xiaoju 9c11cd53e6 rename with-dry-run → role-decorators, remove RFC-004 from this PR 2026-04-29 13:27:08 +00:00
xiaoju c08e7f085d refactor(workflow-utils): split withDryRun into decorator chain
- withDryRun(opts) — only handles dry-run skip
- onFail(opts) — only handles try/catch error wrapping
- decorateRole(role, [...]) — composes decorators left-to-right
- RoleDecorator<M> type for custom decorators

Addresses review feedback on #255
2026-04-29 13:23:40 +00:00
xiaoju 4a4a03a2bc feat(workflow-utils): add withDryRun role wrapper
Extracts repeated dry-run skip + try/catch error handling into a
reusable wrapper. Includes tests.

Closes #254
2026-04-29 12:57:33 +00:00
xiaoju bfb5b9b17d Merge pull request 'chore: add .knowledge/ curated cards + knowledge.yaml' (#251) from chore/knowledge-cards into main 2026-04-29 10:01:22 +00:00
xiaomo 45f5dbe89e fix: update workflow.md and adapter.md for createRole (PR #253)
- workflow.md: replace WorkflowSpec section with createRole helper
- adapter.md: update usage example to createRole
2026-04-29 09:59:04 +00:00
xiaomo a566cdabf8 Merge pull request 'refactor: replace WorkflowSpec with createRole helper' (#253) from refactor/252-create-role into main 2026-04-29 09:55:58 +00:00
xiaoju 9b4ab6225a refactor(core): remove WorkflowSpec and compileWorkflowSpec
Add createRole to workflow-utils wrapping AgentFn, Zod meta, and extractMetaOrThrow.

Refs #252

Signed-off-by: 小橘 🍊(NEKO Team) <dev@uncaged.ai>
Made-with: Cursor
2026-04-29 09:54:13 +00:00
xiaomo dfb3c9ec18 fix: address review feedback on knowledge cards
- knowledge-layer.md: use env var instead of hardcoded URL
- monorepo.md: workflow-utils depends on core only (not adapters)
- cli.md: fix sense subcommands (schema/query, not db)
2026-04-29 09:36:28 +00:00
小橘 🍊(NEKO Team) 77500ee6dd feat(daemon): pass dryRun into compileWorkflowSpec extractFn
Adds optional CursorAdapter.mode for ask/plan. Export zodMeta from workflow-utils for RoleSpec meta schemas.

Made-with: Cursor
2026-04-29 09:35:12 +00:00
xiaomo accc7c59fd chore: add cli.md knowledge card 2026-04-29 09:32:33 +00:00
xiaomo 97840e25ab chore: add .knowledge/ cards + knowledge.yaml
7 curated knowledge cards extracted from RFCs and docs:
- architecture: core pipeline, extension points, process isolation
- sense: compute behavior, Sense→Workflow, config
- workflow: engine, threads, WorkflowSpec
- adapter: AgentFn protocol, available adapters, extract layer
- coding-conventions: functional-first, Result type, naming
- monorepo: package structure, dependency rules
- knowledge-layer: sync/query CLI, embedding service

knowledge.yaml indexes .knowledge/**/*.md only.
2026-04-29 09:29:29 +00:00
xiaomo 526ca68c99 Merge pull request 'refactor: deduplicate spawn-safe into @uncaged/nerve-core' (#249) from fix/247-spawn-safe-dedup into main 2026-04-29 09:17:32 +00:00
小橘 🍊(NEKO Team) 3d02ea20ad fix(core): consolidate spawn-safe into nerve-core
Move spawnSafe, nerveCommandEnv, and related types to @uncaged/nerve-core.

Update adapter-cursor, adapter-hermes, and workflow-utils to consume from core.

Refs #247

Made-with: Cursor
2026-04-29 09:14:28 +00:00
xiaomo 07f1a3d146 Merge pull request 'feat: real embedding integration + remove AgentRegistry (#244, #245)' (#246) from feat/244-phase-c into main 2026-04-29 09:04:41 +00:00
xiaoju ede59ebcc2 feat(core): remove AgentRegistry, roles declare adapter directly
RoleSpec uses adapter: AgentFn; timeouts are configured via adapter factories.

nerve.yaml no longer accepts agents:; extract merge is global to role only.

Added cursorAdapter/hermesAdapter defaults; removed daemon registry and deps.

Signed-off-by: 小橘 🍊(NEKO Team)
Made-with: Cursor
2026-04-29 08:40:37 +00:00
xiaoju 7de75b5df7 rfc-003: remove timeout from RoleSpec, it's an adapter concern
RoleSpec now has exactly 3 fields: adapter, prompt, meta.
Timeout belongs to adapter config — different timeouts = different adapter instances.

Refs #245
小橘 🍊(NEKO Team)
2026-04-29 08:34:00 +00:00
xiaoju 4be465918c rfc-003: adapter as direct function reference, not string
- RoleSpec.adapter: string → AgentFn (direct import)
- Each adapter exports default instance + factory
- No adapter map, no registry, no lookup — compile-time safety
- TypeScript catches missing adapters at import time

Refs #245
小橘 🍊(NEKO Team)
2026-04-29 08:23:59 +00:00
xiaoju 732669fab5 rfc-003: simplify agent layer — remove registry, roles declare adapter directly
- Remove nerve.yaml agents config (keep only extract + knowledge)
- RoleSpec.agent → RoleSpec.adapter
- buildWorkflowSpec receives adapter map directly
- Extract merge: 3-level → 2-level (global → role)
- Update open questions (embedding service resolved)

小橘 🍊(NEKO Team)
2026-04-29 08:11:54 +00:00
xiaoju 7bb6990dc5 test(knowledge): update tests for async embed service integration
- Mock embed service in sync/query tests (1024-dim vectors)
- Fix Buffer.from for sqlite Uint8Array in loadAllChunks
- Add pretest to workflow-utils for build order

All tests passing.

Refs #234
2026-04-29 07:57:33 +00:00
xiaoju ce5462cb59 feat(knowledge): integrate real embedding service
Replace placeholder fake embeddings with real embed service calls:
- Add embed-service.ts (remote API + cosine similarity + fallback)
- knowledge-db stores externally-provided embeddings
- sync.ts/query.ts now async, call embed service
- CLI commands updated for async API

WIP: tests need updating for async changes

Refs #234
2026-04-29 07:43:05 +00:00
xiaomo 84334b7b09 Merge pull request 'feat: RFC-003 Adapter Plugin Architecture + Dynamic Prompts' (#243) from feat/rfc-003-adapter-packages into main 2026-04-29 07:33:46 +00:00
xiaoju b7d9a37981 feat: RFC-003 adapter plugin architecture + dynamic prompts
AgentRegistry plugin model:
- createAgentRegistry(agents, adapterFactories) — second param for adapter map
- Echo adapter built-in, cursor/hermes via factory injection
- Unknown type throws with available adapter list

Dynamic prompts:
- RoleSpec.prompt: string | ((start, messages) => Promise<string>)
- compileWorkflowSpec handles both static and dynamic prompts

Adapter packages:
- @uncaged/nerve-adapter-cursor — cursor-agent CLI spawn
- @uncaged/nerve-adapter-hermes — hermes CLI subagent spawn
- Each with own spawn-safe (inline, avoids circular dep)
- Moved spawn logic from workflow-utils, kept role factories as thin wrappers

Kernel integration:
- defaultAgentAdapterFactories() registers cursor + hermes
- Hot-reload passes factories on rebuild

Ref: #234
2026-04-29 07:24:19 +00:00
xiaoju 18584641bd docs(rfc): RFC-003 — adapter packages + dynamic prompts
- Adapter packages: each adapter in own package (@nerve/adapter-cursor, etc.)
- AgentRegistry accepts adapter factories at construction (plugin model)
- Migration path: move spawn logic from workflow-utils to adapter packages
- Dynamic prompts: RoleSpec.prompt supports string | async function
- Workspace only installs adapters it uses

Ref: #234
2026-04-29 07:08:34 +00:00
xiaomo 03e9d20501 Merge pull request 'feat: RFC-003 Phase 6 — Knowledge Layer + Review Fixes' (#242) from feat/rfc-003-phase-6-knowledge into main 2026-04-29 06:56:52 +00:00
xiaoju 623fb3cd3a fix(cli): knowledge query --repo flag, remove -r alias (conflicts with global remote flag)
- Rename -r to --repo for knowledge query scope
- Update RFC docs to match
- Fix biome format issues
- Add assertZodMetaSchemas export
- KNOWN_AGENT_ADAPTER_IDS: add cursor/hermes/codex

Self-tested: nerve knowledge sync + query work correctly
2026-04-29 06:02:24 +00:00
xiaoju 62434847c4 feat(cli,core): RFC-003 Phase 6 — Knowledge Layer + review fixes
Knowledge Layer:
- knowledge.yaml parser in core (include/exclude globs)
- Chunking: markdown (by heading), TypeScript/JS (by function/block)
- knowledge.db: SQLite storage for chunks + embeddings (node:sqlite)
- CLI: nerve knowledge sync, nerve knowledge query
- Scoping: -r (specific repo), -g (global search), mutually exclusive
- Repo registry (~/.nerve-knowledge-registry.json) for global search
- Placeholder embedding (content hash) until remote service ready
- Word-overlap similarity for query ranking

Review fixes (from PR #241 feedback):
- KNOWN_AGENT_ADAPTER_IDS: add cursor/hermes/codex + sync docs
- collectWorkflowSpecAgentReferences: document regex comment false-positive
- assertZodMetaSchemas: one-time compile-time validation utility

Closes #240
Ref: #234
2026-04-29 05:39:00 +00:00
xiaomo 3d89fc4a7a Merge pull request 'feat: RFC-003 Agent Configuration Layer (Phase 1-5)' (#241) from feat/rfc-003-phase-1 into main 2026-04-29 05:31:57 +00:00
xiaoju a1dda1d731 feat(daemon,cli): RFC-003 Phase 5 — Integration (hot-reload + validate)
- Kernel: rebuild AgentRegistry on config hot-reload, log agent_registry_reload
- Running threads unaffected, new threads use rebuilt registry
- nerve validate: check agent name refs in WorkflowSpec source files
- nerve validate: verify adapter type is known (KNOWN_AGENT_ADAPTER_IDS)
- nerve validate: require extract config when WorkflowSpec agent refs exist
- Tests: kernel reload (mock), validate (missing/valid/extract/adapter)

Closes #239
Ref: #234
2026-04-29 05:23:59 +00:00
xiaoju 1218b5ddbd feat(core,daemon): RFC-003 Phase 4 — WorkflowSpec Compiler
- WorkflowSpec + RoleSpec types in packages/core
- compileWorkflowSpec: WorkflowSpec → WorkflowDefinition (daemon)
- resolveRoleTimeoutMs: two-level timeout (role override > agent default)
- parseDurationStringToMs extracted to shared duration.ts
- AgentRegistry.getAgentConfig for timeout lookup
- Tests: 10 new cases (compile shape, agent→extract flow, timeout resolution)
- Backward compat: hand-written Role<Meta> unchanged

Closes #238
Ref: #234
2026-04-29 05:11:29 +00:00
xiaoju 136aafa209 feat(workflow-utils): RFC-003 Phase 3 — Extract Layer
- llmExtractWithRetry: retry-once on parse failure with error context
- mergeExtractConfig: three-level merge (global → agent → role)
- extractMetaOrThrow + createLlmExtractFn: ExtractFn factory
- ZodMetaSchema bridges core Schema<T> with runtime Zod validation
- Tests: 8 new cases (success/retry/throw/merge/factory)
- core tsconfig: add DOM lib for AbortSignal declaration emit

Closes #237
Ref: #234
2026-04-29 04:59:47 +00:00
xiaoju 88bd30a1e4 feat(daemon): RFC-003 Phase 2 — AgentRegistry + echo adapter
- createAgentRegistry(agents) returns { get(name): AgentFn }
- get() throws with agent name in message if not found
- Echo adapter (type: 'echo') returns prompt as-is for testing
- Tests: 5 cases covering get/throw/echo/multi-agent/AbortSignal

Closes #236
Ref: #234
2026-04-29 04:49:22 +00:00
xiaoju 36e5aed1b1 feat(core): RFC-003 Phase 1 — agent config types + nerve.yaml schema
- Add AgentFn, WorkflowContext (workdir + AbortSignal), ExtractFn, ExtractError
- Add AgentConfig, ExtractConfig types to NerveConfig
- Extend parseNerveConfig: agents (kebab-case keys) + extract sections
- Export all new types from @nerve/core
- Add config parse tests (7 new tests)
- Update all existing test fixtures with agents/extract fields

Closes #235
Ref: #234
2026-04-29 04:43:08 +00:00
xiaomo fe90b492c0 Merge pull request 'RFC-003: Agent Configuration Layer' (#233) from rfc/003-agent-config-layer into main 2026-04-29 04:29:46 +00:00
xiaoju 7a4e16381c docs(rfc): address review — resolve open questions, add error handling/hot-reload/context/validation
- model: auto = delegate to adapter's default strategy
- ExtractFn: retry once + throw ExtractError, three-level merge (global → agent → role)
- Agent hot-reload: AgentRegistry rebuilds on config change, running threads unaffected
- WorkflowContext: add workdir + AbortSignal
- Configuration validation: nerve validate checks agent name refs
- WorkflowSpec compile: runtime lazy compile at daemon startup/hot-reload
- Compatibility: existing hand-written Role functions continue to work (not breaking)
- Resolved 3 of 5 open questions, 2 remaining (long-term memory, embedding service)

Refs #233
2026-04-29 04:26:48 +00:00
xiaoju aecced587c docs(rfc): knowledge layer — built-in, local-first, repo-scoped
Replaces the Alysaril delegation with a built-in knowledge feature:
- knowledge.yaml at repo root with include/exclude
- knowledge.db (SQLite) stores chunks + embeddings locally
- Remote service only for embedding computation + content-hash cache
- In-memory cosine search (sufficient for project scale)
- CLI: nerve knowledge sync/query with -r and -g flags

Refs #233
2026-04-29 04:12:09 +00:00
xiaoju 3950f0e278 docs(rfc): add Knowledge Layer section — delegate to Alysaril
Project knowledge is not a nerve feature. Nerve runtime does not hardcode
knowledge paths; loading is a prompt concern. Adds Alysaril as the
independent knowledge base tool.

小橘 <xiaoju@shazhou.work>
2026-04-29 03:25:43 +00:00
xiaoju ea07c2c667 docs(rfc): RFC-003 agent configuration layer
Separates agent infrastructure (nerve.yaml) from workflow business logic.
Key decisions:
- Agent = domain capability, Role = scenario specialization
- Unified AgentFn protocol: (prompt, context) → string
- Independent extract layer for structured output
- Two-layer timeout (agent default, role override)
- No runtime fallback, fail fast

小橘 <xiaoju@shazhou.work>
2026-04-29 01:26:41 +00:00
xiaomo 7afed2aa0d Merge pull request 'fix(cli): include __start__ message in nerve thread show' (#232) from fix/231-thread-show-start-message into main 2026-04-28 15:36:32 +00:00
xiaoju ce79dbea7e fix(cli): include __start__ message in nerve thread show
When 'nerve thread show' is called without --before, the initial user
prompt (__start__ message) is now displayed first, followed by the most
recent role rounds within the budget.

- Add getThreadStartMessage() to LogStore
- Modify buildThreadCommandOutput to accept optional startRow
- Pass start message from threadShowCommand when before===0
- Add tests for new behavior

Fixes #231
2026-04-28 15:08:23 +00:00
xiaomo 773a23bf9c Merge pull request 'feat(cli): init generates pnpm workspace with TypeScript senses' (#230) from feat/229-init-workspace-pnpm into main 2026-04-28 12:34:20 +00:00
xiaoju 2e739bef6e feat(cli): init generates pnpm workspace with TypeScript senses
- Add pnpm-workspace.yaml generation (workflows/*, senses/*)
- Add scripts.build: 'pnpm -r build' to root package.json
- Convert cpu-usage sense from index.js → src/index.ts with types
- Move schema.ts to src/schema.ts
- Add sense-level package.json with esbuild build script
- Run pnpm build after install during init
- Add --ignore-workspace to create sense install
- Update e2e tests for new file structure

Fixes #229
2026-04-28 11:22:31 +00:00
xingyue a0a91d1699 Merge pull request 'refactor(workflow-utils): reorganize — roles top-level, shared internals in shared/' (#228) from refactor/227-workflow-utils-reorg into main 2026-04-28 08:51:05 +00:00
xiaomo 1a4f94c913 refactor(workflow-utils): reorganize — roles top-level, shared internals in shared/
- Split role-factories.ts into role-cursor/hermes/llm/react.ts
- Move shared internals (spawn-safe, cursor-agent, hermes-agent, llm-*, etc.) to shared/
- Absorb start-step.ts (6 lines) into role-types.ts
- Absorb hermes-options.ts into shared/hermes-agent.ts
- Extract formatLlmError into shared/format-error.ts
- Deduplicate cursor merge helpers with generic pick()
- Public API (index.ts exports) unchanged

Fixes #227
2026-04-28 08:47:09 +00:00
xiaomo 984389eb2b Merge pull request 'feat(cli): scaffold sense as TypeScript + esbuild bundle' (#226) from feat/225-sense-typescript-scaffold into main 2026-04-28 08:37:52 +00:00
xiaoju 8205255c6a feat(cli): scaffold sense as TypeScript + esbuild bundle
- nerve create sense now generates src/index.ts and src/schema.ts
- Adds package.json with esbuild build script
- Runs pnpm install && pnpm build after scaffolding
- Updates nerve-dev skill docs with new sense structure
- Updates tests for new TypeScript scaffold

Fixes #225
2026-04-28 07:42:15 +00:00
xiaomo 814d94f9de Merge pull request 'fix(workflow-utils): omit --model/--provider when not explicitly set' (#223) from fix/222-hermes-role-no-default-model into main 2026-04-28 07:02:25 +00:00
xiaoju 1efdc4dcd1 fix(workflow-utils): omit --model/--provider when not explicitly set
Hermes role defaulted model/provider to 'auto', causing 404 on
custom providers. Now defaults to undefined and only passes
--model/--provider args when explicitly provided.

Fixes #222
2026-04-28 06:52:16 +00:00
xiaoju 8a1d61985d Merge pull request 'fix(workflow-utils): correct hermes CLI args' (#220) from fix/216-hermes-cli-args into main 2026-04-28 05:28:56 +00:00
xiaomo 1933934340 Merge pull request 'refactor(daemon): workflows must be bundled to dist/, daemon only loads dist/index.js' (#221) from refactor/219-workflow-bundle-dist into main 2026-04-28 05:27:20 +00:00
xiaoju e56a01d88a refactor(daemon): workflows must be bundled to dist/, daemon only loads dist/index.js
- workflow-worker: loadWorkflowDefinition only looks for dist/index.js
- file-watcher: watch workflows/*/dist/**/*.js instead of *.ts
- file-watcher: sense watch uses .js regex pattern
- nerve create workflow: scaffold includes package.json with esbuild build script
- Updated all related tests

Fixes #219
2026-04-28 05:25:38 +00:00
xiaomo 5a7246cb98 fix(workflow-utils): correct hermes CLI args (#216)
- 'run' → 'chat' (hermes has no 'run' subcommand)
- '-p' → '-q' (single query mode uses -q/--query)
- '--skill' → '-s' (correct flag is -s/--skills)
2026-04-28 05:18:09 +00:00
xiaomo bda0c69261 Merge pull request 'fix(daemon): unskip and fix 16 daemon tests' (#215) from fix/213-daemon-tests into main 2026-04-28 04:50:31 +00:00
xingyue 0388c6010a fix(daemon): unskip and fix 16 daemon tests
- kernel-workflow-integration (8): mock children now respond to compute
  messages with signal replies, matching real worker behavior
- kernel (2): add ready + compute response to mocks, flush with
  runAllTimersAsync
- phase6-integration (3) + kernel-integration (3): replace setInterval
  polling with async/await setTimeout loop, increase timeouts

All 165 daemon tests pass, zero skips.

Closes #213
2026-04-28 12:46:12 +08:00
xiaomo 03decf0751 Merge pull request 'feat(cli): nerve init installs @uncaged/nerve-skills and generates agent hints' (#212) from feat/211-init-install-skills into main 2026-04-28 04:35:56 +00:00
xiaomo c765becc91 Merge pull request 'chore(daemon): skip 16 flaky/broken kernel tests' (#214) from chore/skip-flaky-daemon-tests into main 2026-04-28 04:35:44 +00:00
xiaoju a03ab64c3e chore(daemon): skip 16 flaky/broken kernel tests
8 flaky tests (worker spawn timing issues):
- phase6-integration: 3 tests
- kernel-integration: 3 tests
- kernel: 2 tests

8 broken tests (routeResult undefined bug):
- kernel-workflow-integration: 8 tests

Tracked in #TBD for proper fix.
2026-04-28 04:32:10 +00:00
xiaoju c45921cd83 feat(cli): nerve init installs @uncaged/nerve-skills and generates .cursor/rules/nerve-skills.mdc
Fixes #211
2026-04-28 04:22:53 +00:00
xiaoju facb25a959 docs(nerve-dev): update workflow section with role factories, meta routing principle, prompt.ts pattern
- Role factory templates (createCursorRole, createHermesRole, createLlmRole, createReActRole)
- Meta = moderator routing only, not data bus between roles
- prompt.ts pure functions instead of readFileSync + prompt.md
- Updated workflow-utils API table
- Real sense-generator example throughout

小橘 🍊(NEKO Team)
2026-04-28 03:41:47 +00:00
xiaoju 7ee7c4503a feat(workflow-utils): export readNerveYaml and nerveAgentContext
Re-export context helpers from the package entry so consumers can import them from @uncaged/nerve-workflow-utils.

Made-with: Cursor
2026-04-28 02:20:03 +00:00
xiaomo b4c78a62aa Merge pull request 'feat(workflow-utils): role factory templates #208' (#209) from feat/208-role-factories into main 2026-04-28 01:55:14 +00:00
xiaoju 2529c68062 feat(workflow-utils): role factory templates — createCursorRole, createHermesRole, createLlmRole, createReActRole
- Add role-types.ts with all shared types (CliPromptFn, LlmPromptFn, MetaExtractConfig, etc.)
- Add role-factories.ts with 4 factory functions
- Add llm-chat.ts with chatCompletionText and reActIterativeChat
- Add hermes-agent.ts and hermes-options.ts for Hermes CLI integration
- Add threadId to StartStep meta (core + daemon)
- Add model param to cursorAgent options
- Tests for all 4 factories

Refs #208
2026-04-28 01:50:44 +00:00
xiaomo 5744a61716 Merge pull request 'refactor(cli): nerve create workflow — role 拆成独立目录' (#207) from refactor/206-workflow-role-dirs into main 2026-04-27 14:05:15 +00:00
xingyue 7cd6f6fa2b refactor(cli): nerve create workflow — role 拆成独立目录 (#206) 2026-04-27 22:02:47 +08:00
xiaomo 787f864732 docs(skills): add nerve-dev coding agent skill
Comprehensive development guide for AI coding agents covering:
- Architecture and core concepts (sense → signal → workflow)
- CLI commands reference
- Sense and workflow development patterns
- nerve.yaml configuration (inline interval/on)
- Coding conventions and pitfalls

Fixes #187
2026-04-27 13:51:20 +00:00
xingyue ea7e064177 Merge pull request 'refactor: redesign workflow trigger — signal entails workflow' (#205) from refactor/204-workflow-trigger into main 2026-04-27 13:28:47 +00:00
xiaomo e159a9b7ca refactor: redesign workflow trigger — signal entails workflow (#204)
Breaking change: compute() returns null | { signal: T; workflow: WorkflowTrigger | null }
- WorkflowTrigger is a structured type (name, maxRounds, prompt, dryRun)
- Signal is always emitted before workflow launch (causal chain)
- CLI: nerve workflow trigger <name> --max-rounds N --prompt '...' --dry-run
- Remove pipe-separated directive format

Fixes #204
2026-04-27 13:23:31 +00:00
xiaomo 6808228c07 Merge pull request 'refactor(daemon): rename reflex-scheduler → sense-scheduler' (#203) from refactor/rename-reflex-to-sense-scheduler into main 2026-04-27 12:14:09 +00:00
xiaoju b269f76b33 refactor(daemon): rename reflex-scheduler → sense-scheduler
Rename ReflexScheduler to SenseScheduler, update all file names,
imports, comments, test descriptions, and log source values.

Fixes #202
2026-04-27 12:07:22 +00:00
xiaomo 27fe50e4b8 Merge pull request 'refactor: remove legacy reflexes backward-compat code' (#200) from refactor/remove-legacy-reflexes into main 2026-04-27 11:13:07 +00:00
xiaoju 2b2895e5be refactor: remove legacy reflexes backward-compat code
BREAKING CHANGE: NerveConfig no longer has 'reflexes' field.
SenseReflexConfig/ReflexConfig types removed.
Config with top-level 'reflexes' array now errors instead of migrating.
Use sense-level 'interval' and 'on' fields instead.

- Remove reflexes from NerveConfig type
- Remove legacy parsing, deprecation warning, buildReflexesFromSenses
- Simplify reflex-scheduler to only read sense-level config
- Rename senseTriggerLabelsWithFallback → senseTriggerLabels
- Delete all legacy reflexes test cases
- -639/+114 lines

Fixes #199
2026-04-27 11:11:37 +00:00
xiaomo 0d54d7fc77 Merge pull request 'refactor: inline reflex config — sense-level trigger declarations' (#198) from refactor/189-inline-reflex-config into main 2026-04-27 10:58:47 +00:00
xiaoju 07165b15cc chore(cli): adapt CLI commands, init template, and validate for inline reflex config
- Init template uses inline interval/on instead of top-level reflexes
- Validate output updated for sense trigger schedules
- Sense list uses senseTriggerLabelsWithFallback
- All 183 CLI tests pass

Fixes #195
2026-04-27 10:52:43 +00:00
xiaoju ef40512977 refactor(daemon): reflex-scheduler reads from sense config with legacy fallback
- Scheduler iterates config.senses directly for interval/on
- Falls back to config.reflexes when sense has no inline triggers
- kernel.ts uses senseTriggerLabelsWithFallback for display
- New core export: senseTriggerLabelsWithFallback
- All 166 daemon tests pass

Fixes #194
2026-04-27 10:46:12 +00:00
xiaoju d0cc8c0840 feat(core): backward-compat parsing for legacy reflexes array
- Merge top-level reflexes into sense-level interval/on fields
- Emit deprecation warning when legacy reflexes detected
- Deduplicate on[] triggers, detect interval conflicts
- Add tests for compat parsing, merge, and conflict cases

Fixes #193
2026-04-27 10:40:46 +00:00
xiaoju 453294a465 fix(daemon): guard against undefined in sense signal persistence
Use loose equality (!=) to catch both null and undefined from compute(),
preventing undefined values from being persisted to SQLite.

Fixes #192
2026-04-27 10:35:26 +00:00
xiaomo c2fd1691bd Merge pull request 'refactor(cli): unify validateResourceName, rename WORKFLOW_NAME_RE → RESOURCE_NAME_RE' (#197) from refactor/resource-name-validate into main 2026-04-27 10:34:13 +00:00
xiaoju 81663ad524 fix(core): remove stale import process breaking core build
Fixes #191
2026-04-27 10:33:36 +00:00
xingyue 5899a858ac refactor(cli): unify validateResourceName, rename WORKFLOW_NAME_RE → RESOURCE_NAME_RE
Address review feedback: merge validateWorkflowName and
validateSenseName into a single validateResourceName(name, type).

Closes #188
2026-04-27 18:23:43 +08:00
xiaomo bbbebf18f3 Merge pull request 'refactor(cli): add nerve create command, remove init workflow' (#190) from refactor/create-command into main 2026-04-27 10:18:30 +00:00
xingyue d13fbe08db refactor(cli): add nerve create command, remove init workflow
- Add top-level `nerve create` with `workflow` and `sense` subcommands
- Move workflow scaffold from `init workflow` to `nerve create workflow`
- Add `nerve create sense <name>` to scaffold sense boilerplate
- Keep `nerve init` for workspace initialization only
- Add tests for create workflow, create sense, and e2e

Closes #188
2026-04-27 18:16:57 +08:00
xiaoju c6c3e0142d chore: bump version to 0.5.0
小橘 🍊(NEKO Team)
2026-04-27 09:40:34 +00:00
xiaomo 3bf8421c83 Merge pull request 'refactor: move experimental-warning-suppression from core to daemon' (#186) from fix/move-experimental-warning-to-daemon into main 2026-04-27 09:39:25 +00:00
xiaoju 03e103d400 refactor: move experimental-warning-suppression from core to daemon
core package should remain platform-agnostic without direct process access.
The suppression module belongs in daemon where Node.js APIs are expected.

小橘 🍊(NEKO Team)
2026-04-27 09:30:16 +00:00
xiaomo e0a9c6a471 Merge pull request 'fix(cli): suppress ExperimentalWarning in CLI and daemon spawn' (#185) from fix/suppress-experimental-warning into main 2026-04-27 09:21:36 +00:00
xiaomo 09098ef4b2 fix(cli): suppress ExperimentalWarning in CLI and daemon spawn 2026-04-27 09:18:34 +00:00
xingyue f9c591fdf2 refactor: reduce cognitive complexity in 3 functions (#184)
Co-authored-by: 星月 <xingyue@shazhou.work>
Co-committed-by: 星月 <xingyue@shazhou.work>
2026-04-27 09:10:05 +00:00
xiaomo 8f3322bc48 Merge pull request 'fix(cli): add logs/ and nerve.pid to init gitignore template' (#182) from fix/gitignore-template into main 2026-04-27 08:40:44 +00:00
Scott Wei 57e4d992e2 fix(cli): add --skip-install flag to init, fix e2e test timeout
- Add --skip-install flag to nerve init for testing/offline use
- Use --skip-install in e2e tests to avoid real pnpm install
- Reduce e2e init test timeout from 60s to 10s

Closes #181
2026-04-27 16:38:21 +08:00
xiaomo fcd3fe760c Merge pull request 'fix: suppress ExperimentalWarning for node:sqlite (#179)' (#180) from fix/179-suppress-experimental-warning into main 2026-04-27 08:20:31 +00:00
Scott Wei 0a0121d2ca fix(cli): add logs/ and nerve.pid to init gitignore template
These are runtime artifacts that should not be tracked.
2026-04-27 16:18:14 +08:00
xiaomo d70e74afde fix: suppress ExperimentalWarning for node:sqlite (#179)
Add side-effect module in @uncaged/nerve-core that filters ExperimentalWarning
from process.emit. Imported at all entry points: CLI, daemon bootstrap,
sense-worker, and workflow-worker.
2026-04-27 08:17:52 +00:00
xiaomo e467271cbc Merge pull request 'refactor(daemon): optimize _signals prune SQL' (#178) from refactor/prune-sql-optimization into main 2026-04-27 07:59:02 +00:00
xingyue 40accf3b7c refactor(daemon): optimize _signals prune query
Replace NOT IN subquery with OFFSET-based cutoff for better
performance at large retention values.

Ref: PR #177 review feedback from @xiaomo
2026-04-27 15:56:43 +08:00
xiaomo 02514972b0 Merge pull request 'feat(daemon): _signals table retention policy (closes #152)' (#177) from feat/152-signals-retention into main 2026-04-27 07:48:38 +00:00
xingyue b05225fa2a fix(daemon): fix flaky file-watcher config test on macOS
Increase settle time to 200ms and clear setup-phase events before
asserting, matching the approach from #175.
2026-04-27 15:45:58 +08:00
xingyue 63a54d4641 feat(daemon): _signals table retention policy (#152)
- Add `retention` field to SenseConfig (default 10000 max rows)
- Parse optional `retention` positive integer in nerve.yaml sense config
- Prune old _signals rows every 100 inserts for amortized performance
- Pass retention from config through sense-worker to openSenseDb
- Add unit tests for config parsing and runtime pruning
2026-04-27 15:44:33 +08:00
xiaomo 2fdb6a5edd Merge pull request 'docs: E2E test scenario specs for all CLI subcommands' (#176) from docs/e2e-scenarios into main 2026-04-27 07:33:42 +00:00
xiaoju aa88a61e80 docs: add E2E test scenario specs for all CLI subcommands
Each .md file documents existing () and planned (🔲) E2E test
scenarios per subcommand, serving as a coverage map and onboarding
reference for contributors.

Refs #153
2026-04-27 07:32:03 +00:00
xiaomo d555eb4bae Merge pull request 'fix(daemon): fix flaky file-watcher workflow test on macOS' (#175) from fix/149-flaky-file-watcher into main 2026-04-27 07:27:16 +00:00
xiaoju 3927411ec7 fix(daemon): fix flaky file-watcher workflow test on macOS
Use a temp dir without workflow files for the negative test case,
avoiding macOS fs.watch event coalescing from setup file creation.

Fixes #149
2026-04-27 07:24:26 +00:00
xiaomo cba24d727c Merge pull request 'test(cli): add e2e test for nerve store archive' (#174) from test/163-store-archive into main 2026-04-27 07:19:42 +00:00
xiaoju 0241f0fd3e test(cli): add e2e test for nerve store archive
Start daemon, trigger workflow, backdate logs >30 days, verify:
- store archive exports old entries to JSONL files
- Archived rows removed from logs.db
- thread list still shows workflow_runs correctly
- --vacuum flag runs SQLite VACUUM successfully

Extends e2e-harness with store subcommand.

Fixes #163
2026-04-27 07:16:09 +00:00
xiaomo 961b657d7e Merge pull request 'test(cli): add e2e test for workflow runs / inspect / thread' (#173) from test/160-workflow-e2e into main 2026-04-27 07:06:57 +00:00
xiaonuo e9042fb403 Merge pull request 'test(e2e): nerve sense schema (closes #158)' (#168) from test/158-sense-schema into main 2026-04-27 07:05:04 +00:00
xiaoju c45a2f36d2 test(cli): add e2e test for workflow runs / inspect / thread
Start real daemon with echo workflow, trigger runs, verify:
- thread list shows active runs
- thread list --all includes completed runs
- thread inspect shows log entries
- thread show displays conversation output

Extends e2e-harness with workflow support (echo workflow config).

Fixes #160
2026-04-27 07:05:04 +00:00
xiaonuo 6076a1e5a4 Merge pull request 'test(e2e): nerve sense trigger (closes #157)' (#167) from test/157-sense-trigger into main 2026-04-27 07:05:00 +00:00
xiaonuo 035682bcea Merge pull request 'test(e2e): nerve sense list (closes #155)' (#166) from test/155-sense-list into main 2026-04-27 07:04:56 +00:00
xiaomo 7703304ae5 Merge pull request 'test(cli): add e2e test for nerve sense query' (#165) from test/156-sense-query into main 2026-04-27 07:01:38 +00:00
xingyue 8caf9d681d fix: use delete process.env.HOME per review 2026-04-27 14:58:24 +08:00
xingyue cad51d306b fix: use vi.spyOn + delete process.env.HOME per review 2026-04-27 14:58:20 +08:00
xingyue c3a03b280d fix: add comment explaining process.exit mock pattern per review 2026-04-27 14:56:26 +08:00
xiaomo aa2e7a290f Merge pull request 'test(cli): e2e logs command (#161)' (#170) from test/161-logs into main 2026-04-27 06:54:43 +00:00
xiaomo 490dfd5157 Merge pull request 'test(cli): e2e validate & init (#162)' (#171) from test/162-validate-init into main 2026-04-27 06:54:37 +00:00
xiaomo bdf5ba8b5c Merge pull request 'test(cli): e2e daemon start/stop/status lifecycle (#159)' (#169) from test/159-daemon-lifecycle into main 2026-04-27 06:54:35 +00:00
xiaomo 082b83adbd fix: use delete instead of assigning undefined to process.env.HOME 2026-04-27 06:54:16 +00:00
xingyue 2f908489e1 test(e2e): nerve sense schema — SQLite table structure + JSON output (closes #158) 2026-04-27 14:41:54 +08:00
xiaomo 9b027a44f6 test(cli): add e2e logs test (#161) 2026-04-27 06:41:13 +00:00
xiaomo 991089a228 test(cli): add e2e validate and init test (#162) 2026-04-27 06:41:12 +00:00
xiaomo 0ac38c6c83 test(cli): add e2e daemon lifecycle test (#159) 2026-04-27 06:41:11 +00:00
xingyue a54cc703c9 test(e2e): nerve sense trigger — mock daemon IPC + CLI path (closes #157) 2026-04-27 14:40:22 +08:00
xingyue 4dde101515 test(e2e): nerve sense list — mock daemon IPC + full CLI path (closes #155) 2026-04-27 14:37:52 +08:00
xiaoju be8ee3cc2e test(cli): add e2e test for nerve sense query
Verifies sense query returns data after signal persistence:
- Default query returns rows (not 0 rows)
- Output contains payload columns
- --json flag returns valid JSON array with payload field
- --sql flag runs custom read-only SQL

Also adds --sql flag support to sense query CLI.

Fixes #156
2026-04-27 06:37:29 +00:00
xiaomo 39d2472a91 Merge pull request 'test(cli): add e2e smoke test for sense list + query' (#164) from test/154-e2e-harness into main 2026-04-27 06:27:38 +00:00
xiaoju 44f20b3fb0 test(cli): add e2e smoke test for sense list + query
Verifies the full daemon → sense → signal → query pipeline works
end-to-end using a real kernel with a mock sense worker.

Refs #154
2026-04-27 06:23:36 +00:00
xiaomo cdeb5ebd61 Merge pull request 'feat: add pnpm run link:dev for local development' (#151) from feat/link-dev-script into main 2026-04-27 05:40:47 +00:00
Wei Wei a834083a0b feat: add pnpm run link:dev for local development
- scripts/link-dev.sh: one-command setup to switch ~/.uncaged-nerve
  to use monorepo packages instead of npm registry versions
- Builds all packages, links CLI globally, links all @uncaged/*
  packages into the nerve workspace, and restarts daemon
- Prevents version mismatch between CLI and daemon during development
2026-04-27 13:38:35 +08:00
xiaomo a56dbadea2 Merge pull request 'feat(daemon): auto-persist signals to sense DB' (#150) from feat/auto-persist-signals into main 2026-04-27 05:33:29 +00:00
xiaoju 715cb8583f feat(daemon): auto-persist signals to sense DB
Every sense now gets a _signals table (id, payload, timestamp) created
automatically. When compute() returns non-null, the signal is persisted
to the sense's own SQLite DB before being sent to the Signal Bus.

This makes `nerve sense query <name>` return signal history out of the
box — no manual INSERT needed in compute functions.

CLI's pickDefaultPreviewTable now prioritizes _signals over other tables.
2026-04-27 05:29:56 +00:00
xiaomo 2447a78f00 Merge pull request 'feat(cli): nerve remote — named remote daemon aliases' (#148) from feat/147-nerve-remote into main 2026-04-27 04:07:54 +00:00
xingyue acdf244426 test(daemon): skip flaky file-watcher test (#149) 2026-04-27 12:03:42 +08:00
xingyue 62996d299b feat(cli): add nerve remote command for named daemon aliases
- Add remotes.ts with loadRemotes/saveRemotes/resolveRemote/getDefaultRemoteName
- Add remote command with add/list/show/set-url/set-token/remove/default subcommands
- Add --remote/-r global flag to cli-global.ts (priority: --host > --remote)
- Register remote command in cli.ts
- Add tests for remotes module
2026-04-27 10:03:27 +08:00
xiaomo 1f67552ff5 Merge pull request 'refactor(cli): split workflow/thread into two top-level command groups' (#146) from refactor/split-workflow-thread into main 2026-04-27 01:46:32 +00:00
xingyue 81571b5349 refactor(cli): split workflow/thread into two top-level command groups (closes #145) 2026-04-27 09:44:54 +08:00
xingyue 624da3d3d7 refactor(cli): remove top-level start/stop/status/logs aliases
These commands are now only accessible via 'nerve daemon start|stop|status|logs'.
2026-04-27 09:02:40 +08:00
xiaomo df71c84eb4 Merge pull request 'fix(cli): repair 5 failing sense-list tests' (#142) from fix/141-sense-list-tests into main 2026-04-25 09:12:22 +00:00
xiaoju bb271e832a fix(cli): repair 5 failing sense-list tests
Mock senseTriggerLabels and isSenseInfo from @uncaged/nerve-core
to match current exports. All 22 tests pass.

Fixes #141

小橘 <xiaoju@shazhou.work>
2026-04-25 09:10:57 +00:00
xiaomo 452dc26afa Merge pull request 'fix(cli): handle invalid timestamps in workflow commands' (#140) from fix/139-cli-workflow-invalid-time into main 2026-04-25 09:03:52 +00:00
xiaoju 8bae382a3c fix(cli): handle invalid timestamps in workflow commands
formatTs() now guards against null/undefined/NaN/Infinity timestamps,
returning '(unknown)' instead of crashing with 'Invalid time value'.

Added 18+ unit tests covering edge cases for formatTs, formatRunLine,
buildListOutput, buildInspectOutput, and formatThreadRoundBlock.

Fixes #139

小橘 <xiaoju@shazhou.work>
2026-04-25 09:00:58 +00:00
xiaomo 83163d9974 Merge pull request 'feat(dashboard): Phase 3 — embedded web dashboard' (#138) from feat/133-phase3-web-dashboard into main 2026-04-25 07:53:04 +00:00
xingyue 913f9ed57d fix: rename threadId to runId in kill-workflow API
The /api/kill-workflow endpoint and all callers (dashboard, HttpTransport)
now consistently use 'runId' instead of 'threadId', matching the handler
name killWorkflowByRunId.

Fixes review feedback on PR #138.
2026-04-25 15:50:56 +08:00
xingyue 69e50d8339 feat(dashboard): Phase 3 — embedded web dashboard
- Single-file dark-theme HTML dashboard (569 lines, zero deps)
- GET / serves dashboard HTML (no auth required, token handled in JS)
- Auto-poll every 5s: health, senses, workflows
- Trigger/Kill buttons with confirmation + toast notifications
- Bearer token input persisted in localStorage
- Connection status indicator (green/red dot)
- Responsive layout for mobile
- SenseInfo gains triggers[] field, WorkflowStatus gains activeRunIds[]
- rslib copies dashboard.html to dist/

Refs #133
2026-04-25 15:01:11 +08:00
xiaomo a4073415b1 Merge pull request 'feat(http-api): Phase 2 — CLI remote access + bearer token auth' (#137) from feat/133-phase2-cli-remote into main 2026-04-25 06:37:32 +00:00
xingyue 203cd8d3c9 feat(http-api): Phase 2 — CLI remote access + bearer token auth
- Bearer token auth middleware with timingSafeEqual
- Enforce api.token when host is non-loopback (security)
- api.host config option (default 127.0.0.1)
- HttpTransport implementing DaemonTransport interface
- CLI --host and --api-token global flags for remote access
- Request body size limit (1MB, 413 on overflow)
- Deduplicate type guards to @uncaged/nerve-core
- 322 tests passing

Refs #133
2026-04-25 14:25:36 +08:00
xiaomo efd15d4b3c Merge pull request 'fix(http-api): bind 127.0.0.1, support trigger body params, fix kill-workflow fields' (#136) from feat/133-http-api into main 2026-04-25 06:11:57 +00:00
xingyue e5bdcf9474 fix(http-api): bind 127.0.0.1, support trigger body params, fix kill-workflow fields
- Default bind host to 127.0.0.1 (no auth in Phase 1)
- POST /api/trigger-workflow reads optional prompt/maxRounds/dryRun from body
- POST /api/kill-workflow: threadId required, name optional (log only)

Refs #133
2026-04-25 14:07:44 +08:00
xingyue 2c262fc8e3 fix: resolve biome lint issues (format, imports, parameter property, complexity) 2026-04-25 14:07:44 +08:00
xingyue 6d74260201 feat(core,daemon,cli): HTTP API + transport interface + workflow list (#133 Phase 1)
- Add WorkflowStatus, HealthInfo types to core IPC protocol
- Add DaemonTransport interface (core/daemon-transport.ts)
- Add list-workflows and health IPC handlers
- WorkflowManager.listWorkflows() exposes runtime status
- Kernel: getHealth(), optional HTTP API server (--port / api.port)
- CLI: nerve workflow list command via IPC
- daemon-client: UnixTransport implements DaemonTransport

Closes: Phase 1 of #133
2026-04-25 14:07:31 +08:00
xiaomo abe205f96c Merge pull request 'fix(daemon): defer hot-reload drain until in-flight runs complete' (#135) from fix/134-hot-reload-in-flight into main 2026-04-25 05:38:53 +00:00
xiaoju 8f1389defe fix(daemon): defer hot-reload drain until in-flight runs complete
When a workflow file changes while runs are active, defer the
drain+respawn until all active threads finish instead of immediately
killing them.

- Add drainWhenIdle() with pendingDrains tracking
- Wire maybeDeferredHotReloadDrain into thread-event and workflow-error paths
- Clean up pendingDrains on worker crash and stop()
- 6 new test cases in hot-reload.test.ts

Fixes #134
2026-04-25 05:37:13 +00:00
tuanzi 45fdf3ff9f fix(khala): address review #132 — reuse nerve-core Result, RETURNING for appendMessage, configurable timeout
- Remove duplicate result.ts, import Result/ok/err from @uncaged/nerve-core
- appendMessage uses INSERT...RETURNING instead of INSERT+SELECT
- CloudRole.timeoutSeconds: per-role timeout (defaults to 300s)
- TODO comments for rate limiting and capacity sensing
2026-04-25 04:53:07 +00:00
tuanzi 8e4f191f3f feat(khala): fix lint issues, add basic tests 2026-04-25 04:44:22 +00:00
tuanzi c3671d86cf feat(khala): complete MVP — CF Worker with D1, DO, auth, task queue, moderator
Implements Khala cloud workflow orchestrator (Phase 0-4):
- Project scaffolding: Hono + Wrangler + D1 + DO
- D1 schema: agents, threads, messages, tasks tables
- Data access layer with atomic claim/release
- Agent auth (SHA-256 Bearer token) + admin API
- ThreadDO workflow engine with JSONata moderator
- Task queue API: poll/claim/release
- Cron-based timeout sweep
- Ping-pong demo workflow

Closes #124, closes #125, closes #127, closes #128, closes #129
2026-04-25 04:44:22 +00:00
tuanzi 0d0b139890 docs: add Khala MVP implementation plan 2026-04-25 04:44:22 +00:00
xiaomo ce20d73ab6 Merge pull request 'fix(workflow-utils): llmExtract dryRun returns schema-shaped defaults' (#126) from fix/123-llmextract-dryrun-defaults into main 2026-04-25 04:35:45 +00:00
xiaoju 7c999a0689 fix(workflow-utils): dryRun llmExtract returns schema-shaped defaults
Add schemaDefaults() from Zod def types; export from package; tests for nested/array/enum/optional.

Made-with: Cursor
2026-04-25 04:31:46 +00:00
xiaomo 111b7e2734 Merge pull request 'feat: workflow exit codes & kill mechanism' (#122) from feat/121-workflow-exit-codes into main 2026-04-25 04:03:29 +00:00
xiaoju 01d7435c4a feat: workflow exit codes & kill mechanism
- Add exit_code to workflow_runs (0=success, 1=role error, 2=maxRounds, 137=killed, 255=crash)
- Expand status enum: started/completed/failed/killed
- Add kill-thread IPC message for graceful workflow termination
- Add 'nerve workflow kill <runId>' CLI command
- Show exit_code in 'nerve workflow list' output

Fixes #121
2026-04-25 03:57:26 +00:00
xiaoju 889bbbb474 Merge pull request 'refactor(core): SenseResult<T> generic + split types.ts' (#118) from refactor/111-split-types-generify-sense-result into main 2026-04-25 02:59:48 +00:00
xiaoju 418ae6a073 refactor(core): SenseResult generic + split types.ts into config/sense/workflow
- SenseResult<T = unknown> with payload: T
- types.ts split into config.ts (types), sense.ts, workflow.ts
- Original config.ts (parseNerveConfig) moved to parse-nerve-config.ts
- index.ts re-exports from new modules, external API unchanged
- daemon-ipc-protocol.ts imports SenseInfo from sense.ts

Fixes #111
2026-04-25 02:56:55 +00:00
xiaoju c6f56155c8 Merge pull request 'refactor(core): restructure ModeratorContext to { start, steps }' (#117) from refactor/110-moderator-context-restructure into main 2026-04-25 02:51:50 +00:00
xiaoju 3ce9e3a846 refactor(core): restructure ModeratorContext to { start, steps }
- ModeratorContext: discriminated union → { start: StartStep; steps: RoleStep<M>[] }
- Moderator signature: (context, round, maxRounds) → (context)
- round derivable from steps.length, maxRounds from start.meta.maxRounds
- workflow-worker.ts: build steps array, pass full context to moderator
- Remove unused ModeratorContext import from workflow-worker
- Update README.md

Refs #110
2026-04-25 02:48:28 +00:00
xiaoju 0fff8ef954 Merge pull request 'refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep' (#116) from refactor/109-role-step into main 2026-04-25 02:37:03 +00:00
xiaoju beada2ae09 refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep
- RoleStep now includes content and timestamp fields (aligned with StartStep)
- ModeratorContext.signal → ModeratorContext.step
- workflow-utils: start-signal.ts → start-step.ts, isDryRun updated

Fixes #109
2026-04-25 02:34:33 +00:00
xiaoju 47d23bc1a7 Merge pull request 'refactor(store): rename LogEntry.ts → LogEntry.timestamp' (#114) from refactor/113-logentry-timestamp into main 2026-04-25 02:28:38 +00:00
xiaoju 3dc835e1de refactor(store): rename LogEntry/WorkflowRun/ThreadRoundRow ts → timestamp
- Rename logs & workflow_runs table column ts → timestamp (breaking, no migration)
- Update all SQL, types, mocks, CLI output, and tests
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:24:39 +00:00
xiaoju 4da2c87a77 refactor(store): rename LogEntry.ts → LogEntry.timestamp
- Rename logs table column ts → timestamp (no migration, breaking)
- Update all SQL, type definitions, and consumers
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:08:57 +00:00
xiaoju 529cceba06 Merge pull request 'refactor(core): remove unnecessary | null, unify timestamp naming' (#112) from refactor/108-remove-null-unify-ts into main 2026-04-25 01:57:48 +00:00
xiaoju 020a1bfe85 refactor(core): remove unnecessary | null, unify timestamp naming
- SenseReflexConfig.on: string[] | null → string[] (empty = no conditions)
- NerveConfig.workflows: Record | null → Record (empty = no workflows)
- Signal.ts → Signal.timestamp
- SenseInfo.lastSignalTs → SenseInfo.lastSignalTimestamp
- All consumers across daemon/cli/store updated
- parseNerveConfig: on defaults to [], workflows defaults to {}

Fixes #108
2026-04-25 01:52:58 +00:00
xiaomo 7ce3970027 Merge pull request 'feat(cli): workspace biome.json with noConsole, remove dryRun console.log' (#107) from feat/106-workspace-biome into main 2026-04-25 01:15:53 +00:00
xiaoju fcde29ed1c feat(cli): add biome.json to workspace init, remove dryRun console.log
- init.ts: scaffold biome.json with noConsole: error for workflows
- package.json template: add @biomejs/biome to devDependencies
- workflow-utils: remove console.log from dryRun paths (stub returns
  are captured by log-store via role results)

Fixes #106
2026-04-25 01:00:41 +00:00
xiaomo 611bc48751 Merge pull request 'feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract' (#105) from feat/104-dryrun-utils into main 2026-04-25 00:26:27 +00:00
xiaoju 70bea92133 feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract
When dryRun=true, each function logs its parameters and returns a stub
result without executing any subprocess or network call. Log output is
captured by log-store for analysis.

- spawnSafe: returns { exitCode: 0, stdout: '[dryRun] skipped' }
- cursorAgent: short-circuits before spawnSafe, returns ok('[dryRun] skipped')
- llmExtract: skips fetch, returns ok({} as T)
- Tests added for spawnSafe and llmExtract dryRun paths

Fixes #104
2026-04-25 00:23:43 +00:00
xiaomo 6f2cddd695 Merge pull request 'feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal' (#103) from feat/101-dry-run into main 2026-04-24 23:50:55 +00:00
xiaoju c4dc707eb0 feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal
- StartSignal.meta gains dryRun: boolean (alongside maxRounds)
- DaemonIpcTriggerWorkflowRequest includes dryRun, parsed with default false
- CLI parses dryRun from --payload JSON, passes through daemon client
- workflow-worker/workflow-manager propagate dryRun through full IPC chain
- Sense-triggered workflows default to dryRun: false
- workflow-utils exports isDryRun(start) helper
- All tests updated, 376 pass

Fixes #101
2026-04-24 23:45:29 +00:00
xiaomo a7ce8401ce Merge pull request 'refactor(core,daemon): extract StartSignal as independent Role parameter' (#102) from refactor/100-extract-start-signal into main 2026-04-24 23:35:09 +00:00
xiaoju e9e6df2f5a refactor(core,daemon): extract StartSignal as independent Role parameter
- Role<Meta> now takes (start: StartSignal, messages: WorkflowMessage[])
- messages no longer contains the __start__ frame
- Add ModeratorContext<M> discriminated union (kind: start | step)
- Moderator receives typed context instead of raw StartSignal | RoleSignal union
- workflow-worker separates start from role messages throughout

Refs #100
2026-04-24 23:14:45 +00:00
xingyue b3b0dad2bb Merge pull request 'feat: add workflow-utils package' (#98) from feat/97-workflow-utils into main 2026-04-24 22:43:07 +00:00
xiaoju e0ce1d995c fix: readNerveYaml returns Result + path traversal guard
Address review feedback:
- Return Result<string, NerveYamlError> instead of throwing
- Add path traversal protection via resolve + startsWith check
- Export NerveYamlError type
- Update sense-generator to handle Result
2026-04-24 22:41:27 +00:00
xiaoju 0a4a2330dc feat: add workflow-utils package
Closes #97
2026-04-24 22:32:29 +00:00
xiaomo d3088c623b Merge pull request 'docs: update all README files to match actual code' (#96) from docs/95-update-readme-to-match-code into main 2026-04-24 21:49:33 +00:00
xiaoju a7e6caf6e7 docs: update all README files to match actual code
Rewrite documentation across all packages to reflect current
architecture, APIs, and CLI commands.

- README.md: fix reflex examples, add store package, update config
- core/README.md: add Sense→workflow routing, IPC types
- daemon/README.md: complete module table, crash recovery, createKernel
- cli/README.md: add workflow/sense/store subcommands
- store/README.md: new file documenting LogStore/BlobStore

Fixes #95
2026-04-24 21:47:37 +00:00
xiaomo d4dcd9722f Merge pull request 'refactor: share IPC message types between CLI and daemon' (#94) from refactor/93-shared-ipc-types into main 2026-04-24 15:14:50 +00:00
xiaoju 3082568b85 refactor(daemon): exhaustive IPC request dispatch
Ensure new DaemonIpcRequest variants require an explicit handler branch.

Made-with: Cursor
2026-04-24 15:11:58 +00:00
xiaoju 830b0aa762 refactor(core): shared daemon IPC request/response types
Move wire protocol types and parseDaemonIpcRequest into @uncaged/nerve-core so CLI and daemon share one definition. Type sendAndReceive message as DaemonIpcRequest. Align workflow trigger CLI with daemon (prompt, maxRounds from --payload JSON).

Made-with: Cursor
2026-04-24 15:10:00 +00:00
xiaoju 777d51cc73 chore: bump version to 0.4.0
小橘 🍊(NEKO Team)
2026-04-24 13:22:30 +00:00
xiaomo 06a957d62a Merge pull request 'chore: add pre-push hook to run tests before push' (#92) from chore/add-pre-push-hook into main 2026-04-24 13:19:10 +00:00
xiaoju b2c379cbfd refactor: reduce cognitive complexity in 3 functions
Extract helpers to bring all functions below biome's complexity threshold (15):
- store/log-store.ts: extract recordToRoundMessage() from parseRoundPayload()
- cli/commands/workflow.ts: extract buildTruncatedSingleRound() from buildThreadCommandOutput()
- daemon/workflow-worker.ts: extract validateRoleResult(), buildInitialLastSignal(),
  initChain(), executeRole() from runThread()

小橘 🍊(NEKO Team)
2026-04-24 12:44:39 +00:00
xiaoju 7cb7112ed6 chore: fix biome lint errors and tune overrides
- Remove duplicate 'prepare' key in package.json
- Allow default exports in rslib.config.ts
- Relax noExplicitAny and noNonNullAssertion in test files
- Auto-fix 17 files (imports, formatting)

小橘 🍊(NEKO Team)
2026-04-24 12:36:57 +00:00
xiaoju 48c81c2e19 chore: add biome lint check to pre-push hook
小橘 🍊(NEKO Team)
2026-04-24 12:32:41 +00:00
xiaoju dd3d4315c4 chore: add pre-push hook to run tests before push
Adds husky with a pre-push hook that runs `pnpm -r test` to catch
test failures before they reach the remote.

小橘 🍊(NEKO Team)
2026-04-24 12:28:47 +00:00
xingyue 788ebc6779 Merge pull request 'fix(test): align tests with type-safety refactor' (#91) from fix/test-failures-after-type-safety-refactor into main 2026-04-24 12:24:50 +00:00
xiaoju 8807b0ac6a fix(test): align tests with type-safety refactor
Update test expectations after workflow reflexes were removed from
YAML config and type signatures were tightened:

- core/config: workflow reflex tests now expect 'not supported' error
- cli/workflow: partitionWorkflowMessage test uses strict typed params
- daemon/crash-recovery: remove triggerPayload from resume-thread assertion
- daemon/daemon-ipc: trigger-workflow sends prompt+maxRounds
- daemon/kernel-workflow: use Sense-driven workflow trigger pattern

Fixes 12 test failures across core, cli, and daemon packages.

Refs #88, #89
2026-04-24 12:23:21 +00:00
xiaomo 5b65afdc4b Merge pull request 'refactor: improve type safety across codebase' (#90) from refactor/type-safety into main 2026-04-24 12:09:36 +00:00
xingyue f5cb72db50 refactor: improve type safety across codebase
- Add isPlainRecord() type guard to eliminate 'as Record<string, unknown>' casts
- Replace 'as any' with properly typed assertions in start.ts
- Remove 'null as unknown as' pattern in kernel.ts
- Add type predicates for array narrowing (item is string)
- Improve IPC message type narrowing in daemon-client.ts and ipc.ts
- Type better-sqlite3 and drizzle return values properly

No runtime behavior changes.
2026-04-24 20:07:58 +08:00
xiaomo e433e7c2a9 Merge pull request 'refactor(daemon): split kernel.ts into focused modules' (#89) from refactor/split-kernel into main 2026-04-24 11:41:44 +00:00
xingyue 47cc49eab4 refactor(daemon): split kernel.ts into focused modules (#86)
- Extract worker-pool.ts (211 LOC): sense worker fork/shutdown/restart/crash recovery
- Extract kernel-file-watch.ts (92 LOC): file change handlers for hot reload
- Extract kernel-sense-groups.ts (29 LOC): group lookup utilities
- kernel.ts reduced from 617 → 380 LOC (thin orchestrator)
- Add worker-pool.test.ts with 8 test cases
- No behavior changes, all existing tests unchanged
2026-04-24 19:39:10 +08:00
xiaomo 65012fbb53 Merge pull request 'refactor(store): extract @uncaged/nerve-store from daemon' (#88) from refactor/extract-nerve-store into main 2026-04-24 11:29:13 +00:00
xingyue 8d00f9cba1 refactor(store): extract @uncaged/nerve-store from daemon (#85)
- Create packages/store/ with log-store, log-archive, blob-store (~900 LOC)
- daemon depends on @uncaged/nerve-store (workspace:*)
- CLI depends on @uncaged/nerve-store, delete daemon-types.ts
- Move store-related tests to packages/store/src/__tests__/
- All store tests pass (73/73), no new regressions
2026-04-24 19:26:46 +08:00
xiaomo ef38b121f7 Merge pull request 'fix: PR #81 review follow-ups (closes #83)' (#84) from fix/pr81-review-followups into main 2026-04-24 11:10:45 +00:00
xiaoju 9bf0b2abb8 fix: PR #81 review follow-ups (closes #83)
- Filter __start__ messages in getThreadRoundCount SQL to fix round offset
- Remove duplicate parseWorkflowField, use parseSenseWorkflowDirective
- Remove unnecessary double casts in workflow CLI
- Add runtime validation for Role meta in workflow-worker
- Export DEFAULT_ENGINE_MAX_ROUNDS from types.ts

小橘 🍊(NEKO Team)
2026-04-24 11:09:31 +00:00
xiaomo d93f5c8fa2 Merge pull request 'refactor(core): restore type-safe workflow automaton from Pulse design' (#81) from refactor/workflow-type-safety into main 2026-04-24 11:02:23 +00:00
xiaoju fa210ec3e0 refactor: restore Pulse-style workflow type safety
- Replace loose payload types with WorkflowLaunchParams { prompt, maxRounds }
- Add SenseResult.workflow field with pipe-separated format (name|rounds|prompt)
- Add parseWorkflowField utility and routeSenseComputeOutput in @nerve/core
- Integrate sense→workflow routing in kernel
- Remove deprecated workflow reflex kind from ReflexScheduler
- Update all test files to use new type-safe interfaces

小橘 🍊(NEKO Team)
2026-04-24 10:58:48 +00:00
xiaoju f72b64d481 refactor(core): restore type-safe workflow automaton from Pulse design (closes #80) 2026-04-24 09:50:28 +00:00
xiaoju b033a98553 chore: bump version to 0.3.0
小橘 <xiaoju@shazhou.work>
2026-04-24 06:08:11 +00:00
xiaomo 68071ffa1e Merge pull request 'feat(cli): add nerve workflow thread <runId> command — closes #77' (#78) from feat/workflow-thread-77 into main 2026-04-24 06:04:32 +00:00
xiaoju f08ad802b0 fix: remove accidentally committed tgz and add to .gitignore
小橘 <xiaoju@shazhou.work>
2026-04-24 06:03:00 +00:00
xiaoju dcfb00128d feat(cli): add nerve workflow thread <runId> command — closes #77
Implements the workflow thread CLI command that retrieves
workflow execution context (logs, events, state) for a given run.

- Add 'nerve workflow thread <runId>' subcommand
- Add log-store query API in daemon
- Add tests for CLI and log-store
- Export new daemon types for thread data

小橘 <xiaoju@shazhou.work>
2026-04-24 05:59:53 +00:00
xiaomo 9cdac05f2c Merge pull request 'docs: add coding agent rules (.cursor/rules + CLAUDE.md)' (#76) from chore/cursor-rules-from-conventions into main 2026-04-23 12:12:08 +00:00
xiaomo 24a8ec927d docs: add core concepts (sense, signal, reflex, workflow) to agent rules 2026-04-23 12:05:31 +00:00
xiaomo 554a79775c docs: add .github/copilot-instructions.md for GitHub Copilot 2026-04-23 12:03:15 +00:00
xiaomo ceb5998fa3 docs: add cursor rules and CLAUDE.md from coding conventions
- .cursor/rules/global.mdc: coding conventions as Cursor agent rules
- CLAUDE.md: same conventions for Claude Code / Hermes agents
- Content derived from docs/coding-conventions.md
- Includes no-dynamic-import rule in CLAUDE.md for completeness
2026-04-23 12:01:03 +00:00
xiaomo 49b5099065 Merge pull request 'fix(daemon): accept string triggerPayload in workflow thread' (#75) from fix/trigger-payload-string-support into main 2026-04-23 11:50:48 +00:00
xiaoju 01d2185495 fix(daemon): accept string triggerPayload in workflow thread
The original code only accepted object-type triggerPayload, silently
discarding string values by replacing them with {}. This meant
`nerve workflow trigger <name> --payload '"some string"'` would
lose the payload entirely.

Changed to `triggerPayload ?? {}` so strings (and other non-null
values) pass through correctly.

小橘 🍊(NEKO Team)
2026-04-23 11:48:05 +00:00
xiaoju 5cedc6a33d release: v0.2.0 — core, daemon, cli 2026-04-23 10:58:49 +00:00
xiaomo c291d3a69a Merge pull request 'feat(cli): add nerve init --from to clone workspace from git' (#74) from feat/init-from-git into main 2026-04-23 10:56:17 +00:00
xiaomo 7960f5af8b Merge pull request 'docs: add comprehensive README for root and all packages' (#73) from docs/readme-update into main 2026-04-23 10:54:43 +00:00
xiaomo 5be14d0d8b docs: add comprehensive README for root and all packages 2026-04-23 10:53:45 +00:00
xiaoju 0e0eb4eec6 feat(cli): add nerve init --from to clone workspace from git
Made-with: Cursor
2026-04-23 10:53:06 +00:00
xiaomo cf2b0ac223 Merge pull request 'build: migrate from tsup to rslib' (#71) from build/tsup-to-rslib into main 2026-04-23 09:50:55 +00:00
xiaoju 1b5a52ea4d build: migrate from tsup to rslib
Replace tsup (esbuild-based) with rslib (rspack-based) across all packages.

tsup's built-in nodeProtocolPlugin strips the 'node:' prefix from all
Node.js builtins. Unlike node:fs etc., node:sqlite has no unprefixed
form, causing ERR_MODULE_NOT_FOUND at runtime. rslib handles node:
imports correctly without any workarounds.

Changes:
- Replace tsup.config.ts with rslib.config.ts in core, daemon, cli
- Swap tsup → @rslib/core in devDependencies
- Fix log-store.ts params type (Record<string, unknown> → Record<string, string | number>)
- Fix logStream.fd type cast in start.ts
- Exclude __tests__ from CLI tsconfig to avoid DTS errors
- All 356 tests pass, nerve init works correctly

Closes #70

小橘 🍊(NEKO Team)
2026-04-23 09:48:45 +00:00
xiaoju a084205b47 Revert "fix: restore node:sqlite prefix stripped by tsup bundler"
This reverts commit 57550ccfdb.
2026-04-23 09:41:28 +00:00
xiaoju 57550ccfdb fix: restore node:sqlite prefix stripped by tsup bundler
tsup's built-in node-protocol-plugin strips the 'node:' prefix from
all builtins. Unlike node:fs etc., node:sqlite has no unprefixed form,
causing ERR_MODULE_NOT_FOUND at runtime.

- Add onSuccess hook to both cli and daemon tsup configs to restore
  'node:sqlite' imports in bundled output
- Fix log-store params type to Record<string, string | number>

小橘 🍊(NEKO Team)
2026-04-23 09:32:20 +00:00
xiaomo 37588df402 Merge pull request 'refactor(daemon): upgrade Drizzle v1.0-beta + migrate better-sqlite3 → node:sqlite' (#69) from refactor/drizzle-v1-node-sqlite into main 2026-04-23 09:20:15 +00:00
287 changed files with 25954 additions and 6157 deletions
+187
View File
@@ -0,0 +1,187 @@
---
description: Nerve project coding conventions — style, patterns, and toolchain
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type WorkflowLaunch = {
senseName: string;
workflowName: string;
ts: number;
};
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class WorkflowLaunch implements IWorkflowLaunch { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type WorkflowConfig =
| { concurrency: number; overflow: "drop" }
| { concurrency: number; overflow: "queue"; maxQueue: number };
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
```typescript
// ✅ Named exports only
export function startEngine(config: EngineConfig): Engine { ... }
export type EngineConfig = { ... };
// ❌ No default exports
export default function startEngine() { ... }
```
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `sense-scheduler.ts` |
| Types | PascalCase | `SenseScheduler` |
| Functions/variables | camelCase | `createSenseScheduler` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseSenseConfig(raw: unknown): Result<SenseConfig> { ... }
```
## Async
- Always `async/await`, never `.then()` chains
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
biome.json # root Biome config
tsconfig.json # root TypeScript config (composite project references)
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+178
View File
@@ -0,0 +1,178 @@
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type WorkflowLaunch = {
senseName: string;
workflowName: string;
ts: number;
};
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class WorkflowLaunch implements IWorkflowLaunch { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type WorkflowConfig =
| { concurrency: number; overflow: "drop" }
| { concurrency: number; overflow: "queue"; maxQueue: number };
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `sense-scheduler.ts` |
| Types | PascalCase | `SenseScheduler` |
| Functions/variables | camelCase | `createSenseScheduler` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
## Async
- Always `async/await`, never `.then()` chains
## No Dynamic Import
Do NOT use `await import()` in production code. Always use static top-level `import`.
Exceptions (must include a comment):
1. `sense-runtime.ts` — user module paths known only at runtime
2. `workflow-worker.ts` — user module paths known only at runtime
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+2
View File
@@ -2,3 +2,5 @@ node_modules
dist
.turbo
*.tsbuildinfo
*.tgz
knowledge.db
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
pnpm check
pnpm -r test
+171
View File
@@ -0,0 +1,171 @@
# Adapter Process Isolation
Describes sandboxing, process isolation, resource limits, and timeout enforcement for adapter invocations in the Nerve workflow system.
## Process Isolation Model
Adapters run in a **two-tier isolation** model:
1. **Workflow Worker Process** — Each workflow runs in a dedicated Node.js worker process (`workflow-worker.ts`) forked from the main daemon
2. **Adapter Child Process** — Each adapter spawns CLI tools as child processes via `spawnSafe()` with `shell: false`
## Resource Limits & Timeouts
### Adapter-Level Timeouts
- **Default timeout**: 300 seconds (300,000ms) for both cursor and hermes adapters
- **Configurable** via `AgentConfig.timeout` in adapter factory functions
- **Wall-clock enforcement** using `setTimeout()` — kills child process with `SIGTERM` on timeout
- **AbortSignal support** — external cancellation triggers immediate `SIGTERM`
### Timeout Behavior
```ts
// Timeout resolution priority (packages/core/src/spawn-safe.ts):
// 1. Explicit timeoutMs value
// 2. AbortSignal presence → no internal timer (relies on external abort)
// 3. DEFAULT_TIMEOUT_MS (300_000) fallback
```
- Child process terminated with `SIGTERM` on timeout/abort
- Returns `{ kind: "timeout", stdout, stderr }` error result
- **No grace period** — immediate kill
- **No SIGKILL escalation** — relies entirely on `SIGTERM` effectiveness
#### SIGTERM Limitations
If a child process **ignores or blocks `SIGTERM`** (e.g., signal handlers, blocked delivery):
- **No fallback to `SIGKILL`** — process may remain alive indefinitely
- **No escalation timer** — spawnSafe() does not implement progressive signal escalation
- **Potential zombie/orphan risk** — unresponsive processes continue consuming resources
- **OS-level cleanup only** — relies on parent process death or OS reaping mechanisms
## Sandboxing Characteristics
### What's Isolated
- **File system**: Child process runs in specified `cwd` (workflow working directory)
- **Environment**: Controlled env vars via `nerveCommandEnv()` + optional overrides
- **Network**: No explicit restrictions (inherits parent process network access)
- **Process tree**: Child processes are direct children, not containerized
### What's NOT Sandboxed
- **No resource quotas** (CPU, memory, disk I/O limits)
- **No filesystem chroot/containers** — full filesystem access within user permissions
- **No network isolation** — can make arbitrary network calls
- **No syscall filtering** — no seccomp or similar restrictions
#### Runtime Resource Enforcement
**No active resource monitoring or constraints**:
- **No cgroups** (Linux) — no CPU, memory, or I/O limits enforced
- **No job objects** (Windows) — no resource quotas or process tree limits
- **No worker_threads resource tracking** — Node.js worker processes run unrestricted
- **Pure timeout-based enforcement** — only wall-clock time limits via `setTimeout()`
- **OS-scheduled resource sharing** — relies entirely on operating system process scheduling
Adapters can consume unlimited:
- **CPU time** (until timeout)
- **Memory** (until OOM)
- **Disk I/O** (no quotas)
- **Network bandwidth** (no throttling)
- **File descriptors** (until ulimit)
#### Environment Variable Security
The `nerveCommandEnv()` function provides **minimal sanitization**:
```ts
// spawn-safe.ts lines 47-55
export function nerveCommandEnv(): SpawnEnv {
const home = homedir();
const pnpmHome = join(home, ".local/share/pnpm");
return {
...process.env, // ← Full parent environment inherited
PNPM_HOME: pnpmHome,
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
};
}
```
- **No filtering of sensitive keys** — `NODE_OPTIONS`, `LD_PRELOAD`, `PYTHONPATH` passed through unchanged
- **Full environment inheritance** — all parent process environment variables copied
- **Injection risk** — malicious env vars (e.g., `NODE_OPTIONS=--require=evil.js`) affect Node.js child processes
- **Path manipulation** — sensitive PATH entries remain accessible to adapters
## Security Model
### Execution Context
- Uses `shell: false` to prevent shell injection attacks
- Arguments passed as separate array elements (not shell-parsed)
- PATH includes `~/.local/share/pnpm` for tool discovery
- Inherits parent process user/group permissions
#### File Descriptor Management
```ts
// spawn-safe.ts line 122
stdio: ["ignore", "pipe", "pipe"]
```
- **stdin closed**: Child receives no input (`stdio[0]: "ignore"`)
- **stdout/stderr captured**: Piped to parent for collection (`stdio[1,2]: "pipe"`)
- **No explicit fd closing**: Node.js default behavior — inherits other file descriptors
- **Parent sockets/pipes accessible**: Child can access parent's open network connections, database handles, etc.
- **Security risk**: Adapter processes may access unintended parent file descriptors
### Attack Surface
- CLI tools have **full user-level filesystem access**
- Can spawn additional processes (not tracked/limited)
- Network requests unrestricted
- Resource consumption relies on OS-level limits
## Worker Process Management
### Workflow Isolation
- Each workflow type gets dedicated worker process
- Worker processes handle multiple concurrent threads (runIds)
- Kill flags enable per-thread cancellation without killing worker
- Graceful shutdown waits up to 10 seconds for in-flight operations
#### Cross-RunId Contamination Risks
**Shared mutable state** poses contamination risks between concurrent runIds:
- **`process.env` mutations**: Environment changes affect all subsequent runIds in same worker
- **`require.cache` pollution**: Module cache shared across all runIds — side effects persist
- **Global variables**: Any global state mutations from one runId visible to others
- **`process.cwd()` changes**: Working directory changes affect entire worker process
- **File descriptors**: Open files/sockets shared between runId executions
**No runId-specific scoping** implemented:
- Worker reuses single Node.js process for efficiency
- Each role execution sees cumulative environment from previous runIds
- **Mitigation relies on adapter discipline** — clean implementations avoid global mutations
### Error Handling
- Adapter failures don't crash the worker process
- Timeout/abort errors are isolated to specific role execution
- Worker process survives adapter failures and continues serving other threads
## Configuration
```yaml
# Example nerve.yaml configuration for timeout overrides
workflows:
my-workflow:
roles:
coder:
adapter:
type: cursor
timeout: 600000 # 10 minutes in milliseconds
```
Timeout configuration happens at the adapter creation level, not as a system-wide sandbox policy.
+89
View File
@@ -0,0 +1,89 @@
# Agent Adapters (RFC-003)
Adapter = capability. Role = scenario. Workflows declare adapters directly via import.
## AgentFn Protocol
```ts
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>
```
- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity)
- Output: **single-shot `Promise<string>`** — no streaming support
- Adapter handles tool-specific details internally
### Streaming Limitations
The `AgentFn` protocol does **not** support streaming responses (`AsyncIterable<string>` or `ReadableStream`). It's strictly limited to single-shot `Promise<string>` returns.
For long-running or incremental agent outputs:
- CLI tools buffer full output until completion
- Timeout enforcement via `timeoutMs` (default 300s)
- No intermediate results exposed to workflow logic
- Progress indication happens at the CLI tool level only
## Available Adapters
| Package | Adapter | Tool |
|---------|---------|------|
| `@uncaged/nerve-adapter-cursor` | `cursorAdapter` / `createCursorAdapter()` | cursor-agent CLI |
| `@uncaged/nerve-adapter-hermes` | `hermesAdapter` / `createHermesAdapter()` | hermes chat CLI |
| `@uncaged/nerve-workflow-utils` | `createLlmAdapter(provider)` | OpenAI-compatible HTTP chat (single-turn) |
The Cursor and Hermes adapter packages each export a **default instance** (sensible defaults) and a **factory** for custom config. `createLlmAdapter` is a factory on `@uncaged/nerve-workflow-utils` only.
## createLlmAdapter
`createLlmAdapter` builds an `AgentFn` from an `LlmProvider` (`baseUrl`, `apiKey`, `model`). One chat completion per role step: **system** = the string passed by `createRole` (your prompt); **user** = `ctx.start.content` (the thread’s start frame). On failure it throws with a formatted LLM error.
```ts
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
const metaSchema = z.object({ ok: z.boolean() });
const planner = createRole(
createLlmAdapter({ baseUrl: "https://api.example.com/v1", apiKey: "…", model: "gpt-4o-mini" }),
"You are a planner…",
metaSchema,
extractConfig,
);
```
Use this when you want a role backed by an HTTP LLM instead of a subprocess CLI adapter.
## Usage in Workflows
Adapters are passed directly to `createRole`:
```ts
import { createRole } from "@uncaged/nerve-workflow-utils";
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
const coder = createRole(cursorAdapter, prompt, schema, extractConfig);
```
No registry, no config indirection. TypeScript catches missing adapters at compile time.
## Extract Layer
Parses agent raw string → typed meta. Configured in `nerve.yaml`:
```yaml
extract:
provider: dashscope
model: qwen-plus
```
Two-level merge: global → role override. Retry once on parse failure (feeds error back to LLM), then throw `ExtractError`.
## Error Handling
When adapters' underlying CLI tools (e.g., `cursor-agent` or `hermes`) fail, errors are surfaced **synchronously via rejection** with no fallback/retry logic:
- **Missing/unavailable tool**: `spawn_failed` error when CLI binary not found in `$PATH`
- **Non-zero exit code**: `non_zero_exit` error with captured stdout/stderr
- **Timeout**: `timeout` error when execution exceeds configured `timeoutMs`
- **Abort signal**: `aborted` error when `AbortSignal` triggers cancellation
All errors are immediately thrown as `Error` instances with descriptive messages (e.g., `"cursor-agent: exitCode=7 stdout=... stderr=..."`). No automatic retries or fallback adapters.
+35
View File
@@ -0,0 +1,35 @@
# Nerve Architecture
Observation engine for autonomous agents — sense the world, react to changes, run workflows.
## Core Pipeline
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
```
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Senses (prevents feedback loops).
## Two Extension Points
| Extension | Question | Nature |
|-----------|----------|--------|
| **Sense** | What to observe & when to react | `compute(state)` stateful function + YAML config (interval / on) |
| **Workflow** | What to do | Roles + Moderator |
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow by returning `{ state, workflow: { name, prompt, maxRounds, dryRun } }`.
## Process Isolation
- One worker per Sense group (long-lived)
- One worker per Workflow type (on-demand)
- Workers never talk to each other
- All user code runs in isolated Workers; kernel never loads user code directly
- **`WorkerRuntime`** (`packages/daemon/src/worker-runtime.ts`) centralizes fork lifecycle for both sense groups (`worker-pool.ts`) and workflow types (`workflow-manager.ts`); see `.knowledge/worker-isolation.md`
## Storage Systems
- **Log Store** — SQLite with WAL mode for audit trails and workflow state
- **Sense State** — JSON files per sense (`data/senses/<name>.json`), atomically written
- **Knowledge Store** — Vector search index for project context
- **Blob Store** — Content-addressable storage for large artifacts
+86
View File
@@ -0,0 +1,86 @@
# Nerve CLI
`nerve` — CLI entry point for nerve workspace management.
## Workspace Lifecycle
```bash
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
nerve init --force # reinitialize workspace even if ~/.uncaged-nerve/ exists (preserves data/)
nerve init --from <git-url> # clone existing workspace from git repository
nerve validate # validate nerve.yaml config
nerve dev # run kernel foreground (development, Ctrl+C to stop)
nerve start # start daemon (background)
nerve stop # stop daemon
nerve status # check daemon health (uptime, senses, workflows)
nerve daemon # restart daemon (stop + start)
```
### Init Behavior
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag.
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense state files and logs) but overwrites all config files.
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory.
## Sense Management
```bash
nerve create sense <name> # scaffold a new sense (compute + initialState)
nerve sense list # list configured senses
nerve sense trigger <name> # manually trigger a sense compute
```
## Workflow Management
```bash
nerve create workflow <name> # scaffold a new workflow
nerve workflow trigger <name> --prompt "..." [--max-rounds N] [--dry-run]
nerve workflow list # list configured workflows
nerve thread # list active (queued/started) workflow threads
```
## Knowledge
```bash
nerve knowledge sync # chunk files per knowledge.yaml, compute embeddings → knowledge.db
nerve knowledge query "text" # search indexed knowledge (cosine similarity)
nerve knowledge query -g "text" # global search across all indexed repos
nerve knowledge query --repo /path "text" # search specific repo
```
## Logs & Store
```bash
nerve logs # view daemon logs (last 50 lines)
nerve logs -f # follow logs (tail -f style)
nerve logs -n 200 # last N lines
nerve store archive # archive old log entries to JSONL
```
## Remote
```bash
nerve remote add <name> <url> # add a remote daemon endpoint
nerve status --remote <name> # check remote daemon health
```
## Workspace Layout
```
my-agent/
nerve.yaml # senses, workflows, extract config
knowledge.yaml # knowledge index config (optional)
senses/
cpu-usage/
src/index.ts # compute(state) + initialState export
workflows/
cleanup/
src/index.ts # workflow definition
data/
senses/ # sense state JSON files (auto-generated)
logs.db # log store (auto-generated)
knowledge.db # generated by nerve knowledge sync
.knowledge/ # curated knowledge cards
```
+88
View File
@@ -0,0 +1,88 @@
# Nerve Coding Conventions
## Functional-First
- `type` over `interface`, `function` over `class`
- No `this`, no inheritance, composition over inheritance
- Immutability first: `Readonly<T>`, `as const`
## No Optional Properties
Never use `?:`. Use `T | null` for nullable fields. Use discriminated unions for mutually exclusive fields.
```ts
// ✅ Good
type Config = { throttle: string | null }
// ❌ Bad
type Config = { throttle?: string }
```
## Error Handling
- `Result<T, E>` for expected failures
- `throw` only for programmer errors (bugs)
- No try-catch for flow control
### Result<T, E> Type
Defined in `@uncaged/nerve-core` (`packages/core/src/result.ts`):
```ts
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
**Discriminated union** with tagged `ok` field. Helper functions:
- `ok(value)``{ ok: true, value }`
- `err(error)``{ ok: false, error }`
**Exhaustive handling**: Pattern is `if (!result.ok) { handle error }` then access `result.value`.
No compiler enforcement - relies on manual discipline and TypeScript's flow control analysis.
## Naming
| Type | Style |
|------|-------|
| Files | `kebab-case.ts` |
| Types | `PascalCase` |
| Functions/vars | `camelCase` |
| Constants | `UPPER_SNAKE` |
## Exports
- Always named exports, never default
- One module = one responsibility
### Module Naming Conventions
**Primary exports** use descriptive, unambiguous names:
- Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`)
- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`)
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_ENGINE_MAX_ROUNDS`, `CURSOR_ADAPTER_DEFAULT_MS`)
**Avoiding ambiguity**:
- Package-scoped naming: `@uncaged/nerve-adapter-cursor` exports `cursorAgent`, `createCursorAdapter`
- Factory pattern: `createXxxAdapter()` for configurable instances, `xxxAdapter` for defaults
- Descriptive type prefixes prevent collision (e.g., `CursorAgentOptions` vs `HermesAgentOptions`)
## Async
- Always `async/await`, never `.then()` chains
- Use `AbortSignal` for cancellation: `AbortController` to create signals, pass to long-running operations
- `spawn-safe.ts` and adapter functions accept `abortSignal: AbortSignal | null` parameter
- On abort: child processes receive `SIGTERM`, async operations should check `signal.aborted`
- No enforced Biome/Vitest rules for AbortSignal usage (manual discipline required)
## No Dynamic Import
Static `import` only. Exceptions: `sense-runtime.ts`, `workflow-worker.ts` (runtime module paths).
## Toolchain
pnpm + TypeScript (strict) + Biome (lint/format) + Vitest (test)
```bash
pnpm run check # biome check
pnpm test # vitest
pnpm run build # full build
```
+48
View File
@@ -0,0 +1,48 @@
# Knowledge Layer (RFC-003 Phase 6)
Local-first, repo-scoped knowledge base for project context.
## Files
- `knowledge.yaml` — repo root, defines include/exclude globs
- `knowledge.db` — SQLite, stores chunks + embeddings
- `.knowledge/` — curated knowledge cards (indexed by sync)
## Commands
```bash
nerve knowledge sync # chunk files, compute embeddings, write to knowledge.db
nerve knowledge query "query" # search by cosine similarity (or word overlap fallback)
nerve knowledge query -g "query" # global search across all indexed repos
nerve knowledge query --repo /path "query" # search specific repo
```
## Embedding
- **Default model**: Dashscope text-embedding-v3 (1024 dimensions)
- **Remote service**: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- **Model configuration**: No mechanism to specify alternate models — hardcoded to text-embedding-v3 in remote service
- **Vector dimensions**: Fixed at 1024 (Float32Array, stored as 4096-byte Buffer blobs in SQLite)
- **Cache**: content-addressable (sha256 of model+text), never expires
- **Fallback**: word-overlap scoring when embed service not configured
### Configuration
The embedding model is **not configurable** through `knowledge.yaml` or other config files. The remote service at `embed.shazhou.workers.dev` uses Dashscope text-embedding-v3 exclusively. To use different models, you would need to:
1. Deploy your own embedding service compatible with the same API
2. Point `EMBED_SERVICE_URL` to your service
3. Ensure vector dimensions match (1024) or modify knowledge database schema
## Chunking
- Markdown: split by headings, large sections split further by paragraphs (max 24)
- TypeScript/JS: split by function declarations, fallback to paragraphs
- Other files: single chunk
## Env Config
```
EMBED_SERVICE_URL=https://embed.shazhou.workers.dev
EMBED_AUTH_TOKEN=<token>
```
+24
View File
@@ -0,0 +1,24 @@
# Nerve Monorepo Structure
```
nerve/
packages/
core/ # @uncaged/nerve-core — shared types, config parser, Result, spawn-safe
cli/ # @uncaged/nerve-cli — CLI (init, validate, dev, daemon, knowledge)
daemon/ # @uncaged/nerve-daemon — kernel, workers, sense scheduler, workflow manager
store/ # @uncaged/nerve-store — append-only log, SQLite, CAS blob store
workflow-utils/ # @uncaged/nerve-workflow-utils — role factories, extract, LLM helpers
adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI adapter
adapter-hermes/ # @uncaged/nerve-adapter-hermes — hermes chat CLI adapter
khala/ # Khala — Sense marketplace (future)
skills/ # nerve-managed skills
docs/ # RFCs, conventions
.knowledge/ # curated knowledge cards (this directory)
```
## Dependency Rules
- `core` is the shared layer — everyone depends on it
- `cli` and `daemon` must NOT depend on each other
- Adapter packages depend only on `core`
- `workflow-utils` depends on `core`
+67
View File
@@ -0,0 +1,67 @@
# Sense
A stateful `compute(state)` function that samples or derives external data. Returns new state and an optional workflow trigger.
## Contract
Each sense module (`src/index.ts`) must export:
```ts
type MyState = { lastRun: number | null };
export const initialState: MyState = { lastRun: null };
export async function compute(state: MyState): Promise<{
state: MyState;
workflow: WorkflowTrigger | null;
}> {
// ... observe external world, derive new state
return { state: { lastRun: Date.now() }, workflow: null };
}
```
**Function Signature:**
- `compute(state: S)` — receives previous state (or `initialState` on first run)
- Returns `{ state: S; workflow: WorkflowTrigger | null }`
- `workflow: null` → no workflow triggered
- `workflow: { name, maxRounds, prompt, dryRun }` → triggers a workflow
**State Persistence:**
- State stored as JSON at `data/senses/<name>.json`
- Read on worker startup; if missing or corrupt, `initialState` is used
- Written atomically (temp file + rename) after each successful compute
- Memory state updated only after disk write succeeds
**Error Handling:**
- Exceptions caught by worker, logged as errors (state unchanged)
- State payload must be JSON-serializable
- Invalid workflow triggers rejected by daemon (workflow not started, compute still succeeds)
**Timeout & Scheduling Semantics:**
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
- Enforced via `Promise.race()` with timeout promise
- Grace period can trigger `process.exit(1)` after timeout (kills worker group)
- Interval translation: YAML config values used directly as milliseconds in `setInterval()`
- Jitter control: throttle mechanism prevents rapid-fire, single deferred trigger per throttle window
## Config (nerve.yaml)
```yaml
senses:
cpu-usage:
group: system # senses in same group share a worker
throttle: 10s # min interval between computes
timeout: 30s # max compute duration
grace_period: 5s # wait before first compute
interval: 30s # periodic trigger (optional)
on: [disk-pressure] # trigger when another sense completes (optional)
```
## Manual Trigger Context
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute runs in the sense's worker process with:
- **State**: Read from `data/senses/<name>.json` (or `initialState` if missing)
- **Environment**: Inherits daemon process environment
- **Isolation**: Runs in forked child process (worker) with full filesystem access within user permissions
- **Persistence**: State written to JSON file after successful compute
+31
View File
@@ -0,0 +1,31 @@
# Sense compute → workflow (RFC #308)
Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`.
## Flow
```
Sense worker: compute(state) → { state, workflow }
persist state JSON (data/senses/<name>.json)
IPC compute-result → kernel
workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
```
## Workflow trigger shape
When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`:
- `name`: non-empty string
- `maxRounds`: integer ≥ 1
- `prompt`: string
- `dryRun`: boolean
Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started).
## Scheduling
Other senses list this sense under `on` in `nerve.yaml` to be scheduled when this sense completes a successful compute (see sense scheduler reverse-index in the daemon).
+131
View File
@@ -0,0 +1,131 @@
# Storage Layer
Nerve uses multiple storage systems designed for different data types and access patterns.
## Core Storage Components
### 1. Log Store (`logs.db`)
Append-only audit trail implemented in SQLite with WAL mode.
**Schema:**
- `logs` — all system events (signals, workflow transitions, sense outputs)
- `meta` — key-value store for system metadata
- `workflow_runs` — materialized view of workflow execution state
**Key Features:**
- Atomic workflow state updates via transactions
- Thread message persistence for crash recovery
- Configurable log archival to JSONL files
- Full-text search across log entries
### 2. Sense State Files
Each sense persists its state as a JSON file.
**Characteristics:**
- One JSON file per sense at `data/senses/<name>.json`
- Atomically written (temp file + rename) after each successful compute
- Read on worker startup; `initialState` used if missing or corrupt
- No cross-sense data sharing
### 3. Knowledge Store (`knowledge.db`)
Vector-enabled search index for project context.
**Contents:**
- Chunked source files with embeddings
- Curated knowledge cards from `.knowledge/`
- Semantic search capabilities
- Global vs. repo-scoped search modes
### 4. Blob Store (CAS)
Content-addressable storage for large artifacts.
**Design:**
- SHA-256 based file naming
- Automatic deduplication
- Used for workflow artifacts and large payloads
## Consistency & Isolation Mechanisms
### SQLite WAL Mode
All SQLite databases use `PRAGMA journal_mode=WAL` for:
- **Writer-reader concurrency** — readers don't block writers
- **Atomic writes** — each transaction is fully applied or rolled back
- **Crash recovery** — WAL provides consistent state after crashes
### Transaction Management
#### Log Store Transactions
Uses `BEGIN IMMEDIATE` transactions (`packages/store/src/log-store.ts`):
```typescript
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE"); // Exclusive write lock
try {
const result = fn();
db.exec("COMMIT");
return result;
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
```
**Key Operations:**
- `upsertWorkflowRun()` — atomically writes log entry + workflow state
- `archiveLogs()` — transactional export + delete + watermark update
#### Sense State Isolation
- Each sense has its own JSON state file
- No cross-sense coordination required
- State files are independent and self-contained
### Process-Level Isolation
#### Worker Process Architecture
- **One worker per sense group** — prevents data races within group
- **One worker per workflow type** — isolated execution contexts
- **No shared memory** — all communication via IPC messages
#### Concurrency Control
Workflow manager enforces limits per workflow:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "queue" # or "drop"
maxQueue: 10 # Queue depth limit
```
### Consistency Guarantees & Failure Modes
**Strong Consistency (Single Database)**:
1. **Within Log Store** — ACID transactions with immediate consistency
2. **Within Sense DB** — WAL mode ensures atomic commits per database
3. **Workflow State**`upsertWorkflowRun()` atomically updates log + materialized view
**No Cross-Database Consistency**:
- No distributed transactions across multiple SQLite files
- Log Store and Sense Databases can temporarily diverge during failures
- State persistence and workflow triggering are separate, non-atomic operations
**Failure Recovery Mechanisms**:
- **Sense worker crash**: State rebuilt from JSON state file on respawn (or `initialState` if corrupt)
- **Workflow worker crash**: Thread state recovered from log store message history
- **Kernel crash**: All workers respawned, state recovered from persistent stores
- **Log Store corruption**: WAL recovery on database open
- **Sense state corruption**: Falls back to `initialState` with stderr warning
**Rollback Scenarios**:
- **Log write failure**: Transaction rolled back, no state changes persisted
- **Sense compute failure**: Error logged, no signal/workflow emitted
- **Workflow failure**: Thread marked as failed in materialized view
- **IPC failure**: Worker respawned, pending operations lost (not rolled back)
## Archive Strategy
Logs older than retention window (default 30 days) are:
1. Exported to `data/archive/logs/YYYY-MM-DD.jsonl`
2. Deleted from active database
3. Watermark updated to prevent re-processing
This keeps the active database size bounded while preserving audit trails.
+158
View File
@@ -0,0 +1,158 @@
# Worker Isolation
Nerve's worker architecture ensures complete isolation between different types of user code while maintaining system stability.
## Process Architecture
```
Kernel (Main Process)
├── Sense Worker (Group A) ── sense-1, sense-2
├── Sense Worker (Group B) ── sense-3, sense-4
├── Workflow Worker (cleanup) ── cleanup workflow instances
└── Workflow Worker (review) ── review workflow instances
```
### WorkerRuntime (RFC-006)
Forked worker processes are managed by **`WorkerRuntime`** (`worker-runtime.ts`): one Node child per logical key, cold start, optional respawn after crash, drain/evict, and coordinated shutdown over IPC. **`worker-pool.ts`** (sense groups) and **`workflow-manager.ts`** (workflow types) both configure and delegate to `createWorkerRuntime` instead of owning ad-hoc fork logic.
Worker **entrypoints** (`sense-worker.ts`, `workflow-worker.ts`) import lightweight helpers only — e.g. `worker-signals.ts` for session broadcast signal handling — so they do not pull in the parent-side runtime module.
## Isolation Boundaries
### 1. Sense Workers
- **One worker per sense group** (configured in `nerve.yaml`)
- Groups share a child process but have isolated execution contexts
- Crash in one sense doesn't affect other groups
- Each group has its own JSON state files
### 2. Workflow Workers
- **One worker per workflow type** (spawned on-demand)
- Multiple threads of the same workflow share a worker process
- Concurrency limits enforced at the workflow level
- Workers terminate when no active threads remain
### 3. Kernel Protection
- **User code never runs in kernel process**
- All `compute()` and workflow role functions run in workers
- Kernel only handles IPC, scheduling, and coordination
- System remains stable even with infinite loops or crashes in user code
## Worker Lifecycle
### Sense Workers
```
nerve daemon start → spawn worker per group → long-lived process
→ hot reload on file changes
→ respawn on crash
```
### Workflow Workers
```
workflow trigger → check existing worker → reuse or spawn
→ execute thread
→ terminate when idle
```
## Communication Patterns
### Kernel ↔ Sense Worker
- IPC via child process stdio
- JSON-formatted messages
- Worker reports compute results back to kernel
- Bidirectional: kernel can request immediate computes
### Kernel ↔ Workflow Worker
- Similar IPC protocol
- Workflow definition loaded in worker
- Role execution results streamed back
- Thread state managed in kernel
## Resource Limits & Control
### Timeout Enforcement
Configurable timeouts per sense (in `nerve.yaml`):
```yaml
senses:
my-sense:
timeout: 30000 # Execution timeout (ms)
gracePeriod: 5000 # Grace period before hard kill
```
**Timeout Implementation:**
- `AbortController` for async operations
- `Promise.race()` between compute and timeout
- Grace period triggers `process.exit(1)` to kill entire worker group
### Memory & CPU Limits
**No Application-Level Resource Quotas**:
- No memory caps, CPU throttling, or disk I/O limits enforced by Nerve
- Workers can consume arbitrary system resources until OS limits
- No cgroup/container isolation — full filesystem access within user permissions
- No syscall filtering (no seccomp restrictions)
**OS-Level Constraints Only**:
- Process memory limited by system `ulimit -m`
- CPU usage bounded by scheduler only
- Network requests unrestricted
- Can spawn additional processes (not tracked by Nerve)
### Concurrency Control
#### Sense Workers
- One active compute per sense at a time (serialized via promise chains)
- No memory sharing between sense groups
- Crash isolation: one sense crash doesn't affect other groups
#### Workflow Workers
Per-workflow limits configured in `nerve.yaml`:
```yaml
workflows:
my-workflow:
concurrency: 2 # Max parallel threads
overflow: "drop" # or "queue"
maxQueue: 10 # Queue size limit
```
### Process Management
#### Signal Handling
Workers ignore session broadcast signals (SIGINT/SIGTERM) via `ignoreSessionBroadcastSignals()` in `worker-signals.ts`:
```typescript
// Workers ignore terminal signals; kernel coordinates shutdown
process.on("SIGINT", () => {});
process.on("SIGTERM", () => {});
```
#### Graceful Shutdown & State Handoff
**Sense Workers**:
- IPC `shutdown` message → `process.exit(0)` (immediate)
- No graceful termination period for senses
- State rebuilt from JSON state files on respawn (no handoff needed)
**Workflow Workers**:
- IPC `shutdown` → wait for in-flight threads to complete
- Drain timeout: `WORKER_SHUTDOWN_TIMEOUT_MS` (10s)
- If threads don't complete → `SIGKILL` force termination
- Thread state preserved in log store for crash recovery
**State Handoff Mechanism**:
- No explicit state transfer between old/new workers
- Sense workers: JSON state files contain full state
- Workflow workers: Log store contains thread message history
- Kernel coordinates recovery via `recoverThreadsForWorker()`
## Failure Handling
### Worker Crashes
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from JSON
- **Workflow workers**: Crash recovery from log store thread messages
- **Kernel protection**: Main process continues, marks affected runs as crashed
- **Crash limits**: Max 5 crashes per workflow in 60s window (prevents infinite respawn)
### Resource Exhaustion
- **Memory**: Worker process killed by OS, kernel respawns automatically
- **Compute timeout**: Grace period → hard kill → respawn
- **Infinite loops**: Timeout enforcement prevents hanging indefinitely
This architecture allows Nerve to run untrusted or experimental code safely while maintaining system availability.
+136
View File
@@ -0,0 +1,136 @@
# Workflow Engine
Stateful multi-step execution driven by Roles and a Moderator.
## Workspace Layout (authoring)
User Nerve workspaces use a **flat** build: one root `package.json`, one root bundle script (typically `scripts/build.mjs` wired from `scripts.build`), and **no** per-workflow `package.json` or `tsconfig.json`.
| Location | Purpose |
|----------|---------|
| `workflows/<name>/index.ts` | Default export: `WorkflowDefinition` (moderator + role map). |
| `workflows/<name>/roles/<role>.ts` | One module per role — schemas, prompts, `createRole` factories, or hand-written async role functions. |
| `dist/workflows/<name>/index.js` | Emit of the root build; this is what the daemon loads. |
**Naming:** Workflow ids should be **verb-first** kebab-case phrases (e.g. `deploy-staging`, `scan-dependencies`), not opaque nouns alone.
Senses follow the same flat pattern: `senses/<name>/src/*.ts`, `migrations/`, root build → `dist/senses/<name>/index.js`. See `.knowledge/sense.md`.
## Core Concepts
- **Workflow** — definition with concurrency strategy
- **Thread** — one execution instance, unique `runId`
- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise<RoleResult<M>>`
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
## Thread Lifecycle
```
trigger → queued → started → step_complete ↺ → completed
failed / crashed
```
## Concurrency Config (nerve.yaml)
```yaml
workflows:
cleanup:
concurrency: 1
overflow: drop # discard if already running
code-review:
concurrency: 3
overflow: queue
max_queue: 20 # queue limit, oldest discarded
```
## createRole Helper
`createRole` builds a `Role<M>` from an adapter, prompt, Zod schema, and extract config:
```ts
import { createRole } from "@uncaged/nerve-workflow-utils";
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { z } from "zod";
const coderSchema = z.object({ plan: z.string(), files: z.array(z.string()) });
const coder = createRole(cursorAdapter, coderPrompt, coderSchema, {
provider: { baseUrl: "...", apiKey: "...", model: "qwen-plus" },
});
// Use in WorkflowDefinition
const workflow: WorkflowDefinition<MyMeta> = {
name: "develop",
roles: { coder, reviewer },
moderator,
};
```
- `adapter: AgentFn` — direct function reference
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
- `extract: LlmExtractorConfig` — provider for structured extraction
## Runtime Enforcement Mechanisms
### Role Authority & Validation
**Role Function Lookup**:
- Roles accessed via `def.roles[nextRole]` dictionary lookup
- Unknown roles trigger immediate workflow error (`Unknown role: ${nextRole}`)
- No dynamic role registration during execution
**Result Validation** (`validateRoleResult()`):
```typescript
// Required return shape from every role function
{ content: string, meta: Record<string, unknown> }
```
- `content` must be string (non-string → workflow error)
- `meta` must be plain object (array/null/primitive → workflow error)
- Validation failure terminates thread immediately
### Moderator Authority & Routing Control
**Next Role Selection**:
- Moderator must return role name from `roles` keys OR `END` symbol
- Called after every role completion (receives full context)
- No validation of role name until execution attempt
- Pure function constraint: cannot perform side effects
**Causal Chain Integrity**:
- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }`
- Steps array contains ALL role outputs in chronological order
- No role can modify prior steps or start metadata
- Thread context built from log store on crash recovery
### Unauthorized Command Event Prevention
**Message Flow Control**:
- Role functions have NO direct access to kernel IPC
- All outputs flow through `sendWorkflowMessage()` wrapper
- Worker process validates messages before kernel transmission
- No direct log store database access from roles
**Process Isolation**:
- Roles execute in forked worker processes (not kernel)
- File system access limited to user permissions
- No network isolation (roles can make arbitrary HTTP calls)
- Worker has read/write access to workflow workspace only
### Concurrent Thread Management
**Kill Flag Implementation**:
```typescript
type KillFlag = { value: boolean };
// Checked before role execution and after completion
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
```
**Concurrency Enforcement**:
- Workflow manager enforces per-workflow limits in kernel
- Excess threads queued/dropped per overflow policy
- No role can spawn additional threads (no access to workflow manager)
+224
View File
@@ -0,0 +1,224 @@
# Nerve Coding Conventions
## Core Concepts
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
↑ ↑
"what to observe" "what to do"
```
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling configured in nerve.yaml. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Two extension points**: Sense (what to observe + when), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
### Sense State Persistence
Each sense's state is persisted as a JSON file at `data/senses/<name>.json` (relative to the nerve root, typically `~/.uncaged-nerve/`).
| Event | Behavior |
|-------|----------|
| **Worker start** | Read `state.json`; if missing or corrupt, use `initialState` from the sense module |
| **Compute success** | Write new state atomically (write-temp + rename), then update in-memory state |
| **Compute failure** | State unchanged (both disk and memory) |
| **Daemon restart** | State restored from last successful write |
State files are written atomically (temp file + rename) to prevent corruption on crash.
## Language & Paradigm
### Functional-first
Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type WorkflowLaunch = {
senseName: string;
workflowName: string;
ts: number;
};
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class WorkflowLaunch implements IWorkflowLaunch { ... }
```
### Rules
| Rule | Description |
|------|-------------|
| `type` over `interface` | All type definitions use `type` |
| `function` over `class` | Pure functions + closures, no class |
| No `this` | Functions must not depend on `this` context |
| No inheritance | No `extends`, `implements`, `abstract` |
| Composition over inheritance | Use function composition |
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
| No optional properties | Use `T \| null` instead of `?:` — see below |
### Exceptions
Classes are allowed when:
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
- Error subclasses (`class NerveError extends Error`)
### No Optional Properties
Never use `?:`. All nullable fields must be explicit `T | null`.
```typescript
// ✅ Good
type SenseConfig = {
group: string;
throttle: string | null;
timeout: string | null;
};
// ❌ Bad
type SenseConfig = {
group: string;
throttle?: string;
timeout?: string;
};
```
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good — sense modules return explicit next state + optional workflow trigger
type SenseComputeReturn<S> = {
state: S;
workflow: WorkflowTrigger | null;
};
```
### Workflow Naming
Workflow identifiers — `WorkflowDefinition.name`, the directory under `workflows/`, and keys under `workflows:` in `nerve.yaml` — must use **verb-first** kebab-case phrases so the name reads as an action.
-`solve-issue`, `extract-knowledge`, `develop-sense`
-`knowledge-extraction`, `issue-solver`
### Workflow authoring (user modules)
Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays.
```typescript
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
type MyMeta = { round: number };
async function planner(ctx: ThreadContext): Promise<RoleResult<MyMeta>> {
void ctx.start;
void ctx.steps;
return { content: "plan", meta: { round: ctx.steps.length } };
}
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
name: "example",
roles: { planner },
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
return ctx.steps.length === 0 ? "planner" : END;
},
};
```
## Modules & Exports
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
## Naming
| Type | Style | Example |
|------|-------|---------|
| Files | kebab-case | `sense-scheduler.ts` |
| Types | PascalCase | `SenseScheduler` |
| Functions/variables | camelCase | `createSenseScheduler` |
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| Generics | Single letter or descriptive | `T`, `TValue` |
## Error Handling
- Use `Result` type for expected failures
- `throw` only for unrecoverable bugs (programmer errors)
- No try-catch for flow control
```typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
## Async
- Always `async/await`, never `.then()` chains
## No Dynamic Import
Do NOT use `await import()` in production code. Always use static top-level `import`.
Exceptions (must include a comment):
1. `sense-runtime.ts` — user module paths known only at runtime
2. `workflow-worker.ts` — user module paths known only at runtime
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **tsup** | Bundling |
### Commands
```bash
pnpm run check # biome check (lint + format)
pnpm run format # biome format --write
pnpm run build # full build
pnpm test # run tests
```
## Monorepo Structure
```
nerve/
packages/
core/ # @nerve/core — shared types and utils
cli/ # @nerve/cli — CLI entry point
daemon/ # @nerve/daemon — engine runtime
docs/ # RFCs, conventions
```
- `core` is the shared layer; `cli` and `daemon` both depend on it
- `cli` and `daemon` must NOT depend on each other
## Commit Convention
```
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: core | cli | daemon | rfc-001 | ...
```
+196 -1
View File
@@ -1,3 +1,198 @@
# nerve
Observation engine — Sense, Reflex, Workflow
**Observation engine for autonomous agents**sense the world, react to changes, run workflows.
Nerve is a lightweight daemon that continuously observes external state through **Senses** (stateful `compute(state)` + JSON persistence), schedules them via **`interval` / `on`** in `nerve.yaml`, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
## Core Concepts
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
scheduling: interval / on (per sense in nerve.yaml)
```
| Concept | Metaphor | Role |
|---------|----------|------|
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, workflow }`. State lives in `data/senses/<name>.json`. |
| **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). |
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started when `workflow` is non-null in the compute result, or via CLI/daemon IPC. |
| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). |
**Sense → Workflow:** when `workflow` is a structured object `{ name, maxRounds, prompt, dryRun }`, the kernel validates it (`@uncaged/nerve-core` `parseWorkflowTrigger`) and starts that workflow. Use `workflow: null` when no run should start.
Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section.
## Packages
| Package | Description |
|---------|-------------|
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, workflow trigger validation, daemon IPC protocol |
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, sense workers, sense scheduler, workflow manager, file watcher, IPC |
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
## Quick Start
```bash
# Requirements: Node.js ≥ 22.5, pnpm
pnpm add -g @uncaged/nerve-cli
# Initialize a workspace
mkdir my-agent && cd my-agent
nerve init
# Write a sense (see `nerve init` for the full template)
mkdir -p senses/cpu-usage/src
cat > senses/cpu-usage/src/index.ts << 'EOF'
import { loadavg } from "node:os";
type CpuState = { lastLoad: number };
export const initialState: CpuState = { lastLoad: 0 };
export async function compute(state: CpuState) {
const [oneMin] = loadavg();
const lastLoad = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
return { state: { lastLoad }, workflow: null };
}
EOF
# Configure scheduling on each sense in nerve.yaml
cat > nerve.yaml << 'EOF'
senses:
cpu-usage:
group: system
throttle: 10s
interval: 30s
EOF
# Run
nerve dev # foreground (development)
nerve daemon start # background (production)
nerve status # check health
nerve logs # view logs
```
## Configuration
`nerve.yaml` declares senses (each with optional `interval` / `on`), optional workflows (concurrency), and optional engine `max_rounds`. Top-level `reflexes` is **not** supported — use `interval` and `on` on each sense.
```yaml
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
senses:
cpu-usage:
group: system # senses in the same group share a worker process
throttle: 10s # min interval between computes
timeout: 30s # max compute duration
grace_period: 5s # wait before first compute after startup
interval: 30s # periodic trigger
derived-example:
group: system
throttle: null
timeout: 30s
grace_period: null
on:
- cpu-usage # run after cpu-usage completes a compute
workflows:
cleanup:
concurrency: 1
overflow: drop # discard if already running
code-review:
concurrency: 3
overflow: queue
max_queue: 20
```
Declare workflows under `workflows:` and start them from Sense `compute()` (non-null `workflow`) or `nerve workflow trigger`.
**Example — Sense starts a workflow** (`senses/disk-pressure/src/index.ts`):
```typescript
export const initialState = { checked: false };
export async function compute(state: typeof initialState) {
const full = await diskNearlyFull();
if (!full) return { state: { ...state, checked: true }, workflow: null };
return {
state: { ...state, checked: true },
workflow: {
name: "cleanup",
maxRounds: 10,
prompt: "Disk partition nearly full",
dryRun: false,
},
};
}
```
## Architecture
```
┌────────────────────────────────────────────────────────────────────────┐
│ Kernel │
│ │
│ ┌──────────────┐ watches nerve.yaml / senses / workflows │
│ │ File Watcher ├──────────────────────────────────────────┐ │
│ └──────────────┘ │ │
│ ┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
│ └──────┬───────┘ ▼ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ Worker │ │ Worker │ │ Worker │ (1 per│
│ │ │ (group A)│ │ (group B)│ │ (group C)│ group) │
│ │ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
│ │ │ sense-2 │ │ sense-4 │ │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ │ └──────────────┼──────────────┘ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │Sense Scheduler │
│ │ │(interval + on) │
│ │ └──────┬───────┘ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
│ └───────────────────┘ (logs.db, …) │
└────────────────────────────────────────────────────────────────────────┘
```
- **Worker pool** — one child process per sense group; isolation between groups.
- **Sense scheduler** — interval timers + `on` reverse-index (upstream sense → dependents), with throttle/coalesce.
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
- **File watcher** — hot reload for config, sense modules, and workflow modules.
- **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running.
- **Log / blob storage** — implemented in `@uncaged/nerve-store` (WAL SQLite, JSONL archive, CAS blobs).
## Tech Stack
- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync) for logs / workflow persistence via `@uncaged/nerve-store`
- **Sense state as JSON** — files under `data/senses/` written by sense workers
- **rslib** (rspack) for building
- **Biome** for formatting/linting
- **Vitest** for testing
- **pnpm** workspaces for monorepo management
## Development
```bash
git clone https://git.shazhou.work/uncaged/nerve.git
cd nerve
pnpm install
pnpm build
pnpm -r test # run all tests
```
## Design Documents
- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — historical sense / scheduling model (superseded in places by stateful senses — see `CLAUDE.md`)
- [RFC-002: Workflow Engine](./docs/rfc-002-workflow-engine.md) — Stateful workflow execution
- [Coding Conventions](./docs/coding-conventions.md)
## License
MIT
+14 -1
View File
@@ -19,7 +19,7 @@
},
"overrides": [
{
"include": ["tsup.config.ts"],
"include": ["tsup.config.ts", "*/rslib.config.ts", "packages/khala/src/index.ts"],
"linter": {
"rules": {
"style": {
@@ -27,6 +27,19 @@
}
}
}
},
{
"include": ["**/__tests__/**"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
}
}
],
"linter": {
+26 -23
View File
@@ -8,25 +8,29 @@
```typescript
// ✅ Good
type Signal = {
senseId: string
value: unknown
type WorkflowLaunch = {
senseName: string
workflowName: string
ts: number
}
function createSignal(senseId: string, value: unknown): Signal {
return { senseId, value, ts: Date.now() }
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() }
}
// ❌ Bad
interface ISignal {
senseId: string
value: unknown
interface IWorkflowLaunch {
senseName: string
workflowName: string
ts: number
}
class Signal implements ISignal {
constructor(public senseId: string, public value: unknown, public ts: number) {}
class WorkflowLaunch implements IWorkflowLaunch {
constructor(
public senseName: string,
public workflowName: string,
public ts: number,
) {}
}
```
@@ -65,17 +69,16 @@ type SenseConfig = {
当多个字段互斥时,用 discriminated union 代替一堆 optional:
```typescript
// ✅ Good — 编译器保证 sense 和 workflow 不会同时出现
type ReflexConfig =
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
| { kind: "workflow"; workflow: string; on: string[] | null }
// ✅ Good — 编译器保证两种 overflow 形态互斥且字段完整
type WorkflowConfig =
| { concurrency: number; overflow: "drop" }
| { concurrency: number; overflow: "queue"; maxQueue: number }
// ❌ Bad — sense 和 workflow 都 optional,运行时才知道到底填了哪
type ReflexConfig = {
sense?: string;
workflow?: string;
interval?: string;
on?: string[];
// ❌ Bad — 字段一堆 optional,运行时才知道到底填了哪种并发策略
type WorkflowConfig = {
concurrency?: number;
overflow?: string;
maxQueue?: number;
}
```
@@ -103,9 +106,9 @@ export default function startEngine() { ... }
| 类型 | 风格 | 示例 |
|------|------|------|
| 文件 | kebab-case | `signal-bus.ts` |
| 类型 | PascalCase | `SignalBus` |
| 函数/变量 | camelCase | `createSignalBus` |
| 文件 | kebab-case | `sense-scheduler.ts` |
| 类型 | PascalCase | `SenseScheduler` |
| 函数/变量 | camelCase | `createSenseScheduler` |
| 常量 | UPPER_SNAKE | `MAX_RETRY_COUNT` |
| 泛型参数 | 单字母或描述性 | `T`, `TValue` |
+146
View File
@@ -0,0 +1,146 @@
# Nerve 单仓死代码分析报告
**分析日期**: 2026-04-30
**范围**: `packages/core`, `daemon`, `store`, `cli`, `khala`, `workflow-utils`, `workflow-meta`, `adapter-cursor`, `adapter-hermes` 的 TypeScript 源码与 `package.json` 依赖。
**方法**: 全仓 `ripgrep` 交叉验证 `import` / `export` 路径;未运行 Knip/TS 死代码专项工具。
**说明**: 未包含 `role-reviewer` / `role-committer` / `skills` 等包(你列出的范围外),但 `workflow-meta` 对其中部分有依赖,相关结论已注明。
---
## 方法限制(读前必读)
| 限制 | 影响 |
|------|------|
| **动态 `import(url)`** | `cli``loadDaemonModule` 在运行时加载 `@uncaged/nerve-daemon`,静态分析无法把 `createKernel` 等映射到具体导出。 |
| **已发布包的公共 API** | 大量导出仅被仓外消费者使用;本报告中的「仓内未引用」≠「应删除」。 |
| **构建入口** | Rslib 多入口(如 `sense-worker``workflow-worker``daemon-bootstrap`)视为存活入口,不作为孤儿文件。 |
| **内部未导出函数** | 未做逐函数调用图;「死函数」仅列出高置信个例。 |
---
## 1. 未使用导出(仓内无 `import`)
以下符号在 **monorepo 内** 没有任何文件从 `@uncaged/nerve-core` / `@uncaged/nerve-workflow-utils` / `@uncaged/nerve-daemon` 等包根导出处引用(测试与定义自身除外)。
### 1.1 `@uncaged/nerve-core`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/core/src/index.ts``agent.js` | `KNOWN_AGENT_ADAPTER_IDS` | **高** | **调查**:若 CLI/校验应约束 adapter id,可接入;否则可从公共 API 移除或保留作文档性常量并注明。 |
| `packages/core/src/index.ts``sense.js` | `labelSenseTrigger``senseTriggerLabels``cli`/`daemon` 有使用) | **高(相对仓内)** | **保留或收敛导出**:仅 `senseTriggerLabels` 对外即可;`labelSenseTrigger` 可作为内部函数若不需要单独暴露。 |
| `packages/core/src/index.ts``util.js` | `parseDurationStringToMs`(仅 `config.ts` 内部使用) | **高** | **调查**:无需在 `index` 再导出则改为非导出或移除 re-export,减少 API 面。 |
| `packages/core/src/index.ts``sense.js` | 类型 `SenseModule` | **中** | **保留**:用户文档/外部 Sense 模块常用;仓内未按名引用属正常。 |
| `packages/core/src/index.ts``config.js` | 单独导出的类型 `NerveApiConfig`(仅 `config.ts``index` 出现) | **低** | **保留**:通常随 `NerveConfig` 一并使用;单独 export 多为 TS 公共类型便利。 |
### 1.2 `@uncaged/nerve-workflow-utils`
下列项在 **`packages/*/src/**/*.ts` 中无人从包入口导入**(`workflow-meta``role-committer` 等主要只用 `createRole``decorateRole``onFail``withDryRun``LlmExtractorConfig`)。
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/workflow-utils/src/index.ts` | `createLlmAdapter` | **高** | **保留(对外 API)** 或标注文档;仓内无调用。 |
| 同上 | `llmExtract``llmExtractWithRetry` | **高** | **保留**:高级用法 / 文档示例;内部与测试使用。 |
| 同上 | `mergeExtractConfig``ExtractConfigLayer` | **高** | **调查**:若 RFC 分层配置仍在推进则保留;否则评估移除导出。 |
| 同上 | `assertZodMetaSchemas``createLlmExtractFn``extractMetaOrThrow``zodMeta``ZodMetaSchema` | **高** | **保留(对外 API)**;当前仅 `workflow-utils` 测试与内部 `create-role` 链路使用 `extractMetaOrThrow`。 |
| 同上 | `readNerveYaml``nerveAgentContext` 及相关类型 | **高** | **调查**:若已无 YAML 上下文注入场景可删导出;否则保留给 Agent 工具链。 |
| 同上 | `spawnSafe``nerveCommandEnv``Spawn*` 类型(从 core 再导出) | **高** | **删除再导出或改为文档链接**:仓内均直接从 `@uncaged/nerve-core` 引用 spawn。 |
| 同上 | `isDryRun` | **高** | **删除或实现**:见 §3「死函数」。 |
| 同上 | `LlmMessage``MetaExtractConfig``LlmChatError``LlmError``LlmProvider` 等类型再导出 | **中** | **保留**:供外部精细类型标注;仓内未单独 import。 |
### 1.3 `@uncaged/nerve-daemon`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/daemon/src/index.ts``agent-adapters/echo.js` | `createEchoAgent` | **高** | **调查**:无任何测试或运行时引用;若设计保留 `type: "echo"` 适配器,应在 workflow/agent 装载路径接线或删除导出与文件。 |
| `packages/daemon/src/index.ts`(及其它导出) | 多数 IPC / runtime 符号 | **低** | **保留**:动态加载与外部集成无法静态判定;仅 `createEchoAgent` 可确定为当前静态图下的死角。 |
### 1.4 `@uncaged/nerve-adapter-cursor` / `@uncaged/nerve-adapter-hermes`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/adapter-cursor/src/index.ts` | `cursorAdapter``createCursorAdapter`(以及 `cursorAgent` 仅被 `workflow-utils/src/role-cursor.ts` 使用) | **中** | **保留**:默认实例与工厂供下游与工作流仓使用;仓内业务包未直接 import `cursorAdapter` 属预期。 |
| `packages/adapter-hermes/src/index.ts` | `hermesAdapter``createHermesAdapter``hermesAgent``workflow-utils` 间接使用) | **中** | 同上。 |
### 1.5 `@uncaged/nerve-cli`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/cli/src/index.ts` | `getNerveRoot``loadDaemonModule` 等程序化导出 | **高(仓内)** | **保留**:无任何 workspace 包引用 `@uncaged/nerve-cli`;面向 CLI 二次开发者。 |
---
## 2. 孤儿文件(非入口、未被其它源码引用)
在当前 Rslib / `cli` 入口定义下,**未发现**「零 import、且非 entry / 非测试」的 `.ts` 业务文件:
- `workflow-utils` 下的 `role-cursor.ts``role-hermes.ts``role-llm.ts``role-react.ts` 虽**未出现在包 `index.ts`**,但被 `packages/workflow-utils/src/__tests__/role-factories.test.ts` 直接引用,**不是孤儿文件**。
- `daemon``sense-worker.ts``workflow-worker.ts` 等为 **显式构建入口**
**置信度**: 对全表扫描为 **中**(未跑依赖图工具;动态路径可能掩盖极少数脚本引用)。
---
## 3. 死函数(内部未导出且未被调用)
| 路径 | 说明 | 置信度 | 建议 |
|------|------|--------|------|
| `packages/workflow-utils/src/role-types.ts` | `isDryRun(_start: StartStep)`:导出在 `index.ts`,但全仓无调用(函数体恒返回 `false` 且标注 deprecated) | **高** | **删除**或改为内部常量;若需向后兼容则保留并文档标注「保留占位」。 |
其它内部函数未系统逐文件枚举。
---
## 4. 未使用的 npm 依赖(package.json)
在声明范围内通过源码 `import` 抽查:**未发现明显「完全未使用」的生产依赖**。
| 包 | 依赖 | 结论 |
|----|------|------|
| `cli` | `citty``yaml``picomatch` | 均有对应源码引用。 |
| `khala` | `hono``jsonata``ulidx``@uncaged/nerve-core` | 均有引用。 |
| `core` / `daemon` | `yaml``drizzle-orm` | 均有引用。 |
| `workflow-utils` / `workflow-meta` | `zod`、workspace 包 | 均有引用。 |
**置信度**: **中**(未对 peer、可选路径、CLI `bin` 专用依赖做自动化扫描)。
---
## 5. 陈旧测试夹具 / 未引用辅助文件
未发现独立的「fixture 目录」明显失联;`cli``e2e-harness``__tests__` 内 helper 均有对应测试引用。
**置信度**: **低**(未枚举每个 `__tests__` 资源文件)。
---
## 6. 重构遗留 / 文档漂移
| 项目 | 位置 | 说明 | 置信度 | 建议 |
|------|------|------|--------|------|
| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseWorkflowTrigger` 对齐;`routeSenseComputeOutput` 已移除 | — | 关闭 |
| Hermes 选项合并注释 | `packages/workflow-utils/src/shared/hermes-agent.ts` | 注释称 absorbed from `hermes-options.ts`,该文件已不存在 | **中** | **清理注释**,避免误导。 |
| `KNOWN_AGENT_ADAPTER_IDS``codex` | `packages/core/src/agent.ts` | 仓内无 `codex` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
未发现用户提到的 `worker-fork-support` 等字符串副本(全仓无匹配)。
---
## 7. 汇总建议优先级
1. **高优先级调查**: `createEchoAgent``KNOWN_AGENT_ADAPTER_IDS` — 要么接入运行时,要么删减以免维护假象。
2. **API 面收敛**: `parseDurationStringToMs``labelSenseTrigger` 若无意对外,可从 `core` 公共导出移除。
3. **`workflow-utils`**: 评估 `isDryRun` 删除;`spawnSafe` 等从 `workflow-utils` 再导出是否仍有必要。
4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成)
---
## 8. 重新验证命令示例
后续可在本地采用工具增强置信度(非本次执行):
```bash
pnpm add -D -w knip
pnpm exec knip
```
或在各包对 `@uncaged/nerve-*` 的导出做面向消费者的契约测试,避免误删对外 API。
+558
View File
@@ -0,0 +1,558 @@
# Khala MVP Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Build Khala — a Cloudflare Workers + D1 + Durable Objects cloud workflow orchestrator that lets agents coordinate multi-agent workflows as a stateless worker pool.
**Architecture:** Khala is a CF Worker that receives events from agents via REST API. Each workflow thread runs in a Durable Object with a JSONata moderator. Agents poll a task queue for unclaimed turns, execute locally, and POST results back. Thread messages are stored in D1.
**Tech Stack:** Cloudflare Workers, D1 (SQLite), Durable Objects, Hono (routing), JSONata (moderator engine), TypeScript
---
## Phase 0: Project Scaffolding
### Task 0.1: Create khala package
**Objective:** Set up the `packages/khala` CF Worker project with wrangler, Hono, and D1 binding.
**Files:**
- Create: `packages/khala/package.json`
- Create: `packages/khala/wrangler.toml`
- Create: `packages/khala/tsconfig.json`
- Create: `packages/khala/src/index.ts`
**Step 1: Create package.json**
```json
{
"name": "@uncaged/khala",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run"
},
"dependencies": {
"hono": "^4.7.0",
"jsonata": "^2.0.5"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"vitest": "^4.1.5",
"wrangler": "^4.14.0"
}
}
```
**Step 2: Create wrangler.toml**
```toml
name = "khala"
main = "src/index.ts"
compatibility_date = "2025-04-01"
[[d1_databases]]
binding = "DB"
database_name = "khala"
database_id = "placeholder"
[durable_objects]
bindings = [
{ name = "THREAD", class_name = "ThreadDO" }
]
[[migrations]]
tag = "v1"
new_classes = ["ThreadDO"]
```
**Step 3: Create minimal Hono entrypoint**
```typescript
// src/index.ts
import { Hono } from "hono";
export type Env = {
DB: D1Database;
THREAD: DurableObjectNamespace;
};
const app = new Hono<{ Bindings: Env }>();
app.get("/health", (c) => c.json({ ok: true }));
export default app;
```
**Step 4: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
}
```
**Step 5: Install dependencies and verify**
```bash
cd packages/khala && pnpm install
pnpm exec wrangler types # generates worker-configuration.d.ts
```
**Step 6: Commit**
```bash
git add packages/khala/
git commit -m "chore(khala): scaffold CF Worker package"
```
---
## Phase 1: D1 Schema & Data Layer
### Task 1.1: Create D1 migration — core tables
**Objective:** Define D1 schema for agents, threads, messages, and task queue.
**Files:**
- Create: `packages/khala/migrations/0001_initial.sql`
**SQL:**
```sql
-- Agent registry
CREATE TABLE agents (
id TEXT PRIMARY KEY, -- agent name (e.g. "tuanzi")
token_hash TEXT NOT NULL, -- bcrypt/sha256 hash of agent token
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Workflow threads
CREATE TABLE threads (
id TEXT PRIMARY KEY, -- ulid
workflow TEXT NOT NULL, -- workflow name (e.g. "code-review")
status TEXT NOT NULL DEFAULT 'active', -- active | completed | failed
initiator TEXT NOT NULL, -- agent id or external caller
result TEXT, -- final result JSON (set on completion)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Thread messages (append-only)
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id TEXT NOT NULL REFERENCES threads(id),
role TEXT NOT NULL, -- role name or "__moderator__"
content TEXT NOT NULL,
meta TEXT, -- JSON
step INTEGER NOT NULL, -- 0-indexed step number
agent_id TEXT, -- which agent executed this turn
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_messages_thread ON messages(thread_id, step);
-- Task queue
CREATE TABLE tasks (
id TEXT PRIMARY KEY, -- ulid
thread_id TEXT NOT NULL REFERENCES threads(id),
role TEXT NOT NULL,
instruction TEXT NOT NULL, -- turn instruction from moderator
status TEXT NOT NULL DEFAULT 'open', -- open | claimed | completed | expired
claim_id TEXT, -- set when claimed
claimed_by TEXT, -- agent id
claimed_at TEXT,
timeout_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_tasks_status ON tasks(status, created_at);
```
**Step 1: Write the migration file**
**Step 2: Apply locally**
```bash
cd packages/khala
pnpm exec wrangler d1 migrations apply khala --local
```
**Step 3: Commit**
```bash
git add packages/khala/migrations/
git commit -m "feat(khala): D1 schema — agents, threads, messages, tasks"
```
### Task 1.2: Create data access functions
**Objective:** Type-safe D1 query functions for all tables.
**Files:**
- Create: `packages/khala/src/db.ts`
- Create: `packages/khala/src/types.ts`
**types.ts:**
```typescript
export type Agent = {
id: string;
token_hash: string;
created_at: string;
};
export type Thread = {
id: string;
workflow: string;
status: "active" | "completed" | "failed";
initiator: string;
result: string | null;
created_at: string;
updated_at: string;
};
export type Message = {
id: number;
thread_id: string;
role: string;
content: string;
meta: string | null;
step: number;
agent_id: string | null;
created_at: string;
};
export type Task = {
id: string;
thread_id: string;
role: string;
instruction: string;
status: "open" | "claimed" | "completed" | "expired";
claim_id: string | null;
claimed_by: string | null;
claimed_at: string | null;
timeout_seconds: number;
created_at: string;
};
```
**db.ts:** Query functions — `createThread`, `appendMessage`, `createTask`, `claimTask`, `completeTask`, `getOpenTasks`, `getThreadMessages`, etc. Each is a plain function taking `D1Database` as first arg.
**Step 1: Write types.ts**
**Step 2: Write db.ts with all query functions**
Key functions:
- `createThread(db, workflow, initiator) → Thread`
- `appendMessage(db, threadId, role, content, meta, step, agentId) → Message`
- `createTask(db, threadId, role, instruction, timeoutSeconds) → Task`
- `claimTask(db, taskId, agentId) → { ok: true, claimId } | { ok: false }`
- `completeTask(db, taskId, claimId) → boolean`
- `expireTimedOutTasks(db) → number` (count expired)
- `getOpenTasks(db, limit) → Task[]`
- `getThreadMessages(db, threadId, opts?) → Message[]` (opts: role, since, step, last)
Use `ulid()` for IDs (add `ulidx` dependency).
**Step 3: Commit**
```bash
git add packages/khala/src/
git commit -m "feat(khala): data access layer — types and D1 queries"
```
---
## Phase 2: Auth & Agent Registry
### Task 2.1: Agent auth middleware
**Objective:** Bearer token auth for agents. Hash-based token verification.
**Files:**
- Create: `packages/khala/src/auth.ts`
- Modify: `packages/khala/src/index.ts`
**auth.ts:**
```typescript
import { createMiddleware } from "hono/factory";
import type { Env } from "./index.ts";
export const agentAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
const header = c.req.header("Authorization");
if (!header?.startsWith("Bearer ")) {
return c.json({ error: "missing token" }, 401);
}
const token = header.slice(7);
const hash = await sha256(token);
const agent = await c.env.DB.prepare(
"SELECT id FROM agents WHERE token_hash = ?"
).bind(hash).first<{ id: string }>();
if (!agent) {
return c.json({ error: "invalid token" }, 401);
}
c.set("agentId", agent.id);
await next();
});
async function sha256(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}
```
### Task 2.2: Admin routes for agent management
**Objective:** Admin API to register/remove agents (protected by admin secret in env).
**Files:**
- Create: `packages/khala/src/routes/admin.ts`
**Routes:**
- `POST /admin/agents` — body `{ id, token }` → hash token, insert agent
- `DELETE /admin/agents/:id` — remove agent
- `GET /admin/agents` — list agents (no tokens)
Protected by `ADMIN_SECRET` env var check.
**Commit:**
```bash
git commit -m "feat(khala): agent auth middleware and admin API"
```
---
## Phase 3: Workflow Engine (Durable Object)
### Task 3.1: Workflow registry
**Objective:** Load workflow definitions from a simple in-memory registry (hardcoded for MVP, later from D1 or KV).
**Files:**
- Create: `packages/khala/src/workflows.ts`
**Structure:**
```typescript
export type CloudRole = {
prompt: string;
};
export type CloudWorkflowDef = {
name: string;
roles: Record<string, CloudRole>;
moderator: string; // JSONata expression
};
// For MVP: hardcoded registry
const registry = new Map<string, CloudWorkflowDef>();
export function registerWorkflow(def: CloudWorkflowDef): void {
registry.set(def.name, def);
}
export function getWorkflow(name: string): CloudWorkflowDef | null {
return registry.get(name) ?? null;
}
```
### Task 3.2: ThreadDO — Durable Object
**Objective:** Each workflow thread runs as a Durable Object. The DO manages the moderator state machine, creates tasks, and processes responses.
**Files:**
- Create: `packages/khala/src/thread-do.ts`
**Key behavior:**
1. `POST /start` — Initialize thread: save workflow def, run moderator to get first turn, create task
2. `POST /response` — Agent posts turn result: validate claim_id, append message, run moderator for next turn or END
3. `GET /messages` — Query thread messages with filters
**Moderator execution:**
- Build context from messages (start frame + role steps)
- Evaluate JSONata expression → returns `{ role: "reviewer" }` or `{ role: "__end__" }`
- If not END, create new task in queue
- If END, mark thread completed, set result
**Important:** The DO holds workflow state in memory during the request but persists everything to D1. The DO itself uses `ctx.storage` only for the thread ID mapping.
### Task 3.3: Wire ThreadDO into worker
**Objective:** Export the DO class, add routes that proxy to the DO.
**Files:**
- Modify: `packages/khala/src/index.ts`
**Routes:**
- `POST /workflows/:name/threads` — Create thread → instantiate DO → start
- `POST /threads/:id/response` — Forward to DO
- `GET /threads/:id/messages` — Forward to DO (or query D1 directly)
- `GET /threads/:id` — Thread status
**Commit:**
```bash
git commit -m "feat(khala): ThreadDO workflow engine with JSONata moderator"
```
---
## Phase 4: Task Queue API
### Task 4.1: Task queue endpoints
**Objective:** Agents poll for work and claim tasks.
**Files:**
- Create: `packages/khala/src/routes/tasks.ts`
**Routes (all require agentAuth):**
- `GET /tasks` — List open tasks (optionally filter by workflow)
- `POST /tasks/:id/claim` — Claim a task → returns `{ claimId, role, instruction, threadId }`
- `POST /tasks/:id/release` — Release a claimed task back to queue
**Claim logic:**
- Atomic: UPDATE ... WHERE status = 'open' → if rowsWritten = 0, already claimed
- Returns claim_id (ulid) for optimistic lock on response
### Task 4.2: Task timeout sweep
**Objective:** Periodically expire timed-out tasks back to open.
**Implementation:** CF Worker Cron Trigger (every 1 minute) that calls `expireTimedOutTasks(db)`.
**Files:**
- Modify: `packages/khala/src/index.ts` (add scheduled handler)
- Modify: `packages/khala/wrangler.toml` (add cron trigger)
```toml
[triggers]
crons = ["* * * * *"]
```
**Commit:**
```bash
git commit -m "feat(khala): task queue API with claim/release and timeout sweep"
```
---
## Phase 5: Agent-Side Integration (KhalaSense)
### Task 5.1: Khala client library
**Objective:** A small client that agents use to interact with Khala.
**Files:**
- Create: `packages/core/src/khala-client.ts`
**API:**
```typescript
export type KhalaClientConfig = {
baseUrl: string;
token: string;
};
export function createKhalaClient(config: KhalaClientConfig) {
return {
pollTasks: () => GET /tasks,
claimTask: (taskId) => POST /tasks/:id/claim,
submitResponse: (threadId, content, meta, claimId) => POST /threads/:id/response,
getMessages: (threadId, opts) => GET /threads/:id/messages,
};
}
```
### Task 5.2: KhalaSense
**Objective:** A Sense that polls Khala for open tasks and emits signals.
**Files:**
- Create: `packages/daemon/src/senses/khala-sense.ts`
**Behavior:**
- `compute()`: poll `/tasks`, if tasks available → return task info as signal value
- Reflex picks up signal → triggers a workflow that executes the turn locally
- After local execution → POST response back to Khala
**This task depends on understanding the existing Sense pattern in daemon. Check `packages/daemon/src/` for examples.**
**Commit:**
```bash
git commit -m "feat(khala): KhalaSense — agent-side polling and integration"
```
---
## Phase 6: End-to-End Demo
### Task 6.1: Register a demo workflow
**Objective:** Register a simple 2-role "ping-pong" workflow for testing.
**Files:**
- Create: `packages/khala/src/workflows/ping-pong.ts`
**Workflow:**
- Roles: `pinger` (says ping), `ponger` (says pong)
- Moderator: alternate pinger/ponger for 3 rounds then END
- JSONata: `steps.length >= 6 ? { "role": "__end__" } : steps.length % 2 = 0 ? { "role": "pinger" } : { "role": "ponger" }`
### Task 6.2: Integration test
**Objective:** Test the full flow with miniflare.
**Files:**
- Create: `packages/khala/src/__tests__/e2e.test.ts`
**Test:**
1. Create thread via API
2. Poll tasks → get first task
3. Claim task
4. POST response
5. Poll again → get next task
6. Repeat until workflow completes
7. Verify thread status = completed and all messages present
**Commit:**
```bash
git commit -m "feat(khala): ping-pong demo workflow and e2e test"
```
---
## Summary
| Phase | Tasks | Description |
|-------|-------|-------------|
| 0 | 0.1 | Project scaffolding |
| 1 | 1.1-1.2 | D1 schema & data layer |
| 2 | 2.1-2.2 | Auth & agent registry |
| 3 | 3.1-3.3 | Workflow engine (DO + moderator) |
| 4 | 4.1-4.2 | Task queue API |
| 5 | 5.1-5.2 | Agent-side integration |
| 6 | 6.1-6.2 | End-to-end demo |
**Deployment:** `khala.shazhou.workers.dev`
**First milestone:** Phase 0-4 (cloud side complete), testable with curl.
**Second milestone:** Phase 5-6 (agent integration + demo).
+1 -1
View File
@@ -473,7 +473,7 @@ Sense 的运行时属性(`group`、`throttle`、`timeout`)在 `nerve.yaml`
```sql
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL, -- "reflex", "workflow", "system"
source TEXT NOT NULL, -- "sense_scheduler", "sense", "workflow", "system"
type TEXT NOT NULL, -- "run_start", "run_complete", "error", "state_change"
ref_id TEXT, -- 关联的 reflex name / workflow run_id
payload TEXT, -- JSON
+5 -5
View File
@@ -350,12 +350,12 @@ export type WorkflowManager = {
};
```
### 7.2 Reflex Scheduler 扩展
### 7.2 Sense Scheduler 扩展
当前 `ReflexScheduler` 只处理 `kind: "sense"` 的 reflex。扩展为同时处理 `kind: "workflow"`
当前 `SenseScheduler` 只处理 `kind: "sense"` 的 reflex。扩展为同时处理 `kind: "workflow"`
```typescript
// reflex-scheduler.ts 扩展
// sense-scheduler.ts 扩展
if (reflex.kind === "workflow") {
const workflowName = reflex.workflow;
if (reflex.on !== null && reflex.on.length > 0) {
@@ -393,8 +393,8 @@ if (reflex.kind === "workflow") {
### Phase 2:Kernel 集成
- [ ] `packages/daemon/src/kernel.ts` — 集成 WorkflowManager,处理 workflow worker 的生命周期
- [ ] `packages/daemon/src/reflex-scheduler.ts` — 扩展支持 `kind: "workflow"` 的 reflex
- [ ] 集成测试:Sense signal → Reflex → Workflow 全链路
- [ ] `packages/daemon/src/sense-scheduler.ts` — 扩展支持 `kind: "workflow"` 的 reflex
- [ ] 集成测试:Sense signal → SenseScheduler → Workflow 全链路
### Phase 3:崩溃恢复与热更新
+318
View File
@@ -0,0 +1,318 @@
# RFC-003: Agent Configuration Layer
**Author:** 小橘 🍊(NEKO Team)
**Status:** Draft
**Created:** 2026-04-29
## Summary
Define a minimal agent abstraction where **adapter = capability** and **role = scenario**. Workflows directly declare which adapter each role uses — no intermediate registry or `nerve.yaml` agent config. `nerve.yaml` only holds `extract` config and `knowledge` settings.
## Motivation
The original design introduced a `nerve.yaml` agents registry to map logical names (e.g. `developer`) to adapter implementations. In practice this added an unnecessary layer of indirection:
- **Agent names are arbitrary** — `developer` vs `coder` vs `engineer` is a naming exercise, not architecture
- **One more config to maintain** — adding/changing an adapter requires editing both `nerve.yaml` and the workflow
- **Same adapter, same config** — in reality, most workflows just need "use cursor" or "use hermes", not a named abstraction on top
The simpler model: **workflow roles declare their adapter directly**. The adapter *is* the capability.
## Key Concepts
### Adapter vs Role
| | Adapter | Role |
|---|---|---|
| **What** | Capability — what tools are available | Scenario — what to do with those tools |
| **Granularity** | Few (cursor, hermes, claude, codex) | Many (per workflow step) |
| **Defines** | How to spawn an agent, tool access | Prompt, schema, timeout |
| **Layer** | Infrastructure (packages) | Business logic (WorkflowSpec) |
A `cursor` adapter becomes an architect, coder, or reviewer depending on the role's prompt. The adapter defines *what it can do*; the role defines *what it does right now*.
### Agent Protocol
All agent types implement a single unified interface:
```ts
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
```
- **Input**: prompt (assembled by Role) + context (start frame + prior messages + workdir + abort signal)
- **Output**: raw string — structured data is extracted separately
- **Internals**: adapter handles tool-specific details (cursor CLI, hermes subagent, codex API, etc.)
Workflow runtime never interacts with agent internals.
### Extract Layer
A separate concern that parses agent output (raw string) into typed meta:
```ts
type ExtractFn<T> = (raw: string, schema: Schema<T>) => Promise<T>
```
Configured globally in `nerve.yaml`, overridable per role (two-level merge: global → role).
**Error handling**: retry once (feed raw output + parse error back to LLM for correction), then throw `ExtractError`. The workflow moderator decides the recovery strategy (retry role, skip, or terminate) — extract never makes workflow-level decisions.
## Design
### Configuration (`nerve.yaml`)
`nerve.yaml` holds only extract and knowledge config — no agent registry:
```yaml
extract:
provider: dashscope
model: qwen-plus
```
### Workflow Definition (TypeScript)
Roles declare their adapter directly — no indirection through named agents:
```ts
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
const workflow: WorkflowSpec<MyMeta> = {
name: "develop-workflow",
roles: {
architect: { adapter: cursorAdapter, prompt: architectPrompt, meta: architectSchema },
coder: { adapter: createCursorAdapter({ model: "claude-sonnet-4", timeout: 600 }), prompt: coderPrompt, meta: coderSchema },
reviewer: { adapter: hermesAdapter, prompt: reviewPrompt, meta: reviewSchema },
deployer: { adapter: hermesAdapter, prompt: deployPrompt, meta: deploySchema },
},
moderator,
};
```
### Runtime Assembly
```
WorkflowSpec → Role(adapter fn + prompt) → adapter(prompt, ctx) → string
nerve.yaml#extract → ExtractFn(string, schema) → T (typed meta)
```
Adapter is a direct function reference on each role — no map, no lookup, no registry.
### Adapter Packages
Each agent adapter lives in its own package to avoid pulling unnecessary dependencies:
```
packages/
adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI
adapter-hermes/ # @uncaged/nerve-adapter-hermes — hermes CLI subagent
adapter-claude/ # @uncaged/nerve-adapter-claude — claude-code CLI (future)
adapter-codex/ # @uncaged/nerve-adapter-codex — codex CLI (future)
```
Each adapter exports a **default instance** and a **factory** for customization:
```ts
// @uncaged/nerve-adapter-cursor
import type { AgentConfig, AgentFn } from "@uncaged/nerve-core";
// Factory — custom config
export function createCursorAdapter(config: AgentConfig): AgentFn;
// Default — sensible defaults (model: "auto", timeout: 300)
export const cursorAdapter: AgentFn;
```
The factory receives adapter config (model, timeout) and returns an `AgentFn` that spawns the CLI tool, passes the prompt, and returns raw output.
**Wiring** — workflows import adapters directly, no daemon-level registry:
```ts
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
// Use default instances directly in roles
{ adapter: cursorAdapter, prompt: "...", meta: schema }
```
Adapters not installed simply can't be imported — TypeScript catches missing dependencies at compile time.
**Workspace `package.json`** only lists the adapters it actually uses:
```json
{
"dependencies": {
"@uncaged/nerve-adapter-cursor": "workspace:*",
"@uncaged/nerve-adapter-hermes": "workspace:*"
}
}
```
**Migration from `workflow-utils`** — the existing `role-cursor.ts` / `shared/cursor-agent.ts` spawn logic moves to `@uncaged/nerve-adapter-cursor`. `role-hermes.ts` / `shared/hermes-agent.ts` moves to `@uncaged/nerve-adapter-hermes`. `workflow-utils` retains only extract, prompt utilities, and shared spawn infrastructure.
### Dynamic Prompts
`RoleSpec.prompt` supports both static strings and async functions:
```ts
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
type RoleSpec<M> = {
adapter: AgentFn;
prompt: PromptInput;
meta: Schema<M>;
};
```
Static prompts cover simple cases. Dynamic prompts (functions) are needed when the prompt depends on thread context — e.g. reading issue content, injecting prior step results, or resolving repo paths at runtime.
### Timeout Resolution
Timeout is an **adapter concern**, not a role concern. Roles define *what to do* (prompt + schema); adapters define *how to do it* (tool, model, timeout).
When different roles need different timeouts, create separate adapter instances:
```ts
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
const fastCursor = createCursorAdapter({ model: "auto", timeout: 60 });
const slowCursor = createCursorAdapter({ model: "auto", timeout: 600 });
roles: {
reviewer: { adapter: fastCursor, prompt: reviewPrompt, meta: reviewSchema },
coder: { adapter: slowCursor, prompt: coderPrompt, meta: coderSchema },
}
```
### No Runtime Fallback
- **`nerve init`** — detects agent availability (CLI exists? service reachable?), reports errors immediately
- **Runtime** — if an agent is unavailable, the workflow fails with a clear error. No silent degradation.
Rationale: silent fallback hides quality differences (cursor → hermes subagent produces very different output) and makes debugging harder.
### Adapter Hot-Reload
Follows the existing `nerve.yaml` hot-reload mechanism. On config change, adapters are rebuilt. Running workflow threads are not affected (they use the `AdapterFn` bound at thread start). New threads automatically use the updated config.
### WorkflowContext
```ts
type WorkflowContext = {
start: StartStep;
messages: WorkflowMessage[];
workdir: string; // repo root — coding agent working directory
signal: AbortSignal; // graceful cancellation
};
```
`workdir` is required for coding agents. `signal` enables graceful cancellation of long-running agent calls — adapters must respect it (e.g. kill subprocess on abort).
### Configuration Validation
`nerve validate` checks:
- All roles have a valid adapter function (not null/undefined)
- Adapter CLIs are available (binary exists in PATH)
- Extract provider is configured and reachable
## Compatibility with Current Types
The existing `Role<Meta>` signature:
```ts
type Role<Meta> = (start: StartStep, messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>
```
remains the runtime interface. The new config layer is syntactic sugar — the runtime assembles `Role<Meta>` functions from `(adapter + prompt + schema)` instead of users writing them by hand. `WorkflowDefinition` stays the same at the engine level; `WorkflowSpec` is the new user-facing authoring format that compiles down to it at daemon startup / hot-reload time (runtime lazy compile, not `nerve init`).
Existing hand-written `Role` functions continue to work — `WorkflowSpec` is additive, not a breaking change.
## Knowledge Layer
Project knowledge is a **built-in nerve feature**. Scope is the **repo** — each repo has its own knowledge base, tracked in git.
### Architecture
```
Local (per repo) Remote Service
┌───────────────────────┐ ┌─────────────────────┐
│ knowledge.yaml │ │ Embedding API │
│ ├── include/exclude │ ──→ │ text → vector │
│ knowledge.db (SQLite) │ ←── │ content-hash cache │
│ ├── chunk text │ │ (avoid recompute) │
│ ├── embedding bytes │ └─────────────────────┘
│ └── cosine search │
└───────────────────────┘
```
- **Local-first** — `knowledge.db` stores chunks + embeddings, search runs locally (in-memory cosine similarity)
- **Remote service only computes embeddings** — content-addressable cache keyed by text hash, avoids redundant computation across agents
- **Branch-aware by design** — different agents on different branches naturally have different `knowledge.db` contents
### Configuration (`knowledge.yaml` at repo root)
```yaml
include:
- "src/**/*.ts"
- "docs/**/*.md"
- "*.md"
exclude:
- "node_modules/**"
- "dist/**"
- "*.test.ts"
```
`knowledge.yaml` is committed to git. `knowledge.db` is gitignored — it's a local cache rebuilt from source files + remote embedding service.
### CLI
```bash
nerve knowledge sync # index/re-index changed files
nerve knowledge query "how does the signal bus work"
# Scope
nerve knowledge query "..." # default: cwd repo
nerve knowledge query --repo /path/to/other/repo "..."
nerve knowledge query -g "..." # global search (all indexed repos)
# --repo and -g are mutually exclusive
```
### Search Implementation
Project-scale knowledge (hundreds to low thousands of chunks) does not need vector indices. Full scan with cosine similarity in memory is sufficient and adds zero native dependencies.
```ts
// Pseudocode
const chunks = db.all("SELECT slug, chunk, embedding FROM chunks");
const query_vec = await embed(query);
const results = chunks
.map(c => ({ ...c, score: cosine(query_vec, c.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
```
### Knowledge Layers
```
Project knowledge (knowledge.yaml) Per repo, git managed, any agent reads
Agent long-term memory Per agent, domain expertise, cross-run
Workflow context (start + msgs) Per run, moderator-controlled history
```
## Open Questions
1. **Agent long-term memory** — storage format and mechanism for persisting domain expertise across runs
### Resolved
- **Agent naming / registry** → removed; workflow roles declare adapter directly, no intermediate registry
- **Extract override granularity** → two-level merge: global → role (agent level removed)
- **Context threading** → `WorkflowContext` includes `workdir` and `signal` (see design above)
- **Embedding service** → self-hosted, 1024-dim vectors, content-hash cache
## References
- [RFC-002: Workflow Engine](./rfc-002-workflow-engine.md)
- Current `Role` / `Moderator` types: `packages/core/src/workflow.ts`
+187
View File
@@ -0,0 +1,187 @@
# RFC-004: Package Architecture — Shareable Workflows, Roles & Senses
**Author:** 小橘 🍊(NEKO Team)
**Status:** Draft
**Created:** 2026-04-29
## Summary
Make workflows, roles, and senses publishable as lightweight npm packages. Workspaces become pure configuration — selecting packages, wiring adapters, and providing credentials. No builtin workflows in the nerve core.
## Motivation
Currently, workflows like `develop-sense` and `develop-workflow` live inside the workspace (`~/.uncaged-nerve/workflows/`). This creates problems:
1. **No sharing** — every workspace duplicates the same workflow code
2. **No versioning** — upgrading a workflow means manual file edits
3. **Builtin is a trap** — if we bake workflows into nerve core, they require adapters and LLM providers that may not be installed. A fresh `nerve` install on a bare machine would fail to load builtins.
4. **Roles are already shared**`_shared/workspace-committer.ts` proves the pattern works; we just need to formalize it as packages
The adapter pattern (`@uncaged/nerve-adapter-hermes`, `@uncaged/nerve-adapter-cursor`) already established the precedent: infrastructure as packages, workspace as wiring.
## Design
### Package Taxonomy
```
@uncaged/nerve-core # types, engine
@uncaged/nerve-daemon # runtime
@uncaged/nerve-workflow-utils # createRole, decorateRole, withDryRun, onFail, etc.
# Adapters (existing)
@uncaged/nerve-adapter-hermes
@uncaged/nerve-adapter-cursor
# Workflows (new)
@uncaged/nerve-workflow-solve-issue
@uncaged/nerve-workflow-develop-sense
@uncaged/nerve-workflow-develop-workflow
# Shared Roles (new)
@uncaged/nerve-role-committer # workspace committer (branch, commit, push)
@uncaged/nerve-role-reviewer # code review role
@uncaged/nerve-role-publisher # PR creation role
# Senses (existing pattern, formalized)
@uncaged/nerve-sense-cpu-usage
@uncaged/nerve-sense-disk-usage
```
### Package Contract
Each package type exports a factory function:
#### Workflow Package
```ts
// @uncaged/nerve-workflow-develop-sense
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
export type SenseMeta = { /* ... */ };
export type CreateDevelopSenseDeps = {
defaultAdapter: AgentFn;
adapters?: Partial<Record<keyof SenseMeta, AgentFn>>;
extract: LlmExtractorConfig;
cwd: string;
};
export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta>;
```
Key design decisions:
- `defaultAdapter` + optional `adapters` override per role — via `Partial<Record<keyof Meta, AgentFn>>`
- Adapter keys are derived from `Meta` type — adding/removing a role automatically updates the adapter map
- Roles that don't need an agent simply don't appear in `adapters` (the `Partial` allows this)
#### Role Package
```ts
// @uncaged/nerve-role-committer
import type { AgentFn, Role } from "@uncaged/nerve-core";
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
export type CommitterMeta = { committed: boolean };
export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CommitterMeta> {
const inner = createRole(adapter, prompt, committerMetaSchema, extract);
return decorateRole(inner, [
withDryRun({ label: "committer", meta: { committed: true } }),
onFail({ label: "committer", meta: { committed: false } }),
]);
}
```
Roles compose with the decorator chain from `workflow-utils`:
- `withDryRun` — skip execution in dry-run mode
- `onFail` — catch errors into structured failure results
- `decorateRole(role, [...])` — apply decorators left-to-right
- Custom `RoleDecorator<M>` can be created for project-specific needs
#### Sense Package
```ts
// @uncaged/nerve-sense-cpu-usage
export const senseName = "cpu-usage";
export const schema = { /* drizzle schema */ };
export async function compute(ctx: SenseContext): Promise<SenseResult>;
```
### Workspace as Configuration
The workspace becomes a thin wiring layer:
```
~/.uncaged-nerve/
nerve.yaml # senses, extract config
package.json # depends on workflow/role/adapter packages
workflows/
develop-sense/
index.ts # ~10 lines: import package, wire adapters, export
solve-issue/
index.ts # same pattern
```
A typical `index.ts`:
```ts
import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-develop-sense";
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
export default createDevelopSenseWorkflow({
defaultAdapter: hermesAdapter,
adapters: { planner: cursorAdapter, coder: cursorAdapter },
extract: { provider: { apiKey, baseUrl, model } },
cwd: nerveRoot,
});
```
### What Stays in Workspace
- **Custom workflows** — project-specific workflows that aren't general enough to share
- **Custom senses** — project-specific metrics
- **Configuration** — adapter selection, credentials, `nerve.yaml`
- **Overrides** — a workspace can always write its own role/workflow instead of using a package
### Dependency Rules
```
nerve-core ← no deps on other nerve packages
nerve-workflow-utils ← depends on nerve-core
nerve-adapter-* ← depends on nerve-core
nerve-role-* ← depends on nerve-core, nerve-workflow-utils
nerve-workflow-* ← depends on nerve-core, nerve-workflow-utils, may depend on nerve-role-*
nerve-sense-* ← depends on nerve-core
nerve-daemon ← depends on nerve-core, nerve-store
```
Workflow packages depend on role packages (not adapters). Adapters are injected at the workspace level.
### Migration Path
1. **Phase 1: Extract role packages** — Start with `@uncaged/nerve-role-committer` (already `_shared/workspace-committer.ts`). Publish, update workspace to import from package.
2. **Phase 2: Extract workflow packages** — Move `develop-sense` and `develop-workflow` to packages. Workspace `index.ts` becomes pure wiring.
3. **Phase 3: Sense packages** — Formalize sense packaging (lower priority, senses are already self-contained directories).
4. **Phase 4: Community** — Document the package contract so others can publish workflows/roles/senses.
### Not in Scope
- **No builtin workflows** — nerve core ships zero workflows. All workflows are packages installed by the workspace.
- **No workflow marketplace/registry** — just npm packages. `pnpm add @uncaged/nerve-workflow-solve-issue`.
- **No nerve.yaml workflow declaration** — workflows are still TypeScript entry points. The daemon discovers them the same way it does today.
## Open Questions
1. **Monorepo vs separate repos?** — Should workflow/role packages live in the nerve monorepo or separate repos? Monorepo is easier for coordinated releases; separate repos allow independent versioning.
2. **Sense package format** — Senses currently bundle with esbuild. Should sense packages ship pre-bundled or as TypeScript source?
3. **Version coupling** — How tightly should workflow packages pin `nerve-core`? Peer deps with semver range?
## Prior Art
- Adapter packages (`@uncaged/nerve-adapter-*`) — established the factory + injection pattern
- `_shared/workspace-committer.ts` — proved roles can be shared across workflows
- `createRole` / `decorateRole` / `withDryRun` / `onFail` in `workflow-utils` — building blocks that role packages compose
- `defaultAdapter` + `Partial<Record<keyof Meta, AgentFn>>` pattern — adapter injection without coupling
+11 -17
View File
@@ -1,23 +1,17 @@
import { loadavg } from "node:os";
import type { DrizzleDB, PeerMap } from "@uncaged/nerve-daemon";
type CpuState = {
samples: Array<{ ts: number; value: number }>;
};
import { samples } from "./schema.js";
export const initialState: CpuState = { samples: [] };
/**
* Read the 1-minute CPU load average, persist it, and emit a Signal.
*
* Returns `null` only if `loadavg` is unavailable (non-POSIX platforms).
* On every successful read a row is inserted and the value is returned,
* which causes the engine to emit a Signal.
*/
export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number | null> {
export async function compute(state: CpuState): Promise<{
state: CpuState;
workflow: null;
}> {
const [oneMin] = loadavg();
if (typeof oneMin !== "number" || Number.isNaN(oneMin)) {
return null;
}
await db.insert(samples).values({ ts: Date.now(), value: oneMin });
return oneMin;
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
return { state: { samples: newSamples }, workflow: null };
}
@@ -1,7 +0,0 @@
-- Migration: 0001_init
-- Creates the samples table for the cpu-usage sense.
CREATE TABLE IF NOT EXISTS samples (
ts INTEGER PRIMARY KEY,
value REAL NOT NULL
);
-11
View File
@@ -1,11 +0,0 @@
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
/**
* Each row records one CPU load sample.
* `ts` is the Unix timestamp in milliseconds (primary key, append-only).
* `value` is the 1-minute load average from os.loadavg()[0].
*/
export const samples = sqliteTable("samples", {
ts: integer("ts").primaryKey(),
value: real("value").notNull(),
});
+4 -14
View File
@@ -1,9 +1,9 @@
# Example nerve.yaml demonstrating Signal Bus & Reflex Scheduler (Phase 3)
# Example nerve.yaml demonstrating per-sense scheduling (interval + on)
#
# Layout:
# - cpu-usage: periodic every 10s, throttled to 5s minimum between computes
# - disk-usage: periodic every 30s
# - system-health: derived sense, triggered whenever cpu-usage OR disk-usage emits
# - system-health: derived sense, scheduled when cpu-usage OR disk-usage completes a compute
senses:
cpu-usage:
@@ -11,30 +11,20 @@ senses:
throttle: 5s
timeout: 8s
grace_period: null
interval: 10s
disk-usage:
group: system
throttle: null
timeout: 15s
grace_period: null
interval: 30s
system-health:
group: derived
throttle: 2s
timeout: 10s
grace_period: null
reflexes:
# cpu-usage runs on a 10-second interval
- sense: cpu-usage
interval: 10s
# disk-usage runs on a 30-second interval
- sense: disk-usage
interval: 30s
# system-health is event-driven: fires whenever cpu-usage or disk-usage emits a signal
- sense: system-health
on:
- cpu-usage
- disk-usage
+17 -7
View File
@@ -2,8 +2,7 @@
* nerve-health — built-in sense that reports daemon health via IPC.
*
* When running inside a sense worker, this compute function sends a
* "health-request" to the parent kernel process and returns the
* health-response payload as its signal.
* "health-request" to the parent kernel process and merges the response into state.
*
* Usage in nerve.yaml:
* senses:
@@ -11,9 +10,6 @@
* group: internal
* throttle: 30s
* timeout: 5s
*
* reflexes:
* - sense: nerve-health
* interval: 30s
*/
@@ -25,9 +21,23 @@ export type NerveHealth = {
workerUptime: number;
};
export async function compute(): Promise<NerveHealth | null> {
type HealthState = {
lastCheck: number | null;
lastHealth: NerveHealth | null;
};
export const initialState: HealthState = { lastCheck: null, lastHealth: null };
export async function compute(_state: HealthState): Promise<{
state: HealthState;
workflow: null;
}> {
void _state;
const health = await requestHealthFromKernel();
return health;
return {
state: { lastCheck: Date.now(), lastHealth: health },
workflow: null,
};
}
function requestHealthFromKernel(): Promise<NerveHealth> {
+4
View File
@@ -0,0 +1,4 @@
include:
- ".knowledge/**/*.md"
exclude: []
+6 -2
View File
@@ -5,13 +5,17 @@
"node": ">=22.5.0"
},
"scripts": {
"prepare": "husky",
"build": "pnpm -r run build",
"test": "pnpm -r test",
"check": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"link:dev": "bash scripts/link-dev.sh"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"tsup": "^8.0.0",
"@rslib/core": "^0.21.3",
"husky": "^9.1.7",
"typescript": "^5.5.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/nerve-adapter-cursor",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+122
View File
@@ -0,0 +1,122 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
export type CursorAgentMode = "plan" | "ask" | "default";
export type CursorAgentOptions = {
prompt: string;
mode: CursorAgentMode;
model: string;
cwd: string;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
abortSignal: AbortSignal | null;
};
type CursorAgentOptionsInput = CursorAgentOptions | Omit<CursorAgentOptions, "dryRun">;
function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: CursorAgentOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
/**
* Invokes `cursor-agent` with the prompt passed as a single argv slot (`shell: false`).
*/
export async function cursorAgent(
options: CursorAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveCursorAgentDryRun(options);
if (dryRun) {
return ok("[dryRun] skipped");
}
const args: string[] = [
"-p",
options.prompt,
"--model",
options.model,
"--output-format",
"text",
"--trust",
"--force",
];
if (options.mode === "plan") {
args.push("--mode=plan");
} else if (options.mode === "ask") {
args.push("--mode=ask");
}
const run = await spawnSafe("cursor-agent", args, {
cwd: options.cwd,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
abortSignal: normalizeAbortSignal(options),
});
if (!run.ok) {
return run;
}
return ok(run.value.stdout);
}
function throwCursorSpawnError(error: SpawnError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("cursor-agent: timeout");
}
if (error.kind === "aborted") {
throw new Error("cursor-agent: aborted");
}
throw new Error(`cursor-agent: ${error.message}`);
}
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
export type CursorAdapterConfig = AgentConfig & {
/** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */
mode?: CursorAgentMode;
};
/**
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
*/
export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
const timeoutMs = config.timeout;
const mode = config.mode ?? "default";
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await cursorAgent({
prompt,
mode,
model: config.model,
cwd: process.cwd(),
env: null,
timeoutMs,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwCursorSpawnError(run.error);
}
return run.value;
};
}
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
export const cursorAdapter: AgentFn = createCursorAdapter({
type: "cursor",
model: "auto",
timeout: CURSOR_ADAPTER_DEFAULT_MS,
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/nerve-adapter-hermes",
"version": "0.5.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"vitest": "^4.1.5"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
},
],
source: {
entry: {
index: "src/index.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
},
});
+124
View File
@@ -0,0 +1,124 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
* (shell: false) following the Nerve issue #208 contract.
*/
export type HermesAgentOptions = {
prompt: string;
model: string | null;
provider: string | null;
skills: string[];
/** When true, suppresses interactive UI noise. */
quiet: boolean;
maxTurns: number;
env: SpawnEnv | null;
timeoutMs: number | null;
dryRun: boolean;
abortSignal: AbortSignal | null;
};
type HermesAgentOptionsInput = HermesAgentOptions | Omit<HermesAgentOptions, "dryRun">;
function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function normalizeAbortSignal(options: HermesAgentOptionsInput): AbortSignal | null {
return "abortSignal" in options ? options.abortSignal : null;
}
export async function hermesAgent(
options: HermesAgentOptionsInput,
): Promise<Result<string, SpawnError>> {
const dryRun = resolveHermesDryRun(options);
if (dryRun) {
return ok("[dryRun] hermes stub");
}
const args: string[] = [
"chat",
"-q",
options.prompt,
"--yolo",
"--max-turns",
String(options.maxTurns),
];
if (options.model) {
args.push("--model", options.model);
}
if (options.provider) {
args.push("--provider", options.provider);
}
for (const s of options.skills) {
args.push("-s", s);
}
if (options.quiet) {
args.push("--quiet");
}
const run = await spawnSafe("hermes", args, {
cwd: null,
env: options.env,
timeoutMs: options.timeoutMs,
dryRun: false,
abortSignal: normalizeAbortSignal(options),
});
if (!run.ok) {
return run;
}
return ok(run.value.stdout);
}
function throwHermesSpawnError(error: SpawnError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("hermes: timeout");
}
if (error.kind === "aborted") {
throw new Error("hermes: aborted");
}
throw new Error(`hermes: ${error.message}`);
}
const HERMES_ADAPTER_DEFAULT_MAX_TURNS = 90;
/** Default wall-clock cap: 300 seconds (milliseconds). */
const HERMES_ADAPTER_DEFAULT_MS = 300_000;
/**
* Builds a Hermes CLI `AgentFn` from adapter config (model, timeout).
*/
export function createHermesAdapter(config: AgentConfig): AgentFn {
const modelFromConfig = config.model === "auto" ? null : config.model;
const timeoutMs = config.timeout;
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
const run = await hermesAgent({
prompt,
model: modelFromConfig,
provider: null,
skills: [],
quiet: true,
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs,
dryRun: false,
abortSignal: null,
});
if (!run.ok) {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
export const hermesAdapter: AgentFn = createHermesAdapter({
type: "hermes",
model: "auto",
timeout: HERMES_ADAPTER_DEFAULT_MS,
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+87
View File
@@ -0,0 +1,87 @@
# @uncaged/nerve-cli
Command-line interface for the [nerve](../../README.md) observation engine.
## Install
```bash
pnpm add -g @uncaged/nerve-cli
# or
npx @uncaged/nerve-cli
```
Requires Node.js ≥ 22.5.
## Commands
### Workspace
```bash
nerve init # Initialize a nerve workspace (installs deps, scaffolds config)
nerve validate # Validate nerve.yaml configuration
```
### Daemon management
```bash
nerve daemon start # Start the daemon (background)
nerve daemon stop # Stop the daemon
nerve daemon status # Show pid, uptime, sense names from nerve.yaml (process must exist)
nerve daemon restart # Stop then start
nerve daemon logs # Tail daemon process logs (file under workspace logs/)
```
### Development
```bash
nerve dev # Foreground kernel with file watcher + IPC (Ctrl+C stops)
```
### Querying & status
```bash
nerve logs # Tail or page the daemon text log file (path in footer; default ~/.uncaged-nerve/logs/nerve.log)
nerve status # Short daemon health summary (aliases daemon status)
```
Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`** / **`nerve workflow list`** (and `LogStore` in code), not via `nerve logs`.
### Sense
```bash
nerve sense list # List senses (live fields from daemon IPC when running)
nerve sense trigger <name> # IPC trigger-sense — queue a compute for that sense
```
Sense state is persisted as JSON under `data/senses/<name>.json` by the sense worker after each successful compute.
### Store maintenance
```bash
nerve store archive # Move old log rows to JSONL under data/archive/logs/… (optional --vacuum)
```
### Workflows
```bash
nerve workflow list # Queued/started runs (add --all for terminal states; --workflow, --limit, --offset)
nerve workflow inspect <runId> # Run metadata + paginated workflow log lines
nerve workflow thread <runId> # Role rounds from persisted messages (--before, --budget)
nerve workflow trigger <name> # IPC trigger-workflow (daemon must be running)
# Optional JSON: --payload '{"prompt":"…","maxRounds":50}'
```
`nerve workflow trigger` sends a `trigger-workflow` line on the daemon Unix socket (same protocol as `@uncaged/nerve-core` / `parseDaemonIpcRequest`). It does not read `nerve.yaml` workflow definitions beyond what the running daemon already loaded.
### Top-level aliases
```bash
nerve start → nerve daemon start
nerve stop → nerve daemon stop
nerve status → nerve daemon status
nerve logs → nerve daemon logs
```
## License
MIT
+9 -6
View File
@@ -3,29 +3,32 @@
"engines": {
"node": ">=22.5.0"
},
"version": "0.1.8",
"version": "0.5.0",
"type": "module",
"bin": {
"nerve": "dist/cli.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist", "skills"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "tsup",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"citty": "^0.1.6"
"@uncaged/nerve-store": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"@uncaged/nerve-daemon": "workspace:*",
"vitest": "^4.1.5"
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@rslib/core";
export default defineConfig({
lib: [
{
format: "esm",
dts: true,
banner: {
js: "#!/usr/bin/env -S node --disable-warning=ExperimentalWarning",
},
},
],
source: {
entry: {
index: "src/index.ts",
cli: "src/cli.ts",
"daemon-bootstrap": "src/daemon-bootstrap.ts",
},
},
output: {
target: "node",
cleanDistPath: true,
externals: ["@uncaged/nerve-daemon", "@uncaged/nerve-store"],
},
});
+587
View File
@@ -0,0 +1,587 @@
<!-- nerve-cli-version: __NERVE_CLI_VERSION__ -->
## Cursor Agent 使用提示
在 Cursor 中与 Agent 对话时,可以用以下方式指代代码与配置:
- **`@Files` / `@file`**:引用单个文件,例如 `@nerve.yaml`、`@senses/cpu-usage/src/index.ts`,减少幻觉并让修改对准正确路径。
- **`@Folder` / `@Codebase`**:需要跨目录理解工作区结构时使用;改动前仍应优先打开相关 sense/workflow 源文件确认。
- **`@Terminal`**:把 CLI 输出纳入上下文,便于对照 `nerve daemon logs`、`nerve sense query` 等结果。
- **`@Docs`**:若项目或依赖有文档索引,可用来对齐 API 与约定。
- 工作区根目录下的 **`nerve.yaml`**、`senses/`、`workflows/` 是 nerve 的核心入口;讨论调度与配置时优先 `@` 这些路径。
- 本规则由 `nerve agent inject cursor` 安装;更新 CLI 后在同一目录再次执行可覆盖为新版。
---
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense → Signal → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense → Signal → Workflow + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ ├── src/index.ts # exports compute() + table
│ ├── src/schema.ts # Drizzle 表定义
│ └── migrations/ # SQL 迁移
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/ # 运行时数据(SQLite、blobs)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request`、`deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
nerve sense schema <name> # 查看 sense 数据库表结构
nerve sense schema <name> --json # JSON 格式
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 Hermes 注入本 skill)
```bash
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除默认 profile 的注入
nerve agent remove hermes --profile <name>
nerve agent inject cursor # 在 cwd 生成 .cursorrules
nerve agent inject cursor --path /foo # 在指定目录生成
nerve agent remove cursor [--path /foo]
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 优雅关闭等待
retention: 10000 # _signals 表最大行数(默认 10000)
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`。
```typescript
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
export const compute: SenseComputeFn<MySignalShape> = async () => {
// ...
};
// 或等价地:
export async function compute(): Promise<ComputeResult<MySignalShape>> {
// ...
}
```
(运行时定义见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts` 的 `executeCompute` 中插入 `result.signal`。)
### 返回值
```typescript
// 返回 null = 静默,不发 signal
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
type ComputeResult<T> =
| null
| { signal: T; workflow: WorkflowTrigger | null };
type WorkflowTrigger = {
name: string; // workflow 名称(对应 nerve.yaml 中的 key)
maxRounds: number; // moderator 最大轮次
prompt: string; // 初始 prompt
dryRun: boolean; // 干跑模式
};
```
若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。
### Sense 模块导出
```typescript
// senses/<name>/src/index.ts
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
return { signal: row, workflow: null };
}
export { table };
```
### Schema 定义
```typescript
// senses/<name>/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
```
### 调度方式
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
// senses/cpu-usage/src/index.ts
import os from "node:os";
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const oneMin = os.loadavg()[0];
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
}
export { table };
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
retention: 10000
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init` 的 `package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter** — `AgentFn`,`(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt** — `string`,或 `async (ctx: ThreadContext) => string`。
3. **meta** — `z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract** — `{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`。
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**:`echo`、`cursor`、`hermes`、`codex`。
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter`(`@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued` → `started` → `completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 检查某个 sense 的历史数据
```bash
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
nerve sense schema cpu-usage # 查看表结构
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
+580
View File
@@ -0,0 +1,580 @@
---
name: nerve
version: 0.5.0
description: >
Nerve — AI agent 观测引擎。掌握 nerve 的核心概念、CLI 操作、sense/workflow 开发。
加载此 skill 后你可以:查看系统状态、监控 sense、触发 workflow、开发新 sense 和 workflow。
metadata:
hermes:
tags: [nerve, sense, workflow, monitoring, agent-kernel]
homepage: https://git.shazhou.work/uncaged/nerve
---
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense → Signal → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense → Signal → Workflow + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
`nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ ├── src/index.ts # exports compute() + table
│ ├── src/schema.ts # Drizzle 表定义
│ └── migrations/ # SQL 迁移
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/ # 运行时数据(SQLite、blobs)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request``deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
nerve sense schema <name> # 查看 sense 数据库表结构
nerve sense schema <name> --json # JSON 格式
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 Hermes 注入本 skill)
```bash
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除默认 profile 的注入
nerve agent remove hermes --profile <name>
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 优雅关闭等待
retention: 10000 # _signals 表最大行数(默认 10000)
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`
```typescript
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
export const compute: SenseComputeFn<MySignalShape> = async () => {
// ...
};
// 或等价地:
export async function compute(): Promise<ComputeResult<MySignalShape>> {
// ...
}
```
(运行时定义见 `@uncaged/nerve-core``SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts``executeCompute` 中插入 `result.signal`。)
### 返回值
```typescript
// 返回 null = 静默,不发 signal
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
type ComputeResult<T> =
| null
| { signal: T; workflow: WorkflowTrigger | null };
type WorkflowTrigger = {
name: string; // workflow 名称(对应 nerve.yaml 中的 key)
maxRounds: number; // moderator 最大轮次
prompt: string; // 初始 prompt
dryRun: boolean; // 干跑模式
};
```
若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。
### Sense 模块导出
```typescript
// senses/<name>/src/index.ts
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
return { signal: row, workflow: null };
}
export { table };
```
### Schema 定义
```typescript
// senses/<name>/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
```
### 调度方式
1. **interval 轮询**`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
// senses/cpu-usage/src/index.ts
import os from "node:os";
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const oneMin = os.loadavg()[0];
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
}
export { table };
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
retention: 10000
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init``package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter**`AgentFn``(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt**`string`,或 `async (ctx: ThreadContext) => string`
3. **meta**`z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract**`{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts``import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**`echo``cursor``hermes``codex`
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter``@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued``started``completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 检查某个 sense 的历史数据
```bash
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
nerve sense schema cpu-usage # 查看表结构
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { chunkMarkdown } from "../knowledge/chunk-markdown.js";
describe("chunkMarkdown", () => {
it("splits markdown by headings into separate chunks", () => {
const md = `# Title One
Intro para under first heading.
## Title Two
Second section body.
`;
const chunks = chunkMarkdown("docs/guide.md", md);
expect(chunks.length).toBeGreaterThanOrEqual(2);
const joined = chunks.map((c) => c.text).join("\n");
expect(joined).toContain("Title One");
expect(joined).toContain("Title Two");
});
it("includes preamble before first heading as its own chunk when present", () => {
const md = `Preamble line here.
# First Real Heading
Under heading.
`;
const chunks = chunkMarkdown("readme.md", md);
const preamble = chunks.find((c) => c.slug.includes("preamble"));
expect(preamble).toBeDefined();
expect(preamble?.text).toContain("Preamble");
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
consumeGlobalDaemonCliFlags,
getCliDaemonApiToken,
getCliDaemonHost,
} from "../cli-global.js";
describe("consumeGlobalDaemonCliFlags", () => {
it("strips --host and --api-token and populates getters", () => {
const out = consumeGlobalDaemonCliFlags([
"--host",
"192.168.1.5:9800",
"--api-token=abc",
"sense",
"list",
]);
expect(out).toEqual(["sense", "list"]);
expect(getCliDaemonHost()).toBe("192.168.1.5:9800");
expect(getCliDaemonApiToken()).toBe("abc");
});
it("supports --host=value form", () => {
consumeGlobalDaemonCliFlags(["--host=luming:9800", "status"]);
expect(getCliDaemonHost()).toBe("luming:9800");
});
it("throws when --host has no value", () => {
expect(() => consumeGlobalDaemonCliFlags(["--host", "--api-token", "x"])).toThrow(/--host/);
});
});
@@ -0,0 +1,32 @@
/**
* Tests for nerve create sense template helpers.
*/
import { describe, expect, it } from "vitest";
import { buildSenseIndexTs, validateResourceName } from "../commands/create.js";
describe("validateSenseName", () => {
it("accepts valid ids", () => {
expect(validateResourceName("a", "Sense")).toBe(null);
expect(validateResourceName("my-sense", "Sense")).toBe(null);
expect(validateResourceName("cpu-usage", "Sense")).toBe(null);
});
it("rejects invalid ids", () => {
expect(validateResourceName("", "Sense")).not.toBe(null);
expect(validateResourceName("My-Sense", "Sense")).not.toBe(null);
expect(validateResourceName("-bad", "Sense")).not.toBe(null);
});
});
describe("buildSenseIndexTs", () => {
it("generates a stateful sense stub with TypeScript types", () => {
const ts = buildSenseIndexTs("my-sense");
expect(ts).toContain("type SenseState");
expect(ts).toContain("export const initialState");
expect(ts).toContain("export async function compute");
expect(ts).toContain("workflow: null");
expect(ts).toContain("lastRun");
});
});
@@ -0,0 +1,107 @@
/**
* Tests for nerve create workflow scaffold logic.
*
* We test the file-generation path by isolating the template rendering,
* not by invoking the full citty command (which calls process.exit).
*/
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowScaffold } from "../commands/create.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-init-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("buildWorkflowScaffold", () => {
it("includes the workflow name in the main role content", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("my-workflow");
expect(roleMainIndexTs).toContain("my-workflow started");
});
it("root index contains WorkflowDefinition import from nerve-core", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("WorkflowDefinition");
expect(indexTs).toContain("@uncaged/nerve-core");
});
it("root index wires moderator with ThreadContext and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
expect(indexTs).toContain("ThreadContext");
expect(indexTs).toContain("ctx.steps.length");
expect(indexTs).toContain("END");
});
it("root index imports main role and sets name field", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain('name: "test"');
expect(indexTs).toContain("main: mainRole");
expect(indexTs).toContain("./roles/main/index.js");
});
it("main role module exports mainRole with ThreadContext", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
expect(roleMainIndexTs).toContain("ThreadContext");
expect(roleMainIndexTs).toContain("RoleResult");
expect(roleMainIndexTs).not.toContain("StartStep");
expect(roleMainIndexTs).not.toContain("WorkflowMessage");
});
it("uses different names per call", () => {
const a = buildWorkflowScaffold("workflow-a");
const b = buildWorkflowScaffold("workflow-b");
expect(a.roleMainIndexTs).toContain("workflow-a started");
expect(b.roleMainIndexTs).toContain("workflow-b started");
expect(a.roleMainIndexTs).not.toContain("workflow-b");
});
it("produces valid TypeScript syntax for index (no unclosed braces)", () => {
const { indexTs } = buildWorkflowScaffold("test");
const opens = (indexTs.match(/\{/g) ?? []).length;
const closes = (indexTs.match(/\}/g) ?? []).length;
expect(opens).toBe(closes);
});
it("ends root index with export default workflow", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs.trim().endsWith("export default workflow;")).toBe(true);
});
it("prompt markdown names the workflow", () => {
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
expect(roleMainPromptMd).toContain("# my-flow — main role");
});
});
describe("workflow scaffold file writing (simulated)", () => {
it("writes all scaffold files to disk correctly", () => {
const workflowDir = join(tmpDir, "workflows", "my-task");
mkdirSync(join(workflowDir, "roles", "main"), { recursive: true });
const scaffold = buildWorkflowScaffold("my-task");
writeFileSync(join(workflowDir, "index.ts"), scaffold.indexTs, "utf8");
writeFileSync(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs, "utf8");
writeFileSync(
join(workflowDir, "roles", "main", "prompt.md"),
scaffold.roleMainPromptMd,
"utf8",
);
expect(readFileSync(join(workflowDir, "index.ts"), "utf8")).toContain('name: "my-task"');
expect(readFileSync(join(workflowDir, "roles", "main", "index.ts"), "utf8")).toContain(
"my-task started",
);
expect(readFileSync(join(workflowDir, "roles", "main", "prompt.md"), "utf8")).toContain(
"# my-task — main role",
);
});
});
@@ -1,80 +0,0 @@
/**
* Compile-time check: daemon-types.ts stays in sync with @uncaged/nerve-daemon exports.
* If the daemon package changes its public API, this file will fail to compile.
*/
import type { SenseInfo } from "@uncaged/nerve-core";
import type {
ArchiveLogsDayResult as DaemonArchiveLogsDayResult,
ArchiveLogsOptions as DaemonArchiveLogsOptions,
ArchiveLogsResult as DaemonArchiveLogsResult,
LogEntry as DaemonLogEntry,
LogQuery as DaemonLogQuery,
LogStore as DaemonLogStore,
SenseInfo as DaemonSenseInfo,
WorkflowRun as DaemonWorkflowRun,
WorkflowRunStatus as DaemonWorkflowRunStatus,
} from "@uncaged/nerve-daemon";
import { describe, expectTypeOf, it } from "vitest";
import type {
ArchiveLogsDayResult,
ArchiveLogsOptions,
ArchiveLogsResult,
LogEntry,
LogQuery,
LogStore,
WorkflowRun,
WorkflowRunStatus,
} from "../daemon-types.js";
describe("daemon-types drift guard", () => {
it("SenseInfo matches daemon package export (list-senses IPC)", () => {
expectTypeOf<SenseInfo>().toMatchTypeOf<DaemonSenseInfo>();
expectTypeOf<DaemonSenseInfo>().toMatchTypeOf<SenseInfo>();
});
it("WorkflowRunStatus is assignable both ways", () => {
expectTypeOf<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>();
expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>();
});
it("WorkflowRun is assignable both ways", () => {
expectTypeOf<WorkflowRun>().toMatchTypeOf<DaemonWorkflowRun>();
expectTypeOf<DaemonWorkflowRun>().toMatchTypeOf<WorkflowRun>();
});
it("LogEntry is assignable both ways", () => {
expectTypeOf<LogEntry>().toMatchTypeOf<DaemonLogEntry>();
expectTypeOf<DaemonLogEntry>().toMatchTypeOf<LogEntry>();
});
it("LogQuery is assignable both ways", () => {
expectTypeOf<LogQuery>().toMatchTypeOf<DaemonLogQuery>();
expectTypeOf<DaemonLogQuery>().toMatchTypeOf<LogQuery>();
});
it("LogStore has all required methods", () => {
expectTypeOf<LogStore>().toMatchTypeOf<
Pick<
DaemonLogStore,
| "query"
| "getWorkflowRun"
| "getActiveWorkflowRuns"
| "getAllWorkflowRuns"
| "upsertWorkflowRun"
| "archiveLogs"
| "close"
>
>();
});
it("ArchiveLogs types match daemon", () => {
expectTypeOf<ArchiveLogsOptions>().toMatchTypeOf<DaemonArchiveLogsOptions>();
expectTypeOf<DaemonArchiveLogsOptions>().toMatchTypeOf<ArchiveLogsOptions>();
expectTypeOf<ArchiveLogsResult>().toMatchTypeOf<DaemonArchiveLogsResult>();
expectTypeOf<DaemonArchiveLogsResult>().toMatchTypeOf<ArchiveLogsResult>();
expectTypeOf<ArchiveLogsDayResult>().toMatchTypeOf<DaemonArchiveLogsDayResult>();
expectTypeOf<DaemonArchiveLogsDayResult>().toMatchTypeOf<ArchiveLogsDayResult>();
});
});
@@ -0,0 +1,183 @@
/**
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
*/
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineCommand, runCommand } from "citty";
import { afterEach, describe, expect, it } from "vitest";
import { createCommand } from "../commands/create.js";
import { initCommand } from "../commands/init.js";
const testRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e-create" },
subCommands: {
init: initCommand,
create: createCommand,
},
});
type CliRunResult = {
stdout: string;
stderr: string;
exitCode: number;
};
class ProcessExitError extends Error {
readonly code: number;
constructor(code: number) {
super(`process.exit(${String(code)})`);
this.name = "ProcessExitError";
this.code = code;
}
}
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
const orig = stream.write.bind(stream) as (
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => boolean;
stream.write = ((
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => {
if (typeof chunk === "string") {
sink.push(chunk);
} else {
sink.push(Buffer.from(chunk).toString("utf8"));
}
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
return true;
}
if (cb !== undefined) {
cb(null);
}
return true;
}) as typeof stream.write;
return () => {
stream.write = orig as typeof stream.write;
};
}
async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResult> {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
const prevHome = process.env.HOME;
process.env.HOME = fakeHome;
let exitCode = 0;
const origExit = process.exit;
process.exit = ((code?: number) => {
exitCode = typeof code === "number" ? code : 0;
throw new ProcessExitError(exitCode);
}) as typeof process.exit;
try {
await runCommand(testRootCommand, { rawArgs: args });
} catch (e) {
if (e instanceof ProcessExitError) {
exitCode = e.code;
} else {
exitCode = 1;
stderrChunks.push(e instanceof Error ? e.message : String(e));
}
} finally {
process.exit = origExit;
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
restoreOut();
restoreErr();
}
return {
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode,
};
}
describe("e2e create", () => {
let fakeHome: string | null = null;
afterEach(() => {
if (fakeHome !== null) {
rmSync(fakeHome, { recursive: true, force: true });
fakeHome = null;
}
});
it(
"create workflow scaffolds sources and root build emits dist/workflows/<name>/index.js",
{ timeout: 120_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const wfDir = join(nerveRoot, "workflows", "e2e-flow");
const indexPath = join(wfDir, "index.ts");
const mainRolePath = join(wfDir, "roles", "main", "index.ts");
expect(existsSync(join(wfDir, "package.json"))).toBe(false);
expect(existsSync(indexPath)).toBe(true);
expect(existsSync(mainRolePath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
expect(existsSync(join(nerveRoot, "dist", "workflows", "e2e-flow", "index.js"))).toBe(true);
},
);
it(
"create sense scaffolds src/ and root build emits dist/senses/<name>/index.js",
{ timeout: 120_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
expect(sense.exitCode).toBe(0);
expect(sense.stdout).toContain("✅");
const base = join(nerveRoot, "senses", "e2e-sense");
expect(existsSync(join(base, "package.json"))).toBe(false);
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true);
},
);
it(
"create workflow exits 1 when directory exists without --force",
{ timeout: 10_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(join(nerveRoot, "workflows", "dup-wf"), { recursive: true });
writeFileSync(join(nerveRoot, "workflows", "dup-wf", "index.ts"), "// x", "utf8");
const first = await runTestCli(fakeHome, ["create", "workflow", "dup-wf"]);
expect(first.exitCode).toBe(1);
expect(first.stderr).toContain("already exists");
},
);
});
@@ -0,0 +1,71 @@
/**
* E2E: daemon start / status / stop lifecycle against the in-process test harness.
*
* Does not invoke the `stop` CLI while the harness PID file points at the current process
* (that would SIGTERM the test runner). After `status` shows running, we stop the kernel
* and remove `nerve.pid`, then assert `status` reports not running; `afterEach` tears down
* the temp HOME.
*/
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
describe("e2e daemon lifecycle", () => {
let daemon: TestDaemonHandle | null = null;
let kernelAlreadyStopped = false;
afterEach(async () => {
const h = daemon;
const skipKernel = kernelAlreadyStopped;
daemon = null;
kernelAlreadyStopped = false;
if (h === null) return;
await Promise.race([
stopTestDaemon(h, skipKernel),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it(
"nerve.pid + kernel up, status running, then stopped + pid cleared, status not running",
{ timeout: 30_000 },
async () => {
daemon = await startTestDaemon();
const pidPath = join(daemon.nerveRoot, "nerve.pid");
expect(existsSync(pidPath)).toBe(true);
const health = daemon.kernel.getHealth();
expect(health.activeGroups).toBeGreaterThan(0);
expect(daemon.kernel.getWorkerPid("e2e")).not.toBeNull();
expect(existsSync(daemon.socketPath)).toBe(true);
const statusUp = await runCli(daemon, ["daemon", "status"]);
expect(statusUp.exitCode).toBe(0);
expect(statusUp.stdout).toContain("running");
expect(statusUp.stdout).toContain("✅ Nerve daemon is running.");
const statusTopLevel = await runCli(daemon, ["status"]);
expect(statusTopLevel.exitCode).toBe(0);
expect(statusTopLevel.stdout).toContain("running");
await daemon.kernel.stop();
kernelAlreadyStopped = true;
unlinkSync(pidPath);
expect(existsSync(pidPath)).toBe(false);
expect(existsSync(daemon.socketPath)).toBe(false);
const statusDown = await runCli(daemon, ["daemon", "status"]);
expect(statusDown.exitCode).toBe(0);
expect(statusDown.stdout).toContain("not running");
expect(statusDown.stdout).toContain("😴 Nerve daemon is not running.");
},
);
});
+439
View File
@@ -0,0 +1,439 @@
/**
* Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker,
* IPC socket for CLI, and `runCommand` helpers with captured stdio.
*
* Stateful senses persist JSON under `data/senses/<name>.json` (see sense-worker).
*
* ## Timeout guard (vitest)
*
* Always tear down the daemon in `afterEach` so a failed assertion does not leave a
* kernel and worker children running. Optionally race `stopTestDaemon` against a timer
* so CI does not hang if shutdown stalls:
*
* ```ts
* import { afterEach } from "vitest";
* import { stopTestDaemon, type TestDaemonHandle } from "./e2e-harness.js";
*
* let daemon: TestDaemonHandle | null = null;
*
* afterEach(async () => {
* const h = daemon;
* daemon = null;
* if (h === null) return;
* await Promise.race([
* stopTestDaemon(h),
* new Promise<never>((_, reject) =>
* setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
* ),
* ]);
* });
* ```
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
import type { Kernel } from "@uncaged/nerve-daemon";
import { defineCommand, runCommand } from "citty";
import { daemonCommand } from "../commands/daemon.js";
import { logsCommand } from "../commands/logs.js";
import { senseCommand } from "../commands/sense.js";
import { statusCommand } from "../commands/status.js";
import { stopCommand } from "../commands/stop.js";
import { storeCommand } from "../commands/store.js";
import { threadCommand } from "../commands/thread.js";
import { workflowCommand } from "../commands/workflow.js";
const require = createRequire(import.meta.url);
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
const nerveYamlTemplate = `senses:
counter:
group: e2e
workflows:
echo:
concurrency: 1
overflow: queue
max_queue: 10
max_rounds: 10
api:
port: null
token: null
host: 127.0.0.1
`;
/**
* Minimal echo workflow (one role round then END).
* Short delay in the role so two sequential CLI triggers can observe a queued run while the first is active.
*/
const echoWorkflowIndexJs = `const END = "__end__";
export default {
name: "echo",
roles: {
echo: async (ctx) => {
await new Promise((r) => setTimeout(r, 350));
const p = typeof ctx.start.content === "string" ? ctx.start.content : "";
return {
content: p.length > 0 ? "echo:" + p : "echo:empty",
meta: {},
};
},
},
moderator({ steps }) {
if (steps.length === 0) return "echo";
return END;
},
};
`;
const nerveYamlWithNoopWorkflow = `senses:
counter:
group: e2e
workflows:
noop:
concurrency: 1
overflow: drop
max_rounds: 10
api:
port: null
token: null
host: 127.0.0.1
`;
/** Minimal counter sense — each compute increments \`count\` in persisted state. */
const counterIndexJs = `export const initialState = { count: 0 };
export async function compute(state) {
return {
state: { count: state.count + 1 },
workflow: null,
};
}
`;
/** First trigger launches local noop workflow; later triggers only advance idleTicks. */
const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 };
export async function compute(state) {
if (!state.launched) {
return {
state: { launched: true, idleTicks: state.idleTicks },
workflow: {
name: "noop",
maxRounds: 3,
prompt: "e2e-archive",
dryRun: false,
},
};
}
return {
state: { launched: state.launched, idleTicks: state.idleTicks + 1 },
workflow: null,
};
}
`;
/** Minimal workflow: moderator ends immediately (no role rounds). */
const noopWorkflowIndexJs = `const END = "__end__";
export default {
name: "noop",
roles: {
bot: async () => ({ content: "ok", meta: {} }),
},
moderator: () => END,
};
`;
const e2eRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e" },
subCommands: {
sense: senseCommand,
logs: logsCommand,
daemon: daemonCommand,
status: statusCommand,
stop: stopCommand,
workflow: workflowCommand,
store: storeCommand,
thread: threadCommand,
},
});
function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
return {
senses: {
counter: {
group: "e2e",
throttle: null,
timeout: null,
gracePeriod: null,
interval: null,
on: [],
},
},
workflows: {
echo: { concurrency: 1, overflow: "queue" as const, maxQueue: 10 },
...(withNoopWorkflow ? { noop: { concurrency: 1, overflow: "drop" as const } } : {}),
},
maxRounds: 10,
extract: null,
api: { port: null, token: null, host: "127.0.0.1" },
};
}
function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): void {
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true });
mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true });
writeFileSync(
join(nerveRoot, "nerve.yaml"),
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
"utf8",
);
writeFileSync(
join(nerveRoot, "dist", "senses", "counter", "index.js"),
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
"utf8",
);
writeFileSync(
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
echoWorkflowIndexJs,
"utf8",
);
if (withNoopWorkflow) {
mkdirSync(join(nerveRoot, "dist", "workflows", "noop"), { recursive: true });
writeFileSync(
join(nerveRoot, "dist", "workflows", "noop", "index.js"),
noopWorkflowIndexJs,
"utf8",
);
}
linkWorkspaceDaemonIntoNerveRoot(nerveRoot);
}
export type TestDaemonHandle = {
fakeHome: string;
nerveRoot: string;
socketPath: string;
kernel: Kernel;
};
export type StartTestDaemonOpts = {
/**
* When true, counter sense's first compute launches a local `noop` workflow (real
* workflow-worker child). Requires built `workflow-worker.js` next to `sense-worker.js`.
*/
withNoopWorkflow: boolean;
} | null;
function useNoopWorkflow(opts: StartTestDaemonOpts): boolean {
return opts !== null && opts.withNoopWorkflow === true;
}
/**
* Symlink workspace `@uncaged/nerve-daemon` into `<nerveRoot>/node_modules` so
* `loadDaemonModule(nerveRoot)` resolves for `nerve store` / `nerve thread` in e2e.
*/
export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void {
const daemonPkgRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const nm = join(nerveRoot, "node_modules");
mkdirSync(nm, { recursive: true });
const linkDir = join(nm, "@uncaged");
mkdirSync(linkDir, { recursive: true });
const linkPath = join(linkDir, "nerve-daemon");
if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath);
}
/**
* Poll until predicate returns true, or reject after `timeoutMs`.
* (Same idea as `packages/daemon/src/__tests__/kernel-integration.test.ts`.)
*/
export async function pollUntil(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 50,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`pollUntil timed out after ${String(timeoutMs)}ms`)),
timeoutMs,
);
const check = setInterval(() => {
if (predicate()) {
clearTimeout(timer);
clearInterval(check);
resolve();
}
}, intervalMs);
});
}
/**
* Creates `fakeHome`, lays out `fakeHome/.uncaged-nerve` (nerve.yaml + counter sense),
* starts a real kernel (sense-worker child + IPC on `nerve.sock`), writes `nerve.pid`
* to the current test process so `isRunning()` succeeds under that HOME, and awaits
* `kernel.ready`.
*/
export async function startTestDaemon(
_opts: StartTestDaemonOpts = null,
): Promise<TestDaemonHandle> {
const withNoop = useNoopWorkflow(_opts);
if (!existsSync(senseWorkerScript)) {
throw new Error(
`Missing "${senseWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\` (cli package "pretest" runs this automatically).`,
);
}
if (!existsSync(workflowWorkerScript)) {
throw new Error(
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
);
}
const fakeHome = mkdtempSync(join(tmpdir(), "nerve-cli-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
writeWorkspaceLayout(nerveRoot, withNoop);
const config = defaultTestConfig(withNoop);
const socketPath = join(nerveRoot, "nerve.sock");
const kernel = createKernel(config, nerveRoot, {
workerScript: senseWorkerScript,
ipcSocketPath: socketPath,
enableFileWatcher: false,
});
await kernel.ready;
writeFileSync(join(nerveRoot, "nerve.pid"), String(process.pid), "utf8");
return { fakeHome, nerveRoot, socketPath, kernel };
}
/**
* Stops the kernel (workers + IPC) and removes the temp HOME tree.
*
* @param kernelAlreadyStopped — pass `true` when the test already called `kernel.stop()`
* (e.g. daemon lifecycle e2e); only the temp directory is removed.
*/
export async function stopTestDaemon(
handle: TestDaemonHandle,
kernelAlreadyStopped = false,
): Promise<void> {
if (!kernelAlreadyStopped) {
await handle.kernel.stop();
}
try {
rmSync(handle.fakeHome, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
export type CliRunResult = {
stdout: string;
stderr: string;
exitCode: number;
};
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
const orig = stream.write.bind(stream) as (
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => boolean;
stream.write = ((
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => {
if (typeof chunk === "string") {
sink.push(chunk);
} else {
sink.push(Buffer.from(chunk).toString("utf8"));
}
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
return true;
}
if (cb !== undefined) {
cb(null);
}
return true;
}) as typeof stream.write;
return () => {
stream.write = orig as typeof stream.write;
};
}
/**
* Runs `nerve <args>` for the subset wired in `e2eRootCommand` (`sense`, `logs`, `daemon`,
* `status`, `stop`, `store`, `workflow`, `thread`), with `process.env.HOME` pointing at `handle.fakeHome`
* so `getNerveRoot()` resolves to the test workspace. Captures stdout/stderr; sets `exitCode`
* when `process.exit` is invoked or on thrown errors.
*/
export async function runCli(handle: TestDaemonHandle, args: string[]): Promise<CliRunResult> {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
const prevHome = process.env.HOME;
process.env.HOME = handle.fakeHome;
let exitCode = 0;
const origExit = process.exit;
process.exit = ((code?: number) => {
exitCode = typeof code === "number" ? code : 0;
throw new ProcessExitError(exitCode);
}) as typeof process.exit;
try {
await runCommand(e2eRootCommand, { rawArgs: args });
} catch (e) {
if (e instanceof ProcessExitError) {
exitCode = e.code;
} else {
exitCode = 1;
stderrChunks.push(e instanceof Error ? e.message : String(e));
}
} finally {
process.exit = origExit;
if (prevHome === undefined) {
process.env.HOME = undefined;
} else {
process.env.HOME = prevHome;
}
restoreOut();
restoreErr();
}
return {
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode,
};
}
class ProcessExitError extends Error {
readonly code: number;
constructor(code: number) {
super(`process.exit(${String(code)})`);
this.name = "ProcessExitError";
this.code = code;
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* E2E test for `nerve logs` command (#161).
*
* The logs command reads from a plain text log file at
* `<nerveRoot>/logs/nerve.log`. Since the e2e harness starts the kernel
* in-process (not as a detached daemon), no log file is created automatically.
* We manually write test log content to the expected path.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
/** Generate N log lines for testing. */
function generateLogLines(count: number): string[] {
const lines: string[] = [];
for (let i = 1; i <= count; i++) {
const ts = new Date(Date.UTC(2025, 0, 1, 0, 0, i)).toISOString();
lines.push(`${ts} [INFO] log entry ${i}`);
}
return lines;
}
/** Write fake log content to the daemon log path. */
function writeTestLogFile(nerveRoot: string, lines: string[]): void {
const logsDir = join(nerveRoot, "logs");
mkdirSync(logsDir, { recursive: true });
writeFileSync(join(logsDir, "nerve.log"), `${lines.join("\n")}\n`, "utf8");
}
describe("e2e logs", () => {
let daemon: TestDaemonHandle | null = null;
afterEach(async () => {
const h = daemon;
daemon = null;
if (h === null) return;
await Promise.race([
stopTestDaemon(h),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it("shows log file not found when no log exists", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
// No log file written — command should fail
const result = await runCli(daemon, ["logs"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Log file not found");
});
it("displays last N lines (tail mode)", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
const lines = generateLogLines(10);
writeTestLogFile(daemon.nerveRoot, lines);
// Default: last 50 lines, but we only have 10
const result = await runCli(daemon, ["logs"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("log entry 1");
expect(result.stdout).toContain("log entry 10");
expect(result.stdout).toContain("lines 1-10 of 10");
});
it("respects -n flag to limit lines", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
const lines = generateLogLines(20);
writeTestLogFile(daemon.nerveRoot, lines);
// Request only last 5 lines
const result = await runCli(daemon, ["logs", "-n", "5"]);
expect(result.exitCode).toBe(0);
// Should show lines 16-20 (last 5)
expect(result.stdout).toContain("log entry 16");
expect(result.stdout).toContain("log entry 20");
expect(result.stdout).toContain("lines 16-20 of 20");
// Should NOT contain earlier lines
expect(result.stdout).not.toContain("log entry 1\n");
expect(result.stdout).not.toContain("log entry 15\n");
});
it("supports --offset for pagination", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
const lines = generateLogLines(20);
writeTestLogFile(daemon.nerveRoot, lines);
// Offset 5 means start at line 5, show default 50 (will get 5-20)
const result = await runCli(daemon, ["logs", "--offset", "5", "-n", "5"]);
expect(result.exitCode).toBe(0);
// Should show lines 5-9
expect(result.stdout).toContain("log entry 5");
expect(result.stdout).toContain("log entry 9");
expect(result.stdout).toContain("lines 5-9 of 20");
});
it("shows pagination hint when earlier lines exist", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
const lines = generateLogLines(100);
writeTestLogFile(daemon.nerveRoot, lines);
// Request last 10 lines — there should be a "previous page" hint
const result = await runCli(daemon, ["logs", "-n", "10"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Earlier lines available");
expect(result.stdout).toContain("nerve logs --offset");
});
it("shows empty message for empty log file", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
// Write an empty log file
const logsDir = join(daemon.nerveRoot, "logs");
mkdirSync(logsDir, { recursive: true });
writeFileSync(join(logsDir, "nerve.log"), "", "utf8");
const result = await runCli(daemon, ["logs"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Log file is empty");
});
});
@@ -0,0 +1,57 @@
/**
* Smoke test: start a real daemon with a counter sense, trigger it,
* then verify CLI lists the sense and state file is written.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
type TestDaemonHandle,
pollUntil,
runCli,
startTestDaemon,
stopTestDaemon,
} from "./e2e-harness.js";
describe("e2e smoke", () => {
let daemon: TestDaemonHandle | null = null;
afterEach(async () => {
const h = daemon;
daemon = null;
if (h === null) return;
await Promise.race([
stopTestDaemon(h),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it("sense list after trigger and persisted counter state", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
expect(triggerResult.stdout).toContain("Triggered");
const statePath = join(daemon.nerveRoot, "data", "senses", "counter.json");
await pollUntil(() => {
try {
const raw = readFileSync(statePath, "utf8");
const parsed = JSON.parse(raw) as { count?: number };
return typeof parsed.count === "number" && parsed.count >= 1;
} catch {
return false;
}
}, 10_000);
const listResult = await runCli(daemon, ["sense", "list"]);
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout).toContain("counter");
expect(listResult.stdout).not.toContain("last signal");
});
});
@@ -0,0 +1,132 @@
/**
* E2E: `nerve store archive` against a real daemon + logs.db (issue #163).
*
* Archive eligibility is by `logs.timestamp` (ms; there is no `created_at` column);
* RFC-001 §5.4 cold-archive uses UTC days.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import {
type TestDaemonHandle,
linkWorkspaceDaemonIntoNerveRoot,
pollUntil,
runCli,
startTestDaemon,
stopTestDaemon,
} from "./e2e-harness.js";
/** Wall time safely outside the 30-day hot window (RFC-001 archive). */
const ARCHIVED_TEST_TS = Date.UTC(2020, 5, 15, 12, 0, 0);
const EXPECTED_ARCHIVE_DAY = "2020-06-15";
describe("e2e store archive", () => {
let daemon: TestDaemonHandle | null = null;
afterEach(async () => {
const h = daemon;
daemon = null;
if (h === null) return;
await Promise.race([
stopTestDaemon(h),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it(
"archives old workflow logs to JSONL, removes rows from logs, thread list still reads workflow_runs",
{ timeout: 60_000 },
async () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
await pollUntil(() => {
if (!existsSync(logsDb)) return false;
try {
const db = new DatabaseSync(logsDb, { readOnly: true });
const row = db
.prepare(
"SELECT COUNT(*) AS c FROM logs WHERE source = 'workflow' AND type = 'completed'",
)
.get() as { c: number } | undefined;
db.close();
return (row?.c ?? 0) > 0;
} catch {
return false;
}
}, 20_000);
const dbMut = new DatabaseSync(logsDb);
dbMut.exec(`UPDATE logs SET timestamp = ${String(ARCHIVED_TEST_TS)} WHERE 1=1`);
dbMut.close();
const archiveResult = await runCli(daemon, ["store", "archive"]);
expect(archiveResult.exitCode).toBe(0);
expect(archiveResult.stdout).toContain("✅ Archived");
expect(archiveResult.stdout).toContain("rows=");
expect(archiveResult.stdout).toContain(EXPECTED_ARCHIVE_DAY);
const dbAfter = new DatabaseSync(logsDb, { readOnly: true });
const logCountRow = dbAfter.prepare("SELECT COUNT(*) AS c FROM logs").get() as { c: number };
dbAfter.close();
expect(logCountRow.c).toBe(0);
const archiveDir = join(daemon.nerveRoot, "data", "archive", "logs");
expect(existsSync(archiveDir)).toBe(true);
const names = readdirSync(archiveDir);
expect(names.some((n) => n === `${EXPECTED_ARCHIVE_DAY}.jsonl`)).toBe(true);
const jsonlPath = join(archiveDir, `${EXPECTED_ARCHIVE_DAY}.jsonl`);
const jsonl = readFileSync(jsonlPath, "utf8");
expect(jsonl.length).toBeGreaterThan(0);
expect(jsonl).toContain('"source":"workflow"');
const listResult = await runCli(daemon, ["thread", "list", "--all"]);
expect(listResult.exitCode).toBe(0);
// workflow_runs is not pruned by archive — list may still show completed runs; hot logs are empty.
expect(listResult.stdout).toContain("noop");
},
);
it("store archive --vacuum completes VACUUM after archiving", { timeout: 60_000 }, async () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
await pollUntil(() => {
if (!existsSync(logsDb)) return false;
try {
const db = new DatabaseSync(logsDb, { readOnly: true });
const row = db
.prepare(
"SELECT COUNT(*) AS c FROM logs WHERE source = 'workflow' AND type = 'completed'",
)
.get() as { c: number } | undefined;
db.close();
return (row?.c ?? 0) > 0;
} catch {
return false;
}
}, 20_000);
const dbMut = new DatabaseSync(logsDb);
dbMut.exec(`UPDATE logs SET timestamp = ${String(ARCHIVED_TEST_TS)} WHERE 1=1`);
dbMut.close();
const archiveVac = await runCli(daemon, ["store", "archive", "--vacuum"]);
expect(archiveVac.exitCode).toBe(0);
expect(archiveVac.stdout).toContain("VACUUM completed.");
});
});
@@ -0,0 +1,247 @@
/**
* E2E tests for `nerve validate` and `nerve init` commands (#162).
* No running daemon needed — just temp dirs with HOME manipulation.
*/
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineCommand, runCommand } from "citty";
import { afterEach, describe, expect, it } from "vitest";
import { initCommand } from "../commands/init.js";
import { validateCommand } from "../commands/validate.js";
const testRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e-validate-init" },
subCommands: {
validate: validateCommand,
init: initCommand,
},
});
type CliRunResult = {
stdout: string;
stderr: string;
exitCode: number;
};
class ProcessExitError extends Error {
readonly code: number;
constructor(code: number) {
super(`process.exit(${String(code)})`);
this.name = "ProcessExitError";
this.code = code;
}
}
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
const orig = stream.write.bind(stream) as (
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => boolean;
stream.write = ((
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
cb?: (err: Error | null | undefined) => void,
) => {
if (typeof chunk === "string") {
sink.push(chunk);
} else {
sink.push(Buffer.from(chunk).toString("utf8"));
}
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
return true;
}
if (cb !== undefined) {
cb(null);
}
return true;
}) as typeof stream.write;
return () => {
stream.write = orig as typeof stream.write;
};
}
async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResult> {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
const prevHome = process.env.HOME;
process.env.HOME = fakeHome;
let exitCode = 0;
const origExit = process.exit;
process.exit = ((code?: number) => {
exitCode = typeof code === "number" ? code : 0;
throw new ProcessExitError(exitCode);
}) as typeof process.exit;
try {
await runCommand(testRootCommand, { rawArgs: args });
} catch (e) {
if (e instanceof ProcessExitError) {
exitCode = e.code;
} else {
exitCode = 1;
stderrChunks.push(e instanceof Error ? e.message : String(e));
}
} finally {
process.exit = origExit;
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
restoreOut();
restoreErr();
}
return {
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
exitCode,
};
}
const VALID_NERVE_YAML = `senses:
counter:
group: e2e
workflows: {}
max_rounds: 10
api:
port: null
token: null
host: 127.0.0.1
`;
describe("e2e validate", () => {
let fakeHome: string | null = null;
afterEach(() => {
if (fakeHome !== null) {
rmSync(fakeHome, { recursive: true, force: true });
fakeHome = null;
}
});
it("exits 0 for valid nerve.yaml", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "nerve.yaml"), VALID_NERVE_YAML, "utf8");
const result = await runTestCli(fakeHome, ["validate"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("✅");
expect(result.stdout).toContain("valid");
});
it("exits 1 for invalid nerve.yaml", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "nerve.yaml"), "not: valid: yaml: {{{\n", "utf8");
const result = await runTestCli(fakeHome, ["validate"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toBeTruthy();
});
it("exits 1 for malformed config (missing required fields)", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "nerve.yaml"), "foo: bar\n", "utf8");
const result = await runTestCli(fakeHome, ["validate"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("❌");
});
it("exits 1 when nerve.yaml is missing", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
// Don't create .uncaged-nerve at all
const result = await runTestCli(fakeHome, ["validate"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toBeTruthy();
});
});
describe("e2e init", () => {
let fakeHome: string | null = null;
afterEach(() => {
if (fakeHome !== null) {
rmSync(fakeHome, { recursive: true, force: true });
fakeHome = null;
}
});
it("init --force creates workspace layout", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
const result = await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
// init should exit 0 (install/git failures are warnings, not fatal)
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("✅");
// Verify key files exist
expect(existsSync(join(nerveRoot, "nerve.yaml"))).toBe(true);
expect(existsSync(join(nerveRoot, "package.json"))).toBe(true);
expect(existsSync(join(nerveRoot, "scripts", "build.mjs"))).toBe(true);
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
expect(existsSync(join(nerveRoot, "AGENT.md"))).toBe(true);
const agentMd = readFileSync(join(nerveRoot, "AGENT.md"), "utf8");
expect(agentMd).toContain("verb-first");
expect(agentMd).toContain("createRole");
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
expect(pkgJson).not.toContain("nerve-skills");
expect(pkgJson).toContain('"build": "node scripts/build.mjs"');
expect(pkgJson).toContain('"esbuild": "^0.27.0"');
const buildScript = readFileSync(join(nerveRoot, "scripts", "build.mjs"), "utf8");
expect(buildScript).toContain('path.join(root, "senses")');
expect(buildScript).toContain('path.join(root, "workflows")');
expect(buildScript).toContain("dist");
});
it("generated nerve.yaml passes validate", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
const validateResult = await runTestCli(fakeHome, ["validate"]);
expect(validateResult.exitCode).toBe(0);
expect(validateResult.stdout).toContain("✅");
expect(validateResult.stdout).toContain("valid");
});
it("init without --force on existing dir exits 1", { timeout: 10_000 }, async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "marker"), "exists", "utf8");
const result = await runTestCli(fakeHome, ["init"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("already exists");
});
});
@@ -0,0 +1,122 @@
/**
* E2E (issue #160): real kernel + workflow worker + IPC, then CLI `workflow` / `thread`
* against logs.db. Run listings live on `nerve thread list` (there is no `nerve workflow runs` subcommand).
*/
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-store";
import { afterEach, describe, expect, it } from "vitest";
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
async function waitForCompletedEchoRuns(
logsDbPath: string,
minCount: number,
timeoutMs: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const store = createLogStore(logsDbPath);
try {
const done = store.getAllWorkflowRuns("echo").filter((r) => r.status === "completed");
if (done.length >= minCount) return;
} finally {
store.close();
}
await new Promise((r) => setTimeout(r, 40));
}
throw new Error(
`Timed out after ${String(timeoutMs)}ms waiting for ${String(minCount)} completed echo run(s)`,
);
}
describe("e2e workflow CLI (real daemon)", () => {
let daemon: TestDaemonHandle | null = null;
afterEach(async () => {
const h = daemon;
daemon = null;
if (h === null) return;
await Promise.race([
stopTestDaemon(h),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
),
]);
});
it(
"trigger, thread list / --all, inspect, show (echo workflow)",
{ timeout: 30_000 },
async () => {
daemon = await startTestDaemon();
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
const t1 = await runCli(daemon, [
"workflow",
"trigger",
"echo",
"--prompt",
"alpha-e2e",
"--max-rounds",
"10",
]);
expect(t1.exitCode).toBe(0);
expect(t1.stdout).toContain("Triggered");
const t2 = await runCli(daemon, [
"workflow",
"trigger",
"echo",
"--prompt",
"beta-e2e",
"--max-rounds",
"10",
]);
expect(t2.exitCode).toBe(0);
const activeRightAfter = await runCli(daemon, ["thread", "list"]);
expect(activeRightAfter.exitCode).toBe(0);
expect(activeRightAfter.stdout).toContain("echo");
expect(activeRightAfter.stdout).toMatch(/queued|started/);
await waitForCompletedEchoRuns(logsDb, 2, 25_000);
const store = createLogStore(logsDb);
let runId: string;
try {
const completed = store
.getAllWorkflowRuns("echo")
.filter((r) => r.status === "completed")
.sort((a, b) => a.timestamp - b.timestamp);
expect(completed.length).toBeGreaterThanOrEqual(2);
runId = completed[0]?.runId ?? "";
expect(runId.length).toBeGreaterThan(0);
} finally {
store.close();
}
const listAll = await runCli(daemon, ["thread", "list", "--all"]);
expect(listAll.exitCode).toBe(0);
expect(listAll.stdout).toContain(runId);
expect(listAll.stdout).toContain("✅");
expect(listAll.stdout).toContain("workflow=echo");
const listDefault = await runCli(daemon, ["thread", "list"]);
expect(listDefault.exitCode).toBe(0);
expect(listDefault.stdout).toMatch(/No active workflow runs|📭 No active/);
const inspect = await runCli(daemon, ["thread", "inspect", runId, "--limit", "50"]);
expect(inspect.exitCode).toBe(0);
expect(inspect.stdout).toContain(`Workflow run: ${runId}`);
expect(inspect.stdout).toContain("type=started");
expect(inspect.stdout).toContain("type=completed");
const show = await runCli(daemon, ["thread", "show", runId, "--budget", "50000"]);
expect(show.exitCode).toBe(0);
expect(show.stdout).toContain("echo:alpha-e2e");
expect(show.stdout).toContain("[#1 echo]");
},
);
});
+32
View File
@@ -0,0 +1,32 @@
# nerve daemon — E2E Scenarios
## daemon start
- ✅ starts daemon, writes PID file, kernel comes up (via lifecycle test)
- 🔲 start when already running — error or no-op
- 🔲 start with `--foreground` flag
## daemon stop
- ✅ stops daemon, clears PID file (via lifecycle test)
- 🔲 stop when not running — graceful message
## daemon status
- ✅ reports "running" when daemon is up, "not running" when stopped (via lifecycle test)
- 🔲 `--json` output
## daemon restart
- 🔲 restarts daemon — stop + start round-trip
- 🔲 restart when not running — starts fresh
## daemon logs (nerve logs)
- ✅ shows "log file not found" when no log exists
- ✅ displays last N lines (tail mode)
- ✅ respects `-n` flag to limit lines
- ✅ supports `--offset` for pagination
- ✅ shows pagination hint when earlier lines exist
- ✅ shows empty message for empty log file
- 🔲 `--follow` / `-f` streams new lines
+5
View File
@@ -0,0 +1,5 @@
# nerve dev — E2E Scenarios
- 🔲 runs foreground kernel session with hot-reload
- 🔲 exits cleanly on Ctrl+C / SIGINT
- 🔲 detects sense file changes and reloads
+14
View File
@@ -0,0 +1,14 @@
# nerve init — E2E Scenarios
## init (workspace)
-`--force` creates workspace layout (nerve.yaml, senses/, workflows/)
- ✅ generated nerve.yaml passes validate
- ✅ init without `--force` on existing dir exits 1
- 🔲 init in empty dir without `--force` — succeeds
- 🔲 `--from <git-url>` clones and sets up workspace
## create workflow / create sense
- 🔲 `nerve create workflow <name>` scaffolds under workflows/
- 🔲 `nerve create sense <name>` scaffolds under senses/
+36
View File
@@ -0,0 +1,36 @@
# nerve remote — E2E Scenarios
## remote add
- 🔲 adds a named remote, persists to config
- 🔲 duplicate name — error message
## remote list
- 🔲 lists all remotes with name and host
- 🔲 empty state — no remotes
## remote show
- 🔲 shows remote details (host, token masked)
- 🔲 non-existent remote — error message
## remote set-url
- 🔲 updates remote host
- 🔲 non-existent remote — error message
## remote set-token
- 🔲 updates remote token
- 🔲 non-existent remote — error message
## remote remove
- 🔲 removes a remote
- 🔲 non-existent remote — error message
## remote default
- 🔲 set default remote
- 🔲 show current default
+13
View File
@@ -0,0 +1,13 @@
# nerve sense — E2E Scenarios
## sense list
- ✅ prints sense list with name, group, throttle, triggers
- 🔲 empty state — no senses registered, prints empty message
- 🔲 `--json` — outputs valid JSON array
## sense trigger
- ✅ trigger known sense exits 0, stdout contains "Triggered"
- ✅ trigger non-existent sense writes error to stderr and exits 1
- ✅ sends correct IPC message `{ type: trigger-sense, sense: <name> }` to daemon
+6
View File
@@ -0,0 +1,6 @@
# nerve smoke — E2E Scenarios
Full round-trip integration tests that exercise multiple subcommands together.
- ✅ sense list after trigger — daemon lists configured senses; trigger queues a compute (state persisted under `data/senses/` by the worker)
- 🔲 init → dev → trigger workflow → thread inspect round-trip
+8
View File
@@ -0,0 +1,8 @@
# nerve store — E2E Scenarios
## store archive
- ✅ archives old workflow logs to JSONL, removes rows from logs DB, thread list still reads workflow_runs
-`--vacuum` completes VACUUM after archiving
- 🔲 archive with no old data — exits cleanly with "nothing to archive"
- 🔲 archive with custom `--before` date filter
+24
View File
@@ -0,0 +1,24 @@
# nerve thread — E2E Scenarios
## thread list
-`--all` completes without throwing
- 🔲 lists active threads with run ID, workflow name, status
- 🔲 empty state — no threads
## thread inspect
- ✅ inspect `<runId>` completes without throwing
- 🔲 inspect non-existent runId — error message
- 🔲 output contains workflow name, roles, round count
## thread show
- ✅ show `<runId>` completes without throwing (role rounds path)
- 🔲 show non-existent runId — error message
- 🔲 output contains conversation messages
## thread kill
- 🔲 kill active thread — exits 0, thread stops
- 🔲 kill non-existent thread — error message
@@ -0,0 +1,7 @@
# nerve validate — E2E Scenarios
- ✅ exits 0 for valid nerve.yaml
- ✅ exits 1 for invalid nerve.yaml
- ✅ exits 1 for malformed config (missing required fields)
- ✅ exits 1 when nerve.yaml is missing
- 🔲 `--json` outputs structured validation errors
@@ -0,0 +1,17 @@
# nerve workflow — E2E Scenarios
## workflow list
- 🔲 lists registered workflows with name and status
- 🔲 empty state — no workflows registered
## workflow status
- 🔲 shows status of a specific workflow
- 🔲 non-existent workflow — error message
## workflow trigger
- ✅ trigger + thread list/inspect/show round-trip (echo workflow)
- 🔲 trigger non-existent workflow — error message
- 🔲 trigger with `--input` JSON payload
@@ -0,0 +1,24 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { listKnowledgeFiles } from "../knowledge/glob-files.js";
describe("listKnowledgeFiles", () => {
it("includes matching paths and applies exclude globs", () => {
const root = mkdtempSync(join(tmpdir(), "nerve-glob-"));
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "src", "keep.ts"), "export function x() {}\n");
writeFileSync(join(root, "src", "drop.test.ts"), "// test\n");
const files = listKnowledgeFiles(root, {
include: ["src/**/*.ts"],
exclude: ["**/*.test.ts"],
});
expect(files).toContain("src/keep.ts");
expect(files).not.toContain("src/drop.test.ts");
});
});
@@ -1,81 +0,0 @@
/**
* Tests for nerve init workflow scaffold logic.
*
* We test the file-generation path by isolating the template rendering,
* not by invoking the full citty command (which calls process.exit).
*/
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowTemplate } from "../commands/init.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-init-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("buildWorkflowTemplate", () => {
it("includes the workflow name in the template", () => {
const tpl = buildWorkflowTemplate("my-workflow");
expect(tpl).toContain("my-workflow started");
});
it("contains WorkflowDefinition type import", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("WorkflowDefinition");
expect(tpl).toContain("@uncaged/nerve-daemon");
});
it("contains a moderate function that returns null to signal completion", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("return null");
expect(tpl).toContain("moderate");
});
it("contains a roles map with main role", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("roles:");
expect(tpl).toContain("main:");
});
it("uses different names per call", () => {
const a = buildWorkflowTemplate("workflow-a");
const b = buildWorkflowTemplate("workflow-b");
expect(a).toContain("workflow-a started");
expect(b).toContain("workflow-b started");
expect(a).not.toContain("workflow-b");
});
it("produces valid TypeScript syntax (no unclosed braces)", () => {
const tpl = buildWorkflowTemplate("test");
const opens = (tpl.match(/\{/g) ?? []).length;
const closes = (tpl.match(/\}/g) ?? []).length;
expect(opens).toBe(closes);
});
it("ends with export default workflow", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl.trim().endsWith("export default workflow;")).toBe(true);
});
});
describe("workflow scaffold file writing (simulated)", () => {
it("writes the template to disk correctly", () => {
const { mkdirSync, writeFileSync } = require("node:fs");
const workflowDir = join(tmpDir, "workflows", "my-task");
mkdirSync(workflowDir, { recursive: true });
const content = buildWorkflowTemplate("my-task");
writeFileSync(join(workflowDir, "index.ts"), content, "utf8");
const read = readFileSync(join(workflowDir, "index.ts"), "utf8");
expect(read).toContain("my-task started");
expect(read).toContain("WorkflowDefinition");
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
describe("knowledgeQueryScopeConflictMessage", () => {
it("returns null when only -r is used", () => {
expect(knowledgeQueryScopeConflictMessage("/tmp/repo", false)).toBeNull();
});
it("returns null when only -g is used", () => {
expect(knowledgeQueryScopeConflictMessage(undefined, true)).toBeNull();
});
it("returns null when neither -r nor -g", () => {
expect(knowledgeQueryScopeConflictMessage(undefined, false)).toBeNull();
});
it("returns error when both -r and -g", () => {
const msg = knowledgeQueryScopeConflictMessage("/some/path", true);
expect(msg).not.toBeNull();
expect(msg).toContain("-r");
expect(msg).toContain("-g");
});
it("treats empty -r as absent", () => {
expect(knowledgeQueryScopeConflictMessage("", true)).toBeNull();
});
});
@@ -0,0 +1,201 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fakeEmbeddingBytes } from "../knowledge/fake-embedding.js";
import { contentHash, openKnowledgeDb, replaceAllChunks } from "../knowledge/knowledge-db.js";
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
const DIM = 1024;
function fakeEmbedding1024(seed: string): Buffer {
const buf = Buffer.alloc(DIM * 4);
for (let i = 0; i < DIM; i++) {
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
}
return buf;
}
const embedMocks = vi.hoisted(() => ({
resolveEmbedConfig: vi.fn(),
embedQuery: vi.fn(),
}));
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
return {
...actual,
resolveEmbedConfig: () => embedMocks.resolveEmbedConfig(),
embedQuery: (cfg: Parameters<typeof actual.embedQuery>[0], text: string) =>
embedMocks.embedQuery(cfg, text),
};
});
import { queryKnowledgeRepo } from "../knowledge/query.js";
describe("queryKnowledgeRepo (word overlap fallback)", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = undefined;
process.env.EMBED_AUTH_TOKEN = undefined;
embedMocks.resolveEmbedConfig.mockReturnValue(null);
embedMocks.embedQuery.mockReset();
});
afterEach(() => {
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("returns higher scores for chunks that share words with the query", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "a.md",
slug: "a.md#0",
chunkIndex: 0,
text: "the sense scheduler triggers computes",
contentHash: contentHash("the sense scheduler triggers computes"),
embedding: fakeEmbeddingBytes("a"),
},
{
path: "b.md",
slug: "b.md#0",
chunkIndex: 0,
text: "unrelated cooking recipes",
contentHash: contentHash("unrelated cooking recipes"),
embedding: fakeEmbeddingBytes("b"),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, "sense scheduler", 10);
expect(ranked.length).toBe(2);
expect(ranked[0]?.path).toBe("a.md");
expect(ranked[1]?.path).toBe("b.md");
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
});
it("respects limit", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q2-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "x.md",
slug: "x.md#0",
chunkIndex: 0,
text: "one",
contentHash: contentHash("one"),
embedding: fakeEmbeddingBytes("x"),
},
{
path: "y.md",
slug: "y.md#0",
chunkIndex: 0,
text: "two",
contentHash: contentHash("two"),
embedding: fakeEmbeddingBytes("y"),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, "one", 1);
expect(ranked).toHaveLength(1);
});
});
describe("queryKnowledgeRepo (embed service)", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = "http://embed.test";
process.env.EMBED_AUTH_TOKEN = "test-token";
embedMocks.resolveEmbedConfig.mockReturnValue({
url: "http://embed.test",
token: "test-token",
});
embedMocks.embedQuery.mockImplementation(async (_c: unknown, text: string) =>
fakeEmbedding1024(text),
);
});
afterEach(() => {
embedMocks.embedQuery.mockReset();
embedMocks.resolveEmbedConfig.mockReset();
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("uses cosine similarity when embed config is present", async () => {
const root = mkdtempSync(join(tmpdir(), "nerve-q-embed-"));
const dbPath = join(root, KNOWLEDGE_DB);
mkdirSync(root, { recursive: true });
const textA = "alpha beta gamma";
const textB = "zzz unrelated";
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, [
{
path: "a.md",
slug: "a.md#0",
chunkIndex: 0,
text: textA,
contentHash: contentHash(textA),
embedding: fakeEmbedding1024(textA),
},
{
path: "b.md",
slug: "b.md#0",
chunkIndex: 0,
text: textB,
contentHash: contentHash(textB),
embedding: fakeEmbedding1024(textB),
},
]);
} finally {
db.close();
}
const ranked = await queryKnowledgeRepo(root, dbPath, textA, 10);
expect(ranked.length).toBe(2);
expect(ranked[0]?.path).toBe("a.md");
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
});
});
@@ -0,0 +1,26 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
listRegisteredKnowledgeRoots,
readKnowledgeRegistry,
registerKnowledgeRepoRoot,
} from "../knowledge/registry.js";
describe("knowledge repo registry", () => {
it("accumulates registered repo roots under a nerve home", () => {
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-reg-"));
const repoA = mkdtempSync(join(tmpdir(), "repo-a-"));
const repoB = mkdtempSync(join(tmpdir(), "repo-b-"));
registerKnowledgeRepoRoot(repoA, nerveHome);
registerKnowledgeRepoRoot(repoB, nerveHome);
registerKnowledgeRepoRoot(repoA, nerveHome);
expect(readKnowledgeRegistry(nerveHome).roots).toEqual([repoA, repoB].sort());
expect(listRegisteredKnowledgeRoots(nerveHome)).toEqual([repoA, repoB].sort());
});
});
@@ -0,0 +1,87 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const DIM = 1024;
function fakeEmbedding1024(seed: string): Buffer {
const buf = Buffer.alloc(DIM * 4);
for (let i = 0; i < DIM; i++) {
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
}
return buf;
}
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
return {
...actual,
resolveEmbedConfig: vi.fn(() => ({ url: "http://embed.test", token: "test-token" })),
embedTexts: vi.fn(async (_config: unknown, texts: string[]) =>
texts.map((t) => fakeEmbedding1024(t)),
),
};
});
import { runKnowledgeSync } from "../knowledge/sync.js";
describe("runKnowledgeSync", () => {
const savedUrl = process.env.EMBED_SERVICE_URL;
const savedToken = process.env.EMBED_AUTH_TOKEN;
beforeEach(() => {
process.env.EMBED_SERVICE_URL = "http://embed.test";
process.env.EMBED_AUTH_TOKEN = "test-token";
});
afterEach(() => {
if (savedUrl !== undefined) {
process.env.EMBED_SERVICE_URL = savedUrl;
} else {
process.env.EMBED_SERVICE_URL = undefined;
}
if (savedToken !== undefined) {
process.env.EMBED_AUTH_TOKEN = savedToken;
} else {
process.env.EMBED_AUTH_TOKEN = undefined;
}
});
it("creates knowledge.db with chunk rows", async () => {
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-home-"));
const root = mkdtempSync(join(tmpdir(), "nerve-know-sync-"));
mkdirSync(join(root, "docs"), { recursive: true });
writeFileSync(
join(root, "docs", "a.md"),
`# Hello
Some body text about bananas.
`,
);
writeFileSync(
join(root, "knowledge.yaml"),
`include:
- "docs/**/*.md"
exclude: []
`,
);
const result = await runKnowledgeSync(root, nerveHome);
expect(result.chunksWritten).toBeGreaterThan(0);
expect(result.embeddingSource).toBe("remote");
const db = new DatabaseSync(result.dbPath, { readOnly: true });
try {
const row = db.prepare("SELECT COUNT(*) AS c FROM chunks").get() as { c: number };
expect(row.c).toBe(result.chunksWritten);
} finally {
db.close();
}
});
});
+10 -2
View File
@@ -234,7 +234,11 @@ describe("logsCommand negative offset", () => {
it("exits with code 1 and writes to stderr when offset is negative", async () => {
await expect(
logsCommand.run!({ args: { n: "50", offset: "-5", follow: false }, rawArgs: [], cmd: logsCommand as never }),
logsCommand.run?.({
args: { n: "50", offset: "-5", follow: false },
rawArgs: [],
cmd: logsCommand as never,
}),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
expect(stderrOutput).toContain("--offset must be a non-negative integer");
@@ -243,7 +247,11 @@ describe("logsCommand negative offset", () => {
it("exits with code 1 for offset=-1", async () => {
await expect(
logsCommand.run!({ args: { n: "10", offset: "-1", follow: false }, rawArgs: [], cmd: logsCommand as never }),
logsCommand.run?.({
args: { n: "10", offset: "-1", follow: false },
rawArgs: [],
cmd: logsCommand as never,
}),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
});
+82
View File
@@ -0,0 +1,82 @@
import * as node_fs from "node:fs";
import * as node_os from "node:os";
import * as node_path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return { ...actual, homedir: vi.fn(() => actual.homedir()) };
});
import {
type RemotesConfig,
getDefaultRemoteName,
loadRemotes,
resolveRemote,
saveRemotes,
} from "../remotes.js";
describe("remotes", () => {
let tmpDir: string;
const homedirMock = node_os.homedir as ReturnType<typeof vi.fn>;
beforeEach(() => {
tmpDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "nerve-remote-test-"));
homedirMock.mockReturnValue(tmpDir);
});
afterEach(() => {
node_fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("loadRemotes returns empty config when file does not exist", () => {
const config = loadRemotes();
expect(config).toEqual({ remotes: {}, default: null });
});
it("saveRemotes and loadRemotes round-trip", () => {
const config: RemotesConfig = {
remotes: {
luming: { host: "192.168.2.58:9800", token: "secret" },
tuanzi: { host: "100.89.82.86:9800", token: null },
},
default: "luming",
};
saveRemotes(config);
const loaded = loadRemotes();
expect(loaded).toEqual(config);
});
it("saveRemotes creates file with restricted permissions", () => {
saveRemotes({ remotes: {}, default: null });
const p = node_path.join(tmpDir, ".nerve", "remotes.json");
const stat = node_fs.statSync(p);
expect(stat.mode & 0o777).toBe(0o600);
});
it("resolveRemote returns entry when found", () => {
saveRemotes({
remotes: { mybox: { host: "10.0.0.1:9800", token: "tok" } },
default: null,
});
const result = resolveRemote("mybox");
expect(result).toEqual({ host: "10.0.0.1:9800", token: "tok" });
});
it("resolveRemote returns null when not found", () => {
saveRemotes({ remotes: {}, default: null });
expect(resolveRemote("nope")).toBeNull();
});
it("getDefaultRemoteName returns default", () => {
saveRemotes({
remotes: { a: { host: "h:1", token: null } },
default: "a",
});
expect(getDefaultRemoteName()).toBe("a");
});
it("getDefaultRemoteName returns null when unset", () => {
expect(getDefaultRemoteName()).toBeNull();
});
});
@@ -0,0 +1,133 @@
/**
* Integration test for `nerve sense list` through citty `runCommand` with a temp
* HOME and nerve.yaml. The daemon IPC layer is exercised against a real Unix
* socket mock server (no daemon process). `workspace.isRunning` and
* `getSocketPath` are mocked so the CLI takes the live-daemon code path while
* the socket points at the fake IPC server.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { type Server, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { senseCommand } from "../commands/sense.js";
import * as workspace from "../workspace.js";
describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () => {
let prevHome: string | undefined;
let fakeHome: string;
let sockDir: string;
let sockPath: string;
let ipcServer: Server | null;
let stdoutSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, "write">> | null;
let listSensesRequests: unknown[];
const daemonSenseRow: SenseInfo = {
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 5s"],
};
function nerveYamlFixture(): string {
return `
senses:
cpu-usage:
group: system
throttle: 5s
timeout: 3s
interval: 5s
`.trim();
}
function collectStdout(): string {
if (stdoutSpy === null) return "";
let out = "";
for (const call of stdoutSpy.mock.calls) {
const chunk = call[0];
if (typeof chunk === "string") out += chunk;
else if (Buffer.isBuffer(chunk)) out += chunk.toString("utf8");
}
return out;
}
beforeEach(async () => {
listSensesRequests = [];
ipcServer = null;
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
prevHome = process.env.HOME;
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-list-cli-e2e-"));
process.env.HOME = fakeHome;
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(nerveRoot, { recursive: true });
writeFileSync(join(nerveRoot, "nerve.yaml"), `${nerveYamlFixture()}\n`, "utf8");
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-e2e-"));
sockPath = join(sockDir, "nerve.sock");
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
ipcServer = createServer((socket) => {
socket.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
const req = JSON.parse(line) as { type: string };
if (req.type === "list-senses") {
listSensesRequests.push(req);
socket.write(`${JSON.stringify({ ok: true, senses: [daemonSenseRow] })}\n`);
}
} catch {
// ignore malformed lines
}
});
});
await new Promise<void>((resolve) => {
ipcServer?.listen(sockPath, resolve);
});
});
afterEach(async () => {
stdoutSpy?.mockRestore();
stdoutSpy = null;
if (ipcServer !== null) {
await new Promise<void>((resolve) => {
ipcServer?.close(() => resolve());
});
ipcServer = null;
}
if (prevHome === undefined) {
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
rmSync(fakeHome, { recursive: true, force: true });
rmSync(sockDir, { recursive: true, force: true });
});
it("prints sense list from daemon path with name, group, throttle, and trigger schedule", async () => {
await runCommand(senseCommand, { rawArgs: ["list"] });
expect(listSensesRequests).toHaveLength(1);
expect(listSensesRequests[0]).toMatchObject({ type: "list-senses" });
const out = collectStdout();
expect(out).toContain("cpu-usage");
expect(out).toContain("group: system");
expect(out).toContain("throttle: 5s");
expect(out).toContain("timeout: 3s");
expect(out).toContain("trigger schedule: every 5s");
expect(out).not.toContain("last signal");
});
});
+57 -28
View File
@@ -29,10 +29,22 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTs: 1_700_000_000_000,
triggers: ["every 30s", "on: cpu-threshold"],
},
{
name: "disk-usage",
group: "system",
throttle: 30000,
timeout: null,
triggers: [],
},
{
name: "active-tasks",
group: "tasks",
throttle: 10000,
timeout: 30000,
triggers: ["every 1m"],
},
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
{ name: "active-tasks", group: "tasks", throttle: 10000, timeout: 30000, lastSignalTs: null },
];
// ---------------------------------------------------------------------------
@@ -100,15 +112,16 @@ describe("formatSenseList", () => {
expect(output).toContain("—");
});
it("shows '(never)' when lastSignalTs is null", () => {
it("shows trigger schedule from sense metadata", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
expect(output).toContain("trigger schedule:");
expect(output).toContain("every 30s");
expect(output).toContain("(none)");
});
it("shows ISO timestamp when lastSignalTs is set", () => {
it("does not include last signal line", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage has lastSignalTs = 1_700_000_000_000
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
expect(output).not.toContain("last signal");
});
});
@@ -152,28 +165,18 @@ senses:
disk-usage:
group: system
throttle: 30s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", lastSignalTs: null });
expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", lastSignalTs: null });
});
it("always sets lastSignalTs to null (static fallback)", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
my-sense:
group: default
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].lastSignalTs).toBeNull();
expect(result[0]).toMatchObject({
name: "cpu-usage",
group: "system",
});
expect(result[1]).toMatchObject({
name: "disk-usage",
group: "system",
});
});
it("populates throttle and timeout from config", () => {
@@ -186,13 +189,33 @@ senses:
group: default
throttle: 10s
timeout: 5s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].throttle).toBe(10000);
expect(result[0].timeout).toBe(5000);
});
it("uses inline interval and on for trigger schedule labels", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
downstream:
group: default
interval: 15s
on: [upstream]
upstream:
group: default
`.trim(),
);
const result = sensesFromConfig(path);
const downstream = result.find((s) => s.name === "downstream");
const upstream = result.find((s) => s.name === "upstream");
expect(downstream?.triggers).toEqual(["every 15s · on: upstream"]);
expect(upstream?.triggers).toEqual([]);
});
});
// ---------------------------------------------------------------------------
@@ -238,7 +261,13 @@ describe("listSensesViaDaemon", () => {
it("resolves with populated senses array", async () => {
const senses: SenseInfo[] = [
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 12345 },
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
triggers: [],
},
];
const server = createServer((s) => {
s.on("data", () => {
@@ -1,159 +0,0 @@
/**
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
*/
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertSenseDbExists,
collectColumnKeys,
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
parseSenseQueryArgs,
pickDefaultPreviewTable,
senseDbPath,
} from "../sense-sqlite.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = join(
tmpdir(),
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("senseDbPath", () => {
it("points at data/senses/<name>.db under the given root", () => {
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
});
});
describe("assertSenseDbExists", () => {
it("throws when the file is missing", () => {
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
});
it("returns the path when the file exists", () => {
const p = join(tmpDir, "data", "senses", "x.db");
new DatabaseSync(p).close();
expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
});
});
describe("listTableSqlStatements", () => {
it("returns CREATE statements ordered by tbl_name", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE zebra (id INTEGER)");
db.exec("CREATE TABLE alpha (id INTEGER)");
const stmts = listTableSqlStatements(db);
db.close();
expect(stmts).toHaveLength(2);
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
});
});
describe("pickDefaultPreviewTable", () => {
it("prefers non-_migrations tables when both exist", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
db.exec("CREATE TABLE readings (id INTEGER)");
expect(pickDefaultPreviewTable(db)).toBe("readings");
db.close();
});
it("uses _migrations when it is the only table", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
db.close();
});
});
describe("defaultPreviewSql", () => {
it("quotes identifiers for SQL safety", () => {
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
});
});
describe("parseSenseQueryArgs", () => {
it("parses sense name only", () => {
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("strips --json", () => {
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("joins remaining tokens into SQL", () => {
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
});
it("throws when name is missing", () => {
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
});
});
describe("formatRowsAsAlignedTable", () => {
it("shows empty marker for no rows", () => {
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
});
it("aligns columns from row data", () => {
const out = formatRowsAsAlignedTable([
{ a: 1, b: "x" },
{ a: 22, b: "yy" },
]);
expect(out).toContain("a");
expect(out).toContain("b");
expect(out).toContain("22");
});
});
describe("collectColumnKeys", () => {
it("preserves key order from first row then appends new keys", () => {
expect(
collectColumnKeys([
{ z: 1, a: 2 },
{ a: 3, b: 4 },
]),
).toEqual(["z", "a", "b"]);
});
});
describe("readonly query integration", () => {
it("runs default preview SQL on a real db", () => {
const p = join(tmpDir, "data", "senses", "demo.db");
const rw = new DatabaseSync(p);
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
rw.close();
const db = new DatabaseSync(p, { readOnly: true });
const table = pickDefaultPreviewTable(db);
expect(table).toBe("items");
if (table === null) {
throw new Error("expected items table");
}
const sql = defaultPreviewSql(table);
const rows = db.prepare(sql).all() as Record<string, unknown>[];
db.close();
expect(rows.length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,122 @@
/**
* E2E-style tests for `nerve sense trigger` (issue #157): citty + workspace stubs
* and a mock daemon on a real Unix socket no running nerve process required.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { type Server, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { senseCommand } from "../commands/sense.js";
import * as workspace from "../workspace.js";
describe("nerve sense trigger (e2e mock daemon)", () => {
let sockDir: string;
let sockPath: string;
let server: Server;
let ipcReceived: unknown[];
let knownOkSenses: Set<string>;
let stdoutBuf: string;
let stderrBuf: string;
beforeEach(async () => {
ipcReceived = [];
knownOkSenses = new Set(["cpu-usage"]);
stdoutBuf = "";
stderrBuf = "";
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-trigger-e2e-"));
sockPath = join(sockDir, "nerve.sock");
server = createServer((socket) => {
socket.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
let req: unknown;
try {
req = JSON.parse(line);
} catch {
return;
}
ipcReceived.push(req);
const r = req as { type?: string; sense?: string };
if (
r.type === "trigger-sense" &&
typeof r.sense === "string" &&
knownOkSenses.has(r.sense)
) {
socket.write(`${JSON.stringify({ ok: true })}\n`);
} else if (r.type === "trigger-sense" && typeof r.sense === "string") {
socket.write(`${JSON.stringify({ ok: false, error: `Unknown sense: "${r.sense}"` })}\n`);
}
});
});
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
vi.spyOn(process.stdout, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
stdoutBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
} else if (cb !== undefined) {
cb(null);
}
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
stderrBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
} else if (cb !== undefined) {
cb(null);
}
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
const c = typeof code === "number" ? code : 1;
// Throw instead of actually exiting so the test runner stays alive;
// the test asserts on this message to verify the exit code.
throw new Error(`process.exit(${String(c)})`);
});
});
afterEach(async () => {
vi.restoreAllMocks();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
rmSync(sockDir, { recursive: true, force: true });
});
it("trigger known sense exits normally and stdout contains Triggered", async () => {
await runCommand(senseCommand, { rawArgs: ["trigger", "cpu-usage"] });
expect(stdoutBuf).toContain("Triggered");
expect(stdoutBuf).toContain("✅");
expect(stderrBuf).toBe("");
});
it("trigger non-existent sense writes daemon error to stderr and exits 1", async () => {
await expect(
runCommand(senseCommand, { rawArgs: ["trigger", "no-such-sense"] }),
).rejects.toThrow("process.exit(1)");
expect(stdoutBuf).toBe("");
expect(stderrBuf).toContain("Daemon rejected trigger");
expect(stderrBuf).toContain("Unknown sense");
expect(stderrBuf).toContain("no-such-sense");
});
it("sends IPC { type: trigger-sense, sense: <name> } to the daemon", async () => {
knownOkSenses.add("custom-sense");
await runCommand(senseCommand, { rawArgs: ["trigger", "custom-sense"] });
expect(ipcReceived).toHaveLength(1);
expect(ipcReceived[0]).toEqual({ type: "trigger-sense", sense: "custom-sense" });
});
});
@@ -0,0 +1,113 @@
/**
* RFC-003 Phase 5: nerve validate workflow adapter usage and extract.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import {
validateAgentConfigurationLayer,
workflowSourcesDeclareAdapterRoles,
} from "../workflow-agent-validation.js";
function baseConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
maxRounds: 10,
senses: {},
workflows: {},
api: { port: null, token: null, host: "127.0.0.1" },
extract: null,
...overrides,
};
}
describe("validateAgentConfigurationLayer", () => {
let nerveRoot: string;
afterEach(() => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("fails when workflow sources use adapters but extract is missing", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
const adapter = async () => "";
const spec = {
name: "demo",
roles: {
r: { adapter: adapter, prompt: "p", meta: {} as never },
},
moderator: () => "__end__" as never,
};
export default spec;
`,
"utf8",
);
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toMatch(/extract/i);
}
});
it("passes when adapters are used and extract is configured", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
roles: { x: { adapter: foo, prompt: "", meta: {} as never } }
`,
"utf8",
);
const result = validateAgentConfigurationLayer(
baseConfig({
extract: { provider: "dashscope", model: "qwen-plus" },
}),
nerveRoot,
);
expect(result.ok).toBe(true);
});
it("passes when no adapter usage is detected", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "wf.ts"),
`const role = { prompt: "x" };`,
"utf8",
);
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(true);
});
});
describe("workflowSourcesDeclareAdapterRoles", () => {
let nerveRoot: string;
afterEach(() => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("detects adapter: identifiers under workflows/*/src", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "w1", "src", "nested"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "w1", "src", "nested", "a.ts"),
"adapter: foo\nadapter: bar",
"utf8",
);
expect(workflowSourcesDeclareAdapterRoles(nerveRoot)).toBe(true);
});
});
@@ -0,0 +1,88 @@
/**
* Smoke / integration tests for `nerve workflow` and `nerve thread` citty handlers with a real HOME
* layout and logs.db. `loadDaemonModule` is mocked so tests use workspace
* `@uncaged/nerve-store` directly (no ~/.uncaged-nerve daemon install required).
*/
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../workspace-daemon.js", async () => {
const { createLogStore } = await import("@uncaged/nerve-store");
return {
loadDaemonModule: vi.fn(async () => ({ createLogStore })),
};
});
import { createLogStore } from "@uncaged/nerve-store";
import { threadCommand } from "../commands/thread.js";
describe("nerve thread CLI (runCommand + temp HOME)", () => {
let prevHome: string | undefined;
let fakeHome: string;
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
beforeEach(() => {
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
prevHome = process.env.HOME;
fakeHome = mkdtempSync(join(tmpdir(), "nerve-wf-cli-e2e-"));
process.env.HOME = fakeHome;
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(join(nerveRoot, "data"), { recursive: true });
const dbPath = join(nerveRoot, "data", "logs.db");
const store = createLogStore(dbPath);
store.upsertWorkflowRun(
{ source: "workflow", type: "started", refId: "e2e-run", payload: "{}", timestamp: 5000 },
{ runId: "e2e-run", workflow: "demo", status: "completed", timestamp: 5000, exitCode: 0 },
);
store.append({
source: "workflow",
type: "completed",
refId: "e2e-run",
payload: null,
timestamp: 5001,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "e2e-run",
payload: JSON.stringify({ type: "step", role: "bot", content: "hello" }),
timestamp: 5100,
});
store.close();
});
afterEach(() => {
stdoutSpy?.mockRestore();
stdoutSpy = null;
if (prevHome === undefined) {
process.env.HOME = undefined;
} else {
process.env.HOME = prevHome;
}
rmSync(fakeHome, { recursive: true, force: true });
});
it("thread list --all completes without throwing", async () => {
await expect(runCommand(threadCommand, { rawArgs: ["list", "--all"] })).resolves.toBeDefined();
});
it("thread inspect <runId> completes without throwing", async () => {
await expect(
runCommand(threadCommand, { rawArgs: ["inspect", "e2e-run", "--limit", "10"] }),
).resolves.toBeDefined();
});
it("thread show <runId> completes without throwing (role rounds path)", async () => {
await expect(
runCommand(threadCommand, { rawArgs: ["show", "e2e-run", "--budget", "50000"] }),
).resolves.toBeDefined();
});
});
+263 -28
View File
@@ -12,19 +12,25 @@ import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-daemon";
import { createLogStore } from "@uncaged/nerve-store";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import {
DEFAULT_THREAD_BUDGET_CHARS,
UNKNOWN_TIMESTAMP_LABEL,
buildInspectOutput,
buildListOutput,
buildThreadCommandOutput,
formatRunLine,
formatThreadRoundBlock,
formatTs,
getAllWorkflowRuns,
parseIntArg,
partitionWorkflowMessage,
statusIcon,
} from "../commands/workflow.js";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import type { LogStore, WorkflowRun } from "../daemon-types.js";
// ---------------------------------------------------------------------------
// Test helpers
@@ -37,11 +43,11 @@ function upsertRun(
runId: string,
workflow: string,
status: WorkflowRun["status"],
ts: number,
timestampMs: number,
): void {
store.upsertWorkflowRun(
{ source: "workflow", type: status, refId: runId, payload: null, ts },
{ runId, workflow, status, ts },
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
);
}
@@ -60,9 +66,74 @@ afterEach(() => {
// ---------------------------------------------------------------------------
describe("formatTs", () => {
it("returns ISO 8601 string", () => {
const ts = new Date("2026-01-01T00:00:00.000Z").getTime();
expect(formatTs(ts)).toBe("2026-01-01T00:00:00.000Z");
it("returns ISO 8601 string for a valid UTC instant", () => {
const timestampMs = new Date("2026-01-01T00:00:00.000Z").getTime();
expect(formatTs(timestampMs)).toBe("2026-01-01T00:00:00.000Z");
});
it("returns placeholder for null and undefined", () => {
expect(formatTs(null)).toBe(UNKNOWN_TIMESTAMP_LABEL);
expect(formatTs(undefined)).toBe(UNKNOWN_TIMESTAMP_LABEL);
});
it("returns placeholder for NaN and non-finite numbers", () => {
expect(formatTs(Number.NaN)).toBe(UNKNOWN_TIMESTAMP_LABEL);
expect(formatTs(Number.POSITIVE_INFINITY)).toBe(UNKNOWN_TIMESTAMP_LABEL);
expect(formatTs(Number.NEGATIVE_INFINITY)).toBe(UNKNOWN_TIMESTAMP_LABEL);
});
it("returns placeholder for non-number runtime values", () => {
expect(formatTs("2024" as unknown as number)).toBe(UNKNOWN_TIMESTAMP_LABEL);
expect(formatTs({} as unknown as number)).toBe(UNKNOWN_TIMESTAMP_LABEL);
});
it("formats 0 as Unix epoch", () => {
expect(formatTs(0)).toBe("1970-01-01T00:00:00.000Z");
});
it("formats negative finite values as ISO strings", () => {
expect(formatTs(-1)).toBe("1969-12-31T23:59:59.999Z");
});
});
// ---------------------------------------------------------------------------
// formatRunLine
// ---------------------------------------------------------------------------
describe("formatRunLine", () => {
it("omits exit_code segment when exitCode is null", () => {
const run: WorkflowRun = {
runId: "r1",
workflow: "wf",
status: "started",
timestamp: 1000,
exitCode: null,
};
const line = formatRunLine(run);
expect(line).not.toContain("exit_code");
expect(line).toContain("timestamp=1970-01-01T00:00:01.000Z");
});
it("includes exit_code when set", () => {
const run: WorkflowRun = {
runId: "r1",
workflow: "wf",
status: "failed",
timestamp: 2000,
exitCode: 7,
};
expect(formatRunLine(run)).toContain("exit_code=7");
});
it("uses unknown timestamp label for bad run timestamps", () => {
const run = {
runId: "r-bad",
workflow: "wf",
status: "completed" as const,
timestamp: Number.NaN,
exitCode: null,
} as WorkflowRun;
expect(formatRunLine(run)).toContain(`timestamp=${UNKNOWN_TIMESTAMP_LABEL}`);
});
});
@@ -79,6 +150,7 @@ describe("statusIcon", () => {
["crashed", "💥"],
["dropped", "🗑"],
["interrupted", "⚠️"],
["killed", "🛑"],
] as const)("maps status=%s to icon=%s", (status, icon) => {
expect(statusIcon(status)).toBe(icon);
});
@@ -123,14 +195,14 @@ describe("getAllWorkflowRuns", () => {
}
});
it("sorts by ts descending (newest first)", () => {
it("sorts by timestamp descending (newest first)", () => {
upsertRun("r1", "cleanup", "completed", 1000);
upsertRun("r2", "cleanup", "started", 3000);
upsertRun("r3", "cleanup", "failed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].ts).toBeGreaterThan(runs[1].ts);
expect(runs[1].ts).toBeGreaterThan(runs[2].ts);
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
});
});
@@ -143,9 +215,9 @@ describe("buildListOutput", () => {
runId: string,
workflow: string,
status: WorkflowRun["status"],
ts: number,
timestampMs: number,
): WorkflowRun {
return { runId, workflow, status, ts };
return { runId, workflow, status, timestamp: timestampMs, exitCode: null };
}
it("returns empty message when no runs and --all=false", () => {
@@ -187,6 +259,7 @@ describe("buildListOutput", () => {
// header + 2 run lines
expect(lines).toHaveLength(3);
expect(paginationHint).not.toBeNull();
expect(paginationHint).toContain("nerve thread list");
expect(paginationHint).toContain("--offset 2");
expect(paginationHint).toContain("3 more");
});
@@ -220,6 +293,22 @@ describe("buildListOutput", () => {
expect(paginationHint).toContain("1 more");
expect(paginationHint).toContain("--offset 4");
});
it("does not throw when a run has null exit_code and invalid timestamp", () => {
const runs: WorkflowRun[] = [
{
runId: "bad-ts",
workflow: "wf",
status: "completed",
timestamp: null as unknown as number,
exitCode: null,
},
];
const { lines } = buildListOutput(runs, 0, 20, true, null);
const text = lines.join("");
expect(text).toContain(UNKNOWN_TIMESTAMP_LABEL);
expect(text).not.toContain("exit_code");
});
});
// ---------------------------------------------------------------------------
@@ -231,7 +320,8 @@ describe("buildInspectOutput", () => {
runId: "run-xyz",
workflow: "cleanup",
status: "completed",
ts: 1_700_000_000_000,
timestamp: 1_700_000_000_000,
exitCode: null,
};
it("shows header with run details", () => {
@@ -247,8 +337,8 @@ describe("buildInspectOutput", () => {
expect(eventLines.join("")).toContain("no events recorded");
});
it("shows event lines with type and ts", () => {
const logs = [{ ts: 1_700_000_001_000, type: "started", payload: null }];
it("shows event lines with type and timestamp", () => {
const logs = [{ timestamp: 1_700_000_001_000, type: "started", payload: null }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("type=started");
@@ -256,7 +346,7 @@ describe("buildInspectOutput", () => {
it("truncates long payloads to 200 chars with ellipsis", () => {
const longPayload = "x".repeat(250);
const logs = [{ ts: 1000, type: "step_complete", payload: longPayload }];
const logs = [{ timestamp: 1000, type: "step_complete", payload: longPayload }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("…");
@@ -264,14 +354,14 @@ describe("buildInspectOutput", () => {
});
it("shows short payloads in full", () => {
const logs = [{ ts: 1000, type: "step_complete", payload: '{"count":5}' }];
const logs = [{ timestamp: 1000, type: "step_complete", payload: '{"count":5}' }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
expect(eventLines.join("")).toContain('{"count":5}');
});
it("paginates events with a hint", () => {
const logs = Array.from({ length: 5 }, (_, i) => ({
ts: 1000 + i,
timestamp: 1000 + i,
type: "step_complete",
payload: null,
}));
@@ -283,17 +373,32 @@ describe("buildInspectOutput", () => {
});
it("no pagination hint when all events fit on one page", () => {
const logs = [{ ts: 1000, type: "started", payload: null }];
const logs = [{ timestamp: 1000, type: "started", payload: null }];
const { paginationHint } = buildInspectOutput(baseRun, logs, 0, 20);
expect(paginationHint).toBeNull();
});
it("renders unknown labels for bad run and event timestamps without throwing", () => {
const run: WorkflowRun = {
...baseRun,
timestamp: Number.NaN as unknown as number,
};
const logs = [{ timestamp: Number.NaN as unknown as number, type: "started", payload: null }];
const { header, eventLines } = buildInspectOutput(run, logs, 0, 20);
const all = [...header, ...eventLines].join("");
expect(all).toContain(UNKNOWN_TIMESTAMP_LABEL);
expect(all.match(new RegExp(UNKNOWN_TIMESTAMP_LABEL, "g"))).not.toBeNull();
expect(
(all.match(new RegExp(UNKNOWN_TIMESTAMP_LABEL, "g")) ?? []).length,
).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// Integration: getAllWorkflowRuns + buildListOutput end-to-end with real store
// ---------------------------------------------------------------------------
describe("workflow list — integration with real store", () => {
describe("workflow runs list — integration with real store", () => {
it("lists active runs from the store", () => {
upsertRun("r1", "cleanup", "started", 1000);
upsertRun("r2", "cleanup", "queued", 2000);
@@ -322,6 +427,136 @@ describe("workflow list — integration with real store", () => {
});
});
// ---------------------------------------------------------------------------
// nerve workflow thread — formatting helpers
// ---------------------------------------------------------------------------
describe("partitionWorkflowMessage", () => {
it("extracts role, content, and meta", () => {
const p = partitionWorkflowMessage({
role: "scanner",
content: "ok",
meta: { items: [1, 2] },
timestamp: 1,
});
expect(p.roleStr).toBe("scanner");
expect(p.contentBody).toBe("ok");
expect(p.meta).toEqual({ items: [1, 2] });
});
it("passes through role and content as-is", () => {
const p = partitionWorkflowMessage({
role: "unknown",
content: '{"n":1}',
meta: null,
timestamp: 0,
});
expect(p.roleStr).toBe("unknown");
expect(p.contentBody).toBe('{"n":1}');
});
});
describe("formatThreadRoundBlock", () => {
const row: ThreadRoundRow = {
round: 2,
logId: 99,
timestamp: new Date("2026-01-02T03:04:05.006Z").getTime(),
message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 },
};
it("includes header, YAML frontmatter for meta, and body", () => {
const text = formatThreadRoundBlock(row);
expect(text).toContain("[#2 bot]");
expect(text).toContain("---\n");
expect(text).toContain("score: 0.5");
expect(text).toContain("hi");
});
it("uses unknown label when row timestamp is null (defensive)", () => {
const badRow = {
...row,
timestamp: null as unknown as number,
};
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
});
it("uses unknown label when row timestamp is NaN (defensive)", () => {
const badRow = { ...row, timestamp: Number.NaN };
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
});
it("uses unknown label when row timestamp is undefined (defensive)", () => {
const badRow = { ...row, timestamp: undefined as unknown as number };
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
});
});
describe("buildThreadCommandOutput", () => {
function row(n: number, content: string): ThreadRoundRow {
return {
round: n,
logId: 10 + n,
timestamp: 1000 + n,
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
};
}
it("orders rounds chronologically (oldest first in output)", () => {
const desc = [row(3, "ccc"), row(2, "bbb"), row(1, "aaa")];
const prefix = ["HEADER\n"];
const { lines, paginationHint } = buildThreadCommandOutput(prefix, desc, 50_000, "run-x");
const text = lines.join("");
const idxA = text.indexOf("\naaa\n");
const idxB = text.indexOf("\nbbb\n");
const idxC = text.indexOf("\nccc\n");
expect(idxA).toBeGreaterThan(-1);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
expect(paginationHint).toBeNull();
});
it("emits pagination hint with --before when oldest shown round is still > 1", () => {
const desc = [row(4, "d"), row(3, "c")];
const { paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-y");
expect(paginationHint).toContain("--before 3");
expect(paginationHint).toContain("run-y");
});
it("respects budget and hints with non-default --budget in command", () => {
const big = "y".repeat(500);
const desc = [row(2, big), row(1, "a")];
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 400, "run-z");
const text = lines.join("");
expect(text).toContain("[#2");
expect(text).not.toContain("[#1");
expect(paginationHint).toContain("--before 2");
expect(paginationHint).toContain("--budget 400");
});
it("formats startRow first (chronologically before role rounds) and consumes budget first", () => {
const start: ThreadRoundRow = {
round: 0,
logId: 1,
timestamp: 100,
message: { role: "__start__", content: "go", meta: {}, timestamp: 100 },
};
const desc = [row(2, "bbb"), row(1, "aaa")];
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-s", start);
const text = lines.join("");
const idxStart = text.indexOf("[#0 __start__]");
const idxA = text.indexOf("\naaa\n");
const idxB = text.indexOf("\nbbb\n");
expect(idxStart).toBeGreaterThan(-1);
expect(idxA).toBeGreaterThan(idxStart);
expect(idxB).toBeGreaterThan(idxA);
expect(paginationHint).toBeNull();
});
it("default budget constant matches workflow command default", () => {
expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000);
});
});
// ---------------------------------------------------------------------------
// parseIntArg
// ---------------------------------------------------------------------------
@@ -366,15 +601,15 @@ describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () =>
expect(runs).toHaveLength(7);
});
it("returns runs sorted by ts descending (newest first)", () => {
it("returns runs sorted by timestamp descending (newest first)", () => {
upsertRun("r1", "deploy", "completed", 1000);
upsertRun("r2", "deploy", "completed", 3000);
upsertRun("r3", "deploy", "completed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].ts).toBe(3000);
expect(runs[1].ts).toBe(2000);
expect(runs[2].ts).toBe(1000);
expect(runs[0].timestamp).toBe(3000);
expect(runs[1].timestamp).toBe(2000);
expect(runs[2].timestamp).toBe(1000);
});
it("filters by workflow name", () => {
@@ -418,7 +653,7 @@ describe("triggerWorkflowViaDaemon", () => {
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", {});
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100);
expect(result).toEqual({ ok: true });
} finally {
await new Promise<void>((r) => server.close(() => r()));
@@ -434,7 +669,7 @@ describe("triggerWorkflowViaDaemon", () => {
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerWorkflowViaDaemon(sockPath, "missing", {});
const result = await triggerWorkflowViaDaemon(sockPath, "missing", "", 100);
expect(result).toEqual({ ok: false, error: "unknown workflow" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
@@ -442,7 +677,7 @@ describe("triggerWorkflowViaDaemon", () => {
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(triggerWorkflowViaDaemon(sockPath, "my-workflow", {})).rejects.toThrow(
await expect(triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100)).rejects.toThrow(
/Cannot connect to daemon/,
);
});
+107
View File
@@ -0,0 +1,107 @@
import { resolveRemote } from "./remotes.js";
let cliDaemonHost: string | null = null;
let cliDaemonApiToken: string | null = null;
function applyRemoteFlag(argv: string[], i: number): number | null {
const read =
readEqOrNextFlag(argv, i, "--remote=", "--remote", "--remote requires a remote name") ??
readEqOrNextFlag(argv, i, "-r=", "-r", "-r requires a remote name");
if (read === null) return null;
const resolved = resolveRemote(read.value);
if (resolved === null) {
throw new Error(`Unknown remote: "${read.value}"`);
}
if (cliDaemonHost === null) cliDaemonHost = resolved.host;
if (cliDaemonApiToken === null && resolved.token !== null) cliDaemonApiToken = resolved.token;
return read.lastConsumedIndex;
}
function readEqOrNextFlag(
argv: string[],
i: number,
flagEq: string,
flagWord: string,
missingMsg: string,
): { value: string; lastConsumedIndex: number } | null {
const a = argv[i];
if (a === undefined) return null;
if (a.startsWith(flagEq)) {
const value = a.slice(flagEq.length);
if (value.length === 0 || value.startsWith("-")) {
throw new Error(missingMsg);
}
return { value, lastConsumedIndex: i };
}
if (a === flagWord) {
const v = argv[i + 1];
if (v === undefined || v.length === 0 || v.startsWith("-")) {
throw new Error(missingMsg);
}
return { value: v, lastConsumedIndex: i + 1 };
}
return null;
}
/**
* Removes `--host` / `--api-token` from argv before citty parses subcommands.
* Must run once at process startup (see `cli.ts`).
*/
export function consumeGlobalDaemonCliFlags(argv: string[]): string[] {
cliDaemonHost = null;
cliDaemonApiToken = null;
const out: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === undefined) continue;
const hostRead = readEqOrNextFlag(
argv,
i,
"--host=",
"--host",
"--host requires a non-empty value (e.g. 192.168.1.1:9800)",
);
if (hostRead !== null) {
cliDaemonHost = hostRead.value;
i = hostRead.lastConsumedIndex;
continue;
}
const tokenRead = readEqOrNextFlag(
argv,
i,
"--api-token=",
"--api-token",
"--api-token requires a value",
);
if (tokenRead !== null) {
cliDaemonApiToken = tokenRead.value.length > 0 ? tokenRead.value : null;
i = tokenRead.lastConsumedIndex;
continue;
}
const remoteResult = applyRemoteFlag(argv, i);
if (remoteResult !== null) {
i = remoteResult;
continue;
}
out.push(a);
}
return out;
}
export function isRemoteDaemonCli(): boolean {
return cliDaemonHost !== null && cliDaemonHost.length > 0;
}
export function getCliDaemonHost(): string | null {
return cliDaemonHost;
}
export function getCliDaemonApiToken(): string | null {
return cliDaemonApiToken;
}
+44 -10
View File
@@ -1,35 +1,69 @@
import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
import { defineCommand, runMain } from "citty";
import { consumeGlobalDaemonCliFlags } from "./cli-global.js";
import { agentCommand } from "./commands/agent.js";
import { createCommand } from "./commands/create.js";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js";
import { knowledgeCommand } from "./commands/knowledge.js";
import { remoteCommand } from "./commands/remote.js";
import { senseCommand } from "./commands/sense.js";
import { daemonStartCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
import { storeCommand } from "./commands/store.js";
import { threadCommand } from "./commands/thread.js";
import { validateCommand } from "./commands/validate.js";
import { workflowCommand } from "./commands/workflow.js";
/**
* Citty picks the first non-flag token as a subcommand name. Rewrite
* `nerve init --from <url>` so the URL is not mistaken for `workflow`/`workspace`.
*/
function normalizeNerveArgv(argv: string[]): string[] {
const initIdx = argv.indexOf("init");
if (initIdx === -1) return argv;
const tail = argv.slice(initIdx + 1);
const fromAt = tail.indexOf("--from");
if (fromAt === -1) return argv;
const beforeFrom = tail.slice(0, fromAt);
if (beforeFrom.some((a) => !a.startsWith("-"))) return argv;
const next = tail[fromAt + 1];
if (next === undefined || next.startsWith("-")) return argv;
const reserved = new Set(["workflow", "workspace"]);
if (reserved.has(next)) return argv;
const mergedTail = [...tail.slice(0, fromAt), `--from=${next}`, ...tail.slice(fromAt + 2)];
return [...argv.slice(0, initIdx + 1), ...mergedTail];
}
const main = defineCommand({
meta: {
name: "nerve",
description: "Nerve — an AI agent kernel",
description:
"Nerve — an AI agent kernel. Global options: --host <host:port> (remote HTTP), --api-token <secret> (Bearer auth).",
},
subCommands: {
agent: agentCommand,
init: initCommand,
create: createCommand,
daemon: daemonCommand,
dev: devCommand,
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
knowledge: knowledgeCommand,
sense: senseCommand,
store: storeCommand,
remote: remoteCommand,
thread: threadCommand,
workflow: workflowCommand,
},
});
runMain(main);
let cliArgv = process.argv.slice(2);
try {
cliArgv = consumeGlobalDaemonCliFlags(cliArgv);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
}
runMain(main, { rawArgs: normalizeNerveArgv(cliArgv) });
+378
View File
@@ -0,0 +1,378 @@
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { defineCommand } from "citty";
function getPackageRootDir(): string {
const thisFile = fileURLToPath(import.meta.url);
let dir = dirname(thisFile);
for (let i = 0; i < 5; i++) {
if (existsSync(join(dir, "package.json"))) return dir;
dir = dirname(dir);
}
throw new Error("Cannot locate package root. Is the CLI package intact?");
}
function getCliVersion(): string {
const pkgPath = join(getPackageRootDir(), "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string };
return pkg.version;
}
let _cachedVersion: string | null = null;
function cliVersion(): string {
if (_cachedVersion === null) _cachedVersion = getCliVersion();
return _cachedVersion;
}
function getSkillSourceDir(): string {
const root = getPackageRootDir();
const skillsDir = join(root, "skills");
if (!existsSync(skillsDir)) {
throw new Error("Cannot locate skills directory. Is the CLI package intact?");
}
return skillsDir;
}
function getHermesSkillDir(profile: string | null): string {
const hermesHome = join(homedir(), ".hermes");
if (profile !== null) {
return join(hermesHome, "profiles", profile, "skills", "nerve");
}
return join(hermesHome, "skills", "nerve");
}
function readVersionFile(skillDir: string): string | null {
const versionPath = join(skillDir, ".nerve-version");
if (!existsSync(versionPath)) return null;
return readFileSync(versionPath, "utf8").trim();
}
function writeVersionFile(skillDir: string, version: string): void {
writeFileSync(join(skillDir, ".nerve-version"), `${version}\n`, "utf8");
}
const CURSOR_VERSION_MARKER_RE = /<!--\s*nerve-cli-version:\s*([^>]+?)\s*-->/;
function resolveCursorProjectDir(pathArg: string | null): string {
const raw = pathArg !== null && pathArg !== "" ? pathArg : process.cwd();
return resolvePath(raw);
}
function assertDirectory(projectDir: string, label: string): void {
if (!existsSync(projectDir)) {
process.stderr.write(`${label} does not exist: ${projectDir}\n`);
process.exit(1);
}
if (!statSync(projectDir).isDirectory()) {
process.stderr.write(`${label} is not a directory: ${projectDir}\n`);
process.exit(1);
}
}
function readCursorInjectVersion(projectDir: string): string | null {
const versionPath = join(projectDir, ".nerve-version");
if (existsSync(versionPath)) {
return readFileSync(versionPath, "utf8").trim();
}
const rulesPath = join(projectDir, ".cursorrules");
if (!existsSync(rulesPath)) return null;
const content = readFileSync(rulesPath, "utf8");
const match = content.match(CURSOR_VERSION_MARKER_RE);
return match !== null ? match[1].trim() : null;
}
function injectCursor(projectDir: string): void {
assertDirectory(projectDir, "Project directory");
const rulesPath = join(projectDir, ".cursorrules");
const existingVer = readCursorInjectVersion(projectDir);
if (existingVer === cliVersion() && existsSync(rulesPath)) {
process.stdout.write(
`✅ Cursor .cursorrules is already up to date (v${cliVersion()}) at ${projectDir}\n`,
);
return;
}
const templatePath = join(getSkillSourceDir(), "cursor", ".cursorrules");
if (!existsSync(templatePath)) {
throw new Error("Cannot locate cursor/.cursorrules template. Is the CLI package intact?");
}
let body = readFileSync(templatePath, "utf8");
body = body.replaceAll("__NERVE_CLI_VERSION__", cliVersion());
writeFileSync(rulesPath, body, "utf8");
writeVersionFile(projectDir, cliVersion());
const action = existingVer !== null ? "Updated" : "Installed";
process.stdout.write(`${action} Cursor .cursorrules v${cliVersion()} at ${projectDir}\n`);
}
function removeCursor(projectDir: string): void {
assertDirectory(projectDir, "Project directory");
const rulesPath = join(projectDir, ".cursorrules");
const versionPath = join(projectDir, ".nerve-version");
if (!existsSync(rulesPath)) {
process.stdout.write(`ℹ️ Cursor .cursorrules is not present at ${projectDir}\n`);
return;
}
rmSync(rulesPath, { force: true });
if (existsSync(versionPath)) {
rmSync(versionPath, { force: true });
}
process.stdout.write(`✅ Removed Cursor .cursorrules from ${projectDir}\n`);
}
function injectHermes(profile: string | null): void {
const sourceDir = join(getSkillSourceDir(), "hermes");
const targetDir = getHermesSkillDir(profile);
const existing = readVersionFile(targetDir);
if (existing === cliVersion()) {
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`✅ Hermes nerve skill is already up to date (v${cliVersion()})${loc}\n`);
return;
}
mkdirSync(targetDir, { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true });
writeVersionFile(targetDir, cliVersion());
const action = existing !== null ? "Updated" : "Installed";
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`${action} Hermes nerve skill v${cliVersion()}${loc}\n`);
process.stdout.write(`${targetDir}/SKILL.md\n`);
}
function removeHermes(profile: string | null): void {
const targetDir = getHermesSkillDir(profile);
if (!existsSync(targetDir)) {
process.stdout.write("ℹ️ Hermes nerve skill is not installed.\n");
return;
}
rmSync(targetDir, { recursive: true, force: true });
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`);
}
function printCursorStatusLine(projectDir: string): void {
const rulesPath = join(projectDir, ".cursorrules");
const label = `Cursor (${projectDir})`;
if (!existsSync(rulesPath)) {
process.stdout.write(` ${label}: ❌ not installed\n`);
return;
}
const ver = readCursorInjectVersion(projectDir);
if (ver === null) {
process.stdout.write(
` ${label}: ⚠️ installed (unknown version; run \`nerve agent inject cursor\`)\n`,
);
return;
}
if (ver === cliVersion()) {
process.stdout.write(` ${label}: ✅ v${ver}\n`);
} else {
process.stdout.write(
` ${label}: ⚠️ v${ver} → v${cliVersion()} available (run \`nerve agent inject cursor\`)\n`,
);
}
}
function printStatus(): void {
process.stdout.write(`nerve agent skills (CLI v${cliVersion()})\n\n`);
printCursorStatusLine(process.cwd());
process.stdout.write("\n");
// Default profile
const defaultDir = getHermesSkillDir(null);
const defaultVer = readVersionFile(defaultDir);
printAgentLine("Hermes (default)", defaultVer);
// Named profiles
const profilesDir = join(homedir(), ".hermes", "profiles");
if (existsSync(profilesDir)) {
const profiles = readdirSync(profilesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const profile of profiles) {
const dir = getHermesSkillDir(profile);
const ver = readVersionFile(dir);
if (ver !== null) {
printAgentLine(`Hermes (${profile})`, ver);
}
}
}
process.stdout.write("\n");
}
function printAgentLine(label: string, version: string | null): void {
if (version === null) {
process.stdout.write(` ${label}: ❌ not installed\n`);
} else if (version === cliVersion()) {
process.stdout.write(` ${label}: ✅ v${version}\n`);
} else {
process.stdout.write(
` ${label}: ⚠️ v${version} → v${cliVersion()} available (run \`nerve agent update\`)\n`,
);
}
}
const injectCommand = defineCommand({
meta: {
name: "inject",
description: "Inject nerve skill into an AI agent",
},
args: {
target: {
type: "positional",
description: "Agent target: hermes | cursor",
},
profile: {
type: "string",
description: "Hermes profile name (default: main profile)",
},
path: {
type: "string",
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
},
},
run({ args }) {
const target = args.target;
if (target === "hermes") {
if (args.path != null && args.path !== "") {
process.stderr.write("❌ --path applies only to the cursor target\n");
process.exit(1);
}
injectHermes(args.profile ?? null);
return;
}
if (target === "cursor") {
if (args.profile != null && args.profile !== "") {
process.stderr.write("❌ --profile applies only to the hermes target\n");
process.exit(1);
}
const pathArg = args.path != null && args.path !== "" ? args.path : null;
injectCursor(resolveCursorProjectDir(pathArg));
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor\n");
process.exit(1);
},
});
const updateCommand = defineCommand({
meta: {
name: "update",
description: "Update all injected nerve skills to current CLI version",
},
run() {
let updated = 0;
// Default profile
const defaultDir = getHermesSkillDir(null);
if (existsSync(defaultDir)) {
injectHermes(null);
updated++;
}
// Named profiles
const profilesDir = join(homedir(), ".hermes", "profiles");
if (existsSync(profilesDir)) {
const profiles = readdirSync(profilesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const profile of profiles) {
const dir = getHermesSkillDir(profile);
if (existsSync(dir)) {
injectHermes(profile);
updated++;
}
}
}
if (updated === 0) {
process.stdout.write("ℹ️ No injected skills found. Run `nerve agent inject hermes` first.\n");
}
},
});
const removeCommand = defineCommand({
meta: {
name: "remove",
description: "Remove injected nerve skill from an AI agent",
},
args: {
target: {
type: "positional",
description: "Agent target: hermes | cursor",
},
profile: {
type: "string",
description: "Hermes profile name (default: main profile)",
},
path: {
type: "string",
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
},
},
run({ args }) {
const target = args.target;
if (target === "hermes") {
if (args.path != null && args.path !== "") {
process.stderr.write("❌ --path applies only to the cursor target\n");
process.exit(1);
}
removeHermes(args.profile ?? null);
return;
}
if (target === "cursor") {
if (args.profile != null && args.profile !== "") {
process.stderr.write("❌ --profile applies only to the hermes target\n");
process.exit(1);
}
const pathArg = args.path != null && args.path !== "" ? args.path : null;
removeCursor(resolveCursorProjectDir(pathArg));
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor\n");
process.exit(1);
},
});
const statusCommand = defineCommand({
meta: {
name: "status",
description: "Show injection status of nerve skills across agents",
},
run() {
printStatus();
},
});
export const agentCommand = defineCommand({
meta: {
name: "agent",
description: "Manage nerve skill injection for AI agents",
},
subCommands: {
inject: injectCommand,
update: updateCommand,
remove: removeCommand,
status: statusCommand,
},
});
+272
View File
@@ -0,0 +1,272 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { defineCommand } from "citty";
import { getNerveRoot } from "../workspace.js";
export const RESOURCE_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateResourceName(name: string, type: string): string | null {
if (name.length === 0) return `${type} name must not be empty.`;
if (name.length > 64) return `${type} name must be 64 characters or fewer.`;
if (!RESOURCE_NAME_RE.test(name))
return `${type} name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.`;
return null;
}
export type WorkflowScaffoldFiles = {
indexTs: string;
roleMainIndexTs: string;
roleMainPromptMd: string;
};
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
return {
indexTs: buildWorkflowIndexTs(name),
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
};
}
function buildWorkflowIndexTs(name: string): string {
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { mainRole } from "./roles/main/index.js";
type MainMeta = Record<string, unknown>;
const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
name: "${name}",
roles: {
main: mainRole,
},
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
if (ctx.steps.length === 0) {
return "main";
}
return END;
},
};
export default workflow;
`;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
/**
* Main role implement LLM calls, scripts, HTTP, etc.
* Optional: align behavior with \`prompt.md\` in this directory.
*/
export async function mainRole(
ctx: ThreadContext,
): Promise<RoleResult<Record<string, unknown>>> {
void ctx;
// TODO: implement your role logic here
return {
content: "${name} started",
meta: {},
};
}
`;
}
function buildWorkflowMainRolePromptMd(name: string): string {
return `# ${name} — main role
Starter template for this role's system or task instructions.
The scaffolded \`index.ts\` returns a fixed content line; replace that with real logic
and optionally load this file at runtime if you keep prompts outside code.
`;
}
export function buildSenseIndexTs(_senseId: string): string {
return `type SenseState = {
lastRun: number | null;
};
export const initialState: SenseState = { lastRun: null };
export async function compute(state: SenseState): Promise<{
state: SenseState;
workflow: null;
}> {
// TODO: implement sense logic
return {
state: { lastRun: Date.now() },
workflow: null,
};
}
`;
}
function writeFile(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content, "utf8");
}
function spawnAsync(cmd: string, args: string[], cwd: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
child.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${String(code)}`));
});
child.on("error", reject);
});
}
const createWorkflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Scaffold a new workflow at ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateResourceName(args.name, "Workflow");
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
const scaffold = buildWorkflowScaffold(args.name);
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
process.stdout.write("✅ Workflow scaffolded:\n");
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
process.stdout.write("\nBuilding workspace (workflows + senses)…\n");
try {
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
process.stdout.write(
`✅ Build complete — ${join("dist", "workflows", args.name, "index.js")} ready.\n`,
);
} catch {
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
);
process.stdout.write(
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
);
process.stdout.write(
` 4. After edits, run \`pnpm run build\` from the workspace root (${nerveRoot}); output is dist/workflows/<name>/index.js.\n`,
);
process.stdout.write(" 5. Run `nerve start` to launch the daemon.\n");
},
});
const createSenseCommand = defineCommand({
meta: {
name: "sense",
description: "Scaffold a new sense at ~/.uncaged-nerve/senses/<name>/",
},
args: {
name: {
type: "positional",
description: "Sense id (must match the key in nerve.yaml senses section)",
},
force: {
type: "boolean",
description: "Overwrite if the sense directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const senseDir = join(nerveRoot, "senses", args.name);
const nameError = validateResourceName(args.name, "Sense");
if (nameError !== null) {
process.stderr.write(`❌ Invalid sense name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(senseDir) && !args.force) {
process.stderr.write(
`⚠️ Sense "${args.name}" already exists at ${senseDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(join(senseDir, "src"), { recursive: true });
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
process.stdout.write("✅ Sense scaffolded:\n");
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
process.stdout.write("\nBuilding workspace (senses + workflows)…\n");
try {
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
process.stdout.write(
`✅ Build complete — ${join("dist", "senses", args.name, "index.js")} ready.\n`,
);
} catch {
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml under senses:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" group: default\n");
process.stdout.write(" throttle: null\n");
process.stdout.write(" timeout: 10s\n");
process.stdout.write(" grace_period: null\n");
process.stdout.write(
` 2. Edit ${join(senseDir, "src", "index.ts")} to implement ${args.name}.\n`,
);
process.stdout.write(
` 3. Re-run \`pnpm run build\` from the workspace root (${nerveRoot}) after edits.\n`,
);
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
},
});
export const createCommand = defineCommand({
meta: {
name: "create",
description: "Scaffold a new workflow or sense in the Nerve workspace",
},
subCommands: {
workflow: createWorkflowCommand,
sense: createSenseCommand,
},
});
+22 -3
View File
@@ -1,6 +1,9 @@
import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import {
type ForegroundSessionOptions,
runForegroundKernelSession,
} from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
@@ -9,9 +12,25 @@ export const devCommand = defineCommand({
name: "dev",
description: "Run the nerve kernel in the foreground (development mode)",
},
async run() {
args: {
port: {
type: "string",
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
default: "",
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
let sessionOpts: ForegroundSessionOptions = {};
if (args.port.length > 0) {
const n = Number.parseInt(args.port, 10);
if (Number.isNaN(n) || n < 1 || n > 65_535) {
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
process.exit(1);
}
sessionOpts = { httpApiPortOverride: n };
}
await runForegroundKernelSession(nerveRoot, createKernel, sessionOpts);
},
});
+321 -164
View File
@@ -1,5 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
@@ -14,80 +14,225 @@ senses:
throttle: 5s
timeout: 10s
grace_period: null
reflexes:
- kind: sense
sense: cpu-usage
interval: 10s
`;
const PACKAGE_JSON = `{
"name": "my-nerve-workspace",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"drizzle-orm": "latest"
const BIOME_JSON = `{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"devDependencies": {
"drizzle-kit": "latest"
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsole": "error"
}
}
}
}
`;
const PACKAGE_JSON = `${JSON.stringify(
{
name: "my-nerve-workspace",
version: "0.0.1",
private: true,
type: "module",
scripts: {
build: "node scripts/build.mjs",
},
dependencies: {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
zod: "^4.3.6",
},
devDependencies: {
"@biomejs/biome": "latest",
"@types/node": "^22.0.0",
esbuild: "^0.27.0",
typescript: "^5.7.0",
},
pnpm: {
onlyBuiltDependencies: ["esbuild"],
},
},
null,
2,
)}\n`;
const BUILD_MJS = `import * as esbuild from "esbuild";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const opts = {
bundle: true,
platform: "node",
format: "esm",
packages: "external",
};
function listDirs(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((name) => !name.startsWith(".") && !name.startsWith("_"))
.map((name) => ({ name, full: path.join(dir, name) }))
.filter(({ full }) => fs.statSync(full).isDirectory());
}
async function main() {
// Clean dist/
fs.rmSync(dist, { recursive: true, force: true });
for (const { name, full } of listDirs(path.join(root, "senses"))) {
const entry = path.join(full, "src", "index.ts");
if (!fs.existsSync(entry)) continue;
const outfile = path.join(dist, "senses", name, "index.js");
fs.mkdirSync(path.dirname(outfile), { recursive: true });
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
}
for (const { name, full } of listDirs(path.join(root, "workflows"))) {
const entry = path.join(full, "index.ts");
if (!fs.existsSync(entry)) continue;
const outfile = path.join(dist, "workflows", name, "index.js");
fs.mkdirSync(path.dirname(outfile), { recursive: true });
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
}
}
await main();
`;
const GITIGNORE = `data/
logs/
nerve.pid
node_modules/
knowledge.db
`;
/** Generated at workspace root so agents can \`cat AGENT.md\` instead of npm skill paths. */
const AGENT_MD = `# Nerve workspace — agent guide
This file is created by \`nerve init\`. Read it before implementing senses or workflows.
## Directory layout
| Path | Purpose |
|------|---------|
| \`nerve.yaml\` | Senses, workflows, intervals, groups |
| \`package.json\` | Single root package — no per-sense/per-workflow packages |
| \`scripts/build.mjs\` | Root esbuild step; output under \`dist/\` |
| \`senses/<name>/src/index.ts\` | Sense \`compute()\` + \`initialState\` |
| \`data/senses/<name>.json\` | Persisted sense state (written by the daemon) |
| \`workflows/<name>/index.ts\` | Default export: \`WorkflowDefinition\` |
| \`workflows/<name>/roles/<role>.ts\` | One TypeScript file per role |
| \`dist/senses/<name>/index.js\` | Bundled sense (after build) |
| \`dist/workflows/<name>/index.js\` | Bundled workflow (after build) |
There is **no** \`package.json\` or \`tsconfig.json\` inside individual senses or workflows.
## Naming
- **Workflows:** verb-first kebab-case (e.g. \`review-pull-request\`, \`deploy-staging\`). Avoid bare nouns like \`notifications\`.
- **Senses:** kebab-case descriptive nouns (e.g. \`cpu-usage\`).
## Workflow roles four-tuple pattern
Wire each role with \`createRole\` from \`@uncaged/nerve-workflow-utils\`:
1. **Adapter** \`AgentFn\` (LLM call)
2. **Prompt builder** \`async (ctx: ThreadContext) => string\`
3. **Meta schema** Zod object (routing / structured output from the model)
4. **Extractor config** how JSON meta is parsed from replies
Keep meta small (often one boolean per role). The **moderator** in \`WorkflowDefinition\` routes between role names.
## Build commands
Always run from the **workspace root**:
\`\`\`bash
pnpm run build
# or: npm run build
\`\`\`
Fix errors until this succeeds. New workflows must appear under \`workflows/<name>/\` and be registered in \`nerve.yaml\`; new senses under \`senses/<name>/\` with matching \`nerve.yaml\` entries.
## Coding style (Nerve conventions)
- Use \`type\`, not \`interface\`; prefer \`function\` over classes (except errors / library requirements).
- **Named exports only** no \`export default\` (exception: \`workflows/<name>/index.ts\` uses default export for the daemon loader).
- Nullable fields: \`T | null\`, not TypeScript optional \`?:\`.
- No dynamic \`import()\` in workspace code (bundling and tooling assume static imports).
- Use \`async\`/\`await\`; use a \`Result\` type for expected failures instead of control-flow try/catch.
## Extra references (optional)
- \`CONVENTIONS.md\` — project-specific overrides at repo root.
- \`.knowledge/*.md\` — deeper docs when working inside the Nerve monorepo.
- \`.cursor/skills/\` — Cursor Agent Skills (\`SKILL.md\` per skill).
`;
const NERVE_SKILLS_MDC = `---
description: >-
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
alwaysApply: true
---
# Nerve Agent Skills
**Agent Skills** are directories that contain a \`SKILL.md\` (with YAML frontmatter). Cursor loads them from **Project Skills** paths (for example \`.cursor/skills/\` or your global skills directory).
## Getting Nerve-oriented skills
There is no separate npm package for skills in the default workspace. To align with Nerve CLI, daemon, and monorepo conventions:
1. Copy or symlink skill folders from the **Nerve** repository (e.g. \`packages/skills/*/\`) into \`.cursor/skills/\`, **or**
2. Follow project documentation and \`CLAUDE.md\` / \`.cursor/rules/\` in this repo.
## How to use in an agent
1. When a task matches a skills **description** (in \`SKILL.md\` frontmatter), open that file and follow its steps.
2. Prefer those conventions for sense/workflow layout, \`nerve.yaml\`, and tooling over generic guesses.
3. Keep skills versioned with your dotfiles or project; update them when you upgrade Nerve.
`;
const execFileAsync = promisify(execFile);
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
const CPU_INDEX_TS = `import { loadavg } from "node:os";
export const cpuUsage = sqliteTable("cpu_usage", {
id: integer("id").primaryKey({ autoIncrement: true }),
ts: integer("ts").notNull(),
model: text("model").notNull(),
loadPercent: real("load_percent").notNull(),
});
`;
type CpuState = {
samples: Array<{ ts: number; value: number }>;
};
const CPU_INDEX_JS = `import { cpus } from "node:os";
export const initialState: CpuState = { samples: [] };
export async function compute() {
const cpuList = cpus();
let totalIdle = 0;
let totalTick = 0;
for (const cpu of cpuList) {
for (const [, time] of Object.entries(cpu.times)) {
totalTick += time;
}
totalIdle += cpu.times.idle;
}
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
return {
model: cpuList[0]?.model ?? "unknown",
loadPercent: Math.round(loadPercent * 100) / 100,
ts: Date.now(),
};
export async function compute(state: CpuState): Promise<{
state: CpuState;
workflow: null;
}> {
const [oneMin] = loadavg();
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
return { state: { samples: newSamples }, workflow: null };
}
`;
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
model TEXT NOT NULL,
load_percent REAL NOT NULL
);
`;
function writeFile(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, content, "utf8");
@@ -117,90 +262,6 @@ async function detectPackageManager(): Promise<{ cmd: string; installArgs: strin
return { cmd: "npm", installArgs: ["install"] };
}
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateWorkflowName(name: string): string | null {
if (name.length === 0) return "Workflow name must not be empty.";
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
const initWorkflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Scaffold a new workflow template in ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateWorkflowName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
const initWorkspaceCommand = defineCommand({
meta: {
name: "workspace",
@@ -212,9 +273,14 @@ const initWorkspaceCommand = defineCommand({
description: "Reinitialize even if workspace already exists (preserves data/)",
default: false,
},
"skip-install": {
type: "boolean",
description: "Skip dependency installation (for testing or offline use)",
default: false,
},
},
async run({ args }) {
await runInitWorkspace(args.force);
await runInitWorkspace(args.force, args["skip-install"]);
},
});
@@ -236,7 +302,77 @@ async function verifyNodeSqlite(): Promise<boolean> {
}
}
async function runInitWorkspace(force: boolean): Promise<void> {
function isNerveRootNonEmpty(nerveRoot: string): boolean {
if (!existsSync(nerveRoot)) return false;
return readdirSync(nerveRoot).length > 0;
}
async function runInitFromGit(url: string): Promise<void> {
const trimmed = url.trim();
if (trimmed.length === 0) {
process.stderr.write("❌ --from requires a non-empty git URL.\n");
process.exit(1);
}
const nerveRoot = getNerveRoot();
if (isNerveRootNonEmpty(nerveRoot)) {
process.stderr.write(
`${nerveRoot} already exists and is not empty. Remove it (or empty it) before using --from.\n`,
);
process.exit(1);
}
try {
await execFileAsync("git", ["--version"]);
} catch {
process.stderr.write("❌ git is not available. Install git and retry.\n");
process.exit(1);
}
try {
await execFileAsync("pnpm", ["--version"]);
} catch {
process.stderr.write("❌ pnpm is not available. Install pnpm and retry.\n");
process.exit(1);
}
process.stdout.write(`Cloning ${trimmed}${nerveRoot}\n`);
try {
await runCommand("git", ["clone", trimmed, nerveRoot], process.cwd());
} catch {
process.stderr.write("❌ git clone failed.\n");
process.exit(1);
}
if (!existsSync(join(nerveRoot, "nerve.yaml"))) {
process.stdout.write(`⚠️ ${join(nerveRoot, "nerve.yaml")} not found after clone.\n`);
}
if (!existsSync(join(nerveRoot, "package.json"))) {
process.stdout.write(`⚠️ ${join(nerveRoot, "package.json")} not found after clone.\n`);
}
process.stdout.write("Installing dependencies with pnpm …\n");
try {
await runCommand("pnpm", ["install", "--no-cache"], nerveRoot);
} catch {
process.stdout.write(
`⚠️ pnpm install failed. Try manually:\n cd ${nerveRoot} && pnpm install --no-cache\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
process.stdout.write(
`✅ Workspace cloned to ${nerveRoot}\n\n💡 Next steps:\n 1. Review nerve.yaml and install any missing tooling.\n 2. Run \`nerve start\` to launch the daemon.\n`,
);
}
async function runInitWorkspace(force: boolean, skipInstall = false): Promise<void> {
const nerveRoot = getNerveRoot();
if (existsSync(nerveRoot) && !force) {
@@ -246,33 +382,41 @@ async function runInitWorkspace(force: boolean): Promise<void> {
mkdirSync(join(nerveRoot, "data"), { recursive: true });
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "src"), { recursive: true });
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "scripts", "build.mjs"), BUILD_MJS);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.js"), CPU_INDEX_JS);
writeFile(
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
CPU_MIGRATION_SQL,
);
writeFile(join(nerveRoot, "AGENT.md"), AGENT_MD);
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
writeFile(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"), NERVE_SKILLS_MDC);
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
if (!skipInstall) {
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
process.stdout.write("Building senses…\n");
try {
await runCommand("pnpm", ["run", "build"], nerveRoot);
} catch {
process.stdout.write(`⚠️ Build failed. Try manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
}
if (!existsSync(join(nerveRoot, ".git"))) {
@@ -294,7 +438,7 @@ export const initCommand = defineCommand({
meta: {
name: "init",
description:
"Initialize workspace (nerve init) or scaffold templates (nerve init workflow <name>)",
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or reinit workspace (nerve init workspace)",
},
args: {
force: {
@@ -302,12 +446,25 @@ export const initCommand = defineCommand({
description: "Reinitialize even if workspace already exists (preserves data/)",
default: false,
},
from: {
type: "string",
description: "Clone an existing git repo into ~/.uncaged-nerve instead of scaffolding",
required: false,
},
"skip-install": {
type: "boolean",
description: "Skip dependency installation (for testing or offline use)",
default: false,
},
},
subCommands: {
workflow: initWorkflowCommand,
workspace: initWorkspaceCommand,
},
async run({ args }) {
await runInitWorkspace(args.force);
if (args.from !== undefined) {
await runInitFromGit(String(args.from));
return;
}
await runInitWorkspace(args.force, args["skip-install"]);
},
});
@@ -0,0 +1,79 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
import { queryKnowledgeGlobal, queryKnowledgeRepo } from "../knowledge/query.js";
import { listRegisteredKnowledgeRoots } from "../knowledge/registry.js";
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
const DEFAULT_LIMIT = 10;
export function parseKnowledgeQueryLimit(raw: string | undefined): number {
if (raw === undefined || raw.trim().length === 0) {
return DEFAULT_LIMIT;
}
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMIT;
}
export async function runKnowledgeQueryGlobal(queryText: string, limit: number): Promise<void> {
const roots = listRegisteredKnowledgeRoots();
if (roots.length === 0) {
process.stderr.write(
"❌ No registered repos — run `nerve knowledge sync` in each repo first.\n",
);
process.exit(1);
}
const hits = await queryKnowledgeGlobal(roots, KNOWLEDGE_DB, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
}
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
if (h === undefined) continue;
const prefix = h.repoRoot !== null ? `[${h.repoRoot}] ` : "";
process.stdout.write(
`${String(i + 1)}. score=${h.score.toFixed(4)} ${prefix}${h.path} (${h.slug})\n${h.text}\n---\n`,
);
}
}
export async function runKnowledgeQueryScoped(
repoFlag: string | undefined,
queryText: string,
limit: number,
): Promise<void> {
let repoRoot: string | null = null;
if (repoFlag !== undefined && String(repoFlag).trim().length > 0) {
repoRoot = resolve(String(repoFlag).trim());
} else {
repoRoot = findKnowledgeRepoRoot(process.cwd());
}
if (repoRoot === null) {
process.stderr.write("❌ No knowledge.yaml found — use -r <path> or run from a repo root.\n");
process.exit(1);
}
const dbPath = `${repoRoot}/${KNOWLEDGE_DB}`;
if (!existsSync(dbPath)) {
process.stderr.write(
`❌ No ${KNOWLEDGE_DB} in ${repoRoot} — run \`nerve knowledge sync\` first.\n`,
);
process.exit(1);
}
const hits = await queryKnowledgeRepo(repoRoot, dbPath, queryText, limit);
if (hits.length === 0) {
process.stdout.write("No results.\n");
return;
}
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
if (h === undefined) continue;
process.stdout.write(
`${String(i + 1)}. score=${h.score.toFixed(4)} ${h.path} (${h.slug})\n${h.text}\n---\n`,
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { defineCommand } from "citty";
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
import { runKnowledgeSync } from "../knowledge/sync.js";
import {
parseKnowledgeQueryLimit,
runKnowledgeQueryGlobal,
runKnowledgeQueryScoped,
} from "./knowledge-query-run.js";
const syncCommand = defineCommand({
meta: {
name: "sync",
description: "Chunk matching files from knowledge.yaml and rebuild knowledge.db",
},
async run() {
const repoRoot = findKnowledgeRepoRoot(process.cwd());
if (repoRoot === null) {
process.stderr.write(
"❌ No knowledge.yaml found — run from a repo that contains knowledge.yaml.\n",
);
process.exit(1);
}
try {
const result = await runKnowledgeSync(repoRoot);
process.stdout.write(
`✅ Indexed ${String(result.filesIndexed)} file(s), ${String(result.chunksWritten)} chunk(s) → ${result.dbPath}\n`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ knowledge sync failed: ${msg}\n`);
process.exit(1);
}
},
});
const queryCommand = defineCommand({
meta: {
name: "query",
description: "Search indexed knowledge (word overlap placeholder until embeddings)",
},
args: {
query: {
type: "positional",
required: true,
description: "Search text",
},
repo: {
type: "string",
description: "Use knowledge.db from another repo root (--repo /path)",
required: false,
},
g: {
type: "boolean",
description: "Search across all repos registered via prior sync",
default: false,
},
limit: {
type: "string",
description: "Max hits (default 10)",
required: false,
},
},
async run({ args }) {
const conflict = knowledgeQueryScopeConflictMessage(args.repo, args.g);
if (conflict !== null) {
process.stderr.write(`${conflict}\n`);
process.exit(1);
}
const queryText = args.query;
const limit = parseKnowledgeQueryLimit(args.limit);
if (args.g) {
await runKnowledgeQueryGlobal(queryText, limit);
return;
}
await runKnowledgeQueryScoped(args.repo as string, queryText, limit);
},
});
export const knowledgeCommand = defineCommand({
meta: {
name: "knowledge",
description: "Project knowledge index (knowledge.yaml + knowledge.db, RFC-003)",
},
subCommands: {
sync: syncCommand,
query: queryCommand,
},
});
+1 -1
View File
@@ -85,7 +85,7 @@ export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string):
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
if (slice.nextOffset !== null) {
footer += `⏩ Earlier lines available. Fetch previous page:\n`;
footer += "⏩ Earlier lines available. Fetch previous page:\n";
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
}
+160
View File
@@ -0,0 +1,160 @@
import { defineCommand } from "citty";
import { loadRemotes, resolveRemote, saveRemotes } from "../remotes.js";
const remoteAddCommand = defineCommand({
meta: { name: "add", description: "Add a named remote" },
args: {
name: { type: "positional", description: "Remote name" },
host: { type: "positional", description: "host:port" },
token: { type: "string", description: "API token", default: "" },
},
run({ args }) {
const config = loadRemotes();
if (config.remotes[args.name] !== undefined) {
process.stderr.write(`Remote "${args.name}" already exists.\n`);
process.exit(1);
}
config.remotes[args.name] = {
host: args.host,
token: args.token.length > 0 ? args.token : null,
};
saveRemotes(config);
process.stdout.write(`Added remote "${args.name}" → ${args.host}\n`);
},
});
const remoteListCommand = defineCommand({
meta: { name: "list", description: "List all remotes" },
run() {
const config = loadRemotes();
const names = Object.keys(config.remotes);
if (names.length === 0) {
process.stdout.write("No remotes configured.\n");
return;
}
for (const name of names) {
const entry = config.remotes[name];
if (entry === undefined) continue;
const def = config.default === name ? " (default)" : "";
const tok = entry.token !== null ? " token=***" : "";
process.stdout.write(`${name}\t${entry.host}${tok}${def}\n`);
}
},
});
const remoteShowCommand = defineCommand({
meta: { name: "show", description: "Show remote details" },
args: {
name: { type: "positional", description: "Remote name" },
},
run({ args }) {
const entry = resolveRemote(args.name);
if (entry === null) {
process.stderr.write(`Remote "${args.name}" not found.\n`);
process.exit(1);
}
process.stdout.write(`name: ${args.name}\n`);
process.stdout.write(`host: ${entry.host}\n`);
process.stdout.write(`token: ${entry.token !== null ? "***" : "(none)"}\n`);
},
});
const remoteSetUrlCommand = defineCommand({
meta: { name: "set-url", description: "Update remote host" },
args: {
name: { type: "positional", description: "Remote name" },
host: { type: "positional", description: "New host:port" },
},
run({ args }) {
const config = loadRemotes();
const entry = config.remotes[args.name];
if (entry === undefined) {
process.stderr.write(`Remote "${args.name}" not found.\n`);
process.exit(1);
}
entry.host = args.host;
saveRemotes(config);
process.stdout.write(`Updated "${args.name}" → ${args.host}\n`);
},
});
const remoteSetTokenCommand = defineCommand({
meta: { name: "set-token", description: "Update remote token" },
args: {
name: { type: "positional", description: "Remote name" },
token: { type: "positional", description: "New token" },
},
run({ args }) {
const config = loadRemotes();
const entry = config.remotes[args.name];
if (entry === undefined) {
process.stderr.write(`Remote "${args.name}" not found.\n`);
process.exit(1);
}
entry.token = args.token;
saveRemotes(config);
process.stdout.write(`Updated token for "${args.name}".\n`);
},
});
const remoteRemoveCommand = defineCommand({
meta: { name: "remove", description: "Remove a remote" },
args: {
name: { type: "positional", description: "Remote name" },
},
run({ args }) {
const config = loadRemotes();
if (config.remotes[args.name] === undefined) {
process.stderr.write(`Remote "${args.name}" not found.\n`);
process.exit(1);
}
delete config.remotes[args.name];
if (config.default === args.name) {
config.default = null;
}
saveRemotes(config);
process.stdout.write(`Removed remote "${args.name}".\n`);
},
});
const remoteDefaultCommand = defineCommand({
meta: { name: "default", description: "Set or show default remote" },
args: {
name: {
type: "positional",
description: "Remote name (omit to show current)",
required: false,
},
},
run({ args }) {
const config = loadRemotes();
if (!args.name || args.name.length === 0) {
if (config.default !== null) {
process.stdout.write(`${config.default}\n`);
} else {
process.stdout.write("No default remote set.\n");
}
return;
}
if (config.remotes[args.name] === undefined) {
process.stderr.write(`Remote "${args.name}" not found.\n`);
process.exit(1);
}
config.default = args.name;
saveRemotes(config);
process.stdout.write(`Default remote set to "${args.name}".\n`);
},
});
export const remoteCommand = defineCommand({
meta: { name: "remote", description: "Manage named remote connections" },
subCommands: {
add: remoteAddCommand,
list: remoteListCommand,
show: remoteShowCommand,
"set-url": remoteSetUrlCommand,
"set-token": remoteSetTokenCommand,
remove: remoteRemoveCommand,
default: remoteDefaultCommand,
},
});
+19 -142
View File
@@ -1,21 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core";
import { type SenseInfo, parseNerveConfig, senseTriggerLabels } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
import {
assertSenseDbExists,
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
openSenseDb,
parseSenseQueryArgs,
pickDefaultPreviewTable,
} from "../sense-sqlite.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { getNerveRoot, isRunning } from "../workspace.js";
// ---------------------------------------------------------------------------
// Formatting helpers (exported for tests)
@@ -44,8 +35,9 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
lines.push(
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
);
}
return lines.join("");
}
@@ -60,12 +52,13 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
}
const result = parseNerveConfig(raw);
if (!result.ok) return [];
return Object.entries(result.value.senses).map(([name, cfg]) => ({
const { senses } = result.value;
return Object.entries(senses).map(([name, cfg]) => ({
name,
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
lastSignalTs: null,
triggers: senseTriggerLabels(name, senses),
}));
}
@@ -79,9 +72,9 @@ const senseListCommand = defineCommand({
description: "List all registered senses and their status",
},
async run() {
if (!isRunning()) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write(
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
"⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n",
);
const configPath = join(getNerveRoot(), "nerve.yaml");
const senses = sensesFromConfig(configPath);
@@ -89,22 +82,17 @@ const senseListCommand = defineCommand({
return;
}
const socketPath = getSocketPath();
let response: { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
const transport = resolveDaemonTransport();
let senses: SenseInfo[];
try {
response = await listSensesViaDaemon(socketPath);
senses = await transport.listSenses();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon error: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(formatSenseList(response.senses));
process.stdout.write(formatSenseList(senses));
},
});
@@ -124,15 +112,15 @@ const senseTriggerCommand = defineCommand({
},
},
async run({ args }) {
if (!isRunning()) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerSenseViaDaemon(socketPath, args.name);
response = await transport.triggerSense(args.name);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
@@ -148,115 +136,6 @@ const senseTriggerCommand = defineCommand({
},
});
// ---------------------------------------------------------------------------
// nerve sense schema <name>
// ---------------------------------------------------------------------------
const senseSchemaCommand = defineCommand({
meta: {
name: "schema",
description: "Print CREATE TABLE statements from a sense SQLite database",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print JSON array of CREATE TABLE SQL strings",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
db = openSenseDb(nerveRoot, args.name);
const statements = listTableSqlStatements(db);
if (args.json) {
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
} else if (statements.length === 0) {
process.stdout.write("(no tables)\n");
} else {
for (const sql of statements) {
process.stdout.write(`${sql};\n\n`);
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve sense query <name> [sql...]
// ---------------------------------------------------------------------------
const senseQueryCommand = defineCommand({
meta: {
name: "query",
description:
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print result rows as JSON",
default: false,
},
},
async run({ args, rawArgs }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
let parsed: { name: string; sql: string | undefined };
try {
parsed = parseSenseQueryArgs(rawArgs);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
}
db = openSenseDb(nerveRoot, args.name);
let sql = parsed.sql?.trim();
if (!sql) {
const table = pickDefaultPreviewTable(db);
if (table === null) {
process.stderr.write("❌ No tables found in database.\n");
process.exit(1);
} else {
sql = defaultPreviewSql(table);
}
}
const rows = db.prepare(sql).all() as Record<string, unknown>[];
if (args.json) {
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
} else {
process.stdout.write(formatRowsAsAlignedTable(rows));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve sense (parent command)
// ---------------------------------------------------------------------------
@@ -269,7 +148,5 @@ export const senseCommand = defineCommand({
subCommands: {
list: senseListCommand,
trigger: senseTriggerCommand,
schema: senseSchemaCommand,
query: senseQueryCommand,
},
});
+43 -9
View File
@@ -1,9 +1,10 @@
import { spawn } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import {
@@ -56,7 +57,7 @@ function daemonBootstrapScript(): string {
);
}
async function runDaemon(nerveRoot: string): Promise<void> {
async function runDaemon(nerveRoot: string, cliHttpPort: number | null): Promise<void> {
if (isRunning()) {
const pid = readPidFile();
process.stderr.write(`⚠️ Nerve daemon is already running (pid ${pid}).\n`);
@@ -74,10 +75,27 @@ async function runDaemon(nerveRoot: string): Promise<void> {
const bootstrapPath = daemonBootstrapScript();
const child = spawn(process.execPath, [bootstrapPath], {
const configPath = join(nerveRoot, "nerve.yaml");
let yamlApiPort: number | null = null;
try {
const raw = readFileSync(configPath, "utf8");
const parsed = parseNerveConfig(raw);
if (parsed.ok) yamlApiPort = parsed.value.api.port;
} catch {
// kernel bootstrap will surface a clearer error if config is missing
}
const resolvedHttpPort = cliHttpPort ?? yamlApiPort;
const env: NodeJS.ProcessEnv = { ...process.env, NERVE_ROOT: nerveRoot };
if (resolvedHttpPort !== null && resolvedHttpPort > 0) {
env.NERVE_API_PORT = String(resolvedHttpPort);
}
// After `open`, file-backed WriteStream has a numeric OS fd for spawn stdio; `@types/node` omits `fd` on this WriteStream alias.
const logFd = (logStream as unknown as { fd: number }).fd;
const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", bootstrapPath], {
detached: true,
stdio: ["ignore", logStream.fd, logStream.fd],
env: { ...process.env, NERVE_ROOT: nerveRoot },
stdio: ["ignore", logFd, logFd],
env,
cwd: nerveRoot,
});
@@ -107,8 +125,8 @@ async function runDaemon(nerveRoot: string): Promise<void> {
}
/** Background daemon only — use `nerve dev` for foreground mode. */
export async function runDaemonStartCommand(): Promise<void> {
await runDaemon(getNerveRoot());
export async function runDaemonStartCommand(cliHttpPort: number | null = null): Promise<void> {
await runDaemon(getNerveRoot(), cliHttpPort);
}
export const daemonStartCommand = defineCommand({
@@ -116,7 +134,23 @@ export const daemonStartCommand = defineCommand({
name: "start",
description: "Start the nerve daemon in the background",
},
async run() {
await runDaemonStartCommand();
args: {
port: {
type: "string",
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
default: "",
},
},
async run({ args }) {
let cliHttpPort: number | null = null;
if (args.port.length > 0) {
const n = Number.parseInt(args.port, 10);
if (Number.isNaN(n) || n < 1 || n > 65_535) {
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
process.exit(1);
}
cliHttpPort = n;
}
await runDaemonStartCommand(cliHttpPort);
},
});
+24 -2
View File
@@ -4,6 +4,8 @@ import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { getNerveRoot, getPidPath, isRunning, readPidFile } from "../workspace.js";
function formatUptime(ms: number): string {
@@ -42,12 +44,33 @@ export const statusCommand = defineCommand({
description: "Show nerve daemon status",
},
async run() {
if (isRemoteDaemonCli()) {
const transport = resolveDaemonTransport();
try {
const health = await transport.health();
process.stdout.write("✅ Nerve daemon is reachable (remote HTTP).\n");
process.stdout.write(` hostname: ${health.hostname}\n`);
process.stdout.write(` version: ${health.version}\n`);
process.stdout.write(` uptime: ${formatUptime(health.uptime * 1000)}\n`);
process.stdout.write(` started: ${health.startedAt}\n`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Cannot reach remote daemon: ${msg}\n`);
process.exit(1);
}
return;
}
if (!isRunning()) {
process.stdout.write("😴 Nerve daemon is not running.\n");
return;
}
const pid = readPidFile() as number;
const pid = readPidFile();
if (pid === null) {
process.stdout.write("😴 Nerve daemon is not running.\n");
return;
}
const configPath = join(getNerveRoot(), "nerve.yaml");
let senseList: string[] = [];
@@ -74,6 +97,5 @@ export const statusCommand = defineCommand({
process.stdout.write(
` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`,
);
process.stdout.write(" signals: (pending SignalBus persistence)\n");
},
});
+279
View File
@@ -0,0 +1,279 @@
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { isRunning } from "../workspace.js";
import {
DEFAULT_PAGE_SIZE,
DEFAULT_THREAD_BUDGET_CHARS,
THREAD_ROUNDS_FETCH_LIMIT,
buildInspectOutput,
buildListOutput,
buildThreadCommandOutput,
getAllWorkflowRuns,
openStore,
parseIntArg,
} from "./workflow.js";
// ---------------------------------------------------------------------------
// nerve thread list
// ---------------------------------------------------------------------------
const threadListCommand = defineCommand({
meta: {
name: "list",
description: "List active (queued/started) workflow runs from logs",
},
args: {
all: {
type: "boolean",
description: "Include completed/failed/crashed runs",
default: false,
},
workflow: {
type: "string",
description: "Filter by workflow name",
default: "",
},
limit: {
type: "string",
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N runs (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
const runs = args.all
? getAllWorkflowRuns(store, filterWorkflow)
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
const { lines, paginationHint } = buildListOutput(
runs,
offset,
limit,
args.all,
filterWorkflow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread show <runId>
// ---------------------------------------------------------------------------
const threadShowCommand = defineCommand({
meta: {
name: "show",
description: "Print role rounds for a workflow run (agent-oriented, budget-limited)",
},
args: {
runId: {
type: "positional",
description: "The run ID to dump role rounds for",
},
before: {
type: "string",
description:
"Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)",
default: "0",
},
budget: {
type: "string",
description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`,
default: String(DEFAULT_THREAD_BUDGET_CHARS),
},
},
async run({ args }) {
const store = await openStore();
try {
const before = Math.max(0, parseIntArg(args.before, 0));
const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const startRow = before === 0 ? store.getThreadStartMessage(args.runId) : null;
const totalRoleRounds = store.getThreadRoundCount(args.runId);
if (totalRoleRounds === 0 && startRow === null) {
process.stdout.write(
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
);
return;
}
const descRows = store.getThreadRounds(args.runId, {
before,
limit: THREAD_ROUNDS_FETCH_LIMIT,
});
const prefixLines = [
"🧵 Role rounds (workflow thread)\n",
` runId: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`,
];
const { lines, paginationHint } = buildThreadCommandOutput(
prefixLines,
descRows,
budgetChars,
args.runId,
startRow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
if (descRows.length === 0 && before > 0) {
process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread inspect <runId>
// ---------------------------------------------------------------------------
const threadInspectCommand = defineCommand({
meta: {
name: "inspect",
description: "Show details and thread events for a workflow run",
},
args: {
runId: {
type: "positional",
description: "The run ID to inspect",
},
limit: {
type: "string",
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N log entries (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const allLogs = store.query({ source: "workflow", refId: args.runId });
const { header, eventLines, paginationHint } = buildInspectOutput(
run,
allLogs,
offset,
limit,
);
for (const line of [...header, ...eventLines]) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread kill <runId>
// ---------------------------------------------------------------------------
const threadKillCommand = defineCommand({
meta: {
name: "kill",
description: "Kill a running or queued workflow thread by runId",
},
args: {
runId: {
type: "positional",
description: "The run ID to kill",
},
},
async run({ args }) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await transport.killWorkflow(args.runId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Kill failed: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Kill signal sent for run "${args.runId}".\n`);
},
});
// ---------------------------------------------------------------------------
// nerve thread (parent command)
// ---------------------------------------------------------------------------
export const threadCommand = defineCommand({
meta: {
name: "thread",
description: "Inspect and manage workflow threads (runs)",
},
subCommands: {
list: threadListCommand,
show: threadShowCommand,
inspect: threadInspectCommand,
kill: threadKillCommand,
},
});
+14 -4
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { validateAgentConfigurationLayer } from "../workflow-agent-validation.js";
import { getNerveRoot } from "../workspace.js";
export const validateCommand = defineCommand({
@@ -12,7 +13,8 @@ export const validateCommand = defineCommand({
description: "Validate nerve.yaml configuration",
},
async run() {
const configPath = join(getNerveRoot(), "nerve.yaml");
const nerveRoot = getNerveRoot();
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
@@ -29,12 +31,20 @@ export const validateCommand = defineCommand({
}
const config = result.value;
const agentLayer = validateAgentConfigurationLayer(config, nerveRoot);
if (!agentLayer.ok) {
process.stderr.write(`❌ Config validation failed: ${agentLayer.message}\n`);
process.exit(1);
}
const senseCount = Object.keys(config.senses).length;
const reflexCount = config.reflexes.length;
const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0;
const triggerScheduleCount = Object.values(config.senses).filter(
(s) => s.interval !== null || s.on.length > 0,
).length;
const workflowCount = Object.keys(config.workflows).length;
process.stdout.write(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${triggerScheduleCount} sense trigger schedule(s), ${workflowCount} workflow(s)\n`,
);
},
});
+286 -132
View File
@@ -1,15 +1,25 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { stringify } from "yaml";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import type { LogStore, WorkflowRun } from "../daemon-types.js";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { formatRowsAsAlignedTable } from "../table-format.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
import { getNerveRoot, isRunning } from "../workspace.js";
export const DEFAULT_PAGE_SIZE = 20;
/** Default max characters for `nerve thread show` output (including run header). */
export const DEFAULT_THREAD_BUDGET_CHARS = 8000;
/** Max role-round rows read from SQLite per invocation (DESC by round). */
export const THREAD_ROUNDS_FETCH_LIMIT = 8192;
export function parseIntArg(raw: string, fallback: number): number {
const v = Number.parseInt(raw, 10);
return Number.isNaN(v) ? fallback : v;
@@ -19,11 +29,28 @@ export function getDbPath(): string {
return join(getNerveRoot(), "data", "logs.db");
}
export function formatTs(ts: number): string {
return new Date(ts).toISOString();
/** Human-readable placeholder when a timestamp is missing or not representable as ISO 8601. */
export const UNKNOWN_TIMESTAMP_LABEL = "(unknown)";
/**
* Format epoch milliseconds as UTC ISO 8601, or {@link UNKNOWN_TIMESTAMP_LABEL} when the value
* is nullish, not a finite number, or cannot be converted (defensive against bad DB / test data).
*/
export function formatTs(timestampMs: number | null | undefined): string {
if (timestampMs === null || timestampMs === undefined) {
return UNKNOWN_TIMESTAMP_LABEL;
}
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) {
return UNKNOWN_TIMESTAMP_LABEL;
}
try {
return new Date(timestampMs).toISOString();
} catch {
return UNKNOWN_TIMESTAMP_LABEL;
}
}
async function openStore(): Promise<LogStore> {
export async function openStore(): Promise<LogStore> {
const nerveRoot = getNerveRoot();
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
@@ -50,6 +77,8 @@ export function statusIcon(status: WorkflowRun["status"]): string {
return "🗑";
case "interrupted":
return "⚠️";
case "killed":
return "🛑";
default: {
const _exhaustive: never = status;
return `?(${_exhaustive})`;
@@ -58,7 +87,7 @@ export function statusIcon(status: WorkflowRun["status"]): string {
}
/**
* Retrieve all workflow runs from the store, sorted by ts descending (newest first).
* Retrieve all workflow runs from the store, sorted by timestamp descending (newest first).
* Delegates to the store's efficient SQL query on the workflow_runs table.
*/
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
@@ -70,7 +99,8 @@ export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | nul
*/
export function formatRunLine(run: WorkflowRun): string {
const icon = statusIcon(run.status);
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status} ts=${formatTs(run.ts)}\n`;
const exitCodeStr = run.exitCode !== null ? ` exit_code=${run.exitCode}` : "";
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status}${exitCodeStr} timestamp=${formatTs(run.timestamp)}\n`;
}
/**
@@ -113,7 +143,7 @@ export function buildListOutput(
const allFlagStr = allFlag ? " --all" : "";
paginationHint =
`\n⏩ ${remaining} more run(s) not shown. Fetch next page:\n` +
` nerve workflow list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
` nerve thread list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
}
return { lines, paginationHint };
@@ -130,7 +160,7 @@ export type InspectOutput = {
export function buildInspectOutput(
run: WorkflowRun,
allLogs: Array<{ ts: number; type: string; payload: string | null }>,
allLogs: Array<{ timestamp: number; type: string; payload: string | null }>,
offset: number,
limit: number,
): InspectOutput {
@@ -143,7 +173,7 @@ export function buildInspectOutput(
`🔍 Workflow run: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` ts: ${formatTs(run.ts)}\n`,
` timestamp: ${formatTs(run.timestamp)}\n`,
`\n📜 Thread events (${shown} of ${total}):\n`,
];
@@ -158,7 +188,7 @@ export function buildInspectOutput(
: entry.payload.length <= 200
? ` payload=${entry.payload}`
: ` payload=${entry.payload.slice(0, 200)}`;
eventLines.push(` [${formatTs(entry.ts)}] type=${entry.type}${payloadStr}\n`);
eventLines.push(` [${formatTs(entry.timestamp)}] type=${entry.type}${payloadStr}\n`);
}
}
@@ -166,130 +196,231 @@ export function buildInspectOutput(
if (remaining > 0) {
paginationHint =
`\n⏩ ${remaining} more event(s) not shown. Fetch next page:\n` +
` nerve workflow inspect ${run.runId} --offset ${offset + limit}\n`;
` nerve thread inspect ${run.runId} --offset ${offset + limit}\n`;
}
return { header, eventLines, paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow list
// nerve thread show <runId> — agent-oriented role rounds
// ---------------------------------------------------------------------------
export type PartitionedMessage = {
roleStr: string;
contentBody: string;
meta: Record<string, unknown>;
};
/**
* Extract display fields from a WorkflowMessage-shaped object.
* `role` and `content` are used for header/body; `meta` is serialized as YAML frontmatter.
*/
export function partitionWorkflowMessage(msg: {
role: string;
content: string;
meta: unknown;
timestamp: number;
}): PartitionedMessage {
const roleStr = msg.role;
const contentBody = msg.content;
const meta =
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
? isPlainRecord(msg.meta)
? msg.meta
: (msg.meta as Record<string, unknown>)
: ({} as Record<string, unknown>);
return { roleStr, contentBody, meta };
}
/**
* One role round as plain text: header line, YAML frontmatter (meta only), body (content).
*/
export function formatThreadRoundBlock(row: ThreadRoundRow): string {
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
const yamlBlock =
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
return `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
}
export type ThreadCommandOutput = {
lines: string[];
paginationHint: string | null;
};
function buildTruncatedSingleRound(
row: ThreadRoundRow,
remaining: number,
prefixLines: string[],
runId: string,
budgetFlag: string,
): ThreadCommandOutput {
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
const yamlBlock =
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
const header = `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n`;
const maxBody = Math.max(0, remaining - header.length - "[truncated]\n".length);
const truncated =
maxBody > 0 && contentBody.length > maxBody
? `${contentBody.slice(0, maxBody)}\n[truncated]\n`
: `${contentBody}\n[truncated]\n`;
const single = `${header + truncated}\n`;
const hintRound = row.round;
return {
lines: [...prefixLines, single],
paginationHint:
hintRound > 1
? `\n⏩ Older rounds exist. Fetch with:\n nerve thread show ${runId} --before ${String(hintRound)}${budgetFlag}\n`
: null,
};
}
/**
* Build stdout lines for `nerve thread show`: newest-first selection from
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
* When `startRow` is set (typically the persisted `__start__` frame on the first page only),
* it is formatted first and its length is subtracted from the budget before consuming `descRows`.
*/
export function buildThreadCommandOutput(
prefixLines: string[],
descRows: ThreadRoundRow[],
budgetChars: number,
runId: string,
startRow: ThreadRoundRow | null = null,
): ThreadCommandOutput {
const prefixText = prefixLines.join("");
let remaining = Math.max(0, budgetChars - prefixText.length);
const leadingRoundBlocks: string[] = [];
const budgetFlag =
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
if (startRow !== null) {
const startBlock = formatThreadRoundBlock(startRow);
if (startBlock.length <= remaining) {
leadingRoundBlocks.push(startBlock);
remaining -= startBlock.length;
} else {
return buildTruncatedSingleRound(startRow, remaining, prefixLines, runId, budgetFlag);
}
}
const picked: ThreadRoundRow[] = [];
for (const row of descRows) {
const block = formatThreadRoundBlock(row);
if (block.length <= remaining) {
picked.push(row);
remaining -= block.length;
continue;
}
if (picked.length === 0) {
return buildTruncatedSingleRound(
row,
remaining,
[...prefixLines, ...leadingRoundBlocks],
runId,
budgetFlag,
);
}
break;
}
const blocksAsc = picked.map(formatThreadRoundBlock).reverse();
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
let paginationHint: string | null = null;
if (shownMinRound !== null && shownMinRound > 1) {
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve thread show ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
}
return { lines: [...prefixLines, ...leadingRoundBlocks, ...blocksAsc], paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow list (reads workflow definitions from workspace YAML)
// ---------------------------------------------------------------------------
const workflowListCommand = defineCommand({
meta: {
name: "list",
description: "List active (queued/started) workflow runs",
description: "List workflow definitions from nerve.yaml",
},
args: {
all: {
type: "boolean",
description: "Include completed/failed/crashed runs",
default: false,
},
workflow: {
type: "string",
description: "Filter by workflow name",
default: "",
},
limit: {
type: "string",
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N runs (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
async run() {
const configPath = join(getNerveRoot(), "nerve.yaml");
let raw: string;
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
const runs = args.all
? getAllWorkflowRuns(store, filterWorkflow)
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
const { lines, paginationHint } = buildListOutput(
runs,
offset,
limit,
args.all,
filterWorkflow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
raw = readFileSync(configPath, "utf8");
} catch {
process.stderr.write(`❌ Could not read ${configPath}\n`);
process.exit(1);
}
const result = parseNerveConfig(raw);
if (!result.ok) {
process.stderr.write(`❌ Config validation failed: ${result.error.message}\n`);
process.exit(1);
}
const config = result.value;
const workflowEntries = Object.entries(config.workflows);
if (workflowEntries.length === 0) {
process.stdout.write("📭 No workflows defined in nerve.yaml.\n");
return;
}
const rows = workflowEntries.map(([name, wf]) => ({
name,
concurrency: wf.concurrency,
overflow: wf.overflow,
...(wf.overflow === "queue" ? { maxQueue: wf.maxQueue } : {}),
}));
process.stdout.write(`📋 Workflow definitions (${String(rows.length)}):\n\n`);
process.stdout.write(formatRowsAsAlignedTable(rows));
},
});
// ---------------------------------------------------------------------------
// nerve workflow inspect <runId>
// nerve workflow status (daemon — registered workflows + queue depth)
// ---------------------------------------------------------------------------
const workflowInspectCommand = defineCommand({
const workflowStatusCommand = defineCommand({
meta: {
name: "inspect",
description: "Show details and thread events for a workflow run",
name: "status",
description: "Show live workflow status from the running daemon (concurrency, active, queued)",
},
args: {
runId: {
type: "positional",
description: "The run ID to inspect",
},
limit: {
type: "string",
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N log entries (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const allLogs = store.query({ source: "workflow", refId: args.runId });
const { header, eventLines, paginationHint } = buildInspectOutput(
run,
allLogs,
offset,
limit,
);
for (const line of [...header, ...eventLines]) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
async run() {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
const transport = resolveDaemonTransport();
let workflows: Awaited<ReturnType<typeof transport.listWorkflows>>;
try {
workflows = await transport.listWorkflows();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
const rows = workflows.map((w) => ({
name: w.name,
active: w.activeThreads,
runIds: w.activeRunIds.length > 0 ? w.activeRunIds.join(", ") : "—",
queued: w.queuedThreads,
concurrency: w.config.concurrency,
overflow: w.config.overflow,
}));
if (rows.length === 0) {
process.stdout.write("📭 No workflows in nerve.yaml (or empty registry).\n");
return;
}
process.stdout.write(`📋 Workflows (${String(rows.length)}):\n\n`);
process.stdout.write(formatRowsAsAlignedTable(rows));
},
});
@@ -297,6 +428,21 @@ const workflowInspectCommand = defineCommand({
// nerve workflow trigger <name>
// ---------------------------------------------------------------------------
function readWorkspaceDefaultMaxRounds(): number {
const configPath = join(getNerveRoot(), "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch {
return DEFAULT_ENGINE_MAX_ROUNDS;
}
const result = parseNerveConfig(raw);
if (!result.ok) {
return DEFAULT_ENGINE_MAX_ROUNDS;
}
return result.value.maxRounds;
}
const workflowTriggerCommand = defineCommand({
meta: {
name: "trigger",
@@ -307,30 +453,38 @@ const workflowTriggerCommand = defineCommand({
type: "positional",
description: "The workflow name to trigger",
},
payload: {
maxRounds: {
type: "string",
description: "JSON payload to pass as trigger payload (default: {})",
default: "{}",
description: "Max moderator rounds (default: nerve.yaml maxRounds)",
default: "",
},
prompt: {
type: "string",
description: "Initial prompt for the workflow run",
default: "",
},
dryRun: {
type: "boolean",
description: "Run the workflow in dry-run mode",
default: false,
},
},
async run({ args }) {
let triggerPayload: unknown = {};
try {
triggerPayload = JSON.parse(args.payload) as unknown;
} catch {
process.stderr.write(`❌ --payload must be valid JSON. Got: ${args.payload}\n`);
const prompt = args.prompt;
const defaultMax = readWorkspaceDefaultMaxRounds();
const maxRounds =
args.maxRounds.length > 0 ? parseIntArg(args.maxRounds, defaultMax) : defaultMax;
const dryRun = args.dryRun;
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerWorkflowViaDaemon(socketPath, args.name, triggerPayload);
response = await transport.triggerWorkflow(args.name, { prompt, maxRounds, dryRun });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
@@ -343,7 +497,7 @@ const workflowTriggerCommand = defineCommand({
}
process.stdout.write(`✅ Triggered workflow "${args.name}" via daemon.\n`);
process.stdout.write("\n💡 Inspect active runs with: nerve workflow list\n");
process.stdout.write("\n💡 Inspect active runs with: nerve thread list\n");
},
});
@@ -354,11 +508,11 @@ const workflowTriggerCommand = defineCommand({
export const workflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Manage and inspect workflow runs",
description: "Manage workflow definitions and trigger workflows",
},
subCommands: {
list: workflowListCommand,
inspect: workflowInspectCommand,
status: workflowStatusCommand,
trigger: workflowTriggerCommand,
},
});

Some files were not shown because too many files have changed in this diff Show More