taskforce-loop-engineering 0.15.5 → 0.15.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.7 - 2026-08-24
6
+
7
+ - Prevent accepted phase checkpoints from producing `ready_to_apply` when they explicitly mark project completion as `in_progress`, including both string and object completion forms.
8
+ - Preserve unresolved checkpoint risks in the final judgement and treat a concrete continuation action as project work that must continue instead of terminal completion.
9
+ - Use an accepted terminal contract as the authoritative project-completion source when no separate acceptance ledger is configured.
10
+ - Reconcile stale optional post-completion authorization gates into `done` rather than `canceled`, while retaining fail-closed behavior for real blockers.
11
+ - Add regression coverage for premature terminal judgement, terminal-contract status projection, and historical stale-gate reconciliation.
12
+
13
+ ## 0.15.6 - 2026-08-24
14
+
15
+ - Allow project configurations to use an external authoritative `backlogSource` without duplicating an embedded backlog registry.
16
+ - Keep project status fail-closed when neither an embedded backlog nor an external backlog source is configured.
17
+ - Suppress false registry-drift findings when no embedded projection is intentionally present, while retaining drift detection when both representations exist.
18
+ - Add regression coverage for external-only authoritative project backlogs.
19
+
5
20
  ## 0.15.5 - 2026-08-24
6
21
 
7
22
  - Treat embedded-runtime `incomplete_turn` / abandoned-liveness trailers as recoverable interruptions even when the dispatcher wrapper exits zero, preventing partial work from being accepted or stale human gates from being replayed.
package/lib/core.mjs CHANGED
@@ -1173,20 +1173,34 @@ async function projectSpecs(root) {
1173
1173
  return specs;
1174
1174
  }
1175
1175
 
1176
- async function projectTerminalAccepted(root, spec) {
1176
+ function acceptedTerminalRecord(record) {
1177
+ if (!record || typeof record !== 'object') return false;
1178
+ const accepted = record.status === 'accepted' || record.terminalState?.accepted === true || record.terminalAccepted === true;
1179
+ const unmet = Array.isArray(record.unmet) ? record.unmet : [];
1180
+ const blockers = Array.isArray(record.blockers) ? record.blockers : [];
1181
+ return accepted && unmet.length === 0 && blockers.length === 0;
1182
+ }
1183
+
1184
+ async function projectAcceptanceRecord(root, spec) {
1177
1185
  const configuredAcceptanceLedger = spec?.acceptanceLedger ?? spec?.ledger;
1178
- if (!configuredAcceptanceLedger) return false;
1186
+ const configuredTerminalContract = spec?.terminalContract;
1187
+ const configured = configuredAcceptanceLedger ?? configuredTerminalContract;
1188
+ if (!configured) return null;
1179
1189
  try {
1180
- const ledgerFile = path.resolve(root, safeRelativePath(configuredAcceptanceLedger, 'project acceptance ledger'));
1181
- const ledger = await readJson(ledgerFile);
1182
- return ledger.status === 'accepted'
1183
- && Array.isArray(ledger.unmet) && ledger.unmet.length === 0
1184
- && Array.isArray(ledger.blockers) && ledger.blockers.length === 0;
1190
+ const kind = configuredAcceptanceLedger ? 'project acceptance ledger' : 'project terminal contract';
1191
+ const file = path.resolve(root, safeRelativePath(configured, kind));
1192
+ const record = await readJson(file);
1193
+ return { file, record, source: configuredAcceptanceLedger ? 'acceptance_ledger' : 'terminal_contract' };
1185
1194
  } catch {
1186
- return false;
1195
+ return null;
1187
1196
  }
1188
1197
  }
1189
1198
 
1199
+ async function projectTerminalAccepted(root, spec) {
1200
+ const acceptance = await projectAcceptanceRecord(root, spec);
1201
+ return acceptedTerminalRecord(acceptance?.record);
1202
+ }
1203
+
1190
1204
  function requirementIdsFromCheckpoint(checkpoint, specs) {
1191
1205
  const explicit = checkpoint?.requirement_ids ?? checkpoint?.requirementIds;
1192
1206
  if (Array.isArray(explicit)) return [...new Set(explicit.map(String))];
@@ -1284,12 +1298,19 @@ export async function reconcileProjectGates(root, options = {}) {
1284
1298
  await writeJson(full, reconciled);
1285
1299
  if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
1286
1300
  const resumeSafeBacklog = invalid.code === 'safe_backlog_actionable';
1287
- const destinationFile = path.join(queueSubdirFor(root, queue, resumeSafeBacklog ? 'inbox' : 'canceled'), path.basename(found.file));
1288
- const restored = { ...task, status: resumeSafeBacklog ? 'queued' : invalid.status, gateReconciliation: reconciled.reconciliation };
1301
+ const projectAccepted = invalid.code === 'project_accepted_optional_deferred';
1302
+ const destination = resumeSafeBacklog ? 'inbox' : projectAccepted ? 'done' : 'canceled';
1303
+ const destinationFile = path.join(queueSubdirFor(root, queue, destination), path.basename(found.file));
1304
+ const restored = {
1305
+ ...task,
1306
+ status: resumeSafeBacklog ? 'queued' : projectAccepted ? 'completed' : invalid.status,
1307
+ gateReconciliation: reconciled.reconciliation
1308
+ };
1289
1309
  delete restored.waitingGateId;
1290
1310
  delete restored.waitKind;
1291
1311
  delete restored.waitingSince;
1292
- if (!resumeSafeBacklog) restored.canceledAt = now;
1312
+ if (projectAccepted) restored.completedAt ??= now;
1313
+ else if (!resumeSafeBacklog) restored.canceledAt = now;
1293
1314
  else restored.requeuedAt = now;
1294
1315
  await writeJson(destinationFile, restored);
1295
1316
  await rm(found.file, { force: true });
@@ -1669,7 +1690,12 @@ function validateProjectSpec(spec) {
1669
1690
  normalizeLoopId(queue.queue);
1670
1691
  if (!['code', 'standard'].includes(queue.kind)) throw new Error(`Unsupported project queue kind: ${queue.kind}`);
1671
1692
  }
1672
- if (!Array.isArray(spec.backlog) || spec.backlog.length === 0) throw new Error('Project spec backlog must be non-empty.');
1693
+ const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
1694
+ const configuredBacklogSource = spec.backlogSource ?? spec.authoritativeBacklog;
1695
+ const hasConfiguredBacklog = typeof configuredBacklogSource === 'string' && configuredBacklogSource.trim().length > 0;
1696
+ if (!hasEmbeddedBacklog && !hasConfiguredBacklog) {
1697
+ throw new Error('Project spec must provide a non-empty backlog or backlogSource.');
1698
+ }
1673
1699
  }
1674
1700
 
1675
1701
  export async function projectPlan(root, options = {}) {
@@ -1801,20 +1827,43 @@ export async function projectStatus(root, options = {}) {
1801
1827
  acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
1802
1828
  }
1803
1829
  }
1804
- const registryItems = Array.isArray(spec.backlog) ? spec.backlog : [];
1830
+ let terminalContract = null;
1831
+ if (!acceptanceLedger && spec.terminalContract) {
1832
+ const terminalFile = path.resolve(root, safeRelativePath(spec.terminalContract, 'project terminal contract'));
1833
+ try {
1834
+ const contract = await readJson(terminalFile);
1835
+ terminalContract = {
1836
+ file: path.relative(root, terminalFile),
1837
+ status: contract.status ?? null,
1838
+ terminalAccepted: contract.terminalState?.accepted === true,
1839
+ updatedAt: contract.updatedAt ?? contract.terminalState?.acceptedAt ?? null,
1840
+ unmet: Array.isArray(contract.unmet) ? contract.unmet : [],
1841
+ blockers: Array.isArray(contract.blockers) ? contract.blockers : []
1842
+ };
1843
+ } catch (error) {
1844
+ terminalContract = { file: path.relative(root, terminalFile), readable: false, error: error.message };
1845
+ }
1846
+ }
1847
+ const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
1848
+ const registryItems = hasEmbeddedBacklog ? spec.backlog : [];
1805
1849
  const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
1806
1850
  const registryById = new Map(registryItems.map((item) => [item.id, item]));
1807
1851
  const sourceById = new Map(sourceItems.map((item) => [item.id, item]));
1808
1852
  const drift = [];
1809
- for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
1810
- const registry = registryById.get(id);
1811
- const authoritative = sourceById.get(id);
1812
- if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
1813
- else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
1814
- drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
1853
+ if (hasEmbeddedBacklog) {
1854
+ for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
1855
+ const registry = registryById.get(id);
1856
+ const authoritative = sourceById.get(id);
1857
+ if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
1858
+ else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
1859
+ drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
1860
+ }
1815
1861
  }
1816
1862
  }
