taskforce-loop-engineering 0.15.0 → 0.15.2

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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.2 - 2026-08-21
6
+
7
+ - Enrich terminal notifications with final judgement, checkpoint summary, verification count, blockers, and next action.
8
+ - Link successful terminal notification ledgers back into queue run artifacts while preserving idempotent delivery.
9
+ - Materialize only actionable deferred human gates, preserve project standing authorizations in task contracts, and allocate append-only checkpoint identifiers when replanning.
10
+
11
+ ## 0.15.1 - 2026-08-20
12
+
13
+ - Keep generated OpenClaw and Hermes scheduler services healthy when asynchronous human or terminal notification delivery fails, while preserving notification logging and retries.
14
+
5
15
  ## 0.15.0 - 2026-08-20
6
16
 
7
17
  - Reconcile project-scoped human gates against the authoritative project contract and acceptance ledger, superseding stale or optional deferred gates without replay.
package/lib/core.mjs CHANGED
@@ -1565,7 +1565,7 @@ function buildProjectSpec(options) {
1565
1565
  assumptions: [
1566
1566
  'Start with local artifacts and local verification.',
1567
1567
  'Use conservative defaults when the brief leaves implementation details open.',
1568
- 'Ask for confirmation before external, destructive, production, push, publish, or deploy actions.'
1568
+ 'Apply the configured project action policy; ask only for actions that remain human-gated there.'
1569
1569
  ]
1570
1570
  };
1571
1571
  }
@@ -2102,9 +2102,42 @@ async function installedQueueLanguage(root, queue, fallback = 'en') {
2102
2102
  try { return normalizeLanguage((await readJson(file)).language); } catch { return normalizeLanguage(fallback); }
2103
2103
  }
2104
2104
 
