@sjawhar/opencode-legion-envoy 1.31.1 → 1.32.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.
@@ -14078,7 +14078,7 @@ var HANDOFF_SCHEMA_VERSION = 1;
14078
14078
  var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
14079
14079
  var isoTimestamp = string2().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
14080
14080
  var handoffPhase = _enum2(HANDOFF_PHASES);
14081
- var nonEmpty = string2().min(1);
14081
+ var nonEmpty = string2().trim().min(1);
14082
14082
  var proofSchema = object({
14083
14083
  criterion: nonEmpty,
14084
14084
  surface: nonEmpty,
@@ -14098,7 +14098,7 @@ var baseHandoffSchema = object({
14098
14098
  completed: isoTimestamp,
14099
14099
  learningsInjected: array(string2()).optional(),
14100
14100
  learningsHelpful: array(string2()).optional()
14101
- });
14101
+ }).passthrough();
14102
14102
  var architectSchema = baseHandoffSchema.extend({
14103
14103
  phase: literal("architect"),
14104
14104
  scope: _enum2(["trivial", "small", "medium", "large"]).optional(),
@@ -14106,7 +14106,7 @@ var architectSchema = baseHandoffSchema.extend({
14106
14106
  subIssues: array(string2()).optional(),
14107
14107
  routingHints: routingHintsSchema,
14108
14108
  concerns: array(string2()).optional()
14109
- }).passthrough();
14109
+ });
14110
14110
  var requiredSkillsSchema = object({
14111
14111
  implement: array(string2()).optional(),
14112
14112
  test: array(string2()).optional(),
@@ -14120,7 +14120,7 @@ var planSchema = baseHandoffSchema.extend({
14120
14120
  concerns: array(string2()).optional(),
14121
14121
  workflowRecommendation: string2().optional(),
14122
14122
  requiredSkills: requiredSkillsSchema
14123
- }).passthrough();
14123
+ });
14124
14124
  var implementSchema = baseHandoffSchema.extend({
14125
14125
  phase: literal("implement"),
14126
14126
  filesChanged: array(string2()).optional(),
@@ -14131,7 +14131,7 @@ var implementSchema = baseHandoffSchema.extend({
14131
14131
  subPlanningNeeded: boolean2().optional(),
14132
14132
  discoveredComplexity: array(string2()).optional(),
14133
14133
  suggestedSubWorkers: number2().optional()
14134
- }).passthrough();
14134
+ });
14135
14135
  var testSchema = baseHandoffSchema.extend({
14136
14136
  phase: literal("test"),
14137
14137
  passed: number2().optional(),
@@ -14141,9 +14141,15 @@ var testSchema = baseHandoffSchema.extend({
14141
14141
  proof: array(proofSchema).min(1).optional(),
14142
14142
  documentationFeedback: string2().optional(),
14143
14143
  observations: array(string2()).optional()
14144
- }).passthrough().refine((handoff) => (handoff.failures?.length ?? 0) > 0 || (handoff.failed ?? 0) > 0 || (handoff.proof?.length ?? 0) > 0, {
14144
+ }).refine((handoff) => (handoff.failures?.length ?? 0) > 0 || (handoff.failed ?? 0) > 0 || (handoff.proof?.length ?? 0) > 0, {
14145
14145
  path: ["proof"],
14146
14146
  message: "a passing test handoff needs the tester's own production-like proof"
14147
+ }).refine((handoff) => (handoff.failed ?? 0) === 0 || (handoff.failures?.length ?? 0) > 0, {
14148
+ path: ["failures"],
14149
+ message: "a test handoff that reports failed > 0 records at least one failure"
14150
+ }).refine((handoff) => handoff.implementerProof.verdict !== "rejected" || (handoff.failures?.length ?? 0) > 0, {
14151
+ path: ["failures"],
14152
+ message: "a rejected implementer proof is a recorded failure"
14147
14153
  });
14148
14154
  var reviewSchema = baseHandoffSchema.extend({
14149
14155
  phase: literal("review"),
@@ -14152,7 +14158,7 @@ var reviewSchema = baseHandoffSchema.extend({
14152
14158
  minor: number2().optional(),
14153
14159
  verdict: _enum2(["approved", "changes_requested"]).optional(),
14154
14160
  keyFindings: array(object({ severity: string2(), file: string2(), description: string2() }).passthrough()).optional()
14155
- }).passthrough();
14161
+ });
14156
14162
  var phaseHandoffSchema = discriminatedUnion("phase", [
14157
14163
  architectSchema,
14158
14164
  planSchema,
@@ -14225,6 +14231,12 @@ var stateTreeLocator = discriminatedUnion("runtime", [
14225
14231
  stateTmuxLocator.extend({ ompSessionFile: nonEmptyString.optional() }),
14226
14232
  stateK8sLocator.extend({ ompSessionFile: nonEmptyString.optional() })
14227
14233
  ]);
14234
+ var stateExternalControllerLocator = strictObject({
14235
+ runtime: literal("kubernetes"),
14236
+ external: literal(true),
14237
+ sessionId: nonEmptyString,
14238
+ registeredAt: number2().int().nonnegative()
14239
+ });
14228
14240
  var stateIssue = strictObject({
14229
14241
  key: nonEmptyString,
14230
14242
  title: string2(),
@@ -14280,7 +14292,7 @@ var LegionDaemonApi = {
14280
14292
  queue: array(nonEmptyString)
14281
14293
  }),
14282
14294
  gates: record(string2(), stateGate),
14283
- controllerLocator: stateTreeLocator.optional(),
14295
+ controllerLocator: union([stateTreeLocator, stateExternalControllerLocator]).optional(),
14284
14296
  roles: record(string2(), stateRole),
14285
14297
  controllerPendingNotices: number2().int().nonnegative(),
14286
14298
  pendingStatusWrites: array(nonEmptyString),
@@ -14295,6 +14307,10 @@ var LegionDaemonApi = {
14295
14307
  }),
14296
14308
  response: object({})
14297
14309
  },
14310
+ ControllerSecret: {
14311
+ request: strictObject({}),
14312
+ response: object({ secret: nonEmptyString })
14313
+ },
14298
14314
  ProcessStarted: {
14299
14315
  request: strictObject({
14300
14316
  tree: nonEmptyString,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "1.31.1",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -183,6 +183,10 @@ passage with `anchor`. Follow up on an ask or comment with `dispatch_comment`; c
183
183
  with a `dispatch://` reference (see [References](#references)). Never write "see above", "the
184
184
  message above", or "as attached".
185
185
 
186
+ **A decision about an uploaded artifact links it.** If the human must read an artifact to answer,
187
+ the question carries `dispatch://KEY/artifact/<slug>` (or `ref`), never just its filename. Text
188
+ they must read to decide belongs in the spec in the first place — see [Artifacts](#artifacts).
189
+
186
190
  Before saying you are waiting for human input, call `dispatch_open_asks`. It lists this session's active asks across open issues and project documents, including whether the human or agent owes the next reply.
187
191
 
188
192
  **Anything that needs the human is an ask, or it does not exist.** An approval, a credential,
@@ -431,6 +435,15 @@ with your text. Never do that: the spec is edited in place with `dispatch_doc_ed
431
435
  artifact by the slug shown in the upload result or by its filename, and a project document by its artifact id, slug, or filename; the
432
436
  slug also arrives on `artifact.created` events.
433
437
 
438
+ **Where a deliverable goes.** Text the human must read to decide — a draft message, a proposal,
439
+ a summary — goes in the spec as a section: the spec is the one document they open. A separate
440
+ artifact is for a real file: something sent as-is, a long report, a binary, a screenshot.
441
+
442
+ When you do upload one, the spec links it as `dispatch://KEY/artifact/<slug>` (the `slug` from the
443
+ upload result; it renders as a link) at the place the reader needs it, and the ask that needs the
444
+ decision carries the same reference. A heading or a sentence naming the filename is not a
445
+ reference.
446
+
434
447
  Documents are CommonMark. A bare `<https://example.com|text>` is a CommonMark autolink and is normalised: the angle brackets are
435
448
  dropped and the URL keeps `|text`. A backslash-escaped `\<https://example.com|text>` displays as `<https://example.com|text>` in the
436
449
  document but comes back re-escaped (`\<`) from `dispatch_doc_read`. A Slack mrkdwn draft, or any other payload that is not Markdown,
@@ -607,3 +620,38 @@ dispatch_message({
607
620
  body: "Release 1.4 is live on the devbox (dispatch://LEGION-815/artifact/release-notes). Nothing needed from you.",
608
621
  })
609
622
  ```
623
+
624
+ Before — a draft the human must read is uploaded as a separate file, the spec only names it, and
625
+ the ask does not point at it, so the reader has to go looking:
626
+
627
+ ```ts
628
+ dispatch_artifact({ issue: "OPS-52", name: "cu-update-2026-09-15.md", content: "Hi team, ..." })
629
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
630
+ { op: "insert", after: "## Context", markdown: "## Draft (artifact cu-update-2026-09-15.md)" },
631
+ ]})
632
+ dispatch_ask({ issue: "OPS-52", question: "Send the customer update as drafted?", options: [...] })
633
+ ```
634
+
635
+ After — the draft is a section of the spec, and the ask anchors there. If it really must be a
636
+ file (something to send as-is), the spec and the ask both link the slug from the upload result:
637
+
638
+ ```ts
639
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
640
+ { op: "insert", after: "## Context", markdown: "## Draft\n\nHi team, ..." },
641
+ ]})
642
+ dispatch_ask({
643
+ issue: "OPS-52",
644
+ question: "Send the customer update as drafted?",
645
+ options: [...],
646
+ anchor: { artifact: "spec", quote: "Hi team," },
647
+ })
648
+ // or, for a real file — the spec links it where the reader needs it, and so does the ask:
649
+ dispatch_doc_edit({ issue: "OPS-52", artifact: "spec", ops: [
650
+ { op: "insert", after: "## Context", markdown: "## Draft\n\nThe update to send as-is: dispatch://OPS-52/artifact/cu-update-2026-09-15-md" },
651
+ ]})
652
+ dispatch_ask({
653
+ issue: "OPS-52",
654
+ question: "Send this customer update as-is? dispatch://OPS-52/artifact/cu-update-2026-09-15-md",
655
+ options: [...],
656
+ })
657
+ ```
@@ -64,6 +64,26 @@ command run `/legion-claim-controller` again.
64
64
  This handshake lets the daemon redeliver held controller work. It does not turn the controller
65
65
  into a state holder: daemon state and the Dispatch project remain authoritative.
66
66
 
67
+ ### Started by the operator (runtime: kubernetes)
68
+
69
+ When the daemon runs inside a Kubernetes cluster it cannot open a terminal anywhere, so nobody
70
+ launched your pane: the operator ran `legion controller start --config controller.yaml
71
+ [--daemon-url <port-forward>]` on their own machine, and you are that foreground OMP session.
72
+ The command fetched a fresh controller secret from the daemon with the operator's token, wrote it
73
+ to a 0600 file under `LEGION_STATE_DIR` (`~/.local/state/legion/<project>-controller` by default)
74
+ beside the `gh` shim and the `legion` launcher, and started you with `LEGION_CONTROLLER=1` and
75
+ the same environment a tmux controller pane carries — so the extension claims the role and calls
76
+ `/controller/ready` exactly as under tmux, and nothing changes in how you handle wakes. The
77
+ daemon records you as `controllerLocator: {runtime: "kubernetes", external: true, sessionId,
78
+ registeredAt}` and reads your liveness from the Envoy role registry (the holder of
79
+ `legion-<project>-controller` and its `last_seen`), not from a pane: keep the session running.
80
+ Exiting it leaves the project without a controller until the operator runs the command again —
81
+ the daemon logs `controller not registered; run legion controller start` once per boot-timeout
82
+ interval and launches nothing itself. `legion state`, `legion gh -- <args>`, and
83
+ `legion status <KEY> <status>` work here over `LEGION_DAEMON_URL` (the port-forward). A second
84
+ `legion controller start` replaces you: it mints a new secret, so your grants stop working and
85
+ the role moves to the new session.
86
+
67
87
  ## Deployment instructions
68
88
 
69
89
  Deployment instructions, when present, are the operator's standing rules for this repository —
@@ -99,7 +99,7 @@ dispatch_message({
99
99
 
100
100
  **Proofs read:** implementer <surface/command>, tester <surface/command>.
101
101
 
102
- **Production check:** <what the implementer will drive after the merge, or the action ask it opened>
102
+ **Production check:** <what the implementer will drive after the merge, or the deploy/restart step a human will have to perform first>
103
103
 
104
104
  <!-- legion: {"session":"<session-id>","phase":"retro"} -->`,
105
105
  })
