diff --git a/docs/plans/2026-04-20-001-feat-native-person-primitive-plan.md b/docs/plans/2026-04-20-001-feat-native-person-primitive-plan.md new file mode 100644 index 0000000..69275b4 --- /dev/null +++ b/docs/plans/2026-04-20-001-feat-native-person-primitive-plan.md @@ -0,0 +1,439 @@ +--- +title: feat: Native person primitive and agent-native primitive surfaces +type: feat +status: active +date: 2026-04-20 +--- + +# feat: Native person primitive and agent-native primitive surfaces + +## Overview + +Promote `person` into the retained native primitive model, give it a strong canonical markdown/frontmatter schema for contact and relationship data, and rebuild the primitive surface so kernel, CLI, and MCP all speak the same clean language: + +- primitive **types** are schemas/templates in the registry +- primitive **instances** are canonical markdown entries in workspace folders +- CLI and MCP expose the same type/instance contract, with ergonomic domain overlays for agent workflows + +The result should let agents and humans create, traverse, update, archive, and relate people naturally while preserving markdown/frontmatter as the source of truth and keeping CLI/MCP as thin layers over kernel behavior. + +## Problem Frame + +The repo already has a broader primitive catalog than its currently retained built-in set. `person`, `client`, and `project` are already modeled in `packages/kernel/src/registry.ts`, but fresh workspaces do not retain them, so they are missing from the active canonical workspace model. At the same time, the current naming and surface design blur the distinction between primitive schemas and primitive instances. That ambiguity makes it harder to build a native, agent-friendly CLI/MCP experience and harder to reason about what the canonical data model actually is. + +We need a cleaner primitive architecture that treats people as first-class canonical context, clarifies the schema-vs-instance split, and exposes agent-native CRUD/query/discovery paths across CLI and MCP without creating duplicate domain logic or a sprawl of bespoke one-off commands. + +## Requirements Trace + +- R1. `person` is a retained native primitive in fresh and existing workspaces. +- R2. `person` instances are canonical markdown files in `people/*.md` with robust contact-oriented frontmatter and optional narrative body. +- R3. The system clearly distinguishes primitive **types** from primitive **instances** in kernel, CLI, MCP, and docs. +- R4. CLI and MCP share one canonical primitive contract for type discovery, schema inspection, and instance CRUD/query flows. +- R5. Agent-native ergonomics are preserved through deterministic JSON/structured outputs, stable naming, and clean permission/error semantics. +- R6. Domain-specific affordances for people are available where they add UX value, but they remain thin overlays on the canonical primitive contract. +- R7. Supporting retained primitives needed for `person` relationships are handled explicitly rather than left as dangling refs. +- R8. Existing published-surface compatibility is preserved where reasonable through aliases and staged cleanup. + +## Scope Boundaries + +- This plan does not re-open the entire historical primitive catalog as retained native scope. +- This plan does not redesign thread/workflow lifecycle semantics for entity primitives like `person`. +- This plan does not introduce a new storage backend; markdown/frontmatter remains canonical. +- This plan does not move policy logic out of kernel. + +### Deferred to Separate Tasks + +- Additional domain-specific retained primitives beyond `person`, `client`, and `project` if future product scope requires them. +- Rich UI surfaces over people/projects if a web or desktop control-plane is introduced later. + +## Context & Research + +### Relevant Code and Patterns + +- `packages/kernel/src/registry.ts` already defines `person`, `client`, and `project`, but they are excluded from `RETAINED_BUILT_IN_TYPE_NAMES`. +- `packages/kernel/src/store.ts` is the canonical primitive-instance CRUD layer and already handles defaults, validation, `etag`, `_wg_type`, and archive semantics. +- `packages/kernel/src/bases.ts` currently derives canonicality from `builtIn`, which overloads schema origin and retained/canonical meaning. +- `packages/kernel/src/workspace.ts` and `packages/kernel/src/starter-kit.ts` control fresh-workspace convergence. +- `packages/cli/src/cli.ts` currently treats `primitive` as a mixed schema/instance noun and has no dedicated CLI parity for schema/get/delete flows. +- `packages/mcp-server/src/mcp/tools/primitive-tools.ts` already provides a generic instance CRUD surface and is the strongest current pattern for agent-native primitive access. +- `packages/mcp-server/src/mcp/result.ts` and `packages/mcp-server/src/mcp/tools/collaboration-tools.ts` provide the best existing pattern for structured MCP outputs and retry-safe write behavior. + +### Institutional Learnings + +- Repo architecture documents consistently require one kernel contract with thin adapters and markdown/frontmatter as canonical truth. +- Current MCP tests already assume `person` is available through generic primitive CRUD, which suggests the intended end state is generic primitive parity first, not a wholly separate people subsystem. +- There is no existing `docs/solutions/` corpus to constrain the design, so the plan should lean on repo docs, tests, and current package boundaries. + +### External References + +- MCP docs and current SDK guidance favor stable capability-scoped tools, structured outputs, and a clear split between resources and tools. +- Agent-native design guidance favors generic CRUD as the platform contract, with domain aliases as thin ergonomic overlays rather than the only access path. +- CLI best practices for large multi-entity systems favor noun-first grouping with deterministic machine-readable outputs. + +## Key Technical Decisions + +- **Introduce an explicit retained/canonical dimension for primitive types.** `builtIn` should continue to answer "shipped by the product" while a new flag answers "retained as canonical in live workspaces". This removes the current overload where `builtIn` also drives manifest/bases generation. +- **Model type and instance as separate first-class concepts.** Type/schema operations should be visibly separate from instance CRUD/query operations in kernel terminology, CLI nouns, MCP tools/resources, and docs. +- **Promote `person` into the retained native set and promote `client`/`project` only where needed to support valid native refs.** This avoids dangling relationship fields and keeps the retained set intentional. +- **Keep generic primitive CRUD as the canonical API surface.** Domain-specific people commands/tools should compile to the same kernel/store contract rather than inventing a second storage path. +- **Use domain overlays only where they materially improve vocabulary or agent UX.** A `person` noun is valuable; a completely separate people-only model is not. +- **Preserve markdown/frontmatter instances as canonical truth.** `people/*.md`, `clients/*.md`, and `projects/*.md` remain the real graph nodes, not generated templates or side indexes. +- **Unify CLI/MCP naming and output semantics around the same conceptual layers.** Type discovery, schema inspection, CRUD, query, and archive behavior should feel equivalent across both interfaces. +- **Normalize permission handling through kernel authorization, not interface-specific policy inventions.** CLI and MCP may differ in transport/auth context, but capability and mutation semantics should come from the same kernel rules. + +## Open Questions + +### Resolved During Planning + +- **Should `person` be modeled as a built-in primitive type or a bespoke subsystem?** It should be a retained native primitive type. +- **What is canonical: templates or entries?** Primitive instances (markdown entries) are canonical; registry/types and `.base` files are metadata/derived artifacts. +- **Should CLI/MCP expose only person-specific commands?** No. Generic primitive type/instance surfaces stay canonical; person-specific affordances are overlays. +- **Should markdown/frontmatter remain the source of truth?** Yes. + +### Deferred to Implementation + +- **Exact final `person` field set:** planning can define the shape and rationale, but exact field names like `preferred_name`, `timezone`, `social_handles`, or `postal_address` may need one final normalization pass during implementation review. +- **Whether `person` should immediately appear in `orientation.brief()`, `companyContext()`, and built-in lenses:** the plan will structure for it, but the implementation can decide whether to land this in the first rollout or in a tightly-coupled follow-up unit. +- **Which compatibility aliases should be permanent vs transitional:** this depends on package-maintenance appetite once implementation reveals blast radius. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```text +Primitive Type Layer + registry.json + -> retained/canonical type metadata + -> field definitions, directories, ref constraints + +Primitive Instance Layer + people/*.md, projects/*.md, clients/*.md + -> frontmatter fields + -> markdown body + -> validated by store.ts + +Kernel Contract + registry: list/get/define/extend retained types + store: create/get/update/delete/query/archive primitive instances + auth/policy: mutation authorization for both CLI and MCP + +CLI Surface + primitive-type ... + primitive ... + person ... + -> thin wrappers over kernel + -> deterministic --json outputs + +MCP Surface + resources: registry/type schema/reference context + tools: primitive_type_*, primitive_*, person_* aliases + -> structuredContent + outputSchema + -> retry-safe writes where needed +``` + +## Implementation Units + +- [ ] **Unit 1: Retained primitive model cleanup in kernel** + +**Goal:** Separate primitive schema origin from retained/canonical workspace presence, then promote `person` into the retained native set with supporting retained relationships. + +**Requirements:** R1, R2, R3, R7 + +**Dependencies:** None + +**Files:** +- Modify: `packages/kernel/src/registry.ts` +- Modify: `packages/kernel/src/types.ts` +- Modify: `packages/kernel/src/bases.ts` +- Test: `packages/kernel/src/registry.test.ts` +- Test: `packages/kernel/src/bases.test.ts` + +**Approach:** +- Introduce explicit retained/canonical metadata for primitive types instead of deriving canonicality from `builtIn`. +- Promote `person` into the retained native set and explicitly decide whether `client` and `project` must also become retained to satisfy ref validation cleanly. +- Refine the native `person` schema to include a richer but disciplined contact/relationship frontmatter shape while preserving markdown body support. +- Keep directories entity-scoped (`people/`, `clients/`, `projects/`) to avoid `_wg_type` coupling unless a deliberate shared-directory decision emerges. + +**Patterns to follow:** +- `packages/kernel/src/store.ts` for type-driven validation/defaults +- `packages/kernel/src/registry.ts` built-in type pattern +- `packages/kernel/src/bases.ts` manifest and `.base` generation + +**Test scenarios:** +- Happy path: fresh registry seed includes `person` as retained/canonical and exposes its schema metadata. +- Happy path: `person` schema includes required contact fields/defaults without breaking existing retained type definitions. +- Edge case: existing workspace registry missing `person` is upgraded idempotently without duplicating or corrupting built-in entries. +- Edge case: retained/canonical metadata for built-ins and runtime-defined types remains distinguishable. +- Error path: redefining retained built-ins still fails with clear errors. +- Integration: primitive registry manifest and generated `.base` files include retained `person` consistently. + +**Verification:** +- Fresh and upgraded workspaces both expose `person` natively through the registry and bases pipeline. + +- [ ] **Unit 2: Workspace bootstrap and migration convergence** + +**Goal:** Ensure new and existing workspaces converge on the native retained primitive set and supporting docs/generated artifacts. + +**Requirements:** R1, R2, R7 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `packages/kernel/src/workspace.ts` +- Modify: `packages/kernel/src/starter-kit.ts` +- Test: `packages/kernel/src/workspace.test.ts` + +**Approach:** +- Update workspace initialization so retained primitive metadata, directories, manifests, and bases are created consistently. +- Decide whether starter content should seed example `person`/`client`/`project` docs or only directories and schema visibility. +- Ensure registry refresh on existing workspaces is additive/idempotent and does not rewrite user content unnecessarily. + +**Patterns to follow:** +- `packages/kernel/src/workspace.ts` init flow +- `packages/kernel/src/starter-kit.ts` seeded type pattern + +**Test scenarios:** +- Happy path: new workspace initialization creates retained primitive directories and manifest/bases entries for `person`. +- Happy path: existing workspace upgrade adds missing retained primitive metadata without deleting user-created docs. +- Edge case: init reruns remain idempotent. +- Integration: workspace init plus registry save/load yields the same retained primitive inventory on repeat runs. + +**Verification:** +- A workspace created from scratch or reopened later exposes the same retained primitive model. + +- [ ] **Unit 3: Kernel primitive instance semantics for people context** + +**Goal:** Finalize how `person` instances behave as canonical graph nodes, including relationship validation and optional read-model integration. + +**Requirements:** R2, R4, R7 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `packages/kernel/src/store.ts` +- Modify: `packages/kernel/src/orientation.ts` +- Modify: `packages/kernel/src/lens.ts` +- Modify: `packages/kernel/src/context-graph-contract.ts` +- Test: `packages/kernel/src/store.test.ts` +- Test: `packages/kernel/src/orientation.test.ts` +- Test: `packages/kernel/src/lens.test.ts` +- Test: `packages/kernel/src/context-graph-contract.test.ts` + +**Approach:** +- Keep `store.ts` as the only canonical instance CRUD path and ensure `person` fields/refs validate cleanly. +- Decide whether `person` should participate in default company-context and lens outputs immediately or whether those read models should remain thread-centric in the first pass. +- If promoted into the default context graph contract, update contract invariants and snapshots deliberately instead of letting them drift implicitly. + +**Execution note:** Start with characterization coverage for current retained-registry/context behavior before changing default context outputs. + +**Patterns to follow:** +- `packages/kernel/src/store.ts` instance CRUD and ref validation +- `packages/kernel/src/orientation.ts` context summary pattern +- `packages/kernel/src/lens.ts` derived read-model pattern + +**Test scenarios:** +- Happy path: creating a `person` instance writes canonical frontmatter/body and validates contact fields. +- Happy path: `project.member_refs` and `client.contact_ref` validate against retained `person` refs. +- Edge case: optional body remains empty-safe while frontmatter-only person docs still serialize correctly. +- Error path: invalid `person` ref targets fail validation with actionable errors. +- Integration: query/search/context contract include `person` when promoted into default read models. + +**Verification:** +- Person instances are valid canonical graph nodes and integrate with relationship-aware kernel behavior intentionally. + +- [ ] **Unit 4: CLI redesign for primitive-type, primitive-instance, and person ergonomics** + +**Goal:** Rebuild CLI commands so schema and instance operations are clearly separated and agent-friendly, while preserving compatibility aliases. + +**Requirements:** R3, R4, R5, R6, R8 + +**Dependencies:** Units 1-3 + +**Files:** +- Modify: `packages/cli/src/cli.ts` +- Create: `packages/cli/src/cli/commands/primitive-types.ts` +- Create: `packages/cli/src/cli/commands/primitives.ts` +- Create: `packages/cli/src/cli/commands/people.ts` +- Modify: `packages/cli/src/cli/core.ts` +- Test: `tests/cli/primitive-types-command.test.ts` +- Test: `tests/cli/primitives-command.test.ts` +- Test: `tests/cli/people-command.test.ts` + +**Approach:** +- Split the overloaded current `primitive` command family into clearer command modules to avoid further growth in `cli.ts`. +- Introduce distinct nouns for schema/type and instance operations, while preserving existing `primitive define/list/create/update` behavior as aliases where needed. +- Add a native `person` command family for high-value vocabulary (`list`, `show`, `create`, `update`, `archive`) that delegates to the same kernel contract as generic primitive-instance commands. +- Keep outputs deterministic with `--json` and stable field names designed for autonomous agents. + +**Patterns to follow:** +- Existing noun-first CLI grouping in `packages/cli/src/cli.ts` +- `packages/cli/src/cli/core.ts` JSON/error/auth wrapper conventions + +**Test scenarios:** +- Happy path: type commands list/show `person` schema with deterministic JSON. +- Happy path: generic primitive-instance commands create/show/update/archive a `person` instance. +- Happy path: `person` commands produce equivalent underlying results to generic primitive-instance commands. +- Edge case: compatibility aliases keep existing primitive commands working for published users. +- Error path: invalid field assignments, missing required fields, and bad refs return clear machine-parseable failures. +- Integration: CLI-created `person` entries are immediately visible to query/search and MCP surfaces. + +**Verification:** +- Agents can discover and manipulate people through a clear CLI without needing bespoke scripts or hidden contract knowledge. + +- [ ] **Unit 5: MCP redesign for native agent-facing primitive and person surfaces** + +**Goal:** Make MCP a first-class agent-native surface for primitive types, instances, and people, with clean naming, structured outputs, and resource/tool separation. + +**Requirements:** R3, R4, R5, R6, R8 + +**Dependencies:** Units 1-3 + +**Files:** +- Modify: `packages/mcp-server/src/mcp-server.ts` +- Modify: `packages/mcp-server/src/mcp/resources.ts` +- Modify: `packages/mcp-server/src/mcp/result.ts` +- Modify: `packages/mcp-server/src/mcp/tools/primitive-tools.ts` +- Modify: `packages/mcp-server/src/mcp/tools/read-tools.ts` +- Create: `packages/mcp-server/src/mcp/tools/person-tools.ts` +- Test: `packages/mcp-server/src/mcp-server.test.ts` +- Test: `packages/mcp-server/src/mcp-http-server.test.ts` + +**Approach:** +- Preserve generic primitive type/instance MCP tools as the canonical capability layer. +- Expand MCP resources for registry/type schema/reference context where passive consumption is more appropriate than imperative reads. +- Standardize structured outputs and stable error codes for primitive tools using the repo’s stronger collaboration-tool patterns where appropriate. +- Add native `person` MCP affordances only as ergonomic overlays that map onto the same kernel/store contract. +- Decide whether to keep `workgraph_*` and `wg_*` as separate semantic namespaces or gradually unify primitive/domain naming under a clearer convention. + +**Patterns to follow:** +- `packages/mcp-server/src/mcp/tools/primitive-tools.ts` generic CRUD pattern +- `packages/mcp-server/src/mcp/tools/collaboration-tools.ts` structured/idempotent write pattern +- `packages/mcp-server/src/mcp/resources.ts` passive context/resource pattern + +**Test scenarios:** +- Happy path: MCP can discover retained `person` type metadata and schema. +- Happy path: generic primitive MCP tools create/get/update/archive `person` instances. +- Happy path: person-specific MCP tools, if added, remain behaviorally equivalent to generic primitive tools. +- Edge case: structured outputs remain stable across stdio and HTTP transports. +- Error path: policy denial, validation errors, missing paths, and concurrency conflicts return machine-actionable envelopes. +- Integration: MCP-created `person` entries are readable through CLI and kernel query/search flows. + +**Verification:** +- Autonomous agents can fully traverse and manage people natively through MCP without special-case hidden behavior. + +- [ ] **Unit 6: Authorization and policy parity across CLI and MCP** + +**Goal:** Normalize mutation semantics so primitive operations follow one permission model, regardless of interface. + +**Requirements:** R4, R5, R6 + +**Dependencies:** Units 4-5 + +**Files:** +- Modify: `packages/kernel/src/auth.ts` +- Modify: `packages/kernel/src/policy.ts` +- Modify: `packages/kernel/src/server-config.ts` +- Modify: `packages/mcp-server/src/mcp/auth.ts` +- Modify: `packages/kernel/src/store.ts` +- Test: `packages/kernel/src/policy.test.ts` +- Test: `packages/kernel/src/agent.test.ts` +- Test: `packages/mcp-server/src/mcp-server.test.ts` + +**Approach:** +- Keep kernel authorization as the only source of truth for mutation permissions. +- Review per-type capability mapping for retained entity primitives so `person` does not accidentally become ungoverned or over-constrained. +- Clarify the intended difference between CLI auth fallback modes and MCP’s explicit `mcp:write` gating, and document which deltas are intentional transport differences versus historical accidents. +- Preserve explicit exceptions like registration request flows where lower-friction access is intentional. + +**Patterns to follow:** +- `packages/kernel/src/auth.ts` decision + audit pattern +- `packages/mcp-server/src/mcp/auth.ts` transport-side gate wrapper + +**Test scenarios:** +- Happy path: authorized actors can mutate `person` instances through both CLI and MCP. +- Edge case: auth fallback behavior remains explicit in hybrid/legacy modes. +- Error path: missing capabilities/scopes deny mutations with stable reasons. +- Integration: denied MCP operations and denied CLI operations correspond to the same kernel permission rules. + +**Verification:** +- Interface choice no longer changes the primitive permission model unexpectedly. + +- [ ] **Unit 7: Contracts, docs, and end-to-end parity coverage** + +**Goal:** Lock the redesigned primitive model into tests, schemas, and docs so it stays stable for both humans and agents. + +**Requirements:** R1-R8 + +**Dependencies:** Units 1-6 + +**Files:** +- Modify: `schemas/primitive.schema.json` +- Modify: `README.md` +- Modify: `SKILL.md` +- Modify: `docs/PRD.md` +- Modify: `docs/PACKAGE_BOUNDARIES.md` +- Test: `packages/testkit/src/contracts/schema-conformance.test.ts` +- Test: `tests/integration/person-primitive-parity.test.ts` + +**Approach:** +- Update public schema/docs to reflect the explicit type-vs-instance model and the native retained `person` primitive. +- Add end-to-end parity coverage that proves the same canonical person entry can be created/traversed through kernel, CLI, and MCP. +- Document the recommended agent-facing workflows and domain vocabulary so future surfaces follow the same contract. + +**Patterns to follow:** +- Existing schema-conformance tests in `packages/testkit` +- README/PRD emphasis on CLI-first, markdown-canonical, thin-adapter design + +**Test scenarios:** +- Happy path: schema fixtures validate retained person instances. +- Happy path: one end-to-end parity test creates a person, updates it, queries it, and archives it across interfaces. +- Edge case: compatibility docs explain aliases and canonical surfaces without contradiction. +- Integration: docs and tests agree on the same primitive type/instance vocabulary. + +**Verification:** +- The redesigned primitive model is explicit, testable, and durable for future contributors and agents. + +## System-Wide Impact + +- **Interaction graph:** registry, workspace init, store validation, bases generation, CLI command graph, MCP tool/resource registration, and auth/policy checks all change together. +- **Error propagation:** validation and policy failures should continue to originate in kernel and surface through CLI/MCP with interface-appropriate structured wrappers. +- **State lifecycle risks:** retained-set migration can silently drift if manifest/bases/workspace init are not updated in lockstep; CLI/MCP alias layers can drift if they are not bound to one canonical kernel contract. +- **API surface parity:** CLI, MCP stdio, MCP HTTP, SDK exports, and public schemas all need coordinated updates. +- **Integration coverage:** kernel-only unit tests are insufficient; parity tests must cross kernel + CLI + MCP boundaries. +- **Unchanged invariants:** markdown/frontmatter remains canonical, kernel owns domain logic, thread/workflow primitives keep their richer lifecycle semantics, and `.base` files remain derived rather than canonical. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| `person` promotion introduces retained-set churn without a clean retained/canonical model | Introduce explicit retained/canonical metadata before broadening the retained set | +| People refs (`client`, `project`) remain dangling or partially modeled | Explicitly decide supporting retained primitives in Unit 1 rather than leaving refs implicit | +| CLI/MCP compatibility breaks for published users | Add compatibility aliases and cover them with regression tests | +| Interface-specific auth behavior remains inconsistent | Centralize semantics in kernel auth and add parity tests | +| MCP naming/output changes create agent integration churn | Standardize naming and structured outputs incrementally with documented compatibility | +| `cli.ts` and MCP tool modules become harder to maintain | Split commands/tools into focused files as part of the redesign | + +## Alternative Approaches Considered + +- **Promote `person` only and leave CLI/MCP mostly unchanged:** rejected because it preserves the schema-vs-instance ambiguity and leaves agent-native parity incomplete. +- **Introduce only person-specific commands/tools and skip generic primitive cleanup:** rejected because it creates a second model and does not scale to future retained primitives. +- **Treat only registry/templates as primitives and rename all entries to something else immediately:** partially attractive conceptually, but too disruptive for current architecture and public contracts. Better to clarify type-vs-instance semantics without discarding the existing primitive-instance model. + +## Documentation / Operational Notes + +- Existing workspaces need an idempotent migration/convergence path rather than a manual registry reset. +- Public docs should explicitly define primitive type vs primitive instance and document the native `person` frontmatter contract. +- If the person schema is expanded significantly, include one canonical example markdown document in docs or fixtures. + +## Sources & References + +- Related code: `packages/kernel/src/registry.ts` +- Related code: `packages/kernel/src/store.ts` +- Related code: `packages/kernel/src/workspace.ts` +- Related code: `packages/cli/src/cli.ts` +- Related code: `packages/mcp-server/src/mcp/tools/primitive-tools.ts` +- Related code: `packages/mcp-server/src/mcp/tools/collaboration-tools.ts` +- Related docs: `docs/PACKAGE_BOUNDARIES.md` +- Related docs: `docs/PRD.md` +- External docs: https://modelcontextprotocol.io/docs +- External docs: https://clig.dev/ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1fd09ef..a95ae89 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,13 +3,16 @@ import * as workgraph from '@versatly/workgraph-kernel'; import { startWorkgraphMcpHttpServer } from '@versatly/workgraph-mcp-server'; import { registerConversationCommands } from './cli/commands/conversation.js'; import { registerMcpCommands } from './cli/commands/mcp.js'; +import { registerPeopleCommands } from './cli/commands/people.js'; +import { registerPrimitiveInstanceCommands, registerPrimitiveTypeCommands } from './cli/commands/primitives.js'; import { addWorkspaceOption, csv, + normalizePrimitivePath, + parseFieldDefinitions, parseNonNegativeIntOption, parsePositiveIntOption, parsePositiveIntegerOption, - parseSetPairs, parsePortOption, resolveWorkspacePath, resolveInitTargetPath, @@ -614,100 +617,9 @@ addWorkspaceOption( ), ); -const primitiveCmd = program - .command('primitive') - .description('Manage primitive schemas and instances'); - -addWorkspaceOption( - primitiveCmd - .command('define ') - .description('Define a new primitive type') - .requiredOption('--description ', 'Type description') - .option('-a, --actor ', 'Actor', DEFAULT_ACTOR) - .option('--directory ', 'Storage directory') - .option('--field ', 'Repeatable field definition', collectFieldSpecs, []) - .option('--json', 'Emit structured JSON output'), -).action((name, opts) => - runCommand( - opts, - () => workgraph.registry.defineType( - resolveWorkspacePath(opts), - name, - opts.description, - parseFieldDefinitions(opts.field), - opts.actor, - opts.directory, - ), - (result) => [ - `Defined primitive type: ${result.name}`, - `Directory: ${result.directory}`, - ], - ), -); - -addWorkspaceOption( - primitiveCmd - .command('list') - .description('List registered primitive types') - .option('--json', 'Emit structured JSON output'), -).action((opts) => - runCommand( - opts, - () => ({ types: workgraph.registry.listTypes(resolveWorkspacePath(opts)) }), - (result) => result.types.map((type) => `${type.name} -> ${type.directory}`), - ), -); - -addWorkspaceOption( - primitiveCmd - .command('create ') - .description('Create a primitive instance') - .option('-a, --actor <actor>', 'Actor', DEFAULT_ACTOR) - .option('--body <markdown>', 'Markdown body') - .option('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) - .option('--json', 'Emit structured JSON output'), -).action((type, title, opts) => - runCommand( - opts, - () => workgraph.store.create( - resolveWorkspacePath(opts), - type, - { - title, - ...mergeSetPairs(opts.set), - }, - opts.body ?? '', - opts.actor, - ), - (result) => [`Created primitive: ${result.path}`], - ), -); - -addWorkspaceOption( - primitiveCmd - .command('update <path>') - .description('Update a primitive instance') - .option('-a, --actor <actor>', 'Actor', DEFAULT_ACTOR) - .option('--body <markdown>', 'Replace markdown body') - .option('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) - .option('--etag <etag>', 'Expected etag for optimistic concurrency') - .option('--json', 'Emit structured JSON output'), -).action((targetPath, opts) => - runCommand( - opts, - () => workgraph.store.update( - resolveWorkspacePath(opts), - normalizePath(targetPath), - mergeSetPairs(opts.set), - opts.body, - opts.actor, - { - expectedEtag: opts.etag, - }, - ), - (result) => [`Updated primitive: ${result.path}`], - ), -); +registerPrimitiveTypeCommands(program, DEFAULT_ACTOR); +registerPrimitiveInstanceCommands(program, DEFAULT_ACTOR); +registerPeopleCommands(program, DEFAULT_ACTOR); addWorkspaceOption( program @@ -1103,41 +1015,7 @@ function normalizeRegistrationDecision(value: string): 'approved' | 'rejected' { } function normalizePath(value: string): string { - const trimmed = String(value).trim().replace(/\\/g, '/').replace(/^\.\//, ''); - return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`; -} - -function collectSetPairs(value: string, existing: string[]): string[] { - existing.push(value); - return existing; -} - -function mergeSetPairs(values: string[]): Record<string, unknown> { - return values.reduce<Record<string, unknown>>((acc, entry) => { - Object.assign(acc, parseSetPairs([entry])); - return acc; - }, {}); -} - -function collectFieldSpecs(value: string, existing: string[]): string[] { - existing.push(value); - return existing; -} - -function parseFieldDefinitions(values: string[]): Record<string, workgraph.FieldDefinition> { - const fields: Record<string, workgraph.FieldDefinition> = {}; - for (const value of values) { - const [namePart, typePart] = String(value).split(':'); - const name = readNonEmptyString(namePart); - const type = readNonEmptyString(typePart); - if (!name || !type) { - throw new Error(`Invalid field definition "${value}". Expected name:type.`); - } - fields[name] = { - type: parseFieldType(type), - }; - } - return fields; + return normalizePrimitivePath(value); } function parseFieldType(value: string): workgraph.FieldDefinition['type'] { diff --git a/packages/cli/src/cli/commands/people.ts b/packages/cli/src/cli/commands/people.ts new file mode 100644 index 0000000..b8e1224 --- /dev/null +++ b/packages/cli/src/cli/commands/people.ts @@ -0,0 +1,260 @@ +import path from 'node:path'; +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + addWorkspaceOption, + csv, + normalizePrimitivePath, + parseSetPairs, + resolveWorkspacePath, + runCommand, +} from '../core.js'; + +function mergeSetPairs(values: string[]): Record<string, unknown> { + return values.reduce<Record<string, unknown>>((acc, entry) => { + Object.assign(acc, parseSetPairs([entry])); + return acc; + }, {}); +} + +function collectSetPairs(value: string, existing: string[]): string[] { + existing.push(value); + return existing; +} + +function serializePersonInput( + name: string, + opts: Record<string, unknown> & { + preferredName?: string; + email?: string; + phone?: string; + phoneSecondary?: string; + role?: string; + jobTitle?: string; + organization?: string; + relationshipContext?: string; + location?: string; + timezone?: string; + communicationPreference?: string; + slackHandle?: string; + whatsappHandle?: string; + telegramHandle?: string; + website?: string; + socialLinks?: string; + address?: string; + notes?: string; + client?: string; + projects?: string; + tags?: string; + set?: string[]; + }, +) { + return { + name, + ...(opts.preferredName ? { preferred_name: opts.preferredName } : {}), + ...(opts.email ? { email: opts.email } : {}), + ...(opts.phone ? { phone: opts.phone } : {}), + ...(opts.phoneSecondary ? { phone_secondary: opts.phoneSecondary } : {}), + ...(opts.role ? { role: opts.role } : {}), + ...(opts.jobTitle ? { job_title: opts.jobTitle } : {}), + ...(opts.organization ? { organization: opts.organization } : {}), + ...(opts.relationshipContext ? { relationship_context: opts.relationshipContext } : {}), + ...(opts.location ? { location: opts.location } : {}), + ...(opts.timezone ? { timezone: opts.timezone } : {}), + ...(opts.communicationPreference ? { communication_preference: opts.communicationPreference } : {}), + ...(opts.slackHandle ? { slack_handle: opts.slackHandle } : {}), + ...(opts.whatsappHandle ? { whatsapp_handle: opts.whatsappHandle } : {}), + ...(opts.telegramHandle ? { telegram_handle: opts.telegramHandle } : {}), + ...(opts.website ? { website: opts.website } : {}), + ...(opts.socialLinks ? { social_links: csv(opts.socialLinks) } : {}), + ...(opts.address ? { address: opts.address } : {}), + ...(opts.notes ? { notes: opts.notes } : {}), + ...(opts.client ? { client: opts.client } : {}), + ...(opts.projects ? { project_refs: csv(opts.projects) } : {}), + ...(opts.tags ? { tags: csv(opts.tags) } : {}), + ...mergeSetPairs(opts.set ?? []), + }; +} + +export function registerPeopleCommands(program: Command, defaultActor: string): void { + const peopleCmd = program + .command('person') + .description('Manage native person primitive instances'); + + addWorkspaceOption( + peopleCmd + .command('list') + .description('List person primitive instances') + .option('--tag <tag>', 'Filter by tag') + .option('--text <text>', 'Filter by text') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => { + const people = workgraph.query.queryPrimitives(resolveWorkspacePath(opts), { + type: 'person', + tag: opts.tag, + text: opts.text, + }); + return { people, count: people.length }; + }, + (result) => result.people.length > 0 + ? [ + ...result.people.map((entry) => + `${String(entry.fields.name)}${entry.fields.email ? ` <${String(entry.fields.email)}>` : ''} -> ${entry.path}`), + `${result.count} person(s)`, + ] + : ['No people found.'], + ), + ); + + addWorkspaceOption( + peopleCmd + .command('show <personPath>') + .description('Show one person primitive') + .option('--json', 'Emit structured JSON output'), + ).action((personPath, opts) => + runCommand( + opts, + () => { + const person = workgraph.store.read(resolveWorkspacePath(opts), normalizePrimitivePath(personPath)); + if (!person || person.type !== 'person') { + throw new Error(`Person not found: ${personPath}`); + } + return { person }; + }, + (result) => [ + `Person: ${String(result.person.fields.name)}`, + `Path: ${result.person.path}`, + `Email: ${String(result.person.fields.email ?? 'none')}`, + `Organization: ${String(result.person.fields.organization ?? 'none')}`, + ], + ), + ); + + addWorkspaceOption( + peopleCmd + .command('create <name>') + .description('Create a native person primitive instance') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--body <markdown>', 'Markdown body') + .option('--preferred-name <text>', 'Preferred name') + .option('--email <text>', 'Primary email') + .option('--phone <text>', 'Primary phone') + .option('--phone-secondary <text>', 'Secondary phone') + .option('--role <text>', 'Relationship or operating role') + .option('--job-title <text>', 'Professional title') + .option('--organization <text>', 'Organization name') + .option('--relationship-context <text>', 'Relationship context') + .option('--location <text>', 'Location') + .option('--timezone <text>', 'Timezone') + .option('--communication-preference <value>', 'email|phone|slack|whatsapp|telegram') + .option('--slack-handle <text>', 'Slack handle') + .option('--whatsapp-handle <text>', 'WhatsApp handle') + .option('--telegram-handle <text>', 'Telegram handle') + .option('--website <url>', 'Website URL') + .option('--social-links <links>', 'Comma-separated social profile URLs') + .option('--address <text>', 'Postal address') + .option('--notes <text>', 'Short notes field') + .option('--client <ref>', 'Primary client ref') + .option('--projects <refs>', 'Comma-separated project refs') + .option('--tags <tags>', 'Comma-separated tags') + .option('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) + .option('--json', 'Emit structured JSON output'), + ).action((name, opts) => + runCommand( + opts, + () => workgraph.store.create( + resolveWorkspacePath(opts), + 'person', + serializePersonInput(name, opts), + opts.body ?? '', + opts.actor, + ), + (result) => [`Created person: ${result.path}`], + ), + ); + + addWorkspaceOption( + peopleCmd + .command('update <personPath>') + .description('Update a native person primitive instance') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--body <markdown>', 'Replace markdown body') + .option('--preferred-name <text>', 'Preferred name') + .option('--email <text>', 'Primary email') + .option('--phone <text>', 'Primary phone') + .option('--phone-secondary <text>', 'Secondary phone') + .option('--role <text>', 'Relationship or operating role') + .option('--job-title <text>', 'Professional title') + .option('--organization <text>', 'Organization name') + .option('--relationship-context <text>', 'Relationship context') + .option('--location <text>', 'Location') + .option('--timezone <text>', 'Timezone') + .option('--communication-preference <value>', 'email|phone|slack|whatsapp|telegram') + .option('--slack-handle <text>', 'Slack handle') + .option('--whatsapp-handle <text>', 'WhatsApp handle') + .option('--telegram-handle <text>', 'Telegram handle') + .option('--website <url>', 'Website URL') + .option('--social-links <links>', 'Comma-separated social profile URLs') + .option('--address <text>', 'Postal address') + .option('--notes <text>', 'Short notes field') + .option('--client <ref>', 'Primary client ref') + .option('--projects <refs>', 'Comma-separated project refs') + .option('--tags <tags>', 'Comma-separated tags') + .option('--etag <etag>', 'Expected etag for optimistic concurrency') + .option('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) + .option('--json', 'Emit structured JSON output'), + ).action((personPath, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const normalizedPath = normalizePrimitivePath(personPath); + const existing = workgraph.store.read(workspacePath, normalizedPath); + if (!existing || existing.type !== 'person') { + throw new Error(`Person not found: ${personPath}`); + } + return workgraph.store.update( + workspacePath, + normalizedPath, + serializePersonInput(String(existing.fields.name ?? ''), opts), + opts.body, + opts.actor, + { + expectedEtag: opts.etag, + }, + ); + }, + (result) => [`Updated person: ${result.path}`], + ), + ); + + addWorkspaceOption( + peopleCmd + .command('archive <personPath>') + .description('Archive a native person primitive instance') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--json', 'Emit structured JSON output'), + ).action((personPath, opts) => + runCommand( + opts, + () => { + const normalized = normalizePrimitivePath(personPath); + const person = workgraph.store.read(resolveWorkspacePath(opts), normalized); + if (!person || person.type !== 'person') { + throw new Error(`Person not found: ${personPath}`); + } + workgraph.store.remove(resolveWorkspacePath(opts), normalized, opts.actor); + return { + archived: { + path: normalized, + archivePath: `.workgraph/archive/${path.basename(normalized)}`, + }, + }; + }, + (result) => [`Archived person: ${result.archived.path}`], + ), + ); +} diff --git a/packages/cli/src/cli/commands/primitives.ts b/packages/cli/src/cli/commands/primitives.ts new file mode 100644 index 0000000..5563e2d --- /dev/null +++ b/packages/cli/src/cli/commands/primitives.ts @@ -0,0 +1,207 @@ +import { Command } from 'commander'; +import * as workgraph from '@versatly/workgraph-kernel'; +import { + addWorkspaceOption, + collectFieldSpecs, + mergeSetPairs, + normalizeWorkspacePath, + parseFieldDefinitions, + readNonEmptyString, + renderPrimitiveSummary, + resolveWorkspacePath, + runCommand, +} from '../core.js'; + +export function registerPrimitiveTypeCommands(program: Command, defaultActor: string): void { + const primitiveTypeCmd = program + .command('primitive-type') + .description('Manage primitive type schemas'); + + addWorkspaceOption( + primitiveTypeCmd + .command('define <name>') + .description('Define a new primitive type') + .requiredOption('--description <text>', 'Type description') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--directory <dir>', 'Storage directory') + .option('--field <name:type>', 'Repeatable field definition', collectFieldSpecs, []) + .option('--json', 'Emit structured JSON output'), + ).action((name, opts) => + runCommand( + opts, + () => workgraph.registry.defineType( + resolveWorkspacePath(opts), + name, + opts.description, + parseFieldDefinitions(opts.field), + opts.actor, + opts.directory, + ), + (result) => [ + `Defined primitive type: ${result.name}`, + `Directory: ${result.directory}`, + ], + ), + ); + + addWorkspaceOption( + primitiveTypeCmd + .command('list') + .description('List registered primitive types') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => ({ types: workgraph.registry.listTypes(resolveWorkspacePath(opts)) }), + (result) => result.types.map((type) => `${type.name} -> ${type.directory}`), + ), + ); + + addWorkspaceOption( + primitiveTypeCmd + .command('show <typeName>') + .description('Show one primitive type schema') + .option('--json', 'Emit structured JSON output'), + ).action((typeName, opts) => + runCommand( + opts, + () => { + const type = workgraph.registry.getType(resolveWorkspacePath(opts), typeName); + if (!type) throw new Error(`Primitive type not found: ${typeName}`); + return type; + }, + (result) => [ + `Primitive type: ${result.name}`, + `Directory: ${result.directory}`, + `Retained: ${result.retained ? 'yes' : 'no'}`, + ], + ), + ); +} + +export function registerPrimitiveInstanceCommands(program: Command, defaultActor: string): void { + const primitiveCmd = program + .command('primitive') + .description('Manage primitive instances'); + + addWorkspaceOption( + primitiveCmd + .command('create <type>') + .description('Create a primitive instance') + .requiredOption('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--body <markdown>', 'Markdown body') + .option('--path <path>', 'Optional explicit workspace-relative path') + .option('--json', 'Emit structured JSON output'), + ).action((type, opts) => + runCommand( + opts, + () => workgraph.store.create( + resolveWorkspacePath(opts), + type, + mergeSetPairs(opts.set), + opts.body ?? '', + opts.actor, + opts.path ? { pathOverride: normalizeWorkspacePath(opts.path) } : undefined, + ), + (result) => [ + `Created ${result.type}: ${result.path}`, + ], + ), + ); + + addWorkspaceOption( + primitiveCmd + .command('show <primitivePath>') + .description('Show one primitive instance') + .option('--json', 'Emit structured JSON output'), + ).action((primitivePath, opts) => + runCommand( + opts, + () => { + const instance = workgraph.store.read(resolveWorkspacePath(opts), normalizeWorkspacePath(primitivePath)); + if (!instance) throw new Error(`Primitive not found: ${primitivePath}`); + return instance; + }, + (result) => [ + `${result.type}: ${result.path}`, + ...renderPrimitiveSummary(result), + ], + ), + ); + + addWorkspaceOption( + primitiveCmd + .command('list') + .description('List primitive instances') + .requiredOption('--type <type>', 'Primitive type') + .option('--json', 'Emit structured JSON output'), + ).action((opts) => + runCommand( + opts, + () => ({ instances: workgraph.store.list(resolveWorkspacePath(opts), opts.type) }), + (result) => result.instances.length > 0 + ? result.instances.map((instance) => `${instance.type} ${instance.path}`) + : ['No primitive instances found.'], + ), + ); + + addWorkspaceOption( + primitiveCmd + .command('update <primitivePath>') + .description('Update a primitive instance') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--body <markdown>', 'Replace markdown body') + .option('--set <key=value>', 'Repeatable field assignment', collectSetPairs, []) + .option('--etag <etag>', 'Expected etag for optimistic concurrency') + .option('--json', 'Emit structured JSON output'), + ).action((primitivePath, opts) => + runCommand( + opts, + () => workgraph.store.update( + resolveWorkspacePath(opts), + normalizeWorkspacePath(primitivePath), + mergeSetPairs(opts.set), + readNonEmptyString(opts.body) ?? opts.body, + opts.actor, + { + expectedEtag: readNonEmptyString(opts.etag), + }, + ), + (result) => [ + `Updated ${result.type}: ${result.path}`, + ], + ), + ); + + addWorkspaceOption( + primitiveCmd + .command('archive <primitivePath>') + .description('Archive a primitive instance') + .option('-a, --actor <actor>', 'Actor', defaultActor) + .option('--json', 'Emit structured JSON output'), + ).action((primitivePath, opts) => + runCommand( + opts, + () => { + const workspacePath = resolveWorkspacePath(opts); + const normalizedPath = normalizeWorkspacePath(primitivePath); + const instance = workgraph.store.read(workspacePath, normalizedPath); + if (!instance) throw new Error(`Primitive not found: ${primitivePath}`); + workgraph.store.remove(workspacePath, normalizedPath, opts.actor); + return { + archived: { + path: normalizedPath, + type: instance.type, + }, + }; + }, + (result) => [`Archived ${result.archived.type}: ${result.archived.path}`], + ), + ); +} + +function collectSetPairs(value: string, existing: string[]): string[] { + existing.push(value); + return existing; +} diff --git a/packages/cli/src/cli/core.ts b/packages/cli/src/cli/core.ts index 4c623a4..4e391a5 100644 --- a/packages/cli/src/cli/core.ts +++ b/packages/cli/src/cli/core.ts @@ -88,11 +88,37 @@ export function parseSetPairs(pairs: string[]): Record<string, unknown> { return fields; } +export function collectSetPairs(value: string, existing: string[]): string[] { + existing.push(value); + return existing; +} + +export function collectFieldSpecs(value: string, existing: string[]): string[] { + existing.push(value); + return existing; +} + +export function mergeSetPairs(values: string[]): Record<string, unknown> { + return values.reduce<Record<string, unknown>>((acc, entry) => { + Object.assign(acc, parseSetPairs([entry])); + return acc; + }, {}); +} + export function csv(value?: string): string[] | undefined { if (!value) return undefined; return String(value).split(',').map((s) => s.trim()).filter(Boolean); } +export function normalizePrimitivePath(value: string): string { + const trimmed = String(value).trim().replace(/\\/g, '/').replace(/^\.\//, ''); + return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`; +} + +export function normalizeWorkspacePath(value: string): string { + return normalizePrimitivePath(value); +} + function parseScalar(value: string): unknown { if (value === 'true') return true; if (value === 'false') return false; @@ -122,6 +148,22 @@ export function parsePositiveIntOption(value: unknown, name: string): number { return parsed; } +export function parseFieldDefinitions(values: string[]): Record<string, workgraph.FieldDefinition> { + const fields: Record<string, workgraph.FieldDefinition> = {}; + for (const value of values) { + const [namePart, typePart] = String(value).split(':'); + const name = readNonEmptyString(namePart); + const type = readNonEmptyString(typePart); + if (!name || !type) { + throw new Error(`Invalid field definition "${value}". Expected name:type.`); + } + fields[name] = { + type: parseFieldType(type), + }; + } + return fields; +} + export function parsePortOption(value: unknown): number { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) { @@ -160,6 +202,20 @@ export function wantsJson(opts: JsonCapableOptions): boolean { return false; } +export function renderPrimitiveSummary(instance: workgraph.PrimitiveInstance): string[] { + const summaryLines = [ + `Path: ${instance.path}`, + ]; + const title = readNonEmptyString(String(instance.fields.title ?? '')); + const name = readNonEmptyString(String(instance.fields.name ?? '')); + if (title) summaryLines.push(`Title: ${title}`); + if (!title && name) summaryLines.push(`Name: ${name}`); + if (typeof instance.fields.status === 'string') { + summaryLines.push(`Status: ${instance.fields.status}`); + } + return summaryLines; +} + export async function runCommand<T>( opts: JsonCapableOptions, action: () => T | Promise<T>, @@ -225,8 +281,24 @@ function resolveApiKey(opts: JsonCapableOptions): string | undefined { return fromEnv; } -function readNonEmptyString(value: unknown): string | undefined { +export function readNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } + +function parseFieldType(value: string): workgraph.FieldDefinition['type'] { + const normalized = value.trim().toLowerCase(); + if ( + normalized === 'string' || + normalized === 'number' || + normalized === 'boolean' || + normalized === 'list' || + normalized === 'date' || + normalized === 'ref' || + normalized === 'any' + ) { + return normalized; + } + throw new Error(`Invalid field type "${value}". Expected string|number|boolean|list|date|ref|any.`); +} diff --git a/packages/kernel/src/bases.test.ts b/packages/kernel/src/bases.test.ts index b81f0d3..4d4461f 100644 --- a/packages/kernel/src/bases.test.ts +++ b/packages/kernel/src/bases.test.ts @@ -34,6 +34,10 @@ describe('bases generation', () => { const thread = parsed.primitives.find((primitive) => primitive.name === 'thread'); expect(thread?.canonical).toBe(true); expect(thread?.fields.some((field) => field.name === 'space')).toBe(true); + const person = parsed.primitives.find((primitive) => primitive.name === 'person'); + expect(person?.canonical).toBe(true); + expect(person?.fields.some((field) => field.name === 'email')).toBe(true); + expect(person?.fields.some((field) => field.name === 'slack_handle')).toBe(true); }); it('generates .base files for canonical primitives by default', () => { @@ -42,10 +46,14 @@ describe('bases generation', () => { expect(result.generated.some((filePath) => filePath.endsWith('/thread.base'))).toBe(true); expect(result.generated.some((filePath) => filePath.endsWith('/relationship.base'))).toBe(true); + expect(result.generated.some((filePath) => filePath.endsWith('/person.base'))).toBe(true); const threadBase = path.join(workspacePath, '.workgraph/bases/thread.base'); expect(fs.existsSync(threadBase)).toBe(true); expect(fs.readFileSync(threadBase, 'utf-8')).toContain('source:'); + const personBase = path.join(workspacePath, '.workgraph/bases/person.base'); + expect(fs.existsSync(personBase)).toBe(true); + expect(fs.readFileSync(personBase, 'utf-8')).toContain('slack_handle'); }); it('can include non-canonical primitive types', () => { diff --git a/packages/kernel/src/bases.ts b/packages/kernel/src/bases.ts index ebde8bb..389ca49 100644 --- a/packages/kernel/src/bases.ts +++ b/packages/kernel/src/bases.ts @@ -19,6 +19,7 @@ export interface PrimitiveRegistryManifestPrimitive { directory: string; canonical: boolean; builtIn: boolean; + retained: boolean; fields: PrimitiveRegistryManifestField[]; } @@ -63,8 +64,9 @@ export function syncPrimitiveRegistryManifest(workspacePath: string): PrimitiveR .map((primitive) => ({ name: primitive.name, directory: primitive.directory, - canonical: primitive.builtIn, + canonical: primitive.retained, builtIn: primitive.builtIn, + retained: primitive.retained, fields: Object.entries(primitive.fields).map(([name, field]) => ({ name, type: field.type, diff --git a/packages/kernel/src/registry.test.ts b/packages/kernel/src/registry.test.ts index bfc430c..32357ca 100644 --- a/packages/kernel/src/registry.test.ts +++ b/packages/kernel/src/registry.test.ts @@ -32,7 +32,12 @@ describe('registry', () => { expect(reg.types.policy).toBeDefined(); expect(reg.types['policy-gate']).toBeDefined(); expect(reg.types.checkpoint).toBeDefined(); + expect(reg.types.person).toBeDefined(); + expect(reg.types.client).toBeDefined(); + expect(reg.types.project).toBeDefined(); expect(reg.types.thread.builtIn).toBe(true); + expect(reg.types.thread.retained).toBe(true); + expect(reg.types.person.retained).toBe(true); }); it('adds company-context fields to existing built-in types without removing legacy fields', () => { @@ -43,6 +48,10 @@ describe('registry', () => { expect(reg.types.policy.fields.scope_type).toBeDefined(); expect(reg.types.relationship.fields.strength).toBeDefined(); expect(reg.types.checkpoint.fields.summary).toBeDefined(); + expect(reg.types.person.fields.preferred_name).toBeDefined(); + expect(reg.types.person.fields.communication_preference?.enum).toContain('slack'); + expect(reg.types.client.fields.contact_ref?.refTypes).toContain('person'); + expect(reg.types.project.fields.member_refs).toBeDefined(); }); it('persists registry to disk', () => { @@ -65,6 +74,7 @@ describe('registry', () => { expect(wf).toBeDefined(); expect(wf!.name).toBe('workflow'); expect(wf!.builtIn).toBe(false); + expect(wf!.retained).toBe(false); expect(wf!.createdBy).toBe('agent-alpha'); expect(wf!.fields.stages.type).toBe('list'); expect(wf!.fields.title).toBeDefined(); @@ -117,9 +127,12 @@ describe('registry', () => { it('ensures built-ins survive registry reload', () => { const reg = loadRegistry(workspacePath); delete reg.types.thread; + delete reg.types.person; saveRegistry(workspacePath, reg); const reloaded = loadRegistry(workspacePath); expect(reloaded.types.thread).toBeDefined(); + expect(reloaded.types.person).toBeDefined(); + expect(reloaded.types.person.retained).toBe(true); }); }); diff --git a/packages/kernel/src/registry.ts b/packages/kernel/src/registry.ts index e310dbe..912eea6 100644 --- a/packages/kernel/src/registry.ts +++ b/packages/kernel/src/registry.ts @@ -21,6 +21,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A unit of coordinated work. The core workgraph node.', directory: 'threads', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -59,6 +60,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A workspace boundary that groups related threads and sets context.', directory: 'spaces', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -76,6 +78,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A recorded decision with reasoning and context.', directory: 'decisions', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -99,6 +102,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A captured insight or pattern learned from experience.', directory: 'lessons', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -118,6 +122,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Top-level organizational context for mission, strategy, and structure.', directory: 'orgs', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -137,6 +142,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A team boundary with members, ownership, and responsibilities.', directory: 'teams', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -155,6 +161,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A reusable delivery or operating pattern with steps and caveats.', directory: 'patterns', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -174,6 +181,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'An explicit typed edge between primitives or entities in the context graph.', directory: 'relationships', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -193,6 +201,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Strategic note capturing decisions or focus across company/team/project/client scopes.', directory: 'strategic-notes', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -210,6 +219,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A structured piece of knowledge with optional temporal validity.', directory: 'facts', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -230,6 +240,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A registered participant in the workgraph.', directory: 'agents', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -251,6 +262,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Agent heartbeat presence status for runtime coordination.', directory: 'agents', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -274,16 +286,30 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A human stakeholder referenced by projects, clients, and incidents.', directory: 'people', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { name: { type: 'string', required: true }, + preferred_name: { type: 'string' }, email: { type: 'string', template: 'email' }, phone: { type: 'string' }, + phone_secondary: { type: 'string' }, role: { type: 'string' }, + job_title: { type: 'string', description: 'Professional title used for contact or reporting context' }, organization: { type: 'string' }, + team: { type: 'ref', refTypes: ['team'] }, relationship_context: { type: 'string' }, + location: { type: 'string' }, + timezone: { type: 'string' }, communication_preference: { type: 'string', enum: ['email', 'phone', 'slack', 'whatsapp', 'telegram'] }, + slack_handle: { type: 'string' }, + whatsapp_handle: { type: 'string' }, + telegram_handle: { type: 'string' }, + website: { type: 'string', template: 'url' }, + social_links: { type: 'list', default: [] }, + address: { type: 'string' }, + notes: { type: 'string' }, client: { type: 'ref', refTypes: ['client'] }, project_refs: { type: 'list', default: [] }, external_links: { type: 'list', default: [] }, @@ -297,6 +323,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'An external customer/account coordinated in the workgraph.', directory: 'clients', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -321,6 +348,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A coordinated initiative spanning multiple threads and stakeholders.', directory: 'projects', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -345,6 +373,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'High-level orchestration primitive composed of milestones and feature threads.', directory: 'missions', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -383,6 +412,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'A reusable agent skill shared through the workgraph workspace.', directory: 'skills', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -406,6 +436,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Agent or team onboarding lifecycle primitive.', directory: 'onboarding', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -426,6 +457,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Thread coordination context with timeline events and execution state.', directory: 'conversations', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -454,6 +486,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Executable step primitive linked to conversations and threads.', directory: 'plan-steps', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -479,6 +512,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Governance policy primitive for approvals and guardrails.', directory: 'policies', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -500,6 +534,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Quality gate rules that must pass before thread claim.', directory: 'policy-gates', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -520,6 +555,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Incident coordination primitive with gated lifecycle.', directory: 'incidents', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -537,6 +573,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Programmable trigger primitive for cron/event/webhook/manual dispatch.', directory: 'triggers', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -569,6 +606,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Agent checkpoint/hand-off primitive for orientation continuity.', directory: 'checkpoints', builtIn: true, + retained: true, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -587,6 +625,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ description: 'Background agent run primitive with lifecycle state.', directory: 'runs', builtIn: true, + retained: false, createdAt: '2026-01-01T00:00:00.000Z', createdBy: 'system', fields: { @@ -607,22 +646,6 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [ }, ]; -const RETAINED_BUILT_IN_TYPE_NAMES = new Set([ - 'thread', - 'space', - 'decision', - 'org', - 'fact', - 'relationship', - 'agent', - 'presence', - 'conversation', - 'plan-step', - 'policy', - 'policy-gate', - 'checkpoint', -]); - // --------------------------------------------------------------------------- // Registry operations // --------------------------------------------------------------------------- @@ -687,6 +710,7 @@ export function defineType( }, directory: directory ?? `${safeName}s`, builtIn: false, + retained: false, createdAt: now, createdBy: actor, }; @@ -744,7 +768,7 @@ export function extendType( function seedRegistry(): Registry { const types: Record<string, PrimitiveTypeDefinition> = {}; for (const t of BUILT_IN_TYPES) { - if (!RETAINED_BUILT_IN_TYPE_NAMES.has(t.name)) continue; + if (!t.retained) continue; types[t.name] = t; } return { version: CURRENT_VERSION, types }; @@ -752,12 +776,12 @@ function seedRegistry(): Registry { function ensureBuiltIns(registry: Registry): Registry { for (const [typeName, typeDef] of Object.entries(registry.types)) { - if (typeDef.builtIn && !RETAINED_BUILT_IN_TYPE_NAMES.has(typeName)) { + if (typeDef.builtIn && typeDef.retained !== true) { delete registry.types[typeName]; } } for (const t of BUILT_IN_TYPES) { - if (!RETAINED_BUILT_IN_TYPE_NAMES.has(t.name)) continue; + if (!t.retained) continue; if (!registry.types[t.name]) { registry.types[t.name] = t; continue; @@ -768,6 +792,7 @@ function ensureBuiltIns(registry: Registry): Registry { ...existing, description: t.description, directory: t.directory, + retained: true, fields: { ...existing.fields, ...t.fields, diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 1cf5324..41329a4 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -42,6 +42,8 @@ export interface PrimitiveTypeDefinition { directory: string; /** Whether this type was defined by an agent at runtime vs built-in. */ builtIn: boolean; + /** Whether this type is retained as a canonical primitive in live workspaces. */ + retained: boolean; /** ISO timestamp of when this type was registered. */ createdAt: string; /** Who registered it (agent name or "system"). */ diff --git a/packages/kernel/src/workspace.test.ts b/packages/kernel/src/workspace.test.ts index d8f1907..44196aa 100644 --- a/packages/kernel/src/workspace.test.ts +++ b/packages/kernel/src/workspace.test.ts @@ -26,6 +26,9 @@ describe('workspace init', () => { expect(fs.existsSync(path.join(workspacePath, 'threads'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'spaces'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'agents'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'people'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'clients'))).toBe(true); + expect(fs.existsSync(path.join(workspacePath, 'projects'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'README.md'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, 'QUICKSTART.md'))).toBe(true); expect(fs.existsSync(path.join(workspacePath, '.workgraph/server.json'))).toBe(true); @@ -45,6 +48,9 @@ describe('workspace init', () => { expect(fs.existsSync(path.join(workspacePath, result.bootstrapTrustTokenPath))).toBe(true); expect(result.bootstrapTrustToken).toMatch(/^wg-bootstrap-[a-f0-9]{24}$/); expect(result.seededTypes).toContain('thread'); + expect(result.seededTypes).toContain('person'); + expect(result.seededTypes).toContain('client'); + expect(result.seededTypes).toContain('project'); expect(result.seededTypes).toContain('role'); expect(result.seededTypes).toContain('trust-token'); expect(result.generatedBases.length).toBeGreaterThan(0); diff --git a/packages/mcp-server/src/mcp-server.test.ts b/packages/mcp-server/src/mcp-server.test.ts index 9646824..039a35e 100644 --- a/packages/mcp-server/src/mcp-server.test.ts +++ b/packages/mcp-server/src/mcp-server.test.ts @@ -65,6 +65,16 @@ describe('workgraph mcp server', () => { 'workgraph_search', 'workgraph_lens_list', 'workgraph_lens_show', + 'workgraph_primitive_types', + 'workgraph_primitive_get', + 'workgraph_primitive_create', + 'workgraph_primitive_update', + 'workgraph_primitive_delete', + 'workgraph_person_list', + 'workgraph_person_get', + 'workgraph_person_create', + 'workgraph_person_update', + 'workgraph_person_archive', 'workgraph_primitive_schema', 'workgraph_thread_list', 'workgraph_thread_show', @@ -269,6 +279,192 @@ describe('workgraph mcp server', () => { } }); + it('supports generic primitive CRUD for shared workspace entities', async () => { + policy.upsertParty(workspacePath, 'agent-mcp', { + roles: ['operator'], + capabilities: ['mcp:write'], + }); + + const server = createWorkgraphMcpServer({ + workspacePath, + defaultActor: 'agent-mcp', + }); + const client = new Client({ + name: 'workgraph-mcp-primitive-client', + version: '1.0.0', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + try { + const types = await client.callTool({ + name: 'workgraph_primitive_types', + arguments: {}, + }); + expect(isToolError(types)).toBe(false); + const typePayload = getStructured<{ types: Array<{ name: string; retained: boolean; canonical: boolean }> }>(types); + expect(typePayload.types.some((entry) => entry.name === 'person')).toBe(true); + expect(typePayload.types.some((entry) => entry.name === 'project')).toBe(true); + expect(typePayload.types.find((entry) => entry.name === 'person')?.retained).toBe(true); + expect(typePayload.types.find((entry) => entry.name === 'person')?.canonical).toBe(true); + + const createdPerson = await client.callTool({ + name: 'workgraph_primitive_create', + arguments: { + actor: 'agent-mcp', + type: 'person', + fields: { + name: 'Ada Lovelace', + email: 'ada@example.com', + role: 'Technical advisor', + tags: ['vip'], + }, + body: 'Early stakeholder profile.', + }, + }); + expect(isToolError(createdPerson)).toBe(false); + const personPath = getStructured<{ primitive: { path: string; type: string } }>(createdPerson).primitive.path; + expect(personPath).toBe('people/ada-lovelace.md'); + + const fetchedPerson = await client.callTool({ + name: 'workgraph_primitive_get', + arguments: { + path: personPath, + }, + }); + expect(isToolError(fetchedPerson)).toBe(false); + const fetchedPayload = getStructured<{ primitive: { fields: { email: string } } }>(fetchedPerson); + expect(fetchedPayload.primitive.fields.email).toBe('ada@example.com'); + + const schema = await client.callTool({ + name: 'workgraph_primitive_schema', + arguments: { + typeName: 'person', + }, + }); + expect(isToolError(schema)).toBe(false); + const schemaPayload = getStructured<{ + type: string; + retained: boolean; + canonical: boolean; + fields: Array<{ name: string }>; + }>(schema); + expect(schemaPayload.type).toBe('person'); + expect(schemaPayload.retained).toBe(true); + expect(schemaPayload.canonical).toBe(true); + expect(schemaPayload.fields.some((field) => field.name === 'preferred_name')).toBe(true); + expect(schemaPayload.fields.some((field) => field.name === 'job_title')).toBe(true); + + const createdProject = await client.callTool({ + name: 'workgraph_primitive_create', + arguments: { + actor: 'agent-mcp', + type: 'project', + fields: { + title: 'Agent Parity Rollout', + owner: 'agent-mcp', + member_refs: [personPath], + status: 'active', + }, + body: 'Enable full primitive parity for agents.', + }, + }); + expect(isToolError(createdProject)).toBe(false); + const projectPath = getStructured<{ primitive: { path: string; fields: { member_refs: string[] } } }>(createdProject); + expect(projectPath.primitive.path).toBe('projects/agent-parity-rollout.md'); + expect(projectPath.primitive.fields.member_refs).toContain(personPath); + + const updatedProject = await client.callTool({ + name: 'workgraph_primitive_update', + arguments: { + actor: 'agent-mcp', + path: projectPath.primitive.path, + fieldUpdates: { + priority: 'high', + }, + body: 'Enable full primitive parity for agents and MCP clients.', + }, + }); + expect(isToolError(updatedProject)).toBe(false); + const updatedPayload = getStructured<{ primitive: { fields: { priority: string }; body: string } }>(updatedProject); + expect(updatedPayload.primitive.fields.priority).toBe('high'); + expect(updatedPayload.primitive.body).toContain('MCP clients'); + + const createdGrace = await client.callTool({ + name: 'workgraph_person_create', + arguments: { + actor: 'agent-mcp', + name: 'Grace Hopper', + email: 'grace@example.com', + preferredName: 'Grace', + jobTitle: 'Rear Admiral', + organization: 'US Navy', + tags: ['legend'], + body: 'Pioneer in compiler design.', + }, + }); + expect(isToolError(createdGrace)).toBe(false); + const gracePayload = getStructured<{ person: { path: string; fields: { preferred_name: string; job_title: string } } }>(createdGrace); + expect(gracePayload.person.path).toBe('people/grace-hopper.md'); + expect(gracePayload.person.fields.preferred_name).toBe('Grace'); + expect(gracePayload.person.fields.job_title).toBe('Rear Admiral'); + + const listedPeople = await client.callTool({ + name: 'workgraph_person_list', + arguments: { + tag: 'legend', + }, + }); + expect(isToolError(listedPeople)).toBe(false); + const listedPeoplePayload = getStructured<{ people: Array<{ path: string }>; count: number }>(listedPeople); + expect(listedPeoplePayload.count).toBe(1); + expect(listedPeoplePayload.people[0]?.path).toBe('people/grace-hopper.md'); + + const updatedGrace = await client.callTool({ + name: 'workgraph_person_update', + arguments: { + actor: 'agent-mcp', + path: 'people/grace-hopper.md', + fieldUpdates: { + timezone: 'America/New_York', + }, + }, + }); + expect(isToolError(updatedGrace)).toBe(false); + const updatedGracePayload = getStructured<{ person: { fields: { timezone: string } } }>(updatedGrace); + expect(updatedGracePayload.person.fields.timezone).toBe('America/New_York'); + + const archivedGrace = await client.callTool({ + name: 'workgraph_person_archive', + arguments: { + actor: 'agent-mcp', + path: 'people/grace-hopper.md', + }, + }); + expect(isToolError(archivedGrace)).toBe(false); + expect(storePathExists(workspacePath, 'people/grace-hopper.md')).toBe(false); + expect(storePathExists(workspacePath, '.workgraph/archive/grace-hopper.md')).toBe(true); + + const deletedPerson = await client.callTool({ + name: 'workgraph_primitive_delete', + arguments: { + actor: 'agent-mcp', + path: personPath, + }, + }); + expect(isToolError(deletedPerson)).toBe(false); + expect(storePathExists(workspacePath, personPath)).toBe(false); + expect(storePathExists(workspacePath, '.workgraph/archive/ada-lovelace.md')).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); + it('supports actor registration request and review tools', async () => { policy.upsertParty(workspacePath, 'admin-reviewer', { roles: ['admin'], @@ -356,3 +552,7 @@ function isToolError(result: unknown): boolean { if (!('isError' in result)) return false; return (result as { isError?: boolean }).isError === true; } + +function storePathExists(root: string, relPath: string): boolean { + return fs.existsSync(path.join(root, relPath)); +} diff --git a/packages/mcp-server/src/mcp-server.ts b/packages/mcp-server/src/mcp-server.ts index 5aad26f..f32b207 100644 --- a/packages/mcp-server/src/mcp-server.ts +++ b/packages/mcp-server/src/mcp-server.ts @@ -1,6 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { registerCollaborationTools } from './mcp/tools/collaboration-tools.js'; +import { registerPersonTools } from './mcp/tools/person-tools.js'; +import { registerPrimitiveTools } from './mcp/tools/primitive-tools.js'; import { registerResources } from './mcp/resources.js'; import { type WorkgraphMcpServerOptions } from './mcp/types.js'; import { registerReadTools } from './mcp/tools/read-tools.js'; @@ -17,6 +19,8 @@ export function createWorkgraphMcpServer(options: WorkgraphMcpServerOptions): Mc registerResources(server, options); registerReadTools(server, options); + registerPrimitiveTools(server, options); + registerPersonTools(server, options); registerWriteTools(server, options); registerCollaborationTools(server, options); return server; diff --git a/packages/mcp-server/src/mcp/tools/person-tools.ts b/packages/mcp-server/src/mcp/tools/person-tools.ts new file mode 100644 index 0000000..d8f3e9d --- /dev/null +++ b/packages/mcp-server/src/mcp/tools/person-tools.ts @@ -0,0 +1,311 @@ +import path from 'node:path'; +import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { query as queryModule, store as storeModule } from '@versatly/workgraph-kernel'; +import { checkWriteGate, resolveActor } from '../auth.js'; +import { errorResult, okResult } from '../result.js'; +import { type WorkgraphMcpServerOptions } from '../types.js'; + +const query = queryModule; +const store = storeModule; +const personFieldShape = { + preferredName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + phoneSecondary: z.string().optional(), + role: z.string().optional(), + jobTitle: z.string().optional(), + organization: z.string().optional(), + relationshipContext: z.string().optional(), + location: z.string().optional(), + timezone: z.string().optional(), + communicationPreference: z.string().optional(), + slackHandle: z.string().optional(), + whatsappHandle: z.string().optional(), + telegramHandle: z.string().optional(), + website: z.string().optional(), + socialLinks: z.array(z.string()).optional(), + address: z.string().optional(), + notes: z.string().optional(), + client: z.string().optional(), + projectRefs: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), +} as const; + +export function registerPersonTools(server: McpServer, options: WorkgraphMcpServerOptions): void { + server.registerTool( + 'workgraph_person_list', + { + title: 'Person List', + description: 'List person primitive instances, optionally filtered by tag or text.', + inputSchema: { + tag: z.string().optional(), + text: z.string().optional(), + limit: z.number().int().min(0).max(1000).optional(), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const people = query.queryPrimitives(options.workspacePath, { + type: 'person', + tag: args.tag, + text: args.text, + limit: args.limit, + }); + return okResult( + { + people: people.map(serializePerson), + count: people.length, + }, + `Person list returned ${people.length} item(s).`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_person_get', + { + title: 'Person Get', + description: 'Read one person primitive by path.', + inputSchema: { + path: z.string().min(1), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const personPath = normalizePrimitivePath(args.path); + const person = store.read(options.workspacePath, personPath); + if (!person || person.type !== 'person') { + return errorResult(`Person not found: ${personPath}`); + } + return okResult({ person: serializePerson(person) }, `Person ${personPath}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_person_create', + { + title: 'Person Create', + description: 'Create a native person primitive instance.', + inputSchema: { + actor: z.string().optional(), + name: z.string().min(1), + body: z.string().optional(), + fields: z.record(z.string(), z.unknown()).optional(), + ...personFieldShape, + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.person.create', + target: 'people', + }); + if (!gate.allowed) return errorResult(gate.reason); + const created = store.create( + options.workspacePath, + 'person', + { + name: args.name, + ...serializePersonFields(args), + ...(args.fields ?? {}), + }, + args.body ?? '', + actor, + ); + return okResult({ person: serializePerson(created) }, `Created person ${created.path}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_person_update', + { + title: 'Person Update', + description: 'Update a native person primitive instance.', + inputSchema: { + path: z.string().min(1), + actor: z.string().optional(), + fieldUpdates: z.record(z.string(), z.unknown()).optional(), + body: z.string().optional(), + expectedEtag: z.string().optional(), + ...personFieldShape, + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const personPath = normalizePrimitivePath(args.path); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.person.update', + target: personPath, + }); + if (!gate.allowed) return errorResult(gate.reason); + const existing = store.read(options.workspacePath, personPath); + if (!existing || existing.type !== 'person') { + return errorResult(`Person not found: ${personPath}`); + } + const updated = store.update( + options.workspacePath, + personPath, + { + name: readNonEmptyString(existing.fields.name) ?? '', + ...serializePersonFields(args), + ...(args.fieldUpdates ?? {}), + }, + args.body, + actor, + { + expectedEtag: args.expectedEtag, + }, + ); + return okResult({ person: serializePerson(updated) }, `Updated person ${updated.path}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_person_archive', + { + title: 'Person Archive', + description: 'Archive a native person primitive instance.', + inputSchema: { + path: z.string().min(1), + actor: z.string().optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const personPath = normalizePrimitivePath(args.path); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.person.delete', + target: personPath, + }); + if (!gate.allowed) return errorResult(gate.reason); + const person = store.read(options.workspacePath, personPath); + if (!person || person.type !== 'person') { + return errorResult(`Person not found: ${personPath}`); + } + store.remove(options.workspacePath, personPath, actor); + return okResult( + { + deleted: { + path: personPath, + archivePath: `.workgraph/archive/${path.basename(personPath)}`, + }, + }, + `Archived person ${personPath}.`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); +} + +function serializePerson(person: { path: string; type: string; fields: Record<string, unknown>; body: string }) { + return { + path: person.path, + type: person.type, + name: readNonEmptyString(person.fields.name) ?? person.path, + preferred_name: readNonEmptyString(person.fields.preferred_name) ?? null, + email: readNonEmptyString(person.fields.email) ?? null, + organization: readNonEmptyString(person.fields.organization) ?? null, + fields: person.fields, + body: person.body, + }; +} + +function serializePersonFields(args: { + preferredName?: string; + email?: string; + phone?: string; + phoneSecondary?: string; + role?: string; + jobTitle?: string; + organization?: string; + relationshipContext?: string; + location?: string; + timezone?: string; + communicationPreference?: string; + slackHandle?: string; + whatsappHandle?: string; + telegramHandle?: string; + website?: string; + socialLinks?: string[]; + address?: string; + notes?: string; + client?: string; + projectRefs?: string[]; + tags?: string[]; +}) { + return { + ...(args.preferredName ? { preferred_name: args.preferredName } : {}), + ...(args.email ? { email: args.email } : {}), + ...(args.phone ? { phone: args.phone } : {}), + ...(args.phoneSecondary ? { phone_secondary: args.phoneSecondary } : {}), + ...(args.role ? { role: args.role } : {}), + ...(args.jobTitle ? { job_title: args.jobTitle } : {}), + ...(args.organization ? { organization: args.organization } : {}), + ...(args.relationshipContext ? { relationship_context: args.relationshipContext } : {}), + ...(args.location ? { location: args.location } : {}), + ...(args.timezone ? { timezone: args.timezone } : {}), + ...(args.communicationPreference ? { communication_preference: args.communicationPreference } : {}), + ...(args.slackHandle ? { slack_handle: args.slackHandle } : {}), + ...(args.whatsappHandle ? { whatsapp_handle: args.whatsappHandle } : {}), + ...(args.telegramHandle ? { telegram_handle: args.telegramHandle } : {}), + ...(args.website ? { website: args.website } : {}), + ...(args.socialLinks ? { social_links: args.socialLinks } : {}), + ...(args.address ? { address: args.address } : {}), + ...(args.notes ? { notes: args.notes } : {}), + ...(args.client ? { client: args.client } : {}), + ...(args.projectRefs ? { project_refs: args.projectRefs } : {}), + ...(args.tags ? { tags: args.tags } : {}), + }; +} + +function normalizePrimitivePath(value: string): string { + const trimmed = String(value ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, ''); + if (!trimmed) { + throw new Error('Primitive path is required.'); + } + return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`; +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/mcp-server/src/mcp/tools/primitive-tools.ts b/packages/mcp-server/src/mcp/tools/primitive-tools.ts new file mode 100644 index 0000000..338550a --- /dev/null +++ b/packages/mcp-server/src/mcp/tools/primitive-tools.ts @@ -0,0 +1,251 @@ +import path from 'node:path'; +import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { + registry as registryModule, + store as storeModule, + type PrimitiveInstance, + type PrimitiveTypeDefinition, +} from '@versatly/workgraph-kernel'; +import { checkWriteGate, resolveActor } from '../auth.js'; +import { errorResult, okResult } from '../result.js'; +import { type WorkgraphMcpServerOptions } from '../types.js'; + +const registry = registryModule; +const store = storeModule; + +const concurrentConflictModeSchema = z.enum(['warn', 'error']); + +export function registerPrimitiveTools(server: McpServer, options: WorkgraphMcpServerOptions): void { + server.registerTool( + 'workgraph_primitive_types', + { + title: 'Primitive Types', + description: 'List available primitive types in the workspace registry.', + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async () => { + try { + const types = registry.listTypes(options.workspacePath).map((typeDef) => serializePrimitiveType(typeDef)); + return okResult({ types, count: types.length }, `Primitive type list returned ${types.length} item(s).`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_primitive_get', + { + title: 'Primitive Get', + description: 'Read one primitive instance by workspace-relative path.', + inputSchema: { + path: z.string().min(1), + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + }, + }, + async (args) => { + try { + const primitivePath = normalizePrimitivePath(args.path); + const primitive = store.read(options.workspacePath, primitivePath); + if (!primitive) { + return errorResult(`Primitive not found: ${primitivePath}`); + } + return okResult({ primitive: serializePrimitive(primitive) }, `Primitive ${primitivePath}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_primitive_create', + { + title: 'Primitive Create', + description: 'Create a primitive instance in the shared workspace.', + inputSchema: { + type: z.string().min(1), + actor: z.string().optional(), + fields: z.record(z.string(), z.unknown()), + body: z.string().optional(), + path: z.string().optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const createPath = normalizeOptionalPrimitivePath(args.path); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.primitive.create', + target: createPath ?? `${args.type}s`, + }); + if (!gate.allowed) return errorResult(gate.reason); + const created = store.create( + options.workspacePath, + args.type, + args.fields, + args.body ?? '', + actor, + createPath ? { pathOverride: createPath } : undefined, + ); + return okResult({ primitive: serializePrimitive(created) }, `Created ${created.type} ${created.path}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_primitive_update', + { + title: 'Primitive Update', + description: 'Update a primitive instance by path.', + inputSchema: { + path: z.string().min(1), + actor: z.string().optional(), + fieldUpdates: z.record(z.string(), z.unknown()).optional(), + body: z.string().optional(), + expectedEtag: z.string().optional(), + concurrentConflictMode: concurrentConflictModeSchema.optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const primitivePath = normalizePrimitivePath(args.path); + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.primitive.update', + target: primitivePath, + }); + if (!gate.allowed) return errorResult(gate.reason); + const updated = store.update( + options.workspacePath, + primitivePath, + args.fieldUpdates ?? {}, + args.body, + actor, + { + expectedEtag: args.expectedEtag, + concurrentConflictMode: args.concurrentConflictMode, + }, + ); + return okResult({ primitive: serializePrimitive(updated) }, `Updated ${updated.type} ${updated.path}.`); + } catch (error) { + return errorResult(error); + } + }, + ); + + server.registerTool( + 'workgraph_primitive_delete', + { + title: 'Primitive Delete', + description: 'Archive a primitive instance by path.', + inputSchema: { + path: z.string().min(1), + actor: z.string().optional(), + }, + annotations: { + destructiveHint: true, + idempotentHint: false, + }, + }, + async (args) => { + try { + const actor = resolveActor(options.workspacePath, args.actor, options.defaultActor); + const primitivePath = normalizePrimitivePath(args.path); + const existing = store.read(options.workspacePath, primitivePath); + if (!existing) { + return errorResult(`Primitive not found: ${primitivePath}`); + } + const gate = checkWriteGate(options, actor, ['mcp:write'], { + action: 'mcp.primitive.delete', + target: primitivePath, + }); + if (!gate.allowed) return errorResult(gate.reason); + store.remove(options.workspacePath, primitivePath, actor); + return okResult( + { + deleted: { + path: primitivePath, + type: existing.type, + archivedPath: `.workgraph/archive/${path.basename(primitivePath)}`, + }, + }, + `Archived ${existing.type} ${primitivePath}.`, + ); + } catch (error) { + return errorResult(error); + } + }, + ); +} + +function serializePrimitiveType(typeDef: PrimitiveTypeDefinition) { + return { + name: typeDef.name, + description: typeDef.description, + directory: typeDef.directory, + builtIn: typeDef.builtIn, + retained: typeDef.retained, + canonical: typeDef.retained, + createdAt: typeDef.createdAt, + createdBy: typeDef.createdBy, + fieldCount: Object.keys(typeDef.fields).length, + }; +} + +function serializePrimitive(primitive: PrimitiveInstance) { + return { + path: primitive.path, + type: primitive.type, + title: readNonEmptyString(primitive.fields.title) ?? readNonEmptyString(primitive.fields.name) ?? primitive.path, + fields: primitive.fields, + body: primitive.body, + }; +} + +function normalizePrimitivePath(value: string): string { + const trimmed = String(value ?? '').trim().replace(/\\/g, '/'); + if (!trimmed) { + throw new Error('Primitive path is required.'); + } + const withoutPrefix = trimmed.replace(/^\.\//, ''); + const normalized = path.posix.normalize(withoutPrefix); + if ( + normalized.length === 0 || + normalized === '.' || + normalized.startsWith('../') || + normalized === '..' || + path.posix.isAbsolute(normalized) + ) { + throw new Error(`Invalid primitive path "${value}".`); + } + return normalized.endsWith('.md') ? normalized : `${normalized}.md`; +} + +function normalizeOptionalPrimitivePath(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? normalizePrimitivePath(trimmed) : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/mcp-server/src/mcp/tools/read-tools.ts b/packages/mcp-server/src/mcp/tools/read-tools.ts index b247ceb..0e7d037 100644 --- a/packages/mcp-server/src/mcp/tools/read-tools.ts +++ b/packages/mcp-server/src/mcp/tools/read-tools.ts @@ -286,6 +286,8 @@ export function registerReadTools(server: McpServer, options: WorkgraphMcpServer description: typeDef.description, directory: typeDef.directory, builtIn: typeDef.builtIn, + retained: typeDef.retained, + canonical: typeDef.retained, fields, }, `Primitive schema for ${typeDef.name}.`, diff --git a/tests/cli-person-commands.test.ts b/tests/cli-person-commands.test.ts new file mode 100644 index 0000000..e09dc1e --- /dev/null +++ b/tests/cli-person-commands.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +let workspacePath: string; + +beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-cli-person-')); +}); + +afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }); +}); + +describe('CLI person commands', () => { + it('supports native person CRUD and primitive-type discovery through the built CLI', async () => { + await runCli('init', workspacePath, '--no-readme', '--no-bases', '--json'); + + const types = await runCli('primitive-type', 'list', '-w', workspacePath, '--json'); + expect(types.data.types.some((entry: { name: string; retained: boolean }) => entry.name === 'person' && entry.retained)).toBe(true); + + const created = await runCli( + 'person', 'create', 'Ada Lovelace', + '-w', workspacePath, + '--email', 'ada@example.com', + '--job-title', 'Technical advisor', + '--organization', 'Analytical Engine Society', + '--tags', 'vip,advisor', + '--body', 'Primary stakeholder profile.', + '--json', + ); + expect(created.data.path).toBe('people/ada-lovelace.md'); + expect(created.data.fields.email).toBe('ada@example.com'); + expect(created.data.fields.job_title).toBe('Technical advisor'); + + const listed = await runCli('person', 'list', '-w', workspacePath, '--json'); + expect(listed.data.count).toBe(1); + expect(listed.data.people[0].path).toBe('people/ada-lovelace.md'); + + const shown = await runCli('primitive', 'show', 'people/ada-lovelace.md', '-w', workspacePath, '--json'); + expect(shown.data.type).toBe('person'); + expect(shown.data.fields.organization).toBe('Analytical Engine Society'); + + const updated = await runCli( + 'person', 'update', 'people/ada-lovelace.md', + '-w', workspacePath, + '--timezone', 'Europe/London', + '--slack-handle', '@ada', + '--json', + ); + expect(updated.data.fields.timezone).toBe('Europe/London'); + expect(updated.data.fields.slack_handle).toBe('@ada'); + + const archived = await runCli('person', 'archive', 'people/ada-lovelace.md', '-w', workspacePath, '--json'); + expect(archived.data.archived.path).toBe('people/ada-lovelace.md'); + expect(fs.existsSync(path.join(workspacePath, 'people/ada-lovelace.md'))).toBe(false); + expect(fs.existsSync(path.join(workspacePath, '.workgraph/archive/ada-lovelace.md'))).toBe(true); + }); +}); + +async function runCli(...args: string[]) { + const { stdout } = await execFileAsync('node', [path.join('/workspace', 'dist/cli.js'), ...args], { + cwd: '/workspace', + env: { + ...process.env, + WORKGRAPH_JSON: '1', + }, + }); + return JSON.parse(stdout) as { + ok: boolean; + data: any; + }; +}