diff --git a/README.md b/README.md index 2fa6cfc..8b2e038 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Relay +The approved agent-integration contract is documented in the [decision record](docs/decisions/0002-agent-integration-contracts.md), [MCP tool reference](docs/mcp-tools.md), [CLI reference](docs/cli-reference.md), and [session semantics](docs/session-semantics.md). These are contract-only artifacts: production MCP and CLI task handlers remain downstream work. + Relay is a local task sidecar for human–AI workflows. The current MVP is usable directly through its local web UI: it stores tasks on this computer and exposes a loopback-only HTTP API behind the UI. Production MCP task tools and companion skills are future work tracked separately under issue #2. ## Prerequisites and setup diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0bd0d12..ebac529 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,20 +1,59 @@ # Relay CLI Contract Reference -This document is reserved by issue #19 and is governed by `docs/decisions/0002-agent-integration-contracts.md` and `docs/superpowers/plans/2026-07-26-agent-integration-contracts.md`. - -The source-checkout CLI is implemented by issue #22. Issue #19 defines the stable future-facing command shape: +Issue #19 reserves a deterministic, versioned CLI contract. Production command handlers are implemented later; the stable executable surface is one `relay` command: ```text -relay task capture -relay task list -relay task get -relay task find-similar -relay task edit -relay task triage -relay task start -relay task complete -relay task archive -relay session captures +relay mcp +relay ui +relay doctor +relay task ... +relay session ... ``` -Agent-facing commands must support deterministic JSON output with schema version `1`. JSON output is authoritative; diagnostics go to stderr. Do not introduce a generic unrestricted status command. +`relay-mcp` may remain as a compatibility entry point, but new integrations target `relay mcp`. + +## JSON protocol + +Every agent-facing command accepts `--output json`. JSON mode writes one JSON document followed by a newline to stdout; diagnostics are written to stderr. No caller needs to parse decorative output. + +```json +{ "schemaVersion": 1, "ok": true, "data": {}, "warnings": [] } +``` + +Error details are optional and never expose SQL, stacks, secrets, or local paths. + +```json +{ + "schemaVersion": 1, + "ok": false, + "error": { "code": "VALIDATION_ERROR", "message": "sessionId has an invalid format" } +} +``` + +| Exit code | Meaning | Error codes | +| --------- | ------------------------------------ | --------------------------- | +| 0 | Success, warnings, or approved no-op | — | +| 1 | Unexpected internal failure | `INTERNAL_ERROR` | +| 2 | Usage or validation failure | `VALIDATION_ERROR` | +| 3 | Task absent | `NOT_FOUND` | +| 4 | Invalid lifecycle operation | `CONFLICT`, `ARCHIVED_TASK` | +| 5 | Storage failure | `STORAGE_ERROR` | + +## Commands + +| Command | Required arguments | Optional arguments | Result | +| -------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `relay task capture` | `--title`, `--agent`, `--session` | `--description`, `--priority`, `--workspace`, `--source-context`, `--output json` | `{ task, change: { action: "CREATED" } }` and optional warnings | +| `relay task list` | — | repeatable `--status`, `--workspace`, `--limit 1..100`, `--output json` | `{ tasks, count }` | +| `relay task get ` | ID | `--output json` | `{ task }` | +| `relay task find-similar` | `--title` | `--workspace`, `--limit 1..5`, `--output json` | `{ candidates }` | +| `relay session captures` | `--session` | `--limit 1..100`, `--output json` | `{ sessionId, tasks, count }` | +| `relay task edit ` | ID and an editable field | clear flags and `--output json` | `{ task, change }` | +| `relay task triage ` | ID and `--to INBOX`, `ACTIVE`, or `BACKLOG` | `--output json` | `{ task, change }` | +| `relay task start ` | ID | `--output json` | `{ task, change }` | +| `relay task complete ` | ID | `--output json` | `{ task, change }` | +| `relay task archive ` | ID | `--output json` | `{ task, change }` | + +Editing accepts existing editable fields only. Clear nullable fields with explicit flags such as `--clear-description`; empty strings and MCP `null` values are rejected rather than treated as clearing requests. A value and its corresponding clear flag cannot be supplied together. `task triage` excludes `IN_PROGRESS`, `DONE`, and `ARCHIVED`, because those transitions have dedicated intent-specific commands. + +CLI commands call application services and reuse `src/database/database-config.ts`; they never access SQLite directly. Database path precedence is explicit command/injected path, non-blank `RELAY_DB_PATH`, then the platform default. The working directory is never production storage configuration. diff --git a/docs/decisions/0002-agent-integration-contracts.md b/docs/decisions/0002-agent-integration-contracts.md index 9431078..362dbaf 100644 --- a/docs/decisions/0002-agent-integration-contracts.md +++ b/docs/decisions/0002-agent-integration-contracts.md @@ -2,7 +2,7 @@ ## Status -Proposed for review under GitHub issue #19. +Accepted for implementation under GitHub issue #19. ## Context diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 9b9a3ae..5948a75 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -1,20 +1,49 @@ # Relay MCP Tool Contracts -This document is reserved by issue #19 and is governed by `docs/decisions/0002-agent-integration-contracts.md` and `docs/superpowers/plans/2026-07-26-agent-integration-contracts.md`. +Issue #19 defines this version `1` contract only. It does not implement production MCP task handlers. Every tool returns structured `{ schemaVersion: 1, data, warnings }`; errors use `VALIDATION_ERROR`, `NOT_FOUND`, `CONFLICT`, `ARCHIVED_TASK`, `STORAGE_ERROR`, or `INTERNAL_ERROR` without stack traces, SQLite details, secrets, or local paths. Compact text is a compatibility supplement, never a parsing requirement. -Production MCP tools are implemented by issues #20 and #21. Issue #19 defines the approved names, versioning, error model, task representation, session semantics, and result envelopes before those handlers are written. +## `task_capture` -Canonical tool names: +Input: required `title`, `createdByName`, and `sessionId`; optional `description`, `priority`, `workspace`, and `sourceContext`. The adapter—not the caller—sets `createdByType: AGENT` and `status: INBOX`; caller-supplied provenance or status is invalid. Output: `{ task, change: { action: "CREATED" } }`, with optional advisory `POSSIBLE_DUPLICATE` warnings. Capture always succeeds when a duplicate warning is returned. -- `task_capture` -- `task_list` -- `task_get` -- `task_find_similar` -- `session_captures_list` -- `task_edit` -- `task_triage` -- `task_start` -- `task_complete` -- `task_archive` +## `task_list` -Do not add an unrestricted generic task update or status mutation tool. +Input: optional non-empty `statuses`, `workspace`, and `limit` from 1 through 100. Output: `{ tasks, count }`. This is a bounded read and has no lifecycle side effects. + +## `task_get` + +Input: required non-empty task ID. Output: `{ task }`. A missing ID maps to `NOT_FOUND`. + +## `task_find_similar` + +Input: required `title`, optional `workspace`, and `limit` from 1 through 5 (default 5). Output: `{ candidates }`, where each candidate carries a task and stable `matchReason`. Matching is bounded, normalized-title based, non-archived, and advisory; it never merges or changes tasks. + +## `session_captures_list` + +Input: required valid `sessionId` and `limit` from 1 through 100. Output: `{ sessionId, tasks, count }`. It selects only agent-created tasks with an exact persisted ID, includes completed and archived tasks, and orders by `createdAt ASC, id ASC`. + +## `task_edit` + +Input: task ID plus one or more editable task fields or explicit clear flags. MCP `null` is rejected; explicit `clear*` flags are the only clear operation, and a value cannot accompany its matching flag. Output: `{ task, change }`, including `NO_CHANGE` for an approved no-op. `sessionId`, provenance, status, and lifecycle timestamps are never editable. + +## `task_triage` + +Input: task ID and target `INBOX`, `ACTIVE`, or `BACKLOG`. Output: `{ task, change }`. `IN_PROGRESS`, `DONE`, and `ARCHIVED` have their own intent-specific tools. + +## `task_start` + +Input: task ID. Output: `{ task, change }`. It performs only the focused start lifecycle operation. + +## `task_complete` + +Input: task ID. Output: `{ task, change }`. It performs only the focused completion lifecycle operation. + +## `task_archive` + +Input: task ID. Output: `{ task, change }`. It performs only the focused archive lifecycle operation. + +## Mutation safety and versioning + +Invoke `task_edit`, `task_triage`, `task_start`, `task_complete`, and `task_archive` only after explicit user direction in the active conversation. Relay validates data and lifecycle legality but cannot authenticate conversational intent under the OS-user trust boundary, so it intentionally has no fake `confirmed`, `requestedBy`, or copied-user-text field. + +There is no `task_update`, `task_set_status`, generic CRUD mutation, or unrestricted lifecycle command. Tool names are not version-prefixed; a breaking change requires a new integer schema version and an explicit compatibility decision. Later issues implement every handler through shared application services; MCP never reads SQLite directly. diff --git a/docs/session-semantics.md b/docs/session-semantics.md index 4b9e072..508e6f0 100644 --- a/docs/session-semantics.md +++ b/docs/session-semantics.md @@ -1,20 +1,23 @@ # Relay Session Semantics -Issue #19 defines `sessionId` as an opaque caller-generated identifier stored as task metadata. +`sessionId` is opaque caller-generated metadata on agent-created tasks. It is not a session table, aggregate, timer, or authentication mechanism. -- No session table is introduced in Epic #2. -- Agent capture requires a valid session ID. -- MCP and CLI callers reuse the same ID for captures and final review. -- Concurrent sessions use distinct IDs. -- Session review selects agent-created tasks whose persisted `sessionId` exactly matches the requested ID. -- Completed and archived tasks remain visible in session review. -- Results are ordered by `createdAt ASC, id ASC`. -- Session completion is never inferred through timers, inactivity, or process lifetime. +## Identifier rules -Validation: +- MCP clients and CLI callers generate IDs in the same namespace. +- Trim surrounding whitespace before validation. +- A valid ID has 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`. +- Agent capture and session-capture retrieval require a valid ID; malformed or missing input is `VALIDATION_ERROR`. +- Human tasks may have `sessionId: null`. -- trim surrounding whitespace -- length 1–128 characters -- allowed characters: ASCII letters, digits, `.`, `_`, `:`, `-` +An agent reuses the same identifier while capturing and reviewing work in one active session. Different concurrent agents or shells use different IDs, so their capture groups remain isolated. Completion is initiated by the agent or user and is never persisted or inferred from process exit, timers, or inactivity. -The production task-model, migration, repository, and application changes are implemented downstream under issue #20. +## Deterministic capture membership + +“Captured during this session” means a task was originally created with `createdByType = AGENT` and its persisted `sessionId` exactly equals the requested identifier. The query uses persisted metadata, not timestamps or process lifetime. + +Session review includes captures in every lifecycle state, including `DONE` and `ARCHIVED`, and sorts them by `createdAt ASC, id ASC`. + +## Downstream implementation boundary + +Issue #19 documents `sessionId` in the external task representation and validates contract input only. Issue #20 adds the nullable domain/persistence field, migration, repository mapping, agent-capture application input, and session-capture query support. No production session storage or query handler is introduced here. diff --git a/docs/superpowers/plans/2026-07-26-agent-integration-contracts.md b/docs/superpowers/plans/2026-07-26-agent-integration-contracts.md index 5d6d6b6..dc0e00b 100644 --- a/docs/superpowers/plans/2026-07-26-agent-integration-contracts.md +++ b/docs/superpowers/plans/2026-07-26-agent-integration-contracts.md @@ -21,18 +21,18 @@ The contract is versioned independently from the Relay package version using int ### 2.1 Canonical capabilities -| Capability | MCP tool | CLI command | -|---|---|---| -| Capture | `task_capture` | `relay task capture` | -| List | `task_list` | `relay task list` | -| Get | `task_get` | `relay task get ` | -| Find similar | `task_find_similar` | `relay task find-similar` | -| Session captures | `session_captures_list` | `relay session captures` | -| Edit | `task_edit` | `relay task edit ` | -| Triage | `task_triage` | `relay task triage ` | -| Start | `task_start` | `relay task start ` | -| Complete | `task_complete` | `relay task complete ` | -| Archive | `task_archive` | `relay task archive ` | +| Capability | MCP tool | CLI command | +| ---------------- | ----------------------- | -------------------------- | +| Capture | `task_capture` | `relay task capture` | +| List | `task_list` | `relay task list` | +| Get | `task_get` | `relay task get ` | +| Find similar | `task_find_similar` | `relay task find-similar` | +| Session captures | `session_captures_list` | `relay session captures` | +| Edit | `task_edit` | `relay task edit ` | +| Triage | `task_triage` | `relay task triage ` | +| Start | `task_start` | `relay task start ` | +| Complete | `task_complete` | `relay task complete ` | +| Archive | `task_archive` | `relay task archive ` | Do not expose generic CRUD or unrestricted status mutation. @@ -220,14 +220,14 @@ Codes: CLI exit codes: -| Exit | Meaning | -|---|---| -| `0` | success, including warnings or approved no-op | -| `1` | unexpected internal error | -| `2` | command usage or validation error | -| `3` | task not found | -| `4` | lifecycle conflict or archived-task restriction | -| `5` | database/storage failure | +| Exit | Meaning | +| ---- | ----------------------------------------------- | +| `0` | success, including warnings or approved no-op | +| `1` | unexpected internal error | +| `2` | command usage or validation error | +| `3` | task not found | +| `4` | lifecycle conflict or archived-task restriction | +| `5` | database/storage failure | Error JSON carries the precise code; do not create an exit code for every domain error. diff --git a/scripts/validate-repository-assets.ts b/scripts/validate-repository-assets.ts index 1c6b497..56e0fbd 100644 --- a/scripts/validate-repository-assets.ts +++ b/scripts/validate-repository-assets.ts @@ -120,11 +120,27 @@ export function validateRepositoryAssets(options: ValidateRepositoryAssetsOption 'eslint.config.js', 'package.json', 'README.md', + 'docs/decisions/0002-agent-integration-contracts.md', + 'docs/mcp-tools.md', + 'docs/cli-reference.md', + 'docs/session-semantics.md', + 'tests/fixtures/contracts/capture-success.json', + 'tests/fixtures/contracts/capture-duplicate-warning.json', + 'tests/fixtures/contracts/validation-error.json', + 'tests/fixtures/contracts/not-found-error.json', + 'tests/fixtures/contracts/transition-conflict-error.json', + 'tests/fixtures/contracts/storage-error.json', 'tsconfig.base.json', 'src/application/health/get-health.ts', 'src/database/connection.ts', 'src/interfaces/mcp/create-mcp-server.ts', 'src/interfaces/http/create-http-server.ts', + 'src/interfaces/contracts/contract-version.ts', + 'src/interfaces/contracts/error-contract.ts', + 'src/interfaces/contracts/json-value-contract.ts', + 'src/interfaces/contracts/session-contract.ts', + 'src/interfaces/contracts/task-contract.ts', + 'src/interfaces/contracts/warning-contract.ts', 'web/src/App.tsx', ]; diff --git a/src/interfaces/contracts/contract-version.ts b/src/interfaces/contracts/contract-version.ts new file mode 100644 index 0000000..032e27b --- /dev/null +++ b/src/interfaces/contracts/contract-version.ts @@ -0,0 +1 @@ +export const CONTRACT_SCHEMA_VERSION = 1; diff --git a/src/interfaces/contracts/error-contract.ts b/src/interfaces/contracts/error-contract.ts new file mode 100644 index 0000000..c86ad01 --- /dev/null +++ b/src/interfaces/contracts/error-contract.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { jsonValueSchema } from './json-value-contract.js'; + +export const CONTRACT_ERROR_CODES = [ + 'VALIDATION_ERROR', + 'NOT_FOUND', + 'CONFLICT', + 'ARCHIVED_TASK', + 'STORAGE_ERROR', + 'INTERNAL_ERROR', +] as const; + +export type ContractErrorCode = (typeof CONTRACT_ERROR_CODES)[number]; + +export const contractErrorSchema = z + .object({ + code: z.enum(CONTRACT_ERROR_CODES), + message: z.string().min(1), + details: z.record(z.string(), jsonValueSchema).optional(), + }) + .strict(); + +const EXIT_CODES: Record = { + VALIDATION_ERROR: 2, + NOT_FOUND: 3, + CONFLICT: 4, + ARCHIVED_TASK: 4, + STORAGE_ERROR: 5, + INTERNAL_ERROR: 1, +}; + +export function errorCodeToExitCode(code: ContractErrorCode): number { + return EXIT_CODES[code]; +} diff --git a/src/interfaces/contracts/json-value-contract.ts b/src/interfaces/contracts/json-value-contract.ts new file mode 100644 index 0000000..6496f1a --- /dev/null +++ b/src/interfaces/contracts/json-value-contract.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export type JsonValue = + null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue }; + +export const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); diff --git a/src/interfaces/contracts/session-contract.ts b/src/interfaces/contracts/session-contract.ts new file mode 100644 index 0000000..92a3b48 --- /dev/null +++ b/src/interfaces/contracts/session-contract.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; + +export const sessionIdSchema = z + .string() + .trim() + .min(1, 'sessionId is required') + .max(128, 'sessionId must be at most 128 characters') + .regex(SESSION_ID_PATTERN, 'sessionId has an invalid format'); + +export const sessionCapturesInputSchema = z + .object({ + sessionId: sessionIdSchema, + limit: z.number().int().min(1).max(100).default(100), + }) + .strict(); + +export function parseSessionId(value: unknown): string { + return sessionIdSchema.parse(value); +} diff --git a/src/interfaces/contracts/task-contract.ts b/src/interfaces/contracts/task-contract.ts new file mode 100644 index 0000000..c5a4f73 --- /dev/null +++ b/src/interfaces/contracts/task-contract.ts @@ -0,0 +1,161 @@ +import { z } from 'zod'; +import { sessionIdSchema } from './session-contract.js'; + +const nullableText = (maximum: number) => z.string().trim().min(1).max(maximum).nullable(); +const optionalText = (maximum: number) => nullableText(maximum).optional(); +const optionalEditableText = (maximum: number) => z.string().trim().min(1).max(maximum).optional(); +const taskStatusSchema = z.enum(['INBOX', 'ACTIVE', 'IN_PROGRESS', 'BACKLOG', 'DONE', 'ARCHIVED']); +const taskPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH']); + +export const taskIdSchema = z.string().trim().min(1).max(100); + +export const taskDtoSchema = z + .object({ + id: z.string().min(1).max(100), + title: z.string().trim().min(1).max(300), + description: nullableText(10_000), + status: taskStatusSchema, + priority: taskPrioritySchema.nullable(), + workspace: nullableText(255), + sourceContext: nullableText(1_000), + createdByType: z.enum(['HUMAN', 'AGENT']), + createdByName: nullableText(100), + sessionId: sessionIdSchema.nullable(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), + startedAt: z.string().datetime({ offset: true }).nullable(), + completedAt: z.string().datetime({ offset: true }).nullable(), + archivedAt: z.string().datetime({ offset: true }).nullable(), + }) + .strict(); + +export const agentCaptureInputSchema = z + .object({ + title: z.string().trim().min(1).max(300), + description: optionalText(10_000), + priority: taskPrioritySchema.nullable().optional(), + workspace: optionalText(255), + sourceContext: optionalText(1_000), + createdByName: z.string().trim().min(1).max(100), + sessionId: sessionIdSchema, + }) + .strict(); + +export const taskListInputSchema = z + .object({ + statuses: z.array(taskStatusSchema).min(1).optional(), + workspace: optionalText(255), + limit: z.number().int().min(1).max(100).default(100), + }) + .strict(); + +export const findSimilarInputSchema = z + .object({ + title: z.string().trim().min(1).max(300), + workspace: optionalText(255), + limit: z.number().int().min(1).max(5).default(5), + }) + .strict(); + +const editableInputFields = { + title: z.string().trim().min(1).max(300).optional(), + description: optionalEditableText(10_000), + priority: taskPrioritySchema.optional(), + workspace: optionalEditableText(255), + sourceContext: optionalEditableText(1_000), + clearDescription: z.literal(true).optional(), + clearPriority: z.literal(true).optional(), + clearWorkspace: z.literal(true).optional(), + clearSourceContext: z.literal(true).optional(), +}; + +const hasEditableInput = (input: Record) => + Object.entries(input).some(([key, value]) => key !== 'taskId' && value !== undefined); + +const clearDirectivePairs = [ + { field: 'description', clearField: 'clearDescription' }, + { field: 'priority', clearField: 'clearPriority' }, + { field: 'workspace', clearField: 'clearWorkspace' }, + { field: 'sourceContext', clearField: 'clearSourceContext' }, +] as const; + +const hasConflictingClearDirective = (input: Record) => + clearDirectivePairs.some( + ({ field, clearField }) => input[field] !== undefined && input[clearField] === true, + ); + +export const mutationInputSchema = z + .object(editableInputFields) + .strict() + .refine(hasEditableInput, { + message: 'At least one editable task field is required.', + }) + .refine((input) => !hasConflictingClearDirective(input), { + message: 'An editable field cannot be supplied with its clear flag.', + }); + +export const taskEditInputSchema = z + .object({ taskId: taskIdSchema, ...editableInputFields }) + .strict() + .refine(hasEditableInput, { message: 'At least one editable task field is required.' }) + .refine((input) => !hasConflictingClearDirective(input), { + message: 'An editable field cannot be supplied with its clear flag.', + }); + +export const taskTriageInputSchema = z + .object({ + taskId: taskIdSchema, + target: z.enum(['INBOX', 'ACTIVE', 'BACKLOG']), + }) + .strict(); + +export const taskGetInputSchema = z.object({ taskId: taskIdSchema }).strict(); +export const taskStartInputSchema = z.object({ taskId: taskIdSchema }).strict(); +export const taskCompleteInputSchema = z.object({ taskId: taskIdSchema }).strict(); +export const taskArchiveInputSchema = z.object({ taskId: taskIdSchema }).strict(); + +export const taskChangeSchema = z + .object({ + action: z.enum([ + 'CREATED', + 'NO_CHANGE', + 'EDITED', + 'TRIAGED', + 'STARTED', + 'COMPLETED', + 'ARCHIVED', + ]), + }) + .strict(); + +const taskResultSchema = (actions: readonly [string, ...string[]]) => + z + .object({ + task: taskDtoSchema, + change: z.object({ action: z.enum(actions) }).strict(), + }) + .strict(); + +export const taskCaptureResultSchema = taskResultSchema(['CREATED']); +export const taskGetResultSchema = z.object({ task: taskDtoSchema }).strict(); +export const taskListResultSchema = z + .object({ tasks: z.array(taskDtoSchema), count: z.number().int().nonnegative() }) + .strict(); +export const similarCandidateSchema = z + .object({ task: taskDtoSchema, matchReason: z.enum(['EXACT_TITLE', 'NORMALIZED_TITLE']) }) + .strict(); +export const taskFindSimilarResultSchema = z + .object({ candidates: z.array(similarCandidateSchema).max(5) }) + .strict(); +export const sessionCapturesResultSchema = z + .object({ + sessionId: sessionIdSchema, + tasks: z.array(taskDtoSchema), + count: z.number().int().nonnegative(), + }) + .strict(); +export const taskEditResultSchema = taskResultSchema(['EDITED', 'NO_CHANGE']); +export const taskTriageResultSchema = taskResultSchema(['TRIAGED', 'NO_CHANGE']); +export const taskStartResultSchema = taskResultSchema(['STARTED', 'NO_CHANGE']); +export const taskCompleteResultSchema = taskResultSchema(['COMPLETED', 'NO_CHANGE']); +export const taskArchiveResultSchema = taskResultSchema(['ARCHIVED', 'NO_CHANGE']); diff --git a/src/interfaces/contracts/warning-contract.ts b/src/interfaces/contracts/warning-contract.ts new file mode 100644 index 0000000..ed616cc --- /dev/null +++ b/src/interfaces/contracts/warning-contract.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { CONTRACT_SCHEMA_VERSION } from './contract-version.js'; +import { contractErrorSchema } from './error-contract.js'; +import { jsonValueSchema } from './json-value-contract.js'; + +export const duplicateWarningSchema = z + .object({ + code: z.literal('POSSIBLE_DUPLICATE'), + message: z.string().min(1), + candidates: z.array(z.object({ id: z.string().min(1) }).strict()).max(5), + }) + .strict(); + +export const warningSchema = z.union([duplicateWarningSchema]); + +export const cliSuccessEnvelopeSchema = z + .object({ + schemaVersion: z.literal(CONTRACT_SCHEMA_VERSION), + ok: z.literal(true), + data: jsonValueSchema, + warnings: z.array(warningSchema), + }) + .strict(); + +export const cliErrorEnvelopeSchema = z + .object({ + schemaVersion: z.literal(CONTRACT_SCHEMA_VERSION), + ok: z.literal(false), + error: contractErrorSchema, + }) + .strict(); diff --git a/tests/fixtures/contracts/capture-duplicate-warning.json b/tests/fixtures/contracts/capture-duplicate-warning.json new file mode 100644 index 0000000..a78be3d --- /dev/null +++ b/tests/fixtures/contracts/capture-duplicate-warning.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "ok": true, + "data": { + "task": { + "id": "task-2", + "title": "Capture contract work", + "description": null, + "status": "INBOX", + "priority": null, + "workspace": null, + "sourceContext": null, + "createdByType": "AGENT", + "createdByName": "Relay agent", + "sessionId": "session-1", + "createdAt": "2026-07-26T00:01:00.000Z", + "updatedAt": "2026-07-26T00:01:00.000Z", + "startedAt": null, + "completedAt": null, + "archivedAt": null + }, + "change": { "action": "CREATED" } + }, + "warnings": [ + { + "code": "POSSIBLE_DUPLICATE", + "message": "A similar active task exists.", + "candidates": [{ "id": "task-1" }] + } + ] +} diff --git a/tests/fixtures/contracts/capture-success.json b/tests/fixtures/contracts/capture-success.json new file mode 100644 index 0000000..6fe4633 --- /dev/null +++ b/tests/fixtures/contracts/capture-success.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "ok": true, + "data": { + "task": { + "id": "task-1", + "title": "Capture contract work", + "description": null, + "status": "INBOX", + "priority": null, + "workspace": null, + "sourceContext": null, + "createdByType": "AGENT", + "createdByName": "Relay agent", + "sessionId": "session-1", + "createdAt": "2026-07-26T00:00:00.000Z", + "updatedAt": "2026-07-26T00:00:00.000Z", + "startedAt": null, + "completedAt": null, + "archivedAt": null + }, + "change": { "action": "CREATED" } + }, + "warnings": [] +} diff --git a/tests/fixtures/contracts/not-found-error.json b/tests/fixtures/contracts/not-found-error.json new file mode 100644 index 0000000..a3d3a74 --- /dev/null +++ b/tests/fixtures/contracts/not-found-error.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "ok": false, + "error": { "code": "NOT_FOUND", "message": "Task was not found." } +} diff --git a/tests/fixtures/contracts/storage-error.json b/tests/fixtures/contracts/storage-error.json new file mode 100644 index 0000000..e8e6f4b --- /dev/null +++ b/tests/fixtures/contracts/storage-error.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "ok": false, + "error": { "code": "STORAGE_ERROR", "message": "Relay could not store the task." } +} diff --git a/tests/fixtures/contracts/transition-conflict-error.json b/tests/fixtures/contracts/transition-conflict-error.json new file mode 100644 index 0000000..de3de04 --- /dev/null +++ b/tests/fixtures/contracts/transition-conflict-error.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "ok": false, + "error": { "code": "CONFLICT", "message": "Task cannot transition to the requested state." } +} diff --git a/tests/fixtures/contracts/validation-error.json b/tests/fixtures/contracts/validation-error.json new file mode 100644 index 0000000..67df957 --- /dev/null +++ b/tests/fixtures/contracts/validation-error.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "ok": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "sessionId has an invalid format", + "details": { "field": "sessionId" } + } +} diff --git a/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts b/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts new file mode 100644 index 0000000..2e0949d --- /dev/null +++ b/tests/unit/interfaces/contracts/agent-integration-contracts.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CONTRACT_SCHEMA_VERSION } from '../../../../src/interfaces/contracts/contract-version.js'; +import { + CONTRACT_ERROR_CODES, + contractErrorSchema, + errorCodeToExitCode, +} from '../../../../src/interfaces/contracts/error-contract.js'; +import { + agentCaptureInputSchema, + findSimilarInputSchema, + mutationInputSchema, + taskArchiveInputSchema, + taskCaptureResultSchema, + taskCompleteInputSchema, + taskDtoSchema, + taskEditInputSchema, + taskGetInputSchema, + taskListInputSchema, + taskStartInputSchema, + taskStartResultSchema, + taskTriageInputSchema, +} from '../../../../src/interfaces/contracts/task-contract.js'; +import { + parseSessionId, + sessionCapturesInputSchema, +} from '../../../../src/interfaces/contracts/session-contract.js'; +import { + cliErrorEnvelopeSchema, + cliSuccessEnvelopeSchema, + duplicateWarningSchema, +} from '../../../../src/interfaces/contracts/warning-contract.js'; + +const FIXTURE_DIRECTORY = join(process.cwd(), 'tests', 'fixtures', 'contracts'); +const VALID_TASK = { + id: 'task-1', + title: 'Capture', + description: null, + status: 'INBOX', + priority: null, + workspace: null, + sourceContext: null, + createdByType: 'AGENT', + createdByName: 'Relay agent', + sessionId: 'session-1', + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, +}; + +describe('agent integration contracts', () => { + it('uses schema version one and maps every stable error to the documented exit code', () => { + expect(CONTRACT_SCHEMA_VERSION).toBe(1); + expect(CONTRACT_ERROR_CODES).toEqual([ + 'VALIDATION_ERROR', + 'NOT_FOUND', + 'CONFLICT', + 'ARCHIVED_TASK', + 'STORAGE_ERROR', + 'INTERNAL_ERROR', + ]); + expect( + Object.fromEntries(CONTRACT_ERROR_CODES.map((code) => [code, errorCodeToExitCode(code)])), + ).toEqual({ + VALIDATION_ERROR: 2, + NOT_FOUND: 3, + CONFLICT: 4, + ARCHIVED_TASK: 4, + STORAGE_ERROR: 5, + INTERNAL_ERROR: 1, + }); + }); + + it.each([ + ['agent:session-1', 'agent:session-1'], + [' relay_2026.07-26 ', 'relay_2026.07-26'], + ])('normalizes valid session identifiers', (value, expected) => { + expect(parseSessionId(value)).toBe(expected); + }); + + it.each(['', ' ', 'contains/slash', 'emoji-😀', 'a'.repeat(129)])( + 'rejects malformed session identifiers: %j', + (value) => { + expect(() => parseSessionId(value)).toThrow(/sessionId/i); + }, + ); + + it('limits list and similar-task requests and exposes only focused mutation inputs', () => { + expect(taskListInputSchema.safeParse({ limit: 101 }).success).toBe(false); + expect(taskListInputSchema.safeParse({ limit: 100 }).success).toBe(true); + expect(sessionCapturesInputSchema.safeParse({ sessionId: 's', limit: 101 }).success).toBe( + false, + ); + expect( + agentCaptureInputSchema.safeParse({ + title: 'Capture', + createdByName: 'Relay agent', + sessionId: 'session-1', + status: 'ACTIVE', + }).success, + ).toBe(false); + expect( + agentCaptureInputSchema.safeParse({ + title: 'Capture', + createdByName: 'Relay agent', + sessionId: 'session-1', + createdByType: 'HUMAN', + }).success, + ).toBe(false); + expect(findSimilarInputSchema.safeParse({ title: 'Capture', limit: 6 }).success).toBe(false); + expect(findSimilarInputSchema.parse({ title: 'Capture' }).limit).toBe(5); + expect(mutationInputSchema.safeParse({ title: 'Edited', sessionId: 'session-1' }).success).toBe( + false, + ); + expect(taskEditInputSchema.safeParse({ taskId: 'task-1', title: 'Edited' }).success).toBe(true); + expect(taskEditInputSchema.safeParse({ title: 'Edited' }).success).toBe(false); + expect( + taskEditInputSchema.safeParse({ + taskId: 'task-1', + description: 'Updated description', + clearDescription: true, + }).success, + ).toBe(false); + expect(taskEditInputSchema.safeParse({ taskId: 'task-1', description: null }).success).toBe( + false, + ); + expect( + taskEditInputSchema.safeParse({ taskId: 'task-1', clearDescription: true }).success, + ).toBe(true); + expect( + taskTriageInputSchema.safeParse({ taskId: 'task-1', target: 'IN_PROGRESS' }).success, + ).toBe(false); + expect(taskTriageInputSchema.safeParse({ taskId: 'task-1', target: 'BACKLOG' }).success).toBe( + true, + ); + for (const schema of [ + taskGetInputSchema, + taskStartInputSchema, + taskCompleteInputSchema, + taskArchiveInputSchema, + ]) { + expect(schema.safeParse({ taskId: '' }).success).toBe(false); + expect(schema.safeParse({ taskId: 'task-1' }).success).toBe(true); + } + }); + + it('validates the public task representation, envelopes, warnings, and committed fixtures', () => { + expect( + taskDtoSchema.safeParse({ + id: 'task-1', + title: 'Capture', + description: null, + status: 'INBOX', + priority: null, + workspace: null, + sourceContext: null, + createdByType: 'AGENT', + createdByName: 'Relay agent', + sessionId: 'session-1', + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, + }).success, + ).toBe(true); + expect( + taskCaptureResultSchema.safeParse({ + task: { + id: 'task-1', + title: 'Capture', + description: null, + status: 'INBOX', + priority: null, + workspace: null, + sourceContext: null, + createdByType: 'AGENT', + createdByName: 'Relay agent', + sessionId: 'session-1', + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, + }, + change: { action: 'CREATED' }, + }).success, + ).toBe(true); + expect( + taskCaptureResultSchema.safeParse({ task: VALID_TASK, change: { action: 'ARCHIVED' } }) + .success, + ).toBe(false); + expect( + taskStartResultSchema.safeParse({ task: VALID_TASK, change: { action: 'CREATED' } }).success, + ).toBe(false); + expect( + duplicateWarningSchema.safeParse({ + code: 'POSSIBLE_DUPLICATE', + message: 'Similar task found', + candidates: [{ id: 'task-1' }], + }).success, + ).toBe(true); + expect( + cliSuccessEnvelopeSchema.safeParse({ + schemaVersion: 1, + ok: true, + data: { task: { id: 'task-1' }, warningCount: 0 }, + warnings: [], + }).success, + ).toBe(true); + expect( + cliSuccessEnvelopeSchema.safeParse({ + schemaVersion: 1, + ok: true, + data: { omitted: undefined }, + warnings: [], + }).success, + ).toBe(false); + expect( + contractErrorSchema.safeParse({ + code: 'VALIDATION_ERROR', + message: 'Invalid input', + details: { receivedAt: new Date() }, + }).success, + ).toBe(false); + + const captureFixtures = ['capture-success.json', 'capture-duplicate-warning.json']; + for (const filename of captureFixtures) { + const fixture = JSON.parse( + readFileSync(join(FIXTURE_DIRECTORY, filename), 'utf8'), + ) as unknown; + const envelope = cliSuccessEnvelopeSchema.parse(fixture); + expect(taskCaptureResultSchema.parse(envelope.data)).toEqual(envelope.data); + expect(JSON.stringify(envelope)).not.toBeUndefined(); + } + + const errorFixtures: ReadonlyArray<[string, (value: unknown) => unknown]> = [ + ['validation-error.json', cliErrorEnvelopeSchema.parse], + ['not-found-error.json', cliErrorEnvelopeSchema.parse], + ['transition-conflict-error.json', cliErrorEnvelopeSchema.parse], + ['storage-error.json', cliErrorEnvelopeSchema.parse], + ]; + for (const [filename, parser] of errorFixtures) { + const fixture = JSON.parse( + readFileSync(join(FIXTURE_DIRECTORY, filename), 'utf8'), + ) as unknown; + expect(parser(fixture)).toEqual(fixture); + } + expect( + contractErrorSchema.parse({ code: 'NOT_FOUND', message: 'Task was not found.' }), + ).toEqual({ + code: 'NOT_FOUND', + message: 'Task was not found.', + }); + }); +}); diff --git a/tests/unit/scripts/validate-repository-assets.test.ts b/tests/unit/scripts/validate-repository-assets.test.ts index 9e0f448..0dcf6bc 100644 --- a/tests/unit/scripts/validate-repository-assets.test.ts +++ b/tests/unit/scripts/validate-repository-assets.test.ts @@ -46,6 +46,35 @@ function createFixtureRoot(): string { writeFileSync(join(rootDir, 'web/src/App.tsx'), 'export function App() { return null; }\n'); mkdirSync(join(rootDir, 'docs/decisions'), { recursive: true }); writeFileSync(join(rootDir, 'docs/decisions/0001-product-and-architecture.md'), '# decision\n'); + writeFileSync( + join(rootDir, 'docs/decisions/0002-agent-integration-contracts.md'), + '# decision\n', + ); + writeFileSync(join(rootDir, 'docs/mcp-tools.md'), '# MCP tools\n'); + writeFileSync(join(rootDir, 'docs/cli-reference.md'), '# CLI reference\n'); + writeFileSync(join(rootDir, 'docs/session-semantics.md'), '# Session semantics\n'); + mkdirSync(join(rootDir, 'src/interfaces/contracts'), { recursive: true }); + for (const filename of [ + 'contract-version.ts', + 'error-contract.ts', + 'json-value-contract.ts', + 'session-contract.ts', + 'task-contract.ts', + 'warning-contract.ts', + ]) { + writeFileSync(join(rootDir, 'src/interfaces/contracts', filename), 'export {};\n'); + } + mkdirSync(join(rootDir, 'tests/fixtures/contracts'), { recursive: true }); + for (const filename of [ + 'capture-success.json', + 'capture-duplicate-warning.json', + 'validation-error.json', + 'not-found-error.json', + 'transition-conflict-error.json', + 'storage-error.json', + ]) { + writeFileSync(join(rootDir, 'tests/fixtures/contracts', filename), '{}\n'); + } mkdirSync(join(rootDir, 'dist/mcp'), { recursive: true }); writeFileSync(join(rootDir, 'dist/mcp/main.js'), 'console.log("ok");\n'); @@ -91,4 +120,12 @@ describe('validateRepositoryAssets', () => { expect(() => validateRepositoryAssets({ rootDir })).toThrow(new RegExp('TO' + 'DO', 'i')); }); + + it('requires the agent-integration contract documents and representative fixtures', () => { + const rootDir = createFixtureRoot(); + createdRoots.push(rootDir); + rmSync(join(rootDir, 'docs/mcp-tools.md')); + + expect(() => validateRepositoryAssets({ rootDir })).toThrow(/agent-integration|mcp-tools/i); + }); });