2105
- function terminalNotificationMessage(queue, task, language = 'en') {
2105
+ async function terminalNotificationContext(root, queue, task) {
2106
+ const taskDir = taskRuntimeDirFor(root, queue, task.id);
2107
+ const judgementFile = path.join(taskDir, 'final_judgement.json');
2108
+ const checkpointsDir = path.join(taskDir, 'checkpoints');
2109
+ const judgement = await exists(judgementFile) ? await readJson(judgementFile) : null;
2110
+ let checkpoint = null;
2111
+ const checkpointFiles = await listJson(checkpointsDir);
2112
+ if (checkpointFiles.length > 0) {
2113
+ const candidates = await Promise.all(checkpointFiles.map(async (file) => ({
2114
+ file,
2115
+ value: await readJson(path.join(checkpointsDir, file)),
2116
+ mtimeMs: (await stat(path.join(checkpointsDir, file))).mtimeMs
2117
+ })));
2118
+ candidates.sort((a, b) => {
2119
+ const sequenceDelta = Number(a.value?.sequence ?? -1) - Number(b.value?.sequence ?? -1);
2120
+ return sequenceDelta || a.mtimeMs - b.mtimeMs;
2121
+ });
2122
+ checkpoint = candidates.at(-1)?.value ?? null;
2123
+ }
2124
+ return { judgement, checkpoint };
2125
+ }
2126
+
2127
+ function compactNotificationText(value, max = 500) {
2128
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
2129
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
2130
+ }
2131
+
2132
+ async function terminalNotificationMessage(root, queue, task, language = 'en') {
2133
+ const { judgement, checkpoint } = await terminalNotificationContext(root, queue, task);
2106
2134
  const needsReview = task.status === 'ready_for_human_review';
2107
2135
  const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
2136
+ const summary = compactNotificationText(checkpoint?.summary);
2137
+ const verificationCount = Array.isArray(checkpoint?.verification) ? checkpoint.verification.length : 0;
2138
+ const blockers = Array.isArray(checkpoint?.blockers) ? checkpoint.blockers.filter(Boolean) : [];
2139
+ const outcome = judgement?.outcome;
2140
+ const nextAction = compactNotificationText(judgement?.next_actions?.[0] ?? checkpoint?.next_action, 300);
2108
2141
  if (language === 'zh') return [
2109
2142
  needsReview ? 'Loop 任务已准备好接受人工验收'
2110
2143
  : needsHuman ? 'Loop 任务需要人工输入'
@@ -2112,6 +2145,11 @@ function terminalNotificationMessage(queue, task, language = 'en') {
2112
2145
  `任务:${task.title}`,
2113
2146
  `队列:${queue}`,
2114
2147
  `状态:${task.status}`,
2148
+ ...(outcome ? [`最终判定:${outcome}`] : []),
2149
+ ...(summary ? [`结果:${summary}`] : []),
2150
+ ...(verificationCount ? [`验收:${verificationCount} 项验证证据已记录`] : []),
2151
+ ...(blockers.length ? [`阻塞:${blockers.map((item) => compactNotificationText(item, 180)).join(';')}`] : []),
2152
+ ...(nextAction ? [`下一步:${nextAction}`] : []),
2115
2153
  ...(needsReview
2116
2154
  ? [`下一步:检查最终判定,并为任务 ${task.id} 记录 approve、request_changes 或 reject。`]
2117
2155
  : needsHuman
@@ -2125,6 +2163,11 @@ function terminalNotificationMessage(queue, task, language = 'en') {
2125
2163
  `task: ${task.title}`,
2126
2164
  `queue: ${queue}`,
2127
2165
  `status: ${task.status}`,
2166
+ ...(outcome ? [`final judgement: ${outcome}`] : []),
2167
+ ...(summary ? [`result: ${summary}`] : []),
2168
+ ...(verificationCount ? [`acceptance: ${verificationCount} verification evidence item(s) recorded`] : []),
2169
+ ...(blockers.length ? [`blockers: ${blockers.map((item) => compactNotificationText(item, 180)).join('; ')}`] : []),
2170
+ ...(nextAction ? [`next: ${nextAction}`] : []),
2128
2171
  ...(needsReview
2129
2172
  ? [`next: review the final judgement and record approve, request_changes, or reject for task ${task.id}.`]
2130
2173
  : needsHuman
@@ -2133,6 +2176,22 @@ function terminalNotificationMessage(queue, task, language = 'en') {
2133
2176
  ].join('\n');
2134
2177
  }
2135
2178
 
2179
+ async function linkTerminalNotificationToRun(root, task, ledgerFile, ledger, outcome = 'sent') {
2180
+ if (!task.runPath) return false;
2181
+ const runFile = path.resolve(root, task.runPath);
2182
+ if (!await exists(runFile)) return false;
2183
+ const run = await readJson(runFile);
2184
+ await writeJson(runFile, {
2185
+ ...run,
2186
+ terminalNotification: {
2187
+ outcome,
2188
+ ledger: path.relative(root, ledgerFile),
2189
+ notifiedAt: ledger.notified_at
2190
+ }
2191
+ });
2192
+ return true;
2193
+ }
2194
+
2136
2195
  export async function notifyTerminalTasks(root, options = {}) {
2137
2196
  const queue = normalizeLoopId(options.queue);
2138
2197
  const language = await installedQueueLanguage(root, queue, options.language);
@@ -2181,10 +2240,14 @@ export async function notifyTerminalTasks(root, options = {}) {
2181
2240
  const key = `${safeTaskId(task.id)}.${normalizeLoopId(task.status)}.json`;
2182
2241
  const ledgerFile = path.join(notificationDir, key);
2183
2242
  if (await exists(ledgerFile)) {
2243
+ if (!options.dryRun) {
2244
+ const ledger = await readJson(ledgerFile);
2245
+ await linkTerminalNotificationToRun(root, task, ledgerFile, ledger);
2246
+ }
2184
2247
  results.push({ taskId: task.id, status: task.status, outcome: 'already_notified', ledger: path.relative(root, ledgerFile) });
2185
2248
  continue;
2186
2249
  }
2187
- const message = terminalNotificationMessage(queue, task, language);
2250
+ const message = await terminalNotificationMessage(root, queue, task, language);
2188
2251
  if (options.dryRun) {
2189
2252
  results.push({ taskId: task.id, status: task.status, outcome: 'dry_run', message, source: task.source });
2190
2253
  continue;
@@ -2215,6 +2278,7 @@ export async function notifyTerminalTasks(root, options = {}) {
2215
2278
  result: compactCommandResult(result)
2216
2279
  };
2217
2280
  await writeJson(ledgerFile, ledger);
2281
+ await linkTerminalNotificationToRun(root, task, ledgerFile, ledger);
2218
2282
  results.push({ taskId: task.id, status: task.status, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
2219
2283
  }
2220
2284
  }
@@ -2280,7 +2344,7 @@ export async function refreshTaskAcceptance(root, options = {}) {
2280
2344
 
2281
2345
  function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2282
2346
  const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
2283
- const deferredGates = Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [];
2347
+ const deferredGates = materializableDeferredGates(checkpoint);
2284
2348
  const requirements = blockers.length > 0 ? blockers : deferredGates;
2285
2349
  const blockerText = requirements.length
2286
2350
  ? requirements.map((item) => typeof item === 'string' ? item : item?.required_authority ?? item?.human_action_required ?? item?.user_action ?? item?.reason ?? item?.description ?? item?.message ?? JSON.stringify(item))
@@ -2303,6 +2367,19 @@ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2303
2367
  ].join('\n');
2304
2368
  }
2305
2369
 
2370
+ function materializableDeferredGates(checkpoint) {
2371
+ const gates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2372
+ return gates.filter((gate) => {
2373
+ if (!gate || typeof gate !== 'object' || Array.isArray(gate)) return false;
2374
+ const action = gate.action ?? gate.kind;
2375
+ const authority = gate.required_authority ?? gate.human_action_required;
2376
+ return typeof action === 'string' && action.trim().length > 0
2377
+ && typeof authority === 'string' && authority.trim().length > 0
2378
+ && gate.materialize !== false
2379
+ && !['future', 'conditional', 'not_required'].includes(gate.state);
2380
+ });
2381
+ }
2382
+
2306
2383
  async function tasksById(root, queue) {
2307
2384
  const tasks = new Map();
2308
2385
  for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
@@ -2341,7 +2418,7 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2341
2418
  for (const file of files) {
2342
2419
  const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
2343
2420
  const checkpoint = await readJson(checkpointFile);
2344
- const deferred = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2421
+ const deferred = materializableDeferredGates(checkpoint);
2345
2422
  if (['needs_human_input', 'blocked'].includes(checkpoint?.status) || deferred.length > 0) {
2346
2423
  checkpoints.push({ file, checkpoint, mtimeMs: (await stat(checkpointFile)).mtimeMs });
2347
2424
  }
@@ -2352,8 +2429,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2352
2429
  || String(b.checkpoint.checkpoint_id ?? b.file).localeCompare(String(a.checkpoint.checkpoint_id ?? a.file)));
2353
2430
  const metadata = await gateProjectMetadata(root, entry.task, checkpoints[0].checkpoint);
2354
2431
  const latestCheckpoint = checkpoints[0].checkpoint;
2355
- const deferredOnly = Array.isArray(latestCheckpoint.deferred_gates)
2356
- && latestCheckpoint.deferred_gates.length > 0
2432
+ const latestDeferredGates = materializableDeferredGates(latestCheckpoint);
2433
+ const deferredOnly = latestDeferredGates.length > 0
2357
2434
  && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2358
2435
  && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2359
2436
  const spec = specs.find((item) => item.project === metadata.project_id);
@@ -2389,7 +2466,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
2389
2466
  const results = [];
2390
2467
  const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
2391
2468
  for (const { taskId, entry, file, checkpoint } of candidates) {
2392
- const deferredGates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2469
+ const deferredGates = materializableDeferredGates(checkpoint);
2393
2470
  const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
2394
2471
  const gateId = `${taskId}:${checkpointId}`;
2395
2472
  const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
@@ -2789,6 +2866,35 @@ function defaultBlockedActions(riskLevel) {
2789
2866
  return blocked;
2790
2867
  }
2791
2868
 
2869
+ function taskProjectReference(task, specs) {
2870
+ const explicit = String(task?.projectId ?? task?.project_id ?? '').trim();
2871
+ if (explicit) return specs.find((spec) => spec.project === explicit) ?? null;
2872
+ const text = `${task?.title ?? ''}\n${task?.body ?? ''}`;
2873
+ return specs
2874
+ .filter((spec) => typeof spec?.project === 'string' && text.includes(spec.project))
2875
+ .sort((a, b) => b.project.length - a.project.length)[0] ?? null;
2876
+ }
2877
+
2878
+ async function taskProjectAuthorization(root, task) {
2879
+ const projectSpec = taskProjectReference(task, await projectSpecs(root));
2880
+ if (!projectSpec?.actionPolicy || typeof projectSpec.actionPolicy !== 'object') return null;
2881
+ const policy = projectSpec.actionPolicy;
2882
+ const standing = (value) => typeof value === 'string' && value.startsWith('standing_authorization_');
2883
+ const productionAuthorized = standing(policy.deploy)
2884
+ && standing(policy.productionConfig)
2885
+ && standing(policy.backupRestoreRollbackRehearsal);
2886
+ const paidLimit = Number(policy.paidActionPerActionCnyLimit);
2887
+ const paidAuthorized = Number.isFinite(paidLimit) && paidLimit > 0;
2888
+ return {
2889
+ project: projectSpec.project,
2890
+ source: `configs/loops/projects/${projectSpec.project}.json`,
2891
+ production_authorized: productionAuthorized,
2892
+ production_authorization: productionAuthorized ? policy.deploy : null,
2893
+ paid_action_authorized: paidAuthorized,
2894
+ paid_action_per_action_cny_limit: paidAuthorized ? paidLimit : null
2895
+ };
2896
+ }
2897
+
2792
2898
  export function inferTaskScope(task) {
2793
2899
  const requestText = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
2794
2900
  // A project can be referenced as context while the routed task is explicitly
@@ -2808,6 +2914,18 @@ function buildTaskContract(queue, task, options = {}) {
2808
2914
  const modelAssessed = task.riskAssessment === 'model_assessed';
2809
2915
  const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
2810
2916
  const taskScope = inferTaskScope(task);
2917
+ const projectAuthorization = options.projectAuthorization ?? null;
2918
+ const blockedActions = defaultBlockedActions(riskLevel).filter((action) => !(
2919
+ projectAuthorization?.production_authorized
2920
+ && action === 'production_config_change_without_explicit_confirmation'
2921
+ ));
2922
+ const allowedActions = defaultAllowedActions(riskLevel);
2923
+ if (projectAuthorization?.production_authorized) {
2924
+ allowedActions.push('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization');
2925
+ }
2926
+ if (projectAuthorization?.paid_action_authorized) {
2927
+ allowedActions.push('paid_provider_action_with_existing_credentials_within_per_action_cny_limit');
2928
+ }
2811
2929
  return {
2812
2930
  version: 1,
2813
2931
  task_id: task.id,
@@ -2822,8 +2940,9 @@ function buildTaskContract(queue, task, options = {}) {
2822
2940
  'Verification evidence or blockers'
2823
2941
  ],
2824
2942
  constraints: {
2825
- allowed_actions: defaultAllowedActions(riskLevel),
2826
- blocked_actions: defaultBlockedActions(riskLevel),
2943
+ allowed_actions: allowedActions,
2944
+ blocked_actions: blockedActions,
2945
+ ...(projectAuthorization ? { project_authorization: projectAuthorization } : {}),
2827
2946
  workspace: options.workspace ?? null
2828
2947
  },
2829
2948
  risk_level: riskLevel,
@@ -2921,7 +3040,8 @@ export async function writeTaskContract(root, queue, task, options = {}) {
2921
3040
  let contract = buildTaskContract(queue, task, {
2922
3041
  ...options,
2923
3042
  workspace: options.workspace ?? root,
2924
- historicalPatterns
3043
+ historicalPatterns,
3044
+ projectAuthorization: options.projectAuthorization ?? await taskProjectAuthorization(root, task)
2925
3045
  });
2926
3046
  contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
2927
3047
  await writeJson(file, contract);
@@ -3150,8 +3270,7 @@ export async function writeAcceptancePlan(root, queue, task, taskContract, optio
3150
3270
  };
3151
3271
  }
3152
3272
 
3153
- function buildDevPlan(contract, acceptancePlan) {
3154
- const firstCheckpointId = 'cp1';
3273
+ function buildDevPlan(contract, acceptancePlan, firstCheckpointId = 'cp1') {
3155
3274
  const gated = contract.requires_human_gate;
3156
3275
  return {
3157
3276
  version: 1,
@@ -3194,6 +3313,10 @@ function buildDevPlan(contract, acceptancePlan) {
3194
3313
  verification: [],
3195
3314
  blockers: [],
3196
3315
  deferred_gates: [],
3316
+ deferred_gate_schema: {
3317
+ action: 'Concrete action that cannot proceed now.',
3318
+ required_authority: 'Specific authority or input required for that action.'
3319
+ },
3197
3320
  project_completion: null,
3198
3321
  risks: [],
3199
3322
  next_action: 'acceptance_review'
@@ -3209,7 +3332,14 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
3209
3332
  await mkdir(checkpointsDir, { recursive: true });
3210
3333
  await mkdir(reviewsDir, { recursive: true });
3211
3334
  const file = path.join(dir, 'dev_plan.json');
3212
- let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan);
3335
+ const existingCheckpointFiles = await listJson(checkpointsDir);
3336
+ const usedCheckpointNumbers = new Set(existingCheckpointFiles
3337
+ .map((file) => Number(String(file).match(/^cp(\d+)\.json$/)?.[1]))
3338
+ .filter((value) => Number.isInteger(value) && value > 0));
3339
+ let checkpointNumber = 1;
3340
+ while (usedCheckpointNumbers.has(checkpointNumber)) checkpointNumber += 1;
3341
+ const nextCheckpointId = `cp${checkpointNumber}`;
3342
+ let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan, nextCheckpointId);
3213
3343
  plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_instructions');
3214
3344
  await writeJson(file, plan);
3215
3345
  return {
@@ -3490,7 +3620,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
3490
3620
  checkpointCreatedAt: checkpoint.created_at ?? null,
3491
3621
  createdAt: checkpoint.created_at ?? review.created_at,
3492
3622
  projectCompletion: checkpoint.project_completion ?? null,
3493
- deferredGates: Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [],
3623
+ deferredGates: materializableDeferredGates(checkpoint),
3494
3624
  status: review.status,
3495
3625
  file: path.relative(root, reviewFile)
3496
3626
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -33,6 +33,7 @@ const install = await run(process.execPath, [installer, ...base, '--confirm-inst
33
33
  const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
34
34
  const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('Hermes English conversation instructions are missing or contain Chinese routing examples');
35
35
  const wrapper = await readFile(path.join(root, 'scripts/loops/hermes-loop.mjs'), 'utf8'); if (!wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper)) throw new Error('Hermes English wrapper routing was not fully localized');
36
+ if (!wrapper.includes('process.exitCode = tickCode;') || wrapper.includes('process.exitCode = tickCode || humanCode || terminalCode;')) throw new Error('Hermes scheduler health still depends on notification delivery');
36
37
  const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dispatch.mjs'), 'utf8'); if (!dispatcher.includes("'-z', prompt")) throw new Error('Hermes one-shot dispatcher missing');
37
38
  const notifier = path.join(root, 'scripts/loops/hermes-loop-notify.mjs'); const notify = await run(process.execPath, [notifier, 'hello from loop'], { env: { ...process.env, HERMES_SEND_CAPTURE: sendCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:12345' }) } }); if (notify.code !== 0) throw new Error(`Hermes notifier failed: ${notify.stderr}`);
38
39
  const sent = JSON.parse(await readFile(sendCapture, 'utf8')); if (!sent.includes('telegram:12345') || !sent.includes('hello from loop')) throw new Error('Hermes notifier did not preserve delivery target/message');
@@ -116,7 +116,7 @@ if (command === 'route') {
116
116
  const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs', ...rest]);
117
117
  const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
118
118
  const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
119
- process.exitCode = tickCode || humanCode || terminalCode;
119
+ process.exitCode = tickCode;
120
120
  } else { console.error(${JSON.stringify(text(language, 'Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick', '用法:node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'))}); process.exitCode = 1; }
121
121
  `;
122
122
  }
@@ -70,6 +70,7 @@ const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
70
70
  if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('English conversation instructions are missing or contain Chinese routing examples');
71
71
  const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
72
72
  if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper) || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, localization, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
73
+ if (!wrapper.includes('process.exitCode = tickCode;') || wrapper.includes('process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;')) throw new Error('scheduler health still depends on notification delivery');
73
74
  const missingSourceRoute = await new Promise((resolve) => {
74
75
  const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
75
76
  let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
@@ -218,7 +218,7 @@ if (command === 'route') {
218
218
  const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs', ...rest]);
219
219
  const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
220
220
  const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
221
- process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
221
+ process.exitCode = tickCode;
222
222
  } else {
223
223
  console.error(${JSON.stringify(usage)});
224
224
  process.exitCode = 1;
@@ -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 } from '../lib/core.mjs';
5
+ import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, 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';
@@ -14,6 +14,32 @@ const spec = { schemaVersion: 1, project: 'openreel', type: 'code_project', queu
14
14
  await mkdir(path.dirname(projectFile), { recursive: true });
15
15
  await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
16
16
 
17
+ // A project standing authorization must reach the generated task contract.
18
+ // It authorizes only the configured in-scope production and bounded paid
19
+ // actions; unrelated external, destructive, credential, push and publish
20
+ // actions remain blocked.
21
+ await writeFile(projectFile, `${JSON.stringify({
22
+ ...spec,
23
+ actionPolicy: {
24
+ deploy: 'standing_authorization_openreel_2026-08-19',
25
+ productionConfig: 'standing_authorization_openreel_2026-08-19',
26
+ backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19',
27
+ paidActionPerActionCnyLimit: 100,
28
+ externalWrites: 'human_confirm',
29
+ destructiveActions: 'human_confirm'
30
+ }
31
+ }, null, 2)}\n`);
32
+ const authorizedTask = await enqueueTask(root, { queue, title: 'OpenReel production acceptance', task: 'Continue project openreel production acceptance under its action policy', projectId: 'openreel' });
33
+ const authorizedContract = (await writeTaskContract(root, queue, authorizedTask.task)).contract;
34
+ assert.equal(authorizedContract.constraints.project_authorization.project, 'openreel');
35
+ assert.equal(authorizedContract.constraints.project_authorization.production_authorized, true);
36
+ assert.equal(authorizedContract.constraints.project_authorization.paid_action_per_action_cny_limit, 100);
37
+ assert.equal(authorizedContract.constraints.blocked_actions.includes('production_config_change_without_explicit_confirmation'), false);
38
+ assert(authorizedContract.constraints.allowed_actions.includes('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization'));
39
+ assert(authorizedContract.constraints.allowed_actions.includes('paid_provider_action_with_existing_credentials_within_per_action_cny_limit'));
40
+ assert(authorizedContract.constraints.blocked_actions.includes('credential_change_without_explicit_confirmation'));
41
+ await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
42
+
17
43
  const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
18
44
  const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
19
45
  await mkdir(checkpointDir, { recursive: true });
@@ -85,6 +111,21 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
85
111
  assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
86
112
  assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
87
113
 
114
+ // Conditional policy boundaries are not current blockers. Plain prose in
115
+ // deferred_gates must not materialize a gate without a concrete action and
116
+ // authority requirement.
117
+ await reconcileProjectGates(root, { queue });
118
+ const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
119
+ const conditionalDir = path.join(taskRuntimeDirFor(root, queue, conditional.task.id), 'checkpoints');
120
+ await mkdir(conditionalDir, { recursive: true });
121
+ await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
122
+ version: 1, task_id: conditional.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
123
+ project_completion: { status: 'in_progress' },
124
+ deferred_gates: ['Any future paid action above the standing limit requires confirmation.']
125
+ }, null, 2)}\n`);
126
+ const conditionalNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
127
+ assert.equal(conditionalNotice.results.some((item) => item.taskId === conditional.task.id), false);
128
+
88
129
  // Once the authoritative project ledger accepts the terminal contract, an
89
130
  // optional post-completion deferred action stays in operations backlog and
90
131
  // neither creates nor retains a project-queue waiting gate.
@@ -104,4 +145,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
104
145
  assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
105
146
  assert.equal((await queueStatus(root, queue)).waiting, 0);
106
147
 
107
- console.log(JSON.stringify({ status: 'ok', assertions: ['structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'accepted project optional deferred gate stays out of queue'] }));
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'] }));
@@ -20,12 +20,25 @@ import {
20
20
  runQueueOnce,
21
21
  runQueueDrain,
22
22
  taskRuntimeDirFor,
23
+ writeDevPlan,
23
24
  writeJson
24
25
  } from '../lib/core.mjs';
25
26
 
26
27
  const root = await mkdtemp(path.join(tmpdir(), 'loop-route-notify-'));
27
28
  const queue = 'route-smoke';
28
29
 
30
+ // Replanning the same durable task must never point a worker at an existing
31
+ // checkpoint file. Historical checkpoint evidence is append-only.
32
+ const checkpointTask = { id: 'checkpoint-sequence' };
33
+ const checkpointContract = { contract: { task_id: checkpointTask.id, risk_level: 'L1', requires_human_gate: false } };
34
+ const checkpointAcceptance = { plan: { functional_checks: [], regression_checks: [], negative_tests: [] } };
35
+ const firstDevPlan = await writeDevPlan(root, queue, checkpointTask, checkpointContract, checkpointAcceptance);
36
+ assert.equal(firstDevPlan.plan.checkpoints[0].id, 'cp1');
37
+ await writeJson(path.join(taskRuntimeDirFor(root, queue, checkpointTask.id), 'checkpoints', 'cp1.json'), { checkpoint_id: 'cp1' });
38
+ const secondDevPlan = await writeDevPlan(root, queue, checkpointTask, checkpointContract, checkpointAcceptance);
39
+ assert.equal(secondDevPlan.plan.checkpoints[0].id, 'cp2');
40
+ assert.equal(secondDevPlan.plan.checkpoint_schema.checkpoint_id, 'cp2');
41
+
29
42
  assert.equal(normalizeGoalDecision({ verdict: 'revise' }).decision, 'change_strategy');
30
43
  assert.deepEqual(goalLoopTransition({ decision: 'change_strategy' }, { round: 1, maxRounds: 3 }), {
31
44
  status: 'replan_pending',
@@ -495,12 +508,27 @@ assert.deepEqual(await readJson(activeRequeued), activeBefore);
495
508
  await rename(activeRequeued, inboxRequeued);
496
509
  await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
497
510
  await rm(inboxRequeued, { force: true });
511
+ await writeJson(path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-terminal.json'), {
512
+ version: 1, task_id: routed.task.id, checkpoint_id: 'cp-terminal', sequence: 99,
513
+ status: 'ready_for_acceptance', summary: 'Terminal summary is visible.', blockers: [],
514
+ verification: [{ command: 'self-test', result: 'passed' }], risks: [], next_action: 'report'
515
+ });
516
+ await writeJson(finalJudgementFile, {
517
+ version: 1, task_id: routed.task.id, outcome: 'ready_to_apply', next_actions: ['Report the accepted result.']
518
+ });
498
519
 
499
520
  const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
500
521
  assert.match(dryRun.results[0].message, /Loop 任务/);
522
+ assert.match(dryRun.results[0].message, /最终判定:/);
523
+ assert.match(dryRun.results[0].message, /结果:/);
501
524
  assert.equal(dryRun.results[0].outcome, 'dry_run');
502
525
  const sent = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
503
526
  assert.equal(sent.sent, 1);
527
+ const notifiedTask = await readJson(path.join(queueSubdirFor(root, queue, 'failed'), path.basename(failedFile)));
528
+ if (notifiedTask.runPath) {
529
+ const notifiedRun = await readJson(path.join(root, notifiedTask.runPath));
530
+ assert.equal(notifiedRun.terminalNotification?.outcome, 'sent');
531
+ }
504
532
  const repeated = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
505
533
  assert.equal(repeated.results[0].outcome, 'already_notified');
506
534
 
@@ -519,7 +547,7 @@ for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['late
519
547
  });
520
548
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
521
549
  version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
522
- status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', required_authority: 'Approve R-1.' }],
550
+ status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', action: 'approve_requirement', required_authority: 'Approve R-1.' }],
523
551
  verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
524
552
  });
525
553
  await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {