@sjawhar/pi-legion-envoy 1.11.0 → 1.12.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 +72 -4
- package/dist/legion.js +96 -4
- package/dist/skills/dispatch/SKILL.md +18 -0
- package/dist/skills/legion-controller/SKILL.md +4 -1
- package/package.json +1 -1
package/dist/envoy.js
CHANGED
|
@@ -29687,6 +29687,7 @@ var ArtifactReviewEventPayloadSchema = object({
|
|
|
29687
29687
|
var askEventPayloadFields = {
|
|
29688
29688
|
id: string2().optional(),
|
|
29689
29689
|
opened_event_id: number2().int().positive(),
|
|
29690
|
+
kind: _enum2(["question", "approval", "action"]).optional(),
|
|
29690
29691
|
question: string2().optional(),
|
|
29691
29692
|
options: array(object({ label: string2().optional() })).nullish(),
|
|
29692
29693
|
answer: object({ selected: array(string2()).nullish(), text: string2().nullish() }).nullish(),
|
|
@@ -29854,13 +29855,14 @@ var dispatchToolSpecs = [
|
|
|
29854
29855
|
},
|
|
29855
29856
|
{
|
|
29856
29857
|
name: "dispatch_ask",
|
|
29857
|
-
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + `reference \u2014 it must be answerable from its own text and anchor alone, never "see above". Question is at most 800 ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
29858
|
+
description: "Open a durable, answerable decision or human to-do on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. Use kind: action for a to-do a human must complete; it has fixed Done / Can't answers. " + "Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + `reference \u2014 it must be answerable from its own text and anchor alone, never "see above". Question is at most 800 ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
29858
29859
|
arguments: (z) => ({
|
|
29859
29860
|
issue: z.string().describe(ISSUE_REFERENCE).optional(),
|
|
29860
29861
|
project: z.string().describe("Project key owning the document.").optional(),
|
|
29861
29862
|
artifact: z.string().describe("Project document artifact id, slug, or filename.").optional(),
|
|
29862
29863
|
ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
|
|
29863
29864
|
question: z.string({ max: 800 }).describe("Decision question, at most 800 characters."),
|
|
29865
|
+
kind: z.enum(["action"]).describe("Optional human to-do ask kind.").optional(),
|
|
29864
29866
|
options: z.array(z.object({
|
|
29865
29867
|
label: z.string().describe("Selectable option label."),
|
|
29866
29868
|
description: z.string().describe("Optional option context.").optional()
|
|
@@ -31915,6 +31917,12 @@ function askUrgency(args) {
|
|
|
31915
31917
|
const value = args.urgency;
|
|
31916
31918
|
return ASK_URGENCIES.find((urgency) => urgency === value);
|
|
31917
31919
|
}
|
|
31920
|
+
function askKind(args) {
|
|
31921
|
+
const value = optionalString(args, "kind");
|
|
31922
|
+
if (value === undefined || value === "action")
|
|
31923
|
+
return value;
|
|
31924
|
+
throw new Error("kind must be action");
|
|
31925
|
+
}
|
|
31918
31926
|
function parseDispatchRef(ref) {
|
|
31919
31927
|
const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
|
|
31920
31928
|
if (projectDocument) {
|
|
@@ -32350,9 +32358,11 @@ async function executeDispatchTool(input) {
|
|
|
32350
32358
|
const options = args.options;
|
|
32351
32359
|
const multiple = optionalBoolean(args, "multiple");
|
|
32352
32360
|
const urgency = askUrgency(args);
|
|
32361
|
+
const kind = askKind(args);
|
|
32353
32362
|
const anchored = anchorArgs && resolved ? anchor(resolved.artifact, anchorArgs) : undefined;
|
|
32354
32363
|
const askInput = {
|
|
32355
32364
|
question: stringArg(args, "question"),
|
|
32365
|
+
...kind === undefined ? {} : { kind },
|
|
32356
32366
|
...Array.isArray(options) ? { options } : {},
|
|
32357
32367
|
...multiple === undefined ? {} : { multiple },
|
|
32358
32368
|
...urgency === undefined ? {} : { urgency },
|
|
@@ -33105,7 +33115,8 @@ function legionRoleClaimBridge() {
|
|
|
33105
33115
|
return bridge;
|
|
33106
33116
|
const createdBridge = {
|
|
33107
33117
|
claim: undefined,
|
|
33108
|
-
ready: Promise.withResolvers()
|
|
33118
|
+
ready: Promise.withResolvers(),
|
|
33119
|
+
regained: undefined
|
|
33109
33120
|
};
|
|
33110
33121
|
store[LEGION_ROLE_CLAIM_BRIDGE] = createdBridge;
|
|
33111
33122
|
return createdBridge;
|
|
@@ -33114,6 +33125,9 @@ async function claimEnvoyRole(sessionID, role, context) {
|
|
|
33114
33125
|
const bridge = legionRoleClaimBridge();
|
|
33115
33126
|
await (bridge.claim ?? await bridge.ready.promise)(sessionID, role, context);
|
|
33116
33127
|
}
|
|
33128
|
+
function onEnvoyRoleRegained(listener) {
|
|
33129
|
+
legionRoleClaimBridge().regained = listener;
|
|
33130
|
+
}
|
|
33117
33131
|
function resolveSkillsDirectory() {
|
|
33118
33132
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
33119
33133
|
const candidates = [
|
|
@@ -33304,10 +33318,58 @@ function envoyExtension(pi) {
|
|
|
33304
33318
|
driving: false,
|
|
33305
33319
|
selfSubscribed: true
|
|
33306
33320
|
});
|
|
33321
|
+
const reassertRole = async (afterOutage, context) => {
|
|
33322
|
+
const topic = claimedRoleTopic;
|
|
33323
|
+
if (topic === undefined)
|
|
33324
|
+
return;
|
|
33325
|
+
const id = sessionID;
|
|
33326
|
+
const role = topic.slice(ROLE_TOPIC_PREFIX.length);
|
|
33327
|
+
const holder = await client.getRole(role).then((info) => info.holder, (error) => {
|
|
33328
|
+
if (error instanceof EnvoyApiError && error.details.status === 404)
|
|
33329
|
+
return;
|
|
33330
|
+
throw error;
|
|
33331
|
+
});
|
|
33332
|
+
let reason;
|
|
33333
|
+
if (holder === id) {
|
|
33334
|
+
if (!afterOutage)
|
|
33335
|
+
return;
|
|
33336
|
+
reason = "reregistered";
|
|
33337
|
+
} else {
|
|
33338
|
+
const result = await client.setRole({ sessionID: id, role, soft: true });
|
|
33339
|
+
if (claimedRoleTopic !== topic || sessionID !== id) {
|
|
33340
|
+
if (result.claimed)
|
|
33341
|
+
await client.unsubscribe({ sessionID: id, topics: [topic] });
|
|
33342
|
+
return;
|
|
33343
|
+
}
|
|
33344
|
+
if (!result.claimed) {
|
|
33345
|
+
claimedRoleTopic = undefined;
|
|
33346
|
+
logger.warn("envoy: role re-assertion refused; held by another live session", {
|
|
33347
|
+
role,
|
|
33348
|
+
sessionID: id,
|
|
33349
|
+
holder: result.holder
|
|
33350
|
+
});
|
|
33351
|
+
context.ui.notify(`envoy: role ${role} is now held by session ${result.holder}; this session no longer holds it`, "warning");
|
|
33352
|
+
return;
|
|
33353
|
+
}
|
|
33354
|
+
logger.warn("envoy: role re-asserted after the listener lost the claim", {
|
|
33355
|
+
role,
|
|
33356
|
+
sessionID: id,
|
|
33357
|
+
previousHolder: holder ?? null
|
|
33358
|
+
});
|
|
33359
|
+
reason = "reclaimed";
|
|
33360
|
+
}
|
|
33361
|
+
const regained = legionRoleClaimBridge().regained;
|
|
33362
|
+
if (regained === undefined)
|
|
33363
|
+
return;
|
|
33364
|
+
Promise.resolve().then(() => regained(role, reason)).catch((error) => {
|
|
33365
|
+
logger.warn("envoy: role regain hook failed", { role, reason, error: messageFor(error) });
|
|
33366
|
+
});
|
|
33367
|
+
};
|
|
33307
33368
|
const ensureHeartbeat = (context) => {
|
|
33308
33369
|
if (heartbeatRegistered)
|
|
33309
33370
|
return;
|
|
33310
33371
|
let heartbeatOutageNotified = false;
|
|
33372
|
+
let registrationFailed = false;
|
|
33311
33373
|
let healing = false;
|
|
33312
33374
|
context.setInterval(() => {
|
|
33313
33375
|
const liveSessionID = context.sessionManager.getSessionId();
|
|
@@ -33317,7 +33379,12 @@ function envoyExtension(pi) {
|
|
|
33317
33379
|
if (healing)
|
|
33318
33380
|
return;
|
|
33319
33381
|
healing = true;
|
|
33320
|
-
|
|
33382
|
+
const afterOutage = registrationFailed;
|
|
33383
|
+
(drifted ? establishSession(context) : registerSession()).then(() => drifted ? undefined : reassertRole(afterOutage, context), (error) => {
|
|
33384
|
+
registrationFailed = true;
|
|
33385
|
+
throw error;
|
|
33386
|
+
}).then(() => {
|
|
33387
|
+
registrationFailed = false;
|
|
33321
33388
|
heartbeatOutageNotified = false;
|
|
33322
33389
|
}).catch((error) => {
|
|
33323
33390
|
if (heartbeatOutageNotified)
|
|
@@ -33767,5 +33834,6 @@ function topicsFor(parameters, fallback = []) {
|
|
|
33767
33834
|
}
|
|
33768
33835
|
export {
|
|
33769
33836
|
claimEnvoyRole,
|
|
33770
|
-
envoyExtension as default
|
|
33837
|
+
envoyExtension as default,
|
|
33838
|
+
onEnvoyRoleRegained
|
|
33771
33839
|
};
|
package/dist/legion.js
CHANGED
|
@@ -29686,6 +29686,7 @@ var ArtifactReviewEventPayloadSchema = object({
|
|
|
29686
29686
|
var askEventPayloadFields = {
|
|
29687
29687
|
id: string2().optional(),
|
|
29688
29688
|
opened_event_id: number2().int().positive(),
|
|
29689
|
+
kind: _enum2(["question", "approval", "action"]).optional(),
|
|
29689
29690
|
question: string2().optional(),
|
|
29690
29691
|
options: array(object({ label: string2().optional() })).nullish(),
|
|
29691
29692
|
answer: object({ selected: array(string2()).nullish(), text: string2().nullish() }).nullish(),
|
|
@@ -29853,13 +29854,14 @@ var dispatchToolSpecs = [
|
|
|
29853
29854
|
},
|
|
29854
29855
|
{
|
|
29855
29856
|
name: "dispatch_ask",
|
|
29856
|
-
description: "Open a durable, answerable decision on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + `reference \u2014 it must be answerable from its own text and anchor alone, never "see above". Question is at most 800 ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
29857
|
+
description: "Open a durable, answerable decision or human to-do on an issue or project document. Do not use it for a status update or discussion; " + "use dispatch_message instead. Use kind: action for a to-do a human must complete; it has fixed Done / Can't answers. " + "Anchor a document question, thread reply_to/reply_to_ask, or cite a dispatch:// " + `reference \u2014 it must be answerable from its own text and anchor alone, never "see above". Question is at most 800 ` + `characters and has at most 8 options. ${OWNER_REFERENCE}`,
|
|
29857
29858
|
arguments: (z) => ({
|
|
29858
29859
|
issue: z.string().describe(ISSUE_REFERENCE).optional(),
|
|
29859
29860
|
project: z.string().describe("Project key owning the document.").optional(),
|
|
29860
29861
|
artifact: z.string().describe("Project document artifact id, slug, or filename.").optional(),
|
|
29861
29862
|
ref: z.string().describe("Optional dispatch:// issue or document reference.").optional(),
|
|
29862
29863
|
question: z.string({ max: 800 }).describe("Decision question, at most 800 characters."),
|
|
29864
|
+
kind: z.enum(["action"]).describe("Optional human to-do ask kind.").optional(),
|
|
29863
29865
|
options: z.array(z.object({
|
|
29864
29866
|
label: z.string().describe("Selectable option label."),
|
|
29865
29867
|
description: z.string().describe("Optional option context.").optional()
|
|
@@ -32341,6 +32343,12 @@ function askUrgency(args) {
|
|
|
32341
32343
|
const value = args.urgency;
|
|
32342
32344
|
return ASK_URGENCIES.find((urgency) => urgency === value);
|
|
32343
32345
|
}
|
|
32346
|
+
function askKind(args) {
|
|
32347
|
+
const value = optionalString(args, "kind");
|
|
32348
|
+
if (value === undefined || value === "action")
|
|
32349
|
+
return value;
|
|
32350
|
+
throw new Error("kind must be action");
|
|
32351
|
+
}
|
|
32344
32352
|
function parseDispatchRef(ref) {
|
|
32345
32353
|
const projectDocument = ref.match(/^dispatch:\/\/([A-Z][A-Z0-9]{1,9})\/artifact\/([^/@]+)(?:@v(\d+))?(?:\/(ask|comment)\/([^/]+))?$/);
|
|
32346
32354
|
if (projectDocument) {
|
|
@@ -32776,9 +32784,11 @@ async function executeDispatchTool(input) {
|
|
|
32776
32784
|
const options = args.options;
|
|
32777
32785
|
const multiple = optionalBoolean(args, "multiple");
|
|
32778
32786
|
const urgency = askUrgency(args);
|
|
32787
|
+
const kind = askKind(args);
|
|
32779
32788
|
const anchored = anchorArgs && resolved ? anchor(resolved.artifact, anchorArgs) : undefined;
|
|
32780
32789
|
const askInput = {
|
|
32781
32790
|
question: stringArg(args, "question"),
|
|
32791
|
+
...kind === undefined ? {} : { kind },
|
|
32782
32792
|
...Array.isArray(options) ? { options } : {},
|
|
32783
32793
|
...multiple === undefined ? {} : { multiple },
|
|
32784
32794
|
...urgency === undefined ? {} : { urgency },
|
|
@@ -33523,7 +33533,8 @@ function legionRoleClaimBridge() {
|
|
|
33523
33533
|
return bridge;
|
|
33524
33534
|
const createdBridge = {
|
|
33525
33535
|
claim: undefined,
|
|
33526
|
-
ready: Promise.withResolvers()
|
|
33536
|
+
ready: Promise.withResolvers(),
|
|
33537
|
+
regained: undefined
|
|
33527
33538
|
};
|
|
33528
33539
|
store[LEGION_ROLE_CLAIM_BRIDGE] = createdBridge;
|
|
33529
33540
|
return createdBridge;
|
|
@@ -33532,6 +33543,9 @@ async function claimEnvoyRole(sessionID, role, context) {
|
|
|
33532
33543
|
const bridge = legionRoleClaimBridge();
|
|
33533
33544
|
await (bridge.claim ?? await bridge.ready.promise)(sessionID, role, context);
|
|
33534
33545
|
}
|
|
33546
|
+
function onEnvoyRoleRegained(listener) {
|
|
33547
|
+
legionRoleClaimBridge().regained = listener;
|
|
33548
|
+
}
|
|
33535
33549
|
function resolveSkillsDirectory() {
|
|
33536
33550
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
33537
33551
|
const candidates = [
|
|
@@ -33722,10 +33736,58 @@ function envoyExtension(pi) {
|
|
|
33722
33736
|
driving: false,
|
|
33723
33737
|
selfSubscribed: true
|
|
33724
33738
|
});
|
|
33739
|
+
const reassertRole = async (afterOutage, context) => {
|
|
33740
|
+
const topic = claimedRoleTopic;
|
|
33741
|
+
if (topic === undefined)
|
|
33742
|
+
return;
|
|
33743
|
+
const id = sessionID;
|
|
33744
|
+
const role = topic.slice(ROLE_TOPIC_PREFIX.length);
|
|
33745
|
+
const holder = await client.getRole(role).then((info) => info.holder, (error) => {
|
|
33746
|
+
if (error instanceof EnvoyApiError && error.details.status === 404)
|
|
33747
|
+
return;
|
|
33748
|
+
throw error;
|
|
33749
|
+
});
|
|
33750
|
+
let reason;
|
|
33751
|
+
if (holder === id) {
|
|
33752
|
+
if (!afterOutage)
|
|
33753
|
+
return;
|
|
33754
|
+
reason = "reregistered";
|
|
33755
|
+
} else {
|
|
33756
|
+
const result = await client.setRole({ sessionID: id, role, soft: true });
|
|
33757
|
+
if (claimedRoleTopic !== topic || sessionID !== id) {
|
|
33758
|
+
if (result.claimed)
|
|
33759
|
+
await client.unsubscribe({ sessionID: id, topics: [topic] });
|
|
33760
|
+
return;
|
|
33761
|
+
}
|
|
33762
|
+
if (!result.claimed) {
|
|
33763
|
+
claimedRoleTopic = undefined;
|
|
33764
|
+
logger.warn("envoy: role re-assertion refused; held by another live session", {
|
|
33765
|
+
role,
|
|
33766
|
+
sessionID: id,
|
|
33767
|
+
holder: result.holder
|
|
33768
|
+
});
|
|
33769
|
+
context.ui.notify(`envoy: role ${role} is now held by session ${result.holder}; this session no longer holds it`, "warning");
|
|
33770
|
+
return;
|
|
33771
|
+
}
|
|
33772
|
+
logger.warn("envoy: role re-asserted after the listener lost the claim", {
|
|
33773
|
+
role,
|
|
33774
|
+
sessionID: id,
|
|
33775
|
+
previousHolder: holder ?? null
|
|
33776
|
+
});
|
|
33777
|
+
reason = "reclaimed";
|
|
33778
|
+
}
|
|
33779
|
+
const regained = legionRoleClaimBridge().regained;
|
|
33780
|
+
if (regained === undefined)
|
|
33781
|
+
return;
|
|
33782
|
+
Promise.resolve().then(() => regained(role, reason)).catch((error) => {
|
|
33783
|
+
logger.warn("envoy: role regain hook failed", { role, reason, error: messageFor(error) });
|
|
33784
|
+
});
|
|
33785
|
+
};
|
|
33725
33786
|
const ensureHeartbeat = (context) => {
|
|
33726
33787
|
if (heartbeatRegistered)
|
|
33727
33788
|
return;
|
|
33728
33789
|
let heartbeatOutageNotified = false;
|
|
33790
|
+
let registrationFailed = false;
|
|
33729
33791
|
let healing = false;
|
|
33730
33792
|
context.setInterval(() => {
|
|
33731
33793
|
const liveSessionID = context.sessionManager.getSessionId();
|
|
@@ -33735,7 +33797,12 @@ function envoyExtension(pi) {
|
|
|
33735
33797
|
if (healing)
|
|
33736
33798
|
return;
|
|
33737
33799
|
healing = true;
|
|
33738
|
-
|
|
33800
|
+
const afterOutage = registrationFailed;
|
|
33801
|
+
(drifted ? establishSession(context) : registerSession()).then(() => drifted ? undefined : reassertRole(afterOutage, context), (error) => {
|
|
33802
|
+
registrationFailed = true;
|
|
33803
|
+
throw error;
|
|
33804
|
+
}).then(() => {
|
|
33805
|
+
registrationFailed = false;
|
|
33739
33806
|
heartbeatOutageNotified = false;
|
|
33740
33807
|
}).catch((error) => {
|
|
33741
33808
|
if (heartbeatOutageNotified)
|
|
@@ -34301,15 +34368,29 @@ function legionExtension(pi) {
|
|
|
34301
34368
|
}
|
|
34302
34369
|
});
|
|
34303
34370
|
};
|
|
34371
|
+
const rerunReadyAfterRegain = async (endpoint, role, reason, call) => {
|
|
34372
|
+
try {
|
|
34373
|
+
await callReadyWithRetry(`${endpoint} after role regain`, call);
|
|
34374
|
+
console.error(`[legion] re-ran ${endpoint} after role ${role} was ${reason}`);
|
|
34375
|
+
} catch (error) {
|
|
34376
|
+
console.error(`[legion] ${endpoint} after role ${role} was ${reason} failed; the daemon's held work stays undelivered until the next regain or boot: ${messageFor(error)}`);
|
|
34377
|
+
}
|
|
34378
|
+
};
|
|
34304
34379
|
const claimController = async (context) => {
|
|
34305
34380
|
const sessionID = context.sessionManager.getSessionId();
|
|
34306
34381
|
const daemon = createLegionDaemonClient(requiredEnvironment(process.env, "LEGION_DAEMON_URL"));
|
|
34307
34382
|
const secret = controllerCapability ?? requiredControllerCapability(process.env);
|
|
34308
34383
|
controllerCapability = secret;
|
|
34309
34384
|
const { project } = await daemon.state();
|
|
34310
|
-
|
|
34385
|
+
const token = controllerToken(project);
|
|
34386
|
+
await claimEnvoyRole(sessionID, token, "setInterval" in context ? context : undefined);
|
|
34311
34387
|
await daemon.controllerReady({ secret, sessionId: sessionID });
|
|
34312
34388
|
controllerSessionID = sessionID;
|
|
34389
|
+
onEnvoyRoleRegained(async (role, reason) => {
|
|
34390
|
+
if (role !== token)
|
|
34391
|
+
return;
|
|
34392
|
+
await rerunReadyAfterRegain("controller/ready", role, reason, () => daemon.controllerReady({ secret, sessionId: sessionID }));
|
|
34393
|
+
});
|
|
34313
34394
|
};
|
|
34314
34395
|
const reclaimArchitect = async () => {
|
|
34315
34396
|
if (capability === undefined || capability.kind !== "root-architect") {
|
|
@@ -34412,6 +34493,17 @@ function legionExtension(pi) {
|
|
|
34412
34493
|
secret: started.secret,
|
|
34413
34494
|
generation: generation(process.env)
|
|
34414
34495
|
}));
|
|
34496
|
+
onEnvoyRoleRegained(async (role, reason) => {
|
|
34497
|
+
if (role !== roleToken || capability === undefined)
|
|
34498
|
+
return;
|
|
34499
|
+
const { secret } = capability;
|
|
34500
|
+
await rerunReadyAfterRegain("process/ready", role, reason, () => roleDaemon().processReady({
|
|
34501
|
+
tree,
|
|
34502
|
+
sessionId: sessionID,
|
|
34503
|
+
secret,
|
|
34504
|
+
generation: generation(process.env)
|
|
34505
|
+
}));
|
|
34506
|
+
});
|
|
34415
34507
|
registerArchitectTools();
|
|
34416
34508
|
await activateLegionTool();
|
|
34417
34509
|
} catch (error) {
|
|
@@ -128,6 +128,24 @@ An ask must be answerable from its own text and its anchor alone. Anchor a quest
|
|
|
128
128
|
about a comment with `reply_to`; thread a follow-up on your own ask with `reply_to_ask`; cite anything else with a `dispatch://`
|
|
129
129
|
reference (see [References](#references)). Never write "see above", "the message above", or "as attached".
|
|
130
130
|
|
|
131
|
+
**Anything that needs the human is an ask, or it does not exist.** An approval, a credential,
|
|
132
|
+
a setting only they can change, a review click, a conflict between two of their own rules - if
|
|
133
|
+
your work waits on it, open a `dispatch_ask` with `kind: "action"` the moment you know, the
|
|
134
|
+
action as the question (the server supplies the fixed `Done` / `Can't` options; `Can't` requires
|
|
135
|
+
an explanation). Never write it into a spec, a comment reply, a message, or a
|
|
136
|
+
pull-request body: nothing in those paths reaches the human's Inbox, and a human who is not
|
|
137
|
+
reading your document does not know they are the blocker. Before asking, try to remove the
|
|
138
|
+
step: a value already on the machine, a permission you already hold, an API that replaces the
|
|
139
|
+
click. One ask per item, `urgency: "high"` when work is stopped on it; while it is open, keep
|
|
140
|
+
working on everything that is not.
|
|
141
|
+
|
|
142
|
+
Use `kind: "action"` for a to-do handed to a human. It has fixed `Done` / `Can't` options;
|
|
143
|
+
`Can't` requires an explanation, while the asker can still correct the action's wording:
|
|
144
|
+
```ts
|
|
145
|
+
dispatch_ask({ issue: "DSP-42", kind: "action",
|
|
146
|
+
question: "Confirm the deployment is complete." })
|
|
147
|
+
```
|
|
148
|
+
|
|
131
149
|
Correct or refine an open ask in place instead of opening a second question:
|
|
132
150
|
```ts
|
|
133
151
|
dispatch_edit_ask({
|
|
@@ -25,7 +25,10 @@ environment, then run:
|
|
|
25
25
|
The command resolves the project from daemon state, claims the Envoy role for the current
|
|
26
26
|
session, and posts readiness before controller commands can act. It retains the environment
|
|
27
27
|
capability for `legion({ op: "set_status", issue, status })`. Never pass a secret as a command argument
|
|
28
|
-
or copy it into a transcript.
|
|
28
|
+
or copy it into a transcript. The claim is kept alive automatically afterwards: the Envoy
|
|
29
|
+
registration heartbeat re-asserts it and re-posts readiness whenever the listener loses sight of
|
|
30
|
+
this session, so `/legion-claim-controller` is the manual override, not a routine step after a
|
|
31
|
+
listener restart.
|
|
29
32
|
|
|
30
33
|
This handshake lets the daemon redeliver held controller work. It does not turn the controller
|
|
31
34
|
into a state holder: daemon state and the Dispatch project remain authoritative.
|