taskforce-loop-engineering 0.15.2 → 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,19 @@
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
+
12
+ ## 0.15.3 - 2026-08-22
13
+
14
+ - Remove workspace-specific Ironman queue and dispatcher instructions from the distributed skill.
15
+ - Resolve the installed queue and managed wrapper from each OpenClaw workspace, while keeping existing queue data untouched.
16
+ - Add a distribution self-test that rejects host-specific dispatcher leakage in future packages.
17
+
5
18
  ## 0.15.2 - 2026-08-21
6
19
 
7
20
  - Enrich terminal notifications with final judgement, checkpoint summary, verification count, blockers, and next action.
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.2",
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",
@@ -20,7 +20,7 @@
20
20
  "check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
21
21
  "demo:adapter": "node examples/adapter-sdk-demo.mjs",
22
22
  "check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
23
- "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
23
+ "check:config-drift": "node scripts/distribution-skill-self-test.mjs && node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
24
24
  "check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
25
25
  "check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
26
26
  "check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ const skill = await readFile(new URL('../skills/taskforce-loop-engineering/SKILL.md', import.meta.url), 'utf8');
5
+
6
+ for (const forbidden of ['ironman-task-runner', 'ironman-task-runner.mjs']) {
7
+ assert.equal(skill.includes(forbidden), false, `distributed skill contains local dispatcher reference: ${forbidden}`);
8
+ }
9
+
10
+ for (const required of ['agent-tasks', 'scripts/loops/openclaw-loop.mjs', 'configs/loops/queues/']) {
11
+ assert.equal(skill.includes(required), true, `distributed skill is missing generic integration guidance: ${required}`);
12
+ }
13
+
14
+ console.log('distribution skill self-test passed');
@@ -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');
@@ -127,15 +127,17 @@ Interpret explicit loop language as follows:
127
127
  - A new explicit `走 loop` request while another task is active is a correction/replacement, not ordinary backlog. Supersede the active task at a safe boundary, retain its evidence and lineage, then start the replacement after the lock is released.
128
128
  - Status, progress, evidence, or failure questions are read-only and must not start another tick unless the user explicitly asks to continue/run.
129
129
 
130
- In this workspace, the default queue is `ironman-task-runner` when no queue is named. Use the installed workspace wrapper when present:
130
+ Do not infer a queue or dispatcher from this skill. Use the integration installed in the current workspace. For OpenClaw, inspect the generated queue configuration under `configs/loops/queues/` and use the managed wrapper when present. A default installation uses queue `agent-tasks` and the generic wrapper below; an operator may choose another queue or worker during installation.
131
131
 
132
132
  ```bash
133
- node scripts/loops/ironman-task-runner.mjs route --message "<original user message>" --confirm-execute [source options]
134
- node scripts/loops/ironman-task-runner.mjs run-once --notify
135
- node scripts/loops/ironman-task-runner.mjs status --json
136
- node scripts/loops/ironman-task-runner.mjs peek --json
133
+ node scripts/loops/openclaw-loop.mjs route --message "<original user message>" [source options]
134
+ node scripts/loops/openclaw-loop.mjs run-once
135
+ loop-engineering queue-status --queue <installed-queue> --root . --json
136
+ loop-engineering queue-peek --queue <installed-queue> --root . --json
137
137
  ```
138
138
 
139
+ If the managed wrapper or queue configuration is absent, stop and run the platform installer/doctor workflow above. Product names, personal agent names, and host-specific dispatchers belong in local workspace instructions, never in this distributed skill.
140
+
139
141
  Pass the original request faithfully. Preserve source channel, target, account, message id, and reply-to metadata so progress, human gates, and terminal results return to the originating conversation. Missing delivery routing must fail closed.
140
142
 
141
143
  ## Task vs Project Classification