Compare commits

...

349 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
xiaoju 85dd11c84d refactor(daemon): upgrade Drizzle v1.0-beta + migrate better-sqlite3 → node:sqlite
- Upgrade drizzle-orm from 0.43.1 to 1.0.0-beta.23
- Replace better-sqlite3 with node:sqlite (DatabaseSync) in:
  - sense-runtime.ts (Drizzle driver)
  - log-store.ts (raw SQL)
  - all test files
- Replace sqlite.pragma() with sqlite.exec('PRAGMA ...')
- Replace sqlite.transaction() with manual BEGIN/COMMIT/ROLLBACK
- Update CLI init command to verify node:sqlite instead of better-sqlite3
- Remove better-sqlite3 and @types/better-sqlite3 from dependencies
- Zero native addons remaining in the monorepo 🎉

Closes #67

小橘 <xiaoju@shazhou.work>
2026-04-23 09:18:44 +00:00
xiaomo d80a414530 Merge pull request 'chore: walkthrough cleanup — engines, types, mock fixes' (#68) from fix/walkthrough-cleanup into main 2026-04-23 09:10:09 +00:00
xiaoju 7f780f0642 chore: walkthrough cleanup — engines, types, mock fixes
- Add engines >= 22.5.0 to root and cli package.json (node:sqlite requirement)
- Remove unused @types/better-sqlite3 from cli devDeps (leftover from sql.js migration)
- Add files/publishConfig to core package.json (parity with other packages)
- Fix daemon test type errors: add getAllWorkflowRuns to mock LogStore,
  fix array destructuring on mock.calls, fix sense-runtime callback signatures

All 356 tests pass across all packages.

小橘 🍊(NEKO Team)
2026-04-23 09:08:24 +00:00
xiaomo 33e0d9a705 Merge pull request 'refactor(cli): replace sql.js with node:sqlite' (#66) from refactor/node-sqlite into main 2026-04-23 08:51:01 +00:00
xiaoju 418d8ee0c8 refactor(cli): replace sql.js with node:sqlite
Drop the sql.js WASM dependency in favour of Node 22's built-in
node:sqlite (DatabaseSync). This eliminates the ~2 MB WASM binary,
removes the async init ceremony, and lets us open databases in
readonly mode directly on disk instead of loading them into memory.

Breaking: requires Node >= 22.5.0 (sqlite support).

- Remove sql.js from cli dependencies
- Rewrite sense-sqlite.ts to use DatabaseSync
- Update sense command (schema/query) — sync API, no more queryAsObjects
- Update tests to use node:sqlite directly
- Remove sql.js from tsup externals

小橘 🍊(NEKO Team)
2026-04-23 08:43:39 +00:00
xiaomo 719c4c1449 Merge pull request 'refactor(cli): replace better-sqlite3 with sql.js (pure WASM) — implements RFC #63' (#64) from refactor/sql-js-migration into main 2026-04-23 07:32:38 +00:00
xiaoju c8bf4bf547 refactor(cli): replace better-sqlite3 with sql.js (pure WASM)
- Remove native C++ addon dependency, no more pnpm approve-builds
- sql.js loads SQLite as WASM, zero compilation required
- WASM init is singleton (once per process)
- Add queryAsObjects() adapter for sql.js columnar → row format
- Tests migrated to sql.js (16 passing)

Implements RFC #63
2026-04-23 07:25:08 +00:00
xiaoju 9b93c4a4d9 chore(cli): bump version to 0.1.8 2026-04-23 07:10:28 +00:00
xiaomo ca14c5f51d Merge pull request 'feat(cli): add nerve sense schema and query commands (closes #60)' (#62) from feat/sense-query into main 2026-04-23 07:06:02 +00:00
xiaomo 1979e0e16c Merge pull request 'refactor: replace dynamic imports with static imports in CLI' (#61) from refactor/static-imports into main 2026-04-23 07:04:31 +00:00
xingyue 9102c6698a chore: remove gitea-access rule from project (belongs in agent local skills) 2026-04-23 15:03:14 +08:00
xiaoju b15fc993f2 feat(cli): add nerve sense schema and query commands
Open each sense SQLite file read-only under data/senses. schema lists CREATE TABLE SQL from sqlite_master; query runs optional SQL or a default SELECT ordered by rowid. Human output uses aligned columns; --json for machine-readable output. Add better-sqlite3 to the CLI package and externalize it in tsup.

Tests cover sense-sqlite helpers and integration against a temp database.

Made-with: Cursor
2026-04-23 07:01:16 +00:00
xingyue 6cc8833b2a chore: add cursor rules and annotate legitimate dynamic imports
- Add .cursor/rules/no-dynamic-import.mdc: ban dynamic import() in
  production code with documented exceptions
- Add .cursor/rules/gitea-access.mdc: tea CLI usage guide
- Add explanatory comments on the 2 legitimate dynamic imports in
  sense-runtime.ts and workflow-worker.ts
2026-04-23 15:00:07 +08:00
xiaomo fc76b862ad Merge pull request 'refactor(cli): replace dynamic imports with static imports — closes #57' (#59) from refactor/static-imports into main 2026-04-23 06:55:46 +00:00
xingyue 787e791aba refactor(cli): replace dynamic imports with static imports
Convert 6 unnecessary `await import()` calls for Node built-in modules
(node:child_process, node:util) and project modules (../workspace.js)
to static top-level imports in init.ts and start.ts.

Closes #57
2026-04-23 14:52:18 +08:00
xiaomo 96188c8cda Merge pull request 'fix(daemon): foreground worker signals and crash diagnostics (closes #55, closes #56)' (#58) from fix/dev-worker-crash into main 2026-04-23 06:48:33 +00:00
xiaoju f1458f8353 fix(daemon): foreground worker signals and crash diagnostics
Ignore SIGINT/SIGTERM only when fork IPC is active (process.send) so terminal signals do not race the kernel shutdown in nerve dev, without breaking standalone worker CLIs (fixes #55).

Pipe worker stderr through the parent with a rolling capture buffer; log exit signal name and stderr tail on worker exit (fixes #56). Apply the same exit logging to workflow workers.

Made-with: Cursor
2026-04-23 06:41:32 +00:00
xiaomo 781f571474 Merge pull request 'refactor: add daemon subcommand group and dev foreground mode' (#54) from refactor/daemon-subcommand into main 2026-04-23 04:24:31 +00:00
xiaoju 640f170de8 refactor: add daemon subcommand group and dev foreground mode
- Create 'nerve daemon' subcommand group: start, stop, status, restart, logs
- Create 'nerve dev' for foreground mode (replaces old start without -d)
- 'nerve daemon start' is always background (removed -d/--daemon flag)
- Keep top-level aliases: nerve start/stop/status/logs → nerve daemon *
- Extract runStopCommand() for restart reuse
- Add daemon-cli tests

Closes #53

小橘 🍊(NEKO Team)
2026-04-23 01:16:13 +00:00
xiaoju 119b1f3722 chore: enforce pnpm publish for all packages unconditionally
小橘 <xiaoju@shazhou.work>
2026-04-23 00:49:39 +00:00
xiaoju 96ea4b46ff chore: add prepublish guard against npm publish with workspace:* deps
小橘 <xiaoju@shazhou.work>
2026-04-23 00:47:56 +00:00
xiaoju 57881533a8 docs: fix publish skill — use pnpm publish for workspace:* conversion
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:51 +00:00
xiaoju a62a993a82 fix(cli): remove duplicate shebang in daemon-bootstrap causing crash on nerve start -d
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:18 +00:00
xiaoju 3f22eb4664 release: @uncaged/nerve-core@0.1.3, @uncaged/nerve-daemon@0.1.4, @uncaged/nerve-cli@0.1.5
小橘 <xiaoju@shazhou.work>
2026-04-23 00:35:40 +00:00
xiaoju b5913263e4 docs: add publish and setup skills
小橘 <xiaoju@shazhou.work>
2026-04-23 00:31:27 +00:00
xiaomo d3ecd2a492 Merge pull request 'fix: address review issues #46-#49' (#52) from fix/review-issues-46-49 into main 2026-04-23 00:24:19 +00:00
xiaoju 8763440436 fix: address review issues #46-#49
#46 — EPIPE handler: only silence EPIPE, log other child errors
#47 — lastSignalTs: query sense/signal instead of reflex/run_complete
#48 — SenseInfo: deduplicate to @uncaged/nerve-core, add expectTypeOf test
#49 — IPC client: extract sendAndReceive<T> to eliminate duplication

小橘 <xiaoju@shazhou.work>
2026-04-23 00:22:55 +00:00
xiaomo f270804002 Merge pull request 'feat(daemon): CAS blob store — sha256 content-addressable storage (closes #39)' (#51) from feat/blob-store into main 2026-04-23 00:21:46 +00:00
xiaoju 404ee3e34f feat(daemon): add CAS blob store with sha256 content-addressable storage — closes #39
- createBlobStore(root) with write/read/exists API
- sha256 hex, first 2 chars as shard directory
- Atomic writes via temp file + rename
- CAS mismatch detection on read and write
- Inject blobStore into sense compute via options.blobs
- Export createBlobStore, normalizeBlobHash, BlobStore type
2026-04-23 00:19:35 +00:00
xiaomo cbc6db6b7d Merge pull request 'feat(daemon): log store archival — Meta table + JSONL cold archive (closes #38)' (#45) from feat/log-archive into main 2026-04-23 00:17:54 +00:00
xiaomo b1f6c775ce Merge pull request 'fix(init): auto-verify and retry better-sqlite3 native build — closes #44' (#50) from fix/init-sqlite-retry into main 2026-04-23 00:14:30 +00:00
xingyue 4ada5ef335 fix(init): auto-verify and retry better-sqlite3 native build
After pnpm install, verify better-sqlite3 actually loads by spawning
a test process. If it fails, rebuild up to 2 times. On final failure,
print actionable fix commands instead of a vague warning.

Closes #44
2026-04-23 08:12:10 +08:00
xiaoju 978b1680a3 feat(daemon): add log store archival with meta watermark + JSONL cold archive — closes #38
- Add meta table with archived_up_to watermark in logs.db
- Archive logs older than 30 days to data/archive/logs/YYYY-MM-DD.jsonl
- Idempotent: same-day re-export overwrites file
- Single transaction: DELETE + UPDATE meta
- Optional VACUUM after archive loop
- CLI: nerve store archive [--vacuum]
- 15+ new tests for archive logic
2026-04-23 00:10:20 +00:00
xiaoju ac34b798c2 feat(cli): add nerve sense list command with IPC + static fallback — closes #37
- daemon-ipc: add list-senses request type returning SenseInfo[]
- kernel: implement listSenses querying logStore for last signal time
- CLI: nerve sense list with table output, fallback to nerve.yaml when daemon is down
- 25 new tests across daemon-ipc and CLI
2026-04-23 00:00:23 +00:00
xiaoju 00c9b7e406 test: add trigger-sense unit + integration tests — closes #36
- daemon-ipc: parse trigger-sense request, success/failure responses
- kernel: triggerSense routing to correct worker, unknown sense error
- CLI: triggerSenseViaDaemon IPC round-trip
2026-04-22 23:53:23 +00:00
xiaoju 8b216e3f01 Revert "feat(cli): add nerve init sense <name> scaffold command — closes #36"
This reverts commit 7ded3a758a.
2026-04-22 23:44:18 +00:00
xiaoju 7ded3a758a feat(cli): add nerve init sense <name> scaffold command — closes #36
Implements nerve init sense <name> command that scaffolds a new sense directory under ~/.uncaged-nerve/senses/<name>/ with schema.ts, index.js, and migrations/0001_init.sql. Also auto-patches nerve.yaml to add the sense config and reflex entry. Includes full test coverage for all exported helpers.

Made-with: Cursor
2026-04-22 23:43:30 +00:00
xiaoju 3257237ba7 fix: handle EPIPE on child process IPC during shutdown
Add error event listener to forked workers in kernel and
workflow-manager to prevent unhandled EPIPE crashes.

Closes #43
小橘 <xiaoju@shazhou.work>
2026-04-22 23:36:48 +00:00
xiaoju 2be11ac81a chore: release core@0.1.2 daemon@0.1.2 cli@0.1.3
小橘 <xiaoju@shazhou.work>
2026-04-22 23:12:29 +00:00
xiaomo 5ed4dfdde3 Merge pull request 'refactor(cli): decouple daemon native deps from CLI global install — closes #41' (#42) from refactor/decouple-daemon-from-cli into main 2026-04-22 23:09:56 +00:00
xingyue 282a802f06 fix: address review feedback on PR #42
1. [BLOCKER] tsup.config.ts: resolve merge conflict — keep both banner
   (shebang) and external (daemon decoupling)

2. [SHOULD-FIX] assertWorkspaceDaemonInstalled: throw Error instead of
   process.exit(1) — callers decide error handling

3. [SHOULD-FIX] getDaemonEntryPath: read daemon's package.json 'main'
   field instead of hardcoding dist/index.js

4. [SHOULD-FIX] daemon startup check: replace sleep(1500) with IPC
   socket polling (200ms intervals, 5s timeout)

5. [SHOULD-FIX] daemon-types drift: add vitest type-level assertions
   that verify CLI mirror types stay assignable with daemon exports
2026-04-23 07:07:38 +08:00
xingyue c8e6409837 refactor(cli): decouple daemon native deps from CLI global install
- Move @uncaged/nerve-daemon from runtime to devDependencies
- Dynamic import daemon from workspace node_modules at runtime
- Add daemon-bootstrap.ts as separate entry for background daemon spawn
- Extract run-foreground-kernel.ts and workspace-daemon.ts modules
- Add daemon-types.ts for structural types (no runtime daemon import)
- Rebuild better-sqlite3 in workspace during nerve init
- Validate daemon process liveness after spawn in background mode
- Mark @uncaged/nerve-daemon as external in tsup config

Closes #41
2026-04-23 06:58:00 +08:00
xiaoju 877da470d7 fix: pre-approve build scripts in nerve init scaffold
Add pnpm.onlyBuiltDependencies for better-sqlite3 and esbuild
to suppress pnpm v10 approve-builds warnings.

小橘 <xiaoju@shazhou.work>
2026-04-22 15:49:13 +00:00
xiaoju 01f54d14c5 chore: bump to 0.1.1 for npm publish fix
小橘 <xiaoju@shazhou.work>
2026-04-22 15:37:30 +00:00
xiaoju 6a689c4094 feat: make nerve-cli and nerve-daemon publishable npm packages
- Remove private:true from cli and daemon package.json
- Add files and publishConfig fields
- Add shebang banner via tsup for CLI entry
- Add trigger-sense IPC support in daemon and client

Closes #40

小橘 <xiaoju@shazhou.work>
2026-04-22 15:28:05 +00:00
xiaomo e66a376a77 Merge pull request 'feat: add nerve logs command with AI-friendly pagination — closes #29' (#34) from feat/nerve-logs into main 2026-04-22 15:04:52 +00:00
xiaoju 10f942b577 fix: address PR #34 review — SIGINT leak, negative offset, follow race conditions
- SIGINT: use process.once instead of process.on
- Negative offset: validate and exit(1) with error to stderr
- Follow mode: sequential while loop replaces setInterval (no async race)
- Log rotation: reset size when newSize < size
- TODO: readAllLines large file optimization note
- 2 new tests for negative offset validation

小橘 <xiaoju@shazhou.work>
2026-04-22 15:00:24 +00:00
xiaoju 76b547d37a feat: add nerve logs command with AI-friendly pagination — closes #29
- nerve logs: tail last 50 lines by default
- -n <lines>: specify line count
- --offset <n>: pagination from line n (1-based)
- -f/--follow: real-time tail with 300ms polling
- Footer with stats + next-page command hint for AI agents
- No ANSI colors, emoji only, data→stdout, errors→stderr
- 19 new tests covering pagination, footer, edge cases

小橘 <xiaoju@shazhou.work>
2026-04-22 14:52:17 +00:00
xiaoju 1b2ff37097 chore: publish @uncaged/nerve-core@0.0.1 to npm — closes #28
Removed 'private: true' to allow npm publish. Package is now available
at https://www.npmjs.com/package/@uncaged/nerve-core

小橘 <xiaoju@shazhou.work>
2026-04-22 14:37:07 +00:00
xiaoju 4add0d88c6 Revert "Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main"
This reverts commit a8404dc096, reversing
changes made to 569c034b49.
2026-04-22 14:36:24 +00:00
xiaoju a8404dc096 Merge pull request 'fix: remove unpublished @uncaged/nerve-core from init template — closes #28' (#33) from fix/remove-unpublished-dep into main 2026-04-22 14:35:24 +00:00
xiaoju 891db36152 fix: remove unpublished @uncaged/nerve-core from init template — closes #28
The workspace package.json template listed @uncaged/nerve-core as a
dependency, but this package is not published to npm. Since the generated
workflow code only imports from @uncaged/nerve-daemon (which is also not
yet published but will be), remove the unnecessary dependency to unblock
`nerve init`.

小橘 <xiaoju@shazhou.work>
2026-04-22 14:35:03 +00:00
xiaoju 569c034b49 Merge pull request 'fix: daemon mode spawn path — closes #27' (#30) from fix/daemon-spawn-path into main 2026-04-22 14:21:33 +00:00
xingyue 85fa282d2e fix(cli): create initial git commit after workspace init
git init without add+commit leaves the workspace in a dirty state
with no baseline to diff against.
2026-04-22 22:16:41 +08:00
xiaomo b75a112c95 Merge pull request 'fix: IPC trigger try/catch + test import cleanup' (#32) from fix/phase4-followup into main 2026-04-22 14:16:10 +00:00
xingyue 606eff6d70 fix(cli): remove self-fallback in cliEntryScript candidates
Per review: third candidate (here) is wrong — if bundled and source
candidates both miss, falling back to self reproduces the original bug.
Keep only the two valid candidates and throw on miss.
2026-04-22 22:15:53 +08:00
xingyue 97305bd9af fix(cli): resolve CLI entry path for bundled dist output
cliEntryScript() assumed source directory structure (src/commands/start.ts → ../cli.ts),
but after tsup bundles everything into dist/cli.js, import.meta.url points to dist/cli.js
and the '../cli.js' path resolves to a non-existent file.

Use candidate-based lookup: try same-dir, parent-dir, then self (bundled case).
2026-04-22 22:15:53 +08:00
xingyue 3f2c9df75d refactor: simplify cliEntryScript() — remove multi-level fallback
Per review feedback from xiaoju: the three-level fallback was over-defensive.
Since start.ts and cli.ts have a fixed relative position (commands/start.ts → ../cli.ts),
we can derive the path directly from import.meta.url with an existsSync guard.

This makes path errors explicit (throw) instead of silently falling back to
a potentially wrong path.
2026-04-22 22:15:53 +08:00
xingyue 1511cfd595 fix: daemon spawn uses CLI entry path instead of command module
The runDaemon function was using import.meta.url (pointing to start.js)
as the script for the spawned child process. This meant the child ran
`node start.js start` which has no CLI entry logic and exits immediately.

Added cliEntryScript() that resolves to the correct CLI entry (cli.js)
regardless of whether the code is bundled or split into separate files.

Closes #27
2026-04-22 22:15:53 +08:00
294 changed files with 29056 additions and 4985 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 | ...
```
+34
View File
@@ -0,0 +1,34 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Why
- Static imports enable tree-shaking and bundler optimizations
- They make dependencies explicit and discoverable at a glance
- Dynamic imports of Node built-ins or project modules add unnecessary async overhead
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user module path resolved at runtime
const mod = await import(senseIndexPath);
```
## Test Files
Test files (`__tests__/**`) are exempt — dynamic import after `vi.mock()` is standard vitest practice.
+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
+80
View File
@@ -0,0 +1,80 @@
# Skill: Publish @uncaged/nerve packages to npm
## When to use
When releasing a new version of any `@uncaged/nerve-*` package to npm.
## Prerequisites
- npm login with an account that has **owner** access to the `@uncaged` org
- All tests pass: `pnpm -r run test`
- Clean working tree (no uncommitted changes)
## Packages
| Package | Path | npm |
|---------|------|-----|
| `@uncaged/nerve-core` | `packages/core` | [link](https://www.npmjs.com/package/@uncaged/nerve-core) |
| `@uncaged/nerve-daemon` | `packages/daemon` | [link](https://www.npmjs.com/package/@uncaged/nerve-daemon) |
| `@uncaged/nerve-cli` | `packages/cli` | [link](https://www.npmjs.com/package/@uncaged/nerve-cli) |
## Dependency order
`core``daemon``cli`
Always publish in this order. If `core` has changes, bump and publish it first, then update dependents.
## Steps
### 1. Ensure clean state
```bash
git checkout main && git pull origin main
pnpm install
pnpm -r run build
pnpm -r run test
```
### 2. Bump versions
Manually update `version` in each changed package's `package.json`.
Follow semver:
- **patch** (0.1.x): bug fixes, refactors
- **minor** (0.x.0): new features, non-breaking API additions
- **major** (x.0.0): breaking changes
If bumping `core`, also update the `@uncaged/nerve-core` dependency version in `daemon` and `cli` package.json. Same for `daemon``cli`.
### 3. Build
```bash
pnpm -r run build
```
### 4. Publish (in order)
```bash
# Only publish packages that have version bumps
# MUST use pnpm publish (not npm) — pnpm converts workspace:* to real versions
cd packages/core && pnpm publish --access public --no-git-checks
cd packages/daemon && pnpm publish --access public --no-git-checks
cd packages/cli && pnpm publish --access public --no-git-checks
```
### 5. Commit & tag
```bash
git add -A
git commit -m "release: @uncaged/nerve-core@X.Y.Z, @uncaged/nerve-daemon@X.Y.Z, @uncaged/nerve-cli@X.Y.Z"
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main --tags
```
## Pitfalls
- **Don't publish without building first** — `tsup` output in `dist/` is what npm ships
- **Dependency order matters** — if you publish `daemon` before `core`, npm may resolve the old `core` version
- **`--access public`** is required for scoped packages on first publish; safe to always include
- **Check `npm whoami`** to confirm you're logged in as the right account
- **No changeset tool** — this project uses manual version bumps (no changesets/lerna)
+101
View File
@@ -0,0 +1,101 @@
# Skill: Setup nerve from scratch
## When to use
Setting up the nerve project for local development from a fresh clone.
## Prerequisites
- **Node.js** ≥ 18
- **pnpm** ≥ 9 (`npm install -g pnpm`)
- **Git** access to `git.shazhou.work`
## Steps
### 1. Clone
```bash
git clone https://git.shazhou.work/uncaged/nerve.git
cd nerve
```
### 2. Install dependencies
```bash
pnpm install
```
This installs all workspace packages and links internal dependencies (`core``daemon``cli`).
### 3. Build all packages
```bash
pnpm -r run build
```
Build order is handled automatically by pnpm workspace — `core` builds first, then `daemon`, then `cli`.
### 4. Run tests
```bash
pnpm -r run test
```
Or test individual packages:
```bash
pnpm --filter @uncaged/nerve-core test
pnpm --filter @uncaged/nerve-daemon test
pnpm --filter @uncaged/nerve-cli test
```
### 5. Try the CLI
```bash
# Link the CLI globally
cd packages/cli && npm link
# Initialize a workspace
mkdir ~/my-nerve-workspace && cd ~/my-nerve-workspace
nerve init
# Edit senses in nerve.yaml, then:
nerve start # start the daemon
nerve sense list # list registered senses
nerve stop # stop the daemon
```
### 6. Lint & format
```bash
pnpm run check # biome lint check
pnpm run format # biome auto-format
```
## Project structure
```
nerve/
├── packages/
│ ├── core/ # @uncaged/nerve-core — shared types, log store, blob store
│ ├── daemon/ # @uncaged/nerve-daemon — kernel, sense runtime, workflow manager
│ └── cli/ # @uncaged/nerve-cli — CLI commands (init, start, stop, sense, etc.)
├── docs/ # RFCs, conventions, skills
├── pnpm-workspace.yaml
└── biome.json # linter/formatter config
```
## Key conventions
- **Monorepo** with pnpm workspaces
- **ESM only** — all packages output ESM (`"type": "module"`)
- **tsup** for builds, **vitest** for tests, **biome** for lint/format
- **SQLite** (better-sqlite3) for log store and blob store
- See `docs/coding-conventions.md` for code style rules
## Pitfalls
- **Must build before test** — daemon and cli import compiled output from core
- **better-sqlite3** requires native compilation — if `pnpm install` fails, ensure you have build tools (`build-essential` on Linux, Xcode CLI tools on macOS)
- **Node 18+** required — uses native `fetch`, `crypto.randomUUID`, etc.
- **pnpm only** — don't use npm/yarn, workspace links won't resolve correctly
+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: []
+9 -2
View File
@@ -1,14 +1,21 @@
{
"name": "nerve",
"private": true,
"engines": {
"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
+17 -6
View File
@@ -1,25 +1,36 @@
{
"name": "@uncaged/nerve-cli",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=22.5.0"
},
"version": "0.5.0",
"type": "module",
"bin": {
"nerve": "dist/cli.js"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist", "skills"],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"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:*",
"@uncaged/nerve-daemon": "workspace:*",
"citty": "^0.1.6"
"@uncaged/nerve-store": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@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",
);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { daemonCommand } from "../commands/daemon.js";
import { devCommand } from "../commands/dev.js";
import { daemonStartCommand } from "../commands/start.js";
describe("nerve daemon command group", () => {
it("exposes start, stop, status, restart, and logs subcommands", () => {
const subs = daemonCommand.subCommands;
expect(subs).toBeDefined();
if (!subs) {
throw new Error("expected daemonCommand.subCommands");
}
expect(Object.keys(subs).sort()).toEqual(["logs", "restart", "start", "status", "stop"]);
});
it("shares the same start command object as top-level nerve start alias", () => {
const subs = daemonCommand.subCommands;
expect(subs?.start).toBe(daemonStartCommand);
});
});
describe("nerve dev", () => {
it("is a foreground dev command", () => {
expect(devCommand.meta?.name).toBe("dev");
expect(devCommand.meta?.description).toMatch(/foreground/i);
});
});
@@ -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();
}
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* Tests for nerve logs command — pure helper functions only.
*
* We test sliceLogs and buildLogFooter without touching the filesystem or
* spawning a real process.
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_LOG_LINES, buildLogFooter, readAllLines, sliceLogs } from "../commands/logs.js";
import { logsCommand } from "../commands/logs.js";
// ---------------------------------------------------------------------------
// sliceLogs
// ---------------------------------------------------------------------------
describe("sliceLogs", () => {
const make = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
it("returns empty result for empty array", () => {
const r = sliceLogs([], 0, 50);
expect(r.lines).toHaveLength(0);
expect(r.total).toBe(0);
expect(r.nextOffset).toBeNull();
});
it("tail mode (offset=0): returns last N lines", () => {
const lines = make(100);
const r = sliceLogs(lines, 0, 10);
expect(r.lines).toHaveLength(10);
expect(r.lines[0]).toBe("line 91");
expect(r.lines[9]).toBe("line 100");
expect(r.startLine).toBe(91);
expect(r.endLine).toBe(100);
});
it("tail mode: when file shorter than limit, returns all", () => {
const lines = make(20);
const r = sliceLogs(lines, 0, 50);
expect(r.lines).toHaveLength(20);
expect(r.startLine).toBe(1);
expect(r.endLine).toBe(20);
expect(r.nextOffset).toBeNull();
});
it("tail mode: provides nextOffset when earlier lines exist", () => {
const lines = make(200);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).not.toBeNull();
expect(r.nextOffset).toBe(151 - 50); // startLine=151, prev page starts at 101
});
it("tail mode: nextOffset is null when showing from line 1", () => {
const lines = make(40);
const r = sliceLogs(lines, 0, 50);
expect(r.nextOffset).toBeNull();
});
it("offset mode: starts at given 1-based line number", () => {
const lines = make(100);
const r = sliceLogs(lines, 10, 5);
expect(r.lines[0]).toBe("line 10");
expect(r.startLine).toBe(10);
expect(r.endLine).toBe(14);
});
it("offset mode: clamps start to 0 for offset=1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 10);
expect(r.startLine).toBe(1);
});
it("offset mode: nextOffset is null when slice starts at line 1", () => {
const lines = make(50);
const r = sliceLogs(lines, 1, 20);
expect(r.nextOffset).toBeNull();
});
it("offset mode: nextOffset points to previous page", () => {
const lines = make(100);
const r = sliceLogs(lines, 51, 50); // lines 51-100
expect(r.nextOffset).toBe(1); // previous page starts at line 1
});
});
// ---------------------------------------------------------------------------
// buildLogFooter
// ---------------------------------------------------------------------------
describe("buildLogFooter", () => {
it("returns empty-file message when total=0", () => {
const slice = { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
expect(buildLogFooter(slice, 50, "/path/to/nerve.log")).toContain("empty");
});
it("includes range and path in footer", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/var/log/nerve.log");
expect(footer).toContain("lines 151-200 of 200");
expect(footer).toContain("/var/log/nerve.log");
});
it("includes pagination hint when nextOffset is set", () => {
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).toContain("nerve logs --offset 101 -n 50");
});
it("no pagination hint when nextOffset is null", () => {
const slice = { lines: ["x"], total: 20, startLine: 1, endLine: 20, nextOffset: null };
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
expect(footer).not.toContain("nerve logs --offset");
});
});
// ---------------------------------------------------------------------------
// DEFAULT_LOG_LINES constant
// ---------------------------------------------------------------------------
describe("DEFAULT_LOG_LINES", () => {
it("is 50", () => {
expect(DEFAULT_LOG_LINES).toBe(50);
});
});
// ---------------------------------------------------------------------------
// readAllLines
// ---------------------------------------------------------------------------
describe("readAllLines", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array for nonexistent file", async () => {
const result = await readAllLines(join(tmpDir, "missing.log"));
expect(result).toHaveLength(0);
});
it("reads all lines from a file", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "line1\nline2\nline3\n");
const result = await readAllLines(logFile);
expect(result).toEqual(["line1", "line2", "line3"]);
});
it("handles file with no trailing newline", async () => {
const logFile = join(tmpDir, "test.log");
writeFileSync(logFile, "a\nb\nc");
const result = await readAllLines(logFile);
expect(result).toEqual(["a", "b", "c"]);
});
it("returns empty array for empty file", async () => {
const logFile = join(tmpDir, "empty.log");
writeFileSync(logFile, "");
const result = await readAllLines(logFile);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Integration: readAllLines + sliceLogs end-to-end
// ---------------------------------------------------------------------------
describe("readAllLines + sliceLogs integration", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-int-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("tail-paginates a large log file correctly", async () => {
const logFile = join(tmpDir, "big.log");
const content = Array.from({ length: 120 }, (_, i) => `entry ${i + 1}`).join("\n");
writeFileSync(logFile, content);
const all = await readAllLines(logFile);
const page1 = sliceLogs(all, 0, 50); // last 50: lines 71-120
expect(page1.startLine).toBe(71);
expect(page1.endLine).toBe(120);
expect(page1.nextOffset).toBe(21); // max(1, 71-50)
const page2 = sliceLogs(all, page1.nextOffset!, 50); // lines 21-70
expect(page2.startLine).toBe(21);
expect(page2.endLine).toBe(70);
expect(page2.nextOffset).toBe(1); // max(1, 21-50) = 1
const page3 = sliceLogs(all, page2.nextOffset!, 50); // lines 1-50
expect(page3.startLine).toBe(1);
expect(page3.endLine).toBe(50);
expect(page3.nextOffset).toBeNull();
});
});
// ---------------------------------------------------------------------------
// logsCommand: negative offset validation
// ---------------------------------------------------------------------------
describe("logsCommand negative offset", () => {
let stderrOutput: string;
let exitCode: number | undefined;
beforeEach(() => {
stderrOutput = "";
exitCode = undefined;
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderrOutput += typeof chunk === "string" ? chunk : chunk.toString();
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
exitCode = typeof code === "number" ? code : 1;
throw new Error(`process.exit(${exitCode})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
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,
}),
).rejects.toThrow("process.exit(1)");
expect(exitCode).toBe(1);
expect(stderrOutput).toContain("--offset must be a non-negative integer");
expect(stderrOutput).toContain("-5");
});
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,
}),
).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");
});
});
@@ -0,0 +1,330 @@
/**
* Tests for `nerve sense list` — formatting helpers and IPC round-trip.
*
* Covers:
* - formatDuration helper
* - formatSenseList output
* - sensesFromConfig (static fallback from nerve.yaml)
* - listSensesViaDaemon IPC round-trip via real Unix socket
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js";
import { listSensesViaDaemon } from "../daemon-client.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SAMPLE_SENSES: SenseInfo[] = [
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
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"],
},
];
// ---------------------------------------------------------------------------
// formatDuration
// ---------------------------------------------------------------------------
describe("formatDuration", () => {
it("returns '—' for null", () => {
expect(formatDuration(null)).toBe("—");
});
it("formats sub-minute durations as seconds", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(1000)).toBe("1s");
expect(formatDuration(59000)).toBe("59s");
});
it("formats minute-range durations as Xm Ys", () => {
expect(formatDuration(60000)).toBe("1m 0s");
expect(formatDuration(90000)).toBe("1m 30s");
expect(formatDuration(3599000)).toBe("59m 59s");
});
it("formats hour-range durations as Xh Ym", () => {
expect(formatDuration(3600000)).toBe("1h 0m");
expect(formatDuration(3660000)).toBe("1h 1m");
expect(formatDuration(7200000)).toBe("2h 0m");
});
});
// ---------------------------------------------------------------------------
// formatSenseList
// ---------------------------------------------------------------------------
describe("formatSenseList", () => {
it("returns empty message when no senses", () => {
const output = formatSenseList([]);
expect(output).toContain("No senses registered");
});
it("shows sense count in header", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("3");
});
it("shows each sense name", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("cpu-usage");
expect(output).toContain("disk-usage");
expect(output).toContain("active-tasks");
});
it("shows group for each sense", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("system");
expect(output).toContain("tasks");
});
it("shows throttle and timeout durations", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage: throttle=5s, timeout=3s
expect(output).toContain("5s");
expect(output).toContain("3s");
// disk-usage: timeout=null → '—'
expect(output).toContain("—");
});
it("shows trigger schedule from sense metadata", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("trigger schedule:");
expect(output).toContain("every 30s");
expect(output).toContain("(none)");
});
it("does not include last signal line", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).not.toContain("last signal");
});
});
// ---------------------------------------------------------------------------
// sensesFromConfig — static fallback from nerve.yaml
// ---------------------------------------------------------------------------
describe("sensesFromConfig", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("returns empty array when file does not exist", () => {
const result = sensesFromConfig(join(tmpDir, "nonexistent.yaml"));
expect(result).toEqual([]);
});
it("returns empty array when file has invalid YAML", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(path, "not: valid: yaml: :::");
const result = sensesFromConfig(path);
expect(result).toEqual([]);
});
it("parses senses from valid nerve.yaml", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
cpu-usage:
group: system
throttle: 5s
timeout: 3s
disk-usage:
group: system
throttle: 30s
`.trim(),
);
const result = sensesFromConfig(path);
expect(result).toHaveLength(2);
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", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
my-sense:
group: default
throttle: 10s
timeout: 5s
`.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([]);
});
});
// ---------------------------------------------------------------------------
// listSensesViaDaemon — IPC round-trip via real Unix socket
// ---------------------------------------------------------------------------
describe("listSensesViaDaemon", () => {
let sockDir: string;
let sockPath: string;
beforeEach(() => {
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-"));
sockPath = join(sockDir, "nerve.sock");
});
afterEach(() => {
rmSync(sockDir, { recursive: true, force: true });
});
it("resolves with { ok: true, senses: [] } when daemon returns empty list", async () => {
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
const req = JSON.parse(line) as { type: string };
if (req.type === "list-senses") {
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
}
} catch {
// ignore
}
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: true, senses: [] });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves with populated senses array", async () => {
const senses: SenseInfo[] = [
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
triggers: [],
},
];
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: true, senses })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: true, senses });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves with { ok: false, error } when daemon returns an error", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: false, error: "something went wrong" })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await listSensesViaDaemon(sockPath);
expect(result).toEqual({ ok: false, error: "something went wrong" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(listSensesViaDaemon(sockPath)).rejects.toThrow(/Cannot connect to daemon/);
});
it("sends a list-senses IPC message to the daemon", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
await listSensesViaDaemon(sockPath);
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ type: "list-senses" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
});
@@ -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" });
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Tests for the sense CLI helper — triggerSenseViaDaemon IPC round-trip.
*
* Uses a real Unix socket server to validate the full client/server
* protocol without requiring a running daemon process.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { triggerSenseViaDaemon } from "../daemon-client.js";
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
let sockDir: string;
let sockPath: string;
beforeEach(() => {
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-test-"));
sockPath = join(sockDir, "nerve.sock");
});
afterEach(() => {
rmSync(sockDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// triggerSenseViaDaemon — IPC round-trip via real Unix socket
// ---------------------------------------------------------------------------
describe("triggerSenseViaDaemon", () => {
it("resolves { ok: true } when daemon responds ok", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerSenseViaDaemon(sockPath, "cpu-usage");
expect(result).toEqual({ ok: true });
// Verify the correct IPC message was sent
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ type: "trigger-sense", sense: "cpu-usage" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("resolves { ok: false, error } when daemon rejects the sense", async () => {
const server = createServer((s) => {
s.on("data", () => {
s.write(`${JSON.stringify({ ok: false, error: 'Unknown sense: "no-such-sense"' })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
const result = await triggerSenseViaDaemon(sockPath, "no-such-sense");
expect(result).toEqual({ ok: false, error: 'Unknown sense: "no-such-sense"' });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
it("rejects when no daemon is listening on the socket", async () => {
await expect(triggerSenseViaDaemon(sockPath, "cpu-usage")).rejects.toThrow(
/Cannot connect to daemon/,
);
});
it("sends the sense name exactly as provided", async () => {
const received: unknown[] = [];
const server = createServer((s) => {
s.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
try {
received.push(JSON.parse(line));
} catch {
// ignore
}
s.write(`${JSON.stringify({ ok: true })}\n`);
});
});
await new Promise<void>((r) => server.listen(sockPath, r));
try {
await triggerSenseViaDaemon(sockPath, "my-custom-sense");
expect(received[0]).toMatchObject({ sense: "my-custom-sense" });
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
});
@@ -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,16 +12,22 @@ import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogStore } from "@uncaged/nerve-daemon";
import type { LogStore, WorkflowRun } 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";
@@ -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;
}
+51 -9
View File
@@ -1,27 +1,69 @@
#!/usr/bin/env node
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 { startCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
import { knowledgeCommand } from "./commands/knowledge.js";
import { remoteCommand } from "./commands/remote.js";
import { senseCommand } from "./commands/sense.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,
start: startCommand,
stop: stopCommand,
status: statusCommand,
create: createCommand,
daemon: daemonCommand,
dev: devCommand,
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,
},
});
+31
View File
@@ -0,0 +1,31 @@
import { defineCommand } from "citty";
import { logsCommand } from "./logs.js";
import { daemonStartCommand, runDaemonStartCommand } from "./start.js";
import { statusCommand } from "./status.js";
import { runStopCommand, stopCommand } from "./stop.js";
const daemonRestartCommand = defineCommand({
meta: {
name: "restart",
description: "Stop then start the nerve daemon",
},
async run() {
await runStopCommand();
await runDaemonStartCommand();
},
});
export const daemonCommand = defineCommand({
meta: {
name: "daemon",
description: "Manage the nerve background daemon",
},
subCommands: {
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
restart: daemonRestartCommand,
logs: logsCommand,
},
});
+36
View File
@@ -0,0 +1,36 @@
import { defineCommand } from "citty";
import {
type ForegroundSessionOptions,
runForegroundKernelSession,
} from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
export const devCommand = defineCommand({
meta: {
name: "dev",
description: "Run the nerve kernel in the foreground (development mode)",
},
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);
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);
},
});
+347 -158
View File
@@ -1,5 +1,7 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand } from "citty";
@@ -12,81 +14,231 @@ 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",
"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"
}
},
"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
`;
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
/** Generated at workspace root so agents can \`cat AGENT.md\` instead of npm skill paths. */
const AGENT_MD = `# Nerve workspace — agent guide
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(),
});
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 CPU_INDEX_JS = `import { cpus } from "node:os";
const NERVE_SKILLS_MDC = `---
description: >-
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
alwaysApply: true
---
export async function compute() {
const cpuList = cpus();
# Nerve Agent Skills
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;
}
**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).
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
## Getting Nerve-oriented skills
return {
model: cpuList[0]?.model ?? "unknown",
loadPercent: Math.round(loadPercent * 100) / 100,
ts: Date.now(),
};
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 skill’s **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_INDEX_TS = `import { loadavg } from "node:os";
type CpuState = {
samples: Array<{ ts: number; value: number }>;
};
export const initialState: CpuState = { samples: [] };
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");
}
async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
child.on("close", (code) => {
@@ -97,107 +249,19 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
});
}
async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
for (const pm of ["pnpm", "yarn", "npm"]) {
try {
await execFileAsync(pm, ["--version"]);
const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, args };
const installArgs = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
return { cmd: pm, installArgs };
} catch {
// not available, try next
}
}
return { cmd: "npm", args: ["install"] };
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",
@@ -209,13 +273,106 @@ 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"]);
},
});
async function runInitWorkspace(force: boolean): Promise<void> {
/** Verify built-in `node:sqlite` (Node.js ≥22.5) loads in a child process. */
async function verifyNodeSqlite(): Promise<boolean> {
try {
await execFileAsync(
"node",
[
"--input-type=module",
"-e",
"import { DatabaseSync } from 'node:sqlite'; new DatabaseSync(':memory:').exec('SELECT 1');",
],
{ timeout: 10_000 },
);
return true;
} catch {
return false;
}
}
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) {
@@ -225,29 +382,48 @@ 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");
try {
const { cmd, args } = await detectPackageManager();
await runCommand(cmd, args, nerveRoot);
} catch {
process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\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`,
);
}
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"))) {
try {
await runCommand("git", ["init"], nerveRoot);
await runCommand("git", ["add", "."], nerveRoot);
await runCommand("git", ["commit", "-m", "Initial nerve workspace"], nerveRoot);
} catch {
process.stdout.write("⚠️ git init failed — skipping.\n");
}
@@ -262,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: {
@@ -270,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,
},
});
+197
View File
@@ -0,0 +1,197 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline";
import { defineCommand } from "citty";
import { getLogPath } from "../workspace.js";
export const DEFAULT_LOG_LINES = 50;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Read all lines from a file. Returns empty array if file does not exist.
*
* TODO: For tail mode (offset=0), avoid reading the whole file into memory by
* seeking to the last N bytes via createReadStream({ start: max(0, size - CHUNK) }).
*/
export async function readAllLines(filePath: string): Promise<string[]> {
if (!existsSync(filePath)) return [];
const lines: string[] = [];
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const line of rl) {
lines.push(line);
}
return lines;
}
/**
* Slice a log line array respecting offset + limit semantics.
*
* When offset is 0 the function returns the *last* `limit` lines (tail mode).
* When offset > 0 it is treated as a 1-based line number and the slice starts
* there (for pagination of earlier pages from the tail).
*
* Returns the selected lines plus metadata used to build the footer.
*/
export type LogSlice = {
lines: string[];
total: number;
startLine: number; // 1-based, inclusive
endLine: number; // 1-based, inclusive
nextOffset: number | null; // null when no previous page exists
};
export function sliceLogs(allLines: string[], offset: number, limit: number): LogSlice {
const total = allLines.length;
if (total === 0) {
return { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
}
let start: number;
if (offset === 0) {
// Tail mode: last `limit` lines
start = Math.max(0, total - limit);
} else {
// offset is 1-based line number
start = Math.max(0, offset - 1);
}
const end = Math.min(start + limit, total);
const lines = allLines.slice(start, end);
const startLine = start + 1;
const endLine = end;
// nextOffset points to lines *before* current slice (earlier in file)
const nextOffset = start > 0 ? Math.max(1, startLine - limit) : null;
return { lines, total, startLine, endLine, nextOffset };
}
/**
* Build the footer string shown after the log lines.
*/
export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string): string {
if (slice.total === 0) {
return "📭 Log file is empty.\n";
}
const rangeStr = `lines ${slice.startLine}-${slice.endLine} of ${slice.total}`;
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
if (slice.nextOffset !== null) {
footer += "⏩ Earlier lines available. Fetch previous page:\n";
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
}
return footer;
}
// ---------------------------------------------------------------------------
// nerve logs
// ---------------------------------------------------------------------------
export const logsCommand = defineCommand({
meta: {
name: "logs",
description: "Show daemon log output",
},
args: {
n: {
type: "string",
description: `Number of lines to show (default: ${DEFAULT_LOG_LINES})`,
default: String(DEFAULT_LOG_LINES),
},
offset: {
type: "string",
description: "Start from line N (1-based, for pagination)",
default: "0",
},
follow: {
type: "boolean",
alias: "f",
description: "Stream new log lines in real time",
default: false,
},
},
async run({ args }) {
const logPath = getLogPath();
const nLines = Math.max(1, Number.parseInt(args.n, 10) || DEFAULT_LOG_LINES);
const rawOffset = Number.parseInt(args.offset, 10) || 0;
if (rawOffset < 0) {
process.stderr.write(`❌ --offset must be a non-negative integer, got: ${args.offset}\n`);
process.exit(1);
}
const offset = rawOffset;
if (!existsSync(logPath)) {
process.stderr.write(`❌ Log file not found: ${logPath}\n`);
process.stderr.write(" Has the daemon been started? Try: nerve start\n");
process.exit(1);
}
if (args.follow) {
await followLog(logPath, nLines);
return;
}
const allLines = await readAllLines(logPath);
const slice = sliceLogs(allLines, offset, nLines);
for (const line of slice.lines) {
process.stdout.write(`${line}\n`);
}
process.stdout.write(buildLogFooter(slice, nLines, logPath));
},
});
/**
* Stream new lines from a log file as they are appended.
* Shows the last `tailLines` lines first, then watches for new content.
*/
async function followLog(logPath: string, tailLines: number): Promise<void> {
const allLines = await readAllLines(logPath);
const initial = allLines.slice(Math.max(0, allLines.length - tailLines));
for (const line of initial) {
process.stdout.write(`${line}\n`);
}
let size = statSync(logPath).size;
process.stdout.write(`\n👁 Following ${logPath} — press Ctrl+C to stop\n`);
let stopped = false;
process.once("SIGINT", () => {
stopped = true;
});
while (!stopped) {
await sleep(300);
if (stopped) break;
try {
const newSize = statSync(logPath).size;
if (newSize < size) {
// Log rotation: file was truncated or replaced, read from the beginning
size = 0;
}
if (newSize <= size) continue;
const stream = createReadStream(logPath, { start: size, encoding: "utf8" });
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
for await (const line of rl) {
process.stdout.write(`${line}\n`);
}
size = newSize;
} catch {
stopped = true;
}
}
}
+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,
},
});
+152
View File
@@ -0,0 +1,152 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { type SenseInfo, parseNerveConfig, senseTriggerLabels } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { getNerveRoot, isRunning } from "../workspace.js";
// ---------------------------------------------------------------------------
// Formatting helpers (exported for tests)
// ---------------------------------------------------------------------------
export function formatDuration(ms: number | null): string {
if (ms === null) return "—";
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return `${minutes}m ${seconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export function formatSenseList(senses: SenseInfo[]): string {
if (senses.length === 0) {
return "📭 No senses registered in nerve.yaml.\n";
}
const lines: string[] = [`📡 Registered senses (${senses.length}):\n`];
for (const s of senses) {
lines.push(`\n ${s.name}\n`);
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
lines.push(
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
);
}
return lines.join("");
}
/** Build a SenseInfo list from nerve.yaml when daemon is not running. */
export function sensesFromConfig(configPath: string): SenseInfo[] {
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch {
return [];
}
const result = parseNerveConfig(raw);
if (!result.ok) return [];
const { senses } = result.value;
return Object.entries(senses).map(([name, cfg]) => ({
name,
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
triggers: senseTriggerLabels(name, senses),
}));
}
// ---------------------------------------------------------------------------
// nerve sense list
// ---------------------------------------------------------------------------
const senseListCommand = defineCommand({
meta: {
name: "list",
description: "List all registered senses and their status",
},
async run() {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write(
"⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n",
);
const configPath = join(getNerveRoot(), "nerve.yaml");
const senses = sensesFromConfig(configPath);
process.stdout.write(formatSenseList(senses));
return;
}
const transport = resolveDaemonTransport();
let senses: SenseInfo[];
try {
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);
}
process.stdout.write(formatSenseList(senses));
},
});
// ---------------------------------------------------------------------------
// nerve sense trigger <name>
// ---------------------------------------------------------------------------
const senseTriggerCommand = defineCommand({
meta: {
name: "trigger",
description: "Manually trigger a sense compute by sending an IPC message to the running daemon",
},
args: {
name: {
type: "positional",
description: "The sense name to trigger",
},
},
async run({ args }) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
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`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon rejected trigger: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Triggered sense "${args.name}" via daemon.\n`);
},
});
// ---------------------------------------------------------------------------
// nerve sense (parent command)
// ---------------------------------------------------------------------------
export const senseCommand = defineCommand({
meta: {
name: "sense",
description: "Interact with sense computes",
},
subCommands: {
list: senseListCommand,
trigger: senseTriggerCommand,
},
});
+92 -91
View File
@@ -1,11 +1,10 @@
import { createWriteStream } from "node:fs";
import { readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
import { defineCommand } from "citty";
import {
@@ -14,106 +13,90 @@ import {
getSocketPath,
isRunning,
readPidFile,
removePidFile,
writePidFile,
} from "../workspace.js";
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
}
return parseNerveConfig(raw);
function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise<boolean> {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const check = (): void => {
if (existsSync(socketPath)) {
resolve(true);
} else if (Date.now() >= deadline) {
resolve(false);
} else {
setTimeout(check, intervalMs);
}
};
check();
});
}
async function runForeground(nerveRoot: string): Promise<void> {
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
/** Path to the CLI entry script (used to locate dist/ next to bundled assets). */
function cliEntryScript(): string {
const here = fileURLToPath(import.meta.url);
const ext = here.endsWith(".ts") ? ".ts" : ".js";
const candidates = [join(dirname(here), `cli${ext}`), join(dirname(here), "..", `cli${ext}`)];
const cliPath = candidates.find((p) => existsSync(p));
if (!cliPath) {
throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`);
}
return cliPath;
}
const config = configResult.value;
const kernel = createKernel(config, nerveRoot, {
enableFileWatcher: true,
ipcSocketPath: getSocketPath(),
});
const senseNames = Object.keys(config.senses);
const groups = [...kernel.groups];
process.stdout.write(
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
function daemonBootstrapScript(): string {
const cliPath = cliEntryScript();
const dir = dirname(cliPath);
const bootstrapJs = join(dir, "daemon-bootstrap.js");
if (existsSync(bootstrapJs)) {
return bootstrapJs;
}
throw new Error(
`daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using \`nerve daemon start\`.`,
);
for (const group of groups) {
const groupSenses = Object.entries(config.senses)
.filter(([, sc]) => sc.group === group)
.map(([name]) => name);
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
}
process.stdout.write(" Press Ctrl+C to stop.\n");
let shuttingDown = false;
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
process.stdout.write("\n[nerve] Shutting down…\n");
await kernel.stop();
process.exit(0);
}
process.on("SIGINT", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
process.on("SIGTERM", () => {
shutdown().catch((e: unknown) => {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
process.exit(1);
});
});
await kernel.ready;
}
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`);
process.exit(1);
}
const configResult = readConfig(nerveRoot);
if (!configResult.ok) {
process.stderr.write(`${configResult.error.message}\n`);
process.exit(1);
}
const logPath = getLogPath();
await mkdir(join(nerveRoot, "logs"), { recursive: true });
const { spawn } = await import("node:child_process");
const logStream = createWriteStream(logPath, { flags: "a" });
await new Promise<void>((resolve) => {
if (logStream.pending) logStream.once("open", () => resolve());
else resolve();
});
const selfPath = fileURLToPath(import.meta.url);
const bootstrapPath = daemonBootstrapScript();
const child = spawn(process.execPath, [selfPath, "start"], {
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_DAEMON_MODE: "1" },
stdio: ["ignore", logFd, logFd],
env,
cwd: nerveRoot,
});
child.unref();
@@ -125,31 +108,49 @@ async function runDaemon(nerveRoot: string): Promise<void> {
}
writePidFile(pid);
const ready = await waitForSocket(getSocketPath(), 5000);
if (!ready || !isRunning()) {
removePidFile();
process.stderr.write(
`❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`,
);
process.exit(1);
}
process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`);
process.stdout.write(` Logs: ${logPath}\n`);
process.stdout.write(" Run `nerve stop` to stop.\n");
process.stdout.write(" Run `nerve daemon stop` (or `nerve stop`) to stop.\n");
}
export const startCommand = defineCommand({
/** Background daemon only — use `nerve dev` for foreground mode. */
export async function runDaemonStartCommand(cliHttpPort: number | null = null): Promise<void> {
await runDaemon(getNerveRoot(), cliHttpPort);
}
export const daemonStartCommand = defineCommand({
meta: {
name: "start",
description: "Start the nerve daemon",
description: "Start the nerve daemon in the background",
},
args: {
daemon: {
type: "boolean",
alias: "d",
description: "Run as background daemon",
default: false,
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();
if (args.daemon) {
await runDaemon(nerveRoot);
} else {
await runForeground(nerveRoot);
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);
},
});

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