paseo-follow-up 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -10,6 +10,6 @@ Every main view links to the overview, project settings and display settings. Yo
10
10
 
11
11
  Repair batches go to the original main agent after a click, or after explicit per-workspace Auto consent and fresh delivery checks. Recovery is a separate manual action. Done requires the main agent's declaration and settled requirements; merging and archiving remain your actions. Tasks are manual unless you explicitly schedule Now, After merge or After deploy, optionally with a delay. Deployment gates require serving-revision evidence; merges alone never prove deployment.
12
12
 
13
- This trusted unsandboxed plugin reads selected workspace metadata, saved agent history/configuration, GitHub status and existing CLI authentication. State and private recovery evidence remain in plugin-data/follow-through. Scoped MCP registration and reversible provider hooks support tracked main agents; Codex's trust review remains in force. Prompts and repair context are sent to your configured agent/provider. Site-specific reviewer and Cloud Run adapters only apply to the configured repository; unsupported deployments remain unknown.
13
+ This trusted unsandboxed plugin reads selected workspace metadata, saved agent history/configuration, GitHub status and existing CLI authentication. State and private recovery evidence remain in plugin-data/follow-through. Scoped MCP registration and reversible provider hooks support tracked root sessions; contributors can register PRs from any worktree without taking main-agent authority. Successful submission receipts are discovered automatically within the workspace repository, while mentions remain unowned; Codex's trust review remains in force. Prompts and repair context are sent to your configured agent/provider. Site-specific reviewer and Cloud Run adapters only apply to the configured repository; unsupported deployments remain unknown.
14
14
 
15
15
  Merge, Merge when ready and reviewer requests require explicit developer controls. Optional direct browser grants trust that machine to change the PR toggle for the allowed repository, including later pushes; read-only grants require confirmation in Chrome. Auto and agent tools cannot authorize them. Follow-up has no Slack posting identity or background channel scanner. Stakeholder replies require the developer's authorization, original destination and a verified sent-message receipt from the agent.
package/README.md CHANGED
@@ -60,7 +60,7 @@ See the [integration and security contract](https://github.com/tomgrin10/send-to
60
60
 
61
61
  This is a trusted, unsandboxed plugin. It reads selected workspace metadata, agent history/configuration and GitHub PR status using your existing host credentials. Local state and private recovery evidence live under `$PASEO_HOME/plugin-data/follow-through`; credentials and provider configuration are not published or transferred.
62
62
 
63
- It registers scoped Claude/Codex MCP tools and reversible provider hooks for tracked main agents. Codex keeps its normal hook trust review. Repair and recovery prompts reach your configured agent provider. Follow-up itself has no Slack posting identity and never scans channels or authorizes replies from historical mentions.
63
+ It registers scoped Claude/Codex MCP tools and reversible provider hooks for tracked root sessions. Contributor sessions receive PR registration, status and sync tools; goal, task and completion authority stays with the designated main agent. Codex keeps its normal hook trust review. Repair and recovery prompts reach your configured agent provider. Follow-up itself has no Slack posting identity and never scans channels or authorizes replies from historical mentions.
64
64
 
65
65
  Human-operated Merge and reviewer controls perform GitHub/Graphite writes only after an explicit click and fresh eligibility checks. Unknown or uncertain results require checking before retrying. Site-specific reviewer and Cloud Run deployment adapters remain limited to their configured repository; other deployment status remains unknown rather than being inferred from a merge.
66
66
 
@@ -81,7 +81,7 @@ Release tags (`vX.Y.Z`) run `.github/workflows/publish-npm.yml`. GitHub Actions
81
81
 
82
82
  ## Paseo 0.14 agent integration
83
83
 
84
- The Follow-up MCP attaches when a tracked main or active recovery agent opens a Claude or Codex session. Normal startup no longer writes global Claude/Codex MCP settings. Existing global entries are preserved. Turn-ended discovery now fetches recent history through the SDK because Paseo 0.13+ sends an empty timeline in lifecycle events.
84
+ The Follow-up MCP attaches when a root agent in a tracked workspace or an active recovery agent opens a Claude or Codex session. Other root sessions get `workspace_status`, `register_pr` and `sync_workspace`, even when the originally recorded main agent is unavailable. Normal startup no longer writes global Claude/Codex MCP settings. Existing global entries are preserved. Turn-ended discovery now fetches recent history through the SDK because Paseo 0.13+ sends an empty timeline in lifecycle events.
85
85
 
86
86
  Requires Paseo **0.14.0 or newer**. Agent tools are scoped to interactive Claude
87
87
  and Codex sessions; history-only opens receive none. They become available on
@@ -101,3 +101,13 @@ Also available from [Tom Gringauz](https://github.com/tomgrin10):
101
101
  - [Session Guard](https://github.com/tomgrin10/paseo-session-guard) — Pause at usage limits and resume after reset.
102
102
  - [Transfer](https://github.com/tomgrin10/paseo-transfer) — Copy or move workspaces and agent conversations between hosts.
103
103
  - [Transcript Search](https://github.com/tomgrin10/paseo-transcript-search) — Search active and archived conversations across hosts.
104
+
105
+
106
+ Successful `gt submit` and `gh pr create` receipts in root-session history also discover submitted
107
+ PRs automatically, including from secondary git worktrees. Discovery checks the workspace's GitHub
108
+ repository identity; merely mentioned PRs and read-only commands do not establish ownership.
109
+ Explicit registrations, exclusions and another tracked workspace's ownership take precedence.
110
+ Observed GitHub base/head branches supply inferred stack relationships; explicit edits override them.
111
+ Tracking continues after the checkout branch's PR merges, and the submitting session remains the
112
+ recipient for Send to Paseo. Existing sessions can use the hook-provided CLI fallback before their
113
+ next MCP session refresh. Automatic discovery does not transfer main-agent authority or enable Auto.
package/index.server.ts CHANGED
@@ -63,6 +63,8 @@ export default function contribute(server: PluginServerContext) {
63
63
  const callerWorkspaceId = async (agentId: string, paseo: PluginHandlerContext["paseo"], allowHandoff = true) => {
64
64
  const agent = (await paseo.agents.ref(agentId).refresh())?.agent;
65
65
  if (!agent?.workspaceId) throw new Error(`Cannot locate the caller's workspace: expected agent in a recorded workspace, received agent ${agentId}.`);
66
+ const record = (await store.snapshot()).workspaces.find(w => w.id === agent.workspaceId);
67
+ if (record?.contributorAgentIds?.includes(agentId) && !record.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && b.tasks.every(t => t.kind === "inventory")) && (agent.archivedAt || agent.labels["paseo.parent-agent-id"] || agent.labels["follow-through.recovery"])) throw new Error("PR registration requires an available root agent in this tracked workspace.");
66
68
  if (allowHandoff) {
67
69
  const w = (await store.snapshot()).workspaces.find(w => w.id === agent.workspaceId);
68
70
  if (w && w.mainAgentId !== agentId && !w.batches.some(b => b.recipientAgentId === agentId)) {
@@ -75,6 +77,7 @@ export default function contribute(server: PluginServerContext) {
75
77
  const at = new Date().toISOString();
76
78
  b.recoveryHandoffs.push({ fromAgentId: handoff.fromAgentId, toAgentId: agentId, at });
77
79
  b.recipientAgentId = agentId; b.activityAt = at;
80
+ record.contributorAgentIds = record.contributorAgentIds?.filter(id => id !== agentId);
78
81
  if (b.state === "blocked") { b.state = "sent"; b.result = "Recovery continued in the replacement agent; prior inventory and evidence are preserved."; }
79
82
  if (record.hold === "Recovery stopped or failed. Check the recovery agent before retrying." || record.hold === "Recovery acknowledgment was refused. Check the identity error on this batch.") record.hold = null;
80
83
  });
@@ -107,12 +110,12 @@ export default function contribute(server: PluginServerContext) {
107
110
  server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
108
111
  await requireReady();
109
112
  const workspaceId = await callerWorkspaceId(agentId, paseo, false);
110
- const w = agentWorkspace(await store.snapshot(), agentId, "write", workspaceId);
111
- if (w.mainAgentId !== agentId || w.archived) throw new Error(`Only the active workspace main agent can confirm sidebar sync: expected ${w.mainAgentId}, received ${agentId}.`);
113
+ const w = agentWorkspace(await store.snapshot(), agentId, "write", workspaceId, "pr");
114
+ if (w.archived) throw new Error(`Only an active workspace root agent can confirm sidebar sync: expected ${w.mainAgentId}, received ${agentId}.`);
112
115
  await agentHooks.confirm(w.id, agentId, summary);
113
116
  return { workspaceId: w.id, synced: true as const };
114
117
  });
115
- server.handle(rpc.agentStatusRpc, async ({ agentId }, { paseo }) => { await requireReady(); const workspaceId = await callerWorkspaceId(agentId, paseo); return agentWorkspace(await store.snapshot(), agentId, "read", workspaceId); });
118
+ server.handle(rpc.agentStatusRpc, async ({ agentId }, { paseo }) => { await requireReady(); const workspaceId = await callerWorkspaceId(agentId, paseo); return agentWorkspace(await store.snapshot(), agentId, "read", workspaceId, "pr"); });
116
119
  server.handle(rpc.activateRpc, async ({ workspaceId }) => { await requireReady(); return engine.activate(workspaceId); });
117
120
  server.handle(rpc.sendRpc, async ({ workspaceId, batchId }) => { await requireReady(); return engine.sendPending(workspaceId, batchId); });
118
121
  server.handle(rpc.recoverRpc, async ({ workspaceId }) => { await requireReady(); return engine.startRecovery(workspaceId); });
@@ -139,7 +142,7 @@ export default function contribute(server: PluginServerContext) {
139
142
  await requireReady();
140
143
  const workspaceId = await callerWorkspaceId(agentId, paseo);
141
144
  const result = await store.mutate(state => {
142
- const w = agentWorkspace(state, agentId, "write", workspaceId);
145
+ const w = agentWorkspace(state, agentId, "write", workspaceId, "pr");
143
146
  const key = `${repo}#${number}`;
144
147
  const excluded = w.excludedStacks.find(s => s.name === stack || s.prs.some(p => p.key === key));
145
148
  if (excluded) throw new Error(`This PR or stack was excluded from workspace scope: ${excluded.reason}. Read workspace_status and preserve the exclusion.`);
@@ -154,7 +157,7 @@ export default function contribute(server: PluginServerContext) {
154
157
  seen.add(ancestor); ancestor = w.prs.find(p => p.key === ancestor)?.parent ?? null;
155
158
  }
156
159
  const altered = !existing || existing.stack !== stack || existing.parent !== parent || disposition !== undefined && existing.disposition !== disposition;
157
- if (existing) { existing.registeredAgentId = agentId; existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
160
+ if (existing) { existing.registeredAgentId = agentId; existing.discoveredFromSubmission = false; existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
158
161
  else w.prs.push({ registeredAgentId: agentId, key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
159
162
  if (altered) changed(w); return w;
160
163
  });
@@ -254,7 +257,7 @@ export default function contribute(server: PluginServerContext) {
254
257
  const removers = [
255
258
  server.on("agent.turn_started", async event => {
256
259
  await ready;
257
- if (!stopped && !loadError) await store.mutate(state => { const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id); if (w) w.completion = null; });
260
+ if (!stopped && !loadError) await store.mutate(state => { const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id))); if (w) w.completion = null; });
258
261
  }),
259
262
  server.before("agent.session_open", async ({ request }) => {
260
263
  await ready;
@@ -272,8 +275,15 @@ export default function contribute(server: PluginServerContext) {
272
275
  return { ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry() } };
273
276
  }
274
277
  }
278
+ if (!w && request.workspaceId && !request.labels["paseo.parent-agent-id"] && !request.labels["follow-through.recovery"]) {
279
+ w = await store.mutate(state => {
280
+ const workspace = state.workspaces.find(w => w.id === request.workspaceId && w.enable_tracking && !w.archived);
281
+ if (workspace) workspace.contributorAgentIds = [...new Set([...(workspace.contributorAgentIds ?? []), request.agentId])];
282
+ return workspace;
283
+ });
284
+ }
275
285
  if (!w) return request;
276
- const recovery = w.mainAgentId !== request.agentId;
286
+ const recovery = w.mainAgentId !== request.agentId && !w.contributorAgentIds?.includes(request.agentId);
277
287
  return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}) } }, directory, clientEntry(), paseoHome, recovery);
278
288
  }),
279
289
  server.on("agent.turn_ended", async (event, { paseo, signal }) => {
@@ -286,20 +296,20 @@ export default function contribute(server: PluginServerContext) {
286
296
  if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => { const w = findWorkspace(state, assigned.id); const b = w.batches.find(b => b.recipientAgentId === event.agent.id && ["sending", "sent", "uncertain"].includes(b.state)); if (b) { b.state = "blocked"; b.result = b.acknowledgmentError ? `Recovery acknowledgment was refused: ${b.acknowledgmentError.error}` : "Recovery stopped or failed. Check its agent before retrying."; } });
287
297
  void engine.tick(); return;
288
298
  }
289
- if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && w.mainAgentId === event.agent.id)) return;
299
+ if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)))) return;
290
300
  gateway.markDirty(event.agent.id);
291
301
  // Since Paseo 0.13 the lifecycle payload deliberately contains no history.
292
302
  // Read a bounded tail through the plugin-owned API, outside the state lock.
293
303
  const history = await paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 100, projection: "canonical" }).catch(() => null);
294
304
  if (signal.aborted || stopped) return;
295
305
  if (history) await store.mutate(state => {
296
- const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id);
306
+ const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
297
307
  if (w) applyHistory(w, extractHistory(history.entries.map(entry => entry.item), w.inventory?.historyComplete ?? false, w.inventory?.historyError ?? null), null);
298
308
  });
299
309
  const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
300
310
  if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
301
- const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id);
302
- if (w && interruption) { w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null; }
311
+ const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
312
+ if (w && w.mainAgentId === event.agent.id && interruption) { w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null; }
303
313
  });
304
314
  void engine.tick();
305
315
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "description": "Track workspace pull requests, queue follow-up work, and verify completion in Paseo.",
6
6
  "type": "module",
7
7
  "files": [
@@ -16,7 +16,12 @@ async function save(path, value) {
16
16
  await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
17
17
  await rename(temporary, path);
18
18
  }
19
- function instructions(lease) {
19
+ function instructions(lease, contributor = false) {
20
+ if (contributor) return [
21
+ "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.",
22
+ quietInstruction,
23
+ `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.`,
24
+ ].join("\n\n");
20
25
  return [
21
26
  "Maintain this tracked workspace's Follow-through sidebar as part of your normal work; do not wait for a developer reminder.",
22
27
  quietInstruction,
@@ -39,19 +44,21 @@ async function main() {
39
44
  const lease = await json(join(directory, "hooks-enabled.json"));
40
45
  if (lease.expiresAt < Date.now() || !lease.clientEntry) return;
41
46
  const state = await json(join(directory, "state.json"));
42
- const workspace = state.workspaces.find(w => w.mainAgentId === agentId && w.enable_tracking && !w.archived);
47
+ const workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived);
43
48
  if (!workspace) return;
49
+ const contributor = workspace.mainAgentId !== agentId;
50
+ const context = () => instructions(lease, contributor);
44
51
  const name = event.hook_event_name;
45
52
  if (!["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"].includes(name)) return;
46
53
  const session = createHash("sha256").update(String(event.session_id ?? agentId)).digest("hex").slice(0, 24);
47
54
  const path = join(directory, "hook-state", `${agentId}-${session}.json`);
48
55
  const turn = await json(path).catch(() => null);
49
56
  const emit = text => process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: name, additionalContext: text } }) + "\n");
50
- if (name === "SessionStart") { emit(instructions(lease)); return; }
57
+ if (name === "SessionStart") { emit(context()); return; }
51
58
  if (name === "UserPromptSubmit") {
52
59
  if (turn?.stopped && String(event.prompt ?? "").startsWith(stopReason)) return;
53
60
  await save(path, { startedAt: Date.now(), dirtyAt: null, stopped: false, quietRevision });
54
- emit(instructions(lease)); return;
61
+ emit(context()); return;
55
62
  }
56
63
  if (name === "PostToolUse") {
57
64
  const tool = String(event.tool_name ?? "");
@@ -67,6 +74,6 @@ async function main() {
67
74
  const sync = await json(join(directory, "hook-state", `sync-${agentId}.json`)).catch(() => null);
68
75
  if (turn && sync?.workspaceId === workspace.id && sync.at >= Math.max(turn.startedAt, turn.dirtyAt ?? 0)) return;
69
76
  await save(path, { ...turn, startedAt: turn?.startedAt ?? Date.now(), stopped: true });
70
- process.stdout.write(JSON.stringify({ decision: "block", reason: stopReason + " This is metadata bookkeeping only; do not start queued repairs or recovery.\n\n" + instructions(lease) }) + "\n");
77
+ 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");
71
78
  }
72
79
  main().catch(() => {});
package/server/daemon.ts CHANGED
@@ -8,7 +8,8 @@ import type { State } from "../shared/model";
8
8
  import { defaultTracking, projectSetting, type Project } from "../shared/settings";
9
9
  import { recoveryHandoff } from "./recovery-handoff";
10
10
  import { recoveryConfig } from "./recovery-config";
11
- import { extractHistory, type DiscoveredWorkspace } from "./discovery";
11
+ import { submittedPrs, type Submission } from "./submissions";
12
+ import { extractHistory, type History, type DiscoveredWorkspace } from "./discovery";
12
13
  import type { PluginBeforeRequests } from "@getpaseo/plugin/server";
13
14
  import { SessionGuard } from "./session-guard";
14
15
  import { DeliveryDeferred } from "./delivery";
@@ -88,6 +89,7 @@ export class PaseoGateway implements Gateway {
88
89
  private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
89
90
  private dirty = new Set<string>();
90
91
  markDirty(agentId: string): void { this.dirty.add(agentId); }
92
+ private histories = new Map<string, { messageAt: string | null; at: number; history: History; submissions: Submission[] }>();
91
93
  async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
92
94
  return this.connect(async client => {
93
95
  const [agents, workspaces, catalog] = await Promise.all([
@@ -97,39 +99,58 @@ export class PaseoGateway implements Gateway {
97
99
  ]);
98
100
  this.directory = { agents, workspaces };
99
101
  const discovered: DiscoveredWorkspace[] = [];
100
- let scans = 0;
102
+ const scans: { row: DiscoveredWorkspace; agent: Agent; repo: string | null }[] = [];
101
103
  const defaults = new Set(catalog.projects.filter(p => defaultTracking(p, state.projectDefaults)).map(p => p.projectId));
102
104
  for (const workspace of workspaces.filter(w => !w.archivingAt && (workspaceId ? w.id === workspaceId : defaults.has(w.projectId ?? "") || state.workspaces.some(saved => saved.id === w.id && saved.enable_tracking && !saved.archived)))) {
103
105
  const existing = state.workspaces.find(w => w.id === workspace.id);
104
106
  if (existing?.archived) continue;
105
107
  const roots = agents.map(row => row.agent).filter(a => a.workspaceId === workspace.id && !a.archivedAt && !a.labels["paseo.parent-agent-id"] && !a.labels["follow-through.recovery"]);
106
108
  const main = chooseMainAgent(roots, existing?.mainAgentId);
107
- if (!main) continue;
108
- const row: DiscoveredWorkspace = { id: workspace.id, title: workspace.name || main.title || "Untitled workspace", mainAgentId: main.id, projectId: workspace.projectId, messageAt: main.lastUserMessageAt, enable_default_tracking: defaults.has(workspace.projectId ?? "") };
109
- const inventory = existing?.inventory;
110
- const needsHistory = !inventory || (!inventory.historyComplete && !inventory.historyError) || inventory.scannedMessageAt !== main.lastUserMessageAt || this.dirty.has(main.id);
111
- if (needsHistory && scans < 4 && !["running", "initializing"].includes(main.status)) {
112
- scans++;
113
- try {
114
- const entries: { item: unknown; seqStart: number }[] = [];
115
- let cursor: { epoch: string; seq: number } | null = null;
116
- let complete = false;
117
- for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
118
- const page = await client.fetchAgentTimeline(main.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
119
- if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
120
- entries.push(...page.entries);
121
- if (!page.hasOlder) { complete = true; break; }
122
- if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
123
- cursor = page.startCursor;
124
- }
125
- row.history = extractHistory(entries.sort((a, b) => a.seqStart - b.seqStart).map(e => e.item), complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
126
- } catch {
127
- row.history = extractHistory([], false, "History could not be read; the recovery agent must inspect the main conversation and local transcripts during inventory");
128
- }
129
- this.dirty.delete(main.id);
109
+ if (!main && !existing) continue;
110
+ const row: DiscoveredWorkspace = { id: workspace.id, title: workspace.name || main?.title || existing!.title, mainAgentId: existing?.mainAgentId ?? main!.id, projectId: workspace.projectId, messageAt: main?.lastUserMessageAt ?? null, enable_default_tracking: defaults.has(workspace.projectId ?? ""), contributorAgentIds: roots.filter(a => a.id !== existing?.mainAgentId && a.id !== main?.id).map(a => a.id) };
111
+ // Preserve the designated owner even when it is no longer indexed. Other root
112
+ // sessions can still contribute their PRs without acquiring completion authority.
113
+ const project = catalog.projects.find(p => p.projectId === workspace.projectId);
114
+ const repo = project?.projectKind === "git" ? /^remote:github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(project.projectKey)?.[1] ?? null : null;
115
+ for (const agent of roots) {
116
+ const cached = this.histories.get(agent.id);
117
+ if (!cached || cached.messageAt !== agent.lastUserMessageAt || this.dirty.has(agent.id) || (cached.history.error && Date.now() - cached.at >= 15_000) || (["running", "initializing"].includes(agent.status) && Date.now() - cached.at >= 15_000)) scans.push({ row, agent, repo });
130
118
  }
131
119
  discovered.push(row);
132
120
  }
121
+ // Oldest scan first prevents busy workspaces from starving later sessions.
122
+ 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 ?? ""));
123
+ for (const { agent, repo } of scans.slice(0, 4)) {
124
+ let history: History; let submissions: Submission[] = [];
125
+ try {
126
+ const entries: { item: unknown; seqStart: number }[] = [];
127
+ let cursor: { epoch: string; seq: number } | null = null;
128
+ let complete = false;
129
+ for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
130
+ const page = await client.fetchAgentTimeline(agent.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
131
+ if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
132
+ entries.push(...page.entries);
133
+ if (!page.hasOlder) { complete = true; break; }
134
+ if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
135
+ cursor = page.startCursor;
136
+ }
137
+ const items = entries.sort((a, b) => a.seqStart - b.seqStart).map(e => e.item);
138
+ history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
139
+ submissions = submittedPrs(items, agent.id, repo);
140
+ } catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
141
+ this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at: Date.now(), history, submissions });
142
+ this.dirty.delete(agent.id);
143
+ }
144
+ for (const row of discovered) {
145
+ const ids = [row.mainAgentId, ...(row.contributorAgentIds ?? [])];
146
+ const cached = ids.flatMap(id => { const h = this.histories.get(id); return h ? [h] : []; });
147
+ if (cached.length) {
148
+ 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 };
149
+ row.submissions = cached.flatMap(c => c.submissions);
150
+ }
151
+ }
152
+ const known = new Set(agents.map(a => a.agent.id));
153
+ for (const id of this.histories.keys()) if (!known.has(id)) this.histories.delete(id);
133
154
  return discovered;
134
155
  });
135
156
  }
@@ -1,7 +1,8 @@
1
+ import type { Submission } from "./submissions";
1
2
  import type { State, Workspace } from "../shared/model";
2
3
 
3
4
  export type History = { candidates: string[]; goal: string | null; complete: boolean; error: string | null };
4
- export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History };
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[] };
5
6
 
6
7
  export function extractHistory(items: readonly unknown[], complete = true, error: string | null = null): History {
7
8
  const candidates = new Set<string>();
@@ -14,7 +15,7 @@ export function extractHistory(items: readonly unknown[], complete = true, error
14
15
  if (/^(Workspace follow-through batch|Follow-through batch|Follow-through recovery) /.test(text)) continue;
15
16
  if (!goal && value.type === "user_message" && value.text && !value.text.trimStart().startsWith("<"))
16
17
  goal = value.text.trim().replace(/\s+/g, " ").slice(0, 350) || null;
17
- for (const match of text.matchAll(/https:\/\/(?:github\.com|app\.graphite\.dev\/github\/pr)\/([\w.-]+)\/([\w.-]+)\/(?:pull\/)?(\d+)(?=[/?#\s)\]"'.,;:<>}]|$)/g))
18
+ for (const match of text.matchAll(/https:\/\/(?:github\.com|app\.graphite\.(?:com|dev)\/github\/pr)\/([\w.-]+)\/([\w.-]+)\/(?:pull\/)?(\d+)(?=[/?#\s)\]"'.,;:<>}]|$)/g))
18
19
  candidates.add(`${match[1]}/${match[2]}#${Number(match[3])}`);
19
20
  }
20
21
  return { candidates: [...candidates].sort(), goal, complete, error };
@@ -32,6 +33,7 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
32
33
  w.title = row.title;
33
34
  // An unavailable original owner is surfaced instead of silently transferring its authority.
34
35
  if (w.mainAgentId !== row.mainAgentId || w.archived) continue;
36
+ if (row.contributorAgentIds) w.contributorAgentIds = row.contributorAgentIds;
35
37
  if (row.enable_default_tracking && !w.enable_tracking && (!row.projectId || state.projectDefaults?.[row.projectId] !== false)) { w.enable_tracking = true; w.enable_auto_sends = false; }
36
38
  if (!w.inventory) {
37
39
  w.inventory = { revision: 1, syncedRevision: 0, candidates: [], historyComplete: false, historyError: null, scannedMessageAt: null };
@@ -41,6 +43,15 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
41
43
  if (w.goal === row.title && row.history.goal) w.goal = row.history.goal;
42
44
  applyHistory(w, row.history, row.messageAt);
43
45
  }
46
+ for (const receipt of row.submissions ?? []) {
47
+ const key = `${receipt.repo}#${receipt.number}`;
48
+ if (!w.enable_tracking || w.prs.some(p => p.key === key) ||
49
+ w.excludedStacks.some(stack => stack.prs.some(p => p.key === key)) ||
50
+ state.workspaces.some(other => other.id !== w.id && other.enable_tracking && !other.archived && other.prs.some(p => p.key === key))) continue;
51
+ w.prs.push({ key, repo: receipt.repo, number: receipt.number, registeredAgentId: receipt.agentId,
52
+ discoveredFromSubmission: true, stack: receipt.branch ?? `PR #${receipt.number}`, parent: null, disposition: null, snapshot: null, error: null });
53
+ w.completion = null; w.updatedAt = new Date(now).toISOString();
54
+ }
44
55
  }
45
56
  }
46
57
 
package/server/engine.ts CHANGED
@@ -3,6 +3,7 @@ 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 { reconcileSubmittedStacks } from "./submissions";
6
7
  import { applyDiscovery, type DiscoveredWorkspace } from "./discovery";
7
8
  import { replyRecorded } from "../shared/communication";
8
9
  import { DeliveryDeferred } from "./delivery";
@@ -80,6 +81,7 @@ export class Engine {
80
81
  p.error = observed.error;
81
82
  if (observed.snapshot) recordSnapshot(p, observed.snapshot, this.now());
82
83
  }
84
+ reconcileSubmittedStacks(w);
83
85
  if (w.archived) return;
84
86
  this.reconcile(w);
85
87
  const unacknowledged = w.batches.find(b => b.state === "sent" && !isRecovery(b));
@@ -309,10 +311,11 @@ export function ownedWorkspace(state: State, agentId: string, workspaceId?: stri
309
311
  requireTracking(w, agentId);
310
312
  return w;
311
313
  }
312
- export function agentWorkspace(state: State, agentId: string, access: "read" | "write" = "write", workspaceId?: string): Workspace {
314
+ export function agentWorkspace(state: State, agentId: string, access: "read" | "write" = "write", workspaceId?: string, scope: "owner" | "pr" = "owner"): Workspace {
313
315
  const w = relatedWorkspace(state, agentId, workspaceId);
314
316
  const recovery = w?.batches.some(b => b.recipientAgentId === agentId && isRecovery(b) && (access === "read" || ["sending", "sent", "uncertain"].includes(b.state) || b.state === "blocked" && !b.settledAt));
315
- if (!w || (w.mainAgentId !== agentId && !recovery)) throw accessError(w, agentId, access, workspaceId);
317
+ const contributor = scope === "pr" && w?.enable_tracking && !w.archived && w.contributorAgentIds?.includes(agentId);
318
+ if (!w || (w.mainAgentId !== agentId && !recovery && !contributor)) throw accessError(w, agentId, access, workspaceId);
316
319
  if (access === "write") requireTracking(w, agentId);
317
320
  return w;
318
321
  }
@@ -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) {\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.enable_tracking && !w.archived);\n if (!workspace) return;\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(instructions(lease)); 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(instructions(lease)); 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\" + instructions(lease) }) + \"\\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 \"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. Main agent 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\") { return tools.filter(t => !recovery || ![\"declare_done\", \"sync_workspace\"].includes(t[0])); }\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 }\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, 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\") : [];\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.1.0\" }, instructions: role ? (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 only to a tracked workspace's main agent or active recovery agent.\" };\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 [\"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, 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.1\" }, 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";
package/server/mcp.mjs CHANGED
@@ -15,7 +15,7 @@ const communication = { type: "object", properties: { threadUrl: string, recipie
15
15
  const trigger = { type: ["object", "null"], properties: { event: { type: "string", enum: ["now", "merge", "deploy"] }, delayDays: { type: "integer", minimum: 0, maximum: 3650 } }, required: ["event"], additionalProperties: false };
16
16
  const tools = [
17
17
  ["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"] } }, []],
18
- ["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. Main agent only.", "follow.sync", { summary: { ...string, minLength: 1, maxLength: 1000 } }, ["summary"]],
18
+ ["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"]],
19
19
  ["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"]],
20
20
  ["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"]],
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"]],
@@ -24,7 +24,9 @@ const tools = [
24
24
  ["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
25
  ];
26
26
 
27
- function toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === "1") { return tools.filter(t => !recovery || !["declare_done", "sync_workspace"].includes(t[0])); }
27
+ 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]));
29
+ }
28
30
  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 } }));
29
31
  async function scope(resolveRecovery = false) {
30
32
  const agentId = process.env.PASEO_AGENT_ID;
@@ -39,6 +41,7 @@ async function scope(resolveRecovery = false) {
39
41
  if (!w.enable_tracking || w.archived) continue;
40
42
  if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === "1" ? "recovery" : "main";
41
43
  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";
44
+ if (w.contributorAgentIds?.includes(agentId)) return "contributor";
42
45
  }
43
46
  if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === "1") {
44
47
  const w = await call("follow.agent-status", {});
@@ -140,10 +143,10 @@ if (command === "list" || command === "--help" || command === "help") {
140
143
  try {
141
144
  let result;
142
145
  const role = await scope(request.method === "initialize" || request.method === "tools/list");
143
- const available = role ? toolList(role === "recovery") : [];
146
+ const available = role ? toolList(role === "recovery", role === "contributor") : [];
144
147
  if (request.method === "initialize") {
145
148
  initialized = true; lastScope = role;
146
- result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.1.0" }, instructions: role ? (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 only to a tracked workspace's main agent or active recovery agent." };
149
+ result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.1" }, 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." };
147
150
  } else if (request.method === "ping") result = {};
148
151
  else if (request.method === "tools/list") result = { tools: schemas(available) };
149
152
  else if (request.method === "tools/call") {
@@ -0,0 +1,75 @@
1
+ import type { Pr, Workspace } from "../shared/model";
2
+
3
+ export type Submission = { repo: string; number: number; agentId: string; branch: string | null };
4
+ const reference = /https:\/\/(?:github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)|app\.graphite\.(?:com|dev)\/github\/pr\/([\w.-]+)\/([\w.-]+)\/(\d+))(?=[/?#\s]|$)/;
5
+
6
+ /** Ignore command-like text inside quoted arguments and comments; never execute shell text. */
7
+ function unquoted(command: string): string {
8
+ let quote = "", result = "";
9
+ for (let i = 0; i < command.length; i++) {
10
+ const c = command[i];
11
+ if (c === "\\" && quote !== "'") { result += " "; i++; continue; }
12
+ if (quote) { if (c === quote) quote = ""; result += " "; continue; }
13
+ if (["'", '\"', "`"].includes(c)) { quote = c; result += " "; continue; }
14
+ if (c === "#" && (i === 0 || /\s/.test(command[i - 1]))) {
15
+ while (i < command.length && command[i] !== "\n") { result += " "; i++; }
16
+ result += "\n"; continue;
17
+ }
18
+ result += c;
19
+ }
20
+ return result;
21
+ }
22
+
23
+ /** Submission receipts are ownership evidence. Links in chat, reviews or file reads are not. */
24
+ export function submittedPrs(items: readonly unknown[], agentId: string, repository: string | null): Submission[] {
25
+ if (!repository) return [];
26
+ const found = new Map<string, Submission>();
27
+ for (const item of items) {
28
+ if (!item || typeof item !== "object") continue;
29
+ const v = item as { type?: string; status?: string; error?: unknown; detail?: { type?: string; command?: string; output?: unknown } };
30
+ const d = v.detail;
31
+ if (v.type !== "tool_call" || v.status !== "completed" || v.error || d?.type !== "shell" || typeof d.command !== "string" || typeof d.output !== "string") continue;
32
+ const command = unquoted(d.command);
33
+ // Do not interpret arbitrary scripts or quoted documentation as commands.
34
+ if (/<</.test(command) || /--(?:help|dry-run)\b/.test(command)) continue;
35
+ const gh = /(?:^|[;&|\n])\s*(?:timeout\s+\d+\s+)?gh\s+pr\s+create\b/.test(command);
36
+ const gt = /(?:^|[;&|\n])\s*(?:timeout\s+\d+\s+)?gt\s+(?:submit|ss)\b/.test(command);
37
+ if (!gh && !gt) continue;
38
+ const output = d.output.replace(/\x1b\[[0-9;]*m/g, "");
39
+ for (const line of output.split(/\r?\n/)) {
40
+ const match = reference.exec(line);
41
+ if (!match) continue;
42
+ const repo = `${match[1] ?? match[4]}/${match[2] ?? match[5]}`;
43
+ const number = Number(match[3] ?? match[6]);
44
+ if (repo.toLowerCase() !== repository.toLowerCase() || !Number.isSafeInteger(number) || number < 1) continue;
45
+ const branch = gt ? /^(\S+): https:\/\/\S+ \((?:created|updated)\)\s*$/.exec(line)?.[1] ?? null : null;
46
+ if (!(gh && line.trim() === match[0]) && !(gt && (branch || line.trim() === `View your PR at ${match[0]}`))) continue;
47
+ const key = `${repository}#${number}`;
48
+ const previous = found.get(key);
49
+ found.set(key, { repo: repository, number, agentId, branch: branch ?? previous?.branch ?? null });
50
+ }
51
+ }
52
+ return [...found.values()];
53
+ }
54
+
55
+ /** Only inferred relationships follow GitHub's observed base/head branches. Explicit edits win. */
56
+ export function reconcileSubmittedStacks(w: Workspace): void {
57
+ for (const p of w.prs.filter(p => p.discoveredFromSubmission)) {
58
+ const s = p.snapshot;
59
+ if (p.error || !s?.complete || !s.headBranch || !s.base) continue;
60
+ const parents = w.prs.filter(row => row.key !== p.key && row.repo === p.repo && !row.error && row.snapshot?.complete && row.snapshot.headBranch && row.snapshot.headBranch !== row.snapshot.trunk && row.snapshot.headBranch === s.base);
61
+ const parent = parents.length === 1 ? parents[0] : null;
62
+ let cursor: Pr | undefined = parent ?? undefined;
63
+ const seen = new Set([p.key]); let cycle = false;
64
+ while (cursor) { if (seen.has(cursor.key)) { cycle = true; break; } seen.add(cursor.key); cursor = w.prs.find(row => row.key === cursor!.parent); }
65
+ if (!cycle) p.parent = parent?.key ?? null;
66
+ }
67
+ for (const p of w.prs.filter(p => p.discoveredFromSubmission && !p.parent)) {
68
+ if (!p.error && p.snapshot?.complete && p.snapshot.headBranch) p.stack = p.snapshot.headBranch;
69
+ }
70
+ for (const p of w.prs.filter(p => p.discoveredFromSubmission)) {
71
+ let root = p; const seen = new Set([p.key]);
72
+ while (root.parent) { const parent = w.prs.find(row => row.key === root.parent); if (!parent || seen.has(parent.key)) break; seen.add(parent.key); root = parent; }
73
+ p.stack = root.stack;
74
+ }
75
+ }
package/shared/model.ts CHANGED
@@ -27,6 +27,7 @@ export const prSchema = z.object({
27
27
  stack: textSchema, parent: idSchema.nullable(), disposition: z.string().nullable(),
28
28
  snapshot: snapshotSchema.nullable(), error: z.string().nullable(),
29
29
  registeredAgentId: idSchema.optional(),
30
+ discoveredFromSubmission: z.boolean().optional(),
30
31
  deploymentAnchor: z.object({ mergeCommit: z.string(), at: z.string() }).optional(),
31
32
  });
32
33
  export const obligationSchema = z.object({
@@ -56,6 +57,7 @@ export const batchSchema = z.object({
56
57
  });
57
58
  export const workspaceSchema = z.object({
58
59
  id: idSchema, title: textSchema, goal: textSchema, mainAgentId: idSchema,
60
+ contributorAgentIds: z.array(idSchema).optional(),
59
61
  enable_tracking: z.boolean().default(false), enable_auto_sends: z.boolean().default(false), hold: z.string().nullable(), archived: z.boolean(),
60
62
  prs: z.array(prSchema), obligations: z.array(obligationSchema), batches: z.array(batchSchema),
61
63
  excludedStacks: z.array(z.object({ name: textSchema, reason: textSchema, excludedAt: z.string(), prs: z.array(prSchema), obligations: z.array(obligationSchema) })).default([]),