taskforce-loop-engineering 0.15.9 → 0.15.10

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,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.10 - 2026-08-28
6
+
7
+ - Materialize human-input gates only for permissions or external conditions that are explicitly missing and needed now; preserve authorized, consumed, future, and conditional boundaries as audit context instead of repeatedly blocking project progress.
8
+ - Honor project standing authorization for in-scope production backup, deploy, restart, readiness, restore, and rollback work while keeping publication, credential, destructive, and other excluded actions gated.
9
+ - Resolve checkpoint-bound subproject backlogs when deciding whether safe project work remains, with regression coverage for current blockers, dormant gates, and publication boundaries.
10
+
5
11
  ## 0.15.9 - 2026-08-25
6
12
 
7
13
  - Clarify that the disposable OpenClaw smoke may write required checkpoint and verification artifacts only inside its temporary Loop runtime queue while user/project files, configuration, credentials, and external state remain read-only.
package/lib/core.mjs CHANGED
@@ -2428,8 +2428,8 @@ export async function refreshTaskAcceptance(root, options = {}) {
2428
2428
  };
2429
2429
  }
2430
2430
 
2431
- function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2432
- const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
2431
+ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en', blockers = null) {
2432
+ blockers = blockers ?? (Array.isArray(checkpoint.blockers) ? checkpoint.blockers : []);
2433
2433
  const deferredGates = materializableDeferredGates(checkpoint);
2434
2434
  const requirements = blockers.length > 0 ? blockers : deferredGates;
2435
2435
  const blockerText = requirements.length
@@ -2453,45 +2453,99 @@ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2453
2453
  ].join('\n');
2454
2454
  }
2455
2455
 
2456
+ function blockerCoveredByContractAuthorization(blocker, contract) {
2457
+ if (!blocker || typeof blocker !== 'object' || Array.isArray(blocker)) return false;
2458
+ const state = String(blocker.authorization_state ?? blocker.state ?? '')
2459
+ .trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
2460
+ const neededWhen = String(blocker.needed_when ?? '')
2461
+ .trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
2462
+ if (['authorized', 'consumed', 'satisfied', 'future', 'not_required'].includes(state)) return true;
2463
+ // A conditional blocker is non-materializable only while the condition is
2464
+ // still in the future. Once the producer explicitly says it is needed now
2465
+ // and materialize=true, it is a real current blocker and must stop the
2466
+ // queue instead of being downgraded to an endless development revision.
2467
+ if (state === 'conditional'
2468
+ && blocker.materialize !== true
2469
+ && !['now', 'current', 'immediate'].includes(neededWhen)) return true;
2470
+ if (blocker.materialize === false) return true;
2471
+ const allowed = new Set(contract?.constraints?.allowed_actions ?? []);
2472
+ if (!allowed.has('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization')) return false;
2473
+ const text = `${blocker.action ?? ''} ${blocker.required_authority ?? blocker.reason ?? ''}`.toLowerCase();
2474
+ const productionSequence = /(deploy|deployment|materialize|activate|reactivate|restart|process[- ]control|backup|restore|rollback|readiness|persistence|部署|重启|备份|恢复|回滚)/.test(text);
2475
+ const excluded = /(publish|publication|credential|secret|delete|destructive|external send|发布|凭据|密钥|删除|破坏性|外部发送)/.test(text);
2476
+ return productionSequence && !excluded;
2477
+ }
2478
+
2479
+ function materializableBlockers(checkpoint, contract = null) {
2480
+ const blockers = Array.isArray(checkpoint?.blockers) ? checkpoint.blockers.filter(Boolean) : [];
2481
+ return blockers.filter((blocker) => !blockerCoveredByContractAuthorization(blocker, contract));
2482
+ }
2483
+
2456
2484
  function materializableDeferredGates(checkpoint) {
2457
2485
  const gates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2458
2486
  return gates.filter((gate) => {
2459
2487
  if (!gate || typeof gate !== 'object' || Array.isArray(gate)) return false;
2460
2488
  const action = gate.action ?? gate.kind;
2461
2489
  const authority = gate.required_authority ?? gate.human_action_required;
2490
+ const authorizationState = String(gate.authorization_state ?? gate.state ?? '')
2491
+ .trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
2492
+ const neededWhen = String(gate.needed_when ?? '')
2493
+ .trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
2494
+ const nonMaterializableStates = new Set([
2495
+ 'authorized', 'consumed', 'satisfied', 'future', 'conditional', 'not_required'
2496
+ ]);
2497
+ // Deferred gates are future-boundary records, not implicit current human
2498
+ // requests. Require an explicit missing/required state and a current
2499
+ // needed_when marker before materializing one. Legacy unstructured notes
2500
+ // remain audit evidence and cannot manufacture a waiting gate.
2501
+ const explicitlyMissing = ['missing', 'required', 'unauthorized'].includes(authorizationState);
2502
+ const neededNow = ['now', 'current', 'immediate'].includes(neededWhen);
2462
2503
  return typeof action === 'string' && action.trim().length > 0
2463
2504
  && typeof authority === 'string' && authority.trim().length > 0
2464
2505
  && gate.materialize !== false
2465
- && !['future', 'conditional', 'not_required'].includes(gate.state);
2506
+ && explicitlyMissing
2507
+ && neededNow
2508
+ && !nonMaterializableStates.has(authorizationState)
2509
+ && !['future', 'later', 'conditional', 'after_acceptance', 'after_completion'].includes(neededWhen);
2466
2510
  });
2467
2511
  }
