@sjawhar/pi-legion-envoy 1.65.2 → 1.66.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/envoy.js
CHANGED
|
@@ -34747,6 +34747,64 @@ function messageMetadata(input) {
|
|
|
34747
34747
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
34748
34748
|
var import_nats = __toESM(require_mod4(), 1);
|
|
34749
34749
|
|
|
34750
|
+
// src/legion/phase-stall.ts
|
|
34751
|
+
var PHASE_STALL_ENTRY = "legion-phase-stall";
|
|
34752
|
+
var LOCAL_ENVOY_NOTICE = { localNotice: true };
|
|
34753
|
+
var WAITING_REPLY = /^\W*WAITING\b/m;
|
|
34754
|
+
var TOOL_CALL_SHAPED = /<(?:[\w-]+:)?(?:function_calls\b|invoke\s+name\s*=|parameter\s+name\s*=)/;
|
|
34755
|
+
var FOLLOW_UP = "Your turn ended with your Legion phase still open: the `legion` tool's `handoff_complete` has " + "not succeeded since your assignment. If your phase's work is done, call the `legion` tool now " + "with op `handoff_complete` and the fields your role's instructions give. If you are waiting on " + "something (CI, a review, a person), reply with a line starting WAITING that names what you are " + "waiting on; nothing more will be sent until a new event arrives.";
|
|
34756
|
+
var TEXT_TOOL_CALL = "Your last message holds a tool call written as text, so it did not run: make it a real tool call. ";
|
|
34757
|
+
function stepPhaseStall(state, input) {
|
|
34758
|
+
switch (input.kind) {
|
|
34759
|
+
case "assignment":
|
|
34760
|
+
return { state: "open" };
|
|
34761
|
+
case "handoff-complete":
|
|
34762
|
+
return { state: "closed" };
|
|
34763
|
+
case "inbound-event":
|
|
34764
|
+
return { state: state === "quiet" ? "open" : state };
|
|
34765
|
+
case "settle":
|
|
34766
|
+
if (state !== "open")
|
|
34767
|
+
return { state };
|
|
34768
|
+
if (WAITING_REPLY.test(input.lastAssistantText))
|
|
34769
|
+
return { state: "quiet" };
|
|
34770
|
+
return {
|
|
34771
|
+
state: "quiet",
|
|
34772
|
+
followUp: TOOL_CALL_SHAPED.test(input.lastAssistantText) ? TEXT_TOOL_CALL + FOLLOW_UP : FOLLOW_UP
|
|
34773
|
+
};
|
|
34774
|
+
}
|
|
34775
|
+
}
|
|
34776
|
+
function inboundKind(message) {
|
|
34777
|
+
if (typeof message !== "object" || message === null || !("role" in message))
|
|
34778
|
+
return;
|
|
34779
|
+
if (message.role === "user")
|
|
34780
|
+
return "assignment";
|
|
34781
|
+
if (message.role === "custom" && "customType" in message && message.customType === "envoy-message" && !(("details" in message) && typeof message.details === "object" && message.details !== null && ("localNotice" in message.details) && message.details.localNotice === LOCAL_ENVOY_NOTICE.localNotice))
|
|
34782
|
+
return "inbound-event";
|
|
34783
|
+
return;
|
|
34784
|
+
}
|
|
34785
|
+
function assistantText(message) {
|
|
34786
|
+
if (typeof message !== "object" || message === null)
|
|
34787
|
+
return "";
|
|
34788
|
+
if (!("role" in message) || message.role !== "assistant" || !("content" in message))
|
|
34789
|
+
return "";
|
|
34790
|
+
if (!Array.isArray(message.content))
|
|
34791
|
+
return "";
|
|
34792
|
+
return message.content.map((block) => typeof block === "object" && block !== null && ("type" in block) && block.type === "text" && ("text" in block) && typeof block.text === "string" ? block.text : "").join(`
|
|
34793
|
+
`);
|
|
34794
|
+
}
|
|
34795
|
+
function isPhaseStall(value) {
|
|
34796
|
+
return value === "closed" || value === "open" || value === "quiet";
|
|
34797
|
+
}
|
|
34798
|
+
function restorePhaseStall(branch) {
|
|
34799
|
+
for (let index = branch.length - 1;index >= 0; index -= 1) {
|
|
34800
|
+
const entry = branch[index];
|
|
34801
|
+
if (typeof entry === "object" && entry !== null && "type" in entry && entry.type === "custom" && "customType" in entry && entry.customType === PHASE_STALL_ENTRY && "data" in entry && typeof entry.data === "object" && entry.data !== null && "state" in entry.data && isPhaseStall(entry.data.state)) {
|
|
34802
|
+
return entry.data.state;
|
|
34803
|
+
}
|
|
34804
|
+
}
|
|
34805
|
+
return "closed";
|
|
34806
|
+
}
|
|
34807
|
+
|
|
34750
34808
|
// src/legion/role-claim-bridge.ts
|
|
34751
34809
|
var LEGION_ROLE_CLAIM_BRIDGE = Symbol.for("legion.pi-envoy.role-claim-bridge");
|
|
34752
34810
|
function legionRoleClaimBridge() {
|
|
@@ -35391,7 +35449,8 @@ function envoyExtension(pi) {
|
|
|
35391
35449
|
detail: "This conversation now lives under a new session id. Any identity you shared earlier (envoy_whoami output, session ids named in messages) is stale, and peers using the old id cannot reach you. Re-run envoy_whoami before identifying yourself and re-announce the new id to peers you already introduced yourself to."
|
|
35392
35450
|
}
|
|
35393
35451
|
}),
|
|
35394
|
-
display: true
|
|
35452
|
+
display: true,
|
|
35453
|
+
details: LOCAL_ENVOY_NOTICE
|
|
35395
35454
|
}, { deliverAs: "steer", triggerTurn: false });
|
|
35396
35455
|
};
|
|
35397
35456
|
pi.on("session_switch", (event, context) => rebind(event.reason, context));
|
|
@@ -35496,7 +35555,7 @@ ${formatOpenAsksSummary(open.snapshot, open.url)}`,
|
|
|
35496
35555
|
}
|
|
35497
35556
|
});
|
|
35498
35557
|
const announceFollow = createFollowAnnouncer((text) => {
|
|
35499
|
-
pi.sendMessage({ customType: "envoy-message", content: text, display: true }, { deliverAs: "steer", triggerTurn: false });
|
|
35558
|
+
pi.sendMessage({ customType: "envoy-message", content: text, display: true, details: LOCAL_ENVOY_NOTICE }, { deliverAs: "steer", triggerTurn: false });
|
|
35500
35559
|
});
|
|
35501
35560
|
pi.on("tool_result", async (event) => {
|
|
35502
35561
|
if (event.isError)
|
package/dist/legion.js
CHANGED
|
@@ -16142,7 +16142,7 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
16142
16142
|
// package.json
|
|
16143
16143
|
var package_default = {
|
|
16144
16144
|
name: "@sjawhar/pi-legion-envoy",
|
|
16145
|
-
version: "1.
|
|
16145
|
+
version: "1.66.0",
|
|
16146
16146
|
type: "module",
|
|
16147
16147
|
omp: {
|
|
16148
16148
|
extensions: [
|
|
@@ -31326,6 +31326,13 @@ async function writeGrantFile(file2, grantId) {
|
|
|
31326
31326
|
throw new Error(`LEGION_GRANT_FILE ${file2} could not be written: ${messageFor(error48)}`);
|
|
31327
31327
|
}
|
|
31328
31328
|
}
|
|
31329
|
+
async function writeMintedGrant(mint) {
|
|
31330
|
+
const file2 = process.env.LEGION_GRANT_FILE;
|
|
31331
|
+
if (file2 === undefined || file2.trim() === "") {
|
|
31332
|
+
throw new Error("LEGION_GRANT_FILE is not set on this pane: the daemon that launched it predates this plugin; restart the daemon on the matching release");
|
|
31333
|
+
}
|
|
31334
|
+
await writeGrantFile(file2, await mint());
|
|
31335
|
+
}
|
|
31329
31336
|
|
|
31330
31337
|
// src/legion/go-bootstrap.ts
|
|
31331
31338
|
import path3 from "path";
|
|
@@ -31799,6 +31806,141 @@ function toolFailure(error48) {
|
|
|
31799
31806
|
return { content: [{ type: "text", text: messageFor(error48) }], details: {}, isError: true };
|
|
31800
31807
|
}
|
|
31801
31808
|
|
|
31809
|
+
// src/legion/handoff-actions.ts
|
|
31810
|
+
import { execFile as execFile2 } from "child_process";
|
|
31811
|
+
var HANDOFF_OPERATION_FIELDS = {
|
|
31812
|
+
handoff_write: ["phase", "data"],
|
|
31813
|
+
handoff_read: ["phase"],
|
|
31814
|
+
handoff_message: ["sender", "recipient", "body"],
|
|
31815
|
+
handoff_complete: ["summary", "verdict", "ready", "phase"]
|
|
31816
|
+
};
|
|
31817
|
+
var HANDOFF_OPERATIONS = Object.keys(HANDOFF_OPERATION_FIELDS);
|
|
31818
|
+
function isHandoffOperation(operation) {
|
|
31819
|
+
return Object.hasOwn(HANDOFF_OPERATION_FIELDS, operation);
|
|
31820
|
+
}
|
|
31821
|
+
function handoffSchemaFields(z2) {
|
|
31822
|
+
return {
|
|
31823
|
+
phase: z2.enum(HANDOFF_PHASES).optional(),
|
|
31824
|
+
data: z2.unknown().optional(),
|
|
31825
|
+
sender: z2.enum(HANDOFF_PHASES).optional(),
|
|
31826
|
+
recipient: z2.enum(HANDOFF_PHASES).optional(),
|
|
31827
|
+
body: z2.string().optional(),
|
|
31828
|
+
summary: z2.string().optional(),
|
|
31829
|
+
verdict: z2.enum(["pass", "fail"]).optional(),
|
|
31830
|
+
ready: z2.boolean().optional()
|
|
31831
|
+
};
|
|
31832
|
+
}
|
|
31833
|
+
var HANDOFF_DESCRIPTION = "Handoff actions (phase workers and sub-architects): handoff_write writes this phase's handoff " + "(`phase`, and `data`: the phase-specific fields as a JSON object) to `.legion/<phase>.json` in " + "the issue workspace; handoff_read returns the handoffs (every phase, or `phase`); " + "handoff_message leaves a message for another phase (`sender`, `recipient`, `body`); " + "handoff_complete reports this phase complete to the daemon (`summary`: two sentences for the " + "architect; `verdict` pass|fail when your role's instructions require one; `ready: true` for " + "the merger's READY; no `phase` is needed, and one other than your own is refused). Each " + "returns the command's output; a failed action changed nothing.";
|
|
31834
|
+
var ROLE_HANDOFF_PHASE = {
|
|
31835
|
+
architect: "architect",
|
|
31836
|
+
planner: "plan",
|
|
31837
|
+
plan: "plan",
|
|
31838
|
+
implementer: "implement",
|
|
31839
|
+
implement: "implement",
|
|
31840
|
+
tester: "test",
|
|
31841
|
+
test: "test",
|
|
31842
|
+
reviewer: "review",
|
|
31843
|
+
review: "review"
|
|
31844
|
+
};
|
|
31845
|
+
var COMMAND_TIMEOUT_MS = 300000;
|
|
31846
|
+
function required2(parameters, operation, name) {
|
|
31847
|
+
const value = parameters[name];
|
|
31848
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
31849
|
+
throw new Error(`${operation} requires ${name}`);
|
|
31850
|
+
}
|
|
31851
|
+
return value;
|
|
31852
|
+
}
|
|
31853
|
+
function commandArguments(operation, parameters) {
|
|
31854
|
+
const accepted = HANDOFF_OPERATION_FIELDS[operation];
|
|
31855
|
+
for (const name of Object.keys(parameters)) {
|
|
31856
|
+
if (name !== "op" && !accepted.includes(name)) {
|
|
31857
|
+
throw new Error(`${operation} does not accept field "${name}"`);
|
|
31858
|
+
}
|
|
31859
|
+
}
|
|
31860
|
+
switch (operation) {
|
|
31861
|
+
case "handoff_write": {
|
|
31862
|
+
const data = parameters.data;
|
|
31863
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
31864
|
+
throw new Error("handoff_write requires data: the phase's handoff fields as a JSON object");
|
|
31865
|
+
}
|
|
31866
|
+
const phase = required2(parameters, operation, "phase");
|
|
31867
|
+
return ["handoff", "write", "--phase", phase, "--data", JSON.stringify(data)];
|
|
31868
|
+
}
|
|
31869
|
+
case "handoff_read":
|
|
31870
|
+
return parameters.phase === undefined ? ["handoff", "read"] : ["handoff", "read", "--phase", required2(parameters, operation, "phase")];
|
|
31871
|
+
case "handoff_message":
|
|
31872
|
+
return [
|
|
31873
|
+
"handoff",
|
|
31874
|
+
"message",
|
|
31875
|
+
"--from",
|
|
31876
|
+
required2(parameters, operation, "sender"),
|
|
31877
|
+
"--to",
|
|
31878
|
+
required2(parameters, operation, "recipient"),
|
|
31879
|
+
"--body",
|
|
31880
|
+
required2(parameters, operation, "body")
|
|
31881
|
+
];
|
|
31882
|
+
case "handoff_complete": {
|
|
31883
|
+
const command = [
|
|
31884
|
+
"handoff",
|
|
31885
|
+
"complete",
|
|
31886
|
+
"--summary",
|
|
31887
|
+
required2(parameters, operation, "summary")
|
|
31888
|
+
];
|
|
31889
|
+
const { verdict, ready, phase } = parameters;
|
|
31890
|
+
if (phase !== undefined) {
|
|
31891
|
+
const role = requiredEnvironment(process.env, "LEGION_ROLE");
|
|
31892
|
+
const own = ROLE_HANDOFF_PHASE[role];
|
|
31893
|
+
if (own === undefined) {
|
|
31894
|
+
throw new Error(`handoff_complete takes no phase for a ${role}, which writes no handoff`);
|
|
31895
|
+
}
|
|
31896
|
+
if (phase !== own) {
|
|
31897
|
+
throw new Error(`handoff_complete's phase is "${own}" for a ${role}: omit phase, or pass "${own}"`);
|
|
31898
|
+
}
|
|
31899
|
+
}
|
|
31900
|
+
if (verdict !== undefined) {
|
|
31901
|
+
if (verdict !== "pass" && verdict !== "fail") {
|
|
31902
|
+
throw new Error("handoff_complete's verdict is pass or fail");
|
|
31903
|
+
}
|
|
31904
|
+
command.push("--verdict", verdict);
|
|
31905
|
+
}
|
|
31906
|
+
if (ready !== undefined && typeof ready !== "boolean") {
|
|
31907
|
+
throw new Error("handoff_complete's ready is true or false");
|
|
31908
|
+
}
|
|
31909
|
+
if (ready === true)
|
|
31910
|
+
command.push("--ready");
|
|
31911
|
+
return command;
|
|
31912
|
+
}
|
|
31913
|
+
}
|
|
31914
|
+
}
|
|
31915
|
+
function runLegion(args, cwd, signal) {
|
|
31916
|
+
const { promise: promise2, resolve, reject } = Promise.withResolvers();
|
|
31917
|
+
execFile2("legion", args, { cwd, env: process.env, signal, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error48, stdout, stderr) => {
|
|
31918
|
+
const output = [stdout, stderr].map((text) => text.trimEnd()).filter((text) => text !== "").join(`
|
|
31919
|
+
`);
|
|
31920
|
+
if (error48 === null) {
|
|
31921
|
+
resolve({ exitCode: 0, output });
|
|
31922
|
+
} else if (typeof error48.code === "number") {
|
|
31923
|
+
resolve({ exitCode: error48.code, output });
|
|
31924
|
+
} else {
|
|
31925
|
+
reject(new Error(`legion ${args.slice(0, 2).join(" ")} did not run: ${messageFor(error48)}`));
|
|
31926
|
+
}
|
|
31927
|
+
});
|
|
31928
|
+
return promise2;
|
|
31929
|
+
}
|
|
31930
|
+
async function runHandoffAction(input) {
|
|
31931
|
+
const args = commandArguments(input.operation, input.parameters);
|
|
31932
|
+
if (input.operation === "handoff_complete")
|
|
31933
|
+
await writeMintedGrant(input.mintGrant);
|
|
31934
|
+
const { exitCode, output } = await runLegion(args, requiredEnvironment(process.env, "LEGION_WORKSPACE"), input.signal);
|
|
31935
|
+
const text = output === "" ? `legion ${args.slice(0, 2).join(" ")} exited ${exitCode}` : output;
|
|
31936
|
+
const details = { operation: input.operation, exitCode };
|
|
31937
|
+
if (exitCode !== 0)
|
|
31938
|
+
return { content: [{ type: "text", text }], details, isError: true };
|
|
31939
|
+
if (input.operation === "handoff_complete")
|
|
31940
|
+
input.onPhaseCompleted();
|
|
31941
|
+
return { content: [{ type: "text", text }], details };
|
|
31942
|
+
}
|
|
31943
|
+
|
|
31802
31944
|
// src/legion/go-tools.ts
|
|
31803
31945
|
var OPERATIONS = {
|
|
31804
31946
|
architect: [
|
|
@@ -31829,7 +31971,8 @@ function toolSchema(pi) {
|
|
|
31829
31971
|
"request_backward_move",
|
|
31830
31972
|
"retry_or_escalate",
|
|
31831
31973
|
"sign_off",
|
|
31832
|
-
"read_record"
|
|
31974
|
+
"read_record",
|
|
31975
|
+
...HANDOFF_OPERATIONS
|
|
31833
31976
|
]),
|
|
31834
31977
|
issue: z2.string().optional(),
|
|
31835
31978
|
artifactId: z2.string().optional(),
|
|
@@ -31837,7 +31980,8 @@ function toolSchema(pi) {
|
|
|
31837
31980
|
issues: z2.array(z2.string()).optional(),
|
|
31838
31981
|
to: z2.enum(LEGION_GO_PHASES).optional(),
|
|
31839
31982
|
reason: z2.string().optional(),
|
|
31840
|
-
decision: z2.enum(["retry", "escalate"]).optional()
|
|
31983
|
+
decision: z2.enum(["retry", "escalate"]).optional(),
|
|
31984
|
+
...handoffSchemaFields(z2)
|
|
31841
31985
|
});
|
|
31842
31986
|
}
|
|
31843
31987
|
function requiredString(parameters, operation, name) {
|
|
@@ -31874,17 +32018,29 @@ function recordFrom(state, issue2) {
|
|
|
31874
32018
|
return record2;
|
|
31875
32019
|
}
|
|
31876
32020
|
function createGoLegionTool(deps) {
|
|
31877
|
-
const { pi, daemon, session } = deps;
|
|
32021
|
+
const { pi, daemon, session, onPhaseCompleted } = deps;
|
|
31878
32022
|
return {
|
|
31879
32023
|
name: "legion",
|
|
31880
32024
|
label: "legion",
|
|
31881
|
-
description: "Perform the workflow operation the Go Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers.",
|
|
32025
|
+
description: "Perform the workflow operation the Go Legion daemon assigned this role. The daemon advances phases; this tool cannot spawn workers. " + HANDOFF_DESCRIPTION,
|
|
31882
32026
|
defaultInactive: true,
|
|
31883
32027
|
parameters: toolSchema(pi),
|
|
31884
|
-
execute: async (_id, parameters,
|
|
32028
|
+
execute: async (_id, parameters, signal, _onUpdate, context) => {
|
|
31885
32029
|
try {
|
|
31886
32030
|
const active = session(context);
|
|
31887
32031
|
const operation = requiredString(parameters, "legion", "op");
|
|
32032
|
+
if (isHandoffOperation(operation)) {
|
|
32033
|
+
if (active.kind === "architect" && active.issue === active.tree) {
|
|
32034
|
+
throw new Error(`${operation} is not available to a root architect session`);
|
|
32035
|
+
}
|
|
32036
|
+
return await runHandoffAction({
|
|
32037
|
+
operation,
|
|
32038
|
+
parameters,
|
|
32039
|
+
signal,
|
|
32040
|
+
mintGrant: () => grantFor(daemon(), active),
|
|
32041
|
+
onPhaseCompleted: () => onPhaseCompleted(context)
|
|
32042
|
+
});
|
|
32043
|
+
}
|
|
31888
32044
|
if (!OPERATIONS[active.kind].includes(operation)) {
|
|
31889
32045
|
throw new Error(`${operation} is not available to a ${active.kind} session`);
|
|
31890
32046
|
}
|
|
@@ -31957,13 +32113,71 @@ function createGoLegionTool(deps) {
|
|
|
31957
32113
|
};
|
|
31958
32114
|
}
|
|
31959
32115
|
|
|
32116
|
+
// src/legion/phase-stall.ts
|
|
32117
|
+
var PHASE_STALL_ENTRY = "legion-phase-stall";
|
|
32118
|
+
var LOCAL_ENVOY_NOTICE = { localNotice: true };
|
|
32119
|
+
var WAITING_REPLY = /^\W*WAITING\b/m;
|
|
32120
|
+
var TOOL_CALL_SHAPED = /<(?:[\w-]+:)?(?:function_calls\b|invoke\s+name\s*=|parameter\s+name\s*=)/;
|
|
32121
|
+
var FOLLOW_UP = "Your turn ended with your Legion phase still open: the `legion` tool's `handoff_complete` has " + "not succeeded since your assignment. If your phase's work is done, call the `legion` tool now " + "with op `handoff_complete` and the fields your role's instructions give. If you are waiting on " + "something (CI, a review, a person), reply with a line starting WAITING that names what you are " + "waiting on; nothing more will be sent until a new event arrives.";
|
|
32122
|
+
var TEXT_TOOL_CALL = "Your last message holds a tool call written as text, so it did not run: make it a real tool call. ";
|
|
32123
|
+
function stepPhaseStall(state, input) {
|
|
32124
|
+
switch (input.kind) {
|
|
32125
|
+
case "assignment":
|
|
32126
|
+
return { state: "open" };
|
|
32127
|
+
case "handoff-complete":
|
|
32128
|
+
return { state: "closed" };
|
|
32129
|
+
case "inbound-event":
|
|
32130
|
+
return { state: state === "quiet" ? "open" : state };
|
|
32131
|
+
case "settle":
|
|
32132
|
+
if (state !== "open")
|
|
32133
|
+
return { state };
|
|
32134
|
+
if (WAITING_REPLY.test(input.lastAssistantText))
|
|
32135
|
+
return { state: "quiet" };
|
|
32136
|
+
return {
|
|
32137
|
+
state: "quiet",
|
|
32138
|
+
followUp: TOOL_CALL_SHAPED.test(input.lastAssistantText) ? TEXT_TOOL_CALL + FOLLOW_UP : FOLLOW_UP
|
|
32139
|
+
};
|
|
32140
|
+
}
|
|
32141
|
+
}
|
|
32142
|
+
function inboundKind(message) {
|
|
32143
|
+
if (typeof message !== "object" || message === null || !("role" in message))
|
|
32144
|
+
return;
|
|
32145
|
+
if (message.role === "user")
|
|
32146
|
+
return "assignment";
|
|
32147
|
+
if (message.role === "custom" && "customType" in message && message.customType === "envoy-message" && !(("details" in message) && typeof message.details === "object" && message.details !== null && ("localNotice" in message.details) && message.details.localNotice === LOCAL_ENVOY_NOTICE.localNotice))
|
|
32148
|
+
return "inbound-event";
|
|
32149
|
+
return;
|
|
32150
|
+
}
|
|
32151
|
+
function assistantText(message) {
|
|
32152
|
+
if (typeof message !== "object" || message === null)
|
|
32153
|
+
return "";
|
|
32154
|
+
if (!("role" in message) || message.role !== "assistant" || !("content" in message))
|
|
32155
|
+
return "";
|
|
32156
|
+
if (!Array.isArray(message.content))
|
|
32157
|
+
return "";
|
|
32158
|
+
return message.content.map((block) => typeof block === "object" && block !== null && ("type" in block) && block.type === "text" && ("text" in block) && typeof block.text === "string" ? block.text : "").join(`
|
|
32159
|
+
`);
|
|
32160
|
+
}
|
|
32161
|
+
function isPhaseStall(value) {
|
|
32162
|
+
return value === "closed" || value === "open" || value === "quiet";
|
|
32163
|
+
}
|
|
32164
|
+
function restorePhaseStall(branch) {
|
|
32165
|
+
for (let index = branch.length - 1;index >= 0; index -= 1) {
|
|
32166
|
+
const entry = branch[index];
|
|
32167
|
+
if (typeof entry === "object" && entry !== null && "type" in entry && entry.type === "custom" && "customType" in entry && entry.customType === PHASE_STALL_ENTRY && "data" in entry && typeof entry.data === "object" && entry.data !== null && "state" in entry.data && isPhaseStall(entry.data.state)) {
|
|
32168
|
+
return entry.data.state;
|
|
32169
|
+
}
|
|
32170
|
+
}
|
|
32171
|
+
return "closed";
|
|
32172
|
+
}
|
|
32173
|
+
|
|
31960
32174
|
// src/legion/tools.ts
|
|
31961
32175
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
31962
32176
|
var jsonSuccess2 = (details) => toolSuccess(JSON.stringify(details), details);
|
|
31963
32177
|
function isDispatchArtifactId(value) {
|
|
31964
32178
|
return LegionDaemonApi.GatesRegister.request.shape.artifactId.safeParse(value).success;
|
|
31965
32179
|
}
|
|
31966
|
-
var
|
|
32180
|
+
var ARCHITECT_OP_FIELDS = {
|
|
31967
32181
|
set_status: ["issue", "status"],
|
|
31968
32182
|
register_gate: ["issue", "artifactId", "version"],
|
|
31969
32183
|
release_wave: ["issues"],
|
|
@@ -31973,7 +32187,7 @@ var LEGION_OP_FIELDS = {
|
|
|
31973
32187
|
function legionToolSchema(pi) {
|
|
31974
32188
|
const z2 = pi.zod;
|
|
31975
32189
|
return z2.object({
|
|
31976
|
-
op: z2.enum([
|
|
32190
|
+
op: z2.enum([...Object.keys(ARCHITECT_OP_FIELDS), ...HANDOFF_OPERATIONS]),
|
|
31977
32191
|
issue: z2.string().optional(),
|
|
31978
32192
|
status: z2.enum(ISSUE_STATUSES).optional(),
|
|
31979
32193
|
artifactId: z2.string().optional(),
|
|
@@ -31983,30 +32197,51 @@ function legionToolSchema(pi) {
|
|
|
31983
32197
|
issues: z2.array(z2.string()).optional(),
|
|
31984
32198
|
rationale: z2.string().optional(),
|
|
31985
32199
|
role: z2.enum(LEGION_ROLES).optional(),
|
|
31986
|
-
task: z2.string().optional()
|
|
32200
|
+
task: z2.string().optional(),
|
|
32201
|
+
...handoffSchemaFields(z2)
|
|
31987
32202
|
});
|
|
31988
32203
|
}
|
|
31989
32204
|
function createLegionTool(deps) {
|
|
31990
|
-
const { pi, roleDaemon,
|
|
32205
|
+
const { pi, roleDaemon, session, onPhaseCompleted } = deps;
|
|
31991
32206
|
return {
|
|
31992
32207
|
name: "legion",
|
|
31993
32208
|
label: "legion",
|
|
31994
|
-
description: "Perform a Legion lifecycle write through the Legion daemon. " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead. A call ' + 'that fails with "got no response in 3 attempts" was retried by the plugin with one ' + "request id; the daemon may still have received it \u2014 read legion state " + "(workerAdmission.queue, and the role in roles) before sending it again. A spawn_worker " + "identical to the task already queued for the role changes nothing and is not announced again.",
|
|
32209
|
+
description: "Perform a Legion lifecycle write through the Legion daemon. " + "Architect operations (set_status, register_gate, release_wave, escalate, spawn_worker): " + "register_gate records the root spec document a human must approve: `artifactId` is the " + "document id (a UUID) and `version` the version number, both copied from the `artifact` and " + "`version` fields of dispatch_request_approval's result \u2014 never the slug or file name you " + "passed to that tool. " + `spawn_worker's response "status" means: "spawned" \u2014 a fresh pane just opened and is ` + 'running now; "resumed" \u2014 an existing worker was prompted directly over its live socket ' + "and its turn started, or (if its boot has not confirmed yet) its task was recorded to " + 'deliver once that boot completes; "queued" \u2014 the task was recorded and this role will ' + "start on its own: either the running-worker cap is full, or the live worker acknowledged " + "the task without starting a turn and the daemon is retrying it. Never re-spawn a role " + 'after "resumed" or "queued" \u2014 wait for the worker-started notification instead. A call ' + 'that fails with "got no response in 3 attempts" was retried by the plugin with one ' + "request id; the daemon may still have received it \u2014 read legion state " + "(workerAdmission.queue, and the role in roles) before sending it again. A spawn_worker " + "identical to the task already queued for the role changes nothing and is not announced again. " + HANDOFF_DESCRIPTION,
|
|
31995
32210
|
defaultInactive: true,
|
|
31996
32211
|
parameters: legionToolSchema(pi),
|
|
31997
|
-
execute: async (_id, parameters,
|
|
32212
|
+
execute: async (_id, parameters, signal, _onUpdate, context) => {
|
|
31998
32213
|
try {
|
|
31999
|
-
const
|
|
32000
|
-
const daemon = roleDaemon();
|
|
32214
|
+
const active = session(context);
|
|
32001
32215
|
const sessionId = context.sessionManager.getSessionId();
|
|
32216
|
+
const op = String(parameters.op);
|
|
32217
|
+
if (isHandoffOperation(op)) {
|
|
32218
|
+
if (active.kind !== "phase-worker") {
|
|
32219
|
+
throw new Error(`${op} is not available to a root architect session`);
|
|
32220
|
+
}
|
|
32221
|
+
return await runHandoffAction({
|
|
32222
|
+
operation: op,
|
|
32223
|
+
parameters,
|
|
32224
|
+
signal,
|
|
32225
|
+
mintGrant: async () => (await roleDaemon().grant({
|
|
32226
|
+
tree: active.tree,
|
|
32227
|
+
issue: active.issue,
|
|
32228
|
+
sessionId,
|
|
32229
|
+
secret: active.secret
|
|
32230
|
+
})).grantId,
|
|
32231
|
+
onPhaseCompleted: () => onPhaseCompleted(context)
|
|
32232
|
+
});
|
|
32233
|
+
}
|
|
32234
|
+
if (active.role !== "architect") {
|
|
32235
|
+
throw new Error(`${op} is not available to a ${active.role} session`);
|
|
32236
|
+
}
|
|
32237
|
+
const daemon = roleDaemon();
|
|
32002
32238
|
const stringInput = (name) => {
|
|
32003
32239
|
const value = parameters[name];
|
|
32004
32240
|
if (typeof value !== "string")
|
|
32005
32241
|
throw new Error(`${String(parameters.op)} requires ${name}`);
|
|
32006
32242
|
return value;
|
|
32007
32243
|
};
|
|
32008
|
-
const
|
|
32009
|
-
const allowedFields = LEGION_OP_FIELDS[op];
|
|
32244
|
+
const allowedFields = ARCHITECT_OP_FIELDS[op];
|
|
32010
32245
|
if (allowedFields) {
|
|
32011
32246
|
for (const key of Object.keys(parameters)) {
|
|
32012
32247
|
if (key !== "op" && !allowedFields.includes(key)) {
|
|
@@ -32021,9 +32256,9 @@ function createLegionTool(deps) {
|
|
|
32021
32256
|
throw new Error("set_status requires a valid Legion issue status");
|
|
32022
32257
|
}
|
|
32023
32258
|
await daemon.issueStatus({
|
|
32024
|
-
tree:
|
|
32259
|
+
tree: active.tree,
|
|
32025
32260
|
sessionId,
|
|
32026
|
-
secret:
|
|
32261
|
+
secret: active.secret,
|
|
32027
32262
|
issue: stringInput("issue"),
|
|
32028
32263
|
status
|
|
32029
32264
|
});
|
|
@@ -32039,9 +32274,9 @@ function createLegionTool(deps) {
|
|
|
32039
32274
|
throw new Error(`register_gate requires artifactId to be the document id (a UUID) from dispatch_request_approval's result, not "${artifactId}"`);
|
|
32040
32275
|
}
|
|
32041
32276
|
await daemon.gatesRegister({
|
|
32042
|
-
tree:
|
|
32277
|
+
tree: active.tree,
|
|
32043
32278
|
sessionId,
|
|
32044
|
-
secret:
|
|
32279
|
+
secret: active.secret,
|
|
32045
32280
|
issue: stringInput("issue"),
|
|
32046
32281
|
artifactId,
|
|
32047
32282
|
version: version2
|
|
@@ -32054,10 +32289,10 @@ function createLegionTool(deps) {
|
|
|
32054
32289
|
throw new Error("release_wave requires issues");
|
|
32055
32290
|
}
|
|
32056
32291
|
return jsonSuccess2(await daemon.releaseWave({
|
|
32057
|
-
tree:
|
|
32292
|
+
tree: active.tree,
|
|
32058
32293
|
issues,
|
|
32059
32294
|
sessionId,
|
|
32060
|
-
secret:
|
|
32295
|
+
secret: active.secret
|
|
32061
32296
|
}));
|
|
32062
32297
|
}
|
|
32063
32298
|
case "escalate": {
|
|
@@ -32068,11 +32303,11 @@ function createLegionTool(deps) {
|
|
|
32068
32303
|
if (!("context" in parameters) || parameters.context === undefined)
|
|
32069
32304
|
throw new Error("escalate requires context");
|
|
32070
32305
|
await daemon.escalate({
|
|
32071
|
-
tree:
|
|
32306
|
+
tree: active.tree,
|
|
32072
32307
|
kind,
|
|
32073
32308
|
context: parameters.context,
|
|
32074
32309
|
sessionId,
|
|
32075
|
-
secret:
|
|
32310
|
+
secret: active.secret
|
|
32076
32311
|
});
|
|
32077
32312
|
return jsonSuccess2({});
|
|
32078
32313
|
}
|
|
@@ -32083,9 +32318,9 @@ function createLegionTool(deps) {
|
|
|
32083
32318
|
}
|
|
32084
32319
|
const requestId = randomUUID2();
|
|
32085
32320
|
return jsonSuccess2(await daemon.spawnWorker({
|
|
32086
|
-
tree:
|
|
32321
|
+
tree: active.tree,
|
|
32087
32322
|
sessionId,
|
|
32088
|
-
secret:
|
|
32323
|
+
secret: active.secret,
|
|
32089
32324
|
issue: stringInput("issue"),
|
|
32090
32325
|
role,
|
|
32091
32326
|
task: stringInput("task"),
|
|
@@ -32180,16 +32415,8 @@ var callReadyWithRetry = async (label, call) => {
|
|
|
32180
32415
|
}
|
|
32181
32416
|
};
|
|
32182
32417
|
async function wrapBashWithGrant(mint) {
|
|
32183
|
-
const grantFile = process.env.LEGION_GRANT_FILE;
|
|
32184
|
-
if (grantFile === undefined || grantFile.trim() === "") {
|
|
32185
|
-
return {
|
|
32186
|
-
block: true,
|
|
32187
|
-
reason: "LEGION_GRANT_FILE is not set on this pane: the daemon that launched it predates this plugin; restart the daemon on the matching release"
|
|
32188
|
-
};
|
|
32189
|
-
}
|
|
32190
32418
|
try {
|
|
32191
|
-
|
|
32192
|
-
await writeGrantFile(grantFile, grant.grantId);
|
|
32419
|
+
await writeMintedGrant(async () => (await mint()).grantId);
|
|
32193
32420
|
return;
|
|
32194
32421
|
} catch (error48) {
|
|
32195
32422
|
return { block: true, reason: messageFor(error48) };
|
|
@@ -32355,6 +32582,16 @@ function legionExtension(pi) {
|
|
|
32355
32582
|
let bootstrap;
|
|
32356
32583
|
const checkSubagentSession = subagentSessionCheck();
|
|
32357
32584
|
let phaseWorkerPane;
|
|
32585
|
+
let phaseStall = "closed";
|
|
32586
|
+
const phaseWorkerSession = (context) => capability !== undefined && capability.sessionID === context.sessionManager.getSessionId() && capability.role !== "architect";
|
|
32587
|
+
const advancePhaseStall = (input) => {
|
|
32588
|
+
const step = stepPhaseStall(phaseStall, input);
|
|
32589
|
+
if (step.state !== phaseStall) {
|
|
32590
|
+
phaseStall = step.state;
|
|
32591
|
+
pi.appendEntry(PHASE_STALL_ENTRY, { state: phaseStall });
|
|
32592
|
+
}
|
|
32593
|
+
return step.followUp;
|
|
32594
|
+
};
|
|
32358
32595
|
let daemonClient;
|
|
32359
32596
|
const roleDaemon = () => {
|
|
32360
32597
|
daemonClient ??= createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"), fetch, {
|
|
@@ -32504,7 +32741,7 @@ function legionExtension(pi) {
|
|
|
32504
32741
|
generation: generation(process.env)
|
|
32505
32742
|
}));
|
|
32506
32743
|
});
|
|
32507
|
-
|
|
32744
|
+
registerLegionTool();
|
|
32508
32745
|
await activateLegionTool();
|
|
32509
32746
|
} catch (error48) {
|
|
32510
32747
|
if (error48 instanceof LegionDaemonApiError && (error48.status === 401 || error48.status === 403)) {
|
|
@@ -32560,10 +32797,8 @@ function legionExtension(pi) {
|
|
|
32560
32797
|
secret: started.secret
|
|
32561
32798
|
};
|
|
32562
32799
|
await claimEnvoyRole(sessionID, started.roleToken, context);
|
|
32563
|
-
|
|
32564
|
-
|
|
32565
|
-
await activateLegionTool();
|
|
32566
|
-
}
|
|
32800
|
+
registerLegionTool();
|
|
32801
|
+
await activateLegionTool();
|
|
32567
32802
|
await callReadyWithRetry("worker/ready", () => roleDaemon().workerReady({
|
|
32568
32803
|
tree,
|
|
32569
32804
|
issue: issue2,
|
|
@@ -32591,6 +32826,7 @@ function legionExtension(pi) {
|
|
|
32591
32826
|
pi.on("session_start", async (_event, context) => {
|
|
32592
32827
|
if (await checkSubagentSession(context))
|
|
32593
32828
|
return;
|
|
32829
|
+
phaseStall = restorePhaseStall(context.sessionManager.getBranch?.() ?? []);
|
|
32594
32830
|
if (process.env.LEGION_DAEMON_API === "go") {
|
|
32595
32831
|
await bootstrapGoClaim(context, {
|
|
32596
32832
|
capability: () => capability,
|
|
@@ -32695,6 +32931,22 @@ function legionExtension(pi) {
|
|
|
32695
32931
|
secret: active.secret
|
|
32696
32932
|
}));
|
|
32697
32933
|
});
|
|
32934
|
+
pi.on("message_start", async (event, context) => {
|
|
32935
|
+
if (!phaseWorkerSession(context))
|
|
32936
|
+
return;
|
|
32937
|
+
const kind = inboundKind(event.message);
|
|
32938
|
+
if (kind !== undefined)
|
|
32939
|
+
advancePhaseStall({ kind });
|
|
32940
|
+
});
|
|
32941
|
+
pi.on("session_stop", async (event, context) => {
|
|
32942
|
+
if (event.signal.aborted || !phaseWorkerSession(context))
|
|
32943
|
+
return;
|
|
32944
|
+
const followUp = advancePhaseStall({
|
|
32945
|
+
kind: "settle",
|
|
32946
|
+
lastAssistantText: assistantText(event.last_assistant_message)
|
|
32947
|
+
});
|
|
32948
|
+
return followUp === undefined ? undefined : { continue: true, additionalContext: followUp };
|
|
32949
|
+
});
|
|
32698
32950
|
pi.on("session_shutdown", async (_event, context) => {
|
|
32699
32951
|
const sessionID = context.sessionManager.getSessionId();
|
|
32700
32952
|
if (capability === undefined || capability.kind !== "root-architect" || capability.sessionID !== sessionID) {
|
|
@@ -32714,17 +32966,22 @@ function legionExtension(pi) {
|
|
|
32714
32966
|
controlConnection = undefined;
|
|
32715
32967
|
}
|
|
32716
32968
|
});
|
|
32717
|
-
const
|
|
32969
|
+
const toolSession = (context) => {
|
|
32718
32970
|
const sessionID = context.sessionManager.getSessionId();
|
|
32719
|
-
if (capability
|
|
32720
|
-
|
|
32721
|
-
tree: capability.tree,
|
|
32722
|
-
issue: capability.issue,
|
|
32723
|
-
role: capability.role,
|
|
32724
|
-
secret: capability.secret
|
|
32725
|
-
};
|
|
32971
|
+
if (capability === undefined || capability.sessionID !== sessionID) {
|
|
32972
|
+
throw new Error("legion is available only to this session's registered Legion role");
|
|
32726
32973
|
}
|
|
32727
|
-
|
|
32974
|
+
return {
|
|
32975
|
+
kind: capability.kind,
|
|
32976
|
+
tree: capability.tree,
|
|
32977
|
+
issue: capability.issue,
|
|
32978
|
+
role: capability.role,
|
|
32979
|
+
secret: capability.secret
|
|
32980
|
+
};
|
|
32981
|
+
};
|
|
32982
|
+
const onPhaseCompleted = (context) => {
|
|
32983
|
+
if (phaseWorkerSession(context))
|
|
32984
|
+
advancePhaseStall({ kind: "handoff-complete" });
|
|
32728
32985
|
};
|
|
32729
32986
|
const activateLegionTool = async () => {
|
|
32730
32987
|
const activeTools = pi.getActiveTools();
|
|
@@ -32732,12 +32989,12 @@ function legionExtension(pi) {
|
|
|
32732
32989
|
return;
|
|
32733
32990
|
await pi.setActiveTools([...activeTools, "legion"]);
|
|
32734
32991
|
};
|
|
32735
|
-
let
|
|
32736
|
-
const
|
|
32737
|
-
if (
|
|
32992
|
+
let legionToolRegistered = false;
|
|
32993
|
+
const registerLegionTool = () => {
|
|
32994
|
+
if (legionToolRegistered)
|
|
32738
32995
|
return;
|
|
32739
|
-
|
|
32740
|
-
pi.registerTool(createLegionTool({ pi, roleDaemon,
|
|
32996
|
+
legionToolRegistered = true;
|
|
32997
|
+
pi.registerTool(createLegionTool({ pi, roleDaemon, session: toolSession, onPhaseCompleted }));
|
|
32741
32998
|
};
|
|
32742
32999
|
let goToolsRegistered = false;
|
|
32743
33000
|
const registerGoTools = () => {
|
|
@@ -32760,7 +33017,8 @@ function legionExtension(pi) {
|
|
|
32760
33017
|
issue: active.issue,
|
|
32761
33018
|
secret: active.secret
|
|
32762
33019
|
};
|
|
32763
|
-
}
|
|
33020
|
+
},
|
|
33021
|
+
onPhaseCompleted
|
|
32764
33022
|
}));
|
|
32765
33023
|
};
|
|
32766
33024
|
pi.registerCommand("legion-claim-controller", {
|
|
@@ -21,7 +21,7 @@ separate coordinator to finish necessary work.
|
|
|
21
21
|
instead of starting a fresh one. Phases on one issue are strictly sequential -- one role
|
|
22
22
|
is the issue's active phase at a time, and calling `spawn_worker` for a different role
|
|
23
23
|
while a phase is active supersedes that phase: the superseded worker's
|
|
24
|
-
`
|
|
24
|
+
`handoff_complete` is then refused, so finish (or deliberately abandon) one role
|
|
25
25
|
before assigning the next. Phase workers escalate lifecycle, scope, and
|
|
26
26
|
cross-phase matters the same way: `envoy_publish` to your own encoded token. Any role
|
|
27
27
|
may use `dispatch_ask` directly for a standalone human question; replies return to the
|
|
@@ -108,8 +108,8 @@ dispatch_message({
|
|
|
108
108
|
|
|
109
109
|
The Dispatch message and the `docs/solutions/` commit are the only retro outputs. Never write a
|
|
110
110
|
handoff, phase artifact, local feedback log, or completion label; `.legion/` was deleted before
|
|
111
|
-
retro and nothing recreates it. Report completion with `legion
|
|
112
|
-
summary: two sentences for the architect) — no `
|
|
111
|
+
retro and nothing recreates it. Report completion with the `legion` tool's `handoff_complete` alone (its
|
|
112
|
+
summary: two sentences for the architect) — no `handoff_write`.
|
|
113
113
|
|
|
114
114
|
## Completion check
|
|
115
115
|
|
|
@@ -105,7 +105,7 @@ Read only files that precede the assigned phase. Every handoff is validated when
|
|
|
105
105
|
`validatePhaseHandoff` (`packages/contracts/src/handoff-schema.ts`) checks the file, and the
|
|
106
106
|
ledger (`packages/daemon/src/handoff/ledger.ts`) treats a file that fails validation as missing.
|
|
107
107
|
Undeclared fields pass validation untouched and reach the next worker; a declared field of the
|
|
108
|
-
wrong type fails the whole file, so `legion
|
|
108
|
+
wrong type fails the whole file, so the `legion` tool's `handoff_read` returns null for that phase.
|
|
109
109
|
Write the phase-specific fields the next phase and the architect need, consistent with what
|
|
110
110
|
predecessor phases already wrote. The durable copy lives in
|
|
111
111
|
`$LEGION_WORKSPACE/.legion/<phase>.json`. If a committed handoff conflicts with memory or a prior
|
|
@@ -190,8 +190,9 @@ comments live on that path too, so edit them with `gh pr comment`) — printing
|
|
|
190
190
|
Legion never reads or writes a GitHub issue (LEGION-78). `pr comment`, `pr review`,
|
|
191
191
|
`api …/pulls/…`, `api graphql`, and issue reads are unaffected. The credential reaches `legion`
|
|
192
192
|
through the file `$LEGION_GRANT_FILE` names, written before each of your bash commands by the
|
|
193
|
-
extension; never `cat`, `echo`, copy, or
|
|
194
|
-
`
|
|
193
|
+
extension (and by the `legion` tool before its `handoff_complete`); never `cat`, `echo`, copy, or
|
|
194
|
+
`export` it — `legion credential`, `legion gh`, `jj git push`, and `handoff_complete` read it
|
|
195
|
+
themselves. The file is the pane's, not the
|
|
195
196
|
command's: a `task` subagent, an `eval` subprocess, or a background job in your pane reads the
|
|
196
197
|
grant your last bash command minted, so its `legion gh` or `jj git push` succeeds only within 60
|
|
197
198
|
seconds of that call and 403s afterwards — a timing artifact, not a broken credential; run
|
|
@@ -343,7 +344,7 @@ this proof.
|
|
|
343
344
|
hides an error, or breaks a gate lands in this PR.
|
|
344
345
|
- **The implementer proves the change before its phase completes, and writes the `E2E (implementer)` line when the pull request opens.**
|
|
345
346
|
The proof is the one defined above. It goes into `.legion/implement.json` as the required `proof`
|
|
346
|
-
array (`
|
|
347
|
+
array (`handoff_write` for phase `implement` refuses a payload without one, or with a blank or
|
|
347
348
|
whitespace-only field, and names the field), and into the PR body, because the reviewer and the
|
|
348
349
|
merger verify facts on GitHub and never from a handoff.
|
|
349
350
|
- **The tester verifies the implementer's proof and adds its own `E2E (tester)` line.** It re-runs
|
|
@@ -352,10 +353,10 @@ this proof.
|
|
|
352
353
|
A test handoff whose predecessor carried no proof is a test failure, not a gap for the tester to fill:
|
|
353
354
|
record it in `failures` with `implementerProof.verdict: "rejected"`, complete the phase, and let
|
|
354
355
|
the architect return the issue to the implementer — the agent that developed the change owns
|
|
355
|
-
proving it (`
|
|
356
|
+
proving it (`handoff_write` for phase `test` refuses a rejected verdict, or `failed > 0`,
|
|
356
357
|
with no recorded failure). Otherwise, add your own proof before completing — a proof as defined
|
|
357
|
-
above — as the `E2E (tester)` line and the `proof` array `
|
|
358
|
-
requires whenever you report no failure. A code path whose first execution is after merge — a
|
|
358
|
+
above — as the `E2E (tester)` line and the `proof` array `handoff_write` for
|
|
359
|
+
phase `test` requires whenever you report no failure. A code path whose first execution is after merge — a
|
|
359
360
|
deploy workflow's inline step, a post-merge helper, a production-only resource — is untested
|
|
360
361
|
until the implementer has executed it against a devN stack; if no surface can reach it, the
|
|
361
362
|
tester names that missing surface as the blocker instead of passing the phase. Environment or
|
|
@@ -502,14 +503,11 @@ above.
|
|
|
502
503
|
|
|
503
504
|
## Completion gate: handoff write, verification, and persistence
|
|
504
505
|
|
|
505
|
-
Write the phase-specific handoff:
|
|
506
|
+
Write the phase-specific handoff: call the `legion` tool with `op: "handoff_write"`, `phase: "<p>"`,
|
|
507
|
+
and `data`: a JSON object of the phase-specific fields only. It runs `legion handoff write` in
|
|
508
|
+
`$LEGION_WORKSPACE` and returns its output.
|
|
506
509
|
|
|
507
|
-
|
|
508
|
-
cd -- "$LEGION_WORKSPACE" && \
|
|
509
|
-
legion handoff write --phase <p> --data '<JSON object of phase-specific fields only>'
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
`legion handoff write` validates the payload against the phase's schema before writing: an
|
|
510
|
+
`handoff_write` validates the payload against the phase's schema before writing: an
|
|
513
511
|
implement handoff without a well-formed `proof`, or a test handoff that reports no failure and
|
|
514
512
|
carries no `proof` of its own, exits 1 naming the field and writes nothing.
|
|
515
513
|
|
|
@@ -554,17 +552,15 @@ that deletion at the reviewer's direction. No other phase removes it — and onc
|
|
|
554
552
|
(`jj -R "$LEGION_WORKSPACE" file list -r @- .legion` prints nothing on stdout; jj warns on
|
|
555
553
|
stderr), this gate no longer applies: a later rebase, bare-gate re-check, confirmation, retro, or
|
|
556
554
|
the post-merge production check writes no `.legion/<phase>.json`, commits no handoff, and reports
|
|
557
|
-
with `
|
|
555
|
+
with `handoff_complete` alone (below). Recreating `.legion/` after its deletion changes the
|
|
558
556
|
approved head and restarts the review loop this rule exists to end.
|
|
559
557
|
|
|
560
558
|
## Completion: report to the architect, then stay
|
|
561
559
|
|
|
562
|
-
Report completion to the architect with:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
legion handoff complete --summary '<two sentences for the architect>'
|
|
567
|
-
```
|
|
560
|
+
Report completion to the architect: call the `legion` tool with `op: "handoff_complete"` and
|
|
561
|
+
`summary`: two sentences for the architect. A worker never runs `legion handoff` from bash: the
|
|
562
|
+
tool call is what the extension records, and a turn that ends with the phase still open gets one
|
|
563
|
+
reminder.
|
|
568
564
|
|
|
569
565
|
This publishes your phase's completion to the architect's role and clears the daemon's
|
|
570
566
|
record of this issue's active phase. Do not add pipeline labels, run a controller loop, or
|