@sjawhar/opencode-legion-envoy 8.5.1 → 8.6.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/dispatch.js
CHANGED
|
@@ -16577,14 +16577,14 @@ function toolSchema(tool) {
|
|
|
16577
16577
|
toolSchemas.set(tool, schema);
|
|
16578
16578
|
return schema;
|
|
16579
16579
|
}
|
|
16580
|
-
async function resolveOwnerArguments(tool, input, cwd, env, exec,
|
|
16580
|
+
async function resolveOwnerArguments(tool, input, cwd, env, exec, dashboardUrls, problems) {
|
|
16581
16581
|
if (issueFreeTools[tool] === true)
|
|
16582
16582
|
return { args: input, ref: null, owner: null };
|
|
16583
16583
|
const refArgument = input.ref;
|
|
16584
16584
|
let ref = null;
|
|
16585
16585
|
let args = input;
|
|
16586
16586
|
if (typeof refArgument === "string") {
|
|
16587
|
-
const refText = dispatchRefFromUrl(refArgument,
|
|
16587
|
+
const refText = dashboardUrls.map((dashboard) => dispatchRefFromUrl(refArgument, dashboard)).find((ref2) => ref2 !== undefined) ?? refArgument;
|
|
16588
16588
|
if (refText !== refArgument)
|
|
16589
16589
|
args = { ...input, ref: refText };
|
|
16590
16590
|
const agentArtifact = parseAgentArtifactRef(refText);
|
|
@@ -16846,7 +16846,7 @@ function progressText(progress, sep) {
|
|
|
16846
16846
|
}
|
|
16847
16847
|
return parts.join(sep);
|
|
16848
16848
|
}
|
|
16849
|
-
function issueSummary(issue2, events, references, graph, titles) {
|
|
16849
|
+
function issueSummary(issue2, events, references, graph, linkBase, titles) {
|
|
16850
16850
|
const asks = issue2.open_asks;
|
|
16851
16851
|
const spec = issue2.artifacts?.find((artifact) => artifact.primary);
|
|
16852
16852
|
const specApproval = spec === undefined ? undefined : approvalLine(spec);
|
|
@@ -16864,6 +16864,7 @@ function issueSummary(issue2, events, references, graph, titles) {
|
|
|
16864
16864
|
return [
|
|
16865
16865
|
`Title: ${issue2.title}`,
|
|
16866
16866
|
`Key: ${issue2.key}`,
|
|
16867
|
+
`Link: ${new URL(`/issues/${encodeURIComponent(issue2.key)}`, linkBase).toString()}`,
|
|
16867
16868
|
`Status: ${issue2.status}`,
|
|
16868
16869
|
`Assignee: ${issue2.assignee ?? "unassigned"}`,
|
|
16869
16870
|
`Claimed by: ${issue2.claim === null ? "nobody" : claimText(issue2.claim, titles)}`,
|
|
@@ -17328,6 +17329,7 @@ function withAccount(error48, account) {
|
|
|
17328
17329
|
}
|
|
17329
17330
|
async function executeDispatchTool(input) {
|
|
17330
17331
|
const { url: configUrl, token: configToken } = input.config;
|
|
17332
|
+
const linkBase = input.config.publicUrl ?? configUrl;
|
|
17331
17333
|
const baseFetch = input.fetchImpl ?? fetch;
|
|
17332
17334
|
const fetchImpl = Object.assign(async (...args2) => {
|
|
17333
17335
|
try {
|
|
@@ -17342,7 +17344,7 @@ async function executeDispatchTool(input) {
|
|
|
17342
17344
|
const env = input.env ?? process.env;
|
|
17343
17345
|
const exec = input.exec ?? defaultExec;
|
|
17344
17346
|
const problems = [];
|
|
17345
|
-
const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, configUrl, problems);
|
|
17347
|
+
const ownerArguments = await resolveOwnerArguments(input.tool, input.args, input.cwd, env, exec, input.config.publicUrl === undefined ? [configUrl] : [configUrl, input.config.publicUrl], problems);
|
|
17346
17348
|
const ownerMissing = issueFreeTools[input.tool] !== true && ownerArguments.owner === null;
|
|
17347
17349
|
const schema = toolSchema(input.tool);
|
|
17348
17350
|
const parsed = schema.safeParse(ownerArguments.args, { reportInput: true });
|
|
@@ -17360,13 +17362,13 @@ async function executeDispatchTool(input) {
|
|
|
17360
17362
|
const project = optionalString(ownerArguments.args, "project");
|
|
17361
17363
|
if (project !== undefined) {
|
|
17362
17364
|
const response2 = await client2.openAsksForProject(project);
|
|
17363
|
-
return { text: formatOpenAsksSummary(response2,
|
|
17365
|
+
return { text: formatOpenAsksSummary(response2, linkBase), details: { ...response2 } };
|
|
17364
17366
|
}
|
|
17365
17367
|
const sessionId = input.sessionId?.trim();
|
|
17366
17368
|
if (!sessionId)
|
|
17367
17369
|
throw new Error("host session id is required for dispatch open-asks");
|
|
17368
17370
|
const response = await client2.openAsks(sessionId);
|
|
17369
|
-
return { text: formatOpenAsksSummary(response,
|
|
17371
|
+
return { text: formatOpenAsksSummary(response, linkBase), details: { ...response } };
|
|
17370
17372
|
}
|
|
17371
17373
|
if (input.tool === "dispatch_whoami") {
|
|
17372
17374
|
const sessionId = input.sessionId?.trim();
|
|
@@ -17448,11 +17450,13 @@ async function executeDispatchTool(input) {
|
|
|
17448
17450
|
isPrimarySpec: spec !== undefined
|
|
17449
17451
|
}),
|
|
17450
17452
|
...componentGuidance === undefined ? [] : [componentGuidance],
|
|
17451
|
-
...renderSuggestions(created.advice?.suggestions,
|
|
17453
|
+
...renderSuggestions(created.advice?.suggestions, linkBase)
|
|
17452
17454
|
];
|
|
17455
|
+
const href = new URL(`/issues/${encodeURIComponent(created.key)}`, linkBase).toString();
|
|
17456
|
+
const assigned = created.assignee === undefined ? "" : created.assignee === null ? "; unassigned" : `; assigned to ${created.assignee}`;
|
|
17453
17457
|
return {
|
|
17454
17458
|
text: [
|
|
17455
|
-
`Created ${created.key}: ${created.title} ${notSubscribed(issueTopic(created.key))}`,
|
|
17459
|
+
`Created ${created.key}: ${created.title} \u2192 ${href}${assigned} ${notSubscribed(issueTopic(created.key))}`,
|
|
17456
17460
|
...adviceLines
|
|
17457
17461
|
].join(`
|
|
17458
17462
|
`),
|
|
@@ -17470,7 +17474,7 @@ async function executeDispatchTool(input) {
|
|
|
17470
17474
|
text: [
|
|
17471
17475
|
`Not created: "${title}" looks like a duplicate.`,
|
|
17472
17476
|
...candidates.map((candidate) => {
|
|
17473
|
-
const href = new URL(candidate.href,
|
|
17477
|
+
const href = new URL(candidate.href, linkBase).toString();
|
|
17474
17478
|
return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
|
|
17475
17479
|
}),
|
|
17476
17480
|
"Reference the existing issue, or run dispatch issue again with --force after reading it."
|
|
@@ -17611,7 +17615,7 @@ async function executeDispatchTool(input) {
|
|
|
17611
17615
|
...limit === undefined ? {} : { limit },
|
|
17612
17616
|
...offset === undefined ? {} : { offset }
|
|
17613
17617
|
});
|
|
17614
|
-
return searchAnswer(search, query, offset,
|
|
17618
|
+
return searchAnswer(search, query, offset, linkBase);
|
|
17615
17619
|
}
|
|
17616
17620
|
case "dispatch_issues": {
|
|
17617
17621
|
const project = stringArg(args, "project");
|
|
@@ -17675,7 +17679,7 @@ async function executeDispatchTool(input) {
|
|
|
17675
17679
|
} catch (error48) {
|
|
17676
17680
|
throw refusalWithCode(error48);
|
|
17677
17681
|
}
|
|
17678
|
-
const href = new URL(`/projects/${encodeURIComponent(project.key)}`,
|
|
17682
|
+
const href = new URL(`/projects/${encodeURIComponent(project.key)}`, linkBase).toString();
|
|
17679
17683
|
return {
|
|
17680
17684
|
text: `Created project ${project.key} (${project.name}) at ${project.created_at}: ${href}`,
|
|
17681
17685
|
details: { project: project.key, name: project.name, created_at: project.created_at }
|
|
@@ -17768,7 +17772,7 @@ async function executeDispatchTool(input) {
|
|
|
17768
17772
|
const askOwner = ask.issue_key !== null ? issueTopic(ask.issue_key) : resolved === undefined ? issueTopic(issue2()) : documentTopic(resolved.artifact);
|
|
17769
17773
|
const adviceLines = [
|
|
17770
17774
|
...renderAdvice(input.tool, askOwner.label, ask.advice, {}),
|
|
17771
|
-
...renderSuggestions(ask.advice?.suggestions,
|
|
17775
|
+
...renderSuggestions(ask.advice?.suggestions, linkBase)
|
|
17772
17776
|
];
|
|
17773
17777
|
return {
|
|
17774
17778
|
text: [
|
|
@@ -18249,7 +18253,7 @@ ${trailer.join(`
|
|
|
18249
18253
|
])
|
|
18250
18254
|
]);
|
|
18251
18255
|
return {
|
|
18252
|
-
text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], titles),
|
|
18256
|
+
text: issueSummary(read.issue, read.events, references, [...pictures2.lines, ...graph], linkBase, titles),
|
|
18253
18257
|
details: { issue: read.issue.key },
|
|
18254
18258
|
...showImages(pictures2)
|
|
18255
18259
|
};
|
|
@@ -18987,7 +18991,17 @@ function dispatchSettings(env, config2) {
|
|
|
18987
18991
|
}
|
|
18988
18992
|
const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
18989
18993
|
const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
|
|
18990
|
-
|
|
18994
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
18995
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
18996
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
18997
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
18998
|
+
}
|
|
18999
|
+
return {
|
|
19000
|
+
enabled: true,
|
|
19001
|
+
url: url2,
|
|
19002
|
+
token,
|
|
19003
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
19004
|
+
};
|
|
18991
19005
|
} catch (error48) {
|
|
18992
19006
|
return offWithError(messageFor(error48));
|
|
18993
19007
|
}
|
package/dist/src/server.js
CHANGED
|
@@ -14317,6 +14317,8 @@ var DISPATCH_DOCUMENT_TOPIC_PREFIX = `${DISPATCH_TOPIC_PREFIX}document.`;
|
|
|
14317
14317
|
function agentSubject(session) {
|
|
14318
14318
|
return `${AGENT_TOPIC_PREFIX}${session}`;
|
|
14319
14319
|
}
|
|
14320
|
+
// ../contracts/src/slack.ts
|
|
14321
|
+
var SLACK_POST_TEXT_MAX = 4000;
|
|
14320
14322
|
// ../contracts/src/tool-schema.ts
|
|
14321
14323
|
function overCapMessage(length, max) {
|
|
14322
14324
|
return `is ${length - max} characters over the ${max}-character limit (${length}/${max})`;
|
|
@@ -14925,7 +14927,17 @@ function dispatchSettings(env, config2) {
|
|
|
14925
14927
|
}
|
|
14926
14928
|
const url2 = (explicitUrl ?? (source.kind === "file" ? source.server : own.serverUrl ?? DEFAULT_SERVER_URL)).replace(/\/+$/, "");
|
|
14927
14929
|
const token = source.kind === "variable" ? source.token : fileBearer(source, url2, "DISPATCH", env);
|
|
14928
|
-
|
|
14930
|
+
const publicUrl = nonEmpty(env.DISPATCH_PUBLIC_URL);
|
|
14931
|
+
const publicParsed = publicUrl === undefined ? undefined : httpUrl2(publicUrl);
|
|
14932
|
+
if (publicUrl !== undefined && (publicParsed === undefined || publicParsed.port === "0")) {
|
|
14933
|
+
return offWithError("DISPATCH_PUBLIC_URL must be an http or https URL");
|
|
14934
|
+
}
|
|
14935
|
+
return {
|
|
14936
|
+
enabled: true,
|
|
14937
|
+
url: url2,
|
|
14938
|
+
token,
|
|
14939
|
+
...publicUrl === undefined ? {} : { publicUrl: publicUrl.replace(/\/+$/, "") }
|
|
14940
|
+
};
|
|
14929
14941
|
} catch (error48) {
|
|
14930
14942
|
return offWithError(messageFor(error48));
|
|
14931
14943
|
}
|
|
@@ -15129,7 +15141,8 @@ var EnvoyToolOperation = {
|
|
|
15129
15141
|
setRole: "setRole",
|
|
15130
15142
|
getRole: "getRole",
|
|
15131
15143
|
whoami: "whoami",
|
|
15132
|
-
listSessions: "listSessions"
|
|
15144
|
+
listSessions: "listSessions",
|
|
15145
|
+
slackPost: "slackPost"
|
|
15133
15146
|
};
|
|
15134
15147
|
var envoyToolSpecs = [
|
|
15135
15148
|
{
|
|
@@ -15200,6 +15213,16 @@ var envoyToolSpecs = [
|
|
|
15200
15213
|
title: schema.string().optional()
|
|
15201
15214
|
}),
|
|
15202
15215
|
operation: EnvoyToolOperation.listSessions
|
|
15216
|
+
},
|
|
15217
|
+
{
|
|
15218
|
+
name: "slack_post",
|
|
15219
|
+
description: "Reply in a Slack thread as the Legion Slack app, with the bot token the secrets broker grants this session (LEGION_SLACK_BOT_TOKEN); the session never holds it. channel is the Slack channel id (a mention's channel_id); thread_ts is the thread's root ts: the mention's thread_ts, or its own ts when it has none. Answers the new message's ts. A refusal carries Slack's own error code (not_in_channel: the app is not in that channel) or the broker's state (waiting for approval, denied). Sent once and never retried: after 'Slack did not answer' the reply may already be in the thread, so do not post it again.",
|
|
15220
|
+
arguments: (schema) => ({
|
|
15221
|
+
channel: schema.string({ min: 1 }).describe("Slack channel id, such as C0123ABCD."),
|
|
15222
|
+
thread_ts: schema.string({ min: 1 }).describe("The thread's root ts, which keeps the reply in the mention's thread."),
|
|
15223
|
+
text: schema.string({ min: 1, max: SLACK_POST_TEXT_MAX }).describe(`The reply, in Slack mrkdwn, at most ${SLACK_POST_TEXT_MAX} characters.`)
|
|
15224
|
+
}),
|
|
15225
|
+
operation: EnvoyToolOperation.slackPost
|
|
15203
15226
|
}
|
|
15204
15227
|
];
|
|
15205
15228
|
var envoyToolSchemas = new Map;
|
package/package.json
CHANGED
|
@@ -57,8 +57,9 @@ dispatch://PROJECT/artifact/<document-ref>/comment/<id>
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
A bare UUID or `KEY#seq` is not a reference; the `dispatch://` form is what Dispatch links and records. `dispatch read` also
|
|
60
|
-
accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server
|
|
61
|
-
`dispatch://` form above), and an
|
|
60
|
+
accepts the dashboard URL of an issue, spec, artifact, ask, comment, or project document on the configured server, or on its public
|
|
61
|
+
dashboard when `DISPATCH_PUBLIC_URL` names one (it maps to the `dispatch://` form above), and an issue's read names its page
|
|
62
|
+
(`Link:`, on that public dashboard when there is one), the link to hand a person. An ask or comment id may be a unique prefix of at least 8 hex characters; a message id is always the
|
|
62
63
|
full uuid. A non-uuid id on `GET /asks/{id}`, `/comments/{id}`, or `/issues/{key}/messages/{id}` is a 400 `ASK_ID_INPUT` /
|
|
63
64
|
`COMMENT_ID_INPUT` / `MESSAGE_ID_INPUT`, never a 500.
|
|
64
65
|
|
package/skills/envoy/SKILL.md
CHANGED
|
@@ -237,6 +237,12 @@ envoy_subscribe([
|
|
|
237
237
|
This follows every message and mention in one thread. Channel-level topics end in `.message` or
|
|
238
238
|
`.mention`; thread timestamps replace dots with underscores.
|
|
239
239
|
|
|
240
|
+
Reply in a thread with `slack_post(channel=<channel_id>, thread_ts=<the message's thread_ts, or
|
|
241
|
+
its ts>, text=…)`, which posts as the Legion Slack app with the bot token the secrets broker grants
|
|
242
|
+
the session (`LEGION_SLACK_BOT_TOKEN`). A refusal carries Slack's own error code, or the broker's
|
|
243
|
+
state when it has not granted the token. The post is sent once; when the answer is that Slack did
|
|
244
|
+
not answer, the reply may already be there, so do not post it again.
|
|
245
|
+
|
|
240
246
|
## Ghost Wispr
|
|
241
247
|
|
|
242
248
|
Ghost Wispr topics are `notifications.ghostwispr.<session>.<kind>`, where `kind` is
|
|
@@ -404,7 +404,8 @@ priority first, then board rank ([Keeping the slots full](#keeping-the-slots-ful
|
|
|
404
404
|
| `todo on <KEY>` from the Go daemon (payload `{kind: "todo"}`) | an issue not handed to Legion that changed while in `todo` and a slot stood free, sent half a minute later | Verify a free slot, then walk the whole `todo` list ([Keeping the slots full](#keeping-the-slots-full-go-daemon)) |
|
|
405
405
|
| `tick on <PROJECT>` from the Go daemon (payload `{kind: "tick", openCapabilities?: [<name>, …]}`) | the project key; the daemon's periodic wake, whatever the slots; `openCapabilities` names the deployment capabilities with no decision, each with its detail and `capabilities.decided.<name>` line under `capabilities` in `read_state`, and the live capabilities a running session's check failed, each with the detail naming the session (pod or pane), role, issue and fact, and no `configLine` | Recheck the trees waiting on a claim, then walk if a slot is free — a gap never stops the walk; post the day's report if this is the day's first turn, naming each open capability in it (the operator closes or decides a deployment row; the operator or the issue's architect fixes a live row's session; you take no other action) |
|
|
406
406
|
| Architect escalation (controller-actionable only: re-file a child as a root issue, capacity, cross-tree conflicts) | request + context | Judge and act; the owning architect writes an issue-design decision as a decision block and opens `dispatch ask` only for a human to-do |
|
|
407
|
-
| Mention |
|
|
407
|
+
| Mention | GitHub PR @mention text | Answer, or route to the owning issue's architect role |
|
|
408
|
+
| Slack mention (`from: slack`) | the mention's `channel_id`, `user_id`, `text`, `ts`, `thread_ts` | Nothing when your `Slack channels:` line does not name its `channel_id`; otherwise answer in its thread with `slack_post`: a state question from `read_state`, a work request filed as a Dispatch issue, an architect's question routed to that architect ([Slack mentions](skill://legion-controller/references/slack-mentions.md)) |
|
|
408
409
|
| `held on <KEY>` from the Go daemon (payload `{kind: "held", phase, role?, reason?}`) | the held issue, the phase it left, and the role whose claim failed, or `reason: "escalated"` | Verify the hold in `read_state` (the issue's phase is `held`). Without `reason`, a phase worker's launches or prompts ran out and the tree's architect decides retry or escalate: no action. With `reason: "escalated"` (on the record, `issues.<KEY>.holdReason` is `escalated`), the architect sent it to you: handle it as an architect escalation below. Parking the tree is `set_status` of the root to `backlog`; setting the root back to `todo` later re-admits it as a new generation, which starts again from its architect |
|
|
409
410
|
| `worker-died on <KEY>` from the Go daemon with `role: "architect"` | the tree root whose architect's claim failed, and the phase the root was in | The tree's architect ran out of launches or prompts and the daemon relaunches nothing; every other notice of the tree goes to that architect, so nobody inside the tree can act. Verify in `read_state` (`issues.<KEY>.architect.state` is `failed`); if the root's phase is `done`, the tree is already parked: no action. Otherwise re-admit the tree (`set_status` of the root to `backlog`, then to `todo`: a new generation, whose architect starts again with fresh budgets) or leave it parked and say why on the issue |
|
|
410
411
|
| Direct user message | — | Always first |
|
|
@@ -482,12 +483,13 @@ judgments.
|
|
|
482
483
|
|
|
483
484
|
## Mentions
|
|
484
485
|
|
|
485
|
-
Read the mention and its artifact
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
gate
|
|
491
|
-
|
|
492
|
-
|
|
486
|
+
Read the mention and its artifact; a Slack mention follows [Slack
|
|
487
|
+
mentions](skill://legion-controller/references/slack-mentions.md). Answer it when it asks the
|
|
488
|
+
controller for triage or human-facing information. A human asking how to let a root proceed past
|
|
489
|
+
its design gate approves the root issue's spec document in Dispatch — the `Approve` control in the
|
|
490
|
+
document's header, or the approval question the architect's request opened in the Inbox. The
|
|
491
|
+
controller never opens a gate and there is no operator command for it; a project that does not
|
|
492
|
+
want the gate at all runs `gates.design: off` in its `legion.yaml`. Otherwise resolve the
|
|
493
|
+
authoritative owning architect role and route the verified context with `envoy_publish`. Do not
|
|
494
|
+
route raw event traffic or invent a role token from a partial issue reference.
|
|
493
495
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Slack mentions
|
|
2
|
+
|
|
3
|
+
A person who mentions the Legion Slack app in one of the project's Slack channels is talking to
|
|
4
|
+
you, and you answer in that thread. The `Slack channels:` line of your system prompt names each
|
|
5
|
+
channel `legion.yaml` gives the project (`projects.<KEY>.slack_channels`). Only the controller the
|
|
6
|
+
daemon launches has one: the Slack bot token is the Legion daemon service's, which the secrets
|
|
7
|
+
broker grants to that service's own pods alone. With no such line the project has no channel, and
|
|
8
|
+
nothing here applies.
|
|
9
|
+
|
|
10
|
+
## Your session subscribes for you
|
|
11
|
+
|
|
12
|
+
Your session hears each listed channel's mentions by itself, on
|
|
13
|
+
`notifications.slack.*.<channel>.mention`, for as long as it holds the controller role: a resumed or
|
|
14
|
+
relaunched session hears the channels its launch names and no others, and drops every Slack topic an
|
|
15
|
+
earlier session subscribed to by hand. Never `envoy_subscribe` a Slack topic yourself.
|
|
16
|
+
|
|
17
|
+
A delivery from Slack whose `channel_id` the line does not name is not yours: do nothing with it.
|
|
18
|
+
|
|
19
|
+
A mention published while no controller ran never arrives: Envoy hands a session no copy of an
|
|
20
|
+
event from before it subscribed. Nothing tells you about one, so there is nothing to catch up. When
|
|
21
|
+
the project's `slack_channels` change, the daemon relaunches you into this session with the new
|
|
22
|
+
line.
|
|
23
|
+
|
|
24
|
+
## What a mention carries
|
|
25
|
+
|
|
26
|
+
The delivery's `message` holds Slack's fields: `channel_id`, `user_id` (a Slack user id such as
|
|
27
|
+
`U0123ABCD`), `text` (the mention itself appears in it as `<@U…>`, the app's own id), `ts` (the
|
|
28
|
+
mention's own timestamp), `thread_ts` (the thread's root, only when the mention was posted inside a
|
|
29
|
+
thread), and `bot_id` and `bot_name` when a bot posted it.
|
|
30
|
+
|
|
31
|
+
## Answer in the thread
|
|
32
|
+
|
|
33
|
+
Every answer is one `slack_post` call:
|
|
34
|
+
|
|
35
|
+
- `channel` is the mention's `channel_id`.
|
|
36
|
+
- `thread_ts` is the mention's `thread_ts`, or its `ts` when it has none. Either keeps the reply in
|
|
37
|
+
the thread the mention opened or joined, never at the channel's top level.
|
|
38
|
+
- `text` is what you say, at most 4,000 characters, written as `skill://dispatch`'s "Writing for
|
|
39
|
+
the human" says. Address the author as `<@user_id>` (by `bot_name` when a bot posted the mention
|
|
40
|
+
with no `user_id`) and give every issue as its key, its title and its Dispatch link exactly as
|
|
41
|
+
`dispatch` printed it (the `Link:` line of `dispatch read`, the link on a `Created` line or a
|
|
42
|
+
search hit): it points at Dispatch's dashboard, and `slack_post` refuses a reply linking an
|
|
43
|
+
address inside the cluster. Never build a link yourself. Say only what Dispatch shows: an issue's
|
|
44
|
+
assignee is the one `dispatch` names, never one you assume.
|
|
45
|
+
|
|
46
|
+
`slack_post` is sent once and never retried. When it answers that Slack did not answer, the reply
|
|
47
|
+
may already be in the thread: do not post it again. When it answers with Slack's refusal, the
|
|
48
|
+
reply is not posted: `not_in_channel` means the app has not been added to that channel, which only
|
|
49
|
+
a person in the workspace can fix, so say so in your next report rather than retrying. When it
|
|
50
|
+
answers that the secrets broker has not granted `LEGION_SLACK_BOT_TOKEN` (its request waits for
|
|
51
|
+
approval, or was denied), nothing was posted and no reply can be until the operator stores the
|
|
52
|
+
token with owner `legion-daemon` and tier `agent`: name the error in your next report.
|
|
53
|
+
|
|
54
|
+
## Decide what the person wants
|
|
55
|
+
|
|
56
|
+
Read the mention's `text`. When it names an issue key, read the issue with `dispatch read` before
|
|
57
|
+
answering. Then take the first of these that fits.
|
|
58
|
+
|
|
59
|
+
- **A question about state.** "What is Legion working on?", "Where is ACME-12?", "Why is it held?"
|
|
60
|
+
Answer from `read_state` and Dispatch as they stand now, never from memory: each issue's
|
|
61
|
+
`issues.<KEY>.phase`, its pull request, `admission.active` and `admission.waiting`. Say what you
|
|
62
|
+
read, not what you infer.
|
|
63
|
+
- **A request for work.** Someone wants a change made, a bug fixed, something investigated. Search
|
|
64
|
+
first (`skill://dispatch-first`): when an open issue already tracks it, reply with that issue and
|
|
65
|
+
file nothing. Otherwise file one issue in the project:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
dispatch issue --project <PROJECT> --title '<the request, as a title>' --spec-file - <<'EOF'
|
|
69
|
+
## Summary
|
|
70
|
+
|
|
71
|
+
<what the person asked for, in your words, and why if they said>
|
|
72
|
+
|
|
73
|
+
Asked in Slack by <@user_id> in channel <channel_id>, message <ts>.
|
|
74
|
+
EOF
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Add `--label legion` only when the person asks Legion to do the work ("Legion, fix…", "can
|
|
78
|
+
Legion take…"); a request for someone to look at something, or one that does not say who, gets
|
|
79
|
+
no label and waits for a person to hand it over. Never label a request outside the scope the
|
|
80
|
+
deployment instructions state. Reply with the new issue's key, its title, the link and assignee
|
|
81
|
+
the `Created` line names (Dispatch assigns the issue to your token's owner when you name nobody),
|
|
82
|
+
and whether Legion will take it.
|
|
83
|
+
- **Something an issue's architect owns.** A question about a tree's design, a change of direction,
|
|
84
|
+
a decision only the tree's architect can make about an issue Legion runs. Find the tree: follow
|
|
85
|
+
`Links:` up through each `child_of` parent with `dispatch read` until you reach the issue
|
|
86
|
+
`read_state` records with an `architect`. When `issues.<ROOT>.architect.locator.claim` is there,
|
|
87
|
+
`envoy_publish` the person's words to `notifications.role.` followed by it, with the channel id
|
|
88
|
+
and `thread_ts` you reply on, and reply in the thread that you passed it to the architect that
|
|
89
|
+
owns `<ROOT>`, which answers on the issue. When the architect is not running (no locator), say
|
|
90
|
+
that in the thread instead and what `read_state` shows for the tree. Never invent a role token.
|
|
91
|
+
- **Anything else** (a greeting, a question about Legion itself): answer it briefly in the thread.
|
|
92
|
+
|
|
93
|
+
A request to approve a design is answered as the skill's "Mentions" section says: a person
|
|
94
|
+
approves the spec in Dispatch, and you never open a gate.
|