lua-cli 3.29.1 → 3.31.0

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 (61) hide show
  1. package/dist/api-exports.d.ts +1185 -77
  2. package/dist/api-exports.js +5032 -137
  3. package/dist/api-exports.js.map +1 -1
  4. package/dist/index.js +25610 -14399
  5. package/dist/index.js.map +1 -1
  6. package/dist/voice/test/index.d.ts +40 -40
  7. package/dist/workflow-builder.d.ts +766 -0
  8. package/dist/workflow-builder.js +5732 -0
  9. package/dist/workflow-builder.js.map +1 -0
  10. package/docs/API_INDEX.md +2 -0
  11. package/docs/README.md +27 -9
  12. package/docs/api/Jobs.md +10 -10
  13. package/docs/api/LuaWorkflow.md +73 -0
  14. package/docs/api/Workflows.md +110 -0
  15. package/docs/workflows/approvals.md +28 -0
  16. package/docs/workflows/artefacts-and-datasets.md +19 -0
  17. package/docs/workflows/coding-harness.md +12 -0
  18. package/docs/workflows/compliance-gates.md +16 -0
  19. package/docs/workflows/connections-in-coding-turns.md +9 -0
  20. package/docs/workflows/correlation-keys.md +11 -0
  21. package/docs/workflows/env-overlays.md +12 -0
  22. package/docs/workflows/evidence-bundles.md +11 -0
  23. package/docs/workflows/exports.md +5 -0
  24. package/docs/workflows/external-content-and-toolscope.md +11 -0
  25. package/docs/workflows/git-credentials.md +11 -0
  26. package/docs/workflows/knowledge-bindings.md +13 -0
  27. package/docs/workflows/limits.md +11 -0
  28. package/docs/workflows/long-steps-and-checkpoints.md +13 -0
  29. package/docs/workflows/migrating-cloud-tasks.md +9 -0
  30. package/docs/workflows/migrating-runs.md +13 -0
  31. package/docs/workflows/output-visibility.md +9 -0
  32. package/docs/workflows/per-item-approvals.md +9 -0
  33. package/docs/workflows/private-network-sources.md +12 -0
  34. package/docs/workflows/recovery.md +28 -0
  35. package/docs/workflows/replay-local.md +35 -0
  36. package/docs/workflows/reply-channels.md +11 -0
  37. package/docs/workflows/retention-and-archival.md +82 -0
  38. package/docs/workflows/roles.md +12 -0
  39. package/docs/workflows/schedules.md +11 -0
  40. package/docs/workflows/script-form.md +50 -0
  41. package/docs/workflows/testing-offline.md +46 -0
  42. package/docs/workflows/workspace-backends.md +11 -0
  43. package/docs/workflows/workspaces-and-long-steps.md +27 -0
  44. package/package.json +7 -2
  45. package/scripts/run-api-extractor.mjs +1 -1
  46. package/template/.gitignore +2 -0
  47. package/template/examples/workflows/CLAUDE.md +24 -0
  48. package/template/examples/workflows/adversarial-verify.workflow.script.js +48 -0
  49. package/template/examples/workflows/github-review.webhook.ts +19 -0
  50. package/template/examples/workflows/linear-ready.trigger.ts +21 -0
  51. package/template/examples/workflows/outreach.ts +55 -0
  52. package/template/examples/workflows/pr-review-round.ts +38 -0
  53. package/template/examples/workflows/provision-tenant.ts +18 -0
  54. package/template/examples/workflows/refund-approval.ts +44 -0
  55. package/template/examples/workflows/research-brief.ts +42 -0
  56. package/template/examples/workflows/reviewed-brief.ts +19 -0
  57. package/template/examples/workflows/support-triage.ts +44 -0
  58. package/template/examples/workflows/ticket-to-pr.ts +65 -0
  59. package/template/examples/workflows/vendor-invoices.ts +30 -0
  60. package/template/lua.skill.yaml +1 -0
  61. package/template/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ # Limits and caps
