paseo-follow-up 0.3.0 → 0.3.2
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 +1 -1
- package/README.md +20 -2
- package/index.server.ts +24 -13
- package/package.json +1 -1
- package/server/agent-hook.mjs +12 -5
- package/server/daemon.ts +55 -26
- package/server/discovery.ts +13 -2
- package/server/engine.ts +5 -2
- package/server/hook-source.ts +1 -1
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +7 -4
- package/server/recovery-handoff.ts +2 -1
- package/server/submissions.ts +75 -0
- package/server/workspace-agent.ts +20 -0
- package/shared/model.ts +2 -0
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
|
|
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
|
|
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
|
|
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,21 @@ 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.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
Agents launched by another agent into a separate workspace can be that new workspace’s main agent.
|
|
117
|
+
The creator’s parent-agent label records launch provenance; it excludes an agent from local main
|
|
118
|
+
selection only when the parent belongs to the same workspace. Parent membership is checked from
|
|
119
|
+
the daemon, including direct lookup when the creator is no longer in the live directory. Unknown
|
|
120
|
+
membership does not grant new scope. Workspace-first creation is supported: tracking waits for an
|
|
121
|
+
available local main, then applies the project default. Explicit Track also supports these launches.
|
package/index.server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import * as rpc from "./shared/contracts";
|
|
7
7
|
import { prOwner } from "./server/pr-owner";
|
|
8
8
|
import { type Workspace } from "./shared/model";
|
|
9
|
+
import { isWorkspaceRoot } from "./server/workspace-agent";
|
|
9
10
|
import { Store } from "./server/store";
|
|
10
11
|
import { Engine, agentWorkspace, findWorkspace, interruptionHold } from "./server/engine";
|
|
11
12
|
import { Github } from "./server/github";
|
|
@@ -63,6 +64,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
63
64
|
const callerWorkspaceId = async (agentId: string, paseo: PluginHandlerContext["paseo"], allowHandoff = true) => {
|
|
64
65
|
const agent = (await paseo.agents.ref(agentId).refresh())?.agent;
|
|
65
66
|
if (!agent?.workspaceId) throw new Error(`Cannot locate the caller's workspace: expected agent in a recorded workspace, received agent ${agentId}.`);
|
|
67
|
+
const record = (await store.snapshot()).workspaces.find(w => w.id === agent.workspaceId);
|
|
68
|
+
if (record?.contributorAgentIds?.includes(agentId) && !record.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && b.tasks.every(t => t.kind === "inventory")) && (!await isWorkspaceRoot(agent, async id => (await paseo.agents.ref(id).refresh())?.agent) || agent.labels["follow-through.recovery"])) throw new Error("PR registration requires an available root agent in this tracked workspace.");
|
|
66
69
|
if (allowHandoff) {
|
|
67
70
|
const w = (await store.snapshot()).workspaces.find(w => w.id === agent.workspaceId);
|
|
68
71
|
if (w && w.mainAgentId !== agentId && !w.batches.some(b => b.recipientAgentId === agentId)) {
|
|
@@ -75,6 +78,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
75
78
|
const at = new Date().toISOString();
|
|
76
79
|
b.recoveryHandoffs.push({ fromAgentId: handoff.fromAgentId, toAgentId: agentId, at });
|
|
77
80
|
b.recipientAgentId = agentId; b.activityAt = at;
|
|
81
|
+
record.contributorAgentIds = record.contributorAgentIds?.filter(id => id !== agentId);
|
|
78
82
|
if (b.state === "blocked") { b.state = "sent"; b.result = "Recovery continued in the replacement agent; prior inventory and evidence are preserved."; }
|
|
79
83
|
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
84
|
});
|
|
@@ -107,19 +111,19 @@ export default function contribute(server: PluginServerContext) {
|
|
|
107
111
|
server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
|
|
108
112
|
await requireReady();
|
|
109
113
|
const workspaceId = await callerWorkspaceId(agentId, paseo, false);
|
|
110
|
-
const w = agentWorkspace(await store.snapshot(), agentId, "write", workspaceId);
|
|
111
|
-
if (w.
|
|
114
|
+
const w = agentWorkspace(await store.snapshot(), agentId, "write", workspaceId, "pr");
|
|
115
|
+
if (w.archived) throw new Error(`Only an active workspace root agent can confirm sidebar sync: expected ${w.mainAgentId}, received ${agentId}.`);
|
|
112
116
|
await agentHooks.confirm(w.id, agentId, summary);
|
|
113
117
|
return { workspaceId: w.id, synced: true as const };
|
|
114
118
|
});
|
|
115
|
-
server.handle(rpc.agentStatusRpc, async ({ agentId }, { paseo }) => { await requireReady(); const workspaceId = await callerWorkspaceId(agentId, paseo); return agentWorkspace(await store.snapshot(), agentId, "read", workspaceId); });
|
|
119
|
+
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
120
|
server.handle(rpc.activateRpc, async ({ workspaceId }) => { await requireReady(); return engine.activate(workspaceId); });
|
|
117
121
|
server.handle(rpc.sendRpc, async ({ workspaceId, batchId }) => { await requireReady(); return engine.sendPending(workspaceId, batchId); });
|
|
118
122
|
server.handle(rpc.recoverRpc, async ({ workspaceId }) => { await requireReady(); return engine.startRecovery(workspaceId); });
|
|
119
123
|
server.handle(rpc.enrollRpc, async ({ agentId, workspaceId, goal }, { paseo }) => {
|
|
120
124
|
await requireReady();
|
|
121
125
|
const agent = (await paseo.agents.ref(agentId).refresh())?.agent;
|
|
122
|
-
if (!agent?.workspaceId || agent
|
|
126
|
+
if (!agent?.workspaceId || !await isWorkspaceRoot(agent, async id => (await paseo.agents.ref(id).refresh())?.agent)) throw new Error("Choose an available main agent in this workspace.");
|
|
123
127
|
if (workspaceId && workspaceId !== agent.workspaceId) throw new Error("The chosen agent belongs to another workspace.");
|
|
124
128
|
await callerWorkspaceId(agentId, paseo);
|
|
125
129
|
const workspace = await paseo.workspaces.ref(agent.workspaceId).refresh();
|
|
@@ -139,7 +143,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
139
143
|
await requireReady();
|
|
140
144
|
const workspaceId = await callerWorkspaceId(agentId, paseo);
|
|
141
145
|
const result = await store.mutate(state => {
|
|
142
|
-
const w = agentWorkspace(state, agentId, "write", workspaceId);
|
|
146
|
+
const w = agentWorkspace(state, agentId, "write", workspaceId, "pr");
|
|
143
147
|
const key = `${repo}#${number}`;
|
|
144
148
|
const excluded = w.excludedStacks.find(s => s.name === stack || s.prs.some(p => p.key === key));
|
|
145
149
|
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 +158,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
154
158
|
seen.add(ancestor); ancestor = w.prs.find(p => p.key === ancestor)?.parent ?? null;
|
|
155
159
|
}
|
|
156
160
|
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; }
|
|
161
|
+
if (existing) { existing.registeredAgentId = agentId; existing.discoveredFromSubmission = false; existing.stack = stack; existing.parent = parent; if (disposition !== undefined) existing.disposition = disposition; }
|
|
158
162
|
else w.prs.push({ registeredAgentId: agentId, key, repo, number, stack, parent, disposition: disposition ?? null, snapshot: null, error: null });
|
|
159
163
|
if (altered) changed(w); return w;
|
|
160
164
|
});
|
|
@@ -254,9 +258,9 @@ export default function contribute(server: PluginServerContext) {
|
|
|
254
258
|
const removers = [
|
|
255
259
|
server.on("agent.turn_started", async event => {
|
|
256
260
|
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; });
|
|
261
|
+
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
262
|
}),
|
|
259
|
-
server.before("agent.session_open", async ({ request }) => {
|
|
263
|
+
server.before("agent.session_open", async ({ request }, { paseo }) => {
|
|
260
264
|
await ready;
|
|
261
265
|
if (loadError || stopped || request.purpose !== "interactive") return request;
|
|
262
266
|
const saved = await store.snapshot();
|
|
@@ -272,8 +276,15 @@ export default function contribute(server: PluginServerContext) {
|
|
|
272
276
|
return { ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry() } };
|
|
273
277
|
}
|
|
274
278
|
}
|
|
279
|
+
if (!w && request.workspaceId && !request.labels["follow-through.recovery"] && await isWorkspaceRoot({ id: request.agentId, workspaceId: request.workspaceId, labels: request.labels }, async id => (await paseo.agents.ref(id).refresh())?.agent)) {
|
|
280
|
+
w = await store.mutate(state => {
|
|
281
|
+
const workspace = state.workspaces.find(w => w.id === request.workspaceId && w.enable_tracking && !w.archived);
|
|
282
|
+
if (workspace) workspace.contributorAgentIds = [...new Set([...(workspace.contributorAgentIds ?? []), request.agentId])];
|
|
283
|
+
return workspace;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
275
286
|
if (!w) return request;
|
|
276
|
-
const recovery = w.mainAgentId !== request.agentId;
|
|
287
|
+
const recovery = w.mainAgentId !== request.agentId && !w.contributorAgentIds?.includes(request.agentId);
|
|
277
288
|
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
289
|
}),
|
|
279
290
|
server.on("agent.turn_ended", async (event, { paseo, signal }) => {
|
|
@@ -286,20 +297,20 @@ export default function contribute(server: PluginServerContext) {
|
|
|
286
297
|
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
298
|
void engine.tick(); return;
|
|
288
299
|
}
|
|
289
|
-
if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && w.mainAgentId === event.agent.id)) return;
|
|
300
|
+
if (!(await store.snapshot()).workspaces.some(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)))) return;
|
|
290
301
|
gateway.markDirty(event.agent.id);
|
|
291
302
|
// Since Paseo 0.13 the lifecycle payload deliberately contains no history.
|
|
292
303
|
// Read a bounded tail through the plugin-owned API, outside the state lock.
|
|
293
304
|
const history = await paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 100, projection: "canonical" }).catch(() => null);
|
|
294
305
|
if (signal.aborted || stopped) return;
|
|
295
306
|
if (history) await store.mutate(state => {
|
|
296
|
-
const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === event.agent.id);
|
|
307
|
+
const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
|
|
297
308
|
if (w) applyHistory(w, extractHistory(history.entries.map(entry => entry.item), w.inventory?.historyComplete ?? false, w.inventory?.historyError ?? null), null);
|
|
298
309
|
});
|
|
299
310
|
const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
|
|
300
311
|
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; }
|
|
312
|
+
const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
|
|
313
|
+
if (w && w.mainAgentId === event.agent.id && interruption) { w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null; }
|
|
303
314
|
});
|
|
304
315
|
void engine.tick();
|
|
305
316
|
}),
|
package/package.json
CHANGED
package/server/agent-hook.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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" +
|
|
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,8 +8,10 @@ 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 {
|
|
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";
|
|
14
|
+
import { isWorkspaceRoot, type WorkspaceAgent } from "./workspace-agent";
|
|
13
15
|
import { SessionGuard } from "./session-guard";
|
|
14
16
|
import { DeliveryDeferred } from "./delivery";
|
|
15
17
|
type RecoveryAgentOptions = Partial<PluginBeforeRequests["agent.create"]["config"]> & { workspaceId: string; labels: Record<string, string>; idempotencyKey: string; env: Record<string, string> };
|
|
@@ -88,6 +90,7 @@ export class PaseoGateway implements Gateway {
|
|
|
88
90
|
private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
|
|
89
91
|
private dirty = new Set<string>();
|
|
90
92
|
markDirty(agentId: string): void { this.dirty.add(agentId); }
|
|
93
|
+
private histories = new Map<string, { messageAt: string | null; at: number; history: History; submissions: Submission[] }>();
|
|
91
94
|
async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
|
|
92
95
|
return this.connect(async client => {
|
|
93
96
|
const [agents, workspaces, catalog] = await Promise.all([
|
|
@@ -96,40 +99,66 @@ export class PaseoGateway implements Gateway {
|
|
|
96
99
|
client.listProjects(),
|
|
97
100
|
]);
|
|
98
101
|
this.directory = { agents, workspaces };
|
|
102
|
+
const parents = new Map<string, WorkspaceAgent | null>(agents.map(({ agent }) => [agent.id, agent]));
|
|
103
|
+
const findParent = async (id: string) => {
|
|
104
|
+
if (!parents.has(id)) parents.set(id, (await client.fetchAgent(id).catch(() => null))?.agent ?? null);
|
|
105
|
+
return parents.get(id);
|
|
106
|
+
};
|
|
99
107
|
const discovered: DiscoveredWorkspace[] = [];
|
|
100
|
-
|
|
108
|
+
const scans: { row: DiscoveredWorkspace; agent: Agent; repo: string | null }[] = [];
|
|
101
109
|
const defaults = new Set(catalog.projects.filter(p => defaultTracking(p, state.projectDefaults)).map(p => p.projectId));
|
|
102
110
|
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
111
|
const existing = state.workspaces.find(w => w.id === workspace.id);
|
|
104
112
|
if (existing?.archived) continue;
|
|
105
|
-
const
|
|
113
|
+
const candidates = agents.map(row => row.agent).filter(a => a.workspaceId === workspace.id && !a.archivedAt && !a.labels["follow-through.recovery"]);
|
|
114
|
+
const roots: Agent[] = [];
|
|
115
|
+
for (const agent of candidates) if (await isWorkspaceRoot(agent, findParent)) roots.push(agent);
|
|
106
116
|
const main = chooseMainAgent(roots, existing?.mainAgentId);
|
|
107
|
-
if (!main) continue;
|
|
108
|
-
const row: DiscoveredWorkspace = { id: workspace.id, title: workspace.name || main
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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);
|
|
117
|
+
if (!main && !existing) continue;
|
|
118
|
+
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) };
|
|
119
|
+
// Preserve the designated owner even when it is no longer indexed. Other root
|
|
120
|
+
// sessions can still contribute their PRs without acquiring completion authority.
|
|
121
|
+
const project = catalog.projects.find(p => p.projectId === workspace.projectId);
|
|
122
|
+
const repo = project?.projectKind === "git" ? /^remote:github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(project.projectKey)?.[1] ?? null : null;
|
|
123
|
+
for (const agent of roots) {
|
|
124
|
+
const cached = this.histories.get(agent.id);
|
|
125
|
+
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
126
|
}
|
|
131
127
|
discovered.push(row);
|
|
132
128
|
}
|
|
129
|
+
// Oldest scan first prevents busy workspaces from starving later sessions.
|
|
130
|
+
scans.sort((a, b) => (this.histories.get(a.agent.id)?.at ?? 0) - (this.histories.get(b.agent.id)?.at ?? 0) || (b.agent.lastUserMessageAt ?? "").localeCompare(a.agent.lastUserMessageAt ?? ""));
|
|
131
|
+
for (const { agent, repo } of scans.slice(0, 4)) {
|
|
132
|
+
let history: History; let submissions: Submission[] = [];
|
|
133
|
+
try {
|
|
134
|
+
const entries: { item: unknown; seqStart: number }[] = [];
|
|
135
|
+
let cursor: { epoch: string; seq: number } | null = null;
|
|
136
|
+
let complete = false;
|
|
137
|
+
for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
|
|
138
|
+
const page = await client.fetchAgentTimeline(agent.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
|
|
139
|
+
if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
|
|
140
|
+
entries.push(...page.entries);
|
|
141
|
+
if (!page.hasOlder) { complete = true; break; }
|
|
142
|
+
if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
|
|
143
|
+
cursor = page.startCursor;
|
|
144
|
+
}
|
|
145
|
+
const items = entries.sort((a, b) => a.seqStart - b.seqStart).map(e => e.item);
|
|
146
|
+
history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
|
|
147
|
+
submissions = submittedPrs(items, agent.id, repo);
|
|
148
|
+
} catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
|
|
149
|
+
this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at: Date.now(), history, submissions });
|
|
150
|
+
this.dirty.delete(agent.id);
|
|
151
|
+
}
|
|
152
|
+
for (const row of discovered) {
|
|
153
|
+
const ids = [row.mainAgentId, ...(row.contributorAgentIds ?? [])];
|
|
154
|
+
const cached = ids.flatMap(id => { const h = this.histories.get(id); return h ? [h] : []; });
|
|
155
|
+
if (cached.length) {
|
|
156
|
+
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 };
|
|
157
|
+
row.submissions = cached.flatMap(c => c.submissions);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const known = new Set(agents.map(a => a.agent.id));
|
|
161
|
+
for (const id of this.histories.keys()) if (!known.has(id)) this.histories.delete(id);
|
|
133
162
|
return discovered;
|
|
134
163
|
});
|
|
135
164
|
}
|
package/server/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/server/hook-source.ts
CHANGED
|
@@ -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(
|
|
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";
|
package/server/mcp-source.ts
CHANGED
|
@@ -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.2\" }, 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.
|
|
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"
|
|
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.
|
|
149
|
+
result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.2" }, 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") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isWorkspaceRoot } from "./workspace-agent";
|
|
1
2
|
import type { Workspace } from "../shared/model";
|
|
2
3
|
import type { Agent, Daemon } from "./daemon";
|
|
3
4
|
|
|
@@ -19,7 +20,7 @@ async function userMessages(client: Daemon, agentId: string): Promise<string[]>
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export async function recoveryHandoff(client: Daemon, w: Workspace, caller: Agent): Promise<{ batchId: string; fromAgentId: string } | null> {
|
|
22
|
-
if (!w.enable_tracking || w.archived || caller.archivedAt || caller.workspaceId !== w.id || caller.id === w.mainAgentId || caller.
|
|
23
|
+
if (!w.enable_tracking || w.archived || caller.archivedAt || caller.workspaceId !== w.id || caller.id === w.mainAgentId || !await isWorkspaceRoot(caller, async id => (await client.fetchAgent(id))?.agent)) return null;
|
|
23
24
|
const candidates = w.batches.filter(b => b.recipientAgentId && b.recipientAgentId !== w.mainAgentId && b.recipientAgentId !== caller.id && b.tasks.every(t => t.kind === "inventory") && ["sending", "sent", "uncertain", "blocked"].includes(b.state) && !b.settledAt);
|
|
24
25
|
if (!candidates.length) return null;
|
|
25
26
|
const initialTask = (await userMessages(client, caller.id))[0];
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type WorkspaceAgent = {
|
|
2
|
+
id: string;
|
|
3
|
+
workspaceId?: string | null;
|
|
4
|
+
archivedAt?: string | null;
|
|
5
|
+
labels: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Parent labels record who launched an agent, including launches into another workspace. */
|
|
9
|
+
export async function isWorkspaceRoot(
|
|
10
|
+
agent: WorkspaceAgent,
|
|
11
|
+
findParent: (id: string) => Promise<WorkspaceAgent | null | undefined>,
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
if (!agent.workspaceId || agent.archivedAt) return false;
|
|
14
|
+
const parentId = agent.labels["paseo.parent-agent-id"];
|
|
15
|
+
if (!parentId) return true;
|
|
16
|
+
if (parentId === agent.id) return false;
|
|
17
|
+
const parent = await findParent(parentId).catch(() => null);
|
|
18
|
+
// Unknown parent membership does not grant a new ownership or tool scope.
|
|
19
|
+
return !!parent && parent.id === parentId && !!parent.workspaceId && parent.workspaceId !== agent.workspaceId;
|
|
20
|
+
}
|
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([]),
|