taskforce-loop-engineering 0.15.4 → 0.15.6

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.6 - 2026-08-24
6
+
7
+ - Allow project configurations to use an external authoritative `backlogSource` without duplicating an embedded backlog registry.
8
+ - Keep project status fail-closed when neither an embedded backlog nor an external backlog source is configured.
9
+ - Suppress false registry-drift findings when no embedded projection is intentionally present, while retaining drift detection when both representations exist.
10
+ - Add regression coverage for external-only authoritative project backlogs.
11
+
12
+ ## 0.15.5 - 2026-08-24
13
+
14
+ - 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.
15
+ - Keep future paid, deployment, and publication authorization boundaries deferred while an unrelated safe project backlog item remains actionable.
16
+ - Reconcile an already-materialized premature deferred gate by superseding it and restoring the project carrier to the runnable queue.
17
+ - Resolve the authoritative project from checkpoint milestones when a task still carries a broader parent project id.
18
+
5
19
  ## 0.15.4 - 2026-08-22
6
20
 
7
21
  - Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
package/lib/core.mjs CHANGED
@@ -1266,17 +1266,32 @@ export async function reconcileProjectGates(root, options = {}) {
1266
1266
  gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
1267
1267
  await writeJson(full, gate);
1268
1268
  }
1269
- const spec = specs.get(gate.project_id);
1269
+ const spec = await projectSpecForCheckpoint(root, [...specs.values()], gate.project_id, checkpoint);
1270
+ if (spec?.project && gate.project_id !== spec.project) {
1271
+ gate = { ...gate, project_id: spec.project };
1272
+ await writeJson(full, gate);
1273
+ }
1274
+ const safeBacklogActionable = gate.gate_kind === 'deferred_authorization'
1275
+ && await projectHasSafeActionableBacklog(root, spec);
1270
1276
  const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
1271
1277
  ? { code: 'project_accepted_optional_deferred', status: 'superseded' }
1272
- : gateInvalidity(gate, spec);
1278
+ : safeBacklogActionable
1279
+ ? { code: 'safe_backlog_actionable', status: 'superseded' }
1280
+ : gateInvalidity(gate, spec);
1273
1281
  if (!invalid) continue;
1274
1282
  const now = new Date().toISOString();
1275
1283
  const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
1276
1284
  await writeJson(full, reconciled);
1277
1285
  if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
1278
- const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), path.basename(found.file));
1279
- await writeJson(canceledFile, { ...task, status: invalid.status, canceledAt: now, gateReconciliation: reconciled.reconciliation });
1286
+ 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 };
1289
+ delete restored.waitingGateId;
1290
+ delete restored.waitKind;
1291
+ delete restored.waitingSince;
1292
+ if (!resumeSafeBacklog) restored.canceledAt = now;
1293
+ else restored.requeuedAt = now;
1294
+ await writeJson(destinationFile, restored);
1280
1295
  await rm(found.file, { force: true });
1281
1296
  }
1282
1297
  results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
@@ -1654,7 +1669,12 @@ function validateProjectSpec(spec) {
1654
1669
  normalizeLoopId(queue.queue);
1655
1670
  if (!['code', 'standard'].includes(queue.kind)) throw new Error(`Unsupported project queue kind: ${queue.kind}`);
1656
1671
  }
1657
- if (!Array.isArray(spec.backlog) || spec.backlog.length === 0) throw new Error('Project spec backlog must be non-empty.');
1672
+ const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
1673
+ const configuredBacklogSource = spec.backlogSource ?? spec.authoritativeBacklog;
1674
+ const hasConfiguredBacklog = typeof configuredBacklogSource === 'string' && configuredBacklogSource.trim().length > 0;
1675
+ if (!hasEmbeddedBacklog && !hasConfiguredBacklog) {
1676
+ throw new Error('Project spec must provide a non-empty backlog or backlogSource.');
1677
+ }
1658
1678
  }
