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

+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 {