paseo-follow-up 0.3.6 → 0.4.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.
Files changed (63) hide show
  1. package/OVERVIEW.md +19 -7
  2. package/README.md +70 -79
  3. package/THIRD_PARTY_NOTICES.md +33 -0
  4. package/assets/LICENSE.lucide +43 -0
  5. package/assets/icon.png +0 -0
  6. package/assets/icon.svg +1 -0
  7. package/client/button-visibility.ts +2 -1
  8. package/client/chat.tsx +163 -0
  9. package/client/display.tsx +12 -17
  10. package/client/header.tsx +92 -0
  11. package/client/keys.ts +3 -0
  12. package/client/kit.tsx +119 -0
  13. package/client/large.tsx +58 -0
  14. package/client/live.tsx +159 -0
  15. package/client/motion.tsx +93 -0
  16. package/client/needs-you.tsx +121 -0
  17. package/client/overview.tsx +111 -16
  18. package/client/panel.tsx +257 -68
  19. package/client/pr-controls.tsx +50 -33
  20. package/client/pr-sheet.tsx +63 -0
  21. package/client/questions.tsx +307 -0
  22. package/client/settings.tsx +55 -20
  23. package/client/spec.tsx +113 -0
  24. package/client/stacks.tsx +128 -0
  25. package/client/tasks.tsx +113 -22
  26. package/client/theme.ts +69 -0
  27. package/docs/screenshots/follow-up-chat.png +0 -0
  28. package/docs/screenshots/follow-up-hero.png +0 -0
  29. package/docs/screenshots/follow-up-large.png +0 -0
  30. package/docs/screenshots/follow-up-mobile.png +0 -0
  31. package/docs/screenshots/follow-up-overview.png +0 -0
  32. package/docs/screenshots/follow-up-pill.png +0 -0
  33. package/docs/screenshots/follow-up-pr.png +0 -0
  34. package/docs/screenshots/follow-up-questions.png +0 -0
  35. package/docs/screenshots/follow-up-states.png +0 -0
  36. package/index.client.tsx +47 -16
  37. package/index.server.ts +53 -0
  38. package/package.json +4 -2
  39. package/paseo-plugin.json +11 -3
  40. package/server/agent-hook.mjs +2 -0
  41. package/server/daemon.ts +37 -6
  42. package/server/discovery.ts +6 -3
  43. package/server/engine.ts +39 -0
  44. package/server/hook-source.ts +1 -1
  45. package/server/mcp-source.ts +1 -1
  46. package/server/mcp.mjs +12 -2
  47. package/server/questions.ts +112 -0
  48. package/server/spec.ts +102 -0
  49. package/shared/activity.ts +56 -0
  50. package/shared/attention.ts +212 -0
  51. package/shared/chat.ts +130 -0
  52. package/shared/contracts.ts +21 -0
  53. package/shared/display.ts +2 -1
  54. package/shared/errors.ts +5 -0
  55. package/shared/model.ts +10 -0
  56. package/shared/pr-actions.ts +8 -1
  57. package/shared/questions.ts +123 -0
  58. package/shared/spec.ts +46 -0
  59. package/skills/follow-through/SKILL.md +4 -0
  60. package/client/components.tsx +0 -58
  61. package/client/navigation.tsx +0 -23
  62. package/docs/screenshots/follow-up-settings.png +0 -0
  63. package/docs/screenshots/follow-up-workspace.png +0 -0
