taskforce-loop-engineering 0.15.3 → 0.15.4

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,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.4 - 2026-08-22
6
+
7
+ - Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
8
+ - Refuse to create an unroutable revision when a routed source task has lost its required channel or target.
9
+ - Materialize a human-input gate for the newest authoritative project carrier when it finishes blocked, while continuing to suppress historical failed-task replay.
10
+ - Add regression coverage for routed revision inheritance and blocked project gate delivery.
11
+
5
12
  ## 0.15.3 - 2026-08-22
6
13
 
7
14
  - Remove workspace-specific Ironman queue and dispatcher instructions from the distributed skill.
package/lib/core.mjs CHANGED
@@ -1883,6 +1883,25 @@ function taskSourceFromOptions(options = {}) {
1883
1883
  return Object.values(source).some(Boolean) ? source : null;
1884
1884
  }
1885
1885
 
1886
+ function taskSourceOptions(source) {
1887
+ if (!source) return {};
1888
+ return {
1889
+ sourceChannel: source.channel ?? undefined,
1890
+ sourceTarget: source.target ?? undefined,
1891
+ sourceAccount: source.account ?? undefined,
1892
+ sourceMessageId: source.message_id ?? undefined,
1893
+ sourceReplyTo: source.reply_to ?? undefined
1894
+ };
1895
+ }
1896
+
1897
+ function assertRevisionRoutingSource(task) {
1898
+ const explicitlyRouted = String(task?.body ?? '').startsWith('This task was explicitly routed to Loop Engineering from a source conversation.');
1899
+ if (!task?.source && !explicitlyRouted) return;
1900
+ if (!task?.source?.channel || !task?.source?.target) {
1901
+ throw new Error(`Routed revision source task ${task?.id ?? 'unknown'} is missing source.channel/source.target; refusing to create an unroutable revision.`);
1902
+ }
1903
+ }
1904
+
1886
1905
  export function classifyLoopMessage(message) {
1887
1906
  const text = String(message ?? '').trim();
1888
1907
  if (!text) throw new Error('route-message requires --message.');
@@ -2446,7 +2465,12 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2446
2465
  if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
2447
2466
  }
2448
2467
  return candidates.filter((candidate) => {
2449
- if (candidate.projectId) return newestByProject.get(candidate.projectId) === candidate && candidate.entry.subdir !== 'failed';
2468
+ if (candidate.projectId) {
2469
+ const authoritative = newestByProject.get(candidate.projectId) === candidate;
2470
+ const actionableFailed = candidate.entry.subdir === 'failed'
2471
+ && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status);
2472
+ return authoritative && (candidate.entry.subdir !== 'failed' || actionableFailed);
2473
+ }
2450
2474
  return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
2451
2475
  || (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
2452
2476
  });
@@ -5232,6 +5256,7 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
5232
5256
  const found = await findTaskFile(root, normalized, taskId, ['failed', 'done']);
5233
5257
  if (!found) throw new Error(`Task not found for revision: ${taskId}`);
5234
5258
  const task = await readJson(found.file);
5259
+ assertRevisionRoutingSource(task);
5235
5260
  if (!task.runPath) throw new Error(`Task has no runPath for revision: ${task.id}`);
5236
5261
  const run = await readJson(path.join(root, safeRelativePath(task.runPath, 'task runPath')));
5237
5262
  const humanDecision = await latestHumanDecision(root, normalized, task.id);
