lua-cli 3.32.1 → 3.32.3

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.
@@ -1,6 +1,10 @@
1
1
  // Example (i) — adversarial verify: spawn finders until two consecutive rounds add nothing
2
2
  // new, then have an independent verifier confirm each finding. Script form (04 §4.3);
3
3
  // push with `lua push workflow`, run offline with `lua test workflow adversarial-verify`.
4
+ // LUA-751: `parallel()` takes THUNKS (04 §4.3.2 — `parallel(angles.map((angle) => () => agent(…)))`); an array of
5
+ // promises resolved to `null`s. And a `step()` closure is journaled once and never re-runs on replay, so it must not
6
+ // mutate module state: it RETURNS the fresh findings and the merge into `found` happens outside it — otherwise the
7
+ // replay tick saw an empty `found`, issued nothing to verify and diverged (`JOURNAL_DIVERGENCE(entry_before_issue)`).
4
8
  export const meta = {
5
9
  name: 'adversarial-verify',
6
10
  description: 'Find candidate issues from several angles, verify each independently, report the confirmed set',
@@ -13,29 +17,29 @@ const found = new Map();
13
17
  let quietRounds = 0;
14
18
  for (let round = 1; round <= 5 && quietRounds < 2; round++) {
15
19
  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
- })
20
+ args.angles.map(
21
+ (angle) => () =>
22
+ agent(`Round ${round}: list concrete issues in ${args.subject} from the ${angle} angle. One per line.`, {
23
+ phase: 'Find',
24
+ label: `find-${angle}-${round}`,
25
+ })
21
26
  )
22
27
  );
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;
28
+ // step(): a pure reduction journaled once — its closure may use anything, it never re-runs on replay, so it
29
+ // returns what it found and touches no state of its own; the merge below is plain script code that replays.
30
+ const fresh = await step(`dedupe-${round}`, () => {
31
+ const out = [];
26
32
  for (const text of batch) {
27
- for (const line of String(text).split('\n')) {
33
+ for (const line of String(text ?? '').split('\n')) {
28
34
  const key = line.trim().toLowerCase();
29
- if (key && !found.has(key)) {
30
- found.set(key, line.trim());
31
- fresh++;
32
- }
35
+ if (key && !found.has(key) && !out.some((f) => f.key === key)) out.push({ key, line: line.trim() });
33
36
  }
34
37
  }
35
- return fresh;
38
+ return out;
36
39
  });
37
- quietRounds = added === 0 ? quietRounds + 1 : 0;
38
- log(`round ${round}: ${added} new finding(s)`);
40
+ for (const f of fresh) found.set(f.key, f.line);
41
+ quietRounds = fresh.length === 0 ? quietRounds + 1 : 0;
42
+ log(`round ${round}: ${fresh.length} new finding(s)`);
39
43
  }
40
44
 
