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 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 is refused — "This task has uncommitted changes. Commit or checkpoint
211
- them before moving the task." — instead of silently continuing the Task from
212
- committed state and leaving that work behind. Asked to checkpoint from the
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 blocks the handoff instead of silently
30
- staying behind, and the destination refuses just as explicitly when it lacks the
31
- named commit or when the capsule's resource claims overlap another execution.
32
- "Uncommitted" is read from `git status` including untracked files, because an
33
- untracked file is work no commit would carry. A probe that cannot answer
34
- publishes `unknown`, and only an explicit `clean` releases the handoff.
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
- function dirtyDiffHash(cwd: string): string | undefined {
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: workingTreeState(cwd),
81
- filesChanged: changedFiles(cwd),
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: ["Continue the task from the current committed state."],
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 { inspectRepository, linkSessionsToProjects, workingTreeState } from "../catalog";
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 even
6
- * though the Task visibly continues elsewhere, so a handoff that would lose it
7
- * is refused instead of silently narrowing to committed state.
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 UNCOMMITTED_WORK_REASON =
12
- "This task has uncommitted changes. Commit or checkpoint them before moving the task.";
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
- /** An unread working tree blocks the move; only a positive "clean" releases it. */
15
- export const UNREADABLE_WORKING_TREE_REASON =
16
- "GrantTap could not read this working tree, so it cannot promise a commit carries every change.";
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 workingTreeDetail(capsule?: TaskCapsule): string {
51
+ function workingTreeWarning(capsule?: TaskCapsule): string | undefined {
52
52
  switch (workingTree(capsule)) {
53
- case "clean": return "Clean";
54
- case "dirty": return UNCOMMITTED_WORK_REASON;
55
- default: return UNREADABLE_WORKING_TREE_REASON;
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 warnings = (input.moduleOverlaps ?? []).slice(0, 8).map((claim) =>
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
  }
@@ -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.69
13
+ npm install -g granttap-mcp@0.8.70
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -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.69\";\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"
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.69";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.69",
3
+ "version": "0.8.70",
4
4
  "description": "Apache-2.0 computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 Task can
222
- * leave without losing it. The branch is local: GrantTap never pushes.
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
  /**