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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/loop-engineering.mjs +15 -2
  3. package/docs/agent-team-backlog.json +1 -0
  4. package/docs/agent-team-terminal-contract.json +1 -0
  5. package/docs/human-gate-command.md +17 -0
  6. package/docs/multi-agent-control-plane.md +8 -0
  7. package/docs/quota-runtime-decision.md +9 -0
  8. package/lib/core.mjs +74 -10
  9. package/lib/human-gate-channel-adapter.mjs +37 -0
  10. package/lib/human-gate-command.mjs +161 -0
  11. package/lib/operator-dashboard.mjs +24 -3
  12. package/lib/quota-runtime-decision.mjs +62 -0
  13. package/lib/todo-control-plane.mjs +105 -6
  14. package/package.json +19 -6
  15. package/scripts/agent-team-control-plane-self-test.mjs +29 -0
  16. package/scripts/agent-team-final-judgement.mjs +27 -0
  17. package/scripts/distribution-skill-self-test.mjs +13 -3
  18. package/scripts/final-judgement-self-test.mjs +25 -0
  19. package/scripts/human-gate-command-self-test.mjs +52 -0
  20. package/scripts/human-gate-final-judgement.mjs +30 -0
  21. package/scripts/live-agent-team-conformance.mjs +61 -0
  22. package/scripts/openclaw-doctor.mjs +8 -0
  23. package/scripts/openclaw-install-self-test.mjs +4 -2
  24. package/scripts/openclaw-install.mjs +38 -3
  25. package/scripts/openclaw-smoke.mjs +4 -0
  26. package/scripts/operator-dashboard-self-test.mjs +2 -1
  27. package/scripts/project-gate-reconciliation-self-test.mjs +27 -12
  28. package/scripts/quota-runtime-decision-self-test.mjs +29 -0
  29. package/scripts/route-notify-self-test.mjs +4 -4
  30. package/scripts/todo-control-plane-self-test.mjs +4 -1
  31. package/skills/taskforce-loop-engineering/SKILL.md +8 -0
  32. 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.
@@ -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 (command === 'agent-register' || command.startsWith('todo-')) return todoControlPlaneCommand(command, args);
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', message, source: entry.task.source });
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
- if (typeof value === 'string') return value;
3702
- if (value && typeof value === 'object') return value.status ?? null;
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
- export function selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews) {
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
- return [...allReviews].sort(compareReviewRecency).slice(-1);
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 html(dataUrl = '/api/v1/overview?') {
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 (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()); }
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`);