feat: add provider-neutral task envelope contracts (#891) (#894)
Some checks failed
CI / Lint & Type Check (push) Has been cancelled
CI / Workspace Unit Tests (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Security Audit (push) Has been cancelled

Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
Brad Groux 2026-07-16 12:48:48 -05:00 committed by GitHub
parent 566ec0f7fe
commit b704ab92d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2173 additions and 56 deletions

View file

@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the versioned `task-envelope/v1` and `completion-result/v1` contracts,
canonical envelope digests, immutable per-attempt persistence, launch HEAD and
dirty-file attribution with staged-blob and worktree SHA-256 fingerprints,
bounded evidence/output/verification/side-effect schemas, and explicit
`forbidden`, `allowed`, or `required` commit policy with run, task, and
legacy-setting precedence. Baseline capture retries unstable repositories,
path side effects are clamped to the effective sandbox, authoritative run
contracts reject generic task PATCH mutation, oversized legacy workspace IDs
normalize deterministically, and task create/update policy is available over
REST, CLI, and MCP. Agent start and status APIs, task storage, attempt history,
and logs now carry the exact envelope used for the run (#891).
- Added versioned, evidence-backed provider runtime manifests for Codex CLI,
Codex SDK, Hermes, and OpenClaw with bounded identity and conformance probes,
race-safe version-skew cache invalidation, canonical immutable run snapshots

View file

@ -56,6 +56,27 @@ describe('vk agent runtime capability controls', () => {
});
});
it('forwards an explicit run commit policy', async () => {
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(
['start', 'task_1', '--agent', 'codex', '--commit-policy', 'forbidden', '--json'],
{ from: 'user' }
);
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/start', {
method: 'POST',
body: JSON.stringify({
agent: 'codex',
profileId: undefined,
requiredRuntimeCapabilities: undefined,
commitPolicy: 'forbidden',
}),
});
});
it('surfaces authoritative fail-closed stop errors from the API', async () => {
mockApi
.mockResolvedValueOnce({ running: true, attemptId: 'attempt_1' })

View file

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
const { mockApi, mockFindTask } = vi.hoisted(() => ({
mockApi: vi.fn(),
mockFindTask: vi.fn(),
}));
vi.mock('../utils/api.js', () => ({ api: mockApi }));
vi.mock('../utils/find.js', () => ({ findTask: mockFindTask }));
import { registerTaskCommands } from '../commands/tasks.js';
describe('vk task execution policy', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi.mockResolvedValue({
id: 'task_1',
title: 'Policy task',
type: 'code',
status: 'todo',
priority: 'medium',
created: '2026-07-16T00:00:00.000Z',
updated: '2026-07-16T00:00:00.000Z',
});
mockFindTask.mockResolvedValue({ id: 'task_1' });
vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
it('forwards a task commit policy on create', async () => {
const program = new Command();
program.exitOverride();
registerTaskCommands(program);
await program.parseAsync(['create', 'Policy task', '--commit-policy', 'forbidden', '--json'], {
from: 'user',
});
expect(mockApi).toHaveBeenCalledWith('/api/tasks', {
method: 'POST',
body: JSON.stringify({
title: 'Policy task',
type: 'code',
description: '',
priority: 'medium',
executionPolicy: { commitPolicy: 'forbidden' },
}),
});
});
it('forwards a task commit policy on update', async () => {
const program = new Command();
program.exitOverride();
registerTaskCommands(program);
await program.parseAsync(['update', 'task_1', '--commit-policy', 'required', '--json'], {
from: 'user',
});
expect(mockApi).toHaveBeenCalledWith('/api/tasks/task_1', {
method: 'PATCH',
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
});
});
});

View file

@ -31,6 +31,10 @@ export function registerAgentCommands(program: Command): void {
'--require-capability <capabilities...>',
'Require provider runtime capabilities before launch'
)
.option(
'--commit-policy <policy>',
'Commit policy for this run (forbidden, allowed, or required)'
)
.option('--json', 'Output as JSON')
.action(async (id, options) => {
try {
@ -57,6 +61,7 @@ export function registerAgentCommands(program: Command): void {
agent: options.profile ? undefined : options.agent,
profileId: options.profile,
requiredRuntimeCapabilities: options.requireCapability,
commitPolicy: options.commitPolicy,
}),
});

View file

@ -94,6 +94,7 @@ export function registerTaskCommands(program: Command): void {
.option('-d, --description <desc>', 'Task description')
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
.option('-s, --status <status>', 'Initial status')
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
.option('--json', 'Output as JSON')
.action(async (title, options) => {
try {
@ -107,6 +108,9 @@ export function registerTaskCommands(program: Command): void {
description: options.description || '',
priority: options.priority,
status: options.status,
executionPolicy: options.commitPolicy
? { commitPolicy: options.commitPolicy }
: undefined,
}),
});
@ -132,6 +136,7 @@ export function registerTaskCommands(program: Command): void {
.option('-S, --sprint <sprint>', 'Sprint name or ID')
.option('--priority <priority>', 'New priority')
.option('--title <title>', 'New title')
.option('--commit-policy <policy>', 'Task commit policy (forbidden, allowed, or required)')
.option('--json', 'Output as JSON')
.action(async (id, options) => {
try {
@ -149,6 +154,9 @@ export function registerTaskCommands(program: Command): void {
if (options.sprint) updates.sprint = options.sprint;
if (options.priority) updates.priority = options.priority;
if (options.title) updates.title = options.title;
if (options.commitPolicy) {
updates.executionPolicy = { commitPolicy: options.commitPolicy };
}
if (Object.keys(updates).length === 0) {
console.error(chalk.yellow('No updates specified'));

View file

@ -74,6 +74,37 @@ routing. Provider version/build changes invalidate the readiness cache and
force a new conformance probe; active controls continue to use the immutable
snapshot persisted for that attempt.
## Task Envelopes And Commit Policy
Every launch also persists a provider-neutral `task-envelope/v1` snapshot. It
binds the task and attempt identity, objective, background, constraints,
acceptance criteria, worktree identity, launch manifest, expected outputs,
verification gates, evidence requirements, and allowed side effects to one
canonical `sha256:` digest. The worktree baseline records `HEAD` plus every
dirty file that existed before launch, including its staged index blob and
worktree-content SHA-256. Capture retries when HEAD, status, or fingerprints
move and fails closed after three unstable attempts, so later completion
evidence cannot claim pre-existing changes.
Commit behavior is explicit instead of implied by a shared prompt:
- `forbidden` does not authorize a commit.
- `allowed` authorizes a commit but does not require one. This is the compatible
default for existing tasks.
- `required` requires completion evidence for a commit created after the launch
baseline.
A one-off `commitPolicy` start value overrides `task.executionPolicy`, which
overrides the legacy `features.agents.autoCommitOnComplete` setting. Legacy
`true` maps to `required`; `false` or an absent value maps to `allowed`.
Requested filesystem, process, commit, and artifact scopes are intersected
with the effective worktree sandbox; ancestor requests such as `/` are clamped
to the assigned worktree and disjoint paths are rejected.
The start response, active status response, task attempt/history, and Markdown
run log expose the same immutable envelope. Provider-owned rendering and
normalized completion enforcement land in the ordered follow-up work for
parent issue #860.
Sandbox launch checks resolve every preset rule through the same manifest.
Settings dry-runs send the digest of the newest matching manifest registered by
a live host; the server resolves that digest rather than trusting a

View file

@ -1927,7 +1927,8 @@ runtime capabilities:
{
"profileId": "docs-reviewer",
"sandboxPresetId": "codex-repo-contained",
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"]
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"],
"commitPolicy": "allowed"
}
```
@ -1939,6 +1940,12 @@ capabilities plus caller, profile, sandbox, and budget requirements must be
mutated. Failure returns `409 Conflict` with `requiredCapabilities`, reasons,
manifest identity, and remediation.
`commitPolicy` accepts `forbidden`, `allowed`, or `required`. A run value
overrides `task.executionPolicy.commitPolicy`, then the legacy
`features.agents.autoCommitOnComplete` setting. Legacy `true` maps to
`required`; `false` or an absent value maps to the compatible `allowed`
default. Unknown policy fields or values return `400 Validation failed`.
`GET /api/agents/:taskId/status` returns the active manifest and its derived
controls:
@ -1948,6 +1955,18 @@ controls:
"attemptId": "attempt_123",
"provider": "codex-cli",
"providerRuntimeManifest": { "digest": "sha256:..." },
"taskEnvelope": {
"schemaVersion": "task-envelope/v1",
"digest": "sha256:...",
"commitPolicy": "allowed",
"workspace": {
"baseline": {
"headSha": "0123456789abcdef0123456789abcdef01234567",
"dirty": false,
"files": []
}
}
},
"controls": {
"manifestDigest": "sha256:...",
"probeState": "ready",
@ -2031,6 +2050,39 @@ Task and trace responses can therefore include the immutable
}
```
### Task Envelope On Attempts
The start API builds the immutable envelope only after readiness, runtime
manifest, and sandbox decisions succeed, but before provider execution. The
same `taskEnvelope` is returned by start/status and persisted on the current
attempt and attempt history. Its digest covers the provider-neutral task,
workspace baseline, policy, expected outputs, gates, and launch-manifest
reference. Dirty baseline entries include `indexBlobHash` and
`worktreeSha256`, allowing completion attribution to distinguish staged and
unstaged pre-launch content. Baseline capture is sequential, retries up to
three times when HEAD, status, or fingerprints move, and fails closed if the
worktree never stabilizes. Existing attempt records without an envelope remain
readable.
Task create/update payloads may set reusable defaults under
`executionPolicy`:
```json
{
"executionPolicy": {
"commitPolicy": "forbidden",
"allowedSideEffects": [{ "kind": "filesystem-write", "scope": "." }]
}
}
```
Run-time allowed side effects are intersected with the effective sandbox and
manifest posture; a task policy cannot grant a capability that launch policy
does not authorize. Path scopes wider than the assigned worktree are clamped to
the worktree, while disjoint path scopes are dropped. Generic task PATCH calls
cannot set `attempt.taskEnvelope` or `attempt.completionResult`; only the
launch and finalization services may persist those authoritative contracts.
The full manifest contains one entry for every known runtime and sandbox
capability. A provider version/build change invalidates cached conformance
evidence. Failed probes and unknown versions are not positively cached.

View file

@ -241,16 +241,18 @@ Create a new task.
```bash
vk create "Implement OAuth login"
vk create "Fix button alignment" --type code --priority high --project my-app
vk create "Audit without commits" --commit-policy forbidden
```
**Flags:**
| Flag | Description |
| ------------ | ----------------------------------------- |
| `--type` | Task type (code, research, content, etc.) |
| `--priority` | Priority level (low, medium, high) |
| `--project` | Project name |
| `--json` | Output as JSON |
| Flag | Description |
| ----------------- | ------------------------------------------------------- |
| `--type` | Task type (code, research, content, etc.) |
| `--priority` | Priority level (low, medium, high) |
| `--project` | Project name |
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
| `--json` | Output as JSON |
---
@ -261,18 +263,20 @@ Update task fields.
```bash
vk update abc123 --status review
vk update abc123 --title "New title" --priority high
vk update abc123 --commit-policy required
```
**Flags:**
| Flag | Description |
| ------------ | -------------- |
| `--status` | New status |
| `--title` | New title |
| `--priority` | New priority |
| `--type` | New type |
| `--project` | New project |
| `--json` | Output as JSON |
| Flag | Description |
| ----------------- | ------------------------------------------------------- |
| `--status` | New status |
| `--title` | New title |
| `--priority` | New priority |
| `--type` | New type |
| `--project` | New project |
| `--commit-policy` | Task commit policy (`forbidden`, `allowed`, `required`) |
| `--json` | Output as JSON |
---
@ -455,6 +459,7 @@ Require one or more capabilities before launch:
```bash
vk start TASK-001 --agent codex \
--require-capability tool.mcp output.structured \
--commit-policy allowed \
--json
```
@ -463,6 +468,11 @@ profile, sandbox, and budget requirements. The server returns a structured
conflict and the CLI exits non-zero when any capability is unsupported,
unknown, missing, or backed by an invalid/failed manifest.
`--commit-policy <forbidden|allowed|required>` sets the policy for this run.
It overrides a task default and the legacy auto-commit setting. Omitting the
flag keeps existing tasks compatible: commits are allowed but not required
unless a task or legacy setting explicitly requires one.
Use `vk agents:status TASK-001 --json` to inspect the persisted manifest and
capability-derived `controls` set. `vk stop` does not infer support from the
agent name. It resolves the current `attemptId` from status and includes it in

View file

@ -291,14 +291,14 @@ These are set in `server/.env`, not in the MCP client config:
### Task Management (6 tools)
| Tool | Description | Required Inputs | Key Options |
| -------------- | --------------------------------------- | --------------- | ------------------------------------------------------------------------- |
| `list_tasks` | List all tasks, optionally filtered | _(none)_ | `status`, `type`, `project`, `sprint` |
| `get_task` | Get task by ID (supports partial match) | `id` | — |
| `create_task` | Create a new task | `title` | `type`, `priority`, `project`, `sprint` |
| `update_task` | Update task fields | `id` | `title`, `description`, `status`, `type`, `priority`, `project`, `sprint` |
| `archive_task` | Archive a completed task | `id` | — |
| `delete_task` | Permanently delete a task | `id` | — |
| Tool | Description | Required Inputs | Key Options |
| -------------- | --------------------------------------- | --------------- | ----------------------------------------------------------------------------------------- |
| `list_tasks` | List all tasks, optionally filtered | _(none)_ | `status`, `type`, `project`, `sprint` |
| `get_task` | Get task by ID (supports partial match) | `id` | — |
| `create_task` | Create a new task | `title` | `type`, `priority`, `project`, `sprint`, `commitPolicy` |
| `update_task` | Update task fields | `id` | `title`, `description`, `status`, `type`, `priority`, `project`, `sprint`, `commitPolicy` |
| `archive_task` | Archive a completed task | `id` | — |
| `delete_task` | Permanently delete a task | `id` | — |
Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or `list_comments` when an assistant needs the full task or comment payload.
@ -326,7 +326,8 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
"type": "code",
"priority": "high",
"project": "rubicon",
"sprint": "sprint-1"
"sprint": "sprint-1",
"commitPolicy": "required"
}
}
```
@ -351,6 +352,7 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
- **status:** `todo` · `in-progress` · `blocked` · `done`
- **type:** `code` · `research` · `content` · `automation`
- **priority:** `low` · `medium` · `high`
- **commitPolicy:** `forbidden` · `allowed` · `required`
---
@ -358,7 +360,7 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
| Tool | Description | Required Inputs | Key Options |
| ------------- | ------------------------------ | --------------- | --------------------------------------------------------------------- |
| `start_agent` | Start a coding agent on a task | `id` | `agent`; `requiredRuntimeCapabilities` |
| `start_agent` | Start a coding agent on a task | `id` | `agent`; `requiredRuntimeCapabilities`; `commitPolicy` |
| `stop_agent` | Stop a running agent | `id` | Resolves status and binds the stop to that exact attempt and manifest |
> **Constraints:** Only works on tasks with `type: "code"` that already have a git worktree attached.
@ -374,7 +376,8 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
"arguments": {
"id": "abc123",
"agent": "claude-code",
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"]
"requiredRuntimeCapabilities": ["tool.mcp", "output.structured"],
"commitPolicy": "allowed"
}
}
```
@ -384,6 +387,9 @@ Task write tools return concise confirmations. Use `get_task`, `list_tasks`, or
The required capability list is forwarded unchanged to the authoritative
launch API. Unsupported, unknown, missing, failed-probe, or invalid manifest
evidence fails closed before provider work starts.
`commitPolicy` accepts `forbidden`, `allowed`, or `required` and overrides the
task and legacy setting for that run. The launch result is bound to the exact
persisted `task-envelope/v1` snapshot and pre-launch worktree baseline.
**Stop a running agent:**

View file

@ -30,6 +30,26 @@ describe('MCP agent runtime capability controls', () => {
expect(start?.inputSchema.properties.requiredRuntimeCapabilities).toMatchObject({
type: 'array',
});
expect(start?.inputSchema.properties.commitPolicy).toMatchObject({
enum: ['forbidden', 'allowed', 'required'],
});
});
it('forwards an explicit run commit policy', async () => {
await handleAgentTool('start_agent', {
id: 'task_1',
agent: 'claude-code',
commitPolicy: 'required',
});
expect(mockApi).toHaveBeenCalledWith('/api/agents/task_1/start', {
method: 'POST',
body: JSON.stringify({
agent: 'claude-code',
requiredRuntimeCapabilities: undefined,
commitPolicy: 'required',
}),
});
});
it('forwards required capabilities to the authoritative launch API', async () => {

View file

@ -14,7 +14,7 @@ vi.mock('../utils/find.js', () => ({
}));
import { handleCommentTool } from '../tools/comments.js';
import { handleTaskTool } from '../tools/tasks.js';
import { handleTaskTool, taskTools } from '../tools/tasks.js';
function task(overrides: Record<string, unknown> = {}) {
return {
@ -59,6 +59,28 @@ describe('MCP write response contract', () => {
expect(text).not.toContain('Full task details');
});
it('exposes and forwards task-level commit policy on create_task', async () => {
mocks.api.mockResolvedValueOnce(task());
await handleTaskTool('create_task', {
title: 'Policy task',
commitPolicy: 'forbidden',
});
expect(
taskTools.find((tool) => tool.name === 'create_task')?.inputSchema.properties
).toHaveProperty('commitPolicy');
expect(mocks.api).toHaveBeenCalledWith('/api/tasks', {
method: 'POST',
body: JSON.stringify({
title: 'Policy task',
type: 'code',
priority: 'medium',
executionPolicy: { commitPolicy: 'forbidden' },
}),
});
});
it('returns changed fields for update_task without echoing comments', async () => {
mocks.findTask.mockResolvedValueOnce(task());
mocks.api.mockResolvedValueOnce(
@ -84,6 +106,25 @@ describe('MCP write response contract', () => {
expect(text).not.toContain('{');
});
it('exposes and forwards task-level commit policy on update_task', async () => {
mocks.findTask.mockResolvedValueOnce(task());
mocks.api.mockResolvedValueOnce(task());
const result = await handleTaskTool('update_task', {
id: 'abc123',
commitPolicy: 'required',
});
expect(
taskTools.find((tool) => tool.name === 'update_task')?.inputSchema.properties
).toHaveProperty('commitPolicy');
expect(mocks.api).toHaveBeenCalledWith('/api/tasks/task_20260612_abc123', {
method: 'PATCH',
body: JSON.stringify({ executionPolicy: { commitPolicy: 'required' } }),
});
expect(result.content[0].text).toContain('fields: commitPolicy');
});
it('returns the added comment id and count for add_comment', async () => {
mocks.api.mockResolvedValueOnce(
task({

View file

@ -14,6 +14,7 @@ const StartAgentSchema = z.object({
)
.max(64)
.optional(),
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
});
const TaskIdSchema = z.object({
@ -41,6 +42,11 @@ export const agentTools = [
items: { type: 'string' },
description: 'Provider runtime capabilities that must be evidenced before launch',
},
commitPolicy: {
type: 'string',
enum: ['forbidden', 'allowed', 'required'],
description: 'Commit policy override for this run',
},
},
required: ['id'],
},
@ -64,7 +70,7 @@ export const agentTools = [
export async function handleAgentTool(name: string, args: any): Promise<any> {
switch (name) {
case 'start_agent': {
const { id, agent, requiredRuntimeCapabilities } = StartAgentSchema.parse(args);
const { id, agent, requiredRuntimeCapabilities, commitPolicy } = StartAgentSchema.parse(args);
const task = await findTask(id);
if (!task) {
@ -90,7 +96,7 @@ export async function handleAgentTool(name: string, args: any): Promise<any> {
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
method: 'POST',
body: JSON.stringify({ agent, requiredRuntimeCapabilities }),
body: JSON.stringify({ agent, requiredRuntimeCapabilities, commitPolicy }),
});
return {

View file

@ -18,6 +18,7 @@ const CreateTaskSchema = z.object({
priority: z.enum(['low', 'medium', 'high']).default('medium'),
project: z.string().optional(),
sprint: z.string().optional(),
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
});
const UpdateTaskSchema = z.object({
@ -29,6 +30,7 @@ const UpdateTaskSchema = z.object({
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
sprint: z.string().optional(),
commitPolicy: z.enum(['forbidden', 'allowed', 'required']).optional(),
});
const TaskIdSchema = z.object({
@ -120,6 +122,11 @@ export const taskTools = [
type: 'string',
description: 'Sprint ID',
},
commitPolicy: {
type: 'string',
enum: ['forbidden', 'allowed', 'required'],
description: 'Task-level commit policy used for future agent runs',
},
},
required: ['title'],
},
@ -164,6 +171,11 @@ export const taskTools = [
type: 'string',
description: 'New sprint ID',
},
commitPolicy: {
type: 'string',
enum: ['forbidden', 'allowed', 'required'],
description: 'Task-level commit policy used for future agent runs',
},
},
required: ['id'],
},
@ -244,10 +256,13 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
}
case 'create_task': {
const params = CreateTaskSchema.parse(args);
const { commitPolicy, ...params } = CreateTaskSchema.parse(args);
const task = await api<Task>('/api/tasks', {
method: 'POST',
body: JSON.stringify(params),
body: JSON.stringify({
...params,
executionPolicy: commitPolicy ? { commitPolicy } : undefined,
}),
});
return {
@ -266,7 +281,7 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
}
case 'update_task': {
const { id, ...updates } = UpdateTaskSchema.parse(args);
const { id, commitPolicy, ...updates } = UpdateTaskSchema.parse(args);
const task = await findTask(id);
if (!task) {
@ -278,14 +293,17 @@ export async function handleTaskTool(name: string, args: any): Promise<any> {
const updated = await api<Task>(`/api/tasks/${task.id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
body: JSON.stringify({
...updates,
executionPolicy: commitPolicy ? { commitPolicy } : undefined,
}),
});
return {
content: [
{
type: 'text',
text: `Task updated: ${updated.id}; fields: ${changedFieldList(updates)}; comments: ${commentCount(updated)}`,
text: `Task updated: ${updated.id}; fields: ${changedFieldList({ ...updates, commitPolicy })}; comments: ${commentCount(updated)}`,
},
],
};

View file

@ -110,6 +110,7 @@ import { AgentReadinessError, ClawdbotAgentService } from '../services/clawdbot-
import type { ThreadEvent } from '@openai/codex-sdk';
import type { AgentConfig, Task } from '@veritas-kanban/shared';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
import { TaskEnvelopeService } from '../services/task-envelope-service.js';
const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'codex');
@ -129,7 +130,19 @@ type TestableClawdbotAgentService = ClawdbotAgentService & {
};
function testableService(tmpDir: string): TestableClawdbotAgentService {
const service = new ClawdbotAgentService() as unknown as TestableClawdbotAgentService;
const taskEnvelopes = new TaskEnvelopeService({
captureLaunchBaseline: async (_worktreePath, capturedAt) => ({
capturedAt,
headSha: 'a'.repeat(40),
dirty: false,
files: [],
}),
});
const service = new ClawdbotAgentService(
undefined,
undefined,
taskEnvelopes
) as unknown as TestableClawdbotAgentService;
service.logsDir = tmpDir;
return service;
}
@ -330,6 +343,17 @@ describe('ClawdbotAgentService Codex providers', () => {
providerVersion: 'codex-cli 0.144.0',
});
expect(task.attempt?.providerRuntimeManifest?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(task.attempt?.taskEnvelope).toMatchObject({
schemaVersion: 'task-envelope/v1',
commitPolicy: 'allowed',
workspace: {
baseline: { headSha: 'a'.repeat(40), dirty: false, files: [] },
},
launchManifest: {
digest: task.attempt?.providerRuntimeManifest?.digest,
},
});
expect(task.attempt?.taskEnvelope?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(task.attempts).toEqual([
expect.objectContaining({
id: task.attempt?.id,
@ -351,6 +375,7 @@ describe('ClawdbotAgentService Codex providers', () => {
expect(log).toContain(
`Provider manifest:** ${task.attempt?.providerRuntimeManifest?.digest}`
);
expect(log).toContain(`Task envelope:** ${task.attempt?.taskEnvelope?.digest}`);
expect(mockStartStep).toHaveBeenCalledWith(
expect.any(String),
'stream',
@ -395,6 +420,23 @@ describe('ClawdbotAgentService Codex providers', () => {
}
);
it('persists a run-level commit policy override in the immutable task envelope', async () => {
const fixture = await fs.readFile(path.join(fixtureDir, 'success.jsonl'), 'utf-8');
mockSpawn.mockReturnValue(createFakeChild(fixture));
const service = testableService(tmpDir);
const status = await service.startAgent(task.id, 'codex', { commitPolicy: 'forbidden' });
expect(status.taskEnvelope.commitPolicy).toBe('forbidden');
expect(status.taskEnvelope.allowedSideEffects).not.toEqual(
expect.arrayContaining([expect.objectContaining({ kind: 'git-commit' })])
);
expect(task.attempt?.taskEnvelope?.digest).toBe(status.taskEnvelope.digest);
await waitFor(async () => {
expect(await service.getAgentStatus(task.id)).toBeNull();
});
});
it('does not trust Codex file events without a persisted runtime snapshot', async () => {
const service = testableService(tmpDir);
const logPath = path.join(tmpDir, 'codex.md');

View file

@ -124,9 +124,29 @@ describe('agent local capability enforcement', () => {
sandboxPresetId: undefined,
budget: undefined,
requiredRuntimeCapabilities: undefined,
commitPolicy: undefined,
});
});
it('validates and forwards an explicit run commit policy', async () => {
const app = createApp(auth({ clientMode: 'desktop-local', capabilities: ['desktop:local'] }));
const response = await request(app)
.post('/api/agents/task_1/start')
.send({ agent: 'codex', commitPolicy: 'forbidden' });
expect(response.status).toBe(201);
expect(mockStartAgent).toHaveBeenCalledWith(
'task_1',
'codex',
expect.objectContaining({ commitPolicy: 'forbidden' })
);
const invalid = await request(app)
.post('/api/agents/task_1/start')
.send({ agent: 'codex', commitPolicy: 'sometimes' });
expect(invalid.status).toBe(400);
});
it('validates and forwards required runtime capabilities', async () => {
const app = createApp(auth({ clientMode: 'desktop-local', capabilities: ['desktop:local'] }));
const response = await request(app)

View file

@ -317,6 +317,75 @@ describe('Tasks Routes (actual module)', () => {
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
});
it('rejects generic PATCH attempts that mutate authoritative run contracts', async () => {
for (const field of ['taskEnvelope', 'completionResult']) {
const res = await request(app)
.patch('/api/tasks/t1')
.send({
attempt: {
id: 'attempt_forged',
agent: 'codex',
status: 'running',
[field]: {},
},
});
expect(res.status).toBe(400);
}
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
});
it('preserves authoritative run contracts when patching the same attempt', async () => {
const taskEnvelope = { digest: 'immutable-envelope' };
const completionResult = { status: 'success' };
mockTaskService.getTask.mockResolvedValue({
id: 't1',
title: 'Old',
status: 'in-progress',
attempt: {
id: 'attempt_1',
agent: 'codex',
status: 'running',
taskEnvelope,
completionResult,
},
});
mockTaskService.updateTask.mockImplementation(async (_id, input) => input);
const res = await request(app)
.patch('/api/tasks/t1')
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
expect(res.status).toBe(200);
expect(mockTaskService.updateTask).toHaveBeenCalledWith(
't1',
expect.objectContaining({
attempt: expect.objectContaining({ taskEnvelope, completionResult }),
})
);
});
it('rejects replacing an attempt that owns authoritative run contracts', async () => {
mockTaskService.getTask.mockResolvedValue({
id: 't1',
title: 'Old',
status: 'in-progress',
attempt: {
id: 'attempt_1',
agent: 'codex',
status: 'running',
taskEnvelope: { digest: 'immutable-envelope' },
},
});
const res = await request(app)
.patch('/api/tasks/t1')
.send({ attempt: { id: 'attempt_2', agent: 'codex', status: 'running' } });
expect(res.status).toBe(400);
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
});
it('should return 404 for missing task on getTask', async () => {
mockTaskService.getTask.mockResolvedValue(null);
const res = await request(app).patch('/api/tasks/nonexistent').send({ title: 'Updated' });

View file

@ -142,14 +142,17 @@ describe('Tasks Routes', () => {
});
it('should create a task with all fields', async () => {
const res = await request(app).post('/api/tasks').send({
title: 'Full Task',
description: 'Detailed description',
type: 'research',
priority: 'high',
project: 'test-project',
sprint: 'US-100',
});
const res = await request(app)
.post('/api/tasks')
.send({
title: 'Full Task',
description: 'Detailed description',
type: 'research',
priority: 'high',
project: 'test-project',
sprint: 'US-100',
executionPolicy: { commitPolicy: 'forbidden' },
});
expect(res.status).toBe(201);
expect(res.body.title).toBe('Full Task');
@ -158,6 +161,7 @@ describe('Tasks Routes', () => {
expect(res.body.priority).toBe('high');
expect(res.body.project).toBe('test-project');
expect(res.body.sprint).toBe('US-100');
expect(res.body.executionPolicy).toEqual({ commitPolicy: 'forbidden' });
});
});
@ -195,6 +199,16 @@ describe('Tasks Routes', () => {
expect(res.body.priority).toBe('high');
});
it('persists task execution policy updates', async () => {
const task = await taskService.createTask({ title: 'Policy Task' });
const updated = await request(app)
.patch(`/api/tasks/${task.id}`)
.send({ executionPolicy: { commitPolicy: 'required' } });
expect(updated.status).toBe(200);
expect(updated.body.executionPolicy).toEqual({ commitPolicy: 'required' });
});
it('should return 404 for non-existent task', async () => {
const res = await request(app)
.patch('/api/tasks/nonexistent_id')

View file

@ -0,0 +1,425 @@
import { createHash } from 'node:crypto';
import { mkdtemp, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
import { describe, expect, it, vi } from 'vitest';
import {
COMPLETION_RESULT_SCHEMA_VERSION,
TASK_ENVELOPE_SCHEMA_VERSION,
type CompletionResult,
type Task,
type TaskLaunchBaseline,
} from '@veritas-kanban/shared';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
import {
CompletionResultSchema,
TaskExecutionPolicySchema,
TaskEnvelopeSchema,
parseCompletionResultForEnvelope,
} from '../schemas/task-envelope-schemas.js';
import {
GitCompletionEvidenceSource,
TaskEnvelopeService,
resolveTaskCommitPolicy,
type CompletionEvidenceSource,
} from '../services/task-envelope-service.js';
import { verifyTaskEnvelopeDigest } from '../utils/task-envelope-digest.js';
const createdAt = '2026-07-16T12:00:00.000Z';
const baseline: TaskLaunchBaseline = {
capturedAt: createdAt,
headSha: 'a'.repeat(40),
dirty: true,
files: [
{
path: 'pre-existing.txt',
status: 'modified',
indexBlobHash: 'b'.repeat(40),
worktreeSha256: 'c'.repeat(64),
},
],
};
const evidenceSource: CompletionEvidenceSource = {
captureLaunchBaseline: async () => structuredClone(baseline),
};
function task(): Task {
return {
id: 'task_20260716_contract',
title: 'Define a task envelope',
description: 'Replace transport-specific instructions with a durable contract.',
type: 'code',
status: 'todo',
priority: 'high',
project: 'veritas-kanban',
created: createdAt,
updated: createdAt,
git: {
repo: 'veritas-kanban',
branch: 'feat/task-envelope',
baseBranch: 'main',
worktreePath: '/tmp/veritas-kanban-task',
},
subtasks: [
{
id: 'subtask-1',
title: 'Contract',
completed: false,
created: createdAt,
acceptanceCriteria: ['The envelope is versioned.'],
},
],
verificationSteps: [{ id: 'verify-1', description: 'Run focused tests', checked: false }],
observations: [
{
id: 'observation-1',
type: 'context',
content: 'Existing callbacks must remain readable.',
score: 8,
timestamp: createdAt,
},
],
};
}
async function envelope(commitPolicy: 'forbidden' | 'allowed' | 'required' = 'allowed') {
return new TaskEnvelopeService(evidenceSource).build({
task: task(),
attemptId: 'attempt_contract',
createdAt,
worktreePath: '/tmp/veritas-kanban-task',
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'artifact.write': 'supported' },
}),
commitPolicy,
networkAccessEnabled: true,
});
}
function completionResult(
taskEnvelope: Awaited<ReturnType<typeof envelope>>,
status: CompletionResult['status']
): CompletionResult {
return {
schemaVersion: COMPLETION_RESULT_SCHEMA_VERSION,
taskEnvelopeSchemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
taskEnvelopeDigest: taskEnvelope.digest,
taskId: taskEnvelope.subject.id,
attemptId: taskEnvelope.attempt.id,
providerRuntimeManifestDigest: taskEnvelope.launchManifest.digest,
status,
summary: `Fixture ${status}`,
error: status === 'failed' ? 'Fixture failure' : null,
blockers:
status === 'blocked'
? [{ code: 'fixture', summary: 'Blocked', detail: 'Fixture blocker', retryable: true }]
: [],
evidence:
status === 'success'
? [
{
id: 'evidence-terminal',
kind: 'provider-output',
source: 'harness',
summary: 'Process exited successfully.',
reference: null,
requirementIds: ['terminal-state'],
verified: true,
},
{
id: 'evidence-verification',
kind: 'verification',
source: 'harness',
summary: 'Focused tests passed.',
reference: null,
requirementIds: ['verification'],
verified: true,
},
]
: [],
changedFiles: [],
artifacts: [],
verification:
status === 'success'
? [
{
gateId: 'verify-1',
status: 'passed',
summary: 'Focused tests passed.',
evidenceIds: ['evidence-verification'],
},
]
: [],
sideEffects: [],
continuation: null,
};
}
describe('TaskEnvelopeService', () => {
it('builds a strict, immutable, digest-bound envelope with launch attribution', async () => {
const result = await envelope('allowed');
expect(result.schemaVersion).toBe(TASK_ENVELOPE_SCHEMA_VERSION);
expect(result.workspace.baseline).toEqual(baseline);
expect(result.subject.acceptanceCriteria).toEqual(['The envelope is versioned.']);
expect(result.subject.background).toContain('Existing callbacks must remain readable.');
expect(result.allowedSideEffects.map((item) => item.kind)).toEqual([
'filesystem-write',
'process-execute',
'git-commit',
'network-egress',
'artifact-write',
]);
expect(result.completionContract.evidenceRequirements.map((item) => item.id)).toEqual([
'terminal-state',
'verification',
]);
expect(TaskEnvelopeSchema.safeParse(result).success).toBe(true);
expect(verifyTaskEnvelopeDigest(result)).toBe(true);
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.workspace.baseline.files)).toBe(true);
const tampered = structuredClone(result);
tampered.commitPolicy = 'required';
expect(TaskEnvelopeSchema.safeParse(tampered).success).toBe(false);
});
it('resolves run, task, legacy, and compatible default commit policy precedence', () => {
expect(TaskExecutionPolicySchema.safeParse({ commitPolicy: 'sometimes' }).success).toBe(false);
expect(
resolveTaskCommitPolicy({
runPolicy: 'forbidden',
taskPolicy: { commitPolicy: 'required' },
legacyAutoCommitOnComplete: true,
})
).toBe('forbidden');
expect(
resolveTaskCommitPolicy({
taskPolicy: { commitPolicy: 'required' },
legacyAutoCommitOnComplete: false,
})
).toBe('required');
expect(resolveTaskCommitPolicy({ legacyAutoCommitOnComplete: true })).toBe('required');
expect(resolveTaskCommitPolicy({ legacyAutoCommitOnComplete: false })).toBe('allowed');
expect(resolveTaskCommitPolicy({})).toBe('allowed');
});
it('rejects contradictory commit and side-effect/output policy', async () => {
const service = new TaskEnvelopeService(evidenceSource);
const base = {
task: task(),
attemptId: 'attempt_policy',
createdAt,
worktreePath: '/tmp/veritas-kanban-task',
providerRuntimeManifest: providerRuntimeManifestFixture(),
};
await expect(
service.build({
...base,
commitPolicy: 'required',
executionPolicy: {
allowedSideEffects: [{ kind: 'filesystem-write', scope: 'assigned worktree' }],
},
})
).rejects.toThrow(/must authorize the git-commit side effect/);
await expect(
service.build({
...base,
commitPolicy: 'forbidden',
executionPolicy: {
expectedOutputs: [
{
id: 'contradictory-commit',
kind: 'commit',
description: 'Create a commit.',
required: true,
},
],
},
})
).rejects.toThrow(/cannot require a commit output/);
});
it('clamps requested filesystem scopes to the effective worktree sandbox', async () => {
const result = await new TaskEnvelopeService(evidenceSource).build({
task: task(),
attemptId: 'attempt_scope',
createdAt,
worktreePath: '/tmp/veritas-kanban-task',
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'artifact.write': 'supported' },
}),
commitPolicy: 'required',
executionPolicy: {
allowedSideEffects: [
{ kind: 'filesystem-write', scope: '/' },
{ kind: 'process-execute', scope: '/tmp' },
{ kind: 'git-commit', scope: '/' },
{ kind: 'artifact-write', scope: '/etc' },
],
},
});
expect(result.allowedSideEffects).toEqual([
{ kind: 'filesystem-write', scope: '/tmp/veritas-kanban-task' },
{ kind: 'process-execute', scope: '/tmp/veritas-kanban-task' },
{ kind: 'git-commit', scope: '/tmp/veritas-kanban-task' },
]);
});
it('normalizes oversized legacy project names into stable workspace IDs', async () => {
const service = new TaskEnvelopeService(evidenceSource);
const oversizedProject = `legacy-${'x'.repeat(300)}`;
const input = {
task: { ...task(), project: oversizedProject },
attemptId: 'attempt_workspace',
createdAt,
worktreePath: '/tmp/veritas-kanban-task',
providerRuntimeManifest: providerRuntimeManifestFixture(),
commitPolicy: 'allowed' as const,
};
const first = await service.build(input);
const second = await service.build(input);
expect(first.workspace.workspaceId).toHaveLength(160);
expect(first.workspace.workspaceId).toBe(second.workspace.workspaceId);
expect(first.workspace.workspaceId).toMatch(/^legacy-x+~[a-f0-9]{16}$/);
});
it('validates all completion statuses and envelope-bound success evidence', async () => {
const taskEnvelope = await envelope('allowed');
for (const status of ['success', 'blocked', 'failed', 'interrupted', 'partial'] as const) {
expect(CompletionResultSchema.safeParse(completionResult(taskEnvelope, status)).success).toBe(
true
);
}
expect(
parseCompletionResultForEnvelope(completionResult(taskEnvelope, 'success'), taskEnvelope)
).toMatchObject({ status: 'success' });
const missingVerification = completionResult(taskEnvelope, 'success');
missingVerification.verification = [];
expect(() => parseCompletionResultForEnvelope(missingVerification, taskEnvelope)).toThrow(
/Missing passed verification evidence/
);
const wrongEvidenceKind = completionResult(taskEnvelope, 'success');
wrongEvidenceKind.evidence[0].kind = 'file-change';
expect(() => parseCompletionResultForEnvelope(wrongEvidenceKind, taskEnvelope)).toThrow(
/Missing verified completion evidence/
);
const unboundVerification = completionResult(taskEnvelope, 'success');
unboundVerification.verification[0].evidenceIds = ['missing-evidence'];
expect(() => parseCompletionResultForEnvelope(unboundVerification, taskEnvelope)).toThrow(
/Missing passed verification evidence/
);
const wrongEnvelope = completionResult(taskEnvelope, 'success');
wrongEnvelope.taskEnvelopeDigest = `sha256:${'f'.repeat(64)}`;
expect(() => parseCompletionResultForEnvelope(wrongEnvelope, taskEnvelope)).toThrow(
/task envelope digest/
);
});
it('captures the committed HEAD and pre-existing dirty files from a real worktree', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'veritas-task-envelope-'));
const git = simpleGit(directory);
await git.init();
await git.addConfig('user.name', 'Veritas Test');
await git.addConfig('user.email', 'veritas@example.com');
await git.addConfig('commit.gpgsign', 'false');
await writeFile(path.join(directory, 'tracked.txt'), 'baseline\n');
await git.add('tracked.txt');
await git.commit('baseline');
const headSha = (await git.revparse(['HEAD'])).trim();
await writeFile(path.join(directory, 'tracked.txt'), 'changed before launch\n');
await writeFile(path.join(directory, 'untracked.txt'), 'pre-existing\n');
const captured = await new GitCompletionEvidenceSource().captureLaunchBaseline(
directory,
createdAt
);
expect(captured.headSha).toBe(headSha);
expect(captured.dirty).toBe(true);
const trackedIndexHash = (await git.raw(['ls-files', '--stage', 'tracked.txt']))
.trim()
.split(/\s+/)[1];
expect(captured.files).toEqual(
expect.arrayContaining([
{
path: 'tracked.txt',
status: 'modified',
indexBlobHash: trackedIndexHash,
worktreeSha256: createHash('sha256').update('changed before launch\n').digest('hex'),
},
{
path: 'untracked.txt',
status: 'untracked',
indexBlobHash: null,
worktreeSha256: createHash('sha256').update('pre-existing\n').digest('hex'),
},
])
);
});
it('retries a baseline capture when HEAD changes and then returns a coherent snapshot', async () => {
const heads = ['a'.repeat(40), 'b'.repeat(40), ...Array(3).fill('c'.repeat(40))];
const git = {
raw: vi.fn().mockResolvedValue(''),
revparse: vi.fn(async () => heads.shift() ?? 'c'.repeat(40)),
status: vi.fn(async () => cleanStatus()),
};
const source = new GitCompletionEvidenceSource(
() => git as unknown as Pick<SimpleGit, 'raw' | 'revparse' | 'status'>
);
const captured = await source.captureLaunchBaseline('/tmp/unused', createdAt);
expect(captured.headSha).toBe('c'.repeat(40));
expect(git.revparse).toHaveBeenCalledTimes(5);
expect(git.status).toHaveBeenCalledTimes(5);
});
it('fails closed after bounded attempts when the worktree baseline never stabilizes', async () => {
let revision = 0;
const git = {
raw: vi.fn().mockResolvedValue(''),
revparse: vi.fn(async () => `${revision++}`.padStart(40, 'a')),
status: vi.fn(async () => cleanStatus()),
};
const source = new GitCompletionEvidenceSource(
() => git as unknown as Pick<SimpleGit, 'raw' | 'revparse' | 'status'>
);
await expect(source.captureLaunchBaseline('/tmp/unused', createdAt)).rejects.toThrow(
/changed while capturing the launch baseline after 3 attempts/
);
expect(git.revparse).toHaveBeenCalledTimes(6);
});
});
function cleanStatus(): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
modified: [],
renamed: [],
staged: [],
files: [],
ahead: 0,
behind: 0,
current: 'main',
tracking: null,
detached: false,
isClean: () => true,
};
}

View file

@ -6,6 +6,7 @@ import { getTaskService } from '../services/task-service.js';
import type {
AgentType,
ProviderRuntimeCapabilityId,
TaskCommitPolicy,
TokenTelemetryEvent,
} from '@veritas-kanban/shared';
import { asyncHandler } from '../middleware/async-handler.js';
@ -14,6 +15,7 @@ import { requireLocalAgentCapability } from '../middleware/local-agent-capabilit
import { AgentBudgetPolicySchema } from '../schemas/agent-budget-schemas.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { ProviderRuntimeCapabilityIdSchema } from '../schemas/provider-runtime-manifest-schemas.js';
import { TaskCommitPolicySchema } from '../schemas/task-envelope-schemas.js';
const router: RouterType = Router();
@ -27,6 +29,7 @@ const startAgentSchema = z.object({
sandboxPresetId: z.string().trim().min(1).max(80).optional(),
budget: AgentBudgetPolicySchema.optional(),
requiredRuntimeCapabilities: z.array(ProviderRuntimeCapabilityIdSchema).max(64).optional(),
commitPolicy: TaskCommitPolicySchema.optional(),
});
const completeAgentSchema = z.object({
@ -68,16 +71,25 @@ router.post(
let sandboxPresetId: string | undefined;
let budget: z.infer<typeof AgentBudgetPolicySchema> | undefined;
let requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] | undefined;
let commitPolicy: TaskCommitPolicy | undefined;
try {
({ agent, profileId, overrideReason, sandboxPresetId, budget, requiredRuntimeCapabilities } =
startAgentSchema.parse(req.body) as {
agent?: AgentType;
profileId?: string;
overrideReason?: string;
sandboxPresetId?: string;
budget?: z.infer<typeof AgentBudgetPolicySchema>;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
});
({
agent,
profileId,
overrideReason,
sandboxPresetId,
budget,
requiredRuntimeCapabilities,
commitPolicy,
} = startAgentSchema.parse(req.body) as {
agent?: AgentType;
profileId?: string;
overrideReason?: string;
sandboxPresetId?: string;
budget?: z.infer<typeof AgentBudgetPolicySchema>;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
commitPolicy?: TaskCommitPolicy;
});
} catch (error) {
if (error instanceof z.ZodError) {
throw new ValidationError('Validation failed', error.issues);
@ -92,6 +104,7 @@ router.post(
sandboxPresetId,
budget,
requiredRuntimeCapabilities,
commitPolicy,
});
} catch (error) {
if (error instanceof AgentReadinessError) {

View file

@ -23,6 +23,7 @@ import { auditLog } from '../services/audit-service.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { actorFromRequest, assertFreshRevision, setRevisionHeaders } from '../utils/concurrency.js';
import type { TaskIdentityDiagnostics } from '../services/task-identity-diagnostics.js';
import { TaskExecutionPolicySchema } from '../schemas/task-envelope-schemas.js';
const router: RouterType = Router();
const taskService = getTaskService();
@ -63,6 +64,7 @@ const createTaskSchema = z.object({
project: z.string().optional(),
sprint: z.string().optional(),
agent: z.string().max(50).optional(), // "auto" | agent type slug
executionPolicy: TaskExecutionPolicySchema.optional(),
reviewScores: reviewScoresSchema.optional(),
reviewComments: z.array(reviewCommentSchema).optional(),
});
@ -89,6 +91,9 @@ const attemptSchema = z
cloudUrl: z.string().max(500).optional(),
cloudTarget: z.string().max(240).optional(),
orchestration: z.unknown().optional(),
// Authoritative run contracts are written only by launch/finalization services.
taskEnvelope: z.never().optional(),
completionResult: z.never().optional(),
})
.optional();
@ -164,6 +169,7 @@ const updateTaskSchema = z.object({
project: z.string().optional(),
sprint: z.string().optional(),
agent: z.string().max(50).optional(),
executionPolicy: TaskExecutionPolicySchema.optional(),
git: gitSchema,
github: githubSchema,
attempt: attemptSchema,
@ -681,6 +687,27 @@ router.patch(
if (!oldTask) {
throw new NotFoundError('Task not found');
}
const authoritativeAttempt = oldTask.attempt;
if (
input.attempt &&
authoritativeAttempt &&
(authoritativeAttempt.taskEnvelope || authoritativeAttempt.completionResult)
) {
if (input.attempt.id !== authoritativeAttempt.id) {
throw new ValidationError(
'Generic task updates cannot replace an attempt with an authoritative run contract'
);
}
input.attempt = {
...input.attempt,
...(authoritativeAttempt.taskEnvelope
? { taskEnvelope: authoritativeAttempt.taskEnvelope }
: {}),
...(authoritativeAttempt.completionResult
? { completionResult: authoritativeAttempt.completionResult }
: {}),
};
}
assertFreshRevision(req, 'task', oldTask.id, oldTask);
const authReq = req as AuthenticatedRequest;

View file

@ -1,5 +1,6 @@
import { z } from 'zod';
import { AgentBudgetPolicySchema } from './agent-budget-schemas.js';
import { TaskCommitPolicySchema } from './task-envelope-schemas.js';
const AgentTypeSchema = z.string().min(1).max(50);
@ -12,6 +13,7 @@ export const StartAgentBodySchema = z.object({
overrideReason: z.string().trim().min(8).max(1000).optional(),
sandboxPresetId: z.string().trim().min(1).max(80).optional(),
budget: AgentBudgetPolicySchema.optional(),
commitPolicy: TaskCommitPolicySchema.optional(),
});
export type StartAgentBody = z.infer<typeof StartAgentBodySchema>;

View file

@ -0,0 +1,368 @@
import { z } from 'zod';
import {
COMPLETION_RESULT_SCHEMA_VERSION,
TASK_ARTIFACT_KINDS,
TASK_CHANGED_FILE_STATUSES,
TASK_COMMIT_POLICIES,
TASK_COMPLETION_STATUSES,
TASK_CONTINUATION_KINDS,
TASK_ENVELOPE_SCHEMA_VERSION,
TASK_EVIDENCE_KINDS,
TASK_EVIDENCE_SOURCES,
TASK_EXPECTED_OUTPUT_KINDS,
TASK_SIDE_EFFECT_KINDS,
TASK_VERIFICATION_STATUSES,
type CompletionResult,
type TaskEnvelope,
} from '@veritas-kanban/shared';
import { verifyTaskEnvelopeDigest } from '../utils/task-envelope-digest.js';
const digestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
const idSchema = z.string().trim().min(1).max(160);
const shortTextSchema = z.string().trim().min(1).max(500);
const textSchema = z.string().trim().min(1).max(20_000);
const pathSchema = z.string().trim().min(1).max(4096);
const isoDateSchema = z.string().datetime();
export const TaskCommitPolicySchema = z.enum(TASK_COMMIT_POLICIES);
export const TaskAllowedSideEffectSchema = z
.object({
kind: z.enum(TASK_SIDE_EFFECT_KINDS),
scope: z.string().trim().min(1).max(4096),
})
.strict();
export const TaskExpectedOutputSchema = z
.object({
id: idSchema,
kind: z.enum(TASK_EXPECTED_OUTPUT_KINDS),
description: textSchema,
required: z.boolean(),
})
.strict();
export const TaskExecutionPolicySchema = z
.object({
commitPolicy: TaskCommitPolicySchema.optional(),
allowedSideEffects: z.array(TaskAllowedSideEffectSchema).max(32).optional(),
expectedOutputs: z.array(TaskExpectedOutputSchema).max(64).optional(),
})
.strict();
export const TaskEnvelopeSchema = z
.object({
schemaVersion: z.literal(TASK_ENVELOPE_SCHEMA_VERSION),
digest: digestSchema,
subject: z
.object({
id: idSchema,
title: z.string().trim().min(1).max(500),
objective: textSchema,
background: z.array(z.string().trim().min(1).max(4000)).max(64),
constraints: z.array(z.string().trim().min(1).max(4000)).max(128),
acceptanceCriteria: z.array(z.string().trim().min(1).max(4000)).max(256),
})
.strict(),
attempt: z
.object({
id: idSchema,
createdAt: isoDateSchema,
})
.strict(),
workspace: z
.object({
workspaceId: idSchema,
worktreeId: idSchema,
repo: shortTextSchema,
branch: shortTextSchema,
baseBranch: shortTextSchema,
worktreePath: pathSchema,
baseline: z
.object({
capturedAt: isoDateSchema,
headSha: z.string().regex(/^[a-f0-9]{40,64}$/),
dirty: z.boolean(),
files: z
.array(
z
.object({
path: pathSchema,
status: z.enum(TASK_CHANGED_FILE_STATUSES),
indexBlobHash: z
.string()
.regex(/^[a-f0-9]{40,64}$/)
.nullable(),
worktreeSha256: z
.string()
.regex(/^[a-f0-9]{64}$/)
.nullable(),
})
.strict()
)
.max(1000),
})
.strict(),
})
.strict(),
commitPolicy: TaskCommitPolicySchema,
allowedSideEffects: z.array(TaskAllowedSideEffectSchema).max(32),
expectedOutputs: z.array(TaskExpectedOutputSchema).max(64),
verificationGates: z
.array(
z
.object({
id: idSchema,
description: textSchema,
required: z.boolean(),
evidenceRequired: z.boolean(),
})
.strict()
)
.max(256),
launchManifest: z
.object({
schemaVersion: idSchema,
digest: digestSchema,
provider: idSchema,
adapter: idSchema,
protocolVersion: idSchema,
})
.strict(),
completionContract: z
.object({
schemaVersion: z.literal(COMPLETION_RESULT_SCHEMA_VERSION),
evidenceRequirements: z
.array(
z
.object({
id: idSchema,
kind: z.enum(TASK_EVIDENCE_KINDS),
description: textSchema,
required: z.boolean(),
})
.strict()
)
.max(128),
})
.strict(),
})
.strict()
.superRefine((envelope, ctx) => {
if (!verifyTaskEnvelopeDigest(envelope)) {
ctx.addIssue({
code: 'custom',
path: ['digest'],
message: 'Task envelope digest does not match the canonical payload',
});
}
});
export const CompletionResultSchema = z
.object({
schemaVersion: z.literal(COMPLETION_RESULT_SCHEMA_VERSION),
taskEnvelopeSchemaVersion: z.literal(TASK_ENVELOPE_SCHEMA_VERSION),
taskEnvelopeDigest: digestSchema,
taskId: idSchema,
attemptId: idSchema,
providerRuntimeManifestDigest: digestSchema,
status: z.enum(TASK_COMPLETION_STATUSES),
summary: textSchema,
error: z.string().trim().min(1).max(20_000).nullable(),
blockers: z
.array(
z
.object({
code: idSchema,
summary: shortTextSchema,
detail: textSchema,
retryable: z.boolean(),
})
.strict()
)
.max(64),
evidence: z
.array(
z
.object({
id: idSchema,
kind: z.enum(TASK_EVIDENCE_KINDS),
source: z.enum(TASK_EVIDENCE_SOURCES),
summary: textSchema,
reference: z.string().trim().min(1).max(4096).nullable(),
requirementIds: z.array(idSchema).max(64),
verified: z.boolean(),
})
.strict()
)
.max(512),
changedFiles: z
.array(
z
.object({
path: pathSchema,
status: z.enum(TASK_CHANGED_FILE_STATUSES),
previousPath: pathSchema.nullable(),
verified: z.boolean(),
})
.strict()
)
.max(2000),
artifacts: z
.array(
z
.object({
id: idSchema,
kind: z.enum(TASK_ARTIFACT_KINDS),
name: shortTextSchema,
reference: z.string().trim().min(1).max(4096),
mediaType: z.string().trim().min(1).max(200).nullable(),
sha256: z
.string()
.regex(/^[a-f0-9]{64}$/)
.nullable(),
verified: z.boolean(),
})
.strict()
)
.max(256),
verification: z
.array(
z
.object({
gateId: idSchema,
status: z.enum(TASK_VERIFICATION_STATUSES),
summary: textSchema,
evidenceIds: z.array(idSchema).max(128),
})
.strict()
)
.max(256),
sideEffects: z
.array(
z
.object({
kind: z.enum(TASK_SIDE_EFFECT_KINDS),
description: textSchema,
target: z.string().trim().min(1).max(4096).nullable(),
authorized: z.boolean(),
verified: z.boolean(),
})
.strict()
)
.max(256),
continuation: z
.object({
provider: idSchema,
kind: z.enum(TASK_CONTINUATION_KINDS),
reference: z.string().trim().min(1).max(4096),
})
.strict()
.nullable(),
})
.strict()
.superRefine((result, ctx) => {
if (result.status === 'success') {
if (result.error !== null || result.blockers.length > 0) {
ctx.addIssue({
code: 'custom',
path: ['status'],
message: 'Successful completion cannot include an error or blockers',
});
}
if (!result.evidence.some((item) => item.verified)) {
ctx.addIssue({
code: 'custom',
path: ['evidence'],
message: 'Successful completion requires verified evidence',
});
}
if (result.verification.some((item) => item.status !== 'passed')) {
ctx.addIssue({
code: 'custom',
path: ['verification'],
message: 'Successful completion cannot include an unpassed verification result',
});
}
}
if (result.status === 'blocked' && result.blockers.length === 0) {
ctx.addIssue({
code: 'custom',
path: ['blockers'],
message: 'Blocked completion requires at least one blocker',
});
}
if (result.status === 'failed' && result.error === null) {
ctx.addIssue({
code: 'custom',
path: ['error'],
message: 'Failed completion requires an error',
});
}
});
export function parseTaskEnvelope(value: unknown): TaskEnvelope {
return TaskEnvelopeSchema.parse(value) as TaskEnvelope;
}
export function parseCompletionResult(value: unknown): CompletionResult {
return CompletionResultSchema.parse(value) as CompletionResult;
}
export function parseCompletionResultForEnvelope(
value: unknown,
envelope: TaskEnvelope
): CompletionResult {
const result = parseCompletionResult(value);
const mismatches = [
result.taskId === envelope.subject.id || 'task ID',
result.attemptId === envelope.attempt.id || 'attempt ID',
result.taskEnvelopeDigest === envelope.digest || 'task envelope digest',
result.providerRuntimeManifestDigest === envelope.launchManifest.digest || 'manifest digest',
].filter((item): item is string => typeof item === 'string');
if (mismatches.length > 0) {
throw new z.ZodError(
mismatches.map((field) => ({
code: 'custom' as const,
path: [field],
message: `Completion result does not match the task envelope ${field}`,
}))
);
}
if (result.status === 'success') {
const verifiedEvidence = result.evidence.filter((item) => item.verified);
const verifiedEvidenceIds = new Set(verifiedEvidence.map((item) => item.id));
const missingEvidence = envelope.completionContract.evidenceRequirements.filter(
(requirement) =>
requirement.required &&
!verifiedEvidence.some(
(evidence) =>
evidence.kind === requirement.kind && evidence.requirementIds.includes(requirement.id)
)
);
const verificationByGate = new Map(result.verification.map((item) => [item.gateId, item]));
const missingVerification = envelope.verificationGates.filter((gate) => {
if (!gate.required) return false;
const verification = verificationByGate.get(gate.id);
if (!verification || verification.status !== 'passed') return true;
if (!gate.evidenceRequired) return false;
return !verification.evidenceIds.some((evidenceId) => verifiedEvidenceIds.has(evidenceId));
});
if (missingEvidence.length > 0 || missingVerification.length > 0) {
throw new z.ZodError([
...missingEvidence.map((requirement) => ({
code: 'custom' as const,
path: ['evidence', requirement.id],
message: `Missing verified completion evidence: ${requirement.description}`,
})),
...missingVerification.map((gate) => ({
code: 'custom' as const,
path: ['verification', gate.id],
message: `Missing passed verification evidence: ${gate.description}`,
})),
]);
}
}
return result;
}

View file

@ -62,6 +62,8 @@ import type {
ProviderRuntimeControlAction,
ProviderRuntimeControlSet,
ProviderRuntimeManifest,
TaskCommitPolicy,
TaskEnvelope,
} from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
import { ConflictError } from '../middleware/error-handler.js';
@ -83,6 +85,7 @@ import {
BASELINE_LAUNCH_CAPABILITIES,
providerRuntimeControls,
} from './provider-runtime-control-service.js';
import { resolveTaskCommitPolicy, TaskEnvelopeService } from './task-envelope-service.js';
const log = createLogger('clawdbot-agent-service');
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
@ -137,6 +140,7 @@ export interface AgentStatus {
provider?: ExecutableAgentProvider;
model?: string;
providerRuntimeManifest: ProviderRuntimeManifest;
taskEnvelope: TaskEnvelope;
controls: ProviderRuntimeControlSet;
}
@ -152,6 +156,7 @@ export interface AgentStartOptions {
sandboxPresetId?: string;
budget?: AgentBudgetPolicy;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
commitPolicy?: TaskCommitPolicy;
}
export interface AgentMessageOptions {
@ -193,6 +198,7 @@ interface PendingAgent {
budgetStopped?: boolean;
agentProfile?: AgentProfileLaunchMetadata;
providerRuntimeManifest: ProviderRuntimeManifest;
taskEnvelope: TaskEnvelope;
threadId?: string;
abortController?: AbortController;
process?: ChildProcessWithoutNullStreams;
@ -234,16 +240,19 @@ export class ClawdbotAgentService {
private taskService: TaskService;
private agentHealth: AgentHealthChecker;
private providerRuntimeManifests: ProviderRuntimeManifestService;
private taskEnvelopes: TaskEnvelopeService;
private logsDir: string;
constructor(
agentHealth?: AgentHealthChecker,
providerRuntimeManifests = new ProviderRuntimeManifestService()
providerRuntimeManifests = new ProviderRuntimeManifestService(),
taskEnvelopes = new TaskEnvelopeService()
) {
this.configService = new ConfigService();
this.taskService = new TaskService();
this.agentHealth = agentHealth || new AgentHealthService();
this.providerRuntimeManifests = providerRuntimeManifests;
this.taskEnvelopes = taskEnvelopes;
this.logsDir = getLogsDir();
this.ensureLogsDir();
}
@ -525,6 +534,23 @@ export class ClawdbotAgentService {
const attemptId = `attempt_${nanoid(8)}`;
const startedAt = new Date().toISOString();
const logPath = path.join(this.logsDir, `${taskId}_${attemptId}.md`);
const worktreePath = this.expandPath(task.git.worktreePath);
const commitPolicy = resolveTaskCommitPolicy({
runPolicy: options.commitPolicy,
taskPolicy: task.executionPolicy,
legacyAutoCommitOnComplete: config.features?.agents.autoCommitOnComplete,
});
const taskEnvelope = await this.taskEnvelopes.build({
task,
attemptId,
createdAt: startedAt,
worktreePath,
providerRuntimeManifest,
commitPolicy,
profileInstructions: profileLaunch?.instructions,
networkAccessEnabled: sandboxPolicy.result.effective.networkAccessEnabled,
executionPolicy: task.executionPolicy,
});
// Create event emitter for status updates
const emitter = new EventEmitter();
@ -540,6 +566,7 @@ export class ClawdbotAgentService {
model: launchAgentConfig?.model,
agentProfile: profileLaunch?.metadata,
providerRuntimeManifest,
taskEnvelope,
budget: budgetPolicy
? {
...budgetService.initialState(budgetPolicy),
@ -558,7 +585,6 @@ export class ClawdbotAgentService {
validatePathSegment(attemptId);
// Build the task prompt for Clawdbot
const worktreePath = this.expandPath(task.git.worktreePath);
const taskPrompt = this.buildTaskPrompt(
task,
worktreePath,
@ -569,7 +595,7 @@ export class ClawdbotAgentService {
// Initialize log file (ensure it stays within logs dir)
ensureWithinBase(this.logsDir, logPath);
await this.initLogFile(logPath, task, agent, taskPrompt, providerRuntimeManifest);
await this.initLogFile(logPath, task, agent, taskPrompt, providerRuntimeManifest, taskEnvelope);
// Update task with attempt info
const attempt: TaskAttempt = {
@ -582,6 +608,7 @@ export class ClawdbotAgentService {
budget: pendingAgents.get(taskId)?.budget,
agentProfile: profileLaunch?.metadata,
providerRuntimeManifest,
taskEnvelope,
};
await this.taskService.updateTask(taskId, {
@ -677,6 +704,7 @@ export class ClawdbotAgentService {
provider,
model: launchAgentConfig?.model,
providerRuntimeManifest,
taskEnvelope,
controls: providerRuntimeControls(providerRuntimeManifest),
};
}
@ -830,6 +858,7 @@ export class ClawdbotAgentService {
budget: pending.budget,
agentProfile: pending.agentProfile,
providerRuntimeManifest: pending.providerRuntimeManifest,
taskEnvelope: pending.taskEnvelope,
};
return (pending.preparedCompletion = {
status,
@ -2581,6 +2610,7 @@ export class ClawdbotAgentService {
provider: pending.provider,
model: pending.model,
providerRuntimeManifest: pending.providerRuntimeManifest,
taskEnvelope: pending.taskEnvelope,
controls: providerRuntimeControls(pending.providerRuntimeManifest),
};
}
@ -2807,7 +2837,8 @@ If you encounter errors, call with \`success: false\` and include the error mess
task: Task,
agent: AgentType,
prompt: string,
providerRuntimeManifest: ProviderRuntimeManifest
providerRuntimeManifest: ProviderRuntimeManifest,
taskEnvelope: TaskEnvelope
): Promise<void> {
const header = `# Agent Log: ${task.title}
@ -2816,6 +2847,7 @@ If you encounter errors, call with \`success: false\` and include the error mess
**Started:** ${new Date().toISOString()}
**Worktree:** ${task.git?.worktreePath}
**Provider manifest:** ${providerRuntimeManifest.digest}
**Task envelope:** ${taskEnvelope.digest}
<details><summary>Provider runtime manifest</summary>
@ -2825,6 +2857,14 @@ ${JSON.stringify(providerRuntimeManifest, null, 2)}
</details>
<details><summary>Task envelope</summary>
\`\`\`json
${JSON.stringify(taskEnvelope, null, 2)}
\`\`\`
</details>
## Task Prompt
\`\`\`

View file

@ -0,0 +1,489 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
import {
COMPLETION_RESULT_SCHEMA_VERSION,
TASK_ENVELOPE_SCHEMA_VERSION,
findProviderRuntimeCapability,
type ProviderRuntimeManifest,
type Task,
type TaskAllowedSideEffect,
type TaskCommitPolicy,
type TaskEnvelope,
type TaskEvidenceRequirement,
type TaskExecutionPolicy,
type TaskExpectedOutput,
type TaskLaunchBaseline,
type TaskLaunchBaselineFile,
} from '@veritas-kanban/shared';
import { parseTaskEnvelope } from '../schemas/task-envelope-schemas.js';
import {
calculateTaskEnvelopeDigest,
type TaskEnvelopePayload,
} from '../utils/task-envelope-digest.js';
import { sha256WorktreeEntry } from '../utils/worktree-fingerprint.js';
const MAX_BASELINE_FILES = 1000;
const BASELINE_GIT_TIMEOUT_MS = 10_000;
const MAX_BASELINE_CAPTURE_ATTEMPTS = 3;
const INDEX_PATHSPEC_CHUNK_LENGTH = 48_000;
const WORKTREE_HASH_CONCURRENCY = 8;
type BaselineGitClient = Pick<SimpleGit, 'raw' | 'revparse' | 'status'>;
type BaselineGitFactory = (worktreePath: string) => BaselineGitClient;
function createBaselineGit(worktreePath: string): BaselineGitClient {
return simpleGit({
baseDir: worktreePath,
maxConcurrentProcesses: 1,
timeout: { block: BASELINE_GIT_TIMEOUT_MS },
});
}
export interface CompletionEvidenceSource {
captureLaunchBaseline(worktreePath: string, capturedAt: string): Promise<TaskLaunchBaseline>;
}
export class GitCompletionEvidenceSource implements CompletionEvidenceSource {
constructor(private readonly gitFactory: BaselineGitFactory = createBaselineGit) {}
async captureLaunchBaseline(
worktreePath: string,
capturedAt: string
): Promise<TaskLaunchBaseline> {
const git = this.gitFactory(worktreePath);
for (let attempt = 1; attempt <= MAX_BASELINE_CAPTURE_ATTEMPTS; attempt++) {
const headBefore = (await git.revparse(['HEAD'])).trim();
const statusBefore = await git.status(['--untracked-files=all']);
assertBaselineFileLimit(statusBefore);
const filesBefore = await captureBaselineFiles(git, worktreePath, statusBefore);
const headAfter = (await git.revparse(['HEAD'])).trim();
const statusAfter = await git.status(['--untracked-files=all']);
assertBaselineFileLimit(statusAfter);
if (
headBefore !== headAfter ||
statusSignature(statusBefore) !== statusSignature(statusAfter)
) {
continue;
}
const filesAfter = await captureBaselineFiles(git, worktreePath, statusAfter);
const headFinal = (await git.revparse(['HEAD'])).trim();
const statusFinal = await git.status(['--untracked-files=all']);
assertBaselineFileLimit(statusFinal);
if (
headAfter !== headFinal ||
statusSignature(statusAfter) !== statusSignature(statusFinal) ||
JSON.stringify(filesBefore) !== JSON.stringify(filesAfter)
) {
continue;
}
return {
capturedAt,
headSha: headFinal,
dirty: !statusFinal.isClean(),
files: filesAfter,
};
}
throw new Error(
`Task worktree changed while capturing the launch baseline after ${MAX_BASELINE_CAPTURE_ATTEMPTS} attempts`
);
}
}
export interface ResolveTaskCommitPolicyInput {
runPolicy?: TaskCommitPolicy;
taskPolicy?: TaskExecutionPolicy;
legacyAutoCommitOnComplete?: boolean;
}
export function resolveTaskCommitPolicy(input: ResolveTaskCommitPolicyInput): TaskCommitPolicy {
return (
input.runPolicy ??
input.taskPolicy?.commitPolicy ??
(input.legacyAutoCommitOnComplete ? 'required' : 'allowed')
);
}
export interface BuildTaskEnvelopeInput {
task: Task;
attemptId: string;
createdAt: string;
worktreePath: string;
providerRuntimeManifest: ProviderRuntimeManifest;
commitPolicy: TaskCommitPolicy;
profileInstructions?: string;
networkAccessEnabled?: boolean;
executionPolicy?: TaskExecutionPolicy;
}
export class TaskEnvelopeService {
constructor(
private readonly evidenceSource: CompletionEvidenceSource = new GitCompletionEvidenceSource()
) {}
async build(input: BuildTaskEnvelopeInput): Promise<TaskEnvelope> {
const baseline = await this.evidenceSource.captureLaunchBaseline(
input.worktreePath,
input.createdAt
);
const payload: TaskEnvelopePayload = {
schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
subject: {
id: input.task.id,
title: input.task.title,
objective: input.task.title,
background: compactStrings([
input.task.description,
...(input.task.observations ?? [])
.filter(
(observation) => observation.type === 'context' || observation.type === 'decision'
)
.map((observation) => observation.content),
]).slice(0, 64),
constraints: compactStrings([
input.profileInstructions,
`Operate only inside the assigned worktree: ${input.worktreePath}`,
]).slice(0, 128),
acceptanceCriteria: compactStrings(
(input.task.subtasks ?? []).flatMap((subtask) => subtask.acceptanceCriteria ?? [])
).slice(0, 256),
},
attempt: {
id: input.attemptId,
createdAt: input.createdAt,
},
workspace: {
workspaceId: normalizeWorkspaceId(
input.task.project?.trim() || input.task.git?.repo || input.task.id
),
worktreeId: input.task.id,
repo: input.task.git?.repo || 'unknown',
branch: input.task.git?.branch || 'unknown',
baseBranch: input.task.git?.baseBranch || 'unknown',
worktreePath: input.worktreePath,
baseline,
},
commitPolicy: input.commitPolicy,
allowedSideEffects: buildAllowedSideEffects(input),
expectedOutputs: buildExpectedOutputs(input),
verificationGates: (input.task.verificationSteps ?? []).slice(0, 256).map((step) => ({
id: step.id,
description: step.description,
required: true,
evidenceRequired: true,
})),
launchManifest: {
schemaVersion: input.providerRuntimeManifest.schemaVersion,
digest: input.providerRuntimeManifest.digest,
provider: input.providerRuntimeManifest.provider,
adapter: input.providerRuntimeManifest.adapter,
protocolVersion: input.providerRuntimeManifest.protocolVersion,
},
completionContract: {
schemaVersion: COMPLETION_RESULT_SCHEMA_VERSION,
evidenceRequirements: buildEvidenceRequirements(input),
},
};
const envelope = parseTaskEnvelope({
...payload,
digest: calculateTaskEnvelopeDigest(payload),
});
return immutableClone(envelope);
}
}
function buildAllowedSideEffects(input: BuildTaskEnvelopeInput): TaskAllowedSideEffect[] {
const defaults: TaskAllowedSideEffect[] = [
{ kind: 'filesystem-write', scope: input.worktreePath },
{ kind: 'process-execute', scope: input.worktreePath },
];
if (input.commitPolicy !== 'forbidden') {
defaults.push({ kind: 'git-commit', scope: input.worktreePath });
}
if (input.networkAccessEnabled) {
defaults.push({ kind: 'network-egress', scope: 'sandbox policy' });
}
const artifactCapability = findProviderRuntimeCapability(
input.providerRuntimeManifest,
'artifact.write'
);
if (artifactCapability?.state === 'supported') {
defaults.push({ kind: 'artifact-write', scope: input.worktreePath });
}
const requested = input.executionPolicy?.allowedSideEffects ?? defaults;
const effective = uniqueBy(
requested.flatMap((sideEffect) =>
defaults
.filter((sandboxEffect) => sandboxEffect.kind === sideEffect.kind)
.flatMap((sandboxEffect) => {
const scope = clampSideEffectScope(sideEffect, sandboxEffect);
return scope ? [{ ...sideEffect, scope }] : [];
})
),
(sideEffect) => `${sideEffect.kind}:${sideEffect.scope}`
).slice(0, 32);
if (
input.commitPolicy === 'required' &&
!effective.some((sideEffect) => sideEffect.kind === 'git-commit')
) {
throw new Error('Required commit policy must authorize the git-commit side effect');
}
return effective;
}
function buildExpectedOutputs(input: BuildTaskEnvelopeInput): TaskExpectedOutput[] {
const configured = input.executionPolicy?.expectedOutputs;
const outputs: TaskExpectedOutput[] = [...(configured ?? [])];
if (!outputs.some((output) => output.id === 'completion-summary')) {
outputs.unshift({
id: 'completion-summary',
kind: 'text',
description: 'A concise summary of the completed work and remaining risks.',
required: true,
});
}
if (
input.commitPolicy === 'required' &&
!outputs.some((output) => output.kind === 'commit' && output.required)
) {
outputs.push({
id: 'git-commit',
kind: 'commit',
description: 'At least one new commit attributable to this attempt.',
required: true,
});
}
if (
input.commitPolicy === 'forbidden' &&
outputs.some((output) => output.kind === 'commit' && output.required)
) {
throw new Error('Forbidden commit policy cannot require a commit output');
}
for (const deliverable of input.task.deliverables ?? []) {
outputs.push({
id: `deliverable-${deliverable.id}`,
kind: deliverable.path ? 'file' : 'artifact',
description: deliverable.title,
required: deliverable.status !== 'accepted',
});
}
return uniqueBy(outputs, (output) => output.id).slice(0, 64);
}
function buildEvidenceRequirements(input: BuildTaskEnvelopeInput): TaskEvidenceRequirement[] {
const requirements: TaskEvidenceRequirement[] = [
{
id: 'terminal-state',
kind: 'provider-output',
description: 'Harness-verified provider terminal state from the native transport.',
required: true,
},
];
if ((input.task.verificationSteps?.length ?? 0) > 0) {
requirements.push({
id: 'verification',
kind: 'verification',
description: 'Harness-verified evidence for every required verification gate.',
required: true,
});
}
if (input.commitPolicy === 'required') {
requirements.push({
id: 'commit',
kind: 'commit',
description: 'Harness-verified commit created after the launch baseline.',
required: true,
});
}
return requirements;
}
function mapBaselineFile(
file: StatusResult['files'][number],
indexBlobHash: string | null,
worktreeSha256: string | null
): TaskLaunchBaselineFile {
const code = `${file.index}${file.working_dir}`;
let status: TaskLaunchBaselineFile['status'] = 'modified';
if (code.includes('?')) status = 'untracked';
else if (code.includes('R')) status = 'renamed';
else if (code.includes('D')) status = 'deleted';
else if (code.includes('A')) status = 'added';
return { path: file.path, status, indexBlobHash, worktreeSha256 };
}
function assertBaselineFileLimit(status: StatusResult): void {
if (status.files.length > MAX_BASELINE_FILES) {
throw new Error(
`Task worktree baseline has ${status.files.length} changed files; maximum is ${MAX_BASELINE_FILES}`
);
}
}
function statusSignature(status: StatusResult): string {
return JSON.stringify({
ahead: status.ahead,
behind: status.behind,
current: status.current,
detached: status.detached,
tracking: status.tracking,
files: status.files
.map((file) => ({
from: file.from ?? null,
path: file.path,
index: file.index,
workingDir: file.working_dir,
}))
.sort((left, right) =>
`${left.path}\0${left.from ?? ''}`.localeCompare(`${right.path}\0${right.from ?? ''}`)
),
});
}
async function captureBaselineFiles(
git: BaselineGitClient,
worktreePath: string,
status: StatusResult
): Promise<TaskLaunchBaselineFile[]> {
const statusFiles = [...status.files].sort((left, right) =>
`${left.path}\0${left.from ?? ''}`.localeCompare(`${right.path}\0${right.from ?? ''}`)
);
const indexBlobHashes = await readIndexBlobHashes(
git,
statusFiles.map((file) => file.path)
);
const result: TaskLaunchBaselineFile[] = [];
for (let offset = 0; offset < statusFiles.length; offset += WORKTREE_HASH_CONCURRENCY) {
const batch = statusFiles.slice(offset, offset + WORKTREE_HASH_CONCURRENCY);
result.push(
...(await Promise.all(
batch.map(async (file) =>
mapBaselineFile(
file,
indexBlobHashes.get(file.path) ?? null,
await sha256WorktreeEntry(worktreePath, file.path)
)
)
))
);
}
return result;
}
async function readIndexBlobHashes(
git: BaselineGitClient,
paths: string[]
): Promise<Map<string, string>> {
const hashes = new Map<string, string>();
for (const pathChunk of chunkPathspecs(paths)) {
const output = await git.raw([
'ls-files',
'--stage',
'-z',
'--',
...pathChunk.map((filePath) => `:(literal)${filePath}`),
]);
for (const record of output.split('\0')) {
if (!record) continue;
const tabIndex = record.indexOf('\t');
if (tabIndex < 0) continue;
const [mode, objectId, stage] = record.slice(0, tabIndex).split(/\s+/);
const filePath = record.slice(tabIndex + 1);
if (!mode || !/^[a-f0-9]{40,64}$/.test(objectId ?? '') || !stage || !filePath) continue;
if (stage === '0' || !hashes.has(filePath)) hashes.set(filePath, objectId);
}
}
return hashes;
}
function chunkPathspecs(paths: string[]): string[][] {
const chunks: string[][] = [];
let chunk: string[] = [];
let length = 0;
for (const filePath of paths) {
if (chunk.length > 0 && length + filePath.length + 1 > INDEX_PATHSPEC_CHUNK_LENGTH) {
chunks.push(chunk);
chunk = [];
length = 0;
}
chunk.push(filePath);
length += filePath.length + 1;
}
if (chunk.length > 0) chunks.push(chunk);
return chunks;
}
const PATH_SCOPED_SIDE_EFFECTS = new Set<TaskAllowedSideEffect['kind']>([
'filesystem-write',
'process-execute',
'git-commit',
'artifact-write',
]);
function clampSideEffectScope(
requested: TaskAllowedSideEffect,
sandboxEffect: TaskAllowedSideEffect
): string | null {
if (!PATH_SCOPED_SIDE_EFFECTS.has(requested.kind)) return requested.scope;
const sandboxRoot = path.resolve(sandboxEffect.scope);
const requestedPath = path.resolve(sandboxRoot, requested.scope);
if (isPathWithin(sandboxRoot, requestedPath)) return requestedPath;
if (isPathWithin(requestedPath, sandboxRoot)) return sandboxRoot;
return null;
}
function isPathWithin(basePath: string, targetPath: string): boolean {
const relative = path.relative(basePath, targetPath);
return (
relative === '' ||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
}
function normalizeWorkspaceId(value: string): string {
const normalized = value.trim();
if (normalized.length <= 160) return normalized;
const suffix = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
return `${normalized.slice(0, 143).trimEnd()}~${suffix}`;
}
function compactStrings(values: Array<string | undefined>): string[] {
return values.flatMap((value) => {
const normalized = value?.trim();
if (!normalized) return [];
const chunks: string[] = [];
for (let offset = 0; offset < normalized.length; offset += 4000) {
chunks.push(normalized.slice(offset, offset + 4000));
}
return chunks;
});
}
function uniqueBy<T>(values: T[], key: (value: T) => string): T[] {
const seen = new Set<string>();
return values.filter((value) => {
const valueKey = key(value);
if (seen.has(valueKey)) return false;
seen.add(valueKey);
return true;
});
}
function immutableClone<T>(value: T): T {
return deepFreeze(structuredClone(value));
}
function deepFreeze<T>(value: T): T {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
return value;
}

View file

@ -656,6 +656,7 @@ export class TaskService {
sprint: data.sprint,
agent: data.agent,
agents: data.agents,
executionPolicy: data.executionPolicy,
created: data.created || new Date().toISOString(),
updated: data.updated || new Date().toISOString(),
git: data.git,
@ -837,6 +838,7 @@ export class TaskService {
project: input.project,
sprint: input.sprint,
agent: input.agent, // Pre-assigned agent (or "auto" for routing)
executionPolicy: input.executionPolicy,
subtasks: input.subtasks, // Include subtasks from template
blockedBy: input.blockedBy, // Include dependencies from blueprint
created: now,

View file

@ -0,0 +1,13 @@
import { createHash } from 'node:crypto';
import type { TaskEnvelope } from '@veritas-kanban/shared';
export type TaskEnvelopePayload = Omit<TaskEnvelope, 'digest'>;
export function calculateTaskEnvelopeDigest(envelope: TaskEnvelopePayload): string {
return `sha256:${createHash('sha256').update(JSON.stringify(envelope)).digest('hex')}`;
}
export function verifyTaskEnvelopeDigest(envelope: TaskEnvelope): boolean {
const { digest: _digest, ...payload } = envelope;
return envelope.digest === calculateTaskEnvelopeDigest(payload);
}

View file

@ -0,0 +1,45 @@
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { lstat, open, readlink } from 'node:fs/promises';
import path from 'node:path';
import { ensureWithinBase } from './sanitize.js';
function isMissingOrRaced(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR';
}
/**
* Hash one worktree entry without following symlinks outside the worktree.
* Symlinks are hashed as their link-target text, matching Git's blob semantics.
*/
export async function sha256WorktreeEntry(
worktreePath: string,
relativePath: string
): Promise<string | null> {
const absolutePath = ensureWithinBase(worktreePath, path.resolve(worktreePath, relativePath));
try {
const stat = await lstat(absolutePath);
const hash = createHash('sha256');
if (stat.isSymbolicLink()) {
return hash.update(await readlink(absolutePath), 'utf8').digest('hex');
}
if (!stat.isFile()) return null;
const handle = await open(absolutePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const openedStat = await handle.stat();
if (!openedStat.isFile()) return null;
const stream = handle.createReadStream({ autoClose: false });
for await (const chunk of stream) hash.update(chunk);
return hash.digest('hex');
} finally {
await handle.close();
}
} catch (error) {
if (isMissingOrRaced(error)) return null;
throw error;
}
}

View file

@ -47,3 +47,4 @@ export * from './queue-monitor.types.js';
export * from './watcher-policy.types.js';
export * from './evidence.types.js';
export * from './time-breakdown.types.js';
export * from './task-envelope.types.js';

View file

@ -0,0 +1,238 @@
export const TASK_ENVELOPE_SCHEMA_VERSION = 'task-envelope/v1' as const;
export const COMPLETION_RESULT_SCHEMA_VERSION = 'completion-result/v1' as const;
export const TASK_COMMIT_POLICIES = ['forbidden', 'allowed', 'required'] as const;
export type TaskCommitPolicy = (typeof TASK_COMMIT_POLICIES)[number];
/** Optional task-level defaults. Run-level values take precedence at launch. */
export interface TaskExecutionPolicy {
commitPolicy?: TaskCommitPolicy;
allowedSideEffects?: TaskAllowedSideEffect[];
expectedOutputs?: TaskExpectedOutput[];
}
export const TASK_COMPLETION_STATUSES = [
'success',
'blocked',
'failed',
'interrupted',
'partial',
] as const;
export type TaskCompletionStatus = (typeof TASK_COMPLETION_STATUSES)[number];
export const TASK_SIDE_EFFECT_KINDS = [
'filesystem-write',
'process-execute',
'network-egress',
'git-commit',
'external-write',
'artifact-write',
'task-mutate',
] as const;
export type TaskSideEffectKind = (typeof TASK_SIDE_EFFECT_KINDS)[number];
export const TASK_EXPECTED_OUTPUT_KINDS = ['text', 'file', 'artifact', 'commit', 'other'] as const;
export type TaskExpectedOutputKind = (typeof TASK_EXPECTED_OUTPUT_KINDS)[number];
export const TASK_EVIDENCE_KINDS = [
'provider-output',
'process-exit',
'stream-event',
'callback',
'verification',
'file-change',
'artifact',
'commit',
'other',
] as const;
export type TaskEvidenceKind = (typeof TASK_EVIDENCE_KINDS)[number];
export const TASK_EVIDENCE_SOURCES = ['provider', 'harness'] as const;
export type TaskEvidenceSource = (typeof TASK_EVIDENCE_SOURCES)[number];
export const TASK_VERIFICATION_STATUSES = ['passed', 'failed', 'skipped', 'unknown'] as const;
export type TaskVerificationStatus = (typeof TASK_VERIFICATION_STATUSES)[number];
export const TASK_CHANGED_FILE_STATUSES = [
'added',
'modified',
'deleted',
'renamed',
'untracked',
] as const;
export type TaskChangedFileStatus = (typeof TASK_CHANGED_FILE_STATUSES)[number];
export const TASK_ARTIFACT_KINDS = ['file', 'log', 'report', 'url', 'other'] as const;
export type TaskArtifactKind = (typeof TASK_ARTIFACT_KINDS)[number];
export const TASK_CONTINUATION_KINDS = ['thread', 'session', 'run', 'other'] as const;
export type TaskContinuationKind = (typeof TASK_CONTINUATION_KINDS)[number];
export interface TaskLaunchBaselineFile {
path: string;
status: TaskChangedFileStatus;
/** Git object ID for the staged index entry, or null when no index entry exists. */
indexBlobHash: string | null;
/** SHA-256 of the worktree entry bytes, or null when the entry does not exist/is not a file. */
worktreeSha256: string | null;
}
export interface TaskLaunchBaseline {
capturedAt: string;
headSha: string;
dirty: boolean;
files: TaskLaunchBaselineFile[];
}
export interface TaskEnvelopeSubject {
id: string;
title: string;
objective: string;
background: string[];
constraints: string[];
acceptanceCriteria: string[];
}
export interface TaskEnvelopeAttempt {
id: string;
createdAt: string;
}
export interface TaskEnvelopeWorkspace {
workspaceId: string;
worktreeId: string;
repo: string;
branch: string;
baseBranch: string;
worktreePath: string;
baseline: TaskLaunchBaseline;
}
export interface TaskAllowedSideEffect {
kind: TaskSideEffectKind;
scope: string;
}
export interface TaskExpectedOutput {
id: string;
kind: TaskExpectedOutputKind;
description: string;
required: boolean;
}
export interface TaskVerificationGate {
id: string;
description: string;
required: boolean;
evidenceRequired: boolean;
}
export interface TaskEvidenceRequirement {
id: string;
kind: TaskEvidenceKind;
description: string;
required: boolean;
}
export interface TaskLaunchManifestReference {
schemaVersion: string;
digest: string;
provider: string;
adapter: string;
protocolVersion: string;
}
export interface TaskCompletionContract {
schemaVersion: typeof COMPLETION_RESULT_SCHEMA_VERSION;
evidenceRequirements: TaskEvidenceRequirement[];
}
/** Immutable, provider-neutral input captured before an agent run starts. */
export interface TaskEnvelope {
schemaVersion: typeof TASK_ENVELOPE_SCHEMA_VERSION;
digest: string;
subject: TaskEnvelopeSubject;
attempt: TaskEnvelopeAttempt;
workspace: TaskEnvelopeWorkspace;
commitPolicy: TaskCommitPolicy;
allowedSideEffects: TaskAllowedSideEffect[];
expectedOutputs: TaskExpectedOutput[];
verificationGates: TaskVerificationGate[];
launchManifest: TaskLaunchManifestReference;
completionContract: TaskCompletionContract;
}
export interface TaskCompletionBlocker {
code: string;
summary: string;
detail: string;
retryable: boolean;
}
export interface TaskCompletionEvidence {
id: string;
kind: TaskEvidenceKind;
source: TaskEvidenceSource;
summary: string;
reference: string | null;
requirementIds: string[];
verified: boolean;
}
export interface TaskCompletionVerification {
gateId: string;
status: TaskVerificationStatus;
summary: string;
evidenceIds: string[];
}
export interface TaskCompletionChangedFile {
path: string;
status: TaskChangedFileStatus;
previousPath: string | null;
verified: boolean;
}
export interface TaskCompletionArtifact {
id: string;
kind: TaskArtifactKind;
name: string;
reference: string;
mediaType: string | null;
sha256: string | null;
verified: boolean;
}
export interface TaskCompletionSideEffect {
kind: TaskSideEffectKind;
description: string;
target: string | null;
authorized: boolean;
verified: boolean;
}
export interface TaskContinuationHandle {
provider: string;
kind: TaskContinuationKind;
reference: string;
}
/** Normalized terminal result returned by every provider transport. */
export interface CompletionResult {
schemaVersion: typeof COMPLETION_RESULT_SCHEMA_VERSION;
taskEnvelopeSchemaVersion: typeof TASK_ENVELOPE_SCHEMA_VERSION;
taskEnvelopeDigest: string;
taskId: string;
attemptId: string;
providerRuntimeManifestDigest: string;
status: TaskCompletionStatus;
summary: string;
error: string | null;
blockers: TaskCompletionBlocker[];
evidence: TaskCompletionEvidence[];
changedFiles: TaskCompletionChangedFile[];
artifacts: TaskCompletionArtifact[];
verification: TaskCompletionVerification[];
sideEffects: TaskCompletionSideEffect[];
continuation: TaskContinuationHandle | null;
}

View file

@ -62,6 +62,8 @@ export interface TaskAttempt {
budget?: import('./agent-budget.types.js').AgentBudgetState;
agentProfile?: import('./agent-profile-package.types.js').AgentProfileLaunchMetadata;
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
taskEnvelope?: import('./task-envelope.types.js').TaskEnvelope;
completionResult?: import('./task-envelope.types.js').CompletionResult;
}
export interface Subtask {
@ -234,6 +236,8 @@ export interface Task {
agent?: AgentType | 'auto';
// Multi-agent assignment — multiple agents collaborating on a task
agents?: AgentType[];
// Provider-neutral run policy defaults. Launch-time overrides take precedence.
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
// Code task specific
git?: TaskGit;
@ -377,6 +381,7 @@ export interface CreateTaskInput {
updatedBy?: string;
agent?: AgentType | 'auto'; // Pre-assign an agent (or "auto" for routing engine)
agents?: AgentType[]; // Multi-agent assignment
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
subtasks?: Subtask[]; // Can be provided when creating from a template
blockedBy?: string[]; // Can be provided when creating from a blueprint
reviewScores?: [number, number, number, number]; // Optional 4x10 scores
@ -395,6 +400,7 @@ export interface UpdateTaskInput {
updatedBy?: string;
agent?: AgentType | 'auto';
agents?: AgentType[];
executionPolicy?: import('./task-envelope.types.js').TaskExecutionPolicy;
git?: Partial<TaskGit>;
github?: TaskGitHub;
delegatedWork?: import('./workspace-capability.types.js').TaskDelegatedWorkLink[];

View file

@ -9,6 +9,7 @@ import type {
AgentHostPreviewRequest,
AgentType,
ProviderRuntimeCapabilityId,
TaskCommitPolicy,
} from '@veritas-kanban/shared';
export interface StartAgentInput {
@ -19,6 +20,7 @@ export interface StartAgentInput {
sandboxPresetId?: string;
budget?: AgentBudgetPolicy;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
commitPolicy?: TaskCommitPolicy;
}
export interface AgentApprovalRequest {
@ -69,6 +71,7 @@ export function useStartAgent() {
sandboxPresetId,
budget,
requiredRuntimeCapabilities,
commitPolicy,
}: StartAgentInput) =>
api.agent.start(taskId, {
agent,
@ -77,6 +80,7 @@ export function useStartAgent() {
sandboxPresetId,
budget,
requiredRuntimeCapabilities,
commitPolicy,
}),
onSuccess: (_, { taskId }) => {
queryClient.invalidateQueries({ queryKey: ['agent', 'status', taskId] });

View file

@ -12,6 +12,8 @@ import type {
ProviderRuntimeManifest,
ProviderRuntimeCapabilityId,
ProviderRuntimeControlSet,
TaskCommitPolicy,
TaskEnvelope,
} from '@veritas-kanban/shared';
import { API_BASE, apiFetch } from './helpers';
@ -22,6 +24,7 @@ export interface StartAgentRequest {
sandboxPresetId?: string;
budget?: AgentBudgetPolicy;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
commitPolicy?: TaskCommitPolicy;
}
export const worktreeApi = {
@ -250,6 +253,7 @@ export interface AgentStatus {
provider?: string;
model?: string;
providerRuntimeManifest: ProviderRuntimeManifest;
taskEnvelope: TaskEnvelope;
controls: ProviderRuntimeControlSet;
}
@ -263,6 +267,7 @@ export interface AgentStatusResponse {
provider?: string;
model?: string;
providerRuntimeManifest?: ProviderRuntimeManifest;
taskEnvelope?: TaskEnvelope;
controls?: ProviderRuntimeControlSet;
}