1659
1679
 
1660
1680
  export async function projectPlan(root, options = {}) {
@@ -1786,17 +1806,20 @@ export async function projectStatus(root, options = {}) {
1786
1806
  acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
1787
1807
  }
1788
1808
  }
1789
- const registryItems = Array.isArray(spec.backlog) ? spec.backlog : [];
1809
+ const hasEmbeddedBacklog = Array.isArray(spec.backlog) && spec.backlog.length > 0;
1810
+ const registryItems = hasEmbeddedBacklog ? spec.backlog : [];
1790
1811
  const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
1791
1812
  const registryById = new Map(registryItems.map((item) => [item.id, item]));
1792
1813
  const sourceById = new Map(sourceItems.map((item) => [item.id, item]));
1793
1814
  const drift = [];
1794
- for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
1795
- const registry = registryById.get(id);
1796
- const authoritative = sourceById.get(id);
1797
- if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
1798
- else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
1799
- drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
1815
+ if (hasEmbeddedBacklog) {
1816
+ for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
1817
+ const registry = registryById.get(id);
1818
+ const authoritative = sourceById.get(id);
1819
+ if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
1820
+ else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
1821
+ drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
1822
+ }
1800
1823
  }
1801
1824
  }
1802
1825
  const projectCompletion = acceptanceLedger?.status === 'accepted' && acceptanceLedger.unmet.length === 0 && acceptanceLedger.blockers.length === 0
@@ -2399,6 +2422,43 @@ function materializableDeferredGates(checkpoint) {
2399
2422
  });
2400
2423
  }
2401
2424
 
2425
+ async function projectBacklogItems(root, spec) {
2426
+ if (!spec) return [];
2427
+ const configured = spec.backlogSource ?? spec.authoritativeBacklog;
2428
+ if (!configured) return [];
2429
+ let loaded;
2430
+ try {
2431
+ loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
2432
+ } catch {
2433
+ return [];
2434
+ }
2435
+ return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
2436
+ }
2437
+
2438
+ async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
2439
+ const direct = specs.find((item) => item.project === projectId);
2440
+ const milestoneId = checkpoint?.milestone_id;
2441
+ if (!milestoneId) return direct;
2442
+ if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
2443
+ for (const spec of specs) {
2444
+ if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
2445
+ }
2446
+ return direct;
2447
+ }
2448
+
2449
+ async function projectHasSafeActionableBacklog(root, spec) {
2450
+ const items = await projectBacklogItems(root, spec);
2451
+ const byId = new Map(items.map((item) => [item.id, item]));
2452
+ const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
2453
+ const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
2454
+ return items.some((item) => {
2455
+ if (!item?.id || !runnable.has(item.status)) return false;
2456
+ if (item.requires_human_input === true || item.blocked === true || item.status === 'blocked') return false;
2457
+ const dependencies = Array.isArray(item.dependsOn) ? item.dependsOn : Array.isArray(item.depends_on) ? item.depends_on : [];
2458
+ return dependencies.every((id) => complete.has(byId.get(id)?.status));
2459
+ });
2460
+ }
2461
+
2402
2462
  async function tasksById(root, queue) {
2403
2463
  const tasks = new Map();
2404
2464
  for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
@@ -2452,9 +2512,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2452
2512
  const deferredOnly = latestDeferredGates.length > 0
2453
2513
  && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2454
2514
  && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2455
- const spec = specs.find((item) => item.project === metadata.project_id);
2515
+ const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
2456
2516
  if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
2457
- candidates.push({ taskId, entry, projectId: metadata.project_id, ...checkpoints[0] });
2517
+ // deferred_gates describe later authority boundaries. They become a
2518
+ // waiting gate only after the authoritative backlog has no unrelated safe
2519
+ // item left to run. Otherwise a future paid/deploy/publish boundary would
2520
+ // incorrectly stop local project development.
2521
+ if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
2522
+ candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
2458
2523
  }
