@onepointfour-packs/flaredeck-agent 0.1.0 → 0.2.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/agent.d.ts +16 -0
- package/dist/agent.js +38 -1
- package/dist/agent.js.map +1 -1
- package/dist/approval.js +39 -1
- package/dist/approval.js.map +1 -1
- package/dist/browser.d.ts +19 -0
- package/dist/browser.js +261 -0
- package/dist/browser.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/desperation.d.ts +24 -0
- package/dist/desperation.js +64 -0
- package/dist/desperation.js.map +1 -0
- package/dist/engine-antigravity.d.ts +7 -0
- package/dist/engine-antigravity.js +280 -32
- package/dist/engine-antigravity.js.map +1 -1
- package/dist/engine.d.ts +28 -0
- package/dist/engine.js.map +1 -1
- package/dist/env.d.ts +5 -1
- package/dist/env.js +19 -1
- package/dist/env.js.map +1 -1
- package/dist/environment.d.ts +87 -2
- package/dist/environment.js +213 -11
- package/dist/environment.js.map +1 -1
- package/dist/flaredeck-proxy.d.ts +47 -1
- package/dist/flaredeck-proxy.js +120 -3
- package/dist/flaredeck-proxy.js.map +1 -1
- package/dist/gitguard.d.ts +6 -0
- package/dist/gitguard.js +30 -1
- package/dist/gitguard.js.map +1 -1
- package/dist/github.d.ts +23 -1
- package/dist/github.js +105 -28
- package/dist/github.js.map +1 -1
- package/dist/journal.d.ts +23 -0
- package/dist/journal.js +142 -0
- package/dist/journal.js.map +1 -0
- package/dist/localmcp.d.ts +50 -0
- package/dist/localmcp.js +229 -0
- package/dist/localmcp.js.map +1 -0
- package/dist/main.js +4 -1
- package/dist/main.js.map +1 -1
- package/dist/permissions.d.ts +17 -0
- package/dist/permissions.js +38 -1
- package/dist/permissions.js.map +1 -1
- package/dist/runs.js +3 -2
- package/dist/runs.js.map +1 -1
- package/dist/sandbox.d.ts +34 -0
- package/dist/sandbox.js +122 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +3 -2
- package/dist/schedules.js.map +1 -1
- package/dist/serve.js +461 -39
- package/dist/serve.js.map +1 -1
- package/dist/stream.d.ts +1 -0
- package/dist/team.d.ts +1 -1
- package/dist/team.js +3 -2
- package/dist/team.js.map +1 -1
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +34 -39
- package/dist/ui.js.map +1 -1
- package/dist/updater-python.d.ts +19 -0
- package/dist/updater-python.js +149 -0
- package/dist/updater-python.js.map +1 -0
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +1 -1
- package/dist/workspace.js.map +1 -1
- package/dist/worktree.d.ts +39 -1
- package/dist/worktree.js +147 -4
- package/dist/worktree.js.map +1 -1
- package/package.json +3 -2
- package/python/sidecar.py +209 -7
package/dist/serve.js
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { environmentBrief, startEnvironment } from './environment.js';
|
|
3
|
-
import { GIT_TOOL_NAMES, githubTools } from './github.js';
|
|
2
|
+
import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment } from './environment.js';
|
|
3
|
+
import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
|
|
4
4
|
import { freshGitHubToken } from './githubauth.js';
|
|
5
5
|
import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
|
|
6
6
|
import { pushStatus, StatusTracker } from './status.js';
|
|
7
7
|
import { fetchTeam, myTeam, teamTools, TEAM_TOOL_NAMES } from './team.js';
|
|
8
8
|
import { scheduleTools, SCHEDULE_TOOL_NAMES } from './schedules.js';
|
|
9
9
|
import { preflightFlaredeck } from './flaredeck-check.js';
|
|
10
|
-
import { callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
|
|
10
|
+
import { NOT_FOR_WORKERS, callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
|
|
11
11
|
import { resumeApprovedRuns, runTools } from './runs.js';
|
|
12
12
|
import { deliverDecision, flaredeckDecider } from './approval.js';
|
|
13
13
|
import { CouldNotWork, engineFor } from './engine.js';
|
|
14
14
|
import { jevGuard } from './jevguard.js';
|
|
15
15
|
import { loadProviders } from './providers.js';
|
|
16
|
-
import { ensureWorktree, releaseWorktree, worktreeBrief } from './worktree.js';
|
|
16
|
+
import { currentBranch, ensureWorktree, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
|
|
17
17
|
import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
|
|
18
18
|
import { intake, intakeText } from './intake.js';
|
|
19
|
+
import { standingBrief } from './agent.js';
|
|
19
20
|
import { startUi } from './ui.js';
|
|
20
21
|
import { checkAndStage, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
|
|
21
|
-
import {
|
|
22
|
+
import { updateSidecarSdk } from './updater-python.js';
|
|
23
|
+
import { managedPython, usingManagedPython } from './engine-antigravity.js';
|
|
24
|
+
import { mkdirSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { configDir, saveConfig } from './config.js';
|
|
27
|
+
import { browserTools } from './browser.js';
|
|
28
|
+
import { watchForDesperation } from './desperation.js';
|
|
29
|
+
import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
|
|
22
30
|
import { EventStream } from './stream.js';
|
|
23
31
|
/**
|
|
24
32
|
* The always-on agent.
|
|
@@ -48,12 +56,21 @@ export async function serve(opts) {
|
|
|
48
56
|
lines: [],
|
|
49
57
|
};
|
|
50
58
|
let ui = null;
|
|
59
|
+
// Which card the lines being logged are about. The live line drops it the moment a turn ends,
|
|
60
|
+
// so without this a finished card leaves a page of work in the scrollback with nothing saying
|
|
61
|
+
// what it was for — the card number survived only in the branch name.
|
|
62
|
+
let tag = '';
|
|
51
63
|
const log = (kind, text) => {
|
|
64
|
+
const line = tag ? { kind, text, card: tag } : { kind, text };
|
|
65
|
+
// Kept for the next session on this card. Tools and its own words say what it was doing;
|
|
66
|
+
// events are harness bookkeeping and would crowd out the work.
|
|
67
|
+
if (tag && (kind === 'tool' || kind === 'say' || kind === 'todo'))
|
|
68
|
+
note(tag, text);
|
|
52
69
|
// Keep a bounded tail: a daemon runs for days and nobody scrolls back that far.
|
|
53
|
-
state.lines.push(
|
|
70
|
+
state.lines.push(line);
|
|
54
71
|
if (state.lines.length > 400)
|
|
55
72
|
state.lines.splice(0, state.lines.length - 400);
|
|
56
|
-
ui?.log(
|
|
73
|
+
ui?.log(line);
|
|
57
74
|
};
|
|
58
75
|
const updated = process.env['FLAREDECK_AGENT_UPDATED'];
|
|
59
76
|
if (updated)
|
|
@@ -69,7 +86,124 @@ export async function serve(opts) {
|
|
|
69
86
|
const looksForUpdates = () => void checkAndStage().then(waiting);
|
|
70
87
|
setTimeout(looksForUpdates, 30_000).unref();
|
|
71
88
|
setInterval(looksForUpdates, 6 * 60 * 60 * 1000).unref();
|
|
89
|
+
// The sidecar's Python SDK on the same schedule. It used to be pip-installed once, at first
|
|
90
|
+
// use, and never looked at again — one engine tracked upstream while the other aged. Only the
|
|
91
|
+
// venv the harness made for itself, and only once the engine in use is actually that one.
|
|
92
|
+
if (config.engine === 'antigravity') {
|
|
93
|
+
const looksForSidecarUpdates = () => void updateSidecarSdk(managedPython(), usingManagedPython(), (t) => log('tool', t));
|
|
94
|
+
setTimeout(looksForSidecarUpdates, 45_000).unref();
|
|
95
|
+
setInterval(looksForSidecarUpdates, 6 * 60 * 60 * 1000).unref();
|
|
96
|
+
}
|
|
72
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* The scratch sandbox, which must EXIST before it is handed to anything. An engine that runs
|
|
100
|
+
* out of process starts its child there, and a child spawned with a cwd that is not there dies
|
|
101
|
+
* with ENOENT naming the interpreter — which reads as a broken Python install and is not one.
|
|
102
|
+
*/
|
|
103
|
+
const scratchDir = () => {
|
|
104
|
+
const dir = join(configDir(), 'scratch');
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
catch { /* reported by whatever uses it */ }
|
|
109
|
+
return dir;
|
|
110
|
+
};
|
|
111
|
+
/** Cards already reported as left unfinished, so a quiet agent is not reported every turn. */
|
|
112
|
+
const toldAbout = new Set();
|
|
113
|
+
/**
|
|
114
|
+
* Work left behind when a session ended. Told to the lead, because the agent that stopped is
|
|
115
|
+
* by definition not going to say anything, and told once per card per state so a lead is not
|
|
116
|
+
* pestered about the same half-finished branch.
|
|
117
|
+
*/
|
|
118
|
+
const reportUnfinished = async (card) => {
|
|
119
|
+
const tree = await existingWorktree(config.repo, card).catch(() => null);
|
|
120
|
+
if (!tree)
|
|
121
|
+
return;
|
|
122
|
+
let left;
|
|
123
|
+
try {
|
|
124
|
+
left = await produced(tree);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const dirty = await uncommittedCount(tree.path);
|
|
130
|
+
if (!dirty && (left.commits === 0 || left.pushed))
|
|
131
|
+
return; // nothing hanging
|
|
132
|
+
const what = [
|
|
133
|
+
dirty ? `${dirty} file(s) changed and not committed` : '',
|
|
134
|
+
left.commits > 0 && !left.pushed ? `${left.commits} commit(s) not published` : '',
|
|
135
|
+
].filter(Boolean).join(', ');
|
|
136
|
+
const stamp = `${card}:${what}`;
|
|
137
|
+
if (toldAbout.has(stamp))
|
|
138
|
+
return;
|
|
139
|
+
toldAbout.add(stamp);
|
|
140
|
+
log('warn', `${card} left unfinished — ${what}`);
|
|
141
|
+
if (!leadId)
|
|
142
|
+
return;
|
|
143
|
+
try {
|
|
144
|
+
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', {
|
|
145
|
+
to: leadId,
|
|
146
|
+
message: `**${card}: my session ended with work unfinished.**\n\n` +
|
|
147
|
+
`Branch \`${left.branch}\` — ${what}. The checkout is still there and nothing is lost; ` +
|
|
148
|
+
`I stopped before finishing, so this did not reach you as a review.\n\n` +
|
|
149
|
+
`The harness picks the card back up by itself shortly. Tell me if you want it differently.`,
|
|
150
|
+
});
|
|
151
|
+
log('tool', `told ${leadName} that ${card} was left unfinished`);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
log('warn', `could not tell ${leadName} about ${card}: ${e instanceof Error ? e.message : String(e)}`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* The checkout a message is about, when it names a card this agent already has one for.
|
|
159
|
+
*
|
|
160
|
+
* Nothing is created here. If there is no worktree the turn stays as it was — a conversation
|
|
161
|
+
* with no code in it — which is right: a message is not authority to start work, only to carry
|
|
162
|
+
* on with work that exists.
|
|
163
|
+
*/
|
|
164
|
+
const worktreeTheMessageIsAbout = async (event, repo, onStep) => {
|
|
165
|
+
// `ask` too: a question typed in the pane is usually about the card in front of the person,
|
|
166
|
+
// and without this the turn has no checkout and therefore no publishing tools — so an agent
|
|
167
|
+
// asked to commit its work truthfully answers that it cannot.
|
|
168
|
+
if (!['message', 'commented', 'ask'].includes(event.event))
|
|
169
|
+
return null;
|
|
170
|
+
const text = `${event.line ?? ''} ${event.message ?? ''}`;
|
|
171
|
+
const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])];
|
|
172
|
+
for (const code of codes) {
|
|
173
|
+
// What the harness wrote down when it made the worktree, first: the directory is named from
|
|
174
|
+
// the card's internal id and the branch from whatever the agent chose, so neither can be
|
|
175
|
+
// matched against the code a message uses.
|
|
176
|
+
const known = recallWorktree(code);
|
|
177
|
+
// The branch is read from the checkout, never from what was written down: an agent may
|
|
178
|
+
// rename its branch, and telling it the name the harness cut months ago is worse than
|
|
179
|
+
// saying nothing. The PATH is the thing worth remembering.
|
|
180
|
+
const found = known
|
|
181
|
+
? { path: known.path, branch: (await currentBranch({ path: known.path, branch: known.branch, created: false })).name, created: false }
|
|
182
|
+
: await existingWorktree(repo, code).catch(() => null);
|
|
183
|
+
if (found) {
|
|
184
|
+
onStep(`this is about ${code} — using its checkout, ${found.branch}`);
|
|
185
|
+
tag = code;
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
/** Said to the lead when an agent has started working around the harness instead of the card. */
|
|
192
|
+
const tellLeadItIsStuck = async (card, refusals) => {
|
|
193
|
+
if (!leadId)
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'send-message', {
|
|
197
|
+
to: leadId,
|
|
198
|
+
message: `**${card}: I am stuck and I have stopped.**\n\n` +
|
|
199
|
+
`${refusals} of my commands were refused this turn and I started looking for ways ` +
|
|
200
|
+
`around them, which is never right. Something the harness told me to do, I cannot do ` +
|
|
201
|
+
`here. I have said the details on the card.`,
|
|
202
|
+
});
|
|
203
|
+
log('tool', `told ${leadName} it is stuck on ${card}`);
|
|
204
|
+
}
|
|
205
|
+
catch { /* the pane already says it */ }
|
|
206
|
+
};
|
|
73
207
|
const say = (text) => log('say', text);
|
|
74
208
|
// The job the publishing tools act on. Set when a card starts, so those tools cannot name a
|
|
75
209
|
// branch — they read this instead.
|
|
@@ -263,16 +397,31 @@ export async function serve(opts) {
|
|
|
263
397
|
};
|
|
264
398
|
/** Who this agent reports to, when it is a worker on a team: production runs go there first. */
|
|
265
399
|
let leadId = null;
|
|
400
|
+
let leadName = '';
|
|
401
|
+
/** A lead's surface is wider than a worker's, so the tool list is too. */
|
|
402
|
+
let isLead = false;
|
|
403
|
+
const keepTool = (name) => isLead || !NOT_FOR_WORKERS.includes(name);
|
|
404
|
+
/** Cards already handed to the lead, so a retried tool call does not assign or message twice. */
|
|
405
|
+
const handedBack = new Set();
|
|
266
406
|
try {
|
|
267
407
|
const team = await fetchTeam(opts.baseUrl, await opts.auth());
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
408
|
+
// A lead this agent was told to report to gets asked, whether or not it is a harness.
|
|
409
|
+
//
|
|
410
|
+
// This used to require the lead to have written a status record recently, which only a
|
|
411
|
+
// harness does — so a lead that is a plain session, driven by a person, read as "not
|
|
412
|
+
// running" and was routed around every time. Somebody set reportsTo on purpose; going over
|
|
413
|
+
// their head because they are not a daemon is the wrong default, and it means approvals
|
|
414
|
+
// reach people without the context the lead has.
|
|
415
|
+
//
|
|
416
|
+
// It is safe to assume, because asking the lead does not replace asking a person: an
|
|
417
|
+
// endorsement PROMOTES the request rather than answering it, and a lead that never responds
|
|
418
|
+
// lapses (see flaredeckDecider) and the request goes on to the approvers.
|
|
271
419
|
const lead = team.you.reportsTo ? team.agents.find((a) => a.agentId === team.you.reportsTo) : undefined;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
log('
|
|
420
|
+
leadId = team.you.reportsTo || null;
|
|
421
|
+
leadName = lead?.name ?? leadId ?? '';
|
|
422
|
+
if (leadId)
|
|
423
|
+
log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
|
|
424
|
+
isLead = team.you.role === 'lead';
|
|
276
425
|
if (team.you.role === 'lead') {
|
|
277
426
|
teamSet = {
|
|
278
427
|
// After a review the list changes — an endorsed request is now answerable, from THIS
|
|
@@ -437,11 +586,45 @@ export async function serve(opts) {
|
|
|
437
586
|
&& event.event !== 'review'
|
|
438
587
|
&& event.event !== 'ask'
|
|
439
588
|
&& !(event.event === 'commented' && event.cardDone && !event.mentioned)
|
|
589
|
+
// Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
|
|
590
|
+
// a card this agent once touched keeps sending it every move and comment made by whoever
|
|
591
|
+
// holds it now — but that check happens when the event's turn comes, and reading ahead
|
|
592
|
+
// happens the moment it arrives. So an agent paid for a brief, a Jev call and a code search
|
|
593
|
+
// on cards belonging to other agents, to people, and on cards already done, before
|
|
594
|
+
// concluding it should ignore them. Same question, asked early enough to matter.
|
|
595
|
+
&& needsMe(event)
|
|
596
|
+
// Named on somebody else's card is correspondence, not work. The agent is still woken —
|
|
597
|
+
// being addressed by name must do something, or @mentioning an agent is useless — but intake
|
|
598
|
+
// exists to brief and SIZE work, and there is no work here to size. One agent ran a full
|
|
599
|
+
// brief, a Jev classification and a code search on a card held by another agent, sitting in
|
|
600
|
+
// a signoff lane, and concluded it was "large · prod data". None of which it was going to do.
|
|
601
|
+
// It reads the card in its turn, as a person would, and answers.
|
|
602
|
+
&& !(event.mentioned === true && event.forYou === false)
|
|
440
603
|
&& !sessions.has(event.cardKey);
|
|
604
|
+
/**
|
|
605
|
+
* How many cards may be read ahead at once.
|
|
606
|
+
*
|
|
607
|
+
* The point of reading ahead is that the NEXT card is ready when the current one ends — one
|
|
608
|
+
* card, not the whole queue. But this fired on every event as it arrived, so a backlog of
|
|
609
|
+
* thirty started thirty intakes at once, each a brief, a Jev call and a code search that takes
|
|
610
|
+
* seconds of git grep. That is a thundering herd against two providers at the same moment, and
|
|
611
|
+
* it showed: briefs fell back to Haiku and Jev came back off, with no error anywhere, because
|
|
612
|
+
* both were simply being rate-limited by this agent's own enthusiasm.
|
|
613
|
+
*
|
|
614
|
+
* Two: the next card, and one spare for when the next card turns out not to need this agent.
|
|
615
|
+
* Anything further back is read when its turn comes, which costs a few seconds of latency and
|
|
616
|
+
* nothing else — `handle` already falls back to reading the card itself when nothing was
|
|
617
|
+
* prepared for it.
|
|
618
|
+
*/
|
|
619
|
+
const PREPARE_AHEAD_LIMIT = 2;
|
|
620
|
+
let preparing = 0;
|
|
441
621
|
const prepareAhead = (event) => {
|
|
442
622
|
if (!wantsIntake(event) || preparedAhead.has(event.id))
|
|
443
623
|
return;
|
|
624
|
+
if (preparing >= PREPARE_AHEAD_LIMIT)
|
|
625
|
+
return;
|
|
444
626
|
const label = event.cardCode || event.cardKey || '';
|
|
627
|
+
preparing++;
|
|
445
628
|
preparedAhead.set(event.id, {
|
|
446
629
|
at: Date.now(),
|
|
447
630
|
// The repo this card belongs to, so the code search behind the brief searches the right
|
|
@@ -449,7 +632,16 @@ export async function serve(opts) {
|
|
|
449
632
|
promise: (async () => {
|
|
450
633
|
const w = await resolveCheckout(config.repo, event, config.repos);
|
|
451
634
|
return intake(opts.deck, event.cardCode || event.cardKey, (t) => log('tool', `${label}: ${t}`), false, w.kind === 'repo' ? w.checkout.path : config.repo);
|
|
452
|
-
})()
|
|
635
|
+
})()
|
|
636
|
+
.catch(() => null)
|
|
637
|
+
.finally(() => {
|
|
638
|
+
preparing--;
|
|
639
|
+
// A slot just freed: warm the card actually next in line, rather than whichever event
|
|
640
|
+
// happened to arrive while the queue was full.
|
|
641
|
+
const next = queue.find((e) => !preparedAhead.has(e.id));
|
|
642
|
+
if (next)
|
|
643
|
+
prepareAhead(next);
|
|
644
|
+
}),
|
|
453
645
|
});
|
|
454
646
|
};
|
|
455
647
|
let working = false;
|
|
@@ -498,12 +690,55 @@ export async function serve(opts) {
|
|
|
498
690
|
if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
|
|
499
691
|
void drain();
|
|
500
692
|
};
|
|
693
|
+
/**
|
|
694
|
+
* A card of mine already in progress, picked up again without being asked.
|
|
695
|
+
*
|
|
696
|
+
* The sweep told the model "a card already in progress: say which one, and stop. It will come
|
|
697
|
+
* back to you." Nothing brought it back. A card sitting in the in-progress lane with this
|
|
698
|
+
* agent's name on it generates no further events, so it sat there until a person prodded —
|
|
699
|
+
* twice, usually, because the first prod produced the same observation.
|
|
700
|
+
*
|
|
701
|
+
* Finding it needs no model at all: the board knows, and `get-outstanding` answers. So the
|
|
702
|
+
* harness reads it and queues the card itself, as the ordinary card event it should always
|
|
703
|
+
* have been — with a worktree, a branch, and everything else a card turn gets.
|
|
704
|
+
*/
|
|
705
|
+
const resumeOwnWork = async () => {
|
|
706
|
+
if (!sweepsEnabled || stopping || working || queue.length || live.size)
|
|
707
|
+
return;
|
|
708
|
+
let mine = [];
|
|
709
|
+
try {
|
|
710
|
+
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', {});
|
|
711
|
+
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
712
|
+
// The codes it names, in the order it names them: priority order, which is the board's.
|
|
713
|
+
mine = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])].map((code) => ({ code }));
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return; // unreachable board is the stream's problem to report, not this timer's
|
|
717
|
+
}
|
|
718
|
+
const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
|
|
719
|
+
if (!next)
|
|
720
|
+
return;
|
|
721
|
+
log('tool', `picking ${next.code} back up — it is yours and already in progress`);
|
|
722
|
+
if (enqueue({
|
|
723
|
+
id: `resume-${next.code}-${Date.now()}`,
|
|
724
|
+
event: 'assigned',
|
|
725
|
+
line: `${next.code} is still yours and in progress`,
|
|
726
|
+
cardCode: next.code,
|
|
727
|
+
cardKey: next.code,
|
|
728
|
+
}))
|
|
729
|
+
void drain();
|
|
730
|
+
};
|
|
501
731
|
const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
|
|
502
732
|
sweepTimer.unref();
|
|
503
733
|
// Not at once: let the stream connect and deliver anything it is holding first, so a sweep
|
|
504
734
|
// never races a redelivery of the very cards it would pick up.
|
|
505
735
|
if (sweepsEnabled)
|
|
506
736
|
setTimeout(sweepNow, 30_000).unref();
|
|
737
|
+
// Between sweeps, and offset from them so the two never race for the same card.
|
|
738
|
+
const resumeTimer = setInterval(() => void resumeOwnWork(), SWEEP_EVERY_MS);
|
|
739
|
+
resumeTimer.unref();
|
|
740
|
+
if (sweepsEnabled)
|
|
741
|
+
setTimeout(() => void resumeOwnWork(), 90_000).unref();
|
|
507
742
|
// Ctrl-C must actually stop it. The first press closes the stream and lets a turn in progress
|
|
508
743
|
// finish; the second gives up on that and exits. A daemon that has to be hunted down with
|
|
509
744
|
// pkill is worse than one that occasionally abandons a turn — the events are unacknowledged,
|
|
@@ -522,6 +757,7 @@ export async function serve(opts) {
|
|
|
522
757
|
stream.close();
|
|
523
758
|
clearInterval(heartbeat);
|
|
524
759
|
clearInterval(sweepTimer);
|
|
760
|
+
clearInterval(resumeTimer);
|
|
525
761
|
teamTimers.forEach(clearInterval);
|
|
526
762
|
// Belt and braces: if unwinding wedges, do not hang forever pretending to shut down. Longer
|
|
527
763
|
// when cards are still open, because their handovers run first and each is a short turn.
|
|
@@ -545,8 +781,22 @@ export async function serve(opts) {
|
|
|
545
781
|
await stream.ack([event.id]);
|
|
546
782
|
continue;
|
|
547
783
|
}
|
|
548
|
-
|
|
784
|
+
// What a person reading the pane wants to know is WHICH card, and a code alone is not
|
|
785
|
+
// that — you have to go and look it up. "a message" was worse: it did not even say who
|
|
786
|
+
// from, on a line that sits there for minutes.
|
|
787
|
+
const cardLabel = event.cardCode || event.cardKey || '';
|
|
788
|
+
const title = (event.cardName || '').trim();
|
|
789
|
+
current = event.event === 'ask'
|
|
790
|
+
? 'your question'
|
|
791
|
+
: event.event === 'sweep'
|
|
792
|
+
? 'your outstanding work'
|
|
793
|
+
: event.event === 'scheduled'
|
|
794
|
+
? event.scheduleName || 'a scheduled task'
|
|
795
|
+
: cardLabel
|
|
796
|
+
? `${cardLabel}${title ? ` · ${title.length > 58 ? `${title.slice(0, 57)}…` : title}` : ''}`
|
|
797
|
+
: `a message${event.actor ? ` from ${event.actor}` : ''}`;
|
|
549
798
|
state.working = current;
|
|
799
|
+
tag = cardLabel;
|
|
550
800
|
state.since = Date.now();
|
|
551
801
|
state.tasks = { done: 0, total: 0 };
|
|
552
802
|
// What it starts the moment this one is done — so a long turn does not read as a stall.
|
|
@@ -584,8 +834,16 @@ export async function serve(opts) {
|
|
|
584
834
|
setTimeout(() => { if (enqueue(event, true))
|
|
585
835
|
void drain(); }, 60_000).unref();
|
|
586
836
|
}
|
|
837
|
+
// A session ends when the model stops talking, which is not the same as the work being
|
|
838
|
+
// done. HAL ended one with sixteen call sites edited, ten files modified, nothing
|
|
839
|
+
// committed and nobody told — and the next session, with the conversation gone, started
|
|
840
|
+
// the card from the beginning. The worktree answers "was this finished" without asking a
|
|
841
|
+
// model: uncommitted changes, or commits that never reached the remote.
|
|
842
|
+
if (tag)
|
|
843
|
+
await reportUnfinished(tag);
|
|
587
844
|
current = '';
|
|
588
845
|
state.working = '';
|
|
846
|
+
tag = '';
|
|
589
847
|
delete state.branch;
|
|
590
848
|
delete state.base;
|
|
591
849
|
delete state.tasks;
|
|
@@ -768,10 +1026,19 @@ export async function serve(opts) {
|
|
|
768
1026
|
log('event', `working in ${chosen.name}`);
|
|
769
1027
|
if (fromCard)
|
|
770
1028
|
log('event', `base ${fromCard.branch} — ${fromCard.role} (${event.environment || 'no environment'}), from the board`);
|
|
1029
|
+
// A card's own turn gets its worktree made. A turn that is ABOUT a card — a lead saying
|
|
1030
|
+
// "commit and push what you have" — gets the one that already exists, and with it the job,
|
|
1031
|
+
// the publishing tools and the journal. Without this, HAL committed its work on a message
|
|
1032
|
+
// turn, was told to publish with push_branch, and went looking for push_branch on the
|
|
1033
|
+
// filesystem: the harness had named a tool that turn did not have.
|
|
771
1034
|
const tree = event.cardKey && (chosen || where.kind === 'none')
|
|
772
|
-
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom)
|
|
773
|
-
:
|
|
1035
|
+
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
|
|
1036
|
+
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
|
|
774
1037
|
const cwd = tree?.path ?? repo;
|
|
1038
|
+
// So a later message naming this card by its CODE can find the work. See rememberWorktree.
|
|
1039
|
+
if (tree && (event.cardCode || event.cardKey)) {
|
|
1040
|
+
rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
|
|
1041
|
+
}
|
|
775
1042
|
// No worktree, but this turn is going to change something. That used to carry on in whatever
|
|
776
1043
|
// directory happened to be the fallback — no branch, no isolation, and no word said. An agent
|
|
777
1044
|
// did exactly that and ended up reading production out of a worktree belonging to someone
|
|
@@ -802,7 +1069,71 @@ export async function serve(opts) {
|
|
|
802
1069
|
log('tool', `FlareDeck reachable · ${check.discovered.tools.length} tools`);
|
|
803
1070
|
// Served from in here for this turn: a fresh token per call, so a card that outlives the
|
|
804
1071
|
// token's hour keeps its tools, and nothing on this machine can shadow the server by name.
|
|
805
|
-
|
|
1072
|
+
// The gate on calling a card finished: it must have commits of its own, published. Checked
|
|
1073
|
+
// here rather than trusted, because a turn that produced nothing still reports confidently.
|
|
1074
|
+
const finishGate = async () => {
|
|
1075
|
+
const why = await tooEarlyToFinish(tree);
|
|
1076
|
+
if (why) {
|
|
1077
|
+
log('warn', `refused a "done" on an empty branch: ${tree?.branch ?? '(no branch)'}`);
|
|
1078
|
+
return why;
|
|
1079
|
+
}
|
|
1080
|
+
// If this agent reports to someone, it does not decide that its own work is finished. The
|
|
1081
|
+
// lead said "I'll check its saved work myself before it goes anywhere" and then was never
|
|
1082
|
+
// told: the card moved itself to Awaiting Deployment. A report hands back; the lead moves
|
|
1083
|
+
// it, or comments and sends it back, which is what a lead is for.
|
|
1084
|
+
if (!leadId)
|
|
1085
|
+
return null;
|
|
1086
|
+
const code = tag || event.cardCode || event.cardKey || '';
|
|
1087
|
+
if (!code)
|
|
1088
|
+
return null;
|
|
1089
|
+
if (!handedBack.has(code)) {
|
|
1090
|
+
handedBack.add(code);
|
|
1091
|
+
const made = tree ? await produced(tree) : null;
|
|
1092
|
+
const where = tree
|
|
1093
|
+
? `Branch \`${tree.branch}\`${made ? ` — ${made.commits} commit(s), ${made.pushed ? 'pushed' : 'NOT pushed'}` : ''}.`
|
|
1094
|
+
: 'No branch: nothing of mine is in a checkout for this card.';
|
|
1095
|
+
try {
|
|
1096
|
+
const token = await opts.auth();
|
|
1097
|
+
await callFlaredeckTool(config.mcpUrl, token, 'assign-card', { card_id: code, assignee: leadId });
|
|
1098
|
+
await callFlaredeckTool(config.mcpUrl, token, 'send-message', {
|
|
1099
|
+
to: leadId,
|
|
1100
|
+
message: `**${code} is ready for your review.**\n\n${where}\n\n` +
|
|
1101
|
+
`I have not moved it — it is assigned to you and still in its lane. ` +
|
|
1102
|
+
`Check the work and move it on, or comment on the card and I will fix it.`,
|
|
1103
|
+
});
|
|
1104
|
+
log('tool', `handed ${code} to ${leadName} for review`);
|
|
1105
|
+
}
|
|
1106
|
+
catch (e) {
|
|
1107
|
+
// A handback that did not happen must not read as one that did.
|
|
1108
|
+
handedBack.delete(code);
|
|
1109
|
+
const text = e instanceof Error ? e.message : String(e);
|
|
1110
|
+
log('warn', `could not hand ${code} to ${leadName}: ${text}`);
|
|
1111
|
+
return `Could not hand this card to ${leadName} for review (${text}). Do not move it; say so on the card instead.`;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return (`${code} is now ${leadName}'s to verify, not yours to close. It is assigned to them and ` +
|
|
1115
|
+
`they have been told, with your branch. Leave the lane alone — they move it on, or comment ` +
|
|
1116
|
+
`and send it back to you. Your part is done; say nothing further about it being finished.`);
|
|
1117
|
+
};
|
|
1118
|
+
// A turn woken by a message says "a message from JARVIS" for as long as it runs, even while
|
|
1119
|
+
// it is plainly working a card. The card tools name the card; this takes it from there, and
|
|
1120
|
+
// only when the turn had none of its own.
|
|
1121
|
+
// Ground truth for the footer on anything this agent says. Null without a worktree: there is
|
|
1122
|
+
// no branch to report on, and a footer saying "nothing committed" would be misleading rather
|
|
1123
|
+
// than informative.
|
|
1124
|
+
const savedSoFar = async () => {
|
|
1125
|
+
if (!tree)
|
|
1126
|
+
return null;
|
|
1127
|
+
const { commits, pushed, branch, known } = await produced(tree);
|
|
1128
|
+
return { branch, commits, pushed, known };
|
|
1129
|
+
};
|
|
1130
|
+
const noteCard = (code) => {
|
|
1131
|
+
if (!code || tag)
|
|
1132
|
+
return;
|
|
1133
|
+
tag = code;
|
|
1134
|
+
state.working = code;
|
|
1135
|
+
};
|
|
1136
|
+
const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
|
|
806
1137
|
const held = live.get(key);
|
|
807
1138
|
if (held?.timer)
|
|
808
1139
|
clearTimeout(held.timer);
|
|
@@ -853,18 +1184,76 @@ export async function serve(opts) {
|
|
|
853
1184
|
},
|
|
854
1185
|
}
|
|
855
1186
|
: null;
|
|
1187
|
+
// Declared checks are OPTIONAL: no .flaredeck/agent.yaml means none, and that is the normal
|
|
1188
|
+
// case. Judging what a task needs is the agent's job — it can read the card and the repo, and
|
|
1189
|
+
// most cards need nothing. This is only for the few things a project wants hard-gated
|
|
1190
|
+
// regardless of what any agent thinks.
|
|
1191
|
+
//
|
|
1192
|
+
// Both of these belong to a CARD's own checkout. Without a worktree there is no card being
|
|
1193
|
+
// worked and `cwd` is the shared checkout itself — so a sweep asking "what is still assigned
|
|
1194
|
+
// to me", or a question typed in the pane, was starting a scratch database, running
|
|
1195
|
+
// migrations and bringing up an API and a web server, in the checkout everything else shares,
|
|
1196
|
+
// to answer something that needs no running software at all. Every hour, on the hour.
|
|
1197
|
+
const forACard = !!tree;
|
|
1198
|
+
const checks = forACard ? await preflight(cwd, (t) => log('tool', t)) : [];
|
|
1199
|
+
// Only bring the project UP when the card needs it running. Intake already judges that, and
|
|
1200
|
+
// the judgement was being used to decide whether to MENTION the environment while starting it
|
|
1201
|
+
// regardless — so a six-pixel CSS alignment fix paid for an install, a database and two
|
|
1202
|
+
// servers before it was allowed to look at a stylesheet. An agent that finds it does need
|
|
1203
|
+
// something running can start it itself; the brief says how.
|
|
1204
|
+
const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
|
|
1205
|
+
const env = forACard && needsRunning
|
|
1206
|
+
// The script this board declared for the repository this card is in, if it declared one.
|
|
1207
|
+
// Matched on the remote, the same way the base branch is.
|
|
1208
|
+
? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
|
|
1209
|
+
: { ran: false, ok: true, endpoints: {}, detail: '' };
|
|
1210
|
+
if (forACard && !needsRunning)
|
|
1211
|
+
log('tool', 'environment not started — this card does not look like it needs one');
|
|
856
1212
|
const engineContext = {
|
|
857
1213
|
config: { ...config, repo: cwd },
|
|
858
1214
|
// Only a real worktree confines anything; without one nothing is enforced here, and the
|
|
859
1215
|
// guard below makes sure a turn that would CHANGE code never gets that far.
|
|
860
|
-
|
|
1216
|
+
//
|
|
1217
|
+
// A SWEEP is the exception, and the reason is worth stating. It is a handover — find the
|
|
1218
|
+
// next piece of work and start it moving — and it has no card, so no worktree. If it tried
|
|
1219
|
+
// to do the work anyway it would be editing the shared checkout every worktree is cut from,
|
|
1220
|
+
// on no branch, with no way to publish it. The brief says so; this makes it true. Pointing
|
|
1221
|
+
// the sandbox at a directory that holds no source confines writes to scratch space (/tmp is
|
|
1222
|
+
// always allowed) without pretending it has a checkout it does not have.
|
|
1223
|
+
// There is ALWAYS a sandbox. It used to be set only for a card or a sweep, so a turn woken
|
|
1224
|
+
// by a message had none — nothing confined — and an agent told "work HR-0621" by its lead
|
|
1225
|
+
// did exactly that, in the shared checkout every worktree is cut from, on a branch of its
|
|
1226
|
+
// own. Without a worktree there is nowhere legitimate to write, so the turn gets scratch
|
|
1227
|
+
// space and has to hand the card back instead.
|
|
1228
|
+
sandbox: tree ? tree.path : scratchDir(),
|
|
861
1229
|
decider,
|
|
1230
|
+
// Both only reachable in-process, so an out-of-process engine is handed them explicitly
|
|
1231
|
+
// rather than being left unable to publish and unchecked when it says it has.
|
|
1232
|
+
...(job ? { publish: async () => publishBranch(job) } : {}),
|
|
1233
|
+
standing: standingBrief(!!teamSet, config.style ?? process.env['FLAREDECK_AGENT_STYLE']),
|
|
1234
|
+
finish: finishGate,
|
|
1235
|
+
noteCard,
|
|
1236
|
+
keep: keepTool,
|
|
1237
|
+
saved: savedSoFar,
|
|
862
1238
|
token: await opts.auth(),
|
|
863
1239
|
...(resume ? { resume } : {}),
|
|
864
1240
|
...(prepared?.model ? { model: prepared.model } : {}),
|
|
865
1241
|
...(prepared?.size ? { size: prepared.size } : {}),
|
|
866
1242
|
tools: {
|
|
867
1243
|
...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
|
|
1244
|
+
// Eyes, when there is something running to look at. A card with no environment gets no
|
|
1245
|
+
// browser tools and is told nothing about them.
|
|
1246
|
+
...(env.ran && Object.keys(env.endpoints).length
|
|
1247
|
+
? {
|
|
1248
|
+
browser: browserTools({
|
|
1249
|
+
repo: config.repo,
|
|
1250
|
+
endpoints: () => env.endpoints,
|
|
1251
|
+
contract: () => env.contract,
|
|
1252
|
+
cwd,
|
|
1253
|
+
onNote: (t) => log('tool', t),
|
|
1254
|
+
}),
|
|
1255
|
+
}
|
|
1256
|
+
: {}),
|
|
868
1257
|
team: teamSet,
|
|
869
1258
|
flaredeck,
|
|
870
1259
|
runs: runsSet,
|
|
@@ -872,23 +1261,46 @@ export async function serve(opts) {
|
|
|
872
1261
|
},
|
|
873
1262
|
guard,
|
|
874
1263
|
};
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
// script that takes a free port for parallel worktrees leaves nothing to guess from.
|
|
882
|
-
const env = await startEnvironment(cwd, (t) => log('tool', t));
|
|
1264
|
+
// Before the agent believes a single thing the environment tells it: is this environment
|
|
1265
|
+
// actually its own? A front end on an assumed port reaches another worktree's API and answers
|
|
1266
|
+
// perfectly, which is the one failure here that looks like success.
|
|
1267
|
+
const wrongEnv = tree && env.ran ? await crossWired(env.endpoints, tree.path) : [];
|
|
1268
|
+
for (const w of wrongEnv)
|
|
1269
|
+
log('warn', w);
|
|
883
1270
|
const brief = prompt(event) + intakeText(prepared) +
|
|
884
1271
|
workspaceBrief(config.repo, await checkouts(config.repo, config.repos), chosen) +
|
|
885
|
-
worktreeBrief(tree) + preflightBrief(checks) +
|
|
1272
|
+
worktreeBrief(tree) + (await resumeBrief(event.cardCode || event.cardKey || '', tree)) + preflightBrief(checks) +
|
|
886
1273
|
// Only tell an agent about the environment when the card actually needs one running.
|
|
887
|
-
environmentBrief(env, cwd, (
|
|
1274
|
+
environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
|
|
1275
|
+
browserBrief(env.ran && Object.keys(env.endpoints).length > 0, env.endpoints) +
|
|
1276
|
+
(wrongEnv.length
|
|
1277
|
+
? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
|
|
1278
|
+
`Anything you test against it tells you about another card's code, and it will answer ` +
|
|
1279
|
+
`normally while doing so. Do not work around it by picking a different port. Say this on ` +
|
|
1280
|
+
`the card, tell whoever owns the board that ${START_SCRIPT_NAME} does not survive parallel ` +
|
|
1281
|
+
`worktrees — it must take a free port AND pass that port to the parts that call it — and stop.`
|
|
1282
|
+
: '');
|
|
888
1283
|
const run = engine.run(brief, engineContext);
|
|
889
1284
|
liveRun = run;
|
|
1285
|
+
// An agent that has started fighting the tools is not going to stop on its own.
|
|
1286
|
+
const struggling = watchForDesperation();
|
|
890
1287
|
for await (const out of run.events) {
|
|
891
1288
|
status.observe(out);
|
|
1289
|
+
if (out.kind === 'tool') {
|
|
1290
|
+
const stepBack = struggling.saw(out.name);
|
|
1291
|
+
if (stepBack) {
|
|
1292
|
+
log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
|
|
1293
|
+
const heard = await run.steer(stepBack).catch(() => false);
|
|
1294
|
+
// An engine that cannot be steered gets stopped instead: letting it carry on is how a
|
|
1295
|
+
// turn becomes twenty minutes of working around a guard.
|
|
1296
|
+
if (!heard) {
|
|
1297
|
+
log('warn', 'cannot steer this engine — ending the turn');
|
|
1298
|
+
await run.stop().catch(() => { });
|
|
1299
|
+
}
|
|
1300
|
+
if (tag)
|
|
1301
|
+
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
892
1304
|
state.tasks = status.snapshot().tasks;
|
|
893
1305
|
// An answer to a question typed here is the whole point of the turn, so it is shown whole;
|
|
894
1306
|
// a card turn's narration is one line each, the card holds the full account.
|
|
@@ -1129,20 +1541,30 @@ async function baseBranch(cwd) {
|
|
|
1129
1541
|
function prompt(event) {
|
|
1130
1542
|
if (event.event === 'sweep') {
|
|
1131
1543
|
return [
|
|
1132
|
-
'Nothing has arrived for a while, so this is a check of what you already hold.',
|
|
1544
|
+
'Nothing has arrived for a while, so this is a check of what you already hold. It is a',
|
|
1545
|
+
'HANDOVER, not a working turn: you find the next piece of work and start it moving, and it',
|
|
1546
|
+
'comes back to you properly a moment later.',
|
|
1133
1547
|
'',
|
|
1134
1548
|
'Call get-outstanding. It lists the cards assigned to you that are not done, and the comments',
|
|
1135
1549
|
'addressed to you that nobody has actioned.',
|
|
1136
1550
|
'',
|
|
1137
|
-
'
|
|
1138
|
-
'
|
|
1139
|
-
'
|
|
1140
|
-
'
|
|
1141
|
-
'
|
|
1551
|
+
'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
|
|
1552
|
+
'costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do not invent a',
|
|
1553
|
+
'task to justify the turn.',
|
|
1554
|
+
'',
|
|
1555
|
+
'Otherwise take the FIRST one by priority and, depending on what it is:',
|
|
1556
|
+
'',
|
|
1557
|
+
'- A card for one of the agents who report to you: assign it to the right one. That dispatch',
|
|
1558
|
+
' IS the work, and this turn is done.',
|
|
1559
|
+
'- A card that is yours and NOT already in progress: move it to the board\'s in-progress lane',
|
|
1560
|
+
' with update-card-status, and stop. Do not read it, do not start the work, do not comment',
|
|
1561
|
+
' on it. Moving it hands it back to you as a card of its own — with a checkout of your own,',
|
|
1562
|
+
' on its own branch — which is the only place the work can actually be done and published',
|
|
1563
|
+
' from. Working it here would edit the shared checkout and could not be pushed anywhere.',
|
|
1564
|
+
'- A card already in progress: say which one in a line, and stop. The harness picks those up',
|
|
1565
|
+
' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
|
|
1142
1566
|
'',
|
|
1143
|
-
'
|
|
1144
|
-
'outcome and costs nothing — do NOT go looking for work elsewhere, do not scan logs, and do',
|
|
1145
|
-
'not invent a task to justify the turn.',
|
|
1567
|
+
'Then stop. One handover per check — the rest are still there next time.',
|
|
1146
1568
|
].join('\n');
|
|
1147
1569
|
}
|
|
1148
1570
|
if (event.event === 'ask') {
|