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)
|
|
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
|
+
"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
|
-
|
|
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/
|
|
134
|
-
node scripts/loops/
|
|
135
|
-
|
|
136
|
-
|
|
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
|