Skip to content
7 changes: 7 additions & 0 deletions docs/releases/pending/issue-2763-empty-scope-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Empty-scope task completion

Verification-only tasks that explicitly declare `files_touched: []` can now complete when the trusted coder settlement proves that no mutation was accepted. The completion and read-only gate-status paths use the same durable evidence, preserve independent advisory gates, and keep ordinary or malformed scopes fail-closed. A rejected empty-scope coder preflight records this proof only after a clean baseline confirms that no child ran; a complete `FILE:` directive remains the authoritative non-empty scope.

No configuration or migration is required. Existing tasks and terminal WAL records remain backward-compatible; only an authoritative empty-scope/no-mutation settlement may use the new path.

Raw workspace mutations observed against an empty declared scope are treated as failed mutations requiring rework rather than as no-mutation proof.
6 changes: 3 additions & 3 deletions scripts/retention-registry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1675,11 +1675,11 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [
canonicalRoot: 'project-swarm',
writerModules: ['src/gate-evidence.ts', 'src/council/council-evidence-writer.ts'],
writerCitations: [
'src/gate-evidence.ts:984 transitionTaskWorkflowEvidence / :1094 recordGateEvidence / :1152 recordAgentDispatch — locked read-modify-write, atomic write',
'src/gate-evidence.ts:1236 transitionTaskWorkflowEvidence / :1351 recordGateEvidence / :1409 recordAgentDispatch — locked read-modify-write, atomic write',
'src/council/council-evidence-writer.ts:96 writeCouncilEvidence — gates.council section under withTaskEvidenceLock',
],
readerCitations: [
'src/gate-evidence.ts:1196 readTaskEvidence — FULL-FILE fail-open, async; :1272 readTaskEvidenceRaw — strict, sync',
'src/gate-evidence.ts:1453 readTaskEvidence — FULL-FILE fail-open, async; :1529 readTaskEvidenceRaw — strict, sync',
'src/council/council-evidence-writer.ts:207 hasCouncilEvidenceAttempt',
],
schemaVersion: 'workflow WAL states; unrecognized states degrade to null (documented :1183-1188)',
Expand All @@ -1693,7 +1693,7 @@ export const RETENTION_REGISTRY: readonly RetentionRow[] = [
bound: 'retryHistory ≤3 (schema :347); per-task file; evidence/ archived+cleaned at close',
scope: 'per-key',
keyspaceBound:
'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:832 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.',
'FINITE BY REAPER, not by key domain: one key per taskId — a flat .swarm/evidence/{taskId}.json (src/gate-evidence.ts:1033 getEvidencePath) whose taskId is only shape-validated (src/validation/task-id.ts:69-114), so the domain is open. The GLOBAL deleter is the same one the task-evidence-trajectory row cites: "evidence" is in ACTIVE_STATE_DIRS_TO_CLEAN (src/commands/close/constants.ts:253-269) and the close clean loop recursively removes the whole tree (src/commands/close/clean-stage.ts:176-190), taking every {taskId}.json with it. Note the per-file retryHistory ≤3 cap is NOT the keyspace bound — it caps one key\'s history and says nothing about how many keys exist. CAVEAT: archive-first-gated (src/commands/close/clean-stage.ts:176-185) and untouched by /swarm reset and /swarm reset-session, so an unclosed session holds one file per distinct taskId.',
citation: 'src/gate-evidence.ts:347; src/commands/close/constants.ts:253-269 ACTIVE_STATE_DIRS_TO_CLEAN',
},
readBound: { pattern: 'full-file', bound: 'single per-task JSON', sync: true, citation: 'src/gate-evidence.ts:1196-1224' },
Expand Down
69 changes: 59 additions & 10 deletions src/evidence/gate-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { Evidence } from '../config/evidence-schema';
import {
deriveApplicableGateSet,
isValidTaskId,
readCurrentTaskDeclaredFiles,
readTaskEvidence,
readTaskEvidenceRaw,
TASK_WORKFLOW_SCHEMA_MARKER,
type TaskEvidence,
} from '../gate-evidence.js';

Expand Down Expand Up @@ -49,10 +52,7 @@ export function getDurableGateEvidenceStatus(
};
}

if (
!Array.isArray(evidence.required_gates) ||
evidence.required_gates.length === 0
) {
if (!Array.isArray(evidence.required_gates)) {
return {
isComplete: false,
missingGates: ['required_gates'],
Expand All @@ -61,12 +61,10 @@ export function getDurableGateEvidenceStatus(
};
}

const missingGates = evidence.required_gates.filter(
(gate) => evidence.gates[gate] == null,
);
const derivedGates = deriveApplicableGateSet(evidence);
return {
isComplete: missingGates.length === 0,
missingGates,
isComplete: derivedGates.missingGates.length === 0,
missingGates: derivedGates.missingGates,
evidenceExists: true,
invalid: false,
};
Expand All @@ -86,7 +84,11 @@ export async function getDurableGateEvidenceStatusForTask(
}

try {
return getDurableGateEvidenceStatus(readTaskEvidenceRaw(directory, taskId));
return getDurableGateEvidenceStatusWithCurrentScope(
readTaskEvidenceRaw(directory, taskId),
directory,
taskId,
);
} catch {
return {
isComplete: false,
Expand All @@ -97,6 +99,53 @@ export async function getDurableGateEvidenceStatusForTask(
}
}

function getDurableGateEvidenceStatusWithCurrentScope(
evidence: TaskEvidence | null,
directory: string,
taskId: string,
): DurableGateEvidenceStatus {
if (!evidence?.gates || typeof evidence.gates !== 'object') {
return getDurableGateEvidenceStatus(evidence);
}
if (!Array.isArray(evidence.required_gates)) {
return getDurableGateEvidenceStatus(evidence);
}
if (
!evidence.workflow ||
evidence.workflow.schema !== TASK_WORKFLOW_SCHEMA_MARKER
) {
// Task-specific legacy callers historically evaluated the persisted list
// directly. Keep that behavior separate from the public pure helper above,
// whose derived set intentionally reports the modern pre_check obligation.
if (evidence.required_gates.length === 0) {
return {
isComplete: false,
missingGates: ['required_gates'],
evidenceExists: true,
invalid: false,
};
}
const missingGates = evidence.required_gates.filter(
(gate) => evidence.gates[gate] == null,
);
return {
isComplete: missingGates.length === 0,
missingGates,
evidenceExists: true,
invalid: false,
};
}
const derivedGates = deriveApplicableGateSet(evidence, {
currentDeclaredFiles: readCurrentTaskDeclaredFiles(directory, taskId),
});
return {
isComplete: derivedGates.missingGates.length === 0,
missingGates: derivedGates.missingGates,
evidenceExists: true,
invalid: false,
};
}

export async function hasCompleteDurableGateEvidenceForTask(
directory: string,
taskId: string,
Expand Down
24 changes: 21 additions & 3 deletions src/gate-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'gate-evidence-test-'));
mkdirSync(path.join(tmpDir, '.swarm'), { recursive: true });
mkdirSync(path.join(tmpDir, '.swarm', 'evidence'), { recursive: true });
});

afterEach(() => {
Expand Down Expand Up @@ -235,8 +235,26 @@ describe('hasPassedAllGates', () => {
expect(await hasPassedAllGates(tmpDir, '1.4')).toBe(false);
});

it('15. returns true for docs task with docs evidence', async () => {
await recordGateEvidence(tmpDir, '1.5', 'docs', 'sess-1');
it('15. returns true when every derived nonempty gate has evidence', async () => {
writeFileSync(
path.join(tmpDir, '.swarm', 'evidence', '1.5.json'),
JSON.stringify({
taskId: '1.5',
required_gates: ['pre_check', 'docs'],
gates: {
pre_check: {
sessionId: 'sess-1',
timestamp: '2026-09-14T00:00:00.000Z',
agent: 'pre_check',
},
docs: {
sessionId: 'sess-1',
timestamp: '2026-09-14T00:00:00.000Z',
agent: 'docs',
},
},
}),
);
expect(await hasPassedAllGates(tmpDir, '1.5')).toBe(true);
});

Expand Down
Loading
Loading