mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-04 20:01:54 +00:00
Add durable tracked backlog lifecycle
This commit is contained in:
1 parent
4b4a0f38f9
commit
13a3bcd5b3
12 files changed
+697
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
Reference in new issue
Block a user