1817
- const projectCompletion = acceptanceLedger?.status === 'accepted' && acceptanceLedger.unmet.length === 0 && acceptanceLedger.blockers.length === 0
1863
+ const completionRecord = acceptanceLedger?.readable !== false && acceptanceLedger
1864
+ ? acceptanceLedger
1865
+ : terminalContract?.readable !== false ? terminalContract : null;
1866
+ const projectCompletion = acceptedTerminalRecord(completionRecord)
1818
1867
  ? 'accepted'
1819
1868
  : 'in_progress';
1820
1869
  const needsAttention = [];
@@ -1824,6 +1873,7 @@ export async function projectStatus(root, options = {}) {
1824
1873
  if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
1825
1874
  if (drift.length > 0) needsAttention.push('authoritative_source_drift');
1826
1875
  if (acceptanceLedger?.readable === false) needsAttention.push('acceptance_ledger_unreadable');
1876
+ if (!acceptanceLedger && terminalContract?.readable === false) needsAttention.push('terminal_contract_unreadable');
1827
1877
  return {
1828
1878
  version: 1,
1829
1879
  project,
@@ -1835,13 +1885,15 @@ export async function projectStatus(root, options = {}) {
1835
1885
  totals,
1836
1886
  backlog,
1837
1887
  acceptanceLedger,
1888
+ terminalContract,
1838
1889
  authority: {
1839
- terminalContract: spec.terminalContract ?? null,
1890
+ terminalContract: terminalContract?.file ?? spec.terminalContract ?? null,
1840
1891
  backlog: backlog?.file ?? null,
1841
1892
  acceptanceLedger: acceptanceLedger?.file ?? null,
1842
- rule: 'acceptance ledger determines project completion; backlog source determines remaining work; registry is a projection validated for drift'
1893
+ completionSource: acceptanceLedger ? 'acceptance_ledger' : terminalContract ? 'terminal_contract' : null,
1894
+ rule: 'acceptance ledger determines project completion when configured; otherwise the terminal contract is authoritative; backlog source determines remaining work; registry is a projection validated for drift'
1843
1895
  },
1844
- consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false, drift },
1896
+ consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false && terminalContract?.readable !== false, drift },
1845
1897
  projectCompletion,
1846
1898
  latestIntake,
1847
1899
  needsAttention,
@@ -3579,6 +3631,26 @@ function aggregateCriticStatus(baseStatus, criticReviews) {
3579
3631
  return 'accepted';
3580
3632
  }
3581
3633
 
3634
+ function projectCompletionStatus(value) {
3635
+ if (typeof value === 'string') return value;
3636
+ if (value && typeof value === 'object') return value.status ?? null;
3637
+ return null;
3638
+ }
3639
+
3640
+ function continuationNextAction(value) {
3641
+ const action = String(value ?? '').trim().toLowerCase();
3642
+ if (!action) return false;
3643
+ return ![
3644
+ 'acceptance_review',
3645
+ 'final_judge',
3646
+ 'ready_to_apply',
3647
+ 'apply',
3648
+ 'report',
3649
+ 'complete',
3650
+ 'completed'
3651
+ ].includes(action);
3652
+ }
3653
+
3582
3654
  function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
3583
3655
  const baseStatus = checkpointReviewStatus(checkpoint);
3584
3656
  const failed = [];
@@ -3701,6 +3773,8 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
3701
3773
  checkpointCreatedAt: checkpoint.created_at ?? null,
3702
3774
  createdAt: checkpoint.created_at ?? review.created_at,
3703
3775
  projectCompletion: checkpoint.project_completion ?? null,
3776
+ checkpointRisks: Array.isArray(checkpoint.risks) ? checkpoint.risks : [],
3777
+ checkpointNextAction: checkpoint.next_action ?? null,
3704
3778
  deferredGates: materializableDeferredGates(checkpoint),
3705
3779
  status: review.status,
3706
3780
  file: path.relative(root, reviewFile)
@@ -3773,8 +3847,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3773
3847
  const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
3774
3848
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
3775
3849
  const dispatchFailure = context.dispatchFailureClassification ?? null;
3776
- const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
3777
- const projectCompletionInProgress = effectiveReviews.some((review) => review.projectCompletion?.status === 'in_progress');
3850
+ const projectCompletionAccepted = effectiveReviews.some((review) => projectCompletionStatus(review.projectCompletion) === 'accepted');
3851
+ const projectCompletionInProgress = effectiveReviews.some((review) => projectCompletionStatus(review.projectCompletion) === 'in_progress');
3852
+ const explicitContinuationReviews = effectiveReviews.filter((review) =>
3853
+ (review.checkpointRisks?.length ?? 0) > 0 && continuationNextAction(review.checkpointNextAction)
3854
+ );
3778
3855
  const deferredGateCount = effectiveReviews.reduce((count, review) => count + (review.deferredGates?.length ?? 0), 0);
3779
3856
  let outcome = 'needs_revision';
3780
3857
 
@@ -3826,9 +3903,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3826
3903
  outcome = 'needs_revision';
3827
3904
  reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
3828
3905
  nextActions.push('Complete and review the remaining planned checkpoints.');
3829
- } else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
3906
+ } else if ((contract.task_scope === 'project' || projectCompletionInProgress || explicitContinuationReviews.length > 0 || deferredGateCount > 0) && !projectCompletionAccepted) {
3830
3907
  outcome = 'project_in_progress';
3831
3908
  reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
3909
+ if (projectCompletionInProgress) reasons.push('The latest effective checkpoint explicitly marks project completion as in_progress.');
3910
+ if (explicitContinuationReviews.length > 0) reasons.push(`${explicitContinuationReviews.length} accepted checkpoint(s) record unresolved risks and an explicit continuation action.`);
3832
3911
  if (deferredGateCount > 0) reasons.push(`${deferredGateCount} deferred authorization gate(s) remain and must be materialized as waiting gates when no unrelated safe work is actionable.`);
3833
3912
  nextActions.push('Reread the authoritative project ledger and continue with the next safe actionable backlog item, or wait on a structured authorization gate.');
3834
3913
  } else if (contract.requires_human_gate) {
@@ -3868,12 +3947,14 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3868
3947
  revise: reviseCount,
3869
3948
  blocked: blockedCount,
3870
3949
  deferred_gates: deferredGateCount,
3950
+ explicit_continuations: explicitContinuationReviews.length,
3871
3951
  rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
3872
3952
  automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
3873
3953
  },
