diff --git a/README.md b/README.md index 61e7daa..b506655 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ and the first Claude reference-fleet path: - purge protection for dirty and uniquely committed domain work. - SQLite-backed stable Fleet identities and presence; - durable direct/group messages with per-recipient acknowledgement; +- revision-checked tracked backlog tasks with dependencies, evidence, and landed-commit completion; - live ownership, neighbours, handoff, coordination, completion, and blocker operations; - equivalent CLI and identity-bound MCP surfaces; - current MCP stdio interoperability through the official protocol SDK; @@ -99,7 +100,8 @@ node bin/torch.mjs down --yes --json ``` The CLI fallback exposes `agents`, `who-owns`, `message`, `inbox`, `ack`, -`status`, `complete`, `blocked`, `coordinate`, and `handoff`. MCP hosts launch +`status`, `complete`, `blocked`, `coordinate`, `handoff`, and the tracked +`backlog` lifecycle. MCP hosts launch `torch-mcp --root /path/to/repository --area ` and receive the same service operations, identity-bound to that Fleet area. @@ -119,10 +121,9 @@ node bin/torch.mjs upgrade --version 0.1.0-alpha.0 --yes --json node bin/torch.mjs rollback --dry-run --json ``` -The current source cannot yet satisfy its own complete candidate gate because -the durable backlog lifecycle is still staged work. This is an intentional -fail-closed dependency, not a waived acceptance check. Bootstrapping an -installed stable TORCH to manage this repository also remains open. +The candidate gate requires named evidence for backlog-through-integration, +not merely a green process exit. Bootstrapping an installed stable TORCH to +manage this repository remains open. Persistent domain sessions also create a context-locality opportunity: each specialist can keep its relevant working set hot instead of one general session diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 6ed6746..d117ba4 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -921,6 +921,14 @@ Owner decisions affecting future work MUST be recorded durably. Chat messages alone are insufficient. Project source, tests, approved configuration, and accepted decision records outrank summaries and generated prompts. +Implementation checkpoint (2026-09-27): backlog items are revisioned JSON +records in the Session Manager's tracked worktree. The service and equivalent +CLI/MCP operations enforce transition, owner, dependency, and concurrent-write +rules; publish assignments and blockers through durable messaging; surface +active work in doctor; and require a landed integration record at the same +commit before completion. Keeping management records in the manager worktree +prevents backlog activity from dirtying canonical main. + ## 18. Checks and evidence Projects declare checks as commands with optional resources and applicability. @@ -1377,10 +1385,10 @@ Implementation checkpoint (2026-09-27): isolated version installation, reserved validation receipts, declared state compatibility, an atomic active symlink, activation journaling, interruption reconciliation, exact rollback, and approval-gated CLI operations are implemented and scenario-tested. The -candidate CLI requires named acceptance evidence and currently fails closed on -the not-yet-implemented durable backlog lifecycle. The exit gate remains open -until that lifecycle exists and an installed stable TORCH manages this source -repository; candidate code does not receive a hidden self-hosting exception. +candidate CLI requires named acceptance evidence, including the implemented +tracked backlog-through-integration lifecycle. The exit gate remains open +until an installed stable TORCH manages this source repository; candidate code +does not receive a hidden self-hosting exception. ### Stage 7: COMBATRIG re-import diff --git a/package.json b/package.json index 2190f26..5433b2e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "main": "src/cli.mjs", "exports": { ".": "./src/cli.mjs", + "./backlog": "./src/backlog/service.mjs", "./adapters/claude": "./src/adapters/claude.mjs", "./adapters/codex": "./src/adapters/codex.mjs", "./canonical/local": "./src/canonical/local.mjs", @@ -45,6 +46,7 @@ "bin/torch.mjs", "src/cli.mjs", "src/adapters/", + "src/backlog/", "src/canonical/", "src/checks/", "src/control-plane/", @@ -63,8 +65,8 @@ "test": "node --test --test-timeout=30000 test/fleet/*.test.mjs", "torch": "node bin/torch.mjs", "check": "node --check bin/torch.mjs && node --check src/cli.mjs && node --check src/mcp/server.mjs", - "format": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet --fix", - "lint": "eslint src/adapters src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet" + "format": "eslint src/adapters src/backlog src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet --fix", + "lint": "eslint src/adapters src/backlog src/canonical src/checks src/control-plane src/integration src/kernel src/mcp src/resources src/runtime src/self-host src/cli.mjs bin/torch.mjs test/fleet" }, "dependencies": { "@modelcontextprotocol/server": "^2.1.0", diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index ddb6da2..917ba15 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -62,6 +62,10 @@ test_integrity_note: given: an installed project using no MCP-capable runtime when: an agent sends, reads, acknowledges, reports status, and queries ownership through CLI then: each operation observes the same durable control-plane state + - id: SCN-cli-backlog + given: an installed fleet with Session Manager and worker worktrees + when: public CLI commands create, ready, assign, and list a tracked task + then: revisioned backlog state changes in the manager worktree and the assigned owner is visible through the same service - id: SCN-cli-self-host given: two isolated candidate artifacts with complete named acceptance evidence when: an owner plans, builds, activates, upgrades, and rolls back through the CLI @@ -73,7 +77,7 @@ test_integrity_note: - id: SCN-mcp-stdio given: an installed TORCH project and an MCP host process when: the host performs the initialize handshake and lists tools over stdio - then: the official MCP transport identifies TORCH and advertises the exact 14-tool contract + then: the official MCP transport identifies TORCH and advertises the complete configured tool contract - id: SCN-claude-adapter given: a Claude runtime adapter with a virtualized command runner when: sessions are planned and runtime state is queried @@ -94,6 +98,10 @@ test_integrity_note: given: one approved Codex worker domain and one Claude Session Manager with stable Fleet identities when: the mixed fleet starts and winds down through virtualized runtime boundaries then: provider-specific commands and runtime IDs remain separate, the manager starts last, Codex completes idle, and only resident processes are stopped + - id: SCN-backlog-lifecycle + given: tracked tasks in the Session Manager worktree with domain owners, dependencies, and an integration lookup + when: tasks move through assignment, work, blocking, verification, readiness, landing, and completion + then: authority and optimistic revisions are enforced, concurrent locks fail closed, dependencies gate assignment, doctor observes active work, and completion requires the same landed commit - id: SCN-candidate-isolation given: an inactive candidate in a namespace separate from project and stable runtime state when: acceptance is incomplete, complete, or the accepted files are later modified @@ -142,6 +150,7 @@ test_integrity_note: - virtualized Claude command runner with no provider calls - virtualized Codex command runner and deterministic JSONL events with no provider calls - local stdio MCP child process with bounded response timeout + - deterministic backlog IDs, revisions, clocks, and injected integration lookup - shell-free fixture checks with controlled exit codes - injected resource clock and identifiers - local-only bare Git remote with no network dependency @@ -157,6 +166,9 @@ test_integrity_note: - in-memory-only messaging that disappears on restart - runtime session IDs masquerading as durable Fleet identities - messages or handoffs silently granting authority + - workers creating tasks, changing owners, or completing work without a landed matching commit + - concurrent backlog writers silently overwriting a newer task revision + - management backlog files dirtying canonical main instead of the manager worktree - repository content redirecting machine-local database writes - MCP and CLI drifting onto separate business logic - launch plans that execute provider commands during preview diff --git a/src/backlog/service.mjs b/src/backlog/service.mjs new file mode 100644 index 0000000..5384a50 --- /dev/null +++ b/src/backlog/service.mjs @@ -0,0 +1,308 @@ +import { randomUUID } from 'node:crypto'; +import { + closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { readInstallManifest } from '../kernel/install.mjs'; + +export const BACKLOG_STATES = Object.freeze([ + 'proposed', 'ready', 'assigned', 'in_progress', 'blocked', + 'verification', 'ready_to_integrate', 'completed', 'cancelled', +]); + +const TRANSITIONS = new Map([ + ['proposed', new Set(['ready', 'cancelled'])], + ['ready', new Set(['assigned', 'cancelled'])], + ['assigned', new Set(['in_progress', 'blocked', 'cancelled'])], + ['in_progress', new Set(['blocked', 'verification', 'cancelled'])], + ['blocked', new Set(['assigned', 'in_progress', 'cancelled'])], + ['verification', new Set(['in_progress', 'blocked', 'ready_to_integrate'])], + ['ready_to_integrate', new Set(['in_progress', 'blocked', 'completed'])], + ['completed', new Set()], + ['cancelled', new Set()], +]); + +const PRIORITIES = new Set(['urgent', 'high', 'normal', 'low']); +const WORKER_TRANSITIONS = new Set(['in_progress', 'blocked', 'verification', 'ready_to_integrate']); + +function requiredText(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new TorchError(`${name} must be a non-empty string`, { + code: 'INVALID_BACKLOG_INPUT', details: { field: name }, + }); + } + return value.trim(); +} + +function textList(value, name, { required = false } = {}) { + if (!Array.isArray(value) || (required && value.length === 0)) { + throw new TorchError(`${name} must be ${required ? 'a non-empty' : 'an'} array`, { + code: 'INVALID_BACKLOG_INPUT', details: { field: name }, + }); + } + return [...new Set(value.map((item) => requiredText(item, name)))]; +} + +function taskId(value) { + const id = requiredText(value, 'taskId'); + if (!/^TASK-[A-Za-z0-9-]+$/.test(id)) { + throw new TorchError(`Invalid backlog task ID: ${id}`, { code: 'INVALID_BACKLOG_TASK_ID' }); + } + return id; +} + +function parseTask(path) { + let task; + try { task = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { + throw new TorchError(`Cannot read backlog task at ${path}`, { + code: 'BACKLOG_TASK_INVALID', details: error.message, + }); + } + if (task.schema !== 'torch.dev/backlog-item/v1alpha1' + || !BACKLOG_STATES.includes(task.state) + || !Number.isInteger(task.revision)) { + throw new TorchError(`Backlog task has invalid schema or state: ${path}`, { + code: 'BACKLOG_TASK_INVALID', details: { path }, + }); + } + return task; +} + +function atomicTask(path, task) { + const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(temporary, `${JSON.stringify(task, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + renameSync(temporary, path); +} + +export class BacklogService { + constructor({ + repositoryRoot, controlPlane, integrationLookup = null, + clock = () => new Date(), idFactory = randomUUID, + } = {}) { + this.repositoryRoot = repositoryRoot; + this.controlPlane = controlPlane; + this.integrationLookup = integrationLookup; + this.clock = clock; + this.idFactory = idFactory; + const manifest = readInstallManifest(repositoryRoot); + const managerWorktree = (manifest.external ?? []).find((entry) => + entry.type === 'worktree' && entry.area === 'session-manager'); + this.managerWorktreeAvailable = Boolean(managerWorktree?.path && existsSync(managerWorktree.path)); + this.root = join( + this.managerWorktreeAvailable ? managerWorktree.path : repositoryRoot, + '.torch', 'backlog', + ); + if (!existsSync(this.root)) { + throw new TorchError('Tracked backlog directory is missing', { code: 'BACKLOG_DIRECTORY_MISSING' }); + } + } + + #assertMutable() { + if (!this.managerWorktreeAvailable) { + throw new TorchError('Backlog mutations require the Session Manager worktree', { + code: 'BACKLOG_WORKTREE_MISSING', + }); + } + } + + #path(id) { + return join(this.root, `${taskId(id)}.json`); + } + + #withTaskLock(id, callback) { + const normalized = taskId(id); + const path = join(this.controlPlane.stateRoot, 'locks', `backlog-${normalized}.lock`); + let descriptor; + try { + descriptor = openSync(path, 'wx', 0o600); + writeSync(descriptor, `${JSON.stringify({ taskId: normalized, createdAt: this.clock().toISOString() })}\n`); + } catch (error) { + if (error.code === 'EEXIST') { + throw new TorchError(`Backlog task is already being updated: ${normalized}`, { + code: 'BACKLOG_TASK_LOCKED', details: { taskId: normalized, lockPath: path }, + }); + } + throw error; + } + try { return callback(); } finally { + closeSync(descriptor); + unlinkSync(path); + } + } + + get(id) { + const path = this.#path(id); + if (!existsSync(path)) throw new TorchError(`Unknown backlog task: ${id}`, { code: 'BACKLOG_TASK_NOT_FOUND' }); + return parseTask(path); + } + + list({ state, owner } = {}) { + if (state && !BACKLOG_STATES.includes(state)) { + throw new TorchError(`Invalid backlog state: ${state}`, { code: 'INVALID_BACKLOG_STATE' }); + } + if (owner) this.controlPlane.assertIdentity(owner); + return readdirSync(this.root) + .filter((name) => /^TASK-[A-Za-z0-9-]+\.json$/.test(name)) + .map((name) => parseTask(join(this.root, name))) + .filter((task) => (!state || task.state === state) && (!owner || task.owner === owner)) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); + } + + create({ + actorId, title, description, priority = 'normal', affectedDomains = [], + dependencies = [], acceptanceCriteria, + } = {}) { + this.#assertMutable(); + const actor = this.controlPlane.assertIdentity(actorId); + if (actor !== 'session-manager') { + throw new TorchError('Only the Session Manager may create durable backlog work', { + code: 'BACKLOG_AUTHORITY_REQUIRED', details: { actorId: actor }, + }); + } + if (!PRIORITIES.has(priority)) { + throw new TorchError(`Invalid backlog priority: ${priority}`, { code: 'INVALID_BACKLOG_PRIORITY' }); + } + const domains = textList(affectedDomains, 'affectedDomains'); + domains.forEach((areaId) => this.controlPlane.assertIdentity(areaId)); + const dependencyIds = textList(dependencies, 'dependencies'); + dependencyIds.forEach((id) => this.get(id)); + const now = this.clock().toISOString(); + const task = { + schema: 'torch.dev/backlog-item/v1alpha1', + id: taskId(`TASK-${this.idFactory()}`), title: requiredText(title, 'title'), + description: requiredText(description, 'description'), priority, + state: 'proposed', owner: null, affectedDomains: domains, dependencies: dependencyIds, + acceptanceCriteria: textList(acceptanceCriteria, 'acceptanceCriteria', { required: true }), + evidence: [], commit: null, integrationRequest: null, blockedReason: null, + createdAt: now, updatedAt: now, revision: 1, + history: [{ from: null, to: 'proposed', actorId: actor, at: now, note: 'Task created.' }], + }; + const path = this.#path(task.id); + if (existsSync(path)) throw new TorchError(`Backlog task already exists: ${task.id}`, { code: 'BACKLOG_TASK_EXISTS' }); + atomicTask(path, task); + this.controlPlane.audit({ + actorId: actor, operation: 'backlog.create', entityType: 'backlog-task', entityId: task.id, + details: { state: task.state, priority }, + }); + return task; + } + + transition(input = {}) { + this.#assertMutable(); + return this.#withTaskLock(input.taskId, () => this.#applyTransition(input)); + } + + #applyTransition({ + taskId: id, actorId, to, expectedRevision, owner, evidence = [], commit, + integrationRequest, blockedReason, note, + } = {}) { + const actor = this.controlPlane.assertIdentity(actorId); + const target = requiredText(to, 'to'); + if (!BACKLOG_STATES.includes(target)) { + throw new TorchError(`Invalid backlog state: ${target}`, { code: 'INVALID_BACKLOG_STATE' }); + } + const task = this.get(id); + if (!Number.isInteger(expectedRevision) || expectedRevision !== task.revision) { + throw new TorchError('Backlog task changed since it was read', { + code: 'BACKLOG_REVISION_CONFLICT', details: { expectedRevision, actualRevision: task.revision }, + }); + } + if (!TRANSITIONS.get(task.state).has(target)) { + throw new TorchError(`Backlog transition ${task.state} -> ${target} is not allowed`, { + code: 'BACKLOG_TRANSITION_INVALID', details: { from: task.state, to: target }, + }); + } + if (actor !== 'session-manager' && (actor !== task.owner || !WORKER_TRANSITIONS.has(target))) { + throw new TorchError(`${actor} may not transition ${task.id} to ${target}`, { + code: 'BACKLOG_AUTHORITY_REQUIRED', details: { actorId: actor, owner: task.owner, to: target }, + }); + } + const nextOwner = owner ?? task.owner; + if (owner !== undefined && target !== 'assigned' && owner !== task.owner) { + throw new TorchError('Backlog ownership changes require an assigned transition', { + code: 'BACKLOG_OWNER_CHANGE_INVALID', details: { from: task.owner, to: owner }, + }); + } + if (target === 'assigned') { + const assigned = this.controlPlane.assertIdentity(nextOwner); + if (assigned === 'session-manager') { + throw new TorchError('Session Manager does not own implementation tasks by default', { + code: 'BACKLOG_OWNER_INVALID', + }); + } + if (task.affectedDomains.length && !task.affectedDomains.includes(assigned)) { + throw new TorchError(`${assigned} is outside the task's affected domains`, { + code: 'BACKLOG_OWNER_INVALID', details: { owner: assigned, affectedDomains: task.affectedDomains }, + }); + } + for (const dependency of task.dependencies) { + if (this.get(dependency).state !== 'completed') { + throw new TorchError(`Backlog dependency is not completed: ${dependency}`, { + code: 'BACKLOG_DEPENDENCY_INCOMPLETE', details: { dependency }, + }); + } + } + } + const nextEvidence = [...new Set([...task.evidence, ...textList(evidence, 'evidence')])]; + const nextCommit = commit ?? task.commit; + const nextIntegration = integrationRequest ?? task.integrationRequest; + if (['verification', 'ready_to_integrate', 'completed'].includes(target) + && (!nextCommit || nextEvidence.length === 0)) { + throw new TorchError(`${target} requires commit and evidence`, { + code: 'BACKLOG_EVIDENCE_REQUIRED', details: { state: target }, + }); + } + if (target === 'blocked' && !blockedReason) { + throw new TorchError('Blocked tasks require a reason', { code: 'BACKLOG_BLOCK_REASON_REQUIRED' }); + } + if (target === 'completed') { + if (!nextIntegration || typeof this.integrationLookup !== 'function') { + throw new TorchError('Completion requires a verifiable landed integration request', { + code: 'BACKLOG_INTEGRATION_REQUIRED', + }); + } + const integration = this.integrationLookup(nextIntegration); + if (!integration || integration.state !== 'landed' || integration.sourceCommit !== nextCommit) { + throw new TorchError('Backlog completion does not match a landed integration commit', { + code: 'BACKLOG_INTEGRATION_NOT_LANDED', + details: { integrationRequest: nextIntegration, state: integration?.state ?? null, taskCommit: nextCommit, + integrationCommit: integration?.sourceCommit ?? null }, + }); + } + } + const now = this.clock().toISOString(); + const updated = { + ...task, state: target, owner: nextOwner, evidence: nextEvidence, + commit: nextCommit ?? null, integrationRequest: nextIntegration ?? null, + blockedReason: target === 'blocked' ? requiredText(blockedReason, 'blockedReason') : null, + updatedAt: now, revision: task.revision + 1, + history: [...task.history, { + from: task.state, to: target, actorId: actor, at: now, + note: note ? requiredText(note, 'note') : null, + }], + }; + atomicTask(this.#path(task.id), updated); + this.controlPlane.audit({ + actorId: actor, operation: 'backlog.transition', entityType: 'backlog-task', entityId: task.id, + details: { from: task.state, to: target, revision: updated.revision }, + }); + if (target === 'assigned') { + this.controlPlane.sendMessage({ + sender: actor, recipient: updated.owner, + body: `Assigned ${updated.id}: ${updated.title}`, + references: { task: updated.id }, + }); + } else if (target === 'blocked' && actor !== 'session-manager') { + this.controlPlane.sendMessage({ + sender: actor, recipient: 'session-manager', kind: 'blocker', + body: updated.blockedReason, references: { task: updated.id, commit: updated.commit }, + }); + } + return updated; + } +} + +export function createBacklogService(options) { + return new BacklogService(options); +} diff --git a/src/cli.mjs b/src/cli.mjs index 13b88ad..367c5b1 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,4 +1,5 @@ import { analyzeRepository } from './kernel/analyze.mjs'; +import { BacklogService } from './backlog/service.mjs'; import { createClaudeAdapter } from './adapters/claude.mjs'; import { createCodexAdapter } from './adapters/codex.mjs'; import { CheckService } from './checks/service.mjs'; @@ -43,6 +44,10 @@ Usage: torch blocked --area --summary [--task ] [--evidence ] [--path ] [--commit ] [--json] torch coordinate --from --body [--with ] [--task ] [--path ] [--json] torch handoff --from [--to ] (--path | --task ) --reason [--json] + torch backlog list [--state ] [--owner ] [--json] + torch backlog get --task [--json] + torch backlog create --area --title --description --accept [--priority ] [--domains ] [--depends ] [--json] + torch backlog transition --task --area --state --revision [--owner ] [--evidence ] [--commit ] [--integration ] [--reason ] [--note ] [--json] torch checks list [--json] torch checks receipts [--commit ] [--id ] [--area ] [--json] torch checks plan --id --area [--json] @@ -83,6 +88,10 @@ function optionValue(argv, name) { return index >= 0 ? argv[index + 1] : undefined; } +function commaList(value) { + return (value ?? '').split(',').map((item) => item.trim()).filter(Boolean); +} + function loadProposal(cwd, proposalPath) { if (!proposalPath) { throw new TorchError('Installation requires an approved domain proposal from torch domains --output.', { @@ -442,6 +451,45 @@ export async function runCli(argv = process.argv.slice(2), { })), { json }); return 0; } + if (command === 'backlog') { + const operation = argv[1] ?? 'list'; + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const resources = new ResourceService({ repositoryRoot: repository.root, controlPlane: control }); + const checks = new CheckService({ + repositoryRoot: repository.root, controlPlane: control, resourceService: resources, + }); + const integration = new IntegrationService({ + repositoryRoot: repository.root, controlPlane: control, checkService: checks, + }); + const backlog = new BacklogService({ + repositoryRoot: repository.root, controlPlane: control, + integrationLookup: (requestId) => integration.get(requestId), + }); + if (operation === 'list') print({ tasks: backlog.list({ + state: optionValue(argv, '--state'), owner: optionValue(argv, '--owner'), + }) }, { json }); + else if (operation === 'get') print(backlog.get(optionValue(argv, '--task')), { json }); + else if (operation === 'create') print(backlog.create({ + actorId: optionValue(argv, '--area'), title: optionValue(argv, '--title'), + description: optionValue(argv, '--description'), priority: optionValue(argv, '--priority') ?? 'normal', + acceptanceCriteria: commaList(optionValue(argv, '--accept')), + affectedDomains: commaList(optionValue(argv, '--domains')), + dependencies: commaList(optionValue(argv, '--depends')), + }), { json }); + else if (operation === 'transition') print(backlog.transition({ + taskId: optionValue(argv, '--task'), actorId: optionValue(argv, '--area'), + to: optionValue(argv, '--state'), expectedRevision: Number(optionValue(argv, '--revision')), + owner: optionValue(argv, '--owner'), evidence: commaList(optionValue(argv, '--evidence')), + commit: optionValue(argv, '--commit'), integrationRequest: optionValue(argv, '--integration'), + blockedReason: optionValue(argv, '--reason'), note: optionValue(argv, '--note'), + }), { json }); + else throw new TorchError(`Unknown backlog operation: ${operation}`, { code: 'UNKNOWN_COMMAND' }); + return 0; + } finally { + control.close(); + } + } if (command === 'checks') { const operation = argv[1] ?? 'list'; const control = openControlPlane({ repositoryRoot: repository.root, env }); diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index 2a90024..667ecc0 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { fileHash } from './files.mjs'; @@ -113,6 +113,33 @@ export function diagnoseProject({ repository, env = process.env }) { }); } } + const managerWorktree = (manifest.external ?? []).find((entry) => + entry.type === 'worktree' && entry.area === 'session-manager'); + if (managerWorktree) { + const backlogRoot = join(managerWorktree.path, '.torch', 'backlog'); + if (!existsSync(backlogRoot)) { + findings.push({ severity: 'error', code: 'BACKLOG_DIRECTORY_MISSING', path: backlogRoot }); + } else { + const tasks = []; + for (const name of readdirSync(backlogRoot).filter((entry) => entry.endsWith('.json'))) { + try { + const task = parseJsonYaml(join(backlogRoot, name)); + if (task.schema !== 'torch.dev/backlog-item/v1alpha1' || !task.id || !task.state) throw new Error('invalid task schema'); + if (!['completed', 'cancelled'].includes(task.state)) { + tasks.push({ id: task.id, state: task.state, owner: task.owner, revision: task.revision }); + } + } catch (error) { + findings.push({ + severity: 'error', code: 'BACKLOG_TASK_INVALID', path: join(backlogRoot, name), message: error.message, + }); + } + } + if (tasks.length) findings.push({ + severity: tasks.some((task) => task.state === 'blocked') ? 'warning' : 'info', + code: 'BACKLOG_ACTIVITY', tasks, + }); + } + } } if (config?.project?.id && manifest?.projectId && config.project.id !== manifest.projectId) { diff --git a/src/mcp/server.mjs b/src/mcp/server.mjs index 564ffae..3b3bd98 100755 --- a/src/mcp/server.mjs +++ b/src/mcp/server.mjs @@ -2,6 +2,7 @@ import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { resolve } from 'node:path'; import { CheckService } from '../checks/service.mjs'; +import { BacklogService } from '../backlog/service.mjs'; import { IntegrationService } from '../integration/service.mjs'; import { openControlPlane } from '../control-plane/service.mjs'; import { ResourceService } from '../resources/service.mjs'; @@ -21,8 +22,11 @@ controlPlane.assertIdentity(actorId); const resourceService = new ResourceService({ repositoryRoot, controlPlane }); const checkService = new CheckService({ repositoryRoot, controlPlane, resourceService }); const integrationService = new IntegrationService({ repositoryRoot, controlPlane, checkService }); +const backlogService = new BacklogService({ + repositoryRoot, controlPlane, integrationLookup: (requestId) => integrationService.get(requestId), +}); const handle = serveStdio(() => createTorchMcpServer(controlPlane, { - actorId, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, }), { onerror: (error) => console.error(`TORCH MCP: ${error.message}`), }); diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index c20ea01..8ae8790 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -28,6 +28,10 @@ export const TORCH_CORE_MCP_TOOL_NAMES = Object.freeze([ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ ...TORCH_CORE_MCP_TOOL_NAMES, + 'torch_list_backlog', + 'torch_get_backlog_task', + 'torch_create_backlog_task', + 'torch_transition_backlog_task', 'torch_list_checks', 'torch_list_check_receipts', 'torch_plan_check', @@ -61,7 +65,7 @@ function claimedIdentity(controlPlane, actorId, claim, field) { } export function createTorchToolset(controlPlane, { - actorId, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, } = {}) { const tools = new Map([ ['torch_identity', { @@ -175,6 +179,52 @@ export function createTorchToolset(controlPlane, { }), }], ]); + if (backlogService) { + tools.set('torch_list_backlog', { + description: 'List durable tracked backlog tasks, optionally filtered by state or owner.', + schema: { state: z.string().min(1).optional(), owner: z.string().min(1).optional() }, + invoke: (input) => ({ tasks: backlogService.list(input) }), + }); + tools.set('torch_get_backlog_task', { + description: 'Read one durable tracked backlog task and its transition history.', + schema: { task_id: z.string().min(1) }, + invoke: ({ task_id: taskId }) => backlogService.get(taskId), + }); + tools.set('torch_create_backlog_task', { + description: 'Create proposed work as the bound Session Manager identity.', + schema: { + actor_id: z.string().min(1).optional(), title: z.string().min(1), description: z.string().min(1), + priority: z.enum(['urgent', 'high', 'normal', 'low']).optional(), + affected_domains: z.array(z.string().min(1)).optional(), + dependencies: z.array(z.string().min(1)).optional(), + acceptance_criteria: z.array(z.string().min(1)).min(1), + }, + invoke: ({ + actor_id: actorIdClaim, affected_domains: affectedDomains, + acceptance_criteria: acceptanceCriteria, ...input + }) => backlogService.create({ + ...input, affectedDomains, acceptanceCriteria, + actorId: claimedIdentity(controlPlane, actorId, actorIdClaim, 'actor_id'), + }), + }); + tools.set('torch_transition_backlog_task', { + description: 'Apply one revision-checked backlog state transition under bound Fleet authority.', + schema: { + task_id: z.string().min(1), actor_id: z.string().min(1).optional(), to: z.string().min(1), + expected_revision: z.number().int().min(1), owner: z.string().min(1).optional(), + evidence: z.array(z.string().min(1)).optional(), commit: z.string().min(1).optional(), + integration_request: z.string().min(1).optional(), blocked_reason: z.string().min(1).optional(), + note: z.string().min(1).optional(), + }, + invoke: ({ + task_id: taskId, actor_id: actorIdClaim, expected_revision: expectedRevision, + integration_request: integrationRequest, blocked_reason: blockedReason, ...input + }) => backlogService.transition({ + ...input, taskId, expectedRevision, integrationRequest, blockedReason, + actorId: claimedIdentity(controlPlane, actorId, actorIdClaim, 'actor_id'), + }), + }); + } if (checkService) { tools.set('torch_list_checks', { description: 'List approved project check definitions.', schema: {}, @@ -291,7 +341,7 @@ export function callTorchTool(controlPlane, name, input = {}, options = {}) { } export function createTorchMcpServer(controlPlane, { - actorId, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, } = {}) { controlPlane.assertIdentity(actorId); const server = new McpServer( @@ -301,7 +351,7 @@ export function createTorchMcpServer(controlPlane, { }, ); for (const [name, tool] of createTorchToolset(controlPlane, { - actorId, checkService, resourceService, integrationService, + actorId, backlogService, checkService, resourceService, integrationService, })) { server.registerTool(name, { description: tool.description, inputSchema: tool.schema }, async (input) => { try { diff --git a/test/fleet/backlog.test.mjs b/test/fleet/backlog.test.mjs new file mode 100644 index 0000000..df1fe42 --- /dev/null +++ b/test/fleet/backlog.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import test from 'node:test'; +import { createBacklogService } from '../../src/backlog/service.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; +import { analyzeRepository } from '../../src/kernel/analyze.mjs'; +import { diagnoseProject } from '../../src/kernel/doctor.mjs'; +import { proposeDomains } from '../../src/kernel/domains.mjs'; +import { inspectRepository } from '../../src/kernel/git.mjs'; +import { installProject } from '../../src/kernel/install.mjs'; +import { createWorktrees } from '../../src/kernel/worktrees.mjs'; +import { callTorchTool } from '../../src/mcp/tools.mjs'; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'torch-backlog-')); + execFileSync('git', ['init', '-b', 'main', root]); + execFileSync('git', ['-C', root, 'config', 'user.email', 'torch-test@example.invalid']); + execFileSync('git', ['-C', root, 'config', 'user.name', 'TORCH Test']); + writeFileSync(join(root, 'app.js'), 'export const app = true;\n'); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, 'commit', '-m', 'fixture']); + let repository = inspectRepository(root); + const proposal = proposeDomains({ repository, analysis: analyzeRepository(repository) }); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + const env = { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-state`) }; + installProject({ repository, proposal, env, projectId: 'backlog-fixture' }); + execFileSync('git', ['-C', root, 'add', '.torch']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + repository = inspectRepository(root); + createWorktrees({ repository, parentOverride: join(tmpdir(), `${basename(root)}-worktrees`) }); + const mainStatus = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }); + return { root, env, worker: proposal.domains[0].id, mainStatus }; +} + +test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, dependencies, evidence, and landed completion', () => { + const context = fixture(); + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let sequence = 0; + const integrations = new Map(); + const backlog = createBacklogService({ + repositoryRoot: context.root, controlPlane: control, + idFactory: () => `task-${++sequence}`, + clock: () => new Date(`2026-09-27T00:00:0${sequence}Z`), + integrationLookup: (id) => integrations.get(id), + }); + const first = backlog.create({ + actorId: 'session-manager', title: 'Implement lifecycle', description: 'Build and verify it.', + affectedDomains: [context.worker], acceptanceCriteria: ['Exact test passes.'], + }); + const dependent = backlog.create({ + actorId: 'session-manager', title: 'Use lifecycle', description: 'Consume the completed work.', + affectedDomains: [context.worker], dependencies: [first.id], acceptanceCriteria: ['Dependency is complete.'], + }); + let task = backlog.transition({ + taskId: first.id, actorId: 'session-manager', to: 'ready', expectedRevision: first.revision, + }); + task = backlog.transition({ + taskId: first.id, actorId: 'session-manager', to: 'assigned', expectedRevision: task.revision, + owner: context.worker, + }); + assert.equal(control.readMessages({ recipient: context.worker }).at(-1).references.task, first.id); + assert.throws( + () => backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'in_progress', expectedRevision: task.revision - 1, + }), + (error) => error.code === 'BACKLOG_REVISION_CONFLICT', + ); + const taskLock = join(control.stateRoot, 'locks', `backlog-${first.id}.lock`); + writeFileSync(taskLock, 'held by concurrent fixture\n'); + assert.throws( + () => backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'in_progress', expectedRevision: task.revision, + }), + (error) => error.code === 'BACKLOG_TASK_LOCKED', + ); + unlinkSync(taskLock); + let dependentTask = backlog.transition({ + taskId: dependent.id, actorId: 'session-manager', to: 'ready', expectedRevision: dependent.revision, + }); + assert.throws( + () => backlog.transition({ + taskId: dependent.id, actorId: 'session-manager', to: 'assigned', + expectedRevision: dependentTask.revision, owner: context.worker, + }), + (error) => error.code === 'BACKLOG_DEPENDENCY_INCOMPLETE', + ); + task = backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'in_progress', expectedRevision: task.revision, + }); + task = backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'blocked', expectedRevision: task.revision, + blockedReason: 'Need an owner decision.', + }); + assert.equal(control.readMessages({ recipient: 'session-manager' }).at(-1).kind, 'blocker'); + task = backlog.transition({ + taskId: first.id, actorId: 'session-manager', to: 'assigned', expectedRevision: task.revision, + owner: context.worker, note: 'Decision supplied.', + }); + task = backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'in_progress', expectedRevision: task.revision, + }); + task = backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'verification', expectedRevision: task.revision, + commit: '1111111111111111111111111111111111111111', evidence: ['SCN-backlog-lifecycle PASS'], + }); + task = backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'ready_to_integrate', expectedRevision: task.revision, + integrationRequest: 'integration-1', + }); + assert.throws( + () => backlog.transition({ + taskId: first.id, actorId: context.worker, to: 'completed', expectedRevision: task.revision, + }), + (error) => error.code === 'BACKLOG_AUTHORITY_REQUIRED', + ); + const diagnosis = diagnoseProject({ repository: inspectRepository(context.root), env: context.env }); + assert.equal(diagnosis.findings.some((finding) => + finding.code === 'BACKLOG_ACTIVITY' && finding.tasks.some((item) => item.id === dependent.id)), true); + integrations.set('integration-1', { + state: 'landed', sourceCommit: '1111111111111111111111111111111111111111', + }); + task = backlog.transition({ + taskId: first.id, actorId: 'session-manager', to: 'completed', expectedRevision: task.revision, + }); + assert.equal(task.state, 'completed'); + + dependentTask = backlog.transition({ + taskId: dependent.id, actorId: 'session-manager', to: 'assigned', expectedRevision: dependentTask.revision, + owner: context.worker, + }); + assert.equal(dependentTask.owner, context.worker); + assert.equal(backlog.list({ state: 'completed' })[0].id, first.id); + assert.equal(callTorchTool(control, 'torch_list_backlog', {}, { + actorId: context.worker, backlogService: backlog, + }).tasks.length, 2); + assert.throws( + () => callTorchTool(control, 'torch_create_backlog_task', { + title: 'Forged task', description: 'Worker cannot create it.', acceptance_criteria: ['No.'], + }, { actorId: context.worker, backlogService: backlog }), + (error) => error.code === 'BACKLOG_AUTHORITY_REQUIRED', + ); + assert.equal( + execFileSync('git', ['-C', context.root, 'status', '--porcelain'], { encoding: 'utf8' }), + context.mainStatus, + 'backlog files must not add dirt to canonical main', + ); + control.close(); +}); diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index a46ad1a..280a4d2 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import test from 'node:test'; const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; @@ -21,7 +21,7 @@ function repo() { function run(root, args) { return spawnSync(process.execPath, [CLI, ...args], { cwd: root, - env: { ...process.env, XDG_DATA_HOME: join(root, '.data') }, + env: { ...process.env, XDG_DATA_HOME: join(tmpdir(), `${basename(root)}-data`) }, encoding: 'utf8', }); } @@ -156,3 +156,43 @@ test('SCN-cli-self-host: candidate acceptance, atomic upgrade, and rollback requ assert.equal(rolledBack.status, 0, rolledBack.stderr || rolledBack.stdout); assert.equal(JSON.parse(rolledBack.stdout).activeVersion, '1.0.0'); }); + +test('SCN-cli-backlog: public commands create, transition, assign, and list tracked work', () => { + const root = repo(); + const proposalPath = join(root, 'fleet-proposal.json'); + run(root, ['domains', '--output', proposalPath, '--json']); + const proposal = JSON.parse(readFileSync(proposalPath, 'utf8')); + proposal.review = { + status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], + }; + writeFileSync(proposalPath, `${JSON.stringify(proposal, null, 2)}\n`); + assert.equal(run(root, ['install', '--proposal', proposalPath, '--yes', '--json']).status, 0); + execFileSync('git', ['-C', root, 'add', '.torch', 'fleet-proposal.json']); + execFileSync('git', ['-C', root, 'commit', '-m', 'install torch']); + const worktreeParent = join(tmpdir(), `${basename(root)}-worktrees`); + const worktrees = run(root, ['worktrees', '--parent', worktreeParent, '--yes', '--json']); + assert.equal(worktrees.status, 0, worktrees.stderr || worktrees.stdout); + + const created = run(root, [ + 'backlog', 'create', '--area', 'session-manager', '--title', 'CLI task', + '--description', 'Exercise public lifecycle.', '--accept', 'Assigned to owner', + '--domains', proposal.domains[0].id, '--json', + ]); + assert.equal(created.status, 0, created.stderr || created.stdout); + let task = JSON.parse(created.stdout); + for (const transition of [ + ['ready'], + ['assigned', '--owner', proposal.domains[0].id], + ]) { + const result = run(root, [ + 'backlog', 'transition', '--task', task.id, '--area', 'session-manager', + '--state', ...transition, '--revision', String(task.revision), '--json', + ]); + assert.equal(result.status, 0, result.stderr || result.stdout); + task = JSON.parse(result.stdout); + } + assert.equal(task.state, 'assigned'); + assert.equal(task.owner, proposal.domains[0].id); + const listed = JSON.parse(run(root, ['backlog', 'list', '--state', 'assigned', '--json']).stdout); + assert.equal(listed.tasks[0].id, task.id); +}); diff --git a/test/fleet/integration-canonical.test.mjs b/test/fleet/integration-canonical.test.mjs index 50c273d..b1df78b 100644 --- a/test/fleet/integration-canonical.test.mjs +++ b/test/fleet/integration-canonical.test.mjs @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import test from 'node:test'; +import { BacklogService } from '../../src/backlog/service.mjs'; import { createLocalCanonical, classifyRecoverability, planLocalCanonical } from '../../src/canonical/local.mjs'; import { CheckService } from '../../src/checks/service.mjs'; import { openControlPlane } from '../../src/control-plane/service.mjs'; @@ -69,6 +70,25 @@ test('SCN-native-integration: exact receipts plus manager authority land only an repositoryRoot: context.root, controlPlane: control, checkService: checks, idFactory: () => `integration-${++id}`, }); + const backlog = new BacklogService({ + repositoryRoot: context.root, controlPlane: control, + idFactory: () => `integration-task-${++id}`, + integrationLookup: (requestId) => integration.get(requestId), + }); + let task = backlog.create({ + actorId: 'session-manager', title: 'Land bounded domain change', + description: 'Exercise backlog through exact integration.', affectedDomains: [context.worker], + acceptanceCriteria: ['Exact receipt and landed commit match.'], + }); + for (const transition of [ + { actorId: 'session-manager', to: 'ready' }, + { actorId: 'session-manager', to: 'assigned', owner: context.worker }, + { actorId: context.worker, to: 'in_progress' }, + { actorId: context.worker, to: 'verification', commit: receipt.commit, evidence: ['verify-pass receipt'] }, + { actorId: context.worker, to: 'ready_to_integrate' }, + ]) { + task = backlog.transition({ taskId: task.id, expectedRevision: task.revision, ...transition }); + } const request = integration.request({ areaId: context.worker, commit: receipt.commit }); assert.equal(request.state, 'ready'); assert.throws( @@ -81,6 +101,11 @@ test('SCN-native-integration: exact receipts plus manager authority land only an assert.equal(landed.state, 'landed'); assert.equal(execFileSync('git', ['-C', context.root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), receipt.commit); assert.equal(readFileSync(join(context.root, 'app.js'), 'utf8'), 'export const value = 2;\n'); + task = backlog.transition({ + taskId: task.id, actorId: 'session-manager', to: 'completed', expectedRevision: task.revision, + integrationRequest: landed.id, + }); + assert.equal(task.state, 'completed'); writeFileSync(join(context.worktree.path, 'app.js'), 'export const value = 3;\n'); execFileSync('git', ['-C', context.worktree.path, 'add', 'app.js']);