@@ -5255,6 +5280,8 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
5255
5280
  const plannedTask = {
5256
5281
  title,
5257
5282
  body,
5283
+ source: task.source ?? null,
5284
+ projectId: task.projectId ?? task.project_id ?? null,
5258
5285
  revisionOf: task.id,
5259
5286
  revisionSourceRun: task.runPath,
5260
5287
  revisionRequestPath,
@@ -5288,7 +5315,9 @@ export async function queueRevisionNext(root, queue, taskId, options = {}) {
5288
5315
  const enqueued = await enqueueTask(root, {
5289
5316
  queue: plan.queue,
5290
5317
  title: plan.plannedTask.title,
5291
- task: plan.plannedTask.body
5318
+ task: plan.plannedTask.body,
5319
+ projectId: plan.plannedTask.projectId ?? undefined,
5320
+ ...taskSourceOptions(plan.plannedTask.source)
5292
5321
  });
5293
5322
  const nextTask = {
5294
5323
  ...enqueued.task,
@@ -5340,7 +5369,9 @@ export async function queueRevisionApplyPlan(root, planPath, options = {}) {
5340
5369
  const enqueued = await enqueueTask(root, {
5341
5370
  queue: normalized,
5342
5371
  title: plan.plannedTask.title,
5343
- task: plan.plannedTask.body
5372
+ task: plan.plannedTask.body,
5373
+ projectId: plan.plannedTask.projectId ?? undefined,
5374
+ ...taskSourceOptions(plan.plannedTask.source)
5344
5375
  });
5345
5376
  const guard = plan.revisionPolicyGuard ?? plan.plannedTask.revisionPolicyGuard ?? null;
5346
5377
  const nextTask = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -11,6 +11,7 @@ import {
11
11
  notifyHumanInputRequests,
12
12
  notifyTerminalTasks,
13
13
  queueHumanDecision,
14
+ queueRevisionNext,
14
15
  queueDirFor,
15
16
  queueStatus,
16
17
  queueSubdirFor,
@@ -562,5 +563,96 @@ assert.equal((await readdir(queueSubdirFor(regressionRoot, regressionQueue, 'don
562
563
  const regressionRepeated = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
563
564
  assert.equal(regressionRepeated.sent, 0);
564
565
 
566
+ // The newest authoritative project carrier may itself finish blocked. It must
567
+ // become a durable waiting gate instead of being discarded with historical
568
+ // failed attempts.
569
+ const blockedProjectTask = 'latest-blocked';
570
+ await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'failed'), `${blockedProjectTask}.json`), {
571
+ id: blockedProjectTask,
572
+ title: 'demo latest blocked',
573
+ body: 'demo R-1',
574
+ projectId: 'demo',
575
+ status: 'blocked',
576
+ enqueuedAt: '2026-01-03T00:00:00Z',
577
+ source: { channel: 'feishu', target: 'owner' }
578
+ });
579
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'checkpoints', 'cp2.json'), {
580
+ version: 1,
581
+ task_id: blockedProjectTask,
582
+ checkpoint_id: 'cp2',
583
+ milestone_id: 'R-1',
584
+ sequence: 2,
585
+ status: 'needs_human_input',
586
+ blockers: ['Approve the production activation.'],
587
+ verification: [],
588
+ risks: [],
589
+ project_completion: 'in_progress',
590
+ next_action: 'wait'
591
+ });
592
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'final_judgement.json'), {
593
+ version: 1,
594
+ task_id: blockedProjectTask,
595
+ outcome: 'blocked',
596
+ coverage: { effective_review_ids: ['cp2'] }
597
+ });
598
+ const blockedProjectGate = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
599
+ assert.equal(blockedProjectGate.sent, 1);
600
+ assert.equal((await readJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'waiting'), `${blockedProjectTask}.json`))).status, 'waiting_for_human');
601
+
602
+ // A routed revision must preserve the complete source envelope so a later
603
+ // human gate or terminal result returns to the originating conversation.
604
+ const revisionQueue = 'revision-routing';
605
+ const revisionSource = await routeLoopMessage(root, {
606
+ route: true,
607
+ confirmExecute: true,
608
+ supersedeActive: true,
609
+ queue: revisionQueue,
610
+ message: '走 loop fix revision routing',
611
+ sourceChannel: 'feishu',
612
+ sourceTarget: 'user-revision',
613
+ sourceAccount: 'main',
614
+ sourceMessageId: 'revision-source-message',
615
+ sourceReplyTo: 'revision-source-reply'
616
+ });
617
+ const revisionSourceFile = path.join(queueSubdirFor(root, revisionQueue, 'inbox'), `${revisionSource.task.id}.json`);
618
+ const revisionFailedFile = path.join(queueSubdirFor(root, revisionQueue, 'failed'), `${revisionSource.task.id}.json`);
619
+ const revisionRequestFile = path.join(taskRuntimeDirFor(root, revisionQueue, revisionSource.task.id), 'revision_request.json');
620
+ const revisionRunFile = path.join(queueSubdirFor(root, revisionQueue, 'runs'), `${revisionSource.task.id}.json`);
621
+ await writeJson(revisionRequestFile, {
622
+ version: 1,
623
+ task_id: revisionSource.task.id,
624
+ status: 'requested',
625
+ revision_goals: [{ check: 'routing', required_change: 'Preserve source routing.' }],
626
+ next_checkpoint: { suggested_id: 'cp2' }
627
+ });
628
+ await writeJson(revisionRunFile, {
629
+ version: 2,
630
+ taskId: revisionSource.task.id,
631
+ status: 'failed',
632
+ finalJudgement: { outcome: 'needs_revision' },
633
+ revisionRequest: { path: path.relative(root, revisionRequestFile) }
634
+ });
635
+ await writeJson(revisionFailedFile, {
636
+ ...revisionSource.task,
637
+ status: 'failed',
638
+ projectId: 'demo-project',
639
+ runPath: path.relative(root, revisionRunFile)
640
+ });
641
+ await rm(revisionSourceFile, { force: true });
642
+ const revision = await queueRevisionNext(root, revisionQueue, revisionSource.task.id, {
643
+ force: true,
644
+ strategy: 'Preserve the source envelope and verify blocked notification delivery.'
645
+ });
646
+ assert.deepEqual(revision.nextTask.source, revisionSource.task.source);
647
+ assert.equal(revision.nextTask.projectId, 'demo-project');
648
+
649
+ const unroutable = { ...await readJson(revisionFailedFile) };
650
+ delete unroutable.source;
651
+ await writeJson(revisionFailedFile, unroutable);
652
+ await assert.rejects(
653
+ queueRevisionNext(root, revisionQueue, revisionSource.task.id, { force: true }),
654
+ /refusing to create an unroutable revision/
655
+ );
656
+
565
657
  console.log('route/notify self-test passed');
566
658
  await import('./config-drift-self-test.mjs');