taskforce-loop-engineering 0.7.1 → 0.7.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 +6 -0
- package/lib/core.mjs +49 -6
- package/package.json +1 -1
- package/scripts/route-notify-self-test.mjs +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2 - 2026-08-06
|
|
4
|
+
|
|
5
|
+
- Keep `ready_for_human_review` tasks out of `done/` until an explicit human decision is recorded; emit a scoped acceptance notification, fail closed when delivery routing is missing, and transition approved tasks to `completed` only after approval.
|
|
6
|
+
- Forward configured human-gate policy into generated task contracts so queue state and final judgement agree.
|
|
7
|
+
- Add regression coverage for the complete `ready_for_human_review → approve → completed` lifecycle.
|
|
8
|
+
|
|
3
9
|
## 0.7.1 - 2026-08-05
|
|
4
10
|
|
|
5
11
|
- Make the standalone ClawHub skill self-sufficient by documenting the official npm package, GitHub repository, Node.js requirement, license, and installation commands.
|
package/lib/core.mjs
CHANGED
|
@@ -1673,13 +1673,20 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1673
1673
|
}
|
|
1674
1674
|
|
|
1675
1675
|
function terminalNotificationMessage(queue, task) {
|
|
1676
|
-
const
|
|
1676
|
+
const needsReview = task.status === 'ready_for_human_review';
|
|
1677
|
+
const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
|
|
1677
1678
|
return [
|
|
1678
|
-
|
|
1679
|
+
needsReview ? 'Loop task is ready for human acceptance'
|
|
1680
|
+
: needsHuman ? 'Loop task needs human input'
|
|
1681
|
+
: 'Loop task reached a terminal state',
|
|
1679
1682
|
`task: ${task.title}`,
|
|
1680
1683
|
`queue: ${queue}`,
|
|
1681
1684
|
`status: ${task.status}`,
|
|
1682
|
-
...(
|
|
1685
|
+
...(needsReview
|
|
1686
|
+
? [`next: review the final judgement and record approve, request_changes, or reject for task ${task.id}.`]
|
|
1687
|
+
: needsHuman
|
|
1688
|
+
? ['next: inspect the task final judgement and checkpoints, resolve the blocker, then explicitly continue or requeue.']
|
|
1689
|
+
: [])
|
|
1683
1690
|
].join('\n');
|
|
1684
1691
|
}
|
|
1685
1692
|
|
|
@@ -1696,7 +1703,16 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1696
1703
|
const dir = queueSubdirFor(root, queue, subdir);
|
|
1697
1704
|
for (const file of await listJson(dir)) {
|
|
1698
1705
|
const task = await readJson(path.join(dir, file));
|
|
1699
|
-
if (!task?.
|
|
1706
|
+
if (!task?.id || !task?.status) continue;
|
|
1707
|
+
if (!task?.source?.channel || !task?.source?.target) {
|
|
1708
|
+
results.push({
|
|
1709
|
+
taskId: task.id,
|
|
1710
|
+
status: task.status,
|
|
1711
|
+
outcome: 'failed',
|
|
1712
|
+
error: 'missing source.channel/source.target; refusing unscoped terminal delivery'
|
|
1713
|
+
});
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1700
1716
|
const key = `${safeTaskId(task.id)}.${normalizeLoopId(task.status)}.json`;
|
|
1701
1717
|
const ledgerFile = path.join(notificationDir, key);
|
|
1702
1718
|
if (await exists(ledgerFile)) {
|
|
@@ -2792,7 +2808,8 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
|
|
|
2792
2808
|
if (!finalJudgement?.judgement) return currentStatus;
|
|
2793
2809
|
if (currentStatus !== 'completed') return currentStatus;
|
|
2794
2810
|
const outcome = finalJudgement.judgement.outcome;
|
|
2795
|
-
if (outcome === '
|
|
2811
|
+
if (outcome === 'ready_to_apply') return currentStatus;
|
|
2812
|
+
if (outcome === 'ready_for_human_review') return outcome;
|
|
2796
2813
|
return outcome;
|
|
2797
2814
|
}
|
|
2798
2815
|
|
|
@@ -4069,6 +4086,27 @@ export async function queueHumanDecision(root, queue, taskId, options = {}) {
|
|
|
4069
4086
|
};
|
|
4070
4087
|
await writeJson(file, artifact);
|
|
4071
4088
|
|
|
4089
|
+
let transitionedTask = null;
|
|
4090
|
+
if (decision === 'approve' && found.subdir !== 'done') {
|
|
4091
|
+
const completedAt = artifact.created_at;
|
|
4092
|
+
transitionedTask = {
|
|
4093
|
+
...task,
|
|
4094
|
+
status: 'completed',
|
|
4095
|
+
humanApprovedAt: completedAt,
|
|
4096
|
+
humanReviewDecision: path.relative(root, file)
|
|
4097
|
+
};
|
|
4098
|
+
const completedFile = path.join(queueSubdirFor(root, normalized, 'done'), path.basename(found.file));
|
|
4099
|
+
await writeJson(completedFile, transitionedTask);
|
|
4100
|
+
await rm(found.file, { force: true });
|
|
4101
|
+
artifact.effects.queueTransition = {
|
|
4102
|
+
from: found.subdir,
|
|
4103
|
+
to: 'done',
|
|
4104
|
+
status: 'completed',
|
|
4105
|
+
file: path.relative(root, completedFile)
|
|
4106
|
+
};
|
|
4107
|
+
await writeJson(file, artifact);
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4072
4110
|
let revisionRequest = null;
|
|
4073
4111
|
if (decision === 'request_changes') {
|
|
4074
4112
|
const revisionFile = path.join(dir, 'human_revision_request.json');
|
|
@@ -4098,6 +4136,7 @@ export async function queueHumanDecision(root, queue, taskId, options = {}) {
|
|
|
4098
4136
|
decisionFile: path.relative(root, file),
|
|
4099
4137
|
revisionRequestFile: revisionRequest?.path ?? null,
|
|
4100
4138
|
revisionNext,
|
|
4139
|
+
transitionedTask,
|
|
4101
4140
|
artifact
|
|
4102
4141
|
};
|
|
4103
4142
|
}
|
|
@@ -7901,7 +7940,11 @@ export async function runQueueOnce(root, options) {
|
|
|
7901
7940
|
let revisionRequest = null;
|
|
7902
7941
|
|
|
7903
7942
|
try {
|
|
7904
|
-
taskContract = await writeTaskContract(root, queue, task
|
|
7943
|
+
taskContract = await writeTaskContract(root, queue, task, {
|
|
7944
|
+
riskLevel: options.riskLevel,
|
|
7945
|
+
riskReasons: options.riskReasons,
|
|
7946
|
+
requiresHumanGate: options.requiresHumanGate
|
|
7947
|
+
});
|
|
7905
7948
|
progress.emit('planning', 'task_contract', `Wrote task contract (${taskContract.contract.risk_level})`, {
|
|
7906
7949
|
taskId: task.id,
|
|
7907
7950
|
artifact: taskContract.file,
|
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
normalizeGoalDecision,
|
|
11
11
|
notifyHumanInputRequests,
|
|
12
12
|
notifyTerminalTasks,
|
|
13
|
+
queueHumanDecision,
|
|
14
|
+
queueStatus,
|
|
13
15
|
queueSubdirFor,
|
|
14
16
|
readJson,
|
|
15
17
|
routeLoopMessage,
|
|
@@ -76,6 +78,55 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
|
|
|
76
78
|
assert.equal(contract.risk_level, 'model_assessed');
|
|
77
79
|
assert.equal(contract.requires_human_gate, false);
|
|
78
80
|
|
|
81
|
+
const reviewQueue = 'human-review-smoke';
|
|
82
|
+
const reviewTask = await routeLoopMessage(root, {
|
|
83
|
+
route: true,
|
|
84
|
+
confirmExecute: true,
|
|
85
|
+
queue: reviewQueue,
|
|
86
|
+
message: '走 loop 生成需要人工验收的交付物',
|
|
87
|
+
sourceChannel: 'feishu',
|
|
88
|
+
sourceTarget: 'user-1'
|
|
89
|
+
});
|
|
90
|
+
let reviewCheckpointWrite = null;
|
|
91
|
+
const reviewRun = await runQueueOnce(root, {
|
|
92
|
+
queue: reviewQueue,
|
|
93
|
+
dispatcher: '/bin/sleep 0.1',
|
|
94
|
+
requiresHumanGate: true,
|
|
95
|
+
timeoutMs: 10_000,
|
|
96
|
+
leaseMs: 20_000,
|
|
97
|
+
staleActiveMs: 60_000,
|
|
98
|
+
onProgress: (event) => {
|
|
99
|
+
if (event.phase !== 'dispatch' || event.status !== 'running' || reviewCheckpointWrite) return;
|
|
100
|
+
reviewCheckpointWrite = writeJson(path.join(taskRuntimeDirFor(root, reviewQueue, reviewTask.task.id), 'checkpoints', 'cp-review.json'), {
|
|
101
|
+
version: 1,
|
|
102
|
+
task_id: reviewTask.task.id,
|
|
103
|
+
checkpoint_id: 'cp-review',
|
|
104
|
+
status: 'ready_for_acceptance',
|
|
105
|
+
summary: 'The deliverable is complete and explicitly awaits human acceptance.',
|
|
106
|
+
files_changed: [],
|
|
107
|
+
verification: [{ command: '/bin/true', outcome: 'passed' }],
|
|
108
|
+
blockers: [],
|
|
109
|
+
risks: [],
|
|
110
|
+
next_action: 'human_acceptance'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
await reviewCheckpointWrite;
|
|
115
|
+
assert.equal(reviewRun.status, 'ready_for_human_review');
|
|
116
|
+
assert.match(reviewRun.taskPath, /failed/);
|
|
117
|
+
const reviewStatus = await queueStatus(root, reviewQueue);
|
|
118
|
+
assert.equal(reviewStatus.done, 0);
|
|
119
|
+
assert.equal(reviewStatus.failed, 1);
|
|
120
|
+
const reviewNotice = await notifyTerminalTasks(root, { queue: reviewQueue, dryRun: true });
|
|
121
|
+
assert.equal(reviewNotice.results[0].outcome, 'dry_run');
|
|
122
|
+
assert.match(reviewNotice.results[0].message, /ready for human acceptance/);
|
|
123
|
+
assert.match(reviewNotice.results[0].message, /approve, request_changes, or reject/);
|
|
124
|
+
const reviewDecision = await queueHumanDecision(root, reviewQueue, reviewTask.task.id, { decision: 'approve' });
|
|
125
|
+
assert.equal(reviewDecision.transitionedTask.status, 'completed');
|
|
126
|
+
const approvedStatus = await queueStatus(root, reviewQueue);
|
|
127
|
+
assert.equal(approvedStatus.done, 1);
|
|
128
|
+
assert.equal(approvedStatus.failed, 0);
|
|
129
|
+
|
|
79
130
|
for (const suffix of ['second', 'third']) {
|
|
80
131
|
await routeLoopMessage(root, {
|
|
81
132
|
route: true,
|