paseo-follow-up 0.3.4 → 0.3.5
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 +1 -1
- package/client/components.tsx +1 -1
- package/client/display.tsx +1 -2
- package/client/navigation.tsx +15 -6
- package/client/panel.tsx +1 -1
- package/index.client.tsx +7 -7
- package/package.json +1 -1
- package/server/github.ts +23 -7
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +2 -2
- package/shared/model.ts +1 -1
- package/shared/presentation.ts +1 -1
package/OVERVIEW.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Follow-up keeps PR stacks, remaining tasks and explicit agent completion attached to a Paseo workspace. It groups workspaces by whether you or an agent can act next and retains settled PR history.
|
|
4
4
|
|
|
5
|
-
Requires Paseo 0.14+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting
|
|
5
|
+
Requires Paseo 0.14+, Node 22+ and authenticated gh on the daemon host. Graphite CLI authentication is used for readiness and explicit Merge. The optional Send to Paseo browser companion enables Graphite's Merge when ready setting through the existing pairing, with confirmation or remembered permission for toggle changes in Chrome; it is not needed for tracking, recovery or follow-up.
|
|
6
6
|
|
|
7
7
|
Open the sidebar or Command-K, then Track this workspace. Project settings can enroll existing and new workspaces with original main-agent ownership; Auto always starts off. The retained development default enables canonical Git repositories named hero-poc, while other projects require opt-in. Disable that project default to stop further enrollment without losing records.
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The installation ID remains `follow-through` to preserve existing development in
|
|
|
28
28
|
|
|
29
29
|
## Use
|
|
30
30
|
|
|
31
|
-
Open **Follow-up** from the sidebar or Command-K. In a workspace, use the header button or the Follow-up panel and click **Track this workspace**. The original main agent owns PRs, tasks and completion; optional history recovery runs in a separate agent only after your click.
|
|
31
|
+
Open **Follow-up** from the sidebar or Command-K. The cog at the bottom opens native project settings; display settings remain in Paseo’s plugin settings. In a workspace, use the header button or the Follow-up panel and click **Track this workspace**. The original main agent owns PRs, tasks and completion; optional history recovery runs in a separate agent only after your click.
|
|
32
32
|
|
|
33
33
|
Use **Overview**, **Project settings** and **Display settings** to move between views. Settings are also available directly from Command-K. Display settings can hide the workspace header button or sidebar entry independently; Command-K remains available.
|
|
34
34
|
|
package/client/components.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export function PrRow({ pr, theme, onOpen, compact, workspace, browserReady }: {
|
|
|
38
38
|
return <View style={{ paddingVertical: compact ? 6 : 5, gap: 3, borderTopWidth: 1, borderColor: theme.colors.border }}><View style={{ flexDirection: "row", alignItems: "center" }}><Pressable accessibilityRole="link" accessibilityLabel={`Open ${pr.repo} pull request ${pr.number} in Graphite: ${s?.title ?? pr.stack}, ${status}`} onPress={() => onOpen(graphitePrUrl(pr))} style={({ pressed }) => ({ flex: 1, backgroundColor: pressed ? theme.colors.surface1 : "transparent", minHeight: compact ? 44 : 36, justifyContent: "center" })}>
|
|
39
39
|
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}><View style={{ width: 88, gap: 2 }}><View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}><Icon name={s?.state === "MERGED" ? "GitMerge" : "GitPullRequest"} size={14} color={color} /><Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 16 }}>#{pr.number}</Text></View><Text style={{ fontSize: 11, lineHeight: 16, color }}>{status}</Text></View><Text numberOfLines={details ? undefined : 2} style={{ color: theme.colors.foreground, fontSize: 13, lineHeight: 18, flex: 1 }}>{s?.title ?? `${pr.repo} pull request`}</Text></View>
|
|
40
40
|
</Pressable><Pressable accessibilityRole="button" accessibilityLabel={`${details ? "Hide" : "Show"} details for PR ${pr.number}`} aria-expanded={details} accessibilityState={{ expanded: details }} onPress={() => setDetails(!details)} style={{ minHeight: compact ? 44 : 36, width: compact ? 44 : 36, justifyContent: "center", alignItems: "center" }}><Icon name={details ? "ChevronUp" : "ChevronDown"} size={12} color={theme.colors.foregroundMuted} /></Pressable></View>
|
|
41
|
-
{details && <View style={{ gap: 4 }}><Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{pr.repo} · {s?.state === "MERGED" ? "Merged" : status}</Text>{pr.error && <Text style={{ fontSize: 12, color: theme.colors.statusWarning }}>{pr.error}</Text>}{s?.reviewers?.complete && [...s.reviewers.requested, ...s.reviewers.teams, ...s.reviewers.reviewed].length > 0 && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>Reviewers: {[...new Set([...s.reviewers.requested, ...s.reviewers.teams.map(team => `team/${team}`), ...s.reviewers.reviewed])].join(", ")}</Text>}{s?.deployment && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>{s.deployment.evidence}</Text>}</View>}
|
|
41
|
+
{details && <View style={{ gap: 4 }}><Text style={{ fontSize: 12, color: theme.colors.foregroundMuted }}>{pr.repo} · {s?.state === "MERGED" ? "Merged" : status}</Text>{pr.error && <Text style={{ fontSize: 12, color: theme.colors.statusWarning }}>{pr.error}</Text>}{!!s?.failedChecks.length && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.statusWarning }}>Required checks failed: {s.failedChecks.join(", ")}</Text>}{!!s?.otherFailedChecks?.length && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.statusWarning }}>Other checks failed: {s.otherFailedChecks.join(", ")}</Text>}{s?.reviewers?.complete && [...s.reviewers.requested, ...s.reviewers.teams, ...s.reviewers.reviewed].length > 0 && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>Reviewers: {[...new Set([...s.reviewers.requested, ...s.reviewers.teams.map(team => `team/${team}`), ...s.reviewers.reviewed])].join(", ")}</Text>}{s?.deployment && <Text style={{ fontSize: 12, lineHeight: 18, color: theme.colors.foregroundMuted }}>{s.deployment.evidence}</Text>}</View>}
|
|
42
42
|
{s?.state === "OPEN" && <PrControls browserReady={browserReady} pr={pr} workspace={workspace} theme={theme} compact={compact} />}
|
|
43
43
|
</View>;
|
|
44
44
|
}
|
package/client/display.tsx
CHANGED
|
@@ -4,7 +4,6 @@ import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@
|
|
|
4
4
|
import { useEffect } from "react";
|
|
5
5
|
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
|
6
6
|
import { displayPreferences, displayRpc } from "../shared/display";
|
|
7
|
-
import { withNavigation } from "./navigation";
|
|
8
7
|
import { sidebarVisibility } from "./sidebar-visibility";
|
|
9
8
|
|
|
10
9
|
type Display = import("zod").infer<typeof displayPreferences.schema>;
|
|
@@ -58,7 +57,7 @@ export function contributeDisplay(client: PluginClientContext, item: PluginSideb
|
|
|
58
57
|
// Register synchronously, then reconcile the saved preference, so that an
|
|
59
58
|
// already-open sidebar route can resolve its surface during that first render.
|
|
60
59
|
sidebar.set(true);
|
|
61
|
-
const Settings =
|
|
60
|
+
const Settings = DisplaySettings;
|
|
62
61
|
const removeSurface = client.addSurface("display-settings", Settings);
|
|
63
62
|
const removeSettings = client.addSettingsScreen({ id: "display", title: "Display", icon: "PanelLeft", Component: Settings });
|
|
64
63
|
const removeCommand = client.addCommandCenterItem({
|
package/client/navigation.tsx
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
3
|
-
import {
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { Pressable, View } from "react-native";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
/** Keep settings outside the scrolling workspace content and its primary controls. */
|
|
7
|
+
export function withSettingsFooter<P extends PluginSurfaceProps>(Component: ComponentType<P>, client: PluginClientContext) {
|
|
6
8
|
return function PluginView(props: P) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const size = props.layout.compact ? 44 : 36;
|
|
10
|
+
return <View style={{ flex: 1, minHeight: 0, backgroundColor: props.theme.colors.surface0 }}>
|
|
11
|
+
<View style={{ flex: 1, minHeight: 0 }}><Component {...props} /></View>
|
|
12
|
+
<View style={{ flexShrink: 0, alignItems: "flex-end", paddingHorizontal: 8, paddingVertical: 4, borderTopWidth: 1, borderColor: props.theme.colors.border }}>
|
|
13
|
+
<Pressable
|
|
14
|
+
accessibilityRole="button"
|
|
15
|
+
accessibilityLabel="Follow-up settings"
|
|
16
|
+
accessibilityHint="Open Follow-up project settings"
|
|
17
|
+
onPress={() => client.openSettings("projects")}
|
|
18
|
+
style={({ pressed }) => ({ width: size, height: size, alignItems: "center", justifyContent: "center", borderRadius: 6, backgroundColor: pressed ? props.theme.colors.surface2 : "transparent" })}
|
|
19
|
+
><Icon name="Settings" size={16} color={props.theme.colors.foregroundMuted} /></Pressable>
|
|
10
20
|
</View>
|
|
11
|
-
<Component {...props} />
|
|
12
21
|
</View>;
|
|
13
22
|
};
|
|
14
23
|
}
|
package/client/panel.tsx
CHANGED
|
@@ -55,7 +55,7 @@ export function WorkspaceView({ theme, layout, workspace, navigation, loadError
|
|
|
55
55
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Text style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600", flex: 1 }}>Follow-up</Text><Pressable accessibilityRole="switch" aria-checked={workspace.enable_auto_sends} accessibilityLabel="Auto" accessibilityHint="Allow automatic sends in this workspace" accessibilityState={{ checked: workspace.enable_auto_sends, disabled: change.isPending }} disabled={change.isPending} onPress={() => change.mutate({ workspaceId: workspace.id, enable_auto_sends: !workspace.enable_auto_sends })} style={{ minHeight: compact ? 44 : 36, paddingHorizontal: 10, borderRadius: 6, borderWidth: 1, borderColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.border, backgroundColor: workspace.enable_auto_sends ? theme.colors.accent : theme.colors.surface0, flexDirection: "row", alignItems: "center", gap: 5, opacity: change.isPending ? 0.5 : 1 }}><Icon name={workspace.enable_auto_sends ? "Check" : "Circle"} size={13} color={workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foregroundMuted} /><Text style={{ fontSize: 13, color: workspace.enable_auto_sends ? theme.colors.accentForeground : theme.colors.foreground }}>Auto</Text></Pressable><Button title={update.isPending ? "Refreshing…" : "Refresh"} icon="RefreshCw" theme={theme} compact={compact} disabled={update.isPending} onPress={() => update.mutate()} /></View>
|
|
56
56
|
<View style={{ marginTop: 8, gap: 6 }}><StatusLabel workspace={workspace} theme={theme} />{(state.status === "needs-you" || state.status === "done") && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{state.reason}</Text>}{workspace.hold && <Button title="Resume follow-up" theme={theme} compact={compact} onPress={() => change.mutate({ workspaceId: workspace.id, clearHold: true })} disabled={change.isPending} />}</View>
|
|
57
57
|
{error && <Text accessibilityRole="alert" style={{ color: theme.colors.statusDanger, fontSize: 12, marginTop: 8 }}>{error}</Text>}
|
|
58
|
-
<Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>
|
|
58
|
+
<Section title="Pull requests" count={workspace.prs.length} theme={theme} compact={compact}>{workspace.prs.length === 0 && <Text style={labelStyle}>No PRs recorded.</Text>}{workspace.prActions.filter(a => ["sending", "uncertain"].includes(a.state)).map(a => <View key={a.id} style={{ gap: 7 }}><Text style={{ ...labelStyle, color: theme.colors.statusWarning, lineHeight: 18 }}>{a.result}</Text>{a.state === "uncertain" && <Button title={inspectAction.isPending ? "Checking…" : "Check state"} theme={theme} compact={compact} disabled={inspectAction.isPending} onPress={() => inspectAction.mutate(a.id)} />}</View>)}{inspectAction.error && <Text accessibilityRole="alert" style={{ ...labelStyle, color: theme.colors.statusDanger }}>{inspectAction.error.message}</Text>}{activeStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}{settledStacks.length > 0 && <Section title="Settled" count={settledStacks.reduce((count, stack) => count + stack.prs.length, 0)} theme={theme} compact={compact} initiallyExpanded={false}>{settledStacks.map(stack => <StackGroup browserReady={browser.data?.ready ?? false} key={stack.name} name={stack.name} prs={stack.prs} workspace={workspace} theme={theme} onOpen={open} compact={compact} />)}</Section>}{activeStacks.length > 0 && browser.data && !browser.data.ready && <Section title="Optional merge when ready" theme={theme} compact={compact} initiallyExpanded={false}><Text style={[labelStyle, { lineHeight: 18 }]}>Connect Chrome for Graphite’s When ready toggle.</Text><Text style={[labelStyle, { lineHeight: 18 }]}>Keep Graphite open and signed in. Send to Paseo uses your existing connection; approve toggle changes in Chrome unless you have remembered permission.</Text><Button title="Connect Graphite" theme={theme} compact={compact} onPress={() => open("https://app.graphite.com")} /></Section>}</Section>
|
|
59
59
|
<Section title="Tasks" count={outstanding.length} theme={theme} compact={compact}>
|
|
60
60
|
{!outstanding.length && <Text style={labelStyle}>No follow-through tasks.</Text>}
|
|
61
61
|
{outstanding.map(o => <FollowTask key={o.id} obligation={o} workspace={workspace} theme={theme} compact={compact} />)}
|
package/index.client.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { contributeDisplay } from "./client/display";
|
|
2
|
-
import {
|
|
2
|
+
import { withSettingsFooter } from "./client/navigation";
|
|
3
3
|
import { withButtonVisibility } from "./client/button-visibility";
|
|
4
4
|
import type { PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
5
5
|
import { Dimensions } from "react-native";
|
|
@@ -13,14 +13,14 @@ export default function contribute(host: PluginClientContext) {
|
|
|
13
13
|
const location = () => Dimensions.get("window").width < 768 ? "workspace" as const : "explorer" as const;
|
|
14
14
|
const panel = () => location() === "workspace" ? "follow-through-tab" : "follow-through";
|
|
15
15
|
const removeDisplay = contributeDisplay(client, { id: "follow-through-overview", title: "Follow-up", icon: "GitPullRequest", surface: "follow-through" });
|
|
16
|
-
client.addSettingsScreen({ id: "projects", title: "Follow-up", icon: "GitPullRequest", Component:
|
|
17
|
-
client.addScreen({ id: "follow-through-settings", title: "Follow-up settings", Component:
|
|
16
|
+
client.addSettingsScreen({ id: "projects", title: "Follow-up", icon: "GitPullRequest", Component: FollowSettings });
|
|
17
|
+
client.addScreen({ id: "follow-through-settings", title: "Follow-up settings", Component: FollowSettings });
|
|
18
18
|
client.addCommandCenterItem({ id: "follow-through-settings", title: "Follow-up: project settings", icon: "Settings", keywords: ["default", "tracking", "project"], context: "global", onSelect({ openScreen }) { openScreen({ screenId: "follow-through-settings" }); } });
|
|
19
|
-
client.addSurface("follow-through",
|
|
20
|
-
client.addSurface("project-settings",
|
|
19
|
+
client.addSurface("follow-through", withSettingsFooter(FollowOverview, client));
|
|
20
|
+
client.addSurface("project-settings", FollowSettings);
|
|
21
21
|
|
|
22
|
-
client.addWorkspacePanel({ id: "follow-through", title: "Follow-up", icon: "GitPullRequest", context: "workspace", locations: ["workspace", "explorer"], Component:
|
|
23
|
-
client.addWorkspacePanel({ id: "follow-through-tab", title: "Follow-up", icon: "GitPullRequest", context: "workspace", locations: ["workspace"], Component:
|
|
22
|
+
client.addWorkspacePanel({ id: "follow-through", title: "Follow-up", icon: "GitPullRequest", context: "workspace", locations: ["workspace", "explorer"], Component: withSettingsFooter(FollowPanel, client) });
|
|
23
|
+
client.addWorkspacePanel({ id: "follow-through-tab", title: "Follow-up", icon: "GitPullRequest", context: "workspace", locations: ["workspace"], Component: withSettingsFooter(FollowPanel, client) });
|
|
24
24
|
client.addCommandCenterItem({ id: "open-follow-through", title: "Open workspace follow-up", icon: "GitPullRequest", keywords: ["PR", "stack", "done", "archive"], context: "workspace", onSelect({ openPanel }) { openPanel(panel(), { location: location() }); } });
|
|
25
25
|
client.addCommandCenterItem({ id: "follow-through-overview", title: "Open follow-up overview", icon: "GitPullRequest", keywords: ["workspaces", "attention", "done"], context: "global", onSelect({ openScreen }) { openScreen({ screenId: "follow-through" }); } });
|
|
26
26
|
client.addSlashCommand({ name: "follow-through", description: "Open this workspace's PRs and remaining work", argumentHint: "", context: "agent", onSubmit({ openPanel }) { openPanel(panel(), { location: location() }); } });
|
package/package.json
CHANGED
package/server/github.ts
CHANGED
|
@@ -19,12 +19,16 @@ export function parsePr(pr: Node, trunk: string, observedAt: string, required: s
|
|
|
19
19
|
const checks = contexts?.nodes ?? [];
|
|
20
20
|
const seen = new Set<string>();
|
|
21
21
|
const failedChecks: string[] = [];
|
|
22
|
+
const otherFailedChecks: string[] = [];
|
|
22
23
|
let checksPending = false;
|
|
23
24
|
for (const c of checks) {
|
|
24
25
|
const name = c.name ?? c.context;
|
|
25
|
-
if (!requiredSet.has(name)) continue;
|
|
26
|
-
seen.add(name);
|
|
27
26
|
const conclusion = c.conclusion ?? c.state;
|
|
27
|
+
if (!requiredSet.has(name)) {
|
|
28
|
+
if ((!c.status || c.status === "COMPLETED") && failed.has(conclusion)) otherFailedChecks.push(name);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
seen.add(name);
|
|
28
32
|
if ((c.status && c.status !== "COMPLETED") || !conclusion || conclusion === "PENDING") checksPending = true;
|
|
29
33
|
else if (failed.has(conclusion)) failedChecks.push(name);
|
|
30
34
|
}
|
|
@@ -42,7 +46,7 @@ export function parsePr(pr: Node, trunk: string, observedAt: string, required: s
|
|
|
42
46
|
},
|
|
43
47
|
mergeWhenReady: "unknown",
|
|
44
48
|
threads: threads.nodes.filter((t: Node) => !t.isResolved).map((t: Node) => ({ id: t.id, updatedAt: t.comments.nodes[0]?.updatedAt ?? observedAt })),
|
|
45
|
-
failedChecks, checksPending,
|
|
49
|
+
failedChecks, otherFailedChecks, checksPending,
|
|
46
50
|
complete: !threads.pageInfo.hasNextPage && !contexts?.pageInfo.hasNextPage && (pr.state !== "OPEN" || pr.mergeable !== "UNKNOWN"), observedAt,
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -51,7 +55,7 @@ const fields = `number title url state headRefOid headRefName baseRefName isDraf
|
|
|
51
55
|
author{login}
|
|
52
56
|
reviewRequests(first:100){pageInfo{hasNextPage} nodes{requestedReviewer{__typename ...on User{login} ...on Team{slug}}}}
|
|
53
57
|
latestReviews(first:100){pageInfo{hasNextPage} nodes{state author{__typename login}}}
|
|
54
|
-
baseRef{branchProtectionRule{requiredStatusCheckContexts}}
|
|
58
|
+
baseRef{branchProtectionRule{requiredStatusCheckContexts} associatedPullRequests(first:100,states:[OPEN]){nodes{number headRefName state headRepository{nameWithOwner}}}}
|
|
55
59
|
reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved comments(last:1){nodes{updatedAt}}}}
|
|
56
60
|
commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{
|
|
57
61
|
...on CheckRun{name status conclusion} ...on StatusContext{context state}
|
|
@@ -87,20 +91,32 @@ export class Github {
|
|
|
87
91
|
for (let offset = 0; offset < targets.length; offset += 15) {
|
|
88
92
|
const group = targets.slice(offset, offset + 15);
|
|
89
93
|
const [owner, name] = repo.split("/");
|
|
90
|
-
const query = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{name} ${group.map((p, i) => `p${i}:pullRequest(number:${p.number}){${fields}}`).join(" ")}}}`;
|
|
94
|
+
const query = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){defaultBranchRef{name branchProtectionRule{requiredStatusCheckContexts}} ${group.map((p, i) => `p${i}:pullRequest(number:${p.number}){${fields}}`).join(" ")}}}`;
|
|
91
95
|
try {
|
|
92
96
|
const payload = JSON.parse(await this.run(["api", "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-f", `query=${query}`], repo));
|
|
93
97
|
if (payload.errors?.length) throw new Error("Partial GitHub response");
|
|
94
98
|
const repository = payload.data?.repository;
|
|
95
99
|
if (!repository) throw new Error("Repository unavailable");
|
|
100
|
+
const stacked = (data: Node) => data.baseRefName !== repository.defaultBranchRef.name &&
|
|
101
|
+
data.baseRef?.associatedPullRequests?.nodes.some((parent: Node) => parent.state === "OPEN" && parent.number !== data.number && parent.headRefName === data.baseRefName && parent.headRepository?.nameWithOwner?.toLowerCase() === repo.toLowerCase());
|
|
102
|
+
const branches = group.flatMap((_, i) => {
|
|
103
|
+
const data = repository[`p${i}`];
|
|
104
|
+
return data?.state === "OPEN" ? [data.baseRefName, ...(stacked(data) ? [repository.defaultBranchRef.name] : [])] : [];
|
|
105
|
+
});
|
|
96
106
|
const rules = new Map<string, string[]>();
|
|
97
|
-
for (const branch of [...new Set(
|
|
107
|
+
for (const branch of [...new Set(branches)] as string[]) {
|
|
98
108
|
rules.set(branch, await this.ruleContexts(repo, branch));
|
|
99
109
|
}
|
|
100
110
|
group.forEach((p, i) => {
|
|
101
111
|
const data = repository[`p${i}`];
|
|
102
112
|
if (!data) { result.set(p.key, { snapshot: null, error: "PR unavailable; check access and its number" }); return; }
|
|
103
|
-
|
|
113
|
+
// A stacked PR is eventually checked against trunk. An unprotected parent
|
|
114
|
+
// branch must not erase the required checks that apply to the stack.
|
|
115
|
+
const trunkChecks = stacked(data) ? [
|
|
116
|
+
...(repository.defaultBranchRef.branchProtectionRule?.requiredStatusCheckContexts ?? []),
|
|
117
|
+
...(rules.get(repository.defaultBranchRef.name) ?? []),
|
|
118
|
+
] : [];
|
|
119
|
+
const required = [...new Set([...(data.baseRef?.branchProtectionRule?.requiredStatusCheckContexts ?? []), ...(rules.get(data.baseRefName) ?? []), ...trunkChecks])];
|
|
104
120
|
result.set(p.key, { snapshot: parsePr(data, repository.defaultBranchRef.name, new Date().toISOString(), required), error: null });
|
|
105
121
|
});
|
|
106
122
|
} catch {
|
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. 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.4\" }, instructions: role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\" };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], input);\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
|
|
2
|
+
export const mcpSource = "#!/usr/bin/env node\nimport { createInterface } from \"node:readline\";\nimport { createRequire } from \"node:module\";\nimport { pathToFileURL } from \"node:url\";\nimport { readFile } from \"node:fs/promises\";\nimport { existsSync, realpathSync } from \"node:fs\";\nimport { join, delimiter } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nconst string = { type: \"string\" };\nconst boolean = { type: \"boolean\" };\nconst integer = { type: \"integer\", minimum: 1 };\nconst array = { type: \"array\", items: string };\nconst communication = { type: \"object\", properties: { threadUrl: string, recipient: string, purpose: { type: \"string\", enum: [\"progress\", \"verified\"] }, authorization: string, replyUrl: { type: [\"string\", \"null\"] } }, required: [\"threadUrl\", \"recipient\"], additionalProperties: false };\nconst trigger = { type: [\"object\", \"null\"], properties: { event: { type: \"string\", enum: [\"now\", \"merge\", \"deploy\"] }, delayDays: { type: \"integer\", minimum: 0, maximum: 3650 } }, required: [\"event\"], additionalProperties: false };\nconst tools = [\n [\"workspace_status\", \"Read the tracked workspace's durable goal, PRs, tasks, work queue and optional recovery history. Read after compaction. Returns current work by default; detail=full includes complete evidence/history. Normal follow-through does not require recovery.\", \"follow.agent-status\", { detail: { type: \"string\", enum: [\"summary\", \"full\"] } }, []],\n [\"sync_workspace\", \"Silently confirm the sidebar matches your work before finishing, after registering owned PRs and substantive tasks. Call even when no changes are needed. Keep the summary in this tool; never announce sync or recap sidebar/plugin status to the developer. Does not declare Done, run recovery, or start repairs. Tracked root agents only.\", \"follow.sync\", { summary: { ...string, minLength: 1, maxLength: 1000 } }, [\"summary\"]],\n [\"enroll_workspace\", \"Refine this developer-activated workspace's goal. Ownership is initialized by Track this workspace or the project’s default-tracking setting. This tool cannot activate another workspace. Never ask the developer to enter this. Keep the goal concise; new scope reopens completion.\", \"follow.enroll\", { goal: string }, [\"goal\"]],\n [\"register_pr\", \"Register a PR produced by this workspace. Call after every submission. Register parents first; repeat to update its stack or parent after restacking. Supply a disposition for a deliberately closed PR.\", \"follow.register-pr\", { repo: string, number: integer, stack: string, parentNumber: { type: [\"integer\", \"null\"], minimum: 1 }, disposition: string }, [\"repo\", \"number\", \"stack\"]],\n [\"exclude_stack\", \"Correct an unrelated stack registration. Preserve its PRs and requirements in an exclusion audit; future registration is blocked. reason must cite the scope/ownership evidence. obligationIds may include related tasks without PR dependencies. Keep external dependencies separate from owned work.\", \"follow.exclude-stack\", { stack: string, reason: string, obligationIds: array }, [\"stack\", \"reason\"]],\n [\"update_obligation\", \"Record concrete follow-through: verification, migrations, decisions or stakeholder replies. Never add approve/merge/deploy checklist tasks. afterPrs uses owner/repo#number keys. Tasks are manual unless explicitly scheduled with trigger={event:\\\"now\\\"|\\\"merge\\\"|\\\"deploy\\\",delayDays:0}. Only set a trigger from the developer’s requested timing; never infer timing from a title or enable_follow_up alone. Merge/deploy triggers require owned afterPrs; delayDays=14 waits fourteen days after the last dependency event. Now requires no PR dependencies or delay. Deployment timing starts at the first verified fully serving observation and remains conservative when history is unknown. Preserve existing trigger fields on updates; trigger=null makes a task manual. Auto controls delivery, not timing or production permissions. For Slack replies, communication records the original threadUrl, recipient, purpose (progress or verified), explicit developer authorization and sent replyUrl. Empty authorization never queues a reply; Auto does not authorize external posts. Use a verify-and-reply task after deployment for the final update. Read the thread first; mark blocked when posting is unavailable or uncertain, never treat a draft as sent. Done requires replyUrl and evidence; preserve fields when updating.\", \"follow.obligation\", { id: string, title: string, status: { type: \"string\", enum: [\"pending\", \"waiting\", \"working\", \"blocked\", \"done\", \"waived\"] }, afterPrs: array, dependencyStage: { type: \"string\", enum: [\"merged\", \"deployed\"] }, evidence: string, enable_follow_up: boolean, communication, trigger }, [\"title\", \"status\"]],\n [\"acknowledge_batch\", \"Record a follow-up result. Complete only after verifying all tasks; blocked surfaces the reason to the developer. Always acknowledge before ending a repair turn. Keep the result within 2000 characters.\", \"follow.acknowledge\", { batchId: string, outcome: { type: \"string\", enum: [\"completed\", \"blocked\"] }, result: { ...string, maxLength: 2000 } }, [\"batchId\", \"outcome\", \"result\"]],\n [\"declare_done\", \"Declare that the workspace goal is fulfilled. Live PR status and remaining obligations are checked; returned blockers must be resolved first. Done labels the workspace but never archives it.\", \"follow.done\", { summary: string }, [\"summary\"]],\n];\n\nfunction toolList(recovery = process.env.FOLLOW_THROUGH_RECOVERY === \"1\", contributor = false) {\n return tools.filter(t => contributor ? [\"workspace_status\", \"register_pr\", \"sync_workspace\"].includes(t[0]) : !recovery || ![\"declare_done\", \"sync_workspace\"].includes(t[0]));\n}\nconst schemas = list => list.map(([name, description, , properties, required]) => ({ name, description, inputSchema: { type: \"object\", properties, required, additionalProperties: false }, annotations: { readOnlyHint: name === \"workspace_status\", destructiveHint: false } }));\nasync function scope(resolveRecovery = false) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) return null;\n try {\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const directory = join(home, \"plugin-data/follow-through\");\n const lease = JSON.parse(await readFile(join(directory, \"hooks-enabled.json\"), \"utf8\"));\n if (!(lease.expiresAt > Date.now())) return null;\n const state = JSON.parse(await readFile(join(directory, \"state.json\"), \"utf8\"));\n for (const w of state.workspaces) {\n if (!w.enable_tracking || w.archived) continue;\n if (w.mainAgentId === agentId) return process.env.FOLLOW_THROUGH_RECOVERY === \"1\" ? \"recovery\" : \"main\";\n if (w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n if (w.contributorAgentIds?.includes(agentId)) return \"contributor\";\n }\n if (resolveRecovery && process.env.FOLLOW_THROUGH_RECOVERY === \"1\") {\n const w = await call(\"follow.agent-status\", {});\n if (w.enable_tracking && !w.archived && w.mainAgentId !== agentId && w.batches.some(b => b.recipientAgentId === agentId && !b.settledAt && [\"sending\", \"sent\", \"blocked\", \"uncertain\"].includes(b.state) && b.tasks.every(t => t.kind === \"inventory\"))) return \"recovery\";\n }\n } catch {}\n return null;\n}\nfunction validate(value, schema, path = \"arguments\") {\n const types = Array.isArray(schema.type) ? schema.type : [schema.type];\n const type = value === null ? \"null\" : Array.isArray(value) ? \"array\" : typeof value;\n if (!types.includes(type) && !(type === \"number\" && types.includes(\"integer\") && Number.isInteger(value))) throw new Error(`${path}: expected ${types.join(\" or \")}.`);\n if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: choose ${schema.enum.join(\", \")}.`);\n if (type === \"object\") {\n const extra = Object.keys(value).filter(k => !Object.hasOwn(schema.properties ?? {}, k));\n if (extra.length && schema.additionalProperties === false) throw new Error(`Unknown argument ${path}.${extra.join(\", \")}. ${extra.includes(\"dependsOnPrs\") ? \"Use afterPrs with owner/repo#number keys.\" : \"Check this tool's input schema with list.\"}`);\n for (const key of schema.required ?? []) if (!(key in value)) throw new Error(`${path}.${key} is required. Check this tool's input schema with list.`);\n for (const [key, nested] of Object.entries(value)) if (schema.properties?.[key]) validate(nested, schema.properties[key], `${path}.${key}`);\n }\n if (type === \"array\") for (let i = 0; i < value.length; i++) validate(value[i], schema.items, `${path}[${i}]`);\n if (type === \"number\" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: outside the allowed range.`);\n if (type === \"string\" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) throw new Error(`${path}: outside the allowed length.`);\n}\nfunction currentWork(w) {\n return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,\n prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),\n obligations: w.obligations.filter(o => ![\"done\", \"waived\"].includes(o.status)), completedObligations: w.obligations.filter(o => [\"done\", \"waived\"].includes(o.status)).length,\n batches: w.batches.filter(b => ![\"completed\", \"cancelled\"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),\n excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: \"summary\" };\n}\nfunction compact(tool, input, result) {\n if (tool === \"workspace_status\") return currentWork(result);\n if (tool === \"declare_done\") return { workspaceId: result.workspace.id, completion: result.workspace.completion, blockers: result.blockers };\n if (!result?.prs) return result;\n const workspaceId = result.id;\n if (tool === \"enroll_workspace\") return { workspaceId, goal: result.goal };\n if (tool === \"register_pr\") { const p = result.prs.find(p => p.key === `${input.repo}#${input.number}`); return { workspaceId, pr: { key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition } }; }\n if (tool === \"update_obligation\") return { workspaceId, obligation: result.obligations.find(o => o.id === result.updatedObligationId) };\n if (tool === \"acknowledge_batch\") { const b = result.batches.find(b => b.id === input.batchId); return { workspaceId, batch: { id: b.id, state: b.state, result: b.result } }; }\n if (tool === \"exclude_stack\") { const s = result.excludedStacks.find(s => s.name === input.stack); return { workspaceId, excludedStack: { name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) } }; }\n return result;\n}\nasync function loadClient() {\n const candidates = [];\n if (process.env.FOLLOW_THROUGH_CLIENT_ENTRY) candidates.push(() => pathToFileURL(process.env.FOLLOW_THROUGH_CLIENT_ENTRY).href);\n const specifier = \"@getpaseo/client/internal/daemon-client\";\n if (process.resourcesPath) candidates.push(() => pathToFileURL(createRequire(join(process.resourcesPath, \"app.asar/node_modules/@getpaseo/cli/dist/index.js\")).resolve(specifier)).href);\n for (const directory of (process.env.PATH ?? \"\").split(delimiter)) {\n const cli = join(directory, process.platform === \"win32\" ? \"paseo.cmd\" : \"paseo\");\n if (existsSync(cli)) candidates.push(() => pathToFileURL(createRequire(realpathSync(cli)).resolve(specifier)).href);\n }\n candidates.push(() => pathToFileURL(createRequire(import.meta.url).resolve(specifier)).href);\n for (const resolve of candidates) try { return await import(resolve()); } catch {}\n throw new Error(\"Cannot load the installed Paseo client. Reload Follow-through to refresh its runtime connection.\");\n}\nconst batchInstructions = \"Follow-through batches contain a header with the batchId and ordered tasks. Read workspace_status and refresh live conditions; skip resolved tasks. Process PRs bottom to top, one fully before the next, using the repository's /fix-pr and safe reparent workflow. Validate feedback, verify changes, submit with Graphite --publish, reply/resolve addressed threads and preserve uncommitted work. Post-deploy tasks require verified serving revisions and existing production permissions. Stakeholder update requests are follow-through tasks: retain the original thread, recipient and developer authorization in communication. Use current evidence, distinguish fixed/deployed/verified, and read existing replies before posting. Record the sent replyUrl; a draft or uncertain send stays blocked and must be checked before retrying. Auto authorizes agent delivery only. Acknowledge the header's batchId with acknowledge_batch before finishing (completed with the result, or blocked with the reason), before declare_done for completion batches. Declare Done only after the whole goal and post-merge work are verified. Leave merging and archiving to the developer.\";\n\nasync function call(method, input) {\n const agentId = process.env.PASEO_AGENT_ID;\n if (!agentId) throw new Error(\"This bridge must run inside a Paseo agent (PASEO_AGENT_ID is missing).\");\n const { DaemonClient } = await loadClient();\n const home = process.env.PASEO_HOME ?? join(homedir(), \".paseo\");\n const config = JSON.parse(await readFile(join(home, \"config.json\"), \"utf8\"));\n let password = process.env.PASEO_PASSWORD;\n if (!password && process.env.PASEO_PASSWORD_FILE) password = (await readFile(process.env.PASEO_PASSWORD_FILE, \"utf8\")).trim();\n if (!password && home === join(homedir(), \".paseo\")) password = await readFile(join(homedir(), \"paseo-hub/secrets/daemon-password\"), \"utf8\").then(s => s.trim()).catch(() => undefined);\n const client = new DaemonClient({ url: process.env.PASEO_DAEMON_URL ?? `ws://${config.daemon?.listen ?? \"127.0.0.1:6767\"}/ws`, clientId: \"follow-through-mcp\", clientType: \"cli\", reconnect: { enabled: false }, connectTimeoutMs: 10000, suppressSendErrors: true, ...(password ? { password } : {}) });\n try { await client.connect(); return await client.invokePluginRpc(\"follow-through\", method, { ...input, agentId }); }\n finally { await client.close().catch(() => undefined); }\n}\n\nconst command = process.argv[2];\nif (command === \"list\" || command === \"--help\" || command === \"help\") {\n process.stdout.write(command === \"list\" ? JSON.stringify({ tools: schemas(toolList()) }, null, 2) + \"\\n\" : \"Usage: node mcp.mjs list | call <tool> '<json>' [--compact]\\nNative MCP returns compact receipts; workspace_status({detail:full}) requests complete history. CLI calls retain full replies unless --compact is supplied.\\n\");\n} else if (command === \"call\") {\n try {\n const tool = toolList().find(t => t[0] === process.argv[3]);\n if (!tool) throw new Error(\"Unknown tool. Run list for names and input schemas.\");\n const input = JSON.parse(process.argv[4] ?? \"{}\");\n validate(input, schemas([tool])[0].inputSchema);\n if (process.argv.slice(5).some(arg => arg !== \"--compact\")) throw new Error(\"Unknown CLI option. Use --compact or omit it for a full reply.\");\n const result = await call(tool[2], input);\n process.stdout.write(JSON.stringify(process.argv.includes(\"--compact\") && input.detail !== \"full\" ? compact(tool[0], input, result) : result, null, 2) + \"\\n\");\n } catch (error) { console.error(error.message); process.exitCode = 1; }\n} else if (command) {\n console.error(\"Unknown command. Use list, --help, or call <tool> '<json>'.\"); process.exitCode = 1;\n} else {\n const lines = createInterface({ input: process.stdin });\n let tail = Promise.resolve(), initialized = false, lastScope = null;\n const timer = process.env.PASEO_AGENT_ID ? setInterval(() => {\n void (async () => { const current = await scope(); if (initialized && current !== lastScope) process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", method: \"notifications/tools/list_changed\" }) + \"\\n\"); lastScope = current; })().catch(() => {});\n }, 2000) : null;\n timer?.unref(); lines.on(\"close\", () => { if (timer) clearInterval(timer); });\n lines.on(\"line\", line => {\n tail = tail.then(async () => {\n let request;\n try { request = JSON.parse(line); } catch { return; }\n if (request.id === undefined) return;\n try {\n let result;\n const role = await scope(request.method === \"initialize\" || request.method === \"tools/list\");\n const available = role ? toolList(role === \"recovery\", role === \"contributor\") : [];\n if (request.method === \"initialize\") {\n initialized = true; lastScope = role;\n result = { protocolVersion: request.params?.protocolVersion ?? \"2024-11-05\", capabilities: { tools: { listChanged: true } }, serverInfo: { name: \"paseo-follow-through\", version: \"0.3.5\" }, instructions: role ? (role === \"contributor\" ? \"Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent.\" : role === \"recovery\" ? \"Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent.\" : \"Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. \" + batchInstructions) + \" Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked.\" : \"Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools.\" };\n } else if (request.method === \"ping\") result = {};\n else if (request.method === \"tools/list\") result = { tools: schemas(available) };\n else if (request.method === \"tools/call\") {\n const tool = available.find(t => t[0] === request.params?.name);\n try {\n if (!tool) throw new Error(\"This tool is unavailable for the current agent. Tracking must be active; recovery authority ends after acknowledgment.\");\n const input = request.params.arguments ?? {}; validate(input, schemas([tool])[0].inputSchema);\n const value = await call(tool[2], input);\n result = { content: [{ type: \"text\", text: JSON.stringify(input.detail === \"full\" ? value : compact(tool[0], input, value)) }] };\n } catch (error) { result = { isError: true, content: [{ type: \"text\", text: error.message }] }; }\n } else { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32601, message: \"Method not found\" } }) + \"\\n\"); return; }\n process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, result }) + \"\\n\");\n } catch (error) { process.stdout.write(JSON.stringify({ jsonrpc: \"2.0\", id: request.id, error: { code: -32603, message: error.message } }) + \"\\n\"); }\n });\n });\n}\n";
|
package/server/mcp.mjs
CHANGED
|
@@ -67,7 +67,7 @@ function validate(value, schema, path = "arguments") {
|
|
|
67
67
|
}
|
|
68
68
|
function currentWork(w) {
|
|
69
69
|
return { id: w.id, title: w.title, goal: w.goal, mainAgentId: w.mainAgentId, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, hold: w.hold, archived: w.archived, runtimeBusy: w.runtimeBusy, runtimeReason: w.runtimeReason, backgroundBusy: w.backgroundBusy, completion: w.completion,
|
|
70
|
-
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 } : {}) })),
|
|
70
|
+
prs: w.prs.map(p => ({ key: p.key, stack: p.stack, parent: p.parent, disposition: p.disposition, error: p.error, ...(p.snapshot ? { title: p.snapshot.title, state: p.snapshot.state, head: p.snapshot.head, draft: p.snapshot.draft, reviewDecision: p.snapshot.reviewDecision, conflict: p.snapshot.conflict, openThreads: p.snapshot.threads.length, failedChecks: p.snapshot.failedChecks, otherFailedChecks: p.snapshot.otherFailedChecks ?? [], deployment: p.snapshot.deployment } : {}) })),
|
|
71
71
|
obligations: w.obligations.filter(o => !["done", "waived"].includes(o.status)), completedObligations: w.obligations.filter(o => ["done", "waived"].includes(o.status)).length,
|
|
72
72
|
batches: w.batches.filter(b => !["completed", "cancelled"].includes(b.state)).map(b => ({ id: b.id, state: b.state, tasks: b.tasks, result: b.result, recipientAgentId: b.recipientAgentId, contextPath: b.contextPath })),
|
|
73
73
|
excludedStacks: w.excludedStacks.map(s => ({ name: s.name, reason: s.reason, prs: s.prs.map(p => p.key) })), detail: "summary" };
|
|
@@ -146,7 +146,7 @@ if (command === "list" || command === "--help" || command === "help") {
|
|
|
146
146
|
const available = role ? toolList(role === "recovery", role === "contributor") : [];
|
|
147
147
|
if (request.method === "initialize") {
|
|
148
148
|
initialized = true; lastScope = role;
|
|
149
|
-
result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.
|
|
149
|
+
result = { protocolVersion: request.params?.protocolVersion ?? "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "paseo-follow-through", version: "0.3.5" }, instructions: role ? (role === "contributor" ? "Register PRs this session submits, including from secondary git worktrees. Read workspace_status and call sync_workspace before finishing. Goal, task, scope and completion changes remain with the designated main agent." : role === "recovery" ? "Recover tracking metadata from evidence, acknowledge your assigned batch, and leave completion to the main agent." : "Keep workspace follow-through updated after PR submission and scope changes. Read workspace_status after compaction. Track all post-merge work. " + batchInstructions) + " Perform bookkeeping silently: no sidebar-update announcements, plugin-status commentary or final sidebar recap. Put receipts and summaries in tools. Report actual work outcomes or decisions/blockers; discuss sidebar status only when explicitly asked." : "Follow-through tools are available to tracked workspace root agents and active recovery agents; contributor sessions receive PR registration and status tools." };
|
|
150
150
|
} else if (request.method === "ping") result = {};
|
|
151
151
|
else if (request.method === "tools/list") result = { tools: schemas(available) };
|
|
152
152
|
else if (request.method === "tools/call") {
|
package/shared/model.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const snapshotSchema = z.object({
|
|
|
17
17
|
reviewers: z.object({ author: z.string().nullable(), requested: z.array(z.string()), teams: z.array(z.string()), reviewed: z.array(z.string()), complete: z.boolean() }).optional(),
|
|
18
18
|
mergeWhenReady: z.enum(["on", "off", "unknown"]).default("unknown"),
|
|
19
19
|
threads: z.array(z.object({ id: z.string(), updatedAt: z.string() })),
|
|
20
|
-
failedChecks: z.array(z.string()), checksPending: z.boolean(),
|
|
20
|
+
failedChecks: z.array(z.string()), otherFailedChecks: z.array(z.string()).optional(), checksPending: z.boolean(),
|
|
21
21
|
complete: z.boolean(), observedAt: z.string(),
|
|
22
22
|
mergeCommit: z.string().nullable().optional(), deployment: deploymentSchema.nullable().optional(),
|
|
23
23
|
mergedAt: z.string().nullable().optional(),
|
package/shared/presentation.ts
CHANGED
|
@@ -10,7 +10,7 @@ export function prPresentation(pr: Pr): { label: string; tone: "merged" | "appro
|
|
|
10
10
|
if (s.state === "CLOSED") return { label: "Closed", tone: "neutral" };
|
|
11
11
|
if (s.conflict) return { label: "Conflicts", tone: "warning" };
|
|
12
12
|
if (s.threads.length) return { label: `${s.threads.length} review threads`, tone: "warning" };
|
|
13
|
-
if (s.failedChecks.length) return { label: "Checks failed", tone: "warning" };
|
|
13
|
+
if (s.failedChecks.length || s.otherFailedChecks?.length) return { label: "Checks failed", tone: "warning" };
|
|
14
14
|
if (s.draft) return { label: "Draft", tone: "neutral" };
|
|
15
15
|
if (s.checksPending) return { label: "Checks running", tone: "neutral" };
|
|
16
16
|
return s.reviewDecision === "APPROVED" ? { label: "Approved", tone: "approved" } : { label: "In review", tone: "neutral" };
|