taskforce-loop-engineering 0.15.13 → 0.15.15
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 +14 -0
- package/bin/loop-engineering.mjs +15 -2
- package/docs/agent-team-backlog.json +1 -0
- package/docs/agent-team-terminal-contract.json +1 -0
- package/docs/human-gate-command.md +17 -0
- package/docs/multi-agent-control-plane.md +8 -0
- package/docs/quota-runtime-decision.md +9 -0
- package/lib/core.mjs +74 -10
- package/lib/human-gate-channel-adapter.mjs +37 -0
- package/lib/human-gate-command.mjs +161 -0
- package/lib/operator-dashboard.mjs +24 -3
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +19 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/final-judgement-self-test.mjs +25 -0
- package/scripts/human-gate-command-self-test.mjs +52 -0
- package/scripts/human-gate-final-judgement.mjs +30 -0
- package/scripts/live-agent-team-conformance.mjs +61 -0
- package/scripts/openclaw-doctor.mjs +8 -0
- package/scripts/openclaw-install-self-test.mjs +4 -2
- package/scripts/openclaw-install.mjs +38 -3
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +2 -1
- package/scripts/project-gate-reconciliation-self-test.mjs +27 -12
- package/scripts/quota-runtime-decision-self-test.mjs +29 -0
- package/scripts/route-notify-self-test.mjs +4 -4
- package/scripts/todo-control-plane-self-test.mjs +4 -1
- package/skills/taskforce-loop-engineering/SKILL.md +8 -0
- package/skills/taskforce-loop-engineering/references/npm-package.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.15 - 2026-08-31
|
|
6
|
+
|
|
7
|
+
- Lock a task-level accepted terminal checkpoint monotonically so later broader-project governance cannot reopen or overwrite the completed task.
|
|
8
|
+
- Require fully materialized, source/actor/generation-bound Human Gate artifacts before moving tasks to waiting or sending actionable notifications.
|
|
9
|
+
- Fail closed for incomplete or non-authoritative Gate artifacts, and keep dry-run previews free of actionable reply commands.
|
|
10
|
+
- Add regressions for broader-project scope creep after task acceptance, empty/unbound gates, authoritative waiting placement, and notification consistency.
|
|
11
|
+
|
|
12
|
+
## 0.15.14 - 2026-08-30
|
|
13
|
+
|
|
14
|
+
- Add one authoritative cross-interface Human Gate command core for Dashboard and trusted chat adapters, with strict source binding, generation fencing, idempotency receipts, confirmation escalation, and fail-closed handling of ordinary chat.
|
|
15
|
+
- Upgrade quota checks into a runtime decision engine covering token, time, cost, and turn budgets, verified-only spend, idle-free accounting, scheduler hints, and audited safe fallback work.
|
|
16
|
+
- Productize the durable multi-agent control plane with agent registration, explainable matching, targeted wakeups, peer collaboration, fenced handoffs, conflict/orphan governance, and cross-runtime conformance evidence.
|
|
17
|
+
- Extend the operator workspace, OpenClaw installer, doctor, smoke, and bundled skill to expose and verify the new gate, quota, team, and project-control capabilities.
|
|
18
|
+
|
|
5
19
|
## 0.15.13 - 2026-08-28
|
|
6
20
|
|
|
7
21
|
- Add the project-first read-only operator workspace with terminal contracts, milestone/project status separation, revision lineage, human gates, external-action reservations, and acceptance/final-judge timelines.
|
package/bin/loop-engineering.mjs
CHANGED
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
settleAction
|
|
82
82
|
} from '../lib/action-reservations.mjs';
|
|
83
83
|
import {
|
|
84
|
+
acknowledgeWake,
|
|
84
85
|
claimTodo,
|
|
85
86
|
createTodo,
|
|
86
87
|
decideHandoff,
|
|
@@ -88,10 +89,15 @@ import {
|
|
|
88
89
|
importLegacyTodos,
|
|
89
90
|
inspectTodo,
|
|
90
91
|
listTodos,
|
|
92
|
+
matchTodo,
|
|
91
93
|
recoverTodos,
|
|
92
94
|
registerAgent,
|
|
95
|
+
resolveOwnershipConflict,
|
|
93
96
|
releaseTodo,
|
|
94
|
-
renewTodo
|
|
97
|
+
renewTodo,
|
|
98
|
+
sendPeerMessage,
|
|
99
|
+
teamWorkbench,
|
|
100
|
+
wakeAgent
|
|
95
101
|
} from '../lib/todo-control-plane.mjs';
|
|
96
102
|
import {
|
|
97
103
|
buildOperatorProjection,
|
|
@@ -146,6 +152,7 @@ function parseArgs(argv) {
|
|
|
146
152
|
else if (a === '--handoff-id') args.handoffId = argv[++i];
|
|
147
153
|
else if (a === '--todo-json') args.todoJson = argv[++i];
|
|
148
154
|
else if (a === '--agent-json') args.agentJson = argv[++i];
|
|
155
|
+
else if (a === '--payload-json') args.payloadJson = argv[++i];
|
|
149
156
|
else if (a === '--state') args.todoState = argv[++i];
|
|
150
157
|
else if (a === '--run-id') args.runId = argv[++i];
|
|
151
158
|
else if (a === '--output') args.output = argv[++i];
|
|
@@ -5653,7 +5660,7 @@ async function main() {
|
|
|
5653
5660
|
if (command === 'summarize') return summarizeCommand(args);
|
|
5654
5661
|
if (command === 'doctor') return doctorCommand(args);
|
|
5655
5662
|
if (command.startsWith('dashboard-')) return dashboardCommand(command, args);
|
|
5656
|
-
if (
|
|
5663
|
+
if (['agent-register', 'agent-wake', 'agent-wake-ack', 'peer-message', 'team-workbench'].includes(command) || command.startsWith('todo-')) return todoControlPlaneCommand(command, args);
|
|
5657
5664
|
if (command.startsWith('action-')) return actionReservationCommand(command, args);
|
|
5658
5665
|
if (command === 'repair-plan') return repairPlanCommand(args);
|
|
5659
5666
|
if (command === 'project-intake') return projectIntakeCommand(args);
|
|
@@ -5767,6 +5774,12 @@ async function todoControlPlaneCommand(command, args) {
|
|
|
5767
5774
|
else if (command === 'todo-reject') result = await decideHandoff(args.root, { ...args, accept: false });
|
|
5768
5775
|
else if (command === 'todo-recover') result = await recoverTodos(args.root, args);
|
|
5769
5776
|
else if (command === 'todo-import-legacy') result = await importLegacyTodos(args.root, args);
|
|
5777
|
+
else if (command === 'todo-match') result = await matchTodo(args.root, args);
|
|
5778
|
+
else if (command === 'agent-wake') result = await wakeAgent(args.root, await jsonInput(args.payloadJson, '--payload-json'));
|
|
5779
|
+
else if (command === 'agent-wake-ack') result = await acknowledgeWake(args.root, await jsonInput(args.payloadJson, '--payload-json'));
|
|
5780
|
+
else if (command === 'peer-message') result = await sendPeerMessage(args.root, await jsonInput(args.payloadJson, '--payload-json'));
|
|
5781
|
+
else if (command === 'todo-conflict-resolve') result = await resolveOwnershipConflict(args.root, await jsonInput(args.payloadJson, '--payload-json'));
|
|
5782
|
+
else if (command === 'team-workbench') result = await teamWorkbench(args.root, args);
|
|
5770
5783
|
else throw new Error(`Unknown command: ${command}`);
|
|
5771
5784
|
console.log(JSON.stringify(result, null, 2));
|
|
5772
5785
|
return result === null ? 1 : 0;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"project":"agent-team-control-plane","items":[{"id":"AT-1","status":"done","depends_on":[]},{"id":"AT-2","status":"done","depends_on":["AT-1"]},{"id":"AT-3","status":"done","depends_on":["AT-1","AT-2"]},{"id":"AT-4","status":"done","depends_on":["AT-1"]},{"id":"AT-5","status":"done","depends_on":["AT-2","AT-4"]},{"id":"AT-6","status":"done","depends_on":["AT-1","AT-3","AT-4","AT-5"]},{"id":"AT-7","status":"done","depends_on":["AT-6"],"evidence":"live-runtime-conformance.json"},{"id":"AT-8","status":"done","depends_on":["AT-7"],"evidence":"npm test, clean package smoke, agent-team-final-judgement.json"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"project":"agent-team-control-plane","status":"complete","terminal_user_visible_outcome":"A fresh Loop Engineering workspace can register heterogeneous agents and operate them as one durable team through the existing todo control plane.","constraints":["extend runtime/loops/control-plane; do not create a second orchestrator","all ownership mutations remain fenced and audited","external effects remain capability and authorization gated"],"requirements":[{"id":"AT-1","title":"product agent registry","status":"done","evidence":"lib/todo-control-plane.mjs"},{"id":"AT-2","title":"explainable capability budget dependency load matching","status":"done","evidence":"matchTodo and agent-team-control-plane-self-test.mjs"},{"id":"AT-3","title":"durable targeted wake","status":"done","evidence":"wakeAgent/acknowledgeWake"},{"id":"AT-4","title":"role-independent peer collaboration and durable handoff","status":"done","evidence":"sendPeerMessage plus fenced handoff"},{"id":"AT-5","title":"conflict and orphan governance","status":"done","evidence":"resolveOwnershipConflict/recoverTodos/teamWorkbench"},{"id":"AT-6","title":"team workbench and CLI","status":"done","evidence":"teamWorkbench and CLI commands"},{"id":"AT-7","title":"Codex OpenClaw Claude cross-runtime conformance","status":"done","evidence":"runtime-adapter-conformance.mjs, agent-team-control-plane-self-test.mjs and live-runtime-conformance.json"},{"id":"AT-8","title":"full regression packaged install final judgement","status":"done","evidence":"npm test, clean npm pack/install/export smoke and agent-team-final-judgement.json"}],"acceptance":["all requirements done","targeted and full tests pass","clean package install passes","live runtime probes use actual installed executables","P0/P1/P2 safety remains intact"],"milestone_rule":"A milestone or simulated runtime test is not project completion.","completion_rule":"Every requirement and check must pass with durable evidence and no unmet blocker."}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Cross-interface Human Gate command core
|
|
2
|
+
|
|
3
|
+
`lib/human-gate-command.mjs` is the sole mutation boundary for governed Human Gates. Dashboard POST actions and channel callbacks must call `executeGateCommand`; projections and adapters must not write gate files directly.
|
|
4
|
+
|
|
5
|
+
Each gate records project, task, Gate ID, action, reason, impact, risk, cost/budget, evidence, Dashboard URL, expiry, generation, allowed actors and one or more exact source bindings. Commands require `gate_id`, `expected_generation`, actor identity, channel/message binding, reply binding for reply commands, and an idempotency key. Successful commands produce durable receipts. Per-gate exclusive creation plus the generation fence makes only the first valid cross-interface command commit.
|
|
6
|
+
|
|
7
|
+
Allowed mutations are card-button callbacks and replies matching `/approve gate_<id>`, `/reject gate_<id>`, or `/request_revision gate_<id> <reason>` where the reply is bound to the registered card. Ordinary language, ordinal references, quotes, forwards and screenshots are ignored or rejected.
|
|
8
|
+
|
|
9
|
+
High/critical risk, production, external publication, irreversible work, or cost at/above the configured threshold enters `awaiting_confirmation` and increments generation. A second command against the new generation is required. `request_revision` writes a revision artifact, increments generation, and invalidates old cards. Clients refresh from `/api/v1/gates`; processed cards render disabled.
|
|
10
|
+
|
|
11
|
+
The Feishu adapter is deliberately transport-neutral. The HTTP/plugin transport must verify the official Feishu callback signature or encrypted-event envelope before normalization, then call `normalizeFeishuGateEvent(payload, { signatureVerified: true })`. Missing or failed verification throws `feishu_signature_unverified`; the adapter does not accept a raw callback on trust. Secret lookup, timestamp/nonce freshness and cryptographic verification belong to the transport boundary and must never be inferred from payload fields. The adapter performs no network I/O.
|
|
12
|
+
|
|
13
|
+
Delivery/update code should render `gateCard`, send it through the platform transport, register the returned message ID as a source binding, and refresh or disable the card after every durable receipt. Ordinary chat is fail-closed: only an exact card button, an exact command reply bound to the registered card, or the display-only `/show_gate gate_<id>` route is recognized. Natural language, quotes, forwards and screenshots never call the command core.
|
|
14
|
+
|
|
15
|
+
OpenClaw installation creates `scripts/loops/openclaw-loop-gate.mjs`, a stdin/stdout Gate Command bridge. A trusted callback/plugin handler verifies the Feishu envelope first and invokes the bridge with `LOOP_GATE_CHANNEL=feishu` and `LOOP_FEISHU_SIGNATURE_VERIFIED=1`; other channels must supply an already normalized event. The bridge calls the same `executeGateCommand` path used by Dashboard. Dashboard and chat therefore share generation fencing, actor/source binding, receipts, idempotency and synchronized-card state; neither interface owns separate approval state.
|
|
16
|
+
|
|
17
|
+
After installation run `loop-engineering-openclaw-doctor`, then `loop-engineering-openclaw-smoke`; both remain local/dry-run and verify the installed Gate Command bridge. Run `npm run check:human-gates` for unit, adapter, Dashboard HTTP integration, concurrency, replay, expiry, authorization, signature-boundary and misrecognition coverage. Tests use temporary local artifacts and make no external calls.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Multi-Agent Control Plane (P2)
|
|
2
2
|
|
|
3
|
+
The product team layer extends this same control-plane state—never a second orchestrator—with runtime-aware registry records, capacity/load-aware explainable matching, targeted wake events, role-independent peer messages, conflict records, and a unified workbench.
|
|
4
|
+
|
|
3
5
|
P2 stores typed todos, registered agents, leases, handoffs, and ownership history in `runtime/loops/control-plane/state.json`. Every mutation is serialized by an atomic filesystem mutex and committed with rename. `audit.jsonl` records each ownership transition.
|
|
4
6
|
|
|
5
7
|
## Todo contract
|
|
@@ -26,6 +28,12 @@ todo-handoff --todo-id ID --agent-id AGENT --target-agent-id AGENT --fencing-tok
|
|
|
26
28
|
todo-accept|todo-reject --handoff-id ID --agent-id AGENT
|
|
27
29
|
todo-recover [--now EPOCH_MS]
|
|
28
30
|
todo-import-legacy
|
|
31
|
+
todo-match [--todo-id ID]
|
|
32
|
+
agent-wake --payload-json JSON
|
|
33
|
+
agent-wake-ack --payload-json JSON
|
|
34
|
+
peer-message --payload-json JSON
|
|
35
|
+
todo-conflict-resolve --payload-json JSON
|
|
36
|
+
team-workbench
|
|
29
37
|
```
|
|
30
38
|
|
|
31
39
|
`--todo-json` and `--agent-json` accept either an inline JSON object or a file path. All commands accept `--root` and emit JSON.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Quota runtime decision engine
|
|
2
|
+
|
|
3
|
+
Quota is a runtime policy decision, not only a static credit check. Import `decideQuota` from `taskforce-loop-engineering/quota` and provide `limits`, already-recorded `spend`, the next slice `request`, work/error/external state, and optional lanes.
|
|
4
|
+
|
|
5
|
+
The result always chooses one of `execute`, `wait`, `ask`, `self-repair`, or `silent` and includes a scheduler hint. Budget vectors use `tokens`, `time_ms`, `money_minor`, and `rounds`.
|
|
6
|
+
|
|
7
|
+
`recordVerifiedSliceSpend` records spend only when a slice is both `completed` and `verified`. Idle, waiting, failed, or partially completed attempts record nothing. `slice_id` makes recording idempotent.
|
|
8
|
+
|
|
9
|
+
A lane waiting for a human may fall back only to a different lane marked both `safe_fallback: true` and `audited: true`; otherwise the decision is `ask`.
|
package/lib/core.mjs
CHANGED
|
@@ -2453,6 +2453,33 @@ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en', blo
|
|
|
2453
2453
|
].join('\n');
|
|
2454
2454
|
}
|
|
2455
2455
|
|
|
2456
|
+
function authoritativeGateBindings(task) {
|
|
2457
|
+
const source = task?.source;
|
|
2458
|
+
const generation = Number(task?.runtimeSessionGeneration ?? 0);
|
|
2459
|
+
if (!source?.channel || !source?.target || !source?.account || !Number.isInteger(generation) || generation < 0) return null;
|
|
2460
|
+
return {
|
|
2461
|
+
source_binding: {
|
|
2462
|
+
channel: source.channel,
|
|
2463
|
+
target: source.target,
|
|
2464
|
+
account: source.account,
|
|
2465
|
+
message_id: source.messageId ?? source.message_id ?? null
|
|
2466
|
+
},
|
|
2467
|
+
actor_binding: { kind: 'source_target', actor_id: source.target, account: source.account },
|
|
2468
|
+
generation
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function authoritativeGateReady(gate, task) {
|
|
2473
|
+
const expected = authoritativeGateBindings(task);
|
|
2474
|
+
if (!expected || !gate || gate.status !== 'waiting_for_human') return false;
|
|
2475
|
+
return gate.source_binding?.channel === expected.source_binding.channel
|
|
2476
|
+
&& gate.source_binding?.target === expected.source_binding.target
|
|
2477
|
+
&& gate.source_binding?.account === expected.source_binding.account
|
|
2478
|
+
&& gate.actor_binding?.actor_id === expected.actor_binding.actor_id
|
|
2479
|
+
&& gate.actor_binding?.account === expected.actor_binding.account
|
|
2480
|
+
&& gate.generation === expected.generation;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2456
2483
|
function blockerCoveredByContractAuthorization(blocker, contract) {
|
|
2457
2484
|
if (!blocker || typeof blocker !== 'object' || Array.isArray(blocker)) return false;
|
|
2458
2485
|
const state = String(blocker.authorization_state ?? blocker.state ?? '')
|
|
@@ -2660,8 +2687,17 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2660
2687
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
2661
2688
|
const gateId = `${taskId}:${checkpointId}`;
|
|
2662
2689
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
2690
|
+
const bindings = authoritativeGateBindings(entry.task);
|
|
2691
|
+
if (!bindings) {
|
|
2692
|
+
results.push({ taskId, checkpointId, gateId, outcome: 'fail_closed', error: 'incomplete_source_actor_generation_binding' });
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2663
2695
|
if (await exists(ledgerFile)) {
|
|
2664
2696
|
const gate = await readJson(ledgerFile);
|
|
2697
|
+
if (!authoritativeGateReady(gate, entry.task)) {
|
|
2698
|
+
results.push({ taskId, checkpointId, gateId, outcome: 'fail_closed', error: 'non_authoritative_gate_artifact', ledger: path.relative(root, ledgerFile) });
|
|
2699
|
+
continue;
|
|
2700
|
+
}
|
|
2665
2701
|
if (gate.status === 'waiting_for_human' && ['inbox', 'done', 'failed'].includes(entry.subdir)) {
|
|
2666
2702
|
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
2667
2703
|
if (await exists(sourceFile)) {
|
|
@@ -2693,7 +2729,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2693
2729
|
id: gate?.id ?? `deferred-${index + 1}`, action: gate?.action ?? gate?.kind ?? null,
|
|
2694
2730
|
required_authority: gate?.required_authority ?? gate?.human_action_required ?? gate?.reason ?? gate?.description ?? String(gate),
|
|
2695
2731
|
scope: gate?.scope ?? null
|
|
2696
|
-
})), source: entry.task.source, requested_at: now,
|
|
2732
|
+
})), source: entry.task.source, ...bindings, requested_at: now,
|
|
2697
2733
|
notification_record: { status: 'pending', attempts: 0, idempotency_key: gateId }
|
|
2698
2734
|
});
|
|
2699
2735
|
if (['inbox', 'done', 'failed'].includes(entry.subdir)) {
|
|
@@ -2711,11 +2747,16 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2711
2747
|
}
|
|
2712
2748
|
}
|
|
2713
2749
|
}
|
|
2714
|
-
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language, blockers);
|
|
2715
2750
|
if (options.dryRun) {
|
|
2716
|
-
results.push({ taskId, checkpointId, gateId, outcome: 'dry_run',
|
|
2751
|
+
results.push({ taskId, checkpointId, gateId, outcome: 'dry_run', preview: 'authoritative_gate_would_be_materialized', source: entry.task.source });
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
const materializedGate = await readJson(ledgerFile);
|
|
2755
|
+
if (!authoritativeGateReady(materializedGate, entry.task)) {
|
|
2756
|
+
results.push({ taskId, checkpointId, gateId, outcome: 'fail_closed', error: 'gate_materialization_validation_failed' });
|
|
2717
2757
|
continue;
|
|
2718
2758
|
}
|
|
2759
|
+
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language, blockers);
|
|
2719
2760
|
const result = await runCommand(`${options.notifyCommand} ${shellQuote(message)}`, {
|
|
2720
2761
|
cwd: root,
|
|
2721
2762
|
timeoutMs: options.timeoutMs ?? 60_000,
|
|
@@ -2873,7 +2914,19 @@ async function prepareHumanInputContext(root, queue, taskId) {
|
|
|
2873
2914
|
for (const file of await listJson(gatesDir)) {
|
|
2874
2915
|
const full = path.join(gatesDir, file);
|
|
2875
2916
|
const gate = await readJson(full);
|
|
2876
|
-
if (gate.task_id !== taskId || !['resolved', 'consumed'].includes(gate.status)) continue;
|
|
2917
|
+
if (gate.task_id !== taskId || !['resolved', 'consumed', 'waiting_for_human'].includes(gate.status)) continue;
|
|
2918
|
+
if (gate.status === 'waiting_for_human') {
|
|
2919
|
+
gates.push({
|
|
2920
|
+
gate_id: gate.gate_id,
|
|
2921
|
+
checkpoint_id: gate.checkpoint_id,
|
|
2922
|
+
status: gate.status,
|
|
2923
|
+
source_binding: gate.source_binding ?? null,
|
|
2924
|
+
actor_binding: gate.actor_binding ?? null,
|
|
2925
|
+
generation: gate.generation ?? null,
|
|
2926
|
+
authoritative: true
|
|
2927
|
+
});
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2877
2930
|
const consumedAt = gate.consumed_at ?? new Date().toISOString();
|
|
2878
2931
|
const consumed = gate.status === 'resolved' ? { ...gate, status: 'consumed', consumed_at: consumedAt } : gate;
|
|
2879
2932
|
if (gate.status === 'resolved') await writeJson(full, consumed);
|
|
@@ -3698,9 +3751,8 @@ function aggregateCriticStatus(baseStatus, criticReviews) {
|
|
|
3698
3751
|
}
|
|
3699
3752
|
|
|
3700
3753
|
function projectCompletionStatus(value) {
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
return null;
|
|
3754
|
+
const raw = typeof value === 'string' ? value : value && typeof value === 'object' ? value.status : null;
|
|
3755
|
+
return ['accepted', 'complete', 'completed'].includes(raw) ? 'accepted' : raw;
|
|
3704
3756
|
}
|
|
3705
3757
|
|
|
3706
3758
|
function continuationNextAction(value) {
|
|
@@ -3880,11 +3932,22 @@ function compareReviewRecency(a, b) {
|
|
|
3880
3932
|
return compareReviewSequence(a, b);
|
|
3881
3933
|
}
|
|
3882
3934
|
|
|
3883
|
-
|
|
3935
|
+
function terminalTaskReview(review, contract = {}) {
|
|
3936
|
+
if (review?.status !== 'accepted') return false;
|
|
3937
|
+
return projectCompletionStatus(review.projectCompletion) === 'accepted'
|
|
3938
|
+
|| review.taskTerminal === true;
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
export function selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews, contract = {}) {
|
|
3884
3942
|
const allReviews = acceptanceReviews?.reviews ?? [];
|
|
3885
3943
|
const planned = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints : [];
|
|
3886
3944
|
if (planned.length <= 1) {
|
|
3887
|
-
|
|
3945
|
+
const ordered = [...allReviews].sort(compareReviewRecency);
|
|
3946
|
+
// A task-level terminal acceptance is monotonic. Later checkpoints may
|
|
3947
|
+
// report broader project governance, but they cannot reopen or overwrite
|
|
3948
|
+
// the completed task. Broader work belongs to the project ledger/gates.
|
|
3949
|
+
const terminal = ordered.find((review) => terminalTaskReview(review, contract));
|
|
3950
|
+
return terminal ? [terminal] : ordered.slice(-1);
|
|
3888
3951
|
}
|
|
3889
3952
|
const selected = [];
|
|
3890
3953
|
for (const checkpoint of planned) {
|
|
@@ -3907,7 +3970,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
3907
3970
|
// Checkpoints produced after the planned set are revision/progress snapshots,
|
|
3908
3971
|
// not additional required milestones. Judge the latest complete set so a
|
|
3909
3972
|
// resolved historical blocker does not permanently poison the task.
|
|
3910
|
-
const effectiveReviews = selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews);
|
|
3973
|
+
const effectiveReviews = selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews, contract);
|
|
3911
3974
|
const reviewCount = effectiveReviews.length;
|
|
3912
3975
|
const acceptedCount = effectiveReviews.filter((item) => item.status === 'accepted').length;
|
|
3913
3976
|
const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
|
|
@@ -4011,6 +4074,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
4011
4074
|
reviews: reviewCount,
|
|
4012
4075
|
historical_reviews: allReviews.length,
|
|
4013
4076
|
effective_review_ids: effectiveReviews.map((review) => review.checkpointId),
|
|
4077
|
+
terminal_lock: effectiveReviews.some((review) => terminalTaskReview(review, contract)),
|
|
4014
4078
|
accepted: acceptedCount,
|
|
4015
4079
|
revise: reviseCount,
|
|
4016
4080
|
blocked: blockedCount,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { executeGateCommand, gateCard, getHumanGate, parseBoundReply } from './human-gate-command.mjs';
|
|
2
|
+
|
|
3
|
+
export async function renderGateForChannel(root, gateId) { return gateCard(await getHumanGate(root, gateId)); }
|
|
4
|
+
|
|
5
|
+
export async function handleChannelGateEvent(root, event, options = {}) {
|
|
6
|
+
if (event.kind === 'card_button') {
|
|
7
|
+
const receipt = await executeGateCommand(root, {
|
|
8
|
+
gate_id: event.action?.gate_id, decision: event.action?.decision,
|
|
9
|
+
expected_generation: event.action?.expected_generation, actor_id: event.actor_id,
|
|
10
|
+
source_channel: event.channel, source_message_id: event.message_id, reply_to: event.reply_to ?? null,
|
|
11
|
+
event_type: 'card_button', idempotency_key: event.event_id, reason: event.action?.reason
|
|
12
|
+
}, options);
|
|
13
|
+
return { ...receipt, synchronized_card: await renderGateForChannel(root, event.action?.gate_id) };
|
|
14
|
+
}
|
|
15
|
+
if (event.kind === 'message_reply' && event.reply_to) {
|
|
16
|
+
const parsed = parseBoundReply(event.text); if (!parsed) return { outcome: 'ignored_untrusted_chat' };
|
|
17
|
+
const receipt = await executeGateCommand(root, {
|
|
18
|
+
...parsed, expected_generation: event.expected_generation, actor_id: event.actor_id,
|
|
19
|
+
source_channel: event.channel, source_message_id: event.card_message_id,
|
|
20
|
+
reply_to: event.reply_to, event_type: 'bound_reply', idempotency_key: event.event_id
|
|
21
|
+
}, options);
|
|
22
|
+
return { ...receipt, synchronized_card: await renderGateForChannel(root, parsed.gate_id) };
|
|
23
|
+
}
|
|
24
|
+
const show = String(event.text ?? '').trim().match(/^\/show_gate\s+(gate_[a-zA-Z0-9._:-]+)$/);
|
|
25
|
+
if (show) return { outcome: 'display_only', card: await renderGateForChannel(root, show[1]) };
|
|
26
|
+
return { outcome: 'ignored_untrusted_chat' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Feishu callback feasibility mapping. It performs no network I/O.
|
|
30
|
+
export function normalizeFeishuGateEvent(payload, options = {}) {
|
|
31
|
+
if (options.signatureVerified !== true) throw new Error('feishu_signature_unverified');
|
|
32
|
+
if (payload?.event?.action?.value?.gate_id) return { kind: 'card_button', event_id: payload.header?.event_id, actor_id: payload.event.operator?.operator_id?.open_id, channel: 'feishu', message_id: payload.event.context?.open_message_id, action: payload.event.action.value };
|
|
33
|
+
const message = payload?.event?.message;
|
|
34
|
+
let text = message?.content;
|
|
35
|
+
try { const parsed = JSON.parse(text); text = typeof parsed?.text === 'string' ? parsed.text : ''; } catch {}
|
|
36
|
+
return { kind: message?.parent_id ? 'message_reply' : 'ordinary_message', event_id: payload.header?.event_id, actor_id: payload.event?.sender?.sender_id?.open_id, channel: 'feishu', card_message_id: message?.root_id, reply_to: message?.parent_id, text, expected_generation: payload.expected_generation };
|
|
37
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DECISIONS = new Set(['approve', 'reject', 'request_revision']);
|
|
6
|
+
const SOURCES = new Set(['card_button', 'bound_reply']);
|
|
7
|
+
|
|
8
|
+
function required(value, name) {
|
|
9
|
+
if (value === undefined || value === null || value === '') throw new Error(`missing_${name}`);
|
|
10
|
+
return String(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safe(value, name) {
|
|
14
|
+
const text = required(value, name);
|
|
15
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(text)) throw new Error(`invalid_${name}`);
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function digest(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
20
|
+
function gateDir(root) { return path.join(root, 'runtime', 'loops', 'human-gates'); }
|
|
21
|
+
function gateFile(root, gateId) { return path.join(gateDir(root), 'gates', `${safe(gateId, 'gate_id')}.json`); }
|
|
22
|
+
function receiptFile(root, key) { return path.join(gateDir(root), 'receipts', `${digest(key)}.json`); }
|
|
23
|
+
|
|
24
|
+
async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); }
|
|
25
|
+
async function atomicJson(file, value) {
|
|
26
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
28
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
29
|
+
await rename(temporary, file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function lock(root, gateId, callback) {
|
|
33
|
+
const lockFile = path.join(gateDir(root), 'locks', `${safe(gateId, 'gate_id')}.lock`);
|
|
34
|
+
await mkdir(path.dirname(lockFile), { recursive: true });
|
|
35
|
+
let handle;
|
|
36
|
+
try { handle = await open(lockFile, 'wx', 0o600); }
|
|
37
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('gate_conflict_retry'); throw error; }
|
|
38
|
+
try { return await callback(); }
|
|
39
|
+
finally { await handle.close(); await rm(lockFile, { force: true }); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function publicGate(gate) {
|
|
43
|
+
return {
|
|
44
|
+
gate_id: gate.gate_id, project: gate.project, task: gate.task, action: gate.action,
|
|
45
|
+
reason: gate.reason, impact: gate.impact, risk: gate.risk, cost: gate.cost,
|
|
46
|
+
evidence: gate.evidence, dashboard_url: gate.dashboard_url, expiry: gate.expiry,
|
|
47
|
+
generation: gate.generation, status: gate.status, decisions: [...DECISIONS],
|
|
48
|
+
confirmation_required: gate.confirmation_required,
|
|
49
|
+
source_binding: gate.source_binding, source_bindings: gate.source_bindings,
|
|
50
|
+
processed: gate.processed ?? null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createHumanGate(root, input, options = {}) {
|
|
55
|
+
const gateId = safe(input.gate_id ?? `gate_${randomUUID()}`, 'gate_id');
|
|
56
|
+
const now = options.now ?? new Date().toISOString();
|
|
57
|
+
const risk = input.risk ?? { level: 'low', reasons: [] };
|
|
58
|
+
const highRisk = input.confirmation_required ?? (
|
|
59
|
+
['high', 'critical'].includes(String(risk.level).toLowerCase()) ||
|
|
60
|
+
Boolean(input.production || input.external_publish || input.irreversible || Number(input.cost?.amount ?? 0) >= Number(input.cost?.confirmation_threshold ?? Infinity))
|
|
61
|
+
);
|
|
62
|
+
const bindingsInput = input.source_bindings ?? [input.source_binding];
|
|
63
|
+
const sourceBindings = bindingsInput.map((binding) => ({
|
|
64
|
+
channel: required(binding?.channel, 'source_channel'), message_id: required(binding?.message_id, 'source_message_id'),
|
|
65
|
+
reply_to: binding?.reply_to ? String(binding.reply_to) : String(binding?.message_id), adapter: binding?.adapter ?? null
|
|
66
|
+
}));
|
|
67
|
+
const gate = {
|
|
68
|
+
version: 1, gate_id: gateId, project: required(input.project, 'project'), task: required(input.task, 'task'),
|
|
69
|
+
action: required(input.action, 'action'), reason: required(input.reason, 'reason'), impact: required(input.impact, 'impact'),
|
|
70
|
+
risk, cost: input.cost ?? { amount: 0, currency: 'CNY', budget: null }, evidence: input.evidence ?? [],
|
|
71
|
+
dashboard_url: input.dashboard_url ?? null, expiry: required(input.expiry, 'expiry'), generation: Number(input.generation ?? 1),
|
|
72
|
+
status: 'pending', confirmation_required: highRisk, confirmation: null,
|
|
73
|
+
allowed_actors: (input.allowed_actors ?? []).map(String),
|
|
74
|
+
source_binding: sourceBindings[0], source_bindings: sourceBindings,
|
|
75
|
+
revision_history: [], created_at: now, updated_at: now
|
|
76
|
+
};
|
|
77
|
+
if (!Number.isInteger(gate.generation) || gate.generation < 1 || Number.isNaN(Date.parse(gate.expiry))) throw new Error('invalid_gate_generation_or_expiry');
|
|
78
|
+
const file = gateFile(root, gateId);
|
|
79
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
80
|
+
try { const handle = await open(file, 'wx', 0o600); await handle.writeFile(`${JSON.stringify(gate, null, 2)}\n`); await handle.close(); }
|
|
81
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('gate_already_exists'); throw error; }
|
|
82
|
+
return publicGate(gate);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getHumanGate(root, gateId) { return publicGate(await readJson(gateFile(root, gateId))); }
|
|
86
|
+
|
|
87
|
+
function validateBinding(gate, command, now) {
|
|
88
|
+
if (!DECISIONS.has(command.decision)) throw new Error('invalid_decision');
|
|
89
|
+
if (!SOURCES.has(command.event_type)) throw new Error('untrusted_event_type');
|
|
90
|
+
if (Number(command.expected_generation) !== gate.generation) throw new Error('stale_generation');
|
|
91
|
+
if (gate.status !== 'pending' && gate.status !== 'awaiting_confirmation') throw new Error('gate_already_processed');
|
|
92
|
+
if (Date.parse(gate.expiry) <= Date.parse(now)) throw new Error('gate_expired');
|
|
93
|
+
const actor = required(command.actor_id, 'actor_id');
|
|
94
|
+
if (gate.allowed_actors.length && !gate.allowed_actors.includes(actor)) throw new Error('actor_unauthorized');
|
|
95
|
+
const channel = required(command.source_channel, 'source_channel'); const messageId = required(command.source_message_id, 'source_message_id');
|
|
96
|
+
const binding = (gate.source_bindings ?? [gate.source_binding]).find((item) => item.channel === channel && item.message_id === messageId);
|
|
97
|
+
if (!binding) throw new Error((gate.source_bindings ?? [gate.source_binding]).some((item) => item.channel === channel) ? 'source_message_mismatch' : 'source_channel_mismatch');
|
|
98
|
+
if (command.event_type === 'bound_reply' && required(command.reply_to, 'reply_to') !== binding.reply_to) throw new Error('reply_binding_mismatch');
|
|
99
|
+
if (command.event_type === 'bound_reply' && required(command.gate_id, 'gate_id') !== gate.gate_id) throw new Error('reply_gate_id_required');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function executeGateCommand(root, command, options = {}) {
|
|
103
|
+
const gateId = safe(command.gate_id, 'gate_id');
|
|
104
|
+
const idempotencyKey = required(command.idempotency_key, 'idempotency_key');
|
|
105
|
+
const priorFile = receiptFile(root, idempotencyKey);
|
|
106
|
+
try { const prior = await readJson(priorFile); if (prior.command_fingerprint !== digest(command)) throw new Error('idempotency_key_reused'); return { ...prior, replayed: true }; }
|
|
107
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
108
|
+
return lock(root, gateId, async () => {
|
|
109
|
+
try { const prior = await readJson(priorFile); if (prior.command_fingerprint !== digest(command)) throw new Error('idempotency_key_reused'); return { ...prior, replayed: true }; }
|
|
110
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
111
|
+
const file = gateFile(root, gateId); const gate = await readJson(file); const now = options.now ?? new Date().toISOString();
|
|
112
|
+
validateBinding(gate, command, now);
|
|
113
|
+
const before = gate.generation; let outcome;
|
|
114
|
+
if (command.decision === 'approve' && gate.confirmation_required && gate.status !== 'awaiting_confirmation') {
|
|
115
|
+
gate.status = 'awaiting_confirmation'; gate.generation += 1;
|
|
116
|
+
gate.confirmation = { first_actor_id: command.actor_id, first_receipt_at: now };
|
|
117
|
+
outcome = 'confirmation_required';
|
|
118
|
+
} else if (command.decision === 'request_revision') {
|
|
119
|
+
gate.revision_history.push({ generation: gate.generation, reason: required(command.reason, 'revision_reason'), actor_id: command.actor_id, at: now });
|
|
120
|
+
gate.generation += 1; gate.status = 'pending'; gate.reason = command.reason;
|
|
121
|
+
gate.processed = { decision: 'request_revision', actor_id: command.actor_id, at: now, superseded_generation: before };
|
|
122
|
+
outcome = 'revision_created';
|
|
123
|
+
} else {
|
|
124
|
+
gate.status = command.decision === 'approve' ? 'approved' : 'rejected';
|
|
125
|
+
gate.processed = { decision: command.decision, actor_id: command.actor_id, at: now, generation: before };
|
|
126
|
+
outcome = gate.status;
|
|
127
|
+
}
|
|
128
|
+
gate.updated_at = now;
|
|
129
|
+
const receipt = {
|
|
130
|
+
version: 1, receipt_id: `receipt_${randomUUID()}`, gate_id: gateId, decision: command.decision, outcome,
|
|
131
|
+
actor_id: command.actor_id, event_type: command.event_type, source_channel: command.source_channel,
|
|
132
|
+
source_message_id: command.source_message_id, reply_to: command.reply_to ?? null,
|
|
133
|
+
expected_generation: Number(command.expected_generation), resulting_generation: gate.generation,
|
|
134
|
+
idempotency_key: idempotencyKey, command_fingerprint: digest(command), created_at: now, replayed: false
|
|
135
|
+
};
|
|
136
|
+
await atomicJson(file, gate);
|
|
137
|
+
if (outcome === 'revision_created') await atomicJson(path.join(gateDir(root), 'revisions', `${gateId}.generation-${gate.generation}.json`), {
|
|
138
|
+
version: 1, gate_id: gateId, amendment_type: 'request_revision', supersedes_generation: before,
|
|
139
|
+
generation: gate.generation, reason: command.reason, actor_id: command.actor_id, source_receipt_id: receipt.receipt_id, created_at: now
|
|
140
|
+
});
|
|
141
|
+
await atomicJson(priorFile, receipt);
|
|
142
|
+
return receipt;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseBoundReply(text) {
|
|
147
|
+
const match = String(text ?? '').trim().match(/^\/(approve|reject|request_revision)\s+(gate_[a-zA-Z0-9._:-]+)(?:\s+(.+))?$/);
|
|
148
|
+
if (!match) return null;
|
|
149
|
+
return { decision: match[1], gate_id: match[2], reason: match[3] ?? null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function gateCard(gate) {
|
|
153
|
+
const g = publicGate(gate);
|
|
154
|
+
return { type: 'human_gate_card', title: `${g.project} · Human Gate`, fields: g, buttons: [...DECISIONS].map((decision) => ({ decision, gate_id: g.gate_id, expected_generation: g.generation, disabled: !['pending', 'awaiting_confirmation'].includes(g.status) })) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function listHumanGates(root) {
|
|
158
|
+
const dir = path.join(gateDir(root), 'gates');
|
|
159
|
+
const names = (await import('node:fs/promises')).readdir(dir).catch(() => []);
|
|
160
|
+
return Promise.all((await names).filter((name) => name.endsWith('.json')).sort().map(async (name) => publicGate(await readJson(path.join(dir, name)))));
|
|
161
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { executeGateCommand, listHumanGates } from './human-gate-command.mjs';
|
|
4
5
|
|
|
5
6
|
export const DASHBOARD_SCHEMA_VERSION = '1.0.0';
|
|
6
7
|
const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key|provider)(_|$)/i;
|
|
@@ -217,10 +218,22 @@ export function dashboardHealth(projection, options = {}) {
|
|
|
217
218
|
return { schema_version: DASHBOARD_SCHEMA_VERSION, status: projection.health.status === 'ok' && !stale ? 'ok' : 'degraded', read_only: true, stale, freshness_seconds: projection.source.freshness_seconds, warnings: projection.health.warnings };
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
function
|
|
221
|
+
function legacyHtml(dataUrl = '/api/v1/overview?') {
|
|
221
222
|
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Loop Engineering Workspace</title><style>:root{color-scheme:dark;--bg:#091018;--panel:#111c28;--line:#26384b;--muted:#91a5b8;--accent:#77d6c9}*{box-sizing:border-box}body{font:14px/1.5 ui-sans-serif,system-ui;margin:0;background:var(--bg);color:#eef6fb}header{position:sticky;top:0;z-index:2;padding:1rem clamp(1rem,4vw,3rem);background:#091018ee;border-bottom:1px solid var(--line);backdrop-filter:blur(12px)}h1,h2,h3,p{margin:.2rem 0}.eyebrow,.muted{color:var(--muted)}main{padding:1.25rem clamp(1rem,4vw,3rem);display:grid;gap:1rem}.stats,.projects,.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.8rem}.card{background:linear-gradient(150deg,#142230,#0d1822);border:1px solid var(--line);border-radius:14px;padding:1rem;min-width:0}.stat strong{font-size:1.65rem}.bar{height:7px;background:#223141;border-radius:9px;overflow:hidden;margin:.75rem 0}.bar i{display:block;height:100%;background:var(--accent)}.pill{display:inline-block;padding:.15rem .5rem;border-radius:1rem;background:#24384a;color:#d9edf8}.ok{color:#7ee2a8}.warn{color:#ffcf70}button,input,select{padding:.65rem;background:#111d29;color:inherit;border:1px solid #3a5268;border-radius:8px}button.project{width:100%;text-align:left;font:inherit}button.project[aria-pressed=true]{border-color:var(--accent)}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.6rem;border-bottom:1px solid var(--line);vertical-align:top}.scroll{overflow:auto}.timeline{border-left:2px solid var(--line);padding-left:1rem}.event{margin:.65rem 0}.project{cursor:pointer}.project:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}#detail:empty{display:none}.empty{padding:1rem;text-align:center;color:var(--muted)}@media(max-width:640px){header{position:static}.desktop{display:none}td,th{min-width:120px}.split{grid-template-columns:1fr}input,select{width:100%;margin-top:.4rem}}</style></head><body><header><div class="eyebrow">READ-ONLY OPERATOR WORKSPACE</div><h1>Loop Engineering</h1><p id="health" role="status" aria-live="polite">Loading durable artifacts…</p></header><main><section class="stats" id="stats" aria-label="Workspace summary"></section><section aria-labelledby="projects-title"><h2 id="projects-title">Projects</h2><p class="muted">Project completion is separate from milestone completion.</p><div class="projects" id="projects"></div></section><section class="card" id="detail" tabindex="-1" aria-live="polite"></section><section class="card"><div class="split"><div><h2>Operational queue</h2><p class="muted">Human gates, reservations and reconciliation remain visible and immutable here.</p></div><div><label>Search <input id="q" placeholder="Task, owner or action"></label><label>State <select id="s"><option value="">All states</option></select></label></div></div><div class="scroll"><table><thead><tr><th>State</th><th>Task</th><th class="desktop">Owner</th><th>Next action</th></tr></thead><tbody id="rows"></tbody></table></div></section></main><script>const dataUrl=${JSON.stringify(dataUrl)},initial=new URLSearchParams(location.search);const states=['runnable','active','parked','waiting_for_human','waiting_for_external_condition','timed_out_or_escalated','reconciliation_required','blocked','completed','failed'];s.innerHTML+=states.map(x=>'<option>'+x+'</option>').join('');q.value=initial.get('q')??'';s.value=initial.get('state')??'';let selected=initial.get('project'),model=null;const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text??'';if(cls)n.className=cls;return n};function syncUrl(){if(dataUrl.startsWith('./'))return;const p=new URLSearchParams();if(q.value)p.set('q',q.value);if(s.value)p.set('state',s.value);if(selected)p.set('project',selected);history.replaceState(null,'','?'+p)}function projectDetail(p,focus=false){selected=p.id;detail.replaceChildren();detail.append(el('h2',p.id),el('p',p.goal,'muted'));const c=p.terminal_contract;if(c){detail.append(el('h3','Terminal contract'),el('p',c.terminal_state?.userVisibleOutcome??'No user-visible outcome recorded'),el('p',c.milestone_rule??'','warn'))}detail.append(el('h3','Milestones'));const list=el('div');for(const m of p.milestones??[])list.append(el('p',(m.status??'pending')+' · '+m.title));detail.append(list,el('h3','Gates & reservations'));const operational=el('div');const gates=(p.tasks??[]).flatMap(x=>x.gates??[]);operational.append(el('p',(gates.length?gates.length+' human/external gate(s)':'No project gates')+' · '+model.actions.length+' workspace reservation(s)','muted'));detail.append(operational,el('h3','Acceptance & final judge timeline'));const tl=el('div',null,'timeline');const events=(p.tasks??[]).flatMap(x=>x.timeline??[]);if(!events.length)tl.append(el('p','No acceptance events yet.','empty'));for(const t of events)tl.append(el('div',(t.at??'undated')+' · '+t.type+' · '+t.status+' — '+(t.summary??''),'event'));detail.append(tl);syncUrl();drawProjects();if(focus)detail.focus()}function drawProjects(){projects.replaceChildren(...model.projects.map(p=>{const n=el('button',null,'card project');n.type='button';n.setAttribute('aria-pressed',String(selected===p.id));const done=p.project_summary?.completed_milestones??0,total=p.project_summary?.total_milestones??0;n.append(el('span',p.terminal_accepted?'PROJECT ACCEPTED':String(p.status).toUpperCase(),p.terminal_accepted?'pill ok':'pill'),el('h3',p.id),el('p',p.goal,'muted'));const b=el('div',null,'bar'),i=el('i');i.style.width=(total?done/total*100:0)+'%';b.append(i);n.append(b,el('p',done+' / '+total+' milestones'));n.onclick=()=>projectDetail(p,true);return n}));if(!model.projects.length)projects.append(el('p','No project artifacts found.','card empty'))}async function draw(){try{const params=new URLSearchParams({q:q.value,state:s.value});const response=await fetch(dataUrl+(dataUrl.includes('?')?params:''));if(!response.ok)throw new Error('HTTP '+response.status);model=await response.json();health.textContent=model.health.status+' · generated '+new Date(model.generated_at).toLocaleString()+' · source is read-only';stats.replaceChildren(...[['Projects',model.overview.project_count],['Task workspaces',model.overview.task_workspace_count],['Human gates',model.gates.length],['Reservations',model.overview.action_count]].map(([k,v])=>{const n=el('div',null,'card stat');n.append(el('div',k,'muted'),el('strong',v));return n}));drawProjects();const all=[...model.todos,...model.queues.flatMap(x=>x.tasks)];rows.replaceChildren(...all.map(x=>{const tr=el('tr');for(const [v,c] of [[x.state,''],[x.title,''],[x.owner??'—','desktop'],[x.next_action??'—','']])tr.append(el('td',String(v),c));return tr}));if(!all.length){const td=el('td','No matching operational work.','empty');td.colSpan=4;const tr=el('tr');tr.append(td);rows.append(tr)}const chosen=model.projects.find(p=>p.id===selected);if(chosen)projectDetail(chosen)}catch(error){health.textContent='Unable to load workspace · '+error.message;health.className='warn'}}q.oninput=()=>{syncUrl();draw()};s.onchange=()=>{syncUrl();draw()};addEventListener('popstate',()=>location.reload());draw()</script></body></html>`;
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
function gateHtml(dataUrl = '/api/v1/overview?') {
|
|
226
|
+
if (dataUrl.startsWith('./')) return legacyHtml(dataUrl);
|
|
227
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Loop Engineering Human Gates</title><style>:root{color-scheme:dark}body{font:14px system-ui;max-width:1100px;margin:auto;padding:24px;background:#091018;color:#eef6fb}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}.card{background:#111c28;border:1px solid #30465b;border-radius:14px;padding:16px}.meta{color:#9db0c2;white-space:pre-wrap}button,input{padding:9px;margin:4px;border-radius:8px;border:1px solid #4c657b;background:#162737;color:inherit}button:disabled{opacity:.45}.approve{border-color:#55bd82}.reject{border-color:#db6e74}.revision{border-color:#d8ad55}</style></head><body><h1>Loop Engineering</h1><p id="health">Loading authoritative artifacts…</p><label>Verified actor identity <input id="actor" autocomplete="username" placeholder="actor id"></label><h2>Human Gates</h2><div id="gates" class="grid"></div><h2>Workspace</h2><div id="workspace" class="grid"></div><script>const node=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};async function decide(g,d){const binding=(g.source_bindings||[]).find(x=>x.channel==='dashboard');if(!binding)return alert('This gate has no registered Dashboard binding.');if(!actor.value)return alert('Actor identity is required.');const reason=d==='request_revision'?prompt('Revision reason (required)'):null;if(d==='request_revision'&&!reason)return;const body={gate_id:g.gate_id,decision:d,expected_generation:g.generation,actor_id:actor.value,source_message_id:binding.message_id,reply_to:binding.reply_to,idempotency_key:crypto.randomUUID(),reason};const r=await fetch('/api/v1/gate-commands',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const out=await r.json();if(!r.ok)return alert(out.message||out.error);alert(out.outcome+(out.outcome==='confirmation_required'?' — confirm again on generation '+out.resulting_generation:''));await draw()}async function draw(){const [overview,gs]=await Promise.all([fetch('/api/v1/overview').then(r=>r.json()),fetch('/api/v1/gates').then(r=>r.json())]);health.textContent=overview.health.status+' · Loop artifacts remain the only source of truth';workspace.replaceChildren(...[['Projects',overview.overview.project_count],['Tasks',overview.overview.task_workspace_count],['Reservations',overview.overview.action_count]].map(([k,v])=>{const n=node('div','', 'card');n.append(node('h3',k),node('strong',String(v)));return n}));gates.replaceChildren(...gs.map(g=>{const n=node('article','', 'card'),active=['pending','awaiting_confirmation'].includes(g.status);n.append(node('h3',g.project+' · '+g.task),node('p','Gate ID: '+g.gate_id+' · generation '+g.generation+' · '+g.status,'meta'),node('p','Action: '+g.action+'\nReason: '+g.reason+'\nImpact: '+g.impact+'\nRisk: '+JSON.stringify(g.risk)+'\nCost/budget: '+JSON.stringify(g.cost)+'\nEvidence: '+JSON.stringify(g.evidence)+'\nDashboard: '+(g.dashboard_url||location.href)+'\nExpiry: '+g.expiry,'meta'));for(const d of ['approve','reject','request_revision']){const b=node('button',d,d==='request_revision'?'revision':d);b.disabled=!active;b.onclick=()=>decide(g,d);n.append(b)}return n}));if(!gs.length)gates.append(node('p','No Human Gates.','card'))}draw()</script></body></html>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function html(dataUrl = '/api/v1/overview?') {
|
|
231
|
+
if (dataUrl.startsWith('./')) return legacyHtml(dataUrl);
|
|
232
|
+
const panel = `<section aria-labelledby="human-gates-title"><h2 id="human-gates-title">Human Gates</h2><p class="muted">Only exact card actions or card-bound commands can decide a gate.</p><label>Verified actor identity <input id="gateactor" autocomplete="username" placeholder="actor id"></label><div class="projects" id="gatecards"></div></section>`;
|
|
233
|
+
const behavior = `<script>async function gateDecision(g,d){const binding=(g.source_bindings||[]).find(x=>x.channel==='dashboard');if(!binding)return alert('No registered Dashboard binding.');if(!gateactor.value)return alert('Actor identity is required.');const reason=d==='request_revision'?prompt('Revision reason (required)'):null;if(d==='request_revision'&&!reason)return;const r=await fetch('/api/v1/gate-commands',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gate_id:g.gate_id,decision:d,expected_generation:g.generation,actor_id:gateactor.value,source_message_id:binding.message_id,reply_to:binding.reply_to,idempotency_key:crypto.randomUUID(),reason})}),out=await r.json();if(!r.ok)return alert(out.message||out.error);await drawHumanGates()}async function drawHumanGates(){const gs=await fetch('/api/v1/gates').then(r=>r.json());gatecards.replaceChildren(...gs.map(g=>{const n=el('article',null,'card'),active=['pending','awaiting_confirmation'].includes(g.status);n.append(el('h3',g.project+' · '+g.task),el('p','Gate ID: '+g.gate_id+' · generation '+g.generation+' · '+g.status,'muted'),el('p','Action: '+g.action+' | Reason: '+g.reason+' | Impact: '+g.impact+' | Risk: '+JSON.stringify(g.risk)+' | Cost/budget: '+JSON.stringify(g.cost)+' | Evidence: '+JSON.stringify(g.evidence)+' | Dashboard: '+(g.dashboard_url||location.href)+' | Expiry: '+g.expiry,'muted'));for(const d of ['approve','reject','request_revision']){const b=el('button',d);b.type='button';b.setAttribute('aria-pressed','false');b.disabled=!active;b.onclick=()=>gateDecision(g,d);n.append(b)}return n}));if(!gs.length)gatecards.append(el('p','No pending gates.','card empty'))}drawHumanGates()</script>`;
|
|
234
|
+
return legacyHtml(dataUrl).replace('READ-ONLY OPERATOR WORKSPACE', 'GOVERNED OPERATOR WORKSPACE').replace('</main>', `${panel}</main>`).replace('</body>', `${behavior}</body>`);
|
|
235
|
+
}
|
|
236
|
+
|
|
224
237
|
function loopback(host) { return host === '127.0.0.1' || host === '::1' || host === 'localhost'; }
|
|
225
238
|
|
|
226
239
|
export async function createDashboardServer(root, options = {}) {
|
|
@@ -229,9 +242,16 @@ export async function createDashboardServer(root, options = {}) {
|
|
|
229
242
|
const server = createServer(async (request, response) => {
|
|
230
243
|
try {
|
|
231
244
|
const url = new URL(request.url, 'http://localhost');
|
|
232
|
-
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD' }); return response.end(); }
|
|
233
245
|
if (url.pathname.includes('..') || /%2e/i.test(request.url)) { response.writeHead(400); return response.end('unsafe path'); }
|
|
234
|
-
if (
|
|
246
|
+
if (request.method === 'POST' && url.pathname === '/api/v1/gate-commands') {
|
|
247
|
+
let raw = ''; for await (const chunk of request) { raw += chunk; if (raw.length > 65536) throw new Error('request_too_large'); }
|
|
248
|
+
const command = JSON.parse(raw || '{}');
|
|
249
|
+
const receipt = await executeGateCommand(root, { ...command, event_type: 'card_button', source_channel: 'dashboard' });
|
|
250
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
251
|
+
return response.end(`${JSON.stringify(receipt)}\n`);
|
|
252
|
+
}
|
|
253
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405, { Allow: 'GET, HEAD, POST' }); return response.end(); }
|
|
254
|
+
if (url.pathname === '/') { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'", 'x-content-type-options': 'nosniff' }); return response.end(request.method === 'HEAD' ? '' : html().replace('<p id="health">', '<p id="health" role="status" aria-live="polite">')); }
|
|
235
255
|
const projection = await buildOperatorProjection(root);
|
|
236
256
|
let body;
|
|
237
257
|
if (url.pathname === '/api/v1/overview') body = filterProjection(projection, { query: url.searchParams.get('q'), state: url.searchParams.get('state') });
|
|
@@ -240,6 +260,7 @@ export async function createDashboardServer(root, options = {}) {
|
|
|
240
260
|
else if (url.pathname.startsWith('/api/v1/todos/')) body = projection.todos.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/todos/'.length)))) ?? null;
|
|
241
261
|
else if (url.pathname === '/api/v1/actions') body = projection.actions;
|
|
242
262
|
else if (url.pathname === '/api/v1/projects') body = projection.projects;
|
|
263
|
+
else if (url.pathname === '/api/v1/gates') body = await listHumanGates(root);
|
|
243
264
|
else if (url.pathname.startsWith('/api/v1/projects/')) body = projection.projects.find((item) => item.id === safeId(decodeURIComponent(url.pathname.slice('/api/v1/projects/'.length)))) ?? null;
|
|
244
265
|
else { response.writeHead(404); return response.end('not found'); }
|
|
245
266
|
response.writeHead(body === null ? 404 : 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); response.end(request.method === 'HEAD' ? '' : `${JSON.stringify(body)}\n`);
|