taskforce-loop-engineering 0.15.12 → 0.15.14

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 (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/bin/loop-engineering.mjs +15 -2
  4. package/docs/agent-team-backlog.json +1 -0
  5. package/docs/agent-team-terminal-contract.json +1 -0
  6. package/docs/human-gate-command.md +17 -0
  7. package/docs/multi-agent-control-plane.md +8 -0
  8. package/docs/operator-dashboard.md +25 -2
  9. package/docs/operator-workspace-project.md +30 -0
  10. package/docs/quota-runtime-decision.md +9 -0
  11. package/lib/human-gate-channel-adapter.mjs +37 -0
  12. package/lib/human-gate-command.mjs +161 -0
  13. package/lib/operator-dashboard.mjs +75 -8
  14. package/lib/quota-runtime-decision.mjs +62 -0
  15. package/lib/todo-control-plane.mjs +105 -6
  16. package/package.json +21 -6
  17. package/scripts/agent-team-control-plane-self-test.mjs +29 -0
  18. package/scripts/agent-team-final-judgement.mjs +27 -0
  19. package/scripts/dashboard-autostart-install.mjs +91 -0
  20. package/scripts/dashboard-autostart-self-test.mjs +46 -0
  21. package/scripts/distribution-skill-self-test.mjs +13 -3
  22. package/scripts/hermes-doctor.mjs +2 -1
  23. package/scripts/hermes-install-self-test.mjs +2 -1
  24. package/scripts/hermes-install.mjs +11 -4
  25. package/scripts/human-gate-command-self-test.mjs +52 -0
  26. package/scripts/human-gate-final-judgement.mjs +30 -0
  27. package/scripts/live-agent-team-conformance.mjs +61 -0
  28. package/scripts/openclaw-doctor.mjs +13 -0
  29. package/scripts/openclaw-install-self-test.mjs +8 -3
  30. package/scripts/openclaw-install.mjs +48 -6
  31. package/scripts/openclaw-smoke.mjs +4 -0
  32. package/scripts/operator-dashboard-self-test.mjs +27 -1
  33. package/scripts/operator-workspace-final-judgement.mjs +41 -0
  34. package/scripts/quota-runtime-decision-self-test.mjs +29 -0
  35. package/scripts/todo-control-plane-self-test.mjs +4 -1
  36. package/skills/taskforce-loop-engineering/SKILL.md +12 -0
  37. package/templates/operator-projection.schema.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.14 - 2026-08-30
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - Extend the operator workspace, OpenClaw installer, doctor, smoke, and bundled skill to expose and verify the new gate, quota, team, and project-control capabilities.
11
+
12
+ ## 0.15.13 - 2026-08-28
13
+
14
+ - 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.
15
+ - Couple the Dashboard service to OpenClaw and Hermes gateway startup and verify the wiring in platform doctors and installer self-tests.
16
+ - Support fail-closed `localhost` and Tailnet-only Dashboard listening modes without binding all network interfaces.
17
+
5
18
  ## 0.15.12 - 2026-08-28
6
19
 
7
20
  - Fix public CI setup on repositories that intentionally do not commit an npm lockfile.
package/README.md CHANGED
@@ -39,7 +39,7 @@ when publication is separately approved.
39
39
 
40
40
  ## Read-only operator dashboard (P3)
41
41
 
42
- Version 0.12 adds a dependency-free operator projection over projects, queues, P0 gates, P1 action reservations and P2 typed todo ownership. Use `dashboard-inspect`, `dashboard-health`, `dashboard-export`, or the loopback-only `dashboard-serve`. See [docs/operator-dashboard.md](docs/operator-dashboard.md) for API, security and schema details.
42
+ Version 0.12 adds a dependency-free operator projection over projects, queues, P0 gates, P1 action reservations and P2 typed todo ownership. Use `dashboard-inspect`, `dashboard-health`, `dashboard-export`, or `dashboard-serve`. Gateway autostart installation supports local-only and Tailnet-only listening without binding all interfaces. See [docs/operator-dashboard.md](docs/operator-dashboard.md) for API, security and schema details.
43
43
 
44
44
  The consolidated P0-P3 local release contract and evidence ledger are recorded in [docs/release-0.12-acceptance.md](docs/release-0.12-acceptance.md). Publishing, tagging, pushing, and production installation remain separate release actions.
45
45
 
@@ -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.
@@ -12,9 +12,32 @@ loop-engineering dashboard-export --output-dir /tmp/loop-dashboard --root /path/
12
12
  loop-engineering dashboard-serve --root /path/to/workspace
13
13
  ```
14
14
 
15
- `dashboard-serve` binds to `127.0.0.1` and an ephemeral port by default. A wider bind is rejected unless `--allow-non-loopback` is explicit. The server has no authentication and is intended for trusted local use. The CLI never starts it during checks or installation.
15
+ `dashboard-serve` binds to `127.0.0.1` and an ephemeral port by default. A wider bind is rejected unless `--allow-non-loopback` is explicit. The server has no authentication and is intended for trusted local use.
16
16
 
17
- Endpoints are `GET /api/v1/overview`, `/api/v1/health`, `/api/v1/todos`, `/api/v1/todos/:id`, and `/api/v1/actions`. Overview and todo lists support `q` and `state` filters. Private raw files are not served.
17
+ The OpenClaw and Hermes integration installers also install a Dashboard service on port `4174` and systemd gateway drop-ins. Starting `openclaw-gateway.service` or `hermes-gateway.service` therefore starts the read-only Dashboard. The default `localhost` mode binds only `127.0.0.1`. Optional `tailscale` mode resolves `tailscale ip -4` at install time and binds only that Tailnet address; it never binds `0.0.0.0`. Tailnet access is governed by Tailscale ACLs/Grants, while the Dashboard itself remains read-only and has no application-level login.
18
+
19
+ ```bash
20
+ loop-engineering-dashboard-autostart-install --root /path/to/workspace
21
+ loop-engineering-dashboard-autostart-install --root /path/to/workspace --confirm-install
22
+ loop-engineering-dashboard-autostart-install --root /path/to/workspace --listen tailscale --confirm-install
23
+ ```
24
+
25
+ To make the workspace follow either the OpenClaw or Hermes user gateway lifecycle, install the loopback-only systemd integration:
26
+
27
+ ```sh
28
+ loop-engineering-dashboard-autostart-install --root /path/to/workspace
29
+ loop-engineering-dashboard-autostart-install --root /path/to/workspace --confirm-install
30
+ ```
31
+
32
+ This creates `loop-engineering-dashboard.service` on fixed port `4174` and systemd drop-ins for `openclaw-gateway.service` and `hermes-gateway.service`. Starting either gateway pulls in the same idempotent dashboard service. Use `--listen localhost` (the default) for local-only access or `--listen tailscale` for Tailnet access. The Tailscale mode fails closed if it cannot resolve exactly one `100.x` IPv4 address. Restrict port `4174` with Tailnet ACLs/Grants when the Tailnet contains users or devices that should not see project metadata.
33
+
34
+ The platform installers expose the same choice as `--dashboard-listen localhost|tailscale` and accept `--tailscale-bin` when the CLI is outside `PATH`.
35
+
36
+ Endpoints are `GET /api/v1/overview`, `/api/v1/health`, `/api/v1/projects`, `/api/v1/projects/:id`, `/api/v1/todos`, `/api/v1/todos/:id`, and `/api/v1/actions`. Overview and todo lists support `q` and `state` filters. Private raw files are not served.
37
+
38
+ The browser workspace leads with projects rather than queue rows. A project card keeps milestone progress separate from terminal acceptance; project detail exposes the terminal outcome/rules, milestone acceptance, checkpoint revision lineage, human gates, reservations, acceptance reviews, and the independent final-judge event. Task workspaces are linked to a project only by explicit `checkpoint.project_id`, so the projection does not guess ownership from names. Unlinked tasks remain available in `task_workspaces` and the operational queue.
39
+
40
+ The interface is responsive down to a narrow phone viewport, keyboard-operable for project selection, dependency-free, and safe for static export. It remains deliberately read-only: there are no approve, retry, settle, resume, or mutation controls.
18
41
 
19
42
  ## Projection and security rules
20
43
 
@@ -0,0 +1,30 @@
1
+ # Operator workspace project contract
2
+
3
+ ## Terminal outcome
4
+
5
+ An operator can use a local browser workspace every day to understand a Loop Engineering project's terminal contract, milestone progress and total-project status; trace checkpoint revisions, human gates, external-action reservations, acceptance reviews, and the independent final judge; and do so without granting the UI mutation authority or exposing raw/private artifacts.
6
+
7
+ No single page, milestone, checkpoint, or passing test subset completes this project. Completion requires every milestone below, the release checks, and an independent final judgement to pass together.
8
+
9
+ ## Milestones
10
+
11
+ 1. **Project projection and information architecture** — project-first projection, terminal/milestone separation, task linkage, revision and acceptance timeline, project detail API, responsive read-only workspace, security regression coverage, and operator documentation. Status: implemented and independently accepted.
12
+ 2. **Daily interaction experience** — persistent URL filters and selection, accessible navigation/focus states, useful empty/error states, gate/reservation inspection, and responsive/accessibility assertions. Status: implemented and accepted.
13
+ 3. **Release hardening** — schema compatibility, realistic large-workspace benchmark, live/static export tests, documentation, package dry run, full regression, and independent final judge. Status: implemented and accepted.
14
+
15
+ ## Acceptance mapping
16
+
17
+ - Terminal contract, milestone and project status: milestone 1 projection/API/UI tests.
18
+ - Revision lineage and acceptance/final-judge timeline: milestone 1 task workspace fixtures.
19
+ - Human gates and external-action reservations: milestones 1–2 projection and inspection views.
20
+ - Safe read-only boundary: no mutation endpoints or controls; loopback default, explicit non-loopback override, redaction, XSS/path traversal checks.
21
+ - Information architecture, interaction and responsive UI: milestones 1–2.
22
+ - Tests, documentation and release-level acceptance: milestones 1–3.
23
+
24
+ ## Explicit boundaries
25
+
26
+ The workspace does not approve gates, resume tasks, settle reservations, modify contracts, repair artifacts, or serve arbitrary evidence files. Those actions remain in governed CLI/control-plane workflows. A future authenticated mutation product would require a separate authority model and project contract.
27
+
28
+ ## Project completion rule
29
+
30
+ The project reached its terminal acceptance candidate after all three milestones passed, all required checks were recorded, no required item remained unmet, and the independent 25-check final judgement returned `accept`. Milestone 1 acceptance was recorded only as a stage completion; project completion is based on the combined terminal evidence.
@@ -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`.
@@ -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
+ }