2
+
3
+ _Source of truth: workflows-spec 10 / 16. This page is the developer summary; the spec is normative._
4
+
5
+ Per-run: `budget.maxCredits` (over the org's `maxCreditsPerRun` default 250 ⇒ push blocker `budget-exceeds-cap` until an org admin raises it, R23), `budget.maxDurationSeconds`, `budget.maxJobSeconds`. Structural: `foreach` `maxItems` (default 256 — with `chunk:{size}` the cap bounds CHUNKS), loop `maxIterations` (default 100), nesting depth ≤ 3, `startBatch` per-item `idempotencyKey`. Push-time caps preflight reports `warnings[]` + `blockers[]`; `lua push --strict-caps` promotes warnings.
6
+
7
+ ## Org pacing
8
+
9
+ `workflowPolicy.pacing` is an org-admin rate limit **per outbound host** shared by every run in the org. A paced step shows as `ready (pacing_deferred …)` and counts against no per-run budget except `maxDurationSeconds`. Code-step `fetch` calls are **not** paced (B26, normative) — pacing governs `tool`/`http` steps. Only `lua features` / the desktop **Workflows policy** page / R23 can change it.
10
+
11
+ Offline, the driver has no org context: a `tool`/`http` step dispatches immediately and `pacing_deferred` never appears locally. Customer-side pacing of a `foreach` (`rateLimit: { perSecond | perMinute }`) IS emulated — an in-memory token bucket printing `[throttle:<id>] resumeAt=<iso>`.
@@ -0,0 +1,13 @@
1
+ # Long steps and checkpoints (Cluster I)
2
+
3
+ _Source of truth: workflows-spec 05 §5.17.4 (D19-r2). This page is the developer summary; the spec is normative._
4
+
5
+ A Job step may run up to **24 h** (`timeoutSeconds` ≤ 86 400). The platform runs it as **segments** of ≤ 4 h: at every 4 h boundary — and every 30 min in between — your branch is committed and pushed and the coding session is saved; the next segment resumes from that commit on the same volume with the same attempt and the same billing epoch; a code step is re-entered from the top with `LUA_WF_JOB_SEGMENT` set, so **make it idempotent on the tree**.
6
+
7
+ - A step past 14 400 s requires a workspace (`long-job-requires-workspace`) — the checkpoint needs somewhere to commit.
8
+ - **Eviction** (`JOB_EVICTED`) resumes from the last checkpoint — at most 30 min of work is redone; no attempt bump.
9
+ - `lua workflows jobs` / `job-logs --segment <n>` read a segmented step; the timeline shows `s<n>/<total> · resumed from checkpoint`.
10
+
11
+ ## Rehearsing offline
12
+
13
+ `--segment-wall <s>` (default 14 400) shrinks the segment wall — set it to `30` to exercise a 24 h step in a minute: the driver commits the checkpoint literally (`[lua-wf] checkpoint <stepId> s<n>`), saves the session under `<workspace>/.home/.lua-wf/`, and re-enters the step with `LUA_WF_JOB_SEGMENT` bumped. `--evict-at <stepId>=<s>` simulates an eviction; `--checkpoint-interval <s>` makes interval checkpoints visible.
@@ -0,0 +1,9 @@
1
+ # Migrating cloud tasks (jobs) to workflows
2
+
3
+ _Source of truth: workflows-spec (Cluster J B9, D12-r2). This page is the developer summary; the spec is normative._
4
+
5
+ `lua workflows import-job <jobName>` writes a one-step workflow file wrapping the job's `execute` (schedule + input carried over) into `src/workflows/`. It **writes a file** — it does not deploy anything.
6
+
7
+ **Deactivate the agent job yourself** (D12-r2: the platform never migrates a job) — after you push and activate the workflow, run `lua jobs`-side deactivation, or both will fire.
8
+
9
+ The unified read: `lua workflows list --all` / the desktop cloud-tasks view show jobs and workflows side by side so nothing is lost mid-migration.
@@ -0,0 +1,13 @@
1
+ # Migrating runs between versions
2
+
3
+ _Source of truth: workflows-spec (Cluster D R46/R47, D7-r2). This page is the developer summary; the spec is normative._
4
+
5
+ `lua workflows migrate-runs <workflowId> --from <ver> --to <ver>` re-pins runs to a new version where compatible (`isResumeCompatible` — same seeding math as repair runs); `cancel-by-version` recalls a bad version's fleet.
6
+
7
+ ## Edited inputs
8
+
9
+ `--resume-override <path>=<json>` patches the seeded input where the target version's schema requires it — validated against the TARGET schema before anything moves.
10
+
11
+ ## Running runs
12
+
13
+ A **running** run is scheduled and moves at its next step boundary or park — never mid-step; after `LUA_WF_MIGRATION_DRAIN_MAX_HOURS` it is refused, not forced. Suspended/gated runs move immediately; incompatible runs are reported per-run with the incompatibility, never skipped silently.
@@ -0,0 +1,9 @@
1
+ # Output visibility
2
+
3
+ _Source of truth: workflows-spec (Cluster K B44). This page is the developer summary; the spec is normative._
4
+
5
+ `outputVisibility: { roles: [...] }` on `createWorkflow` **restricts who can READ what a workflow produced; it never restricts who can run it.** A reader outside the roles gets `restricted: true` and no `output` from `Workflows.get`/`list` — never an error.
6
+
7
+ - ANDs with the org's `workflowPolicy.artefactAcl` — the narrower set wins.
8
+ - **Owner bypass** is on by default; every bypass read leaves an audit line.
9
+ - The ACL is hashed into `run.aclHash` at start: **changing roles affects new runs only.**
@@ -0,0 +1,9 @@
1
+ # Per-item approvals
2
+
3
+ _Source of truth: workflows-spec 06 (Cluster K B20, WF-544). This page is the developer summary; the spec is normative._
4
+
5
+ `itemsPath: 'invoices'` fans one approval node out to **one decision per item**; `itemApprover: { fromItem: 'approverEmail' }` routes each item to ITS approver (the parent `approver` keeps the overview card); `itemTimeout: { timeoutHours }` expires unanswered items as `expired`. The node resumes with `resumeData.items[]` (+ `approved = every item approved`) — feed approved rows to a `foreach` (see the `vendor-invoices` example).
6
+
7
+ > **T50 caution:** `fromItem` trusts the payload to NAME the approver; membership + `workflows:execute` is what makes it safe — never derive the approver from external content.
8
+
9
+ Offline: `--approve-item <id>=<index>:approve|deny[=@payload]` (repeatable); `--approve <id>` on an items approval is exit 2 ("per-item approval: use --approve-item"). Text channels approve/deny the whole batch only.
@@ -0,0 +1,12 @@
1
+ # Private-network sources
2
+
3
+ _Source of truth: workflows-spec (ICP P1-20). This page is the developer summary; the spec is normative._
4
+
5
+ **The boundary:** the sandbox reaches the public internet on any port and Lua services; it does **not** reach `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `100.64/10` — a VPC database, an on-prem ERP, a peered network.
6
+
7
+ **The exact error:** a step that tries gets `EGRESS_DENIED { host, ip, port }` within seconds — never a `WALL_TIMEOUT`. If you see a hang instead, you are not hitting this boundary.
8
+
9
+ ## The two supported patterns
10
+
11
+ 1. **Expose the source behind a public TLS endpoint** (or an API gateway) with an allow-list — the sandbox reaches any public host/port.
12
+ 2. **Ask for a partner carve-out** — a platform-team lua-iac PR adding your source's `/32` + port to the runner and Job NetworkPolicies (`platforms/aws/prod/INTEGRATIONS.md`; carve-outs are CI-guarded by `policy/check-partner-egress.py`). Budget lead time, and supply the exact `/32` and port.
@@ -0,0 +1,28 @@
1
+ # When a step parks
2
+
3
+ _Source of truth: workflows-spec 01 §1.6-r1 / 06 §6.3.5. This page is the developer summary; the spec is normative._
4
+
5
+ A **park** is the platform refusing to guess. Two things park a step:
6
+
7
+ 1. **`sideEffects:'external'` + a platform fault.** A worker crash, an eviction or a network partition while an external-effect step was in flight means the platform cannot know whether the effect happened (`EFFECT_IN_DOUBT`). **The platform never re-runs an external step on its own** — the step parks and the run gates `exception`.
8
+ 2. **`onError:'park'`.** A final (retries exhausted) failure parks instead of failing the run — for steps where an operator should decide.
9
+
10
+ ## The three verbs
11
+
12
+ A parked step waits for exactly one of:
13
+
14
+ - **`retry-step`** — re-runs `execute` with the **same `occurrenceId`** (`${lineageId}:${stepId}`), so every `ctx.once(key, fn)` effect that already settled returns its stored result and is never re-sent.
15
+ - **`resolve-step`** — you supply the step's output (validated against `outputSchema`); the run continues as if the step had returned it.
16
+ - **`skip`** — marks the row skipped; downstream bindings render `status="skipped"`.
17
+
18
+ `fail` (or cancelling the run) applies the step's failure as-is. From the CLI: `lua workflows status <runId>` shows the park, `lua workflows resume` / the desktop inbox carry the verbs.
19
+
20
+ ## Repair runs
21
+
22
+ When a whole run is beyond per-step repair, a **repair run** re-drives the graph seeded from the ledger (the §05 §5.5.4.1 seeding math — the same `seedLedgerFromRun` the CLI's `--from-run` uses): every `completed` step keeps its outputs, attempts and settled effects, and the frontier restarts at the first non-completed step. Because `occurrenceId` is stable across retry, resume, `retry-step` **and** a repair run, an external effect executes at most once per occurrence.
23
+
24
+ > **The rule to remember:** *execution is at-least-once — dedupe on `occurrenceId`*; an effect that must be unique across **independent** runs needs your own business key (`refund:${ticketId}`).
25
+
26
+ ## Rehearsing offline
27
+
28
+ `lua test workflow <name> --park <stepId>` simulates the platform-fault reclaim locally and offers the same verbs (`retry / skip / complete / fail`) on stdin — see [Testing offline](./testing-offline.md).
@@ -0,0 +1,35 @@
1
+ # `lua workflows replay <runId> --local` — runtime packaging
2
+
3
+ _Source of truth: workflows-spec 03 §3.10, 04 §4.3.5, 05 §5.7.1, 12 §12.9.11 (WF-324 / WF-332, WB-09). This page records the packaging decision for the script-form replay wrapper; the spec sections are normative._
4
+
5
+ ## The decision (wave 5, WB-09)
6
+
7
+ `replay --local` for a **script** run re-runs the runner's own replay wrapper (`buildWorkflowScriptWrapper`, `@lua/sandbox-runtime`) against the R6 `?format=replay` bundle and diffs the re-issued `callHash`es against the server journal. `lua test workflow <name>` on a `*.workflow.script.js` file drives the same wrapper through an offline tick loop.
8
+
9
+ Two options were on the table:
10
+
11
+ | Option | Shape | Verdict |
12
+ | --- | --- | --- |
13
+ | **A — project-local runtime, resolved lazily** | `@lua/sandbox-runtime` is NOT bundled into the published CLI. The verb resolves it with `createRequire(<project>/package.json)` at call time; a miss is the typed error `REPLAY_RUNTIME_UNAVAILABLE` (exit 3), never a silent "stable". | **Chosen.** |
14
+ | B — a published replay runtime (`@lua/workflow-replay`) | Extract the wrapper source + `createSandboxContext` into a small published package the CLI depends on. | Deferred: it would fork the wrapper text away from the runner (the §2.2 parity rule says one implementation), and the wrapper is versioned by `journalProtocolVersion` (N-1 window, 04 §4.3.5) — a second package would have to track that window. Revisit at GA if partner projects outside the monorepo need `replay --local`. |
15
+
16
+ Why A: the wrapper drags the whole runtime plane (`@lua/sandbox-runtime` → `@lua/shared-sandbox` polyfills, artefact/dataset contexts) into the CLI bundle otherwise; every project that can run a workflow-script replay today is a monorepo/dev checkout that already has the runtime in `node_modules`; and resolving from the **project** rather than the CLI guarantees the wrapper version matches the runner the project deploys.
17
+
18
+ ## What it means for a project
19
+
20
+ - Inside the monorepo (or any project that lists `@lua/sandbox-runtime` in its devDependencies): `replay --local` and `lua test workflow <script>` work as-is.
21
+ - Elsewhere: `REPLAY_RUNTIME_UNAVAILABLE: replay --local needs @lua/sandbox-runtime resolvable from the project (…)` — add the devDependency, or run the verb from a monorepo checkout with `--project <dir>`.
22
+ - The graph-form `replay --local` (R4/R5 → `replayLedger`) has no runtime dependency and always works.
23
+
24
+ ## Exit matrix (script form)
25
+
26
+ | Exit | Meaning |
27
+ | --- | --- |
28
+ | 0 | stable — every async journal entry re-issued with the same `callHash` |
29
+ | 3 | runtime unavailable (`REPLAY_RUNTIME_UNAVAILABLE`) or the run/bundle could not be fetched |
30
+ | 4 | `LEDGER_DIVERGENCE` / `JOURNAL_DIVERGENCE` — at least one `hash_mismatch` / `journal_longer_than_script` row |
31
+
32
+ ## Related
33
+
34
+ - `docs/workflows/script-form.md` — authoring `*.workflow.script.js`, `lua push`, `lua test workflow` on a script.
35
+ - `packages/lua-cli/src/utils/workflow-script-replay.ts` — `loadLocalScriptRunner`, `compareScriptReplay` (pure; what the tests pin).
@@ -0,0 +1,11 @@
1
+ # Reply channels
2
+
3
+ _Source of truth: workflows-spec (Cluster K B42). This page is the developer summary; the spec is normative._
4
+
5
+ A run started from a conversation carries `replyTo: { channel, threadId }`; at terminal state the platform sends the outcome back through a **`ChannelAdapter`** — a new channel is one adapter file + one registry entry.
6
+
7
+ - Registered today: **whatsapp**. `sms` / `email` / `webchat` / `slack` are specified and land behind their E-10 pins; an unregistered channel fails loudly (`channel_unsupported`), never silently drops.
8
+ - Slack `threadId` format: `<channelId>:<thread_ts>`.
9
+ - A step may answer the customer itself mid-run (`Channels.<channel>.send` inside `ctx.once`) — the terminal reply then lands as a duplicate-safe fallback on the same thread.
10
+
11
+ Offline: `--reply-to <channel>:<threadId>` prints the would-be reply on the terminal.
@@ -0,0 +1,82 @@
1
+ # Workflows — retention and archival
2
+
3
+ _Source of truth: workflows-spec 02 §2.14, 03 §3.10, 11 §11.11.1 (WF-428 / WF-443, WB-01). This page is the operator-facing summary; the spec sections are normative._
4
+
5
+ ## Retention model
6
+
7
+ | Data | Clock | Default | Org control |
8
+ | --- | --- | --- | --- |
9
+ | `workflow_runs` and every child row (`workflow_step_runs`, `workflow_run_events`, `workflow_run_signals`, `workflow_artefacts`) | `expiresAt = completedAt + retentionDays` stamped by the run's terminal write; children tightened to the run's stamp | 90 d (`LUA_WF_RETENTION_DAYS_DEFAULT`) | `workflowPolicy.retentionDays` (R23), **7..90**, clamp-down only |
10
+ | Hidden `wf-` threads (agent-step + loopback threads) | `payloadsExpireAt` (else `expiresAt`) — sweep #31 `hidden-thread-gc` | same | same |
11
+ | `workflow_migrations` rows | `finishedAt + 400 d` — never purged with a run | fixed | none |
12
+ | Audit rows (lua-auth) | audit retention | fixed | none |
13
+
14
+ **Clamp-down only.** Lowering `retentionDays` restamps every terminal row of the org whose `expiresAt` sits past `completedAt + newDays` (the R23 write flags `retentionRestampPending`; sweep #13 `ttl-stamp` lowers the run, then steps/events/signals/artefacts with a `{expiresAt:{$gt}}` guard). Raising it restamps **nothing** — an `expiresAt` already stamped is never raised, and the platform 90 d stays the ceiling (`RETENTION_OUT_OF_RANGE` 400 outside 7..90). Every change emits audit `workflow.policy.retention_changed{from, to, restampPending}`.
15
+
16
+ Runs the org still needs past their retention must be **archived out** before the TTL fires — that is the `archive-runs` hop below.
17
+
18
+ ## `lua workflows archive-runs`
19
+
20
+ Pages the agent's terminal runs (R2, `status ∈ completed|failed|cancelled|timed_out|abandoned`, oldest first), requests an export bundle per run (R50 `POST /runs/:id/export` → R51 `GET /runs/:id/export`) at `--concurrency` (default 4), and writes each bundle plus an `archive-index.ndjson` to the sink.
21
+
22
+ ```
23
+ lua workflows archive-runs \
24
+ --agent <agentId> \
25
+ --since 2026-06-01 --until 2026-08-01 \
26
+ --sink s3://acme-workflow-archive/prod/ \
27
+ --connection <orgStorageConnectionId> \
28
+ --concurrency 4
29
+ ```
30
+
31
+ - **Sinks:** `s3://` and `gs://` through an org storage connection (`--connection`); the CLI never holds cloud credentials itself. A local directory sink is accepted for dry runs.
32
+ - **Skip rule:** a run is skipped (`skipped:'already_exported'` in the index) when `run.exportedAt ≥ run.completedAt` **and** the sink already holds the bundle whose `manifest.sha256` matches — re-running the hop is idempotent and cheap.
33
+ - **Index:** one line per run — `{runId, status, completedAt, exportedAt, sha256, objectKey, bytes, skipped?, error?}` (`WorkflowArchiveIndexRow` in `@lua/shared-types`).
34
+ - **Window guard:** a run whose `completedAt` is older than `retentionDays − 7 d` may be reaped mid-export; the hop refuses the whole window with `ARCHIVE_WINDOW_TOO_OLD` (exit 4) rather than produce a partial archive. Narrow `--since` or run the hop more often.
35
+
36
+ ### Exit matrix
37
+
38
+ | Exit | Meaning |
39
+ | --- | --- |
40
+ | 0 | every selected run exported or skipped |
41
+ | 1 | ≥ 1 run failed to export — listed in the index with `error`; re-run to retry only those |
42
+ | 3 | sink or storage connection unavailable — nothing written |
43
+ | 4 | `ARCHIVE_WINDOW_TOO_OLD` — the window reaches past `retentionDays − 7 d` |
44
+
45
+ ## Weekly cron template
46
+
47
+ Run the hop weekly per agent, one day inside the window guard. The CronJob manifest lives in lua-iac (`workload-cronjob-*.tf`) — this repo carries no k8s manifests. Every cronjob **must** heartbeat (repo `CLAUDE.md`): create the Betterstack heartbeat (period 7 d, grace ≈ 1 d), store its URL in Secrets Manager under `<service>/prod/heartbeat-url-wf-archive-<agent>`, and wrap the command:
48
+
49
+ ```yaml
50
+ command:
51
+ - /bin/sh
52
+ - -c
53
+ - |
54
+ set +e
55
+ lua workflows archive-runs --agent "$AGENT_ID" --since "$(date -u -d '-14 days' +%F)" \
56
+ --sink "$ARCHIVE_SINK" --connection "$STORAGE_CONNECTION_ID" --concurrency 4
57
+ EXIT=$?
58
+ if [ $EXIT -eq 0 ]; then
59
+ curl -fsS --max-time 10 --retry 2 "$HEARTBEAT_URL" || true
60
+ else
61
+ curl -fsS --max-time 10 --retry 2 --data-urlencode "exit=$EXIT" "$HEARTBEAT_URL/fail" || true
62
+ fi
63
+ exit $EXIT
64
+ env:
65
+ - name: HEARTBEAT_URL
66
+ valueFrom:
67
+ secretKeyRef:
68
+ name: lua-cli-archive-secrets
69
+ key: HEARTBEAT_URL_WF_ARCHIVE
70
+ optional: true
71
+ ```
72
+
73
+ The three invariants: the hop's own non-zero exit propagates (`exit $EXIT`), heartbeat pings use `|| true`, and `optional: true` on the secret ref keeps staging (no heartbeat) runnable.
74
+
75
+ ## Related sweeps
76
+
77
+ | # | name | what it does |
78
+ | --- | --- | --- |
79
+ | 13 | `ttl-stamp` | backstop stamp + clamp-down restamp + 400 d migration counter + orphan artefact reap |
80
+ | 15 | `wf-thread-ttl` | 90 d backstop for hidden threads minted before `payloadsExpireAt` existed |
81
+ | 19 | `org-purge` | offboarding / agent-delete cascade (fence → drain → done) |
82
+ | 31 | `hidden-thread-gc` | hidden `wf-` threads past the payload clock, through the conversation scrub |
@@ -0,0 +1,12 @@
1
+ # Roles — the workflow role library
2
+
3
+ _Source of truth: workflows-spec (Cluster J B18, D25-r1). This page is the developer summary; the spec is normative._
4
+
5
+ `lua roles` manages the owner's `workflowRoles[]` library (`list · view · save · delete`). Both forms bind a saved role:
6
+
7
+ - Builder: `specialistStep(id, { role: { ref: '<roleName>' }, … })` — or an inline `{ name, instructions, tools }` role (see the `reviewed-brief` example).
8
+ - Script form: `role: { ref: '<roleName>' }` in an `agent(...)` call — the CLI collects it into `roleBindings` with `status:'deferred'` (it never reads the library); resolution happens at push.
9
+
10
+ **A library edit never changes a published version — push again.** Versions pin the resolved role bytes; `lua workflows versions` marks a version whose role source has drifted with `⚠ stale`.
11
+
12
+ Role tools are a subset of the owning agent's toolset; delegation tools (`agent-*`) are never allowlistable. Offline, `--agents fake` prints `[fake:<id> as <role.name>]`; a role block under `--agents live` is a client-side approximation — the allowlist is enforced server-side only.
@@ -0,0 +1,11 @@
1
+ # Schedules
2
+
3
+ _Source of truth: workflows-spec 07 §7.1. This page is the developer summary; the spec is normative._
4
+
5
+ `schedule: { type:'cron', expression: '0 9 * * 1', timezone: 'Europe/London' }` on `createWorkflow` fires runs on the cron grid; `scheduleInput` is the run input. `concurrencyPolicy:'forbid'` skips a fire while a run is in flight (the skip is recorded, never queued). Manage with `lua workflows activate` / `deactivate`; `lua workflows backfill` starts missed occurrences by hand (deduplicated on `backfill:<workflowId>:<occurrenceIso>`).
6
+
7
+ ## Re-enabling a schedule
8
+
9
+ A paused or auto-disabled schedule never replays missed fires by itself; opt in with `schedule.backfillOnEnable: { maxOccurrences }` (or `lua workflows activate --backfill` for one re-enable) and the most recent misses start as one batch, deduplicated against `lua workflows backfill` by the shared `backfill:<workflowId>:<occurrenceIso>` key, capped, and summarised in your inbox.
10
+
11
+ Offline, schedules do not fire (`backfillOnEnable` is not emulated) — start runs with `lua test workflow` / `lua workflows start`.
@@ -0,0 +1,50 @@
1
+ # Script-form workflows — `src/workflows/<name>.workflow.script.js`
2
+
3
+ _Source of truth: workflows-spec 04 §4.3 (D15-r2, Cluster J B12 — WF-527, WB-09 push half / WB-03 script host). This page is the developer summary; the spec is normative._
4
+
5
+ A script workflow is a plain JavaScript ES module — no TypeScript, no `import`/`require` — that begins with `export const meta = {…}` and continues with a body run in an async context whose top-level `return` is the run output. The host API (`agent`, `tool`, `parallel`, `foreach`, `sleep`, `approval`, `waitForSignal`, `memo`, `step`, `shell`, `merge`, `log`, `phase`, …) is injected as module-scope bindings; the same file is what the composer produces (`lua workflows export`) and what a template freezes.
6
+
7
+ ```js
8
+ export const meta = { name: 'judge-panel', description: 'Three drafts, three judges, synthesize the winner',
9
+ phases: [{ title: 'Draft' }, { title: 'Judge' }, { title: 'Synthesize' }] };
10
+ const drafts = await parallel([agent('Draft, technical angle', { phase: 'Draft' }), agent('Draft, market angle', { phase: 'Draft' })]);
11
+ const scores = await foreach(drafts, (d) => agent(`Score this draft: ${d}`, { phase: 'Judge' }));
12
+ const picked = await step('pick-winner', () => ({ winner: drafts[scores.indexOf(Math.max(...scores))] }));
13
+ return picked;
14
+ ```
15
+
16
+ ## Rules the CLI checks before anything is sent
17
+
18
+ Run in this order — the same order lua-api re-validates on push (one implementation, decision §2.2):
19
+
20
+ 1. **`meta` (04 §4.3.1)** — the FIRST statement, a pure object literal (strings/numbers/booleans/null/arrays/objects only): `name` `/^[a-z][a-z0-9-]{0,63}$/` **equal to the file stem** (`SCRIPT_META_INVALID{name-file-mismatch}`), `description` ≤ 500, optional `phases[]` (≤ 64 × `{title ≤ 80, detail? ≤ 300, model?}`), `whenToUse`, `concurrency` 1..32, `sampleArgs` (the `args` of the validation tick and of `lua test workflow`), ≤ 4 KB.
21
+ 2. **Determinism lint (04 §4.3.3)** — the banned identifier set is DERIVED from the sandbox's own lists (`WORKFLOW_SCRIPT_REMOVED_GLOBALS` + trapped timers + forbidden platform keys), plus `Date.now`, `new Date()` with no argument, `Math.random`, `eval`, `new Function`, `with`, `globalThis`, and any `import`/`export` other than `meta`. Every hit is `file:line:col — SCRIPT_NONDETERMINISM{<kind>} …`. **The closure passed to `step(label, fn)` is exempt** (its result is journaled once and memoised on replay); `fn` must be a function literal or a module-scope function (`step-fn-not-literal`), `timeoutSeconds` a literal 1..600 (`step-timeout-invalid`).
22
+ 3. **`role: { ref }`** collection — literal refs become `roleBindings[{ref, status:'deferred'}]` (the CLI never reads the role library; the server fills them at push); a non-literal `ref` is `role-ref-unknown`, `ref` mixed with inline members is `role-ref-and-inline`.
23
+ 4. **`scriptSurface`** — `{ hostCalls: {member: count}, hasExternalSideEffects }` (`tool(..., {sideEffects:'external'})`, `shell()`, `merge()`, Job-tier `agent()`), shown on the consent card instead of the text.
24
+ 5. **`scriptHash`** = `sha256-cj1:` + sha256(canonicalJson({ script, meta })).
25
+
26
+ ## `lua push workflow`
27
+
28
+ `lua compile` discovers every `src/workflows/*.workflow.script.js` (outside the TypeScript project — never bundled) and emits `ManifestWorkflow{ form:'script', script, scriptHash, meta, journalProtocolVersion: 1, scriptSurface, roleBindings }` — the graph members are absent. `lua push` sends `{ form:'script', script, scriptHash, meta, journalProtocolVersion, roleBindings, schedule?, scheduleInput?, budget? }` and skips the bundle upload. The stored row is `WorkflowVersion{ form:'script', dynamic:false }` — identical to a composed script minus `dynamic:true`. `WORKFLOW_SCRIPT_FORM_NOT_PUSHABLE` is struck (never emitted). An org whose `workflowPolicy.composeForm` is `'graph'` refuses the push with `compose-form-forbidden`, exactly as it refuses a composed script.
29
+
30
+ ## `lua test workflow <name>` on a script
31
+
32
+ Runs the file through the runner's own replay wrapper (parity context) in an offline tick loop: every tick re-runs the script against the journal so far, the wrapper parks with its async intents, the driver settles each with a **fake completion** (no API call) and re-ticks until `done`/`bailed`/`failed`. Steering:
33
+
34
+ | Flag | Effect |
35
+ | --- | --- |
36
+ | `--input <json|@file>` | the script's `args` (default `meta.sampleArgs ?? {}`) |
37
+ | `--step-output <label|seq>=<json>` | the completion for the intent with that `narration.label` (an `agent()`'s `label`/`phase`) or journal seq |
38
+ | `--max-ticks <n>` | default 64 — `SCRIPT_TICK_LIMIT` (exit 4) beyond it |
39
+ | `--ledger-out <file>` | the local journal (`{ form:'script', journal[] }`) |
40
+ | `--json` | the result envelope |
41
+
42
+ Exit 0 `done`/`bailed`; 4 `failed` (`SCRIPT_THREW`, `SCRIPT_DEADLOCK` — parked with zero intents, `SCRIPT_TICK_LIMIT`); 3 when `@lua/sandbox-runtime` is not resolvable from the project (`REPLAY_RUNTIME_UNAVAILABLE`, see `replay-local.md`); 2 on flag grammar.
43
+
44
+ ## `step(label, fn, { timeoutSeconds? })`
45
+
46
+ Journals as `kind:'code'` with `callHash = hash(label, fn.toString())`; replay memoises the recorded result and never re-executes the closure; more than 64 `step()` closures in one tick is `script-step-too-many`; a throw is `SCRIPT_STEP_THREW{message}` (catchable); the wall is `SCRIPT_STEP_TIMEOUT`. Every `step()` site makes the script non-representable as a graph (`lua workflows export --form graph` → `script-not-graph-representable{inline-code}`).
47
+
48
+ ## Example
49
+
50
+ `template/examples/workflows/adversarial-verify.workflow.script.js` — a finder/verifier loop with a `step()` reduction.
@@ -0,0 +1,46 @@
1
+ # Testing offline — the local reference driver
2
+
3
+ _Source of truth: workflows-spec 03 §3.9 (WF-204/WF-225/WF-332). This page is the developer summary; the spec is normative._
4
+
5
+ `lua test workflow <name>` (alias `lua workflows run <name>`) drives a compiled workflow **offline**: the same `compilePlan`, predicates and mappings the engine uses, code steps executed in the `lua test` sandbox, agent steps faked by default, waits fast-forwarded on a virtual clock. Exit codes: `0` completed · `2` usage/validation · `4` failed · `5` missing fixture.
6
+
7
+ ## Steering flags — fully scriptable, no stdin
8
+
9
+ | Flag | Effect |
10
+ |---|---|
11
+ | `--input @file\|<json>` | Run input (validated against `inputSchema`) |
12
+ | `--step-output <id>=@file\|<json>` (rep.) | Completes the row **without** running it — validated against the step's `outputSchema`. This is how a predicate over an agent output gets BOTH truth values under `--agents fake` |
13
+ | `--approve <id>[=@payload]` · `--deny <id>[=@reason]` | Pre-answer approvals (edited payloads use the server's `matchesEditablePath` + `editedPayloadSchema`) |
14
+ | `--signal <name>=@file\|<json>` (rep.) | Pre-supply `waitForSignal` payloads in order of arrival |
15
+ | `--fixtures <dir>` / `--record <dir>` | Replay / record agent+tool outputs (`<stepId>.<attempt>.json`); a missing fixture is exit **5** `FIXTURE_MISSING`, never a silent fake; `--step-output` wins over a fixture |
16
+ | `--from-run <runId>` | Seed every `completed` step from a real run (same math as repair runs); graph drift asks for `--force` |
17
+ | `--park <id>` (rep.) | Simulate a platform-fault park of a `sideEffects:'external'` step — then `retry / skip / complete / fail`, the production verbs (see [When a step parks](./recovery.md)) |
18
+ | `--fast-retries` | Collapse retry backoff waits to 0 |
19
+ | `--real-time` | Actually wait on `sleep`/backoff instead of fast-forwarding the virtual clock |
20
+ | `--now <iso>` | Pin the virtual clock start (resolves `sleepUntil` dateFrom, business-hours deadlines) |
21
+ | `--artefacts-dir <dir>` | Back `ctx.artefacts.*` on disk (`<dir>/<artefactId>` + `<artefactId>.meta.json`, ids `local-<n>`) |
22
+ | `--env KEY=value` (rep.) | The local `env.template()` overlay — a missing key is exit 2 `env-template-missing`; values never reach `--ledger-out` |
23
+ | `--step-wall <s>` | Per-step wall, default 600 |
24
+ | `--max-foreach-items <n>` | Override the local foreach cap |
25
+ | `--agents fake\|live` | Fake stubs (default) or the dev API |
26
+ | `--ledger-out <file>` | Write the in-memory ledger (steps, outputs, attempts, effects, events) — the input of `replay --local` |
27
+ | `--json` | Machine-readable result envelope |
28
+
29
+ ## Semantics worth knowing
30
+
31
+ - **Retry backoff** runs on the virtual clock: `backoffSeconds · 2^(attempt-1)` capped at `maxBackoffSeconds`, printed as `[retry:<id>] backoff <n>s`.
32
+ - **`foreach` `rateLimit`** is an in-memory token bucket — delayed starts print `[throttle:<id>] resumeAt=<iso>`; loop `intervalSeconds` prints `kind=loop_interval`.
33
+ - **`suspend`** re-runs `execute` from the top with `resumeData` set — the documented Mastra semantics, exercised offline.
34
+ - **`ctx.once`** settles against the in-memory effects map: a re-drive over the same ledger sends nothing twice.
35
+ - **Not emulated offline**: the org pacing policy (no org context — `pacing_deferred` never appears locally), the runner mid-run kill (Ctrl-C is the local analogue), `backfillOnEnable`, and the `startWorkflowRunBatch` tool.
36
+
37
+ ## Example
38
+
39
+ ```bash
40
+ lua test workflow outreach --input @leads.json \
41
+ --step-output draftEmail=@fixtures/draft.json \
42
+ --approve reviewDrafts=@edited.json \
43
+ --ledger-out out.json
44
+ ```
45
+
46
+ runs §3.2 (b) end to end with no prompt and no model call; `sendEmails` still runs (`ctx.once` against the effects map, so a second invocation with the same ledger sends nothing).
@@ -0,0 +1,11 @@
1
+ # Workspace backends — `ebs` / `efs` / `s3`
2
+
3
+ _Source of truth: workflows-spec 05 §5.17 (D27). This page is the developer summary; the spec is normative._
4
+
5
+ `workspace.backend`:
6
+
7
+ - **`ebs`** — the default and what every org has today: one zonal volume, one clone per parallel arm, merged through your remote.
8
+ - **`efs`** — arms get one shared checkout and a local merge (shared `mount:'ro'` arms are allowed in one `parallel`; merge without fetch).
9
+ - **`s3`** — no volume; a prefix is synced at every checkpoint — larger workspaces, slower steps.
10
+
11
+ `efs` and `s3` are **refused, not downgraded**, until the platform opens them for your org (`workspace-backend-unavailable`, gate `EXT-EFS` — see [Compliance gates](./compliance-gates.md)). The local driver prints the declared backend, always uses a local dir, and applies the `efs` validator differences so an offline run matches what an open gate would do.
@@ -0,0 +1,27 @@
1
+ # Workspaces and long steps — the Job tier
2
+
3
+ _Source of truth: workflows-spec 05 §5.17 (D19-r1/r2). This page is the developer summary; the spec is normative._
4
+
5
+ `tier:'job'` runs a step as a **Kubernetes Job per attempt** — up to **24 h** as a chain of ≤ 4 h segments (see [Long steps and checkpoints](./long-steps-and-checkpoints.md)), with `jobResources: 'small'|'medium'|'large'`, a real filesystem and real cancel.
6
+
7
+ ## Workspaces
8
+
9
+ `workspace` on `createWorkflow` declares the checkout: `{ kind:'git', repo, ref, credentialsRef, sizeGb?, verify? }` or `{ kind:'empty' }`. Steps mount it with `workspace: { mount: 'rw'|'ro', isolation?: 'worktree' }` and read `ctx.workspace = { path, mount, branch }`.
10
+
11
+ **The release-at-suspension rule:** when your run waits for a person or a signal, its volume is released: committed work is on the run branch `lua/wf-<lineageId>` on your remote; untracked files — `node_modules`, build output — are rebuilt when the next step restores the checkout.
12
+
13
+ **Worktree arms:** parallel arms never share a filesystem; each gets its own clone and branch and the `merge` step integrates them — put `verify` on the workspace so an agent-resolved conflict must pass your tests. `onConflict:'agent'` lets one resolver turn fix conflicts.
14
+
15
+ ## Coding turns
16
+
17
+ A Job-tier `agentStep` is a coding turn (Claude Code or the generic harness — see [Coding harness](./coding-harness.md)) with the `WORKFLOW_JOB_TOOLS` set (`shell`, `read`, `write`, `edit`, `glob`, `grep`, `git`, …). MCP connections mount via `toolScope.connectionIds`: your connections are mounted through a local proxy; the model never sees a token, and a tool that needs approval is refused inside the turn — hand it to a worker-tier step.
18
+
19
+ GitHub review loops: the PR body carries `<!-- lua-run:<runId> -->`; the GitHub webhook routes `pull_request_review` to `Workflows.signal(runId, 'github.review', …)` — see the `ticket-to-pr` example.
20
+
21
+ ## Memory on the Job tier
22
+
23
+ > `jobResources:'large'` (4 CPU / 8 GiB) is the ceiling. A container that exceeds its class's memory limit is OOM-killed and the step fails `job_oom_killed` — that is your fault, not a platform fault: it is not retried unless you declare `retry`, and the error's `sizeClassHint` tells you whether a bigger class exists. Test suites are the usual culprit: jest spawns a worker per CPU and `mongodb-memory-server` boots a `mongod` per test file, so run `npm test -- --maxWorkers=2` (or set `maxWorkers` in `jest.config`) inside a Job step, keep `runInBand` for suites with in-memory databases, and split a monorepo suite across `parallel` worktree arms rather than sizing up.
24
+
25
+ ## Offline
26
+
27
+ The local driver emulates the tier: `--workspace <dir>`, `--job-wall <s>`, worktree arms as literal `git worktree add`, `--segment-wall` for segment rehearsal. `credentialsRef` is printed and never resolved offline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lua-cli",
3
- "version": "3.29.1",
3
+ "version": "3.31.0",
4
4
  "description": "Build, test, and deploy AI agents with custom tools, webhooks, and scheduled jobs. Features LuaAgent unified configuration, streaming chat, and batch deployment.",
5
5
  "readmeFilename": "README.md",
6
6
  "main": "dist/api-exports.js",
@@ -21,6 +21,10 @@
21
21
  "./voice/test": {
22
22
  "types": "./dist/voice/test/index.d.ts",
23
23
  "default": "./dist/voice/test/index.js"
24
+ },
25
+ "./workflow-builder": {
26
+ "types": "./dist/workflow-builder.d.ts",
27
+ "default": "./dist/workflow-builder.js"
24
28
  }
25
29
  },
26
30
  "keywords": [
@@ -110,8 +114,9 @@
110
114
  "ts-node": "^10.9.2",
111
115
  "tsup": "^8.5.1",
112
116
  "@lua/shared-sandbox": "0.0.1",
117
+ "@lua/shared-types": "0.0.1",
113
118
  "@lua/shared-source-sync": "0.0.1",
114
- "@lua/shared-types": "0.0.1"
119
+ "@lua/workflow-graph": "0.0.1"
115
120
  },
116
121
  "scripts": {
117
122
  "clean": "rm -rf dist temp",
@@ -41,7 +41,7 @@ const configDir = path.join(packageRoot, 'api-extractor');
41
41
  const tempDtsDir = path.join(packageRoot, 'temp', 'dts');
42
42
  const distDir = path.join(packageRoot, 'dist');
43
43
 
44
- const rolledEntries = ['api-exports', 'voice-test'];
44
+ const rolledEntries = ['api-exports', 'voice-test', 'workflow-builder'];
45
45
 
46
46
  const rawEntries = [
47
47
  { src: 'index.d.ts', dest: 'index.d.ts' },
@@ -19,3 +19,5 @@ node_modules/
19
19
  # OS files
20
20
  .DS_Store
21
21
  Thumbs.db
22
+ # Workflows v1 (wave-5 gate): the script-form example IS tracked (WB-09, WF-527)
23
+ !examples/workflows/*.workflow.script.js
@@ -0,0 +1,24 @@
1
+ # Workflow examples
2
+
3
+ Verbatim worked examples from workflows-spec 03 §3.2 (the spec is normative — do not restyle them).
4
+
5
+ | File | Shows |
6
+ |---|---|
7
+ | `research-brief.ts` | (a) sequential + parallel research, typed predicates (`stepOf`/`gt`/`lit`), switch + otherwise |
8
+ | `outreach.ts` | (b) `foreach` + editable approval + an exactly-once send (`ctx.once`), cron schedule, `concurrencyPolicy` |
9
+ | `provision-tenant.ts` | (c) nested workflow + `sleep` + `waitForSignal` |
10
+ | `reviewed-brief.ts` | (d) `specialistStep` — an ephemeral reviewer role on the owning agent (D25) |
11
+ | `refund-approval.ts` | (e) maker-checker + four-eyes + business-hours escalation chain + a recoverable external step (`onError:'park'`) |
12
+ | `ticket-to-pr.ts` + `pr-review-round.ts` + `linear-ready.trigger.ts` + `github-review.webhook.ts` | (f) the SWE headline: Job tier, worktree arms + merge, review loop over a child workflow, trigger `startWorkflow`, webhook → `Workflows.signal` |
13
+ | `support-triage.ts` | (g) knowledge grounding, mandatory `toolScope` on external content, dataset rows, `ctx.artefacts` |
14
+ | `adversarial-verify.workflow.script.js` | (i) script form — `step()` inline effect + a library `role:{ref}` |
15
+ | `vendor-invoices.ts` | (j) per-item approvals (`itemsPath`), `env.template()` overlays, `outputVisibility` |
16
+
17
+ Recipes:
18
+
19
+ - Run any of these offline: `lua test workflow <name>` (steering flags in `docs/api/Workflows.md` → "Testing locally").
20
+ - Give a predicate BOTH truth values under `--agents fake` with `--step-output <id>=<json>` — the fake stub alone
21
+ cannot reach the `gt(confidence, 0.6)` arm of `research-brief`.
22
+ - Every doc rule worth repeating: *execute re-runs from the top after resume; execution is at-least-once — dedupe
23
+ on `occurrenceId`* (`${lineageId}:${stepId}`); an effect unique across independent runs needs your own business
24
+ key (`refund:${ticketId}`).
@@ -0,0 +1,48 @@
1
+ // Example (i) — adversarial verify: spawn finders until two consecutive rounds add nothing
2
+ // new, then have an independent verifier confirm each finding. Script form (04 §4.3);
3
+ // push with `lua push workflow`, run offline with `lua test workflow adversarial-verify`.
4
+ export const meta = {
5
+ name: 'adversarial-verify',
6
+ description: 'Find candidate issues from several angles, verify each independently, report the confirmed set',
7
+ phases: [{ title: 'Find' }, { title: 'Verify' }, { title: 'Report' }],
8
+ concurrency: 4,
9
+ sampleArgs: { subject: 'The attached design doc', angles: ['security', 'performance', 'clarity'] },
10
+ };
11
+
12
+ const found = new Map();
13
+ let quietRounds = 0;
14
+ for (let round = 1; round <= 5 && quietRounds < 2; round++) {
15
+ const batch = await parallel(
16
+ args.angles.map((angle) =>
17
+ agent(`Round ${round}: list concrete issues in ${args.subject} from the ${angle} angle. One per line.`, {
18
+ phase: 'Find',
19
+ label: `find-${angle}-${round}`,
20
+ })
21
+ )
22
+ );
23
+ // step(): a pure reduction journaled once — its closure may use anything, it never re-runs on replay.
24
+ const added = await step(`dedupe-${round}`, () => {
25
+ let fresh = 0;
26
+ for (const text of batch) {
27
+ for (const line of String(text).split('\n')) {
28
+ const key = line.trim().toLowerCase();
29
+ if (key && !found.has(key)) {
30
+ found.set(key, line.trim());
31
+ fresh++;
32
+ }
33
+ }
34
+ }
35
+ return fresh;
36
+ });
37
+ quietRounds = added === 0 ? quietRounds + 1 : 0;
38
+ log(`round ${round}: ${added} new finding(s)`);
39
+ }
40
+
41
+ const verdicts = await foreach(
42
+ [...found.values()],
43
+ (issue) =>
44
+ agent(`Independently verify: "${issue}". Answer CONFIRMED or REJECTED with one sentence.`, { phase: 'Verify' }),
45
+ { concurrency: 4 }
46
+ );
47
+ const confirmed = [...found.values()].filter((_, i) => String(verdicts[i]).toUpperCase().startsWith('CONFIRMED'));
48
+ return { confirmed, rejected: found.size - confirmed.length, rounds: quietRounds };
@@ -0,0 +1,19 @@
1
+ // Routes a PR review to the run named in the PR body (Workflows.signal).
2
+ // Verbatim from workflows-spec 03 §3.2 (f) (WF-215 / WF-223 — the spec is normative).
3
+ // src/webhooks/github-review.ts — routes a PR review to the run named in the PR body (§3.7 `Workflows.signal`; 06 §6.5.3 step 2b descends it into the waiting review-round child)
4
+ import { LuaWebhook, Workflows } from 'lua-cli';
5
+
6
+ export default new LuaWebhook({
7
+ name: 'github-pr-review',
8
+ description: 'GitHub pull_request_review → github.review signal',
9
+ async execute({ headers, body }) {
10
+ if (headers['x-github-event'] !== 'pull_request_review') return { ignored: true };
11
+ const runId = /<!-- lua-run:(wfr_[A-Za-z0-9_-]+) -->/.exec(body.pull_request?.body ?? '')?.[1]; // the marker `openPr` wrote (above); absent ⇒ not one of ours
12
+ if (!runId) return { ignored: true };
13
+ const state = body.review.state === 'approved' ? 'approved' : body.review.state === 'changes_requested' ? 'changes_requested' : 'commented';
14
+ const r = await Workflows.signal(runId, 'github.review',
15
+ { state, comments: [{ body: body.review.body ?? '' }] }, // matches the wait's `schema`; inline review comments arrive on `pull_request_review_comment` and are folded the same way
16
+ { dedupeKey: `review:${body.review.id}` }); // GitHub redelivers: one signal per review id (06 §6.5)
17
+ return { runId, accepted: r.accepted, reason: r.reason }; // `accepted:false, reason:'source_not_accepted'` would mean the wait's acceptedSources exclude 'webhook' — it does not (above)
18
+ },
19
+ });
@@ -0,0 +1,21 @@
1
+ // The Linear label trigger that starts ticket-to-pr (07 §7.2.1 TriggerStartWorkflow).
2
+ // Verbatim from workflows-spec 03 §3.2 (f) (WF-215 / WF-223 — the spec is normative).
3
+ // src/triggers/linear-ready.ts — the Linear label that starts a run (07 §7.2.1 `TriggerStartWorkflow`; LuaTrigger has no execute — filter + transform only)
4
+ import { LuaTrigger } from 'lua-cli';
5
+
6
+ export default new LuaTrigger({
7
+ name: 'linear-ready-for-agent',
8
+ description: 'Linear "issue labeled" webhook → start ticket-to-pr',
9
+ source: 'webhook',
10
+ filter: (ctx) => ctx.payload?.type === 'Issue' && ctx.payload?.action === 'update'
11
+ && (ctx.payload.data?.labels ?? []).some((l: { name: string }) => l.name === 'ready-for-agent'), // any other label edit: the trigger's ordinary "filtered" verdict, no run
12
+ transform: (ctx) => {
13
+ const issue = ctx.payload.data;
14
+ return { startWorkflow: {
15
+ name: 'ticket-to-pr',
16
+ input: { ticketId: issue.identifier, title: issue.title, spec: issue.description ?? '', repo: 'https://github.com/acme/backend', baseRef: 'main' },
17
+ idempotencyKey: `linear:${issue.identifier}:ready-for-agent`, // a redelivered webhook or a label toggled twice returns the SAME run (§3.7; 07 §7.2.3 step 2)
18
+ // no `notify`: a trigger start runs as the system principal, and `notify` addresses the creator only (07 §7.4.1 — inert here); the human-facing surface is the PR itself + the approval inbox
19
+ } };
20
+ },
21
+ });