taskforce-loop-engineering 0.15.6 → 0.15.8

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,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.8 - 2026-08-25
6
+
7
+ - Preserve every non-blocking risk from the effective terminal checkpoint in `final_judgement.residual_risks`, including accepted project completions without a continuation action.
8
+ - Deduplicate repeated terminal risks while retaining the accepted completion outcome.
9
+ - Add regression coverage proving accepted terminal checkpoints keep their operational risks visible.
10
+
11
+ ## 0.15.7 - 2026-08-24
12
+
13
+ - 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.
14
+ - Preserve unresolved checkpoint risks in the final judgement and treat a concrete continuation action as project work that must continue instead of terminal completion.
15
+ - Use an accepted terminal contract as the authoritative project-completion source when no separate acceptance ledger is configured.
16
+ - Reconcile stale optional post-completion authorization gates into `done` rather than `canceled`, while retaining fail-closed behavior for real blockers.
17
+ - Add regression coverage for premature terminal judgement, terminal-contract status projection, and historical stale-gate reconciliation.
18
+
5
19
  ## 0.15.6 - 2026-08-24
6
20
 
7
21
  - Allow project configurations to use an external authoritative `backlogSource` without duplicating an embedded backlog registry.
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 });
@@ -1806,6 +1827,23 @@ export async function projectStatus(root, options = {}) {
1806
1827
  acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
1807
1828
  }
1808
1829
  }
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
+ }
1809
1847
  const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
1810
1848
  const registryItems = hasEmbeddedBacklog ? spec.backlog : [];
1811
1849
  const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
@@ -1822,7 +1860,10 @@ export async function projectStatus(root, options = {}) {
1822
1860
  }
1823
1861
  }
1824
1862
  }
1825
- 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)
1826
1867
  ? 'accepted'
1827
1868
  : 'in_progress';
1828
1869
  const needsAttention = [];
@@ -1832,6 +1873,7 @@ export async function projectStatus(root, options = {}) {
1832
1873
  if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
1833
1874
  if (drift.length > 0) needsAttention.push('authoritative_source_drift');
1834
1875
  if (acceptanceLedger?.readable === false) needsAttention.push('acceptance_ledger_unreadable');
1876
+ if (!acceptanceLedger && terminalContract?.readable === false) needsAttention.push('terminal_contract_unreadable');
1835
1877
  return {
1836
1878
  version: 1,
1837
1879
  project,
@@ -1843,13 +1885,15 @@ export async function projectStatus(root, options = {}) {
1843
1885
  totals,
1844
1886
  backlog,
1845
1887
  acceptanceLedger,
1888
+ terminalContract,
1846
1889
  authority: {
1847
- terminalContract: spec.terminalContract ?? null,
1890
+ terminalContract: terminalContract?.file ?? spec.terminalContract ?? null,
1848
1891
  backlog: backlog?.file ?? null,
1849
1892
  acceptanceLedger: acceptanceLedger?.file ?? null,
1850
- 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'
1851
1895
  },
1852
- consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false, drift },
1896
+ consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false && terminalContract?.readable !== false, drift },
1853
1897
  projectCompletion,
1854
1898
  latestIntake,
1855
1899
  needsAttention,
@@ -3587,6 +3631,26 @@ function aggregateCriticStatus(baseStatus, criticReviews) {
3587
3631
  return 'accepted';
3588
3632
  }
3589
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
+
3590
3654
  function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
3591
3655
  const baseStatus = checkpointReviewStatus(checkpoint);
3592
3656
  const failed = [];
@@ -3709,6 +3773,8 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
3709
3773
  checkpointCreatedAt: checkpoint.created_at ?? null,
3710
3774
  createdAt: checkpoint.created_at ?? review.created_at,
3711
3775
  projectCompletion: checkpoint.project_completion ?? null,
3776
+ checkpointRisks: Array.isArray(checkpoint.risks) ? checkpoint.risks : [],
3777
+ checkpointNextAction: checkpoint.next_action ?? null,
3712
3778
  deferredGates: materializableDeferredGates(checkpoint),
3713
3779
  status: review.status,
3714
3780
  file: path.relative(root, reviewFile)
@@ -3781,8 +3847,12 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3781
3847
  const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
3782
3848
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
3783
3849
  const dispatchFailure = context.dispatchFailureClassification ?? null;
3784
- const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
3785
- 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
+ );
3855
+ const effectiveResidualRisks = [...new Set(effectiveReviews.flatMap((review) => review.checkpointRisks ?? []))];
3786
3856
  const deferredGateCount = effectiveReviews.reduce((count, review) => count + (review.deferredGates?.length ?? 0), 0);