41
45
  const verdicts = await foreach(
@@ -1,5 +1,8 @@
1
1
  // foreach + approval + an exactly-once send (`ctx.once`).
2
- // Verbatim from workflows-spec 03 §3.2 (b) (WF-215 / WF-223 — the spec is normative).
2
+ // From workflows-spec 03 §3.2 (b) (WF-215 / WF-223 — the spec is normative). LUA-751: `sendEmails` reads the approval node's
3
+ // REAL output — `WorkflowApprovalOutput` (@lua/shared-types), the ONE shape the engine emits and `lua workflows run --approve`
4
+ // emits offline: the approver's edited `{ drafts }` rides under `editedPayload`; otherwise the drafts are read back from the
5
+ // `drafts` map the approval was shown (`getStepResult`) — the output never echoes the original payload.
3
6
  import { z } from 'zod';
4
7
  import { createStep, createWorkflow, fromInit, fromStep, template, AI, Channels } from 'lua-cli';
5
8
 
@@ -8,7 +11,7 @@ const draft = z.object({ to: z.string(), body: z.string() });
8
11
 
9
12
  const draftEmail = createStep({
10
13
  id: 'draftEmail',
11
- inputSchema: lead, // foreach passes the RAW item — `{ email, name }`, not `{ lead: {…} }` (§3.2.1 check 4)
14
+ inputSchema: lead, // foreach passes the RAW item — `{ email, name }`, not `{ lead: {…} }` (§3.2.1 check 4)
12
15
  outputSchema: draft,
13
16
  async execute({ inputData }) {
14
17
  // string overload → Promise<string> (`api-exports.ts:768`); the object overload returns `AiGenerateOutput{ text, … }` (`:786`, shared-types `ai-generate.types.ts:78-86`)
@@ -18,16 +21,22 @@ const draftEmail = createStep({
18
21
  });
19
22
  const sendEmails = createStep({
20
23
  id: 'sendEmails',
21
- inputSchema: z.object({ drafts: z.array(draft) }),
24
+ // The approval node's output (`WorkflowApprovalOutput`): what this step reads, `.passthrough()` for the rest of the envelope
25
+ // (`decision`, `text`, `note`, `editRevision`, `decidedBy`, `timedOut`, `escalations`).
26
+ inputSchema: z
27
+ .object({ approved: z.boolean(), editedPayload: z.object({ drafts: z.array(draft) }).optional() })
28
+ .passthrough(),
22
29
  outputSchema: z.object({ sent: z.number() }),
23
- sideEffects: 'external', // never auto-retried on platform-fault reclaim
24
- onError: 'park', // an EFFECT_IN_DOUBT parks the step for R37 instead of failing the run
25
- async execute({ inputData, once }) {
30
+ sideEffects: 'external', // never auto-retried on platform-fault reclaim
31
+ onError: 'park', // an EFFECT_IN_DOUBT parks the step for R37 instead of failing the run
32
+ async execute({ inputData, getStepResult, once }) {
33
+ if (!inputData.approved) return { sent: 0 }; // denied — data, not an exception (§06 §6.4.11); a timeout cancels the run before this step (`onTimeout:'cancel-run'`)
34
+ const { drafts } = inputData.editedPayload ?? getStepResult<{ drafts: z.infer<typeof draft>[] }>('drafts'); // the approver's edits, else the batch the approval showed
26
35
  let sent = 0;
27
- for (const d of inputData.drafts) {
36
+ for (const d of drafts) {
28
37
  // exactly-once per {occurrenceId, key}: a retry, resume, repair run or migrated run that reaches this line again gets the stored
29
38
  // result back and never re-sends (I27 claim/settle). No hand-rolled Data check-then-act — that pattern was non-atomic.
30
- const r = await once(d.to, () => Channels.email.send({ to: { email: d.to }, subject: 'Hello', body: d.body })); // `EmailSendInput.to` is `{ userId?, email? }`
39
+ const r = await once(d.to, () => Channels.email.send({ to: { email: d.to }, subject: 'Hello', body: d.body })); // `EmailSendInput.to` is `{ userId?, email? }`
31
40
  // The whole `Channels` facade is callable in-step — incl. `Channels.whatsapp.send({ threadId: ctx.runtime.replyTo!.threadId, text })` on a customer-channel run (07 §7.0-L / §7.4.5): a step MAY answer the customer
32
41
  // itself (e.g. the refund outcome right after the Stripe step, before a QA step that must not delay it); `once()` covers it exactly like email, and the platform's terminal reply then lands as a
33
42
  // duplicate-safe fallback (same threadId; the customer sees two lines only if the step's text differs). Outside WhatsApp's session the in-step send needs a template too — `Channels.whatsapp.sendTemplate` (closing pass 2026-08-27).
@@ -45,11 +54,18 @@ export const outreach = createWorkflow({
45
54
  scheduleInput: { leads: [] },
46
55
  concurrencyPolicy: 'forbid',
47
56
  })
48
- .map({ leads: fromInit('leads') }, { id: 'leads' }) // entry 1 — three maps in this workflow ⇒ every id explicit (`map-id-required`)
49
- .map({ '': fromStep('leads', 'leads') }, { id: 'items' }) // entry 2 — '' key = "output IS this value" (Lua extension, §3.4): foreach needs a raw array upstream
50
- .foreach(draftEmail, { concurrency: 8, maxItems: 500 }) // entry 3 — items are `lead`s, output is `draft[]`
51
- .map({ drafts: fromStep('draftEmail') }, { id: 'drafts' }) // entry 4 — `{ drafts: draft[] }`, the approval's editable payload
52
- .approval('reviewDrafts', { title: 'Approve outreach batch', details: template('${stepResults.drafts.drafts.length} drafts ready'),
53
- approver: 'org-admins', timeoutHours: 48, onTimeout: 'cancel-run', editable: true, editablePaths: ['drafts', 'drafts[*].body'] }) // entry 5
54
- .then(sendEmails) // entry 6 — reads `stepResults.drafts` (the approval passes its edited payload through)
57
+ .map({ leads: fromInit('leads') }, { id: 'leads' }) // entry 1 — three maps in this workflow ⇒ every id explicit (`map-id-required`)
58
+ .map({ '': fromStep('leads', 'leads') }, { id: 'items' }) // entry 2 — '' key = "output IS this value" (Lua extension, §3.4): foreach needs a raw array upstream
59
+ .foreach(draftEmail, { concurrency: 8, maxItems: 500 }) // entry 3 — items are `lead`s, output is `draft[]`
60
+ .map({ drafts: fromStep('draftEmail') }, { id: 'drafts' }) // entry 4 — `{ drafts: draft[] }`, the approval's editable payload
61
+ .approval('reviewDrafts', {
62
+ title: 'Approve outreach batch',
63
+ details: template('${stepResults.drafts.drafts.length} drafts ready'),
64
+ approver: 'org-admins',
65
+ timeoutHours: 48,
66
+ onTimeout: 'cancel-run',
67
+ editable: true,
68
+ editablePaths: ['drafts', 'drafts[*].body'],
69
+ }) // entry 5
70
+ .then(sendEmails) // entry 6 — its input IS the approval's output; the drafts come from `editedPayload` or `getStepResult('drafts')`
55
71
  .commit();
@@ -13,9 +13,13 @@ const pushFix = createStep({
13
13
  tier: 'job',
14
14
  workspace: { mount: 'rw' },
15
15
  timeoutSeconds: 600,
16
- async execute({ workspace }) {
17
- // git runs through the credential proxy — no token in this container (05 §5.17.3)
18
- const sha = (await $`git -C ${workspace!.path} rev-parse HEAD`).stdout.trim(); // `$` = the Job image's shell helper (`@lua/coding-harness/shell`); a plain child_process spawn works too the Job site has no REQUIRE_BLOCKLIST for `child_process`
16
+ async execute({ $, workspace, log }) {
17
+ // `ctx.$` (LUA-682) runs an allow-listed binary in the checkoutargv only, no shell with git's remote on the
18
+ // credential proxy: no token in this container (05 §5.17.3). `child_process` is NOT available to a code step
19
+ // (`lua compile` refuses it: node-capability-unavailable); a coding turn's `shell` tool is the other way to run
20
+ // commands. The harness pushes the branch at the checkpoint / terminal; this step reports what it pushed.
21
+ const sha = (await $!.strict`git rev-parse HEAD`).stdout.trim();
22
+ log(`pushed ${sha} on ${workspace!.branch ?? 'the run branch'}`);
19
23
  return { headSha: sha };
20
24
  },
21
25
  });
@@ -1,22 +1,35 @@
1
1
  // Maker-checker refund with escalation, business-hours deadlines and a recoverable external step.
2
- // Verbatim from workflows-spec 03 §3.2 (e) (WF-215 / WF-223 — the spec is normative).
2
+ // From workflows-spec 03 §3.2 (e) (WF-215 / WF-223 — the spec is normative). LUA-751: the step after the approval reads the
3
+ // approval node's REAL output — `WorkflowApprovalOutput` (@lua/shared-types): `{ approved, decision, text, note?, editedPayload?,
4
+ // editRevision?, decidedBy?, timedOut?, escalations? }`, the ONE shape the engine emits and `lua workflows run --approve` emits
5
+ // offline. The listing's `input: <preceding output>` pass-through (04 §4.2.3) was never implemented — the original payload is not
6
+ // echoed back; the step reads the request back with `getStepResult('refundRequest')` (docs/workflows/approvals.md). The `refundRequest`
7
+ // map ahead of the approval makes the payload the approver sees and edits exactly what `editedPayloadSchema` describes
8
+ // (`{ ticketId, amount }`, not the whole run input): an edit is judged on the pointers that changed against that payload.
3
9
  import { z } from 'zod';
4
- import { createStep, createWorkflow, template, Integrations } from 'lua-cli';
10
+ import { createStep, createWorkflow, fromInit, template, Integrations } from 'lua-cli';
11
+
12
+ const refundRequest = z.object({ ticketId: z.string(), amount: z.number() });
5
13
 
6
14
  const postRefund = createStep({
7
15
  id: 'postRefund',
8
- inputSchema: z.object({
9
- approved: z.boolean(),
10
- editedPayload: z.object({ ticketId: z.string(), amount: z.number() }).optional(),
11
- input: z.object({ ticketId: z.string(), amount: z.number() }),
12
- }), // the approval node's output shape (04 §4.2.3)
16
+ // The approval node's output (`WorkflowApprovalOutput`): the members this step reads; `.passthrough()` admits the rest of the
17
+ // envelope (`decision`, `text`, `note`, `editRevision`, `decidedBy`, `timedOut`, `escalations`) — the engine validates a step's
18
+ // input with `additionalProperties:false` otherwise.
19
+ inputSchema: z
20
+ .object({
21
+ approved: z.boolean(),
22
+ editedPayload: refundRequest.optional(), // only when the approver edited the amount (`editablePaths` below)
23
+ })
24
+ .passthrough(),
13
25
  outputSchema: z.object({ refundId: z.string().nullable() }),
14
26
  sideEffects: 'external', // a platform-fault reclaim PARKS this step (never auto-retried); the run gates `exception` (§06 §6.3.5)
15
27
  onError: 'park', // a final customer-fault failure parks too — the operator retries / skips / supplies the refundId / fails it
16
28
  requiredConnections: ['stripe'], // the declared key below — resolved on the owner agent at run time (LUA-623); no connection of that type ⇒ failed{credentials_unresolved}, unmountable ⇒ failed{credentials_revoked, reason:'required_connection_unmountable'} → parked via onError:'park' (§11 §11.5.5)
17
- async execute({ inputData, occurrenceId }) {
18
- if (!inputData.approved) return { refundId: null };
19
- const r = inputData.editedPayload ?? inputData.input;
29
+ async execute({ inputData, getStepResult, occurrenceId }) {
30
+ if (!inputData.approved) return { refundId: null }; // denied, or timed out under the chain's terminal 'deny' — data, never an exception (§06 §6.4.11)
31
+ // The edited details when the approver lowered the amount, else the request the approver saw (the `refundRequest` map). The output never echoes it.
32
+ const r = inputData.editedPayload ?? getStepResult<z.infer<typeof refundRequest>>('refundRequest');
20
33
  // `occurrenceId` is `${lineageId}:${stepId}`: the same key on retry-step, on resume and on a REPAIR RUN — the refund collapses to one (§06 §6.3.3 (e)).
21
34
  // A key that must hold across INDEPENDENT runs (two runs for the same ticket) is the caller's: `refund:${r.ticketId}` (§06 §6.3.3 (f)).
22
35
  const res = await Integrations.passthrough('stripe', {
@@ -36,6 +49,7 @@ export const refund = createWorkflow({
36
49
  budget: { maxDurationSeconds: 14 * 24 * 3600 }, // long enough for two business-hours escalation hops (the compiler warns `deadline-clamped` otherwise)
37
50
  connections: [{ key: 'stripe', integrationType: 'stripe', required: true }], // the Stripe connection the refund step acts through, declared once by key — never a frozen connection id (LUA-623)
38
51
  })
52
+ .map({ ticketId: fromInit('ticketId'), amount: fromInit('amount') }, { id: 'refundRequest' }) // the payload the approver reviews — and may edit — is the refund request, not the whole run input
39
53
  .approval('approveRefund', {
40
54
  title: 'Refund request',
41
55
  details: template('Refund ${initData.amount} for ticket ${initData.ticketId}'),
@@ -48,10 +62,10 @@ export const refund = createWorkflow({
48
62
  { escalateTo: { role: 'finance-controller' }, timeoutHours: 16 }, // …hop 1: finance, 16 business hours, then…
49
63
  { escalateTo: 'org-admins', timeoutHours: 24 }, // …hop 2: org admins, then…
50
64
  'deny',
51
- ], // …the node completes {approved:false, timedOut:true, escalations:2} — branchable data (§06 §6.4.11)
65
+ ], // …the node completes {approved:false, decision:'timed_out', text:'timed_out', timedOut:true, escalations:2} — branchable data (§06 §6.4.11)
52
66
  editable: true,
53
67
  editablePaths: ['amount'], // the approver may lower the amount; the approve call echoes the fingerprint of the revision it saw (§06 §6.4.9)
54
68
  editedPayloadSchema: z.object({ ticketId: z.string(), amount: z.number().positive().max(500) }),
55
69
  })
56
- .then(postRefund)
70
+ .then(postRefund) // its input IS the approval's output (the default previous-output rule, §05 §5.5.1.1)
57
71
  .commit();
@@ -1,4 +1,4 @@
1
- // Linear label -> clone -> parallel worktree arms -> merge -> tests -> PR -> review loop -> approval -> merge.
1
+ // Linear label -> clone -> parallel worktree arms -> merge -> tests -> PR (coding turn) -> review loop -> approval -> the decision (the merge stays human).
2
2
  // From workflows-spec 03 §3.2 (f) (WF-215 / WF-223 — the spec is normative). LUA-635: `openPr` is placed by `.then(openPr)` on
3
3
  // both test outcomes — the listing's `otherwise: 'openPr'` named a `.then(createStep)` placement, which never declares an id
4
4
  // (03 §3.2.0); a string arm names an agentStep / specialistStep / toolStep / map / workflow declaration. `mergeGate.title` is a
@@ -16,49 +16,43 @@ const runTests = createStep({
16
16
  workspace: { mount: 'rw' },
17
17
  timeoutSeconds: 3600,
18
18
  jobResources: 'large', // a 15–40 min monorepo suite fits one 4 h segment (D19-r1); up to 86 400 s is legal since D19-r2 — see §3.2 (h) for a step that crosses the segment boundary
19
- async execute({ workspace, log }) {
20
- const r = await $`cd ${workspace!.path} && npm ci && npm test -- --maxWorkers=2`.nothrow(); // untracked node_modules were rebuilt on restore (05 §5.17.5, E12); `--maxWorkers=2` keeps a mongodb-memory-server-per-file suite inside `large`'s 8 GiB over it the container is OOM-killed and the step fails `job_oom_killed` (customer fault, 05 §5.17.4; §3.12 note)
19
+ async execute({ $, log }) {
20
+ // `ctx.$` runs ONE allow-listed binary per call, argv only, in the checkout (LUA-682): no `cd`, no `&&` the
21
+ // cwd is the workspace root and a second command is a second call. `.strict` throws on a non-zero exit; the
22
+ // plain form returns it, which is what a test run wants.
23
+ await $!.strict`npm ci`; // untracked node_modules were rebuilt on restore (05 §5.17.5, E12)
24
+ const r = await $!`npm test -- --maxWorkers=2`; // `--maxWorkers=2` keeps a mongodb-memory-server-per-file suite inside `large`'s 8 GiB — over it the container is OOM-killed and the step fails `job_oom_killed` (customer fault, 05 §5.17.4; §3.12 note)
21
25
  log(r.stdout.slice(-4000));
22
- return { passed: r.exitCode === 0, summary: r.stdout.slice(-2000) };
26
+ return { passed: r.code === 0, summary: r.stdout.slice(-2000) };
23
27
  },
24
28
  });
25
29
 
26
- const openPr = createStep({
27
- id: 'openPr',
28
- inputSchema: z.object({ ticketId: z.string(), title: z.string() }),
29
- outputSchema: z.object({ prNumber: z.number(), url: z.string() }),
30
- tier: 'job',
31
- workspace: { mount: 'ro' },
32
- sideEffects: 'external',
33
- onError: 'park', // a PR is an external effect: a platform fault PARKS (06 §6.3.5); `gh` is idempotent on the branch anyway
34
- async execute({ workspace, inputData, runId }) {
35
- const out =
36
- await $`cd ${workspace!.path} && gh pr create --fill --title ${inputData.title} --body ${`Closes ${inputData.ticketId}\n\n<!-- lua-run:${runId} -->`}`; // the run id in the PR body is what the GitHub webhook uses to route review signals (§3.7)
37
- const url = out.stdout.trim();
38
- return { prNumber: Number(url.split('/').pop()), url };
39
- },
40
- });
30
+ // `openPr` is a CODING TURN (05 §5.17.6) — the pilot's real pattern: the harness's own `gh` tool talks to the sidecar's
31
+ // gh proxy. A Job-tier code step can run `gh` too (`ctx.$`, with `jobTools:['gh']` on the step), but the proxy admits
32
+ // `gh pr create` / `gh pr edit` / `gh pr comment` on the pinned repo only — no merge, no /graphql (T17-D-SE25) — and the
33
+ // end-to-end path is the sidecar's to prove; the coding turn is what ships PRs today.
41
34
 
42
- const mergePr = createStep({
43
- id: 'mergePr',
44
- inputSchema: z.object({ approved: z.boolean(), input: z.object({ prNumber: z.number() }) }),
45
- outputSchema: z.object({ merged: z.boolean() }),
46
- tier: 'job',
47
- workspace: { mount: 'ro' },
48
- jobTools: ['gh'],
49
- sideEffects: 'external',
50
- onError: 'park', // `jobTools:['gh']` is what mints GH_TOKEN for a code step (11 §11.5.6: pull_requests:write + contents:write + issues:read, repo-scoped — merge is a contents write on GitHub)
51
- async execute({ workspace, inputData }) {
52
- if (!inputData.approved) return { merged: false };
53
- await $`cd ${workspace!.path} && gh pr merge ${inputData.input.prNumber} --squash --delete-branch`; // succeeds only if the base branch's ruleset lets the App merge — human-only-merge orgs keep contents:write off the App (11 T21)
54
- return { merged: true };
35
+ const recordDecision = createStep({
36
+ id: 'recordDecision',
37
+ inputSchema: z.object({ approved: z.boolean(), prNumber: z.number() }), // projected by the `mergeInput` map below — an approval's output carries `approved` (and `decidedBy`, `editRevision`…), never the payload it was shown (docs/workflows/approvals.md), so the PR number is read back from `openPr`
38
+ outputSchema: z.object({ approved: z.boolean(), prNumber: z.number(), merged: z.boolean() }),
39
+ async execute({ inputData, log }) {
40
+ // The merge itself stays with a person: `gh pr merge` is refused by the sidecar BY DESIGN (a contents write; human-only-merge
41
+ // orgs keep contents:write off the App, 11 T21), so the workflow ends at the decision — `merged` is always false here and
42
+ // the approver merges when ready.
43
+ log(
44
+ inputData.approved
45
+ ? `PR #${inputData.prNumber} approved merge when ready`
46
+ : `PR #${inputData.prNumber} not approved`
47
+ );
48
+ return { approved: inputData.approved, prNumber: inputData.prNumber, merged: false };
55
49
  },
56
50
  });
57
51
 
58
52
  export const ticketToPr = createWorkflow({
59
53
  name: 'ticket-to-pr',
60
54
  description:
61
- 'Linear label → implement/tests/docs in parallel worktrees → merge → test suite → PR → review rounds → approval → merge',
55
+ 'Linear label → implement/tests/docs in parallel worktrees → merge → test suite → PR → review rounds → approval → decision (a human merges)',
62
56
  inputSchema: z.object({
63
57
  ticketId: z.string(),
64
58
  title: z.string(),
@@ -66,7 +60,7 @@ export const ticketToPr = createWorkflow({
66
60
  repo: z.string(),
67
61
  baseRef: z.string().default('main'),
68
62
  }),
69
- outputSchema: z.object({ merged: z.boolean(), prUrl: z.string().optional(), rounds: z.number() }),
63
+ outputSchema: z.object({ approved: z.boolean(), prNumber: z.number(), merged: z.boolean() }), // the decision; the merge is a human act (see `recordDecision`)
70
64
  connections: [{ key: 'github', integrationType: 'github', required: true }], // the GitHub connection this workflow acts through, declared ONCE by key — resolved on the owner agent at run time (an agent-scoped GitHub connection first, then org-scoped; LUA-623), so the same definition runs on any agent: no frozen connection id in source, no per-agent build
71
65
  workspace: {
72
66
  kind: 'git',
@@ -117,7 +111,19 @@ export const ticketToPr = createWorkflow({
117
111
  'The test suite failed:\n${stepResults.runTests.summary}\nFix the code (not the tests unless they are wrong) and re-run `npm test` until green.'
118
112
  ),
119
113
  })
120
- .then(openPr)
114
+ .agentStep('openPr', {
115
+ // a CODING TURN with `gh` (05 §5.17.6): opens the PR from the run branch and reports it as typed output
116
+ agentId: 'swe-implementer',
117
+ tier: 'job',
118
+ workspace: { mount: 'ro' },
119
+ timeoutSeconds: 600,
120
+ jobResources: 'small',
121
+ prompt: template(
122
+ 'Open a pull request from the current branch against ${initData.baseRef} for ticket ${initData.ticketId}, titled "${initData.title}". Put the marker the harness gives you for this run in the PR body (the GitHub webhook routes review signals by it). Reply with the PR number and URL.'
123
+ ),
124
+ toolScope: { jobTools: ['gh', 'git', 'read', 'glob', 'grep'] }, // `gh` here is the harness's tool through the sidecar's gh proxy (create / edit / comment on this repo only)
125
+ outputSchema: z.object({ prNumber: z.number(), url: z.string() }),
126
+ })
121
127
  .dowhile('reviewRound', eq(step('reviewRound').path('state'), lit('changes_requested')), { maxIterations: 8 }) // ≤ 8 review rounds (the SWE_REVIEW_MAX_ROUNDS lesson)
122
128
  .workflow(
123
129
  'reviewRound',
@@ -133,5 +139,6 @@ export const ticketToPr = createWorkflow({
133
139
  timeoutHours: 72,
134
140
  onTimeout: 'deny',
135
141
  })
136
- .then(mergePr)
142
+ .map({ approved: fromStep('mergeGate', 'approved'), prNumber: fromStep('openPr', 'prNumber') }, { id: 'mergeInput' }) // a `.then(step)` receives the PREVIOUS node's output — the approval's, which has `approved` but not the PR number — so the fields `recordDecision` declares are projected explicitly; without this map the row fails `input_schema_invalid` before dispatch (LUA-679, found by the LUA-669 builder)
143
+ .then(recordDecision)
137
144
  .commit();
@@ -20,7 +20,7 @@
20
20
  "inquirer": "^12.9.6",
21
21
  "stripe": "^17.5.0",
22
22
  "js-yaml": "^4.1.0",
23
- "lua-cli": "^3.32.1",
23
+ "lua-cli": "^3.32.3",
24
24
  "openai": "^5.23.0",
25
25
  "uuid": "^13.0.0",
26
26
  "zod": "^3.24.1"