2459
2524
 
2460
2525
  // A project deferred gate is materialized only from its newest authoritative
@@ -8731,6 +8796,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
8731
8796
  ];
8732
8797
 
8733
8798
  const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
8799
+ { category: 'incomplete_turn', pattern: '"kind": "incomplete_turn"' },
8800
+ { category: 'incomplete_turn', pattern: '"livenessState": "abandoned"' },
8801
+ { category: 'incomplete_turn', pattern: 'stopped before confirming the turn was complete' },
8734
8802
  { category: 'compaction_timeout', pattern: 'compaction timed out' },
8735
8803
  { category: 'compaction_timeout', pattern: 'transcript compaction failed' },
8736
8804
  { category: 'transport_error', pattern: 'connection reset' },
@@ -8740,7 +8808,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
8740
8808
  ];
8741
8809
 
8742
8810
  export function dispatchFailureClassification(result, retry = {}) {
8743
- if (!result || result.exitCode === 0) {
8811
+ if (!result) {
8812
+ return {
8813
+ category: 'ok',
8814
+ requiresHumanAction: false,
8815
+ matchedPattern: null
8816
+ };
8817
+ }
8818
+ // Some embedded runtimes exit their wrapper successfully after the model
8819
+ // turn itself was abandoned. Inspect the structured trailer before trusting
8820
+ // exitCode=0 or the queue may accept partial work and replay a stale gate.
8821
+ const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
8822
+ const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
8823
+ const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
8824
+ if (runtimeMatch) {
8825
+ return {
8826
+ category: runtimeMatch.category ?? 'runtime_interruption',
8827
+ requiresHumanAction: false,
8828
+ recoverableRuntime: true,
8829
+ matchedPattern: runtimeMatch.pattern ?? runtimeMatch
8830
+ };
8831
+ }
8832
+ if (result.exitCode === 0) {
8744
8833
  return {
8745
8834
  category: 'ok',
8746
8835
  requiresHumanAction: false,
@@ -8755,7 +8844,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8755
8844
  matchedPattern: null
8756
8845
  };
8757
8846
  }
8758
- const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
8759
8847
  const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
8760
8848
  const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
8761
8849
  if (matched) {
@@ -8766,16 +8854,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8766
8854
  matchedPattern: matched
8767
8855
  };
8768
8856
  }
8769
- const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
8770
- const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
8771
- if (runtimeMatch) {
8772
- return {
8773
- category: runtimeMatch.category ?? 'runtime_interruption',
8774
- requiresHumanAction: false,
8775
- recoverableRuntime: true,
8776
- matchedPattern: runtimeMatch.pattern ?? runtimeMatch
8777
- };
8778
- }
8779
8857
  return {
8780
8858
  category: 'retryable_failure',
8781
8859
  requiresHumanAction: false,
@@ -8908,9 +8986,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
8908
8986
  result,
8909
8987
  failureClassification
8910
8988
  });
8911
- const dispatchStatus = result.exitCode === 0
8912
- ? 'passed'
8913
- : failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
8989
+ const dispatchStatus = failureClassification.recoverableRuntime
8990
+ ? 'interrupted'
8991
+ : result.exitCode === 0
8992
+ ? 'passed'
8993
+ : failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
8914
8994
  runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
8915
8995
  attempt,
8916
8996
  exitCode: result.exitCode,