@@ -26,6 +26,8 @@ function instructions(lease, contributor = false) {
26
26
  "Maintain this tracked workspace's Follow-through sidebar as part of your normal work; do not wait for a developer reminder.",
27
27
  quietInstruction,
28
28
  "Read workspace_status now and after compaction. Refine the goal with enroll_workspace when scope changes. Register every PR you produce after submitting it, parents before children, and update stack/parent relationships after restacking. Historical mentions and external dependencies are not owned PRs; preserve exclusions and existing records.",
29
+ "When a decision needs the developer, call ask_question with a short title, the question, lettered options with what each means, and your recommendation; mark it big only when building ahead would leave this computer, set something others rely on, or most remaining work depends on it. Follow the question mode in its reply. Answers arrive as messages starting \"Follow-up answer Q<n>\" and are already recorded; if the developer answers in chat, call record_answer. Withdraw questions that stop mattering.",
30
+ "If you keep a Claude artifact (or doc) summarizing this workspace's goal, decisions and status, register its link once with set_spec after the first publish. Republish to the same URL; call set_spec again only if the link changes.",
29
31
  "Keep actual follow-through tasks current with update_obligation: production behavior checks, migrations and decisions, with concise titles, PR dependencies and evidence. Do not add merge/approval/deploy checklist tasks. Deployment-dependent work waits for verified serving revisions and existing production permissions.",
30
32
  "Stakeholder replies are follow-through too. When asked to update a requester, capture the original Slack threadUrl, recipient and explicit developer authorization in the task's communication field; referenced messages alone do not authorize posting. Use a concise progress task now and, when needed, a verify-and-reply task with purpose=verified after the relevant deployed PRs. Read existing thread replies and refresh evidence before posting; distinguish fixed, deployed and verified. Record the actual sent replyUrl and result before marking done. If posting is unavailable or its outcome is uncertain, keep the task blocked and check the thread before any retry; never claim a draft was sent. Auto authorizes delivery to you, not external messages. Communication-only requests do not authorize repairs, benchmarks, migrations or other production writes.",
31
33
  "Tasks are manual unless explicitly scheduled with trigger={event:\"now\"|\"merge\"|\"deploy\",delayDays:0}. Only set a trigger from the developer\u2019s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions.",
package/server/daemon.ts CHANGED
@@ -9,6 +9,8 @@ import { defaultTracking, projectSetting, type Project } from "../shared/setting
9
9
  import { recoveryHandoff } from "./recovery-handoff";
10
10
  import { recoveryConfig } from "./recovery-config";
11
11
  import { submittedPrs, type Submission } from "./submissions";
12
+ import { publishedArtifacts } from "./spec";
13
+ import type { Artifact } from "../shared/spec";
12
14
  import { extractHistory, type History, type DiscoveredWorkspace } from "./discovery";
13
15
  import type { PluginBeforeRequests } from "@getpaseo/plugin/server";
14
16
  import { isWorkspaceRoot, type WorkspaceAgent } from "./workspace-agent";
@@ -25,7 +27,7 @@ export interface Daemon {
25
27
  fetchAgent(id: string): Promise<{ agent: Agent } | null>;
26
28
  fetchAgents(options?: unknown): Promise<{ entries: { agent: Agent }[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
27
29
  fetchWorkspaces(options?: unknown): Promise<{ entries: WorkspaceRow[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
28
- fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number }[]; startCursor: { epoch: string; seq: number } | null; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
30
+ fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number; timestamp?: string }[]; startCursor: { epoch: string; seq: number } | null; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
29
31
  listProviderSubagents(id: string): Promise<{ subagents: { status: string }[]; error?: string | null }>;
30
32
  sendMessage(id: string, text: string, options: { messageId: string; activeTurnBehavior: "steer" }): Promise<void>;
31
33
  setWorkspaceLabel(input: { workspaceId: string; label: { name: string; color: "emerald" }; assigned: boolean }): Promise<unknown>;
@@ -90,7 +92,7 @@ export class PaseoGateway implements Gateway {
90
92
  private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
91
93
  private dirty = new Set<string>();
92
94
  markDirty(agentId: string): void { this.dirty.add(agentId); }
93
- private histories = new Map<string, { messageAt: string | null; at: number; history: History; submissions: Submission[] }>();
95
+ private histories = new Map<string, { messageAt: string | null; at: number; history: History; submissions: Submission[]; artifacts: Artifact[] }>();
94
96
  async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
95
97
  return this.connect(async client => {
96
98
  const [agents, workspaces, catalog] = await Promise.all([
@@ -130,9 +132,9 @@ export class PaseoGateway implements Gateway {
130
132
  scans.sort((a, b) => (this.histories.get(a.agent.id)?.at ?? 0) - (this.histories.get(b.agent.id)?.at ?? 0) || (b.agent.lastUserMessageAt ?? "").localeCompare(a.agent.lastUserMessageAt ?? ""));
131
133
  // Session opening and Track need only ownership metadata. Read transcripts in the poll.
132
134
  for (const { agent, repo } of scans.slice(0, workspaceId ? 0 : 4)) {
133
- let history: History; let submissions: Submission[] = [];
135
+ let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = [];
134
136
  try {
135
- const entries: { item: unknown; seqStart: number }[] = [];
137
+ const entries: { item: unknown; seqStart: number; timestamp?: string }[] = [];
136
138
  let cursor: { epoch: string; seq: number } | null = null;
137
139
  let complete = false;
138
140
  for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
@@ -143,11 +145,13 @@ export class PaseoGateway implements Gateway {
143
145
  if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
144
146
  cursor = page.startCursor;
145
147
  }
146
- const items = entries.sort((a, b) => a.seqStart - b.seqStart).map(e => e.item);
148
+ entries.sort((a, b) => a.seqStart - b.seqStart);
149
+ const items = entries.map(e => e.item);
150
+ artifacts = publishedArtifacts(entries, agent.id);
147
151
  history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
148
152
  submissions = submittedPrs(items, agent.id, repo);
149
153
  } catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
150
- this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at: Date.now(), history, submissions });
154
+ this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at: Date.now(), history, submissions, artifacts });
151
155
  this.dirty.delete(agent.id);
152
156
  }
153
157
  for (const row of discovered) {
@@ -156,6 +160,7 @@ export class PaseoGateway implements Gateway {
156
160
  if (cached.length) {
157
161
  row.history = { candidates: [...new Set(cached.flatMap(c => c.history.candidates))].sort(), goal: this.histories.get(row.mainAgentId)?.history.goal ?? null, complete: cached.length === ids.length && cached.every(c => c.history.complete), error: cached.find(c => c.history.error)?.history.error ?? null };
158
162
  row.submissions = cached.flatMap(c => c.submissions);
163
+ row.artifacts = cached.flatMap(c => c.artifacts);
159
164
  }
160
165
  }
161
166
  const known = new Set(agents.map(a => a.agent.id));
@@ -242,6 +247,32 @@ export class PaseoGateway implements Gateway {
242
247
  await client.sendMessage(recipient, text, { messageId: batch.messageId, activeTurnBehavior: "steer" });
243
248
  });
244
249
  }
250
+ /**
251
+ * A developer's answer, sent like the developer typing into the agent's chat (it steers an active turn).
252
+ * Session Guard pauses and pending permission prompts defer it; an unavailable agent falls back to the next.
253
+ */
254
+ async deliver(w: Workspace, recipients: string[], text: string, messageId: string): Promise<string> {
255
+ // Anything that fails before the message goes out is safe to retry; only a failed send itself is ambiguous.
256
+ let attempted = false;
257
+ return this.connect(async client => {
258
+ for (const id of recipients) {
259
+ const a = (await client.fetchAgent(id))?.agent;
260
+ if (!a || a.archivedAt || a.workspaceId !== w.id) continue;
261
+ let guarded: boolean;
262
+ try { guarded = !!this.guard.waiting(await this.guard.read(), { agentIds: [id] }); }
263
+ catch { throw new DeliveryDeferred("Session Guard state is unavailable; the answer will go out on the next check."); }
264
+ if (guarded) throw new DeliveryDeferred("Waiting for Session Guard to resume the agent.");
265
+ if (a.pendingPermissions.length) throw new DeliveryDeferred("The agent is waiting on a permission prompt; the answer goes out after it.");
266
+ attempted = true;
267
+ await client.sendMessage(id, text, { messageId, activeTurnBehavior: "steer" });
268
+ return id;
269
+ }
270
+ throw new DeliveryDeferred("No agent in this workspace can take the answer right now.");
271
+ }).catch(error => {
272
+ if (attempted || error instanceof DeliveryDeferred) throw error;
273
+ throw new DeliveryDeferred("Couldn't reach Paseo; the answer goes out on the next check.");
274
+ });
275
+ }
245
276
  async handoff(w: Workspace, callerId: string): Promise<{ batchId: string; fromAgentId: string } | null> {
246
277
  return this.connect(async client => {
247
278
  const caller = (await client.fetchAgent(callerId))?.agent;
@@ -1,8 +1,10 @@
1
1
  import type { Submission } from "./submissions";
2
+ import type { Artifact } from "../shared/spec";
3
+ import { mergeArtifacts } from "./spec";
2
4
  import type { State, Workspace } from "../shared/model";
3
5
 
4
6
  export type History = { candidates: string[]; goal: string | null; complete: boolean; error: string | null };
5
- export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[] };
7
+ export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[] };
6
8
 
7
9
  export function extractHistory(items: readonly unknown[], complete = true, error: string | null = null): History {
8
10
  const candidates = new Set<string>();
@@ -12,7 +14,7 @@ export function extractHistory(items: readonly unknown[], complete = true, error
12
14
  const value = item as { type?: string; text?: string };
13
15
  if (value.type === "reasoning" || value.type === "plugin") continue;
14
16
  const text = typeof value.text === "string" ? value.text : JSON.stringify(item);
15
- if (/^(Workspace follow-through batch|Follow-through batch|Follow-through recovery) /.test(text)) continue;
17
+ if (/^(Workspace follow-through batch|Follow-through batch|Follow-through recovery|Follow-up answer) /.test(text)) continue;
16
18
  if (!goal && value.type === "user_message" && value.text && !value.text.trimStart().startsWith("<"))
17
19
  goal = value.text.trim().replace(/\s+/g, " ").slice(0, 350) || null;
18
20
  for (const match of text.matchAll(/https:\/\/(?:github\.com|app\.graphite\.(?:com|dev)\/github\/pr)\/([\w.-]+)\/([\w.-]+)\/(?:pull\/)?(\d+)(?=[/?#\s)\]"'.,;:<>}]|$)/g))
@@ -26,7 +28,7 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
26
28
  let w = state.workspaces.find(w => w.id === row.id);
27
29
  if (!w) {
28
30
  w = { id: row.id, title: row.title, goal: row.history?.goal ?? row.title, mainAgentId: row.mainAgentId,
29
- enable_tracking: false, enable_auto_sends: false, hold: null, archived: false, prs: [], obligations: [], batches: [], excludedStacks: [], prActions: [], completion: null,
31
+ enable_tracking: false, enable_auto_sends: false, hold: null, archived: false, prs: [], obligations: [], batches: [], excludedStacks: [], prActions: [], questions: [], spec: null, artifacts: [], completion: null,
30
32
  updatedAt: new Date(now).toISOString(), managedDoneLabel: false, runtimeReason: null, runtimeBusy: false, backgroundBusy: false, inventory: null };
31
33
  state.workspaces.push(w);
32
34
  }
@@ -43,6 +45,7 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
43
45
  if (w.goal === row.title && row.history.goal) w.goal = row.history.goal;
44
46
  applyHistory(w, row.history, row.messageAt);
45
47
  }
48
+ if (w.enable_tracking && row.artifacts) mergeArtifacts(w, row.artifacts, now);
46
49
  for (const receipt of row.submissions ?? []) {
47
50
  const key = `${receipt.repo}#${receipt.number}`;
48
51
  if (!w.enable_tracking || w.prs.some(p => p.key === key) ||
package/server/engine.ts CHANGED
@@ -3,6 +3,8 @@ import { completionBlockers, type Workspace, type State, type Batch } from "../s
3
3
  import { Store } from "./store";
4
4
  import { Github } from "./github";
5
5
  import { batchPrompt, tasksFor, isRecovery, needsPrObservations } from "./tasks";
6
+ import { answerRecipients, developerAnswer, syncTaskQuestions } from "./questions";
7
+ import { answerMessage, isOpen as isOpenQuestion, type Question } from "../shared/questions";
6
8
  import { reconcileSubmittedStacks } from "./submissions";
7
9
  import { applyDiscovery, type DiscoveredWorkspace } from "./discovery";
8
10
  import { replyRecorded } from "../shared/communication";
@@ -21,6 +23,8 @@ export interface Gateway {
21
23
  prepareRecovery(w: Workspace, batch: Batch): Promise<{ agentId: string; contextPath: string }>;
22
24
  recoveryRuntime?(w: Workspace, batch: Batch): Promise<{ busy: boolean; reason: string | null }>;
23
25
  label(w: Workspace, assigned: boolean): Promise<void>;
26
+ /** Sends a developer message to the first available recipient; DeliveryDeferred means try again later. */
27
+ deliver?(w: Workspace, recipients: string[], text: string, messageId: string): Promise<string>;
24
28
  }
25
29
 
26
30
  export class Engine {
@@ -42,6 +46,7 @@ export class Engine {
42
46
  }
43
47
 
44
48
  for (const w of state.workspaces) for (const a of w.prActions) if (a.state === "sending") { a.state = "uncertain"; a.result = `Follow-through reloaded before ${a.kind === "request_reviewers" ? "GitHub confirmed the review request" : "Graphite confirmed the action"}. Check state before sending another request.`; }
49
+ for (const w of state.workspaces) for (const q of w.questions) if (q.answer?.delivery.state === "sending") q.answer.delivery = { ...q.answer.delivery, state: "unconfirmed", reason: "Follow-up reloaded before Paseo confirmed the answer. Check the agent's chat before resending." };
45
50
  for (const w of state.workspaces) for (const b of w.batches) if (b.state === "sending") {
46
51
  b.state = "uncertain";
47
52
  b.result = "Delivery was interrupted. Check the assigned agent before retrying this batch.";
@@ -84,6 +89,7 @@ export class Engine {
84
89
  reconcileSubmittedStacks(w);
85
90
  if (w.archived) return;
86
91
  this.reconcile(w);
92
+ syncTaskQuestions(w, this.now());
87
93
  const unacknowledged = w.batches.find(b => b.state === "sent" && !isRecovery(b));
88
94
  if (unacknowledged && (runtime.guarded || runtime.busy || runtime.backgroundBusy || original.runtimeReason?.startsWith("Waiting for Session Guard"))) unacknowledged.activityAt = new Date(this.now()).toISOString();
89
95
  if (unacknowledged && !runtime.busy && !runtime.reason && this.now() - Date.parse(unacknowledged.activityAt ?? unacknowledged.sentAt!) > 120_000) {
@@ -104,6 +110,7 @@ export class Engine {
104
110
  current.state = "blocked"; current.result = current.acknowledgmentError ? `Acknowledgment was refused for agent ${current.acknowledgmentError.agentId}: ${current.acknowledgmentError.error}` : status?.reason ?? "Recovery finished without an accepted acknowledgment. Check its result before retrying.";
105
111
  });
106
112
  }
113
+ for (const q of findWorkspace(await this.store.snapshot(), original.id).questions.filter(q => q.answer?.delivery.state === "queued")) await this.deliverAnswer(original.id, q.id).catch(() => undefined);
107
114
  let current = findWorkspace(await this.store.snapshot(), original.id);
108
115
  const pending = current.batches.find(b => b.state === "pending" && !isRecovery(b));
109
116
  if (current.enable_auto_sends && !current.archived && !current.hold && pending &&
@@ -134,6 +141,38 @@ export class Engine {
134
141
  void this.tick();
135
142
  return activated;
136
143
  }
144
+ /** Records the developer's answer, then sends it unless they chose to copy it. */
145
+ async answer(workspaceId: string, questionId: string, choice: string, words: string | undefined, action: "send" | "copy" | "resend"): Promise<{ workspace: Workspace; question: Question }> {
146
+ await this.store.mutate(state => {
147
+ const w = findWorkspace(state, workspaceId);
148
+ if (!w.enable_tracking || w.archived) throw new Error("Track this workspace before answering its questions.");
149
+ developerAnswer(w, questionId, choice, words, action, this.now());
150
+ if (!w.questions.some(isOpenQuestion)) w.updatedAt = new Date(this.now()).toISOString();
151
+ });
152
+ if (action !== "copy") await this.deliverAnswer(workspaceId, questionId);
153
+ const workspace = findWorkspace(await this.store.snapshot(), workspaceId);
154
+ return { workspace, question: workspace.questions.find(q => q.id === questionId)! };
155
+ }
156
+ private async deliverAnswer(workspaceId: string, questionId: string): Promise<void> {
157
+ const w = findWorkspace(await this.store.snapshot(), workspaceId);
158
+ const q = w.questions.find(q => q.id === questionId);
159
+ if (!q?.answer?.delivery.messageId || !["sending", "queued"].includes(q.answer.delivery.state)) return;
160
+ let outcome: { state: "sent" | "queued" | "unconfirmed"; to: string | null; reason: string | null };
161
+ try {
162
+ if (!this.gateway.deliver) throw new DeliveryDeferred("Messaging is unavailable in this Paseo version; copy the answer instead.");
163
+ const to = await this.gateway.deliver(w, answerRecipients(w, q), answerMessage(q, q.answer.choice, q.answer.words), q.answer.delivery.messageId);
164
+ outcome = { state: "sent", to, reason: null };
165
+ } catch (error) {
166
+ outcome = error instanceof DeliveryDeferred
167
+ ? { state: "queued", to: null, reason: error.message }
168
+ : { state: "unconfirmed", to: null, reason: "Paseo didn't confirm the message. Check the agent's chat before resending." };
169
+ }
170
+ await this.store.mutate(state => {
171
+ const current = findWorkspace(state, workspaceId).questions.find(x => x.id === questionId);
172
+ if (!current?.answer || current.answer.delivery.messageId !== q.answer!.delivery.messageId) return;
173
+ current.answer.delivery = { ...current.answer.delivery, ...outcome, at: new Date(this.now()).toISOString() };
174
+ });
175
+ }
137
176
  async sendPending(workspaceId: string, batchId: string): Promise<Workspace> {
138
177
  const before = findWorkspace(await this.store.snapshot(), workspaceId).batches.find(b => b.id === batchId);
139
178
  if (before && isRecovery(before)) return this.sendRecoveryPending(workspaceId, batchId);
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build.mjs.
2
- export const hookSource = "#!/usr/bin/env node\nimport { readFile, writeFile, mkdir, rename } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createHash, randomUUID } from \"node:crypto\";\n\nconst directory = dirname(fileURLToPath(import.meta.url));\nconst quietRevision = 1;\nconst quietInstruction = \"Do routine bookkeeping silently. Do not announce tracker calls, say you updated/synced the sidebar, recap panel state, or add plugin-status commentary/final paragraphs. Put receipts and evidence in the tools. User-facing messages should cover the actual work outcome, or a decision/blocker requiring the developer; answer sidebar status only when explicitly asked. If this continuation is bookkeeping only, use the tools and finish without another recap or repeating your previous answer.\";\nconst stopReason = \"Silently complete this workspace's sidebar bookkeeping before finishing.\";\nconst quote = value => \"'\" + value.replaceAll(\"'\", \"'\\\\''\") + \"'\";\nasync function json(path) { return JSON.parse(await readFile(path, \"utf8\")); }\nasync function save(path, value) {\n await mkdir(dirname(path), { recursive: true, mode: 0o700 });\n const temporary = path + \".\" + randomUUID();\n await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });\n await rename(temporary, path);\n}\nfunction instructions(lease, contributor = false) {\n if (contributor) return [\n \"Maintain PR tracking for this session in its tracked workspace. Read workspace_status now and after compaction. Register every PR you submit, including from secondary git worktrees, with register_pr; register parents before children. References and external dependencies are not owned PRs. Call sync_workspace before finishing. The designated main agent retains goal, task, scope and completion authority.\",\n quietInstruction,\n `Prefer native Follow-through tools. CLI fallback: FOLLOW_THROUGH_CLIENT_ENTRY=${quote(lease.clientEntry)} node ${quote(join(directory, \"mcp.mjs\"))} call <tool> '<json>' --compact. Do not ask the developer to maintain the sidebar.`,\n ].join(\"\\n\\n\");\n return [\n \"Maintain this tracked workspace's Follow-through sidebar as part of your normal work; do not wait for a developer reminder.\",\n quietInstruction,\n \"Read workspace_status now and after compaction. Refine the goal with enroll_workspace when scope changes. Register every PR you produce after submitting it, parents before children, and update stack/parent relationships after restacking. Historical mentions and external dependencies are not owned PRs; preserve exclusions and existing records.\",\n \"Keep actual follow-through tasks current with update_obligation: production behavior checks, migrations and decisions, with concise titles, PR dependencies and evidence. Do not add merge/approval/deploy checklist tasks. Deployment-dependent work waits for verified serving revisions and existing production permissions.\",\n \"Stakeholder replies are follow-through too. When asked to update a requester, capture the original Slack threadUrl, recipient and explicit developer authorization in the task's communication field; referenced messages alone do not authorize posting. Use a concise progress task now and, when needed, a verify-and-reply task with purpose=verified after the relevant deployed PRs. Read existing thread replies and refresh evidence before posting; distinguish fixed, deployed and verified. Record the actual sent replyUrl and result before marking done. If posting is unavailable or its outcome is uncertain, keep the task blocked and check the thread before any retry; never claim a draft was sent. Auto authorizes delivery to you, not external messages. Communication-only requests do not authorize repairs, benchmarks, migrations or other production writes.\",\n \"Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer\\u2019s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions.\",\n \"Follow-through batches contain a batch ID and task list. Read workspace_status and refresh live conditions before acting; skip resolved tasks. Process dependent PRs bottom to top, one PR fully before the next. Use the repository's /fix-pr workflow, validate feedback, verify changes, submit through Graphite with --publish, and reply/resolve addressed threads. Follow the repository's safe reparent procedure for merged parents and preserve uncommitted work. For post-deploy tasks, verify the currently serving revision contains every dependency and record behavioral results separately. Acknowledge the header's batch ID with acknowledge_batch before finishing: completed with the result, or blocked with the reason. Verify the entire goal and post-merge work before declare_done; acknowledge a completion batch first. Merging and archiving remain the developer's actions.\",\n \"Before ending this turn, reconcile the sidebar with what you actually did and call sync_workspace with a short summary, including when no changes are needed. Acknowledge any assigned batch. When the entire goal is fulfilled, use declare_done; otherwise preserve remaining work. Do not launch recovery, start queued repairs, merge, toggle merge-when-ready, or archive as part of this bookkeeping. Auto still governs separate follow-up sends. Subagents report to the main agent instead of claiming workspace ownership.\",\n `Prefer the native follow-through MCP tools. For a full historical record, use workspace_status with detail=full. CLI compatibility fallback: FOLLOW_THROUGH_CLIENT_ENTRY=${quote(lease.clientEntry)} node ${quote(join(directory, \"mcp.mjs\"))} call <tool> '<json>' --compact. Omit --compact only when an existing script needs the full response. Never ask the developer to maintain the sidebar or run this bridge.`,\n ].join(\"\\n\\n\");\n}\nasync function main() {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId || !/^[\\w-]+$/.test(agentId) || process.env.FOLLOW_THROUGH_RECOVERY === \"1\") return;\n let input = \"\";\n for await (const chunk of process.stdin) { input += chunk; if (input.length > 2_000_000) return; }\n const event = JSON.parse(input);\n if (event.agent_id || event.hook_event_name === \"SubagentStop\") return;\n const lease = await json(join(directory, \"hooks-enabled.json\"));\n if (lease.expiresAt < Date.now() || !lease.clientEntry) return;\n const state = await json(join(directory, \"state.json\"));\n const workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived);\n if (!workspace) return;\n const contributor = workspace.mainAgentId !== agentId;\n const context = () => instructions(lease, contributor);\n const name = event.hook_event_name;\n if (![\"SessionStart\", \"UserPromptSubmit\", \"PostToolUse\", \"Stop\"].includes(name)) return;\n const session = createHash(\"sha256\").update(String(event.session_id ?? agentId)).digest(\"hex\").slice(0, 24);\n const path = join(directory, \"hook-state\", `${agentId}-${session}.json`);\n const turn = await json(path).catch(() => null);\n const emit = text => process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: name, additionalContext: text } }) + \"\\n\");\n if (name === \"SessionStart\") { emit(context()); return; }\n if (name === \"UserPromptSubmit\") {\n if (turn?.stopped && String(event.prompt ?? \"\").startsWith(stopReason)) return;\n await save(path, { startedAt: Date.now(), dirtyAt: null, stopped: false, quietRevision });\n emit(context()); return;\n }\n if (name === \"PostToolUse\") {\n const tool = String(event.tool_name ?? \"\");\n const args = JSON.stringify(event.tool_input ?? {});\n const trackedTool = /follow[\\s_-]*through/i.test(tool) || args.includes(join(directory, \"mcp.mjs\"));\n const dirty = !trackedTool && /(bash|shell|exec_command|write_stdin|edit|write|apply_patch|notebook)/i.test(tool);\n const upgrade = turn?.quietRevision !== quietRevision;\n if (dirty || upgrade) await save(path, { ...turn, startedAt: turn?.startedAt ?? (dirty ? Date.now() : 0), stopped: turn?.stopped ?? false, ...(dirty ? { dirtyAt: Date.now() } : {}), quietRevision });\n if (upgrade) emit(quietInstruction);\n return;\n }\n if (event.stop_hook_active || turn?.stopped || event.background_tasks?.length) return;\n const sync = await json(join(directory, \"hook-state\", `sync-${agentId}.json`)).catch(() => null);\n if (turn && sync?.workspaceId === workspace.id && sync.at >= Math.max(turn.startedAt, turn.dirtyAt ?? 0)) return;\n await save(path, { ...turn, startedAt: turn?.startedAt ?? Date.now(), stopped: true });\n process.stdout.write(JSON.stringify({ decision: \"block\", reason: stopReason + \" This is metadata bookkeeping only; do not start queued repairs or recovery.\\n\\n\" + context() }) + \"\\n\");\n}\nmain().catch(() => {});\n";
2
+ export const hookSource = "#!/usr/bin/env node\nimport { readFile, writeFile, mkdir, rename } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { createHash, randomUUID } from \"node:crypto\";\n\nconst directory = dirname(fileURLToPath(import.meta.url));\nconst quietRevision = 1;\nconst quietInstruction = \"Do routine bookkeeping silently. Do not announce tracker calls, say you updated/synced the sidebar, recap panel state, or add plugin-status commentary/final paragraphs. Put receipts and evidence in the tools. User-facing messages should cover the actual work outcome, or a decision/blocker requiring the developer; answer sidebar status only when explicitly asked. If this continuation is bookkeeping only, use the tools and finish without another recap or repeating your previous answer.\";\nconst stopReason = \"Silently complete this workspace's sidebar bookkeeping before finishing.\";\nconst quote = value => \"'\" + value.replaceAll(\"'\", \"'\\\\''\") + \"'\";\nasync function json(path) { return JSON.parse(await readFile(path, \"utf8\")); }\nasync function save(path, value) {\n await mkdir(dirname(path), { recursive: true, mode: 0o700 });\n const temporary = path + \".\" + randomUUID();\n await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });\n await rename(temporary, path);\n}\nfunction instructions(lease, contributor = false) {\n if (contributor) return [\n \"Maintain PR tracking for this session in its tracked workspace. Read workspace_status now and after compaction. Register every PR you submit, including from secondary git worktrees, with register_pr; register parents before children. References and external dependencies are not owned PRs. Call sync_workspace before finishing. The designated main agent retains goal, task, scope and completion authority.\",\n quietInstruction,\n `Prefer native Follow-through tools. CLI fallback: FOLLOW_THROUGH_CLIENT_ENTRY=${quote(lease.clientEntry)} node ${quote(join(directory, \"mcp.mjs\"))} call <tool> '<json>' --compact. Do not ask the developer to maintain the sidebar.`,\n ].join(\"\\n\\n\");\n return [\n \"Maintain this tracked workspace's Follow-through sidebar as part of your normal work; do not wait for a developer reminder.\",\n quietInstruction,\n \"Read workspace_status now and after compaction. Refine the goal with enroll_workspace when scope changes. Register every PR you produce after submitting it, parents before children, and update stack/parent relationships after restacking. Historical mentions and external dependencies are not owned PRs; preserve exclusions and existing records.\",\n \"When a decision needs the developer, call ask_question with a short title, the question, lettered options with what each means, and your recommendation; mark it big only when building ahead would leave this computer, set something others rely on, or most remaining work depends on it. Follow the question mode in its reply. Answers arrive as messages starting \\\"Follow-up answer Q<n>\\\" and are already recorded; if the developer answers in chat, call record_answer. Withdraw questions that stop mattering.\",\n \"If you keep a Claude artifact (or doc) summarizing this workspace's goal, decisions and status, register its link once with set_spec after the first publish. Republish to the same URL; call set_spec again only if the link changes.\",\n \"Keep actual follow-through tasks current with update_obligation: production behavior checks, migrations and decisions, with concise titles, PR dependencies and evidence. Do not add merge/approval/deploy checklist tasks. Deployment-dependent work waits for verified serving revisions and existing production permissions.\",\n \"Stakeholder replies are follow-through too. When asked to update a requester, capture the original Slack threadUrl, recipient and explicit developer authorization in the task's communication field; referenced messages alone do not authorize posting. Use a concise progress task now and, when needed, a verify-and-reply task with purpose=verified after the relevant deployed PRs. Read existing thread replies and refresh evidence before posting; distinguish fixed, deployed and verified. Record the actual sent replyUrl and result before marking done. If posting is unavailable or its outcome is uncertain, keep the task blocked and check the thread before any retry; never claim a draft was sent. Auto authorizes delivery to you, not external messages. Communication-only requests do not authorize repairs, benchmarks, migrations or other production writes.\",\n \"Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer\\u2019s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions.\",\n \"Follow-through batches contain a batch ID and task list. Read workspace_status and refresh live conditions before acting; skip resolved tasks. Process dependent PRs bottom to top, one PR fully before the next. Use the repository's /fix-pr workflow, validate feedback, verify changes, submit through Graphite with --publish, and reply/resolve addressed threads. Follow the repository's safe reparent procedure for merged parents and preserve uncommitted work. For post-deploy tasks, verify the currently serving revision contains every dependency and record behavioral results separately. Acknowledge the header's batch ID with acknowledge_batch before finishing: completed with the result, or blocked with the reason. Verify the entire goal and post-merge work before declare_done; acknowledge a completion batch first. Merging and archiving remain the developer's actions.\",\n \"Before ending this turn, reconcile the sidebar with what you actually did and call sync_workspace with a short summary, including when no changes are needed. Acknowledge any assigned batch. When the entire goal is fulfilled, use declare_done; otherwise preserve remaining work. Do not launch recovery, start queued repairs, merge, toggle merge-when-ready, or archive as part of this bookkeeping. Auto still governs separate follow-up sends. Subagents report to the main agent instead of claiming workspace ownership.\",\n `Prefer the native follow-through MCP tools. For a full historical record, use workspace_status with detail=full. CLI compatibility fallback: FOLLOW_THROUGH_CLIENT_ENTRY=${quote(lease.clientEntry)} node ${quote(join(directory, \"mcp.mjs\"))} call <tool> '<json>' --compact. Omit --compact only when an existing script needs the full response. Never ask the developer to maintain the sidebar or run this bridge.`,\n ].join(\"\\n\\n\");\n}\nasync function main() {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId || !/^[\\w-]+$/.test(agentId) || process.env.FOLLOW_THROUGH_RECOVERY === \"1\") return;\n let input = \"\";\n for await (const chunk of process.stdin) { input += chunk; if (input.length > 2_000_000) return; }\n const event = JSON.parse(input);\n if (event.agent_id || event.hook_event_name === \"SubagentStop\") return;\n const lease = await json(join(directory, \"hooks-enabled.json\"));\n if (lease.expiresAt < Date.now() || !lease.clientEntry) return;\n const state = await json(join(directory, \"state.json\"));\n const workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived);\n if (!workspace) return;\n const contributor = workspace.mainAgentId !== agentId;\n const context = () => instructions(lease, contributor);\n const name = event.hook_event_name;\n if (![\"SessionStart\", \"UserPromptSubmit\", \"PostToolUse\", \"Stop\"].includes(name)) return;\n const session = createHash(\"sha256\").update(String(event.session_id ?? agentId)).digest(\"hex\").slice(0, 24);\n const path = join(directory, \"hook-state\", `${agentId}-${session}.json`);\n const turn = await json(path).catch(() => null);\n const emit = text => process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: name, additionalContext: text } }) + \"\\n\");\n if (name === \"SessionStart\") { emit(context()); return; }\n if (name === \"UserPromptSubmit\") {\n if (turn?.stopped && String(event.prompt ?? \"\").startsWith(stopReason)) return;\n await save(path, { startedAt: Date.now(), dirtyAt: null, stopped: false, quietRevision });\n emit(context()); return;\n }\n if (name === \"PostToolUse\") {\n const tool = String(event.tool_name ?? \"\");\n const args = JSON.stringify(event.tool_input ?? {});\n const trackedTool = /follow[\\s_-]*through/i.test(tool) || args.includes(join(directory, \"mcp.mjs\"));\n const dirty = !trackedTool && /(bash|shell|exec_command|write_stdin|edit|write|apply_patch|notebook)/i.test(tool);\n const upgrade = turn?.quietRevision !== quietRevision;\n if (dirty || upgrade) await save(path, { ...turn, startedAt: turn?.startedAt ?? (dirty ? Date.now() : 0), stopped: turn?.stopped ?? false, ...(dirty ? { dirtyAt: Date.now() } : {}), quietRevision });\n if (upgrade) emit(quietInstruction);\n return;\n }\n if (event.stop_hook_active || turn?.stopped || event.background_tasks?.length) return;\n const sync = await json(join(directory, \"hook-state\", `sync-${agentId}.json`)).catch(() => null);\n if (turn && sync?.workspaceId === workspace.id && sync.at >= Math.max(turn.startedAt, turn.dirtyAt ?? 0)) return;\n await save(path, { ...turn, startedAt: turn?.startedAt ?? Date.now(), stopped: true });\n process.stdout.write(JSON.stringify({ decision: \"block\", reason: stopReason + \" This is metadata bookkeeping only; do not start queued repairs or recovery.\\n\\n\" + context() }) + \"\\n\");\n}\nmain().catch(() => {});\n";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/build.mjs.
2
- export const mcpSource = "#!/usr/bin/env node\nimport { createInterface } from \"node:readline\";\nimport { createRequire } from \"node:module\";\nimport { pathToFileURL } from \"node:url\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { join, delimiter } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nconst string = { type: \"string\" };\nconst boolean = { type: \"boolean\" };\nconst integer = { type: \"integer\", minimum: 1 };\nconst array = { type: \"array\", items: string };\nconst communication = { type: \"object\", properties: { threadUrl: string, recipient: string, purpose: { type: \"string\", enum: [\"progress\", \"verified\"] }, authorization: string, replyUrl: { type: [\"string\", \"null\"] } }, required: [\"threadUrl\", \"recipient\"], additionalProperties: false };\nconst trigger = { type: [\"object\", \"null\"], properties: { event: { type: \"string\", enum: [\"now\", \"merge\", \"deploy\"] }, delayDays: { type: \"integer\", minimum: 0, maximum: 3650 } }, required: [\"event\"], additionalProperties: false };\nconst tools = [\n [\"workspace_status\", \"Read the tracked workspace's durable goal, PRs, tasks, work queue and optional recovery history. Read after compaction. Returns current work by default; detail=full includes complete evidence/history. Normal follow-through does not require recovery.\", \"follow.agent-status\", { detail: { type: \"string\", enum: [\"summary\", \"full\"] } }, []],\n [\"sync_workspace\", \"Silently confirm the sidebar matches your work before finishing, after registering owned PRs and substantive tasks. Call even when no changes are needed. Keep the summary in this tool; never announce sync or recap sidebar/plugin status to the developer. Does not declare Done, run recovery, or start repairs. Tracked root agents only.\", \"follow.sync\", { summary: { ...string, minLength: 1, maxLength: 1000 } }, [\"summary\"]],\n [\"enroll_workspace\", \"Refine this developer-activated workspace's goal. Ownership is initialized by Track this workspace or the project’s default-tracking setting. This tool cannot activate another workspace. Never ask the developer to enter this. Keep the goal concise; new scope reopens completion.\", \"follow.enroll\", { goal: string }, [\"goal\"]],\n [\"register_pr\", \"Register a PR produced by this workspace. Call after every submission. Register parents first; repeat to update its stack or parent after restacking. Supply a disposition for a deliberately closed PR.\", \"follow.register-pr\", { repo: string, number: integer, stack: string, parentNumber: { type: [\"integer\", \"null\"], minimum: 1 }, disposition: string }, [\"repo\", \"number\", \"stack\"]],\n [\"exclude_stack\", \"Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.\", \"follow.exclude-stack\", { stack: string, reason: string, obligationIds: array }, [\"stack\", \"reason\"]],\n [\"update_obligation\", \"Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.\", \"follow.obligation\", { id: string, title: string, status: { type: \"string\", enum: [\"pending\", \"waiting\", \"working\", \"blocked\", \"done\", \"waived\"] }, afterPrs: array, dependencyStage: { type: \"string\", enum: [\"merged\", \"deployed\"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, [\"title\", \"status\"]],\n [\"acknowledge_batch\", \"Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.\", \"follow.acknowledge\", { batchId: string, outcome: { type: \"string\", enum: [\"completed\", \"blocked\"] }, result: { ...string, maxLength: 2000 } }, [\"batchId\", \"outcome\", \"result\"]],\n [\"declare_done\", \"Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.\", \"follow.done\", { summary: string }, [\"summary\"]],\n];\n\nfunction toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === \"1\", contributor = false) {\n return tools.filter(t => contributor ? [\"workspace_status\", \"register_pr\", \"sync_workspace\"].includes(t[0]) : !recovery || ![\"declare_done\", \"sync_workspace\"].includes(t[0]));\n}\nconst schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: \"object\", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === \"workspace_status\", destructiveHint: false } }));\nasync function scope(resolveRecovery = false) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) return null;\n try {\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const directory = join(home, \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return null;\n const state = JSON.parse(await readFile(join(directory, \"state.json\"), \"utf8\"));\n for (const w of state.workspaces) {\n if (!w.enable_tracking || w.archived) continue;\n if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === \"1\" ? \"recovery\" : \"main\";\n if (w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n if (w.contributorAgentIds?.includes(agentId)) return \"contributor\";\n }\n if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === \"1\") {\n const w = await call(\"follow.agent-status\", {});\n if (w.enable_tracking && !w.archived && w.mainAgentId !== agentId && w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n }\n } catch {}\n return null;\n}\nfunction validate(value, schema, path = \"arguments\") {\n const types = Array.isArray(schema.type) ? schema.type : [schema.type];\n const type = value === null ? \"null\" : Array.isArray(value) ? \"array\" : typeof value;\n if (!types.includes(type) && !(type === \"number\" && types.includes(\"integer\") && Number.isInteger(value))) throw new Error(`${path}: expected ${types.join(\" or \")}.`);\n if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: choose ${schema.enum.join(\", \")}.`);\n if (type === \"object\") {\n const extra = Object.keys(value).filter(k => !Object.hasOwn(schema.properties ?? {}, k));\n if (extra.length && schema.additionalProperties === false) throw new Error(`Unknown argument ${path}.${extra.join(\", \")}. ${extra.includes(\"dependsOnPrs\") ? \"Use afterPrs with owner/repo#number keys.\" : \"Check this tool's input schema with list.\"}`);\n for (const key of schema.required ?? []) if (!(key in value)) throw new Error(`${path}.${key} is required. Check this tool's input schema with list.`);\n for (const [key, nested] of Object.entries(value)) if (schema.properties?.[key]) validate(nested, schema.properties[key], `${path}.${key}`);\n }\n if (type === \"array\") for (let i = 0; i < value.length; i++) validate(value[i], schema.items, `${path}[${i}]`);\n if (type === \"number\" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: outside the allowed range.`);\n if (type === \"string\" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) throw new Error(`${path}: outside the allowed length.`);\n}\nfunction currentWork(w) {\n return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,\n prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),\n obligations: w.obligations.filter(o => ![\"done\", \"waived\"].includes(o.status)), completedObligations: w.obligations.filter(o => [\"done\", \"waived\"].includes(o.status)).length,\n batches: w.batches.filter(b => ![\"completed\", \"cancelled\"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),\n excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: \"summary\" };\n}\nfunction compact(tool, input, result) {\n if (tool === \"workspace_status\") return currentWork(result);\n if (tool === \"declare_done\") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };\n if (!result?.prs) return result;\n const workspaceId = result.id;\n if (tool === \"enroll_workspace\") return { workspaceId, goal: result.goal };\n if (tool === \"register_pr\") { const p = result.prs.find(p => p.key === `${input.repo}#${input.number}`); return { workspaceId, pr: { key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition } }; }\n if (tool === \"update_obligation\") return { workspaceId, obligation: result.obligations.find(o => o.id === result.updatedObligationId) };\n if (tool === \"acknowledge_batch\") { const b = result.batches.find(b => b.id === input.batchId); return { workspaceId, batch: { id: b.id, state: b.state, result: b.result } }; }\n if (tool === \"exclude_stack\") { const s = result.excludedStacks.find(s => s.name === input.stack); return { workspaceId, excludedStack: { name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) } }; }\n return result;\n}\nasync function loadClient() {\n const candidates = [];\n if (process.env.FOLLOW_THROUGH_CLIENT_ENTRY) candidates.push(() => pathToFileURL(process.env.FOLLOW_THROUGH_CLIENT_ENTRY).href);\n const specifier = \"@getpaseo/client/internal/daemon-client\";\n if (process.resourcesPath) candidates.push(() => pathToFileURL(createRequire(join(process.resourcesPath, \"app.asar/node_modules/@getpaseo/cli/dist/index.js\")).resolve(specifier)).href);\n for (const directory of (process.env.PATH ?? \"\").split(delimiter)) {\n const cli = join(directory, process.platform === \"win32\" ? \"paseo.cmd\" : \"paseo\");\n if (existsSync(cli)) candidates.push(() => pathToFileURL(createRequire(realpathSync(cli)).resolve(specifier)).href);\n }\n candidates.push(() => pathToFileURL(createRequire(import.meta.url).resolve(specifier)).href);\n for (const resolve of candidates) try { return await import(resolve()); } catch {}\n throw new Error(\"Cannot load the installed Paseo client. Reload Follow-through to refresh its runtime connection.\");\n}\nconst batchInstructions = \"Follow-through batches contain a header with the batchId and ordered tasks. Read workspace_status and refresh live conditions; skip resolved tasks. Process PRs bottom to top, one fully before the next, using the repository's /fix-pr and safe reparent workflow. Validate feedback, verify changes, submit with Graphite --publish, reply/resolve addressed threads and preserve uncommitted work. Post-deploy tasks require verified serving revisions and existing production permissions. Stakeholder update requests are follow-through tasks: retain the original thread, recipient and developer authorization in communication. Use current evidence, distinguish fixed/deployed/verified, and read existing replies before posting. Record the sent replyUrl; a draft or uncertain send stays blocked and must be checked before retrying. Auto authorizes agent delivery only. Acknowledge the header's batchId with acknowledge_batch before finishing (completed with the result, or blocked with the reason), before declare_done for completion batches. Declare Done only after the whole goal and post-merge work are verified. Leave merging and archiving to the developer.\";\n\nasync function call(method, input) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) throw new Error(\"This bridge must run inside a Paseo agent (PASEO_AGENT_ID is missing).\");\n const { DaemonClient } = await loadClient();\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const config = JSON.parse(await readFile(join(home, \"config.json\"), \"utf8\"));\n let password = process.env.PASEO_PASSWORD;\n if (!password && process.env.PASEO_PASSWORD_FILE) password = (await readFile(process.env.PASEO_PASSWORD_FILE, \"utf8\")).trim();\n if (!password && home === join(homedir(), \".paseo\")) password = await readFile(join(homedir(), \"paseo-hub/secrets/daemon-password\"), \"utf8\").then(s => s.trim()).catch(() => undefined);\n const client = new DaemonClient({ url: process.env.PASEO_DAEMON_URL ?? `ws://${config.daemon?.listen ?? \"127.0.0.1:6767\"}/ws`, clientId: \"follow-through-mcp\", clientType: \"cli\", reconnect: { enabled: false }, connectTimeoutMs: 10000, suppressSendErrors: true, ...(password ? { password } : {}) });\n try { await client.connect(); return await client.invokePluginRpc(\"follow-through\", method, { ...input, agentId }); }\n finally { await client.close().catch(() => undefined); }\n}\n\nconst command = process.argv[2];\nif (command === \"list\" || command === \"--help\" || command === \"help\") {\n process.stdout.write(command === \"list\" ? JSON.stringify({ tools: schemas(toolList()) }, null, 2) + \"\\n\" : \"Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\\n\");\n} else if (command === \"call\") {\n try {\n const tool = toolList().find(t => t[0] === process.argv[3]);\n if (!tool) throw new Error(\"Unknown tool. Run list for names and input schemas.\");\n const input = JSON.parse(process.argv[4] ?? \"{}\");\n validate(input, schemas([tool])[0].inputSchema);\n if (process.argv.slice(5).some(arg => arg !== \"--compact\")) throw new Error(\"Unknown CLI option. Use --compact or omit it for a full reply.\");\n const result = await call(tool[2], input);\n process.stdout.write(JSON.stringify(process.argv.includes(\"--compact\") && input.detail !== \"full\" ? compact(tool[0], input, result) : result, null, 2) + \"\\n\");\n } catch (error) { console.error(error.message); process.exitCode = 1; }\n} else if (command) {\n console.error(\"Unknown command. Use list, --help, or call <tool> '<json>'.\"); process.exitCode = 1;\n} else {\n const lines = createInterface({ input: process.stdin });\n let tail = Promise.resolve(), initialized = false, lastScope = null;\n const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {\n void (async () => { const current = await scope(); if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/tools/list_changed\" }) + \"\\n\"); lastScope = current; })().catch(() => {});\n }, 2000) : null;\n timer?.unref(); lines.on(\"close\", () => { if (timer) clearInterval(timer); });\n lines.on(\"line\", line => {\n tail = tail.then(async () => {\n let request;\n try { request = JSON.parse(line); } catch { return; }\n if (request.id === undefined) return;\n try {\n let result;\n const role = await scope(request.method === \"initialize\" || request.method === \"tools/list\");\n const available = role ? toolList(role === \"recovery\", role === \"contributor\") : [];\n if (request.method === \"initialize\") {\n initialized = true; lastScope = role;\n result = { protocolVersion: request.params?.protocolVersion ?? \"2024-11-05\", capabilities: { tools: { listChanged: true } }, serverInfo: { name: \"paseo-follow-through\", version: \"0.3.5\" }, instructions: role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\" };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], input);\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
2
+ export const mcpSource = "#!/usr/bin/env node\nimport { createInterface } from \"node:readline\";\nimport { createRequire } from \"node:module\";\nimport { pathToFileURL } from \"node:url\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { join, delimiter } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nconst string = { type: \"string\" };\nconst boolean = { type: \"boolean\" };\nconst integer = { type: \"integer\", minimum: 1 };\nconst array = { type: \"array\", items: string };\nconst communication = { type: \"object\", properties: { threadUrl: string, recipient: string, purpose: { type: \"string\", enum: [\"progress\", \"verified\"] }, authorization: string, replyUrl: { type: [\"string\", \"null\"] } }, required: [\"threadUrl\", \"recipient\"], additionalProperties: false };\nconst trigger = { type: [\"object\", \"null\"], properties: { event: { type: \"string\", enum: [\"now\", \"merge\", \"deploy\"] }, delayDays: { type: \"integer\", minimum: 0, maximum: 3650 } }, required: [\"event\"], additionalProperties: false };\nconst tools = [\n [\"workspace_status\", \"Read the tracked workspace's durable goal, PRs, tasks, work queue and optional recovery history. Read after compaction. Returns current work by default; detail=full includes complete evidence/history. Normal follow-through does not require recovery.\", \"follow.agent-status\", { detail: { type: \"string\", enum: [\"summary\", \"full\"] } }, []],\n [\"sync_workspace\", \"Silently confirm the sidebar matches your work before finishing, after registering owned PRs and substantive tasks. Call even when no changes are needed. Keep the summary in this tool; never announce sync or recap sidebar/plugin status to the developer. Does not declare Done, run recovery, or start repairs. Tracked root agents only.\", \"follow.sync\", { summary: { ...string, minLength: 1, maxLength: 1000 } }, [\"summary\"]],\n [\"enroll_workspace\", \"Refine this developer-activated workspace's goal. Ownership is initialized by Track this workspace or the project’s default-tracking setting. This tool cannot activate another workspace. Never ask the developer to enter this. Keep the goal concise; new scope reopens completion.\", \"follow.enroll\", { goal: string }, [\"goal\"]],\n [\"register_pr\", \"Register a PR produced by this workspace. Call after every submission. Register parents first; repeat to update its stack or parent after restacking. Supply a disposition for a deliberately closed PR.\", \"follow.register-pr\", { repo: string, number: integer, stack: string, parentNumber: { type: [\"integer\", \"null\"], minimum: 1 }, disposition: string }, [\"repo\", \"number\", \"stack\"]],\n [\"exclude_stack\", \"Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.\", \"follow.exclude-stack\", { stack: string, reason: string, obligationIds: array }, [\"stack\", \"reason\"]],\n [\"update_obligation\", \"Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.\", \"follow.obligation\", { id: string, title: string, status: { type: \"string\", enum: [\"pending\", \"waiting\", \"working\", \"blocked\", \"done\", \"waived\"] }, afterPrs: array, dependencyStage: { type: \"string\", enum: [\"merged\", \"deployed\"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, [\"title\", \"status\"]],\n [\"acknowledge_batch\", \"Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.\", \"follow.acknowledge\", { batchId: string, outcome: { type: \"string\", enum: [\"completed\", \"blocked\"] }, result: { ...string, maxLength: 2000 } }, [\"batchId\", \"outcome\", \"result\"]],\n [\"ask_question\", \"Ask the developer a decision you can't make yourself. Give a short title, the question, lettered options A, B, C… with what each means for them, and recommend the option you'd pick. Set big only when building ahead would send something off this computer, set something people or programs outside the repo rely on, or most remaining work would build on it. Link a PR (owner/repo#number) or task id when it is about one. The reply says the question mode and exactly what to do until the answer arrives.\", \"follow.ask-question\", { title: { ...string, maxLength: 120 }, question: { ...string, maxLength: 2000 }, options: { type: \"array\", maxItems: 26, items: { type: \"object\", properties: { letter: { type: \"string\", pattern: \"^[A-Z]$\" }, label: { ...string, maxLength: 200 }, consequence: { ...string, maxLength: 600 } }, required: [\"letter\", \"label\"], additionalProperties: false } }, recommend: string, big: string, background: string, links: { type: \"array\", maxItems: 10, items: { type: \"object\", properties: { url: string, path: string, label: string }, additionalProperties: false } }, pr: string, task: string }, [\"title\", \"question\"]],\n [\"record_answer\", \"Record the developer's answer when they answered a question in chat instead of in Follow-up. choice is an option letter, or their short answer when there were no options; words is what they said.\", \"follow.record-answer\", { id: string, choice: string, words: string }, [\"id\", \"choice\"]],\n [\"withdraw_question\", \"Withdraw a question that no longer matters, with a short reason.\", \"follow.withdraw-question\", { id: string, reason: string }, [\"id\", \"reason\"]],\n [\"set_spec\", \"Register the link to this workspace's spec: the Claude artifact (or doc) you keep summarizing the goal, decisions and status. Call it once after the first publish, with the URL from the publish result, and again only if the link changes. Follow-up shows it in the workspace header.\", \"follow.set-spec\", { url: { ...string, maxLength: 2000 }, title: { ...string, maxLength: 120 } }, [\"url\"]],\n [\"declare_done\", \"Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.\", \"follow.done\", { summary: string }, [\"summary\"]],\n];\n\nfunction toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === \"1\", contributor = false) {\n return tools.filter(t => contributor ? [\"workspace_status\", \"register_pr\", \"sync_workspace\"].includes(t[0]) : !recovery || ![\"declare_done\", \"sync_workspace\", \"ask_question\", \"record_answer\", \"withdraw_question\", \"set_spec\"].includes(t[0]));\n}\nconst schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: \"object\", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === \"workspace_status\", destructiveHint: false } }));\nasync function scope(resolveRecovery = false) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) return null;\n try {\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const directory = join(home, \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return null;\n const state = JSON.parse(await readFile(join(directory, \"state.json\"), \"utf8\"));\n for (const w of state.workspaces) {\n if (!w.enable_tracking || w.archived) continue;\n if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === \"1\" ? \"recovery\" : \"main\";\n if (w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n if (w.contributorAgentIds?.includes(agentId)) return \"contributor\";\n }\n if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === \"1\") {\n const w = await call(\"follow.agent-status\", {});\n if (w.enable_tracking && !w.archived && w.mainAgentId !== agentId && w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n }\n } catch {}\n return null;\n}\nfunction validate(value, schema, path = \"arguments\") {\n const types = Array.isArray(schema.type) ? schema.type : [schema.type];\n const type = value === null ? \"null\" : Array.isArray(value) ? \"array\" : typeof value;\n if (!types.includes(type) && !(type === \"number\" && types.includes(\"integer\") && Number.isInteger(value))) throw new Error(`${path}: expected ${types.join(\" or \")}.`);\n if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: choose ${schema.enum.join(\", \")}.`);\n if (type === \"object\") {\n const extra = Object.keys(value).filter(k => !Object.hasOwn(schema.properties ?? {}, k));\n if (extra.length && schema.additionalProperties === false) throw new Error(`Unknown argument ${path}.${extra.join(\", \")}. ${extra.includes(\"dependsOnPrs\") ? \"Use afterPrs with owner/repo#number keys.\" : \"Check this tool's input schema with list.\"}`);\n for (const key of schema.required ?? []) if (!(key in value)) throw new Error(`${path}.${key} is required. Check this tool's input schema with list.`);\n for (const [key, nested] of Object.entries(value)) if (schema.properties?.[key]) validate(nested, schema.properties[key], `${path}.${key}`);\n }\n if (type === \"array\") for (let i = 0; i < value.length; i++) validate(value[i], schema.items, `${path}[${i}]`);\n if (type === \"number\" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: outside the allowed range.`);\n if (type === \"string\" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) throw new Error(`${path}: outside the allowed length.`);\n}\nfunction currentWork(w) {\n return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,\n prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),\n obligations: w.obligations.filter(o => ![\"done\", \"waived\"].includes(o.status)), completedObligations: w.obligations.filter(o => [\"done\", \"waived\"].includes(o.status)).length,\n openQuestions: (w.questions ?? []).filter(q => !q.answer && !q.closed).map(q => ({ id: q.id, title: q.title, question: q.question, options: q.options, recommend: q.recommend, big: q.big, mode: q.mode, askedBy: q.askedBy, taskId: q.taskId })),\n spec: w.spec?.url ? { url: w.spec.url, title: w.spec.title, source: w.spec.source } : null,\n recentAnswers: (w.questions ?? []).filter(q => q.answer).slice(-5).map(q => ({ id: q.id, title: q.title, choice: q.answer.choice, words: q.answer.words, delivery: q.answer.delivery.state })),\n batches: w.batches.filter(b => ![\"completed\", \"cancelled\"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),\n excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: \"summary\" };\n}\nfunction compact(tool, input, result) {\n if (tool === \"workspace_status\") return currentWork(result);\n if (tool === \"declare_done\") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };\n if (tool === \"ask_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, title: result.question.title, recommend: result.question.recommend, big: result.question.big }, mode: result.mode, instruction: result.instruction };\n if (tool === \"record_answer\" || tool === \"withdraw_question\") return { workspaceId: result.workspaceId, question: { id: result.question.id, answer: result.question.answer, closed: result.question.closed } };\n if (tool === \"set_spec\") return { workspaceId: result.workspaceId, spec: result.spec && { url: result.spec.url, title: result.spec.title, source: result.spec.source }, applied: result.applied, message: result.message };\n if (!result?.prs) return result;\n const workspaceId = result.id;\n if (tool === \"enroll_workspace\") return { workspaceId, goal: result.goal };\n if (tool === \"register_pr\") { const p = result.prs.find(p => p.key === `${input.repo}#${input.number}`); return { workspaceId, pr: { key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition } }; }\n if (tool === \"update_obligation\") return { workspaceId, obligation: result.obligations.find(o => o.id === result.updatedObligationId) };\n if (tool === \"acknowledge_batch\") { const b = result.batches.find(b => b.id === input.batchId); return { workspaceId, batch: { id: b.id, state: b.state, result: b.result } }; }\n if (tool === \"exclude_stack\") { const s = result.excludedStacks.find(s => s.name === input.stack); return { workspaceId, excludedStack: { name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) } }; }\n return result;\n}\nasync function loadClient() {\n const candidates = [];\n if (process.env.FOLLOW_THROUGH_CLIENT_ENTRY) candidates.push(() => pathToFileURL(process.env.FOLLOW_THROUGH_CLIENT_ENTRY).href);\n const specifier = \"@getpaseo/client/internal/daemon-client\";\n if (process.resourcesPath) candidates.push(() => pathToFileURL(createRequire(join(process.resourcesPath, \"app.asar/node_modules/@getpaseo/cli/dist/index.js\")).resolve(specifier)).href);\n for (const directory of (process.env.PATH ?? \"\").split(delimiter)) {\n const cli = join(directory, process.platform === \"win32\" ? \"paseo.cmd\" : \"paseo\");\n if (existsSync(cli)) candidates.push(() => pathToFileURL(createRequire(realpathSync(cli)).resolve(specifier)).href);\n }\n candidates.push(() => pathToFileURL(createRequire(import.meta.url).resolve(specifier)).href);\n for (const resolve of candidates) try { return await import(resolve()); } catch {}\n throw new Error(\"Cannot load the installed Paseo client. Reload Follow-through to refresh its runtime connection.\");\n}\nconst batchInstructions = \"Follow-through batches contain a header with the batchId and ordered tasks. Read workspace_status and refresh live conditions; skip resolved tasks. Process PRs bottom to top, one fully before the next, using the repository's /fix-pr and safe reparent workflow. Validate feedback, verify changes, submit with Graphite --publish, reply/resolve addressed threads and preserve uncommitted work. Post-deploy tasks require verified serving revisions and existing production permissions. Stakeholder update requests are follow-through tasks: retain the original thread, recipient and developer authorization in communication. Use current evidence, distinguish fixed/deployed/verified, and read existing replies before posting. Record the sent replyUrl; a draft or uncertain send stays blocked and must be checked before retrying. Auto authorizes agent delivery only. Acknowledge the header's batchId with acknowledge_batch before finishing (completed with the result, or blocked with the reason), before declare_done for completion batches. Declare Done only after the whole goal and post-merge work are verified. Leave merging and archiving to the developer.\";\n\nasync function call(method, input) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) throw new Error(\"This bridge must run inside a Paseo agent (PASEO_AGENT_ID is missing).\");\n const { DaemonClient } = await loadClient();\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const config = JSON.parse(await readFile(join(home, \"config.json\"), \"utf8\"));\n let password = process.env.PASEO_PASSWORD;\n if (!password && process.env.PASEO_PASSWORD_FILE) password = (await readFile(process.env.PASEO_PASSWORD_FILE, \"utf8\")).trim();\n if (!password && home === join(homedir(), \".paseo\")) password = await readFile(join(homedir(), \"paseo-hub/secrets/daemon-password\"), \"utf8\").then(s => s.trim()).catch(() => undefined);\n const client = new DaemonClient({ url: process.env.PASEO_DAEMON_URL ?? `ws://${config.daemon?.listen ?? \"127.0.0.1:6767\"}/ws`, clientId: \"follow-through-mcp\", clientType: \"cli\", reconnect: { enabled: false }, connectTimeoutMs: 10000, suppressSendErrors: true, ...(password ? { password } : {}) });\n try { await client.connect(); return await client.invokePluginRpc(\"follow-through\", method, { ...input, agentId }); }\n finally { await client.close().catch(() => undefined); }\n}\n\nconst command = process.argv[2];\nif (command === \"list\" || command === \"--help\" || command === \"help\") {\n process.stdout.write(command === \"list\" ? JSON.stringify({ tools: schemas(toolList()) }, null, 2) + \"\\n\" : \"Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\\n\");\n} else if (command === \"call\") {\n try {\n const tool = toolList().find(t => t[0] === process.argv[3]);\n if (!tool) throw new Error(\"Unknown tool. Run list for names and input schemas.\");\n const input = JSON.parse(process.argv[4] ?? \"{}\");\n validate(input, schemas([tool])[0].inputSchema);\n if (process.argv.slice(5).some(arg => arg !== \"--compact\")) throw new Error(\"Unknown CLI option. Use --compact or omit it for a full reply.\");\n const result = await call(tool[2], input);\n process.stdout.write(JSON.stringify(process.argv.includes(\"--compact\") && input.detail !== \"full\" ? compact(tool[0], input, result) : result, null, 2) + \"\\n\");\n } catch (error) { console.error(error.message); process.exitCode = 1; }\n} else if (command) {\n console.error(\"Unknown command. Use list, --help, or call <tool> '<json>'.\"); process.exitCode = 1;\n} else {\n const lines = createInterface({ input: process.stdin });\n let tail = Promise.resolve(), initialized = false, lastScope = null;\n const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {\n void (async () => { const current = await scope(); if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/tools/list_changed\" }) + \"\\n\"); lastScope = current; })().catch(() => {});\n }, 2000) : null;\n timer?.unref(); lines.on(\"close\", () => { if (timer) clearInterval(timer); });\n lines.on(\"line\", line => {\n tail = tail.then(async () => {\n let request;\n try { request = JSON.parse(line); } catch { return; }\n if (request.id === undefined) return;\n try {\n let result;\n const role = await scope(request.method === \"initialize\" || request.method === \"tools/list\");\n const available = role ? toolList(role === \"recovery\", role === \"contributor\") : [];\n if (request.method === \"initialize\") {\n initialized = true; lastScope = role;\n result = { protocolVersion: request.params?.protocolVersion ?? \"2024-11-05\", capabilities: { tools: { listChanged: true } }, serverInfo: { name: \"paseo-follow-through\", version: \"0.3.5\" }, instructions: role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\" };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], input);\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
package/server/mcp.mjs CHANGED
@@ -21,11 +21,15 @@ const tools = [
21
21
  ["exclude_stack", "Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.", "follow.exclude-stack", { stack: string, reason: string, obligationIds: array }, ["stack", "reason"]],
22
22
  ["update_obligation", "Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\"now\"|\"merge\"|\"deploy\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.", "follow.obligation", { id: string, title: string, status: { type: "string", enum: ["pending", "waiting", "working", "blocked", "done", "waived"] }, afterPrs: array, dependencyStage: { type: "string", enum: ["merged", "deployed"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, ["title", "status"]],
23
23
  ["acknowledge_batch", "Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.", "follow.acknowledge", { batchId: string, outcome: { type: "string", enum: ["completed", "blocked"] }, result: { ...string, maxLength: 2000 } }, ["batchId", "outcome", "result"]],
24
+ ["ask_question", "Ask the developer a decision you can't make yourself. Give a short title, the question, lettered options A, B, C… with what each means for them, and recommend the option you'd pick. Set big only when building ahead would send something off this computer, set something people or programs outside the repo rely on, or most remaining work would build on it. Link a PR (owner/repo#number) or task id when it is about one. The reply says the question mode and exactly what to do until the answer arrives.", "follow.ask-question", { title: { ...string, maxLength: 120 }, question: { ...string, maxLength: 2000 }, options: { type: "array", maxItems: 26, items: { type: "object", properties: { letter: { type: "string", pattern: "^[A-Z]$" }, label: { ...string, maxLength: 200 }, consequence: { ...string, maxLength: 600 } }, required: ["letter", "label"], additionalProperties: false } }, recommend: string, big: string, background: string, links: { type: "array", maxItems: 10, items: { type: "object", properties: { url: string, path: string, label: string }, additionalProperties: false } }, pr: string, task: string }, ["title", "question"]],
25
+ ["record_answer", "Record the developer's answer when they answered a question in chat instead of in Follow-up. choice is an option letter, or their short answer when there were no options; words is what they said.", "follow.record-answer", { id: string, choice: string, words: string }, ["id", "choice"]],
26
+ ["withdraw_question", "Withdraw a question that no longer matters, with a short reason.", "follow.withdraw-question", { id: string, reason: string }, ["id", "reason"]],
27
+ ["set_spec", "Register the link to this workspace's spec: the Claude artifact (or doc) you keep summarizing the goal, decisions and status. Call it once after the first publish, with the URL from the publish result, and again only if the link changes. Follow-up shows it in the workspace header.", "follow.set-spec", { url: { ...string, maxLength: 2000 }, title: { ...string, maxLength: 120 } }, ["url"]],
24
28
  ["declare_done", "Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.", "follow.done", { summary: string }, ["summary"]],
25
29
  ];
26
30
 
27
31
  function toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === "1", contributor = false) {
28
- return tools.filter(t => contributor ? ["workspace_status", "register_pr", "sync_workspace"].includes(t[0]) : !recovery || !["declare_done", "sync_workspace"].includes(t[0]));
32
+ return tools.filter(t => contributor ? ["workspace_status", "register_pr", "sync_workspace"].includes(t[0]) : !recovery || !["declare_done", "sync_workspace", "ask_question", "record_answer", "withdraw_question", "set_spec"].includes(t[0]));
29
33
  }
30
34
  const schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === "workspace_status", destructiveHint: false } }));
31
35
  async function scope(resolveRecovery = false) {
@@ -69,12 +73,18 @@ function currentWork(w) {
69
73
  return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,
70
74
  prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),
71
75
  obligations: w.obligations.filter(o => !["done", "waived"].includes(o.status)), completedObligations: w.obligations.filter(o => ["done", "waived"].includes(o.status)).length,
76
+ openQuestions: (w.questions ?? []).filter(q => !q.answer && !q.closed).map(q => ({ id: q.id, title: q.title, question: q.question, options: q.options, recommend: q.recommend, big: q.big, mode: q.mode, askedBy: q.askedBy, taskId: q.taskId })),
77
+ spec: w.spec?.url ? { url: w.spec.url, title: w.spec.title, source: w.spec.source } : null,
78
+ recentAnswers: (w.questions ?? []).filter(q => q.answer).slice(-5).map(q => ({ id: q.id, title: q.title, choice: q.answer.choice, words: q.answer.words, delivery: q.answer.delivery.state })),
72
79
  batches: w.batches.filter(b => !["completed", "cancelled"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),
73
80
  excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: "summary" };
74
81
  }
75
82
  function compact(tool, input, result) {
76
83
  if (tool === "workspace_status") return currentWork(result);
77
84
  if (tool === "declare_done") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };
85
+ if (tool === "ask_question") return { workspaceId: result.workspaceId, question: { id: result.question.id, title: result.question.title, recommend: result.question.recommend, big: result.question.big }, mode: result.mode, instruction: result.instruction };
86
+ if (tool === "record_answer" || tool === "withdraw_question") return { workspaceId: result.workspaceId, question: { id: result.question.id, answer: result.question.answer, closed: result.question.closed } };
87
+ if (tool === "set_spec") return { workspaceId: result.workspaceId, spec: result.spec && { url: result.spec.url, title: result.spec.title, source: result.spec.source }, applied: result.applied, message: result.message };
78
88
  if (!result?.prs) return result;
79
89
  const workspaceId = result.id;
80
90
  if (tool === "enroll_workspace") return { workspaceId, goal: result.goal };
@@ -146,7 +156,7 @@ if (command === "list" || command === "--help" || command === "help") {
146
156
  const available = role ? toolList(role === "recovery", role === "contributor") : [];
147
157
  if (request.method === "initialize") {
148
158
  initialized = true; lastScope = role;
149
- result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.5" }, instructions: role ? (role === "contributor" ? "Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent." : role === "recovery" ? "Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent." : "Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. " + batchInstructions) + " Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked." : "Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools." };
159
+ result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.5" }, instructions: role ? (role === "contributor" ? "Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent." : role === "recovery" ? "Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent." : "Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. When a decision needs the developer, ask it with ask_question (lettered options and a recommendation) and follow the question mode in its reply. If you keep a spec artifact for this workspace, register its link once with set_spec. " + batchInstructions) + " Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked." : "Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools." };
150
160
  } else if (request.method === "ping") result = {};
151
161
  else if (request.method === "tools/list") result = { tools: schemas(available) };
152
162
  else if (request.method === "tools/call") {
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Workspace } from "../shared/model";
3
+ import { followUpObligations } from "../shared/follow-up";
4
+ import { QUESTION_MODE_RULES, isOpen, nextQuestionId, validChoice, type Question, type QuestionMode } from "../shared/questions";
5
+
6
+ export type AskInput = {
7
+ title: string; question: string; options?: { letter: string; label: string; consequence?: string }[]; recommend?: string;
8
+ big?: string; background?: string; links?: { url?: string; path?: string; label?: string }[]; pr?: string; task?: string;
9
+ };
10
+
11
+ function resolvePr(w: Workspace, ref: string): string {
12
+ const trimmed = ref.trim().replace(/^#/, "");
13
+ const exact = w.prs.find(p => p.key.toLowerCase() === trimmed.toLowerCase());
14
+ if (exact) return exact.key;
15
+ const byNumber = w.prs.filter(p => String(p.number) === trimmed);
16
+ if (byNumber.length === 1) return byNumber[0].key;
17
+ throw new Error(`No registered PR matches ${ref}. Use owner/repo#number or a number registered in this workspace.`);
18
+ }
19
+
20
+ /** Validates and records a new question. Returns it with the rule the agent must follow until it is answered. */
21
+ export function askQuestion(w: Workspace, agentId: string, input: AskInput, mode: QuestionMode, now: number): { question: Question; instruction: string } {
22
+ // Decisions belong to the main agent, like goal, tasks and completion. Contributors keep PR tools only.
23
+ if (w.mainAgentId !== agentId) throw new Error("Only this workspace's main agent can ask the developer questions. Ask the main agent to raise it.");
24
+ const options = input.options ?? [];
25
+ const letters = options.map(o => o.letter);
26
+ if (new Set(letters).size !== letters.length) throw new Error("Option letters must be unique.");
27
+ if (letters.some((letter, i) => letter !== String.fromCharCode(65 + i))) throw new Error("Letter options A, B, C… in order.");
28
+ const recommend = input.recommend?.trim() || null;
29
+ if (options.length && !recommend) throw new Error("Recommend one option with recommend=<letter>.");
30
+ if (options.length && recommend && !letters.includes(recommend)) throw new Error(`recommend must be one of ${letters.join(", ")}.`);
31
+ if (input.task && !w.obligations.some(o => o.id === input.task)) throw new Error("No task with that id in this workspace. Read workspace_status for task ids.");
32
+ for (const link of input.links ?? []) if (!!link.url === !!link.path) throw new Error("Each link needs a url or a path, not both.");
33
+ const question: Question = {
34
+ id: nextQuestionId(w.questions), title: input.title.trim(), question: input.question.trim(), options, recommend,
35
+ big: input.big?.trim() || null, background: input.background?.trim() || null, links: input.links ?? [],
36
+ prKey: input.pr ? resolvePr(w, input.pr) : null, taskId: input.task ?? null, taskRevision: null,
37
+ askedBy: agentId, askedAt: new Date(now).toISOString(), mode, answer: null, closed: null,
38
+ };
39
+ w.questions.push(question);
40
+ const rule = QUESTION_MODE_RULES[mode].rule({ recommend, big: question.big });
41
+ const instruction = [
42
+ `Asked ${question.id}. Question mode: ${QUESTION_MODE_RULES[mode].label}. ${rule}`,
43
+ `The developer answers in Follow-up and the answer arrives in this chat as "Follow-up answer ${question.id} …"; it is already recorded. If they answer in chat instead, call record_answer. Call withdraw_question if it stops mattering.`,
44
+ ].join("\n");
45
+ return { question, instruction };
46
+ }
47
+
48
+ function owned(w: Workspace, agentId: string, id: string): Question {
49
+ const q = w.questions.find(q => q.id === id);
50
+ if (!q) throw new Error(`No question ${id} in this workspace.`);
51
+ if (w.mainAgentId !== agentId) throw new Error(`Only the main agent can change ${id}.`);
52
+ return q;
53
+ }
54
+
55
+ /** The developer answered in chat; the agent records what they said. */
56
+ export function recordAnswer(w: Workspace, agentId: string, id: string, choice: string, words: string | undefined, now: number): Question {
57
+ const q = owned(w, agentId, id);
58
+ if (!isOpen(q)) throw new Error(`${id} is already ${q.answer ? "answered" : "withdrawn"}.`);
59
+ const at = new Date(now).toISOString();
60
+ q.answer = { choice: choice.trim(), ...(words?.trim() ? { words: words.trim() } : {}), at, by: "agent", delivery: { state: "recorded", to: agentId, messageId: null, at, reason: null } };
61
+ return q;
62
+ }
63
+
64
+ export function withdrawQuestion(w: Workspace, agentId: string, id: string, reason: string, now: number): Question {
65
+ const q = owned(w, agentId, id);
66
+ if (!isOpen(q)) throw new Error(`${id} is already ${q.answer ? "answered" : "withdrawn"}.`);
67
+ q.closed = { at: new Date(now).toISOString(), reason: reason.trim(), by: agentId };
68
+ return q;
69
+ }
70
+
71
+ /** The developer's answer from the UI. Sending happens outside the state lock; this only records it. */
72
+ export function developerAnswer(w: Workspace, id: string, choice: string, words: string | undefined, action: "send" | "copy" | "resend", now: number): Question {
73
+ const q = w.questions.find(q => q.id === id);
74
+ if (!q) throw new Error(`No question ${id} in this workspace.`);
75
+ if (q.closed) throw new Error(`${id} was withdrawn: ${q.closed.reason}`);
76
+ const at = new Date(now).toISOString();
77
+ if (action === "resend") {
78
+ if (!q.answer || !["queued", "unconfirmed"].includes(q.answer.delivery.state)) throw new Error(`${id} has nothing waiting to be resent.`);
79
+ q.answer.delivery = { ...q.answer.delivery, state: "sending", at, reason: null };
80
+ return q;
81
+ }
82
+ if (q.answer && !(q.answer.by === "developer" && q.answer.delivery.state === "copied" && action === "send")) throw new Error(`${id} is already answered.`);
83
+ if (!validChoice(q, choice)) throw new Error(q.options.length ? `Pick one of ${q.options.map(o => o.letter).join(", ")}.` : "Write an answer first.");
84
+ q.answer = { choice: choice.trim(), ...(words?.trim() ? { words: words.trim() } : {}), at, by: "developer",
85
+ delivery: action === "copy" ? { state: "copied", to: null, messageId: null, at, reason: null } : { state: "sending", to: null, messageId: randomUUID(), at, reason: null } };
86
+ return q;
87
+ }
88
+
89
+ /** Who should hear the answer: the asking agent first, then the main agent. */
90
+ export const answerRecipients = (w: Workspace, q: Question): string[] => [...new Set([q.askedBy, w.mainAgentId].filter((id): id is string => !!id))];
91
+
92
+ /**
93
+ * Blocked tasks are decisions waiting on the developer, so each gets a question (without options) that
94
+ * links back to it. The task itself is untouched; when it stops being blocked, its open question closes.
95
+ */
96
+ export function syncTaskQuestions(w: Workspace, now: number): boolean {
97
+ let changed = false;
98
+ const at = new Date(now).toISOString();
99
+ for (const o of followUpObligations(w)) {
100
+ const linked = w.questions.filter(q => q.taskId === o.id && q.taskRevision !== null);
101
+ const open = linked.find(isOpen);
102
+ if (o.status === "blocked" && !open && !linked.some(q => q.taskRevision === o.revision)) {
103
+ w.questions.push({ id: nextQuestionId(w.questions), title: o.title.slice(0, 120), question: o.evidence?.trim() || o.title, options: [], recommend: null, big: null,
104
+ background: null, links: [], prKey: null, taskId: o.id, taskRevision: o.revision, askedBy: w.mainAgentId, askedAt: at, mode: "work-around", answer: null, closed: null });
105
+ changed = true;
106
+ } else if (o.status !== "blocked" && open) {
107
+ open.closed = { at, reason: o.status === "done" || o.status === "waived" ? `The task was ${o.status === "done" ? "completed" : "waived"}.` : "The task is no longer blocked.", by: null };
108
+ changed = true;
109
+ }
110
+ }
111
+ return changed;
112
+ }