granttap-mcp 0.8.69 → 0.8.70
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/README.md +4 -4
- package/apps/bridge/src/mesh/README.md +10 -6
- package/apps/bridge/src/mesh/admin/capsule.ts +20 -4
- package/apps/bridge/src/mesh/runtime/index.ts +3 -7
- package/apps/bridge/src/mesh/runtime/prepared-transfer/source-checkout.ts +24 -0
- package/apps/bridge/src/mesh/tasks/readiness.ts +16 -18
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +1 -1
- package/packages/protocol/messages/mesh/events.ts +3 -2
package/README.md
CHANGED
|
@@ -207,10 +207,10 @@ offered for it. Unsupported remote-start paths fail closed instead of claiming
|
|
|
207
207
|
parity.
|
|
208
208
|
|
|
209
209
|
A capsule carries facts, not files. A handoff from a checkout with uncommitted
|
|
210
|
-
changes
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
210
|
+
changes goes ahead with a warning — "This task has uncommitted changes. They stay
|
|
211
|
+
on this computer; the next agent starts from the last commit." — and the capsule
|
|
212
|
+
names the changes that stayed behind, so nothing is narrowed silently. Asked to
|
|
213
|
+
checkpoint from the phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
214
214
|
from a temporary index, so HEAD, the current branch, and the working tree stay
|
|
215
215
|
exactly as the agent left them, and the capsule carries that commit. Nothing is
|
|
216
216
|
pushed unless the phone asks for it per handoff: then the source publishes the
|
|
@@ -26,12 +26,16 @@ tool so the public MCP tool allowlist remains exactly `connect`, `notify`,
|
|
|
26
26
|
CLI-only.
|
|
27
27
|
|
|
28
28
|
`readiness.ts` decides whether a handoff may leave at all. A capsule transfers
|
|
29
|
-
committed facts, so uncommitted work
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
committed facts, so uncommitted work stays on the source computer: that is the
|
|
30
|
+
person's call, made with a warning rather than refused, and the capsule tells
|
|
31
|
+
the next agent which changes stayed where. The destination refuses explicitly
|
|
32
|
+
when it lacks the named commit or when the capsule's resource claims overlap
|
|
33
|
+
another execution. "Uncommitted" is read from `git status` including untracked
|
|
34
|
+
files, because an untracked file is work no commit would carry. A probe that
|
|
35
|
+
cannot answer publishes `unknown`, which is warned about the same way, never
|
|
36
|
+
taken for `clean`. A prepared transfer to another computer is confirmed only
|
|
37
|
+
while the source checkout is still on the same commit with the same uncommitted
|
|
38
|
+
changes it had when the person agreed to the move.
|
|
35
39
|
`remote.ts` is the one place a remote is touched: a push of the branch the
|
|
36
40
|
capsule names when the phone asked for it, never forced, and a fetch of that
|
|
37
41
|
branch on a destination that lacks the commit. A handoff whose target is this
|
|
@@ -42,11 +42,22 @@ function changedFiles(cwd: string): string[] {
|
|
|
42
42
|
return [...new Set(files)].slice(0, 64);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
/** The identity of the uncommitted changes, so a later reading can tell they are the same. */
|
|
46
|
+
export function dirtyDiffHash(cwd: string): string | undefined {
|
|
46
47
|
const diff = git(cwd, ["diff", "--binary", "HEAD"]);
|
|
47
48
|
return diff ? createHash("sha256").update(diff).digest("hex") : undefined;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Uncommitted work that does not travel with the capsule. The move is allowed;
|
|
53
|
+
* the next agent is told what it does not have and where it stayed.
|
|
54
|
+
*/
|
|
55
|
+
function leftBehindWork(files: string[], computerId: string): string {
|
|
56
|
+
const named = files.slice(0, 8).join(", ") + (files.length > 8 ? ` and ${files.length - 8} more` : "");
|
|
57
|
+
return `Uncommitted changes to ${named || "this checkout"} stayed on ${computerId}; they are not in this commit.`
|
|
58
|
+
.slice(0, 1_000);
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export function buildTaskCapsule(
|
|
51
62
|
store: MeshStore,
|
|
52
63
|
session: SessionInfo,
|
|
@@ -61,6 +72,8 @@ export function buildTaskCapsule(
|
|
|
61
72
|
if (!cwd || !task || !project || session.taskId !== request.taskId) return undefined;
|
|
62
73
|
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
63
74
|
if (!head) return undefined;
|
|
75
|
+
const tree = workingTreeState(cwd);
|
|
76
|
+
const files = changedFiles(cwd);
|
|
64
77
|
const capsule = {
|
|
65
78
|
taskId: request.taskId,
|
|
66
79
|
goal: task.goal,
|
|
@@ -77,13 +90,16 @@ export function buildTaskCapsule(
|
|
|
77
90
|
branch: session.branch ?? git(cwd, ["branch", "--show-current"]),
|
|
78
91
|
latestCommit: head,
|
|
79
92
|
dirtyDiffHash: dirtyDiffHash(cwd),
|
|
80
|
-
workingTree:
|
|
81
|
-
filesChanged:
|
|
93
|
+
workingTree: tree,
|
|
94
|
+
filesChanged: files,
|
|
82
95
|
dependencies: snapshot.dependencies
|
|
83
96
|
.filter((item) => item.taskId === request.taskId).map((item) => item.dependsOnTaskId).slice(0, 32),
|
|
84
97
|
resourceClaims: snapshot.claims
|
|
85
98
|
.filter((item) => item.taskId === request.taskId).map((item) => item.resource).slice(0, 64),
|
|
86
|
-
remainingWork: [
|
|
99
|
+
remainingWork: [
|
|
100
|
+
"Continue the task from the current committed state.",
|
|
101
|
+
...(tree === "dirty" ? [leftBehindWork(files, computerId)] : []),
|
|
102
|
+
],
|
|
87
103
|
importantDecisions: [],
|
|
88
104
|
createdAt: Date.now(),
|
|
89
105
|
};
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "../../reply";
|
|
22
22
|
import { isProviderEnabled } from "../../config/runtime";
|
|
23
23
|
import { sendMeshPayload } from "../../host/session-keys";
|
|
24
|
-
import {
|
|
24
|
+
import { linkSessionsToProjects, workingTreeState } from "../catalog";
|
|
25
25
|
import { catalogFromEndpoint } from "../catalog/models";
|
|
26
26
|
import { saveProjectCapabilityRequest } from "../catalog/project/requests";
|
|
27
27
|
import { computerId } from "../identity/computer";
|
|
@@ -38,6 +38,7 @@ import { fetchRevision, pushBranch } from "../local-remote/remote";
|
|
|
38
38
|
import { projectSessionCapabilityInventory } from "./capabilities";
|
|
39
39
|
import { createMeshSessionCatalog } from "./catalog/session-catalog";
|
|
40
40
|
import { capturePreparedHandoff, preparedHandoffMetadata } from './prepared-transfer';
|
|
41
|
+
import { sourceCheckoutUnchanged } from './prepared-transfer/source-checkout';
|
|
41
42
|
|
|
42
43
|
export type { MeshRuntimeDependencies };
|
|
43
44
|
|
|
@@ -58,12 +59,7 @@ const defaultDependencies: MeshRuntimeDependencies = {
|
|
|
58
59
|
now: Date.now,
|
|
59
60
|
eventId: randomUUID,
|
|
60
61
|
providerEnabled: isProviderEnabled,
|
|
61
|
-
sourceCaptureCurrent: (session, capsule) =>
|
|
62
|
-
const cwd = session.worktree ?? session.cwd;
|
|
63
|
-
try { return Boolean(cwd && workingTreeState(cwd) === 'clean'
|
|
64
|
-
&& inspectRepository(cwd).revision === (capsule.latestCommit ?? capsule.baseSha)); }
|
|
65
|
-
catch { return false; }
|
|
66
|
-
},
|
|
62
|
+
sourceCaptureCurrent: (session, capsule) => sourceCheckoutUnchanged(session.worktree ?? session.cwd, capsule),
|
|
67
63
|
dispatchPreparedTransfer: async (event, session, confirm) =>
|
|
68
64
|
(await import('../../task-network/runtime')).submitNetworkTransfer(event, session, confirm),
|
|
69
65
|
start: async (provider, prompt, cwd, model) => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TaskCapsule } from '../../../../../../packages/protocol/schema';
|
|
2
|
+
import { dirtyDiffHash } from '../../admin/capsule';
|
|
3
|
+
import { inspectRepository, workingTreeState } from '../../catalog';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the source checkout is still what its capsule described.
|
|
7
|
+
*
|
|
8
|
+
* A prepared transfer to another computer is confirmed later. Uncommitted work
|
|
9
|
+
* may be left behind — the person was warned — but it has to be the same work
|
|
10
|
+
* they agreed to leave, on the same commit. A checkpoint commit already holds
|
|
11
|
+
* that work, so the source only has to keep its commit.
|
|
12
|
+
*/
|
|
13
|
+
export function sourceCheckoutUnchanged(cwd: string | undefined, capsule: TaskCapsule): boolean {
|
|
14
|
+
if (!cwd) return false;
|
|
15
|
+
try {
|
|
16
|
+
if (inspectRepository(cwd).revision !== capsule.baseSha) return false;
|
|
17
|
+
if (capsule.checkpoint) return true;
|
|
18
|
+
const tree = workingTreeState(cwd);
|
|
19
|
+
if (tree !== (capsule.workingTree ?? 'unknown')) return false;
|
|
20
|
+
return tree !== 'dirty' || dirtyDiffHash(cwd) === capsule.dirtyDiffHash;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
* Handoff readiness.
|
|
3
3
|
*
|
|
4
4
|
* A Task Capsule carries facts, not files: the destination starts a worktree
|
|
5
|
-
* from a commit. Anything still uncommitted stays on the source computer
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* from a commit. Anything still uncommitted stays on the source computer while
|
|
6
|
+
* the Task continues elsewhere. That is the person's call, so it is said out
|
|
7
|
+
* loud — on the phone, here, and in the capsule — rather than refused.
|
|
8
8
|
*/
|
|
9
9
|
import type { ResourceClaim, TaskCapsule } from "../../../../../packages/protocol/schema";
|
|
10
10
|
|
|
11
|
-
export const
|
|
12
|
-
"This task has uncommitted changes.
|
|
11
|
+
export const UNCOMMITTED_WORK_WARNING =
|
|
12
|
+
"This task has uncommitted changes. They stay on this computer; the next agent starts from the last commit.";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
export const
|
|
16
|
-
"GrantTap could not read this working tree, so
|
|
14
|
+
/** "We could not look" is not "clean", so an unread tree is warned about too. */
|
|
15
|
+
export const UNREADABLE_WORKING_TREE_WARNING =
|
|
16
|
+
"GrantTap could not read this working tree, so uncommitted changes may stay on this computer.";
|
|
17
17
|
|
|
18
18
|
export type HandoffCheckId = "repository" | "workingTree" | "claims" | "targetAgent";
|
|
19
19
|
|
|
@@ -48,11 +48,11 @@ function workingTree(capsule?: TaskCapsule): "clean" | "dirty" | "unknown" {
|
|
|
48
48
|
return capsule?.workingTree ?? "unknown";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function workingTreeWarning(capsule?: TaskCapsule): string | undefined {
|
|
52
52
|
switch (workingTree(capsule)) {
|
|
53
|
-
case "clean": return
|
|
54
|
-
case "dirty": return
|
|
55
|
-
default: return
|
|
53
|
+
case "clean": return undefined;
|
|
54
|
+
case "dirty": return UNCOMMITTED_WORK_WARNING;
|
|
55
|
+
default: return UNREADABLE_WORKING_TREE_WARNING;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -68,11 +68,7 @@ export function handoffReadiness(input: HandoffReadinessInput): HandoffReadiness
|
|
|
68
68
|
? `Commit ${capsule.latestCommit ?? capsule.baseSha} in ${capsule.repository}`
|
|
69
69
|
: "No git repository, task, or commit was found for this execution.",
|
|
70
70
|
),
|
|
71
|
-
check(
|
|
72
|
-
"workingTree",
|
|
73
|
-
Boolean(capsule) && workingTree(capsule) === "clean",
|
|
74
|
-
workingTreeDetail(capsule),
|
|
75
|
-
),
|
|
71
|
+
check("workingTree", Boolean(capsule), workingTreeWarning(capsule) ?? "Clean"),
|
|
76
72
|
check(
|
|
77
73
|
"claims",
|
|
78
74
|
!conflict,
|
|
@@ -89,9 +85,11 @@ export function handoffReadiness(input: HandoffReadinessInput): HandoffReadiness
|
|
|
89
85
|
),
|
|
90
86
|
];
|
|
91
87
|
const blocked = checks.find((item) => !item.ready);
|
|
88
|
+
const leftBehind = capsule ? workingTreeWarning(capsule) : undefined;
|
|
92
89
|
// Same module, different file: the merge conflict that has not happened
|
|
93
90
|
// yet. Said out loud, not enforced — two agents can share a module.
|
|
94
|
-
const
|
|
91
|
+
const overlaps = (input.moduleOverlaps ?? []).slice(0, 8).map((claim) =>
|
|
95
92
|
`${claim.ownerSessionId} is working in the same module (${claim.resource}).`);
|
|
93
|
+
const warnings = [...(leftBehind ? [leftBehind] : []), ...overlaps];
|
|
96
94
|
return { ready: !blocked, blockedReason: blocked?.detail, checks, warnings };
|
|
97
95
|
}
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.70
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.70\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.70";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -218,8 +218,9 @@ export const MeshHandoffPrepare = z.object({
|
|
|
218
218
|
userComment: Detail.optional(),
|
|
219
219
|
createdAt: z.number().nonnegative(),
|
|
220
220
|
/**
|
|
221
|
-
* Commit uncommitted work to a checkpoint branch first, so the
|
|
222
|
-
*
|
|
221
|
+
* Commit uncommitted work to a checkpoint branch first, so the work travels
|
|
222
|
+
* with the Task instead of staying behind. The branch is local: GrantTap
|
|
223
|
+
* never pushes.
|
|
223
224
|
*/
|
|
224
225
|
checkpoint: z.boolean().optional(),
|
|
225
226
|
/**
|