Add durable tracked backlog lifecycle

This commit is contained in:
thePR0M3TH3AN committed 2026-09-27 16:44:19 -04:00
1 parent 4b4a0f38f9
commit 13a3bcd5b3
12 files changed
+697 -19

No files matched your search

+6 -5
View File
@@ -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 <fleet-id>` 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
+12 -4
View File
@@ -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
+4 -2
View File
@@ -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",
@@ -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
+308
View File
@@ -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);
}
+48
View File
@@ -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 <id> --summary <text> [--task <id>] [--evidence <text>] [--path <path>] [--commit <sha>] [--json]
torch coordinate --from <id> --body <text> [--with <id,id>] [--task <id>] [--path <path>] [--json]
torch handoff --from <id> [--to <id>] (--path <path> | --task <id>) --reason <text> [--json]
torch backlog list [--state <state>] [--owner <id>] [--json]
torch backlog get --task <id> [--json]
torch backlog create --area <id> --title <text> --description <text> --accept <text,...> [--priority <priority>] [--domains <id,...>] [--depends <task,...>] [--json]
torch backlog transition --task <id> --area <id> --state <state> --revision <n> [--owner <id>] [--evidence <text,...>] [--commit <sha>] [--integration <id>] [--reason <text>] [--note <text>] [--json]
torch checks list [--json]
torch checks receipts [--commit <sha>] [--id <check>] [--area <id>] [--json]
torch checks plan --id <check> --area <id> [--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 });
+28 -1
View File
@@ -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) {
+5 -1
View File
@@ -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}`),
});
+53 -3
View File
@@ -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 {
+153
View File
@@ -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', '[email protected]']);
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();
});
+42 -2
View File
@@ -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);
});
+25
View File
@@ -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']);