@@ -9237,6 +9317,10 @@ export async function runQueueOnce(root, options) {
9237
9317
  const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
9238
9318
  if (dispatch?.canceled) {
9239
9319
  finalStatus = 'superseded';
9320
+ } else if (dispatchClassification?.recoverableRuntime) {
9321
+ const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
9322
+ const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
9323
+ finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
9240
9324
  } else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
9241
9325
  verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
9242
9326
  const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
@@ -9246,10 +9330,6 @@ export async function runQueueOnce(root, options) {
9246
9330
  finalStatus = 'completed';
9247
9331
  } else if (dispatchClassification?.requiresHumanAction) {
9248
9332
  finalStatus = 'needs_human_input';
9249
- } else if (dispatchClassification?.recoverableRuntime) {
9250
- const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
9251
- const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
9252
- finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
9253
9333
  } else {
9254
9334
  finalStatus = 'failed';
9255
9335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -47,6 +47,36 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
47
47
  assert.equal(classification.recoverableRuntime, true);
48
48
  }
49
49
 
50
+ {
51
+ const classification = dispatchFailureClassification({
52
+ exitCode: 0,
53
+ timedOut: false,
54
+ stderr: '',
55
+ stdout: JSON.stringify({
56
+ replayInvalid: true,
57
+ livenessState: 'abandoned',
58
+ error: {
59
+ kind: 'incomplete_turn',
60
+ message: 'Codex stopped before confirming the turn was complete. Some work may already have been performed; verify the current state before retrying.'
61
+ }
62
+ }, null, 2)
63
+ });
64
+ assert.equal(classification.category, 'incomplete_turn');
65
+ assert.equal(classification.recoverableRuntime, true);
66
+ assert.equal(classification.requiresHumanAction, false);
67
+ }
68
+
69
+ {
70
+ const classification = dispatchFailureClassification({
71
+ exitCode: 0,
72
+ timedOut: false,
73
+ stderr: '',
74
+ stdout: JSON.stringify({ livenessState: 'working', completion: { stopReason: 'stop' } })
75
+ });
76
+ assert.equal(classification.category, 'ok');
77
+ assert.equal(classification.recoverableRuntime, undefined);
78
+ }
79
+
50
80
  {
51
81
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
52
82
  const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
@@ -91,6 +91,27 @@ 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
+ await writeFile(projectFile, `${JSON.stringify({
109
+ ...spec,
110
+ backlogSource: 'project/backlog.json',
111
+ acceptanceLedger: 'project/acceptance-ledger.json',
112
+ terminalContract: 'project/terminal.md'
113
+ }, null, 2)}\n`);
114
+
94
115
  // A ready milestone with project in progress and a deferred authorization is
95
116
  // converted into a structured waiting gate, not left as prose on a done task.
96
117
  const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
@@ -111,6 +132,28 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
111
132
  assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
112
133
  assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
113
134
 
135
+ // A future authorization must not stop the project while an unrelated safe
136
+ // backlog item remains actionable.
137
+ await reconcileProjectGates(root, { queue });
138
+ await writeFile(authoritativeBacklog, `${JSON.stringify({
139
+ status: 'ongoing',
140
+ items: [
141
+ { id: 'LOCAL-01', status: 'phase_complete', dependsOn: [] },
142
+ { id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
143
+ ]
144
+ }, null, 2)}\n`);
145
+ const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
146
+ const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
147
+ await mkdir(futureGateDir, { recursive: true });
148
+ await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
149
+ version: 1, task_id: futureGateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
150
+ project_completion: { status: 'in_progress' }, next_action: 'Implement LOCAL-02 locally.',
151
+ deferred_gates: [{ id: 'production-later', action: 'production_deploy', required_authority: 'Owner authorization after local candidate acceptance.' }]
152
+ }, null, 2)}\n`);
153
+ const futureNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
154
+ assert.equal(futureNotice.results.some((item) => item.taskId === futureGateTask.task.id), false);
155
+ assert.equal((await queueStatus(root, queue)).waiting, 0);
156
+
114
157
  // Conditional policy boundaries are not current blockers. Plain prose in
115
158
  // deferred_gates must not materialize a gate without a concrete action and
116
159
  // authority requirement.
@@ -145,4 +188,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
145
188
  assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
146
189
  assert.equal((await queueStatus(root, queue)).waiting, 0);
147
190
 
148
- 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', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
191
+ 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'] }));