3874
3954
  residual_risks: [
3875
3955
  ...(contract.requires_human_gate ? ['Human gate still required before external or high-risk action.'] : []),
3876
- ...(context.verificationFailed ? ['Configured verification has failing commands.'] : [])
3956
+ ...(context.verificationFailed ? ['Configured verification has failing commands.'] : []),
3957
+ ...explicitContinuationReviews.flatMap((review) => review.checkpointRisks ?? [])
3877
3958
  ],
3878
3959
  created_at: new Date().toISOString()
3879
3960
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -132,6 +132,34 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
132
132
  assert.equal(judgement.outcome, 'project_in_progress');
133
133
  }
134
134
 
135
+ {
136
+ const devPlan = { checkpoints: [{ id: 'cp1' }] };
137
+ const reviews = { reviews: [{
138
+ checkpointId: 'cp2', milestoneId: 'cp1', sequence: 2, status: 'accepted',
139
+ projectCompletion: 'in_progress',
140
+ checkpointRisks: ['Creative/perceptual director scoring is not wired into the production inspector.'],
141
+ checkpointNextAction: 'wire_director_perceptual_quality_and_quote_cost_without_paid_calls'
142
+ }] };
143
+ const judgement = buildFinalJudgement(baseContract, basePlan, devPlan, { count: 2 }, reviews, { dispatchStatus: 'completed' });
144
+ assert.equal(judgement.outcome, 'project_in_progress');
145
+ assert.equal(judgement.coverage.explicit_continuations, 1);
146
+ assert.deepEqual(judgement.residual_risks, ['Creative/perceptual director scoring is not wired into the production inspector.']);
147
+ assert.match(judgement.reasons.join(' '), /explicitly marks project completion as in_progress/);
148
+ }
149
+
150
+ {
151
+ const devPlan = { checkpoints: [{ id: 'cp1' }] };
152
+ const reviews = { reviews: [{
153
+ checkpointId: 'cp2', milestoneId: 'cp1', sequence: 2, status: 'accepted',
154
+ projectCompletion: null,
155
+ checkpointRisks: ['A required production integration remains incomplete.'],
156
+ checkpointNextAction: 'implement_remaining_production_integration'
157
+ }] };
158
+ const judgement = buildFinalJudgement(baseContract, basePlan, devPlan, { count: 2 }, reviews, { dispatchStatus: 'completed' });
159
+ assert.equal(judgement.outcome, 'project_in_progress');
160
+ assert.equal(judgement.coverage.explicit_continuations, 1);
161
+ }
162
+
135
163
  {
136
164
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
137
165
  const reviews = { reviews: [{
@@ -91,6 +91,45 @@ assert.equal(drifted.authority.acceptanceLedger, 'project/acceptance-ledger.json
91
91
  assert.equal(drifted.consistency.ok, false);
92
92
  assert(drifted.needsAttention.includes('authoritative_source_drift'));
93
93
 
94
+ // A project may keep its authoritative backlog exclusively in backlogSource.
95
+ // Requiring a duplicate embedded backlog makes the two copies drift and used
96
+ // to prevent project-status from reading an otherwise valid project ledger.
97
+ await writeFile(projectFile, `${JSON.stringify({
98
+ ...spec,
99
+ backlog: undefined,
100
+ backlogSource: 'project/backlog.json',
101
+ acceptanceLedger: 'project/acceptance-ledger.json',
102
+ terminalContract: 'project/terminal.md'
103
+ }, null, 2)}\n`);
104
+ const externalOnly = await projectStatus(root, { project: 'openreel' });
105
+ assert.equal(externalOnly.backlog.file, 'project/backlog.json');
106
+ assert.equal(externalOnly.backlog.count, 1);
107
+ assert.equal(externalOnly.consistency.ok, true);
108
+
109
+ // When a project deliberately uses its terminal contract as the completion
110
+ // ledger, accepted terminal bytes must project accepted status and suppress
111
+ // optional post-completion authorization gates.
112
+ const terminalContractFile = path.join(root, 'project', 'terminal-contract.json');
113
+ await writeFile(terminalContractFile, `${JSON.stringify({
114
+ status: 'accepted', terminalState: { accepted: true }, unmet: [], blockers: []
115
+ }, null, 2)}\n`);
116
+ await writeFile(projectFile, `${JSON.stringify({
117
+ ...spec,
118
+ backlog: undefined,
119
+ backlogSource: 'project/backlog.json',
120
+ acceptanceLedger: undefined,
121
+ terminalContract: 'project/terminal-contract.json'
122
+ }, null, 2)}\n`);
123
+ const terminalOnly = await projectStatus(root, { project: 'openreel' });
124
+ assert.equal(terminalOnly.projectCompletion, 'accepted');
125
+ assert.equal(terminalOnly.authority.completionSource, 'terminal_contract');
126
+ await writeFile(projectFile, `${JSON.stringify({
127
+ ...spec,
128
+ backlogSource: 'project/backlog.json',
129
+ acceptanceLedger: 'project/acceptance-ledger.json',
130
+ terminalContract: 'project/terminal.md'
131
+ }, null, 2)}\n`);
132
+
94
133
  // A ready milestone with project in progress and a deferred authorization is
95
134
  // converted into a structured waiting gate, not left as prose on a done task.
96
135
  const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
@@ -155,6 +194,23 @@ await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'accepted', unm
155
194
  await reconcileProjectGates(root, { queue });
156
195
  assert.equal((await queueStatus(root, queue)).waiting, 0);
157
196
  assert.equal(JSON.parse(await readFile(path.join(root, deferredResult.ledger), 'utf8')).status, 'superseded');
197
+ const legacyTerminal = await enqueueTask(root, { queue, title: 'Legacy accepted-project gate', task: 'Project openreel terminal bookkeeping', projectId: 'openreel' });
198
+ const legacyCheckpointDir = path.join(taskRuntimeDirFor(root, queue, legacyTerminal.task.id), 'checkpoints');
199
+ await mkdir(legacyCheckpointDir, { recursive: true });
200
+ await writeFile(path.join(legacyCheckpointDir, 'cp-terminal.json'), `${JSON.stringify({ checkpoint_id: 'cp-terminal', status: 'ready_for_acceptance', blockers: [] }, null, 2)}\n`);
201
+ const legacyGateId = `${legacyTerminal.task.id}:cp-terminal`;
202
+ const legacyGateFile = path.join(root, 'runtime', 'loops', queue, 'human-input', 'gates', `${legacyTerminal.task.id}.cp-terminal.json`);
203
+ await writeFile(legacyGateFile, `${JSON.stringify({
204
+ gate_id: legacyGateId, task_id: legacyTerminal.task.id, checkpoint_id: 'cp-terminal', project_id: 'openreel',
205
+ requirement_ids: [], contract_hash: 'legacy', gate_kind: 'deferred_authorization', status: 'waiting_for_human'
206
+ }, null, 2)}\n`);
207
+ const legacyInbox = path.join(root, legacyTerminal.file);
208
+ const legacyWaiting = path.join(root, 'runtime', 'loops', queue, 'waiting', path.basename(legacyTerminal.file));
209
+ await writeFile(legacyWaiting, `${JSON.stringify({ ...legacyTerminal.task, status: 'waiting_for_human', waitingGateId: legacyGateId }, null, 2)}\n`);
210
+ await rename(legacyInbox, `${legacyInbox}.moved`);
211
+ await reconcileProjectGates(root, { queue });
212
+ assert.equal((await queueStatus(root, queue)).done >= 1, true, 'accepted project task must close in done, not canceled');
213
+ assert.equal(JSON.parse(await readFile(legacyGateFile, 'utf8')).status, 'superseded');
158
214
  const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
159
215
  const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
160
216
  await mkdir(acceptedOptionalDir, { recursive: true });