3787
3857
  let outcome = 'needs_revision';
3788
3858
 
@@ -3834,9 +3904,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3834
3904
  outcome = 'needs_revision';
3835
3905
  reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
3836
3906
  nextActions.push('Complete and review the remaining planned checkpoints.');
3837
- } else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
3907
+ } else if ((contract.task_scope === 'project' || projectCompletionInProgress || explicitContinuationReviews.length > 0 || deferredGateCount > 0) && !projectCompletionAccepted) {
3838
3908
  outcome = 'project_in_progress';
3839
3909
  reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
3910
+ if (projectCompletionInProgress) reasons.push('The latest effective checkpoint explicitly marks project completion as in_progress.');
3911
+ if (explicitContinuationReviews.length > 0) reasons.push(`${explicitContinuationReviews.length} accepted checkpoint(s) record unresolved risks and an explicit continuation action.`);
3840
3912
  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.`);
3841
3913
  nextActions.push('Reread the authoritative project ledger and continue with the next safe actionable backlog item, or wait on a structured authorization gate.');
3842
3914
  } else if (contract.requires_human_gate) {
@@ -3876,12 +3948,14 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3876
3948
  revise: reviseCount,
3877
3949
  blocked: blockedCount,
3878
3950
  deferred_gates: deferredGateCount,
3951
+ explicit_continuations: explicitContinuationReviews.length,
3879
3952
  rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
3880
3953
  automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
3881
3954
  },
3882
3955
  residual_risks: [
3883
3956
  ...(contract.requires_human_gate ? ['Human gate still required before external or high-risk action.'] : []),
3884
- ...(context.verificationFailed ? ['Configured verification has failing commands.'] : [])
3957
+ ...(context.verificationFailed ? ['Configured verification has failing commands.'] : []),
3958
+ ...effectiveResidualRisks
3885
3959
  ],
3886
3960
  created_at: new Date().toISOString()
3887
3961
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.6",
3
+ "version": "0.15.8",
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: [{
@@ -147,7 +175,13 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
147
175
 
148
176
  {
149
177
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
150
- const reviews = { reviews: [{ checkpointId: 'cp6', sequence: 6, status: 'accepted', projectCompletion: { status: 'accepted' } }] };
178
+ const reviews = { reviews: [{
179
+ checkpointId: 'cp6', sequence: 6, status: 'accepted', projectCompletion: { status: 'accepted' },
180
+ checkpointRisks: [
181
+ 'A non-blocking operational risk remains after acceptance.',
182
+ 'A non-blocking operational risk remains after acceptance.'
183
+ ]
184
+ }] };
151
185
  const judgement = buildFinalJudgement(
152
186
  { ...baseContract, task_scope: 'project' },
153
187
  basePlan,
@@ -157,6 +191,7 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
157
191
  { dispatchStatus: 'completed' }
158
192
  );
159
193
  assert.equal(judgement.outcome, 'ready_to_apply');
194
+ assert.deepEqual(judgement.residual_risks, ['A non-blocking operational risk remains after acceptance.']);
160
195
  }
161
196
 
162
197
  console.log('final judgement self-test passed');
@@ -105,6 +105,24 @@ const externalOnly = await projectStatus(root, { project: 'openreel' });
105
105
  assert.equal(externalOnly.backlog.file, 'project/backlog.json');
106
106
  assert.equal(externalOnly.backlog.count, 1);
107
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');
108
126
  await writeFile(projectFile, `${JSON.stringify({
109
127
  ...spec,
110
128
  backlogSource: 'project/backlog.json',
@@ -176,6 +194,23 @@ await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'accepted', unm
176
194
  await reconcileProjectGates(root, { queue });
177
195
  assert.equal((await queueStatus(root, queue)).waiting, 0);
178
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');
179
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' });
180
215
  const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
181
216
  await mkdir(acceptedOptionalDir, { recursive: true });