2468
2512
 
2469
- async function projectBacklogItems(root, spec) {
2513
+ async function projectBacklogItems(root, spec, checkpoint = null) {
2470
2514
  if (!spec) return [];
2471
- const configured = spec.backlogSource ?? spec.authoritativeBacklog;
2472
- if (!configured) return [];
2473
- let loaded;
2474
- try {
2475
- loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
2476
- } catch {
2477
- return [];
2515
+ const checkpointSource = checkpoint?.backlog_source ?? checkpoint?.authoritative_backlog;
2516
+ const configuredSources = [
2517
+ checkpointSource,
2518
+ ...(Array.isArray(spec.checkpointBacklogSources) ? spec.checkpointBacklogSources : []),
2519
+ spec.backlogSource,
2520
+ spec.authoritativeBacklog
2521
+ ].filter(Boolean);
2522
+ const items = [];
2523
+ for (const configured of configuredSources) {
2524
+ let loaded;
2525
+ try {
2526
+ loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
2527
+ } catch {
2528
+ continue;
2529
+ }
2530
+ const loadedItems = Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
2531
+ items.push(...loadedItems);
2478
2532
  }
2479
- return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
2533
+ return [...new Map(items.filter((item) => item?.id).map((item) => [item.id, item])).values()];
2480
2534
  }
2481
2535
 
2482
2536
  async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
2483
2537
  const direct = specs.find((item) => item.project === projectId);
2484
2538
  const milestoneId = checkpoint?.milestone_id;
2485
2539
  if (!milestoneId) return direct;
2486
- if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
2540
+ if ((await projectBacklogItems(root, direct, checkpoint)).some((item) => item.id === milestoneId)) return direct;
2487
2541
  for (const spec of specs) {
2488
- if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
2542
+ if ((await projectBacklogItems(root, spec, checkpoint)).some((item) => item.id === milestoneId)) return spec;
2489
2543
  }
2490
2544
  return direct;
2491
2545
  }
2492
2546
 
2493
- async function projectHasSafeActionableBacklog(root, spec) {
2494
- const items = await projectBacklogItems(root, spec);
2547
+ async function projectHasSafeActionableBacklog(root, spec, checkpoint = null) {
2548
+ const items = await projectBacklogItems(root, spec, checkpoint);
2495
2549
  const byId = new Map(items.map((item) => [item.id, item]));
2496
2550
  const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
2497
2551
  const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
@@ -2528,6 +2582,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2528
2582
  for (const [taskId, entry] of tasks) {
2529
2583
  if (!entry.task.source || entry.subdir === 'canceled') continue;
2530
2584
  const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
2585
+ const contractFile = path.join(runtimeDir, 'task_contract.json');
2586
+ const contract = await exists(contractFile) ? await readJson(contractFile) : null;
2531
2587
  let files = await listJson(path.join(runtimeDir, 'checkpoints'));
2532
2588
  const judgementFile = path.join(runtimeDir, 'final_judgement.json');
2533
2589
  if (await exists(judgementFile)) {
@@ -2542,8 +2598,9 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2542
2598
  const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
2543
2599
  const checkpoint = await readJson(checkpointFile);
2544
2600
  const deferred = materializableDeferredGates(checkpoint);
2545
- if (['needs_human_input', 'blocked'].includes(checkpoint?.status) || deferred.length > 0) {
2546
- checkpoints.push({ file, checkpoint, mtimeMs: (await stat(checkpointFile)).mtimeMs });
2601
+ const blockers = materializableBlockers(checkpoint, contract);
2602
+ if ((['needs_human_input', 'blocked'].includes(checkpoint?.status) && blockers.length > 0) || deferred.length > 0) {
2603
+ checkpoints.push({ file, checkpoint, blockers, mtimeMs: (await stat(checkpointFile)).mtimeMs });
2547
2604
  }
2548
2605
  }
2549
2606
  if (checkpoints.length === 0) continue;
@@ -2554,7 +2611,7 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2554
2611
  const latestCheckpoint = checkpoints[0].checkpoint;
2555
2612
  const latestDeferredGates = materializableDeferredGates(latestCheckpoint);
2556
2613
  const deferredOnly = latestDeferredGates.length > 0
2557
- && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2614
+ && checkpoints[0].blockers.length === 0
2558
2615
  && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2559
2616
  const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
2560
2617
  if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
@@ -2562,8 +2619,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2562
2619
  // waiting gate only after the authoritative backlog has no unrelated safe
2563
2620
  // item left to run. Otherwise a future paid/deploy/publish boundary would
2564
2621
  // incorrectly stop local project development.
2565
- if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
2566
- candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
2622
+ if (deferredOnly && await projectHasSafeActionableBacklog(root, spec, latestCheckpoint)) continue;
2623
+ candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, contract, ...checkpoints[0] });
2567
2624
  }
2568
2625
 
2569
2626
  // A project deferred gate is materialized only from its newest authoritative
@@ -2598,7 +2655,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
2598
2655
  await mkdir(gatesDir, { recursive: true });
2599
2656
  const results = [];
2600
2657
  const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
2601
- for (const { taskId, entry, file, checkpoint } of candidates) {
2658
+ for (const { taskId, entry, file, checkpoint, blockers } of candidates) {
2602
2659
  const deferredGates = materializableDeferredGates(checkpoint);
2603
2660
  const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
2604
2661
  const gateId = `${taskId}:${checkpointId}`;
@@ -2654,7 +2711,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
2654
2711
  }
2655
2712
  }
2656
2713
  }
2657
- const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
2714
+ const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language, blockers);
2658
2715
  if (options.dryRun) {
2659
2716
  results.push({ taskId, checkpointId, gateId, outcome: 'dry_run', message, source: entry.task.source });
2660
2717
  continue;
@@ -3445,6 +3502,14 @@ function buildDevPlan(contract, acceptancePlan, firstCheckpointId = 'cp1') {
3445
3502
  files_changed: [],
3446
3503
  verification: [],
3447
3504
  blockers: [],
3505
+ blocker_schema: {
3506
+ action: 'Concrete action that cannot proceed now.',
3507
+ required_authority: 'Specific authority or input that is currently missing.',
3508
+ authorization_state: 'missing | authorized | consumed | satisfied | future | conditional | not_required',
3509
+ authority_ref: 'Required when authorization_state is not missing.',
3510
+ needed_when: 'now | future condition',
3511
+ materialize: 'Set false for authorized or non-current boundaries.'
3512
+ },
3448
3513
  deferred_gates: [],
3449
3514
  deferred_gate_schema: {
3450
3515
  action: 'Concrete action that cannot proceed now.',
@@ -3494,10 +3559,11 @@ async function checkpointSummary(root, devPlan) {
3494
3559
  };
3495
3560
  }
3496
3561
 
3497
- function checkpointReviewStatus(checkpoint) {
3562
+ function checkpointReviewStatus(checkpoint, contract = null) {
3498
3563
  if (!checkpoint) return 'blocked';
3499
- if (checkpoint.status === 'blocked' || checkpoint.status === 'needs_human_input') return 'blocked';
3500
- if (Array.isArray(checkpoint.blockers) && checkpoint.blockers.length > 0) return 'revise';
3564
+ const blockers = materializableBlockers(checkpoint, contract);
3565
+ if ((checkpoint.status === 'blocked' || checkpoint.status === 'needs_human_input') && blockers.length > 0) return 'blocked';
3566
+ if (blockers.length > 0) return 'revise';
3501
3567
  if (!Array.isArray(checkpoint.verification) || checkpoint.verification.length === 0) return 'revise';
3502
3568
  if (checkpoint.status !== 'ready_for_acceptance') return 'revise';
3503
3569
  return 'accepted';
@@ -3605,7 +3671,7 @@ function evaluateAcceptanceCritic(critic, context) {
3605
3671
  function buildCriticReviews(contract, acceptancePlan, checkpoint) {
3606
3672
  const missingCheckpoint = !checkpoint;
3607
3673
  const hasVerification = Array.isArray(checkpoint?.verification) && checkpoint.verification.length > 0;
3608
- const hasBlockers = Array.isArray(checkpoint?.blockers) && checkpoint.blockers.length > 0;
3674
+ const hasBlockers = materializableBlockers(checkpoint, contract).length > 0;
3609
3675
  const hasSummary = typeof checkpoint?.summary === 'string' && checkpoint.summary.trim().length > 0;
3610
3676
  const hasRisks = Array.isArray(checkpoint?.risks);
3611
3677
  const blockedActions = contract.constraints?.blocked_actions ?? [];
@@ -3652,7 +3718,7 @@ function continuationNextAction(value) {
3652
3718
  }
3653
3719
 
3654
3720
  function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
3655
- const baseStatus = checkpointReviewStatus(checkpoint);
3721
+ const baseStatus = checkpointReviewStatus(checkpoint, contract);
3656
3722
  const failed = [];
3657
3723
  const passed = [];
3658
3724
  const blocked = [];
@@ -3677,8 +3743,9 @@ function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
3677
3743
  });
3678
3744
  }
3679
3745
 
3680
- if (Array.isArray(checkpoint.blockers) && checkpoint.blockers.length > 0) {
3681
- blocked.push(...checkpoint.blockers.map((item) => typeof item === 'string' ? item : JSON.stringify(item)));
3746
+ const effectiveBlockers = materializableBlockers(checkpoint, contract);
3747
+ if (effectiveBlockers.length > 0) {
3748
+ blocked.push(...effectiveBlockers.map((item) => typeof item === 'string' ? item : JSON.stringify(item)));
3682
3749
  } else {
3683
3750
  passed.push('Checkpoint reports no blockers.');
3684
3751
  }
@@ -9348,6 +9415,9 @@ export async function runQueueOnce(root, options) {
9348
9415
  env: {
9349
9416
  ...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
9350
9417
  LOOP_SESSION_GENERATION: String(task.runtimeSessionGeneration ?? 0),
9418
+ LOOP_EXECUTION_TARGET_JSON: options.executionTarget
9419
+ ? JSON.stringify(options.executionTarget)
9420
+ : '',
9351
9421
  LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
9352
9422
  LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
9353
9423
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.9",
3
+ "version": "0.15.10",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { mkdtemp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor, writeTaskContract } from '../lib/core.mjs';
5
+ import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, resolveHumanInput, routeLoopMessage, taskRuntimeDirFor, writeTaskContract } from '../lib/core.mjs';
6
6
 
7
7
  const root = await mkdtemp(path.join(os.tmpdir(), 'loop-project-gates-'));
8
8
  const queue = 'shared';
@@ -139,7 +139,11 @@ await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
139
139
  version: 1, task_id: deferred.task.id, checkpoint_id: 'cp-ready', milestone_id: 'S-01', requirement_ids: ['S-01'],
140
140
  status: 'ready_for_acceptance', blockers: [], verification: ['local phase passed'], risks: [],
141
141
  project_completion: { status: 'in_progress' },
142
- deferred_gates: [{ id: 'S-01-production', action: 'production_rollback_drill', required_authority: 'Owner authorization for the exact production rollback drill scope.' }]
142
+ deferred_gates: [{
143
+ id: 'S-01-production', action: 'production_rollback_drill',
144
+ required_authority: 'Owner authorization for the exact production rollback drill scope.',
145
+ authorization_state: 'missing', needed_when: 'now', materialize: true
146
+ }]
143
147
  }, null, 2)}\n`);
144
148
  const deferredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
145
149
  const deferredResult = deferredNotice.results.find((item) => item.taskId === deferred.task.id);
@@ -187,6 +191,123 @@ await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
187
191
  const conditionalNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
188
192
  assert.equal(conditionalNotice.results.some((item) => item.taskId === conditional.task.id), false);
189
193
 
194
+ // A conditional formal blocker becomes current when the producer explicitly
195
+ // marks it needed now and materialize=true. It must stop once, rather than be
196
+ // filtered into a needs_revision/project_in_progress polling loop.
197
+ const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
198
+ const conditionalNowDir = path.join(taskRuntimeDirFor(root, queue, conditionalNow.task.id), 'checkpoints');
199
+ await mkdir(conditionalNowDir, { recursive: true });
200
+ await writeFile(path.join(conditionalNowDir, 'cp1.json'), `${JSON.stringify({
201
+ version: 1, task_id: conditionalNow.task.id, checkpoint_id: 'cp1', status: 'blocked',
202
+ blockers: [{
203
+ action: 'Run the authorized provider probe.', required_authority: 'Restore the required remote execution precondition.',
204
+ authorization_state: 'conditional', needed_when: 'now', materialize: true
205
+ }],
206
+ deferred_gates: []
207
+ }, null, 2)}\n`);
208
+ const conditionalNowNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
209
+ assert.equal(conditionalNowNotice.results.find((item) => item.taskId === conditionalNow.task.id)?.outcome, 'sent');
210
+ await resolveHumanInput(root, { queue, gateId: `${conditionalNow.task.id}:cp1`, input: 'external precondition restored' });
211
+ await reconcileProjectGates(root, { queue });
212
+
213
+ // Authorization already granted or already consumed is audit context, not a
214
+ // new human-input request. A future boundary is likewise dormant.
215
+ for (const authorizationState of ['authorized', 'consumed', 'future']) {
216
+ const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
217
+ const stateDir = path.join(taskRuntimeDirFor(root, queue, stateTask.task.id), 'checkpoints');
218
+ await mkdir(stateDir, { recursive: true });
219
+ await writeFile(path.join(stateDir, 'cp1.json'), `${JSON.stringify({
220
+ version: 1, task_id: stateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
221
+ project_completion: { status: 'in_progress' },
222
+ deferred_gates: [{
223
+ action: 'Execute the bounded action.', required_authority: 'Recorded owner authority.',
224
+ authorization_state: authorizationState, authority_ref: 'test-authority',
225
+ needed_when: authorizationState === 'future' ? 'after_acceptance' : 'now'
226
+ }]
227
+ }, null, 2)}\n`);
228
+ const stateNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
229
+ assert.equal(stateNotice.results.some((item) => item.taskId === stateTask.task.id), false, `${authorizationState} authority must not create a waiting gate`);
230
+ }
231
+
232
+ // A checkpoint may bind an active subproject backlog. This prevents a global
233
+ // project ledger from hiding the safe next milestone.
234
+ await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
235
+ const subprojectBacklog = path.join(root, 'project', 'cdqi2-backlog.json');
236
+ await writeFile(subprojectBacklog, `${JSON.stringify({
237
+ status: 'ongoing', items: [
238
+ { id: 'CDQI2-10', status: 'accepted', dependsOn: [] },
239
+ { id: 'CDQI2-11', status: 'in_progress', dependsOn: ['CDQI2-10'] }
240
+ ]
241
+ }, null, 2)}\n`);
242
+ const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
243
+ const subprojectDir = path.join(taskRuntimeDirFor(root, queue, subprojectTask.task.id), 'checkpoints');
244
+ await mkdir(subprojectDir, { recursive: true });
245
+ await writeFile(path.join(subprojectDir, 'cp1.json'), `${JSON.stringify({
246
+ version: 1, task_id: subprojectTask.task.id, checkpoint_id: 'cp1', milestone_id: 'CDQI2-11',
247
+ backlog_source: 'project/cdqi2-backlog.json', status: 'ready_for_acceptance', blockers: [],
248
+ project_completion: { status: 'in_progress' },
249
+ deferred_gates: [{ action: 'Publish after T11.', required_authority: 'Owner publication approval.' }]
250
+ }, null, 2)}\n`);
251
+ const subprojectNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
252
+ assert.equal(subprojectNotice.results.some((item) => item.taskId === subprojectTask.task.id), false, 'safe checkpoint-bound backlog must prevent waiting');
253
+
254
+ // A genuinely missing current authorization becomes a waiting gate once no
255
+ // safe project work remains.
256
+ await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
257
+ const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
258
+ const missingDir = path.join(taskRuntimeDirFor(root, queue, missing.task.id), 'checkpoints');
259
+ await mkdir(missingDir, { recursive: true });
260
+ await writeFile(path.join(missingDir, 'cp1.json'), `${JSON.stringify({
261
+ version: 1, task_id: missing.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
262
+ project_completion: { status: 'in_progress' },
263
+ deferred_gates: [{ action: 'Publish now.', required_authority: 'Owner publication approval.', authorization_state: 'missing', needed_when: 'now' }]
264
+ }, null, 2)}\n`);
265
+ const missingNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
266
+ assert.equal(missingNotice.results.find((item) => item.taskId === missing.task.id)?.outcome, 'sent');
267
+ await reconcileProjectGates(root, { queue });
268
+
269
+ // A formal blocker that merely restates an in-scope production sequence
270
+ // covered by the project standing authorization must not stop the queue.
271
+ await writeFile(projectFile, `${JSON.stringify({
272
+ ...spec,
273
+ backlogSource: 'project/backlog.json',
274
+ acceptanceLedger: 'project/acceptance-ledger.json',
275
+ terminalContract: 'project/terminal.md',
276
+ actionPolicy: {
277
+ deploy: 'standing_authorization_openreel_2026-08-19',
278
+ productionConfig: 'standing_authorization_openreel_2026-08-19',
279
+ backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19'
280
+ }
281
+ }, null, 2)}\n`);
282
+ const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
283
+ const coveredContract = await writeTaskContract(root, queue, coveredBlocker.task);
284
+ assert.equal(coveredContract.contract.constraints.project_authorization.production_authorized, true);
285
+ const coveredDir = path.join(taskRuntimeDirFor(root, queue, coveredBlocker.task.id), 'checkpoints');
286
+ await mkdir(coveredDir, { recursive: true });
287
+ await writeFile(path.join(coveredDir, 'cp1.json'), `${JSON.stringify({
288
+ version: 1, task_id: coveredBlocker.task.id, checkpoint_id: 'cp1', status: 'needs_human_input',
289
+ blockers: [{ action: 'Back up, deploy, restart, verify readiness and rehearse rollback on the established production target.', required_authority: 'Separate process-control confirmation.' }],
290
+ verification: ['candidate accepted'], risks: [], project_completion: { status: 'in_progress' }
291
+ }, null, 2)}\n`);
292
+ const coveredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
293
+ assert.equal(coveredNotice.results.some((item) => item.taskId === coveredBlocker.task.id), false, 'standing-authorized production blocker must not create a gate');
294
+
295
+ // Explicitly authorized blocker metadata is also non-materializable, while a
296
+ // genuinely missing publication permission remains a human gate.
297
+ const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
298
+ await writeTaskContract(root, queue, publication.task);
299
+ const publicationDir = path.join(taskRuntimeDirFor(root, queue, publication.task.id), 'checkpoints');
300
+ await mkdir(publicationDir, { recursive: true });
301
+ await writeFile(path.join(publicationDir, 'cp1.json'), `${JSON.stringify({
302
+ version: 1, task_id: publication.task.id, checkpoint_id: 'cp1', status: 'needs_human_input',
303
+ blockers: [{ action: 'Publish externally now.', required_authority: 'Owner publication confirmation.', authorization_state: 'missing', needed_when: 'now' }],
304
+ verification: ['candidate ready'], risks: [], project_completion: { status: 'in_progress' }
305
+ }, null, 2)}\n`);
306
+ const publicationNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
307
+ assert.equal(publicationNotice.results.find((item) => item.taskId === publication.task.id)?.outcome, 'sent', 'missing publication authority must create a gate');
308
+ await resolveHumanInput(root, { queue, gateId: `${publication.task.id}:cp1`, input: 'test resolution' });
309
+ await reconcileProjectGates(root, { queue });
310
+
190
311
  // Once the authoritative project ledger accepts the terminal contract, an
191
312
  // optional post-completion deferred action stays in operations backlog and
192
313
  // neither creates nor retains a project-queue waiting gate.
@@ -223,4 +344,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
223
344
  assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
224
345
  assert.equal((await queueStatus(root, queue)).waiting, 0);
225
346
 
226
- console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
347
+ console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'authorized and consumed authority do not create gates', 'checkpoint-bound subproject backlog remains actionable', 'missing current authority creates a gate', 'standing-authorized production blocker does not create a gate', 'missing publication blocker creates a gate', 'accepted project optional deferred gate stays out of queue'] }));
@@ -548,7 +548,10 @@ for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['late
548
548
  });
549
549
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
550
550
  version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
551
- status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', action: 'approve_requirement', required_authority: 'Approve R-1.' }],
551
+ status: 'ready_for_acceptance', blockers: [], deferred_gates: [{
552
+ id: 'R-1', action: 'approve_requirement', required_authority: 'Approve R-1.',
553
+ authorization_state: 'missing', needed_when: 'now', materialize: true
554
+ }],
552
555
  verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
553
556
  });
554
557
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {