Compare commits

...

172 Commits

Author SHA1 Message Date
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
242 changed files with 18007 additions and 1829 deletions
+47
View File
@@ -0,0 +1,47 @@
# Agent Adapters (RFC-003)
Adapter = capability. Role = scenario. Workflows declare adapters directly via import.
## AgentFn Protocol
```ts
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
```
- Input: prompt + context (start frame, messages, workdir, AbortSignal)
- Output: raw string — structured extraction is separate
- Adapter handles tool-specific details internally
## Available Adapters
| Package | Adapter | Tool |
|---------|---------|------|
| `@uncaged/nerve-adapter-cursor` | `cursorAdapter` / `createCursorAdapter()` | cursor-agent CLI |
| `@uncaged/nerve-adapter-hermes` | `hermesAdapter` / `createHermesAdapter()` | hermes chat CLI |
Each exports a **default instance** (sensible defaults) and a **factory** for custom config.
## 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`.
+33
View File
@@ -0,0 +1,33 @@
# Nerve Architecture
Observation engine for autonomous agents — sense the world, react to changes, run workflows.
## Core Pipeline
```
External World → Sense → Signal → Reflex → Workflow → Log
```
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Reflexes (prevents feedback loops).
## Three Orthogonal Extension Points
| Extension | Question | Nature |
|-----------|----------|--------|
| **Sense** | What to compute | `compute()` function |
| **Reflex** | When to compute | Declarative YAML (interval / on) |
| **Workflow** | What to do | Roles + Moderator |
Each is independent. Reflex doesn't know compute internals, Sense doesn't know when it's triggered, Workflow doesn't know why it was started.
## Two Event Types
- **Signal** — from Sense compute (non-null return). Pure fact, no intent. Drives the front half (perception).
- **Command Event** — inside Workflow Threads. Has causal chain, must be responded to. Drives the back half (action).
## 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
+78
View File
@@ -0,0 +1,78 @@
# Nerve CLI
`nerve` — CLI entry point for nerve workspace management.
## Workspace Lifecycle
```bash
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
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)
```
## Sense Management
```bash
nerve create sense <name> # scaffold a new sense (compute.ts + schema.ts)
nerve sense list # list configured senses
nerve sense trigger <name> # manually trigger a sense compute
nerve sense schema <name> # show sense Drizzle schema
nerve sense query <name> # inspect sense SQLite database
nerve sense query <name> --sql "SELECT * FROM samples LIMIT 5"
```
## 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/
compute.ts # sense implementation
schema.ts # Drizzle schema
migrations/ # auto-generated
workflows/
cleanup/
src/index.ts # workflow definition
knowledge.db # generated by nerve knowledge sync
.knowledge/ # curated knowledge cards
```
+57
View File
@@ -0,0 +1,57 @@
# 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
## 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
## Async
- Always `async/await`, never `.then()` chains
## 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
```
+38
View File
@@ -0,0 +1,38 @@
# 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
- Remote service: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
- Model: Dashscope text-embedding-v3 (1024 dims)
- Cache: content-addressable (sha256 of model+text), never expires
- Fallback: word-overlap scoring when embed service not configured
## 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, signal bus, scheduler
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`
+29
View File
@@ -0,0 +1,29 @@
# Sense
A `compute()` function that samples or derives external data. The only first-class citizen in nerve.
## Behavior
- Returns `T | null` — non-null emits a Signal, null is silent (no storage write, no signal, no downstream trigger)
- Each Sense has its own **independent SQLite database**
- Cross-sense reads are read-only via `peers` parameter
- Schema defined with Drizzle ORM (`schema.ts` is single source of truth)
## Sense → Workflow
If `compute()` returns an object with `workflow: "name|maxRounds|prompt"`, the engine starts that workflow and does **not** emit a Signal. `workflow: null` or `""` means emit signal normally.
See `routeSenseComputeOutput` / `parseSenseWorkflowDirective` in `@uncaged/nerve-core`.
## 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 on signals from other senses (optional)
```
+59
View File
@@ -0,0 +1,59 @@
# Workflow Engine
Stateful multi-step execution driven by Roles and a Moderator.
## Core Concepts
- **Workflow** — definition with concurrency strategy
- **Thread** — one execution instance, unique `runId`
- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }`
- **Moderator** — pure routing function. `(context) → 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 | ((start, messages) => Promise<string>)` — static or dynamic
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
- `extract: LlmExtractorConfig` — provider for structured extraction
+1 -1
View File
@@ -30,7 +30,7 @@ Three extension points for **what / when / multi-step action** — reflexes neve
|---------|-------------|
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, 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, workers, signal bus, reflex scheduler, workflow manager, file watcher, IPC |
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, sense scheduler, workflow manager, file watcher, IPC |
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
## Quick Start
+1 -1
View File
@@ -19,7 +19,7 @@
},
"overrides": [
{
"include": ["tsup.config.ts", "*/rslib.config.ts"],
"include": ["tsup.config.ts", "*/rslib.config.ts", "packages/khala/src/index.ts"],
"linter": {
"rules": {
"style": {
+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
+3 -4
View File
@@ -8,10 +8,9 @@ import { samples } from "./schema.js";
* 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.
* On every successful read a row is inserted and a Signal is emitted with the load value.
*/
export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number | null> {
export async function compute(db: DrizzleDB, _peers: PeerMap) {
const [oneMin] = loadavg();
if (typeof oneMin !== "number" || Number.isNaN(oneMin)) {
@@ -19,5 +18,5 @@ export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number |
}
await db.insert(samples).values({ ts: Date.now(), value: oneMin });
return oneMin;
return { signal: oneMin, workflow: null };
}
+2 -12
View File
@@ -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
+2 -5
View File
@@ -11,9 +11,6 @@
* group: internal
* throttle: 30s
* timeout: 5s
*
* reflexes:
* - sense: nerve-health
* interval: 30s
*/
@@ -25,9 +22,9 @@ export type NerveHealth = {
workerUptime: number;
};
export async function compute(): Promise<NerveHealth | null> {
export async function compute() {
const health = await requestHealthFromKernel();
return health;
return { signal: health, workflow: null };
}
function requestHealthFromKernel(): Promise<NerveHealth> {
+4
View File
@@ -0,0 +1,4 @@
include:
- ".knowledge/**/*.md"
exclude: []
+2 -1
View File
@@ -8,7 +8,8 @@
"prepare": "husky",
"build": "pnpm -r run build",
"check": "biome check .",
"format": "biome format --write ."
"format": "biome format --write .",
"link:dev": "bash scripts/link-dev.sh"
},
"devDependencies": {
"@biomejs/biome": "^1.9.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, WorkflowContext } 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 (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await cursorAgent({
prompt,
mode,
model: config.model,
cwd: context.workdir,
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
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, WorkflowContext } 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 (prompt: string, context: WorkflowContext): Promise<string> => {
const run = await hermesAgent({
prompt,
model: modelFromConfig,
provider: null,
skills: [],
quiet: true,
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
env: null,
timeoutMs,
dryRun: context.start.meta.dryRun,
abortSignal: context.signal,
});
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"]
}
+3 -1
View File
@@ -3,7 +3,7 @@
"engines": {
"node": ">=22.5.0"
},
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"bin": {
"nerve": "dist/cli.js"
@@ -23,11 +23,13 @@
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
"@types/node": "^22.0.0",
"@uncaged/nerve-daemon": "workspace:*",
"vitest": "^4.1.5"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
format: "esm",
dts: true,
banner: {
js: "#!/usr/bin/env node",
js: "#!/usr/bin/env -S node --disable-warning=ExperimentalWarning",
},
},
],
@@ -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,73 @@
/**
* Tests for nerve create sense template helpers.
*/
import { describe, expect, it } from "vitest";
import {
buildSenseIndexTs,
buildSenseMigrationSql,
buildSensePackageJson,
buildSenseSchemaTs,
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("buildSenseSchemaTs", () => {
it("maps kebab-case id to snake table and camel export", () => {
const src = buildSenseSchemaTs("my-sense");
expect(src).toContain('sqliteTable("my_sense"');
expect(src).toContain("export const mySense = ");
});
it("handles single-segment id", () => {
const src = buildSenseSchemaTs("metrics");
expect(src).toContain('sqliteTable("metrics"');
expect(src).toContain("export const metrics = ");
});
});
describe("buildSenseMigrationSql", () => {
it("uses snake_case table name", () => {
expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io");
});
});
describe("buildSensePackageJson", () => {
it("includes esbuild script and sense name", () => {
const pkg = JSON.parse(buildSensePackageJson("my-sense"));
expect(pkg.name).toBe("nerve-sense-my-sense");
expect(pkg.scripts.build).toContain("esbuild");
expect(pkg.scripts.build).toContain("src/index.ts");
expect(pkg.devDependencies.esbuild).toBeTruthy();
});
});
describe("buildSenseIndexTs", () => {
it("embeds sense id in stub with TypeScript types", () => {
const ts = buildSenseIndexTs("my-sense");
expect(ts).toContain("my-sense");
expect(ts).toContain("export async function compute");
expect(ts).toContain("LibSQLDatabase");
expect(ts).toContain("Promise<SenseResult>");
expect(ts).toContain('from "./schema.js"');
});
it("imports the correct schema export", () => {
const ts = buildSenseIndexTs("cpu-usage");
expect(ts).toContain("cpuUsage");
});
});
@@ -0,0 +1,116 @@
/**
* 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 { buildWorkflowPackageJson, 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 and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
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 function", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
});
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");
});
it("package.json defines esbuild bundling to dist/", () => {
const pkg = JSON.parse(buildWorkflowPackageJson("my-flow")) as {
scripts: { build: string };
devDependencies: { esbuild: string };
};
expect(pkg.scripts.build).toContain("esbuild");
expect(pkg.scripts.build).toContain("--outdir=dist");
expect(pkg.devDependencies.esbuild).toBeTruthy();
});
it("buildWorkflowScaffold includes package.json body", () => {
const { packageJson } = buildWorkflowScaffold("wf");
expect(JSON.parse(packageJson).scripts.build).toContain("esbuild");
});
});
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,190 @@
/**
* 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 package.json with esbuild build",
{ timeout: 10_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
expect(wf.stdout).toContain("✅");
const pkgPath = join(nerveRoot, "workflows", "e2e-flow", "package.json");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
expect(existsSync(pkgPath)).toBe(true);
expect(JSON.parse(readFileSync(pkgPath, "utf8")).scripts.build).toContain("esbuild");
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");
},
);
it(
"create sense scaffolds src/index.ts, src/schema.ts, package.json and migration",
{ timeout: 60_000 },
async () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
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(true);
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
const pkg = JSON.parse(readFileSync(join(base, "package.json"), "utf8"));
expect(pkg.scripts.build).toContain("esbuild");
// pnpm install + build should produce index.js
expect(existsSync(join(base, "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.");
},
);
});
+454
View File
@@ -0,0 +1,454 @@
/**
* Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker,
* IPC socket for CLI, and `runCommand` helpers with captured stdio.
*
* ## Signal persistence (CLI `nerve sense list`)
*
* The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a
* worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also
* auto-persists each signal into a `_signals` table in the per-sense SQLite DB
* (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`).
* `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state,
* while `sense query` reads from the `_signals` table (or a user-defined preview table).
*
* ## 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 (start, _messages) => {
await new Promise((r) => setTimeout(r, 350));
const p = typeof start.content === "string" ? 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
`;
/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */
const counterMigration = `-- no-op migration for e2e counter sense
SELECT 1;
`;
/**
* Minimal counter sense — each compute returns an incrementing count.
* Does NOT touch the DB directly; signal persistence is handled by the daemon
* (`runtime.persistSignal`) which writes to `_signals` automatically.
*/
const counterIndexJs = `let _count = 0;
export async function compute(_db, _peers, _options) {
_count += 1;
return { signal: { count: _count }, workflow: null };
}
`;
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
const counterIndexJsWithNoopWorkflow = `let _launched = false;
export async function compute(_db, _peers, _options) {
if (!_launched) {
_launched = true;
return {
signal: { launched: true },
workflow: {
name: "noop",
maxRounds: 3,
prompt: "e2e-archive",
dryRun: false,
},
};
}
return { signal: { idle: true }, 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,
retention: 10_000,
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, "senses", "counter", "migrations"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "echo", "dist"), { recursive: true });
writeFileSync(
join(nerveRoot, "nerve.yaml"),
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
"utf8",
);
writeFileSync(
join(nerveRoot, "senses", "counter", "migrations", "001.sql"),
counterMigration,
"utf8",
);
writeFileSync(
join(nerveRoot, "senses", "counter", "index.js"),
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
"utf8",
);
writeFileSync(
join(nerveRoot, "workflows", "echo", "dist", "index.js"),
echoWorkflowIndexJs,
"utf8",
);
if (withNoopWorkflow) {
mkdirSync(join(nerveRoot, "workflows", "noop", "dist"), { recursive: true });
mkdirSync(join(nerveRoot, "workflows", "noop", "migrations"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "noop", "dist", "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 linkDir = join(nerveRoot, "node_modules", "@uncaged");
const linkPath = join(linkDir, "nerve-daemon");
mkdirSync(linkDir, { recursive: true });
if (existsSync(linkPath)) return;
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,121 @@
/**
* E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156).
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import {
type TestDaemonHandle,
pollUntil,
runCli,
startTestDaemon,
stopTestDaemon,
} from "./e2e-harness.js";
describe("e2e sense query", () => {
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),
),
]);
});
async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise<void> {
const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db");
await pollUntil(() => {
if (!existsSync(dbPath)) return false;
try {
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
| { cnt: number }
| undefined;
db.close();
return row !== undefined && row.cnt > 0;
} catch {
return false;
}
}, 10_000);
}
/** Start daemon, trigger counter, wait until `_signals` has a row. */
async function startDaemonWithPersistedSignal(): Promise<TestDaemonHandle> {
const handle = await startTestDaemon();
const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
expect(triggerResult.stdout).toContain("Triggered");
await waitForSignalsPersisted(handle);
return handle;
}
it(
"after trigger, persisted _signals and sense query counter returns at least one row",
{ timeout: 30_000 },
async () => {
daemon = await startDaemonWithPersistedSignal();
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
expect(queryResult.exitCode).toBe(0);
expect(queryResult.stdout).not.toContain("(0 rows)");
},
);
it(
"default sense query output lists payload column and counter count in payload",
{ timeout: 30_000 },
async () => {
daemon = await startDaemonWithPersistedSignal();
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
expect(queryResult.exitCode).toBe(0);
expect(queryResult.stdout).toContain("payload");
expect(queryResult.stdout).toMatch(/count/);
},
);
it(
"nerve sense query counter --json prints a JSON array with payload on each row",
{ timeout: 30_000 },
async () => {
daemon = await startDaemonWithPersistedSignal();
const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]);
expect(jsonResult.exitCode).toBe(0);
const rows = JSON.parse(jsonResult.stdout.trim()) as unknown;
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const row of rows as Record<string, unknown>[]) {
expect(Object.keys(row)).toContain("payload");
}
},
);
it(
"nerve sense query counter --sql runs custom read-only SQL and prints total column",
{ timeout: 30_000 },
async () => {
daemon = await startDaemonWithPersistedSignal();
const sqlResult = await runCli(daemon, [
"sense",
"query",
"counter",
"--sql",
"SELECT count(*) as total FROM _signals",
]);
expect(sqlResult.exitCode).toBe(0);
expect(sqlResult.stdout).toContain("total");
expect(sqlResult.stdout).not.toContain("(0 rows)");
},
);
});
@@ -0,0 +1,73 @@
/**
* Smoke test: start a real daemon with a counter sense, trigger it,
* then verify CLI commands can list and query the persisted signal.
*/
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 + sense query after trigger", { timeout: 30_000 }, async () => {
daemon = await startTestDaemon();
// Trigger counter sense via IPC
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
expect(triggerResult.stdout).toContain("Triggered");
// Wait for signal to be persisted (_signals table in the sense DB)
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
const { DatabaseSync } = await import("node:sqlite");
const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db");
await pollUntil(() => {
if (!existsSync(dbPath)) return false;
try {
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
| { cnt: number }
| undefined;
db.close();
return row !== undefined && row.cnt > 0;
} catch {
return false;
}
}, 10_000);
// nerve sense list — should show counter with a last signal timestamp
const listResult = await runCli(daemon, ["sense", "list"]);
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout).toContain("counter");
expect(listResult.stdout).toContain("last signal:");
// Should NOT say "(never)" since we triggered and persisted
expect(listResult.stdout).not.toContain("(never)");
// nerve sense query counter — should return rows from _signals
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
expect(queryResult.exitCode).toBe(0);
// Should have actual data rows (not "(0 rows)")
expect(queryResult.stdout).not.toContain("(0 rows)");
});
});
@@ -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,253 @@
/**
* 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, "pnpm-workspace.yaml"))).toBe(true);
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "package.json"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true);
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe(
true,
);
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
expect(pkgJson).toContain('"@uncaged/nerve-skills": "latest"');
expect(pkgJson).toContain('"build": "pnpm -r build"');
const workspaceYaml = readFileSync(join(nerveRoot, "pnpm-workspace.yaml"), "utf8");
expect(workspaceYaml).toContain("workflows/*");
expect(workspaceYaml).toContain("senses/*");
const sensePkgJson = readFileSync(
join(nerveRoot, "senses", "cpu-usage", "package.json"),
"utf8",
);
expect(sensePkgJson).toContain("nerve-sense-cpu-usage");
expect(sensePkgJson).toContain("esbuild");
});
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
+29
View File
@@ -0,0 +1,29 @@
# nerve sense — E2E Scenarios
## sense list
- ✅ prints sense list with name, group, throttle, triggers, and last signal time
- 🔲 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
## sense query
- ✅ after trigger, persisted `_signals` table has at least one row
- ✅ default output lists payload column and counter count
-`--json` prints valid JSON array with payload on each row
-`--sql` runs custom read-only SQL and prints result
- 🔲 query non-existent sense — error message
- 🔲 `--limit` / `--offset` pagination
## sense schema
- ✅ prints CREATE TABLE statements for the sense database
- ✅ includes `_signals` table in output
-`--json` prints valid JSON array of SQL strings
- 🔲 schema for non-existent sense — error message
+6
View File
@@ -0,0 +1,6 @@
# nerve smoke — E2E Scenarios
Full round-trip integration tests that exercise multiple subcommands together.
- ✅ sense list + sense query after trigger — registers sense, triggers, verifies persisted signal and query output
- 🔲 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 signal bus emits notifications",
contentHash: contentHash("the signal bus emits notifications"),
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, "signal bus", 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();
}
});
});
+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,139 @@
/**
* 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 LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions
const daemonSenseRow: SenseInfo = {
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 5s"],
lastSignalTimestamp: LAST_SIGNAL_TS,
};
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, trigger schedule, and last signal time", async () => {
// With a real daemon, we would wait for a compute cycle; the mock server
// returns SenseInfo as if one already produced lastSignalTimestamp.
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("(never)");
expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString());
});
});
+32 -3
View File
@@ -29,6 +29,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 30s", "on: cpu-threshold"],
lastSignalTimestamp: 1_700_000_000_000,
},
{
@@ -36,6 +37,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 30000,
timeout: null,
triggers: [],
lastSignalTimestamp: null,
},
{
@@ -43,6 +45,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "tasks",
throttle: 10000,
timeout: 30000,
triggers: ["every 1m"],
lastSignalTimestamp: null,
},
];
@@ -112,6 +115,13 @@ describe("formatSenseList", () => {
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("shows '(never)' when lastSignalTimestamp is null", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
@@ -164,7 +174,6 @@ senses:
disk-usage:
group: system
throttle: 30s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
@@ -189,7 +198,6 @@ reflexes: []
senses:
my-sense:
group: default
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
@@ -206,13 +214,33 @@ senses:
group: default
throttle: 10s
timeout: 5s
reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].throttle).toBe(10000);
expect(result[0].timeout).toBe(5000);
});
it("uses inline interval and on for trigger schedule labels", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
`
senses:
downstream:
group: default
interval: 15s
on: [upstream]
upstream:
group: default
`.trim(),
);
const result = sensesFromConfig(path);
const downstream = result.find((s) => s.name === "downstream");
const upstream = result.find((s) => s.name === "upstream");
expect(downstream?.triggers).toEqual(["every 15s · on: upstream"]);
expect(upstream?.triggers).toEqual([]);
});
});
// ---------------------------------------------------------------------------
@@ -263,6 +291,7 @@ describe("listSensesViaDaemon", () => {
group: "system",
throttle: 5000,
timeout: 3000,
triggers: [],
lastSignalTimestamp: 12345,
},
];
@@ -0,0 +1,97 @@
/**
* E2E-style tests for `nerve sense schema` with a temp HOME and a real sense SQLite file.
* `getNerveRoot()` uses `os.homedir()`, which respects `process.env.HOME` on POSIX.
*/
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { senseCommand } from "../commands/sense.js";
const SENSE_NAME = "e2e-schema-sense";
function createFakeSenseDb(nerveRoot: string): void {
const sensesDir = join(nerveRoot, "data", "senses");
mkdirSync(sensesDir, { recursive: true });
const dbPath = join(sensesDir, `${SENSE_NAME}.db`);
const db = new DatabaseSync(dbPath);
db.exec(
"CREATE TABLE _signals(id INTEGER PRIMARY KEY, sense TEXT, timestamp INTEGER, payload TEXT)",
);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
db.close();
}
describe("nerve sense schema CLI (runCommand + temp HOME)", () => {
let prevHome: string | undefined;
let fakeHome: string;
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
let capturedStdout: string;
beforeEach(() => {
capturedStdout = "";
stdoutSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation(
(chunk: string | Uint8Array, enc?: BufferEncoding, cb?: (err?: Error | null) => void) => {
if (typeof chunk === "string") {
capturedStdout += chunk;
} else {
capturedStdout += Buffer.from(chunk).toString(typeof enc === "string" ? enc : "utf8");
}
if (typeof cb === "function") {
cb();
}
return true;
},
);
prevHome = process.env.HOME;
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-schema-e2e-"));
process.env.HOME = fakeHome;
const nerveRoot = join(fakeHome, ".uncaged-nerve");
createFakeSenseDb(nerveRoot);
});
afterEach(() => {
stdoutSpy?.mockRestore();
stdoutSpy = 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 });
});
it("prints CREATE TABLE statements for the sense database", async () => {
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
expect(capturedStdout).toMatch(/CREATE TABLE/i);
});
it("includes the _signals table in output", async () => {
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
expect(capturedStdout).toContain("_signals");
});
it("with --json prints a valid JSON array of SQL strings", async () => {
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME, "--json"] });
const parsed: unknown = JSON.parse(capturedStdout.trim());
expect(Array.isArray(parsed)).toBe(true);
const arr = parsed as unknown[];
expect(arr.length).toBeGreaterThanOrEqual(1);
for (const item of arr) {
expect(typeof item).toBe("string");
expect(item).toMatch(/CREATE TABLE/i);
}
const joined = arr.join("\n");
expect(joined).toContain("_signals");
});
});
@@ -105,6 +105,28 @@ describe("parseSenseQueryArgs", () => {
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
});
it("uses --sql value instead of positional SQL", () => {
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({
name: "cpu",
sql: "SELECT 2",
});
expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({
name: "cpu",
sql: "SELECT 3",
});
});
it("prefers --sql over trailing positional SQL", () => {
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({
name: "cpu",
sql: "SELECT a",
});
});
it("throws when --sql has no value", () => {
expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/);
});
it("throws when name is missing", () => {
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
});
@@ -0,0 +1,122 @@
/**
* E2E-style tests for `nerve sense trigger` (issue #157): citty + workspace stubs
* and a mock daemon on a real Unix socket — no running nerve process required.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { type Server, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { senseCommand } from "../commands/sense.js";
import * as workspace from "../workspace.js";
describe("nerve sense trigger (e2e mock daemon)", () => {
let sockDir: string;
let sockPath: string;
let server: Server;
let ipcReceived: unknown[];
let knownOkSenses: Set<string>;
let stdoutBuf: string;
let stderrBuf: string;
beforeEach(async () => {
ipcReceived = [];
knownOkSenses = new Set(["cpu-usage"]);
stdoutBuf = "";
stderrBuf = "";
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-trigger-e2e-"));
sockPath = join(sockDir, "nerve.sock");
server = createServer((socket) => {
socket.on("data", (chunk: Buffer) => {
const line = chunk.toString("utf8").trim();
let req: unknown;
try {
req = JSON.parse(line);
} catch {
return;
}
ipcReceived.push(req);
const r = req as { type?: string; sense?: string };
if (
r.type === "trigger-sense" &&
typeof r.sense === "string" &&
knownOkSenses.has(r.sense)
) {
socket.write(`${JSON.stringify({ ok: true })}\n`);
} else if (r.type === "trigger-sense" && typeof r.sense === "string") {
socket.write(`${JSON.stringify({ ok: false, error: `Unknown sense: "${r.sense}"` })}\n`);
}
});
});
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
vi.spyOn(process.stdout, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
stdoutBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
} else if (cb !== undefined) {
cb(null);
}
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
stderrBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
if (typeof encodingOrCb === "function") {
encodingOrCb(null);
} else if (cb !== undefined) {
cb(null);
}
return true;
});
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
const c = typeof code === "number" ? code : 1;
// Throw instead of actually exiting so the test runner stays alive;
// the test asserts on this message to verify the exit code.
throw new Error(`process.exit(${String(c)})`);
});
});
afterEach(async () => {
vi.restoreAllMocks();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
rmSync(sockDir, { recursive: true, force: true });
});
it("trigger known sense exits normally and stdout contains Triggered", async () => {
await runCommand(senseCommand, { rawArgs: ["trigger", "cpu-usage"] });
expect(stdoutBuf).toContain("Triggered");
expect(stdoutBuf).toContain("✅");
expect(stderrBuf).toBe("");
});
it("trigger non-existent sense writes daemon error to stderr and exits 1", async () => {
await expect(
runCommand(senseCommand, { rawArgs: ["trigger", "no-such-sense"] }),
).rejects.toThrow("process.exit(1)");
expect(stdoutBuf).toBe("");
expect(stderrBuf).toContain("Daemon rejected trigger");
expect(stderrBuf).toContain("Unknown sense");
expect(stderrBuf).toContain("no-such-sense");
});
it("sends IPC { type: trigger-sense, sense: <name> } to the daemon", async () => {
knownOkSenses.add("custom-sense");
await runCommand(senseCommand, { rawArgs: ["trigger", "custom-sense"] });
expect(ipcReceived).toHaveLength(1);
expect(ipcReceived[0]).toEqual({ type: "trigger-sense", sense: "custom-sense" });
});
});
@@ -0,0 +1,113 @@
/**
* RFC-003 Phase 5: nerve validate — workflow adapter usage and extract.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import {
validateAgentConfigurationLayer,
workflowSourcesDeclareAdapterRoles,
} from "../workflow-agent-validation.js";
function baseConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
return {
maxRounds: 10,
senses: {},
workflows: {},
api: { port: null, token: null, host: "127.0.0.1" },
extract: null,
...overrides,
};
}
describe("validateAgentConfigurationLayer", () => {
let nerveRoot: string;
afterEach(() => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("fails when workflow sources use adapters but extract is missing", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
const adapter = async () => "";
const spec = {
name: "demo",
roles: {
r: { adapter: adapter, prompt: "p", meta: {} as never },
},
moderator: () => "__end__" as never,
};
export default spec;
`,
"utf8",
);
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toMatch(/extract/i);
}
});
it("passes when adapters are used and extract is configured", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
`
roles: { x: { adapter: foo, prompt: "", meta: {} as never } }
`,
"utf8",
);
const result = validateAgentConfigurationLayer(
baseConfig({
extract: { provider: "dashscope", model: "qwen-plus" },
}),
nerveRoot,
);
expect(result.ok).toBe(true);
});
it("passes when no adapter usage is detected", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "demo", "src", "wf.ts"),
`const role = { prompt: "x" };`,
"utf8",
);
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
expect(result.ok).toBe(true);
});
});
describe("workflowSourcesDeclareAdapterRoles", () => {
let nerveRoot: string;
afterEach(() => {
rmSync(nerveRoot, { recursive: true, force: true });
});
it("detects adapter: identifiers under workflows/*/src", () => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-adapters-"));
mkdirSync(join(nerveRoot, "workflows", "w1", "src", "nested"), { recursive: true });
writeFileSync(
join(nerveRoot, "workflows", "w1", "src", "nested", "a.ts"),
"adapter: foo\nadapter: bar",
"utf8",
);
expect(workflowSourcesDeclareAdapterRoles(nerveRoot)).toBe(true);
});
});
@@ -0,0 +1,88 @@
/**
* Smoke / integration tests for `nerve workflow` and `nerve thread` citty handlers with a real HOME
* layout and logs.db. `loadDaemonModule` is mocked so tests use workspace
* `@uncaged/nerve-store` directly (no ~/.uncaged-nerve daemon install required).
*/
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../workspace-daemon.js", async () => {
const { createLogStore } = await import("@uncaged/nerve-store");
return {
loadDaemonModule: vi.fn(async () => ({ createLogStore })),
};
});
import { createLogStore } from "@uncaged/nerve-store";
import { threadCommand } from "../commands/thread.js";
describe("nerve thread CLI (runCommand + temp HOME)", () => {
let prevHome: string | undefined;
let fakeHome: string;
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
beforeEach(() => {
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
prevHome = process.env.HOME;
fakeHome = mkdtempSync(join(tmpdir(), "nerve-wf-cli-e2e-"));
process.env.HOME = fakeHome;
const nerveRoot = join(fakeHome, ".uncaged-nerve");
mkdirSync(join(nerveRoot, "data"), { recursive: true });
const dbPath = join(nerveRoot, "data", "logs.db");
const store = createLogStore(dbPath);
store.upsertWorkflowRun(
{ source: "workflow", type: "started", refId: "e2e-run", payload: "{}", timestamp: 5000 },
{ runId: "e2e-run", workflow: "demo", status: "completed", timestamp: 5000, exitCode: 0 },
);
store.append({
source: "workflow",
type: "completed",
refId: "e2e-run",
payload: null,
timestamp: 5001,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "e2e-run",
payload: JSON.stringify({ type: "step", role: "bot", content: "hello" }),
timestamp: 5100,
});
store.close();
});
afterEach(() => {
stdoutSpy?.mockRestore();
stdoutSpy = null;
if (prevHome === undefined) {
process.env.HOME = undefined;
} else {
process.env.HOME = prevHome;
}
rmSync(fakeHome, { recursive: true, force: true });
});
it("thread list --all completes without throwing", async () => {
await expect(runCommand(threadCommand, { rawArgs: ["list", "--all"] })).resolves.toBeDefined();
});
it("thread inspect <runId> completes without throwing", async () => {
await expect(
runCommand(threadCommand, { rawArgs: ["inspect", "e2e-run", "--limit", "10"] }),
).resolves.toBeDefined();
});
it("thread show <runId> completes without throwing (role rounds path)", async () => {
await expect(
runCommand(threadCommand, { rawArgs: ["show", "e2e-run", "--budget", "50000"] }),
).resolves.toBeDefined();
});
});
+143 -4
View File
@@ -18,9 +18,11 @@ 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,
@@ -45,7 +47,7 @@ function upsertRun(
): void {
store.upsertWorkflowRun(
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
{ runId, workflow, status, timestamp: timestampMs },
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
);
}
@@ -64,10 +66,75 @@ afterEach(() => {
// ---------------------------------------------------------------------------
describe("formatTs", () => {
it("returns ISO 8601 string", () => {
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}`);
});
});
// ---------------------------------------------------------------------------
@@ -83,6 +150,7 @@ describe("statusIcon", () => {
["crashed", "💥"],
["dropped", "🗑"],
["interrupted", "⚠️"],
["killed", "🛑"],
] as const)("maps status=%s to icon=%s", (status, icon) => {
expect(statusIcon(status)).toBe(icon);
});
@@ -149,7 +217,7 @@ describe("buildListOutput", () => {
status: WorkflowRun["status"],
timestampMs: number,
): WorkflowRun {
return { runId, workflow, status, timestamp: timestampMs };
return { runId, workflow, status, timestamp: timestampMs, exitCode: null };
}
it("returns empty message when no runs and --all=false", () => {
@@ -191,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");
});
@@ -224,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");
});
});
// ---------------------------------------------------------------------------
@@ -236,6 +321,7 @@ describe("buildInspectOutput", () => {
workflow: "cleanup",
status: "completed",
timestamp: 1_700_000_000_000,
exitCode: null,
};
it("shows header with run details", () => {
@@ -291,13 +377,28 @@ describe("buildInspectOutput", () => {
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);
@@ -336,6 +437,7 @@ describe("partitionWorkflowMessage", () => {
role: "scanner",
content: "ok",
meta: { items: [1, 2] },
timestamp: 1,
});
expect(p.roleStr).toBe("scanner");
expect(p.contentBody).toBe("ok");
@@ -369,6 +471,24 @@ describe("formatThreadRoundBlock", () => {
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", () => {
@@ -413,6 +533,25 @@ describe("buildThreadCommandOutput", () => {
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);
});
+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;
}
+22 -10
View File
@@ -1,14 +1,17 @@
import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
import { defineCommand, runMain } from "citty";
import { consumeGlobalDaemonCliFlags } from "./cli-global.js";
import { createCommand } from "./commands/create.js";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js";
import { knowledgeCommand } from "./commands/knowledge.js";
import { remoteCommand } from "./commands/remote.js";
import { senseCommand } from "./commands/sense.js";
import { daemonStartCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js";
import { storeCommand } from "./commands/store.js";
import { threadCommand } from "./commands/thread.js";
import { validateCommand } from "./commands/validate.js";
import { workflowCommand } from "./commands/workflow.js";
@@ -35,21 +38,30 @@ function normalizeNerveArgv(argv: string[]): string[] {
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: {
init: initCommand,
create: createCommand,
daemon: daemonCommand,
dev: devCommand,
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
logs: logsCommand,
validate: validateCommand,
knowledge: knowledgeCommand,
sense: senseCommand,
store: storeCommand,
remote: remoteCommand,
thread: threadCommand,
workflow: workflowCommand,
},
});
runMain(main, { rawArgs: normalizeNerveArgv(process.argv.slice(2)) });
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) });
+360
View File
@@ -0,0 +1,360 @@
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;
packageJson: string;
};
export function buildWorkflowPackageJson(name: string): string {
return `${JSON.stringify(
{
name: `nerve-workflow-${name}`,
private: true,
type: "module",
scripts: {
build:
"esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
},
},
null,
2,
)}\n`;
}
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
return {
indexTs: buildWorkflowIndexTs(name),
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
packageJson: buildWorkflowPackageJson(name),
};
}
function buildWorkflowIndexTs(name: string): string {
return `import type { 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({ steps }) {
if (steps.length === 0) {
return "main";
}
return END;
},
};
export default workflow;
`;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, StartStep, WorkflowMessage } 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(
start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<Record<string, unknown>>> {
void start;
void messages;
// 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.
`;
}
function senseIdToSqlTableName(id: string): string {
return id.replaceAll("-", "_");
}
function senseIdToSchemaExportName(id: string): string {
const parts = id.split("-");
return parts
.map((part, index) =>
index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1),
)
.join("");
}
export function buildSenseSchemaTs(senseId: string): string {
const table = senseIdToSqlTableName(senseId);
const exportName = senseIdToSchemaExportName(senseId);
return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const ${exportName} = sqliteTable("${table}", {
id: integer("id").primaryKey({ autoIncrement: true }),
ts: integer("ts").notNull(),
label: text("label").notNull(),
});
`;
}
export function buildSensePackageJson(name: string): string {
return `${JSON.stringify(
{
name: `nerve-sense-${name}`,
private: true,
type: "module",
scripts: {
build:
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
"drizzle-orm": "*",
},
},
null,
2,
)}\n`;
}
export function buildSenseIndexTs(senseId: string): string {
const exportName = senseIdToSchemaExportName(senseId);
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
import { ${exportName} } from "./schema.js";
type SenseResult = {
signal: { label: string; ts: number };
workflow: null;
} | null;
/**
* ${senseId} — replace this stub with your sampling logic.
* Returns non-null to emit a signal, null to stay silent.
*/
export async function compute(
db: LibSQLDatabase,
_peers: Record<string, LibSQLDatabase>,
_options: { signal: AbortSignal },
): Promise<SenseResult> {
void ${exportName};
return {
signal: {
label: "${senseId}",
ts: Date.now(),
},
workflow: null,
};
}
`;
}
export function buildSenseMigrationSql(senseId: string): string {
const table = senseIdToSqlTableName(senseId);
return `CREATE TABLE IF NOT EXISTS ${table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
label TEXT NOT 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, "package.json"), scaffold.packageJson);
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, "package.json")}\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("\n💡 Next steps:\n");
process.stdout.write(
` 1. In ${workflowDir}, run \`npm install\` then \`npm run build\` (bundles to dist/index.js).\n`,
);
process.stdout.write(" 2. 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(
` 3. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
);
process.stdout.write(
` 4. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\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 });
mkdirSync(join(senseDir, "migrations"), { recursive: true });
writeFile(join(senseDir, "package.json"), buildSensePackageJson(args.name));
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
process.stdout.write("✅ Sense scaffolded:\n");
process.stdout.write(` ${join(senseDir, "package.json")}\n`);
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
process.stdout.write("\nInstalling sense dependencies and building…\n");
try {
await spawnAsync("pnpm", ["install", "--no-cache", "--ignore-workspace"], senseDir);
await spawnAsync("pnpm", ["run", "build"], senseDir);
process.stdout.write("✅ Build complete — index.js ready.\n");
} catch {
process.stdout.write(
`⚠️ Build failed. Run manually:\n cd ${senseDir} && pnpm install --no-cache --ignore-workspace && 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\` in ${senseDir} after edits.\n`);
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
},
});
export const createCommand = defineCommand({
meta: {
name: "create",
description: "Scaffold a new workflow or sense in the Nerve workspace",
},
subCommands: {
workflow: createWorkflowCommand,
sense: createSenseCommand,
},
});
+22 -3
View File
@@ -1,6 +1,9 @@
import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import {
type ForegroundSessionOptions,
runForegroundKernelSession,
} from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
@@ -9,9 +12,25 @@ export const devCommand = defineCommand({
name: "dev",
description: "Run the nerve kernel in the foreground (development mode)",
},
async run() {
args: {
port: {
type: "string",
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
default: "",
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
let sessionOpts: ForegroundSessionOptions = {};
if (args.port.length > 0) {
const n = Number.parseInt(args.port, 10);
if (Number.isNaN(n) || n < 1 || n > 65_535) {
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
process.exit(1);
}
sessionOpts = { httpApiPortOverride: n };
}
await runForegroundKernelSession(nerveRoot, createKernel, sessionOpts);
},
});
+136 -132
View File
@@ -14,13 +14,14 @@ senses:
throttle: 5s
timeout: 10s
grace_period: null
reflexes:
- kind: sense
sense: cpu-usage
interval: 10s
`;
const PNPM_WORKSPACE_YAML = `packages:
- 'workflows/*'
- 'senses/*'
`;
const BIOME_JSON = `{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"formatter": {
@@ -46,28 +47,67 @@ const BIOME_JSON = `{
}
`;
const PACKAGE_JSON = `{
"name": "my-nerve-workspace",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"drizzle-orm": "latest"
const PACKAGE_JSON = `${JSON.stringify(
{
name: "my-nerve-workspace",
version: "0.0.1",
private: true,
type: "module",
scripts: {
build: "pnpm -r build",
},
dependencies: {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"@uncaged/nerve-skills": "latest",
"drizzle-orm": "latest",
},
devDependencies: {
"@biomejs/biome": "latest",
"drizzle-kit": "latest",
},
pnpm: {
onlyBuiltDependencies: ["esbuild"],
},
},
"devDependencies": {
"@biomejs/biome": "latest",
"drizzle-kit": "latest"
},
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
}
}
`;
null,
2,
)}\n`;
const GITIGNORE = `data/
logs/
nerve.pid
node_modules/
knowledge.db
`;
const NERVE_SKILLS_MDC = `---
description: >-
Nerve skills package — where bundled Agent Skills live in this workspace and how to use them
alwaysApply: true
---
# Nerve skills (\`@uncaged/nerve-skills\`)
This workspace lists **@uncaged/nerve-skills** in \`package.json\`. It ships **Agent Skills** (one directory per skill, each with a \`SKILL.md\`) for Nerve development and related tasks.
## After install
Run your package manager in this workspace (e.g. \`pnpm install\`, \`npm install\` — whatever \`nerve init\` used). Then skills are on disk at:
- \`node_modules/@uncaged/nerve-skills/<skill-id>/SKILL.md\`
Example (current catalog):
- **nerve-dev** — Nerve architecture, CLI, sense/workflow patterns, \`nerve.yaml\`, and conventions: read \`node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`.
## How to use in an agent
1. For tasks that match a skill’s **description** (in the \`SKILL.md\` frontmatter), open that \`SKILL.md\` and follow its structure and checklists.
2. Prefer the skill as the **source of truth** for Nerve-specific conventions over generic assumptions.
3. If the catalog grows, new skills appear as new sibling directories under \`node_modules/@uncaged/nerve-skills/\`.
Do not commit \`node_modules\`; the dependency is the supported way to get and update skills to match \`@uncaged/nerve-skills\` on npm.
`;
const execFileAsync = promisify(execFile);
@@ -82,9 +122,14 @@ export const cpuUsage = sqliteTable("cpu_usage", {
});
`;
const CPU_INDEX_JS = `import { cpus } from "node:os";
const CPU_INDEX_TS = `import { cpus } from "node:os";
export async function compute() {
type SenseResult = {
signal: { model: string; loadPercent: number; ts: number };
workflow: null;
};
export async function compute(): Promise<SenseResult> {
const cpuList = cpus();
let totalIdle = 0;
@@ -99,13 +144,34 @@ export async function compute() {
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
return {
model: cpuList[0]?.model ?? "unknown",
loadPercent: Math.round(loadPercent * 100) / 100,
ts: Date.now(),
signal: {
model: cpuList[0]?.model ?? "unknown",
loadPercent: Math.round(loadPercent * 100) / 100,
ts: Date.now(),
},
workflow: null,
};
}
`;
const CPU_SENSE_PACKAGE_JSON = `${JSON.stringify(
{
name: "nerve-sense-cpu-usage",
private: true,
type: "module",
scripts: {
build:
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
},
devDependencies: {
esbuild: "^0.27.0",
"drizzle-orm": "*",
},
},
null,
2,
)}\n`;
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
@@ -143,90 +209,6 @@ async function detectPackageManager(): Promise<{ cmd: string; installArgs: strin
return { cmd: "npm", installArgs: ["install"] };
}
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
export function validateWorkflowName(name: string): string | null {
if (name.length === 0) return "Workflow name must not be empty.";
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
if (!WORKFLOW_NAME_RE.test(name))
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
const workflow: WorkflowDefinition = {
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
}
return null; // workflow complete
},
};
export default workflow;
`;
}
const initWorkflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Scaffold a new workflow template in ~/.uncaged-nerve/workflows/<name>/",
},
args: {
name: {
type: "positional",
description: "Workflow name (must match the key in nerve.yaml workflows section)",
},
force: {
type: "boolean",
description: "Overwrite if the workflow directory already exists",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
const workflowDir = join(nerveRoot, "workflows", args.name);
const nameError = validateWorkflowName(args.name);
if (nameError !== null) {
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
process.exit(1);
}
if (existsSync(workflowDir) && !args.force) {
process.stderr.write(
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
);
process.exit(1);
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
},
});
const initWorkspaceCommand = defineCommand({
meta: {
name: "workspace",
@@ -238,9 +220,14 @@ const initWorkspaceCommand = defineCommand({
description: "Reinitialize even if workspace already exists (preserves data/)",
default: false,
},
"skip-install": {
type: "boolean",
description: "Skip dependency installation (for testing or offline use)",
default: false,
},
},
async run({ args }) {
await runInitWorkspace(args.force);
await runInitWorkspace(args.force, args["skip-install"]);
},
});
@@ -332,7 +319,7 @@ async function runInitFromGit(url: string): Promise<void> {
);
}
async function runInitWorkspace(force: boolean): Promise<void> {
async function runInitWorkspace(force: boolean, skipInstall = false): Promise<void> {
const nerveRoot = getNerveRoot();
if (existsSync(nerveRoot) && !force) {
@@ -342,34 +329,47 @@ 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", "src"), { recursive: true });
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "pnpm-workspace.yaml"), PNPM_WORKSPACE_YAML);
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", "package.json"), CPU_SENSE_PACKAGE_JSON);
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"), CPU_SCHEMA_TS);
writeFile(
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
CPU_MIGRATION_SQL,
);
writeFile(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"), NERVE_SKILLS_MDC);
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
if (!skipInstall) {
process.stdout.write("Installing dependencies…\n");
const { cmd, installArgs } = await detectPackageManager();
try {
await runCommand(cmd, installArgs, nerveRoot);
} catch {
process.stdout.write(
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
process.stdout.write("Building senses…\n");
try {
await runCommand("pnpm", ["run", "build"], nerveRoot);
} catch {
process.stdout.write(`⚠️ Build failed. Try manually:\n cd ${nerveRoot} && pnpm run build\n`);
}
if (!(await verifyNodeSqlite())) {
process.stdout.write(
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
);
}
}
if (!existsSync(join(nerveRoot, ".git"))) {
@@ -391,7 +391,7 @@ export const initCommand = defineCommand({
meta: {
name: "init",
description:
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), 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: {
@@ -404,9 +404,13 @@ export const initCommand = defineCommand({
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 }) {
@@ -414,6 +418,6 @@ export const initCommand = defineCommand({
await runInitFromGit(String(args.from));
return;
}
await runInitWorkspace(args.force);
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,
},
});
+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,
},
});
+24 -18
View File
@@ -2,10 +2,16 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { type SenseInfo, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import {
type SenseInfo,
isPlainRecord,
parseNerveConfig,
senseTriggerLabels,
} from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import {
defaultPreviewSql,
formatRowsAsAlignedTable,
@@ -14,7 +20,7 @@ import {
parseSenseQueryArgs,
pickDefaultPreviewTable,
} from "../sense-sqlite.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
import { getNerveRoot, isRunning } from "../workspace.js";
// ---------------------------------------------------------------------------
// Formatting helpers (exported for tests)
@@ -43,6 +49,9 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
lines.push(
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
);
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
@@ -60,11 +69,13 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
}
const result = parseNerveConfig(raw);
if (!result.ok) return [];
return Object.entries(result.value.senses).map(([name, cfg]) => ({
const { senses } = result.value;
return Object.entries(senses).map(([name, cfg]) => ({
name,
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
triggers: senseTriggerLabels(name, senses),
lastSignalTimestamp: null,
}));
}
@@ -79,7 +90,7 @@ const senseListCommand = defineCommand({
description: "List all registered senses and their status",
},
async run() {
if (!isRunning()) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write(
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
);
@@ -89,22 +100,17 @@ const senseListCommand = defineCommand({
return;
}
const socketPath = getSocketPath();
let response: { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
const transport = resolveDaemonTransport();
let senses: SenseInfo[];
try {
response = await listSensesViaDaemon(socketPath);
senses = await transport.listSenses();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Daemon error: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(formatSenseList(response.senses));
process.stdout.write(formatSenseList(senses));
},
});
@@ -124,15 +130,15 @@ const senseTriggerCommand = defineCommand({
},
},
async run({ args }) {
if (!isRunning()) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerSenseViaDaemon(socketPath, args.name);
response = await transport.triggerSense(args.name);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
@@ -201,7 +207,7 @@ const senseQueryCommand = defineCommand({
meta: {
name: "query",
description:
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".',
},
args: {
name: {
+40 -8
View File
@@ -1,9 +1,10 @@
import { spawn } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import {
@@ -56,7 +57,7 @@ function daemonBootstrapScript(): string {
);
}
async function runDaemon(nerveRoot: string): Promise<void> {
async function runDaemon(nerveRoot: string, cliHttpPort: number | null): Promise<void> {
if (isRunning()) {
const pid = readPidFile();
process.stderr.write(`⚠️ Nerve daemon is already running (pid ${pid}).\n`);
@@ -74,12 +75,27 @@ async function runDaemon(nerveRoot: string): Promise<void> {
const bootstrapPath = daemonBootstrapScript();
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, [bootstrapPath], {
const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", bootstrapPath], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, NERVE_ROOT: nerveRoot },
env,
cwd: nerveRoot,
});
@@ -109,8 +125,8 @@ async function runDaemon(nerveRoot: string): Promise<void> {
}
/** Background daemon only — use `nerve dev` for foreground mode. */
export async function runDaemonStartCommand(): Promise<void> {
await runDaemon(getNerveRoot());
export async function runDaemonStartCommand(cliHttpPort: number | null = null): Promise<void> {
await runDaemon(getNerveRoot(), cliHttpPort);
}
export const daemonStartCommand = defineCommand({
@@ -118,7 +134,23 @@ export const daemonStartCommand = defineCommand({
name: "start",
description: "Start the nerve daemon in the background",
},
async run() {
await runDaemonStartCommand();
args: {
port: {
type: "string",
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
default: "",
},
},
async run({ args }) {
let cliHttpPort: number | null = null;
if (args.port.length > 0) {
const n = Number.parseInt(args.port, 10);
if (Number.isNaN(n) || n < 1 || n > 65_535) {
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
process.exit(1);
}
cliHttpPort = n;
}
await runDaemonStartCommand(cliHttpPort);
},
});
+19
View File
@@ -4,6 +4,8 @@ import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { getNerveRoot, getPidPath, isRunning, readPidFile } from "../workspace.js";
function formatUptime(ms: number): string {
@@ -42,6 +44,23 @@ export const statusCommand = defineCommand({
description: "Show nerve daemon status",
},
async run() {
if (isRemoteDaemonCli()) {
const transport = resolveDaemonTransport();
try {
const health = await transport.health();
process.stdout.write("✅ Nerve daemon is reachable (remote HTTP).\n");
process.stdout.write(` hostname: ${health.hostname}\n`);
process.stdout.write(` version: ${health.version}\n`);
process.stdout.write(` uptime: ${formatUptime(health.uptime * 1000)}\n`);
process.stdout.write(` started: ${health.startedAt}\n`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Cannot reach remote daemon: ${msg}\n`);
process.exit(1);
}
return;
}
if (!isRunning()) {
process.stdout.write("😴 Nerve daemon is not running.\n");
return;
+279
View File
@@ -0,0 +1,279 @@
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { isRunning } from "../workspace.js";
import {
DEFAULT_PAGE_SIZE,
DEFAULT_THREAD_BUDGET_CHARS,
THREAD_ROUNDS_FETCH_LIMIT,
buildInspectOutput,
buildListOutput,
buildThreadCommandOutput,
getAllWorkflowRuns,
openStore,
parseIntArg,
} from "./workflow.js";
// ---------------------------------------------------------------------------
// nerve thread list
// ---------------------------------------------------------------------------
const threadListCommand = defineCommand({
meta: {
name: "list",
description: "List active (queued/started) workflow runs from logs",
},
args: {
all: {
type: "boolean",
description: "Include completed/failed/crashed runs",
default: false,
},
workflow: {
type: "string",
description: "Filter by workflow name",
default: "",
},
limit: {
type: "string",
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N runs (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
const runs = args.all
? getAllWorkflowRuns(store, filterWorkflow)
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
const { lines, paginationHint } = buildListOutput(
runs,
offset,
limit,
args.all,
filterWorkflow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread show <runId>
// ---------------------------------------------------------------------------
const threadShowCommand = defineCommand({
meta: {
name: "show",
description: "Print role rounds for a workflow run (agent-oriented, budget-limited)",
},
args: {
runId: {
type: "positional",
description: "The run ID to dump role rounds for",
},
before: {
type: "string",
description:
"Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)",
default: "0",
},
budget: {
type: "string",
description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`,
default: String(DEFAULT_THREAD_BUDGET_CHARS),
},
},
async run({ args }) {
const store = await openStore();
try {
const before = Math.max(0, parseIntArg(args.before, 0));
const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const startRow = before === 0 ? store.getThreadStartMessage(args.runId) : null;
const totalRoleRounds = store.getThreadRoundCount(args.runId);
if (totalRoleRounds === 0 && startRow === null) {
process.stdout.write(
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
);
return;
}
const descRows = store.getThreadRounds(args.runId, {
before,
limit: THREAD_ROUNDS_FETCH_LIMIT,
});
const prefixLines = [
"🧵 Role rounds (workflow thread)\n",
` runId: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`,
];
const { lines, paginationHint } = buildThreadCommandOutput(
prefixLines,
descRows,
budgetChars,
args.runId,
startRow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
if (descRows.length === 0 && before > 0) {
process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread inspect <runId>
// ---------------------------------------------------------------------------
const threadInspectCommand = defineCommand({
meta: {
name: "inspect",
description: "Show details and thread events for a workflow run",
},
args: {
runId: {
type: "positional",
description: "The run ID to inspect",
},
limit: {
type: "string",
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N log entries (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const allLogs = store.query({ source: "workflow", refId: args.runId });
const { header, eventLines, paginationHint } = buildInspectOutput(
run,
allLogs,
offset,
limit,
);
for (const line of [...header, ...eventLines]) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve thread kill <runId>
// ---------------------------------------------------------------------------
const threadKillCommand = defineCommand({
meta: {
name: "kill",
description: "Kill a running or queued workflow thread by runId",
},
args: {
runId: {
type: "positional",
description: "The run ID to kill",
},
},
async run({ args }) {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await transport.killWorkflow(args.runId);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
if (!response.ok) {
process.stderr.write(`❌ Kill failed: ${response.error}\n`);
process.exit(1);
}
process.stdout.write(`✅ Kill signal sent for run "${args.runId}".\n`);
},
});
// ---------------------------------------------------------------------------
// nerve thread (parent command)
// ---------------------------------------------------------------------------
export const threadCommand = defineCommand({
meta: {
name: "thread",
description: "Inspect and manage workflow threads (runs)",
},
subCommands: {
list: threadListCommand,
show: threadShowCommand,
inspect: threadInspectCommand,
kill: threadKillCommand,
},
});
+13 -3
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { validateAgentConfigurationLayer } from "../workflow-agent-validation.js";
import { getNerveRoot } from "../workspace.js";
export const validateCommand = defineCommand({
@@ -12,7 +13,8 @@ export const validateCommand = defineCommand({
description: "Validate nerve.yaml configuration",
},
async run() {
const configPath = join(getNerveRoot(), "nerve.yaml");
const nerveRoot = getNerveRoot();
const configPath = join(nerveRoot, "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
@@ -29,12 +31,20 @@ export const validateCommand = defineCommand({
}
const config = result.value;
const agentLayer = validateAgentConfigurationLayer(config, nerveRoot);
if (!agentLayer.ok) {
process.stderr.write(`❌ Config validation failed: ${agentLayer.message}\n`);
process.exit(1);
}
const senseCount = Object.keys(config.senses).length;
const reflexCount = config.reflexes.length;
const triggerScheduleCount = Object.values(config.senses).filter(
(s) => s.interval !== null || s.on.length > 0,
).length;
const workflowCount = Object.keys(config.workflows).length;
process.stdout.write(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${triggerScheduleCount} sense trigger schedule(s), ${workflowCount} workflow(s)\n`,
);
},
});
+169 -232
View File
@@ -1,19 +1,20 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { DaemonIpcTriggerResponse } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { stringify } from "yaml";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import { isRemoteDaemonCli } from "../cli-global.js";
import { resolveDaemonTransport } from "../daemon-client.js";
import { formatRowsAsAlignedTable } from "../sense-sqlite.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
import { getNerveRoot, isRunning } from "../workspace.js";
export const DEFAULT_PAGE_SIZE = 20;
/** Default max characters for `nerve workflow thread` output (including run header). */
/** Default max characters for `nerve thread show` output (including run header). */
export const DEFAULT_THREAD_BUDGET_CHARS = 8000;
/** Max role-round rows read from SQLite per invocation (DESC by round). */
@@ -28,11 +29,28 @@ export function getDbPath(): string {
return join(getNerveRoot(), "data", "logs.db");
}
export function formatTs(timestampMs: number): string {
return new Date(timestampMs).toISOString();
/** Human-readable placeholder when a timestamp is missing or not representable as ISO 8601. */
export const UNKNOWN_TIMESTAMP_LABEL = "(unknown)";
/**
* Format epoch milliseconds as UTC ISO 8601, or {@link UNKNOWN_TIMESTAMP_LABEL} when the value
* is nullish, not a finite number, or cannot be converted (defensive against bad DB / test data).
*/
export function formatTs(timestampMs: number | null | undefined): string {
if (timestampMs === null || timestampMs === undefined) {
return UNKNOWN_TIMESTAMP_LABEL;
}
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) {
return UNKNOWN_TIMESTAMP_LABEL;
}
try {
return new Date(timestampMs).toISOString();
} catch {
return UNKNOWN_TIMESTAMP_LABEL;
}
}
async function openStore(): Promise<LogStore> {
export async function openStore(): Promise<LogStore> {
const nerveRoot = getNerveRoot();
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
@@ -59,6 +77,8 @@ export function statusIcon(status: WorkflowRun["status"]): string {
return "🗑";
case "interrupted":
return "⚠️";
case "killed":
return "🛑";
default: {
const _exhaustive: never = status;
return `?(${_exhaustive})`;
@@ -79,7 +99,8 @@ export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | nul
*/
export function formatRunLine(run: WorkflowRun): string {
const icon = statusIcon(run.status);
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status} timestamp=${formatTs(run.timestamp)}\n`;
const exitCodeStr = run.exitCode !== null ? ` exit_code=${run.exitCode}` : "";
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status}${exitCodeStr} timestamp=${formatTs(run.timestamp)}\n`;
}
/**
@@ -122,7 +143,7 @@ export function buildListOutput(
const allFlagStr = allFlag ? " --all" : "";
paginationHint =
`\n⏩ ${remaining} more run(s) not shown. Fetch next page:\n` +
` nerve workflow list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
` nerve thread list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
}
return { lines, paginationHint };
@@ -175,14 +196,14 @@ export function buildInspectOutput(
if (remaining > 0) {
paginationHint =
`\n⏩ ${remaining} more event(s) not shown. Fetch next page:\n` +
` nerve workflow inspect ${run.runId} --offset ${offset + limit}\n`;
` nerve thread inspect ${run.runId} --offset ${offset + limit}\n`;
}
return { header, eventLines, paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow thread <runId> — agent-oriented role rounds
// nerve thread show <runId> — agent-oriented role rounds
// ---------------------------------------------------------------------------
export type PartitionedMessage = {
@@ -203,12 +224,12 @@ export function partitionWorkflowMessage(msg: {
}): PartitionedMessage {
const roleStr = msg.role;
const contentBody = msg.content;
const meta: Record<string, unknown> =
const meta =
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
? isPlainRecord(msg.meta)
? msg.meta
: (msg.meta as Record<string, unknown>)
: {};
: ({} as Record<string, unknown>);
return { roleStr, contentBody, meta };
}
@@ -249,28 +270,42 @@ function buildTruncatedSingleRound(
lines: [...prefixLines, single],
paginationHint:
hintRound > 1
? `\n⏩ Older rounds exist. Fetch with:\n nerve workflow thread ${runId} --before ${String(hintRound)}${budgetFlag}\n`
? `\n⏩ Older rounds exist. Fetch with:\n nerve thread show ${runId} --before ${String(hintRound)}${budgetFlag}\n`
: null,
};
}
/**
* Build stdout lines for `nerve workflow thread`: newest-first selection from
* Build stdout lines for `nerve thread show`: newest-first selection from
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
* When `startRow` is set (typically the persisted `__start__` frame on the first page only),
* it is formatted first and its length is subtracted from the budget before consuming `descRows`.
*/
export function buildThreadCommandOutput(
prefixLines: string[],
descRows: ThreadRoundRow[],
budgetChars: number,
runId: string,
startRow: ThreadRoundRow | null = null,
): ThreadCommandOutput {
const prefixText = prefixLines.join("");
let remaining = Math.max(0, budgetChars - prefixText.length);
const picked: ThreadRoundRow[] = [];
const leadingRoundBlocks: string[] = [];
const budgetFlag =
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
if (startRow !== null) {
const startBlock = formatThreadRoundBlock(startRow);
if (startBlock.length <= remaining) {
leadingRoundBlocks.push(startBlock);
remaining -= startBlock.length;
} else {
return buildTruncatedSingleRound(startRow, remaining, prefixLines, runId, budgetFlag);
}
}
const picked: ThreadRoundRow[] = [];
for (const row of descRows) {
const block = formatThreadRoundBlock(row);
if (block.length <= remaining) {
@@ -279,7 +314,13 @@ export function buildThreadCommandOutput(
continue;
}
if (picked.length === 0) {
return buildTruncatedSingleRound(row, remaining, prefixLines, runId, budgetFlag);
return buildTruncatedSingleRound(
row,
remaining,
[...prefixLines, ...leadingRoundBlocks],
runId,
budgetFlag,
);
}
break;
}
@@ -288,213 +329,98 @@ export function buildThreadCommandOutput(
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
let paginationHint: string | null = null;
if (shownMinRound !== null && shownMinRound > 1) {
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve workflow thread ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve thread show ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
}
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
return { lines: [...prefixLines, ...leadingRoundBlocks, ...blocksAsc], paginationHint };
}
// ---------------------------------------------------------------------------
// nerve workflow list
// nerve workflow list (reads workflow definitions from workspace YAML)
// ---------------------------------------------------------------------------
const workflowListCommand = defineCommand({
meta: {
name: "list",
description: "List active (queued/started) workflow runs",
description: "List workflow definitions from nerve.yaml",
},
args: {
all: {
type: "boolean",
description: "Include completed/failed/crashed runs",
default: false,
},
workflow: {
type: "string",
description: "Filter by workflow name",
default: "",
},
limit: {
type: "string",
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N runs (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
async run() {
const configPath = join(getNerveRoot(), "nerve.yaml");
let raw: string;
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
const runs = args.all
? getAllWorkflowRuns(store, filterWorkflow)
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
const { lines, paginationHint } = buildListOutput(
runs,
offset,
limit,
args.all,
filterWorkflow,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
raw = readFileSync(configPath, "utf8");
} catch {
process.stderr.write(`❌ Could not read ${configPath}\n`);
process.exit(1);
}
const result = parseNerveConfig(raw);
if (!result.ok) {
process.stderr.write(`❌ Config validation failed: ${result.error.message}\n`);
process.exit(1);
}
const config = result.value;
const workflowEntries = Object.entries(config.workflows);
if (workflowEntries.length === 0) {
process.stdout.write("📭 No workflows defined in nerve.yaml.\n");
return;
}
const rows = workflowEntries.map(([name, wf]) => ({
name,
concurrency: wf.concurrency,
overflow: wf.overflow,
...(wf.overflow === "queue" ? { maxQueue: wf.maxQueue } : {}),
}));
process.stdout.write(`📋 Workflow definitions (${String(rows.length)}):\n\n`);
process.stdout.write(formatRowsAsAlignedTable(rows));
},
});
// ---------------------------------------------------------------------------
// nerve workflow inspect <runId>
// nerve workflow status (daemon — registered workflows + queue depth)
// ---------------------------------------------------------------------------
const workflowInspectCommand = defineCommand({
const workflowStatusCommand = defineCommand({
meta: {
name: "inspect",
description: "Show details and thread events for a workflow run",
name: "status",
description: "Show live workflow status from the running daemon (concurrency, active, queued)",
},
args: {
runId: {
type: "positional",
description: "The run ID to inspect",
},
limit: {
type: "string",
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
default: String(DEFAULT_PAGE_SIZE),
},
offset: {
type: "string",
description: "Skip first N log entries (for pagination)",
default: "0",
},
},
async run({ args }) {
const store = await openStore();
try {
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
const offset = Math.max(0, parseIntArg(args.offset, 0));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const allLogs = store.query({ source: "workflow", refId: args.runId });
const { header, eventLines, paginationHint } = buildInspectOutput(
run,
allLogs,
offset,
limit,
);
for (const line of [...header, ...eventLines]) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
} finally {
store.close();
async run() {
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
},
});
// ---------------------------------------------------------------------------
// nerve workflow thread <runId>
// ---------------------------------------------------------------------------
const workflowThreadCommand = defineCommand({
meta: {
name: "thread",
description: "Print role rounds for a workflow run (agent-oriented, budget-limited)",
},
args: {
runId: {
type: "positional",
description: "The run ID to dump role rounds for",
},
before: {
type: "string",
description:
"Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)",
default: "0",
},
budget: {
type: "string",
description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`,
default: String(DEFAULT_THREAD_BUDGET_CHARS),
},
},
async run({ args }) {
const store = await openStore();
const transport = resolveDaemonTransport();
let workflows: Awaited<ReturnType<typeof transport.listWorkflows>>;
try {
const before = Math.max(0, parseIntArg(args.before, 0));
const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS));
const run = store.getWorkflowRun(args.runId);
if (run === null) {
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
process.exit(1);
}
const totalRoleRounds = store.getThreadRoundCount(args.runId);
if (totalRoleRounds === 0) {
process.stdout.write(
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
);
return;
}
const descRows = store.getThreadRounds(args.runId, {
before,
limit: THREAD_ROUNDS_FETCH_LIMIT,
});
const prefixLines = [
"🧵 Role rounds (workflow thread)\n",
` runId: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`,
];
const { lines, paginationHint } = buildThreadCommandOutput(
prefixLines,
descRows,
budgetChars,
args.runId,
);
for (const line of lines) {
process.stdout.write(line);
}
if (paginationHint !== null) {
process.stdout.write(paginationHint);
}
if (descRows.length === 0 && before > 0) {
process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`);
}
} finally {
store.close();
workflows = await transport.listWorkflows();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
process.exit(1);
}
const rows = workflows.map((w) => ({
name: w.name,
active: w.activeThreads,
runIds: w.activeRunIds.length > 0 ? w.activeRunIds.join(", ") : "—",
queued: w.queuedThreads,
concurrency: w.config.concurrency,
overflow: w.config.overflow,
}));
if (rows.length === 0) {
process.stdout.write("📭 No workflows in nerve.yaml (or empty registry).\n");
return;
}
process.stdout.write(`📋 Workflows (${String(rows.length)}):\n\n`);
process.stdout.write(formatRowsAsAlignedTable(rows));
},
});
@@ -502,6 +428,21 @@ const workflowThreadCommand = defineCommand({
// nerve workflow trigger <name>
// ---------------------------------------------------------------------------
function readWorkspaceDefaultMaxRounds(): number {
const configPath = join(getNerveRoot(), "nerve.yaml");
let raw: string;
try {
raw = readFileSync(configPath, "utf8");
} catch {
return DEFAULT_ENGINE_MAX_ROUNDS;
}
const result = parseNerveConfig(raw);
if (!result.ok) {
return DEFAULT_ENGINE_MAX_ROUNDS;
}
return result.value.maxRounds;
}
const workflowTriggerCommand = defineCommand({
meta: {
name: "trigger",
@@ -512,41 +453,38 @@ const workflowTriggerCommand = defineCommand({
type: "positional",
description: "The workflow name to trigger",
},
payload: {
maxRounds: {
type: "string",
description:
'JSON with optional "prompt" (string), "maxRounds" (number), and "dryRun" (boolean) for the workflow run (default: {})',
default: "{}",
description: "Max moderator rounds (default: nerve.yaml maxRounds)",
default: "",
},
prompt: {
type: "string",
description: "Initial prompt for the workflow run",
default: "",
},
dryRun: {
type: "boolean",
description: "Run the workflow in dry-run mode",
default: false,
},
},
async run({ args }) {
let triggerPayload: unknown = {};
try {
triggerPayload = JSON.parse(args.payload) as unknown;
} catch {
process.stderr.write(`❌ --payload must be valid JSON. Got: ${args.payload}\n`);
const prompt = args.prompt;
const defaultMax = readWorkspaceDefaultMaxRounds();
const maxRounds =
args.maxRounds.length > 0 ? parseIntArg(args.maxRounds, defaultMax) : defaultMax;
const dryRun = args.dryRun;
if (!isRemoteDaemonCli() && !isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
process.exit(1);
}
let prompt = "";
let maxRounds = DEFAULT_ENGINE_MAX_ROUNDS;
let dryRun = false;
if (isPlainRecord(triggerPayload)) {
const p = triggerPayload;
if (typeof p.prompt === "string") prompt = p.prompt;
if (typeof p.maxRounds === "number") maxRounds = p.maxRounds;
if (typeof p.dryRun === "boolean") dryRun = p.dryRun;
}
if (!isRunning()) {
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
process.exit(1);
}
const socketPath = getSocketPath();
let response: DaemonIpcTriggerResponse;
const transport = resolveDaemonTransport();
let response: { ok: true } | { ok: false; error: string };
try {
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds, dryRun);
response = await transport.triggerWorkflow(args.name, { prompt, maxRounds, dryRun });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
@@ -559,7 +497,7 @@ const workflowTriggerCommand = defineCommand({
}
process.stdout.write(`✅ Triggered workflow "${args.name}" via daemon.\n`);
process.stdout.write("\n💡 Inspect active runs with: nerve workflow list\n");
process.stdout.write("\n💡 Inspect active runs with: nerve thread list\n");
},
});
@@ -570,12 +508,11 @@ const workflowTriggerCommand = defineCommand({
export const workflowCommand = defineCommand({
meta: {
name: "workflow",
description: "Manage and inspect workflow runs",
description: "Manage workflow definitions and trigger workflows",
},
subCommands: {
list: workflowListCommand,
inspect: workflowInspectCommand,
thread: workflowThreadCommand,
status: workflowStatusCommand,
trigger: workflowTriggerCommand,
},
});
+2
View File
@@ -1,3 +1,5 @@
import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
import { runForegroundKernelSession } from "./run-foreground-kernel.js";
import { loadDaemonModule } from "./workspace-daemon.js";
+169 -12
View File
@@ -10,28 +10,32 @@ import type { Socket } from "node:net";
import type {
DaemonIpcListSensesResponse,
DaemonIpcListWorkflowsResponse,
DaemonIpcRequest,
DaemonIpcTriggerResponse,
DaemonTransport,
DaemonTransportTriggerResult,
DaemonTransportWorkflowLaunch,
HealthInfo,
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import { isPlainRecord } from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
import { getCliDaemonApiToken, getCliDaemonHost } from "./cli-global.js";
import { HttpTransport } from "./http-transport.js";
import { getSocketPath } from "./workspace.js";
const CONNECT_TIMEOUT_MS = 3_000;
const RESPONSE_TIMEOUT_MS = 5_000;
export type { SenseInfo };
function isSenseInfo(value: unknown): value is SenseInfo {
if (!isPlainRecord(value)) return false;
return (
typeof value.name === "string" &&
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
function parseDaemonResponse(line: string): DaemonIpcTriggerResponse {
try {
const obj: unknown = JSON.parse(line);
@@ -62,6 +66,51 @@ function parseListSensesResponse(line: string): DaemonIpcListSensesResponse {
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
function parseListWorkflowsResponse(line: string): DaemonIpcListWorkflowsResponse {
try {
const obj: unknown = JSON.parse(line);
if (isPlainRecord(obj)) {
const r = obj;
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
if (r.ok === true && Array.isArray(r.workflows) && r.workflows.every(isWorkflowStatus)) {
return { ok: true, workflows: r.workflows };
}
}
} catch {
// fall through
}
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
function parseHealthResponse(line: string): HealthInfo | null {
try {
const obj: unknown = JSON.parse(line);
if (!isPlainRecord(obj)) return null;
const r = obj;
if (r.ok === true && isPlainRecord(r.health)) {
const h = r.health;
if (
typeof h.ok === "boolean" &&
typeof h.version === "string" &&
typeof h.uptime === "number" &&
typeof h.startedAt === "string" &&
typeof h.hostname === "string"
) {
return {
ok: h.ok,
version: h.version,
uptime: h.uptime,
startedAt: h.startedAt,
hostname: h.hostname,
};
}
}
} catch {
// fall through
}
return null;
}
/**
* Connect to the daemon socket, send one JSON request (newline-terminated),
* and resolve with the first non-empty line parsed by `parseFirstLine`.
@@ -126,6 +175,78 @@ function sendAndReceive<T>(
});
}
/** Unix-socket implementation of {@link DaemonTransport} (local daemon). */
export class UnixTransport implements DaemonTransport {
readonly socketPath: string;
constructor(socketPath: string) {
this.socketPath = socketPath;
}
async health(): Promise<HealthInfo> {
const parsed = await sendAndReceive(this.socketPath, { type: "health" }, (line) =>
parseHealthResponse(line),
);
if (parsed === null) {
throw new Error("Unexpected daemon response for health");
}
return parsed;
}
async listSenses(): Promise<SenseInfo[]> {
const r = await sendAndReceive(
this.socketPath,
{ type: "list-senses" },
parseListSensesResponse,
);
if (!r.ok) {
throw new Error(r.error);
}
return r.senses;
}
async listWorkflows(): Promise<WorkflowStatus[]> {
const r = await sendAndReceive(
this.socketPath,
{ type: "list-workflows" },
parseListWorkflowsResponse,
);
if (!r.ok) {
throw new Error(r.error);
}
return r.workflows;
}
async triggerSense(name: string): Promise<DaemonTransportTriggerResult> {
return sendAndReceive(
this.socketPath,
{ type: "trigger-sense", sense: name },
parseDaemonResponse,
);
}
async triggerWorkflow(
name: string,
launch: DaemonTransportWorkflowLaunch | null,
): Promise<DaemonTransportTriggerResult> {
const prompt = launch !== null ? launch.prompt : "";
const maxRounds = launch !== null ? launch.maxRounds : DEFAULT_ENGINE_MAX_ROUNDS;
const dryRun = launch !== null ? launch.dryRun : false;
const message: DaemonIpcRequest = {
type: "trigger-workflow",
workflow: name,
prompt,
maxRounds,
dryRun,
};
return sendAndReceive(this.socketPath, message, parseDaemonResponse);
}
async killWorkflow(runId: string): Promise<DaemonTransportTriggerResult> {
const message: DaemonIpcRequest = { type: "kill-workflow", runId };
return sendAndReceive(this.socketPath, message, parseDaemonResponse);
}
}
/**
* Send a trigger-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
@@ -167,3 +288,39 @@ export function listSensesViaDaemon(socketPath: string): Promise<DaemonIpcListSe
const message: DaemonIpcRequest = { type: "list-senses" };
return sendAndReceive(socketPath, message, parseListSensesResponse);
}
/**
* Send a list-workflows message to the running daemon via its Unix socket.
*/
export function listWorkflowsViaDaemon(
socketPath: string,
): Promise<DaemonIpcListWorkflowsResponse> {
const message: DaemonIpcRequest = { type: "list-workflows" };
return sendAndReceive(socketPath, message, parseListWorkflowsResponse);
}
/**
* Send a kill-workflow message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors.
*/
export function killWorkflowViaDaemon(
socketPath: string,
runId: string,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = { type: "kill-workflow", runId };
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
/** Unix socket when no `--host`; otherwise {@link HttpTransport} for remote HTTP API. */
export function resolveDaemonTransport(): DaemonTransport {
const host = getCliDaemonHost();
if (host !== null && host.length > 0) {
const tok = getCliDaemonApiToken();
return tok !== null && tok.length > 0
? new HttpTransport({ host, token: tok })
: new HttpTransport({ host });
}
return new UnixTransport(getSocketPath());
}
export { HttpTransport } from "./http-transport.js";
+185
View File
@@ -0,0 +1,185 @@
import type {
DaemonTransport,
DaemonTransportTriggerResult,
DaemonTransportWorkflowLaunch,
HealthInfo,
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
function normalizeBaseUrl(host: string): string {
const t = host.trim();
const withScheme = t.startsWith("http://") || t.startsWith("https://") ? t : `http://${t}`;
return withScheme.endsWith("/") ? withScheme.slice(0, -1) : withScheme;
}
function isHealthInfo(value: unknown): value is HealthInfo {
if (!isPlainRecord(value)) return false;
return (
typeof value.ok === "boolean" &&
typeof value.version === "string" &&
typeof value.uptime === "number" &&
typeof value.startedAt === "string" &&
typeof value.hostname === "string"
);
}
async function readJsonBody(res: Response): Promise<unknown> {
const text = await res.text();
if (text.trim().length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function httpErrorMessage(status: number, body: unknown): string {
if (isPlainRecord(body) && body.ok === false && typeof body.error === "string") {
return body.error;
}
return `HTTP ${String(status)}`;
}
/** Remote daemon control plane via JSON HTTP API (Phase 2). */
export class HttpTransport implements DaemonTransport {
private readonly baseUrl: string;
private readonly token: string | null;
constructor(opts: { host: string; token?: string | null }) {
this.baseUrl = normalizeBaseUrl(opts.host);
this.token =
opts.token !== undefined && opts.token !== null && opts.token.length > 0 ? opts.token : null;
}
private baseHeaders(): Record<string, string> {
const h: Record<string, string> = { Accept: "application/json" };
if (this.token !== null) {
h.Authorization = `Bearer ${this.token}`;
}
return h;
}
async health(): Promise<HealthInfo> {
const res = await fetch(`${this.baseUrl}/api/health`, {
headers: this.baseHeaders(),
});
const body = await readJsonBody(res);
if (res.status === 401) {
throw new Error(httpErrorMessage(res.status, body));
}
if (!res.ok || !isHealthInfo(body)) {
throw new Error(httpErrorMessage(res.status, body));
}
return body;
}
async listSenses(): Promise<SenseInfo[]> {
const res = await fetch(`${this.baseUrl}/api/senses`, {
headers: this.baseHeaders(),
});
const body = await readJsonBody(res);
if (res.status === 401) {
throw new Error(httpErrorMessage(res.status, body));
}
if (!res.ok || !isPlainRecord(body) || !Array.isArray(body.senses)) {
throw new Error(httpErrorMessage(res.status, body));
}
if (!body.senses.every(isSenseInfo)) {
throw new Error("Unexpected senses payload from daemon HTTP API");
}
return body.senses;
}
async listWorkflows(): Promise<WorkflowStatus[]> {
const res = await fetch(`${this.baseUrl}/api/workflows`, {
headers: this.baseHeaders(),
});
const body = await readJsonBody(res);
if (res.status === 401) {
throw new Error(httpErrorMessage(res.status, body));
}
if (!res.ok || !isPlainRecord(body) || !Array.isArray(body.workflows)) {
throw new Error(httpErrorMessage(res.status, body));
}
if (!body.workflows.every(isWorkflowStatus)) {
throw new Error("Unexpected workflows payload from daemon HTTP API");
}
return body.workflows;
}
async triggerSense(name: string): Promise<DaemonTransportTriggerResult> {
const res = await fetch(`${this.baseUrl}/api/trigger-sense`, {
method: "POST",
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const body = await readJsonBody(res);
if (res.status === 401) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (!isPlainRecord(body)) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (body.ok === true) return { ok: true };
if (body.ok === false && typeof body.error === "string")
return { ok: false, error: body.error };
return { ok: false, error: httpErrorMessage(res.status, body) };
}
async triggerWorkflow(
name: string,
launch: DaemonTransportWorkflowLaunch | null,
): Promise<DaemonTransportTriggerResult> {
const L =
launch !== null
? launch
: { prompt: "", maxRounds: DEFAULT_ENGINE_MAX_ROUNDS, dryRun: false };
const res = await fetch(`${this.baseUrl}/api/trigger-workflow`, {
method: "POST",
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
name,
prompt: L.prompt,
maxRounds: L.maxRounds,
dryRun: L.dryRun,
}),
});
const body = await readJsonBody(res);
if (res.status === 401) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (!isPlainRecord(body)) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (body.ok === true) return { ok: true };
if (body.ok === false && typeof body.error === "string")
return { ok: false, error: body.error };
return { ok: false, error: httpErrorMessage(res.status, body) };
}
async killWorkflow(runId: string): Promise<DaemonTransportTriggerResult> {
const res = await fetch(`${this.baseUrl}/api/kill-workflow`, {
method: "POST",
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ runId }),
});
const body = await readJsonBody(res);
if (res.status === 401) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (!isPlainRecord(body)) {
return { ok: false, error: httpErrorMessage(res.status, body) };
}
if (body.ok === true) return { ok: true };
if (body.ok === false && typeof body.error === "string")
return { ok: false, error: body.error };
return { ok: false, error: httpErrorMessage(res.status, body) };
}
}
@@ -0,0 +1,88 @@
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
export type MarkdownChunk = {
slug: string;
text: string;
};
function slugPart(title: string): string {
const t = title.trim().toLowerCase().replace(/\s+/g, "-");
const safe = t.replace(/[^a-z0-9_-]+/g, "");
return safe.length > 0 ? safe : "section";
}
function splitLargeMarkdownChunk(slugBase: string, text: string): MarkdownChunk[] {
const maxParagraphs = 24;
const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
if (paragraphs.length <= maxParagraphs) {
return [{ slug: slugBase, text }];
}
const chunks: MarkdownChunk[] = [];
let part = 0;
for (let i = 0; i < paragraphs.length; i += maxParagraphs) {
const slice = paragraphs.slice(i, i + maxParagraphs).join("\n\n");
chunks.push({ slug: `${slugBase}-part${String(part)}`, text: slice });
part += 1;
}
return chunks;
}
function headingLineIndices(lines: string[]): number[] {
const headingIdx: number[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && HEADING_RE.test(line)) {
headingIdx.push(i);
}
}
return headingIdx;
}
function chunksFromHeadings(
lines: string[],
headingIdx: number[],
baseSlug: string,
): MarkdownChunk[] {
const chunks: MarkdownChunk[] = [];
const firstHead = headingIdx[0] ?? 0;
if (firstHead > 0) {
const preamble = lines.slice(0, firstHead).join("\n").trim();
if (preamble.length > 0) {
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#preamble`, preamble));
}
}
for (let h = 0; h < headingIdx.length; h++) {
const start = headingIdx[h] ?? 0;
const end = h + 1 < headingIdx.length ? (headingIdx[h + 1] ?? lines.length) : lines.length;
const block = lines.slice(start, end).join("\n").trim();
if (block.length === 0) {
continue;
}
const titleLine = lines[start] ?? "";
const ht = HEADING_RE.exec(titleLine);
const suffix = ht !== null ? slugPart(ht[2] ?? "h") : `h${String(h)}`;
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#${suffix}-${String(h)}`, block));
}
return chunks;
}
/**
* Split Markdown by headings; long sections are split further by blank-line paragraphs.
*/
export function chunkMarkdown(relativePath: string, source: string): MarkdownChunk[] {
const lines = source.split(/\r?\n/);
const headingIdx = headingLineIndices(lines);
const baseSlug = relativePath.replace(/\//g, "-");
if (headingIdx.length === 0) {
const text = source.trim();
if (text.length === 0) {
return [];
}
return splitLargeMarkdownChunk(`${baseSlug}#doc`, text);
}
const chunks = chunksFromHeadings(lines, headingIdx, baseSlug);
return chunks;
}
@@ -0,0 +1,87 @@
export type TsJsChunk = {
slug: string;
text: string;
};
/**
* Line starts a function-like declaration (heuristic, no full TS parse).
*/
function isFunctionStartLine(line: string): boolean {
const t = line.trimStart();
if (/^(export\s+)?declare\s+/.test(t)) {
return false;
}
if (/^(export\s+)?(async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/.test(t)) {
return true;
}
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?\(/.test(t)) {
return true;
}
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*async\s+function/.test(t)) {
return true;
}
return false;
}
function slugPart(name: string): string {
const safe = name.replace(/[^\w$-]+/g, "-").toLowerCase();
return safe.length > 0 ? safe : "block";
}
function extractRoughName(firstLine: string): string {
const m =
/function\s+([A-Za-z_$][\w$]*)/.exec(firstLine) ?? /const\s+([A-Za-z_$][\w$]*)/.exec(firstLine);
return m !== null && m[1] !== undefined ? m[1] : "fn";
}
/**
* Split `.ts` / `.js` by top-level function-like lines; falls back to paragraph chunks.
*/
export function chunkTypeScriptOrJavaScript(relativePath: string, source: string): TsJsChunk[] {
const baseSlug = relativePath.replace(/\./g, "-").replace(/\//g, "-");
const lines = source.split(/\r?\n/);
const starts: number[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line !== undefined && isFunctionStartLine(line)) {
starts.push(i);
}
}
if (starts.length === 0) {
return paragraphFallbackChunks(baseSlug, source);
}
const chunks: TsJsChunk[] = [];
for (let s = 0; s < starts.length; s++) {
const start = starts[s] ?? 0;
const end = s + 1 < starts.length ? (starts[s + 1] ?? lines.length) : lines.length;
const block = lines.slice(start, end).join("\n").trim();
if (block.length === 0) {
continue;
}
const first = lines[start] ?? "";
const name = extractRoughName(first);
chunks.push({
slug: `${baseSlug}#${slugPart(name)}-${String(s)}`,
text: block,
});
}
return chunks.length > 0 ? chunks : paragraphFallbackChunks(baseSlug, source);
}
function paragraphFallbackChunks(baseSlug: string, source: string): TsJsChunk[] {
const text = source.trim();
if (text.length === 0) {
return [];
}
const parts = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
if (parts.length === 0) {
return [{ slug: `${baseSlug}#0`, text }];
}
return parts.map((p, i) => ({
slug: `${baseSlug}#para-${String(i)}`,
text: p.trim(),
}));
}
+23
View File
@@ -0,0 +1,23 @@
import { chunkMarkdown } from "./chunk-markdown.js";
import { chunkTypeScriptOrJavaScript } from "./chunk-typescript.js";
export type KnowledgeChunk = {
slug: string;
text: string;
};
export function chunkKnowledgeFile(relativePath: string, source: string): KnowledgeChunk[] {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".md")) {
return chunkMarkdown(relativePath, source);
}
if (
lower.endsWith(".ts") ||
lower.endsWith(".tsx") ||
lower.endsWith(".js") ||
lower.endsWith(".jsx")
) {
return chunkTypeScriptOrJavaScript(relativePath, source);
}
return [{ slug: `${relativePath.replace(/\//g, "-")}#0`, text: source.trim() }];
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Remote embedding service client — calls embed.shazhou.workers.dev
* for real vector embeddings. Falls back to fake hash-based embeddings
* if credentials are not configured.
*/
type EmbedResponse = {
embeddings: number[][];
model: string;
dimensions: number;
cached: boolean[];
};
export type EmbedServiceConfig = {
url: string;
token: string;
};
/**
* Resolve embed service config from environment or cfg.
* Returns null if not configured (will fall back to placeholder).
*/
export function resolveEmbedConfig(): EmbedServiceConfig | null {
const url = process.env.EMBED_SERVICE_URL ?? null;
const token = process.env.EMBED_AUTH_TOKEN ?? null;
if (url === null || token === null) {
return null;
}
return { url, token };
}
const BATCH_SIZE = 100;
/**
* Call remote embedding service. Batches texts in groups of 100.
* Returns Float32Array per text (stored as Buffer for SQLite BLOB).
*/
export async function embedTexts(config: EmbedServiceConfig, texts: string[]): Promise<Buffer[]> {
const results: Buffer[] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const resp = await fetch(`${config.url}/embed`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.token}`,
},
body: JSON.stringify({ texts: batch }),
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Embed service error ${String(resp.status)}: ${body}`);
}
const data = (await resp.json()) as EmbedResponse;
for (const vec of data.embeddings) {
const buf = Buffer.alloc(vec.length * 4);
for (let j = 0; j < vec.length; j++) {
buf.writeFloatLE(vec[j] as number, j * 4);
}
results.push(buf);
}
}
return results;
}
/**
* Embed a single text (for query). Returns Float32Array as Buffer.
*/
export async function embedQuery(config: EmbedServiceConfig, text: string): Promise<Buffer> {
const results = await embedTexts(config, [text]);
const first = results[0];
if (first === undefined) {
throw new Error("Embed service returned empty result");
}
return first;
}
/**
* Cosine similarity between two embedding buffers (Float32LE encoded).
*/
export function cosineSimilarity(a: Buffer, b: Buffer): number {
const len = Math.min(a.length, b.length) / 4;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < len; i++) {
const va = a.readFloatLE(i * 4);
const vb = b.readFloatLE(i * 4);
dot += va * vb;
normA += va * va;
normB += vb * vb;
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
if (denom === 0) return 0;
return dot / denom;
}
@@ -0,0 +1,19 @@
import picomatch from "picomatch";
const PICOMATCH_OPTS = { dot: true } as const;
/**
* True if `relativePosixPath` matches any exclude glob (POSIX slashes).
*/
export function matchesKnowledgeExclude(
relativePosixPath: string,
excludePatterns: ReadonlyArray<string>,
): boolean {
for (const pattern of excludePatterns) {
const isMatch = picomatch(pattern, PICOMATCH_OPTS);
if (isMatch(relativePosixPath)) {
return true;
}
}
return false;
}
@@ -0,0 +1,7 @@
import { createHash } from "node:crypto";
/** Deterministic placeholder embedding bytes until a remote embedding service exists (RFC-003). */
export function fakeEmbeddingBytes(text: string): Buffer {
const hash = createHash("sha256").update(text, "utf8").digest();
return Buffer.concat([hash, hash, hash, hash]);
}
+39
View File
@@ -0,0 +1,39 @@
import { globSync, statSync } from "node:fs";
import { join } from "node:path";
import type { KnowledgeConfig } from "@uncaged/nerve-core";
import { matchesKnowledgeExclude } from "./exclude-match.js";
function toPosix(rel: string): string {
return rel.split("\\").join("/");
}
function isFileUnderRoot(repoRoot: string, rel: string): boolean {
try {
return statSync(join(repoRoot, rel)).isFile();
} catch {
return false;
}
}
/**
* Files matched by `include` globs minus `exclude` globs, relative POSIX paths, sorted.
*/
export function listKnowledgeFiles(repoRoot: string, config: KnowledgeConfig): string[] {
const matched = new Set<string>();
for (const pattern of config.include) {
const paths = globSync(pattern, { cwd: repoRoot });
for (const rel of paths) {
const posix = toPosix(rel);
if (!isFileUnderRoot(repoRoot, posix)) {
continue;
}
if (matchesKnowledgeExclude(posix, config.exclude)) {
continue;
}
matched.add(posix);
}
}
return [...matched].sort();
}
@@ -0,0 +1,95 @@
import { createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
export type KnowledgeChunkRow = {
path: string;
slug: string;
chunkIndex: number;
text: string;
embedding: Buffer;
contentHash: string;
};
export type KnowledgeChunkInsert = {
path: string;
slug: string;
chunkIndex: number;
text: string;
contentHash: string;
embedding: Buffer;
};
const SCHEMA = `
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
slug TEXT NOT NULL,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
content_hash TEXT NOT NULL,
UNIQUE(path, chunk_index)
);
CREATE INDEX IF NOT EXISTS idx_chunks_path ON chunks(path);
`;
export function openKnowledgeDb(dbPath: string): DatabaseSync {
const db = new DatabaseSync(dbPath);
db.exec(SCHEMA);
return db;
}
export function contentHash(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
export function replaceAllChunks(db: DatabaseSync, rows: KnowledgeChunkInsert[]): void {
db.exec("BEGIN IMMEDIATE");
try {
db.prepare("DELETE FROM chunks").run();
const insert = db.prepare(
`INSERT INTO chunks (path, chunk_index, slug, text, embedding, content_hash)
VALUES (@path, @chunk_index, @slug, @text, @embedding, @content_hash)`,
);
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row === undefined) continue;
const emb = row.embedding;
insert.run({
path: row.path,
chunk_index: row.chunkIndex,
slug: row.slug,
text: row.text,
embedding: emb,
content_hash: row.contentHash,
});
}
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
export function loadAllChunks(db: DatabaseSync): KnowledgeChunkRow[] {
const stmt = db.prepare(
"SELECT path, chunk_index, slug, text, embedding, content_hash FROM chunks ORDER BY path, chunk_index",
);
const rows = stmt.all() as Array<{
path: string;
chunk_index: number;
slug: string;
text: string;
embedding: Buffer | Uint8Array;
content_hash: string;
}>;
return rows.map((r) => ({
path: r.path,
slug: r.slug,
chunkIndex: r.chunk_index,
text: r.text,
embedding: Buffer.from(r.embedding),
contentHash: r.content_hash,
}));
}
+2
View File
@@ -0,0 +1,2 @@
export const KNOWLEDGE_YAML = "knowledge.yaml";
export const KNOWLEDGE_DB = "knowledge.db";
+13
View File
@@ -0,0 +1,13 @@
/**
* `-r` and `-g` are mutually exclusive for `nerve knowledge query`.
*/
export function knowledgeQueryScopeConflictMessage(
repoFlag: string | null | undefined,
globalFlag: boolean,
): string | null {
const hasR = repoFlag !== undefined && repoFlag !== null && String(repoFlag).trim().length > 0;
if (hasR && globalFlag) {
return "❌ Use either -r <path> or -g, not both.";
}
return null;
}
+116
View File
@@ -0,0 +1,116 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
type EmbedServiceConfig,
cosineSimilarity,
embedQuery,
resolveEmbedConfig,
} from "./embed-service.js";
import type { KnowledgeChunkRow } from "./knowledge-db.js";
import { loadAllChunks, openKnowledgeDb } from "./knowledge-db.js";
import { wordOverlapScore } from "./word-overlap.js";
export type KnowledgeQueryHit = {
repoRoot: string | null;
path: string;
slug: string;
text: string;
score: number;
};
function rankChunksByCosine(
queryEmbedding: Buffer,
chunks: KnowledgeChunkRow[],
limit: number,
): Array<{ chunk: KnowledgeChunkRow; score: number }> {
const scored = chunks.map((chunk) => ({
chunk,
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit);
}
function rankChunksByWordOverlap(
query: string,
chunks: KnowledgeChunkRow[],
limit: number,
): Array<{ chunk: KnowledgeChunkRow; score: number }> {
const scored = chunks.map((chunk) => ({
chunk,
score: wordOverlapScore(query, `${chunk.text}\n${chunk.path}`),
}));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit);
}
async function rankChunks(
queryText: string,
chunks: KnowledgeChunkRow[],
limit: number,
embedConfig: EmbedServiceConfig | null,
): Promise<Array<{ chunk: KnowledgeChunkRow; score: number }>> {
if (embedConfig !== null) {
const queryVec = await embedQuery(embedConfig, queryText);
return rankChunksByCosine(queryVec, chunks, limit);
}
return rankChunksByWordOverlap(queryText, chunks, limit);
}
export async function queryKnowledgeRepo(
repoRoot: string,
dbPath: string,
queryText: string,
limit: number,
): Promise<KnowledgeQueryHit[]> {
const embedConfig = resolveEmbedConfig();
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = await rankChunks(queryText, rows, limit, embedConfig);
return ranked.map((r) => ({
repoRoot,
path: r.chunk.path,
slug: r.chunk.slug,
text: r.chunk.text,
score: r.score,
}));
} finally {
db.close();
}
}
export async function queryKnowledgeGlobal(
repoRoots: ReadonlyArray<string>,
dbFileName: string,
queryText: string,
limit: number,
): Promise<KnowledgeQueryHit[]> {
const embedConfig = resolveEmbedConfig();
const combined: KnowledgeQueryHit[] = [];
for (const root of repoRoots) {
const dbPath = join(root, dbFileName);
if (!existsSync(dbPath)) {
continue;
}
const db = openKnowledgeDb(dbPath);
try {
const rows = loadAllChunks(db);
const ranked = await rankChunks(queryText, rows, limit, embedConfig);
for (const r of ranked) {
combined.push({
repoRoot: root,
path: r.chunk.path,
slug: r.chunk.slug,
text: r.chunk.text,
score: r.score,
});
}
} finally {
db.close();
}
}
combined.sort((a, b) => b.score - a.score);
return combined.slice(0, limit);
}
+60
View File
@@ -0,0 +1,60 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getNerveRoot } from "../workspace.js";
export type KnowledgeRepoRegistry = {
roots: ReadonlyArray<string>;
};
const FILE_NAME = "knowledge-repos.json";
/** When `nerveHome` is omitted, uses `~/.uncaged-nerve`. */
export function getKnowledgeRegistryPath(nerveHome: string | null = null): string {
const root = nerveHome ?? getNerveRoot();
return join(root, "data", FILE_NAME);
}
function defaultRegistry(): KnowledgeRepoRegistry {
return { roots: [] };
}
export function readKnowledgeRegistry(nerveHome: string | null = null): KnowledgeRepoRegistry {
const path = getKnowledgeRegistryPath(nerveHome);
try {
const raw = readFileSync(path, "utf8");
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
"roots" in parsed &&
Array.isArray(parsed.roots)
) {
const roots = parsed.roots.filter((x): x is string => typeof x === "string");
return { roots: [...new Set(roots)].sort() };
}
} catch {
// missing or invalid — treat as empty
}
return defaultRegistry();
}
export function registerKnowledgeRepoRoot(
repoRootAbsolute: string,
nerveHome: string | null = null,
): void {
const resolved = repoRootAbsolute.trim();
if (resolved.length === 0) {
return;
}
const prev = readKnowledgeRegistry(nerveHome);
const nextRoots = [...new Set([...prev.roots, resolved])].sort();
const next: KnowledgeRepoRegistry = { roots: nextRoots };
const path = getKnowledgeRegistryPath(nerveHome);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
export function listRegisteredKnowledgeRoots(nerveHome: string | null = null): string[] {
return [...readKnowledgeRegistry(nerveHome).roots];
}
+21
View File
@@ -0,0 +1,21 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { KNOWLEDGE_YAML } from "./paths.js";
/**
* Walk upward from `startDir` until `knowledge.yaml` exists.
*/
export function findKnowledgeRepoRoot(startDir: string): string | null {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, KNOWLEDGE_YAML))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { type KnowledgeConfig, parseKnowledgeYaml } from "@uncaged/nerve-core";
import { chunkKnowledgeFile } from "./chunk.js";
import { type EmbedServiceConfig, embedTexts, resolveEmbedConfig } from "./embed-service.js";
import { fakeEmbeddingBytes } from "./fake-embedding.js";
import { listKnowledgeFiles } from "./glob-files.js";
import type { KnowledgeChunkInsert } from "./knowledge-db.js";
import { contentHash, openKnowledgeDb, replaceAllChunks } from "./knowledge-db.js";
import { KNOWLEDGE_DB, KNOWLEDGE_YAML } from "./paths.js";
import { registerKnowledgeRepoRoot } from "./registry.js";
export type KnowledgeSyncResult = {
repoRoot: string;
dbPath: string;
filesIndexed: number;
chunksWritten: number;
embeddingSource: "remote" | "placeholder";
};
function loadConfig(repoRoot: string): KnowledgeConfig {
const raw = readFileSync(join(repoRoot, KNOWLEDGE_YAML), "utf8");
const parsed = parseKnowledgeYaml(raw);
if (!parsed.ok) {
throw parsed.error;
}
return parsed.value;
}
async function computeEmbeddings(
texts: string[],
embedConfig: EmbedServiceConfig | null,
): Promise<{ buffers: Buffer[]; source: "remote" | "placeholder" }> {
if (embedConfig !== null) {
const buffers = await embedTexts(embedConfig, texts);
return { buffers, source: "remote" };
}
// Fallback to placeholder when embed service is not configured
const buffers = texts.map((t) => fakeEmbeddingBytes(t));
return { buffers, source: "placeholder" };
}
/**
* @param nerveHomeForRegistry — when set, registers this repo under that Nerve home (for tests); default writes `~/.uncaged-nerve/data/knowledge-repos.json`.
*/
export async function runKnowledgeSync(
repoRoot: string,
nerveHomeForRegistry: string | null = null,
): Promise<KnowledgeSyncResult> {
const config = loadConfig(repoRoot);
const relFiles = listKnowledgeFiles(repoRoot, config);
const preInserts: Array<{
path: string;
slug: string;
chunkIndex: number;
text: string;
hash: string;
}> = [];
for (const rel of relFiles) {
const abs = join(repoRoot, rel);
const source = readFileSync(abs, "utf8");
const chunks = chunkKnowledgeFile(rel, source);
for (let i = 0; i < chunks.length; i++) {
const ch = chunks[i];
if (ch === undefined) continue;
preInserts.push({
path: rel,
slug: ch.slug,
chunkIndex: i,
text: ch.text,
hash: contentHash(ch.text),
});
}
}
// Compute embeddings (remote or placeholder)
const embedConfig = resolveEmbedConfig();
const texts = preInserts.map((p) => p.text);
const { buffers, source } = await computeEmbeddings(texts, embedConfig);
const inserts: KnowledgeChunkInsert[] = preInserts.map((p, idx) => ({
path: p.path,
slug: p.slug,
chunkIndex: p.chunkIndex,
text: p.text,
contentHash: p.hash,
embedding: buffers[idx] ?? fakeEmbeddingBytes(p.text),
}));
const dbPath = join(repoRoot, KNOWLEDGE_DB);
const db = openKnowledgeDb(dbPath);
try {
replaceAllChunks(db, inserts);
} finally {
db.close();
}
registerKnowledgeRepoRoot(repoRoot, nerveHomeForRegistry);
return {
repoRoot,
dbPath,
filesIndexed: relFiles.length,
chunksWritten: inserts.length,
embeddingSource: source,
};
}
@@ -0,0 +1,26 @@
function tokenize(s: string): Set<string> {
const parts = s
.toLowerCase()
.split(/[^\w]+/)
.filter((x) => x.length > 0);
return new Set(parts);
}
/**
* Jaccard-like score over word sets (placeholder until real embeddings; RFC-003).
*/
export function wordOverlapScore(query: string, document: string): number {
const q = tokenize(query);
const d = tokenize(document);
if (q.size === 0) {
return 0;
}
let inter = 0;
for (const w of q) {
if (d.has(w)) {
inter += 1;
}
}
const union = q.size + d.size - inter;
return union === 0 ? 0 : inter / union;
}

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