@sjawhar/claude-envoy 1.14.1 → 1.16.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/.claude-plugin/plugin.json +1 -1
- package/.omp-plugin/plugin.json +1 -1
- package/AGENTS.md +42 -9
- package/README.md +24 -8
- package/dist/dispatch.js +38 -8
- package/dist/envoy-channel.js +137 -46
- package/dist/session-hook.js +21 -6
- package/legion/agents/design-outsider.md +44 -0
- package/legion/skills/legion-architect/SKILL.md +2 -1
- package/legion/skills/legion-architect/references/wake-routing.md +1 -1
- package/legion/skills/legion-worker/references/findings.md +3 -3
- package/legion/skills/legion-worker/references/merge-gate.md +1 -1
- package/package.json +1 -1
- package/scripts/build.ts +4 -1
- package/scripts/smoke-clear-rebind.sh +9 -4
- package/skills/envoy/SKILL.md +4 -4
- package/skills/envoy/references/deliveries.md +3 -3
- package/src/envoy-channel-server.ts +151 -49
- package/src/legion-claim.ts +103 -4
- package/src/session-identity.ts +32 -12
package/dist/envoy-channel.js
CHANGED
|
@@ -37088,7 +37088,7 @@ var dispatchToolSpecs = [
|
|
|
37088
37088
|
{
|
|
37089
37089
|
name: "dispatch_message",
|
|
37090
37090
|
example: { issue: "DSP-1", body: "Implementation started." },
|
|
37091
|
-
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A
|
|
37091
|
+
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
|
|
37092
37092
|
arguments: (z) => ({
|
|
37093
37093
|
issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
|
|
37094
37094
|
body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
|
|
@@ -38415,6 +38415,7 @@ var DISPATCH_TEXT_REFERENCES = [
|
|
|
38415
38415
|
];
|
|
38416
38416
|
// ../contracts/src/envelope.ts
|
|
38417
38417
|
var isSubject = (value) => typeof value === "string" && value.length > 0;
|
|
38418
|
+
var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
|
|
38418
38419
|
var EnvelopeSchema = object({
|
|
38419
38420
|
event_id: string2().min(1),
|
|
38420
38421
|
source: _enum2([
|
|
@@ -38448,7 +38449,7 @@ var EnvelopeSchema = object({
|
|
|
38448
38449
|
in_reply_to: string2().min(1).optional(),
|
|
38449
38450
|
supersedes: string2().min(1).optional(),
|
|
38450
38451
|
urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
|
|
38451
|
-
expects_reply:
|
|
38452
|
+
expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
|
|
38452
38453
|
});
|
|
38453
38454
|
var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
|
|
38454
38455
|
var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
|
|
@@ -38469,6 +38470,7 @@ function dedupeKeyNamesItsEvent(envelope) {
|
|
|
38469
38470
|
}
|
|
38470
38471
|
// ../contracts/src/envoy-session.ts
|
|
38471
38472
|
var SESSION_HEARTBEAT_MS = 90000;
|
|
38473
|
+
var SESSION_LIVE_WINDOW_MS = 300000;
|
|
38472
38474
|
var LISTENER_CALL_TIMEOUT_MS = 5000;
|
|
38473
38475
|
var LISTENER_CALL_RETRY_DELAY_MS = 250;
|
|
38474
38476
|
var LISTENER_CALL_ATTEMPTS = 2;
|
|
@@ -38586,6 +38588,12 @@ var SLACK_POST_TEXT_MAX = 4000;
|
|
|
38586
38588
|
function overCapMessage(length, max) {
|
|
38587
38589
|
return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
|
|
38588
38590
|
}
|
|
38591
|
+
function literalOfSet(api, values) {
|
|
38592
|
+
const node = api.literal?.(values);
|
|
38593
|
+
if (node === undefined || typeof node.safeParse !== "function")
|
|
38594
|
+
return;
|
|
38595
|
+
return values.every((value) => node.safeParse(value).success) ? node : undefined;
|
|
38596
|
+
}
|
|
38589
38597
|
function zodSchemaApi(zod) {
|
|
38590
38598
|
const api = zod;
|
|
38591
38599
|
return {
|
|
@@ -38614,6 +38622,7 @@ function zodSchemaApi(zod) {
|
|
|
38614
38622
|
},
|
|
38615
38623
|
boolean: () => api.boolean(),
|
|
38616
38624
|
enum: (values) => api.enum(values),
|
|
38625
|
+
literal: (values) => literalOfSet(api, values) ?? api.unknown(),
|
|
38617
38626
|
unknown: () => api.unknown(),
|
|
38618
38627
|
array: (item, opts = {}) => {
|
|
38619
38628
|
let schema = api.array(item);
|
|
@@ -43288,6 +43297,7 @@ var InboundSenderSchema = object({
|
|
|
43288
43297
|
title: string2().optional(),
|
|
43289
43298
|
roles: array(string2()).optional()
|
|
43290
43299
|
});
|
|
43300
|
+
var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
|
|
43291
43301
|
var InboundEnvelopeSchema = object({
|
|
43292
43302
|
event_id: string2().optional(),
|
|
43293
43303
|
source: string2(),
|
|
@@ -43304,7 +43314,7 @@ var InboundEnvelopeSchema = object({
|
|
|
43304
43314
|
in_reply_to: string2().optional(),
|
|
43305
43315
|
supersedes: string2().optional(),
|
|
43306
43316
|
urgency: string2().optional(),
|
|
43307
|
-
expects_reply:
|
|
43317
|
+
expects_reply: InboundExpectsReplySchema.optional()
|
|
43308
43318
|
}).passthrough();
|
|
43309
43319
|
function tolerantShape(shape) {
|
|
43310
43320
|
return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
|
|
@@ -44011,7 +44021,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
|
|
|
44011
44021
|
break;
|
|
44012
44022
|
}
|
|
44013
44023
|
const role = envelope.sender?.roles?.[0];
|
|
44014
|
-
const
|
|
44024
|
+
const wantsNoReply = envelope.expects_reply === false;
|
|
44025
|
+
const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
|
|
44015
44026
|
const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
|
|
44016
44027
|
const unrecognised = [
|
|
44017
44028
|
...parseIssues,
|
|
@@ -44030,7 +44041,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
|
|
|
44030
44041
|
...inReplyTo === undefined ? {} : { re: inReplyTo },
|
|
44031
44042
|
...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
|
|
44032
44043
|
...reply === undefined ? {} : { reply_with: reply },
|
|
44033
|
-
...role === undefined ? {} : {
|
|
44044
|
+
...role === undefined || wantsNoReply ? {} : {
|
|
44034
44045
|
reply_role: {
|
|
44035
44046
|
tool: "envoy_publish",
|
|
44036
44047
|
args: { topic: `notifications.role.${role}`, message: "..." }
|
|
@@ -44804,7 +44815,9 @@ var RoleWireSchema = object({
|
|
|
44804
44815
|
host: string2().optional(),
|
|
44805
44816
|
last_seen: number2().int(),
|
|
44806
44817
|
state: WireStateSchema,
|
|
44807
|
-
holder_state: SessionStateSchema.optional()
|
|
44818
|
+
holder_state: SessionStateSchema.optional(),
|
|
44819
|
+
client_instance_id: string2().optional(),
|
|
44820
|
+
handed_back: boolean2().optional()
|
|
44808
44821
|
});
|
|
44809
44822
|
var RoleHeldWireSchema = object({
|
|
44810
44823
|
error: string2(),
|
|
@@ -44883,6 +44896,21 @@ function mergeInterestSources(registryTopics, liveTopics) {
|
|
|
44883
44896
|
function holderStateOf(role) {
|
|
44884
44897
|
return role.holder_state ?? role.state;
|
|
44885
44898
|
}
|
|
44899
|
+
function namedProcess(id) {
|
|
44900
|
+
return id === "" ? undefined : id;
|
|
44901
|
+
}
|
|
44902
|
+
function roleHeldBy(info, sessionID, clientInstanceID) {
|
|
44903
|
+
if (info === undefined || info.holder !== sessionID || info.handed_back === true)
|
|
44904
|
+
return false;
|
|
44905
|
+
const holding = namedProcess(info.client_instance_id);
|
|
44906
|
+
return holding === undefined || holding === namedProcess(clientInstanceID);
|
|
44907
|
+
}
|
|
44908
|
+
function mayActOnRoleRecord(writer, clientInstanceID, holdsRole) {
|
|
44909
|
+
const wrote = namedProcess(writer);
|
|
44910
|
+
if (wrote === undefined || wrote === namedProcess(clientInstanceID))
|
|
44911
|
+
return true;
|
|
44912
|
+
return !holdsRole;
|
|
44913
|
+
}
|
|
44886
44914
|
function newClientInstanceID() {
|
|
44887
44915
|
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
44888
44916
|
}
|
|
@@ -45078,7 +45106,8 @@ function createEnvoyClient(config) {
|
|
|
45078
45106
|
unsubscribe: async (input) => {
|
|
45079
45107
|
await post("/v1/interests/unsubscribe", {
|
|
45080
45108
|
session_id: input.sessionID,
|
|
45081
|
-
topics: expandSubscriptionTopics(input.topics)
|
|
45109
|
+
topics: expandSubscriptionTopics(input.topics),
|
|
45110
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
45082
45111
|
}, { actingSession: input.sessionID });
|
|
45083
45112
|
},
|
|
45084
45113
|
getInterest: async (sessionID) => InterestWireSchema.parse(JSON.parse(await request(`/v1/interests/${sessionID}`, {}))),
|
|
@@ -45148,7 +45177,8 @@ function createEnvoyClient(config) {
|
|
|
45148
45177
|
session_id: input.sessionID,
|
|
45149
45178
|
role: input.role,
|
|
45150
45179
|
...soft ? { soft: true } : {},
|
|
45151
|
-
...previous === "" ? {} : { previous_session_id: previous }
|
|
45180
|
+
...previous === "" ? {} : { previous_session_id: previous },
|
|
45181
|
+
...input.clientInstanceID === undefined ? {} : { client_instance_id: input.clientInstanceID }
|
|
45152
45182
|
};
|
|
45153
45183
|
let raw;
|
|
45154
45184
|
try {
|
|
@@ -45957,6 +45987,9 @@ function replayLedgerIdentity(frame) {
|
|
|
45957
45987
|
...frame.dedupe_key === undefined || !dedupeKeyNamesItsEvent(frame) ? {} : { dedupe_key: frame.dedupe_key }
|
|
45958
45988
|
};
|
|
45959
45989
|
}
|
|
45990
|
+
var StoredEnvelopeSchema = EnvelopeSchema.extend({
|
|
45991
|
+
expects_reply: InboundExpectsReplySchema.optional()
|
|
45992
|
+
});
|
|
45960
45993
|
function parseEnvelope(data) {
|
|
45961
45994
|
let parsed;
|
|
45962
45995
|
try {
|
|
@@ -45964,7 +45997,7 @@ function parseEnvelope(data) {
|
|
|
45964
45997
|
} catch {
|
|
45965
45998
|
return;
|
|
45966
45999
|
}
|
|
45967
|
-
const result =
|
|
46000
|
+
const result = StoredEnvelopeSchema.safeParse(parsed);
|
|
45968
46001
|
return result.success ? result.data : undefined;
|
|
45969
46002
|
}
|
|
45970
46003
|
function createLedger(now) {
|
|
@@ -51811,13 +51844,12 @@ var DELIVERY_CONTRACT = "Delivery is at-least-once, possibly out of order across
|
|
|
51811
51844
|
var STORED_DELIVERY = "Stored topics (agent, dispatch, github, slack, legion, ghostwispr, whatsapp; a role message " + "reaches its holder as a stored copy on the holder's agent topic) reach each subscribed session " + "once from the durable consumer its listener relays to it, also one that was away or restarted. " + "Team and other ad-hoc topics are live only.";
|
|
51812
51845
|
var TOPIC_GUIDE = "Topic guide (all are under notifications.): agent.<session_id> (subscribe: own inbox); role.<role> " + "(publish-to; holders claim via envoy_role_set). > matches one or more trailing tokens and does not " + "match the base subject. Envoy registers the concrete base when you subscribe to <subject>.>, so " + "the recommended default remains github.<owner>.<repo>.pr.<n>.>. <owner> and <repo> are one token " + "each, with every dot in the name written _ (sjawhar/.github is github.sjawhar._github; " + "acme/site.io is github.acme.site_io), and a topic spelled with the dot receives nothing. " + "Default PR subscription: " + "github.<owner>.<repo>.pr.<n>.> (it receives the quiet PR family): pr.<n> (lifecycle: " + "opened/synchronize/closed; closed carries merged, merge_commit_sha, merged_by, head_sha), " + "pr.<n>.comment, pr.<n>.review, pr.<n>.mention, pr.<n>.checks (one settlement event per commit whose checks settle, the head or not, naming its sha: " + 'passed/failed/cancelled/skipped with failing names and URLs; re-fires with superseded_settlement: "true" ' + "when new runs appear for the same commit). Other GitHub: issue.<n>, issue.<n>.comment, " + "issue.<n>.mention, mention, push.branch.<name>, push.tag.<name>, workflow.<file>.<action> (only " + "runs without an associated PR); slack.<team>.<channel>.message|mention and " + "slack.<team>.<channel>.thread.<ts>.message|mention; ghostwispr.<session>.<kind>; " + "whatsapp.<phone>.<jid>.<kind>; envoy.exceptions.<original-topic>.";
|
|
51813
51846
|
var URGENCY_VALUES = EnvelopeSchema.shape.urgency.unwrap().options;
|
|
51814
|
-
var EXPECTS_REPLY_VALUES = EnvelopeSchema.shape.expects_reply.unwrap().options;
|
|
51815
51847
|
function messageMetadataShape(schema) {
|
|
51816
51848
|
return {
|
|
51817
51849
|
in_reply_to: schema.string().optional(),
|
|
51818
51850
|
supersedes: schema.string().optional(),
|
|
51819
51851
|
urgency: schema.enum(URGENCY_VALUES).describe("low | med | high | blocking: how soon the recipient should act, shown on its card. It does not deliver the message sooner or ahead of others.").optional(),
|
|
51820
|
-
expects_reply: schema.
|
|
51852
|
+
expects_reply: schema.literal(EXPECTS_REPLY_VALUES).describe("false | optional | required").optional(),
|
|
51821
51853
|
expires_at: schema.number({ int: true }).optional()
|
|
51822
51854
|
};
|
|
51823
51855
|
}
|
|
@@ -55179,7 +55211,7 @@ var import_nats2 = __toESM(require_mod4(), 1);
|
|
|
55179
55211
|
|
|
55180
55212
|
// package.json
|
|
55181
55213
|
var name = "@sjawhar/claude-envoy";
|
|
55182
|
-
var version2 = "1.
|
|
55214
|
+
var version2 = "1.16.0";
|
|
55183
55215
|
var legion = {
|
|
55184
55216
|
daemonApiVersion: 56
|
|
55185
55217
|
};
|
|
@@ -55868,7 +55900,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
|
|
|
55868
55900
|
}
|
|
55869
55901
|
|
|
55870
55902
|
// src/session-identity.ts
|
|
55871
|
-
var PersistedRole = object({
|
|
55903
|
+
var PersistedRole = object({
|
|
55904
|
+
session_id: string2().min(1),
|
|
55905
|
+
role: string2().min(1),
|
|
55906
|
+
client_instance_id: string2().min(1).optional()
|
|
55907
|
+
});
|
|
55872
55908
|
|
|
55873
55909
|
class SessionIdentity {
|
|
55874
55910
|
directory;
|
|
@@ -56111,9 +56147,13 @@ async function waitForSavedTranscript(transcript, signal, pollMs = 100) {
|
|
|
56111
56147
|
function isFile(file) {
|
|
56112
56148
|
return stat2(file).then((info) => info.isFile(), () => false);
|
|
56113
56149
|
}
|
|
56150
|
+
var ROLE_CLAIM_RETRY_MS = 1e4;
|
|
56151
|
+
var ROLE_CLAIM_WAIT_MS = SESSION_LIVE_WINDOW_MS + ROLE_CLAIM_RETRY_MS;
|
|
56114
56152
|
function startLegionClaim(deps) {
|
|
56115
56153
|
let capability;
|
|
56116
56154
|
const { sessionID } = deps;
|
|
56155
|
+
const roleWaitMs = deps.roleWaitMs ?? ROLE_CLAIM_WAIT_MS;
|
|
56156
|
+
const roleRetryMs = deps.roleRetryMs ?? ROLE_CLAIM_RETRY_MS;
|
|
56117
56157
|
const boot = async () => {
|
|
56118
56158
|
const transcript = await waitForTranscript(deps.pane.configDir, sessionID, deps.signal, deps.pollMs);
|
|
56119
56159
|
if (deps.pane.launcherSaves) {
|
|
@@ -56132,16 +56172,39 @@ function startLegionClaim(deps) {
|
|
|
56132
56172
|
capability = registered;
|
|
56133
56173
|
},
|
|
56134
56174
|
claimRole: async (role) => {
|
|
56135
|
-
const
|
|
56136
|
-
|
|
56137
|
-
|
|
56175
|
+
const deadline = Date.now() + roleWaitMs;
|
|
56176
|
+
let waited = false;
|
|
56177
|
+
for (;; ) {
|
|
56178
|
+
const claimed = await deps.client.setRole({
|
|
56179
|
+
sessionID,
|
|
56180
|
+
role,
|
|
56181
|
+
soft: true,
|
|
56182
|
+
clientInstanceID: deps.clientInstanceID
|
|
56183
|
+
});
|
|
56184
|
+
if (claimed.claimed)
|
|
56185
|
+
break;
|
|
56186
|
+
if (claimed.holder !== sessionID) {
|
|
56187
|
+
throw new Error(`Envoy role ${role} is held by live session ${claimed.holder}`);
|
|
56188
|
+
}
|
|
56189
|
+
const left = deadline - Date.now();
|
|
56190
|
+
if (left <= 0) {
|
|
56191
|
+
throw new Error(`Envoy role ${role} is still claimed under this session by another process after ${roleWaitMs} ms of asking`);
|
|
56192
|
+
}
|
|
56193
|
+
if (!waited) {
|
|
56194
|
+
waited = true;
|
|
56195
|
+
deps.log(`Envoy role ${role} is claimed under this session by the process this pane replaced; asking again every ${roleRetryMs} ms for up to ${roleWaitMs} ms, until nobody has answered for that claim inside the listener's live window`);
|
|
56196
|
+
}
|
|
56197
|
+
await delay(Math.min(roleRetryMs, left), undefined, { signal: deps.signal });
|
|
56138
56198
|
}
|
|
56139
56199
|
await deps.session.rememberRole(role);
|
|
56140
56200
|
},
|
|
56141
56201
|
onRoleRegained: (listener) => deps.session.onRoleRegained(listener),
|
|
56142
56202
|
log: deps.log,
|
|
56143
56203
|
stopping: "stopping the pane so the daemon launches it again",
|
|
56144
|
-
stop:
|
|
56204
|
+
stop: () => {
|
|
56205
|
+
if (!deps.signal.aborted)
|
|
56206
|
+
deps.stop();
|
|
56207
|
+
}
|
|
56145
56208
|
});
|
|
56146
56209
|
};
|
|
56147
56210
|
const booted = boot().catch((error) => {
|
|
@@ -57511,7 +57574,7 @@ function createChannelDelivery(input) {
|
|
|
57511
57574
|
dedupe_key: envelope?.dedupe_key,
|
|
57512
57575
|
urgency: envelope?.urgency,
|
|
57513
57576
|
from_session: envelope?.source_session,
|
|
57514
|
-
expects_reply: envelope?.expects_reply,
|
|
57577
|
+
expects_reply: envelope?.expects_reply?.toString(),
|
|
57515
57578
|
in_reply_to: envelope?.in_reply_to
|
|
57516
57579
|
})
|
|
57517
57580
|
}
|
|
@@ -57849,7 +57912,11 @@ async function startChannelSession(options) {
|
|
|
57849
57912
|
relay.delivery.setFollowed(registeredTopics());
|
|
57850
57913
|
return {
|
|
57851
57914
|
dropping,
|
|
57852
|
-
unsubscribing: serialized(() => options.client.unsubscribe({
|
|
57915
|
+
unsubscribing: serialized(() => options.client.unsubscribe({
|
|
57916
|
+
sessionID: identity.id,
|
|
57917
|
+
topics,
|
|
57918
|
+
clientInstanceID: options.clientInstanceID
|
|
57919
|
+
}))
|
|
57853
57920
|
};
|
|
57854
57921
|
}
|
|
57855
57922
|
function registeredTopics() {
|
|
@@ -57902,27 +57969,40 @@ async function startChannelSession(options) {
|
|
|
57902
57969
|
const result = await registering;
|
|
57903
57970
|
takeDeliveryAnswer(sessionID, topics, asking, result);
|
|
57904
57971
|
};
|
|
57905
|
-
const
|
|
57972
|
+
const writeRoleRecord = (file, role) => writePersistedRole(file, {
|
|
57973
|
+
session_id: identity.id,
|
|
57974
|
+
role,
|
|
57975
|
+
client_instance_id: options.clientInstanceID
|
|
57976
|
+
});
|
|
57977
|
+
let roleTransferFrom;
|
|
57978
|
+
const restoreRole = async (previousSessionID) => {
|
|
57906
57979
|
const previousFile = roleStateFile(options.stateDirectory, previousSessionID);
|
|
57907
57980
|
const persisted = await readPersistedRole(previousFile);
|
|
57908
57981
|
if (persisted === undefined)
|
|
57909
|
-
return;
|
|
57982
|
+
return "settled";
|
|
57983
|
+
if (!mayActOnRoleRecord(persisted.client_instance_id, options.clientInstanceID, heldRole !== undefined)) {
|
|
57984
|
+
return "settled";
|
|
57985
|
+
}
|
|
57910
57986
|
const claim = await options.client.setRole({
|
|
57911
57987
|
sessionID: identity.id,
|
|
57912
57988
|
role: persisted.role,
|
|
57913
57989
|
soft: true,
|
|
57914
|
-
previousSessionID: persisted.session_id
|
|
57990
|
+
previousSessionID: persisted.session_id,
|
|
57991
|
+
clientInstanceID: options.clientInstanceID
|
|
57915
57992
|
});
|
|
57916
57993
|
if (!claim.claimed) {
|
|
57994
|
+
if (claim.holder === identity.id)
|
|
57995
|
+
return "pending";
|
|
57917
57996
|
if (claim.holder !== persisted.session_id)
|
|
57918
57997
|
await rm2(previousFile, { force: true });
|
|
57919
|
-
return;
|
|
57998
|
+
return "settled";
|
|
57920
57999
|
}
|
|
57921
58000
|
heldRole = persisted.role;
|
|
57922
58001
|
const currentFile = roleStateFile(options.stateDirectory, identity.id);
|
|
57923
58002
|
if (previousFile !== currentFile)
|
|
57924
58003
|
await rm2(previousFile, { force: true });
|
|
57925
|
-
await
|
|
58004
|
+
await writeRoleRecord(currentFile, persisted.role);
|
|
58005
|
+
return "settled";
|
|
57926
58006
|
};
|
|
57927
58007
|
let regained;
|
|
57928
58008
|
const fireRegained = (role, reason) => {
|
|
@@ -57952,8 +58032,8 @@ async function startChannelSession(options) {
|
|
|
57952
58032
|
if (heldRole === undefined)
|
|
57953
58033
|
return;
|
|
57954
58034
|
const role = heldRole;
|
|
57955
|
-
const
|
|
57956
|
-
if (
|
|
58035
|
+
const holding = await options.client.getRole(role).catch(undefinedOnNotFound);
|
|
58036
|
+
if (roleHeldBy(holding, identity.id, options.clientInstanceID)) {
|
|
57957
58037
|
await refreshRoleRecord();
|
|
57958
58038
|
if (afterOutage)
|
|
57959
58039
|
fireRegained(role, "reregistered");
|
|
@@ -57962,24 +58042,27 @@ async function startChannelSession(options) {
|
|
|
57962
58042
|
const result = await options.client.setRole({
|
|
57963
58043
|
sessionID: identity.id,
|
|
57964
58044
|
role,
|
|
57965
|
-
soft: true
|
|
58045
|
+
soft: true,
|
|
58046
|
+
clientInstanceID: options.clientInstanceID
|
|
57966
58047
|
});
|
|
57967
58048
|
if (!result.claimed) {
|
|
57968
|
-
|
|
57969
|
-
|
|
58049
|
+
if (result.holder !== identity.id) {
|
|
58050
|
+
heldRole = undefined;
|
|
58051
|
+
await rm2(roleStateFile(options.stateDirectory, identity.id), { force: true });
|
|
58052
|
+
return;
|
|
58053
|
+
}
|
|
58054
|
+
await refreshRoleRecord();
|
|
57970
58055
|
return;
|
|
57971
58056
|
}
|
|
57972
58057
|
fireRegained(role, "reclaimed");
|
|
57973
58058
|
};
|
|
57974
|
-
let roleTransferFrom;
|
|
57975
58059
|
let roleTransferFailureReported = false;
|
|
57976
58060
|
const transferRole = async () => {
|
|
57977
58061
|
const from = roleTransferFrom;
|
|
57978
58062
|
if (from === undefined)
|
|
57979
58063
|
return;
|
|
57980
58064
|
try {
|
|
57981
|
-
await restoreRole(from);
|
|
57982
|
-
roleTransferFrom = undefined;
|
|
58065
|
+
roleTransferFrom = await restoreRole(from) === "pending" ? from : undefined;
|
|
57983
58066
|
roleTransferFailureReported = false;
|
|
57984
58067
|
} catch (error) {
|
|
57985
58068
|
if (!roleTransferFailureReported) {
|
|
@@ -58044,7 +58127,11 @@ async function startChannelSession(options) {
|
|
|
58044
58127
|
const drifted = registry.topics.filter((topic) => topic !== directSubject && !topic.startsWith(ROLE_TOPIC_PREFIX) && !followed.has(topic));
|
|
58045
58128
|
if (drifted.length === 0)
|
|
58046
58129
|
return;
|
|
58047
|
-
await options.client.unsubscribe({
|
|
58130
|
+
await options.client.unsubscribe({
|
|
58131
|
+
sessionID: identity.id,
|
|
58132
|
+
topics: drifted,
|
|
58133
|
+
clientInstanceID: options.clientInstanceID
|
|
58134
|
+
});
|
|
58048
58135
|
};
|
|
58049
58136
|
let heartbeatInFlight = false;
|
|
58050
58137
|
let heartbeatRequested = false;
|
|
@@ -58133,17 +58220,19 @@ async function startChannelSession(options) {
|
|
|
58133
58220
|
await options.connection.flush();
|
|
58134
58221
|
await serialized(async () => {
|
|
58135
58222
|
await register();
|
|
58136
|
-
|
|
58223
|
+
try {
|
|
58224
|
+
roleTransferFrom = await restoreRole(identity.id) === "pending" ? identity.id : undefined;
|
|
58225
|
+
} catch (error) {
|
|
58137
58226
|
if (error instanceof UnparseableRoleRecordError) {
|
|
58138
58227
|
process.stderr.write(`envoy-channel: ${messageFor(error)}; the session starts without a role
|
|
58139
58228
|
`);
|
|
58140
|
-
|
|
58141
|
-
|
|
58142
|
-
|
|
58143
|
-
|
|
58144
|
-
process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
|
|
58229
|
+
} else {
|
|
58230
|
+
roleTransferFrom = identity.id;
|
|
58231
|
+
roleTransferFailureReported = true;
|
|
58232
|
+
process.stderr.write(`envoy-channel: could not restore the role (${messageFor(error)}); the next heartbeat retries
|
|
58145
58233
|
`);
|
|
58146
|
-
|
|
58234
|
+
}
|
|
58235
|
+
}
|
|
58147
58236
|
});
|
|
58148
58237
|
triggerReplay();
|
|
58149
58238
|
for (const prune of [
|
|
@@ -58218,10 +58307,7 @@ async function startChannelSession(options) {
|
|
|
58218
58307
|
},
|
|
58219
58308
|
async rememberRole(role) {
|
|
58220
58309
|
heldRole = role;
|
|
58221
|
-
await
|
|
58222
|
-
session_id: identity.id,
|
|
58223
|
-
role
|
|
58224
|
-
});
|
|
58310
|
+
await writeRoleRecord(roleStateFile(options.stateDirectory, identity.id), role);
|
|
58225
58311
|
},
|
|
58226
58312
|
async shutdown() {
|
|
58227
58313
|
if (shuttingDown)
|
|
@@ -58320,7 +58406,11 @@ async function executeEnvoyTool(runtime, name, input) {
|
|
|
58320
58406
|
}
|
|
58321
58407
|
case EnvoyToolOperation.setRole: {
|
|
58322
58408
|
const args = parseArguments(spec, input);
|
|
58323
|
-
const result = await runtime.client.setRole({
|
|
58409
|
+
const result = await runtime.client.setRole({
|
|
58410
|
+
sessionID: identity.id,
|
|
58411
|
+
role: args.role,
|
|
58412
|
+
clientInstanceID: runtime.clientInstanceID
|
|
58413
|
+
});
|
|
58324
58414
|
if (!result.claimed)
|
|
58325
58415
|
throw new Error(`role ${args.role} is held by ${result.holder}`);
|
|
58326
58416
|
await runtime.session.rememberRole(args.role);
|
|
@@ -58534,13 +58624,14 @@ async function runEnvoyChannelServer() {
|
|
|
58534
58624
|
...configuredValue(overrideSessionId) === undefined ? { handoffPid: process.ppid } : {}
|
|
58535
58625
|
});
|
|
58536
58626
|
}
|
|
58537
|
-
runtime = { identity, client, session };
|
|
58627
|
+
runtime = { identity, client, session, clientInstanceID };
|
|
58538
58628
|
if (pane !== undefined && daemon !== undefined && !stopping) {
|
|
58539
58629
|
claim = startLegionClaim({
|
|
58540
58630
|
pane,
|
|
58541
58631
|
sessionID: identity.id,
|
|
58542
58632
|
daemon,
|
|
58543
58633
|
client,
|
|
58634
|
+
clientInstanceID,
|
|
58544
58635
|
session,
|
|
58545
58636
|
signal: booting.signal,
|
|
58546
58637
|
log: logLegion,
|
package/dist/session-hook.js
CHANGED
|
@@ -14438,7 +14438,7 @@ var dispatchToolSpecs = [
|
|
|
14438
14438
|
{
|
|
14439
14439
|
name: "dispatch_message",
|
|
14440
14440
|
example: { issue: "DSP-1", body: "Implementation started." },
|
|
14441
|
-
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A
|
|
14441
|
+
description: "Post a note humans must read now: a reply to a human's message or a deliverable that landed. A to-do only a human can " + "complete is an ask (dispatch ask), so it reaches their inbox. Never progress or status updates - Dispatch is a high-signal " + "record, not a log. Not a design decision (write it as a decision block in the document) or document feedback (dispatch comment). " + "To answer a human's direct message to this session - one sent from the Agents page, which names no issue - pass that message's bare id as " + "in_reply_to and no issue; the reply lands in that conversation. Another call with the same in_reply_to and new text posts a follow-up, " + "threaded under this session's first reply; the same text again posts nothing. dispatch read --message <id> reads " + "that conversation back. " + "Every other message names its issue; keep --issue whenever the frame you answer gave one. A frame reading expects_reply: optional or " + "required carries a reply_with command: answer with it, on that issue. One reading false carries none and asks for no answer; where " + "you answer one anyway, its card names what to answer. On Oh My Pi an Agents-page Send or Aside " + "arrives as a user message: answer in the conversation, with no dispatch message. " + `${BODY_BLOCK_RULE} The lines images appends count toward it. ${ISSUE_REFERENCE}`,
|
|
14442
14442
|
arguments: (z) => ({
|
|
14443
14443
|
issue: z.string().describe(`${ISSUE_REFERENCE} Omit it only when in_reply_to answers a human's direct message to this session.`).optional(),
|
|
14444
14444
|
body: z.string().describe(`Update text. ${BODY_BLOCK_RULE}`),
|
|
@@ -15765,6 +15765,7 @@ var DISPATCH_TEXT_REFERENCES = [
|
|
|
15765
15765
|
];
|
|
15766
15766
|
// ../contracts/src/envelope.ts
|
|
15767
15767
|
var isSubject = (value) => typeof value === "string" && value.length > 0;
|
|
15768
|
+
var EXPECTS_REPLY_VALUES = [false, "optional", "required"];
|
|
15768
15769
|
var EnvelopeSchema = object({
|
|
15769
15770
|
event_id: string2().min(1),
|
|
15770
15771
|
source: _enum2([
|
|
@@ -15798,7 +15799,7 @@ var EnvelopeSchema = object({
|
|
|
15798
15799
|
in_reply_to: string2().min(1).optional(),
|
|
15799
15800
|
supersedes: string2().min(1).optional(),
|
|
15800
15801
|
urgency: _enum2(["low", "med", "high", "blocking"]).optional(),
|
|
15801
|
-
expects_reply:
|
|
15802
|
+
expects_reply: literal(EXPECTS_REPLY_VALUES).optional()
|
|
15802
15803
|
});
|
|
15803
15804
|
var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
|
|
15804
15805
|
var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
|
|
@@ -15819,6 +15820,7 @@ function dedupeKeyNamesItsEvent(envelope) {
|
|
|
15819
15820
|
}
|
|
15820
15821
|
// ../contracts/src/envoy-session.ts
|
|
15821
15822
|
var SESSION_HEARTBEAT_MS = 90000;
|
|
15823
|
+
var SESSION_LIVE_WINDOW_MS = 300000;
|
|
15822
15824
|
var LISTENER_CALL_TIMEOUT_MS = 5000;
|
|
15823
15825
|
var LISTENER_CALL_RETRY_DELAY_MS = 250;
|
|
15824
15826
|
var LISTENER_CALL_ATTEMPTS = 2;
|
|
@@ -15936,6 +15938,12 @@ var SLACK_POST_TEXT_MAX = 4000;
|
|
|
15936
15938
|
function overCapMessage(length, max) {
|
|
15937
15939
|
return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
|
|
15938
15940
|
}
|
|
15941
|
+
function literalOfSet(api, values) {
|
|
15942
|
+
const node = api.literal?.(values);
|
|
15943
|
+
if (node === undefined || typeof node.safeParse !== "function")
|
|
15944
|
+
return;
|
|
15945
|
+
return values.every((value) => node.safeParse(value).success) ? node : undefined;
|
|
15946
|
+
}
|
|
15939
15947
|
function zodSchemaApi(zod) {
|
|
15940
15948
|
const api = zod;
|
|
15941
15949
|
return {
|
|
@@ -15964,6 +15972,7 @@ function zodSchemaApi(zod) {
|
|
|
15964
15972
|
},
|
|
15965
15973
|
boolean: () => api.boolean(),
|
|
15966
15974
|
enum: (values) => api.enum(values),
|
|
15975
|
+
literal: (values) => literalOfSet(api, values) ?? api.unknown(),
|
|
15967
15976
|
unknown: () => api.unknown(),
|
|
15968
15977
|
array: (item, opts = {}) => {
|
|
15969
15978
|
let schema = api.array(item);
|
|
@@ -18333,6 +18342,7 @@ var InboundSenderSchema = object({
|
|
|
18333
18342
|
title: string2().optional(),
|
|
18334
18343
|
roles: array(string2()).optional()
|
|
18335
18344
|
});
|
|
18345
|
+
var InboundExpectsReplySchema = literal([...EXPECTS_REPLY_VALUES, "none"]).transform((value) => value === "none" ? false : value);
|
|
18336
18346
|
var InboundEnvelopeSchema = object({
|
|
18337
18347
|
event_id: string2().optional(),
|
|
18338
18348
|
source: string2(),
|
|
@@ -18349,7 +18359,7 @@ var InboundEnvelopeSchema = object({
|
|
|
18349
18359
|
in_reply_to: string2().optional(),
|
|
18350
18360
|
supersedes: string2().optional(),
|
|
18351
18361
|
urgency: string2().optional(),
|
|
18352
|
-
expects_reply:
|
|
18362
|
+
expects_reply: InboundExpectsReplySchema.optional()
|
|
18353
18363
|
}).passthrough();
|
|
18354
18364
|
function tolerantShape(shape) {
|
|
18355
18365
|
return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [key, _catch2(schema, undefined)]));
|
|
@@ -19056,7 +19066,8 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
|
|
|
19056
19066
|
break;
|
|
19057
19067
|
}
|
|
19058
19068
|
const role = envelope.sender?.roles?.[0];
|
|
19059
|
-
const
|
|
19069
|
+
const wantsNoReply = envelope.expects_reply === false;
|
|
19070
|
+
const reply = wantsNoReply ? undefined : dispatchReply ?? replyWith(envelope);
|
|
19060
19071
|
const sourceIssue = parseIssues.includes("source") || KNOWN_SOURCES[envelope.source] !== undefined ? undefined : `source=${envelope.source}`;
|
|
19061
19072
|
const unrecognised = [
|
|
19062
19073
|
...parseIssues,
|
|
@@ -19075,7 +19086,7 @@ function renderInbound(raw, sessionID, subject, wholeBody) {
|
|
|
19075
19086
|
...inReplyTo === undefined ? {} : { re: inReplyTo },
|
|
19076
19087
|
...envelope.supersedes === undefined ? {} : { supersedes: envelope.supersedes },
|
|
19077
19088
|
...reply === undefined ? {} : { reply_with: reply },
|
|
19078
|
-
...role === undefined ? {} : {
|
|
19089
|
+
...role === undefined || wantsNoReply ? {} : {
|
|
19079
19090
|
reply_role: {
|
|
19080
19091
|
tool: "envoy_publish",
|
|
19081
19092
|
args: { topic: `notifications.role.${role}`, message: "..." }
|
|
@@ -26862,7 +26873,11 @@ async function pruneStaleSessionTitles(stateDirectory) {
|
|
|
26862
26873
|
}
|
|
26863
26874
|
|
|
26864
26875
|
// src/session-identity.ts
|
|
26865
|
-
var PersistedRole = object({
|
|
26876
|
+
var PersistedRole = object({
|
|
26877
|
+
session_id: string2().min(1),
|
|
26878
|
+
role: string2().min(1),
|
|
26879
|
+
client_instance_id: string2().min(1).optional()
|
|
26880
|
+
});
|
|
26866
26881
|
|
|
26867
26882
|
class SessionIdentity {
|
|
26868
26883
|
directory;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: design-outsider
|
|
3
|
+
description: |
|
|
4
|
+
Outside reader of a design problem. Reads no file of the repository. Use before you write a
|
|
5
|
+
design: it says where the proposal is stupid, whether the thing should exist at all, what the
|
|
6
|
+
smallest thing that gives the person what they asked for is, and what to delete.
|
|
7
|
+
tools: WebSearch
|
|
8
|
+
color: magenta
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are an outsider to this codebase. The caller gives you a problem statement and the
|
|
12
|
+
repository's design principles, and nothing else. You answer the problem, not the caller.
|
|
13
|
+
|
|
14
|
+
## What you answer
|
|
15
|
+
|
|
16
|
+
Four questions, in this order, each in your own words:
|
|
17
|
+
|
|
18
|
+
1. Where is the proposal stupid?
|
|
19
|
+
2. Should the thing in question exist at all?
|
|
20
|
+
3. What is the smallest thing that gives the person what they asked for?
|
|
21
|
+
4. What should be deleted?
|
|
22
|
+
|
|
23
|
+
## How you answer
|
|
24
|
+
|
|
25
|
+
- Answer from the problem statement and the principles the caller gave you. Say plainly what
|
|
26
|
+
you cannot judge from them, and name what you would need to judge it.
|
|
27
|
+
- Where a standard solution to this problem exists, name it. A web search for how two or three
|
|
28
|
+
well-regarded projects solve the same problem is the one source you can read.
|
|
29
|
+
- Do not agree. A reply that gives the caller's own design back to it is worth nothing, and a
|
|
30
|
+
verdict with no reasoning behind it is worth less.
|
|
31
|
+
- You are a challenge, never a gate and never an approval. The caller answers you in writing,
|
|
32
|
+
and it may decide against you and give its reason. You decide nothing and you block nothing.
|
|
33
|
+
|
|
34
|
+
## What your tool list does not prevent
|
|
35
|
+
|
|
36
|
+
You hold no tool that reads a file, by design: your value is that you read the problem instead
|
|
37
|
+
of the code that already exists. Two things still reach you, so do not claim otherwise:
|
|
38
|
+
|
|
39
|
+
- A plugin device of the pod stays available, so an indexed workspace can still be read through
|
|
40
|
+
one.
|
|
41
|
+
- A subagent inherits the caller's repository guide, which in this repository imports the whole
|
|
42
|
+
of `AGENTS.md`. So you may already hold that guide and the design principles in it.
|
|
43
|
+
|
|
44
|
+
Where you used either, say so in your answer.
|
|
@@ -16,7 +16,7 @@ You own one issue tree from its first decision to the merge that closes it. The
|
|
|
16
16
|
- Before you end a turn that waits on a human, open what they must answer: a decision block, a `dispatch ask` or `dispatch request-approval`. A turn that ends with nothing open asks nobody, and the tree stops. Open an ordinary ask on the root issue itself, never a child: only a question a session of this tree opens on the root starts a wait on a person, so an ask on a child starts no wait and leaves the tree standing still. Once you have handed a root question to the human, end your turn: when nothing else of the tree is at work — no other issue of the tree in a phase, the `legion` label still on, and no person or outside session holding the root's Dispatch claim — the root returns its admission slot (`awaiting_person`) while this session stays live. Read the human's comments and replies as they arrive. Their answer gives this live claim a task naming what answered and what is still with them. The same holds once you request approval and `register_gate` that version: the registration hands the spec to the human, so end your turn there too, and the human's review, or a newer version once Dispatch shows the request back on your turn, gives you that task.
|
|
17
17
|
- Deliver every criterion of the root spec in this tree. Work leaves it only when a root splits, as a `dispatch ask` for the rest: work outside has no owner.
|
|
18
18
|
- A problem outside the tree is a `dispatch ask`, never an issue you file, because a person decides whether it needs one (`skill://legion-worker/references/findings.md`). A clear defect is the exception. Fix one in code this tree touches or depends on in this tree's pull request. Send any other to the controller, which gives it to the tree that owns that code.
|
|
19
|
-
- Before you write a spec or ask a person anything, read the repository's `STRATEGY.md` when it has one, then use a standard best-practice solution; ask only what neither settles, with one recommendation. A design that crosses a Boundary in
|
|
19
|
+
- Before you write a spec or ask a person anything, read the repository's `STRATEGY.md` when it has one and the design principles where it states them (`AGENTS.md`, in this repository), then use a standard best-practice solution; ask only what neither settles, with one recommendation. A design that crosses a Boundary in `STRATEGY.md` goes to its owner before it is built. Find the standard with a web search and with how two or three well-regarded projects solve the problem. Cite them in a few lines of the spec's `Design`. A design far from the standard is a sign to fix the underlying problem, not to add a workaround.
|
|
20
20
|
- Write lifecycle changes with the `legion` tool, which takes Dispatch issue keys (`LEGION-41`). Never apply a label: Legion has none.
|
|
21
21
|
- Message a phase worker with `envoy_publish` to `notifications.role.<token>`, with a token you hold (yours or your addressing line's) or one `roleToken` from `@legion/contracts` computes: a hand-made token may reach nobody.
|
|
22
22
|
- Deployment instructions override this skill where they conflict: they are the operator's rules (required checks, deploy commands, code owners, standing roles you may consult, the merge credential).
|
|
@@ -31,6 +31,7 @@ You own one issue tree from its first decision to the merge that closes it. The
|
|
|
31
31
|
- A child that duplicates another issue and is not yet released is never released: fold it by the duplicate rule of `skill://dispatch-first`. The daemon records a child at its release and re-asserts a status only on an issue it has recorded, so that close stands. A released duplicate goes out with `park_child` instead.
|
|
32
32
|
- Every child names the running system its criteria are proven on (the command, service or page the tester drives) and the repository skill that drives it. If the repository cannot exercise a criterion end to end, building that path is work of this tree.
|
|
33
33
|
- The issue's primary document is the root spec: extend it in place, because a second "spec" artifact replaces the human's document. Mark each requirement of it as a `requirement` block, and write the issue's Verification document (`verification.md`) beside it: one `check` block per thing that must be proved, each with `serves` naming the requirements it serves, its `surface` (`browser`, `cli` or `api`), its `run`, its `when` (`before_merge`, `after_merge` or `default_branch`) and its `negative_control`. The checks are the gate every later role reads: the implementer and the tester record a result against each, and the reviewer approves only once every `before_merge` check of the issue has a passing result current at the head. The decomposition's order and the plan's tasks go in the children and the planner's `plan` handoff, not the root spec.
|
|
34
|
+
- Before you write a design, state the problem to `task(agent="design-outsider")` and read its answer with `wait`. The brief holds the problem statement and the design principles, and no design of your own. The four questions are where the proposal is stupid, whether the thing in question should exist at all, what the smallest thing that gives the person what they asked for is, and what to delete. Record the answer in a comment on the issue, or in the issue's `plan.md` where one exists, before any action that states or favours a design, and give your reason where you do not follow it. Run the step once per design: at the first design of an issue, and again when a push-back or a review changes what the design is for. It is a challenge, never a gate.
|
|
34
35
|
- Read `skill://legion-architect/references/design-gate.md` before you write the root spec and before any edit to it after approval, because any new version closes the gate. It also names the design gate steps your `Legion addressing` line declares, the document each step records, and says that a sub-architect runs none.
|
|
35
36
|
- File every child of your decomposition before `dispatch request-approval`, and before `register_gate` where your line gates no step: the gate's opening starts your root's own first phase unless Dispatch shows an open child under it, so a child filed after it opens leaves the root working beside its children.
|
|
36
37
|
- A root architect then ends its turn and releases no child until every gated step is open: a delivered wake shows `design-approved` on the root, and under more than one step `gatedSteps` in your record (the `legion` tool's `read_record`) shows each step `approved`. The daemon starts no phase in the tree before that. A root that waited on a person's answer gets no `design-approved` notice: a resume task saying "this resume is your design-approved wake" is that wake. A sub-architect waits for none and releases its children with `release_children`.
|
|
@@ -36,7 +36,7 @@ and 7 (a conflict, the hand-over, the close) is in
|
|
|
36
36
|
| `merge-stalled` | GitHub shows the pull request closed, or at another head, while the record still awaits READY's head, and that change's webhook never reached the daemon: read the pull request on GitHub and act on what it shows (`skill://legion-architect/references/merge-notices.md#merge-stalled`). |
|
|
37
37
|
| `merge-removed` | A person, or GitHub on its own, took the pull request out of the merge queue or turned its auto-merge off at READY's head after the daemon submitted the merge: read the pull request on GitHub and act on the removal's reason (`skill://legion-architect/references/merge-notices.md#merge-removed`). |
|
|
38
38
|
| `merge-waiting` | GitHub has not completed the merge the daemon submitted within an hour of READY's acceptance, and nothing has been withdrawn: read the pull request on GitHub for what GitHub waits on and ask a person for what no worker can do (`skill://legion-architect/references/merge-notices.md#merge-waiting`). |
|
|
39
|
-
| A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused),
|
|
39
|
+
| A reply on an ask you follow | Interpret the reply in the issue's design context. Answer in its thread (`dispatch comment --reply-to <comment id>`; under an open ask whose next move is yours, such as the approval request you must revise or hand back, `--reply-to-ask <ask id> --turn agent`, since a default-turn reply hands that request back to the human and a corrected `--summary` is then refused) where the reply carries a question, a disagreement, a correction or a fact you do not have, and whenever you are unsure; an approval, or an answer that settles the question with nothing left to say, draws no comment and no message back to the person who answered. Then adjust the plan or relay the reply via `envoy_publish` to the responsible worker's role token, which an answer needing nothing from you still needs; scope and product decisions remain with you. |
|
|
40
40
|
| `worker-died` | Its `role` and `phase`. That role's claim failed: its launches or prompts ran out. For a phase worker the daemon holds the issue (phase `held`) and starts nothing more on it. Reassess the work, then decide with `retry_or_escalate`: `retry` when the failure looks agent-specific or transient, `escalate` to hand the held issue to the controller when it looks environmental. |
|
|
41
41
|
| `held` | Its `role` and `phase` (the phase the issue left), or `phase` with `reason: "escalated"`. Without `reason`, that phase's worker ran out of launches or prompts: the issue is held (phase `held`), the `worker-died` that comes with it is yours to answer with `retry_or_escalate` as that row says, and the controller hears of the hold too. With `reason: "escalated"`, it records your own `escalate`: the controller has the issue now, and you start nothing for it. |
|
|
42
42
|
| `ready-refused` | Its `version` and `reason`, and the `reason` names the gated step the READY waits on. The merger's READY was refused, and its packet is kept with the completion so no second READY is ever needed. With `version` greater than 0, `reason` reads `READY refused: approve design version <N> of the tree's <step> document before requesting READY.`: that step's document is already registered at that version, so get it approved (`skill://legion-architect/references/design-gate.md`), and the daemon advances the merge and posts the kept packet the moment every gated step is open. With `version` 0, `reason` reads `READY refused: no design version of the tree's <step> document is approved; register and approve it before requesting READY.`: that step is not registered yet, so register it and get it approved (`skill://legion-architect/references/design-gate.md`) exactly as above; the daemon releases the kept packet the same way — at registration itself when that opens the step (a deployment that gates no step, or Dispatch already shows the version approved), otherwise the moment a human approves the version you registered. The refusal names the first closed step, in the order the deployment gates them, and no second READY is asked for however many steps were closed: read `gatedSteps` in your own record (the `legion` tool's `read_record`) for every step still closed, and open each one. Nothing else is needed once every gated step is open. On a root whose catch-up said `carried`, the gate is a copy on the old root's spec document: run nothing of `skill://legion-architect/references/design-gate.md` for that step; tell the controller the READY waits on a human's approval of that document, and `carried-gate-opened` follows the approval. A `reason` starting `READY_PACKET_MISSING` names a READY refused before the daemon kept packets: that READY is void and the issue stays in merging, so start it over (`park_child` then `rerun_child`, for a child) or end it. |
|