@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.
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
}).
|
|
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
|
-
})
|
|
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
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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}`)
|
|
334
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
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
|
|
540
|
-
writes no `.legion/<phase>.json`, commits no handoff, and reports
|
|
541
|
-
alone (below). Recreating `.legion/` after its deletion changes the
|
|
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
|
|