@@ -280,6 +280,25 @@ Verified the implementer's proof by <re-running its command | driving the same s
280
280
  **Retarget:** Retargeting a pull request to a new base does not re-run Tests; after a retarget, rebase onto the new base and push — the new head runs Tests against the new merge result — and cite that run in the PR body.
281
281
  ```
282
282
 
283
+ **A proof** is the changed behaviour exercised on the surface a user reaches it through, recorded
284
+ as the exact command or run id, what was observed, the head SHA, and one negative control —
285
+ a deliberately broken input and the refusal or failure it produced. The surface is
286
+ **production-like** — the repository's real-process test harness and fixtures, a sandbox
287
+ repository, a real browser, a devN stack, staging, or a local stack with real migrations, one that
288
+ has the resource the change touches — and each `E2E` line carries a **link** to that run,
289
+ screenshot, or e2e; the merge queue does not approve a user-facing change without it, and a
290
+ green unit suite is not it. A unit or integration test is a regression lock, never proof of a
291
+ criterion. Sami, 2026-09-13, verbatim: "They need to test everything in a production-like
292
+ environment before merging, and it is the agent that develops the feature that is responsible
293
+ for doing that. If there's anything blocking that, we need to fix it: if it's infrastructure, we
294
+ need to fix it; if it's tooling, we need to develop it; if it's skills, we need to fix the skills
295
+ ... it should not require deploying to production to realize your feature doesn't work."
296
+ Evidence for the rule: in the week of 2026-09-08 three surfaces merged green and were wrong on
297
+ inspection (the Astrolabe IPI stack, Dispatch on ECS, the candidate flow), and on 2026-09-12 six
298
+ deploy slots died on code first executed after merge, including a production-only ECS bootstrap
299
+ the whole staging gate never ran. The implementer's proof and the tester's proof below are both
300
+ this proof.
301
+
283
302
  - **Threads are dispositioned individually, never resolved in bulk.** Every open review
284
303
  thread gets its own line naming the fixing commit or the reason it isn't a defect. The
285
304
  reviewer answers each thread it opened with exactly one of `Accepted: fixed in <commit> — <one line>`,
@@ -319,40 +338,23 @@ Verified the implementer's proof by <re-running its command | driving the same s
319
338
  field names naming, duplication, or wording cleanup only; anything that changes behaviour,
320
339
  hides an error, or breaks a gate lands in this PR.
321
340
  - **The implementer proves the change before its phase completes, and writes the `E2E (implementer)` line when the pull request opens.**
322
- The proof is the changed behaviour exercised on the surface a user reaches it through — the daemon's
323
- test harness (`packages/daemon/src/daemon/__tests__/`) and real-process fixtures; a live check at the
324
- operator's next daemon restart, recorded on the PR; a sandbox repository, a real browser, a devN stack,
325
- or a local stack with real migrations — with the exact command or run id, what was observed, the head SHA,
326
- one negative control. The same proof goes into `.legion/implement.json` as its required `proof`
327
- array (`legion handoff write --phase implement` refuses a payload without one and names the
328
- field), and into the PR body, because the reviewer and the merger verify facts on GitHub and
329
- never from a handoff. A unit or integration test is a regression lock, never proof of a
330
- criterion.
341
+ The proof is the one defined above. It goes into `.legion/implement.json` as the required `proof`
342
+ array (`legion handoff write --phase implement` refuses a payload without one, or with a blank or
343
+ whitespace-only field, and names the field), and into the PR body, because the reviewer and the
344
+ merger verify facts on GitHub and never from a handoff.
331
345
  - **The tester verifies the implementer's proof and adds its own `E2E (tester)` line.** It re-runs
332
- the implementer's command or drives the same surface independently, records the verdict in
333
- `.legion/test.json` as `implementerProof` (`{verdict, how}`), and records its own proof beside
334
- it. A test handoff whose predecessor carried no proof is a test failure, not a gap for the tester to fill:
346
+ the implementer's command or drives the same surface independently, and records the verdict in
347
+ `.legion/test.json` as `implementerProof` (`{verdict, how}`).
348
+ A test handoff whose predecessor carried no proof is a test failure, not a gap for the tester to fill:
335
349
  record it in `failures` with `implementerProof.verdict: "rejected"`, complete the phase, and let
336
350
  the architect return the issue to the implementer — the agent that developed the change owns
337
- proving it. The tester's own proof names the real surface a user reaches the criterion
338
- through, the exact command or run id, what was observed, the head SHA, and one negative
339
- control — a deliberately broken input and the refusal or failure it produced. The surface is
340
- **production-like** — a devN stack, staging, or a local stack with real migrations, one that
341
- has the resource the change touches — and each `E2E` line carries a **link** to that run,
342
- screenshot, or e2e; the merge queue does not approve a user-facing change without it, and a
343
- green unit suite is not it. Sami, 2026-09-13, verbatim: "They need to test everything in a
344
- production-like environment before merging, and it is the agent that develops the feature
345
- that is responsible for doing that. If there's anything blocking that, we need to fix it: if
346
- it's infrastructure, we need to fix it; if it's tooling, we need to develop it; if it's
347
- skills, we need to fix the skills ... it should not require deploying to production to
348
- realize your feature doesn't work." A code path whose first execution is after merge — a
351
+ proving it (`legion handoff write --phase test` refuses a rejected verdict, or `failed > 0`,
352
+ with no recorded failure). Otherwise, add your own proof before completing — a proof as defined
353
+ above — as the `E2E (tester)` line and the `proof` array `legion handoff write --phase test`
354
+ requires whenever you report no failure. A code path whose first execution is after merge — a
349
355
  deploy workflow's inline step, a post-merge helper, a production-only resource — is untested
350
356
  until the implementer has executed it against a devN stack; if no surface can reach it, the
351
- tester names that missing surface as the blocker instead of passing the phase. Evidence for
352
- the rule: in the week of 2026-09-08 three surfaces merged green and were wrong on inspection
353
- (the Astrolabe IPI stack, Dispatch on ECS, the candidate flow), and on 2026-09-12 six deploy
354
- slots died on code first executed after merge, including a production-only ECS bootstrap the
355
- whole staging gate never ran. Environment or
357
+ tester names that missing surface as the blocker instead of passing the phase. Environment or
356
358
  secret-scrub evidence (e.g. "`LEGION_*`/`DISPATCH_*`/`ENVOY_*` unset") is recorded once, in
357
359
  `.legion/test.json`, and only when the issue's acceptance criteria call for it — never
358
360
  re-pasted into the PR body each round. After a conflict-forced rebase, compute the
@@ -536,10 +538,10 @@ and, for the implementer, until the push has too. This is the committed copy the
536
538
  reads after revival. It is removed once, at the end of a clean review: the implementer pushes
537
539
  that deletion at the reviewer's direction. No other phase removes it — and once it is gone
538
540
  (`jj -R "$LEGION_WORKSPACE" file list -r @- .legion` prints nothing on stdout; jj warns on
539
- stderr), this gate no longer applies: a later rebase, bare-gate re-check, confirmation, or retro
540
- writes no `.legion/<phase>.json`, commits no handoff, and reports with `legion handoff complete`
541
- alone (below). Recreating `.legion/` after its deletion changes the approved head and restarts
542
- the review loop this rule exists to end.
541
+ stderr), this gate no longer applies: a later rebase, bare-gate re-check, confirmation, retro, or
542
+ the post-merge production check writes no `.legion/<phase>.json`, commits no handoff, and reports
543
+ with `legion handoff complete` alone (below). Recreating `.legion/` after its deletion changes the
544
+ approved head and restarts the review loop this rule exists to end.
543
545
 
544
546
  ## Completion: report to the architect, then stay
545
547