toolroll 0.9.58 → 0.9.60
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/CHANGELOG.md +18 -0
- package/README.md +8 -6
- package/dist/action-ledger.js +10 -1
- package/dist/assignment-adapters.d.ts +1 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +13 -4
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +6 -2
- package/dist/assignment.js +13 -8
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +19 -19
- package/dist/builder.js +78 -27
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +4 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/design/tokens.css +234 -0
- package/dist/discord.js +2 -1
- package/dist/dispatch.js +1 -1
- package/dist/evidence.d.ts +22 -0
- package/dist/evidence.js +76 -17
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/lead-cli.js +4 -1
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +8 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +11 -3
- package/dist/mailbox.js +3 -2
- package/dist/mcp-core.js +1 -1
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +38 -17
- package/dist/password-bearer.d.ts +4 -4
- package/dist/password-bearer.js +2 -2
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +12 -1
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/scope.js +14 -0
- package/dist/serve.js +3 -2
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/render-chat.js +12 -2
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +30 -22
- package/dist/server/task-views.js +1 -0
- package/dist/server/tasks.js +3 -1
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +19 -0
- package/dist/steer-status.js +30 -0
- package/dist/store.d.ts +85 -8
- package/dist/store.js +203 -15
- package/dist/surface.js +2 -2
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/task-status.js +3 -1
- package/dist/team-updates.js +3 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +20 -1
- package/package.json +21 -21
package/dist/flow-actions.js
CHANGED
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
import nodemailer from "nodemailer";
|
|
25
24
|
import { redactSecretAssignments } from "./builder.js";
|
|
26
25
|
import { redactSecretLines, scanForSecrets } from "./evidence.js";
|
|
27
26
|
import { fillFlowText } from "./flows.js";
|
|
@@ -132,6 +131,8 @@ export async function sendingAccount(dir, fetcher) {
|
|
|
132
131
|
}
|
|
133
132
|
/** The production sender: the operator's mail server through nodemailer. */
|
|
134
133
|
export const sendThroughServer = async (settings, mail) => {
|
|
134
|
+
// nodemailer loads when mail is sent, not with every command that imports this module.
|
|
135
|
+
const { default: nodemailer } = await import("nodemailer");
|
|
135
136
|
const transport = nodemailer.createTransport({
|
|
136
137
|
// A password never travels unencrypted: with a sign-in, the connection must be (or become) TLS.
|
|
137
138
|
host: settings.host, port: settings.port, secure: settings.secure, requireTLS: !settings.secure && settings.user !== "",
|
package/dist/guides.js
CHANGED
|
@@ -208,22 +208,25 @@ runner never pushes, merges, or opens pull requests itself.
|
|
|
208
208
|
const steering = {
|
|
209
209
|
name: "steering",
|
|
210
210
|
title: "Steering a task",
|
|
211
|
-
oneLiner: "an operator ceremony: credentialed notes
|
|
211
|
+
oneLiner: "an operator ceremony: credentialed notes a running build reads when it next checks for notes, or the next attempt — agents cannot file one",
|
|
212
212
|
content: `# Steering a task
|
|
213
213
|
|
|
214
214
|
${AUTHORITY_LINE}
|
|
215
215
|
|
|
216
216
|
Steering is an OPERATOR ceremony. \`task steer <id> --note "..." --as
|
|
217
|
-
<you> --token <t>\` files one note (≤4000 characters) for the
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
217
|
+
<you> --token <t>\` files one note (≤4000 characters) for the task's
|
|
218
|
+
agent, and the credential is the point: the note reaches the agent as
|
|
219
|
+
operator guidance, so only a verified person may put words there.
|
|
220
|
+
Anonymous or agent-authored notes are refused outright.
|
|
221
|
+
|
|
222
|
+
- While a build's agent is running, the note is put in its steering
|
|
223
|
+
folder; the agent reads it when it next checks for notes and moves it
|
|
224
|
+
to handled/, which is the only thing that marks it read. Otherwise —
|
|
225
|
+
or if the agent finishes before reading it — the next attempt's brief
|
|
226
|
+
quotes it under an OPERATOR STEERING heading.
|
|
227
|
+
- Each note says where it stands, the same on the task page, in
|
|
228
|
+
\`task show\` and to the lead: waiting for the agent to read it, read
|
|
229
|
+
by the agent at a time, or will reach the next attempt.
|
|
227
230
|
- A note is guidance INSIDE the approved scope. It cannot widen scope,
|
|
228
231
|
approve anything, or replace the ceremony a change of scope needs.
|
|
229
232
|
- Refusals: missing credential is usage; a wrong credential is
|
package/dist/lead-cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
|
|
2
|
+
import { closedQuestionWords } from "./decision.js";
|
|
2
3
|
/**
|
|
3
4
|
* `toolroll chat` (mate arc §6): the same thread the console shows,
|
|
4
5
|
* driven from a terminal. The password is typed once — it mints the lead
|
|
@@ -45,6 +46,8 @@ export function answerContextLines(store, payload) {
|
|
|
45
46
|
const decision = id === null ? null : store.getDecision(id);
|
|
46
47
|
if (decision === null)
|
|
47
48
|
return [" (the decision is gone)"];
|
|
49
|
+
if (decision.supersededReason !== null)
|
|
50
|
+
return [` ${decision.question}`, ` ${closedQuestionWords(decision.supersededReason)}`];
|
|
48
51
|
const pick = typeof payload["option"] === "string" ? payload["option"] : "";
|
|
49
52
|
return [
|
|
50
53
|
` ${decision.question}${decision.state !== "open" ? ` [${decision.state}]` : ""}`,
|
|
@@ -68,7 +71,7 @@ export function proposalLines(proposals, repos, ordinal = (_p) => 0) {
|
|
|
68
71
|
? `${t("operation") === "revise" ? "request a revision" : "save feedback"} for ${t("taskTitle") || t("task")}: ${t("note")}${Array.isArray(payload["notes"]) && payload["notes"].length > 0 ? ` (saved notes: ${payload["notes"].join(", ")})` : ""}`
|
|
69
72
|
: one.kind === "control"
|
|
70
73
|
? (isChatControl(payload["control"]) ? `${CHAT_CONTROLS[payload["control"]].label}: ${chatControlHref(payload["control"], t("task"), payload["run"], payload["project"])} (open in the console)` : "control unavailable")
|
|
71
|
-
: one.kind === "steer" ? `
|
|
74
|
+
: one.kind === "steer" ? `guidance for ${t("task")}'s agent: ${t("note")}`
|
|
72
75
|
: one.kind === "agents" ? `change agents for ${t("task")}: ${t("role")} ${t("provider")} ${t("model")} ${t("risk")}`
|
|
73
76
|
: one.kind === "repair" ? `${t("operation")} dependency ${t("blocker")} for ${t("task")}`
|
|
74
77
|
:
|
package/dist/lead-contract.js
CHANGED
|
@@ -20,7 +20,7 @@ export const LEAD_CONTRACT = [
|
|
|
20
20
|
"Talk like a colleague in everyday words: task titles, 'the changes', 'the checks', 'try again'. Never say execution, run, scope, digest, dispatch or lane to the operator; say what happens next and what they can do.",
|
|
21
21
|
"Read get_result for the exact execution/run; page nextFeedbackOffset. propose_review revise requests changes; note saves feedback. Use read notes only; clarify ambiguous intent. Ready is a saved result, not a reviewer stage. Historical missing assessments never require rerunning work. Failed checks and missing files remain visible; completion never deploys or changes checks.",
|
|
22
22
|
"For screenshots, call get_result_images for that exact execution and run. On Telegram say they follow, never that they were delivered; elsewhere name result and image count. Limit 8/reply. If nextImageOffset exists, name remaining count; call it again with that offset or with the image ids it listed when asked. Select a newer revision explicitly; never describe unselected images or expose paths.",
|
|
23
|
-
"propose_steer
|
|
23
|
+
"propose_steer reaches a running build when it next checks for notes, else the next attempt; get_task steering says if it was read. propose_scope changes scope. propose_task_action uses control.run for stop/resume and supports retry, plan, dependencies. Requested stop is not stopped; ending chat differs. For protected actions read get_actions, use propose_action and full secure review. get_action_status proves saved outcomes; show_control only opens controls.",
|
|
24
24
|
"For a task's models/risk, read get_agents and answer in its words; for defaults, CLI versions and new models, read get_models; never an agent that is not listed. Changes require renewed approval. Before propose_answer read get_decision in an earlier step, including every option and consequence. Irreversible answers require explicit confirmation; cards retain consequences and recommendations.",
|
|
25
25
|
"For intake, a plain-language outcome is enough to draft a task. Infer title, narrow goal, safe non-goals and testable criteria; leave touches empty for discovery. Do not ask the operator for a title, paths, implementation details, acceptance wording, model, budget or safely inferable fields. Ask at most three questions with defaults (one at a time with ask_owner) for material ambiguity, conflicting goals or unresolved irreversible/public/security/data-loss/migration choices. With 'use your judgment', use reversible defaults; real blockers may still arise.",
|
|
26
26
|
"Set propose_task planning to 'required' for broad, risky or plan-first work, 'skip' for explicitly small direct builds, otherwise 'auto'. report:true investigates without a branch; follow-ups require confirmed proposals.",
|
package/dist/lead-doors.js
CHANGED
|
@@ -14,6 +14,8 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
|
|
|
14
14
|
* scope was rewritten, the decision answered) is a refusal, never a
|
|
15
15
|
* silent re-read.
|
|
16
16
|
*/
|
|
17
|
+
import { closedQuestionWords } from "./decision.js";
|
|
18
|
+
import { steerDestinationWords } from "./steer-status.js";
|
|
17
19
|
import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
|
|
18
20
|
import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
|
|
19
21
|
import { isVerifiedApprover, reproveApprover } from "./principal.js";
|
|
@@ -57,7 +59,7 @@ const VIA_WORDS = {
|
|
|
57
59
|
};
|
|
58
60
|
const CARD_WORDS = {
|
|
59
61
|
task: "New task", next: "Queue priority", reserve: "Worker assignment", hold: "Pause work", unhold: "Resume work",
|
|
60
|
-
steer: "Guidance for the
|
|
62
|
+
steer: "Guidance for the agent", scope: "Scope revision", answer: "Decision answer", repair: "Waiting task",
|
|
61
63
|
agents: "Agents change", task_action: "Task update", action: "Action",
|
|
62
64
|
};
|
|
63
65
|
/** A card about a task, confirmed anywhere but that task's own chat (the
|
|
@@ -317,6 +319,8 @@ function executeProposal(store, actor, kind, payload, now, options) {
|
|
|
317
319
|
return refuse("stale", "that option no longer exists on the decision");
|
|
318
320
|
// The proposal contract is "open": a decision past its deadline — swept
|
|
319
321
|
// or not — is not answered through a card (v3 review, finding 7).
|
|
322
|
+
if (decision.supersededReason !== null)
|
|
323
|
+
return refuse("stale", closedQuestionWords(decision.supersededReason));
|
|
320
324
|
if (decision.state === "answered")
|
|
321
325
|
return refuse("already-answered", "already answered — somebody got there first");
|
|
322
326
|
if (decision.state !== "open" || (decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime())) {
|
|
@@ -418,7 +422,9 @@ function executeProposal(store, actor, kind, payload, now, options) {
|
|
|
418
422
|
return refuse("refused", said);
|
|
419
423
|
}
|
|
420
424
|
const taskName = payloadString(payload, "taskTitle") ?? taskId;
|
|
421
|
-
|
|
425
|
+
const steered = store.lookupRef(taskId);
|
|
426
|
+
const live = steered !== null && store.steeringLiveBuild(steered.id, now) !== null;
|
|
427
|
+
return { ok: true, kind, said: `Guidance saved for ${taskName}. ${steerDestinationWords(live)}`, taskId };
|
|
422
428
|
}
|
|
423
429
|
if (kind === "repair") {
|
|
424
430
|
const blocker = payloadString(payload, "blocker");
|
package/dist/lead-follow.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare function leadFollowStatus(store: Store, actor: string): {
|
|
|
6
6
|
enabled: boolean;
|
|
7
7
|
detail: string;
|
|
8
8
|
};
|
|
9
|
+
/** A follow pass that failed outright says so where each enabled conversation reads its follow status. */
|
|
10
|
+
export declare function recordLeadFollowFailure(store: Store, error: unknown, now: Date): void;
|
|
9
11
|
export type LeadFollowInput = {
|
|
10
12
|
store: Store;
|
|
11
13
|
repos: () => readonly string[];
|
package/dist/lead-follow.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
/** Durable crew updates for the built-in lead. Idle scans use no model.
|
|
2
|
-
*
|
|
2
|
+
* The lead is an ordinary consumer of the assignment inbox: its scan records
|
|
3
|
+
* status events under its follow grant (assignment-status.ts), and it reads
|
|
4
|
+
* and acknowledges them through the same saved pages every coordinator uses
|
|
5
|
+
* (assignment-delivery.ts). Replays reuse the saved lead request; they never
|
|
6
|
+
* dispatch a task. */
|
|
3
7
|
import { createHash } from 'node:crypto';
|
|
4
8
|
import { LEAD_FOLLOW_MESSAGE } from './store.js';
|
|
5
9
|
import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './principal.js';
|
|
6
10
|
import { assignmentOf } from './assignment.js';
|
|
11
|
+
import { ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, followActor, LEAD_WAKE_STATES, noteFollowStatus } from './assignment-status.js';
|
|
12
|
+
import { acknowledgeLeadFollowDelivery, leadFollowInbox } from './assignment-delivery.js';
|
|
13
|
+
import { publicChatText } from './chat-display.js';
|
|
7
14
|
import { runLeadTurn } from './lead.js';
|
|
8
15
|
import { updateAdmissionPaused } from './desktop-update-gate.js';
|
|
9
16
|
import { checkLeadCommitments } from './lead-commitments.js';
|
|
17
|
+
import { sweepIfMoved } from './sweep-gate.js';
|
|
10
18
|
const CONFIG = 'lead follow configured';
|
|
19
|
+
/** What follow status reads while nothing is waiting on the lead. */
|
|
20
|
+
const FOLLOW_READY = 'The lead will respond when a result or decision is ready, or a task is cancelled.';
|
|
21
|
+
const WAITING = 'Crew updates are waiting:';
|
|
22
|
+
const FAILED = `${WAITING} the last update check failed`;
|
|
23
|
+
/** The built-in lead's one inbox per grant. */
|
|
24
|
+
const CONSUMER = 'lead';
|
|
25
|
+
const PAGE = 20;
|
|
11
26
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
12
27
|
export function configureLeadFollow(store, who, session, thread, enabled, now) {
|
|
13
28
|
if (!isVerifiedApprover(who) || !reproveApprover(store, who).ok || session.approver !== who.name || thread.approver !== who.name ||
|
|
@@ -46,7 +61,7 @@ export function leadFollowStatus(store, actor) {
|
|
|
46
61
|
if (!grant?.enabled || !authorized(store, grant, grant.repos))
|
|
47
62
|
return { enabled: false, detail: 'Automatic crew updates are off.' };
|
|
48
63
|
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? AND source='work' ORDER BY id DESC LIMIT 1").get(actor, `lead delivery status:${grant.id}`);
|
|
49
|
-
return { enabled: true, detail: row ? String(row['outcome']) :
|
|
64
|
+
return { enabled: true, detail: row ? String(row['outcome']) : FOLLOW_READY };
|
|
50
65
|
}
|
|
51
66
|
function authorized(store, grant, admitted) {
|
|
52
67
|
const latest = grantFor(store, grant.actor);
|
|
@@ -59,63 +74,81 @@ function authorized(store, grant, admitted) {
|
|
|
59
74
|
const binding = liveBinding(store, proved.who, grant.session, grant.thread);
|
|
60
75
|
return binding ? { who: proved.who, ...binding } : null;
|
|
61
76
|
}
|
|
62
|
-
/** Observe bounded pages; first sight of
|
|
63
|
-
*
|
|
77
|
+
/** Observe bounded pages; first sight of work that has not moved since follow
|
|
78
|
+
* was enabled is only a baseline, recorded so the next change is compared to it. Every later transition — cancellation too —
|
|
79
|
+
* becomes an immutable status event in the grant's own inbox. */
|
|
64
80
|
function observe(store, grant, now, root) {
|
|
65
81
|
store.transact(() => {
|
|
66
82
|
const cursor = `lead:${grant.id}:scan`, after = store.serviceCursor(cursor);
|
|
67
83
|
const roots = store.handle.prepare(`SELECT id,external_id FROM task_ref WHERE backend='built-in' AND revision_of IS NULL
|
|
68
84
|
AND repo IN (SELECT value FROM json_each(?)) AND id>? ORDER BY id LIMIT 50`).all(JSON.stringify(grant.repos), after);
|
|
85
|
+
const since = baselineOf(store, grant);
|
|
69
86
|
for (const row of roots) {
|
|
70
87
|
const a = assignmentOf(store, String(row['external_id']), now, { principal: 'operator', repos: grant.repos }, root);
|
|
71
88
|
if (!a || !a.repo)
|
|
72
89
|
continue;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
continue;
|
|
79
|
-
store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: a.repo, taskId: a.rootId, runId: a.receipt?.runId ?? null, action, outcome: digest, source: 'work' });
|
|
80
|
-
// Scope, question, result and completion writes do not necessarily
|
|
81
|
-
// update task.updated_at. A transition between enabling follow and
|
|
82
|
-
// the first scan must not be mistaken for a pre-existing baseline.
|
|
83
|
-
const scope = store.getScope(a.activeTaskId);
|
|
84
|
-
const question = observation.decision === null ? null : store.getDecision(observation.decision);
|
|
85
|
-
const changedAfterGrant = [store.getTask(a.activeTaskId)?.updatedAt, scope?.proposedAt, scope?.approvedAt,
|
|
86
|
-
question?.createdAt, a.completion?.at, ...a.attempts.map(attempt => attempt.runId === null ? null : store.getRun(attempt.runId)?.finishedAt)]
|
|
87
|
-
.some(at => typeof at === 'string' && at >= grant.at);
|
|
88
|
-
if ((old || changedAfterGrant) && ['ready-to-check', 'needs-decision', 'complete'].includes(a.state)) {
|
|
89
|
-
store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: a.repo, taskId: a.rootId, runId: a.receipt?.runId ?? null,
|
|
90
|
-
action: `lead update:${grant.id}`, outcome: JSON.stringify(observation), source: 'work' });
|
|
91
|
-
}
|
|
90
|
+
const seen = store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id=? AND action IN (?,?) AND source='work' AND actor=? LIMIT 1")
|
|
91
|
+
.get(a.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, followActor(grant.id)) !== undefined;
|
|
92
|
+
const undelivered = !seen && store.handle.prepare("SELECT 1 FROM action_ledger WHERE actor=? AND action=? AND task_id=? AND source='work' AND id>? LIMIT 1")
|
|
93
|
+
.get(grant.actor, `lead update:${grant.id}`, a.rootId, store.serviceCursor(`lead:${grant.id}:events`)) !== undefined;
|
|
94
|
+
noteFollowStatus(store, grant.id, a, now, seen || undelivered || changedSince(store, a, since) ? ASSIGNMENT_STATUS_ACTION : ASSIGNMENT_STATUS_BASELINE);
|
|
92
95
|
}
|
|
93
96
|
store.setServiceCursor(cursor, roots.length < 50 ? 0 : Number(roots.at(-1)['id']), now);
|
|
94
97
|
});
|
|
95
98
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
99
|
+
/** Scope, question, result and completion writes do not necessarily update
|
|
100
|
+
* task.updated_at. A transition between enabling follow and the first scan
|
|
101
|
+
* must not be mistaken for a pre-existing baseline. */
|
|
102
|
+
function changedSince(store, a, since) {
|
|
103
|
+
const scope = store.getScope(a.activeTaskId);
|
|
104
|
+
const decision = a.primaryAction?.target.decisionId ?? null;
|
|
105
|
+
const question = decision === null ? null : store.getDecision(decision);
|
|
106
|
+
return [store.getTask(a.activeTaskId)?.updatedAt, scope?.proposedAt, scope?.approvedAt,
|
|
107
|
+
question?.createdAt, a.completion?.at, ...a.attempts.map(attempt => attempt.runId === null ? null : store.getRun(attempt.runId)?.finishedAt)]
|
|
108
|
+
.some(at => typeof at === 'string' && at >= since);
|
|
109
|
+
}
|
|
110
|
+
/** When follow began counting news: the grant itself, or, for a grant that
|
|
111
|
+
* predates this inbox, the newest crew update its old private batches actually
|
|
112
|
+
* handed the lead — that is not news again. Updates recorded past that point
|
|
113
|
+
* were never received, so observe still treats their tasks as news. */
|
|
114
|
+
function baselineOf(store, grant) {
|
|
115
|
+
const received = store.serviceCursor(`lead:${grant.id}:events`);
|
|
116
|
+
if (received === 0)
|
|
117
|
+
return grant.at;
|
|
118
|
+
const row = store.handle.prepare("SELECT at FROM action_ledger WHERE id<=? AND actor=? AND action=? AND source='work' ORDER BY id DESC LIMIT 1")
|
|
119
|
+
.get(received, grant.actor, `lead update:${grant.id}`);
|
|
120
|
+
return row !== undefined && String(row['at']) > grant.at ? String(row['at']) : grant.at;
|
|
121
|
+
}
|
|
122
|
+
/** What wakes the lead from one page: each task's latest result, question, completion or cancellation. */
|
|
123
|
+
function wakeUpdates(events) {
|
|
124
|
+
const latest = new Map();
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
if ('unavailable' in event || !LEAD_WAKE_STATES.includes(event.assignment.state))
|
|
127
|
+
continue;
|
|
128
|
+
const a = event.assignment;
|
|
129
|
+
latest.set(a.rootId, { id: event.id, task: a.rootId, repo: event.project, title: a.title.slice(0, 200), state: a.state, detail: a.detail.slice(0, 400),
|
|
130
|
+
result: a.result?.runId ?? null, checks: a.result?.checks.status ?? null, decision: a.primaryAction?.target.decisionId ?? null });
|
|
131
|
+
}
|
|
132
|
+
return [...latest.values()];
|
|
133
|
+
}
|
|
134
|
+
/** The lead's follow status line, written once per distinct status. */
|
|
135
|
+
const lastDeliveryStatus = (store, grant) => store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? ORDER BY id DESC LIMIT 1").get(grant.actor, `lead delivery status:${grant.id}`)?.['outcome'];
|
|
136
|
+
function noteDeliveryStatus(store, grant, status, now) {
|
|
137
|
+
if (lastDeliveryStatus(store, grant) !== status)
|
|
138
|
+
store.recordAction({ at: now.toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
|
|
139
|
+
}
|
|
140
|
+
const enabledGrants = (store) => store.handle.prepare("SELECT a.* FROM action_ledger a WHERE a.action=? AND a.source='work' AND a.id=(SELECT MAX(b.id) FROM action_ledger b WHERE b.actor=a.actor AND b.action=a.action AND b.source=a.source) ORDER BY a.id")
|
|
141
|
+
.all(CONFIG).map(readGrant).filter((one) => one !== null && one.enabled);
|
|
142
|
+
const failureStatus = (error) => `${FAILED} (${publicChatText(error instanceof Error ? error.message : String(error), 200)}). It retries in a few seconds.`;
|
|
143
|
+
/** With no page left unacknowledged, an earlier fault or refusal is no longer waiting. */
|
|
144
|
+
function settle(store, grant, now) {
|
|
145
|
+
if (String(lastDeliveryStatus(store, grant) ?? '').startsWith(WAITING))
|
|
146
|
+
noteDeliveryStatus(store, grant, FOLLOW_READY, now);
|
|
147
|
+
}
|
|
148
|
+
/** A follow pass that failed outright says so where each enabled conversation reads its follow status. */
|
|
149
|
+
export function recordLeadFollowFailure(store, error, now) {
|
|
150
|
+
for (const grant of enabledGrants(store))
|
|
151
|
+
noteDeliveryStatus(store, grant, failureStatus(error), now);
|
|
119
152
|
}
|
|
120
153
|
export async function runLeadFollowPass(input) {
|
|
121
154
|
const { store } = input, clock = input.clock ?? (() => new Date());
|
|
@@ -124,48 +157,65 @@ export async function runLeadFollowPass(input) {
|
|
|
124
157
|
store.sweepStaleLeadTurns(clock());
|
|
125
158
|
// The lead's own promises are kept whether or not automatic crew updates are on: no model, one line when met.
|
|
126
159
|
checkLeadCommitments(store, clock(), input.evidenceRoot);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!batch)
|
|
135
|
-
continue;
|
|
136
|
-
// A replay after process exit reads the saved turn; even a failed turn is
|
|
137
|
-
// terminal for this delivery. Human chat remains available for follow-up.
|
|
138
|
-
const saved = store.leadRequestReceipt(grant.session, batch.request);
|
|
139
|
-
let turn = saved ? store.getLeadTurn(saved.turn) : null;
|
|
140
|
-
if (!turn) {
|
|
141
|
-
const provider = input.provider();
|
|
142
|
-
if (!provider)
|
|
143
|
-
continue;
|
|
144
|
-
const result = await (input.runTurn ?? runLeadTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
|
|
145
|
-
requestId: batch.request, message: LEAD_FOLLOW_MESSAGE,
|
|
146
|
-
context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
|
|
147
|
-
...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
|
|
148
|
-
if (!result.ok && 'refused' in result) {
|
|
149
|
-
const status = `Crew updates are waiting: ${result.message}`;
|
|
150
|
-
const last = store.handle.prepare("SELECT outcome FROM action_ledger WHERE actor=? AND action=? ORDER BY id DESC LIMIT 1").get(grant.actor, `lead delivery status:${grant.id}`);
|
|
151
|
-
if (last?.['outcome'] !== status)
|
|
152
|
-
store.recordAction({ at: clock().toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
turn = store.getLeadTurn(result.turn);
|
|
160
|
+
for (const grant of enabledGrants(store)) {
|
|
161
|
+
// One conversation's fault never holds up another's updates, and it is said where follow status is read.
|
|
162
|
+
try {
|
|
163
|
+
await followGrant(input, grant, clock);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
noteDeliveryStatus(store, grant, failureStatus(error), clock());
|
|
156
167
|
}
|
|
157
|
-
if (!turn || turn.state === 'queued' || turn.state === 'running')
|
|
158
|
-
continue;
|
|
159
|
-
store.transact(() => {
|
|
160
|
-
// Concurrent passes may finish the same receipt, but ACK once.
|
|
161
|
-
if (store.serviceCursor(`lead:${grant.id}:delivery`) >= batch.id)
|
|
162
|
-
return;
|
|
163
|
-
store.setServiceCursor(`lead:${grant.id}:delivery`, batch.id, clock());
|
|
164
|
-
store.setServiceCursor(`lead:${grant.id}:events`, batch.through, clock());
|
|
165
|
-
const status = turn.state === 'answered' ? 'Crew updates handled.' : 'The lead response stopped. Open chat to continue; crew work was not rerun.';
|
|
166
|
-
store.recordAction({ at: clock().toISOString(), actor: grant.actor, repo: null, taskId: null, runId: null, action: `lead delivery status:${grant.id}`, outcome: status, source: 'work' });
|
|
167
|
-
if (turn.state !== 'answered' && authorized(store, grant, input.repos()))
|
|
168
|
-
store.appendLeadMessage({ thread: grant.thread, turn: null, role: 'assistant', text: status }, clock());
|
|
169
|
-
});
|
|
170
168
|
}
|
|
171
169
|
}
|
|
170
|
+
async function followGrant(input, grant, clock) {
|
|
171
|
+
const { store } = input;
|
|
172
|
+
const access = authorized(store, grant, input.repos());
|
|
173
|
+
if (!access || updateAdmissionPaused(store.raw()))
|
|
174
|
+
return;
|
|
175
|
+
const now = clock();
|
|
176
|
+
sweepIfMoved(store, `lead:${grant.id}:scan`, now, () => observe(store, grant, now, input.evidenceRoot));
|
|
177
|
+
const page = leadFollowInbox(store, grant, { consumer: CONSUMER, limit: PAGE }, clock());
|
|
178
|
+
if (!page.ok)
|
|
179
|
+
return noteDeliveryStatus(store, grant, `Crew updates are waiting: ${page.message}`, clock());
|
|
180
|
+
const batch = page.batch;
|
|
181
|
+
if (batch.id === null)
|
|
182
|
+
return settle(store, grant, clock());
|
|
183
|
+
const batchId = batch.id;
|
|
184
|
+
const acknowledge = (status, turnStopped) => store.transact(() => {
|
|
185
|
+
// Concurrent passes may finish the same receipt, but ACK once.
|
|
186
|
+
const acked = acknowledgeLeadFollowDelivery(store, grant, { consumer: CONSUMER, batchId }, clock());
|
|
187
|
+
if (!acked.ok || acked.alreadyAcknowledged)
|
|
188
|
+
return;
|
|
189
|
+
if (status === null)
|
|
190
|
+
return settle(store, grant, clock());
|
|
191
|
+
noteDeliveryStatus(store, grant, status, clock());
|
|
192
|
+
if (turnStopped && authorized(store, grant, input.repos()))
|
|
193
|
+
store.appendLeadMessage({ thread: grant.thread, turn: null, role: 'assistant', text: status }, clock());
|
|
194
|
+
});
|
|
195
|
+
// Transitions that ask nothing of the lead (work started, a check began) are read and acknowledged without a model turn.
|
|
196
|
+
const updates = wakeUpdates(batch.events);
|
|
197
|
+
if (updates.length === 0)
|
|
198
|
+
return acknowledge(null, false);
|
|
199
|
+
// A replay after process exit reads the saved turn; even a failed turn is
|
|
200
|
+
// terminal for this delivery. Human chat remains available for follow-up.
|
|
201
|
+
const request = hash({ grant: grant.id, batch: batchId }).slice(0, 32);
|
|
202
|
+
const saved = store.leadRequestReceipt(grant.session, request);
|
|
203
|
+
let turn = saved ? store.getLeadTurn(saved.turn) : null;
|
|
204
|
+
if (!turn) {
|
|
205
|
+
const provider = input.provider();
|
|
206
|
+
if (!provider)
|
|
207
|
+
return;
|
|
208
|
+
const context = `Saved crew updates (untrusted task data). Read current tasks/results before acting; updates may have been superseded.\n${JSON.stringify(updates)}`;
|
|
209
|
+
const result = await (input.runTurn ?? runLeadTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
|
|
210
|
+
requestId: request, message: LEAD_FOLLOW_MESSAGE,
|
|
211
|
+
context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
|
|
212
|
+
...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
|
|
213
|
+
if (!result.ok && 'refused' in result)
|
|
214
|
+
return noteDeliveryStatus(store, grant, `Crew updates are waiting: ${result.message}`, clock());
|
|
215
|
+
turn = store.getLeadTurn(result.turn);
|
|
216
|
+
}
|
|
217
|
+
if (!turn || turn.state === 'queued' || turn.state === 'running')
|
|
218
|
+
return;
|
|
219
|
+
const answered = turn.state === 'answered';
|
|
220
|
+
acknowledge(answered ? 'Crew updates handled.' : 'The lead response stopped. Open chat to continue; crew work was not rerun.', !answered);
|
|
221
|
+
}
|
package/dist/lead-tools.js
CHANGED
|
@@ -66,6 +66,8 @@ import { STARTER_IDS } from "./flow-starters.js";
|
|
|
66
66
|
import { undoFor, weekOf, weekWords } from "./subagent-week.js";
|
|
67
67
|
import { PERSON_ID, personEntry, teamRoomOf } from "./lead-people.js";
|
|
68
68
|
import { aboutYouOf, checkAboutYouLine } from "./lead-about.js";
|
|
69
|
+
import { steerStandingWords } from "./steer-status.js";
|
|
70
|
+
import { utcMinute } from "./when-html.js";
|
|
69
71
|
const REPO_ID = /^r[0-9]{1,3}$/;
|
|
70
72
|
const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
71
73
|
const ISO_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
@@ -284,12 +286,13 @@ export function decisionOver(store, repos, decisionId, now) {
|
|
|
284
286
|
return null;
|
|
285
287
|
// "Open" is derived, not stored (v3 review, finding 7): a decision past
|
|
286
288
|
// its deadline is expired here whether or not the sweep has run.
|
|
287
|
-
const state = decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
|
|
289
|
+
const state = decision.supersededReason !== null ? "closed" : decision.state === "open" && decision.deadline !== null && Date.parse(decision.deadline) <= now.getTime() ? "expired" : decision.state;
|
|
288
290
|
return {
|
|
289
291
|
repoIndex,
|
|
290
292
|
decision: decision.id,
|
|
291
293
|
task: ref.externalId,
|
|
292
294
|
state,
|
|
295
|
+
...(decision.supersededReason === null ? {} : { closedBecause: decision.supersededReason }),
|
|
293
296
|
question: decision.question,
|
|
294
297
|
options: decision.options.map(one => ({ id: one.id, label: one.label, reversible: one.reversible, consequence: one.consequence })),
|
|
295
298
|
ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(decision.createdAt)) / 3_600_000)),
|
|
@@ -1491,7 +1494,7 @@ const LEAD_TOOL_HANDLERS = {
|
|
|
1491
1494
|
},
|
|
1492
1495
|
},
|
|
1493
1496
|
get_task: {
|
|
1494
|
-
description: "Read currentExecution, state, dispatch, history, scope, dependencies, holds, queue, attempts and
|
|
1497
|
+
description: "Read currentExecution, state, dispatch, history, scope, dependencies, holds, queue, attempts, decisions and whether the agent has read your notes before acting.",
|
|
1495
1498
|
handle: (ctx, args) => {
|
|
1496
1499
|
const taskId = taskIdOf(args);
|
|
1497
1500
|
if (taskId === null)
|
|
@@ -1537,6 +1540,11 @@ const LEAD_TOOL_HANDLERS = {
|
|
|
1537
1540
|
attempts: runs.length,
|
|
1538
1541
|
lastAttempt: runs[0] === undefined ? null : { build: runs[0].id, outcome: runs[0].outcome ?? "unfinished", worker: runs[0].runner, tools: ctx.store.runTools(runs[0].id), secretsFenced: ctx.store.runFence(runs[0].id)?.method ?? null },
|
|
1539
1542
|
decisionsOpen,
|
|
1543
|
+
// Operator steering (v120), in the task page's words: read (and when), waiting, or for the next attempt.
|
|
1544
|
+
steering: (() => {
|
|
1545
|
+
const live = ctx.store.steeringLiveBuild(ref.id, ctx.now) !== null;
|
|
1546
|
+
return ctx.store.listSteerNotes(ref.id).slice(-5).map(one => ({ note: one.id, filedAt: one.createdAt, state: steerStandingWords(one, live, utcMinute) }));
|
|
1547
|
+
})(),
|
|
1540
1548
|
},
|
|
1541
1549
|
};
|
|
1542
1550
|
},
|
|
@@ -1690,7 +1698,7 @@ const LEAD_TOOL_HANDLERS = {
|
|
|
1690
1698
|
},
|
|
1691
1699
|
},
|
|
1692
1700
|
propose_steer: {
|
|
1693
|
-
description: "
|
|
1701
|
+
description: "Send guidance within existing scope: a running build's agent reads it when it next checks for notes, otherwise the next attempt does. It never stops work.",
|
|
1694
1702
|
handle: (ctx, args) => {
|
|
1695
1703
|
const taskId = taskIdOf(args);
|
|
1696
1704
|
const ref = taskId === null ? null : admittedRef(ctx, taskId);
|
package/dist/mailbox.js
CHANGED
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
* answering machines. The card keeps the message's ID so a Send email step
|
|
16
16
|
* replying to the sender stays in the same thread.
|
|
17
17
|
*/
|
|
18
|
-
import { ImapFlow } from "imapflow";
|
|
19
|
-
import { simpleParser } from "mailparser";
|
|
20
18
|
import { googleAccessToken, googleConnected } from "./google-mail.js";
|
|
21
19
|
import { readEmailSettings } from "./email-settings.js";
|
|
22
20
|
const BODY_CHARS = 4000;
|
|
@@ -72,6 +70,8 @@ const header = (headers, name) => {
|
|
|
72
70
|
};
|
|
73
71
|
/** One raw message as plain words, and whether a machine sent it. */
|
|
74
72
|
export async function readMail(uid, source) {
|
|
73
|
+
// The mail SDKs load when mail is read, not with every command that imports this module.
|
|
74
|
+
const { simpleParser } = await import("mailparser");
|
|
75
75
|
const parsed = await simpleParser(source, { skipImageLinks: true, skipTextToHtml: true, skipTextLinks: true });
|
|
76
76
|
const sender = parsed.from?.value[0];
|
|
77
77
|
const from = (sender?.address ?? "").toLowerCase();
|
|
@@ -103,6 +103,7 @@ function problemOf(error) {
|
|
|
103
103
|
}
|
|
104
104
|
/** The production reader: one read-only IMAP visit. */
|
|
105
105
|
export const readThroughImap = async (access, folder, after, limit) => {
|
|
106
|
+
const { ImapFlow } = await import("imapflow");
|
|
106
107
|
const client = new ImapFlow({
|
|
107
108
|
host: access.host, port: access.port, secure: access.secure, logger: false, disableAutoIdle: true,
|
|
108
109
|
auth: access.accessToken !== undefined ? { user: access.user, accessToken: access.accessToken } : { user: access.user, pass: access.password ?? "" },
|
package/dist/mcp-core.js
CHANGED
|
@@ -56,7 +56,7 @@ const CONTRACT_GUIDE = [
|
|
|
56
56
|
"recap, list_decisions, get_decision, and queue are the plane's own read queries (shared with the operator's mate). get_decision shows each option's consequence, never the builder's recommendation.",
|
|
57
57
|
"propose_next, propose_reserve, propose_hold, propose_unhold, propose_scope, propose_cancel, and propose_answer write a PROPOSAL row and nothing else: an approver confirms it on the console or the CLI, and a stale one refuses there. Proposals share your hourly filing rate and hold at most 20 pending per credential; they expire after seven days.",
|
|
58
58
|
"Approve, steer, answer, pick, mint, configure, merge: those verbs do not exist on this surface, by construction.",
|
|
59
|
-
"Claim one assignment, then
|
|
59
|
+
"Claim one assignment, then receive its changes with get_assignment_inbox and acknowledge each handled batch with acknowledge_assignment_delivery; inspect get_assignment when a current result or decision needs attention. list_assignment_updates is only the history of handoffs sent to people. claim_assignment records its lead; acknowledge_assignment records that lead's check of an exact ready receipt. Neither operation grants authority, accepts proof, answers decisions, approves work, publishes, or deploys.",
|
|
60
60
|
].join("\n");
|
|
61
61
|
/** An assignment tool: the adapter reads its call with the same schema `toolroll assignment` does. */
|
|
62
62
|
const assignmentHandler = (operation) => (ctx, args) => {
|
package/dist/needs-you.js
CHANGED
|
@@ -20,7 +20,9 @@ export const NEEDS = {
|
|
|
20
20
|
hold: { sentence: () => "A hold is keeping this task from starting. Review it to release it.", action: { code: "inspect-hold", label: "Review hold" } },
|
|
21
21
|
"choose-project": { sentence: () => "Choose a project so a builder can start.", action: { code: "place-task", label: "Choose a project" } },
|
|
22
22
|
"define-task": { sentence: () => "Say what the task should do so it can be planned.", action: { code: "write-scope", label: "Define the task" } },
|
|
23
|
-
"fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" }
|
|
23
|
+
"fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" },
|
|
24
|
+
// Its own size and limit say exactly what to shorten.
|
|
25
|
+
useReason: reason => /the planner can carry at most/.test(reason) },
|
|
24
26
|
"choose-agent": { sentence: () => "Choose an agent to build this.", action: { code: "select-agent", label: "Choose an agent" } },
|
|
25
27
|
"fix-dependency": { sentence: () => "A task this one waits on didn't finish. Fix or remove it so this one can start.", action: { code: "repair-dependency", label: "Fix the required task" },
|
|
26
28
|
useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => `${reason.replace(/[.\s]*$/, ".")} Fix or remove it so this one can start.` },
|